In plain words: a timer set for 09:20 on a live tick stream now fires at 09:20, even when the row that revealed 09:20 carried no price. It used to wait for the next print the session accepted, which for an index that only ticks on a trade could be minutes later. A row with ltp == 0 and no two-sided book appends no event, and push_tick still returns 0, but its timestamp proves the market reached that instant. The stream now advances that symbol's clock on such a row and fires every alert and timer due by then inside the push. Orders the timer submits are routed to their instrument at once and match from its next print, the rule submission_idx already applied across instruments. Intents such as enter() and close_position() name no symbol, so they are held for that symbol's next trade event rather than left on the queue for whichever symbol prints first. The batch runner drops such rows in the Rust merge and cannot fire on them; the docstring and README say so. Four tests pin it: an alert fires on a quote-less print with ts_fired at that instant and not again on the next print; a timer fires once per symbol whichever row reveals the time; an order submitted between prints is already resting and fills on the next print; an intent queued between prints closes its own symbol, not the one that prints first. All four fail with the old behaviour. Version 0.13.4. Co-authored-by: Pier Code <no-reply@piercode.com>
125 KiB
Changelog
All notable changes to raptorbt are documented here.
The format follows Keep a Changelog, and this project adheres to Semantic Versioning.
[0.13.4] - 2026-09-10
In plain words: a scheduled hook on a live tick stream now fires at the time it asked for, even when the row that revealed that time carried no price. A timer set for 09:20 waited for the next print the engine accepted — for an index that only ticks on a trade, or a feed that sends quote-only rows, that could be seconds or minutes after 09:20, and a strategy reading the clock at the wrong instant made the wrong decision.
Fixed
TickStrategyStream.push_tickadvances the clock on a refused row. A row withltp == 0and no two-sided book appends no event (the return value is still 0), but its timestamp proves the market reached that instant, so every alert and timer due by then now fires inside that push, on that symbol's own clock. Before, the time event fired on the next accepted print of the same symbol, withts_firedset to that later print.- Orders a timer submits between prints are routed to their instrument at once
and stamped on its last stepped print, so they match from its next one —
the rule
submission_idxalready applied to cross-instrument orders.enter()/close_position()intents, which name no symbol, are held for that symbol's next trade event; another symbol's print cannot drain them.
Changed
drive_tick_eventstakes an optionalbefore_trade(symbol)callback, and the clock step is factored intoadvance_clock(strategy, ctx, clocks, symbol, ts); both are internal to the strategy runners.
Not changed
run_tick_strategy(the batch replay) still drops such rows in the Rust merge, so it cannot fire on them. The stream's docstring and the README say so; feed the batch runner rows that carry a price.
[0.13.3] - 2026-09-10
In plain words: a result now says what happened to every order the strategy placed, including the ones that never filled. A backtest could always tell you the trades it made; it could not tell you the trades it tried to make and could not — an entry refused for margin, a limit that rested all day and expired, a stop refused because a position was already open. None of those produce a trade, so a strategy that never got into the market looked exactly like one whose idea was wrong.
Added
-
BacktestResult.orders()— the order log. OneOrderrecord per order the run placed, in submission order, carrying its id and client id, symbol, side, kind, time-in-force and final status; the units requested and the units actually filled; a size-weighted average fill price and how many slices it took; the limit and trigger prices it named; its OTO parent and OCO group; and, when the engine refused it, why.The log is reconciled against the order book rather than assembled from events alone, so an order that rested and expired — emitting no fill and no rejection — still appears. That is the case the log exists for.
requested_qtyisNonewhen the request named a capital fraction rather than a number: the size is not known until the engine prices it, and0.0there would read as "asked for nothing".filled_qtyis0.0for an order that never filled, which is a measurement;avg_fill_priceisNone, because there is no price. Empty for a run that placed no typed orders. -
OrderSide::as_str,OrderKind::as_str,TimeInForce::as_str,OrderStatus::as_str— stable snake_case identifiers for reporting, andOrderKind::limit_price/trigger_pricefor the prices a kind carries.
Changed
-
BREAKING (string values): every refusal now reports the same name.
RejectReasonhas always hadas_str()as its "stable identifier for reporting", and order-level refusals used it. Entry-level refusals did not — they were Debug-formatted, so the same cause arrived as"MaxPositions"on one event and"max_positions"on another. Callers that count refusals by reason therefore split one cause across two keys and undercounted both.Both now use
as_str(). A caller matching on the Debug spelling ("InsufficientMargin","ZeroSize","MarginCall", …) must switch to the snake_case form. Two tests in this repo had already worked around the split by accepting either spelling or lower-casing before comparing; both now assert the single value.
[0.13.2] - 2026-09-09
In plain words: every trade now reports how far it went against you before it worked, and how far in your favour before it failed. A realized loss alone cannot tell a bad entry apart from a good one that was stopped too early — both report the same number. These two do.
And a run now reports how long it spent losing, not only how deep the worst loss went. A 8% dip lasting a week and an 8% dip lasting a year are the same number under max drawdown; the second is the one people abandon.
Trying many parameter sets at once — the most common thing anyone does with a backtester — now uses every core instead of one, 7.7x on a ten-core machine. Large runs also stopped copying their own input, so a 25-million-bar backtest uses a gigabyte less memory and finishes 21% sooner.
And a result now answers why a strategy behaves as it does: whether a high Sharpe is really a short-volatility payoff, whether costs are eating the edge and how much room is left before they kill it, where a stop can sit, and whether the exit is giving back the move the entry found.
Added
-
batch_single_backtest— parameter sweeps across every core. Takes one price series and a list ofBatchSingleItems, converts the shared OHLCV once, then runs the items on Rayon with the GIL released. Each item carries its own signals and may override the batch config, direction, symbol, position sizes and instrument config.184 SMA-crossover combinations over 93,750 bars on ten cores: 1.31 s down a Python loop, 170 ms batched — 7.7x. It also stops re-copying the same price data once per combination.
Results are bit-identical to the serial loop, which is the point rather than a bonus: a sweep whose numbers shifted with the thread count would make comparing two parameter sets meaningless. Order is preserved, so results zip against the parameters that produced them. A bad item — signals that do not match the shared length, or a direction that is neither 1 nor -1 — raises
ValueErrornaming that item before any worker starts, because a panic on a Rayon thread crosses PyO3 asPanicException, which a caller can neither catch asValueErrornor trace to the argument at fault. -
Ten diagnostic metrics, each tied to a decision.
BacktestMetricsnow carries 48 fields, 31 into_dict().Is the edge real?
return_skewandreturn_kurtosisdescribe the shape Sharpe assumes away;tail_ratiocompares the two tails. High Sharpe with strongly negative skew is a short-volatility payoff wearing a good number.Do costs eat it?
cost_to_gross_profit_pctis the overfit tell.breakeven_cost_multiplesays how many times current costs the run could absorb before net P&L reaches zero — directly comparable against the gap between backtested and live slippage.Is it grinding?
return_consistency_pctis the equity curve's batting average, a different question fromwin_rate_pctover trades.avg_drawdown_pctseparates one bad week from chronic pain at the same headlinemax_drawdown_pct.Where should the stop go, and is the exit the problem?
avg_mae_pnlis how far the average trade goes against you before it works — a stop tighter than that closes trades that would have won.mfe_capture_ratiois the share of the favourable move winners keep; 0.35 points at the exit rule rather than the entry signal.mae_mfe_coverage_pctmust be read first: excursions are absent on synthesized legs, and coverage says how much of the trade list the two aggregates describe rather than letting a subset pass for the whole.Every field is
Option.Nonemeans not measured on this path, never a measured zero — 0.0 is a legitimate value for most of them, which is exactly the ambiguityulcer_index's baref64was forced into when it had to report 0.0 on a path that never computed it.Conventions are pinned in the doc comments and tested against
scipy, because a silent disagreement with another library is the failure mode here: kurtosis is excess (Gaussian 0.0, not 3.0); skew and kurtosis are sample bias-corrected (bias=False);tail_ratiouses linear-interpolation percentiles matching NumPy;avg_drawdown_pctis conditional on being underwater, since averaging the zeros in would just be a worse ulcer index.mfe_capture_ratiocarries three deliberate restrictions: a ratio of sums rather than a mean of per-trade ratios (one near-zero excursion would otherwise dominate), winners only (a loser puts negative P&L over a positive excursion; the naive form returns −2.38 on a real fixture), and a gross numerator, sincemfe_pnlis measured before costs. A value above 1.0 is not clamped — it means bar-resolution excursion missed an intra-bar extreme, which is a diagnostic worth seeing. -
Trade.mae_pnl/Trade.mfe_pnl— maximum adverse and favourable excursion, plusTrade.mae_price/Trade.mfe_price, the prices at those points. Measured bar by bar while the position is open, from watermarks the engine already tracked for trailing stops, so there is no added work in the bar loop.mae_pnl <= 0 <= mfe_pnl, both in the same money and on the same contract multiplier aspnl, before costs, and gross P&L always falls inside the band:mae_pnl <= (pnl + fees) <= mfe_pnl.Their resolution is the bar, not the tick: a run on 5-minute bars knows the worst 5-minute extreme, not the worst moment inside it. That is a real limit and the engine makes no finer claim — reconstructing intra-bar order from an OHLC window cannot know whether the high or the low came first.
Noneon paths that synthesize a trade rather than closing a tracked position (spread, basket and pairs legs).Nonemeans "not measured" and must not be read as a zero excursion; a flat trade that genuinely never moved reports0.0, which is a measurement. -
BacktestMetrics.ulcer_index— the root mean square of the drawdown curve, in the same percentage points asmax_drawdown_pct. Max drawdown is a single order statistic: it answers "how deep" and cannot distinguish a -8% pit lasting five bars from a -8% pit lasting two hundred. Ulcer weights every shortfall by how long it persisted, so it is0.0only for a curve that never fell, and it can never exceedmax_drawdown_pct. -
BacktestMetrics.time_under_water_pct— the share of equity samples strictly below the running high-water mark. The companion toexposure_pct: that says how much of the run was spent in the market, this says how much of it was spent behind. Depth is discarded — a -0.01% sample and a -40% sample each count once — which is the point, since it isolates persistence from severity.Both fold the same streamed drawdown curve that
max_drawdown_pctfolds, so all three describe one curve. The pre-existing helpermetrics::drawdown::ulcer_indexrebuilds a curve seeded from the first equity sample rather than from initial capital, and is deliberately not used on this path; the two disagree whenever a run opens away from its funding level.StreamingMetrics::finalizereports0.0for both: that accumulator keeps a running peak but no drawdown history, so it cannot compute an RMS or count underwater samples.0.0there means "not measured on this path", matching the existing convention forexposure_pctandtotal_turnover. Every strategy path that produces a drawdown curve — single, multi, basket, pairs, options, tick — gets the real figures.Metric count is now 38 fields, 28 of them in
to_dict().
Fixed
-
Type stubs corrected for six long-standing mismatches. Nothing checks
_raptorbt.pyiagainst the bindings, so these were silently wrong for several releases.avg_win_pct,avg_loss_pct,avg_winning_durationandavg_losing_durationareOption<f64>in the bindings but were declared barefloat— the stub told callers they could always format them, which raisesTypeErroron exactly the runs (no winners, or no losers) theNonewas introduced to describe. Andequity_curve(),drawdown_curve()andreturns()return NumPy arrays, notlist[float]:+concatenates a list and adds elementwise on an array, so the declared type decided what a caller's code meant. -
The metric counts in
README.mdwere stale, reading 33 fields / 24 into_dict()from a release that added fields without updating them. Now 48 and 31, checked against the built wheel — along with every other published figure, all re-measured for this release. -
batch_spread_backtest's type stub was wrong. It returns(strategy_id, result)pairs but was declaredlist[BacktestResult], so a caller following the stub indexes a tuple and getsAttributeErroron the first.metrics. Both batch functions now carry fully typed stubs. -
Sortino and Omega had no value pinning, and one ATR branch had no test at all.
omega_ratiowas asserted nowhere in the suite — not in the golden digest, not in the class-vs-array equivalence check, and every Rust caller discards it as_omega.sortino_ratiowas pinned only relationally: the two paths had to agree with each other, but nothing said what either should equal. Both now ride the golden digest, shown to fail by injection before they counted. Separately, the ATR precompute fires when either the stop or the target is ATR-based, but only the stop limb was exercised; the newatr_target_fixed_stopfixture covers the other. -
The spread path reported zeros for metrics it had the data for. It built its metrics through
StreamingMetrics::finalize, which receives only the return series — while the same expression handed a real drawdown curve and a real trade list to the result it returned. So a spread result could report 250 of 300 samples underwater in its owndrawdown_curvewhiletime_under_water_pctread 0.0, and carry seven trades whiletotal_turnoverandexposure_pctread 0.0. It now usescompute_backtest_metrics_with_config, the estimator every other runner shares.This changes published spread numbers. On the golden fixtures:
ulcer_index0.0 → 0.0826886,time_under_water_pct0.0 → 83.333333,avg_drawdown_pctNone→ 0.0809098,total_turnover0.0 → 16424.70,exposure_pct0.0 → 38.0.Sharpe and Sortino move too, and by more: −0.139244 → −2.696442, a factor of 19.365. The accumulator hardcodes 252 periods/year, which assumes one bar is one trading day; these are minute bars, so each minute was annualized as a day. 19.365 is exactly
sqrt(94500/252), where 94,500 is 375 NSE session minutes × 252 sessions — the default session spec. The old figure was the wrong one.A position still open at the end is now counted as open, not closed (
total_closed_trades7 → 6 on the fixture). The accumulator hardcodedtotal_open_trades: 0;EndOfDatameans the position never traded out, and every other runner reports it open. -
A spread run's equity curve stopped one exit fee short of the account. The final sample was pushed before the end-of-data close, so it omitted that close's costs — the curve said 99,954.93 where the account held 99,953.68. The final value reached the metrics separately, so
total_return_pctwas right while the curve backing it was not, and integrating the curve gave a different answer from the reported return. Only the last sample changes, and the curve keeps its length. -
ulcer_indexhad no value-pinning test. Its only assertion was that the result is positive, which every arithmetic error preserves — including dividing the sum of squares by the count of underwater samples instead of the curve length, which roughly doubles the metric on a mostly-flat curve.
Performance
All figures Apple M4, ten cores, against the published 0.13.1 wheel on the
harness in benches/. The golden corpus is byte-identical across every change
below — none of them alters a result.
-
NumPy buffers are read, not duplicated.
OhlcvData's series are nowCow, so the six pyfunctions that build one borrow the caller's arrays for the duration of the call instead of copying them. A NumPy array is already a contiguousf64buffer andPyReadonlyArray1holds the GIL and a read lock on it, so the copy bought nothing and cost a full duplicate of the input.On 25 million bars over a 1.25 GB input: peak RSS 2.16 GB → 1.13 GB above baseline (about the input size, as expected) and 2.43 s → 1.92 s, 21% faster. 1.875M bars 165 → 158 ms. Small runs are unchanged, as expected — the copy was never a large share of one.
Arrays must be C-contiguous, which ordinary NumPy code produces; a strided view is refused rather than silently copied. The tick path and the spread path's premium arrays still copy — the same fix, not yet done.
-
Sharpe, Sortino and Omega no longer copy the return series.
risk_metricsallocated two full copies of the per-bar returns — one to drop NaNs, one to hold the negative subset — to produce three scalars, then walked the filtered series five more times. Both allocations are gone; NaN is skipped inline and one pass accumulates everything the ratios need. 30 MB of transient heap on a 1.875M-bar run, and roughly a tenth of total runtime, on work that never touches a bar.Two passes over the slice, not one, and deliberately so: the variance is taken about a known mean, and folding it into a single sum-of-squares pass would change the floating-point result. The point is that it does not.
-
No ATR array is allocated when no stop asked for one. A run with no ATR-based stop or target still allocated and zero-filled one
f64per bar so the loop could read 0.0 out of it — 200 MB on a 25M-bar run, written once and never read. Peak RSS at 8M bars: 1351 → 1301 MB against a 61 MB array avoided. Time is unchanged; a zero-fill is cheap in time and real in memory, so this is a memory fix and is not claimed as a speed one. -
The distribution-shape metrics cost nothing extra. Their central moments ride the pass
risk_metricsalready makes, andtail_ratioselects its order statistics in O(n) rather than sorting a copy of the series. The first cut of these metrics ran 210 ms against 0.13.1's 168 ms at 1.875M bars; the shipped version is within noise of it.
Changed
EngineEvent::Exitednow carries itsTradeboxed. ATradeis by far the largest thing any event variant holds, and an unboxed one made every event in every queue pay its width. Rust callers that match on the variant and pass the trade by value need*trade; field access is unchanged.
[0.13.1] - 2026-09-04
In plain words: 0.13.0 could report a typed order's fill twice when a strategy listened for fills — once for the order and once more for the position it opened or closed. One fill is reported per fill again.
Fixed
- A typed order's fill event is immediately followed by the position
event it caused. 0.13.0 emitted the fill, then its contingencies
(bracket children accepted, one-cancels-other siblings canceled), then
the position event; the Python runner reads an
entered/exitedthat does not directly follow anorder_filledas a signal-path fill and fireson_order_filledfor it again. The position event now rides directly behind the fill, as before 0.13.
[0.13.0] - 2026-09-04
Tick-path realism: the size behind every print and quote, a queue that follows the touch, fills that respect what printed, orders that take time to reach the venue — and a cross-instrument order that used to vanish.
In plain words: on tick data the engine now knows how much traded on each print and how much is displayed at the best bid and ask, so a resting order waits its turn behind real size and a large order fills across several prints instead of all at once on a one-lot print; an order can be given a delay before it reaches the exchange; and an order placed for one contract from another instrument's event — a hedge leg bought when the index prints — fills on that contract's next print instead of sitting unfilled forever. Every new behaviour is off by default and switched on by the caller, so existing backtests are unchanged.
In plain words: on tick data, an order placed for one contract from another instrument's event — a hedge leg bought when the index prints, or a straddle sold when a 09:20 timer fires — could sit unfilled for the whole run without a word, whenever the two instruments' event counts differed (which, with quotes in the tape, they always do). Such an order now fills on the target's next print, as it always did when placed from that instrument's own print.
Added
-
Print size, L1 sizes and open interest on the tick path. Tick input takes four optional arrays/keywords —
ltq,bid_qty,ask_qty,oi— zero-filled when absent.ltqis the print's true size and becomestick.sizewhen present (the buy/sell flow deltas were only ever a proxy for it);bid_qty/ask_qtyreachquote.bid_size/quote.ask_sizeand size the top-of-book level, so the queue-fill model joins behind displayed size from a quote alone, with no depth snapshot;oirides the print astick.oi. A size the feed did not carry stays unknown (nan), never zero._TICK_FIELDSnames the arrays, so a caller can detect the capability rather than trust a version string. -
Partial fills on the tick path (
BacktestConfig.partial_fills, off by default). A typed order in explicit units fills at most a print's size per print and stays working asPartiallyFilledfor the rest; a later slice of an opening order adds to the position it opened (size-weighted average entry; a stop or target derived from the entry price moves with the average, explicit levels stay), so a 100-lot entry that prints 40 then 60 is one 100-lot position, not a refused second entry. A closing slice books its own trade record at the average entry with entry costs prorated, and the remainder stays open; an exit larger than the position closes what is held and cancels the surplus, never reversing. IOC fills one slice and cancels the rest; FOK fills nothing on a print too small for the whole order; DAY expiry expires the remainder and keeps what filled. Capital-fraction orders, close-all, the signal path and bar events fill whole as before.ctx.open_orders()lists what is still working withfilled_qty;on_order_filledfires once per slice; one-cancels- other siblings cancel on the completing slice only. The deadFillModel.fill_ratiois gone. Not a real queue rank: a slice is bounded by the print, not by where the order sits in line. -
Order-entry latency on the tick path (
BacktestConfig.order_latency_ns, default 0). An order placed on the event attis invisible to prints beforet + latency: it neither matches, expires, nor burns an IOC/FOK evaluation until it has reached the venue. The fill is known when the tape reaches it — the response leg is not modelled. Bars are unaffected.
Fixed
- Market orders routed across instruments on the tick path. A market
order was swept only by a print whose per-instrument ordinal equalled
the order's
submitted_idx. Ordinals count quotes as well as prints, so an order stamped with another instrument's ordinal — or placed from a quote handler — almost never met that print and stayed working unacknowledged.PortfolioSession::submit_ordernow stamps a cross-instrument submission with the target's last stepped print, and a print sweeps every market order submitted at or before its ordinal (kernel_tests::market_order_placed_off_this_clock_fills_on_the_next_print,tests/python/test_tick_strategy.py). Bar semantics are unchanged: a bar still sweeps only its own submissions.
[0.12.1] - 2026-09-02
Sold options that hedge each other are margined as one group, and the leverage-rate margin path is bit-identical to 0.11 again.
In plain words: a sold put protected by a bought put can only lose the gap between them, and a sold call beside a sold put on the same index cannot both lose at once. An exchange charges such pairs far less than two separate deposits. 0.12 charged every sold leg its full deposit, which refused spreads a real account carries easily; the portfolio session now re-prices sold legs on one underlying and expiry as a group once they are open together.
Added
- Position-group margin (
portfolio::option_groups). After every applied event the session gathers open, deposit-modelled option legs by(underlying, expiry)and locks the group's requirement across its sold legs: a scenario component charged once — the largest sold leg'sspan_pctdeposit when some short side is uncovered, the structure's intrinsic worst loss when every short is covered — plusexposure_pcton each sold leg's notional, less the net premium the group collected, floored at the structure's worst loss net of premium. Held in tests to within 5% of one broker's basket margin for a sold straddle, a bull put spread and an iron condor (measured 2026-09-02). Bought legs keep locking their premium. Cash accounts and legs without the deposit model are untouched. underlyingonInstrumentSpec.optiondecides the group. Legs with no declared underlying group by their own symbol, i.e. never with another leg.ctx.equity,ctx.cashandctx.free_capitalon the portfolio strategy context, the same three the single-strategy context already carried, so a multi-instrument strategy can read what a new entry may draw on without reaching into the session.
Changed
- Maintenance follows the group. A sold leg's maintenance requirement is its group share, so a hedged pair is not margin-called at a level only two naked deposits would have breached.
- Sizing a NEW sold leg still uses its naked deposit. The group benefit arrives once the leg is on — freeing capital for later entries and lowering maintenance — so a leg must be carriable on its own before it may lean on its hedge. This is the conservative order and it is deliberate.
Fixed
- Margin-account sizing and locking on the rate path evaluate
contract_value × (rate + fee)andcontract_value × size × ratein the 0.11 order again, pinned by a bit-exact test. The sold-option deposit path (span_pct/exposure_pct) is unaffected.
[0.12.0] - 2026-09-02
Sold options reserve an exchange-style deposit, and the reason a sized entry came out at zero now says whether margin or lot size was the cause.
In plain words: selling an option collects a small premium but can lose without limit, so a real account must set aside a deposit scaled to the underlying's value — many times the premium. A margin-account backtest used to charge a sold option only its premium, so a small book could sell far more lots than any broker would allow and report a profit no account could have earned. An option spec can now carry that deposit, sizing and margin use it, and a book too small to carry it books no trade and says so.
Added
span_pctandexposure_pctonInstrumentSpec.option— the risk-scenario and exposure components of a sold option's deposit, each as a fraction of the underlying notional at the strike. Per contract the engine reserves(span_pct + exposure_pct) × strike × multiplier; the premium collected stays in the balance, the way a broker credits it and blocks the deposit separately. Both default to0.0, which leaves a sold option funded at its premium exactly as before — every existing result, and the golden corpus, is byte-identical. Bought options are unaffected: a buyer can lose only the premium, so they keep the account's rate path (the full premium at leverage 1.0). The kernel has no spot series, so the strike stands in for spot — the at-the-money case, the largest requirement and the safe side to err on.RejectReason::InsufficientMargin("insufficient_margin", reported to strategies asInsufficientMargin) — capital-fraction sizing landed on zero contracts because an instrument-level margin requirement (a sold option's deposit, a future'smargin_init) exceeded the available capital, as distinct fromZeroSize, where the lot's own notional did. Telling a user "loosen the entry" is the one thing that cannot help when the strategy was never reached; now the reason says which floor it was.
Changed
- Sizing and margin share one per-contract requirement. Under a margin
account the sizing denominator, the locked initial margin and the
maintenance requirement all read the same figure: a sold option's modelled
deposit, else
contract_value × margin_init(or1/leverage). A sold option's maintenance requirement IS its locked deposit, so a margin call fires when equity falls below the deposit — the broker's rule — rather than below a fraction of the premium's notional. With the model off, every path reduces to the 0.11 arithmetic.
Unreleased-since-0.11.0, released here
Premium-only runners can now fill at a real opening premium, and the golden corpus covers the multi-leg runners.
In plain words: under next-bar-open timing, an options or spread trade was filled at the next bar's settled premium, because those runners carry one premium per bar and have no opening price to fill at. That stays true when no open data exists — nothing is ever invented — but callers who HAVE the premiums' opening prices can now pass them in, and signal fills then price at the open the market actually traded, exactly like the equity runners.
Added
option_open_pricesonrun_options_backtest,legs_open_premiumsonrun_spread_backtestandBatchSpreadItem— optional opening-premium series, mirroring the premium series' shape (a mismatch raisesValueError). Consulted only underfill_timing="next_bar_open", and in the spread runner only for SIGNAL entries and exits: expiry settlement, squareoff, max-loss and target-profit closes are forced or protective exits against current marks and keep pricing there. Absent the series, the next bar's settled value remains the fill, as in 0.11.0.- Golden fixtures for the basket, pairs, options, and spread runners —
eight new pinned runs (each runner in both
same_bar_closeandnext_bar_open, the latter exercising the premium-open path where the runner accepts one), with their inputs frozen alongside. The multi-leg execution cores were previously outside the bit-exact gate entirely. Every pre-existing fixture is byte-identical.
[0.11.0] - 2026-08-31
Open-mode execution no longer trades on information from the future.
In plain words: a strategy decides at a bar's close, using everything that
bar showed. With upon_bar_close=False, the engine then executed that
decision at the same bar's OPENING price — a price the market had already
left behind before the decision existed. That is a time machine, and it
inflated every result run in this mode: on a fixture where one bar rallies
100 → 200 and every later price is 150, a strategy whose signal fires on
that close reported +50.0% — a profit no real trader could earn, since 100
never traded again once the signal existed. The same run now reports 0.0%,
buying at the next bar's open like a real order would. Every mature engine
surveyed (backtrader, zipline, LEAN, NautilusTrader, vnpy) enforces exactly
this next-bar contract; none fills a close-decided signal at that bar's
open.
Fixed
upon_bar_close=Falsenow means next-bar-open, not same-bar-open. A signal decided on bar i fills at bar i+1's open — never at bar i's own open. The fill is structurally unreachable on the decision bar: the deferred intent is consumed at the top of the next bar's step, before any code that could create a new one runs. A position opened at bar i+1's open lives through bar i+1, so its stop or target can fire within the fill bar.- Order-API market orders follow the same contract in this mode. An
order submitted while observing bar i is acknowledged on bar i's step and
fills at bar i+1's open. In
same_bar_closemode (the default) nothing changes: market orders still fill on their submission bar at its close. - Margin-call liquidation prices at the breaching bar's close. The breach is detected marking equity at the close; the old open-based fill liquidated at a price from before the detection. Close-mode results are unchanged (the two coincided).
Changed
- New
fill_timingpolicy onBacktestConfig—"same_bar_close"(decide and fill at bar i's close, the default),"next_bar_open"(decide at bar i's close, fill at bar i+1's open), and"same_bar_open_lookahead"(the pre-0.11 behavior, named for what it is; see Migration).upon_bar_closeis deprecated and maps onto the policy (True→"same_bar_close",False→"next_bar_open"); an explicitfill_timingwins over the bool. - The basket, pairs, options, and spread runners now honor the timing
policy. They previously ignored
upon_bar_closeentirely and always filled at the decision bar's close — causally valid, and still their behavior under the default. Under"next_bar_open"a decision executes on the following bar: at each leg's own open where the series carries one (basket, pairs), and at the following bar's premium for premium-only series (options, spread legs), which have no open to fill at. Decision- time information stays on the decision bar: the pairs hedge ratio and the options strike are computed from data available when the signal fired. Spread expiry settlement, squareoff, max-loss and target-profit closes are forced or protective exits and keep filling on their own bar. - Streaming sessions surface deferred fills one step later. In
"next_bar_open"mode, aKernelSession/EventSessionstep that carries an entry or exit signal returns noEntered/Exitedevent; the event arrives on the next step, priced at that bar's open.
Notes
- A decision on the final bar never fills in
"next_bar_open"mode — there is no next bar to fill it on. End-of-data finalization still closes open positions at the last close, as before. - The tick path is unaffected: a tick fill happens at the print itself, which is already causal, and a print carries no "next open".
- Resting orders (limit, stop, at-open, at-close) already matched from the bar after submission and are unchanged.
FillModel.delay_to_next_barandFillModel::at_next_open()— dead code that was never consulted — are removed;fill_timingsupersedes them.
Migration
-
Results previously produced with
upon_bar_close=Falseare not comparable to 0.11 results and were optimistically biased. To reproduce them exactly:config = raptorbt.BacktestConfig(fill_timing="same_bar_open_lookahead")The name states what it does: it fills a bar's signal at that same bar's open, a price from before the signal's information existed. Use it only to reproduce pre-0.11 numbers.
-
Results produced with the default
upon_bar_close=Trueare unchanged — the golden corpus is byte-identical across this release. -
Pin
raptorbt>=0.11.0,<0.12.0.
[0.10.4] - 2026-08-30
Averages over an empty set now report "undefined" instead of zero.
In plain words: a backtest where every trade won still reported "average losing trade: 0.00" and "average losing trade duration: 0.00" -- figures describing trades that do not exist. Zero is a measurement; it says the losers broke even. The honest answer is that there were no losers, so there is nothing to average. These four now come back empty, the way profit factor already did.
Changed
-
avg_win_pct,avg_loss_pct,avg_winning_durationandavg_losing_durationare nowOption<f64>(Python:float | None), and areNonewhen their population is empty -- no winning trade, or no losing trade. Previously each returned0.0, which is indistinguishable from a real measurement of zero and reads as a claim about trades that were never taken. Measured on a two-leg straddle that closed two winners and no losers: the stored row carriedavg_loss_pct = 0.00andavg_losing_duration = 0.00beside a 100% win rate.This is the same rule 0.10.3 applied to
calmar_ratioand thatprofit_factoralready followed: a quantity with no denominator, or no population, is undefined rather than zero.payoff_ratiois unaffected -- it reads both averages throughunwrap_or(0.0), reproducing its previous branch exactly.
Migration
Callers reading these four fields must handle None. In Python they arrive as
None and can be formatted like any other optional metric; a caller that
previously relied on the 0.0 fallback for arithmetic should decide
explicitly whether "no such trades" should score as zero or be skipped.
[0.10.3] - 2026-08-30
Calmar is no longer annualized, and every runner now computes it the same way.
In plain words: the results panel showed a risk score of 115,906.80 where anything above about 5 is implausible. The number was not corrupt -- it came from a formula that stretches a run's profit out to a full year before dividing by the worst loss along the way. Over five days that stretch is enormous, so the shorter the backtest, the sillier the number got. Calmar now divides profit by the worst loss directly, with no stretching, which is what the README always said it did.
Fixed
-
calmar_ratiois no longer annualized.PortfolioEnginecomputed it as CAGR / max drawdown, compounding the run's return up to a full year from elapsed wall-clock time with no minimum-window floor. Measured on a real 5.27-day options backtest: a 15.4971% return against an 18.6952% drawdown compounded to a CAGR of 2.17e4 and reported Calmar = 115,906.80. The same inputs now give 0.83. The shorter the run the worse it got -- one day of the same strategy reported ~3.7e23 -- and a value large enough to overflowNUMERIC(10,4)had previously been observed downstream.Because the ratio carries no time term, a caller comparing two strategies is now comparing the strategies rather than their window lengths.
-
All six call sites share one definition.
metrics::drawdown::calmar_ratioalready implemented the plain ratio and nothing called it.PortfolioEngineannualized;StreamingMetrics,MultiStrategyBacktest, the basket, pairs and options runners each carried their own inline copy. The same strategy therefore reported a Calmar differing by five orders of magnitude depending only on which runner executed it. Every one of them now calls the shared function. -
A profitable run with no drawdown reports "undefined", not "terrible". The multi-strategy, pairs and options runners returned
0.0when max drawdown was zero, which reads as the worst possible score; the correct answer is that the ratio is undefined. They now returnf64::INFINITY, which crosses to Python asNonelike every other undefined ratio.
Notes
annualization::elapsed_yearsandLEGACY_CALMAR_DAYSare retained. Calmar no longer calls either, but both are public API with their own tests.- Results stored by earlier versions keep their annualized values. A Calmar compared across the 0.10.2/0.10.3 boundary is comparing two definitions.
[0.10.2] - 2026-08-30
Holding-period seconds now survive a tick session, where they were always dropped.
In plain words: 0.10.1 taught the engine to report how long a trade lasted in real time rather than in bars. On tick runs that number never arrived — it came back empty every time, and a caller with nothing to render fell back to the bar count, so a scalping strategy still reported "132 bars" for a hold that really lasted about four minutes. The measurement was being thrown away before anyone saw it.
Fixed
-
avg_holding_period_secsis no longerNoneon every tick backtest. The average was computed by looking up each trade'sentry_idx/exit_idxin the equity timeline. Those indices count events, while the timeline holds one entry per equity sample — and on a tick session equity is sampled once per print while quotes advance the index too. Measured on one session of a liquid NSE equity (28,642 prints against 28,635 quotes), a trade closing on the final event carriedexit_idx = 57276against a 28,642-entry timeline. The lookup missed, every span was discarded, and the "cover every trade or report nothing" guard then correctly returnedNonefor the whole run.The span now comes from the trade's own
entry_time/exit_time, which every production path already populates from the same clock. Bar runs are unaffected — there one event is one bar, so the old indexing happened to agree — and both existing duration tests pass unchanged.A tick run that reported no holding duration at all now reports a real one: a two-hour hold measures 7,200 s.
[0.10.1] - 2026-08-28
Duration metrics are now reported in real elapsed time as well as in bars, and time-in-market can no longer exceed 100%.
In plain words: the engine measured how long a trade lasted by counting bars. On daily data one bar is one day, so that was right by accident. On a tick run one bar is one tick, so a trade lasting 45 seconds was reported as "329" -- and a caller printing that as days showed 329 days. A six-day backtest reported a drawdown lasting roughly 256 years. The bar counts are still there and still mean bars; alongside them the engine now reports the same figures in seconds, taken from the timestamps it already had.
Added
BacktestMetrics.max_drawdown_duration_secsandBacktestMetrics.avg_holding_period_secs(Rust), exposed asmetrics.max_drawdown_duration_secs/metrics.avg_holding_period_secsandto_dict()["Max Drawdown Duration [s]"](Python). Both areNonewhen the run carried no usable timestamps -- "cannot say" rather than a zero that reads as a real measurement.
Fixed
exposure_pctcould exceed 100%. Concurrent positions each contribute their own holding period, and the sum was divided by a single equity curve, so a netting book with overlapping trades reported more time in the market than the backtest ran -- 123.5% observed on a real run. Time in the market is now capped at the time available.
Notes for callers
max_drawdown_duration and avg_holding_period are unchanged and still
count bars. Anything rendered to a person should read the _secs
fields and fall back to the bar counts only when they are None.
[0.10.0] - 2026-08-27
Backtest metrics now report total turnover -- the total traded notional across the run, both sides counted.
In plain words: a result used to say only what you made on the money you
started with. It now also says how much money actually moved through the
market. A round trip is two legs -- buying Rs 1,00,000 of stock and
selling it back is Rs 2,00,000 of turnover -- because that is exactly the
per-leg value fees are charged on, so total_fees_paid / total_turnover
is a meaningful cost rate. Two strategies with the same return but very
different churn stop looking identical.
Added
BacktestMetrics.total_turnover(Rust) andmetrics.total_turnover/to_dict()["Total Turnover"](Python): every entry leg plus every exit leg that really traded, atprice * |size|-- the same base the fee models charge on. Exit legs that never crossed the market contribute nothing:EndOfData(the run ended while still holding) andSettlement(an option left to expire) pay no exit fee and move no money.SquareoffandLiquidationare real trade-outs and count.- Result paths that carry no trade list report
0.0, meaning "not measured", never "measured as zero".
Notes
- The contract multiplier is deliberately not folded in, matching the
fee models (which also charge on
price * |size|): for multiplier-bearing instruments both figures share the same per-point unit, so their ratio stays exact.
[0.9.0] - 2026-08-20
The Indian cost schedules now match the broker's published schedule (zerodha.com/charges, verified 2026-08-20), and every rate is pinned by a test against that source. Backtest and rebalance costs change for every segment; equity delivery and small-order costs change the most.
In plain words: buying and selling costs money in several ways -- broker fees and government taxes. The old numbers charged a Rs 20 broker fee on delivery trades where the broker actually charges nothing, always charged the full Rs 20 on small trades where the broker's percentage rate is cheaper, and used tax rates for futures and options that were two statutory increases out of date. Every backtest was slightly wrong; small delivery backtests were very wrong (47 bps of phantom fees on a Rs 10,000 position).
Changed
- Equity delivery brokerage is now zero (was a flat Rs 20 per order).
- Intraday equity and all futures brokerage is now min(Rs 20, 0.03% of order value) (was an unconditional Rs 20). Options stay flat Rs 20.
- F&O STT raised to the rates effective 2026-04-01 (Budget 2026-27): futures 0.01% → 0.05% sell-side; options 0.0625% → 0.15% of sell-side premium. The old values predated both the 2024-10-01 and 2026-04-01 statutory increases.
- MCX futures now levy CTT (0.01% non-agri sell side; was zero -- "no STT on commodity futures" ignored the commodities transaction tax).
- Exchange transaction charges corrected to the published values (all sub-0.1 bps): NSE equity 0.00345% → 0.00307%, NFO futures 0.002% → 0.00183%, NFO options 0.035% → 0.03553%, MCX 0.002%/0.035% → 0.0021%/0.0418%, CDS options 0.031% → 0.0311%.
- Currency (CDS) stamp duty corrected to 0.0001% (Rs 10/crore) buy-side; the old values were 10x (futures) and 30x (options) the published rate.
Breaking
CostSchedule.brokerage_per_orderis replaced bybrokerage_flat+brokerage_rate(charge per order =min(flat, rate * value)whenrate > 0, elseflat).indian_cost_schedule()exports the two new keys and no longer carriesbrokerage_per_order-- deliberately, so a stale consumer fails loudly instead of reading the cap as the charge.
Deliberate approximations (stated, not hidden)
- BSE equity / BFO derivatives share the NSE-family schedules; their published exchange-transaction rates differ by under 0.2 bps and execution truth for the platform is Zerodha on NSE/NFO.
- STT on exercised options (0.15% of intrinsic) is not modelled; the engine trades out of positions rather than simulating exercise.
[0.8.1] - 2026-08-18
Patch. The optimizer now refuses a book whose post-solve snapping pushed a weight past a cap, instead of returning it. No behaviour changes for any run that was already inside its caps.
In plain words: you tell the optimizer the most it may put in any one stock. After it picks the weights, it undoes trades too small to be worth making -- that keeps the book from churning over trivial adjustments. Undoing a trade can leave a holding sitting above the limit you set, and nothing checked. The engine handed the book back as if it were fine.
Measured: with no_trade_band = 0.02 the largest weight came back at 0.0980
against an 8% cap -- 22.5% over a limit a mandate treats as hard, with no
error, no warning, and a Solved status.
The module header had admitted the hazard in prose since the feature landed ("never rescaled across other names, which could breach a cap") while only the CASH budget was actually re-checked afterwards.
Why it was latent rather than live
book_optimizer.py in the backend never passes no_trade_band, so production
could not reach this. It becomes reachable the moment anyone sets one -- which
is a reasonable thing to want, since a no-trade band is exactly how you stop a
book churning on tiny adjustments.
Fixed
-
Post-snap weights are re-checked against every cap they can breach.
position_cap,short_cap, the per-sector totals andgross_max, each refusing withPortfolioMathError::Infeasibleand the arithmetic that breached -- the same shape as the existing cash and net-exposure guards. Never clamped: clamping would silently re-open the stranded-weight problem those guards exist to catch. -
The check applies to a PARTIAL snap only. When every diff snaps away the status-quo book stands, and a book that already exists is feasible by definition -- a live holding may legitimately sit above the cap, which binds the target, not what is already owned. Refusing there would block every rebalance of a concentrated book, which is precisely the book that most needs one.
[0.8.0] - 2026-08-14
Minor. Spread legs now settle on their own expiry dates instead of the whole structure closing when the first one expires. This is what makes calendar and diagonal spreads measurable; it also rejects an input that used to be accepted.
In plain words: a calendar spread sells an option expiring soon and buys one expiring later. The whole trade is the gap between the two -- the near one dies and the far one keeps living. The engine closed both the moment the near one expired, so it never simulated the part of the trade that is the trade. If you have backtested a calendar or a diagonal on any earlier release, the number it gave you was not a number about your strategy. Same-expiry structures -- straddles, strangles, verticals, iron condors, butterflies -- are unaffected and produce identical results.
Why this one is worse than an optimistic result
An over-optimistic backtest is still informative: you can discount it. This was not that. Because the engine closed at the first expiry, nothing the surviving leg did afterwards reached the P&L at all -- so the same structure reported the same figure no matter how the trade turned out.
Selling the near leg at 50 (expiring worthless) and buying the far leg at 80, on a 75 lot, with the far leg free to settle anywhere:
| Far leg settles at | Reported | The truth | Error |
|---|---|---|---|
| 120 | 3,750 | 6,750 | +3,000 |
| 100 | 3,750 | 5,250 | +1,500 |
| 80 | 3,750 | 3,750 | 0 |
| 60 | 3,750 | 2,250 | -1,500 |
| 30 | 3,750 | 0 | -3,750 |
The error changes sign, so it is not a bias that could be corrected for -- the
result is uncorrelated with the trade. And it was silent: no error, no warning,
no NaN, just a clean-looking P&L stamped exit_reason=Settlement.
Fixed
-
Each leg settles at its own expiry, and the survivors keep marking.
spreads.rstestedexpiries.iter().any(...), force-closing the whole position at the earliest leg expiry. Legs now carry their own settled state; the structure closes when the last one goes. -
A settled leg's profit is credited once, not twice. It leaves the mark-to-market and enters cash on its expiry bar. Those are the two halves of the equity line, so equity does not move across a settlement at all -- the accounting is invisible, as it should be. The reported P&L is reconciled against the account's actual gain end to end.
-
A settled leg is frozen at what it settled for. Its contract no longer exists, so whatever the premium series carries past that bar is not a price. A stale quote there can no longer reach the exit price or the P&L.
-
A settled leg pays no exit cost; a surviving leg still pays full. An option left to expire is never sold, so no order is placed and no brokerage or transaction tax is owed. Entry costs are unchanged either way -- the order that opened the leg was real regardless of how it ended.
-
A spread containing a dead leg is not re-entered. The re-entry block waited for every leg to expire; one is enough. Identical for same-expiry structures, where the two conditions coincide.
Changed
-
leg_expiry_timestampsmust carry one entry per leg. Expiries are matched to legs by position, so a shorter list left the trailing legs immortal and a longer one settled on a date belonging to no leg -- silently, in both cases.run_spread_backtestnow raisesValueErrornaming both counts.This is the breaking change in this release. Any caller passing a correctly-sized list is unaffected.
-
The engine still does not compute intrinsic value, and now says so. The contract is documented on
leg_expiry_timestamps: the premium series must carry the leg's settlement value at and after its expiry, and the engine freezes the leg there. A caller settling options against the underlying substitutes intrinsic itself. Two implementations of the same number, silently reconciled, would be a worse defect than the one this release fixes. -
run_spread_backtesthas a real type stub. It was*args, **kwargs, which told a type checker nothing about a function with eleven parameters.
Not in this release
batch_spread_backteststill passes no expiries, so batch runs never settle at expiry. That is correct by omission rather than an oversight, and is now marked as such in the source. Wiring it in needs a new field on the item class, its stub, and a test.
[0.7.4] - 2026-08-14
Patch. The last two strategy paths that computed their own trading costs -- tick and options -- now use the engine's fee model like every other path. Both under-charged, and both reported less than they charged.
In plain words: if you backtested a tick strategy or a single-leg option, the costs it reported were too low and the profit too high. How much depends on how many contracts you traded: the bigger the position, the wider the gap. Anything that looked marginally profitable is worth running again.
Release 0.7.3 fixed this same family of defect for spreads, pairs, baskets and portfolios. These are the remaining two.
The tick path
The tick strategy path priced every backtest as if you had traded exactly one unit, whatever you actually traded.
In plain words: a tick backtest charged one unit's costs, earned one unit's profit, and then measured that one-unit profit against your entire account. If you traded a 75-lot option, it reported roughly 1/75th of your real profit and 1/75th of your real costs, and your return, drawdown and Sharpe were all computed from the wrong number. Costs and profit were wrong by the same factor, so the trade list looked entirely self-consistent -- it was simply describing a position nobody held.
This resolves the gap 0.7.3 recorded as "the tick strategy path computes fees from price alone and does not scale them by position size." That description was accurate but understated the defect in two ways, both fixed here.
Fixed
-
Costs and P&L scale with the position traded.
tick.rscomputedentry_price * feesand(exit_price - entry_price) * 1.0, withsizehard- coded to1.0on every emitted trade. It now scales both by|quantity| * lot_size.The 0.7.3 note named only the fee half. The P&L half matters more: because
build_resultadds per-trade P&L to a realinitial_capital, a per-unit profit was being accumulated against a full-size account, so the equity curve, the returns series, the drawdown curve and every metric derived from them were wrong -- not just the cost line. -
The "caller scales by lot_size" contract was unfulfillable. A comment in the tick loop instructed callers to scale the result themselves. The only caller is
run_tick_backtest, whose signature had no lot-size or quantity parameter, so no caller could have complied and none did. The comment is gone and the engine does the scaling. -
fee_segmentreaches the tick path.tick.rsnever imported a fee model -- it hand-rolledprice * rateinline -- and the binding built its config with..Default::default(), so the itemized regulatory schedule was unreachable from a tick backtest by two independent routes. Both are closed; this path now usesBacktestConfig::fee_model()like every other strategy.This is the same defect 0.7.3 fixed for spreads, and it matters for the same reason: brokerage is a flat charge per order, and a purely proportional model cannot express it at any rate. Measured on a 75-lot round trip against
fee_segment="NFO-OPT":Premium Flat rate as % of the real schedule 2 0.6% 20 6.1% 100 25.7% -
Each charge lands on the side that owes it. Entry and exit are priced through
calculate_side, so transaction tax falls on the sell and stamp duty on the buy. Routing throughcalculateinstead would charge the buy schedule on both sides, and a test now fails if anyone does. -
A zero-quantity position costs nothing. It places no order, so it owes no per-order brokerage. Only visible once this path charged one at all.
Added
-
lot_sizeandquantityonTickBacktestConfigand on the Pythonrun_tick_backtest, both defaulting to1. -
fee_segmenton the Pythonrun_tick_backtest, matching the other strategy entry points.Trade.fee_breakdownis populated when it is set, sofee_breakdown.total()equalsfees.
Changed
-
A negative
quantityis refused with aValueErrorrather than accepted. This path is long-only by construction -- it enters at the ask, exits at the bid, and places its stop below entry and its target above -- so running it against a short would report a trade that could not have happened. Short support is a real feature, not a sign flip, and is not in this release. -
return_pctdivides by notional rather than by entry price, so it stays a true percentage return and does not move with position size.
Upgrading
Nothing changes unless you pass the new arguments. lot_size=1 and
quantity=1 reproduce the pre-0.7.4 numbers exactly, and a test pins that.
Any stored tick-path result for a position larger than one unit understates
both its profit and its costs, in proportion to the size traded, and should be
re-run with the real lot_size. A 75-lot option backtest was off by ~75x on
both. Because the two errors are the same factor, the reported percentage
return was roughly right while every rupee figure -- P&L, costs, equity curve,
drawdown -- was not.
Setting fee_segment moves results further: on cheap contracts the real
schedule costs many multiples of the flat rate (158x at a 2 premium on a 75
lot), because the per-order brokerage it adds does not shrink with the premium.
The options path
Six defects, all under-charging, under-reporting, or both.
In plain words: an options backtest under-charged its costs by the lot size, reported the cost of opening a position as zero, and then told you total costs were zero however much it had billed. A strategy trading a 50-lot contract paid 1/50th of its real costs while earning full-size profit, so it looked better than it was — and every figure that would have exposed this was either blank or agreed with itself.
options.rs was the last strategy path still computing its own costs instead of
using the engine's fee model. It now uses the same path as spreads and ticks.
-
Costs scale with the lot size.
calculate_contractsreturns lots, and every P&L, cash and equity line multiplied back up bylot_size— but all three fee calls passed the bare lot count. A 50-lot position was charged as a single contract. Unlike the tick defect above, costs and profit were wrong by different factors here, so net P&L was overstated on every trade rather than merely scaled down. -
The opening charge is reported, and subtracted.
entry_feeswas hard-coded to0.0at both trade sites. The charge was really deducted from cash, but the local binding died with the block that computed it, sofeesdisclosed the exit half alone — and the entry half was missing frompnltoo, not just from the report.Tradedocumentsfees == entry_fees + exit_fees; here it held only because both sides of the equation were wrong. -
Each charge lands on the side that owes it. Both sides were priced through
calculate, which assumes every call is an entry, so exits were billed the buy schedule — stamp duty instead of transaction tax. Pricing now goes throughcalculate_side, andfee_breakdownis populated instead of hard-codedNone, so a configuredfee_segmentfinally reports its components.Measured on a 50-lot round trip against
fee_segment="NFO-OPT", the flat rate as a share of the real schedule: 0.4% at a premium of 2, 4.1% at 20, 18.3% at 100. -
total_fees_paidreports what the run charged. It fell to a default of zero for every options backtest. This path builds its own metrics rather than finalizing aStreamingMetrics, so it now sums the trade list, as the portfolio engine does. -
Trade.sizereports contracts, not lots — consistent with every other path. -
An end-of-data close is paid for out of the equity curve. Found while fixing the five above, and scope beyond them. The end-of-data close computed fees and pushed a trade but never touched
cash, and it runs after the loop had already written the last equity point from the position marked to market. The exit charge appeared in the trade list and nowhere else, and the reported end value was one exit charge too high.It could not be deferred: correcting the lot multiplier multiplies that unrecorded charge by the lot size, so leaving it would have widened the gap between the trade list and the equity curve rather than narrowing it.
Any stored options result should be re-run. Costs rise and net profit falls, by more the larger the lot size and the cheaper the premium. Results held at end of data move further, since closing out is now paid for.
The options path is also now covered: it had three tests, none of which ever ran
a backtest, which is how six defects accumulated in it. Tests moved to
src/strategies/options_tests.rs alongside pins for each defect above.
[0.7.3] - 2026-08-14
Patch. Multi-leg option spreads charged the wrong costs, four ways at once, and every one of them billed too little.
In plain words: a backtest of a multi-leg option strategy under-charged its costs and then under-reported even what it did charge. The engine billed the opening trade twice, told you about only half of it, never charged the flat per-order fee a broker really takes, and ignored how many lots you traded. All four errors point the same way, so a strategy that loses money could be reported as making money -- and the cheaper the options, the likelier that is.
Fixed
-
A spread round trip is charged twice, not three times.
calculate_feescomputed a full round-trip charge -- its own comment read// Entry + Exit-- and was called at exit, on top of a separate entry charge that had already been taken. The opening side was therefore billed twice, the second time against exit premiums rather than the premiums actually paid.On a flat 100 premium over a 75 lot at 0.1%, a round trip cost 22.50 where 15.00 was owed.
-
The trade list now reports the same money the equity curve lost. The entry charge was a local variable that nothing retained, so
Trade.feesdisclosed the exit side alone while the curve had been debited for both.Tradealready documented the opposite as its invariant.This is the shape of defect that survives review, because every trade-level audit passes: the figures are self-consistent, they are simply not the ones charged. It is the same failure 0.7.2 fixed for open positions.
Tradenow carriesentry_feesandexit_fees, andfeesis their sum, so the two can no longer drift apart. -
fee_segmentreaches multi-leg spreads.SpreadBacktestheld no fee model and never imported one, so the itemized regulatory schedule was unreachable from the spread path however the config was set, and only the flat proportional rate ever applied.This matters more than the rate being wrong. Brokerage is a flat charge per order, and a purely proportional model cannot express it at all -- a 4-leg structure is 8 orders whose per-order fee was never billed at any premium. Because the missing charge is flat, the error is worst on cheap contracts:
Premium Flat rate as % of the real schedule 2 0.6% 20 6.1% 100 25.7% 400 65.4% Measured on a 4-leg structure, one round trip, 75 lot,
fees=0.001againstfee_segment="NFO-OPT". -
Costs scale with a leg's quantity. Both fee functions used
lot_sizealone and never|quantity|, so a leg holding two lots was charged as one however large the position, while P&L correctly used both. -
total_fees_paidreports what a spread run charged. The spread path never calledrecord_fees, so the summary metric read0.0for every spread backtest regardless of the costs actually taken. -
A leg holding zero contracts is charged nothing. Quantity is signed and zero means the leg trades nothing, so it places no order and owes no per-order charge. Only visible once this path charged a flat per-order fee at all.
-
fee_segmentreaches four more strategy paths.options,pairs,basketandportfolioeach built a flat percentage model directly, silently ignoring a configured segment.
Added
-
Trade.entry_feesandTrade.exit_fees, in Rust and Python, alongside the existingfees-- which now holds their sum and finally means what it documented. Existing readers offeesneed no change, and serialized results from earlier versions still load. -
Trade.fee_breakdownis populated for spreads. Settingfee_segmentreports the itemized components per trade, summed across legs and both sides, sofee_breakdown.total()equalsfees.
Changed
-
An option left to expire pays no exit-side cost. A leg exiting via
ExitReason::Settlementis not traded out: no order is placed, so no brokerage and no transaction tax are owed. Entry costs stand. Charging a full exit there would overstate every structure held to expiry -- the mirror image of the undercharge above, and worth naming because fixing costs in one direction makes the other error easy to introduce. -
MultiStrategyBacktestdrops its unusedfee_modelfield. It was constructed, marked#[allow(dead_code)], and read by nothing; the path delegates toSingleBacktest, which charges its own. Rust callers constructing the struct literally are unaffected -- it was private.
Upgrading
Any stored multi-leg spread result produced by 0.7.2 or earlier understates
its costs and should be re-run. How far off it was depends on the premiums
traded and on whether a fee_segment was set; on the measurement above, the
real schedule cost between 1.5x (at a 400 premium) and 158x (at a 2 premium)
the flat rate.
Results move in three ways: total costs rise, trades() reports a larger
fees than before (it now includes the entry side), and total_fees_paid is
no longer zero for spread runs. Strategies held to expiry may see costs fall
slightly, since settlement no longer pays a trade-out it never made.
Nothing changes for a caller who sets no fee_segment beyond the four bug
fixes -- the flat rate remains the default, and single-instrument backtests
are untouched.
Known gap, not fixed here: the tick strategy path (strategies/tick.rs)
computes fees from price alone and does not scale them by position size. It is
a separate defect and is left for its own release rather than bundled into
this one. (Fixed in 0.7.4, which also found the same hard-coded unit size in
that path's P&L and equity curve, not only in its fees.)
[0.7.2] - 2026-08-13
Patch, but a consequential one: intraday backtests can now be told to close their positions before the market shuts, and a position left open at the end of a run is finally reported as a trade.
In plain words: a backtest could report profit earned overnight, while the market was closed — money no trader could have made, because their broker would have closed the position at the end of the day. There was no setting to stop it. Now there is, and the results change materially.
Added
-
BacktestConfig(squareoff_time="15:25")— force-closes open positions at the first bar at or after that local time in each trading day.None(the default) keeps the old behaviour, so no existing result moves unless you ask it to.The time is local, interpreted through
session_tz_offset_ns, so it is market-agnostic:"15:25"with an IST offset is NSE's squareoff,"16:00"with a zero offset is a UTC-quoted market. Settingsquareoff_timeand leaving the offset at its0default is the one easy mistake — 15:29 IST then reads as 09:59 UTC and nothing fires. Unreadable values raiseValueErrorrather than silently disabling squareoff.Positions closed this way carry the new
ExitReason::Squareoff("Squareoff"from Python), distinct fromEndOfData: it is a real trade-out at a real in-session price, paying normal exit costs. The engine will not re-enter on the squareoff bar itself. -
core::session::squareoff_flags— the shared session-boundary helper behind it, usable by any strategy path.
Fixed
-
A position still open when the data ends is now recorded as a trade. The spread path settled it into cash without pushing a
Tradeor callingrecord_trade, so its P&L reachedend_value,total_return_pctand the equity curve whiletrades()returned empty andtotal_closed_tradesread zero.This is the most dangerous shape a reporting defect can take: every trade-level audit passes, because there is nothing to audit. It was found by a run whose entire return came from one position opened on the first morning and never closed — visible in the equity curve, invisible in the trade book.
-
BacktestConfig.set_session_configno longer appears in the type stub. It was declared in_raptorbt.pyiand never existed in the engine. Callers guarding withhasattr(config, "set_session_config")took the else-branch every time, sosession_aware=Truewas silently dropped and every intraday backtest ran with no squareoff. A type checker reading the stub agreed with the call throughout.A new guard (
TestStubDeclaresNothingFictional) pins the stub -> runtime direction. The existingTestStubCompletenessonly checked runtime -> stub, which is why this was never caught. Verified by injection: reinstating the fictional declaration fails the guard.
Changed
-
SpreadConfig.close_at_eodis removed. It was declared, defaulted tofalse, hardcoded tofalseat both binding sites, and read by nothing — dead since it shipped.squareoff_timesupersedes it and is actually honoured. A field that looks like a working setting and does nothing is what this release exists to stop; leaving it in place would repeat the defect.Rust callers constructing
SpreadConfigliterally should drop the field; those using..Default::default()need no change. No Python API used it.
Measured
On a real NIFTY option corpus (7 sessions, one expiry), enforcing a 15:25 squareoff moved net-of-cost P&L by:
| Strategy | No squareoff | With squareoff | Change |
|---|---|---|---|
| Short ATM straddle | ₹18,405 | ₹13,934 | −24% |
| Short strangle | ₹7,736 | ₹4,523 | −42% |
| Long ATM straddle | −₹18,627 | −₹15,590 | +16% |
The long straddle moving the other way is the important one: the defect does not add a constant bias, it amplifies whichever direction a position already points — making winners look better and losers worse. On that corpus one boundary (the night into expiry day) carried 47.1% of all overnight P&L, so the direction is robust but the magnitude is corpus-specific.
Upgrading
Existing results are unchanged unless you set squareoff_time, with one
exception: a backtest that ended with a position still open now reports one
more trade than it did before. The P&L was always in end_value; it is now
also in trades(), so trade counts and per-trade statistics will differ for
those runs. That is the fix, not a regression.
If you run intraday strategies, set squareoff_time and
session_tz_offset_ns together — the first without the second silently does
nothing.
[0.7.1] - 2026-08-12
Patch. The 0.7.0 deprecated names resolved but could not be enumerated.
Fixed
-
Deprecated
Py*names now appear indir(raptorbt._raptorbt). They resolved through__getattr__in 0.7.0, but never showed up indir(), so they were invisible to autocomplete and to any tool that enumerates a module.That is not merely cosmetic. A consumer guard comparing
_raptorbt.pyiagainstdir(_raptorbt)read the stub's alias block as 21 declarations for symbols the engine had dropped -- precisely the "type-checks clean,AttributeErrorin production" drift such a guard exists to catch. The aliases are real, so they are listed. -
The stub-completeness test in this repo had two blind spots and so never flagged the above. It recognised
class X,def X(andX:but not theX = Yalias form; and it matched declarations by substring, soclass Foomatchedclass FooBarand a renamed-away class left the guard green. Both are anchored now, verified by deletion.
Upgrading
No API change. If you consume the stub in CI, this is the release that makes the 0.7.0 alias block agree with the runtime module.
[0.7.0] - 2026-08-12
Two things: the public class names lose a prefix that never belonged in Python, and five places where the engine quietly guessed now refuse instead.
Plain words on the second half, because it matters more. When raptorbt was handed something it could not interpret -- an option type it could not parse, a direction that was neither long nor short, a correlation matrix that is not mathematically valid -- it picked a default and returned numbers that looked completely normal. Not a crash, not an obviously silly figure: a smooth, well-formed result computed from something other than what you asked for. No metric, equity curve, or risk check downstream could tell.
Changed
-
Every public class drops its
Pyprefix.PyBacktestConfigis nowBacktestConfig,PyTradeisTrade,PyRiskModelisRiskModel, and so on for 21 classes. The old spellings still work and emit aDeprecationWarningnaming the replacement; they are removed in 0.8.0.The prefix was a Rust-side disambiguator -- the crate has its own
BacktestConfig,TradeandBacktestResultinsrc/core, and two Rust types cannot share a name -- that was never stripped on the way out.BarAggregator,IndicatorandInstrumentSpecalready reached Python clean; this finishes the other 21. Rust struct names are unchanged.Deep imports keep working too:
from raptorbt._raptorbt import PyXwarns and resolves, becausePortfolioSessionhad never been re-exported at top level and a deep import was the only way to reach it. It is exported properly now. -
max_tradesno longer defaults to 50. It defaults to unlimited. This is a behaviour change to existing tick backtests: any run that relied on the implicit cap will now return different -- correct -- numbers.max_tradesis a hard early exit, not a filter. The tick loopbreaks and the result is reported as if the tape ended there. On a 1,000,000-tick input the old default produced 50 trades covering 0.81% of the data: a total return of -0.12% where the true figure was -14.13%, and a max drawdown of 0.124% against a true 14.13%. That is a 114-fold understatement of the single number a risk check reads. The knob remains for anyone who explicitly wants a truncated run. -
run_options_backteststring arguments are case-insensitive and closed.option_type,strike_selectionandsize_typeused a catch-all match arm, sooption_type="PUT"selected a long call -- the mirror image of the intended payoff -- while the identical string was accepted byrun_spread_backtest. The same call meant two different things depending on which function you entered through. Unknown values now raiseValueError; the documented defaults are unchanged.
Fixed
-
BarAggregatorignoredbrick_size. The constructor accepted the argument and then called a helper that hard-coded0.0, whichresolved_brickreads as "fall back tostep". Asking for 5-point Renko bricks gave youstep-point bricks -- a 10-point move produced 10 bars instead of 2. Every Renko backtest built through the streaming aggregator was wrong; re-run any stored Renko results. The batchaggregate_barspath was always correct. Every pre-existing test usedstep=1, brick_size=1.0, where the fallback returns the number you asked for and the bug is invisible. -
A correlation matrix that is not positive definite is refused, not repaired. Cholesky patched a negative pivot with
sqrt(|diag|)and a zero pivot with0.0, then returned success. On an indefinite 3-asset matrix (smallest eigenvalue -0.8)simulate_portfolio_mcreturnedvar_95 = 0andprobability_of_loss = 0-- a risk model reporting no risk at all, from input it should have rejected. The identity-matrix fallback beneath it was dead code and is gone; substituting one would have made every asset independent, the most optimistic assumption available to a risk model. -
An unparseable option-type code no longer becomes a Call.
OptionType::from_codedocuments that "defaulting an unrecognised code to Call would price a put as a call", and both PyO3 call sites did exactly that. An iron condor whose put legs failed to parse became a four-leg call structure.batch_spread_backtestmultiplied it across an entire sweep. -
directionmust be 1 or -1. Six call sites fell back to long, so a book encoded0/1instead of-1/1backtested entirely long, flipping the sign of the P&L on every short behind a well-formed equity curve. In the basket and portfolio runners the parse runs per instrument, so one bad row turned a leg of a market-neutral book into a doubled long. -
simulate_portfolio_mcvalidates its shapes. Passing an(n_obs, n_assets)matrix where a per-asset list of series was expected indexed past the end ofweightsinside a Rayon worker, surfacing asPanicException-- not catchable asValueError, thrown from a thread with no user code in the traceback. It now raises aValueErrornaming the mistake. -
A test that never ran now runs. A duplicated
#[test]attribute left the following function without one, soday_expires_on_utc_date_rollover-- DAY-order expiry across UTC midnight -- silently never executed. It passes.
Internal
-
Build and lint are silent:
cargo clippy --all-targets -- -D warningspasses and the library build emits no warnings, down from 89 diagnostics. Not by suppression -- 8 manualDefaultimpls became derives, shift loops becamecopy_from_slice, thew'Σwquadratic form was deduplicated between the optimizer and risk contributions, andOptionType::from_str(which shadowed theFromStrtrait, so"CE".parse()did not work) becamefrom_codewith a realFromStrimpl beside it.Six
#[allow]s remain, each with its reasoning in a comment. The load-bearing one isadopt_position, which guards with!(price > 0.0)rather thanprice <= 0.0because the negated form is also false for NaN. Clippy's suggestion would let a NaN price become a position's cost basis, turning cash, equity and every drawdown figure into NaN with no error raised.The optimizer index-math refactors were verified against a captured baseline of 18 numeric surfaces -- covariance, optimizer weights, risk contributions, MACD/RSI/ADX/VWAP, Monte Carlo, and a full backtest's metrics, equity curve and drawdown curve. Bit-identical before and after.
-
benches/python/ships the benchmark harness behind every published performance figure, so a claim can be re-run rather than trusted.
Upgrading
Nothing breaks on import. Old class names work for this release.
Three behaviour changes to be aware of:
- Renko backtests through
BarAggregatorwere wrong and are now correct. Re-run any stored Renko results. - Tick backtests that used the default
max_tradeswere truncated and are now complete. Their numbers will change, substantially. - Input that used to be guessed is now refused. If you were passing
direction=0, an option-type string outsideCE/CALL/C/PE/PUT/P, an unrecognisedstrike_selection, or a non-positive-definite correlation matrix, you will now get aValueErrornaming the argument. Those calls were already producing wrong answers; they were just not saying so.
The published performance numbers moved because the harness changed, not the
engine. The 0.6.4 wheel and this build measure at 71.0 µs and 70.5 µs on 1,000
bars on the harness now in benches/, with identical results.
[0.6.4] - 2026-08-10
One defect, one character, and it inverted every multi-leg options backtest.
Plain words: if you backtested a spread — any structure with more than one option leg, like a credit spread, a straddle, or an iron condor — the result was reported backwards. A structure that made money showed a loss, and one that lost money showed a profit. Worse than the wrong number: the automatic stop-loss read the same backwards figure, so it closed positions that were winning, and the profit target closed positions that were losing.
Nothing that trades real money was affected. Paper and live deployments price their leg groups through a different code path entirely, which was always correct and is pinned by test. The damage was to research: a user could have discarded a profitable strategy or deployed a losing one on the strength of an inverted backtest.
Fixed
-
Spread P&L is no longer negated.
LegPosition::unrealized_pnlcomputed-quantity * premium_change * lot_size.LegConfig.quantityis already signed (+1long,-1short), so the leading minus applied the direction convention a second time and flipped the result:short (-1) + premium falls (-30) -> (-1) * (-30) * 75 = +2250, a gain long (+1) + premium falls (-30) -> (+1) * (-30) * 75 = -2250, a lossEverything downstream reads that one function, so
pnl, the equity curve, the drawdown curve, and every derived metric —sharpe_ratio,profit_factor,win_rate,expectancy,best_trade_pct— inherit the correction. -
max_lossandtarget_profitfire on the right side now. Both compare against the same figure, so through 0.6.3 a max-loss threshold closed structures that had gained and a target-profit booked wins on structures that had lost. This changes when positions close, so a backtest re-run under 0.6.4 with either threshold set will differ from its 0.6.3 result by more than a sign.
Upgrading
Any stored spread backtest produced by 0.6.3 or earlier is wrong and should
be re-run. pnl and every metric derived from it are inverted. Results with
max_loss or target_profit set differ further, because the exit timing
itself was wrong. Single-leg backtests are unaffected — they never went
through this code path.
Added
- Nine Rust regression tests covering all four short/long x win/lose cases and
all four stop/target x winner/loser cases, plus a Python behavior suite
(
tests/python/test_spread_backtest.py) exercising the same contract against the built wheel. The defect survived because neither existed: the Rust tests asserted only that a trade was recorded, and no Python test calledrun_spread_backtestat all.
[0.6.3] - 2026-08-06
Two defects in position adoption, both on the path that seeds a strategy with shares a user already owns. Neither was firing in production — the supported entry point adopts before the run starts, and today's seeded strategies are long-only — but both were reachable.
This release also teaches the portfolio optimizer to hold short
positions — by explicit configuration only. Plain words: until now the
optimizer could only say "buy, hold, trim, or sit in cash." With
short_cap > 0 it may also propose NEGATIVE weights: positions that
profit when a price falls. Nothing changes for existing callers — the
default (short_cap = 0) poses the byte-identical long-only problem it
always has, pinned by test.
Added
-
An update notice. raptorbt now writes one
INFOlog line, at most once a day, when the installed version is behind the newest release on PyPI:raptorbt 0.6.2 is behind the latest release 0.6.3. Install the latest version: pip install -U raptorbt. Plain words: it tells you to upgrade, and does nothing else.It cannot slow or break an import, and it fails silently by design: the request runs on a daemon thread with a 2s timeout, every failure path is swallowed at two independent layers plus a guard inside the thread body, and the answer is cached on disk for 24h so a restarting fleet is not a burst of requests. An unreachable PyPI is indistinguishable from the check never running — no traceback, no stderr output, nothing on the log at all.
The notice is
INFOrather thanWARNINGdeliberately. With no logging configured, Python'slogging.lastResortprintsWARNINGand above to stderr;INFOstays under that bar, so the line appears for anyone who asked for INFO logs and is invisible to everyone else. A library telling you to upgrade has not detected a problem with your program.Set
RAPTORBT_NO_VERSION_CHECK=1to disable it; continuous-integration environments are skipped automatically, since a pinned wheel there is deliberate. Versions that cannot be parsed as plain dotted releases — a pre-release, a local build, or theunknownof a source checkout — produce no message rather than a wrong one. -
Long/short mode on
optimize_portfolio(PyOptimizerConfig):short_cap(per-name short bound, default 0 = long-only),gross_max(sum |w| <= gross_max— the total size of all bets), andnet_min/net_max(bounds onsum(w)— the directional tilt;net_min = net_max = 0is a dollar-neutral book). Gross exposure and the gross sector caps are linearized with auxiliary variables (u_i >= |w_i|), the same epigraph device the turnover term already uses; the variables and their rows exist only when shorting is enabled. -
gross_exposure/net_exposureonPyOptimizationResult.cashremains1 - sum(w)(net-based) and is documented as such — for a long/short book read the exposure fields, not the cash residual. -
optimize_bookas the honest name for the Rust entry point;optimize_long_onlyremains as a delegating alias so existing callers keep compiling.
Changed
- Sector caps are GROSS in long/short mode (
sum_{i in k} |w_i| <= cap): a cap bounds the size of a sector's bets, not their direction. For a long-only book the gross and signed sums coincide, so the two modes agree exactly where they overlap. - A negative
w_currentis accepted whenshort_cap > 0(it is the book being rebalanced); still refused in long-only mode.
Deferred, pinned
- Short position adoption stays refused (
short_adoption_stays_refused_ by_construction): posted broker collateral is not derivable from quantity x average price, and no supported flow seeds a short. The refusal is structural — adoption has no direction parameter.
Fixed
-
A position adopted mid-run made the strategy look less risky than it was. The equity curve is written as the run proceeds, one sample per event, against a running peak that starts at the initial capital. Adopting after the run began left that curve flat for the stretch before the adoption, which held the peak down, so the decline that followed was measured against a high-water mark lower than the truth.
On a 6-bar 100→95 fixture adopting 100 shares at 90, a 0.495% max drawdown reported as 0.199%. Total return and
open_trade_pnlwere identical either way, so nothing in the headline numbers hinted at it — only the risk metric moved, and it moved to look safer.Because the samples are written as the run proceeds, they are already wrong by the time metrics are computed; there is no repairing it afterwards. So
adopt_positionnow returns an error once any equity sample has been taken, raisingValueErrorfrom Python.The gate is the equity curve, not the event cursor: quote and depth events advance the schedule without sampling equity (marking on a quote would append a zero return per quote and distort annualized metrics by how chatty the feed is), and a live feed routinely delivers quotes before the first trade print. Adopting after one corrupts nothing and stays allowed.
TickStrategyStream(initial_positions=...)is unaffected — it adopts before warmup replay and before the first push, which is why this never fired in production.EngineKernel::adopt_positionholds no equity curve and cannot check this itself; a Rust consumer driving the kernel directly owns the ordering, and its doc comment now says so. -
A seeded long/short strategy could not be deployed at all. A short leg only transacts as a short under a margin account — in cash mode its P&L never reaches equity — so a strategy holding one runs under margin at leverage 1.0, which keeps the book fully funded. Adoption refused margin outright, so the seed and the short were mutually exclusive: construction raised and the deploy died before it began.
Fully funded margin books (initial margin rate ≥ 1.0) are now adopted by locking the cost basis as initial margin rather than debiting cash. That is not a cosmetic difference: margin equity is
balance + unrealized, with no position-value term, so a cash-style debit would never be offset and would understate equity by the cost basis for the entire run. Relaxing the account check without fixing the funding arm would have replaced a loud failure with a silent wrong number.Leveraged books stay refused, and the original reasoning is why: the margin a broker has already posted against a position it holds cannot be derived from quantity and average price, and inventing a figure would misstate free capital, which gates every later entry. At a rate of 1.0 the whole notional is locked and the posted margin simply is the cost basis, so the objection lapses there and only there.
The error message changed from
"adopt_position supports cash accounts only"to one naming the fully-funded requirement. Callers matching on that string need updating.The portfolio session now also reconciles the locked delta into its shared account, where it previously passed a hardcoded zero. Left as it was, the account would never learn about the adopted margin and portfolio free capital would read high by the whole cost basis.
Adoption remains long-only: an existing short cannot be seeded. That is separate scope — direction-aware cost basis, short proceeds, borrow — and is stated here so the boundary is explicit rather than accidental.
[0.6.2] - 2026-08-05
A strategy attached to a stock the user already owns can now start out knowing it holds those shares, at the price the user actually paid — without the engine pretending a buy happened.
Added
-
Position adoption.
EngineKernel::adopt_positionopens a ledger position with no order, no fill, no fees, no trade record and noEnteredevent; cash is reduced by the cost basis, so equity reads as initial + unrealized exactly like an account that bought earlier. Without this, seeding a holding meant faking an entry — which charged fees that were never paid and left a phantom trade in the log.PortfolioSession::adopt_positionapplies the same lend/drain pool discipline asapply_current, so the cost basis comes out of the shared cash pool rather than appearing from nowhere.Exposed to Python as
PyPortfolioSession.adopt_position(...)and asTickStrategyStream(initial_positions={symbol: {"quantity", "avg_price", "timestamp_ns"?}}). Adoption runs before warmup replay and before the first push, so the position is present in every before-snapshot: a caller diffingpositions()around a push can never mistake it for a fresh entry.Cash accounts only — margin adoption is refused, not guessed, since the margin already posted against a broker-held position is not derivable from quantity and average price. A seed with non-positive quantity or price is rejected rather than silently skipped.
Design reference: NautilusTrader's position adoption in live-execution reconciliation, where adopted state coexists with the order lifecycle without synthetic fills. Ported as a design, not as code.
[0.6.1] - 2026-07-31
The significance score on factor measurement was overstated, because overlapping test windows were counted as if they were independent. This release reports the corrected number alongside the old one.
Added
-
Overlap-deflated rank-IC t-statistic.
rank_icpreviously reported only the naive IID t-stat,mean / (stdev / sqrt(n)). With a 21-day forward window on daily dates, consecutive ICs share 20 of their 21 days, son_dates_scoredoverstates the independent sample by ~21× and inflates the t-stat by ~sqrt(21).Plain words: the same three weeks of market movement was being counted twenty-one times over as if it were twenty-one separate pieces of evidence.
RankIc/PyRankIcgain three fields, all additive:t_stat_deflated—t_stat / sqrt(horizon); the number to decide onn_independent—n_dates_scored / horizon; the sample actually behind itoverlap_days— the window, so a stored result is self-describing
The naive
t_statis deliberately kept, so the inflation stays auditable rather than being quietly corrected away.n_independentis0.0(not NaN) on a panel that scores nothing, so callers can gate on sample size without anisnandance.This is not theoretical. Measured on a live 2023-02..2026-07 vendor panel (1045 names), momentum 12-1 scores IC +0.0386 with a naive t of +7.15 and a deflated t of +1.56, over 17.3 independent forward windows — real, but a materially smaller claim than the naive figure suggests. The same correction took the Indian fund cross-section from t=+4.78 to +1.04, which is the measurement that retired funds from the equity model; reporting equities on the naive statistic while funds were judged on the deflated one would have been exactly that double standard.
Purely additive — no existing field changes meaning.
[0.6.0] - 2026-07-25
An order's side now decides the direction a position opens in, so a single
run can hold long and short legs and a leg can flip side once it is flat.
This makes a cross-sectional long/short book — long the winners, short the
losers, rebalanced — expressible in one run against one capital pool.
This release also adds the portfolio-construction maths: how much risk a book carries, what weights to hold, and what rebalancing actually costs.
Added
-
Strategy.enter_long()/Strategy.enter_short(), andenter(side=...). Withoutside,enter()opens in the session's configured direction exactly as before.enter()could previously open only in the session's configured direction, so the sided order types were the only way to short — a nine-field kw-only dataclass that is easy to fill wrong, and unreachable from a sandboxed strategy at all.enter_long()/enter_short()take no side argument to mis-spell. A sided entry passes an explicitsize_frac, because omitting both sizing kwargs means "close the whole position", which an opening order refuses — a sided entry that silently rejected itself would be worse than no feature. -
Covariance estimation (
estimate_covariance→PyRiskModel). Ledoit-Wolf shrinkage against a constant-correlation target — plain words: a covariance matrix estimated from a few hundred days of returns is mostly noise, so it is pulled part-way toward a simpler, steadier matrix. Carriesperiods_per_yearand the asset ordering structurally, so a risk model cannot be silently applied to a differently-ordered basket. -
Constrained portfolio optimizer (
optimize_portfolio,batch_optimize_portfolios→PyOptimizationResult). Long-only quadratic program via Clarabel (new dependency, pure Rust) with an L1 turnover penalty, per-position and per-sector caps, and explicit cash. Post-solve, a no-trade band and a minimum-trade-value rule snap tiny trades away.If all trades snap away, the result is the status-quo book with turnover 0 — a legitimate "do nothing" answer. If only some snap, leaving weights that no longer sum correctly, it refuses with arithmetic rather than returning a book that does not add up.
batch_optimize_portfoliosruns via Rayon and is deterministic: batch results are bit-identical to serial. -
Factor panels (
winsorize_panel,zscore_panel,rank_panel,momentum_panel,composite_scores). Row-major panel transforms — trim outliers, standardize, rank, compute past-return momentum, and blend several signals into one score.NaNmeans absent and is handled; infinity is a hard error, never a silent maximum. No factor list is hard-coded in Rust — the caller decides what to score. -
Rank-IC factor validation (
rank_ic→PyRankIc). Per-date Spearman rank correlation between a factor panel and forward returns at a chosen horizon — plain words: does yesterday's ranking of stocks predict tomorrow's ordering of returns? Returns the mean IC, the naive t-stat, and an overlap-deflated t-stat (see 0.6.1, which added the deflated fields to the Python surface).PyRankIccarries the panel span and name count, so the number is reproducible rather than a constant with a citation.First use caught a real artifact: fund momentum on 67 NSE funds read t=+4.78 naive but +1.04 deflated, and collapsed to +0.016 once the 25 precious-metal funds were removed — a metal rally, not a factor. The fund ranking was therefore not shipped.
-
Risk contributions (
compute_risk_contributions→PyRiskContributions). Euler decomposition of portfolio volatility, so contributions sum exactly to sigma — it says which holdings the risk is actually coming from, not merely which are largest. -
Rebalance policy simulation (
simulate_rebalance_policy→PyRebalanceSimResult, andindian_cost_schedule). Simulates a rebalancing policy on the Indian delivery settlement schedule, including the flat DP sell charge — ₹15.34 per ISIN per day on any day with a sell. That flat fee is the cost that dominates small books, and a percentage-only cost model misses it entirely. Reports turnover, regulatory / brokerage / DP costs separately, and annualized cost drag. -
Maintenance margin for fully funded positions — a position covered entirely by posted cash no longer contributes a maintenance requirement it cannot breach.
Changed
- Netting: an order opposing a FLAT instrument now opens in the order's
own side, where it was previously read as a close, found no position, and
was discarded. An order opposing an open position still closes it, so
bracket legs and take-profit orders are unaffected.
reduce_onlyorders route to the closing branch unconditionally and never open. submit_bracketmarks its stop and target legsreduce_only, so a leg still working after the position closed by another route cannot open a fresh position on the opposite side.- The kernel's per-instrument
directionnow governs the signal path only (enter()and the signal arrays). Runs usingdirection=/directions=without submitting sided orders are bit-identical to 0.5.0.
Fixed
- Every refused order counts against
rejected_entries. Previouslyno_position,position_open,reduce_onlyandinvalid_qtyrejections were invisible, so a discarded order looked like an order never placed. Sizing refusals (zero_size) and unfillable closes stay uncounted: they are not refused entries. - An order-path open honors an ATR stop/target config instead of computing levels from a hardcoded zero ATR, which silently produced no stop at all.
[0.5.0] - 2026-07-21
This release fixes five defects where the engine silently returned wrong numbers, adds a shared-capital portfolio runner, and introduces the class-based strategy contract — an event-driven alternative to precomputed signal arrays.
Read "Migrating from 0.4.x" before upgrading — several metrics change
value. Setting apply_slippage=False, legacy_annualization=True reproduces
0.4.1 results bit-identically, so stored backtests stay reproducible while you
migrate.
Fixed
-
Timers and alerts fired for only one symbol in multi-symbol runs. The clock was global, so a timer set in
on_startfired once for whichever symbol's event happened to cross the threshold first and never for the rest — a two-symbol heartbeat delivered half its beats, silently. Each symbol now has its own clock, carrying whateveron_startscheduled. Single-symbol runs are unchanged. -
Options never settled to intrinsic value.
settle_expirycalledsettlement_value(close, None), and theNonemeant the option branch could never match, so every option settled at its own last close no matter how far from intrinsic that was. The strategy can now supply an underlying viactx.set_underlying_price(...)— routed per symbol in portfolio runs — and without one, contracts still settle at their own close, since an option's bars carry the option's price and the engine has no second series to read. -
TimeInForce::Dayexpired on the UTC date, not the trading date. A session whose local hours cross UTC midnight would see DAY orders die while the trading date was still running.session_tz_offset_nsonPyBacktestConfigsets the offset — e.g.IST_OFFSET_NS— and defaults to0, which is arithmetically identical to the old behavior.This is a latent fix rather than a live bug for NSE users: 09:15–15:30 IST does not cross UTC midnight, so the common case was already correct. It follows the trading date, not the trading session — a DAY order still survives past the session close to the next session of the same date.
-
Indicator registration was a silent no-op in portfolio runs.
register_indicatorappended to the strategy's list butrun_portfolio_strategynever updated anything, so.valuestayedNoneandindicators_initialized()never became true. Indicators now update, routed per symbol. Registrations also reset per run, so re-running one strategy instance no longer accumulates duplicates (matchingrun_strategy_backtest). -
modify_orderraisedNotImplementedErrorin portfolio runs. The id map already carried the owning instrument; the routed binding was missing. Modifies now route without the caller naming a symbol. -
max_positionswas per-instrument in portfolio strategy runs.EventSessiongave each instrument its own copy of the risk gate, andRiskGateisCopy, so every kernel checked the limit against its own ledger:max_positions=1across three symbols allowed three concurrent positions. It is now counted across all instruments, as the array runner (run_portfolio_backtest) has always done, and is enforced on the resting-order path as well as on signal entries. Runs that setmax_positionsonrun_portfolio_strategywill open fewer positions than before — the previous behavior did not match the documented meaning of the setting or the sibling API. -
Portfolio session results reported stubbed halt/rejection fields.
PyPortfolioSession::finishhardcodedrejected_entries: 0,halted: false, andhalted_at: None, so arun_portfolio_strategyrun could refuse entries or trip its drawdown kill-switch and still report a clean, unhalted result. All three now carry real values:rejected_entriessums the per-instrument counters (already reported correctly onper_instrument), andhalted/halted_atcover both the drawdown kill-switch and the new portfolio margin call. -
Configured slippage was ignored.
PyBacktestConfigacceptedslippageandBacktestConfigcarried it, butPortfolioEngine::newhardcodedSlippageModel::None, so the model was applied as a no-op. Every bar-level backtest run with slippage configured executed at zero slippage. A 0.2% slippage on a 9-trade fixture now costs ~3.4% of capital; before it cost exactly nothing.run_tick_backtestwas never affected — it readsslippagedirectly and always honored it. -
Sharpe and Sortino were computed from different quantities per runner.
run_single_backtestannualized per-bar returns at 365, whilerun_basket_backtest,run_pairs_backtest,run_options_backtestandrun_multi_backtestannualized per-trade returns at 252. The return basis is the more serious half: annualizing trade returns assumes one trade per trading day, inflating the ratio by roughlysqrt(n_bars / n_trades). On a 2-trade/500-bar basket, Sharpe drops from 1.175 to 0.218 once corrected. All five runners now share one estimator fed per-bar returns. -
Calmar was meaningless on intraday data. Years were derived from bar count over 365.25, so an 11k-bar 1-minute backtest was scored against ~31 "years" of compounding. Years now come from elapsed wall-clock timestamps.
-
Undefined ratios crossed to Python as
inf.profit_factor,payoff_ratio,recovery_factor,calmar_ratio,sortino_ratioandomega_ratiodivide by a denominator that can legitimately be zero — a strategy with no losing trades has an undefined profit factor, not an infinite one.json.dumpswritesfloat('inf')as a bareInfinitytoken, which is not valid JSON and whichallow_nan=Falserejects outright. These are nowOptional[float]and returnNone. -
__version__had drifted.python/raptorbt/__init__.pyhardcoded"0.4.0"while the crate was at0.4.1, so any version check read a value that was never released. It now derives from installed package metadata. -
cargo testcould not link, so CI never ran it. pyo3'sextension-modulefeature was unconditional; it leaves Python symbols to be resolved by the host interpreter at import time, which a test binary cannot satisfy. The feature is now opt-in-by-default and tests run with--no-default-features. CI previously ran only an import smoke test; it now runs 198 Rust tests and 16 Python behavioral tests across ubuntu/macos × Python 3.10–3.12.
Added
-
Incremental (live) session feed.
PyPortfolioSessiongainspush_tick,push_bar,push_depthandremaining(): events append to the schedule tail in arrival order afterseal()(idempotent — batch warmup data merges ahead of the first push), and the existingcurrent_event()/apply_current()loop drives them. A batch replay and a push-per-row stream of the same rows produce identical results. -
TickStrategyStream— a Python driver for open-ended live feeds. Construct with symbols and optionalwarmup_bars, thenpush_tick/push_bar/push_depthas events arrive; every strategy hook a push triggers fires before it returns.finish()closes out and computes metrics. Shares its dispatch loop withrun_tick_strategy, with one addition in streaming sessions: real bars (warmup or pushed) execute — they match orders and mark equity — unlike bars aggregated from prints viaprimary_bars, which remain a view. -
Four deferred execution knobs, all default-off:
limit_slippageapplies an adverse adjustment to limit fills, which previously always printed exactly at the limit. It is suppressed whenqueue_fill_modelgranted the fill: volume observed trading ahead of an order is evidence it genuinely held that price, so slipping it too would double-penalize.liquidate_on_margin_callforce-closes positions when a margin call fires, instead of only latching a halt. Unlike expiry settlement or end-of-data finalization — both of which close free — a liquidation is a real trade-out: it prices through the fill model and pays exit costs, and reports the newExitReason::Liquidation.InstrumentSpec.settlement_feecharges a fee on the settled notional at expiry. It sits alongsidemaker_fee/taker_feerather than on the config because exercise and assignment are commonly priced differently from a trade-out, and a portfolio run needs per-instrument rates.EngineKernel::set_underlying_pricelets options settle to intrinsic value — see below. -
TWAP execution schedules.
orders.Twap(side=..., units=..., slices=N, every=<ns>)releases N equal slices at a fixed interval, each an ordinary order reporting its own fill with a client id of"<parent>#<n>". Newon_algo_started/on_algo_completedhooks bracket the schedule; "completed" means fully released, not necessarily fully filled.A schedule is deliberately not an order. Modelling it as a parent would deadlock its slices — the one-triggers-other gate holds a child until its parent fills, and a schedule never fills — so slices carry only a back-pointer and the matcher needs to know nothing about them.
The interval is a duration, not a bar count:
idxis a bar ordinal in a bar session and an event ordinal in a tick session, so "every 1 bar" would silently mean "every 1 print" on a tick feed and collapse a five-slice TWAP into a burst. Passevery_barswithbar_nsfor bar-shaped ergonomics. Slices release one per step even after a data gap, since dumping a backlog defeats the point of spreading an order.Only explicit
unitscan be sliced —size_fracresolves against equity at fill time, so each slice would size against a different account. Cancelling a schedule stops the remaining slices; it does not unwind what already traded. Only TWAP ships in 0.5.x; VWAP needs a volume forecast and POV needs partial fills, both still deferred. -
Renko and signed-flow bar units. Every variant declared in
AggregationUnitnow builds; none returnsUnimplemented."renko"emits a brick per full brick-height price move, ignoring time and volume entirely. Height comes from a newbrick_sizeargument (BarAggregator,aggregate_bars,bars_from_ticks,subscribe_bars), falling back tostepread as whole price units. Because one move can complete several bricks,BarBuildergainsnext_pending()and its Python mirror — drain it after every push or those bricks are lost. Bricks carry no wicks and a partial brick is discarded, not flushed: an incomplete brick is not a brick.The six information-driven units —
{tick,volume,value}_imbalanceand{tick,volume,value}_runs— sample by signed order flow. Imbalance closes on net flow, so balanced two-sided trading never closes a bar however heavy; runs closes on the larger one-sided accumulation, so it does. The threshold isstep, fixed rather than the literature's adaptive EWMA: deterministic, reproducible, and consistent with howstepalready reads for tick/volume/value bars.Direction comes from the feed when known —
TradeTickandSourceRecordgain a signed field, populated from the buy/sell quantity deltas thattick_data_to_eventspreviously summed away. The unsignedsizeis unchanged, so no existing bar moves. Without a split, direction falls back to the tick rule, which is what lets these units work over plain bars. -
Order book state and queue-position limit fills.
OrderBooktracks the visible book from quotes (L1) or depth snapshots (L2, five levels), exposed to strategies through a newon_order_book(ctx, book)hook,ctx.book, and adepth=argument torun_tick_strategy.With
queue_fill_model=True(opt-in, default off), resting limits fill from observed queue position rather thanfill_prob_limit's coin flip. The size ahead is estimated once, when the order rests, then consumed by print volume at that price; a print through the level fills unconditionally. Unlike the probability model, progress is monotone — an order passed over repeatedly genuinely gets closer to the front.The model claims no real queue rank: without an order-by-order feed there is no way to know your position, nor to tell size that executed ahead of you from size that was cancelled. It therefore falls back to
fill_prob_limitrather than guessing — on bar events (a bar's volume is not volume at the limit price) and on a quote-only book (a quote gives the price but not the size). A level outside the visible five reports "unknown", never "empty".Book updates are observation only, like quotes: they never fill an order, move a trailing stop, mark equity, or sample the equity curve. They do change future fills by sizing the queue a new order joins.
-
Tick-driven class contract —
run_tick_strategy(strategy, ticks, ...)drives the same event session from trade prints and quotes, so orders, positions, risk gates and the shared account behave as they do on bars; only the resolution changes. Newon_trade_tick(ctx, tick)andon_quote(ctx, quote)hooks,TradeTick/QuoteTickpayloads, andctx.best_bid/ctx.best_ask/ctx.last_price.Three semantics worth knowing before using it:
- Quotes are observation only. They do not fill orders, move trailing
stops, or mark equity. Filling against a quote would assert a
counterparty the engine has no evidence for; the print that follows is
that evidence. An order submitted from
on_quoterests and matches on the next print. ctx.best_bid/ctx.best_askinsideon_trade_tickare the book observed before that print — the quote from the same feed row arrives in the followingon_quote. Reading it earlier would be a lookahead onto a book the print itself moved.primary_bars=(step, unit)builds bars from prints as a view: they fireon_barand feed indicators, but nothing executes on them. Orders match against ticks only.
AT_OPEN/AT_CLOSEmarket orders keep resting on a print, since a print has no bar phase to queue against. Trailing stops ratchet off every print, so a tick run and a bar run over the same data legitimately differ there — a bar can trigger a stop against a low that preceded the high which set the watermark, and prints cannot. - Quotes are observation only. They do not fill orders, move trailing
stops, or mark equity. Filling against a quote would assert a
counterparty the engine has no evidence for; the print that follows is
that evidence. An order submitted from
-
Per-symbol indicators and composite bars in portfolio runs.
register_indicator(indicator, stream_id=None, symbol=None)gainssymbol=to route an indicator to one instrument, andregister_indicators(factory, symbols)builds one per symbol. Onesubscribe_barsdeclaration now yields one aggregated stream per symbol, each built only from that symbol's bars, andCompositeBargains a trailingsymbolfield (Noneoutside portfolio runs) naming the instrument that completed it. A symbol's composite bar dispatches before that symbol'son_barwhich completed it; across symbols, order follows the merged schedule.Note: an indicator registered without
symbol=in a portfolio run is fed every symbol's bars interleaved — rarely meaningful, since one indicator cannot track N series — and now warns. It previously did nothing at all, so no working strategy changes behavior. -
Shared margin accounts in portfolio runs —
run_portfolio_strategyacceptsaccount_type="margin"andleverage, previously available only to single-instrument runs. One account funds every instrument: leverage applies portfolio-wide, sizing draws on the portfolio's free capital (balance less all locked margin), and equity marks the balance plus direction-aware unrealized PnL, so a winning short raises portfolio equity instead of lowering it. The maintenance requirement is the sum of each instrument's own requirement, so per-symbolmargin_maintrates apply rather than one blended rate; a breach fireson_margin_callonce and halts new entries on every instrument, including symbols that never traded.PyPortfolioSessiongainsfree_capital()andis_halted(). Cash-account runs are unchanged and remain pinned by the golden fixtures.Note: in portfolio runs
halted_atis a schedule-event ordinal, since the session interleaves N instrument streams; the array runners'halted_atremains a bar index. -
Portfolio drawdown halts now record
halted_aton the shared account, so margin-call and drawdown halts report identically. A drawdown halt keeps its own reject reason (DrawdownHalt) rather than borrowing the margin-call switch. One consequence: once any halt has latched, a later margin-maintenance breach no longer emits a secondMarginCallevent — halts are latch-once. -
run_portfolio_backtest— simulates N instruments against one cash pool, withmax_positionsand a drawdown kill-switch gating each entry before it opens, so reported metrics describe the constrained run.This is materially different from running one backtest per symbol and summing the equity curves, which gives every symbol its own private copy of the capital. On 5 symbols with a 500k account, the summed approach reports 2,381,392 final equity having deployed 2.5m — 5× the account. The shared-pool runner reports 478,537 for the same signals.
Reports
rejected_entries,halted,halted_at, and per-instrument attribution, so a constrained run is distinguishable from one with no signals. -
max_positionsandmax_drawdown_pctonPyBacktestConfig, enforced in-loop rather than by filtering trades afterwards. The kill-switch latches: once tripped it stays tripped, since a switch that re-arms on recovery is a materially less conservative policy. -
Itemized Indian transaction costs via
fee_segment("NSE-INTRADAY","NFO-OPT","MCX-FUT", …), covering brokerage, STT, exchange transaction, SEBI turnover, stamp duty and GST across NSE/BSE equity, NFO/BFO, MCX and CDS.trade.fee_breakdown["total"]equalstrade.fees, and their sum equalsmetrics.total_fees_paid— the itemized costs and the equity curve are now the same money.Charges land on the leg that owes them: STT on the sell, stamp duty on the buy, keyed off
(direction, is_entry). GST applies to brokerage, exchange and SEBI charges only, never to STT or stamp duty.Note: for options, STT and exchange charges are levied on premium, not contract notional.
-
session_minuteswith exported constantsSESSION_NSE(375),SESSION_MCX(870),SESSION_CDS(480) andSESSION_CONTINUOUS(24×7). Intraday annualization scales with session length, so assuming NSE hours on MCX data understates Sharpe bysqrt(870/375)≈ 1.52×. -
periods_per_yearto override annualization explicitly, andrisk_free_rate, wired into Sharpe and Sortino as excess return. -
EngineKernel— the per-bar simulation body extracted into a steppable core (step(bar) -> Vec<EngineEvent>). Batch backtests loop it; a live feed can drive the same code, which is the groundwork for backtest/live parity. -
Class-based strategy contract. Strategies can now be written as Python classes with lifecycle hooks instead of precomputed signal arrays:
class SmaCross(raptorbt.Strategy): def on_start(self, ctx): ... # precompute indicators on ctx.close etc. def on_bar(self, ctx): if crossed_up: self.enter() if crossed_down: self.close_position() result = raptorbt.run_strategy_backtest(SmaCross(), timestamps, o, h, l, c, v)Hooks:
on_start,on_bar,on_stop,on_order_filled,on_order_rejected,on_position_opened,on_position_closed. Order intents (enter(size_frac=..., stop_price=..., target_price=...),close_position()) are applied through the same execution core as the array runners —SingleRunner, extracted from the batch engine loop — so identical decisions produce bit-identical trades, curves, and metrics (pinned by the equivalence tests intests/python/test_strategy.py).ctxexposes the OHLCV arrays, current index/bar, position snapshot, equity/cash, and programmaticset_stop_price/set_target_price.Entries whose computed size rounds to zero units (size fraction below one lot, or insufficient capital at the fill price) now emit
EntryRejected { reason: ZeroSize }instead of being silently skipped; class strategies receive it viaon_order_rejectedwithreject_reason="ZeroSize". Array-runner results are unchanged — the batch path ignores rejection events.Rust/PyO3 surface:
PyKernelSession(per-barstepdriving the engine with scalar inputs),PyEngineEvent,PyPositionSnapshot,resolve_atr_period, kernelset_stop_price/set_target_price/position_snapshot, andStepInput.stop_price_override/target_price_overridefor per-entry explicit exit levels.The array-based runners are unchanged and remain fully supported; they are the fast path for vectorized workloads. New strategies should prefer the class contract.
-
Type stubs.
_raptorbt.pyiand apy.typedmarker now ship in the wheel. TheTyping :: Typedclassifier was previously inaccurate.
Changed
PortfolioContextposition state matchesStrategyContext.position,positionsreads for the current symbol, plusis_flat/is_net_long/is_net_short/net_position, are now PROPERTIES on the portfolio and tick contexts, exactly as on the single-instrument context — so a bar-style strategy (if ctx.position is None) behaves identically on the live stream instead of silently seeing a truthy bound method and never entering. Cross-symbol lookups are explicit methods:position_for(symbol),positions(symbol=None),net_position_for(symbol).- Stop and take-profit fills route through
FillModel, which handles gap-through for all four(direction, is_entry)cases; the engine previously inlined a long/short-only copy. Behavior is unchanged. compute_backtest_metricsgained atimestampsparameter, andcompute_backtest_metrics_with_configwas added for callers that have a full config.PositionManager::close_positiontakes anExitDetailsstruct.StepInputgainedstop_price_overrideandtarget_price_overridefields. Rust rlib consumers constructing it as a struct literal without..Default::default()must add the new fields; the Python API is unaffected.
Removed
signals::expression(456 lines). It had no parser or AST despite its module documentation, was never re-exported, never bound to Python, and had zero references anywhere in the crate. Its role is superseded by the class-based strategy contract shipped in this release. This is a breaking change only for Rust consumers of the rlib that referencedraptorbt::signals::expression::directly.- The checked-in
_raptorbt.cpython-311-darwin.sobuild artifact andlibraptorbt.dylib.dSYM/. Compiled extensions are now gitignored.
Migrating from 0.4.x
Reproducing old results
cfg = raptorbt.PyBacktestConfig(
initial_capital=100_000.0,
fees=0.001,
slippage=0.002,
apply_slippage=False, # restores the 0.4.1 no-op
legacy_annualization=True, # restores 365/252 and bar-count Calmar
)
Verified bit-identical to 0.4.1 across 13 single-instrument scenarios and for
the basket runner, where legacy mode reproduces the old Sharpe to the last
digit. The only unconditional difference is inf → None on undefined ratios.
Required code changes
Optional metrics. Six metrics are now Optional[float]:
pf = metrics.profit_factor
if pf is not None:
...
A getattr(metrics, "profit_factor", 0.0) guard does not help — the
attribute exists, so the default never fires and the call returns None.
Arithmetic on these fields needs an explicit check.
Expected metric changes (defaults)
| Metric | Change |
|---|---|
| Sharpe / Sortino (daily, single) | unchanged — daily data still resolves to 365 |
| Sharpe / Sortino (intraday) | increases; annualized on session count, not calendar days |
| Sharpe / Sortino (basket/pairs/options/multi) | decreases substantially; per-bar rather than per-trade returns |
| Calmar (daily) | shifts slightly; elapsed time rather than bar count |
| Calmar (intraday) | changes substantially |
Any run with slippage > 0 |
returns decrease; slippage is now actually charged |
| Everything else | unchanged |
Recommended
- Pin
raptorbt>=0.5.0,<0.6.0. A floating>=lower bound means an unattended upgrade silently picks up behavior changes. - Pass
session_minutesfor MCX and CDS strategies. - Replace
hasattrfeature-sniffing with a__version__check; it is now accurate.
[0.4.1] - earlier
Releases before 0.5.0 were tracked in commit messages only.