Auto-Trading — Per-Strategy Account Routing

How mi-casa decides whether an auto-trade opens as a paper trade or routes to a specific live cTrader account, and the guardrails that keep the two worlds from leaking into each other.

The Routing Rule

Every Strategy row carries an optional ctraderAccountId foreign key:

strategies.ctraderAccountIdWhere the trade lands
NULLPaper trade (internal simulation, no broker connection)
<uuid>Live order sent to that specific cTrader account

The auto-trade engine reads this field once when a signal matches a Strategy and branches immediately — paper and live code paths are completely separate from that point forward. A single user can have multiple Strategies pointing at different accounts simultaneously.

Account Lifecycle

OAuth Callback → Paper Account Bootstrap

When a user completes the cTrader OAuth flow, the callback handler automatically creates a paper account row in paper_trading_accounts if one does not already exist for that user. This gives the bookkeeping layer a stable home for paper P&L even before the user configures any Strategy, and means paper trades always have a valid financialAccountId to write against.

autoTradingEnabledAt — Stale-Signal Gate

ctrader_accounts carries an autoTradingEnabledAt timestamp. When a user enables auto-trading on an account, this is set to NOW(). The engine ignores any signal whose detection timestamp predates autoTradingEnabledAt — this prevents a backlog of queued signals from firing trades the moment the user turns trading on.

signal.detectedAt < account.autoTradingEnabledAt  →  skip (stale)
signal.detectedAt ≥ account.autoTradingEnabledAt  →  process normally

Disconnect Guard

A cTrader account cannot be disconnected (OAuth revoked / account unlinked) while any Strategy still references it via ctraderAccountId. The disconnect endpoint checks for linked Strategies first and returns an HTTP 400 validation error if any exist. The user must re-assign or delete those Strategies before disconnecting.

pending_orders — Immutable Account Snapshot

When entryLagCandles > 0, the engine creates a pending order instead of an immediate trade. The ctraderAccountId resolved at creation time is written into pending_orders and never updated afterwards. This is intentional:

  • The user may re-assign the Strategy to a different account between signal detection and order activation.
  • The pending order must execute on the account that was live when the user committed to the trade.
  • Cancellation logic reads pending_orders.ctraderAccountId directly — no Strategy join needed at cancel time.
Signal matches Strategy
  └─ entryLagCandles > 0
       └─ pending_orders.ctraderAccountId = strategy.ctraderAccountId  ← snapshot, immutable
            └─ price hits entry level
                 └─ activate using pending_orders.ctraderAccountId (not strategy's current value)

Full Routing Flow

Signal received by auto-trade engine
  └─ fetch matching enabled Strategies for (userId, signalType, symbol, timeframe)
       └─ for each Strategy:
            ├─ strategy.ctraderAccountId = NULL?
            │    └─ open paper trade
            └─ strategy.ctraderAccountId = <id>?
                 ├─ load ctrader_accounts row
                 ├─ signal.detectedAt < autoTradingEnabledAt?  →  skip (stale)
                 ├─ entryLagCandles = 0?
                 │    └─ send live order to cTrader account
                 └─ entryLagCandles > 0?
                      └─ create pending_orders row (snapshot ctraderAccountId)

What Does NOT Change the Route

ActionEffect on routing
Re-assigning ctraderAccountId on a StrategyOnly affects future signals — existing pending orders are unaffected
Disabling a StrategyEngine skips it; no route decision made at all
Revoking OAuth for an accountBlocked at API level if any Strategy still references the account