The position executor
One position, driven from Pending to Closed over a portable Broker — the lifecycle states, the triple barrier and its fixed precedence, and the realized-PnL law.
crates/vike-strategy/src/position_executor.rs drives ONE position's full lifecycle over a portable
Broker handle. It is a plain struct with explicit step methods — start, on_bar, on_tick,
on_fill, on_refresh, on_order_event, cancel — that a
controller harness calls from the matching Strategy hooks.
It is deliberately NOT a Strategy itself. Every order crosses the same Broker, so it still
passes the live RiskGate, and all time comes from the injected Broker::now and each fill's own
epoch-millisecond timestamp — never a wall clock — so the machine is deterministic and runs
bit-identically live and in backtest.
The PARAM types live one layer down, in vike_model::barrier: TripleBarrier and
ControllerParams are pure serde data and are transitively part of the StrategyParams payload the
exec ingest lane carries, so they must sit in the bottom domain crate. The state machine imports
them back down.
States and transitions
ExecutorState has four live labels — Pending (created from an intent, nothing submitted),
EntryWorking (the entry is in flight or resting, driving refresh and submit-retry), Open (the
position is live and the barriers armed) and Closing (a barrier fired, the close is in flight) —
and three terminals: Closed (flat, records an outcome), Failed (the entry never landed, retries
exhausted) and Canceled (stopped before any fill).
start submits the entry per the intent's EntryKind. A FULL entry fill — on_fill accumulates
partials — records the VWAP entry price and the entry timestamp, arms the barriers, and opens the
position. The FIRST barrier hit submits a market close; a full flatten produces the terminal outcome.
Closing is sticky
Once Closing, the executor NEVER re-opens or re-evaluates entry or barriers: on_bar and
on_tick are no-ops until the close completes, so a late favourable tick cannot flip a closing
position back to Open. A position, once told to flatten, only completes the flatten — a position
must never be stranded. A rejected CLOSE resubmits the unflattened remainder and STAYS Closing.
The triple barrier
Take-profit, stop-loss and trailing are ABSOLUTE PRICE OFFSETS — a positive distance in price units
— from the ENTRY FILL price; the signed barrier price is derived at check time from the entry price
and the position side by take_profit_price and stop_loss_price. TripleBarrier::from_bps is a
caller-side convenience that converts basis points against a reference price, so the stored shape
stays absolute and unit-free. Every leg is optional and defaults to None, an un-armed position.
The third leg is the genuinely new one: time_limit_ms is a max HOLDING duration, and
time_barrier_hit fires once now >= entry_ts + time_limit_ms. Nothing else in the workspace
closes a position on elapsed time.
Each price barrier is detected through vike_model::order_fill_price — the same gap-open
adverse-fill and trailing-ratchet oracle the conditional-order emulator and the backtest engines use
— so a barrier hit behaves identically on both stacks. The stop is evaluated as a reduce-only stop,
the take-profit as a reduce-only limit, and the trailing stop as a trailing order whose extreme is
seeded from the entry price and ratcheted in place on every non-firing check. The exit_px on a
BarrierHit is DIAGNOSTIC — the oracle's trigger price, or the current mark for a time exit; the
real close fill is whatever the venue or the sim engine resolves when the market close lands.
Precedence
evaluate_barriers returns the FIRST hit under a fixed, deterministic order: stop-loss, then
trailing, then take-profit, then time. Within one ambiguous bar the intrabar path is unknown, so
the ADVERSE barriers are assumed to hit first — the same no-look-ahead-optimism convention the
backtest engines apply to protective stops. A wide bar straddling both a stop and the target
therefore resolves to the stop, and a position past its deadline that also crossed a price barrier
reports the price barrier, because a real cross is more specific — and more pessimistic — than the
timeout. evaluate_barriers_at_price is the tick twin: a degenerate one-price bar.
Refresh and retry
on_refresh is the harness-driven timer step, a no-op outside EntryWorking. It resubmits a
backed-off retry that has come due, and reprices a still-resting, wholly-unfilled LIMIT entry to the
current mark once RefreshPolicy::every_ms has elapsed since the last submit.
Both refresh modes take the same action here
The portable Broker surface has NEITHER a modify nor a cancel verb — only submit_* — so
Reprice and CancelReplace both degrade to one fresh submit_limit, and the stale resting order
is abandoned at this layer. Honouring the distinction needs the richer HftBroker::modify_tagged
and cancel_tagged verbs and is a later live-only enhancement; the mode is preserved on the intent
for it.
on_order_rejected treats a venue Rejected and a RiskGate Denied identically — the order
never established, so it is retryable. An entry reject with budget remaining schedules a resubmit
backoff_ms in the future and terminalizes Failed only once RetryPolicy::max_attempts is
exhausted; a reject of a partially-filled entry is ignored, because there is no clean single order
to retry. cancel terminalizes as Canceled before any inventory exists and FLATTENS instead once
the position is even partly open, so an external stop never strands inventory.
The outcome
ExecutorOutcome records the barrier that fired, the entry and exit timestamps and prices, the
realized PnL and the round-trip fees. The PnL is folded through the canonical vike_model::TradeFold
— the one cost-basis primitive the account, the sim broker, the vector engine and reconciliation
share — rather than a hand-rolled VWAP difference, so it inherits the contract multiplier
(PositionExecutor::with_multiplier) and the GROSS convention for free. Fees are reported
SEPARATELY rather than netted silently into the same field; ExecutorOutcome::net is the
fee-adjusted figure.
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.
Authoring in Rust
The seam a native strategy implements, the reader convention its parameters follow, and the folder that compiles a user strategy into your own binary.