Trading System — Dependency Map
This document maps every dependency in the trading pipeline: what must exist before a trade can open, what conditions trigger SL/TP resolution, and how pattern trading config flows through the system. It is a reference for building and debugging.
1. System Overview
TwelveData WS ──► ohlc-service ──► mi-casa backend WS ──► auto-trading engine ──► paper_trades DB
│ │
pattern detector pending order activator
event resolver trade resolution service
Two microservices collaborate:
- ohlc-service — ingests tick data, detects patterns, resolves detection event outcomes.
- mi-casa backend — listens to ohlc-service via WebSocket, manages paper trades, runs auto-trading engine.
2. For a Trade to Open
2a. Auto-trade (pattern-driven)
All of the following must be true before autoTradingEngine.evaluatePatternEvent creates a trade:
| # | Condition | Source |
|---|---|---|
| 1 | OHLC_SERVICE_URL env var is set | marketDataWebSocket.ts |
| 2 | ohlc-service WebSocket is connected | marketDataWebSocket.ts |
| 3 | Detection event has entryPrice, sl, tp1 | autoTradingEngine.ts line 26 |
| 4 | At least one PaperTradingAccount has enabled = true | paperTradingAccountRepository.getAllEnabled() |
| 5 | Event detectedAt ≥ account enabledAt (signal is not stale from before auto-trading was turned on) | autoTradingEngine.ts line 41 |
| 6 | At least one enabled Strategy exists for this user where strategyType matches the pattern type, symbol is in strategy.symbols, timeframe is in strategy.timeframes, and entryLagCandles = 0 (D+0) | strategyRepository.getEnabledForUser |
| 7 | No existing open trade for this (userId, strategyId, symbol, timeframe) combo | paperTradeRepository.hasOpenStrategyTrade |
| 8 | openCount < account.maxOpenTrades | paperTradeRepository.countOpenByUserId |
| 9 | getLivePrice(symbol) returns a non-null value | marketDataWebSocket.liveCandles map |
| 10 | Price drift from event.entryPrice to live price ≤ account.maxEntryDeviationPips display pips (default: 50); measured via getDisplayPipSize — for NOK pairs this is 0.0001, so 50 means 50 display pips not 50 raw pipettes | autoTradingEngine.ts |
If all checks pass, the engine iterates every matching Strategy and creates one trade per Strategy:
status: 'open'entrySource: 'auto'strategyIdfrom the matched StrategyentryPrice: live price (not event price — drift-adjusted)- SL/TP shifted by entry drift so distances remain identical to the signal
patternSnapshotfetched from ohlc-service viaGET /patterns/by-event/:id
If two Strategies match the same event, two trades open — one per Strategy.
Entry strategies
| Strategy | WS trigger | Pending order type | Entry price | Notes |
|---|---|---|---|---|
emerging_prz | ohlc:pattern:emerging | BUY_LIMIT / SELL_LIMIT | PRZ midpoint ± 5 pips (inside PRZ) | Default for harmonic strategies (ABCD, Gartley, Bat, Butterfly, Crab, Shark, Cypher). Placed while pattern is still forming; fills if price enters the PRZ zone. SL from pattern.projectedSl; TP1 from pattern.projectedTp1. |
prz_touch | ohlc:pattern:prz_touch | BUY_STOP / SELL_STOP | D ± 5 pips | Backtest fallback default (tradingConfig?.entryStrategy ?? 'prz_touch'). SL shifted by the same 5-pip offset to preserve risk distance. SL/TP from the detection event. |
candle_close | ohlc:pattern:candle_close_prz | BUY_STOP / SELL_STOP | D ± 5 pips | Same order type and SL/TP logic as prz_touch; triggered by a candle close inside the PRZ instead of an intra-candle touch. |
swing_confirmation | ohlc:pattern:detected | market order | live market price at signal time | Confirmed-entry mode for harmonic strategies when you intentionally wait for the post-D swing confirmation instead of entering earlier at the PRZ. Fires ~PIVOT_LOOKBACK+1 candles after D, so price has already moved and the trade opens at market. SL/TP comes from the detection event. |
Backtest vs live discrepancy: ABCD backtests/optimisations now use the emerging ABC path: they require a valid XABC sequence, wait for the first PRZ touch after C, and enter on that touch price. They no longer wait for a confirmed D swing point. Live emerging_prz still depends on the strategy’s abcdPrzEntry edge, so small entry/SL-TP differences can still remain.
2b. Manual trade (user-initiated)
paperTradeService.openTrade path:
validateTradeSides— SL on wrong side of entry, or TP ordering invalid → reject- Count open trades; if
openCount >= account.maxOpenTrades→ reject - Compute lot size (see §5)
- Create trade with
status: 'open',entrySource: 'manual' - Broadcast
trading:trade:opened
Note: Manual trades bypass the strategy gate — they open immediately on user request.
2c. Pending order (limit order)
paperTradeService.createPendingOrder path:
orderEntryPriceis required (notentryPrice)- Same
validateTradeSidescheck onorderEntryPrice - Lot size computed on
orderEntryPricevs SL - Trade created with
status: 'pending',entryPrice: null
Activation (pendingOrderActivationService.checkPendingOrders):
- Called on every
candle:updatemessage from ohlc-service - For bullish: activates if
candle.low <= orderEntryPrice - For bearish: activates if
candle.high >= orderEntryPrice - Sets
status: 'open',openedAt: now(),entryPrice = orderEntryPrice - Broadcasts
trading:trade:activated - ⚠️ No
maxOpenTradescheck on activation — see §7 bug list
3. How SL/TP Levels Are Calculated
3a. ohlc-service (origin)
slTpCalculator.calculateSlTp(input, config?):
- Each pattern type has a dedicated formula (SL behind X-point or PRZ bounds)
- Returns raw
{ sl, tp1, tp2, tp3 } - If a
TradingConfigis passed, levels are scaled by multipliers from the D-point:scaledSl = D ± dist(raw.sl, D) * slMultiplierscaledTpN = D ± dist(raw.tpN, D) * tpNMultiplier
- Multipliers default to
100if no config provided (100 = 100% = no change) - ohlc-service does not receive user-specific configs during live detection (it detects globally). User multipliers are applied later in mi-casa via
applySlTpMultipliers()insideevaluateCandleClosePrzandevaluateEmergingPrzbefore order placement.
For prz_touch events, the D-point (entry) is the actual candle touch price, so the levels reflect real-time geometry.
3b. mi-casa auto-trading engine (drift adjustment)
When the live price has drifted from the event entry price:
entryDrift = livePrice - event.entryPrice
adjustedSl = event.sl + entryDrift
adjustedTp1 = event.tp1 + entryDrift
adjustedTp2 = event.tp2 + entryDrift (if present)
adjustedTp3 = event.tp3 + entryDrift (if present)
This preserves the pip-distance of every level while anchoring the trade to the current market price.
4. Trade Resolution (SL/TP Hit)
4a. ohlc-service — outcome detection
resolveOutcomeFromCandle(event, candle) is called for every closed candle against every open detection event.
Priority (SL always wins over TP if same candle hits both):
SL hit + any TP hit → outcome: 'sl'
TP3 hit → outcome: 'tp3'
TP2 hit → outcome: 'tp2'
TP1 hit → outcome: 'tp1'
The resolved outcome is saved to the detection event and broadcast as ohlc:detection-event:updated.
4b. mi-casa — resolution matrix
paperTradeResolutionService.resolveByDetectionEvent handles each outcome:
| Outcome | tpTargetMode | partialExitEnabled | Action |
|---|---|---|---|
sl | any | any | Close trade at SL price (or entry price if slMovedToBreakeven = true) |
tp1 | tp1 | any | Close fully at TP1 |
tp1 | tp1_tp2 | false | Do nothing — wait for TP2 |
tp1 | tp1_tp2 | true | Mark tp1HitAt, set slMovedToBreakeven = true, broadcast trading:trade:tp1-partial |
tp1 | tp1_tp2_tp3 | false | Do nothing — wait for TP2/TP3 |
tp1 | tp1_tp2_tp3 | true | Mark tp1HitAt, set slMovedToBreakeven = true, broadcast trading:trade:tp1-partial |
tp2 | tp1_tp2 | any | Close fully at TP2 |
tp2 | tp1_tp2_tp3 | any | Do nothing — wait for TP3 (⚠️ no partial exit at TP2 even if partialExitEnabled) |
tp3 | any | any | Close fully at TP3 |
4c. Breakeven SL
When slMovedToBreakeven = true and the trade later hits SL:
- The
effectiveSlused for P&L isentryPrice, not the storedsl - Result: 0-pip loss (capital preserved)
4d. Reconnection reconciliation
On WebSocket reconnect, reconcileOpenTrades() is called:
- Fetches all open paper trades that have a
detectionEventId - Batch-queries ohlc-service
GET /detection-events/resolved?ids=... - Calls
resolveByDetectionEventfor any that have a non-open outcome
- Guards against trades getting stuck open after a disconnect
4e. Candle-based fallback resolution
resolveCandleBasedTrades(candle) runs on every candle:closed event and acts as a permanent safety net for trades with detectionEventId = null (manual trades, and any strategy trades opened before fix #713 was deployed):
- Fetches all open paper trades where
detectionEventId IS NULLandsymbol + timeframematch the closed candle viapaperTradeRepository.getOpenNullDetectionEventBySymbol - For each trade, applies the same SL > TP3 > TP2 > TP1 priority as ohlc-service (§4a)
- Calls
paperTradeRepository.closeByIdwith the computed P&L and broadcaststrading:trade:closed - Respects the full §4b resolution matrix (tpTargetMode, partialExitEnabled, slMovedToBreakeven)
Fix #770: Prior to this fix the implementation used
paperTradeRepository.getOpenBySymbol(all open trades) instead of the null-detectionEvent filter. Trades filled from pending orders (which carry adetectionEventId) were evaluated by the candle fallback on the same tick they were filled, closing them immediately at full SL loss with 0-minute duration. Fixed by switching togetOpenNullDetectionEventBySymbol— now matching this description exactly.
5. Lot Size Calculation
computeLotSize(account, symbol, entryPrice, sl):
riskMode | Formula |
|---|---|
fixed | account.fixedLotSize |
percent | (balance × riskPercent/100) / (slDistancePips × 10) |
- Clamped to
[0.001, 100] - If SL distance is 0 pips → returns
0.001(minimum) pipMultiplier(closed P&L viacomputeTradePnl): 100 for JPY pairs, 10000 for all others (raw)displayPipMultiplier(unrealised P&L display viaohlcPaperPortfolioService):Math.max(1, pipMultiplier / 10)— 100 for JPY, 1000 for EURNOK/USDNOK, 10000 for standard forex
P&L formula:
pnlPips = (exitPrice - entryPrice) * pipMultiplier (bullish)
pnlPips = (entryPrice - exitPrice) * pipMultiplier (bearish)
pnlAmount = pnlPips * lotSize * 10
6. Pattern Trading Config — Fallback Chain
patternTradingConfigService.getMergedConfig(userId, patternType, symbol?, timeframe?):
Scoped (symbol + timeframe provided) — 8 steps:
- User · scoped · specific pattern
- User · scoped ·
allcatch-all - User · global · specific pattern
- User · global ·
allcatch-all - System · scoped · specific pattern
- System · scoped ·
allcatch-all - System · global · specific pattern
- System · global ·
allcatch-all
Unscoped (no symbol/timeframe) — 4 steps:
- User · global · specific pattern
- User · global ·
allcatch-all - System · global · specific pattern
- System · global ·
allcatch-all
The auto-trading engine calls the scoped variant, passing symbol and timeframe from the event.
Config fields and their effect in auto-trading
| Field | Used by auto-trading? | Notes |
|---|---|---|
isActive | ❌ No | Superseded by Strategy gating — the Strategy’s own strategyType, symbols, and timeframes control which events open trades |
entryStrategy | ❌ No | Superseded by Strategy gating — no per-pattern entry-strategy filter in the engine |
slMultiplier | ✅ Yes | Applied in mi-casa via applySlTpMultipliers() in evaluateCandleClosePrz and evaluateEmergingPrz before order placement |
tp1Multiplier | ✅ Yes | Same as above |
tp2Multiplier | ✅ Yes | Same as above |
tp3Multiplier | ✅ Yes | Same as above |
minSignalScore | ❌ No | Not used by the auto-trading engine |
tpCount | ❌ No | Controls ohlc-service detection; not used in mi-casa engine |
entryLagCandles | ❌ No | ohlc-service concern |
partialClose | ❌ No | Superseded by strategy-level then account-level partialExitEnabled (see §6b) |
tpTargetMode | ⚠️ Partial | Config-level value used as fallback for pattern-triggered paths only — strategy-level takes precedence (see §6b) |
System defaults
Seeded by db/seed.ts for every pattern type with all defaults:
slMultiplier = 100,tp1Multiplier = 100, etc.isActive = trueentryStrategy = 'swing_confirmation'(PatternTradingConfig seed value only — this field is marked ❌ unused in auto-trading above; it does not affect which entry strategy live trades use)
6b. Per-trade field resolution at creation
Two trade fields — tpTargetMode and partialExitEnabled — are resolved by the auto-trading engine at trade creation time and stored on the paper_trade row. The §4b resolution matrix then reads them from the trade record.
| Field | Pattern-triggered path (evaluateCandleClosePrz) | Strategy-signal path (evaluateStrategySignal) |
|---|---|---|
tpTargetMode | strategy.tpTargetMode ?? tradingConfig?.tpTargetMode ?? 'tp1' | strategy.tpTargetMode ?? 'tp1' |
partialExitEnabled | strategy.partialExitEnabled ?? account.partialExitEnabled | strategy.partialExitEnabled ?? account.partialExitEnabled |
Key points:
- Strategy-level always wins. If
strategy.tpTargetModeis set it is used regardless of what thePatternTradingConfigsays. - The config-level
tpTargetMode(fromtradingConfig?.tpTargetMode) is a fallback for the pattern-triggered path only; the strategy-signal path skips it entirely and falls back straight to'tp1'. partialExitEnabledis the same for both paths: strategy first, account second. The config-levelpartialClosefield is never read by the engine.
7. Known Bugs / Gaps
BUG-A: prz_touch auto-trades silently skipped by default — resolved
The patternConfig.entryStrategy guard that caused this has been removed. Auto-trade gating is now handled entirely by the Strategy system (§11). prz_touch events will open a trade for any user who has a matching enabled Strategy.
BUG-B: SL/TP multipliers from patternConfig are not applied during auto-trading — resolved
Location: backend/services/autoTrading/evaluateStrategySignal.ts
slMultiplier, tp1Multiplier, etc. from the Strategy were never applied to the event’s SL/TP before creating the trade. For rsi_obos, the signal’s raw SL/TP were also computed using the detector’s hardcoded defaults (25 pips / RR 3), ignoring the strategy’s rsiSlPips and rsiRrRatio.
Fix: evaluateStrategySignal now calls resolveStrategySignalLevels(event, strategy, entryPrice) before creating both paper and cTrader trades. For rsi_obos, this recomputes raw SL/TP from strategy.rsiSlPips / strategy.rsiRrRatio (falling back to detector defaults), then applies applySlTpMultipliers using the strategy’s slMultiplier / tp1Multiplier. For all other strategy types, the signal’s own SL/TP are used as the raw base and the multipliers are applied on top.
BUG-C: tp1_tp2_tp3 mode — TP2 outcome is completely ignored
Location: backend/services/paperTradeResolutionService.ts lines 95–115
When outcome === 'tp2' and tpTargetMode === 'tp1_tp2_tp3', the resolution service does nothing (waits for TP3). No partial exit is triggered, no SL is moved to breakeven, and there is no tp2HitAt field to record the milestone. This is asymmetric with TP1 handling.
BUG-D: Pending order activation bypasses maxOpenTrades limit
Location: backend/services/pendingOrderActivationService.ts
When a pending order triggers, it is activated unconditionally. There is no check against account.maxOpenTrades at activation time. A user could queue many pending orders, all of which activate simultaneously and breach the open trade limit.
BUG-E: Missing DB index on detectionEventId
Location: backend/db/schemas/paperTrades.ts
paperTradeRepository.getOpenByDetectionEventId is called on every ohlc:detection-event:updated WebSocket event. The table only has an index on (userId, status), not on (detectionEventId, status). Under load this query performs a full table scan.
BUG-F (#713): Strategy-signal trades had detectionEventId: null — resolved
Location: backend/services/autoTradingEngine.ts (evaluateStrategySignal)
Strategy-signal auto-trades were created with detectionEventId: null, meaning ohlc-service never monitored their SL/TP levels and ohlc:detection-event:updated was never sent. The trades ran forever.
Fix: evaluateStrategySignal now calls insertDetectionEvent (via ohlcDetectionEventService) before creating the paper trade and stores the returned event ID in detectionEventId. ohlc-service will now watch the SL/TP levels and broadcast ohlc:detection-event:updated when they are hit.
BUG-G (#714): handleCandleClosed had no SL/TP resolution hook — resolved
Location: backend/services/marketDataWebSocket.ts (handleCandleClosed)
handleCandleClosed only forwarded the closed-candle event to the frontend and updated the in-memory candle map. Manual trades and any trade with detectionEventId = null (e.g., pre-fix strategy trades) were never evaluated for SL/TP hits.
Fix: handleCandleClosed now calls resolveCandleBasedTrades(candle) — a permanent fallback resolver that fetches open paper trades with detectionEventId IS NULL for the candle’s symbol+timeframe and closes any whose SL/TP was hit, following the full resolution matrix (§4b). Uses the computeTradePnl utility.
Note: The original deployment of this fix still used
getOpenBySymbol(all open trades) despite the intent. Fix #770 (see below) corrected it togetOpenNullDetectionEventBySymbol.
BUG-H (#770): resolveCandleBasedTrades fetched all open trades instead of null-detectionEvent trades — resolved
Location: backend/services/marketDataWebSocket.ts (resolveCandleBasedTrades)
Despite fix #714 (BUG-G) describing the fallback as filtering by detectionEventId IS NULL, the actual call was paperTradeRepository.getOpenBySymbol — which returns ALL open trades for the symbol, including those with a detectionEventId. Trades filled from pending orders (which have a detectionEventId) were therefore evaluated by the candle fallback on the same tick that filled them, closing them immediately with a full SL loss and 0-minute duration.
Fix: Switched to paperTradeRepository.getOpenNullDetectionEventBySymbol. The fallback now only touches trades that have no detection event, matching §4e exactly.
BUG-I (#771): Fresh harmonic patterns never broadcast — resolved
Location: microservices/ohlc-service/src/lib/saveHarmonicMatch.ts
The ohlc:pattern:detected broadcast was gated on savedEvent.entryTime != null. For freshly-detected patterns (D at the most recent confirmed swing), entryTime was always null — the entry candle had not been added to the DB yet. These events were silently dropped and never reached evaluateCandleClosePrz on the mi-casa side. Only patterns from historical redetect (with a pre-computed future entryTime) were ever broadcast.
Fix: Removed the entryTime != null guard. Detection events are now broadcast immediately when first saved, regardless of entryTime. The silent flag continues to suppress broadcasts during batch redetect.
BUG-J (#772): PRZ blown-through check compared candle close against PRZ zone boundary instead of SL level — resolved
Location: microservices/ohlc-service/src/lib/przTouchWatcher.ts
The blown-through check compared the candle close against match.projectedPrzLow/High (the PRZ zone edge). For ABCD patterns the actual SL is D − cdLen × 0.1, which sits tighter than projectedPrzLow. The watcher treated a pattern as still active after the trade’s SL had already been violated, keeping stale patterns in the watch loop.
Fix: Pre-computed entryPrice and levels = calculateSlTp(...) before the blown check. The check now uses levels.sl instead of projectedPrzLow/High. The entryPrice and levels variables are reused for event building further down the loop.
BUG-K (#773): SL anchored at D-level when pending-order entry was offset ±5 pips — resolved
Location: backend/services/autoTradingEngine.ts (evaluateCandleClosePrz)
When creating a pending stop order, entry was placed at D ± 5 × pipSize but the SL was left at its original D-based value. This made the risk distance 5 pips wider than the theoretical signal intended, causing computeLotSize to undersize lots for percent-risk accounts.
Fix: The SL is now shifted by the same offset (pendingSl = sl ± entryOffset). TPs remain at their original D-based Fibonacci extension levels. computeLotSize now receives the correct pendingSl for accurate risk sizing.
BUG-L (#809): ABCD emerging_prz — projectedSl/projectedTp1 anchored to PRZ midpoint but entry placed at PRZ boundary — resolved
Location: microservices/ohlc-service/src/lib/emergingDetector.ts (lines 161–172) + backend/services/autoTradingEngine.ts evaluateEmergingPrz
In emergingDetector.ts, projectedD = (przLow + przHigh) / 2 (PRZ midpoint), and calculateSlTp is called with this midpoint to produce projectedSl and projectedTp1. However, evaluateEmergingPrz places the ABCD entry at the PRZ boundary level — either projectedPrzHigh − 5 pips (1.272 entry) or projectedPrzLow + 5 pips (1.618 entry) — while reusing the midpoint-anchored projectedSl/projectedTp1 unchanged.
Impact:
abcdPrzEntry = '1.272'(default): SL distance from entry =0.3175 × bcLen(3.2× wider than intended); actual R:R ≈ 2.27:1 instead of theoretical 6.18:1.abcdPrzEntry = '1.618':projectedSlends up above entry for bullish (wrong side), causing the trade to SL-hit immediately on the first tick.
Fix: SL/TP are now recomputed inline from pendingEntryPrice in evaluateEmergingPrz. SL is anchored at the 1.618 CD extension with a 0.1% buffer (matching slTpCalculator.ts); TP1 uses 0.382 × CD from entry:
const bcLen = Math.abs(pattern.cPrice - pattern.bPrice);
const d1618 = isBull
? pattern.cPrice - 1.618 * bcLen
: pattern.cPrice + 1.618 * bcLen;
const buffer = Math.abs(d1618) * 0.001;
const slPrice = isBull ? d1618 - buffer : d1618 + buffer;
const cdLen = Math.abs(pendingEntryPrice - pattern.cPrice);
const tp1Price = isBull
? pendingEntryPrice + 0.382 * cdLen
: pendingEntryPrice - 0.382 * cdLen;BUG-M (#810): syncMissingPendingOrdersService ignores abcdPrzEntry — ABCD reconciliation always uses PRZ midpoint — resolved
Location: backend/services/syncMissingPendingOrdersService.ts (lines 141–144)
The reconnect reconciliation path always computes ABCD entry as projectedPrzMid ± 5 pips, regardless of the strategy’s abcdPrzEntry setting. After a WebSocket disconnect/reconnect, re-created ABCD orders use a different entry price (and therefore different SL/TP geometry) than orders created by the live evaluateEmergingPrz path.
Fix: The reconciliation path now mirrors the abcdPrzEntry-aware entry selection from evaluateEmergingPrz and recomputes SL/TP from the chosen boundary using the same d1618 ± 0.1% buffer formula.
BUG-N (#811): No validateTradeSides guard in auto-trading order creation — invalid SL/TP geometry is silently persisted
Location: backend/services/autoTradingEngine.ts (evaluateEmergingPrz), backend/services/autoTradingEngine.ts (evaluateCandleClosePrz), backend/services/syncMissingPendingOrdersService.ts
Manual trade creation (paperTradeService) is protected by validateTradeSides. Auto-trading paths have no equivalent guard. A miscalculated SL/TP (e.g. from BUG-L or BUG-M) can be inserted into the DB and fill without any warning.
Fix: Add a validateTradeSides call before each pendingOrderService.createPendingOrder invocation in all auto-trading paths. Log a warning and skip the order on validation failure.
BUG-O (#812): ABCD SL formula uses only 10% of CD — no candle-extreme anchor, easily whipsawed — resolved
Location: microservices/ohlc-service/src/lib/slTpCalculator.ts (lines 76–83)
sl = D ± 0.1 × cdLen. For small ABCD patterns (bcLen = 30 pips), this places SL only 3–4 pips from D — within normal spread/tick noise for the prz_touch and swing_confirmation paths where D is a confirmed candle.
Fix: SL is now anchored at the 1.618 CD extension (the extreme projection of the CD leg) with a 0.1% proportional buffer: buffer = |d1618| × 0.001, sl = d1618 ∓ buffer. This is instrument-agnostic — metals, crypto, and FX all get a buffer proportional to the price level rather than a flat pip value that was negligible on high-priced assets. The same formula is applied consistently in slTpCalculator.ts, evaluateEmergingPrz (autoTradingEngine.ts), and the reconciliation path (syncMissingPendingOrdersService.ts). The dCandleExtreme anchor originally proposed was not implemented.
BUG-P: Paper comparison orders created alongside cTrader orders — resolved
Location: backend/services/autoTradingEngine.ts, backend/services/marketDataWebSocket.ts, backend/services/syncMissingPendingOrdersService.ts
All three auto-trading paths unconditionally created a second isCtraderLinked: false paper comparison row alongside the cTrader-linked row, even when a cTrader account was active. This doubled pending_orders for cTrader users and polluted paper P&L with mirrored cTrader positions.
Fix: All three paths now gate paper comparison order/trade creation behind !ctraderAccount — paper rows are only created when no active cTrader account is present. Dedup checks were tightened to match: each path now checks only the order type that will actually be created (alreadyPendingCtrader when cTrader is active, alreadyPendingPaper when not).
BUG-Q: syncMissingPendingOrdersService never cancelled cTrader orders for invalidated patterns — resolved
Location: backend/services/syncMissingPendingOrdersService.ts
The sync service only created missing orders; it never cleaned up PENDING orders whose patternEventId referred to a detection event that had since been resolved or invalidated. Those ghost orders remained open in cTrader indefinitely.
Fix: After creating missing orders the service now runs a cancellation sweep — pendingOrderRepository.listDistinctPendingPatternEventIds() returns all distinct patternEventId values for live PENDING orders; any whose detection event is no longer open is cancelled via pendingOrderService.cancelByPatternEventId, which calls cancelInCtrader to remove the order from cTrader.
BUG-R: Drift guard used raw pip size — EURNOK/USDNOK patterns silently blocked — resolved
Location: backend/services/autoTradingEngine.ts
The drift guard computed pip distance using getPipSize(symbol) (raw: 0.00001 for EURNOK) and compared it against account.maxEntryDeviationPips (default: 50). For EURNOK, 0.00001 is a pipette (1/10th of a display pip), so the threshold of 50 resolved to only 5 display pips — silently blocking patterns where the live price had drifted even modestly from the event price.
Fix: Changed to getDisplayPipSize(symbol) (0.0001 for EURNOK), so maxEntryDeviationPips = 50 now correctly means 50 display pips on all instruments.
BUG-S: ohlcPaperPortfolioService unrealised P&L 10× inflated for NOK pairs — resolved
Location: backend/services/ohlcPaperPortfolioService.ts
Unrealised pip P&L was computed using getPipMultiplier(symbol) (raw: 10 000 for EURNOK). Because display pips for NOK pairs are 1/10th the raw value (1 display pip = 10 raw pips), portfolio positions showed 10× the actual unrealised pip count for those instruments.
Fix: Changed to getDisplayPipMultiplier(symbol) (Math.max(1, pipMultiplier / 10) — 1 000 for EURNOK) so unrealised P&L is expressed in display pips, consistent with how closed-trade P&L is shown everywhere else.
BUG-T: cTrader position SL not moved to breakeven after TP1 partial exit — resolved
Location: backend/jobs/ctraderOrderSyncJob.ts
When a paper trade had slMovedToBreakeven = true (set by the resolution service after a TP1 partial exit — see §4b/§4c), the paper trade record correctly recorded breakeven state. However, the corresponding live cTrader position retained its original SL price. On subsequent SL hits, cTrader closed at the original SL level rather than at entry, causing a real loss where zero loss was expected.
Fix: The sync job now iterates all open paper trades with slMovedToBreakeven = true and, for each, finds the matching live cTrader position (matched by symbol + direction; skips when the match is ambiguous). If the position’s current SL differs from entryPrice by more than half a pip, ctraderOrderService.amendPositionSLTP is called to move the SL to entry. The openPaperTrades fetch is shared between Fix #3 (missing SL/TP) and Fix #4 — only one DB query is made when livePositions.length > 0.
BUG-U: evaluateEmergingPrz ignored strategy abcdBcRetMin/abcdBcRetMax/abcdCdTolerance filters — resolved
Location: backend/services/autoTradingEngine.ts (evaluateEmergingPrz) and backend/services/syncMissingPendingOrdersService.ts
passesAbcdFilters was only called in evaluateCandleClosePrz and evaluateStrategySignal. The evaluateEmergingPrz path (and its reconnect-reconciliation mirror in syncMissingPendingOrdersService) never applied BC retracement or CD equality filters, so strategies with custom ABCD filter ranges would still receive emerging_prz orders for patterns that should have been rejected.
Fix: Added passesAbcdFiltersForEmerging (backend/utils/passesAbcdFiltersForEmerging.ts) — a parallel helper that works from the EmergingWsPattern shape (no confirmed D yet). For BC ratio it uses A, B, C directly; for CD tolerance it derives the projected D from the PRZ boundary selected by strategy.abcdPrzEntry (mirrors the geometry used by buildOrderPrices). Called in all four emerging sub-paths (paper + cTrader in both engine and sync service).
BUG-V: evaluateEmergingPrz paper path skipped checkMinTpDistance guard — resolved
Location: backend/services/autoTradingEngine.ts (evaluateEmergingPrz — paper sub-path) and backend/services/syncMissingPendingOrdersService.ts (both paper and cTrader sub-paths)
The minTpPips guard (checkMinTpDistance) was only enforced on the cTrader sub-path of evaluateEmergingPrz. The paper sub-path went directly to createPendingOrder without the check. The sync service lacked the guard in both sub-paths entirely.
Fix: checkMinTpDistance is now called before order creation in all three missing sub-paths. Uses strategy.minTpPips directly (not the merged pattern config, which does not carry this field).
8. Trade Lifecycle State Machine
[pending] ──(candle touches orderEntryPrice)──► [open]
│
┌──────────────┼──────────────────┐
TP1 hit SL hit manual
(tpTargetMode │ close
determines) │ │
│ │ │
partial exit? │ ▼ ▼
│ │ [closed/sl] [closed/manual]
yes │
│ full close?
▼ yes
[still open] ▼
tp1HitAt set [closed/tp1]
SL→breakeven
│
TP2 hit
│
tpTargetMode=tp1_tp2? → close [closed/tp2]
tpTargetMode=tp1_tp2_tp3? → wait
│
TP3 hit → [closed/tp3]
Manual cancel: [open|pending] → [cancelled]
9. WebSocket Message Flow
ohlc-service mi-casa backend
│ │
│──candle:update──────────────────►│─► handleCandleUpdate()
│ │ └─► checkPendingOrders(candle)
│──candle:closed──────────────────►│─► handleCandleClosed()
│ │ └─► resolveCandleBasedTrades(candle) [FALLBACK RESOLVE]
│──ohlc:pattern:detected──────────►│─► broadcast to frontend (fix #771: fires immediately; no longer
│ │ gated on entryTime != null)
│ │─► notificationService.persistPatternEvent
│ │─► evaluateCandleClosePrz(event) [AUTO-TRADE]
│──ohlc:pattern:emerging──────────►│─► broadcast to frontend
│ │─► syncEmergingPendingOrders(emerging) [AUTO-TRADE]
│──ohlc:pattern:prz-reached───────►│─► broadcast + notification
│──ohlc:pattern:prz_touch─────────►│─► broadcast + notification
│ │─► insertPrzTouchDetectionEvent()
│ │─► evaluateCandleClosePrz(event) [AUTO-TRADE]
│──ohlc:pattern:candle_close_prz──►│─► broadcast to frontend
│ │─► insertCandleCloseDetectionEvent()
│ │─► evaluateCandleClosePrz(event) [AUTO-TRADE]
│──ohlc:strategy:signal───────────►│─► insertDetectionEvent() → ohlc-service [REGISTER]
│ │─► evaluateStrategySignal(event) [AUTO-TRADE]
│ │─► persistStrategySignal(event)
│──ohlc:detection-event:updated───►│─► broadcast to frontend
│ │─► resolveByDetectionEvent(event) [RESOLVE]
│──ohlc:pattern:alert─────────────►│─► handlePatternAlertEmail()
10. Live Price Resolution
getLivePrice(symbol) in marketDataWebSocket.ts iterates timeframes ['5m', '15m', '1h', '4h', '1d'] and returns the close of the first candle found for that symbol — regardless of timeframe. It does not pick the most recent candle across timeframes; it just picks the first non-null one. This is fine for proximity checks but not a precise bid/ask.
11. Strategy-gates-Pattern Rule
Design rule
Auto-trades only fire when the user has an enabled Strategy that matches the incoming signal. A pattern detection event alone is never enough to open a trade. Manual chart trades are intentionally exempt — they bypass this gate entirely.
Three auto-trade code paths
| Code path | Trigger | Order type | Loop unit | Dedup check |
|---|---|---|---|---|
evaluateCandleClosePrz | ohlc:pattern:detected, ohlc:pattern:prz_touch, ohlc:pattern:candle_close_prz | BUY_STOP / SELL_STOP | per Strategy | hasExistingPendingForStrategy |
evaluateEmergingPrz | ohlc:pattern:emerging | BUY_LIMIT / SELL_LIMIT | per Strategy | hasExistingPendingForEmergingPattern |
evaluateStrategySignal | ohlc:strategy:signal | BUY_STOP / SELL_STOP (via pending) | per Strategy | hasOpenStrategyTrade |
All three paths share the same structure:
- Load all enabled Strategies for the user that match the signal (type + symbol + timeframe +
entryLagCandles = 0) - For each matching Strategy, check
hasOpenStrategyTrade(userId, strategyId, symbol, timeframe) - Create one trade per Strategy that passes; save
strategyIdon every trade
Pattern detection is always independent
Pattern detection (emerging, valid, PRZ) fires unconditionally — patterns are displayed on the chart for all users. Strategies only gate trade execution, not pattern visibility.
Supported strategyType values
Only harmonic strategies are valid in the current system: gartley, bat, butterfly, crab, shark, abcd, cypher.