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:

#ConditionSource
1OHLC_SERVICE_URL env var is setmarketDataWebSocket.ts
2ohlc-service WebSocket is connectedmarketDataWebSocket.ts
3Detection event has entryPrice, sl, tp1autoTradingEngine.ts line 26
4At least one PaperTradingAccount has enabled = truepaperTradingAccountRepository.getAllEnabled()
5Event detectedAt ≥ account enabledAt (signal is not stale from before auto-trading was turned on)autoTradingEngine.ts line 41
6At 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
7No existing open trade for this (userId, strategyId, symbol, timeframe) combopaperTradeRepository.hasOpenStrategyTrade
8openCount < account.maxOpenTradespaperTradeRepository.countOpenByUserId
9getLivePrice(symbol) returns a non-null valuemarketDataWebSocket.liveCandles map
10Price 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 pipettesautoTradingEngine.ts

If all checks pass, the engine iterates every matching Strategy and creates one trade per Strategy:

  • status: 'open'
  • entrySource: 'auto'
  • strategyId from the matched Strategy
  • entryPrice: live price (not event price — drift-adjusted)
  • SL/TP shifted by entry drift so distances remain identical to the signal
  • patternSnapshot fetched from ohlc-service via GET /patterns/by-event/:id

If two Strategies match the same event, two trades open — one per Strategy.

Entry strategies

StrategyWS triggerPending order typeEntry priceNotes
emerging_przohlc:pattern:emergingBUY_LIMIT / SELL_LIMITPRZ 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_touchohlc:pattern:prz_touchBUY_STOP / SELL_STOPD ± 5 pipsBacktest 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_closeohlc:pattern:candle_close_przBUY_STOP / SELL_STOPD ± 5 pipsSame order type and SL/TP logic as prz_touch; triggered by a candle close inside the PRZ instead of an intra-candle touch.
swing_confirmationohlc:pattern:detectedmarket orderlive market price at signal timeConfirmed-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:

  1. validateTradeSides — SL on wrong side of entry, or TP ordering invalid → reject
  2. Count open trades; if openCount >= account.maxOpenTrades → reject
  3. Compute lot size (see §5)
  4. Create trade with status: 'open', entrySource: 'manual'
  5. 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:

  1. orderEntryPrice is required (not entryPrice)
  2. Same validateTradeSides check on orderEntryPrice
  3. Lot size computed on orderEntryPrice vs SL
  4. Trade created with status: 'pending', entryPrice: null

Activation (pendingOrderActivationService.checkPendingOrders):

  • Called on every candle:update message 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 maxOpenTrades check 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 TradingConfig is passed, levels are scaled by multipliers from the D-point:
    • scaledSl = D ± dist(raw.sl, D) * slMultiplier
    • scaledTpN = D ± dist(raw.tpN, D) * tpNMultiplier
  • Multipliers default to 100 if 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() inside evaluateCandleClosePrz and evaluateEmergingPrz before 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:

OutcometpTargetModepartialExitEnabledAction
slanyanyClose trade at SL price (or entry price if slMovedToBreakeven = true)
tp1tp1anyClose fully at TP1
tp1tp1_tp2falseDo nothing — wait for TP2
tp1tp1_tp2trueMark tp1HitAt, set slMovedToBreakeven = true, broadcast trading:trade:tp1-partial
tp1tp1_tp2_tp3falseDo nothing — wait for TP2/TP3
tp1tp1_tp2_tp3trueMark tp1HitAt, set slMovedToBreakeven = true, broadcast trading:trade:tp1-partial
tp2tp1_tp2anyClose fully at TP2
tp2tp1_tp2_tp3anyDo nothing — wait for TP3 (⚠️ no partial exit at TP2 even if partialExitEnabled)
tp3anyanyClose fully at TP3

4c. Breakeven SL

When slMovedToBreakeven = true and the trade later hits SL:

  • The effectiveSl used for P&L is entryPrice, not the stored sl
  • Result: 0-pip loss (capital preserved)

4d. Reconnection reconciliation

On WebSocket reconnect, reconcileOpenTrades() is called:

  1. Fetches all open paper trades that have a detectionEventId
  2. Batch-queries ohlc-service GET /detection-events/resolved?ids=...
  3. Calls resolveByDetectionEvent for 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):

  1. Fetches all open paper trades where detectionEventId IS NULL and symbol + timeframe match the closed candle via paperTradeRepository.getOpenNullDetectionEventBySymbol
  2. For each trade, applies the same SL > TP3 > TP2 > TP1 priority as ohlc-service (§4a)
  3. Calls paperTradeRepository.closeById with the computed P&L and broadcasts trading:trade:closed
  4. 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 a detectionEventId) 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 to getOpenNullDetectionEventBySymbol — now matching this description exactly.


5. Lot Size Calculation

computeLotSize(account, symbol, entryPrice, sl):

riskModeFormula
fixedaccount.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 via computeTradePnl): 100 for JPY pairs, 10000 for all others (raw)
  • displayPipMultiplier (unrealised P&L display via ohlcPaperPortfolioService): 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:

  1. User · scoped · specific pattern
  2. User · scoped · all catch-all
  3. User · global · specific pattern
  4. User · global · all catch-all
  5. System · scoped · specific pattern
  6. System · scoped · all catch-all
  7. System · global · specific pattern
  8. System · global · all catch-all

Unscoped (no symbol/timeframe) — 4 steps:

  1. User · global · specific pattern
  2. User · global · all catch-all
  3. System · global · specific pattern
  4. System · global · all catch-all

The auto-trading engine calls the scoped variant, passing symbol and timeframe from the event.

Config fields and their effect in auto-trading

FieldUsed by auto-trading?Notes
isActive❌ NoSuperseded by Strategy gating — the Strategy’s own strategyType, symbols, and timeframes control which events open trades
entryStrategy❌ NoSuperseded by Strategy gating — no per-pattern entry-strategy filter in the engine
slMultiplier✅ YesApplied in mi-casa via applySlTpMultipliers() in evaluateCandleClosePrz and evaluateEmergingPrz before order placement
tp1Multiplier✅ YesSame as above
tp2Multiplier✅ YesSame as above
tp3Multiplier✅ YesSame as above
minSignalScore❌ NoNot used by the auto-trading engine
tpCount❌ NoControls ohlc-service detection; not used in mi-casa engine
entryLagCandles❌ Noohlc-service concern
partialClose❌ NoSuperseded by strategy-level then account-level partialExitEnabled (see §6b)
tpTargetMode⚠️ PartialConfig-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 = true
  • entryStrategy = '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.

FieldPattern-triggered path (evaluateCandleClosePrz)Strategy-signal path (evaluateStrategySignal)
tpTargetModestrategy.tpTargetMode ?? tradingConfig?.tpTargetMode ?? 'tp1'strategy.tpTargetMode ?? 'tp1'
partialExitEnabledstrategy.partialExitEnabled ?? account.partialExitEnabledstrategy.partialExitEnabled ?? account.partialExitEnabled

Key points:

  • Strategy-level always wins. If strategy.tpTargetMode is set it is used regardless of what the PatternTradingConfig says.
  • The config-level tpTargetMode (from tradingConfig?.tpTargetMode) is a fallback for the pattern-triggered path only; the strategy-signal path skips it entirely and falls back straight to 'tp1'.
  • partialExitEnabled is the same for both paths: strategy first, account second. The config-level partialClose field 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: nullresolved

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 to getOpenNullDetectionEventBySymbol.

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_przprojectedSl/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': projectedSl ends 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 pathTriggerOrder typeLoop unitDedup check
evaluateCandleClosePrzohlc:pattern:detected, ohlc:pattern:prz_touch, ohlc:pattern:candle_close_przBUY_STOP / SELL_STOPper StrategyhasExistingPendingForStrategy
evaluateEmergingPrzohlc:pattern:emergingBUY_LIMIT / SELL_LIMITper StrategyhasExistingPendingForEmergingPattern
evaluateStrategySignalohlc:strategy:signalBUY_STOP / SELL_STOP (via pending)per StrategyhasOpenStrategyTrade

All three paths share the same structure:

  1. Load all enabled Strategies for the user that match the signal (type + symbol + timeframe + entryLagCandles = 0)
  2. For each matching Strategy, check hasOpenStrategyTrade(userId, strategyId, symbol, timeframe)
  3. Create one trade per Strategy that passes; save strategyId on 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.