USD price ledger
Stage A supports diagnostic, long-only cash-equity and ETF accounting with USD cash. Quantities remain fixed between modeled transactions. Price and FX changes cause natural weight drift. Dividends, splits, futures, margin, shorts, foreign cash balances and total returns are unsupported.
Synthetic example
from datetime import UTC, datetime, timedelta
from decimal import Decimal as D
from research_contracts import ArtifactRef, ResearchClock
from portfolio_backtester.usd_ledger import run_usd_price_replay
from portfolio_backtester.usd_ledger_models import (
USDInstrument,
USDPriceObservation,
USDRebalanceDecision,
USDReplayConfig,
USDReplayRequest,
)
def t(day, hour=0):
return datetime(2026, 1, day, hour, tzinfo=UTC)
ref = ArtifactRef("synthetic-prices", "a" * 64)
instrument = USDInstrument("example", "etf", "USD", 1, "synthetic-session.v1")
prices = tuple(
USDPriceObservation(
"example",
time,
time,
value,
"currency_per_share",
ref,
"verified",
"synthetic-session.v1",
True,
)
for time, value in ((t(1), D("10")), (t(1, 1), D("10")), (t(3), D("20")))
)
clock = ResearchClock(
"UTC",
t(1),
t(1),
t(1),
t(3),
"synthetic-next-mark.v1",
"synthetic.v1",
t(1, 1),
t(1, 1),
t(1, 1),
)
config = USDReplayConfig(
D("100"),
"fractional",
D("0"),
D("0"),
D("0"),
timedelta(days=5),
timedelta(days=5),
{},
)
request = USDReplayRequest(
(instrument,),
prices,
(),
(USDRebalanceDecision("decision-1", clock, {"example": D("0.5")}, {"example": t(1, 1)}),),
(t(1), t(2), t(3)),
config,
)
result = run_usd_price_replay(request)
assert list(result.daily.nav_usd) == [D("100"), D("100"), D("150")]
assert result.holdings.iloc[-1].quantity == D("5")
assert result.daily.iloc[-1].cash_usd == D("50")
The initial 50% target buys five shares. It does not rebalance every valuation: when the price doubles, cash remains 50, position value becomes 100 and NAV is 150. The fixture uses invented timestamps and prices, not real session evidence.
Public interfaces
- Records:
portfolio_backtester.usd_ledger_modelsdefinesUSDInstrument,USDPriceObservation,USDModeledExecutionPrice,USDFXObservation,USDRebalanceDecision,USDReplayConfig,USDReplayRequest,USDReplayResult,USDValidationError. validate_usd_request(request: USDReplayRequest) -> Noneinusd_ledger_inputs.select_usd_price(request: USDReplayRequest, instrument_id: str, at: datetime, *, execution: bool = False) -> USDPriceObservation.select_usd_modeled_execution_price(request, instrument_id, at)selects only an exact caller-scheduled UTC open fromUSDReplayRequest.modeled_execution_prices.select_usd_fx(request: USDReplayRequest, currency: str, at: datetime) -> tuple[Decimal, USDFXObservation | None].value_usd_book(quantities, cash, local_prices, usd_per_local)inusd_ledger_accountingreturns Decimal position value and NAV.settle_usd_rebalance(quantities, cash, desired_quantities, local_prices, usd_per_local, instruments, config, *, execution_ids)returns quantities, cash and modeled transactions. Only the supplied batch IDs change.run_usd_price_replay(request: USDReplayRequest) -> USDReplayResult.write_usd_price_replay_bundle(output_dir: Path, *, result: USDReplayResult, run_id: str, research_clock: ResearchClock, producer: Mapping[str, Any], configuration_sha256: str, input_refs: Sequence[Mapping[str, Any]]) -> BacktestBundleManifestinusd_ledger_bundle.
Units, clocks and sizing
Use finite Decimal values for all economic fields and aware UTC timestamps.
Instrument currency is explicit; price unit is currency_per_share. FX unit is
quote_per_base. Each foreign currency selects one direct or inverse USD pair:
GBPUSD 1.25 means USD per GBP is 1.25; USDHKD 8 means USD per HKD is 0.125.
USD has an explicit identity conversion. There is no triangulation or provider
fallback. A series has one immutable source reference and no duplicate bar times.
Valuation selects the newest mark whose bar and availability times are at or before the event, bounded by configured price/FX maximum age. Every held asset needs marks. Unused assets and all-cash runs do not. Raw FX direction, source hash, units, bar time and availability time remain in detailed evidence.
Every decision includes its own ResearchClock and an execution schedule covering the instrument universe, including zero targets for liquidation. Windows cannot overlap. Execution is strictly after decision time, requires an exact eligible price timestamp with matching session-policy identity, and rejects assumed availability. These flags are caller attestations; the ledger is not a market calendar service and cannot establish their truth. Date-only historical CSVs cannot supply verified execution marks. A caller may instead wrap an observed daily open as a modeled reference with an explicit assumed session timestamp; that remains a research diagnostic. An assumed conservative lag may support valuations when explicitly enabled, but cannot make a carried close eligible.
For research-only next-open replay, set allow_modeled_execution_prices=True
and provide a USDModeledExecutionPrice for each changed instrument and its
exact scheduled execution timestamp. This separate input records the caller's
local session date, assumed UTC open, reference price, immutable source, session
policy and model ID. The timestamp must match a decision's execution schedule.
The platform does not infer the local session date from UTC or verify exchange
calendar correctness. In this mode, a missing exact reference is an error; the
ledger does not fall back to a close or verified observation. The reference is
not a broker fill and does not establish market availability; transactions
carry execution_evidence_kind="modeled_reference" and
execution_eligible=False, while orders and fills remain empty.
Modeled buys apply positive signed slippage to the reference price and modeled
sells apply negative signed slippage. Commission and FX costs remain separate;
the replay and bundle publisher reconcile the resulting cash, inventory and
NAV. An FX observation with availability_basis="assumed_market_session" is
accepted only when both allow_assumed_availability and
allow_modeled_execution_prices are enabled. This is an explicit FX timing
assumption, not proof of a tradable conversion quote. The verified execution
selector remains unchanged and cannot select a modeled reference.
Desired quantities freeze from pretrade NAV and available marks at decision time. Events execute chronologically. Same-time sells settle first, then affordable buy increments scale proportionally including costs. Future sells cannot fund earlier buys; a scaled batch is never retried silently. Commission and FX costs are separate fractions of reference USD notional. In modeled-reference mode, slippage is reflected in the signed execution price and reconciled separately as a cost; in verified mode the existing slippage cost treatment is retained. USD assets incur no FX fee.
Fractional increments round down to 1e-12 shares. Integral increments round down to each positive integer instrument lot; partial sells also round down, while a full liquidation may sell a held odd lot. USD cash is retained with zero interest. Spot conversion is modeled per transaction; there is no foreign cash account.
Reconciliation and publication
Daily NAV equals cash plus marked inventory. Between mark updates, local P&L is
(P_new-P_old)*F_old*q and FX P&L is P_new*(F_new-F_old)*q, on quantities held
before the update. Costs reduce NAV once. Arithmetic uses local Decimal precision
50; reconciliation permits only relative 1e-45 arithmetic roundoff.
The replay retains a sequenced event log: mark updates include pre-update quantities and old/new price/FX; decisions retain pretrade NAV; modeled transactions and valuations reference their event sequence. Target records retain decision NAV and complete selected-mark metadata.
The publisher independently replays these events and recomputes both P&L
components, target sizing, cash, inventory, costs, holdings and source lineage
before checking root bounds and using the existing immutable, hashed writer.
Aggregate Parquet economic values and detailed JSON economic values are Decimal
strings. This preserves precision; consumers must parse Decimal explicitly.
The existing generic bundle reconciliation also produces its usual numeric
receipt; diagnostics.json/usd_reconciliation records the stricter USD checks.
The root clock starts at the first decision and ends at the final valuation; its execution bounds cover the run. Per-decision clocks remain authoritative for later decisions. The root information cutoff does not authorize all later inputs. An all-cash run may use a root decision at or before its first valuation. Lineage is nonempty even for an all-cash run, e.g. the configuration artifact.
Results are diagnostic and usd_price_nav, with orders_submitted=False.
Orders/fills are empty and lifecycle capability is false. Modeled transactions
are separate evidence, not broker fills. The execution-aware multi-decision
restriction remains unchanged. Report cumulative return, drawdown and
calendar-time CAGR (365.25 days/year); a single valuation has undefined CAGR.
Sharpe is omitted until an explicit sampling policy exists.
Research consumers must pin a reviewed reachable platform revision and supply released datasets and versioned signal schedules. Strategy choices, provider acquisition and runtime orchestration remain outside this platform. Total-return accounting and futures settlement require separate capabilities and plans.