Repository navigation
v0.18.0
The member crates move with the core because they now depend on nanobook 0.18
and its fractional Position.quantity: nanobook-broker 0.8.1 to 0.9.0,
nanobook-risk 0.6.4 to 0.7.0, nanobook-rebalancer 0.8.4 to 0.9.0. Their
published predecessors still pin nanobook 0.17.
Breaking (nanobook 0.17.2 → 0.18.0):
Position.quantityisShares, noti64.- The Python
Position.quantitygetter returns a float. backtest_weights_with_optionstakes new optional fields on
BacktestBridgeOptions; existing calls keep compiling, and every new default
is a strict no-op.
Added
- Optional
fill_scheduleonbacktest_weights/py_backtest_weights.
Callers that decide on a coarse calendar (monthly rebalances) but want to
fill on the real next trading bar can pass a parallel bar schedule. When
supplied,NextBarOpen/NextBarTypicalreadfill_schedule[i]instead
ofprice_schedule[i+1]; an empty entry means no fill for that period.
When omitted, behaviour is unchanged.SignalBarCloseignores it.
Changed
- BREAKING: position quantities are fractional.
Position.quantitybecomes
Shares, a newtype overi64counting micro-shares (1e-6), andPortfolio
gainsquantity_stepin the same unit —1_000_000for whole shares
(default),1_000for 0.001,1for effectively continuous. Whole shares
were not a rounding detail at small size: $1,000 spread over 20 names priced
near $219 rounds every target to zero and the portfolio holds nothing. A
newtype rather than an alias so the compiler flags every call site instead of
letting them silently mean something 1e6 different; fixed point rather than
f64so results stay reproducible regardless of summation order.notional
goes throughi128before narrowing, since micro-shares of a large position
exceedi64. The order book is untouched —Quantity = u64still drives
matching, because a limit order book matches whole lots. - BREAKING (Python):
Position.quantityreturns a float. It called
Shares::whole(), so a position of 0.4 shares reported 0 and was invisible
from Python.quantity_microgives the exact integer andquantity_whole
the old truncated value. - The monthly value budget is an exact cap, not an approximation. Each
order was tightened against the budget remaining at the start of a period,
so every order looked compliant alone while the period overspent — three
positions each fitting under a $20,000 budget spent $30,000 of it. It is now
a running sum over the priority-sorted orders. This matters because
transaction volume relative to portfolio size is a criterion that can
reclassify a private investor as a professional trader for tax, and a cap
that can be exceeded by half again is not a cap. - CI now lints the whole workspace.
cargo clippyran without
--workspace, so only the root crate was checked and broker, risk and
rebalancer accumulated 141 unchecked findings. Clearing them was mostly
mechanical: 46 were the100_00= $100.00 digit-grouping convention, which
the root crate had declared crate-wide but the other two had not — now set
once per crate in[lints.clippy], which reaches integration tests as well
(each file undertests/is its own crate root, so alib.rsattribute
cannot). Another 38 were machine-applied bycargo clippy --fix. The
remainder were dead-code warnings on shared test mocks:mock_binance.rs
andmock_tws.rsare each pulled into four test binaries, so a method one
binary drives looks dead to the other three. - Public API baselines regenerated (
docs/public-api/) — they were eight
releases out of date. Nothing was removed; the growth isscenariosand
indicatorsarriving in v0.17.0, plus a rendering change in newer
cargo-public-api. Regeneration is now a step inRELEASING.md, and
docs/public-api/README.mdrecords the tool version, since the output
format is not stable across releases of it. rebalancer killstates plainly that it does nothing on Windows. It
already refused there, but with a bare "not fully supported"; it now names
the manual steps (taskkill, then cancel open orders at the broker) so an
operator is not left assuming an emergency stop happened.
docs/operations/kill-switch.mdmarks live execution on Windows unsupported.ibapimajor bumps are now ignored by dependabot, with the reasoning in
.github/dependabot.yml. 3.x removes the arm that detects IBKR disconnects
mid-execution; it needs its own change driven by the mock-TWS drills rather
than arriving as a weekly PR. Minor and patch bumps still come through.
Fixed
- Buys never spend cash the account does not have.
execute_filldeducted
an unaffordable buy withsaturating_sub, so cash went negative and the
portfolio held more than its equity — leverage financed by an overdraft no
broker extends. On a $1,000 account (10 equal-weight names, US-equities-tiered
costs) that stabilised around cash −$5.30 / invested 100.55%. Buys are now
truncated to the largestquantity_stepmultiple whose notional plus
CostModel::compute_costfits in cash; a quantity that snaps to zero is
skipped entirely (no zero-notional fill that would still charge
min_commission). Sell quantities are never reduced, but a sell whose
min_commissionexceeds cash plus proceeds is skipped whole — reducing it
would only make the floor worse. The buy-side trim is observable through
trimmed_fill_countandtrimmed_shortfall_centsso the portfolio cannot
quietly diverge from the strategy with nothing recording why. Additive
accessors only —new,with_quantity_step,rebalance_simple, and the
execute_fillcall sites keep their signatures.
Added
-
Execution constraints on
Portfolio, as parameters rather than policy:
no_trade_band_bps(leave a position alone until it is off target by more
than this),min_order_value(never place an order below this notional),
max_trades_per_rebalance,max_order_value(truncates the order rather
than dropping it, the way a desk works a large order down) and
max_rebalance_notional. Which values are right depends on the account and
the venue, so they are something to sweep or solve for, not to hardcode.These exist because of what fractional shares uncovered. A portfolio with
constant target weights and flat prices placed 360 orders across 36
rebalances of 10 positions; the correct answer is 10, the initial buy. The
loop is driven by commission itself — a rebalance pays commission, equity
falls, every target value falls with it, so next period every position reads
as marginally over target and gets corrected, which costs more commission.
Whole-share rounding used to mask this as an accidental no-trade band. A band
of one basis point brings it back to 10 orders, which is the measure of how
little those other 350 were correcting.Defaults (
0,0.0,None) are a strict no-op and reproduce the previous
behaviour exactly. When a cap binds, the surviving orders are those furthest
from target with ties broken by symbol, so the result never depends on hash
iteration order. -
Windowed trade budgets on the backtest bridge:
max_trades_per_day,
max_trades_per_month,max_traded_value_per_month. They live in the bridge
becausePortfoliohas no concept of dates and does not gain one. The bridge
has no dates either, so the caller suppliesperiod_day_ordinaland
period_month_ordinal— plain monotonic integers. That keeps a calendar
library out of this crate, since the only questions asked are "same day?" and
"same month?", and it makes 24/7 venues fall out for free: several periods
sharing a day ordinal simply share the daily budget. A budget is inert
unless both the budget and its ordinal array are supplied — a cap can never
be enforced against a calendar the caller did not provide, so absent ordinals
fail open to no constraint rather than to a guessed one. Mismatched ordinal
length is an error, not a silent truncation. -
last_rebalance_order_countandlast_rebalance_notionalreport what a
rebalance actually executed, after every filter. Additive accessors rather
than a changedrebalance_simplesignature, which is published API. -
quantity_stepis settable from Python, so fractional sizing is
reachable through the bindings;Positionalso exposesquantity_micro
(exact) andquantity_whole(truncated). -
Frozen reference test for the native ChaCha20 Monte Carlo path
(native_mc_matches_frozen_reference). The existing reproducibility test
compares two runs inside one build, so it passes whether or not an RNG
upgrade moves the stream — therand0.8 → 0.10 bump in 0.17.1 had to be
checked by hand. The comparison is relative to 1e-12 rather than bitwise,
because the terminal math goes throughexp/lnwhose last ulp is
libm-dependent across platforms; any real stream change moves the numbers by
whole percent.