vike
Strategies

The controller seam

The Controller decides whether to open a position; the ControllerHarness is itself a Strategy and owns the executors, the routing and the cooldown.

crates/vike-strategy/src/controller.rs is the TOP half of a controller-to-executor split. It carries two things: the Controller decision seam — the WHAT, emit a PositionIntent or not — and the ControllerHarness that wraps a controller into a plain Strategy, the glue that owns the live position executors and does the HOW.

Why it is a Strategy, not a parallel runtime

ControllerHarness<C> implements Strategy<B> for any B: Broker. It IS a strategy, so it mounts through the existing strategy seam and runs unchanged on BOTH engines — the live single-writer runtime over LiveBroker and the backtest engine over SimBroker. Direct-submit strategies are untouched; a strategy adopts managed positions by mounting a harness instead of itself. Every order an executor emits still crosses the SAME Broker handle, then the drain, then the RiskGate, then the client, so the harness adds NO authority and cannot bypass the gate. It is additive: no serde event or wire change, no new command verb — it is composed entirely from the existing Strategy handlers plus the executor.

What the harness owns

The controller, asked per (venue, symbol) whether to open. The active executors, ONE per (venue, symbol) — a Vec keyed by the pair, because there are few of them and a linear scan with deterministic insertion order replays reproducibly, the same rationale the multi-venue paper client uses. And a post-exit cooldown stamp per pair: the controller may not re-open a pair until last_close_ts + cooldown_ms, with 0 meaning eligible on the very next observation.

Routing is a venue_map of symbol -> venue alongside a default venue. That is what makes a CROSS-VENUE controller tradeable from ONE mount: each series is keyed and evaluated under its own venue, so a controller whose decision needs two venues' data can observe them all. An empty map — the default — makes the harness byte-identical to a single-venue mount.

The per-callback flow

On each on_bar, on_quote_tick or on_trade_tick the harness does three things in order. Maybe-open: if there is no active executor for this pair and the cooldown has elapsed, ask Controller::evaluate; on Some(intent) spawn an executor and start it, which submits the entry through the same broker. Drive: advance the active executor — its refresh and retry timer, then the barrier evaluation. Reap: when an executor reaches a terminal state, record its outcome if it has one, stamp the cooldown from its close timestamp, and drop it.

on_fill routes the execution to the active executor for the fill's symbol. on_order_event forwards the reject or deny an executor's retry reacts to, BROADCAST to the active executors rather than routed, because the portable Broker surface exposes no client-order-id to route a single order by; with one executor per pair, and typically one pair per mount, that is exact, and a non-reject transition is a no-op inside the executor, so the broadcast is harmless.

The decision seam

Controller::evaluate reads the same market and account view a strategy gets — but through a SHARED &B, so it can call price, bars, position and now and yet structurally cannot submit orders, because every submit_* verb needs &mut B. A controller decides; the executor executes. It reasons in POSITIONS — it emits a PositionIntent carrying side, quantity, entry kind, triple barrier and the refresh and retry policies — never in raw orders, and it never sees fills. The harness invokes it only when it is free to open, so an implementation need not re-check the cooldown or the active-executor condition.

MomentumController is an example, not a recommendation

The shipped reference controller compares the current mark to the mark at its PREVIOUS invitation and opens long on a move of at least threshold, short on the mirror, otherwise declining. The first invitation has no reference, so it records the mark and declines. It is deterministic and read-only, works identically on the bar path (price is the bar close) and the tick path, and the harness mounting it is parity-clean.

Determinism and live re-tune

Every decision is a pure function of the portable broker reads plus the delivered fills and order events, and ALL time comes from the injected Broker::now and each fill's own epoch-millisecond timestamp — never a wall clock. Both runtimes deliver those identically, so a harness produces the same fills and outcomes live and in backtest; crates/vike-backtest/tests/controller_harness.rs is the parity gate.

A mounted harness is re-tuned live without unmounting. StrategyParams::PositionController carries a ControllerParams bag spanning the two layers a re-tune touches — the harness cooldown, and the controller's intent template and decision knob. ControllerHarness::on_params_updated applies the cooldown itself and forwards the whole bag to Controller::apply_params, which keeps each controller the sole authority on which knobs it absorbs, then bumps an observable params_epoch. A foreign variant is a no-op and does not bump it. The policy is that a re-tune affects only FUTURE opens: an in-flight executor keeps its armed intent and barriers and is never torn down or retroactively resized.

Harness knobs from a profile

Mounted through the registry, the harness reads venue, cooldown_ms and an optional [strategy.params.venues] table. Two hazards are declared rather than hidden: the venue fallback is the literal string sim, which on a live mount is a tag no venue answers to — the registry's own echo reports it for exactly that reason — and a venues row whose value is not a string is dropped silently by the reader's filter_map, which is why the echo prints the surviving map rather than the table that was typed.

On this page