diff --git a/README.md b/README.md index ba5bca8..3a2317b 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,8 @@ - `retrospective_*_contracts` — 未发布的显式 v2 回顾性合同:区分历史业务日期与实际可得/计算时间,保留 v1 和现有金融公式,不授予历史可得性、发布或执行权限;见 [v2 接口说明](docs/RETROSPECTIVE_COMPUTATION_V2.md) - `attribution` — 基于实际成交后持仓的隔夜 / 日内 / 交易成本逐日收益归因与闭合审计 - `metrics` — 绝对绩效 + 严格日期对齐的 TE / IR / alpha / beta 基准相对绩效 +- `strategy_research` / `strategy_optimizer` / `trade_pairing` / `strategy_artifact` — 七策略候选、下一日开盘执行、统一账本、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 / 因子归因) - `risk` — ndarray 低层风险公式 + 标签安全、可分组的 Euler 成分风险分解 diff --git a/docs/factor-diagnostics.md b/docs/factor-diagnostics.md new file mode 100644 index 0000000..359d7eb --- /dev/null +++ b/docs/factor-diagnostics.md @@ -0,0 +1,37 @@ +# Keyed factor diagnostics, candidate API 0.1.0 + +`quant_engine.factor_diagnostics` is a pure calculation module for caller-supplied observations. It does not change the older `factor_library.ic_summary` API or the governed factor-set contracts. Its calculation/API version does not establish a production algorithm, source qualification, historical availability or execution eligibility. + +## Inputs and outputs + +Factor values are a DataFrame indexed by unique `(date, asset)` keys, with unique factor columns. Dates must be a naive DatetimeIndex of midnight session labels; strings and timezone-aware/intraday timestamps are rejected. Order may vary and is normalized without modifying inputs. Identifiers must be nonempty printable strings. Values are real finite numbers or missing (`None`, `pd.NA`, NaN); booleans, numeric strings, infinities and duplicate keys are rejected. + +`daily_ic(factors, returns, min_pairs=3, min_days=2)` aligns the forward-return Series by complete keys inside each date. The factor keys define the observed universe. Extra return keys are ignored and counted, while absent return keys remain missing. Every observed factor/date survives, including zero valid pairs. Output includes daily Pearson and average-tie Spearman values, actual pair/observation/value counts and separate reasons. Returns must already be labels for the intended forward interval; this function does not infer their unit, calendar, origin or availability. + +Daily summaries weight each valid date equally. Mean and sample standard deviation (`ddof=1`) use valid daily ICs, not the number of securities. IR is unannualized `mean/std`; the descriptive IID statistic is `IR*sqrt(valid_days)`, with two-sided Student-t p and `df=valid_days-1`. Pearson and RankIC have separate valid/missing day counts. No-valid-day and insufficient-day results remain explicit. Constant series have standard deviation zero and undefined ratios. + +The absolute standard-deviation resolution for ratio statistics is `32*float64 epsilon` (about 7.11e-15) because IC is bounded to [-1,1]. Nonconstant dispersion at or below that resolution retains its mean, observed standard deviation and counts, but IR/t/p are null with `below_resolution`. This candidate numerical policy prevents floating-point noise between equivalent cross sections from becoming extreme significance. It is not a statistical materiality threshold. Serial correlation, overlapping forward intervals and effective sample size are **not corrected**; t/p do not establish inferential validity or decision admission. + +`correlation_matrix(factors, method='pearson', min_pairs=3)` pools complete `(date,asset)` pairs for each cell. It is not the mean of daily cross-sectional correlations: dates with more pairs contribute more observations. Every cell reports pair count and status. Empty, short or constant diagonals are null, not identity values. Pairwise deletion can produce a non-positive-semidefinite matrix; this is not an admitted risk/covariance matrix. Pearson translates before scaling to preserve representable small differences near a large offset, with scale-first fallback only if subtraction overflows. Spearman ranks original paired observations to avoid creating ties through underflow. + +## Explicit forward-return intervals + +`forward_returns(prices, sessions=..., entry_lag_sessions=..., holding_sessions=..., price_field=..., price_basis=...)` accepts a keyed price Series and an explicit, unique, increasing session calendar. Lag must be an integer >=0 and holding an integer >=1; booleans are rejected. The returned `ForwardReturns` object owns a return Series and interval DataFrame for every supplied session and observed asset, plus method metadata. + +For signal session `t`, entry is session `t+lag`, exit is `t+lag+holding`, and the label is `P_exit/P_entry-1`. Endpoint prices must be positive when present and comparable under the caller-declared field/basis. Missing endpoint prices yield `missing_price`; calendar-tail insufficiency yields `insufficient_calendar`; nonfinite arithmetic yields `numerical_failure`. No filling, per-asset dropna calendar, next-available-price jump or daily-return summation is used. Intermediate prices are not required for this endpoint ratio. A lag of zero describes a same-session price basis and does not mean a closing signal can trade at the same close. + +## Reuse decision and verification + +需求:按完整证券/日期键提供逐日IC、相关矩阵与显式前瞻标签,保留样本及未定义原因。 + +已有方案:`factor_library.ic_summary(periods=(1,))`的单截面Pearson和`spearman_ic`;旧多周期rolling-sum不符合显式端点区间,旧摘要也不代替逐日统计。 + +候选开源方案:不需要,已有pandas/numpy/scipy及核心函数足够,无新增依赖。 + +推荐方案:在核心中二次封装现有单截面相关函数,增加观察键、分组、计数与区间合同。 + +原因:保持计算归属quant_engine,平台只做输入/输出适配;旧API保持兼容。 + +风险:浮点分辨率、缺失机制、序列依赖和PIT均须显式记录,纯合成通过不能升级正式资格。 + +Tests include manually checkable daily `[1,-0.5,0]` ICs with three effective dates, pairwise missingness, empty/constant results, average ranks, extreme numeric ranges, affine-equivalent daily ICs, large-offset Pearson precision, and explicit-calendar endpoint labels. Existing factor-library tests remain unchanged. No database, provider, production recomputation or ETL is part of this contract. diff --git a/docs/strategy-research.md b/docs/strategy-research.md new file mode 100644 index 0000000..2031473 --- /dev/null +++ b/docs/strategy-research.md @@ -0,0 +1,78 @@ +# 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. The reserved asset identity `CASH` cannot be used as a security, preventing collision with projected cash positions. 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. + +## Strategy artifact projection + +`strategy_artifact.build_strategy_research_artifact` accepts an actual `StrategyResearchResult` or `StrategyOptimizationResult` and returns the existing schema1.1.0 `ResearchRunArtifact`. The calculation result retains detached OHLC and benchmark-return views and explicit cost inputs; optimization retains a detached grid snapshot. The builder projects the selected result's ledger, actual fees, next-session signal links, close-marked holdings and existing metrics. It does not run a second strategy, accounting system or performance formula. + +`params_json.strategy_report` uses `researchhub.strategy-research.v1`. It covers every causal signal, including `no_next_session`, unfilled targets and partial fills; complete FIFO matches, closed/open lots and net PnL; daily versus completed-trade win rates; benchmark status; exact parameters/costs; and all ranked candidate summaries. A grid artifact stores the selected first-ranked ledger plus every candidate's parameters, objective score, metrics and missing reasons, up to100 trials. It does not repeat100 full ledgers. Report contents are covered by the artifact's canonical content digest. User metadata cannot overwrite this report or its performance explanation. + +The legacy factor-signal table remains empty: strategy signals have no factor score. The report explicitly locates the real strategy signals and marks factor attribution and covariance risk as not computed. Actual fills link to report signal IDs. A zero-value portfolio retains its actual zero values and an undefined weight, with the exact dates and reason recorded; it is not assigned an invented zero or full-cash weight. Non-numerical performance values carry explicit reasons keyed to their fact-table columns, and win-rate basis is completed trades. Requested empty benchmark data retains its identity and empty status, while unrequested data has no identity. + +This is a storage-neutral candidate. The platform's isolated durable-file adapter must validate this report and expose undefined weights before enabling this path. This projection does not validate a production database schema, execute SQL, admit real data, or grant decision eligibility. Existing factor-artifact consumers remain unchanged. + +## 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. + +The reviewed implementation was saved and pushed as `7d3e840483d7f3b5d7b2d987c95fb79a5dc4a63b` to the existing PR #21 (https://gitea.puyuanfh.cn/ageorge156/quant_engine/pulls/21), advancing the original 3c97102f candidate. This receipt-only checkpoint does not change the tested code. One explicit central candidate refresh and `ship --ready --confirm-l3` is the next boundary; no prior CI wait was polled or resumed. Push is a save checkpoint, and does not establish merge or source admission. + +Artifact increment verification: 26 new artifact tests plus81 strategy/optimizer and7 existing factor-artifact tests passed together (114). The final complete core suite passed1212 tests in15.59s with1170 pandas deprecation warnings. Four-file Ruff and three-module strict typing passed. The read-only reviewer confirmed the reserved-CASH rejection and fact-column explanation keys after actual regressions. The earlier5192370 central receipt remains CI pending without polling; this new increment is saved and gated separately on the same delivery. diff --git a/handoff-factor-diagnostics.md b/handoff-factor-diagnostics.md new file mode 100644 index 0000000..cae84a0 --- /dev/null +++ b/handoff-factor-diagnostics.md @@ -0,0 +1,39 @@ +# Quant OS factor diagnostics and strategy core increments + +## 2026-10-04 strategy artifact increment + +The existing delivery now adds a storage-neutral strategy artifact projection required by platform Draft #102. The earlier central boundary for candidate5192370a9ecfeca09ed39ee9ca3583060d8777d9 returned `waiting_on_dependency`, CI pending, receipt e28c09224685c20569a8a6f627a2c34b6a1c69f3dc43eadabe918c7458476e45. That candidate and the older3c97102f receipt remain preserved and have not been polled or resumed. This is a new independently completed code increment on the same branch, PR #21, lifecycle and sole writer. + +The result keeps detached input/grid snapshots. The new builder reuses schema1.1.0 and the original ledger, projecting real fills, NAV, positions and metrics while covering all strategy signals, FIFO pairing and up to100 ranked trials in `strategy_report`. It does not fabricate factor scores, attribution or covariance risk. Zero-NAV weight is undefined with a reason. A security cannot claim reserved CASH identity. Production schema/storage and source admission remain outside this pure candidate; platform isolated validation and display still need integration. + +Actual missing-module RED preceded implementation. Independent review found a reproducible cash-identity collision (run/optimizer RED) and fact-column explanation naming inconsistency (RED); both are fixed with permanent regressions and final read-only PASS. Final focused run114 passed (26 new artifact tests,81 strategy/optimizer tests,7 existing factor-artifact tests). Final whole-core run1212 passed in15.59s with1170 pandas deprecation warnings. Targeted four-file Ruff and three-source strict mypy passed. Prior financial calculations and their accepted test evidence are not recomputed by the projection. The fixed49 framework/source and delivery route remain unchanged, resume/review critical gpt-6-astra/xhigh was reused/validated, runtime observation remains unknown. + +Next: save this completed increment and enter one central candidate refresh/Ready gate, then bind its saved revision in the platform isolated task/artifact path. The broad platform delivery remains WIP and formal run/optimize remains gated. + +## 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. + +The reviewed implementation was saved and pushed as `7d3e840483d7f3b5d7b2d987c95fb79a5dc4a63b` to the existing PR #21 (https://gitea.puyuanfh.cn/ageorge156/quant_engine/pulls/21), advancing the original 3c97102f candidate. This receipt-only checkpoint does not change the tested code. One explicit central candidate refresh and `ship --ready --confirm-l3` is the next boundary; no prior CI wait was polled or resumed. Push is a save checkpoint, and does not establish merge or source admission. + +## 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. + +The fixed framework source is 16e96351fbc5bd5a918c7deff556490bbc44fb99. Valid source cleanliness/version evidence was reused; actual core feature/worktree router ranges and applicable AGENTS were read. Resume/review model-policy critical gpt-6-astra/xhigh passed; actual runtime remains unknown. The core has no stage ledger at its declared path, so no foreign stage state is borrowed. Lifecycle created this task after policy-check; old completed trees were retained for ignored local data, no old lease/state was rewritten. The new .venv was created through lifecycle run with the existing frozen lock and dev extras, offline from local caches. + +Scope is L3 mathematical behavior, pure memory/caller data. The new candidate module provides daily IC/RankIC summaries, keyed pooled correlation matrices and explicit-calendar forward-return labels. Production algorithm/source/PIT/execution qualifications remain unestablished and decision_eligible=false. Existing factor-library and governed factor-set contracts are unchanged. Reuse rationale and exact numerical/statistical assumptions are in docs/factor-diagnostics.md. + +Evidence so far: initial47 tests failed for the missing API; first implementation46 passed and one strict floating-zero assertion failed, corrected to a stated1e-15 tolerance without clipping values. A genuine extreme-rank scaling regression was reproduced and fixed by ranking original values. An affine-equivalent IC case reproduced enormous IR/t from rounding dispersion, fixed with the public32eps resolution policy. Independent reviewer found two representable-offset Pearson errors; both were reproduced and fixed by translate-before-scale. Latest54 new plus16 old tests=70 passed; final whole-core run1093 passed in12.82s with1166 existing pandas deprecation warnings. Targeted Ruff and final module-only mypy passed. Reviewer rechecked the two numeric fixes with tiny independent samples, no new blocker. + +The implementation was saved and pushed as 69383a30b079790018edb25679be08460a5ab17f in Draft PR #21 (https://gitea.puyuanfh.cn/ageorge156/quant_engine/pulls/21). The existing platform delivery now consumes that exact prepared archive through an unwired thin adapter. Its 25 tests call this real core; 9 isolated HTTP tests and 21 actual-component tests pass. The local browser displayed daily IC [1, -0.5, approximately 0], mean 1/6, actual daily pairs, pooled pairs, constant/missing reasons and holding-period valid days [3, 2, 1]; source failure cleared old results and explicit retry restored them. The prepared-report boundary rejects conflicting counts, intervals and method claims without recalculating statistics. Platform changes remain its separate, unmerged delivery; this is evidence for a consumer, not combined production acceptance. + +The bounded pure-library increment is code complete and reviewed. The next operation is the central delivery gate for this core PR; its receipt, not this handoff, establishes Ready/CI/merge/main acceptance and cleanup facts. After successful core delivery, the platform will bind the accepted core source revision while retaining candidate and unestablished qualifications. Main full-suite evidence remains the unchanged 1093-pass candidate run above until the gate establishes its own validation receipt. + +No source/provider/NAS query, production ETL, migration, release, deployment or trade occurred in this increment. Production data/source/PIT/algorithm admission is outside this pure-library delivery and remains explicitly unestablished. The broader platform/M0–M5 delivery remains unfinished. Reuse this same lifecycle/branch/PR; a push alone is not completion. 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/factor_diagnostics.py b/src/quant_engine/factor_diagnostics.py new file mode 100644 index 0000000..aa4d3a3 --- /dev/null +++ b/src/quant_engine/factor_diagnostics.py @@ -0,0 +1,325 @@ +"""Pure, observation-keyed factor diagnostics on caller-supplied data. + +These low-level candidate calculations neither fetch sources nor grant data, +historical-availability, production-algorithm or execution qualification. The +legacy factor_library API remains unchanged. IC summaries here aggregate daily +cross sections, never pooled asset/day observations. All dates are naive session +labels, not information-availability timestamps. +""" +from __future__ import annotations + +from dataclasses import dataclass +from decimal import Decimal +import math +from numbers import Real +from typing import Any + +import numpy as np +import pandas as pd +from scipy import stats + +from quant_engine.factor_library import ic_summary, spearman_ic + +FACTOR_DIAGNOSTICS_VERSION = "0.1.0" +# IC is bounded to [-1, 1]. Below this absolute float64 resolution, dispersion +# cannot reliably distinguish equivalent cross sections from rounding noise. +MINIMUM_IC_STD_FOR_RATIOS = 32 * np.finfo(np.float64).eps + + +def _qualification() -> dict[str, Any]: + return { + "contract_version": FACTOR_DIAGNOSTICS_VERSION, + "production_algorithm_version": None, + "source_admission": "not_established", + "historical_availability": "not_established", + "decision_eligible": False, + } + + +def _integer(value: int, minimum: int, name: str) -> None: + if type(value) is not int or value < minimum: + raise ValueError(f"{name} must be an integer >= {minimum}") + + +def _label(value: str) -> bool: + return (isinstance(value, str) and 0 < len(value) <= 128 + and value == value.strip() and all(char.isprintable() for char in value)) + + +def _dates(index: pd.Index) -> None: + if (not isinstance(index, pd.DatetimeIndex) or index.tz is not None + or index.hasnans or not index.equals(index.normalize())): + raise ValueError("Expected naive midnight session dates") + + +def _keys(index: pd.Index) -> None: + if (not isinstance(index, pd.MultiIndex) or list(index.names) != ["date", "asset"] + or not index.is_unique): + raise ValueError("Expected unique (date, asset) observation keys") + _dates(index.get_level_values("date")) + if any(not _label(asset) for asset in index.get_level_values("asset")): + raise ValueError("Asset identifiers must be nonempty strings") + + +def _numbers(series: pd.Series) -> pd.Series: + numbers = [] + for value in series: + if value is None or value is pd.NA: + numbers.append(math.nan) + continue + if isinstance(value, (bool, np.bool_)) or not isinstance(value, (Real, Decimal)): + raise ValueError("Values must be real numbers or missing, without coercion") + try: + number = float(value) + except (OverflowError, ValueError) as exc: + raise ValueError("Value cannot be represented as a finite number") from exc + if math.isinf(number): + raise ValueError("Infinite observations are not permitted") + numbers.append(number) + return pd.Series(numbers, index=series.index, name=series.name, dtype=float) + + +def _panel(frame: pd.DataFrame) -> pd.DataFrame: + if not isinstance(frame, pd.DataFrame): + raise ValueError("Expected a factor DataFrame") + _keys(frame.index) + if not frame.columns.is_unique or any(not _label(name) for name in frame.columns): + raise ValueError("Factor identifiers must be unique nonempty strings") + result = pd.DataFrame(index=frame.index) + for name in frame.columns: + result[name] = _numbers(frame[name]) + return result.sort_index() + + +@dataclass(frozen=True) +class _Correlation: + value: float | None + n_pairs: int + status: str + + +def _center_scale(series: pd.Series) -> pd.Series: + # Translate first to preserve distinguishable low bits beside a large offset. + # Opposite extreme endpoints may overflow subtraction; only then scale first. + with np.errstate(all="ignore"): + shifted = series - series.iloc[0] + if not bool(np.isfinite(shifted).all()): + bounded = series / series.abs().max() + shifted = bounded - bounded.iloc[0] + return shifted / shifted.abs().max() + + +def _correlation(left: pd.Series, right: pd.Series, method: str, minimum: int) -> _Correlation: + # Callers already bind the observation domain. Concat still aligns complete + # keys rather than independently deleting missing left and right values. + paired = pd.concat([left.rename("left"), right.rename("right")], axis=1).dropna() + count = len(paired) + if count < minimum: + return _Correlation(None, count, "no_pairs" if count == 0 else "insufficient_pairs") + constant_left = bool((paired["left"] == paired["left"].iloc[0]).all()) + constant_right = bool((paired["right"] == paired["right"].iloc[0]).all()) + if constant_left or constant_right: + reason = ("constant_both" if constant_left and constant_right else + "constant_left" if constant_left else "constant_right") + return _Correlation(None, count, reason) + # Pearson needs bounded magnitudes to avoid covariance overflow. Rank the + # original observations: scaling could underflow distinct tiny values into + # artificial ties next to a very large outlier. + with np.errstate(all="ignore"): + if method == "pearson": + scaled_left = _center_scale(paired["left"]) + scaled_right = _center_scale(paired["right"]) + value = float(ic_summary(scaled_left, scaled_right, periods=(1,), + method="pearson").loc[1, "ic_mean"]) + else: + value = float(spearman_ic(paired["left"], paired["right"])) + if not math.isfinite(value) or abs(value) > 1 + 1e-12: + return _Correlation(None, count, "numerical_failure") + return _Correlation(max(-1.0, min(1.0, value)), count, "ok") + + +def _summary(values: list[float | None], minimum: int) -> dict[str, Any]: + available = [value for value in values if value is not None] + count = len(available) + result: dict[str, Any] = { + "valid_days": count, "missing_days": len(values) - count, + "mean": None, "std": None, "ir": None, "t": None, "p": None, + "status": "no_valid_days" if count == 0 else "insufficient_days", + } + if count == 0: + return result + constant = all(value == available[0] for value in available) + mean = available[0] if constant else math.fsum(available) / count + result["mean"] = mean + if count < minimum: + return result + std = 0.0 if constant else float(np.std(available, ddof=1)) + result["std"] = std + if constant: + result["status"] = "constant_values" + return result + if not math.isfinite(std) or std <= 0: + result["std"] = None + result["status"] = "numerical_failure" + return result + if std <= MINIMUM_IC_STD_FOR_RATIOS: + result["status"] = "below_resolution" + return result + ir = mean / std + t_value = ir * math.sqrt(count) + p_value = float(2 * stats.t.sf(abs(t_value), df=count - 1)) + if not all(math.isfinite(value) for value in (ir, t_value, p_value)): + result["status"] = "numerical_failure" + return result + result.update(ir=ir, t=t_value, p=p_value, status="ok") + return result + + +def daily_ic( + factors: pd.DataFrame, returns: pd.Series, *, min_pairs: int = 3, min_days: int = 2, +) -> dict[str, Any]: + """Daily Pearson/average-tie RankIC and unannualized daily-IC summaries. + + Factors define the observation domain. Extra return keys are ignored and + counted; missing return keys remain unavailable. Every factor/date is kept, + including zero-pair dates. IID t/p are descriptive only: serial correlation + and overlapping holding intervals are not corrected or admitted. + """ + _integer(min_pairs, 3, "min_pairs") + _integer(min_days, 2, "min_days") + frame = _panel(factors) + if not isinstance(returns, pd.Series): + raise ValueError("Expected a forward-return Series") + _keys(returns.index) + clean_returns = _numbers(returns) + aligned = clean_returns.reindex(frame.index) + rows: list[dict[str, Any]] = [] + summaries = [] + for factor_id in frame.columns: + points = [] + for day, left in frame[factor_id].groupby(level="date", sort=True): + right = aligned.reindex(left.index) + pearson = _correlation(left, right, "pearson", min_pairs) + rank = _correlation(left, right, "spearman", min_pairs) + point = { + "date": day.strftime("%Y-%m-%d"), "factor_id": factor_id, + "ic": pearson.value, "rank_ic": rank.value, "n_pairs": pearson.n_pairs, + "n_observations": len(left), "n_factor": int(left.notna().sum()), + "n_return": int(right.notna().sum()), + "ic_status": pearson.status, "rank_ic_status": rank.status, + } + rows.append(point) + points.append(point) + summary: dict[str, Any] = {"factor_id": factor_id, "n_days": len(points)} + for field in ("ic", "rank_ic"): + summary.update({f"{field}_{key}": value for key, value in + _summary([point[field] for point in points], min_days).items()}) + summaries.append(summary) + return { + **_qualification(), "rows": rows, "summary": summaries, + "method": {"aggregation": "equal_weight_daily_cross_sections", "min_pairs": min_pairs, + "min_days": min_days, "standard_deviation_ddof": 1, "ir_annualized": False, + "minimum_ic_std_for_ratios": float(MINIMUM_IC_STD_FOR_RATIOS), + "ic_std_resolution_policy": "absolute_32_float64_eps", + "rank_ties": "average", "t_method": "naive_iid_unadjusted", + "pearson_normalization": "translate_then_scale_with_overflow_fallback", + "p_method": "two_sided_student_t", "t_degrees_of_freedom": "valid_days - 1", + "serial_correlation_adjusted": False, "holding_overlap_adjusted": False, + "observation_domain": "factor_keys", "missing_policy": "pairwise_complete_keys", + "extra_return_keys": len(returns.index.difference(frame.index))}, + } + + +def correlation_matrix( + factors: pd.DataFrame, *, method: str = "pearson", min_pairs: int = 3, +) -> dict[str, Any]: + """Pool keyed asset/session pairs, not an average of daily correlations. + + Larger cross sections contribute more pairs. Pairwise deletion may produce + a non-PSD matrix; this output is not a covariance/risk-matrix contract. + """ + _integer(min_pairs, 3, "min_pairs") + if method not in ("pearson", "spearman"): + raise ValueError("method must be pearson or spearman") + frame = _panel(factors) + names = list(frame.columns) + estimates = [[_correlation(frame[left], frame[right], method, min_pairs) + for right in names] for left in names] + return { + **_qualification(), "factors": names, "n_observations": len(frame), + "matrix": [[estimate.value for estimate in row] for row in estimates], + "n_pairs": [[estimate.n_pairs for estimate in row] for row in estimates], + "status": [[estimate.status for estimate in row] for row in estimates], + "method": {"correlation": method, "aggregation": "pooled_asset_session_pairwise", + "pearson_normalization": "translate_then_scale_with_overflow_fallback", + "min_pairs": min_pairs, "missing_policy": "pairwise_complete_keys", + "rank_ties": "average", "positive_semidefinite_guaranteed": False}, + } + + +@dataclass(frozen=True) +class ForwardReturns: + """Owned output frames; frozen attributes do not make pandas objects immutable.""" + returns: pd.Series + intervals: pd.DataFrame + metadata: dict[str, Any] + + +def forward_returns( + prices: pd.Series, *, sessions: pd.DatetimeIndex, entry_lag_sessions: int, + holding_sessions: int, price_field: str, price_basis: str, +) -> ForwardReturns: + """Label P[t+lag+holding]/P[t+lag]-1 on an explicit session calendar. + + Requires comparable endpoint prices, not every intermediate price. Missing + keys/prices remain missing on the supplied calendar. lag=0 is a same-session + price basis, not a claim that a closing signal can execute at that close. + """ + _integer(entry_lag_sessions, 0, "entry_lag_sessions") + _integer(holding_sessions, 1, "holding_sessions") + if not _label(price_field) or not _label(price_basis): + raise ValueError("Explicit price field and comparable-price basis are required") + _dates(sessions) + if not sessions.is_unique or not sessions.is_monotonic_increasing: + raise ValueError("Calendar sessions must be unique and increasing") + if not isinstance(prices, pd.Series): + raise ValueError("Expected a keyed price Series") + _keys(prices.index) + values = _numbers(prices) + if len(prices.index.get_level_values("date").difference(sessions)): + raise ValueError("Price observation outside the supplied calendar") + if bool((values.dropna() <= 0).any()): + raise ValueError("Endpoint prices must be positive when present") + assets = sorted(prices.index.get_level_values("asset").unique()) + index = pd.MultiIndex.from_product([sessions, assets], names=["date", "asset"]) + grid = values.reindex(index) + output, intervals = [], [] + for position, signal_date in enumerate(sessions): + entry_position = position + entry_lag_sessions + exit_position = entry_position + holding_sessions + entry_date = sessions[entry_position] if entry_position < len(sessions) else None + exit_date = sessions[exit_position] if exit_position < len(sessions) else None + for asset in assets: + value, status = math.nan, "insufficient_calendar" + if entry_date is not None and exit_date is not None: + entry, exit_price = grid.loc[(entry_date, asset)], grid.loc[(exit_date, asset)] + status = "missing_price" + if pd.notna(entry) and pd.notna(exit_price): + with np.errstate(over="ignore", invalid="ignore"): + value = float(exit_price / entry - 1) + status = "ok" if math.isfinite(value) else "numerical_failure" + if status != "ok": + value = math.nan + output.append(value) + intervals.append({"signal_date": signal_date.strftime("%Y-%m-%d"), + "entry_date": entry_date.strftime("%Y-%m-%d") if entry_date is not None else None, + "exit_date": exit_date.strftime("%Y-%m-%d") if exit_date is not None else None, + "status": status}) + metadata = {**_qualification(), "formula": "exit_price / entry_price - 1", "unit": "ratio", + "entry_lag_sessions": entry_lag_sessions, "holding_sessions": holding_sessions, + "price_field": price_field, "price_basis": price_basis, + "price_coverage": "endpoints_only", "calendar": "explicit_caller_sessions", + "missing_policy": "no_fill_no_session_skipping", "execution_eligibility": "not_established"} + return ForwardReturns(pd.Series(output, index=index, dtype=float, name="forward_return"), + pd.DataFrame(intervals, index=index, + columns=["signal_date", "entry_date", "exit_date", "status"]), metadata) diff --git a/src/quant_engine/strategy_artifact.py b/src/quant_engine/strategy_artifact.py new file mode 100644 index 0000000..2cc778b --- /dev/null +++ b/src/quant_engine/strategy_artifact.py @@ -0,0 +1,354 @@ +"""Storage-neutral projection of seven-strategy research and bounded grid rankings. + +All accounting and metrics are owned by the completed core result. Strategy +signals have no factor scores, so their full causal record lives in the covered +report, not the legacy factor-signal table. No source or decision admission is +granted by this projection. +""" + +from __future__ import annotations + +import hashlib +from collections.abc import Mapping +from dataclasses import asdict + +import pandas as pd + +from .artifact import ( + RESEARCH_ARTIFACT_SCHEMA_VERSION, + RISK_COLUMNS, + ResearchRunArtifact, + _aware_timestamp, + _canonical_mapping_json, + _required_text, +) +from .strategy_optimizer import StrategyOptimizationResult +from .strategy_research import StrategyResearchResult + +STRATEGY_REPORT_SCHEMA = "researchhub.strategy-research.v1" +_PERFORMANCE = { + "total_ret": "total_return", + "ann_ret": "ann_return", + "ann_volatility": "ann_volatility", + "sharpe": "sharpe", + "sortino": "sortino", + "max_dd": "max_drawdown", + "calmar": "calmar", + "win_rate": "trade_win_rate", +} +_RELATIVE = { + "tracking_error": "tracking_error", + "ir": "information_ratio", + "alpha": "alpha", + "beta": "beta", +} +_SIGNAL_COLUMNS = [ + "run_id", + "signal_date", + "execution_date", + "asset_id", + "factor_score", + "target_weight", +] +_POSITION_COLUMNS = [ + "run_id", + "trade_date", + "asset_id", + "asset_type", + "quantity", + "mark_price", + "market_value", + "weight", +] + + +def _report( + result: StrategyResearchResult, + run_id: str, + optimization: StrategyOptimizationResult | None, +) -> dict[str, object]: + ranking: dict[str, object] | None = None + if optimization is not None: + ranking = { + "objective": optimization.objective, + "grid": optimization.param_grid, + "trial_count": len(optimization.trials), + "selected_rank": 1, + "trials": [ + { + "rank": rank, + "parameters": dict(trial.parameters), + "score": trial.score, + "metrics": dict(trial.result.metrics), + "metric_unavailable": dict(trial.result.metric_unavailable), + } + for rank, trial in enumerate(optimization.trials, start=1) + ], + } + return { + "schema_version": STRATEGY_REPORT_SCHEMA, + "strategy": result.strategy, + "asset": result.asset, + "parameters": dict(result.parameters), + "costs": dict(result.cost_parameters), + "execution": { + "signal_observation": "after_close", + "fill_price": "next_session_open", + "valuation_price": "session_close", + "lag_sessions": 1, + "quantity_basis": "fractional_research", + }, + "signals": [ + asdict(signal) + | { + "asset_id": result.asset, + "signal_id": f"{run_id}:signal:{signal.decision_date}", + } + for signal in result.signals + ], + "trade_pairing": asdict(result.pairing), + "metrics": dict(result.metrics), + "metric_unavailable": dict(result.metric_unavailable), + "benchmark": {"status": result.benchmark_status, "metrics": result.benchmark_metrics}, + "optimization": ranking, + "projections": { + "signals": "strategy_report.signals", + "attribution": "not_computed", + "risk": "not_computed", + "position_weight_basis": "market_value_over_nav", + "undefined_weight_reason": "zero_portfolio_value", + "undefined_weight_dates": [ + position.date + for position in result.ledger.positions + if position.portfolio_value == 0 + ], + }, + "decision_eligible": False, + } + + +def _performance( + result: StrategyResearchResult, + run_id: str, +) -> tuple[pd.DataFrame, dict[str, object]]: + values: dict[str, object] = {"run_id": run_id} + reasons: dict[str, str] = {} + for target, source in _PERFORMANCE.items(): + value = result.metrics[source] + values[target] = value + if value is None: + reasons[target] = result.metric_unavailable[source] + for target, source in _RELATIVE.items(): + value = None if result.benchmark_metrics is None else result.benchmark_metrics[source] + values[target] = value + if value is None: + reasons[target] = ( + "benchmark_" + result.benchmark_status + if result.benchmark_metrics is None + else "benchmark_metric_undefined" + ) + values["n_trades"] = len(result.ledger.trades_frame) + values["n_days"] = len(result.ledger.positions) + return pd.DataFrame([values]), { + "unavailable_reasons": reasons, + "win_rate_basis": "completed_trades", + } + + +def _nav(result: StrategyResearchResult, run_id: str) -> pd.DataFrame: + frame = result.ledger.ledger_frame + frame.insert(0, "run_id", run_id) + frame["trade_date"] = pd.to_datetime(frame["trade_date"]).dt.date + frame["total_cost"] = [ + sum(fill.total_cost for fill in day.executions) for day in result.ledger.daily_executions + ] + benchmark_returns = result.benchmark_returns + if result.benchmark_nav is None or benchmark_returns is None: + frame["benchmark_nav"] = None + frame["benchmark_return"] = None + frame["excess_ret"] = None + else: + frame["benchmark_nav"] = result.benchmark_nav.to_numpy(copy=True) + frame["benchmark_return"] = benchmark_returns.to_numpy(copy=True) + frame["excess_ret"] = result.ledger.daily_returns.to_numpy() - benchmark_returns.to_numpy() + return frame + + +def _trades(result: StrategyResearchResult, run_id: str) -> pd.DataFrame: + frame = result.ledger.trades_frame + frame.insert(0, "run_id", run_id) + frame.insert( + 1, "trade_id", [f"{run_id}:{sequence:08d}" for sequence in range(1, len(frame) + 1)] + ) + signal_by_execution = { + signal.execution_date: signal.decision_date + for signal in result.signals + if signal.execution_date is not None + } + frame["signal_id"] = [ + f"{run_id}:signal:{signal_by_execution[day]}" for day in frame["trade_date"] + ] + frame["trade_date"] = pd.to_datetime(frame["trade_date"]).dt.date + frame["total_cost"] = frame["fee"] + frame["slippage"] + return frame + + +def _positions(result: StrategyResearchResult, run_id: str) -> pd.DataFrame: + rows = [] + observed = result.bars + for day, position in zip(observed.index, result.ledger.positions, strict=True): + for asset, quantity in position.holdings.items(): + if asset != result.asset: + raise ValueError("Strategy ledger contains an unexpected asset") + price = float(observed.at[day, "close"]) + market_value = quantity * price + rows.append( + { + "run_id": run_id, + "trade_date": day.date(), + "asset_id": asset, + "asset_type": "security", + "quantity": quantity, + "mark_price": price, + "market_value": market_value, + "weight": market_value / position.portfolio_value + if position.portfolio_value + else None, + } + ) + rows.append( + { + "run_id": run_id, + "trade_date": day.date(), + "asset_id": "CASH", + "asset_type": "cash", + "quantity": position.cash, + "mark_price": 1.0, + "market_value": position.cash, + "weight": position.cash / position.portfolio_value + if position.portfolio_value + else None, + } + ) + return pd.DataFrame(rows, columns=_POSITION_COLUMNS) + + +def build_strategy_research_artifact( + result: StrategyResearchResult | StrategyOptimizationResult, + *, + run_id: str, + strategy_id: str, + strategy_name: str, + strategy_version: str, + engine_version: str, + code_revision: str, + data_snapshot_id: str, + calendar: str, + timezone: str, + started_at: str | pd.Timestamp, + finished_at: str | pd.Timestamp, + parameters: Mapping[str, object], + benchmark_id: str | None = None, +) -> ResearchRunArtifact: + """Snapshot the chosen real ledger plus all bounded trial summaries. + + ``parameters`` carries task/source identity supplied by the caller. The core + reserves its report and metric explanation; callers cannot substitute them. + Empty requested benchmark data remains distinguishable from no request. + """ + optimization = result if type(result) is StrategyOptimizationResult else None + if optimization is not None: + if not 1 <= len(optimization.trials) <= 100: + raise ValueError("A bounded nonempty optimization result is required") + selected = optimization.trials[0].result + elif type(result) is StrategyResearchResult: + selected = result + else: + raise TypeError("result must be strategy research or optimization") + if selected.decision_eligible or (optimization is not None and optimization.decision_eligible): + raise ValueError("Strategy research cannot grant decision eligibility") + if selected.asset == "CASH": + raise ValueError("Strategy asset cannot use the reserved CASH identity") + normalized_run_id = _required_text(run_id, "run_id", max_length=128) + metadata = { + name: _required_text(value, name) + for name, value in { + "strategy_id": strategy_id, + "strategy_name": strategy_name, + "strategy_version": strategy_version, + "engine_version": engine_version, + "code_revision": code_revision, + "data_snapshot_id": data_snapshot_id, + "calendar": calendar, + "timezone": timezone, + }.items() + } + if not isinstance(parameters, Mapping): + raise TypeError("parameters must be a mapping") + if set(parameters) & {"strategy_report", "performance_interpretation"}: + raise ValueError("Strategy report and performance interpretation are reserved") + started, finished = ( + _aware_timestamp(started_at, "started_at"), + _aware_timestamp(finished_at, "finished_at"), + ) + if finished < started: + raise ValueError("finished_at must not precede started_at") + if (selected.benchmark_status == "not_requested") != (benchmark_id is None): + raise ValueError( + "Requested benchmark requires its identity; unrequested benchmark cannot have one" + ) + benchmark = "" if benchmark_id is None else _required_text(benchmark_id, "benchmark_id") + performance, interpretation = _performance(selected, normalized_run_id) + params_json = _canonical_mapping_json( + dict(parameters) + | { + "strategy_report": _report(selected, normalized_run_id, optimization), + "performance_interpretation": interpretation, + } + ) + dates = selected.bars.index + run = pd.DataFrame( + [ + metadata + | { + "schema_version": RESEARCH_ARTIFACT_SCHEMA_VERSION, + "run_id": normalized_run_id, + "config_hash": hashlib.sha256(params_json.encode("utf-8")).hexdigest(), + "benchmark_id": benchmark, + "benchmark_alignment_policy": "exact_session_index" + if selected.benchmark_status == "present" + else "none", + "frequency": "1d", + "initial_capital": selected.ledger.initial_cash, + "start_date": dates[0].date(), + "end_date": dates[-1].date(), + "status": "success", + "started_at": started, + "finished_at": finished, + "params_json": params_json, + } + ] + ) + return ResearchRunArtifact( + schema_version=RESEARCH_ARTIFACT_SCHEMA_VERSION, + _run=run, + _signals=pd.DataFrame(columns=_SIGNAL_COLUMNS), + _nav=_nav(selected, normalized_run_id), + _trades=_trades(selected, normalized_run_id), + _positions=_positions(selected, normalized_run_id), + _attribution=pd.DataFrame( + columns=["run_id", "trade_date", "asset_id", "overnight", "intraday", "asset_total"] + ), + _attribution_daily=pd.DataFrame( + columns=[ + "run_id", + "trade_date", + "transaction_cost", + "explained_return", + "residual", + "total_return", + ] + ), + _risk=pd.DataFrame(columns=RISK_COLUMNS), + _performance=performance, + ) 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..7b447f0 --- /dev/null +++ b/src/quant_engine/strategy_optimizer.py @@ -0,0 +1,109 @@ +"""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, ...] + _param_grid: dict[str, list[int | float]] + decision_eligible: bool = False + + @property + def param_grid(self) -> dict[str, list[int | float]]: + return {key: list(values) for key, values in self._param_grid.items()} + + +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 == "CASH" + 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), + {key: list(values) for key, values in param_grid.items()}, + ) diff --git a/src/quant_engine/strategy_research.py b/src/quant_engine/strategy_research.py new file mode 100644 index 0000000..81e7dfb --- /dev/null +++ b/src/quant_engine/strategy_research.py @@ -0,0 +1,472 @@ +"""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 + asset: str + _bars: pd.DataFrame + _benchmark_returns: pd.Series | None + cost_parameters: dict[str, float] + decision_eligible: bool = False + + @property + def bars(self) -> pd.DataFrame: + return self._bars.copy(deep=True) + + @property + def benchmark_returns(self) -> pd.Series | None: + return None if self._benchmark_returns is None else self._benchmark_returns.copy(deep=True) + + +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 == "CASH" + 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, benchmark_returns = None, 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, + asset, + observed.copy(deep=True), + benchmark_returns, + { + "initial_cash": initial_cash, + "commission": commission, + "stamp_duty": stamp_duty, + "min_trade_amount": min_trade_amount, + "slippage_bps": 0, + }, + ) 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_factor_diagnostics.py b/tests/test_factor_diagnostics.py new file mode 100644 index 0000000..647ed30 --- /dev/null +++ b/tests/test_factor_diagnostics.py @@ -0,0 +1,318 @@ +"""Deterministic diagnostics contracts with independently checkable samples.""" +from __future__ import annotations + +import importlib +from math import sqrt + +import numpy as np +import pandas as pd +import pytest + + +def api(): + return importlib.import_module("quant_engine.factor_diagnostics") + + +def panel(values, *, days=1, assets=None): + assets = assets or ["A", "B", "C"] + index = pd.MultiIndex.from_product( + [pd.date_range("2026-09-21", periods=days), assets], names=["date", "asset"] + ) + return pd.DataFrame(values, index=index) + + +def test_pairwise_matrix_keeps_complete_security_date_pairs(): + frame = panel({"pe": [None, 0, 1, 2, 3, 4, 5, 6], + "pb": [1000, None, 1, 2, 3, 4, 5, 6]}, assets=list("ABCDEFGH")) + original = frame.copy() + result = api().correlation_matrix(frame) + assert result["matrix"][0][1] == pytest.approx(1) + assert result["n_pairs"] == [[7, 6], [6, 7]] + assert result["status"][0][1] == "ok" + assert result["method"]["aggregation"] == "pooled_asset_session_pairwise" + assert result["method"]["positive_semidefinite_guaranteed"] is False + pd.testing.assert_frame_equal(frame, original) + + +def test_matrix_empty_constant_and_short_diagonals_are_undefined(): + frame = panel({"empty": [None] * 3, "constant": [0.05] * 3, "short": [1, 2, None]}) + result = api().correlation_matrix(frame) + assert result["matrix"] == [[None] * 3 for _ in range(3)] + assert result["n_pairs"][0][0] == 0 + assert result["status"][0][0] == "no_pairs" + assert result["status"][1][1] == "constant_both" + assert result["status"][2][2] == "insufficient_pairs" + + +def test_rank_correlation_uses_average_ties_after_pairing(): + frame = panel({"f": [1, 1, 2, None], "r": [1, 2, 3, 999]}, assets=list("ABCD")) + result = api().correlation_matrix(frame, method="spearman") + assert result["matrix"][0][1] == pytest.approx(sqrt(3) / 2) + assert result["n_pairs"][0][1] == 3 + + +def test_daily_ic_is_not_one_pooled_correlation(): + frame = panel({"f": [1, 2, 3, 101, 102, 103]}, days=2) + returns = pd.Series([3, 2, 1, 101, 102, 103], index=frame.index) + result = api().daily_ic(frame, returns) + assert [r["ic"] for r in result["rows"]] == pytest.approx([-1, 1]) + assert result["summary"][0]["ic_mean"] == pytest.approx(0) + assert result["summary"][0]["ic_std"] == pytest.approx(sqrt(2)) + assert result["summary"][0]["ic_ir"] == 0 + assert result["summary"][0]["ic_t"] == 0 + assert result["method"]["ir_annualized"] is False + assert result["method"]["t_method"] == "naive_iid_unadjusted" + assert result["method"]["serial_correlation_adjusted"] is False + + +def test_daily_summary_matches_three_known_days_and_keeps_zero(): + frame = panel({"f": [1, 2, 3] * 3}, days=3) + returns = pd.Series([3, 2, 1, 1, 0, 1, 1, 2, 3], index=frame.index) + result = api().daily_ic(frame, returns) + assert [r["ic"] for r in result["rows"]] == pytest.approx([-1, 0, 1]) + assert [r["rank_ic"] for r in result["rows"]] == pytest.approx([-1, 0, 1]) + summary = result["summary"][0] + assert (summary["n_days"], summary["ic_valid_days"], summary["rank_ic_valid_days"]) == (3, 3, 3) + assert summary["ic_std"] == pytest.approx(1) + assert summary["ic_ir"] == pytest.approx(0, abs=1e-15) + assert summary["ic_t"] == pytest.approx(0, abs=1e-15) + assert summary["rank_ic_ir"] == pytest.approx(0, abs=1e-15) + assert summary["rank_ic_t"] == pytest.approx(0, abs=1e-15) + + +def test_undefined_days_and_factor_observation_domain_survive_alignment(): + frame = panel({"f": [1, 2, 3, None, None, None, 1, 2, 3]}, days=3) + returns = pd.Series([1, 2, 3], index=frame.index[:3]) + extra = pd.Series([100], index=pd.MultiIndex.from_tuples( + [(pd.Timestamp("2026-09-25"), "D")], names=["date", "asset"])) + result = api().daily_ic(frame, pd.concat([returns, extra])) + assert len(result["rows"]) == 3 + assert [r["n_pairs"] for r in result["rows"]] == [3, 0, 0] + assert [r["n_observations"] for r in result["rows"]] == [3, 3, 3] + assert [r["ic_status"] for r in result["rows"]] == ["ok", "no_pairs", "no_pairs"] + summary = result["summary"][0] + assert summary["ic_valid_days"] == 1 + assert summary["ic_missing_days"] == 2 + assert summary["ic_mean"] == pytest.approx(1) + assert summary["ic_std"] is summary["ic_ir"] is summary["ic_t"] is None + assert summary["ic_status"] == "insufficient_days" + assert result["method"]["extra_return_keys"] == 1 + + +def test_daily_summary_of_constant_ic_has_no_ratio_statistics(): + frame = panel({"f": [1, 2, 3] * 3}, days=3) + returns = pd.Series([1, 1, 2] * 3, index=frame.index) + summary = api().daily_ic(frame, returns)["summary"][0] + assert summary["ic_mean"] == pytest.approx(sqrt(3) / 2) + assert summary["ic_std"] == 0 + assert summary["ic_ir"] is summary["ic_t"] is None + assert summary["ic_status"] == "constant_values" + assert summary["rank_ic_ir"] is summary["rank_ic_t"] is None + + +def test_permutation_and_input_copies_do_not_change_diagnostics(): + frame = panel({"f": [1, 2, 3, 4, 5, 6]}, days=2) + returns = pd.Series([1, 3, 2, 4, 6, 5], index=frame.index) + original_frame, original_returns = frame.copy(), returns.copy() + baseline = api().daily_ic(frame, returns) + assert api().daily_ic(frame.iloc[::-1], returns.iloc[[2, 0, 4, 1, 5, 3]]) == baseline + pd.testing.assert_frame_equal(frame, original_frame) + pd.testing.assert_series_equal(returns, original_returns) + + +def test_large_finite_inputs_reuse_scaled_correlation_without_overflow(): + frame = panel({"a": [-1e308, 0, 1e308], "b": [-1e307, 0, 1e307]}) + assert api().correlation_matrix(frame)["matrix"][0][1] == pytest.approx(1) + + +@pytest.mark.parametrize("value", [True, "1.2", np.inf, -np.inf, object()]) +def test_diagnostics_reject_invalid_numbers(value): + frame = panel({"f": [value, 2, 3]}) + with pytest.raises(ValueError, match=r"Values|Infinite"): + api().correlation_matrix(frame) + + +@pytest.mark.parametrize("kind", ["duplicate_keys", "bad_names", "bad_date", "timezone", "empty_asset", "duplicate_factors"]) +def test_diagnostics_reject_ambiguous_keys(kind): + frame = panel({"f": [1, 2, 3]}) + if kind == "duplicate_keys": + frame.index = pd.MultiIndex.from_tuples([frame.index[0]] * 3, names=["date", "asset"]) + elif kind == "bad_names": + frame.index.names = ["day", "asset"] + elif kind == "bad_date": + frame.index = pd.MultiIndex.from_product([["2026-09-21"], list("ABC")], names=["date", "asset"]) + elif kind == "timezone": + frame.index = pd.MultiIndex.from_product([pd.date_range("2026-09-21", periods=1, tz="UTC"), list("ABC")], names=["date", "asset"]) + elif kind == "empty_asset": + frame.index = pd.MultiIndex.from_product([pd.date_range("2026-09-21", periods=1), ["", "B", "C"]], names=["date", "asset"]) + else: + frame = pd.concat([frame, frame], axis=1) + with pytest.raises(ValueError, match=r"keys|dates|identifiers"): + api().correlation_matrix(frame) + + +@pytest.mark.parametrize("minimum", [True, 0, 2, 3.1, "3"]) +def test_minimum_pairs_is_explicit_and_at_least_three(minimum): + with pytest.raises(ValueError, match="min_pairs"): + api().correlation_matrix(panel({"f": [1, 2, 3]}), min_pairs=minimum) + + +def test_all_missing_and_empty_inputs_remain_observed_not_identity_or_zero(): + frame = panel({"f": [None] * 3}) + result = api().daily_ic(frame, pd.Series([None] * 3, index=frame.index)) + assert result["summary"][0]["ic_mean"] is None + assert result["summary"][0]["ic_status"] == "no_valid_days" + empty = frame.iloc[:0] + assert api().correlation_matrix(empty)["matrix"] == [[None]] + assert api().daily_ic(empty, pd.Series([], index=empty.index, dtype=float))["rows"] == [] + + +def prices(values=(100, 110, 121, 133.1)): + sessions = pd.DatetimeIndex(["2026-09-18", "2026-09-21", "2026-09-22", "2026-09-23"]) + index = pd.MultiIndex.from_product([sessions, ["A"]], names=["date", "asset"]) + return pd.Series(values, index=index), sessions + + +def forward(series, sessions, **changes): + options = {"entry_lag_sessions": 0, "holding_sessions": 2, + "price_field": "close", "price_basis": "synthetic_comparable"} + options.update(changes) + return api().forward_returns(series, sessions=sessions, **options) + + +def test_forward_returns_use_explicit_calendar_endpoints_and_compound_price_ratio(): + series, sessions = prices() + result = forward(series, sessions) + assert result.returns.iloc[0] == pytest.approx(0.21) + assert result.intervals.iloc[0]["entry_date"] == "2026-09-18" + assert result.intervals.iloc[0]["exit_date"] == "2026-09-22" + assert result.intervals.iloc[-1]["status"] == "insufficient_calendar" + assert np.isnan(result.returns.iloc[-1]) + assert result.metadata["formula"] == "exit_price / entry_price - 1" + assert result.metadata["entry_lag_sessions"] == 0 + assert result.metadata["holding_sessions"] == 2 + assert result.metadata["execution_eligibility"] == "not_established" + assert result.metadata["historical_availability"] == "not_established" + + +def test_forward_entry_lag_changes_both_endpoints(): + series, sessions = prices([10, 20, 30, 80]) + result = forward(series, sessions, entry_lag_sessions=1) + assert result.returns.iloc[0] == pytest.approx(3) + assert result.intervals.iloc[0]["entry_date"] == "2026-09-21" + assert result.intervals.iloc[0]["exit_date"] == "2026-09-23" + + +def test_forward_does_not_skip_missing_prices_or_derive_calendar_from_rows(): + series, sessions = prices([100, None, 121, 133.1]) + result = forward(series.drop(index=(sessions[1], "A")), sessions, holding_sessions=1) + assert len(result.returns) == 4 + assert np.isnan(result.returns.iloc[0]) + assert np.isnan(result.returns.iloc[1]) + assert result.intervals.iloc[0]["status"] == "missing_price" + assert result.intervals.iloc[0]["exit_date"] == "2026-09-21" + assert result.returns.iloc[2] == pytest.approx(0.1) + + +@pytest.mark.parametrize("options", [{"holding_sessions": 0}, {"holding_sessions": True}, + {"holding_sessions": 1.5}, {"entry_lag_sessions": -1}, + {"entry_lag_sessions": False}, {"price_field": ""}, + {"price_basis": ""}]) +def test_forward_rejects_ambiguous_interval_parameters(options): + series, sessions = prices() + with pytest.raises(ValueError, match=r"sessions|price field"): + forward(series, sessions, **options) + + +@pytest.mark.parametrize("kind", ["duplicate", "descending", "timezone", "intraday", "outside"]) +def test_forward_rejects_invalid_calendar(kind): + series, sessions = prices() + if kind == "duplicate": + sessions = sessions.insert(1, sessions[0]) + elif kind == "descending": + sessions = sessions[::-1] + elif kind == "timezone": + sessions = sessions.tz_localize("UTC") + elif kind == "intraday": + sessions = sessions + pd.Timedelta(hours=1) + else: + sessions = sessions[:-1] + with pytest.raises(ValueError, match=r"sessions|dates|calendar"): + forward(series, sessions) + + +@pytest.mark.parametrize("value", [0, -1, True, "2", np.inf]) +def test_forward_rejects_nonpositive_or_nonfinite_prices(value): + series, sessions = prices([value, 110, 121, 133.1]) + with pytest.raises(ValueError, match=r"positive|Values|Infinite"): + forward(series, sessions) + + +def test_result_version_and_parameters_do_not_grant_source_or_decision_admission(): + frame = panel({"f": [1, 2, 3]}) + result = api().daily_ic(frame, pd.Series([1, 2, 3], index=frame.index)) + assert result["contract_version"] == api().FACTOR_DIAGNOSTICS_VERSION + assert result["method"]["min_pairs"] == 3 + assert result["method"]["min_days"] == 2 + assert result["production_algorithm_version"] is None + assert result["source_admission"] == result["historical_availability"] == "not_established" + assert result["decision_eligible"] is False + + +def test_nonzero_daily_summary_uses_three_days_not_nine_asset_rows(): + frame = panel({"f": [1, 2, 3] * 3}, days=3) + returns = pd.Series([.01, .02, .03, .02, .03, .01, .01, -.02, .01], index=frame.index) + result = api().daily_ic(frame, returns) + assert [point["ic"] for point in result["rows"]] == pytest.approx([1, -.5, 0], abs=1e-15) + summary = result["summary"][0] + for prefix in ("ic", "rank_ic"): + assert summary[f"{prefix}_valid_days"] == 3 + assert summary[f"{prefix}_mean"] == pytest.approx(1 / 6) + assert summary[f"{prefix}_std"] == pytest.approx(sqrt(7 / 12)) + assert summary[f"{prefix}_ir"] == pytest.approx(.2182178902359924) + assert summary[f"{prefix}_t"] == pytest.approx(.3779644730092272) + assert summary[f"{prefix}_p"] == pytest.approx(.7418011102528389) + + +def test_each_side_has_three_values_but_only_two_common_pairs(): + frame = panel({"left": [1, 2, 3, None], "right": [None, 2, 3, 4]}, assets=list("ABCD")) + result = api().correlation_matrix(frame) + assert result["n_pairs"][0][1] == 2 + assert result["matrix"][0][1] is None + assert result["status"][0][1] == "insufficient_pairs" + + +def test_rank_preserves_distinct_tiny_values_beside_a_large_outlier(): + frame = panel({"left": [1e-308, 2e-308, 1e308], "right": [1, 2, 3]}) + assert api().correlation_matrix(frame, method="spearman")["matrix"][0][1] == pytest.approx(1) + + +def test_daily_rank_ic_preserves_tiny_distinct_observations(): + frame = panel({"f": [1e-200, 2e-200, 3e-200, 1e308]}, assets=list("ABCD")) + returns = pd.Series([4, 3, 2, 1], index=frame.index) + result = api().daily_ic(frame, returns) + assert result["rows"][0]["rank_ic"] == pytest.approx(-1) + assert result["rows"][0]["rank_ic_status"] == "ok" + + +def test_affine_equivalent_daily_ic_does_not_turn_roundoff_into_extreme_significance(): + frame = panel({"f": [1, 2, 3, 10, 20, 30, 101, 102, 103]}, days=3) + returns = pd.Series([1, 1, 2, 101, 101, 102, 100001, 100001, 100002], index=frame.index) + result = api().daily_ic(frame, returns) + assert [point["ic"] for point in result["rows"]] == pytest.approx([sqrt(3) / 2] * 3) + summary = result["summary"][0] + assert summary["ic_mean"] == pytest.approx(sqrt(3) / 2) + assert summary["ic_valid_days"] == 3 + assert summary["ic_ir"] is summary["ic_t"] is summary["ic_p"] is None + assert summary["ic_std"] <= result["method"]["minimum_ic_std_for_ratios"] + assert summary["ic_status"] in {"constant_values", "below_resolution"} + + +@pytest.mark.parametrize("offset,step", [(1e16, 2.0), (1e12, np.spacing(1e12))]) +def test_pearson_preserves_representable_differences_beside_a_large_offset(offset, step): + frame = panel({"f": offset + np.arange(5) * step}, assets=list("ABCDE")) + returns = pd.Series([2, 5, 1, 4, 3], index=frame.index) + matrix = api().correlation_matrix(frame.assign(other=returns)) + assert matrix["matrix"][0][1] == pytest.approx(.1, abs=1e-14) + daily = api().daily_ic(frame, returns) + assert daily["rows"][0]["ic"] == pytest.approx(.1, abs=1e-14) diff --git a/tests/test_strategy_artifact.py b/tests/test_strategy_artifact.py new file mode 100644 index 0000000..713eae4 --- /dev/null +++ b/tests/test_strategy_artifact.py @@ -0,0 +1,293 @@ +"""Strategy reports preserve real ledger facts without factor-score fabrication.""" + +import json +from dataclasses import asdict + +import numpy as np +import pandas as pd +import pytest + +from quant_engine.strategy_artifact import build_strategy_research_artifact +from quant_engine.strategy_optimizer import optimize_strategy_research +from quant_engine.strategy_research import BenchmarkInput, run_strategy_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(strategy="BuyAndHold", feed=None, **kwargs): + return run_strategy_research( + strategy, + bars([10, 11, 12, 13]) if feed is None else feed, + asset="SYNTHETIC", + initial_cash=1000, + **kwargs, + ) + + +def build(result, **kwargs): + metadata = { + "run_id": "strategy-run", + "strategy_id": "isolated-strategy", + "strategy_name": "Synthetic", + "strategy_version": "1", + "engine_version": "candidate", + "code_revision": "candidate", + "data_snapshot_id": "synthetic:ohlc-v1", + "calendar": "synthetic-sessions", + "timezone": "Asia/Shanghai", + "started_at": "2026-01-08T10:00:00+08:00", + "finished_at": "2026-01-08T10:00:01+08:00", + "parameters": {"synthetic": True}, + } + return build_strategy_research_artifact(result, **(metadata | kwargs)) + + +def report(artifact): + return json.loads(artifact.run.iloc[0]["params_json"])["strategy_report"] + + +def test_projects_same_ledger_cash_fees_positions_and_completed_trade_basis(): + result = run( + "SmaCross", + bars([10, 8, 12, 6, 14, 5, 12]), + params={"fast": 1, "slow": 2}, + commission=0.01, + stamp_duty=0.02, + ) + artifact = build(result) + detail = report(artifact) + assert artifact.schema_version == "1.1.0" + assert artifact.nav.portfolio_value.tolist() == result.ledger.nav_series.tolist() + assert artifact.nav.pnl_pct.tolist() == result.ledger.daily_returns.tolist() + assert artifact.trades.fee.sum() == pytest.approx(result.ledger.trades_frame.fee.sum()) + assert artifact.nav.total_cost.sum() == pytest.approx(artifact.trades.total_cost.sum()) + assert artifact.performance.iloc[0].win_rate == result.pairing.win_rate + assert artifact.performance.iloc[0].n_trades == len(result.ledger.trades_frame) + assert detail["trade_pairing"] == json.loads(json.dumps(asdict(result.pairing))) + assert detail["costs"] == { + "initial_cash": 1000, + "commission": 0.01, + "stamp_duty": 0.02, + "min_trade_amount": 0, + "slippage_bps": 0, + } + assert detail["decision_eligible"] is False + for day, frame in artifact.positions.groupby("trade_date"): + nav = artifact.nav.loc[artifact.nav.trade_date == day].iloc[0] + assert frame.market_value.sum() == pytest.approx(nav.portfolio_value) + assert frame.weight.sum() == pytest.approx(1) + assert artifact.signals.empty + assert artifact.attribution.empty + assert artifact.risk.empty + assert detail["projections"]["signals"] == "strategy_report.signals" + assert detail["projections"]["attribution"] == "not_computed" + params = json.loads(artifact.run.iloc[0].params_json) + assert params["performance_interpretation"]["win_rate_basis"] == "completed_trades" + + +def test_last_signal_is_not_lost_or_fabricated_as_a_factor_signal(): + result = run("SmaCross", bars([10, 8, 12]), params={"fast": 1, "slow": 2}) + artifact = build(result) + signal = report(artifact)["signals"][0] + assert signal["status"] == "no_next_session" + assert signal["execution_date"] is None + assert signal["signal_id"] == "strategy-run:signal:2026-01-05" + assert artifact.trades.empty + assert artifact.signals.empty + + +def test_signal_ids_join_actual_fills_and_report(): + artifact = build(run()) + signals = {item["signal_id"]: item for item in report(artifact)["signals"]} + for fill in artifact.trades.to_dict("records"): + signal = signals[fill["signal_id"]] + assert signal["execution_date"] == fill["trade_date"].isoformat() + assert signal["decision_date"] < signal["execution_date"] + + +@pytest.mark.parametrize( + "name", + [ + "BuyAndHold", + "SmaCross", + "MACross", + "RSI", + "BollingerBreakout", + "DualThrust", + "TurtleBreakout", + ], +) +def test_all_seven_defaults_have_canonical_serializable_reports(name): + values = 10 + np.sin(np.arange(80) / 2) * 2 + result = run(name, bars(values, np.r_[values[0], values[:-1]])) + artifact = build(result) + assert report(artifact)["parameters"] == result.parameters + assert json.loads(artifact.canonical_json())["schema_version"] == "1.1.0" + assert artifact.content_sha256 == build(result).content_sha256 + assert len(artifact.nav) == 80 + + +@pytest.mark.parametrize("status", ["not_requested", "empty", "present"]) +def test_benchmark_states_and_original_returns_are_preserved(status): + feed = bars([10, 11, 12, 13]) + closes = ( + pd.Series([20, 22, 21, 23], index=feed.index) + if status == "present" + else (pd.Series(dtype=float) if status == "empty" else None) + ) + result = run(feed=feed, benchmark=BenchmarkInput(status, closes)) + artifact = build(result, benchmark_id="SYNTHETIC-BENCH" if status != "not_requested" else None) + assert report(artifact)["benchmark"]["status"] == status + if status == "present": + assert artifact.nav.benchmark_return.tolist() == pytest.approx( + [0, 0.1, 21 / 22 - 1, 23 / 21 - 1] + ) + assert artifact.nav.benchmark_nav.tolist() == pytest.approx([1, 1.1, 1.05, 1.15]) + assert artifact.run.iloc[0].benchmark_alignment_policy == "exact_session_index" + else: + assert artifact.nav.benchmark_nav.isna().all() + assert artifact.run.iloc[0].benchmark_alignment_policy == "none" + + +def test_zero_nav_preserves_zero_value_and_undefined_weight_with_reason(): + result = run( + "SmaCross", + bars([10, 8, 12, 6, 14]), + params={"fast": 1, "slow": 2}, + commission=0, + stamp_duty=1, + ) + artifact = build(result) + last = artifact.positions.iloc[-1] + assert last.market_value == 0 + assert pd.isna(last.weight) + assert artifact.nav.iloc[-1].nav == 0 + assert report(artifact)["projections"]["undefined_weight_dates"] == ["2026-01-07"] + + +def test_no_closed_lot_metrics_are_null_with_covered_reason(): + artifact = build(run()) + metadata = json.loads(artifact.run.iloc[0].params_json) + assert report(artifact)["metrics"]["trade_win_rate"] is None + assert ( + metadata["performance_interpretation"]["unavailable_reasons"]["win_rate"] + == "no_closed_lots" + ) + assert pd.isna(artifact.performance.iloc[0].win_rate) + + +def test_result_snapshots_detach_caller_data_and_returned_views(): + feed = bars([10, 11, 12, 13]) + result = run(feed=feed) + before = build(result).content_sha256 + feed.iloc[:] = 999 + view = result.bars + view.iloc[:] = 777 + artifact = build(result) + assert artifact.content_sha256 == before + positions = artifact.positions + positions["market_value"] = 0 + assert artifact.content_sha256 == before + + +def test_grid_artifact_retains_all_ranks_and_selected_ledger_and_detaches_input(): + grid = {"buy_pct": [0.2, 0.5, 1]} + result = optimize_strategy_research( + "BuyAndHold", + bars([10, 11, 12, 13]), + asset="SYNTHETIC", + param_grid=grid, + objective="total_return", + initial_cash=1000, + commission=0, + stamp_duty=0, + ) + grid["buy_pct"].append(0.9) + artifact = build(result) + ranking = report(artifact)["optimization"] + assert ranking["grid"] == {"buy_pct": [0.2, 0.5, 1]} + assert ranking["trial_count"] == 3 + assert ranking["selected_rank"] == 1 + assert [trial["rank"] for trial in ranking["trials"]] == [1, 2, 3] + assert [trial["score"] for trial in ranking["trials"]] == [ + trial.score for trial in result.trials + ] + assert ranking["trials"][0]["parameters"] == {"buy_pct": 1} + assert ( + artifact.nav.portfolio_value.tolist() == result.trials[0].result.ledger.nav_series.tolist() + ) + assert all("ledger" not in trial for trial in ranking["trials"]) + + +@pytest.mark.parametrize("key", ["strategy_report", "performance_interpretation"]) +def test_callers_cannot_overwrite_authoritative_report_or_metric_explanation(key): + with pytest.raises(ValueError, match="reserved"): + build(run(), parameters={key: {"decision_eligible": True}}) + + +def test_report_mutation_changes_canonical_artifact_digest(): + first = build(run(commission=0)) + second = build(run(commission=0.01)) + assert first.content_sha256 != second.content_sha256 + assert first.run.iloc[0].config_hash != second.run.iloc[0].config_hash + + +def test_invalid_metadata_fails_before_artifact_creation(): + with pytest.raises(ValueError, match="finished_at"): + build(run(), finished_at="2026-01-07T10:00:00+08:00") + with pytest.raises(ValueError, match="benchmark"): + build(run(), benchmark_id="FAKE") + + +def test_missing_relative_metrics_explain_their_fact_column_names(): + artifact = build(run()) + reasons = json.loads(artifact.run.iloc[0].params_json)["performance_interpretation"][ + "unavailable_reasons" + ] + assert reasons["ir"] == "benchmark_not_requested" + assert "information_ratio" not in reasons + + +@pytest.mark.parametrize("producer", ["run", "optimization", "artifact"]) +def test_reserved_cash_asset_cannot_collide_with_cash_position(producer): + from dataclasses import replace + + operation = { + "run": lambda: run_strategy_research("BuyAndHold", bars([10, 11]), asset="CASH"), + "optimization": lambda: optimize_strategy_research( + "BuyAndHold", + bars([10, 11]), + asset="CASH", + param_grid={"buy_pct": [0.5]}, + objective="total_return", + ), + "artifact": lambda: build(replace(run(params={"buy_pct": 0}), asset="CASH")), + }[producer] + with pytest.raises(ValueError, match="asset"): + operation() + + +def test_full_100_trial_tied_grid_keeps_complete_stable_ranking(): + grid = {"k1": [index / 10 for index in range(10)], "k2": [index / 10 for index in range(10)]} + result = optimize_strategy_research( + "DualThrust", bars([10] * 10), asset="SYNTHETIC", param_grid=grid, objective="total_return" + ) + ranking = report(build(result))["optimization"] + assert ranking["trial_count"] == 100 + assert len(ranking["trials"]) == 100 + assert [trial["parameters"] for trial in ranking["trials"]] == [ + trial.parameters for trial in result.trials + ] + assert all(trial["score"] == 0 for trial in ranking["trials"]) 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