Files
raptorbt/CHANGELOG.md
T
porcelaincodeandPier Code 326f145322 fix(stream): a refused print still advances the schedule clock
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>
2026-09-10 22:33:07 +05:30

125 KiB
Raw Blame History

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_tick advances the clock on a refused row. A row with ltp == 0 and 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, with ts_fired set 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_idx already 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_events takes an optional before_trade(symbol) callback, and the clock step is factored into advance_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. One Order record 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_qty is None when the request named a capital fraction rather than a number: the size is not known until the engine prices it, and 0.0 there would read as "asked for nothing". filled_qty is 0.0 for an order that never filled, which is a measurement; avg_fill_price is None, 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, and OrderKind::limit_price / trigger_price for the prices a kind carries.

Changed

  • BREAKING (string values): every refusal now reports the same name. RejectReason has always had as_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 of BatchSingleItems, 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 ValueError naming that item before any worker starts, because a panic on a Rayon thread crosses PyO3 as PanicException, which a caller can neither catch as ValueError nor trace to the argument at fault.

  • Ten diagnostic metrics, each tied to a decision. BacktestMetrics now carries 48 fields, 31 in to_dict().

    Is the edge real? return_skew and return_kurtosis describe the shape Sharpe assumes away; tail_ratio compares 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_pct is the overfit tell. breakeven_cost_multiple says 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_pct is the equity curve's batting average, a different question from win_rate_pct over trades. avg_drawdown_pct separates one bad week from chronic pain at the same headline max_drawdown_pct.

    Where should the stop go, and is the exit the problem? avg_mae_pnl is how far the average trade goes against you before it works — a stop tighter than that closes trades that would have won. mfe_capture_ratio is the share of the favourable move winners keep; 0.35 points at the exit rule rather than the entry signal. mae_mfe_coverage_pct must 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. None means not measured on this path, never a measured zero — 0.0 is a legitimate value for most of them, which is exactly the ambiguity ulcer_index's bare f64 was 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_ratio uses linear-interpolation percentiles matching NumPy; avg_drawdown_pct is conditional on being underwater, since averaging the zeros in would just be a worse ulcer index.

    mfe_capture_ratio carries 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, since mfe_pnl is 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, plus Trade.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 as pnl, 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.

    None on paths that synthesize a trade rather than closing a tracked position (spread, basket and pairs legs). None means "not measured" and must not be read as a zero excursion; a flat trade that genuinely never moved reports 0.0, which is a measurement.

  • BacktestMetrics.ulcer_index — the root mean square of the drawdown curve, in the same percentage points as max_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 is 0.0 only for a curve that never fell, and it can never exceed max_drawdown_pct.

  • BacktestMetrics.time_under_water_pct — the share of equity samples strictly below the running high-water mark. The companion to exposure_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_pct folds, so all three describe one curve. The pre-existing helper metrics::drawdown::ulcer_index rebuilds 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::finalize reports 0.0 for both: that accumulator keeps a running peak but no drawdown history, so it cannot compute an RMS or count underwater samples. 0.0 there means "not measured on this path", matching the existing convention for exposure_pct and total_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.pyi against the bindings, so these were silently wrong for several releases. avg_win_pct, avg_loss_pct, avg_winning_duration and avg_losing_duration are Option<f64> in the bindings but were declared bare float — the stub told callers they could always format them, which raises TypeError on exactly the runs (no winners, or no losers) the None was introduced to describe. And equity_curve(), drawdown_curve() and returns() return NumPy arrays, not list[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.md were stale, reading 33 fields / 24 in to_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 declared list[BacktestResult], so a caller following the stub indexes a tuple and gets AttributeError on 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_ratio was 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_ratio was 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 new atr_target_fixed_stop fixture 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 own drawdown_curve while time_under_water_pct read 0.0, and carry seven trades while total_turnover and exposure_pct read 0.0. It now uses compute_backtest_metrics_with_config, the estimator every other runner shares.

    This changes published spread numbers. On the golden fixtures: ulcer_index 0.0 → 0.0826886, time_under_water_pct 0.0 → 83.333333, avg_drawdown_pct None → 0.0809098, total_turnover 0.0 → 16424.70, exposure_pct 0.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_trades 7 → 6 on the fixture). The accumulator hardcoded total_open_trades: 0; EndOfData means 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_pct was 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_index had 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 now Cow, 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 contiguous f64 buffer and PyReadonlyArray1 holds 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_metrics allocated 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 f64 per 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_metrics already makes, and tail_ratio selects 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::Exited now carries its Trade boxed. A Trade is 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/exited that does not directly follow an order_filled as a signal-path fill and fires on_order_filled for 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. ltq is the print's true size and becomes tick.size when present (the buy/sell flow deltas were only ever a proxy for it); bid_qty/ask_qty reach quote.bid_size/quote.ask_size and size the top-of-book level, so the queue-fill model joins behind displayed size from a quote alone, with no depth snapshot; oi rides the print as tick.oi. A size the feed did not carry stays unknown (nan), never zero. _TICK_FIELDS names 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 as PartiallyFilled for 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 with filled_qty; on_order_filled fires once per slice; one-cancels- other siblings cancel on the completing slice only. The dead FillModel.fill_ratio is 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 at t is invisible to prints before t + 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_order now 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's span_pct deposit when some short side is uncovered, the structure's intrinsic worst loss when every short is covered — plus exposure_pct on 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.
  • underlying on InstrumentSpec.option decides the group. Legs with no declared underlying group by their own symbol, i.e. never with another leg.
  • ctx.equity, ctx.cash and ctx.free_capital on 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) and contract_value × size × rate in 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_pct and exposure_pct on InstrumentSpec.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 to 0.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 as InsufficientMargin) — capital-fraction sizing landed on zero contracts because an instrument-level margin requirement (a sold option's deposit, a future's margin_init) exceeded the available capital, as distinct from ZeroSize, 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 (or 1/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_prices on run_options_backtest, legs_open_premiums on run_spread_backtest and BatchSpreadItem — optional opening-premium series, mirroring the premium series' shape (a mismatch raises ValueError). Consulted only under fill_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_close and next_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=False now 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_close mode (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_timing policy on BacktestConfig — "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_close is deprecated and maps onto the policy (True → "same_bar_close", False → "next_bar_open"); an explicit fill_timing wins over the bool.
  • The basket, pairs, options, and spread runners now honor the timing policy. They previously ignored upon_bar_close entirely 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, a KernelSession/EventSession step that carries an entry or exit signal returns no Entered/Exited event; 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_bar and FillModel::at_next_open() — dead code that was never consulted — are removed; fill_timing supersedes them.

Migration

  • Results previously produced with upon_bar_close=False are 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=True are 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_duration and avg_losing_duration are now Option<f64> (Python: float | None), and are None when their population is empty -- no winning trade, or no losing trade. Previously each returned 0.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 carried avg_loss_pct = 0.00 and avg_losing_duration = 0.00 beside a 100% win rate.

    This is the same rule 0.10.3 applied to calmar_ratio and that profit_factor already followed: a quantity with no denominator, or no population, is undefined rather than zero.

    payoff_ratio is unaffected -- it reads both averages through unwrap_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_ratio is no longer annualized. PortfolioEngine computed 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 overflow NUMERIC(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_ratio already implemented the plain ratio and nothing called it. PortfolioEngine annualized; 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.0 when max drawdown was zero, which reads as the worst possible score; the correct answer is that the ratio is undefined. They now return f64::INFINITY, which crosses to Python as None like every other undefined ratio.

Notes

  • annualization::elapsed_years and LEGACY_CALMAR_DAYS are 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_secs is no longer None on every tick backtest. The average was computed by looking up each trade's entry_idx / exit_idx in 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 carried exit_idx = 57276 against a 28,642-entry timeline. The lookup missed, every span was discarded, and the "cover every trade or report nothing" guard then correctly returned None for 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_secs and BacktestMetrics.avg_holding_period_secs (Rust), exposed as metrics.max_drawdown_duration_secs / metrics.avg_holding_period_secs and to_dict()["Max Drawdown Duration [s]"] (Python). Both are None when the run carried no usable timestamps -- "cannot say" rather than a zero that reads as a real measurement.

Fixed

  • exposure_pct could 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) and metrics.total_turnover / to_dict()["Total Turnover"] (Python): every entry leg plus every exit leg that really traded, at price * |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) and Settlement (an option left to expire) pay no exit fee and move no money. Squareoff and Liquidation are 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_order is replaced by brokerage_flat + brokerage_rate (charge per order = min(flat, rate * value) when rate > 0, else flat). indian_cost_schedule() exports the two new keys and no longer carries brokerage_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 and gross_max, each refusing with PortfolioMathError::Infeasible and 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.rs tested expiries.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_timestamps must 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_backtest now raises ValueError naming 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_backtest has 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_backtest still 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.rs computed entry_price * fees and (exit_price - entry_price) * 1.0, with size hard- coded to 1.0 on 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_result adds per-trade P&L to a real initial_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_segment reaches the tick path. tick.rs never imported a fee model -- it hand-rolled price * rate inline -- 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 uses BacktestConfig::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 through calculate instead 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_size and quantity on TickBacktestConfig and on the Python run_tick_backtest, both defaulting to 1.

  • fee_segment on the Python run_tick_backtest, matching the other strategy entry points. Trade.fee_breakdown is populated when it is set, so fee_breakdown.total() equals fees.

Changed

  • A negative quantity is refused with a ValueError rather 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_pct divides 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_contracts returns lots, and every P&L, cash and equity line multiplied back up by lot_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_fees was hard-coded to 0.0 at both trade sites. The charge was really deducted from cash, but the local binding died with the block that computed it, so fees disclosed the exit half alone — and the entry half was missing from pnl too, not just from the report. Trade documents fees == 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 through calculate_side, and fee_breakdown is populated instead of hard-coded None, so a configured fee_segment finally 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_paid reports what the run charged. It fell to a default of zero for every options backtest. This path builds its own metrics rather than finalizing a StreamingMetrics, so it now sums the trade list, as the portfolio engine does.

  • Trade.size reports 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_fees computed 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.fees disclosed the exit side alone while the curve had been debited for both. Trade already 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. Trade now carries entry_fees and exit_fees, and fees is their sum, so the two can no longer drift apart.

  • fee_segment reaches multi-leg spreads. SpreadBacktest held 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.001 against fee_segment="NFO-OPT".

  • Costs scale with a leg's quantity. Both fee functions used lot_size alone 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_paid reports what a spread run charged. The spread path never called record_fees, so the summary metric read 0.0 for 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_segment reaches four more strategy paths. options, pairs, basket and portfolio each built a flat percentage model directly, silently ignoring a configured segment.

Added

  • Trade.entry_fees and Trade.exit_fees, in Rust and Python, alongside the existing fees -- which now holds their sum and finally means what it documented. Existing readers of fees need no change, and serialized results from earlier versions still load.

  • Trade.fee_breakdown is populated for spreads. Setting fee_segment reports the itemized components per trade, summed across legs and both sides, so fee_breakdown.total() equals fees.

Changed

  • An option left to expire pays no exit-side cost. A leg exiting via ExitReason::Settlement is 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.

  • MultiStrategyBacktest drops its unused fee_model field. It was constructed, marked #[allow(dead_code)], and read by nothing; the path delegates to SingleBacktest, 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. Setting squareoff_time and leaving the offset at its 0 default is the one easy mistake — 15:29 IST then reads as 09:59 UTC and nothing fires. Unreadable values raise ValueError rather than silently disabling squareoff.

    Positions closed this way carry the new ExitReason::Squareoff ("Squareoff" from Python), distinct from EndOfData: 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 Trade or calling record_trade, so its P&L reached end_value, total_return_pct and the equity curve while trades() returned empty and total_closed_trades read 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_config no longer appears in the type stub. It was declared in _raptorbt.pyi and never existed in the engine. Callers guarding with hasattr(config, "set_session_config") took the else-branch every time, so session_aware=True was 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 existing TestStubCompleteness only checked runtime -> stub, which is why this was never caught. Verified by injection: reinstating the fictional declaration fails the guard.

Changed

  • SpreadConfig.close_at_eod is removed. It was declared, defaulted to false, hardcoded to false at both binding sites, and read by nothing — dead since it shipped. squareoff_time supersedes 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 SpreadConfig literally 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 in dir(raptorbt._raptorbt). They resolved through __getattr__ in 0.7.0, but never showed up in dir(), so they were invisible to autocomplete and to any tool that enumerates a module.

    That is not merely cosmetic. A consumer guard comparing _raptorbt.pyi against dir(_raptorbt) read the stub's alias block as 21 declarations for symbols the engine had dropped -- precisely the "type-checks clean, AttributeError in 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( and X: but not the X = Y alias form; and it matched declarations by substring, so class Foo matched class FooBar and 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 Py prefix. PyBacktestConfig is now BacktestConfig, PyTrade is Trade, PyRiskModel is RiskModel, and so on for 21 classes. The old spellings still work and emit a DeprecationWarning naming the replacement; they are removed in 0.8.0.

    The prefix was a Rust-side disambiguator -- the crate has its own BacktestConfig, Trade and BacktestResult in src/core, and two Rust types cannot share a name -- that was never stripped on the way out. BarAggregator, Indicator and InstrumentSpec already reached Python clean; this finishes the other 21. Rust struct names are unchanged.

    Deep imports keep working too: from raptorbt._raptorbt import PyX warns and resolves, because PortfolioSession had never been re-exported at top level and a deep import was the only way to reach it. It is exported properly now.

  • max_trades no 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_trades is a hard early exit, not a filter. The tick loop breaks 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_backtest string arguments are case-insensitive and closed. option_type, strike_selection and size_type used a catch-all match arm, so option_type="PUT" selected a long call -- the mirror image of the intended payoff -- while the identical string was accepted by run_spread_backtest. The same call meant two different things depending on which function you entered through. Unknown values now raise ValueError; the documented defaults are unchanged.

Fixed

  • BarAggregator ignored brick_size. The constructor accepted the argument and then called a helper that hard-coded 0.0, which resolved_brick reads as "fall back to step". Asking for 5-point Renko bricks gave you step-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 batch aggregate_bars path was always correct. Every pre-existing test used step=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 with 0.0, then returned success. On an indefinite 3-asset matrix (smallest eigenvalue -0.8) simulate_portfolio_mc returned var_95 = 0 and probability_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_code documents 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_backtest multiplied it across an entire sweep.

  • direction must be 1 or -1. Six call sites fell back to long, so a book encoded 0/1 instead of -1/1 backtested 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_mc validates its shapes. Passing an (n_obs, n_assets) matrix where a per-asset list of series was expected indexed past the end of weights inside a Rayon worker, surfacing as PanicException -- not catchable as ValueError, thrown from a thread with no user code in the traceback. It now raises a ValueError naming the mistake.

  • A test that never ran now runs. A duplicated #[test] attribute left the following function without one, so day_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 warnings passes and the library build emits no warnings, down from 89 diagnostics. Not by suppression -- 8 manual Default impls became derives, shift loops became copy_from_slice, the w'Σw quadratic form was deduplicated between the optimizer and risk contributions, and OptionType::from_str (which shadowed the FromStr trait, so "CE".parse() did not work) became from_code with a real FromStr impl beside it.

    Six #[allow]s remain, each with its reasoning in a comment. The load-bearing one is adopt_position, which guards with !(price > 0.0) rather than price <= 0.0 because 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:

  1. Renko backtests through BarAggregator were wrong and are now correct. Re-run any stored Renko results.
  2. Tick backtests that used the default max_trades were truncated and are now complete. Their numbers will change, substantially.
  3. Input that used to be guessed is now refused. If you were passing direction=0, an option-type string outside CE/CALL/C/PE/PUT/P, an unrecognised strike_selection, or a non-positive-definite correlation matrix, you will now get a ValueError naming 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_pnl computed -quantity * premium_change * lot_size. LegConfig.quantity is already signed (+1 long, -1 short), 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 loss
    

    Everything 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_loss and target_profit fire 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 called run_spread_backtest at 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 INFO log 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 INFO rather than WARNING deliberately. With no logging configured, Python's logging.lastResort prints WARNING and above to stderr; INFO stays 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=1 to 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 the unknown of 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), and net_min / net_max (bounds on sum(w) — the directional tilt; net_min = net_max = 0 is 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_exposure on PyOptimizationResult. cash remains 1 - sum(w) (net-based) and is documented as such — for a long/short book read the exposure fields, not the cash residual.

  • optimize_book as the honest name for the Rust entry point; optimize_long_only remains 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_current is accepted when short_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_pnl were 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_position now returns an error once any equity sample has been taken, raising ValueError from 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_position holds 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_position opens a ledger position with no order, no fill, no fees, no trade record and no Entered event; 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_position applies the same lend/drain pool discipline as apply_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 as TickStrategyStream(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 diffing positions() 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_ic previously 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, so n_dates_scored overstates 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 / PyRankIc gain three fields, all additive:

    • t_stat_deflated — t_stat / sqrt(horizon); the number to decide on
    • n_independent — n_dates_scored / horizon; the sample actually behind it
    • overlap_days — the window, so a stored result is self-describing

    The naive t_stat is deliberately kept, so the inflation stays auditable rather than being quietly corrected away. n_independent is 0.0 (not NaN) on a panel that scores nothing, so callers can gate on sample size without an isnan dance.

    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(), and enter(side=...). Without side, 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 explicit size_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. Carries periods_per_year and 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_portfolios runs 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. NaN means 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). PyRankIc carries 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, and indian_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_only orders route to the closing branch unconditionally and never open.
  • submit_bracket marks its stop and target legs reduce_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 direction now governs the signal path only (enter() and the signal arrays). Runs using direction= / directions= without submitting sided orders are bit-identical to 0.5.0.

Fixed

  • Every refused order counts against rejected_entries. Previously no_position, position_open, reduce_only and invalid_qty rejections 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_start fired 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 whatever on_start scheduled. Single-symbol runs are unchanged.

  • Options never settled to intrinsic value. settle_expiry called settlement_value(close, None), and the None meant 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 via ctx.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::Day expired 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_ns on PyBacktestConfig sets the offset — e.g. IST_OFFSET_NS — and defaults to 0, 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_indicator appended to the strategy's list but run_portfolio_strategy never updated anything, so .value stayed None and indicators_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 (matching run_strategy_backtest).

  • modify_order raised NotImplementedError in 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_positions was per-instrument in portfolio strategy runs. EventSession gave each instrument its own copy of the risk gate, and RiskGate is Copy, so every kernel checked the limit against its own ledger: max_positions=1 across 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 set max_positions on run_portfolio_strategy will 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::finish hardcoded rejected_entries: 0, halted: false, and halted_at: None, so a run_portfolio_strategy run could refuse entries or trip its drawdown kill-switch and still report a clean, unhalted result. All three now carry real values: rejected_entries sums the per-instrument counters (already reported correctly on per_instrument), and halted/halted_at cover both the drawdown kill-switch and the new portfolio margin call.

  • Configured slippage was ignored. PyBacktestConfig accepted slippage and BacktestConfig carried it, but PortfolioEngine::new hardcoded SlippageModel::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_backtest was never affected — it reads slippage directly and always honored it.

  • Sharpe and Sortino were computed from different quantities per runner. run_single_backtest annualized per-bar returns at 365, while run_basket_backtest, run_pairs_backtest, run_options_backtest and run_multi_backtest annualized 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 roughly sqrt(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_ratio and omega_ratio divide by a denominator that can legitimately be zero — a strategy with no losing trades has an undefined profit factor, not an infinite one. json.dumps writes float('inf') as a bare Infinity token, which is not valid JSON and which allow_nan=False rejects outright. These are now Optional[float] and return None.

  • __version__ had drifted. python/raptorbt/__init__.py hardcoded "0.4.0" while the crate was at 0.4.1, so any version check read a value that was never released. It now derives from installed package metadata.

  • cargo test could not link, so CI never ran it. pyo3's extension-module feature 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. PyPortfolioSession gains push_tick, push_bar, push_depth and remaining(): events append to the schedule tail in arrival order after seal() (idempotent — batch warmup data merges ahead of the first push), and the existing current_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 optional warmup_bars, then push_tick / push_bar / push_depth as events arrive; every strategy hook a push triggers fires before it returns. finish() closes out and computes metrics. Shares its dispatch loop with run_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 via primary_bars, which remain a view.

  • Four deferred execution knobs, all default-off:

    limit_slippage applies an adverse adjustment to limit fills, which previously always printed exactly at the limit. It is suppressed when queue_fill_model granted 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_call force-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 new ExitReason::Liquidation.

    InstrumentSpec.settlement_fee charges a fee on the settled notional at expiry. It sits alongside maker_fee/taker_fee rather 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_price lets 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>". New on_algo_started / on_algo_completed hooks 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: idx is 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. Pass every_bars with bar_ns for 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 units can be sliced — size_frac resolves 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 AggregationUnit now builds; none returns Unimplemented.

    "renko" emits a brick per full brick-height price move, ignoring time and volume entirely. Height comes from a new brick_size argument (BarAggregator, aggregate_bars, bars_from_ticks, subscribe_bars), falling back to step read as whole price units. Because one move can complete several bricks, BarBuilder gains next_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}_imbalance and {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 is step, fixed rather than the literature's adaptive EWMA: deterministic, reproducible, and consistent with how step already reads for tick/volume/value bars.

    Direction comes from the feed when known — TradeTick and SourceRecord gain a signed field, populated from the buy/sell quantity deltas that tick_data_to_events previously summed away. The unsigned size is 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. OrderBook tracks the visible book from quotes (L1) or depth snapshots (L2, five levels), exposed to strategies through a new on_order_book(ctx, book) hook, ctx.book, and a depth= argument to run_tick_strategy.

    With queue_fill_model=True (opt-in, default off), resting limits fill from observed queue position rather than fill_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_limit rather 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. New on_trade_tick(ctx, tick) and on_quote(ctx, quote) hooks, TradeTick/QuoteTick payloads, and ctx.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_quote rests and matches on the next print.
    • ctx.best_bid/ctx.best_ask inside on_trade_tick are the book observed before that print — the quote from the same feed row arrives in the following on_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 fire on_bar and feed indicators, but nothing executes on them. Orders match against ticks only.

    AT_OPEN/AT_CLOSE market 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.

  • Per-symbol indicators and composite bars in portfolio runs. register_indicator(indicator, stream_id=None, symbol=None) gains symbol= to route an indicator to one instrument, and register_indicators(factory, symbols) builds one per symbol. One subscribe_bars declaration now yields one aggregated stream per symbol, each built only from that symbol's bars, and CompositeBar gains a trailing symbol field (None outside portfolio runs) naming the instrument that completed it. A symbol's composite bar dispatches before that symbol's on_bar which 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_strategy accepts account_type="margin" and leverage, 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-symbol margin_maint rates apply rather than one blended rate; a breach fires on_margin_call once and halts new entries on every instrument, including symbols that never traded. PyPortfolioSession gains free_capital() and is_halted(). Cash-account runs are unchanged and remain pinned by the golden fixtures.

    Note: in portfolio runs halted_at is a schedule-event ordinal, since the session interleaves N instrument streams; the array runners' halted_at remains a bar index.

  • Portfolio drawdown halts now record halted_at on 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 second MarginCall event — halts are latch-once.

  • run_portfolio_backtest — simulates N instruments against one cash pool, with max_positions and 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_positions and max_drawdown_pct on PyBacktestConfig, 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"] equals trade.fees, and their sum equals metrics.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_minutes with exported constants SESSION_NSE (375), SESSION_MCX (870), SESSION_CDS (480) and SESSION_CONTINUOUS (24×7). Intraday annualization scales with session length, so assuming NSE hours on MCX data understates Sharpe by sqrt(870/375) ≈ 1.52×.

  • periods_per_year to override annualization explicitly, and risk_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 in tests/python/test_strategy.py). ctx exposes the OHLCV arrays, current index/bar, position snapshot, equity/cash, and programmatic set_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 via on_order_rejected with reject_reason="ZeroSize". Array-runner results are unchanged — the batch path ignores rejection events.

    Rust/PyO3 surface: PyKernelSession (per-bar step driving the engine with scalar inputs), PyEngineEvent, PyPositionSnapshot, resolve_atr_period, kernel set_stop_price/set_target_price/ position_snapshot, and StepInput.stop_price_override/ target_price_override for 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.pyi and a py.typed marker now ship in the wheel. The Typing :: Typed classifier was previously inaccurate.

Changed

  • PortfolioContext position state matches StrategyContext. position, positions reads for the current symbol, plus is_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_metrics gained a timestamps parameter, and compute_backtest_metrics_with_config was added for callers that have a full config.
  • PositionManager::close_position takes an ExitDetails struct.
  • StepInput gained stop_price_override and target_price_override fields. 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 referenced raptorbt::signals::expression:: directly.
  • The checked-in _raptorbt.cpython-311-darwin.so build artifact and libraptorbt.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
  • Pin raptorbt>=0.5.0,<0.6.0. A floating >= lower bound means an unattended upgrade silently picks up behavior changes.
  • Pass session_minutes for MCX and CDS strategies.
  • Replace hasattr feature-sniffing with a __version__ check; it is now accurate.

[0.4.1] - earlier

Releases before 0.5.0 were tracked in commit messages only.