From 7d3e840483d7f3b5d7b2d987c95fb79a5dc4a63b Mon Sep 17 00:00:00 2001 From: ao gong <41768719+ageorge156@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:10:01 +0800 Subject: [PATCH] feat(strategy): share causal ledger and bounded seven-strategy research --- README.md | 1 + docs/strategy-research.md | 64 ++++ handoff-factor-diagnostics.md | 14 +- src/quant_engine/execution.py | 41 ++- src/quant_engine/strategy_contracts.py | 140 ++++++++ src/quant_engine/strategy_optimizer.py | 93 ++++++ src/quant_engine/strategy_research.py | 444 +++++++++++++++++++++++++ src/quant_engine/trade_pairing.py | 170 ++++++++++ tests/test_daily_decision_policy.py | 58 ++++ tests/test_strategy_optimizer.py | 179 ++++++++++ tests/test_strategy_research.py | 436 ++++++++++++++++++++++++ tests/test_trade_pairing.py | 127 +++++++ 12 files changed, 1760 insertions(+), 7 deletions(-) create mode 100644 docs/strategy-research.md create mode 100644 src/quant_engine/strategy_contracts.py create mode 100644 src/quant_engine/strategy_optimizer.py create mode 100644 src/quant_engine/strategy_research.py create mode 100644 src/quant_engine/trade_pairing.py create mode 100644 tests/test_daily_decision_policy.py create mode 100644 tests/test_strategy_optimizer.py create mode 100644 tests/test_strategy_research.py create mode 100644 tests/test_trade_pairing.py diff --git a/README.md b/README.md index 9716c46..8c4730a 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ - `retrospective_*_contracts` — 未发布的显式 v2 回顾性合同:区分历史业务日期与实际可得/计算时间,保留 v1 和现有金融公式,不授予历史可得性、发布或执行权限;见 [v2 接口说明](docs/RETROSPECTIVE_COMPUTATION_V2.md) - `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计 - `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_library` — 通用方法(turnover / winsorize / IC / OLS / jb_test) - `portfolio_decomp` — 组合分解(risk_parity / mean_variance / 因子归因) diff --git a/docs/strategy-research.md b/docs/strategy-research.md new file mode 100644 index 0000000..3281a38 --- /dev/null +++ b/docs/strategy-research.md @@ -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. diff --git a/handoff-factor-diagnostics.md b/handoff-factor-diagnostics.md index df31684..07ae939 100644 --- a/handoff-factor-diagnostics.md +++ b/handoff-factor-diagnostics.md @@ -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. diff --git a/src/quant_engine/execution.py b/src/quant_engine/execution.py index 9fd431e..8de1549 100644 --- a/src/quant_engine/execution.py +++ b/src/quant_engine/execution.py @@ -22,7 +22,7 @@ from __future__ import annotations import math -from collections.abc import Mapping +from collections.abc import Callable, Mapping from dataclasses import dataclass, replace from typing import Any @@ -614,16 +614,21 @@ def _rebalance_at_prices( filled: list[ExecutionResult] = [] for raw_execution in sell_executions: 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( raw_execution, side="sell", quantity=quantity, price=price, ) - held = holdings.get(execution.stock_code, 0.0) 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] cash += execution.net_cash_flow filled.append(execution) @@ -706,10 +711,23 @@ def _simulate_daily_ledger( valuation_price_history: list[tuple[str, dict[str, float]]], initial_cash: float, config: ExecutionConfig, + decision_policy: Callable[[DailyPosition], Mapping[str, float] | None] | None = None, ) -> 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 = ( _validate_sparse_daily_histories( - target_weights_history, + validation_targets, execution_price_history, valuation_price_history, ) @@ -719,8 +737,9 @@ def _simulate_daily_ledger( positions: list[DailyPosition] = [] daily_executions: list[DailyExecution] = [] + pending_targets: dict[str, float] | None = None 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: executions: tuple[ExecutionResult, ...] = () nav_before = 0.0 @@ -764,6 +783,11 @@ def _simulate_daily_ledger( 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( initial_cash=initial_cash, @@ -778,11 +802,15 @@ def simulate_daily_ledger_with_audit( valuation_price_history: list[tuple[str, dict[str, float]]], initial_cash: float, config: ExecutionConfig | None = None, + *, + decision_policy: Callable[[DailyPosition], Mapping[str, float] | None] | None = None, ) -> ExecutionSimulationResult: """以稀疏调仓和完整日历运行成交后持仓 Ledger。 执行价只用于调仓日现金与股数变化,估值价用于每个交易日日末 NAV;二者 显式分离,从而支持“下一日 open 成交、同日 close 估值”的无前视研究。 + 可选 decision_policy 在收盘估值后接收实际持仓副本,仅为下一日生成目标; + 此模式须传空固定目标和完整开盘价日历。最后日决定不会执行。 """ if not math.isfinite(initial_cash) or initial_cash <= 0: 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, initial_cash, ExecutionConfig() if config is None else config, + decision_policy, ) diff --git a/src/quant_engine/strategy_contracts.py b/src/quant_engine/strategy_contracts.py new file mode 100644 index 0000000..f237e25 --- /dev/null +++ b/src/quant_engine/strategy_contracts.py @@ -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)) + ) diff --git a/src/quant_engine/strategy_optimizer.py b/src/quant_engine/strategy_optimizer.py new file mode 100644 index 0000000..506abf0 --- /dev/null +++ b/src/quant_engine/strategy_optimizer.py @@ -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)) diff --git a/src/quant_engine/strategy_research.py b/src/quant_engine/strategy_research.py new file mode 100644 index 0000000..e92497c --- /dev/null +++ b/src/quant_engine/strategy_research.py @@ -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, + ) diff --git a/src/quant_engine/trade_pairing.py b/src/quant_engine/trade_pairing.py new file mode 100644 index 0000000..5de037f --- /dev/null +++ b/src/quant_engine/trade_pairing.py @@ -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, + ) diff --git a/tests/test_daily_decision_policy.py b/tests/test_daily_decision_policy.py new file mode 100644 index 0000000..19a836b --- /dev/null +++ b/tests/test_daily_decision_policy.py @@ -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, + ) diff --git a/tests/test_strategy_optimizer.py b/tests/test_strategy_optimizer.py new file mode 100644 index 0000000..dd6d8c9 --- /dev/null +++ b/tests/test_strategy_optimizer.py @@ -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 == [] diff --git a/tests/test_strategy_research.py b/tests/test_strategy_research.py new file mode 100644 index 0000000..076586f --- /dev/null +++ b/tests/test_strategy_research.py @@ -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" diff --git a/tests/test_trade_pairing.py b/tests/test_trade_pairing.py new file mode 100644 index 0000000..3dd207e --- /dev/null +++ b/tests/test_trade_pairing.py @@ -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