mirror of
https://github.com/alphabench/raptorbt.git
synced 2026-11-02 07:56:12 +00:00
main
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
a7f680c3dc |
feat(orders): report every order placed, including the ones that never filled
A result could always say which trades happened. It could not say which trades were attempted and did not happen — 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 was indistinguishable from one whose idea was wrong. `BacktestResult.orders()` is one record per order, in submission order: identity and symbol, side, kind, time-in-force and final status; units requested against units filled; a size-weighted average fill price with the number of 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 the event stream. An order that rested and expired emits neither a fill nor a rejection, so an event-sourced log would omit exactly the case someone is asking about. Events supply what the book cannot know — the price and size of each fill slice, and the reason for a refusal — and the book supplies everything else. Absent and zero stay distinct, as they do for excursions. `requested_qty` is None when the order named a capital fraction rather than a number, because the size is not known until the engine prices it and 0.0 would read as "asked for nothing". `filled_qty` is 0.0 for an unfilled order, which is a measurement, while `avg_fill_price` is None, because no price exists. Partial fills fold slice by slice into a size-weighted mean: an order that took 90 units at 100 and 10 at 130 paid 103, and reporting the last price would misstate every partial fill. The seed is zeroed once at creation, not per slice — doing it per slice would keep only the final fill, which is the exact case this record exists to describe. Also BREAKING, in string values: every refusal now reports one name. `RejectReason::as_str()` has always been documented as the stable identifier for reporting, and order-level refusals used it, but entry-level refusals were Debug-formatted. The same cause therefore arrived as "MaxPositions" on one event and "max_positions" on another, so a caller counting refusals by reason split one cause across two keys and undercounted both. Both paths now use `as_str()`. Two tests here had already worked around the split by accepting either spelling or lower-casing before comparing; both now assert the single value. Verified through the wheel (tests/python/test_order_log.py): a filled order reports what it actually filled, an unreachable limit still appears with filled_qty 0.0 and no fill price, submission order is preserved, and a run placing no typed orders reports an empty log. What a user sees: asking why a strategy underperformed can be answered with "it never got in" — and with the reason — instead of blaming a signal that was never given a chance. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
c911122c03 | Merge remote-tracking branch 'origin/main' | ||
|
|
fa79ffcad3 |
docs: record the spread metrics fix and what it changes
The changelog listed the spread path's dropped metrics as known-and-not-fixed, with an estimate of what fixing it would cost. It is fixed, and the estimate was wrong: Sharpe moves by 19.365x, not the 45.7x first guessed. That earlier figure assumed continuous-market annualization; the default is a session spec, so the real factor is sqrt(94500/252) where 94,500 is 375 NSE session minutes times 252 sessions. Now states the before and after for every metric that moved, and separately records the equity-curve fix -- the final sample used to stop one exit fee short of the account, so integrating the curve disagreed with the reported return. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
b323a057d7 |
fix(spreads): report the metrics the run already had the data for
A spread backtest returned a result that contradicted itself. Its own drawdown curve had 250 of 300 samples underwater while `time_under_water_pct` read 0.0; it carried seven trades while `total_turnover` and `exposure_pct` read 0.0. Those were never "not measured". The runner built a real drawdown curve and a real trade list, put both into the `BacktestResult` it returned, and then computed its metrics from the return series alone -- `StreamingMetrics:: finalize` takes only `returns`. The data was sitting in the same expression that discarded it. It now calls `compute_backtest_metrics_with_config`, the estimator every other runner shares, with the curves and trades it already built. Three things change, all of them corrections: **Drawdown and trade figures become real.** `ulcer_index` 0.0 -> 0.0826886, `time_under_water_pct` 0.0 -> 83.333333 (matching 250/300 samples), `avg_drawdown_pct` None -> 0.0809098, `total_turnover` 0.0 -> 16424.70, `exposure_pct` 0.0 -> 38.0. Each was hand-computed from the returned curve and trade list and matches the engine to 1e-9. **Sharpe and Sortino are annualized from the timestamps.** The accumulator hardcodes 252 periods/year, which assumes one bar is one trading day; these fixtures are minute bars, so each minute was annualized as a day. Sharpe moves -0.139244 -> -2.696442 on both variants, a factor of 19.365 -- exactly sqrt(94500/252), where 94,500 is 375 NSE session minutes x 252 sessions, the default session spec. The magnitude was verified against the arithmetic rather than assumed: an earlier estimate of 45.7x assumed continuous-market annualization and was wrong. **A position open at the end is now counted as open.** The accumulator hardcoded `total_open_trades: 0` and called every trade closed; `EndOfData` means the position never traded out, and every other runner reports it open. `total_closed_trades` 7 -> 6 on the fixture, and `a_position_open_at_the_end_is_still_recorded_as_a_trade` now pins the engine-wide convention, including that `open_trade_pnl` carries the trade's P&L. Also fixes the equity curve itself. The final sample was pushed before the end-of-data close, so it omitted that close's exit fee -- the curve said the account held 99,954.93 where it held 99,953.68. The final value was passed to the metrics separately as `cash`, so the reported return was right while the curve backing it was not, and anyone integrating the curve got a different answer from `total_return_pct`. The last sample is overwritten rather than appended, keeping the curve the same length as the bar series. Its drawdown is recomputed against the running peak directly rather than through `update_equity`, which would advance `bars_since_peak` twice for one bar. Fixtures regenerated deliberately. Only the two spread digests changed, and within them only sharpe, sortino and the last equity sample -- by exactly 1.2567878, the omitted exit fee. `total_return_pct`, `max_drawdown_pct`, `omega` and every trade record are byte-identical: no P&L moved. The other 20 fixtures are untouched. 561 Rust and 478 Python tests green, no warnings. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
0a1b5f769d |
docs: re-measure every published figure for 0.13.2
The performance numbers in README.md were carried over from 0.7.0 and stamped 0.4.0 in one table, and this release changed most of them. Everything is re-measured on the harness in `benches/` against the built wheel. Corrected because they were wrong, not merely old: - The headline claimed "<1 MB compiled engine". It is 1.75 MB, and was 1.59 MB when that line was written. - The Overview table was stamped 0.4.0 and claimed ~0.03 ms at 1K bars against a real ~0.075 ms -- optimistic by roughly 2.5x across every row. - The Verification Test printed `Sharpe Ratio: -0.9086` as a comment while the snippet produced -3,580,919.85. Its `np.arange(n)` timestamps are 500 *nanoseconds* of history, and annualization is derived from elapsed wall-clock time, so the figure exploded. Spacing the bars a day apart reproduces both documented values exactly. Anyone who copied that snippet as a template has been reading nonsense risk metrics. Updated for this release: 48 metrics / 31 in `to_dict()`; engine 1.75 MB; bar timings across all seven sizes; ticks 107-221M/sec; spreads 40,817/sec at 7.6x; sweep 1.51 s and 56 MB. New sections. **Memory** documents that a run's peak is now roughly its input plus its curves rather than twice its input (25M bars over a 1.25 GB input: 2.16 GB -> 1.13 GB), and states the two consequences a caller can actually hit -- arrays must be C-contiguous, and the tick and spread-premium paths still copy. **Diagnostics** documents the ten new metrics by the decision each one changes, with the conventions spelled out, because a silent disagreement with another library is the failure mode. `batch_single_backtest` and `BatchSingleItem` get full API entries. Two claims are deliberately softened rather than dropped. The banner no longer leads with "sub-millisecond backtests" -- true at 1K and 10K bars, but 50K is ~3.85 ms -- and the throughput prose gives the measured 12-13M band instead of asserting flatness. The tick range's ten-million row is called out as the one figure here that varies by more than a few percent between runs. The changelog gains a Performance section stating what each change bought, and records the spread-path defect under Fixed as known-and-not-fixed, with what fixing it would cost. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
837482ca33 |
perf(bindings): read NumPy buffers instead of duplicating them
Every backtest copied its price data before running. A NumPy array is already
a contiguous f64 buffer, and `PyReadonlyArray1` holds the GIL and a read lock
on it for the whole call, so the copy bought nothing and cost a full duplicate
of the input -- which is why peak memory was roughly twice the input size.
`OhlcvData`'s six series are now `Cow`, so one type covers data the engine
owns and data it is borrowing for the duration of a call. The six pyfunctions
that build one -- single, basket, portfolio, options, pairs, multi -- borrow;
everything else keeps passing owned Vecs and is unaffected.
Measured on 25 million bars, a 1.25 GB input:
peak RSS 2158 MB -> 1134 MB extra (1,024 MB less, ~= the input size)
runtime 2432 ms -> 1915 ms (21% faster)
Smaller runs are unchanged within noise (1K 0.072 -> 0.075 ms, 93,750 7.17 ->
7.20 ms), which is expected: the copy was never a large share of a small run.
1.875M bars improves from 165.1 to 159.4 ms. Trade counts are identical at
every size.
The borrow is sound because nothing in the crate outlives the call that built
it: no struct holds an `OhlcvData` as a field, none is returned or moved
across a thread boundary, and no OHLCV-carrying path crosses `allow_threads`.
The batch runner borrows the shared series into its Rayon closure, which is
the one place a lifetime actually has to be reasoned about, and it holds
because the guards live in the enclosing function.
Basket and portfolio switch from `into_iter` to `iter` for the same reason:
consuming the vector would drop each numpy guard at the end of its own closure
body, leaving nothing to borrow from.
Tests that mutate a fixture in place now go through `Cow::to_mut`, which is
copy-on-write and therefore does exactly what the old `Vec` did. Test helpers
that hand back an owned series say `OhlcvData<'static>`.
Deliberately NOT converted, and not claimed: the tick path (`TickData`, ten
arrays) and the spread path's premium arrays still copy. They are the same fix
and would be worth the same again on a large tick run; they are separate types
and belong in their own change rather than being folded in here.
`numpy_bridge`'s module doc has claimed "zero-copy numpy array interface"
since it was written, while every helper in it called `to_vec()`. It is now
true for the two that matter.
Golden corpus byte-identical; 561 Rust and 478 Python tests green, no warnings.
Co-authored-by: Pier Code <no-reply@piercode.com>
|
||
|
|
d89fa59679 |
feat(metrics): report whether an edge is real, and what is eating it
A Sharpe ratio cannot tell a genuine edge from a strategy collecting pennies in front of a steamroller, and it says nothing about why a losing strategy loses. Ten fields do, each tied to a decision someone -- or some agent searching strategy space -- actually has to make. Is the edge real? `return_skew`, `return_kurtosis` and `tail_ratio` describe the shape Sharpe assumes away. High Sharpe with strongly negative skew is the signature of a short-vol payoff; fat tails mean the observed max drawdown understates what the strategy can lose. Do costs eat it? `cost_to_gross_profit_pct` is the overfit tell -- an edge handing most of its gross profit to brokerage is one slippage assumption from unprofitable. `breakeven_cost_multiple` says how many times current costs the run could absorb before net P&L reaches zero, which is 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: winning most trades while winning few bars means losers are being held. `avg_drawdown_pct` separates one bad week (18% max, 2% average) from chronic pain (18% and 11%), which are the same headline risk and opposite sizing decisions. Where should the stop go? 0.13.2 added per-trade MAE/MFE and aggregated them nowhere. `avg_mae_pnl` says how far the average trade goes against you before it works -- a stop tighter than that kills trades that would have won. `mfe_capture_ratio` says what share of the favourable move the exits keep; 0.35 points at the exit rule rather than the entry signal. Conventions are pinned in the doc comments and tested against scipy, because 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 as scipy's 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. Every field is Option. The struct already derives Default and five runner construction sites end in `..Default::default()`, so paths that cannot compute a figure report an honest None with no code change -- rather than repeating the `ulcer_index: 0.0` compromise a bare f64 forced. None means NOT MEASURED, never a measured zero, and 0.0 is a legitimate value for most of these. `mfe_capture_ratio` carries three restrictions, each load-bearing: a ratio of sums (not a mean of per-trade ratios, where one near-zero MFE dominates), 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. `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. Cost was measured and then removed. The first cut ran 210 ms against 0.13.1's 168 ms at 1.875M bars. tail_ratio sorted a full copy of the return series to read four order statistics -- now `select_nth_unstable`, O(n) instead of O(n log n), verified identical to NumPy percentiles across 40 randomised runs. Skew and kurtosis needed the same mean and NaN filter `risk_metrics` had just computed, so their central moments now ride that existing loop instead of walking a million-element array twice more. Net: ~168 ms against 0.13.1's ~166 ms, within noise. `to_dict()` gains three keys (28 -> 31) and stays a curated subset, not a mirror: the cost gate, the exit-quality figure, the drawdown texture. The other seven are attributes. Also documents a defect rather than papering over it. The spread path builds its metrics through `StreamingMetrics::finalize`, which receives only the return series -- while its caller holds a real drawdown curve and a real trade list and puts both in the result it returns. So a spread result can report 379 underwater samples in its own drawdown_curve while time_under_water_pct reads 0.0, and carry three trades while total_turnover reads 0.0. That predates this release (total_turnover and exposure_pct are 0.0 on 0.13.1 too). The current behaviour is now pinned by a test whose comments say plainly that it records a bug, so a fix has a baseline; fixing it means routing that path through the shared estimator, which would also stop annualizing minute bars at 252 and so changes published Sharpe -- its own commit and its own fixture regeneration. Golden corpus byte-identical: the digest reads five keys by name, so added fields cannot move it, which is the proof this is purely additive. 561 Rust and 478 Python tests green, zero warnings. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
507d8971cb |
feat(batch): run a parameter sweep across cores instead of down a loop
Trying many parameter sets against one price series is the most common thing anyone does with a backtester, and until now it ran on a single core. The engine already had a parallel batch path for spreads and another for portfolio optimization; the ordinary single-instrument backtest had none, so a sweep was a Python `for` loop calling `run_single_backtest` N times, each call re-converting the same six unchanged price arrays. `batch_single_backtest` takes the price series once and a list of `BatchSingleItem`s, converts the shared arrays under the GIL, then releases it and runs the items on Rayon. Each item carries its own signals and may override the batch config, direction, symbol, position sizes and instrument config; items without an override inherit the batch config. Measured on 184 SMA-crossover combinations over 93,750 bars, ten cores: 1173 ms serial, 167 ms batched -- 7.03x. A 184-combo sweep also stops copying the same 4.5 MB of price data 184 times. The results are bit-identical to the serial loop, and that is the point rather than a bonus: a sweep whose numbers shifted with the thread count would make comparing two parameter sets meaningless. `test_batch_single.py` checks equity curves, drawdown curves, return series, every field of every trade, and thirteen metrics across a whole sweep, plus that two runs of the same batch agree and that input order is preserved. All 184 items in the benchmark above match serial exactly. Bad items are refused before any worker starts, naming themselves: a length mismatch or an invalid direction raises ValueError identifying the item. A panic inside a Rayon worker crosses PyO3 as PanicException, which a caller can neither catch as ValueError nor trace back to the argument at fault. Also corrects a pre-existing stub error found while writing the new one: `batch_spread_backtest` returns (strategy_id, result) pairs but was declared `list[BacktestResult]`. Indexing that as a result raises AttributeError on the first `.metrics`. Both batch functions now carry fully typed stubs modelled on `batch_optimize_portfolios` rather than `*args: Any`. Golden corpus byte-identical. 561 Rust and 463 Python tests green. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
294febeb6a |
perf(metrics): compute Sharpe, Sortino and Omega without copying the returns
`risk_metrics` allocated two full copies of the per-bar return series to produce three scalars: one to drop NaNs, and a second to hold just the negative returns. On a 1.875-million-bar run that is 30 MB of transient heap, and the filtered series was then walked five more times. Both allocations are gone. NaN is skipped inline, and one pass accumulates everything the three ratios need -- the deviation sum for the variance, the downside sum of squares for Sortino, and the signed sums for Omega. Two passes over the slice, not one, and that is deliberate: the variance is taken about a known mean, so folding it into a single sum-of-squares pass would change the floating-point result. The point of this change is that it does not. The arithmetic is otherwise untouched, including the parts that look like mistakes and are not: sample variance divides by n-1, downside variance divides by the total count rather than the count of downside returns, and the `len < 2` short-circuit still returns (0.0, 0.0, 1.0). Summation order is preserved, so results are bit-identical rather than merely close. Measured on 1.875M bars, best of 20, two independent repeats: 169.7 -> 151.5 ms and 167.9 -> 145.6 ms against 0.13.1. Also 0.749 -> 0.686 ms at 10K bars and 7.28 -> 6.54 ms at 93,750. Roughly a tenth of total run time, on work that never touches a bar. The golden corpus is byte-identical across this change. That is load-bearing here: Sortino and Omega were pinned one commit ago precisely so this refactor could not alter either branch unnoticed, and both were exercised by it. 561 Rust and 455 Python tests green. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
f5c3f7ae88 |
perf(engine): stop allocating an ATR array no stop asked for
A backtest with no ATR-based stop or target still allocated and zero-filled one f64 per bar to hold ATR values, so that the bar loop could read 0.0 out of it. On a 25-million-bar run that is a 200 MB array written once and never read; on a shared-capital portfolio it was one such array per instrument. `atr_values` is now `Option<Vec<f64>>` and the bar loop reads `map_or(0.0, ...)`, which is the same 0.0 the zero-fill was there to supply. Both precomputes move together on purpose. `PortfolioEngine` has one and `PortfolioBacktest::precompute_atr` has another, and `test_class_matches_array_atr_stop` pins them against each other -- changing one alone would have failed it. Behaviour is deliberately untouched: the condition that decides whether ATR is needed, the period fallback (stop period, else target period, else 14), and the `unwrap_or_else(|_| vec![0.0; n])` fallback on a failed ATR are all as they were. That last one matters -- a failed ATR yields zeros rather than aborting the run, and the golden corpus encodes it. Measured, in a fresh process on a no-ATR config at 8M bars: peak RSS 1351 MB -> 1301 MB, against a 61 MB array avoided. Time is unchanged within noise (1K 0.072 -> 0.074 ms, 10K 0.744 -> 0.746 ms, 1.875M 167.5 -> 168.6 ms) -- 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. All 21 golden digests byte-identical; 561 Rust and 455 Python tests green. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
6134057948 |
test(golden): pin Sortino and Omega, and the ATR-target-only path
Two metrics were shipping without a single assertion behind them, and one branch of the ATR precompute had never been executed by a test. `omega_ratio` was asserted nowhere in the repo -- not in the golden digest, not in the class-vs-array equivalence loop, 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. A refactor isolated to either branch would have passed the entire suite. Both now ride the golden digest, so they are pinned bit-exactly (float hex) across all eleven single-path variants and the multi-leg corpus, on the same gate as `sharpe`. Shown to fail by injection before counting: perturbing the sortino branch alone by 1e-7 relative fails 18 fixtures with "sortino diverged from golden baseline"; the same perturbation to the omega branch fails with "omega diverged". Before this commit, both perturbations passed. The new `atr_target_fixed_stop` variant closes the other gap. ATR is precomputed when EITHER the stop or the target is ATR-based, and the target also supplies the period when the stop is not -- but the existing `atr_stop_rr_target` variant reaches that code only through `StopConfig::Atr`, pairing it with a risk-reward target. Nothing exercised `TargetConfig::Atr` on its own, so the second limb of the condition and its period fallback were uncovered. Fixtures regenerated deliberately, as the corpus requires. Every pre-existing value is byte-identical -- the diff is exactly two added keys per digest plus the one new variant; inputs and `portfolio/shared_pool` (which uses its own inline digest) are untouched. No engine code changed in this commit. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
ac3c5a0b0a |
feat(metrics): report how long a run spent losing, not only how deep
Max drawdown is a single order statistic. An 8% dip lasting a week and an 8% dip lasting a year report the same number, and the second is the one people abandon. Two fields close that gap. `ulcer_index` is the root mean square of the drawdown curve, in the same percentage points as `max_drawdown_pct` and never larger than it: every shortfall weighted by how long it persisted. `time_under_water_pct` is the share of equity samples below the running high-water mark — persistence with depth discarded, the companion to `exposure_pct`. Both fold the SAME streamed drawdown curve that `max_drawdown_pct` folds, so all three describe one curve. The pre-existing `metrics::drawdown:: ulcer_index` helper rebuilds a curve seeded from the first equity sample rather than from initial capital; the two disagree whenever a run opens away from its funding level, and a caller would see an ulcer describing a curve no other reported figure describes. The fold is inline and commented so a later cleanup cannot quietly undo it — pinned by `ulcer_describes_the_same_curve_as_max_drawdown`. `StreamingMetrics::finalize` reports 0.0 for both: that accumulator keeps a running peak but no drawdown history, matching the existing convention for `exposure_pct` and `total_turnover`. Every path that produces a drawdown curve gets real figures. 38 metric fields now, 28 in `to_dict()`. Also fixed, all pre-existing: - Six type-stub mismatches. Nothing checks `_raptorbt.pyi` against the bindings. `avg_win_pct`, `avg_loss_pct` and the two duration averages are `Option<f64>` but were declared bare `float` — the stub promised 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 the three curve accessors 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. - `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, roughly doubling it on a mostly-flat curve. - README metric counts read 33/24 from a release that added fields without updating them. Now 38/28, checked against the built wheel. `drawdown_periods` overstates time under water: `dd_start` is pinned to `peak_idx`, which only advances on a strictly new high, so a high-water plateau is folded into the following drawdown — `[100,110,110,110,105,120]` has one underwater sample and reports a span of four. That is why `time_under_water_pct` counts samples off the streamed curve instead. The current behaviour is now pinned by a test so a fix has a baseline; fixing it changes a public function's return values and needs its own caller audit. Golden fixtures deliberately NOT regenerated: `result_digest` reads five keys by named attribute access, so added fields cannot move it. The golden gate passing untouched is the proof this change is purely additive. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
afd97657c7 |
feat(metrics): report maximum adverse and favourable excursion per trade
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. MAE and MFE do. `Trade` gains `mae_pnl` / `mfe_pnl` and the prices at those points, `mae_price` / `mfe_price`. They are derived from `highest_since_entry` / `lowest_since_entry`, watermarks the engine already maintained for trailing stops, so nothing new happens in the bar loop. One shared definition, `Position::excursions`, serves both the per-symbol manager and the multi-position ledger so the two cannot drift into reporting excursions differently. Invariants, pinned by tests: `mae_pnl <= 0 <= mfe_pnl`, both in the same money and on the same contract multiplier as `pnl` and before costs, and gross P&L always lands inside the band the trade travelled -- `mae_pnl <= (pnl + fees) <= mfe_pnl`. Direction decides which watermark hurt, so a short takes its adverse reading from the high; a sign error there would report a winning short as a loss, which is why both directions are tested separately. 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 limit is stated in the field docs, the README and the changelog rather than left for a caller to discover -- reconstructing intra-bar order from an OHLC window cannot know whether the high or the low came first, and a confident claim built on that would be a guess wearing a measurement's clothes. Paths that synthesize a trade instead of closing a tracked position (spread, basket and pairs legs) report `None`, which 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. `EngineEvent::Exited` now boxes its `Trade`. The struct grew by four optional floats and tripped clippy's large-variant threshold: a `Trade` is by far the biggest thing any variant carries, and unboxed it made every event in every queue pay that width. Rust callers matching the variant and passing the trade by value need `*trade`; field access is unchanged. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
51777c3686 |
Merge pull request #31 from alphabench/test/fill-reported-once
Pin that a bracket target's fill is reported once |
||
|
|
2c5e90bd46 |
Pin that a bracket target's fill is reported once
The regression 0.13.1 fixed shipped without a test. The shape that double-fired is a bracket: one-triggers-other children linked one-cancels-other. When the target fills, its sibling's cancel is emitted in the same step; 0.13.0 emitted that cancel between the fill event and the position event, and the runner then read the position event as a signal-path fill and fired on_order_filled again — four calls for two fills, each phantom carrying no client id. Attached stop_price / target_price levels never show it: they close the position without an order event in between, which is why a sweep over attached levels found nothing. Verified against the published wheels: fails on 0.13.0 (4 == 2), passes on 0.13.1 and on this checkout. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
b6381462d8 |
Merge pull request #30 from alphabench/feat/tick-path-realism
Report a typed order's fill once (0.13.1) |
||
|
|
bf154923fe |
Report a typed order's fill once (0.13.1)
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. The partial-fill work moved a fill's contingencies (bracket children accepted, one-cancels-other siblings canceled) between the fill event and the position event it caused. The Python runner treats an entered/exited event that does not directly follow an order_filled as a signal-path fill and fires on_order_filled for it again. record_fill now takes the position event and emits it right behind the fill; contingencies follow. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
3e53ce1583 |
Merge pull request #29 from alphabench/feat/tick-path-realism
Tick-path realism: sizes, queue on quotes, partial fills, latency (0.13.0) |
||
|
|
24951f282a |
Release the tick-path realism work as 0.13.0
One version for the whole set: print size, L1 sizes and open interest on the tick path; a queue that follows the touch; partial fills; order-entry latency; and the cross-instrument market-order fix. Every new behaviour is off by default and switched on by the caller, so existing backtests are unchanged. The CHANGELOG entry leads with the plain-words summary. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
8b89997616 |
Model order-entry latency on the tick path
In plain words: an order placed on one print used to be able to fill on that very print, as if it had reached the exchange instantly. With order_latency_ns set, an order placed at time t is invisible to every print before t + latency — it neither matches, expires, nor burns an IOC/FOK evaluation until it has arrived — and fills on the first print after that. The fill is known when the tape reaches it; the response leg is not modelled. Bars are unaffected, and the default of 0 keeps the same-print behaviour. The gate sits in the matcher beside the DAY-expiry clock that already reads submitted_ts, and in the kernel's market sweep, so limits, stops and plain market orders all wait the same way. Tests: a marketable limit and a market order placed at t=0 do not fill on a 100 ms print and fill on a 300 ms print with 250 ms latency (kernel_partial_tests); the same through the wheel. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
736126c355 |
Fill typed orders across prints on the tick path
In plain words: a 100-lot order used to fill whole on the first print that touched its price, however small that print was — a one-lot print filled a hundred lots. With partial_fills on, a fill takes at most what printed, the order keeps working for the rest, and the later slices of an opening order add to the position it opened (one position at the size-weighted average entry) instead of being refused as a second entry. Exits reduce the position slice by slice, each slice booking its own trade at the average entry with entry costs prorated, and an exit larger than the position closes what is held and cancels the surplus — it never reverses you. Order gains filled_qty and a PartiallyFilled state (non-terminal; a partly filled stop-limit keeps matching at its limit). The ledger gains add_to_position (a stop or target derived from the entry price moves with the average; explicit levels stay) and reduce_position; the kernel gains reduce_at and add_at, and remembers which order opened a position so netting exempts that order's later slices. IOC keeps one slice and cancels the rest; FOK takes nothing from a print too small for the whole order; DAY expiry expires the remainder and keeps what filled; one-cancels-other siblings cancel on the completing slice only, so a bracket's protective leg keeps working while its partner is partly filled. Capital-fraction orders, close-all, the signal path and bar events fill whole as before. The flag defaults off. FillModel.fill_ratio, dead scaffolding, is gone. PortfolioSession.working_orders / ctx.open_orders() list what is still working with filled_qty and remaining. The flag is an attribute of BacktestConfig, so a caller can detect the capability. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
721e102a0b |
Let a top-of-book quote shorten a resting order's queue
In plain words: an order resting at the best bid used to learn about the queue ahead of it only from prints at its price. Now a quote can tighten that estimate too — if the feed shows fewer displayed at our price than we believe are ahead of us, at most that many are; and if the bid drops below our price, we are alone at the top and nothing is ahead. A quote can only ever shorten the queue, never lengthen it: size that traded ahead of you never un-trades. QueueTracker::on_quote applies both rules; the kernel calls it from step_quote when queue_fill_model is on. A touch beyond the order's price says nothing about its level and leaves the estimate alone; an unsized quote is ignored. Quotes remain observation only — nothing fills on one. Tests: the cap, the never-lengthen rule, the alone-at-the-top rule (queue.rs); a 100 print behind 300 displayed rests, a quote showing 50 lets the next 60 print fill (kernel_tests). Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
555a9e391a |
Carry print size, L1 sizes and open interest on the tick path
In plain words: a tick used to reach the engine with its price but not its true size — the size was rebuilt from buy/sell flow deltas, a proxy — and a quote reached it with prices but no sizes, so the queue-fill model could only work from a depth snapshot. Now a feed can hand over the print's own traded quantity, the displayed size at the best bid and ask, and the open interest, and each lands where a strategy and the fill model read it. Tick input takes four optional arrays/keywords — ltq, bid_qty, ask_qty, oi — zero-filled when absent. ltq becomes tick.size when present (else the flow-delta proxy stands, unchanged); bid_qty/ask_qty reach quote.bid_size/quote.ask_size and size the top-of-book level, so an order resting at the touch joins behind displayed size from a quote alone; oi rides the print as tick.oi. A size the feed did not carry stays unknown (NaN), never zero, so a quote-only book still falls back rather than guessing. _TICK_FIELDS names the arrays: a caller can detect the capability instead of trusting a version string. Tests: the conversion splits ltq/proxy and NaN/size rows; a sized quote seeds the tracker; the kernel joins behind a 300-lot quote and fills after 100 + 250 print through (kernel_tests); the same through the wheel (tests/python/test_tick_strategy.py). Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
2f4687b76a |
Fill market orders routed across instruments on the tick path
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 (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. 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::submission_idx 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. Bar semantics are unchanged: a bar still sweeps only its own submissions. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
d86f0335b2 |
Release the position-group margin as 0.12.1, not 0.13.0
The group margin landed on top of the 0.12.0 deposit model within hours and neither had shipped, so it belongs to the same minor line as a patch release. This folds the two unreleased entries into one 0.12.1 changelog entry (the group rule, the bit-identical rate-path fix) and sets every manifest and lock to 0.12.1. No code changes. Co-authored-by: Pier Code <no-reply@piercode.com> Claude-Session: https://claude.ai/code/session_01UTk6EwhdGGgohtch1GTuuX |
||
|
|
cf9301bbbc |
Margin sold options that hedge each other as one group (0.13.0)
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 gathers open, deposit-modelled option legs by underlying and expiry after every applied event and locks the group's requirement across its sold legs: a scenario component charged once (the largest sold leg's span deposit when some short side is uncovered, the structure's intrinsic worst loss when every short is covered), plus exposure on each sold leg's notional, less the net premium a multi-leg 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. A lone sold leg keeps its full deposit, as the broker's single-order figure does. Maintenance follows the group, 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 lands once the leg is on. That is the conservative order and it is deliberate. Also: the portfolio strategy context gains equity, cash and free_capital, matching the single-strategy context. Co-authored-by: Pier Code <no-reply@piercode.com> Claude-Session: https://claude.ai/code/session_01UTk6EwhdGGgohtch1GTuuX |
||
|
|
6b72810df8 |
Keep the leverage-rate margin path bit-identical (0.12.1)
In plain words: 0.12.0 rewrote how a margin account sizes an entry so the new sold-option deposit could share the arithmetic. That reordered one multiplication on the ordinary leverage path and moved position sizes by the last decimal place. A person would never see it, but a parity check that compares a cash run and a margin run bit-for-bit did. The rate path now evaluates `contract_value × (rate + fee)` and `contract_value × size × rate` in the 0.11 order again, and a bit-exact test pins that. Funding is an explicit enum: the rate path, or a modelled deposit for a sold option, which alone takes the new arithmetic. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
fd58f2554f |
Reserve an exchange-style deposit on sold options (0.12.0)
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 charged 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: `InstrumentSpec.option(..., span_pct=, exposure_pct=)` reserves `(span_pct + exposure_pct) × strike × multiplier` per contract for a SHORT entry. Sizing, the locked initial margin and the maintenance requirement all read the same figure, so a margin call fires when equity falls below the deposit, the broker's rule. The premium collected stays in the balance. Both percentages default to 0.0, which keeps every existing result and the golden corpus byte-identical; bought options keep the account's rate path unchanged. A sized entry that lands on zero contracts because an instrument-level margin requirement (the deposit, or a future's `margin_init`) exceeded the capital now reports `InsufficientMargin` instead of `ZeroSize`, so the reason can say which floor it was: the lot's notional may well have fit, and "loosen the entry" is the wrong advice when the strategy was never reached. Tests: kernel tests pin the deposit's size, lock, maintenance and refusal paths plus the zero-default behaviour and the futures rate path; a Python suite pins the same through `run_portfolio_strategy`. Rust and Python suites pass in full. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
1654d65ff6 |
Fill premium-only runners at real opens, pin 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 value 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. And the four multi-leg runners (basket, pairs, options, spread), whose numbers were previously outside the bit-exact golden gate entirely, are now pinned in both timings. - run_options_backtest gains option_open_prices; run_spread_backtest and BatchSpreadItem gain legs_open_premiums. Optional, shape-checked against the premium series (ValueError on mismatch), consulted only under fill_timing="next_bar_open". In the spread runner only SIGNAL entries and exits use them: 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 honest causal fill, unchanged from 0.11.0. - Rust: OptionsBacktest::run_with_opens and SpreadBacktest::run_with_opens carry the series; run() delegates with None, so existing callers are untouched. - Golden corpus: eight new fixtures - each multi-leg runner in both same_bar_close and next_bar_open, the latter exercising the premium-open path where the runner accepts one - with inputs frozen alongside as float hex, and replay tests in test_golden.py. The basket fixture uses sync_mode="any": the default "all" requires every instrument to signal on the same bar, which two independently seeded series essentially never do, and pinned an empty run. Every pre-existing fixture is byte-identical. Validation: cargo test --no-default-features (551 tests), clippy clean, pytest tests/python through the rebuilt wheel (429 tests; golden gate now 20 replays). No version bump: ships with the next release. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
4f34da53f8 |
Execute decisions after the information they use exists (0.11.0)
In plain words: a strategy decides at a bar's close, using everything that bar showed. With upon_bar_close=False the engine 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 to 200 and every later price is 150, a close-decided signal 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 this next-bar contract, and most make the wrong fill structurally unreachable rather than merely avoided. This change does the same: under the new "next_bar_open" timing, a signal observed on bar i is stashed as a deferred intent and consumed at the very top of the NEXT bar's step, before any code that can create a new intent runs - so a bar's own fill logic can never see a signal that bar generated (the ordering zipline gets from computing fills before handle_data, and backtrader from its creation-time guard). - New BacktestConfig.fill_timing policy: "same_bar_close" (default, unchanged), "next_bar_open" (decide at bar i close, fill at bar i+1 open; a last-bar decision never trades), and "same_bar_open_lookahead" (the pre-0.11 behavior, named for what it is, kept only to reproduce old results). upon_bar_close is deprecated and maps onto the policy; an explicit fill_timing wins. - Order-API market orders follow the same contract in next-bar-open mode: acknowledged on the submission bar, filled at the next bar's open. - Margin-call liquidation now prices at the breaching bar's close - the price at which the breach was detected - instead of the fill-price model. - The basket, pairs, options, and spread runners, which previously ignored the timing setting entirely, now honor it: next-bar-open fills at each leg's own open where the series has one, and at the following bar's premium for premium-only series. Decision-time information (pairs hedge ratio, options strike) stays on the decision bar. - Streaming sessions surface deferred Entered/Exited events on the step after the signal, priced at that bar's open. - Golden corpus: one new "next_bar_open" fixture; every existing fixture is byte-identical, since the default mode is untouched. Validation: cargo test --no-default-features (549 tests), cargo clippy clean, pytest tests/python through the rebuilt wheel (417 tests, golden corpus included). tests/test_fill_timing.rs and tests/python/test_fill_timing.py pin the causality contract permanently. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
49427cf4c2 |
Report an average over an empty set as undefined (0.10.4)
A backtest where every trade won still reported "average losing trade: 0.00" and "average losing trade duration: 0.00". Zero is a measurement -- it says the losers broke even, in no time at all -- and it was describing trades that were never taken. Measured on a two-leg straddle that closed two winners and no losers, beside a 100% win rate on the same panel. avg_win_pct, avg_loss_pct, avg_winning_duration and avg_losing_duration are now Option<f64> (Python: float | None) and report None when their population is empty. This is the 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. The four were the last fields in BacktestMetrics still answering "cannot say" with a number. payoff_ratio is unaffected. It reads both averages through unwrap_or(0.0), which reproduces its previous branch exactly -- a None unwraps to the same 0.0 the old code already saw there, so its output is bit-identical. Verified on the built wheel: a monotonically rising series closes two winners and no losers, and reports avg_loss_pct = None, avg_losing_duration = None, profit_factor = None while the winning-side fields keep real values. A mixed run still computes all four (avg_win_pct 4.80, avg_loss_pct -7.75, durations 95.0 and 100.0), so only genuinely empty populations null out. Pinned by a unit test in metrics::drawdown, where the "undefined is not zero" convention already lives. cargo test --lib: 511 passed. clippy and fmt clean. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
4619577e22 |
Report Calmar un-annualized, from one shared definition (0.10.3)
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. PortfolioEngine computed Calmar as CAGR / max drawdown, annualizing 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. One day of the same strategy reported ~3.7e23, and a value large enough to overflow NUMERIC(10,4) had been observed downstream. metrics::drawdown::calmar_ratio already implemented the plain ratio and nothing called it. PortfolioEngine annualized; StreamingMetrics, MultiStrategyBacktest, and the basket, pairs and options runners each carried their own inline copy, so one strategy could report a Calmar five orders of magnitude apart depending only on which runner ran it. All six now call the shared function, and the years/cagr derivation that existed solely to feed the annualized form is gone. Consolidating also settled a second disagreement: multi, pairs and options returned 0.0 when max drawdown was zero. For a profitable run that reads as the worst possible score when the truth is that the ratio is undefined. They now return INFINITY, which crosses to Python as None like every other undefined ratio. The README already documented this metric as "Return / Max Drawdown"; the code was the outlier. Its description now says so explicitly. 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, so a Calmar compared across the 0.10.2/0.10.3 boundary compares two definitions. Pinned by two new unit tests (the measured production numbers must give ~0.83 and stay below 5.0; no-drawdown is INFINITY rather than 0.0) and a rewritten behavior test asserting the exact identity calmar == total_return_pct / max_drawdown_pct, so it holds at any run length. The test it replaces described only the older bar-count bug. cargo test --lib: 510 passed. clippy and fmt clean. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
59662b559f |
Measure holding period from the trade's own clock (0.10.2)
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, on a run where no bar existed at all. The measurement was being thrown away before anyone saw it. The average was computed by looking each trade's entry_idx / exit_idx up in the equity timeline. Those indices count EVENTS; the timeline holds one entry per EQUITY SAMPLE. On a bar run the two agree, because one event is one bar -- which is why this never showed up there. On a tick session they diverge: equity is sampled once per print, but 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 guard was right; its input was wrong. The span now comes from the trade's own entry_time / exit_time, which every production path -- portfolio ledger, single-instrument position book, options runner -- already populates from the same clock. That makes the figure exact on bar and tick runs alike rather than merely tolerant of the mismatch, and it removes an indexing assumption that was only ever true by coincidence. A tick run that reported no holding duration at all now reports a real one: on the shape above, 28,641 s of hold where the field was previously None. Not addressed here: avg_holding_period itself still counts events, so on that same run it reads 57,282 rather than the 28,642 prints. It is a bar count and still means bars wherever bars exist; what a caller should render to a person is the _secs field, as the 0.10.1 README already says. Tests: one Rust case pinning that a trade index sitting past the equity timeline still yields the right duration, and one Python case driving run_tick_strategy through the wheel, where the print and quote streams actually diverge. Both were confirmed to fail when the fix is reverted to the old indexing. The two existing duration cases pass unchanged, which is the evidence that bar runs are untouched. 529 Rust and 409 Python tests pass. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
0500f0a226 |
Report durations in real time, cap exposure at 100% (0.10.1)
Plain words: the engine measured how long something 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 six-day backtest reported a drawdown of ~93,510 -- which a caller printing that as days showed as roughly 256 years. The bar counts are unchanged and still mean bars. Alongside them the engine now reports the same two figures in seconds, taken from the timestamps it already had in hand for annualization: max_drawdown_duration_secs avg_holding_period_secs Both are None when a run carried no usable timestamps -- "cannot say" rather than a zero that reads as a real measurement. The streaming accumulator keeps no timestamps, so it reports None by construction. Callers rendering a duration to a person should read the _secs fields and fall back to the bar counts only when they are None; README says so at both metrics tables. Also 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 run lasted -- 123.5% observed. Time in the market is now capped at the time available. This is the same class of defect the Calmar ratio fix addressed before 0.5.0, where bar count stood in for elapsed time and 11k one-minute bars read as ~31 years. The remedy is the same: use the timestamps. Tests: three Rust cases pinning that the seconds figure tracks bar spacing while the bar count does not, that the drawdown stretch is measured on the clock, and that overlapping positions cannot exceed 100% exposure; four Python cases exercising the same properties through the wheel. Each was confirmed to fail when the fix is reverted. 507 Rust and 408 Python tests pass. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
52675770fa |
docs: add a one-time support link, post1 release
RaptorBT is free and MIT licensed and stays that way. This adds an optional way for people who find it useful to send a one-time amount of their choosing, through a hosted pay-what-you-want checkout. Three entry points, all pointing at the same link: - a Support badge alongside the existing five in the README - a short Support section above the licence, explicit that a contribution buys no features, no support commitment and no priority - .github/FUNDING.yml, which gives the repository GitHub's own Sponsor button in the header and sidebar The README is the package long description, so the badge only reaches the project page on a release. Hence the post1: 0.10.0.post1 republishes identical code with the updated description, exactly as 0.3.2.post1 did. Only the Python version carries the suffix -- .post1 is not valid semver and Cargo would reject it -- and __version__ now reads from package metadata, so it needs no edit. No changelog entry: a metadata-only republish is not a notable change, matching the earlier post release. Co-authored-by: Pier Code <no-reply@piercode.com> |
||
|
|
9c6bc9dd0c |
Report total turnover in backtest metrics (0.10.0)
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 price * |size| base the fee models charge on (execution/fees.rs), so total_fees_paid / total_turnover is a meaningful cost rate. Two strategies with the same return but very different churn stop looking identical. - BacktestMetrics.total_turnover, computed by metrics::trade_stats::total_turnover as a fold over the trade list, beside the existing fees fold in the portfolio engine's calculate_metrics. Exit legs that never crossed the market contribute nothing: EndOfData (the run ended while 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. - Exposed on PyBacktestMetrics and in to_dict() as "Total Turnover"; stub updated in the same commit. - Result paths without a trade list (StreamingMetrics::finalize, the Default-built empties) report 0.0, meaning "not measured here", so a consumer can detect the capability by value rather than trusting the version string. - The contract multiplier is deliberately NOT folded in, matching the fee models: both figures share the same per-point unit, so their ratio stays exact for multiplier-bearing instruments. Rust unit tests pin the two-leg fold and the untraded-exit rule; tests/python/test_turnover_metric.py holds the metric to a Python recomputation from the reported trade list, through the wheel. |
||
|
|
2b7073c07f | chore: doc update | ||
|
|
f3e7243fab |
Merge pull request #27 from alphabench/fix/transaction-cost-defects
fees: match the published cost schedule; pin every rate by test (0.9.0) |
||
|
|
b537370935 |
fees: match the published cost schedule; pin every rate by test (0.9.0)
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 Rs 10,000). Verified field-by-field against https://zerodha.com/charges/ (2026-08-20) and pinned by tests against that source, so a stale rate now fails a test instead of surviving two budgets unnoticed: - Equity delivery brokerage -> zero (was flat Rs 20/order). - Intraday equity and all futures brokerage -> min(Rs 20, 0.03% of order value); options stay flat Rs 20. CostSchedule.brokerage_per_order is replaced by brokerage_flat + brokerage_rate, and indian_cost_schedule() drops the old key on purpose so stale consumers fail loudly. - F&O STT -> rates effective 2026-04-01 (Budget 2026-27): futures 0.05% sell-side (was 0.01%), options 0.15% of sell-side premium (was 0.0625%). The old values predated BOTH the 2024-10-01 and 2026-04-01 increases. - MCX futures now levy CTT (0.01% non-agri sell side; was zero). - Exchange-transaction rates trued up (all sub-0.1 bps) and CDS stamp duty corrected to 0.0001% (was 10x/30x the published rate). Deliberate, stated approximations: BSE/BFO share the NSE-family rates (published differences are under 0.2 bps; execution truth is Zerodha on NSE/NFO), and exercise STT is not modelled (the engine trades out). Golden fixtures regenerated — a deliberate fee change is exactly what that gate exists to make explicit. cargo test 522 passed; tests/python 401 passed through the built wheel. Found by the 2026-08-20 platform fees audit. The consuming platform's Python copy of these schedules moves in lockstep in its own repo, pinned by the existing parity tests. |
||
|
|
6678e19cc6 |
Merge pull request #26 from alphabench/post-snap-cap-guard
optimize: refuse a post-snap book that breaches a cap (0.8.1) |
||
|
|
7a1ca722bf |
optimize: refuse a post-snap book that breaches a cap (0.8.1)
In plain words: you tell the optimizer the most it may put in any one
stock. After picking weights it undoes trades too small to be worth
making, which 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. Found by
notebook 15 (alphajupyter BACKEND_FINDINGS.md § 8).
Latent, not live: book_optimizer.py never passes no_trade_band, so
production could not reach this. It becomes reachable the moment anyone
sets one -- a reasonable thing to want, since a no-trade band is exactly
how you stop a book churning on tiny adjustments.
What changed:
- 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. An earlier draft of this guard did refuse it,
caught by all_trades_snapped_returns_status_quo_not_infeasible.
Three tests: the breach is refused, a status-quo book over the cap is
not, and an unbreached snap still succeeds (the positive control -- a
guard that refused unconditionally would leave the first two green).
512 tests pass; fmt and clippy clean.
User-facing consequence: none today, since production cannot reach the
path. It converts a future silent over-cap book into an explicit refusal.
Version bumped to 0.8.1 for the release cut from this branch. The five
consuming floors in backend/ are a separate commit in a separate repo,
and must not be raised until 0.8.1 is published to PyPI.
|
||
|
|
eb2e383472 |
docs: the Py-prefix removal has happened, so stop promising it
The upgrade notice still read "old names still work and warn; they are removed in 0.8.0". This IS 0.8.0 -- they were removed in the previous commit, and the notice was describing a future that had already arrived. Someone on 0.7.x reading it would conclude they had time. It now says what the wheel does: the old names raise AttributeError, rename them or pin `raptorbt<0.8`. Verified against the built wheel rather than asserted -- `PyBacktestConfig`, `PyTrade` and a deep `_raptorbt.PyKernelSession` all raise, and the canonical names resolve. Also folds in what 0.8.0 changes for results -- spread legs settling on their own expiry dates -- alongside the two 0.7.0 behaviour fixes that were already listed, and points the CHANGELOG link at the 0.8.0 entry instead of 0.7.0. The Rust-side `Py*` struct names in `src/python/` are deliberately unchanged. That prefix is a real disambiguator, not a cosmetic one: the crate has its own `BacktestConfig`, `Trade` and `BacktestResult` in `src/core`, and the wrapper types sit in the same files as the types they convert to -- `impl From<&PyBacktestConfig> for BacktestConfig` needs both names to exist. Python never sees them; every `#[pyclass]` already carries a `name = "..."` giving the clean spelling. Documentation only. No version change, no behaviour change. |
||
|
|
bea5d7bd51 |
chore: remove the Py-prefixed name shim, as 0.8.0 scheduled
The `Py` prefix is a Rust-side disambiguator -- the crate has its own `BacktestConfig`, `Trade` and `BacktestResult` in `src/core`, and two Rust types cannot share a name -- which was never meant to cross into Python. The clean names have been canonical since 0.7.0, with the old ones resolving through a deprecation shim that this release retires. `test_removal_is_scheduled` was written to go red the moment 0.8.0 was cut, with the removal steps in its docstring. It did, and this is those steps: the `_RENAMED` map and both `__getattr__` hooks, the alias block at the end of the stub, and the test file itself. Removed from the stub and the runtime together, deliberately. Leaving an alias in one and not the other is the "type-checks clean, AttributeError at runtime" shape that a stub is supposed to prevent. 400 Python tests pass against the rebuilt wheel; both entry points now raise AttributeError for the old names, and the canonical ones are unchanged. |
||
|
|
b3163f06d8 |
release: 0.8.0 -- spread legs settle on their own expiry dates
Minor rather than patch, for two reasons: calendar and diagonal P&L changes materially, and `run_spread_backtest` now rejects an input it used to accept. A patch bump would let a resolver satisfy a >=0.7.4 floor with the wheel that has the defect. The changelog entry leads with the plain-words statement and the table showing why this was worse than an optimistic result -- the error changes sign, so it was uncorrelated with the trade rather than a bias anyone could discount. |
||
|
|
7a64af5a38 |
fix: do not re-enter a spread that contains a dead leg
In plain words: once one leg of a spread has expired, the engine will no longer open that spread again. Before, it waited until every leg had expired -- so a calendar could be re-entered with a leg that no longer existed. The premium series for an expired contract is not quoting anything, so entering there prices that leg off a number that is not a price. Identical for every same-expiry structure trading today, where "any leg expired" and "all legs expired" are the same moment. It only differs for staggered expiries, which is what the previous two commits made measurable in the first place. The guard was checked by injection, not just observed green: restoring the old all-legs test makes it report two trades where one is correct. 508 Rust tests pass. No user-visible change. |
||
|
|
6cfbac40e7 |
fix: refuse a leg-expiry list that does not match the legs
In plain words: if you hand the engine three legs and two expiry dates, it can no longer guess which leg goes without one. It says so. Expiries are matched to legs by position. A short list left the trailing legs immortal; a long one settled on a date belonging to no leg. Both silently -- the run returned a clean, well-formed P&L for a structure nobody traded, which is the shape of defect `test_refuses_ambiguous_input` exists to catch. The engine already refused this by returning an empty result. From Python that is indistinguishable from a strategy that never fired, so the binding now raises ValueError naming both counts. Also here: - The `.pyi` stub for `run_spread_backtest` was `*args, **kwargs`, which told a type checker nothing. It now carries the real signature, with the expiry contract stated on the parameter: the premium series must carry the leg's settlement value at and after its expiry, because the engine freezes the leg there and never invents a price. - `batch_spread_backtest` passes no expiries at all, so batch runs never settle. That is correct by omission rather than an oversight, and now says so in a comment -- wiring it in needs a new field on the item class, its stub, and a test, which is its own change. - The first tests to exercise leg expiry through the wheel. There were none: the whole surface was Rust-tested only, and the boundary is where the signature actually lives. 510 Python tests pass against the built wheel, 508 Rust tests pass. What a user sees: nothing yet. A misconfigured spread backtest now fails loudly instead of returning a plausible wrong number. |
||
|
|
05111a8db2 |
fix: settle each spread leg at its own expiry
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, 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. Any leg reaching its expiry force-closed the entire structure. For a calendar or a diagonal that made the backtest not merely optimistic but UNRELATED to the trade: with the near leg expiring at bar 20 and the far leg free to do anything afterwards, the engine returned the same number every time, because it had already closed. far leg settles at 120 100 80 60 30 engine reported 3,750 3,750 3,750 3,750 3,750 the truth 6,750 5,250 3,750 2,250 0 And it was silent. No error, no warning, no NaN -- a clean-looking P&L with exit_reason=Settlement. Now each leg settles on its own date and the survivors keep marking. The structure closes when the LAST leg goes. - A settled leg's P&L leaves the mark-to-market and is credited to cash on its expiry bar. Those are the two halves of the equity line, so equity does not move across a settlement -- the same rupees are never counted twice. `the_reported_pnl_equals_what_the_account_actually_gained` pins the ledger end to end. - A settled leg is frozen at what it settled for. Its contract no longer exists, so a stale quote in the series past that bar is not a price and must not reach the exit price or the P&L. - It pays no exit cost -- an option left to expire is never sold, so no order is placed. The surviving leg, if it is traded out, still owes full brokerage. Entry costs are unchanged either way: that order was real regardless of how the leg ended. - An expiry list whose length does not match the legs is now refused. Expiries are matched by position, so a wrong length settled the wrong leg or left the trailing legs immortal, silently. The engine still does not compute intrinsic value, and the contract on `leg_expiry_timestamps` now says so: the caller writes the settlement value into the premium series. Two implementations of the same number, silently reconciled, is a worse defect than the one being fixed. Every same-expiry structure -- straddles, strangles, verticals, iron condors, everything trading today -- is unaffected, with `a_same_expiry_structure_is_unchanged` guarding it. 508 passed, 0 failed. |
||
|
|
1da98d6878 |
refactor: split spread config out, and close a trade in one place
In plain words: no behaviour changes here. This moves code so that the next commit -- which fixes how a spread with legs expiring on different days is settled -- is small enough to read. Three things, all preparation: - `SpreadType`, `OptionType`, `LegConfig`, `SpreadConfig` and the four `create_*_config` builders move to `spreads_config.rs`, re-exported from `spreads.rs` so every existing import path keeps working. The bytes moved are unchanged. `spreads.rs` was 710 lines against the 500-600 cap, and the settlement fix adds to it; it is now 530. - The in-loop exit and the end-of-data sweep built their `Trade` record from two near-identical blocks. They are now one `emit_trade`. Two copies of "how a closed spread is reported" is exactly the pair that drifts when only one of them learns about a partially settled position. - Those two blocks had already drifted, in one place: the end-of-data path guarded its division by the entry premium and the in-loop path did not. A structure opened for nothing -- a calendar entered at equal premiums is the everyday case -- divides by zero there and records a NaN return into the metrics. Both paths now use the same guarded `return_pct`, and a zero-premium structure is skipped in the metrics rather than recorded as a 0% trade, matching what the end-of-data path already did. No user-visible change. Every existing test passes unchanged: 499 passed, 0 failed. |
||
|
|
400bac1652 |
Merge pull request #25 from alphabench/fix/options-costs
fix: charge options costs on the contracts actually traded |
||
|
|
2a28ed2624 |
fix: charge options costs on the contracts actually traded
The options path computed its own fees instead of using the engine's fee
model, and got six things wrong. Every one of them billed too little or
disclosed too little.
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 -- and every figure that would have exposed this was either blank
or agreed with itself.
1. Fees ignored the lot multiplier. `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.
Unlike the tick defect in this release, costs and profit were wrong
by *different* factors, so net P&L was overstated on every trade
rather than merely scaled down.
2. `entry_fees` was hard-coded to 0.0 at both trade sites. The charge
really was 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 only from the
report. `Trade` documents `fees == entry_fees + exit_fees`; it held
here only because both sides of the equation were wrong.
3. 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 -- and `fee_breakdown` was
hard-coded `None`, so a configured `fee_segment` reached the model
and reported nothing.
4. `total_fees_paid` fell to a default of zero for every run.
5. `Trade.size` reported lots where every other path reports
contracts.
6. The end-of-data close never touched cash or the equity curve.
Defect 6 was found while fixing the other five and is scope beyond them.
The close computed fees and pushed a trade, but unlike the signal exit
there was no `cash +=`, and it runs after the loop has already written
the last equity point from the position marked to market -- so 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
(1) multiplies that unrecorded charge by the lot size, so leaving it
would have widened the gap between the trade list and the curve rather
than narrowing it.
The fix follows spreads.rs and tick.rs: one `charge()` helper is now the
only place this path prices an order, so (1), (3) and (5) are no longer
representable rather than merely corrected. The entry charge and its
breakdown are retained on the position instead of recomputed at exit,
and `total_fees_paid` is summed from the trade list as the portfolio
engine does -- this path builds its own metrics rather than finalizing a
StreamingMetrics, so `record_fees` was never reachable here.
Measured on a 50-lot round trip at a 100 premium: fees 10.00 against
0.20 before, split 5.00 each side. Against `fee_segment="NFO-OPT"` the
real schedule is 54.62, of which 40.00 is per-order brokerage that no
percentage rate can charge. 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.
The path had three tests and none of them ever ran a backtest, which is
how six defects accumulated in it. They move to a sidecar alongside pins
for each defect. Verified by injection: reverting the lot multiplier
fails 4 pins, reverting the entry-fee retention fails 5, swapping
`calculate_side` back for `calculate` fails 2, and the last three fail
one apiece. Full gate green -- fmt clean, 0 clippy diagnostics, 499 Rust
tests, 506 Python tests.
Also types the `run_options_backtest` stub, which was an untyped
catch-all; no PyO3 signature changed, so this documents what already
exists.
No version bump: 0.7.4 is unreleased and already carries the tick fix,
so these notes join that entry.
|
||
|
|
d45cf1a9f6 |
Merge pull request #24 from alphabench/fix/tick-position-size
fix: charge and earn tick costs on the position actually traded |
||
|
|
f207efa744 |
fix: charge and earn tick costs on the position actually traded
The tick strategy path priced every backtest as if you had traded exactly
one unit, whatever you actually traded.
In plain words: it charged one unit's costs, earned one unit's profit,
and then measured that one-unit profit against your entire account. Trade
a 75-lot option and it reported roughly 1/75th of your real profit and
1/75th of your real costs. Both errors are the same factor, so the trade
list looked entirely self-consistent -- it was simply describing a
position nobody held.
This closes the gap 0.7.3 recorded. That note was accurate but understated
the defect in two ways:
1. P&L was per-unit too, not just fees. `gross_pnl` was
`(exit - entry) * 1.0` and `Trade.size` was hard-coded `1.0`. Because
`build_result` adds per-trade P&L to a real `initial_capital`, a
per-unit profit 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.
2. The "caller scales by lot_size" contract was unfulfillable. A comment
in the tick loop told 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.
`fee_segment` was also unreachable from this path by two independent
routes: `tick.rs` never imported a fee model, and the binding built its
config with `..Default::default()`. Both are closed. This is the same
defect 0.7.3 fixed for spreads and it matters for the same reason --
brokerage is flat per order, and a proportional rate cannot express it at
any rate.
The fix is deletion, not addition: the hand-rolled `price * fee_frac` is
gone and the path now uses `BacktestConfig::fee_model` like every other
strategy, pricing each side 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 twice, and a test now fails if
anyone does. `return_pct` divides by notional so it stays scale-free.
A negative `quantity` is refused with a ValueError rather than accepted.
This path enters at the ask, exits at the bid, and places its stop below
entry -- it is long-only, and running that 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.
`lot_size` and `quantity` default to 1, so an existing call reproduces the
pre-0.7.4 numbers exactly; a test pins that.
Measured on a 75-lot round trip at a 100 premium: per-unit fees 0.20,
75-lot fees 15.00 (exactly 75x), itemized `NFO-OPT` 58.33 of which 40.00
is brokerage that no percentage rate could have charged. The flat rate as
a share of the real schedule: 0.6% at a premium of 2, 6.1% at 20, 25.7%
at 100.
Verified by injection: reverting the size-scaled P&L fails one pin,
reverting the size-scaled fees fails four, and swapping `calculate_side`
for `calculate` fails two. That first run exposed a weak test of my own --
asserting only that equity moved by `trade.pnl` passes even when both are
per-unit -- so it now pins the absolute figure instead. Full gate green:
fmt clean, 0 clippy diagnostics, 489 Rust tests, 506 Python tests.
Known gap, deliberately not fixed here: `strategies/options.rs` holds a
fee model but prices through `calculate`, reports `entry_fees` as zero
with the entry charge folded into `fees`, and sizes by contracts without
the lot multiplier. Same family, its own change.
|
||
|
|
03de55b54f |
Merge pull request #23 from alphabench/fix/spread-costs
fix: charge multi-leg spread costs once, per leg, per side |
||
|
|
5e0dc58473 |
fix: charge multi-leg spread costs once, per leg, per side
Multi-leg option spreads computed their own fees instead of using the
engine's fee model, and got six things wrong. Every one of them billed
too little, so a losing structure could be reported as a winner.
In plain words: a backtest of a multi-leg option strategy under-charged
its costs and then under-reported even what it did charge -- and the
cheaper the options, the larger the gap.
1. The entry was charged twice. `calculate_fees` computed a full round
trip (its comment read `// Entry + Exit`) and ran at exit, on top of
a separate entry charge already taken. A flat 100 premium over a 75
lot cost 22.50 where 15.00 was owed.
2. `Trade.fees` reported the exit side alone. The entry figure was a
local that nothing retained, so the trade list and the equity curve
described different amounts of money -- the shape of defect that
survives review, because every trade-level audit passes.
3. `fee_segment` was unreachable. `SpreadBacktest` held no fee model,
so the itemized schedule could not apply however the config was set.
Brokerage is flat per order and a proportional rate cannot express
it: a 4-leg round trip is 8 orders that were never billed.
4. Costs ignored a leg's quantity, charging a two-lot leg as one lot.
5. `total_fees_paid` read 0.0 for every spread run -- `record_fees` was
never called.
6. A zero-quantity leg was charged full per-order brokerage for a trade
it never made.
The fix mirrors the portfolio kernel: charge per side at the fill, keep
the entry breakdown on the position, and derive the trade's totals from
what was actually charged. `Trade` now carries `entry_fees` and
`exit_fees` with `fees` as their sum, so the divergence in (2) is no
longer representable rather than merely corrected. The new fields are
additive and serde-defaulted; existing readers need no change.
A leg exiting via Settlement now pays no exit-side cost -- an option left
to expire is never sold, so it owes no brokerage or transaction tax.
Charging one there would overstate every structure held to expiry, the
mirror image of the undercharge above.
Also routes options, pairs, basket and portfolio through
`BacktestConfig::fee_model` so a configured segment reaches them, and
drops the unused `fee_model` field from MultiStrategyBacktest.
Measured on a 4-leg structure, one round trip, 75 lot, `fees=0.001`
against `fee_segment="NFO-OPT"` -- the flat rate as a share of the real
schedule: 0.6% at a premium of 2, 6.1% at 20, 25.7% at 100, 65.4% at 400.
Verified by injection: reinstating the double charge fails three of the
new pins. Full gate green -- fmt clean, 0 clippy diagnostics, 480 Rust
tests, 502 Python tests.
Known gap, deliberately not fixed here: the tick strategy path computes
fees from price alone and does not scale them by position size. It is a
separate defect and gets its own change.
|
||
|
|
aaffa9d23b |
Merge pull request #22 from alphabench/fix/session-squareoff-reachable
0.7.2 — intraday backtests can close before the bell |
||
|
|
fc43a447a8 |
release: 0.7.2 -- intraday backtests can close before the bell
Patch. Adds `squareoff_time` so an intraday backtest can be told to flatten before the market shuts, and fixes a position left open at the end of a run being absent from `trades()` while its P&L still moved equity. Existing results are unchanged unless `squareoff_time` is set, with one exception noted in the changelog: a run that ended holding a position now reports one more trade than it did before. Set `squareoff_time` and `session_tz_offset_ns` together -- the first without the second reads a local time in UTC and silently never fires. Not yet published to PyPI, so the consuming backend's four requirements floors stay where they are until it is. |
||
|
|
65ec0881b0 |
fix: make session squareoff reachable, and report open positions
In plain words: a backtest could report profit earned overnight, while the market was closed. That is money no trader could have made -- their broker would have closed the position before the close -- and there was no setting to stop it, because the setting the engine advertised did not exist. Two independent defects, both flattering, found while measuring an intraday option strategy on a real NIFTY corpus. 1. Squareoff was unreachable `BacktestConfig.set_session_config` was declared in `_raptorbt.pyi` and never existed in the engine. The consuming backend called it behind `hasattr(config, "set_session_config")`, so the guard was False every time and the else-branch logged at DEBUG. `session_aware=True` was silently dropped for months. A full SessionTracker/SessionConfig sat in `core/session.rs`, imported by nothing but a `pub use`. `SpreadConfig.close_at_eod` was the same story one layer down: declared, defaulted false, hardcoded false at both binding sites, read by nothing. Adds `BacktestConfig(squareoff_time="15:25")`, which force-closes open positions at the first bar at or after that local time each day. The time is local, read through `session_tz_offset_ns`, so it is market-agnostic rather than NSE-specific. Closes carry the new `ExitReason::Squareoff` -- a real trade-out at a real in-session price, paying normal exit costs, distinct from `EndOfData`. The engine will not re-enter on the squareoff bar itself, since that would put the position straight back on and carry it overnight anyway. An unreadable value raises ValueError rather than silently disabling squareoff. That refusal is the whole point: a setting that quietly does nothing is the defect being fixed, so there is deliberately no fallback. `close_at_eod` is removed rather than left as a second dead switch. 2. A position open at the end of data was never recorded The spread path settled it into `cash` without pushing a Trade or calling `record_trade`. Its P&L reached `end_value`, `total_return_pct` and the equity curve, while `trades()` returned empty and `total_closed_trades` read zero. That is the most dangerous shape a reporting defect can take: every trade-level audit passes, because there is nothing to audit. It surfaced as a run whose entire return came from one position opened on the first morning and never closed -- plainly visible in the equity curve, invisible in the trade book. Measured, through the built wheel, on the batch path the pipeline calls: no squareoff trades=1 reasons=[EndOfData] ret% = -0.15 squareoff trades=2 reasons=[Squareoff, Squareoff] ret% = +0.15 The sign flips. On the NIFTY corpus that prompted this, enforcing squareoff moved a short straddle -24%, a short strangle -42%, and a LONG straddle +16% -- the other way. The defect does not add a constant bias, it amplifies whichever direction a position already points, which is worse than noise because it inflates conviction. Two existing tests asserted `trades.is_empty()` to mean "the threshold did not fire". That was only true because of defect 2, so they now assert `exit_reason == EndOfData` -- the thing their own comments said they were about. Both remain able to fail. What a user sees: an intraday backtest can now be told to flatten before the close, and its reported trades account for every position that moved equity. Results are unchanged unless `squareoff_time` is set, except that a run ending with an open position reports one more trade than before -- the P&L was always in `end_value`, it is now also in `trades()`. |
||
|
|
de9b48ce74 |
docs: reformat README and fix three broken bullets
A Prettier pass over the README: badge set replaced with pypi.org-sourced shields, tables aligned, emphasis normalised from `*` to `_`, and the indicator examples re-indented under their bullet. Three continuation lines had lost the two-space indent that keeps them inside their list item -- the bracket, modify_order and stochastic-fill bullets -- which ends the bullet early and renders the rest as body text on GitHub and PyPI. Restored; no wording changed anywhere. No code, no behaviour, no API change. |
||
|
|
596e6b4b3a |
Merge pull request #21 from alphabench/fix/extension-shim-dir
0.7.1 — make the deprecated names enumerable, not just resolvable |
||
|
|
5c792397bc |
release: 0.7.1 -- deprecated names are enumerable
Patch on 0.7.0. The Py* aliases resolved through __getattr__ but never appeared in dir(_raptorbt), so they were invisible to autocomplete and to any tool that enumerates the module -- including the consuming backend's stub guard, which read the .pyi alias block as 21 phantom declarations. No API change. See CHANGELOG for the two blind spots in this repo's own stub-completeness test that let it go unnoticed. |
||
|
|
1fe00ca289 |
fix: make deprecated names discoverable, not merely resolvable
The 0.7.0 aliases resolved through `__getattr__` but never appeared in
`dir(_raptorbt)`. A name that works but cannot be enumerated is invisible to
tooling, and the consuming backend has a guard that compares `_raptorbt.pyi`
against `dir(_raptorbt)`: with the aliases absent from `dir()`, the stub's
alias block read as 21 declarations for symbols the engine had dropped -- the
exact "type-checks clean, AttributeError in prod" failure that guard exists to
catch. It was right to complain.
The extension shim now installs `__dir__` alongside `__getattr__`, listing the
real exports plus the aliases. Two tests pin it: every deprecated name appears
in `dir()`, and the custom `__dir__` does not hide anything the extension
actually has.
Also fixes the sibling guard in this repo, which had two blind spots and so
never noticed:
- It recognised `class X`, `def X(` and `X:` but not `X = Y`, so the whole
alias block read as undeclared.
- `f"class {name}" in stub` is a substring test, and `class Foo` is a prefix of
`class FooBar` -- renaming a class away left the guard green. Now anchored
per declaration form.
Verified by deletion rather than by a green run: renaming `class
RiskContributions` to `class RiskContributionsGONE` now fails the guard with
`absent from _raptorbt.pyi: ['RiskContributions']`, where before the change it
passed. That is the case it was written for and could not previously catch.
460 Rust tests, 485 Python tests, clippy clean under -D warnings, fmt clean.
No user-visible behaviour change: names that resolved before still resolve, and
now also show up in `dir()` and autocomplete.
|
||
|
|
284f84cf9e |
Merge pull request #20 from alphabench/release/v0.7.0
0.7.0 — drop the Py prefix, and stop guessing at ambiguous input |
||
|
|
adb8fa7166 |
docs: 0.7.0 changelog, measured benchmarks, and Py-free examples
CHANGELOG entry for 0.7.0 covering the rename, the five refuse-don't-guess fixes, and the three behaviour changes a user needs to know about before upgrading -- Renko results were wrong, tick backtests were truncated, and input that used to be guessed is now refused. README: benchmark table re-measured against 0.7.0 (it was still quoting 0.4.0 figures) with a note that the numbers moved because the harness changed, not the engine, plus the same-data A/B that shows it. All 51 Py-prefixed class references in the examples updated to the new names, and the stale 'not re-exported at top level' comment on PortfolioSession removed -- it is exported now. Verified the quick-start example actually runs against the built wheel. No code change; no user-visible behaviour change beyond the docs being accurate. |
||
|
|
9b61a5cdb2 |
fix!: refuse ambiguous input instead of silently guessing
Plain words: 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 quietly picked a default and returned numbers that looked completely normal. Five such cases. All of them now raise an error naming the bad input. These share the most dangerous shape a financial library can have. Not a crash, not an obviously silly figure, but a smooth well-formed result computed from something other than what the caller asked for. No metric, no equity curve, no risk check downstream could tell the difference. ## What was wrong - **`max_trades` defaulted to 50 and truncated the run.** It `break`s out of the tick loop and reports as if the tape ended there. Measured on 1,000,000 ticks: 50 trades covering 0.81% of the data, total return -0.12% against a true -14.13%, max drawdown 0.124% against a true 14.13%. A 114x understatement of the one number a risk check reads. Now unlimited by default; the knob remains for anyone who explicitly wants a truncated run. - **A correlation matrix that is not positive definite was silently repaired.** Cholesky patched a negative pivot with `sqrt(|diag|)` and a zero pivot with `0.0`, then returned Ok. On an indefinite 3-asset matrix (smallest eigenvalue -0.8) the result was `var_95 = 0` and `probability_of_loss = 0` -- a risk model reporting no risk at all. `covariance.rs::chol_strict` already refuses and its comment says it is "deliberately unlike monte_carlo::cholesky"; the Monte Carlo path was the wrong half of that pair. The identity-matrix fallback below it was dead code (cholesky had no Err path) and is gone -- substituting one would have made every asset independent, the most optimistic assumption a risk model can make. - **An unparseable option type became a Call.** `OptionType::from_code` says in so many words that "defaulting an unrecognised code to Call would price a put as a call", and both PyO3 call sites then did exactly that. An iron condor whose put legs failed to parse became a four-leg call structure. `batch_spread_backtest` multiplied it across a whole sweep. - **`run_options_backtest` parsed three enums with a catch-all arm.** `option_type="PUT"` selected a long CALL -- the mirror image of the intended payoff -- while the identical string is accepted by `run_spread_backtest`. The same call meant two different things depending on which function you entered through. Now case-insensitive and closed; the documented defaults still work. - **`direction` fell back to Long.** Six call sites did `from_int(d).unwrap_or(Long)`, so a book encoded `0`/`1` instead of `-1`/`1` backtested entirely long, flipping the sign of the P&L on every short. In the basket and portfolio runners the parse is per instrument, so one bad row turned a leg of a market-neutral book into a doubled long. Two of the five were found by reading, three by an audit sweep prompted by the first. The audit also confirmed the optimizer's `max_iter` handling is correct (it returns SolverFailed with the iteration count rather than passing off a non-converged answer) and that no Rust `Default` diverges from its PyO3 default. ## Also here `benches/python/` -- the harness behind every published performance figure, so a claim can be re-run rather than trusted. Its README records why the 0.7.0 numbers are larger than the 0.6.4 ones: a different harness, not a slower engine. Same-data A/B of the published 0.6.4 wheel against this build gives 71.0us vs 70.5us on 1,000 bars with identical results, so 0.7.0 is marginally faster. The harness also encodes the two traps that produce flattering-but-wrong numbers: measure the release build, and raise `max_trades` so tick runs actually reach the end of the array. Version bumped to 0.7.0 in Cargo.toml and pyproject.toml. NOT yet published, so the backend requirement floors stay where they are until the wheel is on PyPI. ## Verification 460 Rust tests, 463 Python tests through the built wheel, clippy clean under -D warnings, fmt clean. tests/python/test_refuses_ambiguous_input.py adds 44 cases covering all five defects plus the valid inputs that must keep working. User-facing consequence: code that was relying on any of these silent fallbacks now raises a ValueError telling you which argument is wrong. That is a break, and an intentional one -- the alternative was continuing to hand people confident numbers computed from inputs the engine did not understand. Tick backtests that used the default `max_trades` will return different, correct results; Renko and spread numbers are unaffected by this commit. |
||
|
|
645e5db991 |
feat(api)!: drop the Py prefix from every public class; clear all warnings
Plain words: the 21 classes you used to write as `raptorbt.PyBacktestConfig` are now `raptorbt.BacktestConfig`. Old spellings still work and print a warning naming the replacement; they stop working in 0.8.0. Separately, the build and linter are now completely silent, and getting them there uncovered three real bugs -- one of which silently corrupted every Renko backtest. ## The rename `Py` is a Rust-side disambiguator: `src/core/types.rs` owns `BacktestConfig`, `Trade` and `BacktestResult`, and two Rust types cannot share a name. Correct Rust; it was simply never stripped on the way out. The Rust struct names are unchanged -- only `#[pyclass(name = "...")]` was added, exactly as BarAggregator, Indicator and InstrumentSpec already did. It had been applied to 3 of 24 classes and forgotten for the other 21. Deprecated names resolve through a module-level `__getattr__` and are kept out of `__all__`, so `import *` will not hand anyone a name that is about to go. Two things the first pass got wrong, both now covered by tests: - **Deep imports broke.** `from raptorbt._raptorbt import PyPortfolioSession` bypasses the package, so the shim never saw it. Caught by test_position_adoption.py. The shim now extends to the extension module too. - **The warning pointed at a name that did not exist.** `PortfolioSession` was never re-exported at top level, so "use raptorbt.PortfolioSession" was advice that raised AttributeError. It is exported now, and a test asserts every replacement the warning names actually resolves. ## Three bugs found while clearing warnings - **BarAggregator ignored `brick_size`.** The constructor accepted it, then called a helper hard-coding 0.0; `resolved_brick` reads non-positive as "fall back to step". Asking for 5-point Renko bricks silently gave you `step`-point bricks -- a 10-point move produced 10 bars instead of 2. Every Renko backtest through the streaming aggregator was wrong. Every existing test used step=1, brick_size=1.0, where the fallback returns the number you asked for and the bug is invisible. - **A test had never run.** A duplicated `#[test]` left the *next* function without one, so `day_expires_on_utc_date_rollover` -- DAY-order expiry across UTC midnight -- silently never executed. It passes; it just was not running. - **simulate_portfolio_mc panicked instead of refusing.** No dimension checks, so passing an (n_obs, n_assets) matrix where a per-asset list was expected indexed past the end of `weights` inside a Rayon worker. That surfaces as PanicException: not catchable as ValueError, thrown from a thread with no user code in the traceback. Now a ValueError naming the actual mistake. ## Warnings: 89 -> 0 `cargo clippy --no-default-features --all-targets -- -D warnings` passes, and the lib build is silent. Not by suppression: 8 manual Default impls became derives, shift loops became copy_from_slice, the quadratic form w'Σw was deduplicated between optimize.rs and risk_contrib.rs, Cholesky's inner accumulation became a dot product, and `OptionType::from_str` -- which shadowed the FromStr trait so `"CE".parse()` did not work -- became `from_code` with a real FromStr impl alongside. Six `#[allow]`s remain, each with its reasoning in a comment above it. Five are `too_many_arguments` on signatures that ARE the Python call signature, matching the precedent already in session_bindings.rs. The sixth is load-bearing: `adopt_position` 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 be adopted as a cost basis, turning cash, equity and every drawdown figure downstream into NaN with no error raised. A new test pins it -- applying clippy's advice makes it fail with "NaN price must be refused, got Ok(0)". ## Verification Refactoring index math in the optimizer is exactly the class of change that caused the 0.6.4 spread-sign bug, so it was done against a captured baseline: 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**, re-checked after each batch. 458 Rust tests, 419 Python tests through the built wheel, fmt clean, zero stub drift (all 24 stub classes resolve at runtime and appear in `__all__` -- the pre-existing PyPortfolioSession gap is closed). User-facing consequence: Renko backtests run through BarAggregator were wrong and are now correct -- if you have stored Renko results, re-run them. Everything else is a spelling change with no behaviour difference. |
||
|
|
2310d39190 |
feat(api): expose RiskModel without the Py prefix
Plain words: the class you write as `raptorbt.PyRiskModel` is now `raptorbt.RiskModel`. The old spelling still works and prints a warning telling you what to change; it stops working in 0.8.0. Nothing about what the class *does* has changed -- same object, same numbers. Why the prefix existed: `Py` is a Rust-side disambiguator. The crate has its own `RiskModel` in src/portfolio, and two Rust types cannot share a name, so the binding wrapper became `PyRiskModel`. That is correct Rust. The mistake was never stripping it on the way out, so it reached Python -- where "the Python one" describes every object in the library and the name reads as a stutter advertising an implementation detail. The codebase already knew the answer. BarAggregator, Indicator and InstrumentSpec are all declared `#[pyclass(name = "...")]` and reach Python clean. It was applied to 3 of 24 classes and forgotten for the other 21. This commit does the first of the remaining 21 and, more importantly, builds the machinery the rest will reuse: - `#[pyclass(name = "RiskModel")]` on the unchanged Rust struct PyRiskModel. Registration in lib.rs is by Rust type, so it needs no edit. - A module-level `__getattr__` (PEP 562) resolving old names with a DeprecationWarning that names both the replacement and the removal version. - Old names deliberately left out of `__all__`: they resolve, but `from raptorbt import *` will not hand anyone a name that is about to go. - `.pyi` alias so type checkers keep resolving old code during migration. tests/python/test_deprecated_names.py pins both halves of the promise -- the new name works silently, the old name works *and still warns*. A shim that stops warning is indistinguishable from a permanent alias, and this one is meant to go away. test_removal_is_scheduled fails the moment the version reaches 0.8.0; that failure is the instruction to delete the shim. Verified through the built wheel, not the source tree: 330 Python tests pass (including the existing test_portfolio_math.py, which calls r.PyRiskModel untouched and still works -- backward compatibility proven empirically), 457 Rust tests pass, clippy and fmt clean. The 79 clippy warnings under -D warnings are pre-existing; CI does not set that flag and neither did I after confirming the baseline carries the same 90. User-facing consequence: none yet. No behaviour changes and no numbers move. This is a rename of how the API is spelled, landing one class at a time. |
||
|
|
793a009a43 |
Merge pull request #19 from alphabench/fix-spread-pnl-sign
fix(spreads): stop negating spread P&L (0.6.4) |
||
|
|
1cb554e400 |
release: 0.6.4 -- spread P&L sign fix
Bumps Cargo.toml and pyproject.toml to 0.6.4 and records the fix in the changelog. Also adds tests/python/test_spread_backtest.py. The sign bug crossed the Rust/Python boundary and shipped in a wheel, and the reason it survived is that neither side tested it: the Rust unit tests asserted only that a trade was recorded, and this Python suite -- the one CI runs against the built wheel -- never called run_spread_backtest at all. Nine tests now cover all four short/long x win/lose cases, both threshold triggers on both sides, and the pnl-agrees-with-price-difference invariant. Verified these tests actually catch the defect: reintroducing the stray minus and rebuilding turns all nine red, and restoring it turns all nine green. The changelog leads with the upgrade consequence, which matters more than the diff: any stored spread backtest from 0.6.3 or earlier is inverted and should be re-run, and results with max_loss or target_profit set differ by more than a sign, because the exit timing itself was wrong. Local CI equivalent green: cargo fmt --check, cargo clippy --no-default-features --all-targets (0 errors), cargo test --no-default-features --all-targets (457), pytest tests/python (324). |
||
|
|
07ee7b511e |
fix(spreads): stop negating spread P&L
`LegPosition::unrealized_pnl` returned `-quantity * premium_change * lot_size`.
`LegConfig.quantity` is already signed (+1 long, -1 short -- see its doc
comment, and `is_short()`, which is `quantity < 0`), so the leading minus
applied the direction convention a second time and negated every result.
short (-1) + premium falls (-30) -> (-1) * (-30) * 75 = +2250, a gain
long (+1) + premium falls (-30) -> (+1) * (-30) * 75 = -2250, a loss
One character of production code. Everything downstream reads this one
function, so `total_unrealized_pnl`, `close`, `net_pnl`, `record_trade`, the
equity curve, and the two exit triggers all inherit the correction.
The exit triggers are the part that goes beyond misreporting. `check_max_loss`
and `check_target_profit` compare against this figure, so through 0.6.3 a
max-loss stop closed structures that had GAINED and a target-profit booked wins
on structures that had LOST. All four cases now have a test, each with the
exit signal removed so the threshold is the only way out and `exit_reason`
carries the assertion.
Also flipped the nine characterisation tests added in the previous commit and
gave the iron condor a P&L assertion: four legs at flat premiums must net
exactly zero gross, which pins the multi-leg summation path that the
single-leg tests cannot reach.
`net_premium_signs_are_already_correct` is unchanged, which is the point --
`entry_price` / `exit_price` are computed independently and were always right,
so holding them still proves this is a pure sign flip and not a repricing.
Whole crate green: 457 tests. Nothing downstream had encoded the inverted
expectation.
User-visible: a backtest of a multi-leg options structure that made money now
reports a profit instead of a loss, and a max-loss stop no longer closes a
winning position early. Paper and live deployments were never affected -- they
mark through `group_net_liq_pnl`, not this function.
|
||
|
|
2abfac0928 |
test(spreads): pin the P&L sign bug before fixing it
The two existing spread tests assert only that a trade was recorded and that the equity curve has the right length. Neither asserts a P&L value, a sign, or a magnitude -- which is why `unrealized_pnl` has been returning the negative of the truth without anything going red. Adds nine characterisation tests that assert the CURRENT, WRONG behaviour, each marked `// BUG:`. They exist so the one-character fix that follows is a reviewable diff rather than an assertion that the author got the sign right. Fees are zeroed in the helper so the numbers are exact integers: the true 2250 otherwise arrives as 2259, and a fee-shaped fudge is exactly what would hide a sign-adjacent slip. Two of these matter more than the sign tests. `check_max_loss` and `check_target_profit` both read `total_unrealized_pnl`, so with no exit signal present a max-loss threshold currently closes a leg that GAINED 2250, and a target-profit threshold books a win on a leg that LOST 2250. Both are pinned with the trigger as the only available exit, so the exit reason is the assertion. `net_premium_signs_are_already_correct` is pinned deliberately and must not move in the fix: `entry_price` / `exit_price` are computed independently of `unrealized_pnl` and are already right. Holding them still is what proves the next commit is a pure sign flip and not a repricing. Exact-value assertions on a full deterministic engine run, rather than a snapshot library, follow nautilus's acceptance tests (tests/acceptance_tests/test_backtest.py). Design read and adapted, no code taken. No user-visible change. |
||
|
|
bbac1b37d5 |
Write down what breaks at the Rust/Python boundary
Plain words: the Rust engine and the Python code that calls it are built separately and meet at a thin layer of glue. Almost every failure that has reached a user lived in that glue, not in the trading maths -- and nothing in this repo said so anywhere a reader would find it. Adds .claude/skills/pyo3-interop/, which records the four traps in the order they have actually cost us: 1. A version floor is a floor, so a stale one fails silently. On 2026-08-06 the requirements floors sat at >=0.6.0 while the code needed >=0.6.2; the paper image had cached 0.6.1, the resolver was satisfied, and nothing warned. Bumping the version here is step one of five -- the other four files are in the backend repo. 2. `extension-module` is on by default, so `cargo test` cannot link. Verified by running it: undefined symbol PyList_New, _Py_TrueStruct. The flag `--no-default-features` is not optional, and the failure is inscrutable without knowing why. 3. A panic crossing FFI becomes PanicException, not the ValueError the caller catches. src/ has 223 unwrap/expect/panic sites but src/python/ has 6 -- that ratio is the invariant worth protecting. 4. _raptorbt.pyi is 795 lines of hand-maintained stubs that py.typed tells consumers to trust, and no test compares them to the real signatures. Every figure above was measured against the tree, not recalled. Also surveyed six third-party Rust skill packs; not one mentions PyO3, maturin, abi3, or the GIL, which is why this had to be written rather than installed. The one worth keeping (leonardomso/rust-skills, 265 rules) sits untracked on disk and is gitignored -- a clone restores it, so it does not need to be in this history. SKILLS.md carries the restore command and records which packs were rejected and why. .gitignore also stops settings.local.json, which is per-machine, from riding along with any future .claude commit. No user-visible change; no engine behaviour is touched. |
||
|
|
d99661fb98 |
Merge pull request #18 from alphabench/release/0.6.3
0.6.3: adoption fixes, long/short optimizer, update notice |
||
|
|
ecec74a8df |
fix: make the version check fail silently, and prove it
The check already swallowed its failures, but "silent" had a hole I had asserted rather than tested: nothing wrapped the WORKER THREAD itself. An exception escaping _run onto the thread is printed as a full traceback to stderr by Python's default threading.excepthook, which no try/except at the call site can intercept. _run's own catch-all made that unreachable in practice -- but it was a single point of failure guarding the loudest possible outcome, one refactor away from a traceback in a production trading log. Adds _thread_body, a catch-all at the thread boundary, and points the thread at it. Costs a function call. Also pins the quiet level. The notice is INFO, not WARNING, for a concrete reason: with no logging configured, logging.lastResort prints WARNING and above to stderr, so a WARNING would appear on stderr for every user with default logging. A library telling you to upgrade has not detected a problem with your program. Four of the silence tests were decorative and are rewritten. They had to move to subprocesses, because the test harness masks exactly what they check: - caplog attaches a root handler, which suppresses logging.lastResort -- so a logger.warning() that WOULD hit a real user's stderr printed nothing under pytest. Raising the notice to WARNING passed green. - monkeypatching version_check._run left _thread_body resolving the patched name, so deleting the thread guard, or pointing the thread straight at _run, both still passed. Now verified by subprocess with no logging configured: a dead network, a raising fetch, a leaky _run, and a plain `import raptorbt` all produce completely empty stderr. 8 silence mutations, each confirmed to fail the suite: thread-body guard removed, thread target rewired to _run, notice raised to WARNING, a failure logged at ERROR, the _run guard removed, the cache-read guard removed, and print() swapped for the logger. (A ninth -- removing _fetch_latest's guard -- stays green and correctly so: _run's catch-all absorbs it, and that layer is pinned on its own. That is what defence in depth means.) 41 tests for this module, 315 Python total. User-visible: unchanged from the previous commit for anyone on an old version. For everyone whose network cannot reach pypi.org, the guarantee is now tested rather than claimed -- they see nothing at all. |
||
|
|
7475b4b6c1 |
feat: tell the log when the installed raptorbt is out of date
Plain words: on import, raptorbt asks pypi.org what the newest published version is and writes one INFO line if yours is older -- "raptorbt 0.6.2 is behind the latest release 0.6.3. Install the latest version: pip install -U raptorbt". That is the whole feature. It changes no engine behaviour. This runs on the import path of a production trading service, so the design is dominated by what it must NOT do. Four properties, each pinned by a test that was proven to fail when the property is removed: - It cannot block. The request runs on a daemon thread with a 2s timeout, so `import raptorbt` returns at once and a hung socket cannot hold up interpreter shutdown. - It cannot fail. Every path is wrapped, at two independent layers. No DNS, a proxy, a read-only cache dir, or malformed JSON all end in silence. - It cannot spam. The answer is cached on disk for 24h, so a restarting fleet of services is not a burst of PyPI requests. - It can be turned off. RAPTORBT_NO_VERSION_CHECK=1 opens no socket at all. CI is skipped automatically -- a pinned wheel there is deliberate, so the nag would be noise. The comparison is deliberately conservative: it parses only plain dotted releases, and anything else -- a pre-release, a local build, or the "unknown" of a source checkout without install metadata -- yields no message rather than a wrong one. An install AHEAD of PyPI (a local dev build, or 0.6.3 before it is published) is not "old" and stays silent. Comparison is numeric, not lexicographic, so 1.0.0 is not reported as behind 0.9.9. 35 new Python tests. Every guard proven by injection per the repo rule that a guard which only passes green is decorative -- 14 separate mutations, each confirmed to fail the suite. Four of those mutations initially passed and the tests were rewritten: two catch-all layers meant neither was individually covered, a thread assertion scanned an enumeration the finished thread had already left, and the skip tests asserted no-fetch in a state where a warm cache made that true regardless. User-visible: anyone on an older raptorbt now sees one log line telling them to upgrade. Anyone up to date, offline, in CI, or opted out sees nothing. |
||
|
|
05332328e2 |
style: apply cargo fmt to the long/short optimizer changes
CI's `cargo fmt --check` failed on the previous commit. The repo's rustfmt.toml sets `use_small_heuristics = "Max"`, which collapses short struct literals and assert! calls onto one line; the new long/short test bodies and the adoption-ordering error return were written expanded. Formatting only -- no behaviour change, no user-visible change. |
||
|
|
9c2b320b61 |
feat(optimize): long/short mode — short_cap, gross/net budgets, gross sector caps
Ships in the ongoing 0.6.3 release (version unchanged; the changelog entry folds into 0.6.3's). Plain words: 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 — bounded per name by short_cap, in total size by gross_max (sum of |w|), and in directional tilt by net_min/net_max (sum of w; both zero pins a dollar-neutral book). Nothing changes for existing callers: the default short_cap = 0 poses the byte-identical long-only problem (the auxiliary |w| variables and every new row exist only when shorting is enabled), pinned by short_cap_zero_is_inert_golden_equivalence. Sector caps are GROSS in long/short mode — a cap bounds the size of a sector's bets, not their direction; for a long-only book gross and signed sums coincide, so the modes agree where they overlap. Results gain gross_exposure/net_exposure; cash stays 1 - sum(w) and is documented as net-based. optimize_book is the honest Rust name; optimize_long_only delegates. Short position ADOPTION stays refused, now pinned by name (short_adoption_stays_refused_by_construction): posted broker collateral is not derivable from qty x avg price, and no supported flow seeds a short. 427 lib tests green (8 new long/short cases + the inertness pin). |
||
|
|
7fdd239caa |
chore: release 0.6.3 — adoption ordering and fully funded margin seeding
Bumps Cargo.toml / pyproject.toml to 0.6.3 and adds the CHANGELOG entry for the two adoption fixes in this branch. Also corrects every place the docs still claimed "cash accounts only", which became false with the previous commit: the PyO3 docstring, the TickStrategyStream docstring, the type stub comment, and the README's adoption section. The 0.6.2 CHANGELOG entry is left as written — it was accurate for that release. The README's adoption section now also documents the ordering requirement and why it is enforced, with the measured drawdown numbers. |
||
|
|
6ed12875d0 |
fix(adoption): let a fully funded margin book be seeded
Plain words: a strategy that holds even one short position could not be deployed with a seeded holding at all. This fixes that. A short leg only transacts as a short under a margin account — in cash mode its P&L never reaches equity — so the runtime gives any strategy with a short `account_type="margin"` at leverage 1.0, which keeps the book fully funded. Adoption refused margin outright, so the seed and the short were mutually exclusive: TickStrategyStream raised at construction and the deploy died before it began. The session now defers the account-mode decision to the kernel (which learned mode-correct funding in the previous commit) and carries the locked delta through its lend/drain wrapper. Account::reconcile already took a delta_locked argument; adoption was passing a hardcoded 0.0. Left that way, the shared account never learns about the adopted margin and portfolio free capital reads high by the entire cost basis — every later entry then sizes against money that is not there. Verified by injection: restoring the 0.0 fails two tests. Leveraged books are still refused, now with a message that says why rather than the blanket "cash accounts only". Tests: the long/short seeding scenario that could not deploy; cash and fully-funded margin agreeing on equity, free capital, open PnL, fees, closed trades and max drawdown; margin locking rather than debiting; leverage 2.0 still refused. The module docstring stated the cash-only refusal as deliberate design and would have been left contradicting the code, so it is rewritten. 274 python + 439 rust tests pass; no golden fixture moved. User-visible: a holding-coverage strategy that takes a short position can now be deployed against shares the user already owns. Long-only seeding is unchanged. |
||
|
|
c33bcb5fc2 |
fix(adoption): fund a margin adoption by locking, not by debiting cash
Groundwork only — the session still refuses margin adoption, so nothing
changes from Python in this commit. Splitting it out keeps the arithmetic
revertible on its own.
Adoption unconditionally did `self.cash -= price * size * multiplier`, which
is the CASH mode formula. The two modes fund an open differently (see
`open_at`): cash debits the balance, margin locks the notional and leaves the
balance alone. Measured, one long opened at 90x100 via the normal path:
cash balance 0 equity 100000
margin balance 100000 equity 100000
Margin equity is `cash + unrealized`, with no position-value term, so a
cash-style debit under margin is never offset — it would understate equity by
the cost basis for the whole run. That is why simply relaxing the cash-only
predicate would have been worse than the refusal it removed: a silent wrong
number in place of a loud failure.
adopt_position now dispatches on margin_rate() exactly as the ordinary open
path does, with no fee term in either arm since an adoption is not a trade.
Leveraged books stay refused, and the original rationale is why: the margin a
broker has already posted against a position it holds is not derivable from
quantity and average price, and inventing a figure would misstate free
capital, which gates every later entry. At an initial 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.
Pinned by three kernel tests: fully funded adoption locks the cost basis and
leaves the balance untouched; cash and fully-funded margin agree on equity at
several closes and on free capital; leverage 2.0 is refused. 439 rust + 270
python tests pass.
No user-visible change yet.
|
||
|
|
37f85a2845 |
fix(adoption): refuse mid-run adoption, which understated max drawdown
Plain words: seeding a holding after the run had already started made the
strategy look LESS risky than it really was.
The equity curve is written streaming — one sample per applied event, against
a running peak seeded to the initial capital. A position adopted mid-run
leaves that curve flat for the pre-adoption stretch, which holds the peak
down, so the decline that follows is measured against a high-water mark lower
than the truth.
Measured on a 6-bar 100->95 fixture adopting 100 shares at 90:
adopt pre-run (correct) max drawdown 0.495%
adopt after 3 applied events max drawdown 0.199%
Total return and open_trade_pnl are IDENTICAL in both, so nothing in the
headline numbers hints at it. Only the risk metric moves, and it moves to look
safer — the worst direction for a risk metric to be wrong in. 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 the ordering is
now refused.
The gate is `equity_curve.is_empty()`, NOT the event cursor. A quote or depth
snapshot advances the cursor but samples no 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 — a broker's holdings callback can easily return after them. Gating on
the cursor would reject that valid sequence. A test pins it.
EngineKernel is unchanged in behaviour: it owns no equity curve and cannot
exhibit this defect, so it gets a doc comment stating that a consumer driving
it directly owns the ordering, rather than a new field guarding a symptom it
does not have. Every path reachable from Python goes through EventSession.
Verified by injection: removing the guard fails both the new Python test and
the new Rust test. 270 python + 436 rust tests pass; no golden fixture moved.
User-visible: a seeded deployment that adopts correctly (every supported
path does) is unchanged. Misuse now raises instead of silently reporting a
flattering drawdown.
|
||
|
|
0222136688 |
docs+types: bring 0.6.x changelog, README and stubs up to date
Plain words: the docs stopped describing the engine four releases ago, and the type stubs disagreed with the code they describe. CHANGELOG had 0.6.0 covering only the sided-orders half of the release; the ~3,270-line src/portfolio/ addition (covariance, optimizer, factor panels, rank-IC, risk contributions, rebalance costs) was never written up. Adds that, plus the missing 0.6.1 and 0.6.2 entries. README documented neither TickStrategyStream nor position adoption, and its changelog stopped at v0.4.0. Adds a live-feeds section and a position-adoption section, and summarises 0.4.1-0.6.2 pointing at CHANGELOG.md as the maintained record rather than duplicating it. _raptorbt.pyi had drifted from the module it types: - oi_position_pct declared 1 arg, takes 3 - PyPortfolioSession.adopt_position missing entirely - push_tick/push_bar/push_depth/remaining (the 0.5.0 incremental feed) absent - PyStopConfig/PyTargetConfig were bare `...`; PyBatchSpreadItem lacked its readable attributes Audited programmatically against the compiled module; no drift remains. mypy goes 25 -> 21 errors, none in the stub. The now-complete PyPortfolioSession stub surfaces 4 pre-existing dtype mismatches in tick_stream.py that the gaps were hiding. Adds tests/python/test_position_adoption.py: 18 guard tests pinning that adoption charges no fees, writes no closed trade, and is visible before the first event -- the three properties that distinguish it from faking a buy. Verified by injection: a fee-on-adoption regression fails 3 tests, a double-adoption regression fails 1. No engine behaviour change. |
||
|
|
7a651d4aab |
Merge pull request #17 from alphabench/release/0.6.2
Position adoption: seed a stream with what the account already holds |
||
|
|
e344344723 | chore: satisfy rustfmt in kernel.rs | ||
|
|
e043fc8ae3 |
Position adoption: seed a stream with what the account already holds
Plain words: a strategy attached to a stock the user already owns must start KNOWING it holds those shares at the user's real average cost — without pretending a buy happened. This adds adopt_position through the stack: - 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 cost basis so equity stays initial + unrealized, like an account that bought earlier. Cash accounts only (margin adoption is refused, not guessed). - PortfolioSession::adopt_position — same lend/drain pool discipline as apply_current, so the cost basis comes out of the shared cash pool. - PyPortfolioSession binding + TickStrategyStream(initial_positions=...) — adoption happens before warmup replay and before the first push, so the position is in every before-snapshot: a caller diffing positions() around pushes can never read it as a fresh entry. Design reference: NautilusTrader's position adoption in live execution reconciliation (adopted state coexists with the order lifecycle without synthetic fills). Ported as a design, not as code. Consumed by alphabench/quant5 holding coverage (seeded paper deployments). |
||
|
|
1afe8b63f8 |
Merge pull request #16 from alphabench/release/0.6.1
release: 0.6.1 — overlap-deflated rank-IC t-statistic |
||
|
|
af11e47c14 |
feat(portfolio): overlap-deflate the rank-IC t-statistic
`rank_ic` 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 ~21x and the t-stat is inflated by ~sqrt(21). This is not theoretical. The same correction turned the Indian fund cross-section's t from +4.78 to +1.04 — 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. 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. `RankIc` gains 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 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. Bumps to 0.6.1: purely additive, no existing field changes meaning. |
||
|
|
fd7ac4c0d3 |
Merge pull request #15 from alphabench/release/0.6.0
raptorbt 0.6.0 — sided orders, tick-resolution sessions, and portfolio risk tooling |
||
|
|
c917806c18 |
test(golden): replay frozen inputs instead of regenerating them
The golden gate rebuilt its OHLCV series through make_data() on every run, so fixtures.json pinned outputs against inputs that were recomputed by NumPy at test time. That made the gate assert on NumPy composed with the engine rather than on the engine, and the composition is not stable: the vectorized exp/cumsum kernels behind make_data() are not correctly rounded, dispatch on CPU features, and changed across the 1.x-to-2.x boundary. The result was a gate that reported on the runner instead of the code. Identical commits passed on macOS and failed on ubuntu by 1-2 ULP; ubuntu 3.10 and 3.11 failed only the portfolio fixture, whose n=300/seed=11,12,13 draw lands on a different exp() path than the n=400/seed=7 series every other fixture shares; 3.12 failed all fifteen, being the first interpreter in the matrix to resolve NumPy 2.x. The arrays are now written into fixtures.json as float hex and replayed verbatim, so the gate sees identical inputs everywhere and fails only when the engine's own numbers move. Every pre-existing output is bit-identical -- the baselines are untouched and this is not a fixture regeneration. Verified in both directions: perturbing make_data() by 1 ULP no longer moves any fixture, while perturbing a frozen input still fails the gate. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
231ba4ce1f |
ci: satisfy rustfmt and pin the interpreter pyo3 can build against
Two unrelated CI failures, both mechanical. `cargo fmt --check` failed on 143 hunks across 32 files that had drifted from rustfmt's output -- long signatures left unwrapped, array literals hand-packed, `use` blocks out of order. Running `cargo fmt` settles all of them. No token is added or removed: the 431-test Rust suite and clippy both pass unchanged. `cargo test (macos-latest)` failed before compiling anything. pyo3 0.20.3 supports 3.12 at the newest and its build script aborts against later interpreters; the macOS runner image now defaults to 3.14. Neither the `test` nor the `lint` job pinned a version, so both inherited whatever the image shipped -- `lint` survives only because ubuntu's default has not moved yet. Both now pin 3.12 explicitly, which keeps a runner upgrade from breaking a build that this repo did not change. The golden-fixture failures in the wheel jobs are a separate problem and are deliberately untouched here. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
6f5aa2ffb5 |
feat(portfolio): rank-IC measurement for factor validation
rank_ic: per-date Spearman rank correlation between a factor panel and forward returns at a chosen horizon, returning the mean IC, naive t, and an overlap-DEFLATED t (daily ICs over an N-day forward window are ~N-fold serially dependent; the naive t overstates significance by ~sqrt(N)). 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 without the 25 precious-metal funds -- a metal rally, not a factor. The fund ranking was therefore not shipped. |
||
|
|
a6230cbbb6 |
feat(portfolio): covariance, constrained optimizer, factor panels, risk contributions, rebalance simulation
- covariance.rs: Ledoit-Wolf shrinkage (constant-correlation target), chol_strict, RiskModel carrier pinning periods_per_year + asset ordering structurally - optimize.rs: long-only QP via Clarabel (new dep, pure Rust) with L1 turnover penalty, position/sector caps, explicit cash; post-solve no-trade band and min-trade-value snaps; all-trades-snapped returns the status-quo book (turnover 0) while a partial snap-strand refuses with arithmetic - factor_panel.rs: winsorize/zscore/rank/momentum/composite over row-major panels; NaN = absent, infinity = hard error; no factor list in Rust - risk_contrib.rs: Euler decomposition (contributions sum to sigma) - rebalance.rs: policy simulation on the Indian delivery schedule plus the flat DP sell charge (Rs 15.34 per ISIN per day with any sell) -- the cost that dominates small books; DP constant + indian_cost_schedule export in execution/indian_costs.rs - bindings in python/portfolio_bindings.rs incl. rayon batch_optimize_portfolios (deterministic, batch == serial); numpy_to_vec2_f64 bridge; stubs + exports |
||
|
|
4f96642fc4 | feat: add maintenance margin logic for fully funded positions and corresponding tests | ||
|
|
b753e348bb |
docs: document choosing a side in the README
The 0.6.0 order-side change is unreachable if nobody knows the methods exist. Covers enter_long/enter_short, the flat-before-flipping rule, and the netting semantics an opposing order still has. |
||
|
|
5e7ed50aad |
feat: enter_long() / enter_short(), and bump to 0.6.0
`enter()` could only open 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, and `enter(side=...)` is there for computed sides. Without `side`, `enter()` queues the same intent it always did, so every existing strategy and every `direction=` / `directions=` run is unchanged. A sided entry rides the typed-order path, where the side survives to the kernel and decides the position's direction. It 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. `submit_bracket` now marks its protective legs `reduce_only`, so a leg still working after the position closed by another route cannot open a fresh position on the opposite side. |
||
|
|
4a6eb6b2d1 |
feat: an order's side opens the position
Under netting an order's side was not a direction at all: it selected open-vs-close against the kernel's fixed `direction`. A sell on a flat long-registered instrument was therefore read as a close, found no position, and vanished — no trade, and no rejection either, because the `no_position` reject never touched the risk gate's counter. That made a cross-sectional long/short book inexpressible in one run: a leg's side was bound at registration and could not flip as its rank changed. Side is now authoritative for opening. Flat plus any side opens in that side; an order opposing an open position still closes it, so bracket legs and take-profit orders are unaffected; `reduce_only` short-circuits to the closing branch so a protective leg left working after a stop already exited can never reverse into a fresh position. The kernel's `direction` field now governs the signal path alone, leaving `enter()`, the signal arrays and every `direction=`/`directions=` caller untouched. Also: every refused order now counts against `rejected_entries`, so a discarded order can never again look like an order that was never placed. Counting is the default in the reject helper; the two deliberate exceptions (sizing arithmetic, which `open_at` already reports, and a close that could not fill, which is not a refused *entry*) say so explicitly. And an order-path open now honors an ATR stop/target config instead of passing a hardcoded zero ATR, which silently yielded no stop. |
||
|
|
627b62613f |
0.6.0: incremental session feed, TickStrategyStream, context position parity
Live-feed support: PyPortfolioSession gains push_tick/push_bar/push_depth plus remaining(); TickStrategyStream drives open-ended feeds through the same dispatch loop as run_tick_strategy, with real bars executing in streaming sessions. A push-per-row stream reproduces batch replay results bit-identically. Unify position state across contexts: PortfolioContext/TickContext now expose position, positions-derived flags (is_flat, is_net_long, is_net_short) and net_position as properties for the current symbol, matching the single-instrument StrategyContext, so a bar-style strategy (`if ctx.position is None`) behaves identically on the live stream. Cross-symbol lookups move to position_for(symbol) / net_position_for(symbol). TestContextParity locks bar-vs-stream trade equivalence. Also untracks a stray committed __pycache__ artifact (already gitignored). Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
3e84baf652 |
0.6.0: reach the option-settlement fix from Python
The previous commit fixed settle_expiry to honor an underlying price, but set_underlying_price existed only on the Rust kernel — no Python strategy could call it. So a Python user holding an option to expiry still got it settled at its own last close, which was the whole bug. The fix was real but unreachable. Exposes it on both session bindings and as ctx.set_underlying_price, routed per symbol in portfolio runs. A test settles a 100-strike call at intrinsic 12.0 against spot 112 rather than the 0.50 the contract itself last printed — a 24x difference on the exit, so this mattered. Also pins the per-symbol clock fix at the Python surface for both the portfolio and tick runners, where it was only checked by a throwaway probe before. One self-inflicted regression caught and fixed along the way: an edit stripped the @property off ctx.positions, turning it into a bound method; the existing hedging test caught it, which is the argument for running the whole suite rather than just the new file. 356 Rust / 207 Python tests, golden bit-exact. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
6650d57854 |
0.6.0: limit slippage, forced liquidation, settlement fees, per-symbol clocks
Four deferred knobs and two bugs the survey turned up. Everything new is default-off, so nothing existing moves. limit_slippage adjusts limit fills adversely; they 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 held that price, and slipping it too would charge twice for one queue. liquidate_on_margin_call force-closes instead of only latching a halt. Unlike expiry settlement and end-of-data finalization, which both close free, a liquidation is a real trade-out: it prices through the fill model and pays exit costs, under a new ExitReason::Liquidation. The session fans it out per instrument through the shared account's lend/drain. InstrumentSpec.settlement_fee charges on the settled notional at expiry. It belongs on the spec rather than the config because exercise is commonly priced differently from a trade-out and a portfolio run needs per-instrument rates. Two bugs found while scoping, neither in the brief: Options never settled to intrinsic value. settle_expiry passed None as the underlying, so the option branch of settlement_value could not match and every option settled at its own last close — the function and its unit tests supported intrinsic settlement, but no caller ever reached it. set_underlying_price lets a strategy supply the price; without one the old behavior stands, since an option's bars carry the option's price and there is no second series to read. Timers fired for one symbol only. The clock was global, so a timer set in on_start fired for whichever symbol's event crossed the threshold first and never for the rest — a probe showed a two-symbol heartbeat delivering exactly half its beats. Each symbol now carries its own clock seeded from whatever on_start scheduled. Single-symbol runs are untouched. FX rollover and funding stays deferred: it needs a new phase in the load-bearing step pipeline, which would collide with the kernel.rs split. 356 Rust / 202 Python tests, golden bit-exact. Co-Authored-By: Pier Code <no-reply@piercode.com> |
||
|
|
10e269010d |
0.6.0: trading-date DAY expiry and TWAP schedules
Two items in one pass. TimeInForce::Day compared UTC dates, so a session whose local hours cross UTC midnight would expire a DAY order while its trading date was still running. session_tz_offset_ns shifts the date the comparison uses and defaults to 0, which is the same arithmetic as before. Worth being plain about the framing: NSE 09:15-15:30 IST does not cross UTC midnight, so this is a latent fix rather than a live bug for the primary users. It follows the trading date, not the trading session — a DAY order still survives past the close to the next session of the same date, which is a larger change and stays out of scope. TWAP arrives as a schedule, not an order. Making it a parent order would have deadlocked its own slices: the one-triggers-other gate holds a child until the parent fills, and a schedule never fills, so every child would wait forever. Slices are ordinary orders carrying only a back-pointer, and the matcher is untouched. Slicing is timed rather than counted in bars because idx is a bar ordinal in a bar session and an event ordinal in a tick session — "every 1 bar" would silently become "every 1 print" on a tick feed and collapse a five-slice TWAP into a burst. There is a test for exactly that. Slices release inside the step, immediately before the market sweep, so a slice fills on the step it was released on; that ordering is the design's load-bearing detail and has its own test. Only explicit units can be sliced: size_frac resolves against equity at fill time, so each slice would size against a different account. Cancelling stops the remainder without unwinding what traded, and completion means fully released rather than fully filled. Slice sizes are carved from cumulative boundaries so they sum to the total exactly, including when the division is not even. Only TWAP ships. VWAP needs a volume forecast and POV needs partial fills, which item 5 established the engine cannot express yet. 344 Rust / 193 Python tests, golden bit-exact. Co-Authored-By: Pier Code <no-reply@piercode.com> |