feat(strategy): share causal ledger and bounded seven-strategy research
CI / lite (pull_request) Canceled after 0s

This commit is contained in:
ao gong
2026-10-04 11:10:01 +08:00
parent 3c97102f64
commit 7d3e840483
12 changed files with 1760 additions and 7 deletions
+1
View File
@@ -32,6 +32,7 @@
- `retrospective_*_contracts` — 未发布的显式 v2 回顾性合同:区分历史业务日期与实际可得/计算时间,保留 v1 和现有金融公式,不授予历史可得性、发布或执行权限;见 [v2 接口说明](docs/RETROSPECTIVE_COMPUTATION_V2.md) - `retrospective_*_contracts` — 未发布的显式 v2 回顾性合同:区分历史业务日期与实际可得/计算时间,保留 v1 和现有金融公式,不授予历史可得性、发布或执行权限;见 [v2 接口说明](docs/RETROSPECTIVE_COMPUTATION_V2.md)
- `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计 - `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计
- `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效 - `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效
- `strategy_research` / `strategy_optimizer` / `trade_pairing` — 七策略候选、下一日开盘执行、统一账本、FIFO双边成本和有界真实优化;见 [策略合同](docs/strategy-research.md)
- `factor_diagnostics` — 候选0.1.0:完整键配对、逐日IC/RankIC、样本与未定义值、显式日历前瞻标签;见 [诊断合同](docs/factor-diagnostics.md) - `factor_diagnostics` — 候选0.1.0:完整键配对、逐日IC/RankIC、样本与未定义值、显式日历前瞻标签;见 [诊断合同](docs/factor-diagnostics.md)
- `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test) - `factor_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test)
- `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因) - `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因)
+64
View File
@@ -0,0 +1,64 @@
# Seven-strategy candidate research
The core owns the seven example strategies, sequential decisions, shared execution ledger, FIFO trade pairing and bounded exhaustive optimization. The platform supplies one selected asset's OHLC and displays results. These pure functions perform no source reads, persistence, publication or order routing. `decision_eligible` remains false. Caller dates do not establish an exchange calendar or historical data availability.
## Reuse and scope
Requirement: reproduce the seven platform examples with explicit causal timing, cash/cost accounting, benchmark status and testable results.
Existing capabilities: platform strategy definitions, core `simulate_daily_ledger_with_audit`, cash-constrained fills and `metrics.summary`/`benchmark_summary`. The shared ledger is reused, including fee calculation. The new policy input adapts stateful decisions to that ledger; there is no second accounting engine. FIFO pairing adds the previously missing entry-cost and completed-lot interpretation. No external package or new dependency is needed.
Financial mathematical behavior is L3. The candidate is isolated to the original core branch/PR. It neither changes formal source admission nor asserts exchange lot sizes, tradability, T+1, price-limit, volume or point-in-time coverage. Those require qualified caller facts and further integration. Existing independent execution-constraint helpers are not silently enabled by this research interface.
## Input and timing contract
`run_strategy_research(strategy, bars, *, asset, params=None, initial_cash=..., commission=..., stamp_duty=..., benchmark=None, min_trade_amount=0)` consumes a single asset's complete daily OHLC DataFrame.
- Its naive daily DatetimeIndex is unique, sorted and within 1900–2100. There are 2–5000 observations. OHLC must be real finite positive values with valid bounds. Missing high/low/open, strings, booleans, duplicate dates and malformed bars fail. Prices are never synthesized from close.
- Parameters merge the seven original defaults before strict validation. Periods are native integers from 1 to 500; optional ATR period also allows zero. Fast is less than slow; RSI oversold is below overbought. Allocation is between zero and one. Multipliers are bounded by 100; positive multipliers cannot be zero, while DualThrust coefficients may be zero.
- Initial capital is positive, finite and at most 1e12. Explicit fee inputs are fractions: 0.01 means 1%. Commission and additional sell fee are nonnegative and their sum cannot exceed one. Defaults preserve implementation values and are not current-market tax assertions. Fractional shares follow the pre-existing research ledger. Costs may reduce a full allocation to a cash-constrained partial fill.
- All input, benchmark and history checks precede ledger execution. Insufficient initialization history is an error. Optional longer ATR and exit lookbacks do not delay an otherwise valid entry signal; their own conditions wait for their own available history.
- At each supplied session's open, the ledger processes the previous close's pending target. It then values actual holdings at the current close. The policy sees a separate copy of actual post-fill holdings and cash. `None` means no order; it does not liquidate or rebalance existing holdings.
- A close signal schedules only the next supplied session's open. A final-session signal records `no_next_session` with no execution date. There is no same-close fallback. A zero allocation while already flat is `no_change`; an unfilled exit that retains holdings is `not_filled`.
The shared ledger's optional `decision_policy` cannot be combined with a fixed target schedule. Its execution-price calendar must cover the full valuation calendar. Static schedule behavior remains supported. A caller policy can itself misuse future information; the supplied seven policies use only causal windows. Future-perturbation tests establish that implementation property, not real-source PIT qualification.
## Strategy definitions
| Name | Entry while flat | Exit while held | Defaults |
|---|---|---|---|
| BuyAndHold | First supplied close schedules the allocation once | No automatic exit | buy_pct=.95 |
| SmaCross | Fast SMA crosses strictly above slow SMA | Fast SMA crosses strictly below slow SMA | fast=5, slow=20 |
| MACross | Same SMA cross definition | SMA cross down or optional trailing ATR stop | fast=10, slow=30, atr_period=0, atr_mult=2 |
| RSI | Wilder RSI strictly below oversold | Strictly above overbought | period=14, oversold=30, overbought=70 |
| BollingerBreakout | Close strictly above current-window mean plus population standard deviation times multiplier | Close strictly below current-window mean | period=20, std_mult=2 |
| DualThrust | Close strictly above current open plus k1 times prior-window HH−LL | Close strictly below current open minus k2 times prior-window HH−LL | period=5, k1=.5, k2=.5 |
| TurtleBreakout | Close strictly above the preceding entry-window high | Close strictly below the preceding exit-window low | entry_period=20, exit_period=10 |
MACross remains a dual-moving-average example; it is not renamed MACD. ATR is the arithmetic mean of true ranges over its explicit window. Zero disables ATR. After an actual entry the historical reference starts at its execution open, then tracks observed closes while held. At each later close, the stop is the greater of the prior stop and historical peak minus the previous session's ATR times the multiplier. Close at or below that stop schedules the next open exit. The current close does not construct a stop that is then impossibly compared with itself. Stops reset only when actual holdings become flat. If both exit conditions occur together, the recorded reason is `atr_stop`.
RSI is 50 when average gain and loss are both zero, 100 when only loss is zero, and otherwise follows Wilder smoothing. Bollinger uses population standard deviation, with each observed window independently scaled and deviations translated before scaling. This prevents tiny-price squared variance underflow and huge-price overflow without using a future/global scale. Nonfinite indicators after warmup fail explicitly. DualThrust deliberately preserves the existing example's HH−LL variant; it does not silently replace it with another range definition.
## Accounting, completed trades and metrics
Every fill is charged once by the shared ledger before that session's final NAV. Multi-asset same-session fills, final-session fills and both sides of rotation retain individual costs. Tiny fractional residual holdings are preserved; an explicit full exit consumes the exact held quantity, avoiding a rounded notional leaving a phantom lot.
`pair_ledger_trades` matches actual buy and sell fills FIFO per asset, using their net cash flows. Buy cost includes entry fees/slippage; net sell proceeds include exit fees/slippage. A match records allocated entry cost, exit proceeds and net PnL. A trade for win-rate purposes is one fully closed entry lot, even if exited in pieces. Partial exits contribute realized PnL but do not enter the completed-trade denominator. No completed lots yields `None`, not zero. An actual small residual is not treated as closed by relative tolerance. For a final asset fill followed by an exact flat ledger position, FIFO consumes every entry lot only when total quantity agrees within accumulated ULP resolution; this reconciles multi-entry subtraction rounding. The last matched lot receives the remaining net proceeds so the sell cash flow is conserved. Winning lots require PnL above 32 float64 ULPs at the cash magnitude; raw PnL is retained.
Daily returns and fees come from the shared ledger. Total return is final NAV divided by initial capital minus one. Complete loss is valid zero NAV, with no invented recovery; positive NAV whose return cannot be represented is rejected. Nonfinite returns are checked before general metrics, preventing generic cleaning from dropping an observation.
Other absolute measures reuse `metrics.summary`: 252 supplied trading sessions per year, CAGR-based annual return and Sharpe numerator, sample daily volatility, initial-capital-aware drawdown and daily-positive-return frequency. The latter is named `daily_win_rate`, distinct from FIFO `trade_win_rate`. Sharpe with zero volatility, Calmar with zero drawdown, Sortino with no downside and trade win rate with no closed lots are `None` with explicit reasons. Nonfinite derived metric outputs are marked unavailable; no default score is substituted.
## Benchmark and optimization
`BenchmarkInput` has four caller-reported states. `not_requested` contains no data; `empty` contains an empty Series. `present` requires a complete positive-price Series with exactly the valuation dates; missing/extra/duplicate dates fail rather than inner join. `source_error` fails before any strategy execution. Benchmark normalization and returns must be representable, finite and compatible with positive prices; non-first missing returns are never filled as zero. Output preserves empty versus not-requested status. Relative metrics reuse the existing strict `benchmark_summary`; constant-benchmark regressions remain unavailable.
`optimize_strategy_research` validates the entire grid before any trial. It allows at most four axes, ten candidates per axis and 100 combinations. Empty axes, unknown keys, duplicate values, non-native numbers, invalid relationships and any insufficient history fail the whole request. The size bound precedes Cartesian expansion. Every trial runs a fresh real seven-strategy pipeline and ledger with explicit fees. Targets are `total_return`, `sharpe_ratio` or `calmar_ratio`. An unavailable objective fails the ranking instead of silently skipping a candidate. Stable descending sorting preserves canonical axis order and supplied candidate order for ties; zero and negative finite scores remain valid.
## Verification scope
The missing public modules and sequential policy first failed actual tests. Further actual RED→GREEN regressions cover unfilled exits, legitimate zero NAV, independent ATR/exit windows, benchmark underflow, nonfinite portfolio returns, tiny residual holdings, Bollinger scale invariance, undefined Sortino, premature FIFO closure, full-exit quantity rounding and phantom residues after three accumulated entries at prices 3, 11 and 13.
Final focused verification is 93 new tests plus 85 existing execution tests, 178 passing in one run. The complete core suite then passed 1186 tests in 15.42 seconds, including governance, existing execution, pipeline, metrics and artifact contracts; 1168 existing-style pandas deprecation warnings remain. The new tests include seven default strategies, six separate trade/cash/NAV hand calculations plus BuyAndHold costs, a triggerable historical ATR stop, future perturbation for all seven, final signals without a next session, FIFO partial exits and same-session fees, and 100 actual optimization trials. Final targeted Ruff passed for all nine affected Python files, and strict typing passed for the four new source modules. Central clean-candidate validation and delivery state are separate receipts.
Two reused read-only reviewers independently exercised causal prefixes and cash/FIFO examples and found substantive defects subsequently turned into permanent regressions. The final independent FIFO review passed after reproducing three-lot full exits, retaining real tiny balances and reconciling fee-bearing add/partial-exit/full-exit PnL with final NAV. Saved revision and central delivery status are separate evidence. These tests do not establish real data coverage, source/PIT qualification, platform task publication or formal production availability.
+13 -1
View File
@@ -1,4 +1,16 @@
# Quant OS factor diagnostics core increment # Quant OS factor diagnostics and strategy core increments
## 2026-10-04 seven-strategy core increment
The same owner, lifecycle, branch and PR #21 now include the independent seven-strategy calculation increment needed by platform Draft #102. The original 3c97102f CI-wait receipt has not been queried, resumed or treated as resolved. New code is being reviewed as a new candidate; previous fixed-archive platform consumers remain bound to their saved revisions.
The active source binding is the clean pinned49d0fc5929653a3a98a0edcf1da630237dcec770. Its core feature entry was reused; the delivery operation returned §16/§21 and those ranges were read. Resume/review parameters critical gpt-6-astra/xhigh passed; actual runtime remains unknown. Root remains sole code/Git writer. The business stage source is absent; no foreign stage ledger or task is borrowed.
Pure OHLC research now uses the existing daily ledger with a post-close policy and next-open execution. Seven signal definitions, explicit historical ATR stop, source/benchmark states, fee/cash timing, completed-lot FIFO accounting and a maximum100-combination real optimizer are implemented. Financial scope is L3; no source access, DB, migration, live execution, service reload or platform production admission is included. Precise methods, numeric guards and current evidence are in docs/strategy-research.md.
Actual tests first failed for missing APIs. Review and additional boundary tests reproduced and closed the documented unfilled-exit, numeric, fractional-holding and FIFO defects. Final focused run178 passed (93 new plus85 existing execution), then the entire core suite1186 passed in15.42s with1168 pandas deprecation warnings. Final nine-file Ruff and four-module mypy passed. Both reused reviewers passed their final relevant scopes; the final FIFO review independently reconciled fee-bearing add/partial-exit/full-exit PnL with final NAV and retained real tiny balances. The pure-library increment is code complete; clean-candidate save/delivery receipts follow at the final boundary. The broader platform/M0–M5 scope remains unfinished. Commit-budget continuation: this same delivery now includes an independently tested seven-strategy calculation increment plus necessary saved-revision receipts; preserve reviewed history rather than split or rewrite the delivery. Next: complete this new core candidate's applicable review and one central delivery boundary, then bind the saved source in the platform candidate task/artifact path without opening formal run/optimize.
## Previous factor-diagnostics increment
Delivery: quant-os-factor-diagnostics-20261004; branch codex/quant-os-factor-diagnostics-20261004. Base is remote-confirmed origin/main 861c1e97a8bf1c3e962c4cd1ee88ef58e6b9ddd5. This is the independent core-repository increment consumed by the existing platform delivery/Draft #102, not a new platform branch or chat. Root in chat 01a0bc8f-dcaf-7452-9ab3-3215df6dfa97 is the sole core code/Git writer; reviewers are read-only. Primary, merged retrospective-v2 and old Alpha158 worktrees and their user files remain untouched. Delivery: quant-os-factor-diagnostics-20261004; branch codex/quant-os-factor-diagnostics-20261004. Base is remote-confirmed origin/main 861c1e97a8bf1c3e962c4cd1ee88ef58e6b9ddd5. This is the independent core-repository increment consumed by the existing platform delivery/Draft #102, not a new platform branch or chat. Root in chat 01a0bc8f-dcaf-7452-9ab3-3215df6dfa97 is the sole core code/Git writer; reviewers are read-only. Primary, merged retrospective-v2 and old Alpha158 worktrees and their user files remain untouched.
+35 -6
View File
@@ -22,7 +22,7 @@
from __future__ import annotations from __future__ import annotations
import math import math
from collections.abc import Mapping from collections.abc import Callable, Mapping
from dataclasses import dataclass, replace from dataclasses import dataclass, replace
from typing import Any from typing import Any
@@ -614,16 +614,21 @@ def _rebalance_at_prices(
filled: list[ExecutionResult] = [] filled: list[ExecutionResult] = []
for raw_execution in sell_executions: for raw_execution in sell_executions:
price = prices[raw_execution.stock_code] price = prices[raw_execution.stock_code]
quantity = abs(raw_execution.target_value) / price held = holdings.get(raw_execution.stock_code, 0.0)
# A full exit consumes the exact held quantity. Dividing a rounded
# weight-derived notional back by price can otherwise leave a phantom lot.
quantity = (held if effective_targets[raw_execution.stock_code] == 0
else abs(raw_execution.target_value) / price)
execution = replace( execution = replace(
raw_execution, raw_execution,
side="sell", side="sell",
quantity=quantity, quantity=quantity,
price=price, price=price,
) )
held = holdings.get(execution.stock_code, 0.0)
holdings[execution.stock_code] = max(0.0, held - quantity) holdings[execution.stock_code] = max(0.0, held - quantity)
if holdings[execution.stock_code] < 1e-6: # Fractional research holdings can be tiny shares with substantial value.
# Only a requested full exit or an exact zero removes the position.
if effective_targets[execution.stock_code] == 0 or holdings[execution.stock_code] == 0:
del holdings[execution.stock_code] del holdings[execution.stock_code]
cash += execution.net_cash_flow cash += execution.net_cash_flow
filled.append(execution) filled.append(execution)
@@ -706,10 +711,23 @@ def _simulate_daily_ledger(
valuation_price_history: list[tuple[str, dict[str, float]]], valuation_price_history: list[tuple[str, dict[str, float]]],
initial_cash: float, initial_cash: float,
config: ExecutionConfig, config: ExecutionConfig,
decision_policy: Callable[[DailyPosition], Mapping[str, float] | None] | None = None,
) -> ExecutionSimulationResult: ) -> ExecutionSimulationResult:
if decision_policy is not None:
if target_weights_history:
raise ValueError("decision_policy cannot be combined with a fixed schedule")
if not callable(decision_policy):
raise ValueError("decision_policy must be callable")
if [date for date, _ in execution_price_history] != [date for date, _ in valuation_price_history]:
raise ValueError("policy execution prices must cover the complete valuation calendar")
# Empty targets here validate the full price calendar only. Policy targets
# are produced after a close and consumed at the following session's open.
validation_targets = [(date, {}) for date, _ in execution_price_history]
else:
validation_targets = target_weights_history
targets_by_date, execution_prices_by_date, valuation_history = ( targets_by_date, execution_prices_by_date, valuation_history = (
_validate_sparse_daily_histories( _validate_sparse_daily_histories(
target_weights_history, validation_targets,
execution_price_history, execution_price_history,
valuation_price_history, valuation_price_history,
) )
@@ -719,8 +737,9 @@ def _simulate_daily_ledger(
positions: list[DailyPosition] = [] positions: list[DailyPosition] = []
daily_executions: list[DailyExecution] = [] daily_executions: list[DailyExecution] = []
pending_targets: dict[str, float] | None = None
for date, valuation_prices in valuation_history: for date, valuation_prices in valuation_history:
targets = targets_by_date.get(date) targets = pending_targets if decision_policy is not None else targets_by_date.get(date)
if targets is None: if targets is None:
executions: tuple[ExecutionResult, ...] = () executions: tuple[ExecutionResult, ...] = ()
nav_before = 0.0 nav_before = 0.0
@@ -764,6 +783,11 @@ def _simulate_daily_ledger(
rebalance_triggered=rebalance_triggered, rebalance_triggered=rebalance_triggered,
) )
) )
if decision_policy is not None:
# The policy receives its own snapshot, never live holdings or a
# snapshot already stored in the result. None means no order.
proposed = decision_policy(DailyPosition(date, cash, dict(holdings), portfolio_value))
pending_targets = None if proposed is None else _validate_target_weights(date, proposed)
return ExecutionSimulationResult( return ExecutionSimulationResult(
initial_cash=initial_cash, initial_cash=initial_cash,
@@ -778,11 +802,15 @@ def simulate_daily_ledger_with_audit(
valuation_price_history: list[tuple[str, dict[str, float]]], valuation_price_history: list[tuple[str, dict[str, float]]],
initial_cash: float, initial_cash: float,
config: ExecutionConfig | None = None, config: ExecutionConfig | None = None,
*,
decision_policy: Callable[[DailyPosition], Mapping[str, float] | None] | None = None,
) -> ExecutionSimulationResult: ) -> ExecutionSimulationResult:
"""以稀疏调仓和完整日历运行成交后持仓 Ledger。 """以稀疏调仓和完整日历运行成交后持仓 Ledger。
执行价只用于调仓日现金与股数变化,估值价用于每个交易日日末 NAV;二者 执行价只用于调仓日现金与股数变化,估值价用于每个交易日日末 NAV;二者
显式分离,从而支持“下一日 open 成交、同日 close 估值”的无前视研究。 显式分离,从而支持“下一日 open 成交、同日 close 估值”的无前视研究。
可选 decision_policy 在收盘估值后接收实际持仓副本,仅为下一日生成目标;
此模式须传空固定目标和完整开盘价日历。最后日决定不会执行。
""" """
if not math.isfinite(initial_cash) or initial_cash <= 0: if not math.isfinite(initial_cash) or initial_cash <= 0:
raise ValueError(f"initial_cash must be positive and finite, got {initial_cash}") raise ValueError(f"initial_cash must be positive and finite, got {initial_cash}")
@@ -792,6 +820,7 @@ def simulate_daily_ledger_with_audit(
valuation_price_history, valuation_price_history,
initial_cash, initial_cash,
ExecutionConfig() if config is None else config, ExecutionConfig() if config is None else config,
decision_policy,
) )
+140
View File
@@ -0,0 +1,140 @@
"""Bounded contracts for the seven migrated example strategies, without I/O."""
from __future__ import annotations
import math
from dataclasses import dataclass
from itertools import product
type Number = int | float
type Parameters = dict[str, Number]
MAX_OBSERVATIONS = 5000
MAX_COMBINATIONS = 100
@dataclass(frozen=True)
class _Parameter:
default: Number
minimum: Number
maximum: Number
integer: bool = False
strict_minimum: bool = False
def _period(default: int, minimum: int = 1) -> _Parameter:
return _Parameter(default, minimum, 500, integer=True)
_DEFINITIONS: dict[str, dict[str, _Parameter]] = {
"BuyAndHold": {"buy_pct": _Parameter(0.95, 0, 1)},
"SmaCross": {"fast": _period(5), "slow": _period(20)},
"MACross": {
"fast": _period(10),
"slow": _period(30),
"atr_period": _period(0, 0),
"atr_mult": _Parameter(2.0, 0, 100, strict_minimum=True),
},
"RSI": {
"period": _period(14),
"oversold": _Parameter(30, 0, 100),
"overbought": _Parameter(70, 0, 100),
},
"BollingerBreakout": {
"period": _period(20),
"std_mult": _Parameter(2.0, 0, 100, strict_minimum=True),
},
"DualThrust": {
"period": _period(5),
"k1": _Parameter(0.5, 0, 100),
"k2": _Parameter(0.5, 0, 100),
},
"TurtleBreakout": {"entry_period": _period(20), "exit_period": _period(10)},
}
STRATEGIES = tuple(_DEFINITIONS)
def finite_number(value: object, name: str) -> Number:
if type(value) not in (int, float):
raise ValueError(f"{name} must be a native finite number")
assert isinstance(value, (int, float))
try:
valid = math.isfinite(value)
except OverflowError:
valid = False
if not valid:
raise ValueError(f"{name} must be a native finite number")
return value
def strategy_parameters(name: str, overrides: Parameters | None = None) -> Parameters:
if type(name) is not str or name not in _DEFINITIONS:
raise ValueError("Unknown built-in strategy")
definition = _DEFINITIONS[name]
if overrides is None:
overrides = {}
if type(overrides) is not dict or any(
type(key) is not str or key not in definition for key in overrides
):
raise ValueError("Unknown strategy parameter")
values = {key: spec.default for key, spec in definition.items()} | overrides
for key, value in values.items():
spec = definition[key]
finite_number(value, key)
if spec.integer and type(value) is not int:
raise ValueError(f"{key} must be an integer")
if value > spec.maximum or (
value <= spec.minimum if spec.strict_minimum else value < spec.minimum
):
raise ValueError(f"{key} outside supported bounds")
if name in ("SmaCross", "MACross") and values["fast"] >= values["slow"]:
raise ValueError("fast must be less than slow")
if name == "RSI" and values["oversold"] >= values["overbought"]:
raise ValueError("oversold must be less than overbought")
return values
def required_history(name: str, params: Parameters) -> int:
if name in ("SmaCross", "MACross"):
return int(max(params["slow"], params.get("atr_period", 0))) + 1
if name in ("RSI", "BollingerBreakout", "DualThrust"):
return int(params["period"]) + 1
if name == "TurtleBreakout":
return int(max(params["entry_period"], params["exit_period"])) + 1
return 2
def validate_money(initial_cash: Number, commission: Number, stamp_duty: Number) -> None:
for name, value in (
("initial_cash", initial_cash),
("commission", commission),
("stamp_duty", stamp_duty),
):
finite_number(value, name)
if not 0 < initial_cash <= 1e12:
raise ValueError("initial_cash outside supported bounds")
if not (0 <= commission <= 1 and 0 <= stamp_duty <= 1 and commission + stamp_duty <= 1):
raise ValueError("Fee fractions and combined sell fee must be between zero and one")
def parameter_grid(name: str, grid: dict[str, list[Number]]) -> tuple[Parameters, ...]:
defaults = strategy_parameters(name)
if type(grid) is not dict or not 1 <= len(grid) <= 4:
raise ValueError("A bounded nonempty grid is required")
count = 1
for key, candidates in grid.items():
if type(key) is not str or key not in defaults:
raise ValueError("Unknown grid parameter")
if type(candidates) is not list or not 1 <= len(candidates) <= 10:
raise ValueError("A bounded nonempty candidate list is required")
count *= len(candidates)
if count > MAX_COMBINATIONS:
raise ValueError("Grid combination limit exceeded")
for value in candidates:
finite_number(value, key)
if len(set(candidates)) != len(candidates):
raise ValueError("Duplicate grid candidates")
keys = [key for key in defaults if key in grid]
return tuple(
strategy_parameters(name, dict(zip(keys, values, strict=True)))
for values in product(*(grid[key] for key in keys))
)
+93
View File
@@ -0,0 +1,93 @@
"""Bounded exhaustive research over the real seven-strategy core pipeline."""
from __future__ import annotations
import math
from dataclasses import dataclass
import pandas as pd
from .strategy_contracts import (
Parameters,
finite_number,
parameter_grid,
required_history,
validate_money,
)
from .strategy_research import (
BenchmarkInput,
StrategyResearchResult,
_benchmark,
run_strategy_research,
validate_strategy_bars,
)
_OBJECTIVES = {"sharpe_ratio": "sharpe", "total_return": "total_return", "calmar_ratio": "calmar"}
@dataclass(frozen=True)
class StrategyTrial:
parameters: Parameters
score: float
result: StrategyResearchResult
@dataclass(frozen=True)
class StrategyOptimizationResult:
strategy: str
objective: str
trials: tuple[StrategyTrial, ...]
decision_eligible: bool = False
def optimize_strategy_research(
strategy: str,
bars: pd.DataFrame,
*,
asset: str,
param_grid: dict[str, list[int | float]],
objective: str = "sharpe_ratio",
initial_cash: float = 1_000_000,
commission: float = 0.0003,
stamp_duty: float = 0.001,
benchmark: BenchmarkInput | None = None,
min_trade_amount: float = 0,
) -> StrategyOptimizationResult:
"""Validate every candidate first; an unavailable objective fails the ranking.
This has no source fetch, publication, task state or model-selection claim.
Ties retain canonical axis/candidate order. Every trial owns a fresh ledger.
"""
if type(objective) is not str or objective not in _OBJECTIVES:
raise ValueError("Unsupported optimization objective")
validate_money(initial_cash, commission, stamp_duty)
finite_number(min_trade_amount, "min_trade_amount")
if min_trade_amount < 0:
raise ValueError("min_trade_amount must be nonnegative")
if type(asset) is not str or not asset or asset.strip() != asset or len(asset) > 100:
raise ValueError("An explicit bounded asset key is required")
combinations = parameter_grid(strategy, param_grid)
observed = validate_strategy_bars(bars)
for parameters in combinations:
if len(observed) < required_history(strategy, parameters):
raise ValueError("Insufficient strategy history for all grid candidates")
_benchmark(benchmark, observed.index)
trials = []
for parameters in combinations:
result = run_strategy_research(
strategy,
observed,
asset=asset,
params=parameters,
initial_cash=initial_cash,
commission=commission,
stamp_duty=stamp_duty,
benchmark=benchmark,
min_trade_amount=min_trade_amount,
)
value = result.metrics[_OBJECTIVES[objective]]
if value is None or not math.isfinite(value):
raise ValueError(f"Optimization objective {objective} is unavailable for {parameters}")
trials.append(StrategyTrial(dict(parameters), value, result))
trials.sort(key=lambda trial: trial.score, reverse=True)
return StrategyOptimizationResult(strategy, objective, tuple(trials))
+444
View File
@@ -0,0 +1,444 @@
"""Seven long-only examples on the shared ledger, using caller-supplied OHLC.
Signals are observed after close, filled at the next supplied session's open,
and marked at that day's close. Fractional quantities follow the shared research
ledger. This is not exchange lot sizing, market/source admission or live execution.
"""
from __future__ import annotations
import math
from dataclasses import dataclass
from decimal import Decimal
from numbers import Real
from typing import Literal
import numpy as np
import pandas as pd
from .execution import (
DailyPosition,
ExecutionConfig,
ExecutionSimulationResult,
simulate_daily_ledger_with_audit,
)
from .metrics import benchmark_summary, summary
from .strategy_contracts import (
MAX_OBSERVATIONS,
Parameters,
finite_number,
required_history,
strategy_parameters,
validate_money,
)
from .trade_pairing import TradePairing, pair_ledger_trades
@dataclass(frozen=True)
class BenchmarkInput:
status: Literal["not_requested", "present", "empty", "source_error"] = "not_requested"
closes: pd.Series | None = None
@dataclass(frozen=True)
class StrategySignal:
decision_date: str
execution_date: str | None
target_weight: float
reason: str
status: str
@dataclass(frozen=True)
class StrategyResearchResult:
strategy: str
parameters: Parameters
ledger: ExecutionSimulationResult
signals: tuple[StrategySignal, ...]
pairing: TradePairing
metrics: dict[str, float | None]
metric_unavailable: dict[str, str]
benchmark_status: str
benchmark_nav: pd.Series | None
benchmark_metrics: dict[str, float | None] | None
decision_eligible: bool = False
def _calendar(index: pd.Index) -> pd.DatetimeIndex:
if (
not isinstance(index, pd.DatetimeIndex)
or index.tz is not None
or index.hasnans
or index.has_duplicates
or not index.is_monotonic_increasing
or not index.equals(index.normalize())
or (len(index) and (index.min().year < 1900 or index.max().year > 2100))
):
raise ValueError("A unique ordered naive daily DatetimeIndex is required")
return index.copy()
def _prices(values: list[object], name: str) -> list[float]:
result = []
for value in values:
if isinstance(value, (bool, np.bool_)) or not isinstance(value, (Real, Decimal)):
raise ValueError(f"{name} requires finite positive numeric prices")
try:
price = float(value)
except (OverflowError, ValueError) as error:
raise ValueError(f"{name} requires finite positive numeric prices") from error
if not math.isfinite(price) or price <= 0:
raise ValueError(f"{name} requires finite positive numeric prices")
result.append(price)
return result
def validate_strategy_bars(bars: pd.DataFrame) -> pd.DataFrame:
if (
not isinstance(bars, pd.DataFrame)
or bars.columns.has_duplicates
or not {"open", "high", "low", "close"}.issubset(bars.columns)
or not 2 <= len(bars) <= MAX_OBSERVATIONS
):
raise ValueError("Complete OHLC and 2 to 5000 observed sessions are required")
index = _calendar(bars.index)
result = pd.DataFrame(
{
column: _prices(bars[column].tolist(), column)
for column in ("open", "high", "low", "close")
},
index=index,
)
if (result["high"] < result[["open", "close", "low"]].max(axis=1)).any() or (
result["low"] > result[["open", "close", "high"]].min(axis=1)
).any():
raise ValueError("Invalid OHLC bounds")
return result
def _benchmark(
observation: BenchmarkInput | None, calendar: pd.DatetimeIndex
) -> tuple[str, pd.Series | None]:
if observation is None:
observation = BenchmarkInput()
if type(observation) is not BenchmarkInput:
raise ValueError("An explicit benchmark observation is required")
status, closes = observation.status, observation.closes
if status == "source_error":
raise ValueError("Requested benchmark source failed")
if status == "not_requested":
if closes is not None:
raise ValueError("Unrequested benchmark cannot contain prices")
return status, None
if status == "empty":
if not isinstance(closes, pd.Series) or len(closes):
raise ValueError("Empty benchmark must contain an empty series")
return status, None
if status != "present" or not isinstance(closes, pd.Series):
raise ValueError("Invalid benchmark status or prices")
index = _calendar(closes.index)
if not index.equals(calendar):
raise ValueError("Benchmark dates must exactly match the valuation calendar")
prices = pd.Series(_prices(closes.tolist(), "benchmark"), index=calendar)
with np.errstate(over="ignore", under="ignore", invalid="ignore", divide="ignore"):
normalized = prices / prices.iloc[0]
returns = prices.pct_change(fill_method=None)
returns.iloc[0] = 0.0
if (
not np.isfinite(normalized.to_numpy()).all()
or (normalized <= 0).any()
or not np.isfinite(returns.to_numpy()).all()
or (returns <= -1).any()
):
raise ValueError("Benchmark numeric range cannot represent positive prices and returns")
return status, prices
def _positive_mean(values: np.ndarray) -> float:
scale = float(np.max(values))
return scale * float(np.mean(values / scale)) if scale else 0.0
def _bollinger_windows(
close: pd.Series, period: int, multiple: float
) -> tuple[np.ndarray, np.ndarray]:
"""Scale each observed window, never a future/global price range.
Translate before scaling deviations so variance neither squares huge prices
nor underflows tiny ones. The mean is scaled independently to avoid summation
overflow and cancellation against a large translation anchor.
"""
values = close.to_numpy()
middle, upper = np.full(len(values), np.nan), np.full(len(values), np.nan)
for index in range(period - 1, len(values)):
window = values[index - period + 1 : index + 1]
mean = _positive_mean(window)
deviations = window - window[0]
spread = float(np.max(np.abs(deviations)))
std = spread * float(np.std(deviations / spread, ddof=0)) if spread else 0.0
if spread and std == 0:
raise ValueError("Bollinger deviation exceeded numeric resolution")
middle[index], upper[index] = mean, mean + multiple * std
return middle, upper
def _indicators(name: str, bars: pd.DataFrame, params: Parameters) -> dict[str, np.ndarray]:
close, high, low = bars["close"], bars["high"], bars["low"]
result: dict[str, np.ndarray] = {}
if name in ("SmaCross", "MACross"):
for label in ("fast", "slow"):
result[label] = close.rolling(int(params[label])).mean().to_numpy()
period = int(params.get("atr_period", 0))
if period:
# Arithmetic rolling ATR, matching the migrated strategy definition.
tr = pd.concat(
(high - low, (high - close.shift()).abs(), (low - close.shift()).abs()), axis=1
).max(axis=1)
result["atr"] = tr.rolling(period).mean().to_numpy()
elif name == "RSI":
period = int(params["period"])
changes = np.diff(close.to_numpy())
gains, losses = np.maximum(changes, 0), np.maximum(-changes, 0)
gain, loss = _positive_mean(gains[:period]), _positive_mean(losses[:period])
values = np.full(len(close), np.nan)
for index in range(period, len(close)):
if index > period:
gain = gain * (1 - 1 / period) + float(gains[index - 1]) / period
loss = loss * (1 - 1 / period) + float(losses[index - 1]) / period
values[index] = (
50 if gain == loss == 0 else 100 if loss == 0 else 100 - 100 / (1 + gain / loss)
)
result["rsi"] = values
elif name == "BollingerBreakout":
result["middle"], result["upper"] = _bollinger_windows(
close, int(params["period"]), float(params["std_mult"])
)
elif name == "DualThrust":
period = int(params["period"])
# Preserve this example's documented HH-LL range, not another variant.
width = high.shift().rolling(period).max() - low.shift().rolling(period).min()
result["upper"] = (bars["open"] + float(params["k1"]) * width).to_numpy()
result["lower"] = (bars["open"] - float(params["k2"]) * width).to_numpy()
elif name == "TurtleBreakout":
result["upper"] = high.shift().rolling(int(params["entry_period"])).max().to_numpy()
result["lower"] = low.shift().rolling(int(params["exit_period"])).min().to_numpy()
for label, values in result.items():
if name in ("SmaCross", "MACross"):
warmup = int(params["atr_period"] if label == "atr" else params[label]) - 1
elif name == "TurtleBreakout":
warmup = int(params["entry_period"] if label == "upper" else params["exit_period"])
else:
warmup = int(params["period"]) - (1 if name == "BollingerBreakout" else 0)
if not np.isfinite(values[warmup:]).all():
raise ValueError("Strategy indicator exceeded numeric range")
return result
class _Policy:
def __init__(self, name: str, bars: pd.DataFrame, params: Parameters, asset: str):
self.name, self.bars, self.params, self.asset = name, bars, params, asset
self.dates = bars.index.strftime("%Y-%m-%d").tolist()
self.indicators = _indicators(name, bars, params)
self.index = 0
self.was_held = False
self.peak = 0.0
self.stop: float | None = None
self.signals: list[StrategySignal] = []
def __call__(self, position: DailyPosition) -> dict[str, float] | None:
index = self.index
self.index += 1
held = position.holdings.get(self.asset, 0) > 0
if held and not self.was_held:
self.peak = float(self.bars["open"].iloc[index])
self.stop = None
if not held:
self.peak, self.stop = 0.0, None
self.was_held = held
weight, reason = self._decide(index, held)
if weight is None:
return None
next_date = self.dates[index + 1] if index + 1 < len(self.dates) else None
self.signals.append(
StrategySignal(
position.date,
next_date,
weight,
reason,
"pending" if next_date else "no_next_session",
)
)
return {self.asset: weight}
def _decide(self, index: int, held: bool) -> tuple[float | None, str]:
name, p, values = self.name, self.params, self.indicators
close = float(self.bars["close"].iloc[index])
if name == "BuyAndHold":
return (float(p["buy_pct"]), "initial_allocation") if index == 0 else (None, "")
if name in ("SmaCross", "MACross"):
start = int(p["slow"])
elif name == "TurtleBreakout":
start = int(p["exit_period"] if held else p["entry_period"])
else:
start = int(p["period"])
if index < start:
return None, ""
enter, leave, reason = False, False, "signal_exit"
if name in ("SmaCross", "MACross"):
fast, slow = values["fast"], values["slow"]
enter = bool(fast[index - 1] <= slow[index - 1] and fast[index] > slow[index])
leave = bool(fast[index - 1] >= slow[index - 1] and fast[index] < slow[index])
reason = "ma_cross_down"
if name == "MACross" and held and "atr" in values:
prior_atr = float(values["atr"][index - 1])
if math.isfinite(prior_atr):
historical_stop = self.peak - float(p["atr_mult"]) * prior_atr
self.stop = (
historical_stop if self.stop is None else max(self.stop, historical_stop)
)
if close <= self.stop:
leave, reason = True, "atr_stop"
self.peak = max(self.peak, close)
elif name == "RSI":
enter, leave = (
values["rsi"][index] < p["oversold"],
values["rsi"][index] > p["overbought"],
)
else:
enter = close > values["upper"][index]
lower = values["middle"] if name == "BollingerBreakout" else values["lower"]
leave = close < lower[index]
if held and leave:
return 0.0, reason
if not held and enter:
return 1.0, "signal_entry"
return None, ""
def _metrics(
ledger: ExecutionSimulationResult, pairing: TradePairing
) -> tuple[dict[str, float | None], dict[str, str]]:
returns = ledger.daily_returns
if (
not np.isfinite(returns.to_numpy()).all()
or ((ledger.nav_series > 0) & (returns <= -1)).any()
):
raise ValueError("Portfolio returns must be finite and within representable numeric range")
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
values = dict(summary(returns))
values["daily_win_rate"] = values.pop("win_rate")
values["total_return"] = ledger.final_portfolio_value / ledger.initial_cash - 1
metrics: dict[str, float | None] = {
key: float(value) if math.isfinite(value) else None for key, value in values.items()
}
unavailable = {key: "numeric_range" for key, value in metrics.items() if value is None}
if values["ann_volatility"] == 0:
metrics["sharpe"] = None
unavailable["sharpe"] = "zero_volatility"
if values["max_drawdown"] == 0:
metrics["calmar"] = None
unavailable["calmar"] = "zero_drawdown"
if not (returns < 0).any():
metrics["sortino"] = None
unavailable["sortino"] = "no_downside_deviation"
metrics["trade_win_rate"] = pairing.win_rate
metrics["closed_trades"] = float(len(pairing.closed_lots))
metrics["realized_net_pnl"] = pairing.realized_net_pnl
if pairing.win_rate is None:
unavailable["trade_win_rate"] = "no_closed_lots"
return metrics, unavailable
def run_strategy_research(
strategy: str,
bars: pd.DataFrame,
*,
asset: str,
params: Parameters | None = None,
initial_cash: float = 1_000_000,
commission: float = 0.0003,
stamp_duty: float = 0.001,
benchmark: BenchmarkInput | None = None,
min_trade_amount: float = 0,
) -> StrategyResearchResult:
"""Pure candidate research. Fees are explicit fractions, not current tax claims."""
parameters = strategy_parameters(strategy, params)
validate_money(initial_cash, commission, stamp_duty)
finite_number(min_trade_amount, "min_trade_amount")
if min_trade_amount < 0:
raise ValueError("min_trade_amount must be nonnegative")
if type(asset) is not str or not asset or asset.strip() != asset or len(asset) > 100:
raise ValueError("An explicit bounded asset key is required")
observed = validate_strategy_bars(bars)
if len(observed) < required_history(strategy, parameters):
raise ValueError("Insufficient strategy history")
benchmark_status, benchmark_close = _benchmark(benchmark, observed.index)
policy = _Policy(strategy, observed, parameters, asset)
dates = policy.dates
ledger = simulate_daily_ledger_with_audit(
[],
list(zip(dates, ({asset: price} for price in observed["open"]), strict=True)),
list(zip(dates, ({asset: price} for price in observed["close"]), strict=True)),
initial_cash,
ExecutionConfig(
commission_bps=commission * 10000,
stamp_tax_bps=stamp_duty * 10000,
slippage_bps=0,
min_trade_amount=min_trade_amount,
),
decision_policy=policy,
)
if not np.isfinite(ledger.nav_series.to_numpy()).all() or (ledger.nav_series < 0).any():
raise ValueError("Ledger NAV must remain nonnegative and finite")
days = {day.date: day for day in ledger.daily_executions}
positions = {position.date: position for position in ledger.positions}
signals = []
for signal in policy.signals:
status = signal.status
if signal.execution_date is not None:
fills = [fill for fill in days[signal.execution_date].executions if fill.quantity > 0]
if fills:
status = (
"partial_fill" if any(fill.partial_fill_pct < 1 for fill in fills) else "filled"
)
else:
flat_target_met = (
signal.target_weight == 0
and positions[signal.execution_date].holdings.get(asset, 0) == 0
)
status = "no_change" if flat_target_met else "not_filled"
signals.append(
StrategySignal(
signal.decision_date,
signal.execution_date,
signal.target_weight,
signal.reason,
status,
)
)
pairing = pair_ledger_trades(ledger)
metrics, unavailable = _metrics(ledger, pairing)
benchmark_nav, benchmark_metrics = None, None
if benchmark_close is not None:
benchmark_nav = benchmark_close / benchmark_close.iloc[0]
benchmark_returns = benchmark_close.pct_change(fill_method=None)
benchmark_returns.iloc[0] = 0.0
benchmark_returns.index = ledger.daily_returns.index
with np.errstate(over="ignore", invalid="ignore", divide="ignore"):
relative = benchmark_summary(ledger.daily_returns, benchmark_returns)
benchmark_metrics = {
key: float(value) if math.isfinite(value) else None for key, value in relative.items()
}
benchmark_metrics["total_return"] = float(benchmark_nav.iloc[-1] - 1)
return StrategyResearchResult(
strategy,
dict(parameters),
ledger,
tuple(signals),
pairing,
metrics,
unavailable,
benchmark_status,
benchmark_nav,
benchmark_metrics,
)
+170
View File
@@ -0,0 +1,170 @@
"""FIFO long-only lot pairing from actual fills and their net cash flows.
An entry lot is one trade for win-rate purposes, and counts only once fully closed.
Partial exits contribute realized PnL but do not count as completed trades. Costs
include entry/exit fees and slippage exactly once through execution cash flows.
"""
from __future__ import annotations
import math
from collections import defaultdict, deque
from dataclasses import dataclass
from .execution import ExecutionSimulationResult
@dataclass(frozen=True)
class TradeMatch:
asset: str
buy_date: str
sell_date: str
quantity: float
cost: float
net_proceeds: float
net_pnl: float
@dataclass(frozen=True)
class ClosedLot:
asset: str
buy_date: str
sell_date: str
quantity: float
cost: float
net_proceeds: float
net_pnl: float
@dataclass(frozen=True)
class OpenLot:
asset: str
buy_date: str
quantity: float
remaining_cost: float
realized_net_pnl: float
@dataclass(frozen=True)
class TradePairing:
matches: tuple[TradeMatch, ...]
closed_lots: tuple[ClosedLot, ...]
open_lots: tuple[OpenLot, ...]
realized_net_pnl: float
win_rate: float | None
@dataclass
class _Lot:
asset: str
date: str
quantity: float
remaining: float
cost: float
proceeds: float = 0.0
matched_cost: float = 0.0
def pair_ledger_trades(ledger: ExecutionSimulationResult) -> TradePairing:
lots: dict[str, deque[_Lot]] = defaultdict(deque)
matches: list[TradeMatch] = []
closed: list[ClosedLot] = []
positions = {position.date: position for position in ledger.positions}
if len(positions) != len(ledger.positions):
raise ValueError("Duplicate ledger position dates")
for day in ledger.daily_executions:
if day.date not in positions:
raise ValueError("Execution date has no ledger position")
last_fill = {
fill.stock_code: index
for index, fill in enumerate(day.executions)
if fill.quantity > 0
}
for index, fill in enumerate(day.executions):
quantity, cash = fill.quantity, fill.net_cash_flow
if not math.isfinite(quantity) or quantity < 0 or not math.isfinite(cash):
raise ValueError("Invalid fill quantity or cash flow")
if quantity == 0:
if cash != 0:
raise ValueError("Unfilled order cannot move cash")
continue
if fill.side == "buy":
if cash >= 0:
raise ValueError("Buy cash flow must be negative")
lots[fill.stock_code].append(
_Lot(fill.stock_code, day.date, quantity, quantity, -cash)
)
continue
if fill.side != "sell" or cash < 0:
raise ValueError("Invalid long-only sell fill")
remaining = quantity
queue = lots[fill.stock_code]
available = math.fsum(lot.remaining for lot in queue)
quantity_resolution = 8 * math.fsum(
[math.ulp(quantity), math.ulp(available)]
+ [math.ulp(lot.remaining) for lot in queue]
)
if remaining - available > quantity_resolution:
raise ValueError("Sell quantity exceeds FIFO inventory")
# A final fill followed by an actual flat ledger position proves a
# full exit. Reconcile only accumulated floating-point rounding here;
# any real positive holding keeps the exact partial-lot behavior.
full_exit = (
index == last_fill[fill.stock_code]
and positions[day.date].holdings.get(fill.stock_code, 0.0) == 0
)
if full_exit and abs(quantity - available) > quantity_resolution:
raise ValueError("Flat ledger position conflicts with FIFO inventory")
allocated_proceeds: list[float] = []
while queue and (remaining > 0 or full_exit):
lot = queue[0]
take = lot.remaining if full_exit else min(remaining, lot.remaining)
full_lot = take == lot.remaining
final_match = len(queue) == 1 if full_exit else take == remaining
cost = lot.cost - lot.matched_cost if full_lot else lot.cost * (take / lot.quantity)
proceeds = (
cash - math.fsum(allocated_proceeds)
if final_match
else cash * (take / quantity)
)
allocated_proceeds.append(proceeds)
matches.append(
TradeMatch(lot.asset, lot.date, day.date, take, cost, proceeds, proceeds - cost)
)
lot.proceeds += proceeds
lot.matched_cost += cost
lot.remaining -= take
remaining -= take
if full_lot:
closed.append(
ClosedLot(
lot.asset,
lot.date,
day.date,
lot.quantity,
lot.cost,
lot.proceeds,
lot.proceeds - lot.cost,
)
)
lots[fill.stock_code].popleft()
opened = tuple(
OpenLot(
lot.asset,
lot.date,
lot.remaining,
lot.cost - lot.matched_cost,
lot.proceeds - lot.matched_cost,
)
for queue in lots.values()
for lot in queue
)
# A representational residual is not a winning trade; raw PnL is retained.
wins = sum(lot.net_pnl > 32 * math.ulp(max(lot.cost, lot.net_proceeds, 1.0)) for lot in closed)
return TradePairing(
tuple(matches),
tuple(closed),
opened,
math.fsum(match.net_pnl for match in matches),
wins / len(closed) if closed else None,
)
+58
View File
@@ -0,0 +1,58 @@
"""Policies observe actual post-fill holdings and only schedule the next open."""
import pytest
from quant_engine.execution import ExecutionConfig, simulate_daily_ledger_with_audit
def test_policy_next_open_actual_holdings_and_immutable_past_snapshots():
seen = []
def decide(position):
seen.append((position.date, dict(position.holdings), position.cash))
position.holdings.clear()
return {"A": 0.5} if position.date == "d1" else None
result = simulate_daily_ledger_with_audit(
[],
[("d1", {"A": 10}), ("d2", {"A": 20}), ("d3", {"A": 30})],
[("d1", {"A": 10}), ("d2", {"A": 25}), ("d3", {"A": 40})],
1000,
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
decision_policy=decide,
)
assert seen[0][1] == {}
assert seen[1][1] == {"A": 25}
assert result.nav_series.tolist() == [1000, 1125, 1500]
assert result.positions[1].holdings == {"A": 25}
assert len(result.trades_frame) == 1
def test_rejected_entry_does_not_create_a_position_for_policy():
holdings = []
def decide(position):
holdings.append(dict(position.holdings))
return {"A": 1} if position.date == "d1" else None
result = simulate_daily_ledger_with_audit(
[],
[("d1", {"A": 10}), ("d2", {"A": 10})],
[("d1", {"A": 10}), ("d2", {"A": 10})],
1000,
ExecutionConfig(min_trade_amount=2000),
decision_policy=decide,
)
assert holdings == [{}, {}]
assert result.trades_frame.empty
def test_policy_and_fixed_schedule_cannot_be_mixed():
with pytest.raises(ValueError, match="fixed"):
simulate_daily_ledger_with_audit(
[("d1", {"A": 1})],
[("d1", {"A": 10})],
[("d1", {"A": 10})],
1000,
decision_policy=lambda p: None,
)
+179
View File
@@ -0,0 +1,179 @@
"""Bounded optimizer runs actual core strategies and rejects ambiguous ranking."""
import numpy as np
import pandas as pd
import pytest
from quant_engine import strategy_optimizer as optimizer
from quant_engine.strategy_contracts import STRATEGIES, strategy_parameters
from quant_engine.strategy_research import BenchmarkInput, run_strategy_research
from quant_engine import strategy_contracts
def feed(values=None):
values = np.asarray(
values if values is not None else 10 + 2 * np.sin(np.arange(80) / 2), dtype=float
)
return pd.DataFrame(
dict.fromkeys(("open", "high", "low", "close"), values),
index=pd.date_range("2026-01-01", periods=len(values), freq="B"),
)
@pytest.mark.parametrize("name", STRATEGIES)
def test_each_default_strategy_uses_the_real_ledger(name):
defaults = strategy_parameters(name)
key = next(iter(defaults))
result = optimizer.optimize_strategy_research(
name,
feed(),
asset="SYNTHETIC",
param_grid={key: [defaults[key]]},
objective="total_return",
initial_cash=1000,
commission=0.01,
stamp_duty=0.002,
)
direct = run_strategy_research(
name, feed(), asset="SYNTHETIC", initial_cash=1000, commission=0.01, stamp_duty=0.002
)
assert len(result.trials) == 1
assert result.trials[0].result.ledger.positions == direct.ledger.positions
assert result.trials[0].result.ledger.daily_executions == direct.ledger.daily_executions
assert result.trials[0].score == direct.metrics["total_return"]
assert result.decision_eligible is False
def test_real_negative_zero_scores_and_explicit_costs_sort_without_defaults():
result = optimizer.optimize_strategy_research(
"BuyAndHold",
feed([10, 10, 9, 8]),
asset="SYNTHETIC",
param_grid={"buy_pct": [0.5, 0, 0.25]},
objective="total_return",
initial_cash=1000,
commission=0.01,
stamp_duty=0.002,
)
assert [trial.parameters["buy_pct"] for trial in result.trials] == [0, 0.25, 0.5]
assert [trial.score for trial in result.trials] == pytest.approx([0, -0.0525, -0.105])
assert [trial.result.ledger.total_costs for trial in result.trials] == pytest.approx(
[0, 2.5, 5]
)
def test_stable_ties_retain_canonical_axis_and_candidate_order():
result = optimizer.optimize_strategy_research(
"MACross",
feed([10] * 40),
asset="SYNTHETIC",
param_grid={"atr_period": [2, 0], "fast": [5, 4]},
objective="total_return",
commission=0,
stamp_duty=0,
)
assert [
(trial.parameters["fast"], trial.parameters["atr_period"]) for trial in result.trials
] == [(5, 2), (5, 0), (4, 2), (4, 0)]
def test_hundred_combinations_are_unique_actual_results():
result = optimizer.optimize_strategy_research(
"MACross",
feed(),
asset="SYNTHETIC",
param_grid={"fast": list(range(1, 11)), "slow": list(range(11, 21))},
objective="total_return",
commission=0,
stamp_duty=0,
)
assert len(result.trials) == 100
assert len({tuple(trial.parameters.items()) for trial in result.trials}) == 100
assert all(len(trial.result.ledger.positions) == 80 for trial in result.trials)
@pytest.mark.parametrize(
"grid,objective",
[
({"fast": []}, "total_return"),
({"fast": [5, 40]}, "total_return"),
({"fast": [True]}, "total_return"),
({"fast": [5]}, "unknown"),
({"fast": [5, 5]}, "total_return"),
(
{"fast": list(range(1, 11)), "slow": list(range(11, 21)), "atr_period": [0, 1]},
"total_return",
),
],
)
def test_invalid_or_partly_invalid_grid_never_starts_a_strategy(monkeypatch, grid, objective):
calls = []
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append(1))
with pytest.raises(
ValueError, match=r"grid|Grid|candidate|number|less than|objective|Duplicate"
):
optimizer.optimize_strategy_research(
"MACross", feed(), asset="SYNTHETIC", param_grid=grid, objective=objective
)
assert calls == []
def test_history_failure_for_one_candidate_prevents_all_runs(monkeypatch):
calls = []
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append(1))
with pytest.raises(ValueError, match=r"history"):
optimizer.optimize_strategy_research(
"MACross",
feed([10] * 40),
asset="SYNTHETIC",
param_grid={"slow": [30, 100]},
objective="total_return",
)
assert calls == []
def test_requested_benchmark_error_prevents_every_run(monkeypatch):
calls = []
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append(1))
with pytest.raises(ValueError, match=r"benchmark source"):
optimizer.optimize_strategy_research(
"BuyAndHold",
feed(),
asset="SYNTHETIC",
param_grid={"buy_pct": [0.5, 1]},
objective="total_return",
benchmark=BenchmarkInput("source_error"),
)
assert calls == []
def test_undefined_sharpe_fails_the_ranking_instead_of_winning_as_zero():
with pytest.raises(ValueError, match=r"objective.*unavailable"):
optimizer.optimize_strategy_research(
"BuyAndHold",
feed([10] * 4),
asset="SYNTHETIC",
param_grid={"buy_pct": [0, 0.5]},
objective="sharpe_ratio",
commission=0,
stamp_duty=0,
)
def test_large_grid_fails_before_creating_cartesian_product(monkeypatch):
calls = []
monkeypatch.setattr(strategy_contracts, "product", lambda *a, **kw: calls.append("product"))
monkeypatch.setattr(optimizer, "run_strategy_research", lambda *a, **kw: calls.append("run"))
with pytest.raises(ValueError, match=r"limit"):
optimizer.optimize_strategy_research(
"MACross",
feed(),
asset="SYNTHETIC",
param_grid={
"fast": list(range(1, 11)),
"slow": list(range(11, 21)),
"atr_period": list(range(10)),
"atr_mult": list(range(1, 11)),
},
)
assert calls == []
+436
View File
@@ -0,0 +1,436 @@
"""Caller-supplied OHLC, causal signals and the real shared daily ledger."""
from __future__ import annotations
import numpy as np
import pandas as pd
import pytest
from quant_engine import strategy_research as research
def bars(closes, opens=None):
close = np.asarray(closes, dtype=float)
opening = np.asarray(opens if opens is not None else closes, dtype=float)
return pd.DataFrame(
{
"open": opening,
"high": np.maximum(close, opening),
"low": np.minimum(close, opening),
"close": close,
},
index=pd.date_range("2026-01-01", periods=len(close), freq="B"),
)
def run(name, feed, **kwargs):
return research.run_strategy_research(
name, feed, asset="SYNTHETIC", initial_cash=1000, commission=0, stamp_duty=0, **kwargs
)
def test_buy_hold_signal_close_next_open_and_last_close_value():
result = research.run_strategy_research(
"BuyAndHold",
bars([10, 11, 12, 13], [10, 10, 11, 12]),
asset="SYNTHETIC",
initial_cash=1000,
commission=0.01,
stamp_duty=0,
params={"buy_pct": 1},
)
assert result.ledger.nav_series.tolist() == pytest.approx(
[1000, 1100 / 1.01, 1200 / 1.01, 1300 / 1.01]
)
assert result.ledger.total_rebalances == 1
trade = result.ledger.trades_frame.iloc[0]
assert trade["trade_date"] == "2026-01-02"
assert trade["price"] == 10
assert trade["fee"] == pytest.approx(1000 - 1000 / 1.01)
assert result.signals[0].decision_date == "2026-01-01"
assert result.signals[0].execution_date == "2026-01-02"
assert result.signals[0].status == "partial_fill"
assert result.metrics["total_return"] == pytest.approx(1300 / 1.01 / 1000 - 1)
assert result.pairing.win_rate is None
@pytest.mark.parametrize(
"name,params,closes",
[
("SmaCross", {"fast": 1, "slow": 2}, [10, 8, 12, 6, 14, 5, 12]),
("MACross", {"fast": 1, "slow": 2}, [10, 8, 12, 6, 14, 5, 12]),
("RSI", {"period": 2}, [10, 8, 6, 10, 14, 8, 6, 10]),
("BollingerBreakout", {"period": 2, "std_mult": 0.5}, [10, 10, 12, 8, 12, 8]),
("DualThrust", {"period": 2, "k1": 0.5, "k2": 0.5}, [10, 10, 12, 8, 12, 8]),
("TurtleBreakout", {"entry_period": 2, "exit_period": 2}, [10, 10, 12, 8, 12, 8]),
],
)
def test_each_strategy_has_real_entry_exit_and_sparse_execution(name, params, closes):
opening = [closes[0], *closes[:-1]]
result = run(name, bars(closes, opening), params=params)
trades = result.ledger.trades_frame
assert trades["side"].iloc[:2].tolist() == ["buy", "sell"]
assert result.signals[0].decision_date == "2026-01-05"
assert trades["trade_date"].iloc[0] == "2026-01-06"
for signal in result.signals:
if signal.execution_date:
assert signal.execution_date > signal.decision_date
assert len(result.ledger.positions) == len(closes)
assert all(position.cash >= -1e-9 for position in result.ledger.positions)
assert result.pairing.closed_lots
def test_final_day_signal_is_recorded_without_same_close_execution():
result = run("SmaCross", bars([10, 8, 12]), params={"fast": 1, "slow": 2})
assert result.ledger.trades_frame.empty
assert len(result.signals) == 1
assert result.signals[0].status == "no_next_session"
assert result.signals[0].execution_date is None
def test_atr_stop_uses_prior_peak_and_prior_atr_and_can_trigger():
feed = bars([10, 8, 8, 10, 20, 19, 18], [10, 10, 8, 8, 10, 20, 19])
params = {"fast": 2, "slow": 3, "atr_period": 1, "atr_mult": 0.1}
result = run("MACross", feed, params=params)
stop = next(signal for signal in result.signals if signal.reason == "atr_stop")
assert stop.decision_date == "2026-01-08"
assert stop.execution_date == "2026-01-09"
assert result.ledger.trades_frame.iloc[-1]["side"] == "sell"
disabled = run("MACross", feed, params=params | {"atr_period": 0})
assert all(signal.reason != "atr_stop" for signal in disabled.signals)
def test_flat_rsi_is_neutral_and_does_not_create_artificial_trades():
result = run("RSI", bars([10] * 8), params={"period": 2, "oversold": 40, "overbought": 60})
assert result.ledger.trades_frame.empty
assert result.metrics["sharpe"] is None
assert result.metric_unavailable["sharpe"] == "zero_volatility"
@pytest.mark.parametrize(
"name",
[
"BuyAndHold",
"SmaCross",
"MACross",
"RSI",
"BollingerBreakout",
"DualThrust",
"TurtleBreakout",
],
)
def test_default_parameters_and_future_perturbation_preserve_observed_prefix(name):
values = 10 + np.sin(np.arange(80) / 2) * 2
original = bars(values, np.r_[values[0], values[:-1]])
changed = original.copy()
changed.iloc[55:] *= 7
first = run(name, original)
second = run(name, changed)
assert first.ledger.positions[:55] == second.ledger.positions[:55]
assert first.ledger.daily_executions[:55] == second.ledger.daily_executions[:55]
observed = original.index[53].strftime("%Y-%m-%d")
assert [s for s in first.signals if s.decision_date <= observed] == [
s for s in second.signals if s.decision_date <= observed
]
assert first.parameters == research.strategy_parameters(name)
@pytest.mark.parametrize(
"mutation",
[
"missing_open",
"missing_high",
"missing_low",
"null",
"boolean",
"string",
"infinite",
"bad_bounds",
"duplicate",
"unsorted",
],
)
def test_invalid_ohlc_fails_before_ledger(monkeypatch, mutation):
feed = bars([10] * 40)
if mutation.startswith("missing_"):
feed = feed.drop(columns=mutation[8:])
elif mutation == "duplicate":
feed.index = [feed.index[0]] * len(feed)
elif mutation == "unsorted":
feed = feed.iloc[::-1]
elif mutation == "bad_bounds":
feed.iloc[0, feed.columns.get_loc("high")] = 9
else:
feed = feed.astype(object)
feed.iloc[0, 0] = {"null": None, "boolean": True, "string": "10", "infinite": float("inf")}[
mutation
]
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"OHLC|prices|DatetimeIndex"):
run("DualThrust", feed)
assert calls == []
@pytest.mark.parametrize(
"name,params",
[
("SmaCross", {"fast": True}),
("SmaCross", {"fast": 20}),
("RSI", {"oversold": 70}),
("MACross", {"atr_period": -1}),
("TurtleBreakout", {"entry_period": "20"}),
("BuyAndHold", {"buy_pct": float("nan")}),
("DualThrust", {"unknown": 1}),
],
)
def test_invalid_parameters_before_ledger(monkeypatch, name, params):
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"parameter|finite|less than|bounds|integer"):
run(name, bars([10] * 40), params=params)
assert calls == []
def test_insufficient_history_is_failure_before_ledger(monkeypatch):
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"history"):
run("MACross", bars([10] * 30))
assert calls == []
def test_zero_allocation_is_no_change_not_a_filled_position():
result = run("BuyAndHold", bars([10, 11, 12]), params={"buy_pct": 0})
assert result.ledger.trades_frame.empty
assert result.signals[0].status == "no_change"
assert result.ledger.nav_series.tolist() == [1000, 1000, 1000]
def test_exit_below_minimum_is_not_filled_and_state_keeps_actual_holdings():
result = run(
"SmaCross",
bars([10, 8, 12, 6, 14, 5, 12], [10, 10, 8, 12, 6, 14, 5]),
params={"fast": 1, "slow": 2},
min_trade_amount=900,
)
exit_signal = result.signals[1]
assert exit_signal.target_weight == 0
assert exit_signal.status == "not_filled"
assert result.ledger.positions[4].holdings == {"SYNTHETIC": pytest.approx(1000 / 12)}
assert len(result.ledger.trades_frame) == 1
def test_total_loss_from_explicit_full_sell_fee_is_reported_as_zero_nav():
result = research.run_strategy_research(
"SmaCross",
bars([10, 8, 12, 6, 14, 5]),
asset="SYNTHETIC",
initial_cash=1000,
commission=0,
stamp_duty=1,
params={"fast": 1, "slow": 2},
)
assert result.ledger.nav_series.iloc[-1] == 0
assert result.metrics["total_return"] == -1
assert result.pairing.win_rate == 0
def test_turtle_entry_does_not_wait_for_longer_exit_lookback():
result = run(
"TurtleBreakout",
bars([10, 10, 12, 13, 14, 15, 16]),
params={"entry_period": 2, "exit_period": 5},
)
assert result.signals[0].decision_date == "2026-01-05"
def test_ma_entry_does_not_wait_for_optional_atr_calibration():
result = run(
"MACross", bars([10, 8, 12, 6, 14, 5, 12]), params={"fast": 1, "slow": 2, "atr_period": 5}
)
assert result.signals[0].decision_date == "2026-01-05"
def test_benchmark_exact_calendar_and_distinct_empty_unrequested_states():
feed = bars([10, 10, 10])
benchmark = pd.Series([100.0, 90.0, 99.0], index=feed.index)
result = run("BuyAndHold", feed, benchmark=research.BenchmarkInput("present", benchmark))
assert result.benchmark_status == "present"
assert result.benchmark_nav.tolist() == pytest.approx([1, 0.9, 0.99])
assert result.benchmark_metrics["total_return"] == pytest.approx(-0.01)
empty = run(
"BuyAndHold", feed, benchmark=research.BenchmarkInput("empty", pd.Series(dtype=float))
)
none = run("BuyAndHold", feed)
assert empty.benchmark_status == "empty"
assert empty.benchmark_nav is None
assert none.benchmark_status == "not_requested"
assert none.benchmark_nav is None
@pytest.mark.parametrize("status", ["missing_day", "duplicate", "source_error"])
def test_bad_benchmark_fails_before_any_strategy_execution(monkeypatch, status):
feed = bars([10, 10, 10])
series = pd.Series([100.0, 90.0, 99.0], index=feed.index)
if status == "missing_day":
series = series.iloc[:2]
elif status == "duplicate":
series.index = [feed.index[0]] * 3
observation = research.BenchmarkInput(
"source_error" if status == "source_error" else "present",
series if status != "source_error" else None,
)
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"Benchmark|benchmark|DatetimeIndex"):
run("BuyAndHold", feed, benchmark=observation)
assert calls == []
def test_benchmark_numeric_underflow_is_not_filled_as_zero_return(monkeypatch):
feed = bars([10] * 4)
benchmark = research.BenchmarkInput(
"present", pd.Series([1e300, 1e300, 1e-300, 1e-300], index=feed.index)
)
calls = []
real_ledger = research.simulate_daily_ledger_with_audit
def observed(*args, **kwargs):
calls.append(1)
return real_ledger(*args, **kwargs)
monkeypatch.setattr(research, "simulate_daily_ledger_with_audit", observed)
with pytest.raises(ValueError, match=r"benchmark.*numeric|Benchmark.*numeric"):
run("BuyAndHold", feed, benchmark=benchmark)
assert calls == []
def test_nonfinite_portfolio_return_cannot_be_silently_dropped_from_metrics():
with pytest.raises(ValueError, match=r"return.*finite|return.*numeric"):
run("BuyAndHold", bars([10, 10, 1e-300, 1e300]), params={"buy_pct": 1})
@pytest.mark.parametrize(
"name,params,closes,expected_nav,expected_cash,prices,quantities,pnl",
[
(
"SmaCross",
{"fast": 1, "slow": 2},
[10, 8, 12, 6, 14, 5, 12],
[1000, 1000, 1000, 500, 500, 1250 / 7, 1250 / 7],
[1000, 1000, 1000, 0, 500, 0, 1250 / 7],
[12, 6, 14, 5],
[250 / 3, 250 / 3, 250 / 7, 250 / 7],
-5750 / 7,
),
(
"MACross",
{"fast": 1, "slow": 2},
[10, 8, 12, 6, 14, 5, 12],
[1000, 1000, 1000, 500, 500, 1250 / 7, 1250 / 7],
[1000, 1000, 1000, 0, 500, 0, 1250 / 7],
[12, 6, 14, 5],
[250 / 3, 250 / 3, 250 / 7, 250 / 7],
-5750 / 7,
),
(
"RSI",
{"period": 2},
[10, 8, 6, 10, 14, 8, 6, 10],
[1000, 1000, 1000, 5000 / 3, 7000 / 3, 7000 / 3, 7000 / 3, 35000 / 9],
[1000, 1000, 1000, 0, 0, 7000 / 3, 7000 / 3, 0],
[6, 14, 6],
[500 / 3, 500 / 3, 3500 / 9],
4000 / 3,
),
(
"BollingerBreakout",
{"period": 2, "std_mult": 0.5},
[10, 10, 12, 8, 12, 8],
[1000, 1000, 1000, 2000 / 3, 2000 / 3, 4000 / 9],
[1000, 1000, 1000, 0, 2000 / 3, 0],
[12, 8, 12],
[250 / 3, 250 / 3, 500 / 9],
-1000 / 3,
),
(
"DualThrust",
{"period": 2, "k1": 0.5, "k2": 0.5},
[10, 10, 12, 8, 12, 8],
[1000, 1000, 1000, 2000 / 3, 2000 / 3, 4000 / 9],
[1000, 1000, 1000, 0, 2000 / 3, 0],
[12, 8, 12],
[250 / 3, 250 / 3, 500 / 9],
-1000 / 3,
),
(
"TurtleBreakout",
{"entry_period": 2, "exit_period": 2},
[10, 10, 12, 8, 12, 8],
[1000, 1000, 1000, 2000 / 3, 2000 / 3, 2000 / 3],
[1000, 1000, 1000, 0, 2000 / 3, 2000 / 3],
[12, 8],
[250 / 3, 250 / 3],
-1000 / 3,
),
],
)
def test_hand_calculated_strategy_cash_nav_and_every_fill(
name, params, closes, expected_nav, expected_cash, prices, quantities, pnl
):
# These rational constants were calculated from the expected sparse trades,
# independently of the signal and ledger implementation.
result = run(name, bars(closes, [closes[0], *closes[:-1]]), params=params)
assert result.ledger.nav_series.tolist() == pytest.approx(expected_nav)
assert [position.cash for position in result.ledger.positions] == pytest.approx(expected_cash)
assert result.ledger.trades_frame["price"].tolist() == prices
assert result.ledger.trades_frame["qty"].tolist() == pytest.approx(quantities)
assert result.pairing.realized_net_pnl == pytest.approx(pnl)
assert result.metrics["total_return"] == pytest.approx(expected_nav[-1] / 1000 - 1)
@pytest.mark.parametrize(
"field,value",
[
("initial_cash", True),
("initial_cash", 0),
("commission", "0.01"),
("commission", -1),
("stamp_duty", float("nan")),
("stamp_duty", 2),
],
)
def test_money_contract_fails_before_ledger(monkeypatch, field, value):
calls = []
monkeypatch.setattr(
research, "simulate_daily_ledger_with_audit", lambda *a, **kw: calls.append(1)
)
with pytest.raises(ValueError, match=r"number|bounds|fee|Fee"):
research.run_strategy_research(
"BuyAndHold", bars([10, 10]), asset="SYNTHETIC", **{field: value}
)
assert calls == []
@pytest.mark.parametrize("scale", [1e-200, 1e200])
def test_bollinger_signal_is_invariant_to_representable_price_scaling(scale):
original = bars([10, 10, 12, 8, 12, 8])
normal = run("BollingerBreakout", original, params={"period": 2, "std_mult": 2})
scaled = run("BollingerBreakout", original * scale, params={"period": 2, "std_mult": 2})
assert scaled.signals == normal.signals
assert scaled.ledger.nav_series.tolist() == normal.ledger.nav_series.tolist()
def test_no_downside_sortino_is_explicitly_unavailable():
result = run("BuyAndHold", bars([10, 10, 11, 12]), params={"buy_pct": 0.5})
assert result.metrics["sortino"] is None
assert result.metric_unavailable["sortino"] == "no_downside_deviation"
+127
View File
@@ -0,0 +1,127 @@
"""Cost-aware FIFO pairing uses actual ledger cash flows, including both fees."""
from __future__ import annotations
import pytest
from quant_engine.execution import ExecutionConfig, simulate_daily_ledger_with_audit
from quant_engine.trade_pairing import pair_ledger_trades
def test_loss_after_both_fees_is_not_a_win():
ledger = simulate_daily_ledger_with_audit(
[("d1", {"A": 0.1}), ("d2", {})],
[("d1", {"A": 10}), ("d2", {"A": 9})],
[("d1", {"A": 10}), ("d2", {"A": 9})],
1000,
ExecutionConfig(commission_bps=100, stamp_tax_bps=200, slippage_bps=0, min_trade_amount=0),
)
pairing = pair_ledger_trades(ledger)
assert len(pairing.closed_lots) == 1
assert pairing.closed_lots[0].quantity == 10
assert pairing.closed_lots[0].cost == 101
assert pairing.closed_lots[0].net_proceeds == pytest.approx(87.3)
assert pairing.realized_net_pnl == pytest.approx(-13.7)
assert pairing.win_rate == 0
assert pairing.open_lots == ()
assert ledger.nav_series.tolist() == pytest.approx([999, 986.3])
def test_last_day_multiple_fills_each_pay_once_and_match_nav():
ledger = simulate_daily_ledger_with_audit(
[("d2", {"A": 0.25, "B": 0.5})],
[("d2", {"A": 10, "B": 20})],
[("d1", {"A": 10, "B": 20}), ("d2", {"A": 10, "B": 20})],
1000,
ExecutionConfig(commission_bps=100, stamp_tax_bps=200, slippage_bps=0, min_trade_amount=0),
)
assert ledger.nav_series.tolist() == pytest.approx([1000, 992.5])
assert ledger.trades_frame["fee"].tolist() == [2.5, 5.0]
pairing = pair_ledger_trades(ledger)
assert len(pairing.open_lots) == 2
assert pairing.closed_lots == ()
assert pairing.realized_net_pnl == 0
assert pairing.win_rate is None
def test_partial_fifo_sales_allocate_entry_cost_and_keep_unclosed_lot_out_of_win_rate():
ledger = simulate_daily_ledger_with_audit(
[("d1", {"A": 0.2}), ("d2", {"A": 0.1}), ("d3", {})],
[("d1", {"A": 10}), ("d2", {"A": 10}), ("d3", {"A": 10})],
[("d1", {"A": 10}), ("d2", {"A": 10}), ("d3", {"A": 10})],
1000,
ExecutionConfig(commission_bps=100, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
)
pairing = pair_ledger_trades(ledger)
assert len(pairing.matches) == 2
assert len(pairing.closed_lots) == 1
assert pairing.closed_lots[0].quantity == 20
assert pairing.closed_lots[0].cost == 202
assert pairing.closed_lots[0].net_proceeds == pytest.approx(198)
assert pairing.realized_net_pnl == pytest.approx(-4)
assert pairing.win_rate == 0
def test_same_day_sell_and_buy_are_different_lots_with_no_duplicate_fees():
ledger = simulate_daily_ledger_with_audit(
[("d1", {"A": 0.5}), ("d2", {"B": 0.5})],
[("d1", {"A": 10, "B": 10}), ("d2", {"A": 10, "B": 10})],
[("d1", {"A": 10, "B": 10}), ("d2", {"A": 10, "B": 10})],
1000,
ExecutionConfig(commission_bps=100, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
)
assert ledger.nav_series.tolist() == pytest.approx([995, 985.025])
pairing = pair_ledger_trades(ledger)
assert pairing.closed_lots[0].asset == "A"
assert pairing.closed_lots[0].net_pnl == pytest.approx(-10)
assert pairing.open_lots[0].asset == "B"
def test_small_fractional_holding_is_not_destroyed_after_partial_sale():
ledger = simulate_daily_ledger_with_audit(
[("d1", {"A": 0.5}), ("d2", {"A": 0.25})],
[("d1", {"A": 1e9}), ("d2", {"A": 1e9})],
[("d1", {"A": 1e9}), ("d2", {"A": 1e9})],
1000,
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
)
assert ledger.nav_series.tolist() == [1000, 1000]
assert ledger.positions[-1].holdings["A"] == 2.5e-7
pairing = pair_ledger_trades(ledger)
assert pairing.open_lots[0].quantity == 2.5e-7
assert pairing.open_lots[0].remaining_cost == 250
def test_real_tiny_remaining_lot_is_not_treated_as_a_completed_trade():
ledger = simulate_daily_ledger_with_audit(
[("d1", {"A": 1}), ("d2", {"A": 1e-13})],
[("d1", {"A": 1}), ("d2", {"A": 1})],
[("d1", {"A": 1}), ("d2", {"A": 1})],
1e12,
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
)
pairing = pair_ledger_trades(ledger)
assert pairing.closed_lots == ()
assert pairing.win_rate is None
assert pairing.open_lots[0].quantity == ledger.positions[-1].holdings["A"]
assert pairing.open_lots[0].remaining_cost == pytest.approx(ledger.positions[-1].holdings["A"])
@pytest.mark.parametrize("price", [3, 11, 13])
def test_complete_exit_closes_all_accumulated_lots_without_rounding_residue(price):
prices = [(date, {"A": price}) for date in ("d1", "d2", "d3", "d4")]
ledger = simulate_daily_ledger_with_audit(
[("d1", {"A": 0.1}), ("d2", {"A": 0.2}), ("d3", {"A": 0.3}), ("d4", {})],
prices,
prices,
1000,
ExecutionConfig(commission_bps=0, stamp_tax_bps=0, slippage_bps=0, min_trade_amount=0),
)
pairing = pair_ledger_trades(ledger)
assert ledger.positions[-1].holdings == {}
assert pairing.open_lots == ()
assert len(pairing.closed_lots) == 3
assert sum(lot.cost for lot in pairing.closed_lots) == pytest.approx(300)
assert sum(match.net_proceeds for match in pairing.matches) == pytest.approx(300)
assert pairing.realized_net_pnl == pytest.approx(0)
assert pairing.win_rate == 0