Sizing, and zero-trade diagnosis
The swappable position-sizer model the simulator applies to opening entries, and the analyzer that turns a flat, zero-trade run into a ranked list of probable causes.
Two modules of vike-analytics that serve the simulator rather than the tearsheet: sizing.rs, the sizer a backtest applies to every opening entry, and zero_trade.rs, the diagnosis a report prints when a run did nothing at all.
Sizing
crates/vike-analytics/src/sizing.rs is the swappable sizer framework, an exact port of the original core/sizing.py. A sizer implements PositionSizer — one method, size(&self, ctx: &SizeContext) -> f64, returning the quantity (at or above zero) to open for one entry intent. The trait is Send-bounded so a Box<dyn PositionSizer> can ride inside EngineParams onto a worker thread; every shipped sizer is plain data, so that costs nothing.
SizeContext is the live portfolio context handed in for one entry: symbol, side (+1 buy, -1 sell), intent (the raw size the strategy passed — used only by the pass-through sizer), basis_price (the current close), equity, cash, multiplier, atr (recent average true range, 0 when unavailable), drawdown (the account drawdown fraction, 0..1), risk_stop (the protective stop for this entry, which the risk sizers need), and open_risk (the dollar risk already open across the book, for portfolio-heat caps).
The eight sizers:
| sizer | quantity |
|---|---|
PassThroughSizer | intent, unchanged — the default |
FixedDollarSizer { amount } | amount / (basis_price · multiplier); 0 when the denominator is zero (a negative one is allowed, as in the port) |
FixedSharesSizer { shares } | shares |
PctEquitySizer { pct } | pct · equity / (basis_price · multiplier) |
PctVolatilitySizer { pct } | pct · equity / (atr · multiplier) so the position's ATR-risk is pct of equity; the percent-equity formula when ATR is 0 |
MaxRiskPctSizer { pct } | pct · equity / risk_per_unit with risk_per_unit = abs(basis_price − stop) · multiplier; 0 without a stop |
PortfolioHeatSizer { base, max_heat } | wraps a base sizer and caps total open risk to max_heat · equity: the budget is max_heat · equity − open_risk, 0 if exhausted, and the base quantity is clipped to budget / risk_per_unit; passes through without a stop |
DrawdownThrottleSizer { base, sensitivity, floor } | wraps a base sizer and scales by max(floor, 1 − sensitivity · drawdown) |
The two pure unit converters — units_from_percent and units_from_value — are not here: they moved to vike_model::sizing so the live order_target_* verbs share the one sizing law with the backtest engines, and call sites spell vike_model:: directly.
Where it runs. vike_backtest::EngineParams::sizer is an Option<Box<dyn PositionSizer>>; when it is None the engine installs PassThroughSizer. SimBroker::size_entry runs the sizer on opening or increasing entries only and passes the size through when the submission is raw or reducing, building the context from the current price, the 14-bar ATR, drawdown = max(0, 1 − equity/peak) and the summed open risk. The sim broker page covers the fill side.
The sizer is an engine-only concept. Live has nothing for it to be portable to.
crates/vike-backtest/src/ref_strategies.rs lists the sizer first among the concepts that bind a strategy permanently to the simulator: it lives in SimBroker::submit's raw = false arm, and the live path is always raw. Its CapsSizersMask reference strategy submits a size of 999.0 as a sentinel, relying on a DrawdownThrottleSizer { base: PctEquitySizer } to ignore intent entirely; submitted through the portable Broker::submit_market, which is raw = true, that sentinel would be taken literally. A strategy that leans on a sizer is a simulator-only strategy by construction.
crates/vike-analytics/tests/sizing_props.rs holds the sizers to their contract with property tests over random but valid inputs — positive prices and multipliers, non-negative equity, fractional percentages — after finding that size had zero direct tests and only one golden run touched it. Every sizer must return a finite, non-negative quantity, and the two cap properties carry only a few-ULP round-trip epsilon, never a fudge factor. See property tests.
Zero-trade diagnosis
When a run closes zero trades and its equity curve never moves off the starting cash, the bare metrics table — total return 0%, Sharpe NaN, drawdown 0% — says nothing about why. crates/vike-analytics/src/zero_trade.rs is a pure, report-side composition over diagnostic counters the engine already accumulates on the result; it adds no counting to any fill lane.
The trigger. ZeroTradeReport::analyze(&result) returns None for any run that closed a trade or moved equity — "flat" is exact equality with the first value, which is correct because an idle run's equity is cash bit-for-bit every step, and an empty curve counts as flat. So a buy-and-hold run, with zero closed trades but a real marked position, is deliberately not flagged: its equity moved. The report carries the result as BacktestReport::zero_trade, skipped on serialization when None, and its Display prints the diagnosis instead of the all-zero metrics table — a normal report is byte-identical to before the field existed.
The inputs. ZeroTradeInputs mirrors five things the run already recorded: steps (the equity-curve length — bars or ticks processed), stale_deferrals and session_deferrals (BacktestResult's counters from the stale-price wait and the session gate), denials (the gate-drop channel BacktestResult::dropped, aggregated by reason in first-seen order by aggregate_denials — the live RiskGate's own reason strings, cash-gate drops by order kind, "volume_cap", "latency_reject"), and warmup (the strategy's warm-up requirement).
The ranking. rank_causes is deterministic, does no I/O, and always returns at least one cause, most probable first. Structural certainties lead with sentinel weights: no-data when steps == 0, else warmup-shortfall when warmup >= steps — the dispatch gate never opened, so on_bar/on_tick ran zero times; the default warm-up of 0 can never trip it. Then the "tried but was blocked" signals, each weighted by how many orders it blocked: orders-denied, with the reasons rendered largest-first; stale-price; session-closed. Ties keep that push order, because the sort is stable. When nothing fired, the single catch-all no-orders says so and carries the one hint the result cannot disambiguate: the strategy most likely never submitted, and in bar mode snap_to_properties re-tags bars as SYMBOL.VENUE, which a strategy routing on the bare symbol will miss.
A vector-kernel run can only ever be no-data or no-orders
BacktestResult's doc states that stale_deferrals, session_deferrals, dropped and warmup are all zero or empty for every vector-kernel run. The vector engine therefore hands the analyzer nothing but a step count, and a flat vector run diagnoses as no-data when the slice was empty and as the catch-all otherwise.
Each ZeroTradeCause carries a stable kebab-case code, a one-line headline and an actionable detail; the Display form numbers them under "no trades executed - probable cause(s), most likely first:", and the JSON form serializes causes as an array.
The signal backtest
The vectorized backtest for sweeping signal variants — a position series and a log-return series in, per-bar PnL, equity and trade statistics out — plus the frictioned scorer a model search ranks by.
Order flow
The pure trade-aggregation core behind every order-flow study — one buy/sell semantic, flow bars, CVD, footprint and volume profile folded from the trade tape, the book-fed microstructure studies beside them, and the parity discipline each builder is gated by.