A backtest can look finished while still being nowhere near a trading account. For Model to Market, I had to close that gap: the same strategy definition had to survive the move from a research panel into MT5 positions that I could inspect and reconcile.

The competition used a shared simulated MT5 account, a fixed 15-instrument universe and a fixed operating window. The project records place the entry 19th out of 440 with a return of +5.94%. I do not have a separate organizer certificate for those figures, so I treat them as project records rather than externally audited results.

That is the only results summary this article needs. Part 1 is about the machinery between a strategy calculation and an actual broker position. Part 2 covers which research candidates were allowed into the configuration. Part 3 covers the live equity path and the changes made during the competition.

From research output to broker state

A notebook return series does not create a broker position.

The backtest could tell me what target weights a strategy would have wanted at a historical timestamp. It could not tell me whether the broker recognised the symbol, whether the requested volume matched the contract specification, whether the account already held an offsetting position, whether the order passed the broker’s checks or whether the position I thought I held still matched MT5 afterward.

I needed an explicit path through each of those states:

  1. collect and align market data;
  2. calculate strategy outputs;
  3. combine them into portfolio targets;
  4. apply portfolio and account-level risk limits;
  5. compare the target with the current broker position;
  6. create an order intent for the remaining difference;
  7. validate the order against MT5;
  8. submit it when permitted;
  9. read the resulting broker state;
  10. reconcile that state with the system’s internal record.
Model to Market runtime pathFrom a processed market panel to a broker-observed position. Strategy code cannot call MT5 directly.

Strategy code cannot call MT5 directly.

Input and strategy
Processed 15-minute panel
Registered Alpha implementationsMomentum · Kalman at launch
Ensemble target contribution
Portfolio and risk
Portfolio targetVolatility sizing · instrument caps
Risk limitsLeverage · drawdown · margin · concentration
Disabled / quarantined symbols
STOP-file operator control
Execution boundary
Current MT5 positions
OMS target-versus-current diff
OrderRequest / order intent
MT5 preflightorder_check before order_send
Broker state
Broker response
Broker-observed positions
ReconciliationExplicit mismatch result
Operational side channels
YAML configurationStrategy and risk state
config_sha1Ties runtime record to loaded config
Heartbeatsmode · cycle · equity · positions · targets
Operator reviewTelemetry does not self-modify the live strategy
View text version
Runtime path stages
StageStep
InputProcessed 15-minute market panel
StrategyRegistered Alpha → ensemble target contribution
PortfolioVolatility sizing and instrument caps
RiskLeverage, drawdown, margin, concentration, STOP file
OMSDiff permitted target against current MT5 positions
MT5Preflight and order_check, then order_send if permitted
BrokerResponse and broker-observed positions
ReconcileCompare internal and external lots; surface mismatches
OpsYAML, config_sha1, heartbeats → operator review only
Market panel & broker stateTarget / order pathRisk-permitted handoffOperator telemetry

A processed 15-minute market panel feeds registered Alpha implementations. At launch, momentum and Kalman contribute to an ensemble target. Portfolio sizing and risk limits — including leverage, drawdown, margin, concentration, disabled or quarantined symbols, and the STOP-file operator control — shape a permitted target. The OMS compares that target with current MT5 positions and emits an OrderRequest. MT5 preflight and order_check run before order_send. Broker responses and observed positions are reconciled with internal state, surfacing mismatches explicitly. YAML configuration and config_sha1 identify the loaded settings; heartbeats expose mode, cycle, equity, positions and targets for operator review. Telemetry does not feed a self-modifying live strategy. Strategy code cannot call MT5 directly.

The runtime path from a processed market panel to a broker-observed position. Strategy code produces target weights but cannot submit an MT5 order directly.

The important boundary was not between research and “production” in the abstract. It was between a desired position and evidence that the account actually held it.

Constraints I could not design around

The competition environment fixed several things before I wrote the live loop:

  • the account was simulated rather than funded with live capital;
  • execution went through MT5;
  • the instrument list was fixed;
  • the competition window was fixed;
  • the account could not simply be reset after a bad run;
  • I was building and operating the system alone;
  • the launch panel already contained incomplete bars and irregular 15-minute spacing.

Those constraints pushed the first configuration toward lower exposure and explicit failure points. A strategy could miss an opportunity without damaging the system. A stale position, malformed order or silent mismatch between local state and MT5 was harder to accept.

The fixed window also changed the way I thought about development. Once the process was running, a code change was no longer only a research improvement. It could alter the behaviour of the account, break state compatibility or make the monitor harder to interpret. I needed to know which parts of the system were allowed to change and which interfaces needed to stay boring.

Configuration, sizing and risk

I did not want a separate live engine for every strategy candidate.

The instruments, signal weights, portfolio limits and risk thresholds lived in YAML. The execution loop could therefore remain recognisable while the research decision changed. Adding or removing a strategy did not require a second OMS, another MT5 client or a new monitoring process.

The locked baseline that matched the launch blend looked like this:

# configs/m2m_baseline_locked.yaml
alpha:
  name: ensemble
  members:
    - {name: momentum, enabled: true, weight: 0.6, params: {lookback: 64, vol_window: 32}}
    - {name: kalman_meanrev, enabled: true, weight: 0.4, params: {leg_y: XAUUSD, leg_x: XAGUSD, delta: 0.001, r: 1.0e-5, z_entry: 2.0, z_exit: 0.5}}

portfolio:
  target_vol: 0.03
  max_weight: 0.10

risk:
  max_leverage: 2.0
  max_drawdown: 0.10
  kill_switch_file: STOP

The launch snapshot records the same 0.60 momentum and 0.40 Kalman weights, with leverage capped at 2.0.

The useful part of the configuration was not that YAML is convenient. It was that the research decision had a concrete representation. The same file described which strategies were enabled, how much influence each received and the limits applied after their outputs were combined.

That also made unsafe states possible. A configuration file can express a larger leverage limit just as easily as a smaller one. Configuration did not replace validation; it gave validation and live operation a shared object to inspect.

Later configurations added OU mean reversion and allowed materially higher exposure. Those changes belong to the timeline in Parts 2 and 3. For the launch system, the relevant state was the conservative two-sleeve baseline above.

Building the shared market panel

Research and live operation both consumed a processed 15-minute market panel.

That was important because I did not want the backtest and the live loop to assign different meanings to the same strategy input. A momentum lookback or spread calculation should receive the same column structure, timestamp convention and instrument naming in both paths.

A shared panel did not make the data automatically valid.

The launch snapshot records incomplete bars and irregular spacing after the data was trimmed. A strategy can still produce a number when timestamps are missing. That does not make the number useful. A cross-instrument signal is especially easy to corrupt because one leg can be current while another is stale.

The panel therefore had to do more than concatenate price series. It had to make missing observations visible, align symbols to a common index and give the live loop enough information to skip or quarantine an instrument rather than quietly filling every gap and carrying on.

This was one of the places where a notebook workflow had been too forgiving. In research, it is easy to clean a DataFrame once and forget how many assumptions were hidden in that preparation. In a running process, those assumptions need names, checks and observable failure behaviour.

The strategy contract

Every strategy subclassed a shared Alpha interface and registered by name so the configuration loader could construct it:

# quantsilico/signals/base.py
class Alpha(ABC):
    """Base alpha. Subclass + decorate with @register_alpha('name')."""

    def __init__(self, **params) -> None:
        self.params = params

    @abstractmethod
    def generate(self, data: pd.DataFrame) -> pd.DataFrame:
        """Return a DataFrame of target weights, indexed like `data`.
        Columns = instruments, values in [-1, 1]."""
        ...
# quantsilico/signals/registry.py
def register_alpha(name: str):
    def _wrap(cls: Type[Alpha]) -> Type[Alpha]:
        if name in _REGISTRY:
            raise ValueError(f"alpha '{name}' already registered")
        _REGISTRY[name] = cls
        return cls
    return _wrap

def get_alpha(name: str, **params) -> Alpha:
    if name not in _REGISTRY:
        raise KeyError(f"unknown alpha '{name}'. known: {list(_REGISTRY)}")
    return _REGISTRY[name](**params)

There are two design choices in those excerpts that mattered during the competition.

First, the strategy returns weights rather than orders. It describes the position it wants, not the broker command required to reach it.

Second, registration fails when a name is duplicated or unknown. Configuration can select a strategy by name, but it cannot silently instantiate an arbitrary class or resolve two implementations under the same label.

That separation made the strategy replaceable without giving it control over execution. Momentum, Kalman and later OU could share the same downstream portfolio, risk and order-management path.

A file existing under quantsilico/signals/ was therefore not evidence that it traded. The implementation, registry entry and configuration state were separate facts.

Keeping strategies away from the broker

The ensemble combined strategy outputs before the OMS saw anything.

At launch, momentum contributed 60% of the configured ensemble weight and Kalman contributed 40%. The portfolio layer received their combined target contribution and applied the sizing rules. It did not translate each signal into an independent market order.

That distinction matters when strategies disagree.

If one sleeve wants a larger position and another wants a smaller or opposite position, sending both orders independently would create needless turnover and make risk harder to reason about. Combining them upstream produces one desired portfolio state. The OMS can then compare that state with the account once.

The strategy layer answered:

What position would this model prefer?

The portfolio and risk layers answered:

How much of that position is permitted?

The OMS answered:

What change is required from the position the broker currently reports?

Those are different questions, and I wanted them answered in different modules.

Sizing and risk limits

The launch configuration targeted 3% volatility, capped an individual instrument at 10% weight and limited leverage to 2.0.

Those settings shaped the target portfolio before it reached MT5. The wider risk layer also accounted for drawdown, margin, concentration, disabled symbols and the presence of the STOP file.

The STOP file was deliberately simple. I wanted an operator control that did not depend on the strategy, the data pipeline or a successful broker request. A file check is not clever, but it is easy to inspect and difficult to misunderstand at two in the morning.

The risk layer also had to distinguish between two types of failure:

  • a target that was valid in strategy terms but incompatible with current portfolio limits;
  • an execution request that was malformed or unacceptable to the broker.

The first belonged before the OMS emitted an order. The second belonged in MT5 preflight and response handling.

Having both did not make the system safe by default. The limits themselves still came from configuration, and later competition settings were much less conservative than the launch values. What the architecture provided was a consistent place to apply and observe those decisions.

Launch risk settings represented in configuration
Target volatility
0.03
Maximum instrument weight
0.10
Maximum leverage
2.0
Maximum drawdown
0.10
Operator kill switch
STOP file

Orders, broker checks and reconciliation

The OMS compared target weights with the positions MT5 currently reported and emitted market-order requests for the remaining difference:

# quantsilico/execution/oms.py
def target_weights_to_orders(
    target_weights: dict[str, float],
    current_positions: dict[str, BrokerPosition],
    prices: dict[str, float],
    symbol_specs: dict[str, SymbolSpec],
    equity: float,
    symbol_map: dict[str, str],
    *,
    min_delta_lots: float = 0.0,
    # ... policies, disabled_symbols, quarantine ...
) -> list[OrderRequest]:
    """Diff target weights against current netting positions; emit market orders with stops."""

The arguments show how much context the OMS needed.

It could not convert a weight into a lot size from the weight alone. It needed equity, current prices and broker symbol specifications. It also needed the current net position because a target of 0.10 means something different when the account is flat, already long or currently short.

min_delta_lots gave the conversion a way to ignore changes too small to justify another order. Disabled and quarantined symbols also belonged at this boundary because a valid portfolio target should not override an operational restriction.

The resulting object was still an intent, not a fill.

  1. 01
    Strategy output
    Registered alpha implementations return desired weights on the processed panel.
  2. 02
    Portfolio target
    The ensemble and sizing rules produce one desired position per instrument.
  3. 03
    Risk-adjusted target
    Leverage, concentration, drawdown, margin and symbol controls restrict the desired state.
  4. 04
    Order intent
    The OMS compares the permitted target with the broker-observed position and calculates the required lot change.
  5. 05
    Broker request
    MT5 preflight and order_check validate the request before submission.
  6. 06
    Reconciled position
    The next broker snapshot is compared with the internal position record.

The distinction I kept returning to was that a signal is not a target, a target is not an order intent, an order intent is not an accepted order, and an accepted order is not a reconciled position.

A large part of the live system existed to stop those states from being collapsed into one optimistic assumption.

MT5 preflight and broker responses

Before submission, the MT5 client could validate the requested volume and stops and run order_check without calling order_send.

That mattered because broker constraints are part of the effective strategy whether the research code acknowledges them or not. A target can be mathematically valid and still fail because the volume is below the minimum, the lot step is wrong, the stop distance is invalid or the symbol is unavailable.

Preflight gave those failures a place to appear before an order was treated as live.

The response handling also kept submission separate from success. Calling order_send was not proof that the account had moved to the requested state. The retcode, resulting order or deal information and the next observed position all mattered.

I did not want the live loop to print “order sent” and then update its internal book as though the broker had agreed.

Reconciliation after submission

MT5 remained authoritative for account and position state.

After an order attempt, reconciliation compared the lots recorded internally with the lots reported by the broker:

# quantsilico/live/reconciliation.py
def reconcile_positions(
    internal: dict[str, float], external: dict[str, float], tolerance: float = 1e-9
) -> ReconciliationResult:
    keys = set(internal) | set(external)
    diffs = {k: float(internal.get(k, 0.0)) - float(external.get(k, 0.0)) for k in keys}
    bad = {k: v for k, v in diffs.items() if abs(v) > tolerance}
    if bad:
        return ReconciliationResult(False, "Position mismatch exceeds tolerance", bad)
    return ReconciliationResult(True, "Positions reconciled", diffs)

The function checks the union of symbols from both books. That catches positions that exist on only one side instead of comparing only the instruments the internal state expects to find.

It also returns the differences rather than hiding them behind a Boolean. A mismatch can therefore be surfaced with the instrument and amount that failed.

This function does not solve the mismatch by itself. That was the correct boundary. Reconciliation reports whether the books agree; policy decides whether the process should stop, retry, flatten or wait for another broker snapshot.

I was less worried about missing one trade than about the internal book drifting away from MT5 without the monitor making it obvious. A skipped trade is visible in performance later. An unobserved position mismatch can corrupt every sizing decision that follows.

Telemetry, provenance and operating weaknesses

The live process emitted heartbeats and state records containing the operating mode, cycle, equity, positions and configuration identity.

The monitor was not part of the strategy. It was there for me.

I needed to know whether the process was advancing, whether it was actually running in live, paper-live or dry-run mode, which configuration it had loaded and whether its positions still matched the account.

Runtime fields I needed to see
mode
Distinguish live, paper-live and dry-run behaviour
cycle
Confirm that the main loop was still advancing
equity
Track account state without treating a single snapshot as settlement
config_sha1
Tie runtime behaviour to the exact loaded configuration
positions and targets
Compare intended exposure with broker-observed exposure

config_sha1 became more important once the configuration started changing during the competition. Git history can show when a file changed. It does not, by itself, prove the first cycle that loaded the new file. A runtime hash closes that gap.

I did not preserve every runtime artifact in a form suitable for publication, which is why later parts of the series distinguish between a configuration commit and direct evidence that a specific cycle loaded it.

Where the first implementation was weak

The first working system was usable, but it was not finished.

The market panel still had incomplete bars and irregular spacing. Some research paths used simplified transaction-cost assumptions. The public repository later accumulated fixes, tests and documentation that did not exist in the same form at launch.

That makes the current main branch a poor substitute for a competition-time source reference. When I describe launch behaviour, I need the launch snapshot or the relevant historical commit, not whichever implementation happens to be easiest to find today.

Runtime provenance was another weak point. The system emitted configuration identity, but I did not retain every heartbeat and event record in a clean public evidence bundle. In a few places, Git can prove when I changed a configuration without proving the exact first live cycle that loaded it.

The architecture also separated modules more cleanly than the operating workflow separated decisions. I was still researching, changing configuration and running the account inside the same short competition window. The interfaces reduced the blast radius of those changes; they did not remove the risk of making them late.

Those weaknesses are why the next article focuses on promotion decisions rather than presenting every implemented strategy as part of the live system.

What carried into QuantSilico

The competition shifted my attention away from strategy code alone.

I still cared about signals, but the harder recurring questions were now around them:

  • Which dataset produced the result?
  • Which configuration passed validation?
  • Which configuration did the running process actually load?
  • Which risk limit changed the target?
  • Which broker response prevented the intended position?
  • Which evidence would justify promoting the next candidate?

That is the part of the work that carried into QuantSilico: not this exact competition engine, but the need for a more disciplined route from research output to a monitored deployment.

Part 2 follows the candidates that passed, failed or remained gated. Part 3 follows the live telemetry and the configuration changes made during the competition window.

Reproducibility