diff --git a/.gitignore b/.gitignore index 733ecbd..ede6cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ ENV/ .mypy_cache/ .ruff_cache/ .uv-*-cache/ +.uv-cache-codex-review/ .coverage .coverage.* htmlcov/ diff --git a/README.md b/README.md index e3d902d..96672e8 100644 --- a/README.md +++ b/README.md @@ -166,12 +166,15 @@ a config's `robustness:` block in one pass. All five branch on `validation.method`: with `walk_forward`, each starts from the same walk-forward-stitched out-of-sample result rather than a single backtest, so the evidence never silently comes from a different validation method than -the one configured. `stress-test` and `sensitivity` re-run the whole -walk-forward selection process per scenario/candidate, since each one -represents a different cost/methodology assumption or parameter to -re-optimise under. `bootstrap` and `permutation-test` do not: they resample -or permute the walk-forward's already-realised out-of-sample return series -statistically, without re-running the selection process itself. +the one configured. `sensitivity` re-runs the whole walk-forward selection process per candidate +parameter pair. `stress-test`'s commission/slippage scenarios reuse the +baseline's own cached per-fold candidate weights and only re-score them +under the new costs (cheaper, since selection never depends on execution +costs); its execution-delay and reduced-universe scenarios genuinely change +the weights themselves, so each still re-runs selection end to end. +`bootstrap` and `permutation-test` do neither: they resample or permute the +walk-forward's already-realised out-of-sample return series statistically, +without touching the selection process at all. `walk-forward`, `stress-test`, `sensitivity` and `robustness` show a live progress bar with an ETA in the terminal, and checkpoint their progress to @@ -222,8 +225,10 @@ progress bar with an ETA while a run is in flight. Both modes' Robustness tab includes stress tests, block bootstrap, a Monte Carlo permutation test and a 2-parameter sensitivity heatmap, individually or via "Run all robustness tests" — in Walk-forward mode, -stress tests and sensitivity re-run the whole selection process per -scenario/cell rather than a single backtest. +sensitivity re-runs the whole selection process per cell; stress tests do +too for execution-delay/reduced-universe scenarios, but reuse the +baseline's cached candidate weights (just re-scored under new costs) for +commission/slippage scenarios. ![QuantLab dashboard results](reports/figures/dashboard_results.png) @@ -281,13 +286,13 @@ produces the full 26-fold report the numbers below are drawn from. Real, non-cherry-picked results from real Yahoo Finance / Binance data (2008–2025 for ETFs, 2018–2025 for BTC), net of modelled transaction costs: -| Experiment | Universe | Period | CAGR | Sharpe | Max DD | Trades | +| Experiment | Universe | Period | CAGR | Sharpe | Max DD | Fills | | --- | --- | --- | --- | --- | --- | --- | -| Cross-sectional momentum (example above) | 8 multi-asset ETFs | 2008–2025 | 5.7% | 0.44 | −15.8% | 277 | +| Cross-sectional momentum (example above) | 8 multi-asset ETFs | 2008–2025 | 5.5% | 0.43 | −15.8% | 1,514 | | ↳ walk-forward out-of-sample | same | 26 folds | 4.7% | 0.32 | −18.5% | — | -| Mean reversion (z-score) | 5 equity ETFs | 2010–2025 | 4.4% | 0.25 | −34.6% | 1,180 | +| Mean reversion (z-score) | 5 equity ETFs | 2010–2025 | 4.4% | 0.25 | −34.6% | 3,351 | | Pairs trading (EWA/EWC, vs SPY) | 2 country ETFs | 2010–2025 | 0.9% | −0.35 | −8.0% | 1,314 | -| Trend following (BTC) | BTCUSDT | 2018–2025 | 30.3% | 0.93 | −43.9% | 1,560 | +| Trend following (BTC) | BTCUSDT | 2018–2025 | 30.3% | 0.93 | −43.9% | 1,563 | Every number above — including the negative Sharpe on the pairs trade — is reported as computed; nothing here is filtered for looking good. **These are diff --git a/configs/btc_trend.yaml b/configs/btc_trend.yaml index 1115e17..38d157e 100644 --- a/configs/btc_trend.yaml +++ b/configs/btc_trend.yaml @@ -2,6 +2,15 @@ # Crypto uses 365 periods/year and higher costs than ETFs. experiment_name: btc_trend_following +research_question: >- + Does a simple moving-average trend-following rule on Bitcoin capture a + persistent risk-adjusted edge once volatility targeting and elevated + crypto transaction costs are accounted for? +hypothesis: >- + H1: net of modelled transaction costs, long-only trend following on BTC + has a genuine positive risk-adjusted edge. H0: any apparent edge is + indistinguishable from noise or is eliminated by trading costs. + data: instruments: - symbol: BTCUSDT @@ -18,6 +27,9 @@ strategy: fast_window: 20 slow_window: 100 long_only: true + # Price series the strategy's own generate_signals() reads. Execution/ + # costs always use the raw close regardless of this setting. + signal_price_type: adjusted_close portfolio: allocator: volatility_targeting @@ -57,8 +69,19 @@ reproducibility: robustness: stress_test: enabled: true + # Each list is a set of magnitudes evaluated independently (one + # scenario row per value); an empty list disables that scenario type + # entirely. Crypto's higher baseline costs (see execution: above) make + # a larger commission stress meaningful here too. + commission_multipliers: [2.0, 5.0] + slippage_multipliers: [2.0] + execution_delays: [1] + best_days_removed: [10] bootstrap: enabled: true + # Width of the reported p_lower/p_upper percentile band (0.90 -> the + # 5th/95th percentiles). + confidence_level: 0.90 permutation_test: enabled: true sensitivity: @@ -66,3 +89,10 @@ robustness: parameters: fast_window: [10, 20, 40] slow_window: [50, 100, 200] + +# Where and what a run saves. Omit entirely for the default: reports/ +# generated//, both artefact toggles enabled. +output: + # directory: reports/generated/btc_trend_following + save_html_report: true + save_figures: true diff --git a/configs/default.yaml b/configs/default.yaml index 11be7e1..ec41379 100644 --- a/configs/default.yaml +++ b/configs/default.yaml @@ -18,6 +18,9 @@ data: strategy: name: buy_and_hold parameters: {} + # Price series the strategy's own generate_signals() reads. Execution/ + # costs always use the raw close regardless of this setting. + signal_price_type: adjusted_close portfolio: allocator: equal_weight @@ -60,5 +63,15 @@ robustness: enabled: true bootstrap: enabled: true + # Width of the reported p_lower/p_upper percentile band (0.90 -> the + # 5th/95th percentiles). + confidence_level: 0.90 permutation_test: enabled: true + +# Where and what a run saves. Omit entirely for the default: reports/ +# generated//, both artefact toggles enabled. +output: + # directory: reports/generated/default_buy_and_hold + save_html_report: true + save_figures: true diff --git a/configs/demo_offline.yaml b/configs/demo_offline.yaml index 5e22f60..2d68054 100644 --- a/configs/demo_offline.yaml +++ b/configs/demo_offline.yaml @@ -3,6 +3,16 @@ # NOTE: the prices here are SYNTHETIC (generated), not real market data. experiment_name: demo_offline_momentum +research_question: >- + Does cross-sectional momentum on this small offline demo universe + produce a stable out-of-sample signal, purely as a fast, no-network + illustration of the walk-forward pipeline? +hypothesis: >- + H1: the walk-forward pipeline selects a consistent momentum parameter + across folds on this synthetic demo data. H0: fold-to-fold selection is + effectively random. (Demo data is synthetic -- not evidence about real + markets either way.) + data: # The synthetic symbols model US equity ETFs. instruments: @@ -34,6 +44,9 @@ strategy: bottom_fraction: 0.5 long_short: false signal_scaling: binary + # Price series the strategy's own generate_signals() reads. Execution/ + # costs always use the raw close regardless of this setting. + signal_price_type: adjusted_close portfolio: allocator: inverse_volatility @@ -64,6 +77,9 @@ validation: train_window: 500 validation_window: 126 test_window: 126 + # Advance between consecutive folds' train windows. Omit (or set equal to + # test_window) for the default: contiguous, non-overlapping test blocks. + step: 126 expanding: true optimization_metric: sharpe @@ -80,8 +96,19 @@ reproducibility: robustness: stress_test: enabled: true + # Each list is a set of magnitudes evaluated independently (one + # scenario row per value); an empty list disables that scenario type + # entirely. + commission_multipliers: [2.0, 5.0] + slippage_multipliers: [2.0] + execution_delays: [1] + best_days_removed: [10] + reduce_universe_by: [1] bootstrap: enabled: true + # Width of the reported p_lower/p_upper percentile band (0.90 -> the + # 5th/95th percentiles). + confidence_level: 0.90 permutation_test: enabled: true sensitivity: @@ -89,3 +116,10 @@ robustness: parameters: lookback_period: [126, 189, 252] top_fraction: [0.25, 0.50] + +# Where and what a run saves. Omit entirely for the default: reports/ +# generated//, both artefact toggles enabled. +output: + # directory: reports/generated/demo_offline_momentum + save_html_report: true + save_figures: true diff --git a/configs/mean_reversion_etfs.yaml b/configs/mean_reversion_etfs.yaml index c233618..d154159 100644 --- a/configs/mean_reversion_etfs.yaml +++ b/configs/mean_reversion_etfs.yaml @@ -3,6 +3,15 @@ # research notebook. experiment_name: mean_reversion_etfs +research_question: >- + Does short-term price mean reversion across liquid ETFs generate a + positive risk-adjusted edge after transaction costs? +hypothesis: >- + H1: net of modelled transaction costs, a rolling z-score mean-reversion + signal across this ETF universe has a genuine positive risk-adjusted + edge. H0: any apparent edge is indistinguishable from noise or is + eliminated by trading costs. + data: instruments: - symbol: SPY @@ -29,10 +38,14 @@ strategy: name: mean_reversion parameters: lookback_period: 20 - entry_zscore: 2.0 - exit_zscore: 0.5 - stop_zscore: 4.0 + indicator: zscore + entry_threshold: 2.0 + exit_threshold: 0.5 + stop_threshold: 4.0 long_only: true + # Price series the strategy's own generate_signals() reads. Execution/ + # costs always use the raw close regardless of this setting. + signal_price_type: adjusted_close portfolio: allocator: equal_weight @@ -65,12 +78,30 @@ reproducibility: robustness: stress_test: enabled: true + # Each list is a set of magnitudes evaluated independently (one + # scenario row per value); an empty list disables that scenario type + # entirely. + commission_multipliers: [2.0, 5.0] + slippage_multipliers: [2.0] + execution_delays: [1] + best_days_removed: [10] + reduce_universe_by: [1] bootstrap: enabled: true + # Width of the reported p_lower/p_upper percentile band (0.90 -> the + # 5th/95th percentiles). + confidence_level: 0.90 permutation_test: enabled: true sensitivity: enabled: true parameters: lookback_period: [10, 20, 40] - entry_zscore: [1.5, 2.0, 2.5] + entry_threshold: [1.5, 2.0, 2.5] + +# Where and what a run saves. Omit entirely for the default: reports/ +# generated//, both artefact toggles enabled. +output: + # directory: reports/generated/mean_reversion_etfs + save_html_report: true + save_figures: true diff --git a/configs/momentum_sp500.yaml b/configs/momentum_sp500.yaml index 70f3c01..88e7d10 100644 --- a/configs/momentum_sp500.yaml +++ b/configs/momentum_sp500.yaml @@ -1,11 +1,20 @@ # Example experiment: # Robust Cross-Sectional Momentum Across Liquid Multi-Asset ETFs. -# -# Research question: Can cross-sectional momentum generate stable out-of-sample -# risk-adjusted returns across liquid ETFs after transaction costs and -# volatility targeting? experiment_name: cross_sectional_momentum_etfs +# Overrides the auto-generated report text (see quantlab.reporting. +# research_summary) with this experiment's own wording. Both are optional -- +# omit either to fall back to the auto-generated version. +research_question: >- + Can cross-sectional momentum generate stable out-of-sample risk-adjusted + returns across liquid ETFs after transaction costs and volatility + targeting? +hypothesis: >- + H1: net of modelled transaction costs, long-only cross-sectional momentum + across this ETF universe has a genuine positive risk-adjusted edge. H0: any + apparent edge is indistinguishable from noise or is eliminated by trading + costs and volatility targeting. + data: instruments: - symbol: SPY # US large cap @@ -46,6 +55,11 @@ strategy: bottom_fraction: 0.25 long_short: false # long-only for the main version signal_scaling: binary + # Price series the strategy's own generate_signals() reads. Execution/ + # costs always use the raw close regardless of this setting. Default is + # "adjusted_close" (recommended for equities/ETFs, avoids false signals + # from splits/dividends) -- shown explicitly here for illustration. + signal_price_type: adjusted_close portfolio: allocator: inverse_volatility @@ -77,6 +91,13 @@ validation: train_window: 1000 validation_window: 252 test_window: 126 + # Advance between consecutive folds' train windows. Omit (or set equal to + # test_window) for the default: contiguous, non-overlapping test blocks. + # A smaller step overlaps test blocks for denser evaluation at higher + # compute cost. step must not exceed test_window -- a larger value is + # rejected outright (it would skip dates between folds' test blocks + # entirely), not silently allowed to skip periods. + step: 126 expanding: true optimization_metric: sharpe # Freeze the published search space: tune timing while keeping the 25% @@ -91,10 +112,21 @@ reproducibility: robustness: stress_test: enabled: true + # Each list is a set of magnitudes evaluated independently (one + # scenario row per value); an empty list disables that scenario type + # entirely. Values shown here match this project's own defaults. + commission_multipliers: [2.0, 5.0] + slippage_multipliers: [2.0] + execution_delays: [1] + best_days_removed: [10] + reduce_universe_by: [1] bootstrap: enabled: true n_iterations: 1000 block_size: 5 + # Width of the reported p_lower/p_upper percentile band (0.90 -> the + # 5th/95th percentiles). + confidence_level: 0.90 permutation_test: enabled: true n_iterations: 1000 @@ -105,3 +137,10 @@ robustness: parameters: lookback_period: [126, 189, 252] top_fraction: [0.10, 0.25, 0.50] + +# Where and what a run saves. Omit entirely for the default: reports/ +# generated//, both artefact toggles enabled. +output: + # directory: reports/generated/cross_sectional_momentum_etfs + save_html_report: true + save_figures: true diff --git a/configs/pairs_trading.yaml b/configs/pairs_trading.yaml index 3d0c587..cb9499c 100644 --- a/configs/pairs_trading.yaml +++ b/configs/pairs_trading.yaml @@ -3,6 +3,15 @@ # classic, economically-motivated pair (not chosen for historical performance). experiment_name: pairs_trading_ewa_ewc +research_question: >- + Does the EWA/EWC spread mean-revert predictably enough to generate a + positive risk-adjusted edge after transaction costs? +hypothesis: >- + H1: net of modelled transaction costs, a dynamically hedged EWA/EWC + pairs-trading signal has a genuine positive risk-adjusted edge. H0: any + apparent edge is indistinguishable from noise or is eliminated by + trading costs. + data: instruments: - symbol: EWA @@ -22,11 +31,16 @@ strategy: symbol_a: EWA symbol_b: EWC formation_window: 252 - zscore_window: 63 - entry_zscore: 2.0 - exit_zscore: 0.5 - stop_zscore: 4.0 + indicator_window: 63 + indicator: zscore + entry_threshold: 2.0 + exit_threshold: 0.5 + stop_threshold: 4.0 dynamic_hedge_ratio: true + adf_pvalue_threshold: 0.10 + # Price series the strategy's own generate_signals() reads. Execution/ + # costs always use the raw close regardless of this setting. + signal_price_type: adjusted_close portfolio: allocator: signal_proportional @@ -61,12 +75,33 @@ reproducibility: robustness: stress_test: enabled: true + # Each list is a set of magnitudes evaluated independently (one + # scenario row per value); an empty list disables that scenario type + # entirely. + commission_multipliers: [2.0, 5.0] + slippage_multipliers: [2.0] + execution_delays: [1] + best_days_removed: [10] + # Disabled: this pair is exactly 2 symbols, so removing either one + # doesn't leave a smaller version of the same strategy -- it breaks the + # pair. Empty means "this scenario type does not apply here", not zero. + reduce_universe_by: [] bootstrap: enabled: true + # Width of the reported p_lower/p_upper percentile band (0.90 -> the + # 5th/95th percentiles). + confidence_level: 0.90 permutation_test: enabled: true sensitivity: enabled: true parameters: formation_window: [126, 252, 504] - zscore_window: [21, 63, 126] + indicator_window: [21, 63, 126] + +# Where and what a run saves. Omit entirely for the default: reports/ +# generated//, both artefact toggles enabled. +output: + # directory: reports/generated/pairs_trading_ewa_ewc + save_html_report: true + save_figures: true diff --git a/data/raw/GLD.csv b/data/raw/GLD.csv index cb74233..f74775e 100644 --- a/data/raw/GLD.csv +++ b/data/raw/GLD.csv @@ -1,271 +1,261 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume -2019-01-01,GLD,99.43499018110464,99.93216513201016,98.93781523019912,99.43499018110464,99.43499018110464,1000000.0 2019-01-02,GLD,99.43499018110464,99.93216513201016,98.80212060729887,99.29861367567726,99.29861367567726,1000000.0 -2019-01-03,GLD,99.29861367567726,101.32089590414938,98.80212060729887,100.81681184492477,100.81681184492477,1000000.0 -2019-01-04,GLD,100.81681184492477,101.94413805833923,100.31272778570015,101.43695329187985,101.43695329187985,1000000.0 -2019-01-07,GLD,101.43695329187985,101.94413805833923,99.46962669133593,99.96947406164415,99.96947406164415,1000000.0 -2019-01-08,GLD,99.96947406164415,100.48471155908108,99.46962669133593,99.9847876209762,99.9847876209762,1000000.0 -2019-01-09,GLD,99.9847876209762,100.48471155908108,98.94798768270729,99.44521375146462,99.44521375146462,1000000.0 -2019-01-10,GLD,99.44521375146462,100.09623789789302,98.94798768270729,99.59824666457018,99.59824666457018,1000000.0 +2019-01-03,GLD,99.29861367567726,101.32089590414938,98.80212060729887,100.81681184492476,100.81681184492476,1000000.0 +2019-01-04,GLD,100.81681184492476,101.94413805833923,100.31272778570016,101.43695329187985,101.43695329187985,1000000.0 +2019-01-07,GLD,101.43695329187985,101.94413805833923,99.46962669133592,99.96947406164416,99.96947406164416,1000000.0 +2019-01-08,GLD,99.96947406164416,100.48471155908108,99.46962669133592,99.9847876209762,99.9847876209762,1000000.0 +2019-01-09,GLD,99.9847876209762,100.48471155908108,98.94798768270728,99.44521375146462,99.44521375146462,1000000.0 +2019-01-10,GLD,99.44521375146462,100.09623789789302,98.94798768270728,99.59824666457018,99.59824666457018,1000000.0 2019-01-11,GLD,99.59824666457018,100.09623789789302,97.69577649452586,98.1867100447496,98.1867100447496,1000000.0 2019-01-14,GLD,98.1867100447496,98.91237518003794,97.69577649452586,98.42027381098303,98.42027381098303,1000000.0 -2019-01-15,GLD,98.42027381098303,99.14196246476082,97.92817244192811,98.64871887040879,98.64871887040879,1000000.0 -2019-01-16,GLD,98.64871887040879,100.5779874967278,98.15547527605675,100.07759949923165,100.07759949923165,1000000.0 -2019-01-17,GLD,100.07759949923165,100.88519899027051,99.57721150173549,100.3832825773836,100.3832825773836,1000000.0 -2019-01-18,GLD,100.3832825773836,101.37009703873137,99.88136616449668,100.86576819774267,100.86576819774267,1000000.0 -2019-01-21,GLD,100.86576819774267,101.37009703873137,99.04160488003922,99.53930138697409,99.53930138697409,1000000.0 -2019-01-22,GLD,99.53930138697409,102.10632360144672,99.04160488003922,101.59833194173804,101.59833194173804,1000000.0 -2019-01-23,GLD,101.59833194173804,102.10632360144672,99.38227375037067,99.88168216117656,99.88168216117656,1000000.0 -2019-01-24,GLD,99.88168216117656,101.40172119322123,99.38227375037067,100.89723501813059,100.89723501813059,1000000.0 -2019-01-25,GLD,100.89723501813059,101.40172119322123,100.11513215800257,100.61822327437444,100.61822327437444,1000000.0 +2019-01-15,GLD,98.42027381098303,99.14196246476082,97.92817244192813,98.6487188704088,98.6487188704088,1000000.0 +2019-01-16,GLD,98.6487188704088,100.5779874967278,98.15547527605676,100.07759949923164,100.07759949923164,1000000.0 +2019-01-17,GLD,100.07759949923164,100.88519899027052,99.57721150173548,100.3832825773836,100.3832825773836,1000000.0 +2019-01-18,GLD,100.3832825773836,101.37009703873136,99.88136616449668,100.86576819774268,100.86576819774268,1000000.0 +2019-01-22,GLD,99.53930138697407,102.10632360144672,99.04160488003922,101.59833194173804,101.59833194173804,1000000.0 +2019-01-23,GLD,101.59833194173804,102.10632360144672,99.38227375037069,99.88168216117656,99.88168216117656,1000000.0 +2019-01-24,GLD,99.88168216117656,101.40172119322123,99.38227375037069,100.8972350181306,100.8972350181306,1000000.0 +2019-01-25,GLD,100.8972350181306,101.40172119322123,100.11513215800257,100.61822327437444,100.61822327437444,1000000.0 2019-01-28,GLD,100.61822327437444,101.1213143907463,99.34464097172236,99.8438602730878,99.8438602730878,1000000.0 -2019-01-29,GLD,99.8438602730878,100.34307957445323,98.77933881171454,99.27571739870808,99.27571739870808,1000000.0 -2019-01-30,GLD,99.27571739870808,99.77209598570161,98.20335005779799,98.6968342289427,98.6968342289427,1000000.0 -2019-01-31,GLD,98.6968342289427,99.55020746985014,98.20335005779799,99.05493280582104,99.05493280582104,1000000.0 -2019-02-01,GLD,99.05493280582104,99.55020746985014,98.48177473555377,98.97665802568218,98.97665802568218,1000000.0 -2019-02-04,GLD,98.97665802568218,100.82786206262531,98.48177473555377,100.3262309080849,100.3262309080849,1000000.0 -2019-02-05,GLD,100.3262309080849,100.82786206262531,98.21394459345369,98.70748200347104,98.70748200347104,1000000.0 -2019-02-06,GLD,98.70748200347104,99.21811037594946,98.21394459345369,98.72448793626813,98.72448793626813,1000000.0 -2019-02-07,GLD,98.72448793626813,99.21811037594946,97.4648671820103,97.95464038392994,97.95464038392994,1000000.0 -2019-02-08,GLD,97.95464038392994,99.15407033660871,97.4648671820103,98.66076650408829,98.66076650408829,1000000.0 -2019-02-11,GLD,98.66076650408829,99.15407033660871,96.33312942240681,96.81721549990634,96.81721549990634,1000000.0 -2019-02-12,GLD,96.81721549990634,97.30130157740587,96.05481648597973,96.53750400600978,96.53750400600978,1000000.0 -2019-02-13,GLD,96.53750400600978,97.22332708399816,96.05481648597973,96.73962893930165,96.73962893930165,1000000.0 -2019-02-14,GLD,96.73962893930165,97.22332708399816,94.99765172956039,95.47502686387979,95.47502686387979,1000000.0 -2019-02-15,GLD,95.47502686387979,96.82635273586925,94.99765172956039,96.34462958792962,96.34462958792962,1000000.0 -2019-02-18,GLD,96.34462958792962,97.00161395741488,95.86290643998997,96.51901886309939,96.51901886309939,1000000.0 -2019-02-19,GLD,96.51901886309939,97.90418810483705,96.03642376878389,97.41710259187766,97.41710259187766,1000000.0 -2019-02-20,GLD,97.41710259187766,98.77277318897042,96.93001707891828,98.28136635718451,98.28136635718451,1000000.0 -2019-02-21,GLD,98.28136635718451,98.77277318897042,96.95075966876041,97.43794941583961,97.43794941583961,1000000.0 -2019-02-22,GLD,97.43794941583961,97.9251391629188,96.27638404121946,96.7601849660497,96.7601849660497,1000000.0 -2019-02-25,GLD,96.7601849660497,97.24398589087994,96.11958209733677,96.60259507270028,96.60259507270028,1000000.0 -2019-02-26,GLD,96.60259507270028,97.76090805635421,96.11958209733677,97.27453537945694,97.27453537945694,1000000.0 -2019-02-27,GLD,97.27453537945694,98.53220899773781,96.78816270255965,98.0419990027242,98.0419990027242,1000000.0 -2019-02-28,GLD,98.0419990027242,98.53220899773781,96.95008345150627,97.43726980050882,97.43726980050882,1000000.0 -2019-03-01,GLD,97.43726980050882,97.92445614951134,96.44100437734602,96.92563254004625,96.92563254004625,1000000.0 -2019-03-04,GLD,96.92563254004625,97.41026070274647,95.77015112704368,96.25140816788308,96.25140816788308,1000000.0 +2019-01-29,GLD,99.8438602730878,100.34307957445324,98.77933881171454,99.27571739870808,99.27571739870808,1000000.0 +2019-01-30,GLD,99.27571739870808,99.7720959857016,98.203350057798,98.6968342289427,98.6968342289427,1000000.0 +2019-01-31,GLD,98.6968342289427,99.55020746985014,98.203350057798,99.05493280582104,99.05493280582104,1000000.0 +2019-02-01,GLD,99.05493280582104,99.55020746985014,98.48177473555376,98.97665802568218,98.97665802568218,1000000.0 +2019-02-04,GLD,98.97665802568218,100.82786206262531,98.48177473555376,100.3262309080849,100.3262309080849,1000000.0 +2019-02-05,GLD,100.3262309080849,100.82786206262531,98.21394459345368,98.70748200347104,98.70748200347104,1000000.0 +2019-02-06,GLD,98.70748200347104,99.21811037594946,98.21394459345368,98.72448793626812,98.72448793626812,1000000.0 +2019-02-07,GLD,98.72448793626812,99.21811037594946,97.4648671820103,97.95464038392994,97.95464038392994,1000000.0 +2019-02-08,GLD,97.95464038392994,99.15407033660873,97.4648671820103,98.66076650408829,98.66076650408829,1000000.0 +2019-02-11,GLD,98.66076650408829,99.15407033660873,96.3331294224068,96.81721549990634,96.81721549990634,1000000.0 +2019-02-12,GLD,96.81721549990634,97.30130157740588,96.05481648597971,96.53750400600978,96.53750400600978,1000000.0 +2019-02-13,GLD,96.53750400600978,97.22332708399816,96.05481648597971,96.73962893930164,96.73962893930164,1000000.0 +2019-02-14,GLD,96.73962893930164,97.22332708399816,94.9976517295604,95.4750268638798,95.4750268638798,1000000.0 +2019-02-15,GLD,95.4750268638798,96.82635273586924,94.9976517295604,96.34462958792962,96.34462958792962,1000000.0 +2019-02-19,GLD,96.5190188630994,97.90418810483705,96.03642376878388,97.41710259187766,97.41710259187766,1000000.0 +2019-02-20,GLD,97.41710259187766,98.77277318897042,96.93001707891828,98.28136635718452,98.28136635718452,1000000.0 +2019-02-21,GLD,98.28136635718452,98.77277318897042,96.9507596687604,97.4379494158396,97.4379494158396,1000000.0 +2019-02-22,GLD,97.4379494158396,97.9251391629188,96.27638404121946,96.7601849660497,96.7601849660497,1000000.0 +2019-02-25,GLD,96.7601849660497,97.24398589087994,96.11958209733676,96.60259507270028,96.60259507270028,1000000.0 +2019-02-26,GLD,96.60259507270028,97.7609080563542,96.11958209733676,97.27453537945694,97.27453537945694,1000000.0 +2019-02-27,GLD,97.27453537945694,98.5322089977378,96.78816270255965,98.0419990027242,98.0419990027242,1000000.0 +2019-02-28,GLD,98.0419990027242,98.5322089977378,96.95008345150627,97.43726980050882,97.43726980050882,1000000.0 +2019-03-01,GLD,97.43726980050882,97.92445614951134,96.44100437734602,96.92563254004624,96.92563254004624,1000000.0 +2019-03-04,GLD,96.92563254004624,97.41026070274648,95.77015112704368,96.25140816788308,96.25140816788308,1000000.0 2019-03-05,GLD,96.25140816788308,96.7326652087225,95.2869549738472,95.76578389331377,95.76578389331377,1000000.0 -2019-03-06,GLD,95.76578389331377,96.24461281278033,95.10215285453117,95.58005312013182,95.58005312013182,1000000.0 -2019-03-07,GLD,95.58005312013182,96.05795338573247,95.00839647699577,95.48582560502088,95.48582560502088,1000000.0 -2019-03-08,GLD,95.48582560502088,97.77690316712463,95.00839647699577,97.29045091256184,97.29045091256184,1000000.0 -2019-03-11,GLD,97.29045091256184,97.77690316712463,96.38308013518596,96.86741722129243,96.86741722129243,1000000.0 -2019-03-12,GLD,96.86741722129243,97.35175430739888,96.15218047631333,96.63535726262647,96.63535726262647,1000000.0 -2019-03-13,GLD,96.63535726262647,97.53969589542642,96.15218047631333,97.05442377654371,97.05442377654371,1000000.0 -2019-03-14,GLD,97.05442377654371,97.53969589542642,95.76350692568785,96.24473057858076,96.24473057858076,1000000.0 -2019-03-15,GLD,96.24473057858076,96.72595423147365,95.46541528344899,95.94514098839095,95.94514098839095,1000000.0 -2019-03-18,GLD,95.94514098839095,96.45571469496166,95.46541528344899,95.97583551737479,95.97583551737479,1000000.0 -2019-03-19,GLD,95.97583551737479,97.14952645637841,95.49595633978791,96.6661954789835,96.6661954789835,1000000.0 -2019-03-20,GLD,96.6661954789835,97.14952645637841,95.06939364756359,95.54712929403375,95.54712929403375,1000000.0 -2019-03-21,GLD,95.54712929403375,97.23691203677782,95.06939364756359,96.75314630525158,96.75314630525158,1000000.0 +2019-03-06,GLD,95.76578389331377,96.24461281278032,95.10215285453116,95.58005312013182,95.58005312013182,1000000.0 +2019-03-07,GLD,95.58005312013182,96.05795338573247,95.00839647699576,95.48582560502088,95.48582560502088,1000000.0 +2019-03-08,GLD,95.48582560502088,97.77690316712464,95.00839647699576,97.29045091256184,97.29045091256184,1000000.0 +2019-03-11,GLD,97.29045091256184,97.77690316712464,96.38308013518596,96.86741722129244,96.86741722129244,1000000.0 +2019-03-12,GLD,96.86741722129244,97.35175430739888,96.15218047631332,96.63535726262648,96.63535726262648,1000000.0 +2019-03-13,GLD,96.63535726262648,97.53969589542642,96.15218047631332,97.05442377654371,97.05442377654371,1000000.0 +2019-03-14,GLD,97.05442377654371,97.53969589542642,95.76350692568784,96.24473057858076,96.24473057858076,1000000.0 +2019-03-15,GLD,96.24473057858076,96.72595423147364,95.465415283449,95.94514098839096,95.94514098839096,1000000.0 +2019-03-18,GLD,95.94514098839096,96.45571469496166,95.465415283449,95.9758355173748,95.9758355173748,1000000.0 +2019-03-19,GLD,95.9758355173748,97.1495264563784,95.49595633978792,96.6661954789835,96.6661954789835,1000000.0 +2019-03-20,GLD,96.6661954789835,97.1495264563784,95.0693936475636,95.54712929403377,95.54712929403377,1000000.0 +2019-03-21,GLD,95.54712929403377,97.23691203677782,95.0693936475636,96.75314630525158,96.75314630525158,1000000.0 2019-03-22,GLD,96.75314630525158,97.23691203677782,95.98368155485812,96.46601161292274,96.46601161292274,1000000.0 -2019-03-25,GLD,96.46601161292274,97.11569739073904,95.98368155485812,96.63253471715329,96.63253471715329,1000000.0 -2019-03-26,GLD,96.63253471715329,97.8784907510588,96.14937204356752,97.39153308563067,97.39153308563067,1000000.0 -2019-03-27,GLD,97.39153308563067,98.48201625039414,96.90457542020252,97.99205597054144,97.99205597054144,1000000.0 -2019-03-28,GLD,97.99205597054144,99.44523019865814,97.50209569068873,98.95047780961009,98.95047780961009,1000000.0 -2019-03-29,GLD,98.95047780961009,99.62028641281199,98.45572542056205,99.12466309732538,99.12466309732538,1000000.0 -2019-04-01,GLD,99.12466309732538,99.62262550662905,98.62903978183876,99.12699055385976,99.12699055385976,1000000.0 -2019-04-02,GLD,99.12699055385976,99.9267691648121,98.63135560109046,99.42962105951453,99.42962105951453,1000000.0 -2019-04-03,GLD,99.42962105951453,99.9267691648121,98.06946827737383,98.56227967575259,98.56227967575259,1000000.0 -2019-04-04,GLD,98.56227967575259,100.16350063563121,98.06946827737383,99.66517476182212,99.66517476182212,1000000.0 -2019-04-05,GLD,99.66517476182212,100.16350063563121,98.49692186658315,98.9918812729479,98.9918812729479,1000000.0 +2019-03-25,GLD,96.46601161292274,97.11569739073904,95.98368155485812,96.63253471715328,96.63253471715328,1000000.0 +2019-03-26,GLD,96.63253471715328,97.8784907510588,96.14937204356752,97.39153308563068,97.39153308563068,1000000.0 +2019-03-27,GLD,97.39153308563068,98.48201625039414,96.90457542020252,97.99205597054144,97.99205597054144,1000000.0 +2019-03-28,GLD,97.99205597054144,99.44523019865814,97.50209569068872,98.95047780961008,98.95047780961008,1000000.0 +2019-03-29,GLD,98.95047780961008,99.620286412812,98.45572542056205,99.12466309732538,99.12466309732538,1000000.0 +2019-04-01,GLD,99.12466309732538,99.62262550662903,98.62903978183876,99.12699055385976,99.12699055385976,1000000.0 +2019-04-02,GLD,99.12699055385976,99.9267691648121,98.63135560109046,99.42962105951452,99.42962105951452,1000000.0 +2019-04-03,GLD,99.42962105951452,99.9267691648121,98.06946827737384,98.5622796757526,98.5622796757526,1000000.0 +2019-04-04,GLD,98.5622796757526,100.1635006356312,98.06946827737384,99.66517476182212,99.66517476182212,1000000.0 +2019-04-05,GLD,99.66517476182212,100.1635006356312,98.49692186658316,98.9918812729479,98.9918812729479,1000000.0 2019-04-08,GLD,98.9918812729479,99.48684067931262,97.40554144202122,97.89501652464445,97.89501652464445,1000000.0 -2019-04-09,GLD,97.89501652464445,100.24236112693524,97.40554144202122,99.74364291237337,99.74364291237337,1000000.0 -2019-04-10,GLD,99.74364291237337,100.24236112693524,99.14295289573862,99.64115868918454,99.64115868918454,1000000.0 -2019-04-11,GLD,99.64115868918454,100.13936448263044,98.11601974647301,98.60906507183218,98.60906507183218,1000000.0 -2019-04-12,GLD,98.60906507183218,100.78818779758117,98.11601974647301,100.28675402744396,100.28675402744396,1000000.0 -2019-04-15,GLD,100.28675402744396,100.78818779758117,99.50955475756552,100.00960277142264,100.00960277142264,1000000.0 +2019-04-09,GLD,97.89501652464445,100.24236112693524,97.40554144202122,99.74364291237336,99.74364291237336,1000000.0 +2019-04-10,GLD,99.74364291237336,100.24236112693524,99.14295289573862,99.64115868918454,99.64115868918454,1000000.0 +2019-04-11,GLD,99.64115868918454,100.13936448263044,98.116019746473,98.60906507183218,98.60906507183218,1000000.0 +2019-04-12,GLD,98.60906507183218,100.78818779758116,98.116019746473,100.28675402744396,100.28675402744396,1000000.0 +2019-04-15,GLD,100.28675402744396,100.78818779758116,99.50955475756552,100.00960277142264,100.00960277142264,1000000.0 2019-04-16,GLD,100.00960277142264,101.49529398153868,99.50955475756552,100.99034227018775,100.99034227018775,1000000.0 2019-04-17,GLD,100.99034227018775,101.49529398153868,99.75796985243991,100.2592661833567,100.2592661833567,1000000.0 -2019-04-18,GLD,100.2592661833567,100.76056251427347,99.55476857382602,100.05504379278997,100.05504379278997,1000000.0 -2019-04-19,GLD,100.05504379278997,100.55531901175391,98.07290851213493,98.56573719812556,98.56573719812556,1000000.0 -2019-04-22,GLD,98.56573719812556,99.05856588411618,96.42164525441223,96.90617613508766,96.90617613508766,1000000.0 -2019-04-23,GLD,96.90617613508766,97.39070701576308,95.59713021676389,96.07751780579285,96.07751780579285,1000000.0 -2019-04-24,GLD,96.07751780579285,96.5579053948218,95.549118947127,96.02926527349447,96.02926527349447,1000000.0 +2019-04-18,GLD,100.2592661833567,100.76056251427347,99.55476857382602,100.05504379278996,100.05504379278996,1000000.0 +2019-04-22,GLD,98.56573719812556,99.05856588411618,96.42164525441224,96.90617613508766,96.90617613508766,1000000.0 +2019-04-23,GLD,96.90617613508766,97.39070701576308,95.59713021676389,96.07751780579284,96.07751780579284,1000000.0 +2019-04-24,GLD,96.07751780579284,96.5579053948218,95.549118947127,96.02926527349447,96.02926527349447,1000000.0 2019-04-25,GLD,96.02926527349447,97.22446764318506,95.549118947127,96.74076382406476,96.74076382406476,1000000.0 -2019-04-26,GLD,96.74076382406476,99.39068420751211,96.25706000494444,98.89620319155435,98.89620319155435,1000000.0 -2019-04-29,GLD,98.89620319155435,99.39068420751211,97.51161886340941,98.00162699840142,98.00162699840142,1000000.0 -2019-04-30,GLD,98.00162699840142,99.41002709342068,97.51161886340941,98.9154498441997,98.9154498441997,1000000.0 -2019-05-01,GLD,98.9154498441997,99.41002709342068,97.90658725361915,98.39858015439111,98.39858015439111,1000000.0 -2019-05-02,GLD,98.39858015439111,98.89057305516306,97.79183325953461,98.28324950706997,98.28324950706997,1000000.0 -2019-05-03,GLD,98.28324950706997,98.77466575460531,96.51614928872212,97.00115506404234,97.00115506404234,1000000.0 -2019-05-06,GLD,97.00115506404234,97.48616083936254,95.25899763012495,95.7376860604271,95.7376860604271,1000000.0 +2019-04-26,GLD,96.74076382406476,99.39068420751212,96.25706000494444,98.89620319155436,98.89620319155436,1000000.0 +2019-04-29,GLD,98.89620319155436,99.39068420751212,97.5116188634094,98.00162699840142,98.00162699840142,1000000.0 +2019-04-30,GLD,98.00162699840142,99.41002709342068,97.5116188634094,98.9154498441997,98.9154498441997,1000000.0 +2019-05-01,GLD,98.9154498441997,99.41002709342068,97.90658725361916,98.39858015439113,98.39858015439113,1000000.0 +2019-05-02,GLD,98.39858015439113,98.89057305516306,97.7918332595346,98.28324950706995,98.28324950706995,1000000.0 +2019-05-03,GLD,98.28324950706995,98.77466575460532,96.51614928872212,97.00115506404234,97.00115506404234,1000000.0 +2019-05-06,GLD,97.00115506404234,97.48616083936254,95.25899763012497,95.7376860604271,95.7376860604271,1000000.0 2019-05-07,GLD,95.7376860604271,96.21637449072922,94.61285241266631,95.0882938820767,95.0882938820767,1000000.0 -2019-05-08,GLD,95.0882938820767,95.56373535148707,93.62105753337335,94.09151510891793,94.09151510891793,1000000.0 -2019-05-09,GLD,94.09151510891793,94.57786142195272,93.62105753337335,94.10732479796292,94.10732479796292,1000000.0 -2019-05-10,GLD,94.10732479796292,94.57786142195272,93.06523623531855,93.53290073901361,93.53290073901361,1000000.0 -2019-05-13,GLD,93.53290073901361,95.08365367550721,93.06523623531855,94.61060067214649,94.61060067214649,1000000.0 -2019-05-14,GLD,94.61060067214649,95.23373578728638,94.13754766878576,94.75993610675262,94.75993610675262,1000000.0 -2019-05-15,GLD,94.75993610675262,95.39454440043808,94.28613642621886,94.91994467705283,94.91994467705283,1000000.0 -2019-05-16,GLD,94.91994467705283,96.3953830723331,94.44534495366756,95.91580405207274,95.91580405207274,1000000.0 +2019-05-08,GLD,95.0882938820767,95.56373535148708,93.62105753337336,94.09151510891792,94.09151510891792,1000000.0 +2019-05-09,GLD,94.09151510891792,94.57786142195272,93.62105753337336,94.10732479796292,94.10732479796292,1000000.0 +2019-05-10,GLD,94.10732479796292,94.57786142195272,93.06523623531857,93.5329007390136,93.5329007390136,1000000.0 +2019-05-13,GLD,93.5329007390136,95.0836536755072,93.06523623531857,94.61060067214648,94.61060067214648,1000000.0 +2019-05-14,GLD,94.61060067214648,95.23373578728638,94.13754766878576,94.75993610675262,94.75993610675262,1000000.0 +2019-05-15,GLD,94.75993610675262,95.39454440043808,94.28613642621886,94.91994467705284,94.91994467705284,1000000.0 +2019-05-16,GLD,94.91994467705284,96.3953830723331,94.44534495366756,95.91580405207274,95.91580405207274,1000000.0 2019-05-17,GLD,95.91580405207274,96.86529649564288,95.43622503181238,96.38337959765462,96.38337959765462,1000000.0 -2019-05-20,GLD,96.38337959765462,96.86529649564288,95.88842129072061,96.37027265399057,96.37027265399057,1000000.0 -2019-05-21,GLD,96.37027265399057,96.85212401726051,95.82932331478446,96.31087770330096,96.31087770330096,1000000.0 -2019-05-22,GLD,96.31087770330096,96.79243209181746,95.74421316230807,96.22533986161615,96.22533986161615,1000000.0 -2019-05-23,GLD,96.22533986161615,97.667687276684,95.74421316230807,97.18177838476022,97.18177838476022,1000000.0 +2019-05-20,GLD,96.38337959765462,96.86529649564288,95.8884212907206,96.37027265399055,96.37027265399055,1000000.0 +2019-05-21,GLD,96.37027265399055,96.85212401726052,95.82932331478446,96.31087770330096,96.31087770330096,1000000.0 +2019-05-22,GLD,96.31087770330096,96.79243209181746,95.74421316230809,96.22533986161616,96.22533986161616,1000000.0 +2019-05-23,GLD,96.22533986161616,97.667687276684,95.74421316230809,97.18177838476022,97.18177838476022,1000000.0 2019-05-24,GLD,97.18177838476022,98.37813172342312,96.69586949283642,97.88868828201306,97.88868828201306,1000000.0 -2019-05-27,GLD,97.88868828201306,98.72239322299393,97.399244840603,98.23123703780492,98.23123703780492,1000000.0 -2019-05-28,GLD,98.23123703780492,99.45460076698166,97.74008085261589,98.95980175819071,98.95980175819071,1000000.0 -2019-05-29,GLD,98.95980175819071,99.45460076698166,98.00322470841053,98.49570322453319,98.49570322453319,1000000.0 -2019-05-30,GLD,98.49570322453319,99.46438813345767,98.00322470841053,98.96954043130118,98.96954043130118,1000000.0 +2019-05-28,GLD,98.23123703780492,99.45460076698166,97.74008085261588,98.95980175819072,98.95980175819072,1000000.0 +2019-05-29,GLD,98.95980175819072,99.45460076698166,98.00322470841051,98.4957032245332,98.4957032245332,1000000.0 +2019-05-30,GLD,98.4957032245332,99.46438813345767,98.00322470841051,98.96954043130118,98.96954043130118,1000000.0 2019-05-31,GLD,98.96954043130118,99.46438813345767,97.25035040150242,97.73904562965068,97.73904562965068,1000000.0 2019-06-03,GLD,97.73904562965068,99.44593243868174,97.25035040150242,98.95117655590224,98.95117655590224,1000000.0 -2019-06-04,GLD,98.95117655590224,99.54646680590953,98.45642067312272,99.0512107521488,99.0512107521488,1000000.0 -2019-06-05,GLD,99.0512107521488,99.73085177963434,98.55595469838806,99.23467838769587,99.23467838769587,1000000.0 -2019-06-06,GLD,99.23467838769587,100.29035510204523,98.73850499575738,99.7913981114878,99.7913981114878,1000000.0 -2019-06-07,GLD,99.7913981114878,100.30164981907937,99.29244112093035,99.80263663589989,99.80263663589989,1000000.0 -2019-06-10,GLD,99.80263663589989,100.30164981907937,99.09659577944336,99.59456862255614,99.59456862255614,1000000.0 -2019-06-11,GLD,99.59456862255614,101.19033358043681,99.09659577944336,100.68689908501176,100.68689908501176,1000000.0 +2019-06-04,GLD,98.95117655590224,99.54646680590952,98.45642067312272,99.0512107521488,99.0512107521488,1000000.0 +2019-06-05,GLD,99.0512107521488,99.73085177963434,98.55595469838806,99.23467838769588,99.23467838769588,1000000.0 +2019-06-06,GLD,99.23467838769588,100.29035510204524,98.73850499575738,99.7913981114878,99.7913981114878,1000000.0 +2019-06-07,GLD,99.7913981114878,100.30164981907936,99.29244112093036,99.80263663589989,99.80263663589989,1000000.0 +2019-06-10,GLD,99.80263663589989,100.30164981907936,99.09659577944336,99.59456862255614,99.59456862255614,1000000.0 +2019-06-11,GLD,99.59456862255614,101.1903335804368,99.09659577944336,100.68689908501176,100.68689908501176,1000000.0 2019-06-12,GLD,100.68689908501176,101.31606533591162,100.1834645895867,100.8120053093648,100.8120053093648,1000000.0 2019-06-13,GLD,100.8120053093648,101.31606533591162,99.5044196684564,100.00444187784564,100.00444187784564,1000000.0 2019-06-14,GLD,100.00444187784564,100.9230328692165,99.5044196684564,100.42092822807614,100.42092822807614,1000000.0 -2019-06-17,GLD,100.42092822807614,100.9230328692165,98.41684793158345,98.91140495636527,98.91140495636527,1000000.0 -2019-06-18,GLD,98.91140495636527,100.91837012646906,98.41684793158345,100.41628868305379,100.41628868305379,1000000.0 -2019-06-19,GLD,100.41628868305379,100.91837012646906,99.78039390340795,100.28180291799795,100.28180291799795,1000000.0 -2019-06-20,GLD,100.28180291799795,100.84500641078266,99.78039390340795,100.34328996097778,100.34328996097778,1000000.0 -2019-06-21,GLD,100.34328996097778,102.93571655624113,99.8415735111729,102.42359856342402,102.42359856342402,1000000.0 -2019-06-24,GLD,102.42359856342402,104.96955460810275,101.9114805706069,104.44731801801271,104.44731801801271,1000000.0 -2019-06-25,GLD,104.44731801801271,105.10003512816904,103.92508142792265,104.57714938126274,104.57714938126274,1000000.0 -2019-06-26,GLD,104.57714938126274,105.71390219738329,104.05426363435643,105.18796238545602,105.18796238545602,1000000.0 -2019-06-27,GLD,105.18796238545602,106.77758519647783,104.66202257352873,106.24635342933118,106.24635342933118,1000000.0 -2019-06-28,GLD,106.24635342933118,108.42798469924024,105.71512166218453,107.88854198929378,107.88854198929378,1000000.0 -2019-07-01,GLD,107.88854198929378,108.42798469924024,107.24396627209093,107.78288067546828,107.78288067546828,1000000.0 -2019-07-02,GLD,107.78288067546828,108.47543295341866,107.24396627209093,107.93575418250614,107.93575418250614,1000000.0 -2019-07-03,GLD,107.93575418250614,109.69956818539802,107.39607541159361,109.15379918945078,109.15379918945078,1000000.0 -2019-07-04,GLD,109.15379918945078,109.98801262018165,108.60803019350352,109.44080857729519,109.44080857729519,1000000.0 -2019-07-05,GLD,109.44080857729519,110.7595773502545,108.89360453440871,110.20853467687016,110.20853467687016,1000000.0 -2019-07-08,GLD,110.20853467687016,111.39145490391881,109.65749200348581,110.83726856111326,110.83726856111326,1000000.0 -2019-07-09,GLD,110.83726856111326,111.39145490391881,109.18419596631715,109.73286026765543,109.73286026765543,1000000.0 -2019-07-10,GLD,109.73286026765543,110.28152456899369,108.36507580211706,108.9096239217257,108.9096239217257,1000000.0 -2019-07-11,GLD,108.9096239217257,109.45417204133432,105.84055976207445,106.37242187143161,106.37242187143161,1000000.0 -2019-07-12,GLD,106.37242187143161,106.90428398078876,105.61418841234747,106.14491297723364,106.14491297723364,1000000.0 +2019-06-17,GLD,100.42092822807614,100.9230328692165,98.41684793158343,98.91140495636527,98.91140495636527,1000000.0 +2019-06-18,GLD,98.91140495636527,100.91837012646906,98.41684793158343,100.4162886830538,100.4162886830538,1000000.0 +2019-06-19,GLD,100.4162886830538,100.91837012646906,99.78039390340795,100.28180291799796,100.28180291799796,1000000.0 +2019-06-20,GLD,100.28180291799796,100.84500641078266,99.78039390340795,100.34328996097778,100.34328996097778,1000000.0 +2019-06-21,GLD,100.34328996097778,102.93571655624112,99.8415735111729,102.42359856342402,102.42359856342402,1000000.0 +2019-06-24,GLD,102.42359856342402,104.96955460810275,101.9114805706069,104.44731801801272,104.44731801801272,1000000.0 +2019-06-25,GLD,104.44731801801272,105.10003512816904,103.92508142792263,104.57714938126274,104.57714938126274,1000000.0 +2019-06-26,GLD,104.57714938126274,105.71390219738328,104.05426363435645,105.18796238545602,105.18796238545602,1000000.0 +2019-06-27,GLD,105.18796238545602,106.77758519647784,104.66202257352872,106.24635342933118,106.24635342933118,1000000.0 +2019-06-28,GLD,106.24635342933118,108.42798469924024,105.71512166218452,107.88854198929378,107.88854198929378,1000000.0 +2019-07-01,GLD,107.88854198929378,108.42798469924024,107.24396627209092,107.78288067546828,107.78288067546828,1000000.0 +2019-07-02,GLD,107.78288067546828,108.47543295341866,107.24396627209092,107.93575418250614,107.93575418250614,1000000.0 +2019-07-03,GLD,107.93575418250614,109.69956818539802,107.3960754115936,109.15379918945078,109.15379918945078,1000000.0 +2019-07-05,GLD,109.4408085772952,110.7595773502545,108.89360453440872,110.20853467687016,110.20853467687016,1000000.0 +2019-07-08,GLD,110.20853467687016,111.3914549039188,109.6574920034858,110.83726856111326,110.83726856111326,1000000.0 +2019-07-09,GLD,110.83726856111326,111.3914549039188,109.18419596631716,109.73286026765544,109.73286026765544,1000000.0 +2019-07-10,GLD,109.73286026765544,110.28152456899367,108.36507580211706,108.9096239217257,108.9096239217257,1000000.0 +2019-07-11,GLD,108.9096239217257,109.45417204133432,105.84055976207443,106.3724218714316,106.3724218714316,1000000.0 +2019-07-12,GLD,106.3724218714316,106.90428398078876,105.61418841234747,106.14491297723364,106.14491297723364,1000000.0 2019-07-15,GLD,106.14491297723364,107.99040087657556,105.61418841234747,107.45313520057272,107.45313520057272,1000000.0 -2019-07-16,GLD,107.45313520057272,107.99040087657556,105.87502598191112,106.40706128835289,106.40706128835289,1000000.0 -2019-07-17,GLD,106.40706128835289,108.15481943038486,105.87502598191112,107.61673575162673,107.61673575162673,1000000.0 -2019-07-18,GLD,107.61673575162673,109.15398911573963,107.0786520728686,108.61093444352204,108.61093444352204,1000000.0 -2019-07-19,GLD,108.61093444352204,109.15398911573963,108.03615789444615,108.57905316024738,108.57905316024738,1000000.0 -2019-07-22,GLD,108.57905316024738,109.13629838260358,108.03615789444615,108.59333172398368,108.59333172398368,1000000.0 -2019-07-23,GLD,108.59333172398368,109.23953536117276,108.05036506536376,108.69605508574405,108.69605508574405,1000000.0 -2019-07-24,GLD,108.69605508574405,109.23953536117276,107.33912098698761,107.87851355476141,107.87851355476141,1000000.0 -2019-07-25,GLD,107.87851355476141,108.41790612253521,107.088136822312,107.62626816312763,107.62626816312763,1000000.0 +2019-07-16,GLD,107.45313520057272,107.99040087657556,105.87502598191112,106.40706128835288,106.40706128835288,1000000.0 +2019-07-17,GLD,106.40706128835288,108.15481943038486,105.87502598191112,107.61673575162672,107.61673575162672,1000000.0 +2019-07-18,GLD,107.61673575162672,109.15398911573963,107.0786520728686,108.61093444352204,108.61093444352204,1000000.0 +2019-07-19,GLD,108.61093444352204,109.15398911573963,108.03615789444616,108.57905316024738,108.57905316024738,1000000.0 +2019-07-22,GLD,108.57905316024738,109.13629838260358,108.03615789444616,108.59333172398368,108.59333172398368,1000000.0 +2019-07-23,GLD,108.59333172398368,109.23953536117276,108.05036506536376,108.69605508574404,108.69605508574404,1000000.0 +2019-07-24,GLD,108.69605508574404,109.23953536117276,107.3391209869876,107.8785135547614,107.8785135547614,1000000.0 +2019-07-25,GLD,107.8785135547614,108.4179061225352,107.088136822312,107.62626816312763,107.62626816312763,1000000.0 2019-07-26,GLD,107.62626816312763,108.16439950394326,107.06467865585736,107.60269211643956,107.60269211643956,1000000.0 -2019-07-29,GLD,107.60269211643956,108.75967702044659,107.06467865585736,108.21858409994687,108.21858409994687,1000000.0 -2019-07-30,GLD,108.21858409994687,109.55941071358873,107.67749117944713,109.01433901849626,109.01433901849626,1000000.0 -2019-07-31,GLD,109.01433901849626,109.55941071358873,107.768158583135,108.30970711872865,108.30970711872865,1000000.0 -2019-08-01,GLD,108.30970711872865,109.68512286320374,107.768158583135,109.1394257345311,109.1394257345311,1000000.0 +2019-07-29,GLD,107.60269211643956,108.7596770204466,107.06467865585736,108.21858409994688,108.21858409994688,1000000.0 +2019-07-30,GLD,108.21858409994688,109.55941071358872,107.67749117944712,109.01433901849626,109.01433901849626,1000000.0 +2019-07-31,GLD,109.01433901849626,109.55941071358872,107.768158583135,108.30970711872864,108.30970711872864,1000000.0 +2019-08-01,GLD,108.30970711872864,109.68512286320374,107.768158583135,109.1394257345311,109.1394257345311,1000000.0 2019-08-02,GLD,109.1394257345311,109.68512286320374,107.85770621050834,108.39970473417924,108.39970473417924,1000000.0 -2019-08-05,GLD,108.39970473417924,108.94170325785012,107.71540116336273,108.2566845862942,108.2566845862942,1000000.0 -2019-08-06,GLD,108.2566845862942,109.18221743655357,107.71540116336273,108.63902232492894,108.63902232492894,1000000.0 -2019-08-07,GLD,108.63902232492894,109.18221743655357,108.0954077403307,108.63860074405096,108.63860074405096,1000000.0 -2019-08-08,GLD,108.63860074405096,109.18179374777121,107.9935598881279,108.53624109359589,108.53624109359589,1000000.0 -2019-08-09,GLD,108.53624109359589,109.36562747362204,107.9935598881279,108.8215198742508,108.8215198742508,1000000.0 -2019-08-12,GLD,108.8215198742508,109.36562747362204,107.76159943573785,108.30311501079181,108.30311501079181,1000000.0 -2019-08-13,GLD,108.30311501079181,108.84463058584576,107.15189200160971,107.69034372021076,107.69034372021076,1000000.0 +2019-08-05,GLD,108.39970473417924,108.94170325785012,107.71540116336271,108.2566845862942,108.2566845862942,1000000.0 +2019-08-06,GLD,108.2566845862942,109.18221743655356,107.71540116336271,108.63902232492894,108.63902232492894,1000000.0 +2019-08-07,GLD,108.63902232492894,109.18221743655356,108.0954077403307,108.63860074405096,108.63860074405096,1000000.0 +2019-08-08,GLD,108.63860074405096,109.1817937477712,107.9935598881279,108.53624109359588,108.53624109359588,1000000.0 +2019-08-09,GLD,108.53624109359588,109.36562747362204,107.9935598881279,108.8215198742508,108.8215198742508,1000000.0 +2019-08-12,GLD,108.8215198742508,109.36562747362204,107.76159943573784,108.3031150107918,108.3031150107918,1000000.0 +2019-08-13,GLD,108.3031150107918,108.84463058584576,107.15189200160972,107.69034372021076,107.69034372021076,1000000.0 2019-08-14,GLD,107.69034372021076,108.2287954388118,106.97662496207974,107.51419594178869,107.51419594178869,1000000.0 -2019-08-15,GLD,107.51419594178869,109.01242608858419,106.97662496207974,108.47007571003404,108.47007571003404,1000000.0 -2019-08-16,GLD,108.47007571003404,109.01242608858419,107.8505832167653,108.39254594649779,108.39254594649779,1000000.0 -2019-08-19,GLD,108.39254594649779,110.83998180252992,107.8505832167653,110.28853910699496,110.28853910699496,1000000.0 -2019-08-20,GLD,110.28853910699496,111.07613146466612,109.73709641145999,110.52351389519018,110.52351389519018,1000000.0 -2019-08-21,GLD,110.52351389519018,111.07613146466612,107.08749004886079,107.62561813955858,107.62561813955858,1000000.0 -2019-08-22,GLD,107.62561813955858,108.16374623025636,106.68602564648147,107.22213632812208,107.22213632812208,1000000.0 -2019-08-23,GLD,107.22213632812208,109.40707637478943,106.68602564648147,108.86276256197954,108.86276256197954,1000000.0 +2019-08-15,GLD,107.51419594178869,109.0124260885842,106.97662496207974,108.47007571003404,108.47007571003404,1000000.0 +2019-08-16,GLD,108.47007571003404,109.0124260885842,107.8505832167653,108.3925459464978,108.3925459464978,1000000.0 +2019-08-19,GLD,108.3925459464978,110.83998180252992,107.8505832167653,110.28853910699496,110.28853910699496,1000000.0 +2019-08-20,GLD,110.28853910699496,111.07613146466612,109.73709641146,110.52351389519018,110.52351389519018,1000000.0 +2019-08-21,GLD,110.52351389519018,111.07613146466612,107.0874900488608,107.62561813955858,107.62561813955858,1000000.0 +2019-08-22,GLD,107.62561813955858,108.16374623025636,106.68602564648148,107.22213632812208,107.22213632812208,1000000.0 +2019-08-23,GLD,107.22213632812208,109.40707637478944,106.68602564648148,108.86276256197954,108.86276256197954,1000000.0 2019-08-26,GLD,108.86276256197954,109.4352099199279,108.31844874916963,108.89075613923175,108.89075613923175,1000000.0 -2019-08-27,GLD,108.89075613923175,109.4352099199279,107.94178969214254,108.48421074587189,108.48421074587189,1000000.0 -2019-08-28,GLD,108.48421074587189,109.02663179960123,107.11779961219622,107.6560800122575,107.6560800122575,1000000.0 -2019-08-29,GLD,107.6560800122575,109.51301751712977,107.11779961219622,108.96817663395998,108.96817663395998,1000000.0 -2019-08-30,GLD,108.96817663395998,110.5279957827327,108.42333575079019,109.97810525645046,109.97810525645046,1000000.0 -2019-09-02,GLD,109.97810525645046,111.22077145266739,109.42821473016821,110.6674342812611,110.6674342812611,1000000.0 -2019-09-03,GLD,110.6674342812611,111.67245475883931,110.11409710985478,111.11687040680529,111.11687040680529,1000000.0 -2019-09-04,GLD,111.11687040680529,113.15269975414648,110.56128605477126,112.58975099915074,112.58975099915074,1000000.0 +2019-08-27,GLD,108.89075613923175,109.4352099199279,107.94178969214254,108.48421074587188,108.48421074587188,1000000.0 +2019-08-28,GLD,108.48421074587188,109.02663179960123,107.11779961219622,107.6560800122575,107.6560800122575,1000000.0 +2019-08-29,GLD,107.6560800122575,109.51301751712975,107.11779961219622,108.96817663395998,108.96817663395998,1000000.0 +2019-08-30,GLD,108.96817663395998,110.5279957827327,108.4233357507902,109.97810525645046,109.97810525645046,1000000.0 +2019-09-03,GLD,110.6674342812611,111.67245475883932,110.11409710985478,111.11687040680528,111.11687040680528,1000000.0 +2019-09-04,GLD,111.11687040680528,113.15269975414648,110.56128605477126,112.58975099915074,112.58975099915074,1000000.0 2019-09-05,GLD,112.58975099915074,114.0148892216518,112.02680224415498,113.44765096681772,113.44765096681772,1000000.0 -2019-09-06,GLD,113.44765096681772,114.0148892216518,111.66024791090729,112.22135468432893,112.22135468432893,1000000.0 -2019-09-09,GLD,112.22135468432893,113.58788282259196,111.66024791090729,113.02276897770346,113.02276897770346,1000000.0 -2019-09-10,GLD,113.02276897770346,113.78417728152071,112.45765513281495,113.2180868472843,113.2180868472843,1000000.0 -2019-09-11,GLD,113.2180868472843,113.78417728152071,112.56271250759109,113.12835427898602,113.12835427898602,1000000.0 +2019-09-06,GLD,113.44765096681772,114.0148892216518,111.66024791090727,112.22135468432892,112.22135468432892,1000000.0 +2019-09-09,GLD,112.22135468432892,113.58788282259196,111.66024791090727,113.02276897770346,113.02276897770346,1000000.0 +2019-09-10,GLD,113.02276897770346,113.78417728152073,112.45765513281496,113.2180868472843,113.2180868472843,1000000.0 +2019-09-11,GLD,113.2180868472843,113.78417728152073,112.56271250759109,113.12835427898602,113.12835427898602,1000000.0 2019-09-12,GLD,113.12835427898602,114.5007172687769,112.56271250759109,113.931061958982,113.931061958982,1000000.0 -2019-09-13,GLD,113.931061958982,114.55288176803755,113.36140664918709,113.9829669333707,113.9829669333707,1000000.0 -2019-09-16,GLD,113.9829669333707,114.55288176803755,110.47999776339334,111.0351736315511,111.0351736315511,1000000.0 -2019-09-17,GLD,111.0351736315511,113.18810324881247,110.47999776339334,112.62497835702735,112.62497835702735,1000000.0 -2019-09-18,GLD,112.62497835702735,114.27302546686543,112.06185346524221,113.70450295210492,113.70450295210492,1000000.0 -2019-09-19,GLD,113.70450295210492,114.27302546686543,113.033830511299,113.60183970984825,113.60183970984825,1000000.0 -2019-09-20,GLD,113.60183970984825,114.16984890839748,112.67815727923579,113.24437917511135,113.24437917511135,1000000.0 -2019-09-23,GLD,113.24437917511135,114.06580383393292,112.67815727923579,113.49831227257008,113.49831227257008,1000000.0 +2019-09-13,GLD,113.931061958982,114.55288176803757,113.36140664918707,113.9829669333707,113.9829669333707,1000000.0 +2019-09-16,GLD,113.9829669333707,114.55288176803757,110.47999776339334,111.0351736315511,111.0351736315511,1000000.0 +2019-09-17,GLD,111.0351736315511,113.18810324881248,110.47999776339334,112.62497835702736,112.62497835702736,1000000.0 +2019-09-18,GLD,112.62497835702736,114.27302546686543,112.0618534652422,113.70450295210492,113.70450295210492,1000000.0 +2019-09-19,GLD,113.70450295210492,114.27302546686543,113.033830511299,113.60183970984824,113.60183970984824,1000000.0 +2019-09-20,GLD,113.60183970984824,114.16984890839748,112.6781572792358,113.24437917511136,113.24437917511136,1000000.0 +2019-09-23,GLD,113.24437917511136,114.06580383393292,112.6781572792358,113.49831227257008,113.49831227257008,1000000.0 2019-09-24,GLD,113.49831227257008,114.06580383393292,111.65523677746074,112.21631836930727,112.21631836930727,1000000.0 -2019-09-25,GLD,112.21631836930727,113.11144990591237,111.65523677746074,112.54870637404217,112.54870637404217,1000000.0 -2019-09-26,GLD,112.54870637404217,113.11144990591237,111.7765465303856,112.33823771898051,112.33823771898051,1000000.0 -2019-09-27,GLD,112.33823771898051,112.8999289075754,111.44433187670883,112.0043536449335,112.0043536449335,1000000.0 +2019-09-25,GLD,112.21631836930727,113.11144990591237,111.65523677746074,112.54870637404215,112.54870637404215,1000000.0 +2019-09-26,GLD,112.54870637404215,113.11144990591237,111.7765465303856,112.33823771898052,112.33823771898052,1000000.0 +2019-09-27,GLD,112.33823771898052,112.8999289075754,111.44433187670884,112.0043536449335,112.0043536449335,1000000.0 2019-09-30,GLD,112.0043536449335,112.56437541315816,110.54438710329718,111.09988653597706,111.09988653597706,1000000.0 -2019-10-01,GLD,111.09988653597706,111.65538596865694,109.89324644893365,110.44547381802377,110.44547381802377,1000000.0 -2019-10-02,GLD,110.44547381802377,112.13081255452647,109.89324644893365,111.57294781544924,111.57294781544924,1000000.0 -2019-10-03,GLD,111.57294781544924,113.50891138923461,111.015083076372,112.94419043704937,112.94419043704937,1000000.0 -2019-10-04,GLD,112.94419043704937,114.04657195824133,112.37946948486413,113.47917607785209,113.47917607785209,1000000.0 -2019-10-07,GLD,113.47917607785209,114.04657195824133,112.88809045305624,113.45536728950376,113.45536728950376,1000000.0 -2019-10-08,GLD,113.45536728950376,114.02264412595126,112.02206351573831,112.58498845802845,112.58498845802845,1000000.0 -2019-10-09,GLD,112.58498845802845,113.90396875107639,112.02206351573831,113.3372823393795,113.3372823393795,1000000.0 +2019-10-01,GLD,111.09988653597706,111.65538596865694,109.89324644893364,110.44547381802376,110.44547381802376,1000000.0 +2019-10-02,GLD,110.44547381802376,112.13081255452649,109.89324644893364,111.57294781544924,111.57294781544924,1000000.0 +2019-10-03,GLD,111.57294781544924,113.5089113892346,111.015083076372,112.94419043704936,112.94419043704936,1000000.0 +2019-10-04,GLD,112.94419043704936,114.04657195824132,112.37946948486412,113.47917607785207,113.47917607785207,1000000.0 +2019-10-07,GLD,113.47917607785207,114.04657195824132,112.88809045305624,113.45536728950376,113.45536728950376,1000000.0 +2019-10-08,GLD,113.45536728950376,114.02264412595126,112.02206351573832,112.58498845802843,112.58498845802843,1000000.0 +2019-10-09,GLD,112.58498845802843,113.9039687510764,112.02206351573832,113.3372823393795,113.3372823393795,1000000.0 2019-10-10,GLD,113.3372823393795,115.59054240298602,112.7705959276826,115.01546507759804,115.01546507759804,1000000.0 2019-10-11,GLD,115.01546507759804,115.59054240298602,112.81209075288058,113.37898568128702,113.37898568128702,1000000.0 -2019-10-14,GLD,113.37898568128702,115.17951806725881,112.81209075288058,114.60648563906351,114.60648563906351,1000000.0 -2019-10-15,GLD,114.60648563906351,115.17951806725881,111.85471343005467,112.41679741714037,112.41679741714037,1000000.0 -2019-10-16,GLD,112.41679741714037,112.97888140422606,111.5352568662522,112.09573554397205,112.09573554397205,1000000.0 -2019-10-17,GLD,112.09573554397205,113.06947477897337,111.5352568662522,112.50694007858048,112.50694007858048,1000000.0 -2019-10-18,GLD,112.50694007858048,113.06947477897337,110.59971183929927,111.15548928572791,111.15548928572791,1000000.0 -2019-10-21,GLD,111.15548928572791,111.71126673215653,109.12178663885148,109.67013732547888,109.67013732547888,1000000.0 -2019-10-22,GLD,109.67013732547888,110.42490690016068,109.12178663885148,109.87552925389123,109.87552925389123,1000000.0 -2019-10-23,GLD,109.87552925389123,111.32191616746906,109.32615160762177,110.76807578852643,110.76807578852643,1000000.0 -2019-10-24,GLD,110.76807578852643,111.32191616746906,109.76557868267415,110.31716450520015,110.31716450520015,1000000.0 -2019-10-25,GLD,110.31716450520015,112.24029906742886,109.76557868267415,111.68188961933221,111.68188961933221,1000000.0 -2019-10-28,GLD,111.68188961933221,112.24029906742886,110.36827389399696,110.92288833567534,110.92288833567534,1000000.0 -2019-10-29,GLD,110.92288833567534,113.27426074873352,110.36827389399696,112.71070721267019,112.71070721267019,1000000.0 -2019-10-30,GLD,112.71070721267019,113.41975805962339,112.14715367660683,112.8554806563417,112.8554806563417,1000000.0 -2019-10-31,GLD,112.8554806563417,113.68293928103097,112.29120325305999,113.11735251843879,113.11735251843879,1000000.0 -2019-11-01,GLD,113.11735251843879,114.33671788147667,112.5517657558466,113.76787848903153,113.76787848903153,1000000.0 -2019-11-04,GLD,113.76787848903153,114.69047778294377,113.19903909658638,114.11987839098884,114.11987839098884,1000000.0 +2019-10-14,GLD,113.37898568128702,115.1795180672588,112.81209075288058,114.60648563906352,114.60648563906352,1000000.0 +2019-10-15,GLD,114.60648563906352,115.1795180672588,111.85471343005467,112.41679741714036,112.41679741714036,1000000.0 +2019-10-16,GLD,112.41679741714036,112.97888140422606,111.5352568662522,112.09573554397204,112.09573554397204,1000000.0 +2019-10-17,GLD,112.09573554397204,113.06947477897336,111.5352568662522,112.50694007858048,112.50694007858048,1000000.0 +2019-10-18,GLD,112.50694007858048,113.06947477897336,110.59971183929927,111.15548928572792,111.15548928572792,1000000.0 +2019-10-21,GLD,111.15548928572792,111.71126673215652,109.12178663885148,109.67013732547888,109.67013732547888,1000000.0 +2019-10-22,GLD,109.67013732547888,110.42490690016068,109.12178663885148,109.87552925389124,109.87552925389124,1000000.0 +2019-10-23,GLD,109.87552925389124,111.32191616746906,109.32615160762175,110.76807578852645,110.76807578852645,1000000.0 +2019-10-24,GLD,110.76807578852645,111.32191616746906,109.76557868267416,110.31716450520015,110.31716450520015,1000000.0 +2019-10-25,GLD,110.31716450520015,112.24029906742886,109.76557868267416,111.6818896193322,111.6818896193322,1000000.0 +2019-10-28,GLD,111.6818896193322,112.24029906742886,110.36827389399696,110.92288833567534,110.92288833567534,1000000.0 +2019-10-29,GLD,110.92288833567534,113.27426074873352,110.36827389399696,112.7107072126702,112.7107072126702,1000000.0 +2019-10-30,GLD,112.7107072126702,113.4197580596234,112.14715367660683,112.8554806563417,112.8554806563417,1000000.0 +2019-10-31,GLD,112.8554806563417,113.68293928103095,112.29120325306,113.1173525184388,113.1173525184388,1000000.0 +2019-11-01,GLD,113.1173525184388,114.33671788147667,112.5517657558466,113.76787848903152,113.76787848903152,1000000.0 +2019-11-04,GLD,113.76787848903152,114.69047778294376,113.19903909658638,114.11987839098884,114.11987839098884,1000000.0 2019-11-05,GLD,114.11987839098884,116.63495621246268,113.5492789990339,116.05468279847034,116.05468279847034,1000000.0 -2019-11-06,GLD,116.05468279847034,118.99808166833982,115.47440938447798,118.40605141128341,118.40605141128341,1000000.0 -2019-11-07,GLD,118.40605141128341,119.60832340020352,117.81402115422699,119.01325711463038,119.01325711463038,1000000.0 -2019-11-08,GLD,119.01325711463038,119.60832340020352,118.26537102307705,118.85966936992669,118.85966936992669,1000000.0 -2019-11-11,GLD,118.85966936992669,119.45396771677632,117.04355144906977,117.6317099990651,117.6317099990651,1000000.0 -2019-11-12,GLD,117.6317099990651,118.21986854906041,116.27484331894082,116.85913901401088,116.85913901401088,1000000.0 +2019-11-06,GLD,116.05468279847034,118.99808166833982,115.47440938447798,118.4060514112834,118.4060514112834,1000000.0 +2019-11-07,GLD,118.4060514112834,119.60832340020352,117.814021154227,119.01325711463038,119.01325711463038,1000000.0 +2019-11-08,GLD,119.01325711463038,119.60832340020352,118.26537102307704,118.85966936992668,118.85966936992668,1000000.0 +2019-11-11,GLD,118.85966936992668,119.45396771677632,117.04355144906977,117.6317099990651,117.6317099990651,1000000.0 +2019-11-12,GLD,117.6317099990651,118.2198685490604,116.27484331894082,116.85913901401088,116.85913901401088,1000000.0 2019-11-13,GLD,116.85913901401088,117.47670335894315,116.27484331894082,116.89224214820216,116.89224214820216,1000000.0 -2019-11-14,GLD,116.89224214820216,117.47670335894315,116.22499349324187,116.80903868667525,116.80903868667525,1000000.0 -2019-11-15,GLD,116.80903868667525,117.39308388010862,116.20255698440026,116.78648943155805,116.78648943155805,1000000.0 -2019-11-18,GLD,116.78648943155805,117.3992323479558,116.20255698440026,116.81515656513017,116.81515656513017,1000000.0 -2019-11-19,GLD,116.81515656513017,117.9475726427826,116.23108078230452,117.36076879878867,117.36076879878867,1000000.0 -2019-11-20,GLD,117.36076879878867,117.9475726427826,115.38610711976195,115.96593680378085,115.96593680378085,1000000.0 -2019-11-21,GLD,115.96593680378085,116.54576648779974,114.34894304446128,114.92356084870481,114.92356084870481,1000000.0 -2019-11-22,GLD,114.92356084870481,116.69364109001926,114.34894304446128,116.11307571146196,116.11307571146196,1000000.0 -2019-11-25,GLD,116.11307571146196,117.60089915204388,115.53251033290465,117.01582005178497,117.01582005178497,1000000.0 -2019-11-26,GLD,117.01582005178497,117.60089915204388,115.89383590940444,116.47621699437633,116.47621699437633,1000000.0 -2019-11-27,GLD,116.47621699437633,118.45494218365066,115.89383590940444,117.86561411308524,117.86561411308524,1000000.0 -2019-11-28,GLD,117.86561411308524,118.45494218365066,116.58784798627954,117.17371656912516,117.17371656912516,1000000.0 -2019-11-29,GLD,117.17371656912516,117.75958515197078,116.34721115976332,116.93187051232495,116.93187051232495,1000000.0 -2019-12-02,GLD,116.93187051232495,117.51652986488656,114.47422408292285,115.04947144012347,115.04947144012347,1000000.0 -2019-12-03,GLD,115.04947144012347,116.53292652777223,114.47422408292285,115.95316072415149,115.95316072415149,1000000.0 -2019-12-04,GLD,115.95316072415149,116.54705585708368,115.37339492053073,115.96721975829223,115.96721975829223,1000000.0 -2019-12-05,GLD,115.96721975829223,117.69813307676162,115.38738365950077,117.11257022563346,117.11257022563346,1000000.0 -2019-12-06,GLD,117.11257022563346,118.47554101765955,116.52700737450529,117.8861104653329,117.8861104653329,1000000.0 +2019-11-14,GLD,116.89224214820216,117.47670335894315,116.22499349324188,116.80903868667524,116.80903868667524,1000000.0 +2019-11-15,GLD,116.80903868667524,117.39308388010862,116.20255698440026,116.78648943155804,116.78648943155804,1000000.0 +2019-11-18,GLD,116.78648943155804,117.3992323479558,116.20255698440026,116.81515656513017,116.81515656513017,1000000.0 +2019-11-19,GLD,116.81515656513017,117.9475726427826,116.23108078230452,117.36076879878868,117.36076879878868,1000000.0 +2019-11-20,GLD,117.36076879878868,117.9475726427826,115.38610711976196,115.96593680378083,115.96593680378083,1000000.0 +2019-11-21,GLD,115.96593680378083,116.54576648779974,114.34894304446128,114.9235608487048,114.9235608487048,1000000.0 +2019-11-22,GLD,114.9235608487048,116.69364109001926,114.34894304446128,116.11307571146196,116.11307571146196,1000000.0 +2019-11-25,GLD,116.11307571146196,117.60089915204388,115.53251033290464,117.01582005178496,117.01582005178496,1000000.0 +2019-11-26,GLD,117.01582005178496,117.60089915204388,115.89383590940444,116.47621699437632,116.47621699437632,1000000.0 +2019-11-27,GLD,116.47621699437632,118.45494218365066,115.89383590940444,117.86561411308524,117.86561411308524,1000000.0 +2019-11-29,GLD,117.17371656912516,117.75958515197078,116.34721115976332,116.93187051232496,116.93187051232496,1000000.0 +2019-12-02,GLD,116.93187051232496,117.51652986488656,114.47422408292285,115.04947144012348,115.04947144012348,1000000.0 +2019-12-03,GLD,115.04947144012348,116.53292652777225,114.47422408292285,115.95316072415147,115.95316072415147,1000000.0 +2019-12-04,GLD,115.95316072415147,116.54705585708368,115.37339492053071,115.96721975829225,115.96721975829225,1000000.0 +2019-12-05,GLD,115.96721975829225,117.69813307676162,115.38738365950076,117.11257022563346,117.11257022563346,1000000.0 +2019-12-06,GLD,117.11257022563346,118.47554101765957,116.52700737450527,117.8861104653329,117.8861104653329,1000000.0 2019-12-09,GLD,117.8861104653329,118.60537120872638,117.29667991300624,118.01529473505114,118.01529473505114,1000000.0 -2019-12-10,GLD,118.01529473505114,118.80646641576064,117.42521826137589,118.21538946841856,118.21538946841856,1000000.0 -2019-12-11,GLD,118.21538946841856,120.12278927679829,117.62431252107648,119.52516345950079,119.52516345950079,1000000.0 -2019-12-12,GLD,119.52516345950079,120.12278927679829,118.78570506068611,119.38261815144332,119.38261815144332,1000000.0 -2019-12-13,GLD,119.38261815144332,119.97953124220052,118.65742534485578,119.25369381392541,119.25369381392541,1000000.0 -2019-12-16,GLD,119.25369381392541,119.96047744355765,118.65742534485578,119.36365914781857,119.36365914781857,1000000.0 -2019-12-17,GLD,119.36365914781857,120.87185811979391,118.76684085207947,120.27050559183475,120.27050559183475,1000000.0 -2019-12-18,GLD,120.27050559183475,121.92116195384666,119.66915306387557,121.31458900880266,121.31458900880266,1000000.0 -2019-12-19,GLD,121.31458900880266,124.08726862618748,120.70801606375865,123.46991903103233,123.46991903103233,1000000.0 -2019-12-20,GLD,123.46991903103233,124.08726862618748,122.81197613926615,123.42912174800618,123.42912174800618,1000000.0 -2019-12-23,GLD,123.42912174800618,125.5182051222248,122.81197613926615,124.89373644002468,124.89373644002468,1000000.0 +2019-12-10,GLD,118.01529473505114,118.80646641576064,117.42521826137587,118.21538946841856,118.21538946841856,1000000.0 +2019-12-11,GLD,118.21538946841856,120.12278927679829,117.62431252107648,119.5251634595008,119.5251634595008,1000000.0 +2019-12-12,GLD,119.5251634595008,120.12278927679829,118.78570506068613,119.38261815144332,119.38261815144332,1000000.0 +2019-12-13,GLD,119.38261815144332,119.97953124220052,118.65742534485578,119.2536938139254,119.2536938139254,1000000.0 +2019-12-16,GLD,119.2536938139254,119.96047744355764,118.65742534485578,119.36365914781857,119.36365914781857,1000000.0 +2019-12-17,GLD,119.36365914781857,120.87185811979391,118.76684085207948,120.27050559183476,120.27050559183476,1000000.0 +2019-12-18,GLD,120.27050559183476,121.92116195384666,119.66915306387556,121.31458900880266,121.31458900880266,1000000.0 +2019-12-19,GLD,121.31458900880266,124.08726862618748,120.70801606375863,123.46991903103232,123.46991903103232,1000000.0 +2019-12-20,GLD,123.46991903103232,124.08726862618748,122.81197613926616,123.42912174800618,123.42912174800618,1000000.0 +2019-12-23,GLD,123.42912174800618,125.5182051222248,122.81197613926616,124.89373644002468,124.89373644002468,1000000.0 2019-12-24,GLD,124.89373644002468,125.5182051222248,123.59600637701786,124.21709183619885,124.21709183619885,1000000.0 -2019-12-25,GLD,124.21709183619885,125.57880306389222,123.59600637701786,124.95403289939526,124.95403289939526,1000000.0 2019-12-26,GLD,124.95403289939526,125.78454455105376,124.32926273489828,125.15875079706842,125.15875079706842,1000000.0 -2019-12-27,GLD,125.15875079706842,127.83210840574002,124.53295704308307,127.1961277669055,127.1961277669055,1000000.0 +2019-12-27,GLD,125.15875079706842,127.83210840574002,124.53295704308309,127.1961277669055,127.1961277669055,1000000.0 2019-12-30,GLD,127.1961277669055,128.60591378311258,126.56014712807098,127.9660833662812,127.9660833662812,1000000.0 -2019-12-31,GLD,127.9660833662812,128.60591378311258,126.88557679586384,127.52319275966215,127.52319275966215,1000000.0 -2020-01-01,GLD,127.52319275966215,128.9042908478581,126.88557679586384,128.26297596801803,128.26297596801803,1000000.0 -2020-01-02,GLD,128.26297596801803,129.3832356386594,127.62166108817793,128.73953794891483,128.73953794891483,1000000.0 -2020-01-03,GLD,128.73953794891483,129.3832356386594,127.80791606614565,128.4501669006489,128.4501669006489,1000000.0 -2020-01-06,GLD,128.4501669006489,129.09241773515214,127.54726160971093,128.18820262282506,128.18820262282506,1000000.0 -2020-01-07,GLD,128.18820262282506,128.82914363593918,126.54623828629875,127.18214903145602,127.18214903145602,1000000.0 -2020-01-08,GLD,127.18214903145602,131.1113329661292,126.54623828629875,130.459037777243,130.459037777243,1000000.0 +2019-12-31,GLD,127.9660833662812,128.60591378311258,126.88557679586384,127.52319275966217,127.52319275966217,1000000.0 +2020-01-02,GLD,128.26297596801805,129.3832356386594,127.62166108817792,128.73953794891483,128.73953794891483,1000000.0 +2020-01-03,GLD,128.73953794891483,129.3832356386594,127.80791606614564,128.4501669006489,128.4501669006489,1000000.0 +2020-01-06,GLD,128.4501669006489,129.09241773515214,127.54726160971092,128.18820262282506,128.18820262282506,1000000.0 +2020-01-07,GLD,128.18820262282506,128.82914363593918,126.54623828629876,127.18214903145602,127.18214903145602,1000000.0 +2020-01-08,GLD,127.18214903145602,131.1113329661292,126.54623828629876,130.459037777243,130.459037777243,1000000.0 2020-01-09,GLD,130.459037777243,131.35360274109186,129.80674258835677,130.70010222994216,130.70010222994216,1000000.0 2020-01-10,GLD,130.70010222994216,132.59466235813773,130.04660171879246,131.9349874210326,131.9349874210326,1000000.0 2020-01-13,GLD,131.9349874210326,132.59466235813773,129.98821917498535,130.64142630651793,130.64142630651793,1000000.0 @@ -273,13 +263,12 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-01-15,GLD,129.45386724426712,130.10113658048843,128.71197684717035,129.35877070067372,129.35877070067372,1000000.0 2020-01-16,GLD,129.35877070067372,130.00556455417708,128.6227533391593,129.26909883332593,129.26909883332593,1000000.0 2020-01-17,GLD,129.26909883332593,130.94189009838308,128.6227533391593,130.2904379088389,130.2904379088389,1000000.0 -2020-01-20,GLD,130.2904379088389,130.94189009838308,128.5566953734904,129.20270891808082,129.20270891808082,1000000.0 2020-01-21,GLD,129.20270891808082,130.5662652271698,128.5566953734904,129.9166818180794,129.9166818180794,1000000.0 2020-01-22,GLD,129.9166818180794,132.44544945902845,129.267098408989,131.78651687465518,131.78651687465518,1000000.0 2020-01-23,GLD,131.78651687465518,132.44544945902845,130.13833007989285,130.79229153758075,130.79229153758075,1000000.0 2020-01-24,GLD,130.79229153758075,132.61394056958738,130.13833007989285,131.9541697209825,131.9541697209825,1000000.0 -2020-01-27,GLD,131.9541697209825,132.61394056958738,130.24965130713917,130.90417216797908,130.90417216797908,1000000.0 -2020-01-28,GLD,130.90417216797908,132.05595697524723,130.24965130713917,131.39896216442511,131.39896216442511,1000000.0 +2020-01-27,GLD,131.9541697209825,132.61394056958738,130.24965130713915,130.90417216797908,130.90417216797908,1000000.0 +2020-01-28,GLD,130.90417216797908,132.05595697524723,130.24965130713915,131.39896216442511,131.39896216442511,1000000.0 2020-01-29,GLD,131.39896216442511,132.5756494614272,130.741967353603,131.91606911584796,131.91606911584796,1000000.0 2020-01-30,GLD,131.91606911584796,134.75361179157878,131.25648877026873,134.0831958125162,134.0831958125162,1000000.0 2020-01-31,GLD,134.0831958125162,134.75361179157878,132.6732687356543,133.33996857854703,133.33996857854703,1000000.0 @@ -293,7 +282,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-02-12,GLD,132.92223957270576,134.51968922067343,132.25762837484223,133.85043703549596,133.85043703549596,1000000.0 2020-02-13,GLD,133.85043703549596,137.76695581309573,133.1811848503185,137.0815480727321,137.0815480727321,1000000.0 2020-02-14,GLD,137.0815480727321,139.57619187826387,136.39614033236842,138.88178296344665,138.88178296344665,1000000.0 -2020-02-17,GLD,138.88178296344665,141.29716013673212,138.1873740486294,140.59418919077825,140.59418919077825,1000000.0 2020-02-18,GLD,140.59418919077825,141.29716013673212,139.77223903924224,140.47461209974094,140.47461209974094,1000000.0 2020-02-19,GLD,140.47461209974094,141.17698516023964,139.48484715960973,140.1857760398088,140.1857760398088,1000000.0 2020-02-20,GLD,140.1857760398088,141.15101128026788,139.48484715960973,140.44876744305265,140.44876744305265,1000000.0 @@ -305,8 +293,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-02-28,GLD,137.36675617459076,139.22535697986115,136.67992239371782,138.53269351229966,138.53269351229966,1000000.0 2020-03-02,GLD,138.53269351229966,140.87104325379704,137.84003004473817,140.17019229233537,140.17019229233537,1000000.0 2020-03-03,GLD,140.17019229233537,140.87104325379704,139.0731173923445,139.7719772787382,139.7719772787382,1000000.0 -2020-03-04,GLD,139.7719772787382,141.0983512967833,139.0731173923445,140.39636944953565,140.39636944953565,1000000.0 -2020-03-05,GLD,140.39636944953565,141.0991158926258,139.69438760228797,140.39713024141872,140.39713024141872,1000000.0 +2020-03-04,GLD,139.7719772787382,141.0983512967833,139.0731173923445,140.39636944953563,140.39636944953563,1000000.0 +2020-03-05,GLD,140.39636944953563,141.0991158926258,139.69438760228795,140.39713024141872,140.39713024141872,1000000.0 2020-03-06,GLD,140.39713024141872,141.0991158926258,138.5183908227126,139.21446313840462,139.21446313840462,1000000.0 2020-03-09,GLD,139.21446313840462,140.19400865789,138.5183908227126,139.49652602775126,139.49652602775126,1000000.0 2020-03-10,GLD,139.49652602775126,140.29669754125808,138.7990433976125,139.59870402115234,139.59870402115234,1000000.0 @@ -332,7 +320,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-04-07,GLD,136.19928289111886,136.88027930557445,134.42825601978566,135.10377489425696,135.10377489425696,1000000.0 2020-04-08,GLD,135.10377489425696,137.5601759264672,134.42825601978566,136.8757969417584,136.8757969417584,1000000.0 2020-04-09,GLD,136.8757969417584,137.5601759264672,136.12235721458364,136.80638916038558,136.80638916038558,1000000.0 -2020-04-10,GLD,136.80638916038558,137.49042110618748,135.82681683177975,136.5093636500299,136.5093636500299,1000000.0 2020-04-13,GLD,136.5093636500299,137.19191046828004,134.74727930586005,135.42440131242216,135.42440131242216,1000000.0 2020-04-14,GLD,135.42440131242216,136.10152331898425,134.22441743286498,134.89891199282914,134.89891199282914,1000000.0 2020-04-15,GLD,134.89891199282914,135.57340655279327,134.15370498499195,134.82784420602206,134.82784420602206,1000000.0 @@ -347,7 +334,7 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-04-28,GLD,138.86136543599898,140.025192726996,138.167058608819,139.32854997711047,139.32854997711047,1000000.0 2020-04-29,GLD,139.32854997711047,140.659068491801,138.63190722722493,139.9592721311453,139.9592721311453,1000000.0 2020-04-30,GLD,139.9592721311453,140.659068491801,139.25258672415916,139.95234846649163,139.95234846649163,1000000.0 -2020-05-01,GLD,139.95234846649163,142.95293588981463,139.25258672415916,142.2417272535469,142.2417272535469,1000000.0 +2020-05-01,GLD,139.95234846649163,142.95293588981465,139.25258672415916,142.2417272535469,142.2417272535469,1000000.0 2020-05-04,GLD,142.2417272535469,144.3557135150715,141.53051861727917,143.6375258856433,143.6375258856433,1000000.0 2020-05-05,GLD,143.6375258856433,144.3557135150715,142.0388191931663,142.75258210368472,142.75258210368472,1000000.0 2020-05-06,GLD,142.75258210368472,144.220646437121,142.0388191931663,143.503130783205,143.503130783205,1000000.0 @@ -363,19 +350,18 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-05-20,GLD,142.049445138207,143.72749539645503,141.33919791251597,143.01243323030351,143.01243323030351,1000000.0 2020-05-21,GLD,143.01243323030351,143.72749539645503,141.4826092058863,142.193577091343,142.193577091343,1000000.0 2020-05-22,GLD,142.193577091343,142.9045449767997,140.7702364297646,141.47762455252723,141.47762455252723,1000000.0 -2020-05-25,GLD,141.47762455252723,142.18501267528984,139.92487996175882,140.62802006206917,140.62802006206917,1000000.0 -2020-05-26,GLD,140.62802006206917,141.3311601623795,138.26124489364815,138.95602501874185,138.95602501874185,1000000.0 -2020-05-27,GLD,138.95602501874185,142.74240656122677,138.26124489364815,142.032245334554,142.032245334554,1000000.0 +2020-05-26,GLD,140.62802006206917,141.3311601623795,138.26124489364815,138.95602501874183,138.95602501874183,1000000.0 +2020-05-27,GLD,138.95602501874183,142.74240656122677,138.26124489364815,142.032245334554,142.032245334554,1000000.0 2020-05-28,GLD,142.032245334554,142.74240656122677,141.10364469149707,141.81270823266036,141.81270823266036,1000000.0 2020-05-29,GLD,141.81270823266036,142.52177177382364,140.68734902303586,141.3943206261667,141.3943206261667,1000000.0 2020-06-01,GLD,141.3943206261667,143.28518744176048,140.68734902303586,142.57232581269702,142.57232581269702,1000000.0 2020-06-02,GLD,142.57232581269702,143.28518744176048,137.92060774336844,138.61367612398837,138.61367612398837,1000000.0 2020-06-03,GLD,138.61367612398837,139.3067445046083,137.90689123262712,138.5998906860574,138.5998906860574,1000000.0 -2020-06-04,GLD,138.5998906860574,139.33230437298036,137.90689123262712,138.63910882883619,138.63910882883619,1000000.0 -2020-06-05,GLD,138.63910882883619,139.33230437298036,136.76642403143578,137.4536924939053,137.4536924939053,1000000.0 +2020-06-04,GLD,138.5998906860574,139.33230437298036,137.90689123262712,138.6391088288362,138.6391088288362,1000000.0 +2020-06-05,GLD,138.6391088288362,139.33230437298036,136.76642403143578,137.4536924939053,137.4536924939053,1000000.0 2020-06-08,GLD,137.4536924939053,138.73325349594236,136.76642403143578,138.04303830442026,138.04303830442026,1000000.0 -2020-06-09,GLD,138.04303830442026,138.73325349594236,134.8869794608996,135.56480347829105,135.56480347829105,1000000.0 -2020-06-10,GLD,135.56480347829105,136.25876906976265,134.8869794608996,135.5808647460325,135.5808647460325,1000000.0 +2020-06-09,GLD,138.04303830442026,138.73325349594236,134.8869794608996,135.56480347829103,135.56480347829103,1000000.0 +2020-06-10,GLD,135.56480347829103,136.25876906976265,134.8869794608996,135.5808647460325,135.5808647460325,1000000.0 2020-06-11,GLD,135.5808647460325,137.3461677822669,134.90296042230233,136.66285351469344,136.66285351469344,1000000.0 2020-06-12,GLD,136.66285351469344,137.3461677822669,133.2743959945092,133.9441165773962,133.9441165773962,1000000.0 2020-06-15,GLD,133.9441165773962,134.61383716028314,131.52358386006543,132.18450639202555,132.18450639202555,1000000.0 @@ -383,879 +369,849 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-06-17,GLD,131.95827194819285,132.6180633079338,131.2225537268783,131.8819635446013,131.8819635446013,1000000.0 2020-06-18,GLD,131.8819635446013,132.5413733623243,130.39048099033042,131.04570953802053,131.04570953802053,1000000.0 2020-06-19,GLD,131.04570953802053,132.04460569975583,130.39048099033042,131.38766736294113,131.38766736294113,1000000.0 -2020-06-22,GLD,131.38766736294113,132.23505670974313,130.73072902612643,131.57717085546582,131.57717085546582,1000000.0 +2020-06-22,GLD,131.38766736294113,132.23505670974313,130.73072902612645,131.57717085546582,131.57717085546582,1000000.0 2020-06-23,GLD,131.57717085546582,132.23505670974313,129.23203212520892,129.88143932181802,129.88143932181802,1000000.0 2020-06-24,GLD,129.88143932181802,130.5308465184271,128.99718075481664,129.64540779378558,129.64540779378558,1000000.0 2020-06-25,GLD,129.64540779378558,130.2936348327545,127.39735746043118,128.037545186363,128.037545186363,1000000.0 -2020-06-26,GLD,128.037545186363,128.6777329122948,124.90480280916644,125.53246513484065,125.53246513484065,1000000.0 -2020-06-29,GLD,125.53246513484065,126.16012746051484,122.08374348626741,122.69722963443961,122.69722963443961,1000000.0 -2020-06-30,GLD,122.69722963443961,124.30848556960458,122.08374348626741,123.69003539264139,123.69003539264139,1000000.0 -2020-07-01,GLD,123.69003539264139,124.30848556960458,122.77721135720154,123.39418226854426,123.39418226854426,1000000.0 -2020-07-02,GLD,123.39418226854426,126.05399381785995,122.77721135720154,125.42685952025867,125.42685952025867,1000000.0 -2020-07-03,GLD,125.42685952025867,126.05399381785995,123.00585210592202,123.62397196575077,123.62397196575077,1000000.0 -2020-07-06,GLD,123.62397196575077,124.24209182557951,122.45014490092304,123.06547226223421,123.06547226223421,1000000.0 -2020-07-07,GLD,123.06547226223421,123.89127728357764,122.45014490092304,123.27490276972901,123.27490276972901,1000000.0 -2020-07-08,GLD,123.27490276972901,123.89127728357764,122.33470706948553,122.94945434119148,122.94945434119148,1000000.0 -2020-07-09,GLD,122.94945434119148,123.95537914164586,122.33470706948553,123.33868571308048,123.33868571308048,1000000.0 -2020-07-10,GLD,123.33868571308048,123.95537914164586,122.71914756722202,123.33582670072565,123.33582670072565,1000000.0 -2020-07-13,GLD,123.33582670072565,123.95250583422926,121.88093139465978,122.49339838659274,122.49339838659274,1000000.0 -2020-07-14,GLD,122.49339838659274,123.10586537852569,119.19561194374091,119.79458486808132,119.79458486808132,1000000.0 +2020-06-26,GLD,128.037545186363,128.6777329122948,124.90480280916644,125.53246513484063,125.53246513484063,1000000.0 +2020-06-29,GLD,125.53246513484063,126.16012746051484,122.0837434862674,122.6972296344396,122.6972296344396,1000000.0 +2020-06-30,GLD,122.6972296344396,124.30848556960458,122.0837434862674,123.6900353926414,123.6900353926414,1000000.0 +2020-07-01,GLD,123.6900353926414,124.30848556960458,122.77721135720154,123.39418226854426,123.39418226854426,1000000.0 +2020-07-02,GLD,123.39418226854426,126.05399381785996,122.77721135720154,125.42685952025867,125.42685952025867,1000000.0 +2020-07-06,GLD,123.62397196575075,124.24209182557952,122.45014490092304,123.0654722622342,123.0654722622342,1000000.0 +2020-07-07,GLD,123.0654722622342,123.89127728357764,122.45014490092304,123.274902769729,123.274902769729,1000000.0 +2020-07-08,GLD,123.274902769729,123.89127728357764,122.33470706948552,122.94945434119148,122.94945434119148,1000000.0 +2020-07-09,GLD,122.94945434119148,123.95537914164586,122.33470706948552,123.33868571308048,123.33868571308048,1000000.0 +2020-07-10,GLD,123.33868571308048,123.95537914164586,122.71914756722202,123.33582670072563,123.33582670072563,1000000.0 +2020-07-13,GLD,123.33582670072563,123.95250583422926,121.88093139465978,122.49339838659274,122.49339838659274,1000000.0 +2020-07-14,GLD,122.49339838659274,123.10586537852568,119.19561194374091,119.79458486808132,119.79458486808132,1000000.0 2020-07-15,GLD,119.79458486808132,120.3935577924217,118.02929752635066,118.62240957422176,118.62240957422176,1000000.0 -2020-07-16,GLD,118.62240957422176,120.92474516267703,118.02929752635066,120.32312951510153,120.32312951510153,1000000.0 -2020-07-17,GLD,120.32312951510153,121.61290595868739,119.72151386752601,121.0078666255596,121.0078666255596,1000000.0 -2020-07-20,GLD,121.0078666255596,122.9839172428256,120.40282729243181,122.37205695803543,122.37205695803543,1000000.0 -2020-07-21,GLD,122.37205695803543,122.9839172428256,120.98005436176963,121.58799433343681,121.58799433343681,1000000.0 -2020-07-22,GLD,121.58799433343681,122.19593430510398,120.63657982905036,121.24279379804057,121.24279379804057,1000000.0 +2020-07-16,GLD,118.62240957422176,120.92474516267704,118.02929752635066,120.32312951510151,120.32312951510151,1000000.0 +2020-07-17,GLD,120.32312951510151,121.6129059586874,119.721513867526,121.0078666255596,121.0078666255596,1000000.0 +2020-07-20,GLD,121.0078666255596,122.9839172428256,120.4028272924318,122.37205695803544,122.37205695803544,1000000.0 +2020-07-21,GLD,122.37205695803544,122.9839172428256,120.98005436176965,121.5879943334368,121.5879943334368,1000000.0 +2020-07-22,GLD,121.5879943334368,122.19593430510398,120.63657982905036,121.24279379804057,121.24279379804057,1000000.0 2020-07-23,GLD,121.24279379804057,121.84900776703076,119.5583728019928,120.1591686452189,120.1591686452189,1000000.0 2020-07-24,GLD,120.1591686452189,121.05879741132274,119.5583728019928,120.45651483713706,120.45651483713706,1000000.0 -2020-07-27,GLD,120.45651483713706,121.05879741132274,119.79891923688949,120.40092385617034,120.40092385617034,1000000.0 -2020-07-28,GLD,120.40092385617034,121.37663588904555,119.79891923688949,120.77277202890106,120.77277202890106,1000000.0 +2020-07-27,GLD,120.45651483713706,121.05879741132274,119.79891923688947,120.40092385617034,120.40092385617034,1000000.0 +2020-07-28,GLD,120.40092385617034,121.37663588904556,119.79891923688947,120.77277202890106,120.77277202890106,1000000.0 2020-07-29,GLD,120.77277202890106,123.19770496237474,120.16890816875656,122.58478105708932,122.58478105708932,1000000.0 -2020-07-30,GLD,122.58478105708932,123.19770496237474,120.94205605999827,121.54980508542539,121.54980508542539,1000000.0 -2020-07-31,GLD,121.54980508542539,122.85630668827928,120.94205605999827,122.24508128186994,122.24508128186994,1000000.0 -2020-08-03,GLD,122.24508128186994,122.85630668827928,119.99653187898635,120.59952952661945,120.59952952661945,1000000.0 -2020-08-04,GLD,120.59952952661945,122.30071151432168,119.99653187898635,121.69225026300666,121.69225026300666,1000000.0 -2020-08-05,GLD,121.69225026300666,122.30071151432168,120.49372918551893,121.09922531207933,121.09922531207933,1000000.0 -2020-08-06,GLD,121.09922531207933,121.70472143863971,120.30679525831813,120.91135201841018,120.91135201841018,1000000.0 -2020-08-07,GLD,120.91135201841018,121.55602610275189,120.30679525831813,120.95126975398199,120.95126975398199,1000000.0 -2020-08-10,GLD,120.95126975398199,123.26054314935965,120.34651340521208,122.64730661627827,122.64730661627827,1000000.0 -2020-08-11,GLD,122.64730661627827,123.26054314935965,121.89038585406807,122.5029003558473,122.5029003558473,1000000.0 -2020-08-12,GLD,122.5029003558473,123.11541485762652,121.23697876267926,121.84620981173795,121.84620981173795,1000000.0 -2020-08-13,GLD,121.84620981173795,122.45544086079663,119.84411490941558,120.44634664262873,120.44634664262873,1000000.0 +2020-07-30,GLD,122.58478105708932,123.19770496237474,120.94205605999828,121.5498050854254,121.5498050854254,1000000.0 +2020-07-31,GLD,121.5498050854254,122.85630668827928,120.94205605999828,122.24508128186994,122.24508128186994,1000000.0 +2020-08-03,GLD,122.24508128186994,122.85630668827928,119.99653187898636,120.59952952661943,120.59952952661943,1000000.0 +2020-08-04,GLD,120.59952952661943,122.30071151432168,119.99653187898636,121.69225026300666,121.69225026300666,1000000.0 +2020-08-05,GLD,121.69225026300666,122.30071151432168,120.49372918551892,121.09922531207933,121.09922531207933,1000000.0 +2020-08-06,GLD,121.09922531207933,121.70472143863972,120.30679525831812,120.91135201841018,120.91135201841018,1000000.0 +2020-08-07,GLD,120.91135201841018,121.55602610275189,120.30679525831812,120.951269753982,120.951269753982,1000000.0 +2020-08-10,GLD,120.951269753982,123.26054314935963,120.34651340521208,122.64730661627829,122.64730661627829,1000000.0 +2020-08-11,GLD,122.64730661627829,123.26054314935963,121.89038585406809,122.5029003558473,122.5029003558473,1000000.0 +2020-08-12,GLD,122.5029003558473,123.11541485762652,121.23697876267926,121.84620981173796,121.84620981173796,1000000.0 +2020-08-13,GLD,121.84620981173796,122.45544086079664,119.84411490941558,120.44634664262873,120.44634664262873,1000000.0 2020-08-14,GLD,120.44634664262873,121.2411445235576,119.84411490941558,120.63795474980856,120.63795474980856,1000000.0 -2020-08-17,GLD,120.63795474980856,121.2411445235576,119.0080352991577,119.60606562729417,119.60606562729417,1000000.0 -2020-08-18,GLD,119.60606562729417,120.20409595543063,118.36402703784249,118.95882114356029,118.95882114356029,1000000.0 -2020-08-19,GLD,118.95882114356029,119.55361524927808,118.23093922516716,118.82506454790669,118.82506454790669,1000000.0 -2020-08-20,GLD,118.82506454790669,120.03596057842222,118.23093922516716,119.43876674469874,119.43876674469874,1000000.0 +2020-08-17,GLD,120.63795474980856,121.2411445235576,119.0080352991577,119.60606562729416,119.60606562729416,1000000.0 +2020-08-18,GLD,119.60606562729416,120.20409595543065,118.36402703784248,118.95882114356029,118.95882114356029,1000000.0 +2020-08-19,GLD,118.95882114356029,119.55361524927808,118.23093922516716,118.82506454790668,118.82506454790668,1000000.0 +2020-08-20,GLD,118.82506454790668,120.03596057842222,118.23093922516716,119.43876674469874,119.43876674469874,1000000.0 2020-08-21,GLD,119.43876674469874,120.03596057842222,117.97329577741807,118.5661264094654,118.5661264094654,1000000.0 2020-08-24,GLD,118.5661264094654,119.77297326521236,117.97329577741807,119.17708782608196,119.17708782608196,1000000.0 -2020-08-25,GLD,119.17708782608196,121.58838223319822,118.58120238695155,120.98346490865495,120.98346490865495,1000000.0 -2020-08-26,GLD,120.98346490865495,121.58838223319822,118.66882594667706,119.26515170520308,119.26515170520308,1000000.0 +2020-08-25,GLD,119.17708782608196,121.58838223319822,118.58120238695156,120.98346490865497,120.98346490865497,1000000.0 +2020-08-26,GLD,120.98346490865497,121.58838223319822,118.66882594667706,119.26515170520308,119.26515170520308,1000000.0 2020-08-27,GLD,119.26515170520308,121.23231117727651,118.66882594667706,120.6291653505239,120.6291653505239,1000000.0 2020-08-28,GLD,120.6291653505239,123.49557970239114,120.02601952377128,122.88117383322503,122.88117383322503,1000000.0 -2020-08-31,GLD,122.88117383322503,123.49557970239114,121.51007892855853,122.12068234025983,122.12068234025983,1000000.0 -2020-09-01,GLD,122.12068234025983,123.30252159715644,121.51007892855853,122.68907621607607,122.68907621607607,1000000.0 +2020-08-31,GLD,122.88117383322503,123.49557970239114,121.51007892855851,122.12068234025983,122.12068234025983,1000000.0 +2020-09-01,GLD,122.12068234025983,123.30252159715644,121.51007892855851,122.68907621607607,122.68907621607607,1000000.0 2020-09-02,GLD,122.68907621607607,123.30252159715644,120.46882271672986,121.07419368515563,121.07419368515563,1000000.0 -2020-09-03,GLD,121.07419368515563,121.94524215244942,120.46882271672986,121.33854940542233,121.33854940542233,1000000.0 -2020-09-04,GLD,121.33854940542233,121.94524215244942,120.27264108744683,120.87702621853953,120.87702621853953,1000000.0 -2020-09-07,GLD,120.87702621853953,121.70828113668354,120.27264108744683,121.10276730018263,121.10276730018263,1000000.0 -2020-09-08,GLD,121.10276730018263,121.70828113668354,119.39978739720263,119.99978632884687,119.99978632884687,1000000.0 -2020-09-09,GLD,119.99978632884687,120.59978526049109,118.41272274030362,119.00776154804385,119.00776154804385,1000000.0 -2020-09-10,GLD,119.00776154804385,119.60280035578405,117.82857278025932,118.42067616106465,118.42067616106465,1000000.0 -2020-09-11,GLD,118.42067616106465,119.01277954186996,116.96604543357269,117.55381450610321,117.55381450610321,1000000.0 -2020-09-14,GLD,117.55381450610321,118.14158357863371,116.20403707811944,116.78797696293411,116.78797696293411,1000000.0 -2020-09-15,GLD,116.78797696293411,117.37191684774876,115.877623377625,116.45992299258793,116.45992299258793,1000000.0 -2020-09-16,GLD,116.45992299258793,117.04222260755085,114.60841957047728,115.18434127686159,115.18434127686159,1000000.0 -2020-09-17,GLD,115.18434127686159,115.76026298324588,114.33898361494303,114.91355137180204,114.91355137180204,1000000.0 -2020-09-18,GLD,114.91355137180204,115.86714606795876,114.33898361494303,115.2906926049341,115.2906926049341,1000000.0 +2020-09-03,GLD,121.07419368515563,121.94524215244942,120.46882271672986,121.33854940542231,121.33854940542231,1000000.0 +2020-09-04,GLD,121.33854940542231,121.94524215244942,120.27264108744684,120.87702621853953,120.87702621853953,1000000.0 +2020-09-08,GLD,121.10276730018263,121.70828113668354,119.39978739720264,119.99978632884688,119.99978632884688,1000000.0 +2020-09-09,GLD,119.99978632884688,120.59978526049107,118.41272274030362,119.00776154804385,119.00776154804385,1000000.0 +2020-09-10,GLD,119.00776154804385,119.60280035578404,117.82857278025932,118.42067616106463,118.42067616106463,1000000.0 +2020-09-11,GLD,118.42067616106463,119.01277954186996,116.96604543357267,117.5538145061032,117.5538145061032,1000000.0 +2020-09-14,GLD,117.5538145061032,118.14158357863371,116.20403707811944,116.78797696293412,116.78797696293412,1000000.0 +2020-09-15,GLD,116.78797696293412,117.37191684774876,115.877623377625,116.45992299258792,116.45992299258792,1000000.0 +2020-09-16,GLD,116.45992299258792,117.04222260755084,114.60841957047728,115.1843412768616,115.1843412768616,1000000.0 +2020-09-17,GLD,115.1843412768616,115.76026298324588,114.33898361494305,114.91355137180204,114.91355137180204,1000000.0 +2020-09-18,GLD,114.91355137180204,115.86714606795876,114.33898361494305,115.2906926049341,115.2906926049341,1000000.0 2020-09-21,GLD,115.2906926049341,115.86714606795876,112.22908106178508,112.79304629325134,112.79304629325134,1000000.0 2020-09-22,GLD,112.79304629325134,113.4345569910723,112.22908106178508,112.87020596126598,112.87020596126598,1000000.0 -2020-09-23,GLD,112.87020596126598,113.4345569910723,111.42984259026271,111.98979154800273,111.98979154800273,1000000.0 -2020-09-24,GLD,111.98979154800273,112.54974050574273,110.18623356359333,110.73993322974205,110.73993322974205,1000000.0 -2020-09-25,GLD,110.73993322974205,111.29363289589075,108.73291210856088,109.27930865181999,109.27930865181999,1000000.0 -2020-09-28,GLD,109.27930865181999,109.82570519507908,107.77394803577326,108.31552566409373,108.31552566409373,1000000.0 +2020-09-23,GLD,112.87020596126598,113.4345569910723,111.42984259026272,111.98979154800271,111.98979154800271,1000000.0 +2020-09-24,GLD,111.98979154800271,112.54974050574272,110.18623356359332,110.73993322974204,110.73993322974204,1000000.0 +2020-09-25,GLD,110.73993322974204,111.29363289589077,108.73291210856088,109.27930865182,109.27930865182,1000000.0 +2020-09-28,GLD,109.27930865182,109.82570519507908,107.77394803577326,108.31552566409373,108.31552566409373,1000000.0 2020-09-29,GLD,108.31552566409373,108.85710329241418,106.9213020422736,107.4585950173604,107.4585950173604,1000000.0 -2020-09-30,GLD,107.4585950173604,108.19587412242184,106.9213020422736,107.65758619146453,107.65758619146453,1000000.0 -2020-10-01,GLD,107.65758619146453,108.19587412242184,105.77372434324715,106.30525059622829,106.30525059622829,1000000.0 -2020-10-02,GLD,106.30525059622829,107.70606149861126,105.77372434324715,107.17021044637937,107.17021044637937,1000000.0 -2020-10-05,GLD,107.17021044637937,107.70606149861126,105.71621674242607,106.24745401248852,106.24745401248852,1000000.0 -2020-10-06,GLD,106.24745401248852,107.30203596255508,105.71621674242607,106.76819498761701,106.76819498761701,1000000.0 -2020-10-07,GLD,106.76819498761701,108.22322093017206,106.23435401267892,107.68479694544484,107.68479694544484,1000000.0 +2020-09-30,GLD,107.4585950173604,108.19587412242184,106.9213020422736,107.65758619146452,107.65758619146452,1000000.0 +2020-10-01,GLD,107.65758619146452,108.19587412242184,105.77372434324715,106.30525059622828,106.30525059622828,1000000.0 +2020-10-02,GLD,106.30525059622828,107.70606149861126,105.77372434324715,107.17021044637936,107.17021044637936,1000000.0 +2020-10-05,GLD,107.17021044637936,107.70606149861126,105.71621674242608,106.24745401248852,106.24745401248852,1000000.0 +2020-10-06,GLD,106.24745401248852,107.30203596255508,105.71621674242608,106.768194987617,106.768194987617,1000000.0 +2020-10-07,GLD,106.768194987617,108.22322093017206,106.23435401267892,107.68479694544484,107.68479694544484,1000000.0 2020-10-08,GLD,107.68479694544484,108.22322093017206,105.84461098157622,106.37649344882033,106.37649344882033,1000000.0 -2020-10-09,GLD,106.37649344882033,107.81795169736161,105.84461098157622,107.28154397747424,107.28154397747424,1000000.0 +2020-10-09,GLD,106.37649344882033,107.8179516973616,105.84461098157622,107.28154397747424,107.28154397747424,1000000.0 2020-10-12,GLD,107.28154397747424,109.8416384874142,106.74513625758686,109.295162674044,109.295162674044,1000000.0 -2020-10-13,GLD,109.295162674044,109.8416384874142,108.21212768074153,108.75590721682566,108.75590721682566,1000000.0 -2020-10-14,GLD,108.75590721682566,109.78143484816455,108.21212768074153,109.23525855538763,109.23525855538763,1000000.0 -2020-10-15,GLD,109.23525855538763,109.78143484816455,108.48160738202598,109.02674108746331,109.02674108746331,1000000.0 -2020-10-16,GLD,109.02674108746331,109.58044761721156,108.48160738202598,109.03527126090704,109.03527126090704,1000000.0 -2020-10-19,GLD,109.03527126090704,109.58044761721156,108.24360925726722,108.78754699222837,108.78754699222837,1000000.0 -2020-10-20,GLD,108.78754699222837,109.3314847271895,107.70128364353369,108.24249612415446,108.24249612415446,1000000.0 +2020-10-13,GLD,109.295162674044,109.8416384874142,108.21212768074152,108.75590721682566,108.75590721682566,1000000.0 +2020-10-14,GLD,108.75590721682566,109.78143484816457,108.21212768074152,109.23525855538765,109.23525855538765,1000000.0 +2020-10-15,GLD,109.23525855538765,109.78143484816457,108.48160738202598,109.02674108746332,109.02674108746332,1000000.0 +2020-10-16,GLD,109.02674108746332,109.58044761721156,108.48160738202598,109.03527126090704,109.03527126090704,1000000.0 +2020-10-19,GLD,109.03527126090704,109.58044761721156,108.24360925726722,108.78754699222836,108.78754699222836,1000000.0 +2020-10-20,GLD,108.78754699222836,109.3314847271895,107.70128364353369,108.24249612415446,108.24249612415446,1000000.0 2020-10-21,GLD,108.24249612415446,109.44902492815582,107.70128364353369,108.90450241607546,108.90450241607546,1000000.0 -2020-10-22,GLD,108.90450241607546,110.28227783549683,108.35997990399508,109.73360978656402,109.73360978656402,1000000.0 -2020-10-23,GLD,109.73360978656402,110.28227783549683,109.15212513256215,109.70062827393181,109.70062827393181,1000000.0 -2020-10-26,GLD,109.70062827393181,111.7347869934679,109.15212513256215,111.17889253081385,111.17889253081385,1000000.0 -2020-10-27,GLD,111.17889253081385,112.8136266322321,110.62299806815979,112.25236480819116,112.25236480819116,1000000.0 +2020-10-22,GLD,108.90450241607546,110.28227783549684,108.35997990399508,109.73360978656402,109.73360978656402,1000000.0 +2020-10-23,GLD,109.73360978656402,110.28227783549684,109.15212513256216,109.7006282739318,109.7006282739318,1000000.0 +2020-10-26,GLD,109.7006282739318,111.7347869934679,109.15212513256216,111.17889253081384,111.17889253081384,1000000.0 +2020-10-27,GLD,111.17889253081384,112.8136266322321,110.6229980681598,112.25236480819116,112.25236480819116,1000000.0 2020-10-28,GLD,112.25236480819116,113.51301746615222,111.6911029841502,112.94827608572362,112.94827608572362,1000000.0 -2020-10-29,GLD,112.94827608572362,113.51301746615222,111.64672037358616,112.20775916943333,112.20775916943333,1000000.0 -2020-10-30,GLD,112.20775916943333,114.70711450876685,111.64672037358616,114.1364323470317,114.1364323470317,1000000.0 -2020-11-02,GLD,114.1364323470317,116.07435194873024,113.56575018529654,115.49686761067687,115.49686761067687,1000000.0 -2020-11-03,GLD,115.49686761067687,116.07435194873024,112.71504196376759,113.28144920981667,113.28144920981667,1000000.0 -2020-11-04,GLD,113.28144920981667,113.84785645586574,111.5956650352203,112.1564472715782,112.1564472715782,1000000.0 +2020-10-29,GLD,112.94827608572362,113.51301746615222,111.64672037358616,112.20775916943332,112.20775916943332,1000000.0 +2020-10-30,GLD,112.20775916943332,114.70711450876684,111.64672037358616,114.1364323470317,114.1364323470317,1000000.0 +2020-11-02,GLD,114.1364323470317,116.07435194873024,113.56575018529654,115.49686761067689,115.49686761067689,1000000.0 +2020-11-03,GLD,115.49686761067689,116.07435194873024,112.7150419637676,113.28144920981669,113.28144920981669,1000000.0 +2020-11-04,GLD,113.28144920981669,113.84785645586574,111.5956650352203,112.1564472715782,112.1564472715782,1000000.0 2020-11-05,GLD,112.1564472715782,112.71722950793608,111.11851014003754,111.67689461310306,111.67689461310306,1000000.0 -2020-11-06,GLD,111.67689461310306,113.191452028956,111.11851014003754,112.62831047657313,112.62831047657313,1000000.0 -2020-11-09,GLD,112.62831047657313,114.47276061217414,112.06516892419027,113.90324439022304,113.90324439022304,1000000.0 -2020-11-10,GLD,113.90324439022304,114.47276061217414,113.04773837218023,113.61581745947763,113.61581745947763,1000000.0 -2020-11-11,GLD,113.61581745947763,114.44737943768418,113.04773837218023,113.87798949023302,113.87798949023302,1000000.0 -2020-11-12,GLD,113.87798949023302,115.18001231655784,113.30859954278185,114.60697742941079,114.60697742941079,1000000.0 -2020-11-13,GLD,114.60697742941079,115.18001231655784,112.74666336614264,113.31322951371119,113.31322951371119,1000000.0 -2020-11-16,GLD,113.31322951371119,114.50284822720401,112.74666336614264,113.9331823156259,113.9331823156259,1000000.0 -2020-11-17,GLD,113.9331823156259,114.50284822720401,113.06807486608398,113.63625614681807,113.63625614681807,1000000.0 -2020-11-18,GLD,113.63625614681807,114.4128156896509,113.06807486608398,113.84359770114519,113.84359770114519,1000000.0 -2020-11-19,GLD,113.84359770114519,114.4128156896509,113.24409704610464,113.81316286040668,113.81316286040668,1000000.0 +2020-11-06,GLD,111.67689461310306,113.191452028956,111.11851014003754,112.62831047657312,112.62831047657312,1000000.0 +2020-11-09,GLD,112.62831047657312,114.47276061217414,112.06516892419027,113.90324439022304,113.90324439022304,1000000.0 +2020-11-10,GLD,113.90324439022304,114.47276061217414,113.04773837218023,113.61581745947764,113.61581745947764,1000000.0 +2020-11-11,GLD,113.61581745947764,114.44737943768418,113.04773837218023,113.87798949023302,113.87798949023302,1000000.0 +2020-11-12,GLD,113.87798949023302,115.18001231655784,113.30859954278183,114.6069774294108,114.6069774294108,1000000.0 +2020-11-13,GLD,114.6069774294108,115.18001231655784,112.74666336614264,113.3132295137112,113.3132295137112,1000000.0 +2020-11-16,GLD,113.3132295137112,114.502848227204,112.74666336614264,113.9331823156259,113.9331823156259,1000000.0 +2020-11-17,GLD,113.9331823156259,114.502848227204,113.06807486608398,113.63625614681808,113.63625614681808,1000000.0 +2020-11-18,GLD,113.63625614681808,114.4128156896509,113.06807486608398,113.8435977011452,113.8435977011452,1000000.0 +2020-11-19,GLD,113.8435977011452,114.4128156896509,113.24409704610464,113.81316286040668,113.81316286040668,1000000.0 2020-11-20,GLD,113.81316286040668,114.3822286747087,112.06646430440634,112.62961236623752,112.62961236623752,1000000.0 2020-11-23,GLD,112.62961236623752,113.1927604280687,111.08737130294053,111.64559929943772,111.64559929943772,1000000.0 -2020-11-24,GLD,111.64559929943772,112.26696543751018,111.08737130294053,111.70842332090567,111.70842332090567,1000000.0 -2020-11-25,GLD,111.70842332090567,112.4195323324149,111.14988120430114,111.86023117653225,111.86023117653225,1000000.0 -2020-11-26,GLD,111.86023117653225,112.4195323324149,111.05886137672623,111.61694610726254,111.61694610726254,1000000.0 -2020-11-27,GLD,111.61694610726254,113.2754088146489,111.05886137672623,112.71184956681483,112.71184956681483,1000000.0 +2020-11-24,GLD,111.64559929943772,112.26696543751018,111.08737130294053,111.70842332090568,111.70842332090568,1000000.0 +2020-11-25,GLD,111.70842332090568,112.4195323324149,111.14988120430114,111.86023117653224,111.86023117653224,1000000.0 +2020-11-27,GLD,111.61694610726254,113.2754088146489,111.05886137672624,112.71184956681483,112.71184956681483,1000000.0 2020-11-30,GLD,112.71184956681483,113.2754088146489,111.80909802289474,112.3709527868289,112.3709527868289,1000000.0 -2020-12-01,GLD,112.3709527868289,112.93280755076303,110.6127543920796,111.16859737897447,111.16859737897447,1000000.0 -2020-12-02,GLD,111.16859737897447,111.72444036586933,110.291200437797,110.84542757567537,110.84542757567537,1000000.0 -2020-12-03,GLD,110.84542757567537,111.39965471355374,110.13931260313346,110.69277648556127,110.69277648556127,1000000.0 -2020-12-04,GLD,110.69277648556127,111.24624036798906,110.03139921560417,110.58432081970268,110.58432081970268,1000000.0 -2020-12-07,GLD,110.58432081970268,112.32962652768636,110.03139921560417,111.77077266436454,111.77077266436454,1000000.0 -2020-12-08,GLD,111.77077266436454,112.86979197700441,111.21191880104271,112.30825072338749,112.30825072338749,1000000.0 -2020-12-09,GLD,112.30825072338749,113.96916143600421,111.74670946977055,113.40215068259127,113.40215068259127,1000000.0 -2020-12-10,GLD,113.40215068259127,114.63439393511447,112.83513992917831,114.0640735672781,114.0640735672781,1000000.0 +2020-12-01,GLD,112.3709527868289,112.93280755076304,110.6127543920796,111.16859737897448,111.16859737897448,1000000.0 +2020-12-02,GLD,111.16859737897448,111.72444036586931,110.291200437797,110.84542757567536,110.84542757567536,1000000.0 +2020-12-03,GLD,110.84542757567536,111.39965471355374,110.13931260313346,110.69277648556128,110.69277648556128,1000000.0 +2020-12-04,GLD,110.69277648556128,111.24624036798906,110.03139921560415,110.58432081970268,110.58432081970268,1000000.0 +2020-12-07,GLD,110.58432081970268,112.32962652768636,110.03139921560415,111.77077266436454,111.77077266436454,1000000.0 +2020-12-08,GLD,111.77077266436454,112.8697919770044,111.21191880104271,112.30825072338747,112.30825072338747,1000000.0 +2020-12-09,GLD,112.30825072338747,113.9691614360042,111.74670946977056,113.40215068259128,113.40215068259128,1000000.0 +2020-12-10,GLD,113.40215068259128,114.63439393511447,112.83513992917833,114.0640735672781,114.0640735672781,1000000.0 2020-12-11,GLD,114.0640735672781,114.63439393511447,113.34913742268948,113.91873107807989,113.91873107807989,1000000.0 -2020-12-14,GLD,113.91873107807989,115.58705109808844,113.34913742268948,115.01199114237657,115.01199114237657,1000000.0 -2020-12-15,GLD,115.01199114237657,115.58705109808844,114.19364563915178,114.7674830544239,114.7674830544239,1000000.0 -2020-12-16,GLD,114.7674830544239,115.62519087277329,114.19364563915178,115.04994116693861,115.04994116693861,1000000.0 -2020-12-17,GLD,115.04994116693861,115.62519087277329,113.47326510900076,114.04348252160881,114.04348252160881,1000000.0 -2020-12-18,GLD,114.04348252160881,114.74473289437375,113.47326510900076,114.17386357649131,114.17386357649131,1000000.0 +2020-12-14,GLD,113.91873107807989,115.58705109808844,113.34913742268948,115.01199114237656,115.01199114237656,1000000.0 +2020-12-15,GLD,115.01199114237656,115.58705109808844,114.19364563915178,114.7674830544239,114.7674830544239,1000000.0 +2020-12-16,GLD,114.7674830544239,115.62519087277327,114.19364563915178,115.0499411669386,115.0499411669386,1000000.0 +2020-12-17,GLD,115.0499411669386,115.62519087277327,113.47326510900076,114.0434825216088,114.0434825216088,1000000.0 +2020-12-18,GLD,114.0434825216088,114.74473289437375,113.47326510900076,114.17386357649131,114.17386357649131,1000000.0 2020-12-21,GLD,114.17386357649131,114.74473289437375,113.298446844079,113.86778577294372,113.86778577294372,1000000.0 2020-12-22,GLD,113.86778577294372,114.8486666983996,113.298446844079,114.27728029691504,114.27728029691504,1000000.0 -2020-12-23,GLD,114.27728029691504,116.77077009817988,113.70589389543046,116.18982099321383,116.18982099321383,1000000.0 -2020-12-24,GLD,116.18982099321383,119.3201012068641,115.60887188824776,118.72646886255136,118.72646886255136,1000000.0 -2020-12-25,GLD,118.72646886255136,120.45071940853629,118.1328365182386,119.85146209804607,119.85146209804607,1000000.0 -2020-12-28,GLD,119.85146209804607,120.45095786795208,119.25220478755584,119.85169937109661,119.85169937109661,1000000.0 -2020-12-29,GLD,119.85169937109661,121.38196378366777,119.25244087424113,120.77807341658486,120.77807341658486,1000000.0 -2020-12-30,GLD,120.77807341658486,121.66611362901602,120.17418304950193,121.06080958111049,121.06080958111049,1000000.0 -2020-12-31,GLD,121.06080958111049,121.66611362901602,119.83140847412075,120.43357635590026,120.43357635590026,1000000.0 -2021-01-01,GLD,120.43357635590026,121.03574423767975,119.79669313987188,120.39868657273556,120.39868657273556,1000000.0 -2021-01-04,GLD,120.39868657273556,121.00068000559922,119.56053866977646,120.16134539676027,120.16134539676027,1000000.0 -2021-01-05,GLD,120.16134539676027,120.76215212374406,118.94529727220878,119.54301233387817,119.54301233387817,1000000.0 -2021-01-06,GLD,119.54301233387817,121.67696678088436,118.94529727220878,121.07160873719837,121.07160873719837,1000000.0 -2021-01-07,GLD,121.07160873719837,121.67696678088436,119.91560315460609,120.51819412523226,120.51819412523226,1000000.0 -2021-01-08,GLD,120.51819412523226,121.12078509585841,119.41409976218408,120.01417061526038,120.01417061526038,1000000.0 +2020-12-23,GLD,114.27728029691504,116.77077009817988,113.70589389543046,116.18982099321384,116.18982099321384,1000000.0 +2020-12-24,GLD,116.18982099321384,119.3201012068641,115.60887188824776,118.72646886255136,118.72646886255136,1000000.0 +2020-12-28,GLD,119.85146209804608,120.45095786795208,119.25220478755584,119.8516993710966,119.8516993710966,1000000.0 +2020-12-29,GLD,119.8516993710966,121.38196378366776,119.25244087424112,120.77807341658486,120.77807341658486,1000000.0 +2020-12-30,GLD,120.77807341658486,121.66611362901602,120.17418304950192,121.06080958111048,121.06080958111048,1000000.0 +2020-12-31,GLD,121.06080958111048,121.66611362901602,119.83140847412076,120.43357635590026,120.43357635590026,1000000.0 +2021-01-04,GLD,120.39868657273556,121.00068000559922,119.56053866977646,120.16134539676028,120.16134539676028,1000000.0 +2021-01-05,GLD,120.16134539676028,120.76215212374406,118.94529727220878,119.54301233387817,119.54301233387817,1000000.0 +2021-01-06,GLD,119.54301233387817,121.67696678088436,118.94529727220878,121.07160873719836,121.07160873719836,1000000.0 +2021-01-07,GLD,121.07160873719836,121.67696678088436,119.91560315460607,120.51819412523226,120.51819412523226,1000000.0 +2021-01-08,GLD,120.51819412523226,121.1207850958584,119.41409976218408,120.01417061526038,120.01417061526038,1000000.0 2021-01-11,GLD,120.01417061526038,121.41482749193877,119.41409976218408,120.81077362381968,120.81077362381968,1000000.0 2021-01-12,GLD,120.81077362381968,121.41482749193877,119.49176612153428,120.0922272578234,120.0922272578234,1000000.0 2021-01-13,GLD,120.0922272578234,120.6926883941125,118.29535540061936,118.88980442273302,118.88980442273302,1000000.0 -2021-01-14,GLD,118.88980442273302,119.48425344484667,117.4263335830475,118.01641566135427,118.01641566135427,1000000.0 -2021-01-15,GLD,118.01641566135427,119.1566350765195,117.4263335830475,118.56381599653683,118.56381599653683,1000000.0 -2021-01-18,GLD,118.56381599653683,119.59591671238897,117.97099691655414,119.00091215163083,119.00091215163083,1000000.0 -2021-01-19,GLD,119.00091215163083,119.95360523093991,118.40590759087267,119.35682112531336,119.35682112531336,1000000.0 -2021-01-20,GLD,119.35682112531336,119.95360523093991,118.29992431559839,118.8943962970838,118.8943962970838,1000000.0 -2021-01-21,GLD,118.8943962970838,119.48886827856921,117.36675220426346,117.95653487865675,117.95653487865675,1000000.0 +2021-01-14,GLD,118.88980442273302,119.48425344484669,117.4263335830475,118.01641566135427,118.01641566135427,1000000.0 +2021-01-15,GLD,118.01641566135427,119.1566350765195,117.4263335830475,118.56381599653685,118.56381599653685,1000000.0 +2021-01-19,GLD,119.00091215163084,119.95360523093991,118.40590759087269,119.35682112531336,119.35682112531336,1000000.0 +2021-01-20,GLD,119.35682112531336,119.95360523093991,118.2999243155984,118.8943962970838,118.8943962970838,1000000.0 +2021-01-21,GLD,118.8943962970838,119.4888682785692,117.36675220426346,117.95653487865675,117.95653487865675,1000000.0 2021-01-22,GLD,117.95653487865675,119.55423952656528,117.36675220426346,118.95944231499034,118.95944231499034,1000000.0 -2021-01-25,GLD,118.95944231499034,119.55423952656528,116.87004510069099,117.45733175948843,117.45733175948843,1000000.0 -2021-01-26,GLD,117.45733175948843,118.04461841828585,116.6830703691414,117.26941745642353,117.26941745642353,1000000.0 -2021-01-27,GLD,117.26941745642353,117.85576454370563,115.33560714475671,115.91518306005699,115.91518306005699,1000000.0 -2021-01-28,GLD,115.91518306005699,116.49475897535726,114.95142222595504,115.5290675637739,115.5290675637739,1000000.0 +2021-01-25,GLD,118.95944231499034,119.55423952656528,116.870045100691,117.45733175948844,117.45733175948844,1000000.0 +2021-01-26,GLD,117.45733175948844,118.04461841828584,116.6830703691414,117.26941745642353,117.26941745642353,1000000.0 +2021-01-27,GLD,117.26941745642353,117.85576454370565,115.33560714475672,115.915183060057,115.915183060057,1000000.0 +2021-01-28,GLD,115.915183060057,116.49475897535726,114.95142222595504,115.5290675637739,115.5290675637739,1000000.0 2021-01-29,GLD,115.5290675637739,116.10671290159276,114.83064078354045,115.40767917943764,115.40767917943764,1000000.0 -2021-02-01,GLD,115.40767917943764,117.31210055329683,114.83064078354045,116.7284582619869,116.7284582619869,1000000.0 -2021-02-02,GLD,116.7284582619869,117.31210055329683,113.22325425977913,113.79221533646144,113.79221533646144,1000000.0 -2021-02-03,GLD,113.79221533646144,115.815803413904,113.22325425977913,115.23960538696917,115.23960538696917,1000000.0 -2021-02-04,GLD,115.23960538696917,115.815803413904,113.7938000427738,114.36562818369225,114.36562818369225,1000000.0 -2021-02-05,GLD,114.36562818369225,115.28452865825763,113.7938000427738,114.71097378931108,114.71097378931108,1000000.0 +2021-02-01,GLD,115.40767917943764,117.31210055329684,114.83064078354045,116.7284582619869,116.7284582619869,1000000.0 +2021-02-02,GLD,116.7284582619869,117.31210055329684,113.22325425977913,113.79221533646144,113.79221533646144,1000000.0 +2021-02-03,GLD,113.79221533646144,115.815803413904,113.22325425977913,115.23960538696916,115.23960538696916,1000000.0 +2021-02-04,GLD,115.23960538696916,115.815803413904,113.7938000427738,114.36562818369224,114.36562818369224,1000000.0 +2021-02-05,GLD,114.36562818369224,115.28452865825764,113.7938000427738,114.71097378931108,114.71097378931108,1000000.0 2021-02-08,GLD,114.71097378931108,116.16629766352294,114.13741892036452,115.58835588410244,115.58835588410244,1000000.0 -2021-02-09,GLD,115.58835588410244,116.16629766352294,114.26470197712729,114.8388964594244,114.8388964594244,1000000.0 +2021-02-09,GLD,115.58835588410244,116.16629766352294,114.26470197712727,114.8388964594244,114.8388964594244,1000000.0 2021-02-10,GLD,114.8388964594244,115.41309094172152,114.21827235955136,114.7922335271873,114.7922335271873,1000000.0 2021-02-11,GLD,114.7922335271873,115.9247843785846,114.21827235955136,115.34804415779564,115.34804415779564,1000000.0 -2021-02-12,GLD,115.34804415779564,115.9247843785846,114.68949554347672,115.26582466681077,115.26582466681077,1000000.0 -2021-02-15,GLD,115.26582466681077,115.84215379014482,113.97836836369801,114.5511239836161,114.5511239836161,1000000.0 -2021-02-16,GLD,114.5511239836161,115.3676228303683,113.97836836369801,114.79365455758041,114.79365455758041,1000000.0 -2021-02-17,GLD,114.79365455758041,115.53071467826048,114.2196862847925,114.95593500324428,114.95593500324428,1000000.0 -2021-02-18,GLD,114.95593500324428,115.53071467826048,112.89749810808013,113.46482221917601,113.46482221917601,1000000.0 -2021-02-19,GLD,113.46482221917601,115.48639540607512,112.89749810808013,114.91183622495038,114.91183622495038,1000000.0 -2021-02-22,GLD,114.91183622495038,115.77399412988089,114.33727704382562,115.19800410933424,115.19800410933424,1000000.0 -2021-02-23,GLD,115.19800410933424,115.77399412988089,112.20211012899317,112.76593982813384,112.76593982813384,1000000.0 +2021-02-12,GLD,115.34804415779564,115.9247843785846,114.68949554347672,115.26582466681076,115.26582466681076,1000000.0 +2021-02-16,GLD,114.5511239836161,115.3676228303683,113.978368363698,114.7936545575804,114.7936545575804,1000000.0 +2021-02-17,GLD,114.7936545575804,115.53071467826048,114.2196862847925,114.95593500324428,114.95593500324428,1000000.0 +2021-02-18,GLD,114.95593500324428,115.53071467826048,112.89749810808011,113.464822219176,113.464822219176,1000000.0 +2021-02-19,GLD,113.464822219176,115.48639540607512,112.89749810808011,114.91183622495038,114.91183622495038,1000000.0 +2021-02-22,GLD,114.91183622495038,115.77399412988088,114.33727704382562,115.19800410933424,115.19800410933424,1000000.0 +2021-02-23,GLD,115.19800410933424,115.77399412988088,112.20211012899316,112.76593982813384,112.76593982813384,1000000.0 2021-02-24,GLD,112.76593982813384,113.3297695272745,111.41898360112472,111.97887799108013,111.97887799108013,1000000.0 2021-02-25,GLD,111.97887799108013,114.34914409765966,111.41898360112472,113.78024288324346,113.78024288324346,1000000.0 -2021-02-26,GLD,113.78024288324346,116.05144869316979,113.21134166882725,115.47407830166149,115.47407830166149,1000000.0 -2021-03-01,GLD,115.47407830166149,116.39172527458143,114.89670791015318,115.81266196475765,115.81266196475765,1000000.0 -2021-03-02,GLD,115.81266196475765,116.39172527458143,113.55613706740084,114.12677092201089,114.12677092201089,1000000.0 -2021-03-03,GLD,114.12677092201089,114.69740477662093,113.37078600750948,113.94048844975828,113.94048844975828,1000000.0 -2021-03-04,GLD,113.94048844975828,115.66649872898995,113.37078600750948,115.0910435114328,115.0910435114328,1000000.0 +2021-02-26,GLD,113.78024288324346,116.0514486931698,113.21134166882725,115.47407830166148,115.47407830166148,1000000.0 +2021-03-01,GLD,115.47407830166148,116.39172527458145,114.89670791015318,115.81266196475764,115.81266196475764,1000000.0 +2021-03-02,GLD,115.81266196475764,116.39172527458145,113.55613706740084,114.12677092201088,114.12677092201088,1000000.0 +2021-03-03,GLD,114.12677092201088,114.69740477662091,113.37078600750948,113.94048844975828,113.94048844975828,1000000.0 +2021-03-04,GLD,113.94048844975828,115.66649872898996,113.37078600750948,115.0910435114328,115.0910435114328,1000000.0 2021-03-05,GLD,115.0910435114328,116.13742325392067,114.51558829387564,115.5596251282793,115.5596251282793,1000000.0 -2021-03-08,GLD,115.5596251282793,116.13742325392067,114.69791334053285,115.27428476435462,115.27428476435462,1000000.0 -2021-03-09,GLD,115.27428476435462,115.85065618817637,114.00075441112543,114.57362252374415,114.57362252374415,1000000.0 -2021-03-10,GLD,114.57362252374415,116.06769780143833,114.00075441112543,115.49024656859537,115.49024656859537,1000000.0 -2021-03-11,GLD,115.49024656859537,116.06769780143833,114.75588891725486,115.33255167563303,115.33255167563303,1000000.0 -2021-03-12,GLD,115.33255167563303,116.31137358011897,114.75588891725486,115.73271002996914,115.73271002996914,1000000.0 -2021-03-15,GLD,115.73271002996914,116.89274830711625,115.1540464798193,116.31119234538932,116.31119234538932,1000000.0 +2021-03-08,GLD,115.5596251282793,116.13742325392067,114.69791334053284,115.27428476435462,115.27428476435462,1000000.0 +2021-03-09,GLD,115.27428476435462,115.85065618817636,114.00075441112544,114.57362252374416,114.57362252374416,1000000.0 +2021-03-10,GLD,114.57362252374416,116.06769780143831,114.00075441112544,115.49024656859535,115.49024656859535,1000000.0 +2021-03-11,GLD,115.49024656859535,116.06769780143831,114.75588891725486,115.33255167563304,115.33255167563304,1000000.0 +2021-03-12,GLD,115.33255167563304,116.31137358011895,114.75588891725486,115.73271002996914,115.73271002996914,1000000.0 +2021-03-15,GLD,115.73271002996914,116.89274830711624,115.1540464798193,116.31119234538932,116.31119234538932,1000000.0 2021-03-16,GLD,116.31119234538932,118.69752833316495,115.72963638366237,118.1069933663333,118.1069933663333,1000000.0 -2021-03-17,GLD,118.1069933663333,118.96118167361479,117.51645839950163,118.3693349986217,118.3693349986217,1000000.0 -2021-03-18,GLD,118.3693349986217,119.3067456918466,117.77748832362859,118.7131797928822,118.7131797928822,1000000.0 +2021-03-17,GLD,118.1069933663333,118.9611816736148,117.51645839950164,118.3693349986217,118.3693349986217,1000000.0 +2021-03-18,GLD,118.3693349986217,119.3067456918466,117.7774883236286,118.7131797928822,118.7131797928822,1000000.0 2021-03-19,GLD,118.7131797928822,119.3067456918466,116.30071353409016,116.88513923024138,116.88513923024138,1000000.0 -2021-03-22,GLD,116.88513923024138,118.71937416587899,116.30071353409016,118.12873051331243,118.12873051331243,1000000.0 -2021-03-23,GLD,118.12873051331243,119.74185325865041,117.53808686074586,119.1461226454233,119.1461226454233,1000000.0 -2021-03-24,GLD,119.1461226454233,119.79528017823816,118.55039203219619,119.19928375944096,119.19928375944096,1000000.0 -2021-03-25,GLD,119.19928375944096,119.79528017823816,116.26856749383735,116.85283165209785,116.85283165209785,1000000.0 -2021-03-26,GLD,116.85283165209785,117.43709581035833,115.74650501807675,116.3281457468108,116.3281457468108,1000000.0 -2021-03-29,GLD,116.3281457468108,117.05643191832553,115.74650501807675,116.47406161027418,116.47406161027418,1000000.0 -2021-03-30,GLD,116.47406161027418,119.35661183447274,115.89169130222281,118.76279784524652,118.76279784524652,1000000.0 -2021-03-31,GLD,118.76279784524652,120.06147497830658,118.16898385602029,119.46415420727024,119.46415420727024,1000000.0 -2021-04-01,GLD,119.46415420727024,120.99158300901486,118.86683343623389,120.38963483484066,120.38963483484066,1000000.0 -2021-04-02,GLD,120.38963483484066,121.9579914069105,119.78768666066647,121.35123523075673,121.35123523075673,1000000.0 -2021-04-05,GLD,121.35123523075673,123.5033430720464,120.74447905460295,122.88889857915066,122.88889857915066,1000000.0 +2021-03-22,GLD,116.88513923024138,118.719374165879,116.30071353409016,118.12873051331243,118.12873051331243,1000000.0 +2021-03-23,GLD,118.12873051331243,119.7418532586504,117.53808686074586,119.1461226454233,119.1461226454233,1000000.0 +2021-03-24,GLD,119.1461226454233,119.79528017823816,118.5503920321962,119.19928375944096,119.19928375944096,1000000.0 +2021-03-25,GLD,119.19928375944096,119.79528017823816,116.26856749383737,116.85283165209783,116.85283165209783,1000000.0 +2021-03-26,GLD,116.85283165209783,117.43709581035831,115.74650501807676,116.3281457468108,116.3281457468108,1000000.0 +2021-03-29,GLD,116.3281457468108,117.05643191832552,115.74650501807676,116.47406161027418,116.47406161027418,1000000.0 +2021-03-30,GLD,116.47406161027418,119.35661183447274,115.8916913022228,118.76279784524652,118.76279784524652,1000000.0 +2021-03-31,GLD,118.76279784524652,120.06147497830658,118.16898385602028,119.46415420727024,119.46415420727024,1000000.0 +2021-04-01,GLD,119.46415420727024,120.99158300901486,118.86683343623388,120.38963483484066,120.38963483484066,1000000.0 +2021-04-05,GLD,121.35123523075671,123.5033430720464,120.74447905460296,122.88889857915066,122.88889857915066,1000000.0 2021-04-06,GLD,122.88889857915066,123.5033430720464,121.09885337604176,121.70739032768016,121.70739032768016,1000000.0 2021-04-07,GLD,121.70739032768016,122.31592727931854,120.24031661651276,120.84453931307814,120.84453931307814,1000000.0 -2021-04-08,GLD,120.84453931307814,121.44876200964352,119.03307244831849,119.63122859127486,119.63122859127486,1000000.0 +2021-04-08,GLD,120.84453931307814,121.44876200964352,119.03307244831848,119.63122859127486,119.63122859127486,1000000.0 2021-04-09,GLD,119.63122859127486,120.22938473423122,118.79233358967194,119.38927998962004,119.38927998962004,1000000.0 -2021-04-12,GLD,119.38927998962004,119.98622638956812,118.47697507533182,119.07233675912747,119.07233675912747,1000000.0 -2021-04-13,GLD,119.07233675912747,119.8410448911515,118.47697507533182,119.24482078721543,119.24482078721543,1000000.0 -2021-04-14,GLD,119.24482078721543,119.8410448911515,117.6183400784695,118.20938701353718,118.20938701353718,1000000.0 +2021-04-12,GLD,119.38927998962004,119.98622638956812,118.47697507533182,119.07233675912748,119.07233675912748,1000000.0 +2021-04-13,GLD,119.07233675912748,119.8410448911515,118.47697507533182,119.24482078721545,119.24482078721545,1000000.0 +2021-04-14,GLD,119.24482078721545,119.8410448911515,117.6183400784695,118.20938701353718,118.20938701353718,1000000.0 2021-04-15,GLD,118.20938701353718,118.96043428116526,117.6183400784695,118.36859132454256,118.36859132454256,1000000.0 -2021-04-16,GLD,118.36859132454256,118.96043428116526,116.93306673282565,117.52067008324187,117.52067008324187,1000000.0 -2021-04-19,GLD,117.52067008324187,120.44098623357208,116.93306673282565,119.8417773468379,119.8417773468379,1000000.0 +2021-04-16,GLD,118.36859132454256,118.96043428116526,116.93306673282564,117.52067008324188,117.52067008324188,1000000.0 +2021-04-19,GLD,117.52067008324188,120.44098623357208,116.93306673282564,119.8417773468379,119.8417773468379,1000000.0 2021-04-20,GLD,119.8417773468379,120.44098623357208,118.73395669332804,119.33060974203823,119.33060974203823,1000000.0 -2021-04-21,GLD,119.33060974203823,122.77455536069643,118.73395669332804,122.16373667730988,122.16373667730988,1000000.0 -2021-04-22,GLD,122.16373667730988,124.65725273347442,121.55291799392333,124.03706739649198,124.03706739649198,1000000.0 +2021-04-21,GLD,119.33060974203823,122.77455536069644,118.73395669332804,122.16373667730988,122.16373667730988,1000000.0 +2021-04-22,GLD,122.16373667730988,124.65725273347442,121.55291799392332,124.03706739649198,124.03706739649198,1000000.0 2021-04-23,GLD,124.03706739649198,124.65725273347442,122.15150445104238,122.76533110657526,122.76533110657526,1000000.0 -2021-04-26,GLD,122.76533110657526,124.50867479126545,122.15150445104238,123.88922864802534,123.88922864802534,1000000.0 -2021-04-27,GLD,123.88922864802534,125.25235366089983,123.26978250478521,124.62920762278591,124.62920762278591,1000000.0 -2021-04-28,GLD,124.62920762278591,125.25235366089983,122.3607873212865,122.97566564953416,122.97566564953416,1000000.0 -2021-04-29,GLD,122.97566564953416,124.3293437298729,122.3607873212865,123.71078978096807,123.71078978096807,1000000.0 -2021-04-30,GLD,123.71078978096807,124.3293437298729,123.04991026457162,123.66825152218253,123.66825152218253,1000000.0 -2021-05-03,GLD,123.66825152218253,124.73615060501012,123.04991026457162,124.11557274130362,124.11557274130362,1000000.0 -2021-05-04,GLD,124.11557274130362,124.73615060501012,122.60646078591716,123.22257365418811,123.22257365418811,1000000.0 -2021-05-05,GLD,123.22257365418811,123.83868652245904,122.59517250788664,123.21122865114236,123.21122865114236,1000000.0 -2021-05-06,GLD,123.21122865114236,124.34626078270958,122.59517250788664,123.72762266936277,123.72762266936277,1000000.0 -2021-05-07,GLD,123.72762266936277,124.34626078270958,122.9640877145058,123.5819977030209,123.5819977030209,1000000.0 -2021-05-10,GLD,123.5819977030209,124.199907691536,122.34796260737629,122.96277648982542,122.96277648982542,1000000.0 -2021-05-11,GLD,122.96277648982542,123.57759037227453,122.22915981971437,122.84337670323052,122.84337670323052,1000000.0 -2021-05-12,GLD,122.84337670323052,123.45759358674665,121.20910124914204,121.818192210193,121.818192210193,1000000.0 +2021-04-26,GLD,122.76533110657526,124.50867479126543,122.15150445104238,123.88922864802534,123.88922864802534,1000000.0 +2021-04-27,GLD,123.88922864802534,125.25235366089984,123.2697825047852,124.62920762278593,124.62920762278593,1000000.0 +2021-04-28,GLD,124.62920762278593,125.25235366089984,122.3607873212865,122.97566564953416,122.97566564953416,1000000.0 +2021-04-29,GLD,122.97566564953416,124.3293437298729,122.3607873212865,123.71078978096808,123.71078978096808,1000000.0 +2021-04-30,GLD,123.71078978096808,124.3293437298729,123.04991026457162,123.66825152218252,123.66825152218252,1000000.0 +2021-05-03,GLD,123.66825152218252,124.73615060501012,123.04991026457162,124.11557274130362,124.11557274130362,1000000.0 +2021-05-04,GLD,124.11557274130362,124.73615060501012,122.60646078591716,123.22257365418812,123.22257365418812,1000000.0 +2021-05-05,GLD,123.22257365418812,123.83868652245904,122.59517250788664,123.21122865114236,123.21122865114236,1000000.0 +2021-05-06,GLD,123.21122865114236,124.34626078270958,122.59517250788664,123.72762266936276,123.72762266936276,1000000.0 +2021-05-07,GLD,123.72762266936276,124.34626078270958,122.9640877145058,123.5819977030209,123.5819977030209,1000000.0 +2021-05-10,GLD,123.5819977030209,124.199907691536,122.34796260737627,122.96277648982542,122.96277648982542,1000000.0 +2021-05-11,GLD,122.96277648982542,123.57759037227451,122.22915981971435,122.84337670323052,122.84337670323052,1000000.0 +2021-05-12,GLD,122.84337670323052,123.45759358674664,121.20910124914204,121.818192210193,121.818192210193,1000000.0 2021-05-13,GLD,121.818192210193,122.86069104235426,121.20910124914204,122.24944382323808,122.24944382323808,1000000.0 -2021-05-14,GLD,122.24944382323808,122.86069104235426,118.91676951330022,119.51434121939721,119.51434121939721,1000000.0 -2021-05-17,GLD,119.51434121939721,121.80907312083431,118.91676951330022,121.20305783167595,121.20305783167595,1000000.0 -2021-05-18,GLD,121.20305783167595,121.93296703240448,120.59704254251757,121.32633535562636,121.32633535562636,1000000.0 -2021-05-19,GLD,121.32633535562636,122.50912736042932,120.71970367884822,121.89962921435755,121.89962921435755,1000000.0 -2021-05-20,GLD,121.89962921435755,123.5107960330297,121.29013106828576,122.89631446072609,122.89631446072609,1000000.0 -2021-05-21,GLD,122.89631446072609,123.5107960330297,120.97319721714587,121.58110273079987,121.58110273079987,1000000.0 -2021-05-24,GLD,121.58110273079987,124.77817670158193,120.97319721714587,124.15738975281785,124.15738975281785,1000000.0 -2021-05-25,GLD,124.15738975281785,124.77817670158193,122.3493253759758,122.96414610650834,122.96414610650834,1000000.0 +2021-05-14,GLD,122.24944382323808,122.86069104235426,118.91676951330022,119.5143412193972,119.5143412193972,1000000.0 +2021-05-17,GLD,119.5143412193972,121.80907312083431,118.91676951330022,121.20305783167596,121.20305783167596,1000000.0 +2021-05-18,GLD,121.20305783167596,121.93296703240448,120.59704254251756,121.32633535562636,121.32633535562636,1000000.0 +2021-05-19,GLD,121.32633535562636,122.50912736042932,120.71970367884822,121.89962921435756,121.89962921435756,1000000.0 +2021-05-20,GLD,121.89962921435756,123.5107960330297,121.29013106828576,122.89631446072607,122.89631446072607,1000000.0 +2021-05-21,GLD,122.89631446072607,123.5107960330297,120.97319721714588,121.58110273079988,121.58110273079988,1000000.0 +2021-05-24,GLD,121.58110273079988,124.77817670158193,120.97319721714588,124.15738975281783,124.15738975281783,1000000.0 +2021-05-25,GLD,124.15738975281783,124.77817670158193,122.3493253759758,122.96414610650834,122.96414610650834,1000000.0 2021-05-26,GLD,122.96414610650834,124.1545473248,122.3493253759758,123.53686300975126,123.53686300975126,1000000.0 2021-05-27,GLD,123.53686300975126,124.57323872268088,122.9191786947025,123.95347136585164,123.95347136585164,1000000.0 -2021-05-28,GLD,123.95347136585164,126.11087158735029,123.33370400902238,125.48345431577144,125.48345431577144,1000000.0 -2021-05-31,GLD,125.48345431577144,126.15389752528827,124.85603704419258,125.5262661943167,125.5262661943167,1000000.0 -2021-06-01,GLD,125.5262661943167,126.15389752528827,123.37241149022287,123.9923733570079,123.9923733570079,1000000.0 +2021-05-28,GLD,123.95347136585164,126.11087158735027,123.33370400902238,125.48345431577144,125.48345431577144,1000000.0 +2021-06-01,GLD,125.5262661943167,126.15389752528829,123.37241149022287,123.9923733570079,123.9923733570079,1000000.0 2021-06-02,GLD,123.9923733570079,125.02917310415828,123.37241149022287,124.40713741707292,124.40713741707292,1000000.0 -2021-06-03,GLD,124.40713741707292,125.02917310415828,121.11234022935807,121.72094495412871,121.72094495412871,1000000.0 -2021-06-04,GLD,121.72094495412871,122.32954967889934,120.94157345234737,121.54932005261043,121.54932005261043,1000000.0 -2021-06-07,GLD,121.54932005261043,122.15706665287347,120.48825689224091,121.0937255198401,121.0937255198401,1000000.0 -2021-06-08,GLD,121.0937255198401,123.47086491908709,120.48825689224091,122.8565820090419,122.8565820090419,1000000.0 -2021-06-09,GLD,122.8565820090419,123.47086491908709,121.98004871370232,122.5930137826154,122.5930137826154,1000000.0 -2021-06-10,GLD,122.5930137826154,123.20597885152847,121.53074681591376,122.14145408634549,122.14145408634549,1000000.0 -2021-06-11,GLD,122.14145408634549,122.75216135677721,120.74767630786098,121.35444855061405,121.35444855061405,1000000.0 -2021-06-14,GLD,121.35444855061405,121.9612207933671,119.86206180562182,120.46438372424304,120.46438372424304,1000000.0 -2021-06-15,GLD,120.46438372424304,121.06670564286424,119.30664649752619,119.90617738444843,119.90617738444843,1000000.0 -2021-06-16,GLD,119.90617738444843,121.19930417176963,119.30664649752619,120.59632255897476,120.59632255897476,1000000.0 -2021-06-17,GLD,120.59632255897476,121.19930417176963,118.61430567815367,119.21035746548108,119.21035746548108,1000000.0 -2021-06-18,GLD,119.21035746548108,119.80640925280848,118.42881455473123,119.02393422586053,119.02393422586053,1000000.0 -2021-06-21,GLD,119.02393422586053,119.61905389698983,116.91287328067217,117.50037515645444,117.50037515645444,1000000.0 +2021-06-03,GLD,124.40713741707292,125.02917310415828,121.11234022935808,121.72094495412873,121.72094495412873,1000000.0 +2021-06-04,GLD,121.72094495412873,122.32954967889934,120.94157345234736,121.54932005261044,121.54932005261044,1000000.0 +2021-06-07,GLD,121.54932005261044,122.15706665287348,120.48825689224093,121.0937255198401,121.0937255198401,1000000.0 +2021-06-08,GLD,121.0937255198401,123.47086491908708,120.48825689224093,122.8565820090419,122.8565820090419,1000000.0 +2021-06-09,GLD,122.8565820090419,123.47086491908708,121.98004871370232,122.5930137826154,122.5930137826154,1000000.0 +2021-06-10,GLD,122.5930137826154,123.20597885152849,121.53074681591376,122.14145408634548,122.14145408634548,1000000.0 +2021-06-11,GLD,122.14145408634548,122.7521613567772,120.74767630786098,121.35444855061404,121.35444855061404,1000000.0 +2021-06-14,GLD,121.35444855061404,121.9612207933671,119.86206180562182,120.46438372424304,120.46438372424304,1000000.0 +2021-06-15,GLD,120.46438372424304,121.06670564286424,119.3066464975262,119.90617738444844,119.90617738444844,1000000.0 +2021-06-16,GLD,119.90617738444844,121.19930417176964,119.3066464975262,120.59632255897476,120.59632255897476,1000000.0 +2021-06-17,GLD,120.59632255897476,121.19930417176964,118.61430567815368,119.21035746548108,119.21035746548108,1000000.0 +2021-06-18,GLD,119.21035746548108,119.80640925280848,118.42881455473125,119.02393422586051,119.02393422586051,1000000.0 +2021-06-21,GLD,119.02393422586051,119.61905389698984,116.91287328067216,117.50037515645444,117.50037515645444,1000000.0 2021-06-22,GLD,117.50037515645444,118.0878770322367,116.77252319796234,117.35931979694708,117.35931979694708,1000000.0 -2021-06-23,GLD,117.35931979694708,118.15474708463843,116.77252319796234,117.5669125220283,117.5669125220283,1000000.0 -2021-06-24,GLD,117.5669125220283,118.15474708463843,115.71801392495789,116.29951148236974,116.29951148236974,1000000.0 -2021-06-25,GLD,116.29951148236974,116.88100903978157,115.15346462036113,115.73212524659411,115.73212524659411,1000000.0 -2021-06-28,GLD,115.73212524659411,116.45103784299548,115.15346462036113,115.87167944576666,115.87167944576666,1000000.0 -2021-06-29,GLD,115.87167944576666,117.292977634473,115.29232104853783,116.7094304820627,116.7094304820627,1000000.0 -2021-06-30,GLD,116.7094304820627,118.33243061598888,116.12588332965238,117.74371205571035,117.74371205571035,1000000.0 -2021-07-01,GLD,117.74371205571035,118.33243061598888,117.06433601546759,117.65259901052019,117.65259901052019,1000000.0 -2021-07-02,GLD,117.65259901052019,118.24086200557278,116.03958081179944,116.62269428321552,116.62269428321552,1000000.0 -2021-07-05,GLD,116.62269428321552,117.20580775463159,114.37562193301444,114.95037380202457,114.95037380202457,1000000.0 -2021-07-06,GLD,114.95037380202457,116.36470937064183,114.37562193301444,115.78578046830035,115.78578046830035,1000000.0 -2021-07-07,GLD,115.78578046830035,117.86264802512459,115.20685156595884,117.27626669166627,117.27626669166627,1000000.0 -2021-07-08,GLD,117.27626669166627,118.2762022722726,116.68988535820793,117.68776345499762,117.68776345499762,1000000.0 -2021-07-09,GLD,117.68776345499762,119.98222652200003,117.09932463772263,119.38530002189059,119.38530002189059,1000000.0 -2021-07-12,GLD,119.38530002189059,119.98222652200003,118.3264332424474,118.9210384346205,118.9210384346205,1000000.0 -2021-07-13,GLD,118.9210384346205,119.51564362679359,117.93174644328772,118.52436828471127,118.52436828471127,1000000.0 -2021-07-14,GLD,118.52436828471127,120.84026571415423,117.93174644328772,120.23907036234253,120.23907036234253,1000000.0 -2021-07-15,GLD,120.23907036234253,120.84026571415423,118.57603304986831,119.17189251243046,119.17189251243046,1000000.0 -2021-07-16,GLD,119.17189251243046,120.44642848657803,118.57603304986831,119.84719252395826,119.84719252395826,1000000.0 -2021-07-19,GLD,119.84719252395826,120.89013002064306,119.24795656133847,120.28868658770455,120.28868658770455,1000000.0 -2021-07-20,GLD,120.28868658770455,121.08082062364723,119.68724315476602,120.47842848124104,120.47842848124104,1000000.0 -2021-07-21,GLD,120.47842848124104,123.01248011643582,119.87603633883484,122.40047772779685,122.40047772779685,1000000.0 -2021-07-22,GLD,122.40047772779685,123.01248011643582,120.84867516285622,121.45595493754395,121.45595493754395,1000000.0 -2021-07-23,GLD,121.45595493754395,122.06323471223166,120.40510041493737,121.01015117079132,121.01015117079132,1000000.0 -2021-07-26,GLD,121.01015117079132,121.61520192664526,118.17560861023361,118.76945588968202,118.76945588968202,1000000.0 +2021-06-23,GLD,117.35931979694708,118.15474708463844,116.77252319796234,117.5669125220283,117.5669125220283,1000000.0 +2021-06-24,GLD,117.5669125220283,118.15474708463844,115.71801392495787,116.29951148236974,116.29951148236974,1000000.0 +2021-06-25,GLD,116.29951148236974,116.88100903978156,115.15346462036112,115.73212524659412,115.73212524659412,1000000.0 +2021-06-28,GLD,115.73212524659412,116.45103784299548,115.15346462036112,115.87167944576666,115.87167944576666,1000000.0 +2021-06-29,GLD,115.87167944576666,117.292977634473,115.29232104853784,116.7094304820627,116.7094304820627,1000000.0 +2021-06-30,GLD,116.7094304820627,118.33243061598888,116.12588332965238,117.74371205571036,117.74371205571036,1000000.0 +2021-07-01,GLD,117.74371205571036,118.33243061598888,117.0643360154676,117.6525990105202,117.6525990105202,1000000.0 +2021-07-02,GLD,117.6525990105202,118.24086200557278,116.03958081179944,116.62269428321552,116.62269428321552,1000000.0 +2021-07-06,GLD,114.95037380202456,116.36470937064183,114.37562193301444,115.78578046830036,115.78578046830036,1000000.0 +2021-07-07,GLD,115.78578046830036,117.8626480251246,115.20685156595884,117.27626669166628,117.27626669166628,1000000.0 +2021-07-08,GLD,117.27626669166628,118.2762022722726,116.68988535820792,117.68776345499762,117.68776345499762,1000000.0 +2021-07-09,GLD,117.68776345499762,119.98222652200003,117.09932463772265,119.3853000218906,119.3853000218906,1000000.0 +2021-07-12,GLD,119.3853000218906,119.98222652200003,118.3264332424474,118.9210384346205,118.9210384346205,1000000.0 +2021-07-13,GLD,118.9210384346205,119.5156436267936,117.93174644328772,118.52436828471129,118.52436828471129,1000000.0 +2021-07-14,GLD,118.52436828471129,120.84026571415424,117.93174644328772,120.23907036234252,120.23907036234252,1000000.0 +2021-07-15,GLD,120.23907036234252,120.84026571415424,118.57603304986831,119.17189251243046,119.17189251243046,1000000.0 +2021-07-16,GLD,119.17189251243046,120.44642848657804,118.57603304986831,119.84719252395826,119.84719252395826,1000000.0 +2021-07-19,GLD,119.84719252395826,120.89013002064306,119.24795656133848,120.28868658770456,120.28868658770456,1000000.0 +2021-07-20,GLD,120.28868658770456,121.08082062364724,119.68724315476602,120.47842848124104,120.47842848124104,1000000.0 +2021-07-21,GLD,120.47842848124104,123.01248011643582,119.87603633883484,122.40047772779684,122.40047772779684,1000000.0 +2021-07-22,GLD,122.40047772779684,123.01248011643582,120.84867516285622,121.45595493754396,121.45595493754396,1000000.0 +2021-07-23,GLD,121.45595493754396,122.06323471223166,120.40510041493737,121.01015117079132,121.01015117079132,1000000.0 +2021-07-26,GLD,121.01015117079132,121.61520192664526,118.1756086102336,118.76945588968202,118.76945588968202,1000000.0 2021-07-27,GLD,118.76945588968202,119.36330316913042,117.08412866854336,117.67249112416418,117.67249112416418,1000000.0 -2021-07-28,GLD,117.67249112416418,118.26085357978499,116.56449408673284,117.15024531329934,117.15024531329934,1000000.0 +2021-07-28,GLD,117.67249112416418,118.260853579785,116.56449408673284,117.15024531329934,117.15024531329934,1000000.0 2021-07-29,GLD,117.15024531329934,117.73599653986582,116.1786223374765,116.76243451002664,116.76243451002664,1000000.0 -2021-07-30,GLD,116.76243451002664,117.34624668257676,115.98731435380283,116.57016517970133,116.57016517970133,1000000.0 -2021-08-02,GLD,116.57016517970133,117.49222654537245,115.98731435380283,116.90768810484822,116.90768810484822,1000000.0 +2021-07-30,GLD,116.76243451002664,117.34624668257676,115.98731435380284,116.57016517970132,116.57016517970132,1000000.0 +2021-08-02,GLD,116.57016517970132,117.49222654537245,115.98731435380284,116.90768810484822,116.90768810484822,1000000.0 2021-08-03,GLD,116.90768810484822,117.49222654537245,115.64508204761832,116.22621311318424,116.22621311318424,1000000.0 2021-08-04,GLD,116.22621311318424,116.80734417875016,113.61530388197356,114.18623505725986,114.18623505725986,1000000.0 2021-08-05,GLD,114.18623505725986,114.93706313394428,113.61530388197356,114.3652369491983,114.3652369491983,1000000.0 -2021-08-06,GLD,114.3652369491983,115.39738939134413,113.79341076445232,114.82327302621309,114.82327302621309,1000000.0 -2021-08-09,GLD,114.82327302621309,115.39738939134413,113.0772014514655,113.64542859443769,113.64542859443769,1000000.0 -2021-08-10,GLD,113.64542859443769,114.21365573740987,112.29671286660702,112.86101795638896,112.86101795638896,1000000.0 -2021-08-11,GLD,112.86101795638896,115.26763690067777,112.29671286660702,114.69416607032615,114.69416607032615,1000000.0 -2021-08-12,GLD,114.69416607032615,116.36521195924551,114.12069523997452,115.78628055646321,115.78628055646321,1000000.0 -2021-08-13,GLD,115.78628055646321,117.53858796016053,115.2073491536809,116.95381886583138,116.95381886583138,1000000.0 +2021-08-06,GLD,114.3652369491983,115.39738939134412,113.79341076445232,114.82327302621307,114.82327302621307,1000000.0 +2021-08-09,GLD,114.82327302621307,115.39738939134412,113.0772014514655,113.64542859443767,113.64542859443767,1000000.0 +2021-08-10,GLD,113.64542859443767,114.21365573740988,112.29671286660702,112.86101795638896,112.86101795638896,1000000.0 +2021-08-11,GLD,112.86101795638896,115.26763690067776,112.29671286660702,114.69416607032616,114.69416607032616,1000000.0 +2021-08-12,GLD,114.69416607032616,116.36521195924551,114.12069523997452,115.7862805564632,115.7862805564632,1000000.0 +2021-08-13,GLD,115.7862805564632,117.53858796016053,115.2073491536809,116.95381886583138,116.95381886583138,1000000.0 2021-08-16,GLD,116.95381886583138,118.2751473357785,116.36904977150222,117.6867137669438,117.6867137669438,1000000.0 -2021-08-17,GLD,117.6867137669438,118.45451199337248,117.09828019810908,117.86518606305721,117.86518606305721,1000000.0 -2021-08-18,GLD,117.86518606305721,118.45451199337248,116.75795888706274,117.34468229855553,117.34468229855553,1000000.0 +2021-08-17,GLD,117.6867137669438,118.45451199337248,117.09828019810908,117.8651860630572,117.8651860630572,1000000.0 +2021-08-18,GLD,117.8651860630572,118.45451199337248,116.75795888706274,117.34468229855553,117.34468229855553,1000000.0 2021-08-19,GLD,117.34468229855553,118.56585160284222,116.75795888706274,117.97597174412162,117.97597174412162,1000000.0 2021-08-20,GLD,117.97597174412162,118.56585160284222,117.19765421247608,117.78658714821717,117.78658714821717,1000000.0 2021-08-23,GLD,117.78658714821717,118.37552008395824,116.75371590000738,117.34041798995716,117.34041798995716,1000000.0 2021-08-24,GLD,117.34041798995716,117.93741431530832,116.75371590000738,117.35066101025704,117.35066101025704,1000000.0 -2021-08-25,GLD,117.35066101025704,117.93741431530832,116.70087310695622,117.28730965523239,117.28730965523239,1000000.0 -2021-08-26,GLD,117.28730965523239,118.84839334771682,116.70087310695622,118.25710780867347,118.25710780867347,1000000.0 -2021-08-27,GLD,118.25710780867347,118.84839334771682,116.67063570248823,117.25692030400828,117.25692030400828,1000000.0 -2021-08-30,GLD,117.25692030400828,120.55405257837448,116.67063570248823,119.95428117251194,119.95428117251194,1000000.0 +2021-08-25,GLD,117.35066101025704,117.93741431530832,116.70087310695622,117.2873096552324,117.2873096552324,1000000.0 +2021-08-26,GLD,117.2873096552324,118.84839334771682,116.70087310695622,118.25710780867348,118.25710780867348,1000000.0 +2021-08-27,GLD,118.25710780867348,118.84839334771682,116.67063570248824,117.25692030400828,117.25692030400828,1000000.0 +2021-08-30,GLD,117.25692030400828,120.55405257837448,116.67063570248824,119.95428117251194,119.95428117251194,1000000.0 2021-08-31,GLD,119.95428117251194,120.55405257837448,118.2933142726358,118.88775303782492,118.88775303782492,1000000.0 -2021-09-01,GLD,118.88775303782492,119.48219180301403,117.8590641197204,118.45132072333709,118.45132072333709,1000000.0 -2021-09-02,GLD,118.45132072333709,120.08716096024692,117.8590641197204,119.48971239825565,119.48971239825565,1000000.0 -2021-09-03,GLD,119.48971239825565,120.08716096024692,118.01728349809616,118.61033517396599,118.61033517396599,1000000.0 -2021-09-06,GLD,118.61033517396599,119.20338684983581,117.52590973646001,118.11649219744724,118.11649219744724,1000000.0 -2021-09-07,GLD,118.11649219744724,118.75972525836484,117.52590973646001,118.16888085409438,118.16888085409438,1000000.0 -2021-09-08,GLD,118.16888085409438,118.75972525836484,114.48387104952158,115.05916688394129,115.05916688394129,1000000.0 -2021-09-09,GLD,115.05916688394129,115.63446271836098,113.98098505710257,114.55375382623374,114.55375382623374,1000000.0 -2021-09-10,GLD,114.55375382623374,115.1265225953649,113.01119663899671,113.57909209949418,113.57909209949418,1000000.0 -2021-09-13,GLD,113.57909209949418,114.14698755999163,112.78038884239821,113.34712446472182,113.34712446472182,1000000.0 -2021-09-14,GLD,113.34712446472182,114.45198019982398,112.78038884239821,113.88256736300895,113.88256736300895,1000000.0 -2021-09-15,GLD,113.88256736300895,115.13933703178895,113.3131545261939,114.56650450924275,114.56650450924275,1000000.0 -2021-09-16,GLD,114.56650450924275,116.73968630521256,113.99367198669654,116.15889184598265,116.15889184598265,1000000.0 -2021-09-17,GLD,116.15889184598265,118.29901667996181,115.57809738675273,117.71046435817097,117.71046435817097,1000000.0 -2021-09-20,GLD,117.71046435817097,119.43613851559701,117.12191203638011,118.84192887124081,118.84192887124081,1000000.0 -2021-09-21,GLD,118.84192887124081,119.43613851559701,118.22983729487129,118.82395708027265,118.82395708027265,1000000.0 -2021-09-22,GLD,118.82395708027265,120.5773216508811,118.22983729487129,119.97743447848868,119.97743447848868,1000000.0 +2021-09-01,GLD,118.88775303782492,119.48219180301405,117.8590641197204,118.45132072333708,118.45132072333708,1000000.0 +2021-09-02,GLD,118.45132072333708,120.08716096024692,117.8590641197204,119.48971239825563,119.48971239825563,1000000.0 +2021-09-03,GLD,119.48971239825563,120.08716096024692,118.01728349809616,118.610335173966,118.610335173966,1000000.0 +2021-09-07,GLD,118.11649219744724,118.75972525836484,117.52590973646,118.16888085409438,118.16888085409438,1000000.0 +2021-09-08,GLD,118.16888085409438,118.75972525836484,114.48387104952158,115.05916688394127,115.05916688394127,1000000.0 +2021-09-09,GLD,115.05916688394127,115.63446271836098,113.98098505710256,114.55375382623374,114.55375382623374,1000000.0 +2021-09-10,GLD,114.55375382623374,115.1265225953649,113.01119663899672,113.57909209949418,113.57909209949418,1000000.0 +2021-09-13,GLD,113.57909209949418,114.14698755999164,112.7803888423982,113.34712446472182,113.34712446472182,1000000.0 +2021-09-14,GLD,113.34712446472182,114.45198019982398,112.7803888423982,113.88256736300896,113.88256736300896,1000000.0 +2021-09-15,GLD,113.88256736300896,115.13933703178895,113.3131545261939,114.56650450924276,114.56650450924276,1000000.0 +2021-09-16,GLD,114.56650450924276,116.73968630521256,113.99367198669654,116.15889184598264,116.15889184598264,1000000.0 +2021-09-17,GLD,116.15889184598264,118.2990166799618,115.57809738675272,117.71046435817097,117.71046435817097,1000000.0 +2021-09-20,GLD,117.71046435817097,119.436138515597,117.12191203638012,118.8419288712408,118.8419288712408,1000000.0 +2021-09-21,GLD,118.8419288712408,119.436138515597,118.22983729487127,118.82395708027263,118.82395708027263,1000000.0 +2021-09-22,GLD,118.82395708027263,120.5773216508811,118.22983729487127,119.97743447848868,119.97743447848868,1000000.0 2021-09-23,GLD,119.97743447848868,120.5773216508811,118.33073691859197,118.92536373727836,118.92536373727836,1000000.0 -2021-09-24,GLD,118.92536373727836,119.51999055596474,118.25245565564526,118.84668910115101,118.84668910115101,1000000.0 -2021-09-27,GLD,118.84668910115101,121.03537262469027,118.25245565564526,120.43320659173162,120.43320659173162,1000000.0 -2021-09-28,GLD,120.43320659173162,121.93278692982851,119.83104055877295,121.3261561490831,121.3261561490831,1000000.0 -2021-09-29,GLD,121.3261561490831,121.93278692982851,119.2260497438156,119.82517562192523,119.82517562192523,1000000.0 -2021-09-30,GLD,119.82517562192523,120.42430150003484,118.61410988647962,119.21016068992927,119.21016068992927,1000000.0 -2021-10-01,GLD,119.21016068992927,120.82112821413516,118.61410988647962,120.22002807376634,120.22002807376634,1000000.0 -2021-10-04,GLD,120.22002807376634,121.52716539579336,119.61892793339752,120.92255263263023,120.92255263263023,1000000.0 -2021-10-05,GLD,120.92255263263023,122.75059447137089,120.31793986946708,122.13989499638896,122.13989499638896,1000000.0 -2021-10-06,GLD,122.13989499638896,122.75059447137089,119.62662266096481,120.22776146830634,120.22776146830634,1000000.0 -2021-10-07,GLD,120.22776146830634,120.82890027564785,118.29066909705723,118.88509456990677,118.88509456990677,1000000.0 -2021-10-08,GLD,118.88509456990677,119.47952004275629,118.05351808392444,118.64675184314015,118.64675184314015,1000000.0 -2021-10-11,GLD,118.64675184314015,119.23998560235584,117.04612794247679,117.63429943967516,117.63429943967516,1000000.0 -2021-10-12,GLD,117.63429943967516,118.2227881306701,117.04612794247679,117.63461505539314,117.63461505539314,1000000.0 -2021-10-13,GLD,117.63461505539314,118.2713237850324,117.04644198011617,117.68290923883822,117.68290923883822,1000000.0 +2021-09-24,GLD,118.92536373727836,119.51999055596474,118.25245565564526,118.846689101151,118.846689101151,1000000.0 +2021-09-27,GLD,118.846689101151,121.03537262469028,118.25245565564526,120.43320659173162,120.43320659173162,1000000.0 +2021-09-28,GLD,120.43320659173162,121.93278692982852,119.83104055877295,121.3261561490831,121.3261561490831,1000000.0 +2021-09-29,GLD,121.3261561490831,121.93278692982852,119.2260497438156,119.82517562192524,119.82517562192524,1000000.0 +2021-09-30,GLD,119.82517562192524,120.42430150003484,118.61410988647962,119.21016068992928,119.21016068992928,1000000.0 +2021-10-01,GLD,119.21016068992928,120.82112821413516,118.61410988647962,120.22002807376634,120.22002807376634,1000000.0 +2021-10-04,GLD,120.22002807376634,121.52716539579336,119.61892793339752,120.92255263263024,120.92255263263024,1000000.0 +2021-10-05,GLD,120.92255263263024,122.75059447137087,120.31793986946708,122.13989499638896,122.13989499638896,1000000.0 +2021-10-06,GLD,122.13989499638896,122.75059447137087,119.6266226609648,120.22776146830634,120.22776146830634,1000000.0 +2021-10-07,GLD,120.22776146830634,120.82890027564784,118.29066909705725,118.88509456990676,118.88509456990676,1000000.0 +2021-10-08,GLD,118.88509456990676,119.47952004275628,118.05351808392444,118.64675184314017,118.64675184314017,1000000.0 +2021-10-11,GLD,118.64675184314017,119.23998560235584,117.0461279424768,117.63429943967516,117.63429943967516,1000000.0 +2021-10-12,GLD,117.63429943967516,118.2227881306701,117.0461279424768,117.63461505539314,117.63461505539314,1000000.0 +2021-10-13,GLD,117.63461505539314,118.2713237850324,117.04644198011616,117.68290923883822,117.68290923883822,1000000.0 2021-10-14,GLD,117.68290923883822,118.68034015870327,117.09449469264403,118.0898907051774,118.0898907051774,1000000.0 2021-10-15,GLD,118.0898907051774,118.68034015870327,117.39786231876988,117.98780132539687,117.98780132539687,1000000.0 -2021-10-18,GLD,117.98780132539687,119.22345088702437,117.39786231876988,118.63029939007401,118.63029939007401,1000000.0 -2021-10-19,GLD,118.63029939007401,120.11027915309455,118.03714789312365,119.51271557521848,119.51271557521848,1000000.0 -2021-10-20,GLD,119.51271557521848,120.3202312652323,118.91515199734239,119.72162314948488,119.72162314948488,1000000.0 -2021-10-21,GLD,119.72162314948488,120.3202312652323,117.8526803685682,118.44490489303337,118.44490489303337,1000000.0 -2021-10-22,GLD,118.44490489303337,120.48315554326236,117.8526803685682,119.88373685896754,119.88373685896754,1000000.0 +2021-10-18,GLD,117.98780132539687,119.22345088702436,117.39786231876988,118.630299390074,118.630299390074,1000000.0 +2021-10-19,GLD,118.630299390074,120.11027915309457,118.03714789312365,119.51271557521848,119.51271557521848,1000000.0 +2021-10-20,GLD,119.51271557521848,120.3202312652323,118.9151519973424,119.72162314948488,119.72162314948488,1000000.0 +2021-10-21,GLD,119.72162314948488,120.3202312652323,117.8526803685682,118.44490489303335,118.44490489303335,1000000.0 +2021-10-22,GLD,118.44490489303335,120.48315554326236,117.8526803685682,119.88373685896754,119.88373685896754,1000000.0 2021-10-25,GLD,119.88373685896754,120.76599707694125,119.2843181746727,120.16517122083708,120.16517122083708,1000000.0 -2021-10-26,GLD,120.16517122083708,121.27661309784659,119.5643453647329,120.67324686352896,120.67324686352896,1000000.0 -2021-10-27,GLD,120.67324686352896,121.51178270891518,120.06988062921131,120.90724647653252,120.90724647653252,1000000.0 -2021-10-28,GLD,120.90724647653252,121.51178270891518,118.60385884584609,119.19985813652873,119.19985813652873,1000000.0 -2021-10-29,GLD,119.19985813652873,120.47217785069968,118.60385884584609,119.87281378179074,119.87281378179074,1000000.0 +2021-10-26,GLD,120.16517122083708,121.2766130978466,119.5643453647329,120.67324686352896,120.67324686352896,1000000.0 +2021-10-27,GLD,120.67324686352896,121.51178270891518,120.06988062921133,120.90724647653252,120.90724647653252,1000000.0 +2021-10-28,GLD,120.90724647653252,121.51178270891518,118.60385884584608,119.19985813652872,119.19985813652872,1000000.0 +2021-10-29,GLD,119.19985813652872,120.47217785069968,118.60385884584608,119.87281378179074,119.87281378179074,1000000.0 2021-11-01,GLD,119.87281378179074,120.47217785069968,118.61186351924194,119.207903034414,119.207903034414,1000000.0 -2021-11-02,GLD,119.207903034414,119.80394254958605,117.35158280835614,117.94128925462928,117.94128925462928,1000000.0 +2021-11-02,GLD,119.207903034414,119.80394254958604,117.35158280835614,117.94128925462928,117.94128925462928,1000000.0 2021-11-03,GLD,117.94128925462928,119.96589222001222,117.35158280835614,119.3690469850868,119.3690469850868,1000000.0 -2021-11-04,GLD,119.3690469850868,119.96589222001222,118.48819395859337,119.08361201868681,119.08361201868681,1000000.0 -2021-11-05,GLD,119.08361201868681,120.03750648201479,118.48819395859337,119.44030495722866,119.44030495722866,1000000.0 -2021-11-08,GLD,119.44030495722866,121.05667684479106,118.84310343244252,120.45440482068763,120.45440482068763,1000000.0 -2021-11-09,GLD,120.45440482068763,122.89131112612957,119.85213279658419,122.27991156828814,122.27991156828814,1000000.0 -2021-11-10,GLD,122.27991156828814,122.89131112612957,119.72994840241793,120.33160643459088,120.33160643459088,1000000.0 +2021-11-04,GLD,119.3690469850868,119.96589222001222,118.48819395859336,119.0836120186868,119.0836120186868,1000000.0 +2021-11-05,GLD,119.0836120186868,120.0375064820148,118.48819395859336,119.44030495722866,119.44030495722866,1000000.0 +2021-11-08,GLD,119.44030495722866,121.05667684479106,118.84310343244252,120.45440482068764,120.45440482068764,1000000.0 +2021-11-09,GLD,120.45440482068764,122.89131112612957,119.8521327965842,122.27991156828814,122.27991156828814,1000000.0 +2021-11-10,GLD,122.27991156828814,122.89131112612957,119.72994840241792,120.33160643459088,120.33160643459088,1000000.0 2021-11-11,GLD,120.33160643459088,120.93326446676382,118.96945545695614,119.56729191653884,119.56729191653884,1000000.0 -2021-11-12,GLD,119.56729191653884,120.94791599601805,118.96945545695614,120.34618507066475,120.34618507066475,1000000.0 -2021-11-15,GLD,120.34618507066475,120.94791599601805,119.06435337107402,119.662666704597,119.662666704597,1000000.0 -2021-11-16,GLD,119.662666704597,120.26098003811997,115.63761501570724,116.21870855849974,116.21870855849974,1000000.0 -2021-11-17,GLD,116.21870855849974,116.99425737106813,115.63761501570724,116.41219638912253,116.41219638912253,1000000.0 -2021-11-18,GLD,116.41219638912253,117.2752926364985,115.83013540717691,116.69183346915275,116.69183346915275,1000000.0 -2021-11-19,GLD,116.69183346915275,118.16464194291896,116.10837430180699,117.57675815215818,117.57675815215818,1000000.0 +2021-11-12,GLD,119.56729191653884,120.94791599601804,118.96945545695614,120.34618507066476,120.34618507066476,1000000.0 +2021-11-15,GLD,120.34618507066476,120.94791599601804,119.06435337107402,119.662666704597,119.662666704597,1000000.0 +2021-11-16,GLD,119.662666704597,120.26098003811995,115.63761501570724,116.21870855849974,116.21870855849974,1000000.0 +2021-11-17,GLD,116.21870855849974,116.99425737106812,115.63761501570724,116.41219638912251,116.41219638912251,1000000.0 +2021-11-18,GLD,116.41219638912251,117.2752926364985,115.83013540717693,116.69183346915275,116.69183346915275,1000000.0 +2021-11-19,GLD,116.69183346915275,118.16464194291896,116.108374301807,117.57675815215818,117.57675815215818,1000000.0 2021-11-22,GLD,117.57675815215818,118.45335067591866,116.9888743613974,117.86403052330216,117.86403052330216,1000000.0 -2021-11-23,GLD,117.86403052330216,118.45335067591866,116.84867503617379,117.43585430771235,117.43585430771235,1000000.0 -2021-11-24,GLD,117.43585430771235,118.29443936817657,116.84867503617379,117.70590981908117,117.70590981908117,1000000.0 -2021-11-25,GLD,117.70590981908117,118.29962268306981,117.11738026998576,117.71106734633814,117.71106734633814,1000000.0 -2021-11-26,GLD,117.71106734633814,118.29962268306981,116.62911387081492,117.21518981991449,117.21518981991449,1000000.0 -2021-11-29,GLD,117.21518981991449,118.11745562128013,116.62911387081492,117.52980658833845,117.52980658833845,1000000.0 -2021-11-30,GLD,117.52980658833845,118.54135358574672,116.94215755539675,117.95159560770819,117.95159560770819,1000000.0 -2021-12-01,GLD,117.95159560770819,118.54135358574672,116.3304844922364,116.91505979119236,116.91505979119236,1000000.0 -2021-12-02,GLD,116.91505979119236,118.64065224135047,116.3304844922364,118.05040024014973,118.05040024014973,1000000.0 -2021-12-03,GLD,118.05040024014973,119.57741428447403,117.46014823894897,118.98250177559606,118.98250177559606,1000000.0 -2021-12-06,GLD,118.98250177559606,120.47666714350237,118.38758926671808,119.87728073980337,119.87728073980337,1000000.0 -2021-12-07,GLD,119.87728073980337,120.47666714350237,116.38788572256523,116.9727494699148,116.9727494699148,1000000.0 -2021-12-08,GLD,116.9727494699148,117.72467737356473,116.38788572256523,117.13898246125845,117.13898246125845,1000000.0 -2021-12-09,GLD,117.13898246125845,117.72467737356473,116.46368954760509,117.04893421869858,117.04893421869858,1000000.0 -2021-12-10,GLD,117.04893421869858,117.63417888979205,115.4523296462701,116.03249210680413,116.03249210680413,1000000.0 -2021-12-13,GLD,116.03249210680413,117.26896491289843,115.4523296462701,116.68553722676461,116.68553722676461,1000000.0 -2021-12-14,GLD,116.68553722676461,117.26896491289843,114.56968121015822,115.1454082514153,115.1454082514153,1000000.0 -2021-12-15,GLD,115.1454082514153,117.05988978696195,114.56968121015822,116.47750227558404,116.47750227558404,1000000.0 -2021-12-16,GLD,116.47750227558404,117.51017594987097,115.89511476420613,116.92554820882685,116.92554820882685,1000000.0 -2021-12-17,GLD,116.92554820882685,117.51017594987097,115.84842285991942,116.43057573861249,116.43057573861249,1000000.0 -2021-12-20,GLD,116.43057573861249,118.57239036652523,115.84842285991942,117.98247797664203,117.98247797664203,1000000.0 -2021-12-21,GLD,117.98247797664203,119.10904045375027,117.39256558675882,118.5164581629356,118.5164581629356,1000000.0 -2021-12-22,GLD,118.5164581629356,119.5101715570568,117.92387587212093,118.91559358911125,118.91559358911125,1000000.0 -2021-12-23,GLD,118.91559358911125,119.5101715570568,116.97135574425153,117.55915150176033,117.55915150176033,1000000.0 -2021-12-24,GLD,117.55915150176033,118.75087158421005,116.97135574425153,118.16007122806971,118.16007122806971,1000000.0 -2021-12-27,GLD,118.16007122806971,120.31485539994638,117.56927087192936,119.7162740297974,119.7162740297974,1000000.0 -2021-12-28,GLD,119.7162740297974,121.56129525666823,119.11769265964841,120.95651269320223,120.95651269320223,1000000.0 -2021-12-29,GLD,120.95651269320223,121.56129525666823,118.98944966722311,119.58738660022424,119.58738660022424,1000000.0 -2021-12-30,GLD,119.58738660022424,121.74590924630228,118.98944966722311,121.14020820527591,121.14020820527591,1000000.0 -2021-12-31,GLD,121.14020820527591,123.564082208596,120.53450716424953,122.94933553094131,122.94933553094131,1000000.0 -2022-01-03,GLD,122.94933553094131,124.55065403341533,122.3345888532866,123.93099903822421,123.93099903822421,1000000.0 -2022-01-04,GLD,123.93099903822421,124.55065403341533,122.91583172317594,123.5334992192723,123.5334992192723,1000000.0 -2022-01-05,GLD,123.5334992192723,124.15116671536865,121.28440361329699,121.89387297818793,121.89387297818793,1000000.0 -2022-01-06,GLD,121.89387297818793,122.50334234307886,120.31745976585096,120.92207011643312,120.92207011643312,1000000.0 -2022-01-07,GLD,120.92207011643312,121.52668046701527,119.71365395384053,120.31523010436234,120.31523010436234,1000000.0 -2022-01-10,GLD,120.31523010436234,121.24086310865168,119.71365395384053,120.63767473497681,120.63767473497681,1000000.0 -2022-01-11,GLD,120.63767473497681,121.87093313947604,120.03448636130193,121.2646100890309,121.2646100890309,1000000.0 -2022-01-12,GLD,121.2646100890309,121.87093313947604,119.86844006128484,120.47079403144205,120.47079403144205,1000000.0 -2022-01-13,GLD,120.47079403144205,121.07314800159925,118.11906834918173,118.71263150671531,118.71263150671531,1000000.0 -2022-01-14,GLD,118.71263150671531,119.30619466424888,117.69093600965539,118.28234774839737,118.28234774839737,1000000.0 -2022-01-17,GLD,118.28234774839737,118.87375948713934,116.29190033560585,116.87628174432749,116.87628174432749,1000000.0 -2022-01-18,GLD,116.87628174432749,118.89543283967595,116.29190033560585,118.30391327330942,118.30391327330942,1000000.0 -2022-01-19,GLD,118.30391327330942,118.89543283967595,116.40962890977372,116.99460191937057,116.99460191937057,1000000.0 -2022-01-20,GLD,116.99460191937057,118.09273389705459,116.40962890977372,117.50520785776577,117.50520785776577,1000000.0 -2022-01-21,GLD,117.50520785776577,118.97710486292682,116.91768181847694,118.38517896808641,118.38517896808641,1000000.0 -2022-01-24,GLD,118.38517896808641,118.97710486292682,116.74458778185796,117.33124400186729,117.33124400186729,1000000.0 -2022-01-25,GLD,117.33124400186729,118.98777024953458,116.74458778185796,118.39579129306924,118.39579129306924,1000000.0 +2021-11-23,GLD,117.86403052330216,118.45335067591866,116.8486750361738,117.43585430771236,117.43585430771236,1000000.0 +2021-11-24,GLD,117.43585430771236,118.29443936817655,116.8486750361738,117.70590981908116,117.70590981908116,1000000.0 +2021-11-26,GLD,117.71106734633814,118.2996226830698,116.62911387081492,117.21518981991449,117.21518981991449,1000000.0 +2021-11-29,GLD,117.21518981991449,118.11745562128011,116.62911387081492,117.52980658833845,117.52980658833845,1000000.0 +2021-11-30,GLD,117.52980658833845,118.54135358574672,116.94215755539676,117.9515956077082,117.9515956077082,1000000.0 +2021-12-01,GLD,117.9515956077082,118.54135358574672,116.3304844922364,116.91505979119236,116.91505979119236,1000000.0 +2021-12-02,GLD,116.91505979119236,118.64065224135048,116.3304844922364,118.05040024014971,118.05040024014971,1000000.0 +2021-12-03,GLD,118.05040024014971,119.57741428447405,117.46014823894896,118.98250177559606,118.98250177559606,1000000.0 +2021-12-06,GLD,118.98250177559606,120.47666714350235,118.38758926671808,119.87728073980335,119.87728073980335,1000000.0 +2021-12-07,GLD,119.87728073980335,120.47666714350235,116.38788572256524,116.9727494699148,116.9727494699148,1000000.0 +2021-12-08,GLD,116.9727494699148,117.72467737356472,116.38788572256524,117.13898246125844,117.13898246125844,1000000.0 +2021-12-09,GLD,117.13898246125844,117.72467737356472,116.46368954760509,117.04893421869858,117.04893421869858,1000000.0 +2021-12-10,GLD,117.04893421869858,117.63417888979204,115.4523296462701,116.03249210680411,116.03249210680411,1000000.0 +2021-12-13,GLD,116.03249210680411,117.26896491289844,115.4523296462701,116.6855372267646,116.6855372267646,1000000.0 +2021-12-14,GLD,116.6855372267646,117.26896491289844,114.56968121015822,115.1454082514153,115.1454082514153,1000000.0 +2021-12-15,GLD,115.1454082514153,117.05988978696196,114.56968121015822,116.47750227558404,116.47750227558404,1000000.0 +2021-12-16,GLD,116.47750227558404,117.51017594987096,115.89511476420611,116.92554820882684,116.92554820882684,1000000.0 +2021-12-17,GLD,116.92554820882684,117.51017594987096,115.84842285991942,116.43057573861248,116.43057573861248,1000000.0 +2021-12-20,GLD,116.43057573861248,118.57239036652524,115.84842285991942,117.98247797664204,117.98247797664204,1000000.0 +2021-12-21,GLD,117.98247797664204,119.10904045375028,117.39256558675882,118.5164581629356,118.5164581629356,1000000.0 +2021-12-22,GLD,118.5164581629356,119.5101715570568,117.92387587212092,118.91559358911124,118.91559358911124,1000000.0 +2021-12-23,GLD,118.91559358911124,119.5101715570568,116.97135574425153,117.55915150176033,117.55915150176033,1000000.0 +2021-12-27,GLD,118.16007122806973,120.31485539994638,117.56927087192936,119.7162740297974,119.7162740297974,1000000.0 +2021-12-28,GLD,119.7162740297974,121.56129525666825,119.1176926596484,120.95651269320224,120.95651269320224,1000000.0 +2021-12-29,GLD,120.95651269320224,121.56129525666825,118.98944966722313,119.58738660022424,119.58738660022424,1000000.0 +2021-12-30,GLD,119.58738660022424,121.74590924630228,118.98944966722313,121.14020820527593,121.14020820527593,1000000.0 +2021-12-31,GLD,121.14020820527593,123.564082208596,120.53450716424952,122.94933553094133,122.94933553094133,1000000.0 +2022-01-03,GLD,122.94933553094133,124.55065403341531,122.3345888532866,123.9309990382242,123.9309990382242,1000000.0 +2022-01-04,GLD,123.9309990382242,124.55065403341531,122.91583172317594,123.5334992192723,123.5334992192723,1000000.0 +2022-01-05,GLD,123.5334992192723,124.15116671536865,121.284403613297,121.89387297818791,121.89387297818791,1000000.0 +2022-01-06,GLD,121.89387297818791,122.50334234307886,120.31745976585096,120.92207011643312,120.92207011643312,1000000.0 +2022-01-07,GLD,120.92207011643312,121.52668046701528,119.71365395384052,120.31523010436234,120.31523010436234,1000000.0 +2022-01-10,GLD,120.31523010436234,121.24086310865168,119.71365395384052,120.6376747349768,120.6376747349768,1000000.0 +2022-01-11,GLD,120.6376747349768,121.87093313947604,120.03448636130192,121.2646100890309,121.2646100890309,1000000.0 +2022-01-12,GLD,121.2646100890309,121.87093313947604,119.86844006128484,120.47079403144204,120.47079403144204,1000000.0 +2022-01-13,GLD,120.47079403144204,121.07314800159924,118.11906834918172,118.71263150671533,118.71263150671533,1000000.0 +2022-01-14,GLD,118.71263150671533,119.30619466424888,117.6909360096554,118.28234774839736,118.28234774839736,1000000.0 +2022-01-18,GLD,116.87628174432749,118.89543283967596,116.29190033560585,118.30391327330942,118.30391327330942,1000000.0 +2022-01-19,GLD,118.30391327330942,118.89543283967596,116.40962890977372,116.99460191937057,116.99460191937057,1000000.0 +2022-01-20,GLD,116.99460191937057,118.0927338970546,116.40962890977372,117.50520785776575,117.50520785776575,1000000.0 +2022-01-21,GLD,117.50520785776575,118.97710486292682,116.91768181847694,118.3851789680864,118.3851789680864,1000000.0 +2022-01-24,GLD,118.3851789680864,118.97710486292682,116.74458778185796,117.33124400186728,117.33124400186728,1000000.0 +2022-01-25,GLD,117.33124400186728,118.98777024953458,116.74458778185796,118.39579129306924,118.39579129306924,1000000.0 2022-01-26,GLD,118.39579129306924,118.98777024953458,117.64064191521808,118.23180091981718,118.23180091981718,1000000.0 -2022-01-27,GLD,118.23180091981718,118.82295992441625,116.7834897291292,117.37034143631075,117.37034143631075,1000000.0 -2022-01-28,GLD,117.37034143631075,117.95719314349229,115.76816789842773,116.34991748585702,116.34991748585702,1000000.0 -2022-01-31,GLD,116.34991748585702,117.02839586351858,115.76816789842773,116.44616503832697,116.44616503832697,1000000.0 -2022-02-01,GLD,116.44616503832697,118.15184287132303,115.86393421313534,117.56402275753537,117.56402275753537,1000000.0 -2022-02-02,GLD,117.56402275753537,118.15184287132303,114.95511337983567,115.5327772661665,115.5327772661665,1000000.0 -2022-02-03,GLD,115.5327772661665,116.73380563236233,114.95511337983567,116.15304043021129,116.15304043021129,1000000.0 -2022-02-04,GLD,116.15304043021129,116.73380563236233,115.3877201196985,115.96755790924472,115.96755790924472,1000000.0 -2022-02-07,GLD,115.96755790924472,116.54739569879094,114.57143372762317,115.14716957550068,115.14716957550068,1000000.0 -2022-02-08,GLD,115.14716957550068,115.72290542337818,114.43458176326405,115.00962991282819,115.00962991282819,1000000.0 -2022-02-09,GLD,115.00962991282819,115.58467806239231,114.39565956846937,114.97051212911495,114.97051212911495,1000000.0 -2022-02-10,GLD,114.97051212911495,115.54536468976052,114.30708232281424,114.8814897716726,114.8814897716726,1000000.0 +2022-01-27,GLD,118.23180091981718,118.82295992441624,116.7834897291292,117.37034143631077,117.37034143631077,1000000.0 +2022-01-28,GLD,117.37034143631077,117.95719314349228,115.76816789842772,116.34991748585702,116.34991748585702,1000000.0 +2022-01-31,GLD,116.34991748585702,117.02839586351858,115.76816789842772,116.44616503832697,116.44616503832697,1000000.0 +2022-02-01,GLD,116.44616503832697,118.15184287132304,115.86393421313534,117.56402275753535,117.56402275753535,1000000.0 +2022-02-02,GLD,117.56402275753535,118.15184287132304,114.95511337983568,115.5327772661665,115.5327772661665,1000000.0 +2022-02-03,GLD,115.5327772661665,116.73380563236232,114.95511337983568,116.15304043021128,116.15304043021128,1000000.0 +2022-02-04,GLD,116.15304043021128,116.73380563236232,115.3877201196985,115.96755790924472,115.96755790924472,1000000.0 +2022-02-07,GLD,115.96755790924472,116.54739569879094,114.57143372762316,115.14716957550068,115.14716957550068,1000000.0 +2022-02-08,GLD,115.14716957550068,115.72290542337818,114.43458176326403,115.0096299128282,115.0096299128282,1000000.0 +2022-02-09,GLD,115.0096299128282,115.58467806239231,114.39565956846936,114.97051212911497,114.97051212911497,1000000.0 +2022-02-10,GLD,114.97051212911497,115.54536468976052,114.30708232281424,114.8814897716726,114.8814897716726,1000000.0 2022-02-11,GLD,114.8814897716726,116.2810267522315,114.30708232281424,115.70251418132489,115.70251418132489,1000000.0 -2022-02-14,GLD,115.70251418132489,116.2810267522315,114.15839127232577,114.73205152997565,114.73205152997565,1000000.0 -2022-02-15,GLD,114.73205152997565,117.36231762076184,114.15839127232577,116.77842549329537,116.77842549329537,1000000.0 -2022-02-16,GLD,116.77842549329537,118.29326989020056,116.19453336582889,117.70474615940356,117.70474615940356,1000000.0 +2022-02-14,GLD,115.70251418132489,116.2810267522315,114.15839127232576,114.73205152997564,114.73205152997564,1000000.0 +2022-02-15,GLD,114.73205152997564,117.36231762076184,114.15839127232576,116.77842549329536,116.77842549329536,1000000.0 +2022-02-16,GLD,116.77842549329536,118.29326989020056,116.19453336582887,117.70474615940356,117.70474615940356,1000000.0 2022-02-17,GLD,117.70474615940356,118.29326989020056,116.46084842701106,117.04607882111664,117.04607882111664,1000000.0 2022-02-18,GLD,117.04607882111664,117.6313092152222,114.41920597129462,114.99417685557248,114.99417685557248,1000000.0 -2022-02-21,GLD,114.99417685557248,115.56914773985032,114.37698192210215,114.95174062522828,114.95174062522828,1000000.0 2022-02-22,GLD,114.95174062522828,115.52649932835442,113.93589434884362,114.50843653150112,114.50843653150112,1000000.0 2022-02-23,GLD,114.50843653150112,115.84350664811755,113.93589434884362,115.26717079414684,115.26717079414684,1000000.0 -2022-02-24,GLD,115.26717079414684,116.59232233022335,114.6908349401761,116.01226102509787,116.01226102509787,1000000.0 -2022-02-25,GLD,116.01226102509787,118.62424372746968,115.43219971997239,118.03407336066635,118.03407336066635,1000000.0 -2022-02-28,GLD,118.03407336066635,118.62424372746968,117.29717499605539,117.88660803623657,117.88660803623657,1000000.0 -2022-03-01,GLD,117.88660803623657,119.32386192622721,117.29717499605539,118.73021087186788,118.73021087186788,1000000.0 -2022-03-02,GLD,118.73021087186788,119.32386192622721,117.67936858490185,118.27072219588126,118.27072219588126,1000000.0 +2022-02-24,GLD,115.26717079414684,116.59232233022335,114.6908349401761,116.01226102509789,116.01226102509789,1000000.0 +2022-02-25,GLD,116.01226102509789,118.62424372746968,115.4321997199724,118.03407336066635,118.03407336066635,1000000.0 +2022-02-28,GLD,118.03407336066635,118.62424372746968,117.2971749960554,117.88660803623657,117.88660803623657,1000000.0 +2022-03-01,GLD,117.88660803623657,119.3238619262272,117.2971749960554,118.73021087186788,118.73021087186788,1000000.0 +2022-03-02,GLD,118.73021087186788,119.3238619262272,117.67936858490184,118.27072219588126,118.27072219588126,1000000.0 2022-03-03,GLD,118.27072219588126,118.86207580686066,117.27997476667622,117.86932137354394,117.86932137354394,1000000.0 -2022-03-04,GLD,117.86932137354394,120.11987137287956,117.27997476667622,119.52226007251699,119.52226007251699,1000000.0 -2022-03-07,GLD,119.52226007251699,120.11987137287956,118.77912857308989,119.37600861617075,119.37600861617075,1000000.0 -2022-03-08,GLD,119.37600861617075,119.9728886592516,118.66767475876853,119.26399473243067,119.26399473243067,1000000.0 -2022-03-09,GLD,119.26399473243067,119.86031470609282,118.29364982165285,118.88809027301794,118.88809027301794,1000000.0 -2022-03-10,GLD,118.88809027301794,119.8455536982322,118.29364982165285,119.24930716242011,119.24930716242011,1000000.0 -2022-03-11,GLD,119.24930716242011,120.50620270879689,118.65306062660801,119.90666936198697,119.90666936198697,1000000.0 -2022-03-14,GLD,119.90666936198697,120.50620270879689,119.19065686985887,119.78960489433052,119.78960489433052,1000000.0 +2022-03-04,GLD,117.86932137354394,120.11987137287956,117.27997476667622,119.522260072517,119.522260072517,1000000.0 +2022-03-07,GLD,119.522260072517,120.11987137287956,118.77912857308988,119.37600861617076,119.37600861617076,1000000.0 +2022-03-08,GLD,119.37600861617076,119.9728886592516,118.66767475876853,119.26399473243067,119.26399473243067,1000000.0 +2022-03-09,GLD,119.26399473243067,119.86031470609282,118.29364982165283,118.88809027301794,118.88809027301794,1000000.0 +2022-03-10,GLD,118.88809027301794,119.8455536982322,118.29364982165283,119.24930716242012,119.24930716242012,1000000.0 +2022-03-11,GLD,119.24930716242012,120.50620270879688,118.653060626608,119.90666936198696,119.90666936198696,1000000.0 +2022-03-14,GLD,119.90666936198696,120.50620270879688,119.19065686985888,119.78960489433052,119.78960489433052,1000000.0 2022-03-15,GLD,119.78960489433052,120.38855291880216,117.97263033328392,118.56545762139088,118.56545762139088,1000000.0 -2022-03-16,GLD,118.56545762139088,119.54352397227231,117.97263033328392,118.94878007191276,118.94878007191276,1000000.0 -2022-03-17,GLD,118.94878007191276,119.54352397227231,117.49165335297091,118.08206367132755,118.08206367132755,1000000.0 -2022-03-18,GLD,118.08206367132755,118.67247398968418,117.13167476305058,117.72027614376942,117.72027614376942,1000000.0 -2022-03-21,GLD,117.72027614376942,118.56245210616375,117.13167476305058,117.97258916036195,117.97258916036195,1000000.0 -2022-03-22,GLD,117.97258916036195,118.85449030787446,117.38272621456014,118.26317443569599,118.26317443569599,1000000.0 -2022-03-23,GLD,118.26317443569599,118.85449030787446,115.24147823570162,115.82058114140867,115.82058114140867,1000000.0 -2022-03-24,GLD,115.82058114140867,118.23763647301527,115.24147823570162,117.64938952538834,117.64938952538834,1000000.0 +2022-03-16,GLD,118.56545762139088,119.54352397227233,117.97263033328392,118.94878007191276,118.94878007191276,1000000.0 +2022-03-17,GLD,118.94878007191276,119.54352397227233,117.49165335297091,118.08206367132756,118.08206367132756,1000000.0 +2022-03-18,GLD,118.08206367132756,118.67247398968418,117.13167476305058,117.72027614376942,117.72027614376942,1000000.0 +2022-03-21,GLD,117.72027614376942,118.56245210616376,117.13167476305058,117.97258916036196,117.97258916036196,1000000.0 +2022-03-22,GLD,117.97258916036196,118.85449030787446,117.38272621456014,118.263174435696,118.263174435696,1000000.0 +2022-03-23,GLD,118.263174435696,118.85449030787446,115.24147823570162,115.82058114140868,115.82058114140868,1000000.0 +2022-03-24,GLD,115.82058114140868,118.23763647301529,115.24147823570162,117.64938952538834,117.64938952538834,1000000.0 2022-03-25,GLD,117.64938952538834,119.1244392694586,117.0611425777614,118.53178036762051,118.53178036762051,1000000.0 -2022-03-28,GLD,118.53178036762051,121.26431950162906,117.9391214657824,120.66101442948167,120.66101442948167,1000000.0 -2022-03-29,GLD,120.66101442948167,124.09788915241265,120.05770935733426,123.48048671881857,123.48048671881857,1000000.0 -2022-03-30,GLD,123.48048671881857,124.09788915241265,121.84316009750717,122.4554372839268,122.4554372839268,1000000.0 -2022-03-31,GLD,122.4554372839268,123.06771447034642,121.24184194807633,121.8510974352526,121.8510974352526,1000000.0 -2022-04-01,GLD,121.8510974352526,122.75458001470776,121.24184194807633,122.14386071115202,122.14386071115202,1000000.0 +2022-03-28,GLD,118.53178036762051,121.26431950162906,117.9391214657824,120.66101442948168,120.66101442948168,1000000.0 +2022-03-29,GLD,120.66101442948168,124.09788915241263,120.05770935733426,123.48048671881855,123.48048671881855,1000000.0 +2022-03-30,GLD,123.48048671881855,124.09788915241263,121.84316009750717,122.4554372839268,122.4554372839268,1000000.0 +2022-03-31,GLD,122.4554372839268,123.06771447034642,121.24184194807631,121.8510974352526,121.8510974352526,1000000.0 +2022-04-01,GLD,121.8510974352526,122.75458001470776,121.24184194807631,122.14386071115202,122.14386071115202,1000000.0 2022-04-04,GLD,122.14386071115202,123.8192655529406,121.53314140759626,123.20324930640858,123.20324930640858,1000000.0 2022-04-05,GLD,123.20324930640858,123.8192655529406,122.03179280808422,122.64501789757207,122.64501789757207,1000000.0 -2022-04-06,GLD,122.64501789757207,123.25824298705992,121.69264844450342,122.30416929095821,122.30416929095821,1000000.0 -2022-04-07,GLD,122.30416929095821,124.12811392616027,121.69264844450342,123.5105611205575,123.5105611205575,1000000.0 -2022-04-08,GLD,123.5105611205575,124.12811392616027,121.9124035054278,122.52502864867117,122.52502864867117,1000000.0 -2022-04-11,GLD,122.52502864867117,123.37796425151078,121.9124035054278,122.76414353384158,122.76414353384158,1000000.0 -2022-04-12,GLD,122.76414353384158,124.59881253642217,122.15032281617236,123.97891794668874,123.97891794668874,1000000.0 -2022-04-13,GLD,123.97891794668874,124.59881253642217,123.06209579149935,123.68049828291392,123.68049828291392,1000000.0 -2022-04-14,GLD,123.68049828291392,125.17329190764332,123.06209579149935,124.5505392115854,124.5505392115854,1000000.0 -2022-04-15,GLD,124.5505392115854,125.17329190764332,123.78699275028085,124.40903793998075,124.40903793998075,1000000.0 -2022-04-18,GLD,124.40903793998075,125.03108312968064,122.00139837386094,122.61447072749843,122.61447072749843,1000000.0 -2022-04-19,GLD,122.61447072749843,123.2275430811359,121.38116448813369,121.99112008857658,121.99112008857658,1000000.0 -2022-04-20,GLD,121.99112008857658,122.60107568901945,121.13333546223633,121.74204569068978,121.74204569068978,1000000.0 -2022-04-21,GLD,121.74204569068978,122.35075591914321,120.73459418812956,121.3413006915875,121.3413006915875,1000000.0 +2022-04-06,GLD,122.64501789757207,123.25824298705992,121.69264844450342,122.3041692909582,122.3041692909582,1000000.0 +2022-04-07,GLD,122.3041692909582,124.12811392616028,121.69264844450342,123.5105611205575,123.5105611205575,1000000.0 +2022-04-08,GLD,123.5105611205575,124.12811392616028,121.9124035054278,122.52502864867115,122.52502864867115,1000000.0 +2022-04-11,GLD,122.52502864867115,123.37796425151078,121.9124035054278,122.76414353384158,122.76414353384158,1000000.0 +2022-04-12,GLD,122.76414353384158,124.59881253642216,122.15032281617236,123.97891794668874,123.97891794668874,1000000.0 +2022-04-13,GLD,123.97891794668874,124.59881253642216,123.06209579149936,123.68049828291392,123.68049828291392,1000000.0 +2022-04-14,GLD,123.68049828291392,125.17329190764332,123.06209579149936,124.5505392115854,124.5505392115854,1000000.0 +2022-04-18,GLD,124.40903793998076,125.03108312968064,122.00139837386094,122.61447072749844,122.61447072749844,1000000.0 +2022-04-19,GLD,122.61447072749844,123.2275430811359,121.38116448813368,121.99112008857658,121.99112008857658,1000000.0 +2022-04-20,GLD,121.99112008857658,122.60107568901944,121.13333546223632,121.74204569068978,121.74204569068978,1000000.0 +2022-04-21,GLD,121.74204569068978,122.3507559191432,120.73459418812956,121.3413006915875,121.3413006915875,1000000.0 2022-04-22,GLD,121.3413006915875,121.94800719504543,120.72784501239217,121.33451760039414,121.33451760039414,1000000.0 -2022-04-25,GLD,121.33451760039414,122.34076548323179,120.72784501239217,121.7321049584396,121.7321049584396,1000000.0 -2022-04-26,GLD,121.7321049584396,122.61710098930274,121.1234444336474,122.00706566099777,122.00706566099777,1000000.0 -2022-04-27,GLD,122.00706566099777,122.7660515460327,121.39703033269278,122.15527517018181,122.15527517018181,1000000.0 -2022-04-28,GLD,122.15527517018181,122.7660515460327,121.01398997442563,121.62210047680968,121.62210047680968,1000000.0 -2022-04-29,GLD,121.62210047680968,122.23021097919371,120.71326463631257,121.31986395609304,121.31986395609304,1000000.0 -2022-05-02,GLD,121.31986395609304,121.9264632758735,120.24050908212853,120.84473274585781,120.84473274585781,1000000.0 -2022-05-03,GLD,120.84473274585781,121.44895640958708,119.53208950164921,120.13275326798916,120.13275326798916,1000000.0 -2022-05-04,GLD,120.13275326798916,122.1721833853229,119.53208950164921,121.56436157743573,121.56436157743573,1000000.0 -2022-05-05,GLD,121.56436157743573,122.1721833853229,118.81364170769945,119.41069518361753,119.41069518361753,1000000.0 -2022-05-06,GLD,119.41069518361753,120.17812004650844,118.81364170769945,119.5802189517497,119.5802189517497,1000000.0 +2022-04-25,GLD,121.33451760039414,122.3407654832318,120.72784501239217,121.7321049584396,121.7321049584396,1000000.0 +2022-04-26,GLD,121.7321049584396,122.61710098930274,121.1234444336474,122.00706566099775,122.00706566099775,1000000.0 +2022-04-27,GLD,122.00706566099775,122.7660515460327,121.39703033269278,122.1552751701818,122.1552751701818,1000000.0 +2022-04-28,GLD,122.1552751701818,122.7660515460327,121.01398997442564,121.62210047680968,121.62210047680968,1000000.0 +2022-04-29,GLD,121.62210047680968,122.23021097919371,120.71326463631256,121.31986395609304,121.31986395609304,1000000.0 +2022-05-02,GLD,121.31986395609304,121.9264632758735,120.24050908212853,120.8447327458578,120.8447327458578,1000000.0 +2022-05-03,GLD,120.8447327458578,121.44895640958708,119.5320895016492,120.13275326798916,120.13275326798916,1000000.0 +2022-05-04,GLD,120.13275326798916,122.1721833853229,119.5320895016492,121.56436157743572,121.56436157743572,1000000.0 +2022-05-05,GLD,121.56436157743572,122.1721833853229,118.81364170769945,119.41069518361752,119.41069518361752,1000000.0 +2022-05-06,GLD,119.41069518361752,120.17812004650844,118.81364170769945,119.5802189517497,119.5802189517497,1000000.0 2022-05-09,GLD,119.5802189517497,120.17812004650844,117.14341339394343,117.73207376275722,117.73207376275722,1000000.0 -2022-05-10,GLD,117.73207376275722,118.320734131571,116.41564299802887,117.00064622917475,117.00064622917475,1000000.0 -2022-05-11,GLD,117.00064622917475,118.34019826736065,116.41564299802887,117.7514410620504,117.7514410620504,1000000.0 -2022-05-12,GLD,117.7514410620504,118.34019826736065,116.75632895087877,117.34304417173746,117.34304417173746,1000000.0 -2022-05-13,GLD,117.34304417173746,119.32091471275679,116.75632895087877,118.72727832115105,118.72727832115105,1000000.0 -2022-05-16,GLD,118.72727832115105,119.32091471275679,117.00652394911756,117.59449643127394,117.59449643127394,1000000.0 +2022-05-10,GLD,117.73207376275722,118.320734131571,116.41564299802889,117.00064622917476,117.00064622917476,1000000.0 +2022-05-11,GLD,117.00064622917476,118.34019826736063,116.41564299802889,117.7514410620504,117.7514410620504,1000000.0 +2022-05-12,GLD,117.7514410620504,118.34019826736063,116.75632895087875,117.34304417173746,117.34304417173746,1000000.0 +2022-05-13,GLD,117.34304417173746,119.3209147127568,116.75632895087875,118.72727832115103,118.72727832115103,1000000.0 +2022-05-16,GLD,118.72727832115103,119.3209147127568,117.00652394911756,117.59449643127394,117.59449643127394,1000000.0 2022-05-17,GLD,117.59449643127394,118.1824689134303,116.57159785432766,117.15738477821876,117.15738477821876,1000000.0 -2022-05-18,GLD,117.15738477821876,117.74317170210983,115.77823951183089,116.36003971038281,116.36003971038281,1000000.0 -2022-05-19,GLD,116.36003971038281,116.94183990893471,115.65785254201231,116.23904778091689,116.23904778091689,1000000.0 -2022-05-20,GLD,116.23904778091689,116.82024301982146,115.191051499748,115.76990100477185,115.76990100477185,1000000.0 -2022-05-23,GLD,115.76990100477185,116.3487505097957,114.6520218377658,115.22816265102091,115.22816265102091,1000000.0 -2022-05-24,GLD,115.22816265102091,115.804303464276,114.59218647593195,115.16802660897683,115.16802660897683,1000000.0 -2022-05-25,GLD,115.16802660897683,116.74868667537758,114.59218647593195,116.16784743818667,116.16784743818667,1000000.0 -2022-05-26,GLD,116.16784743818667,116.74868667537758,115.58107875426477,116.16188819524098,116.16188819524098,1000000.0 -2022-05-27,GLD,116.16188819524098,116.74269763621717,115.4699222927459,116.0501731585386,116.0501731585386,1000000.0 -2022-05-30,GLD,116.0501731585386,116.63042402433128,114.98954009687039,115.56737698177928,115.56737698177928,1000000.0 -2022-05-31,GLD,115.56737698177928,116.14521386668817,114.07363508644767,114.64686943361575,114.64686943361575,1000000.0 -2022-06-01,GLD,114.64686943361575,115.22010378078382,112.40526525802741,112.97011583721348,112.97011583721348,1000000.0 +2022-05-18,GLD,117.15738477821876,117.74317170210983,115.77823951183088,116.3600397103828,116.3600397103828,1000000.0 +2022-05-19,GLD,116.3600397103828,116.94183990893472,115.65785254201232,116.23904778091688,116.23904778091688,1000000.0 +2022-05-20,GLD,116.23904778091688,116.82024301982146,115.191051499748,115.76990100477184,115.76990100477184,1000000.0 +2022-05-23,GLD,115.76990100477184,116.3487505097957,114.6520218377658,115.22816265102092,115.22816265102092,1000000.0 +2022-05-24,GLD,115.22816265102092,115.804303464276,114.59218647593195,115.16802660897685,115.16802660897685,1000000.0 +2022-05-25,GLD,115.16802660897685,116.74868667537758,114.59218647593195,116.16784743818668,116.16784743818668,1000000.0 +2022-05-26,GLD,116.16784743818668,116.74868667537758,115.58107875426477,116.16188819524098,116.16188819524098,1000000.0 +2022-05-27,GLD,116.16188819524098,116.74269763621716,115.4699222927459,116.0501731585386,116.0501731585386,1000000.0 +2022-05-31,GLD,115.56737698177928,116.14521386668817,114.07363508644768,114.64686943361576,114.64686943361576,1000000.0 +2022-06-01,GLD,114.64686943361576,115.22010378078382,112.4052652580274,112.97011583721348,112.97011583721348,1000000.0 2022-06-02,GLD,112.97011583721348,113.53496641639954,111.1559290088696,111.71450151645186,111.71450151645186,1000000.0 -2022-06-03,GLD,111.71450151645186,112.2730740240341,110.99416489765393,111.55192452025521,111.55192452025521,1000000.0 -2022-06-06,GLD,111.55192452025521,112.10968414285648,108.89412416994763,109.44133082406798,109.44133082406798,1000000.0 -2022-06-07,GLD,109.44133082406798,110.43820725283113,108.89412416994763,109.88876343565288,109.88876343565288,1000000.0 -2022-06-08,GLD,109.88876343565288,110.43820725283113,108.71938261493833,109.26571117079229,109.26571117079229,1000000.0 +2022-06-03,GLD,111.71450151645186,112.2730740240341,110.99416489765392,111.5519245202552,111.5519245202552,1000000.0 +2022-06-06,GLD,111.5519245202552,112.10968414285648,108.89412416994764,109.44133082406798,109.44133082406798,1000000.0 +2022-06-07,GLD,109.44133082406798,110.43820725283112,108.89412416994764,109.88876343565288,109.88876343565288,1000000.0 +2022-06-08,GLD,109.88876343565288,110.43820725283112,108.71938261493833,109.26571117079229,109.26571117079229,1000000.0 2022-06-09,GLD,109.26571117079229,109.81203972664623,107.59459464055335,108.135270995531,108.135270995531,1000000.0 -2022-06-10,GLD,108.135270995531,108.67594735050865,106.85728780813015,107.3942591036484,107.3942591036484,1000000.0 -2022-06-13,GLD,107.3942591036484,108.36251893362073,106.85728780813015,107.82340192400075,107.82340192400075,1000000.0 -2022-06-14,GLD,107.82340192400075,108.51081967323648,107.28428491438075,107.97096484899154,107.97096484899154,1000000.0 -2022-06-15,GLD,107.97096484899154,108.55908196203497,107.43111002474659,108.01898702690048,108.01898702690048,1000000.0 -2022-06-16,GLD,108.01898702690048,110.09587286938826,107.47889209176597,109.54813220834654,109.54813220834654,1000000.0 -2022-06-17,GLD,109.54813220834654,112.25323502875685,109.00039154730482,111.69476122264363,111.69476122264363,1000000.0 -2022-06-20,GLD,111.69476122264363,112.25323502875685,109.67644537777261,110.22758329424383,110.22758329424383,1000000.0 -2022-06-21,GLD,110.22758329424383,111.49691422440029,109.67644537777261,110.9422032083585,110.9422032083585,1000000.0 -2022-06-22,GLD,110.9422032083585,111.49691422440029,109.47382318145782,110.02394289593751,110.02394289593751,1000000.0 -2022-06-23,GLD,110.02394289593751,110.57406261041719,107.97910624122053,108.52171481529702,108.52171481529702,1000000.0 -2022-06-24,GLD,108.52171481529702,109.06432338937348,107.33882849216491,107.87821959011549,107.87821959011549,1000000.0 -2022-06-27,GLD,107.87821959011549,109.30263846691645,107.33882849216491,108.75884424568802,108.75884424568802,1000000.0 -2022-06-28,GLD,108.75884424568802,109.30263846691645,107.81099616834334,108.35275996818426,108.35275996818426,1000000.0 -2022-06-29,GLD,108.35275996818426,108.89452376802517,107.25951218293015,107.79850470646247,107.79850470646247,1000000.0 -2022-06-30,GLD,107.79850470646247,109.64944260399302,107.25951218293015,109.1039229890478,109.1039229890478,1000000.0 -2022-07-01,GLD,109.1039229890478,110.09409545281315,108.55840337410257,109.54636363463996,109.54636363463996,1000000.0 -2022-07-04,GLD,109.54636363463996,110.09409545281315,108.87266410662053,109.41976292122666,109.41976292122666,1000000.0 -2022-07-05,GLD,109.41976292122666,111.7496922317145,108.87266410662053,111.19372361364628,111.19372361364628,1000000.0 -2022-07-06,GLD,111.19372361364628,112.22017216384297,110.63775499557805,111.66186284959501,111.66186284959501,1000000.0 -2022-07-07,GLD,111.66186284959501,112.22017216384297,110.36298610035927,110.91757397021031,110.91757397021031,1000000.0 -2022-07-08,GLD,110.91757397021031,111.47216184006135,109.27884063076296,109.82798053343011,109.82798053343011,1000000.0 -2022-07-11,GLD,109.82798053343011,110.37712043609724,108.86758269101553,109.41465597086987,109.41465597086987,1000000.0 -2022-07-12,GLD,109.41465597086987,110.1415611641263,108.86758269101553,109.59359319813564,109.59359319813564,1000000.0 -2022-07-13,GLD,109.59359319813564,111.10101316366877,109.04562523214496,110.54827180464555,110.54827180464555,1000000.0 -2022-07-14,GLD,110.54827180464555,111.97657526493953,109.99553044562232,111.41947787556172,111.41947787556172,1000000.0 -2022-07-15,GLD,111.41947787556172,112.11975885293823,110.86238048618391,111.56194910740123,111.56194910740123,1000000.0 -2022-07-18,GLD,111.56194910740123,112.11975885293823,109.86150816831187,110.41357604855465,110.41357604855465,1000000.0 -2022-07-19,GLD,110.41357604855465,111.93344401451226,109.86150816831187,111.37656120846992,111.37656120846992,1000000.0 -2022-07-20,GLD,111.37656120846992,112.05672973004238,110.81967840242757,111.49923356223124,111.49923356223124,1000000.0 +2022-06-10,GLD,108.135270995531,108.67594735050864,106.85728780813017,107.3942591036484,107.3942591036484,1000000.0 +2022-06-13,GLD,107.3942591036484,108.36251893362072,106.85728780813017,107.82340192400076,107.82340192400076,1000000.0 +2022-06-14,GLD,107.82340192400076,108.51081967323648,107.28428491438076,107.97096484899154,107.97096484899154,1000000.0 +2022-06-15,GLD,107.97096484899154,108.55908196203497,107.4311100247466,108.01898702690048,108.01898702690048,1000000.0 +2022-06-16,GLD,108.01898702690048,110.09587286938826,107.47889209176596,109.54813220834654,109.54813220834654,1000000.0 +2022-06-17,GLD,109.54813220834654,112.25323502875683,109.00039154730482,111.69476122264363,111.69476122264363,1000000.0 +2022-06-21,GLD,110.22758329424384,111.49691422440029,109.6764453777726,110.9422032083585,110.9422032083585,1000000.0 +2022-06-22,GLD,110.9422032083585,111.49691422440029,109.47382318145782,110.02394289593752,110.02394289593752,1000000.0 +2022-06-23,GLD,110.02394289593752,110.5740626104172,107.97910624122052,108.52171481529702,108.52171481529702,1000000.0 +2022-06-24,GLD,108.52171481529702,109.06432338937348,107.33882849216492,107.87821959011548,107.87821959011548,1000000.0 +2022-06-27,GLD,107.87821959011548,109.30263846691643,107.33882849216492,108.75884424568802,108.75884424568802,1000000.0 +2022-06-28,GLD,108.75884424568802,109.30263846691643,107.81099616834334,108.35275996818426,108.35275996818426,1000000.0 +2022-06-29,GLD,108.35275996818426,108.89452376802517,107.25951218293017,107.79850470646248,107.79850470646248,1000000.0 +2022-06-30,GLD,107.79850470646248,109.64944260399302,107.25951218293017,109.1039229890478,109.1039229890478,1000000.0 +2022-07-01,GLD,109.1039229890478,110.09409545281316,108.55840337410255,109.54636363463996,109.54636363463996,1000000.0 +2022-07-05,GLD,109.41976292122666,111.7496922317145,108.87266410662052,111.19372361364628,111.19372361364628,1000000.0 +2022-07-06,GLD,111.19372361364628,112.22017216384296,110.63775499557804,111.661862849595,111.661862849595,1000000.0 +2022-07-07,GLD,111.661862849595,112.22017216384296,110.36298610035928,110.91757397021031,110.91757397021031,1000000.0 +2022-07-08,GLD,110.91757397021031,111.47216184006136,109.27884063076296,109.82798053343012,109.82798053343012,1000000.0 +2022-07-11,GLD,109.82798053343012,110.37712043609724,108.86758269101551,109.41465597086987,109.41465597086987,1000000.0 +2022-07-12,GLD,109.41465597086987,110.1415611641263,108.86758269101551,109.59359319813564,109.59359319813564,1000000.0 +2022-07-13,GLD,109.59359319813564,111.10101316366875,109.04562523214496,110.54827180464557,110.54827180464557,1000000.0 +2022-07-14,GLD,110.54827180464557,111.97657526493953,109.99553044562232,111.41947787556172,111.41947787556172,1000000.0 +2022-07-15,GLD,111.41947787556172,112.11975885293823,110.86238048618392,111.56194910740123,111.56194910740123,1000000.0 +2022-07-18,GLD,111.56194910740123,112.11975885293823,109.86150816831189,110.41357604855465,110.41357604855465,1000000.0 +2022-07-19,GLD,110.41357604855465,111.93344401451226,109.86150816831189,111.37656120846992,111.37656120846992,1000000.0 +2022-07-20,GLD,111.37656120846992,112.05672973004238,110.81967840242756,111.49923356223124,111.49923356223124,1000000.0 2022-07-21,GLD,111.49923356223124,112.05672973004238,109.87718179498962,110.4293284371755,110.4293284371755,1000000.0 -2022-07-22,GLD,110.4293284371755,110.98147507936136,108.67907665762277,109.22520267097767,109.22520267097767,1000000.0 -2022-07-25,GLD,109.22520267097767,110.01158674258164,108.67907665762277,109.46426541550413,109.46426541550413,1000000.0 -2022-07-26,GLD,109.46426541550413,110.37379261628777,108.9169440884266,109.8246692699381,109.8246692699381,1000000.0 -2022-07-27,GLD,109.8246692699381,111.20357236545782,109.27554592358841,110.65032076164958,110.65032076164958,1000000.0 +2022-07-22,GLD,110.4293284371755,110.98147507936136,108.67907665762276,109.22520267097768,109.22520267097768,1000000.0 +2022-07-25,GLD,109.22520267097768,110.01158674258164,108.67907665762276,109.46426541550412,109.46426541550412,1000000.0 +2022-07-26,GLD,109.46426541550412,110.37379261628776,108.9169440884266,109.8246692699381,109.8246692699381,1000000.0 +2022-07-27,GLD,109.8246692699381,111.20357236545782,109.2755459235884,110.65032076164958,110.65032076164958,1000000.0 2022-07-28,GLD,110.65032076164958,111.20357236545782,108.89853294373546,109.44576175249794,109.44576175249794,1000000.0 -2022-07-29,GLD,109.44576175249794,111.92904099983073,108.89853294373546,111.37218009933407,111.37218009933407,1000000.0 -2022-08-01,GLD,111.37218009933407,111.92904099983073,109.01166184611003,109.55945914181913,109.55945914181913,1000000.0 -2022-08-02,GLD,109.55945914181913,110.10725643752822,108.97618360039358,109.52380261346089,109.52380261346089,1000000.0 -2022-08-03,GLD,109.52380261346089,111.41875864897807,108.97618360039358,110.86443646664486,110.86443646664486,1000000.0 +2022-07-29,GLD,109.44576175249794,111.92904099983072,108.89853294373546,111.37218009933407,111.37218009933407,1000000.0 +2022-08-01,GLD,111.37218009933407,111.92904099983072,109.01166184611004,109.55945914181912,109.55945914181912,1000000.0 +2022-08-02,GLD,109.55945914181912,110.10725643752822,108.97618360039358,109.52380261346087,109.52380261346087,1000000.0 +2022-08-03,GLD,109.52380261346087,111.41875864897808,108.97618360039358,110.86443646664486,110.86443646664486,1000000.0 2022-08-04,GLD,110.86443646664486,112.4759472352717,110.31011428431164,111.91636540823056,111.91636540823056,1000000.0 2022-08-05,GLD,111.91636540823056,112.4759472352717,110.39316394883124,110.94790346616206,110.94790346616206,1000000.0 2022-08-08,GLD,110.94790346616206,112.11701858037186,110.39316394883124,111.55922246803172,111.55922246803172,1000000.0 -2022-08-09,GLD,111.55922246803172,113.36942681109896,111.00142635569156,112.80539981203879,112.80539981203879,1000000.0 -2022-08-10,GLD,112.80539981203879,114.12271037912575,112.2413728129786,113.55493570062265,113.55493570062265,1000000.0 -2022-08-11,GLD,113.55493570062265,114.12271037912575,111.97462235695498,112.5373089014623,112.5373089014623,1000000.0 +2022-08-09,GLD,111.55922246803172,113.36942681109896,111.00142635569156,112.8053998120388,112.8053998120388,1000000.0 +2022-08-10,GLD,112.8053998120388,114.12271037912576,112.2413728129786,113.55493570062264,113.55493570062264,1000000.0 +2022-08-11,GLD,113.55493570062264,114.12271037912576,111.97462235695498,112.5373089014623,112.5373089014623,1000000.0 2022-08-12,GLD,112.5373089014623,114.81138607976423,111.97462235695498,114.24018515399428,114.24018515399428,1000000.0 2022-08-15,GLD,114.24018515399428,114.81138607976423,111.9882666246302,112.55102173329668,112.55102173329668,1000000.0 -2022-08-16,GLD,112.55102173329668,113.13884075033656,111.9882666246302,112.57596094560853,112.57596094560853,1000000.0 -2022-08-17,GLD,112.57596094560853,113.13884075033656,110.90883973629775,111.46617058924397,111.46617058924397,1000000.0 -2022-08-18,GLD,111.46617058924397,112.02350144219018,110.45306868008282,111.00810922621389,111.00810922621389,1000000.0 -2022-08-19,GLD,111.00810922621389,111.56314977234494,110.0924906203536,110.64571921643578,110.64571921643578,1000000.0 -2022-08-22,GLD,110.64571921643578,111.66978067628493,110.0924906203536,111.11420962814422,111.11420962814422,1000000.0 -2022-08-23,GLD,111.11420962814422,111.66978067628493,109.26336945723797,109.81243161531454,109.81243161531454,1000000.0 -2022-08-24,GLD,109.81243161531454,110.54300957901384,109.26336945723797,109.99304435722772,109.99304435722772,1000000.0 -2022-08-25,GLD,109.99304435722772,111.81217068734006,109.44307913544158,111.25589123118415,111.25589123118415,1000000.0 -2022-08-26,GLD,111.25589123118415,111.85234630889627,110.69961177502823,111.29586697402615,111.29586697402615,1000000.0 +2022-08-16,GLD,112.55102173329668,113.13884075033656,111.9882666246302,112.57596094560851,112.57596094560851,1000000.0 +2022-08-17,GLD,112.57596094560851,113.13884075033656,110.90883973629776,111.46617058924396,111.46617058924396,1000000.0 +2022-08-18,GLD,111.46617058924396,112.02350144219018,110.45306868008282,111.00810922621388,111.00810922621388,1000000.0 +2022-08-19,GLD,111.00810922621388,111.56314977234494,110.0924906203536,110.64571921643578,110.64571921643578,1000000.0 +2022-08-22,GLD,110.64571921643578,111.66978067628492,110.0924906203536,111.11420962814422,111.11420962814422,1000000.0 +2022-08-23,GLD,111.11420962814422,111.66978067628492,109.26336945723796,109.81243161531454,109.81243161531454,1000000.0 +2022-08-24,GLD,109.81243161531454,110.54300957901384,109.26336945723796,109.99304435722772,109.99304435722772,1000000.0 +2022-08-25,GLD,109.99304435722772,111.81217068734006,109.44307913544158,111.25589123118417,111.25589123118417,1000000.0 +2022-08-26,GLD,111.25589123118417,111.85234630889629,110.69961177502825,111.29586697402615,111.29586697402615,1000000.0 2022-08-29,GLD,111.29586697402615,112.36365532384848,110.73938763915602,111.80463216303332,111.80463216303332,1000000.0 -2022-08-30,GLD,111.80463216303332,112.76031615720508,111.24560900221815,112.19931955940805,112.19931955940805,1000000.0 -2022-08-31,GLD,112.19931955940805,112.76031615720508,110.84291489801065,111.39991447036246,111.39991447036246,1000000.0 +2022-08-30,GLD,111.80463216303332,112.76031615720508,111.24560900221816,112.19931955940804,112.19931955940804,1000000.0 +2022-08-31,GLD,112.19931955940804,112.76031615720508,110.84291489801063,111.39991447036246,111.39991447036246,1000000.0 2022-09-01,GLD,111.39991447036246,111.95691404271426,110.42755113608092,110.98246345334766,110.98246345334766,1000000.0 -2022-09-02,GLD,110.98246345334766,111.54158848439639,110.42755113608092,110.98665520835462,110.98665520835462,1000000.0 -2022-09-05,GLD,110.98665520835462,111.98040319725601,110.43172193231284,111.42328676343882,111.42328676343882,1000000.0 -2022-09-06,GLD,111.42328676343882,114.01742288220511,110.86617032962162,113.45017202209465,113.45017202209465,1000000.0 -2022-09-07,GLD,113.45017202209465,115.671531251442,112.88292116198417,115.09605099645972,115.09605099645972,1000000.0 +2022-09-02,GLD,110.98246345334766,111.5415884843964,110.42755113608092,110.98665520835462,110.98665520835462,1000000.0 +2022-09-06,GLD,111.42328676343882,114.01742288220512,110.86617032962162,113.45017202209463,113.45017202209463,1000000.0 +2022-09-07,GLD,113.45017202209463,115.671531251442,112.88292116198416,115.09605099645972,115.09605099645972,1000000.0 2022-09-08,GLD,115.09605099645972,115.671531251442,113.98999486980814,114.56280891438004,114.56280891438004,1000000.0 -2022-09-09,GLD,114.56280891438004,115.13562295895193,113.49595407790856,114.06628550543574,114.06628550543574,1000000.0 -2022-09-12,GLD,114.06628550543574,114.63661693296291,112.40797604290697,112.97284024412761,112.97284024412761,1000000.0 -2022-09-13,GLD,112.97284024412761,113.53770444534824,110.0245968429773,110.5774842642988,110.5774842642988,1000000.0 -2022-09-14,GLD,110.5774842642988,112.41132401545084,110.0245968429773,111.85206369696603,111.85206369696603,1000000.0 -2022-09-15,GLD,111.85206369696603,112.90542758474025,111.2928033784812,112.34370903954255,112.34370903954255,1000000.0 -2022-09-16,GLD,112.34370903954255,112.90542758474025,109.77434398834293,110.32597385763108,110.32597385763108,1000000.0 +2022-09-09,GLD,114.56280891438004,115.13562295895191,113.49595407790856,114.06628550543574,114.06628550543574,1000000.0 +2022-09-12,GLD,114.06628550543574,114.63661693296292,112.40797604290697,112.9728402441276,112.9728402441276,1000000.0 +2022-09-13,GLD,112.9728402441276,113.53770444534824,110.0245968429773,110.5774842642988,110.5774842642988,1000000.0 +2022-09-14,GLD,110.5774842642988,112.41132401545084,110.0245968429773,111.85206369696604,111.85206369696604,1000000.0 +2022-09-15,GLD,111.85206369696604,112.90542758474024,111.2928033784812,112.34370903954256,112.34370903954256,1000000.0 +2022-09-16,GLD,112.34370903954256,112.90542758474024,109.77434398834292,110.32597385763108,110.32597385763108,1000000.0 2022-09-19,GLD,110.32597385763108,110.87760372691923,109.7407828170515,110.29224403723768,110.29224403723768,1000000.0 -2022-09-20,GLD,110.29224403723768,113.19584817001345,109.7407828170515,112.63268474628205,112.63268474628205,1000000.0 -2022-09-21,GLD,112.63268474628205,113.65388701164059,112.06952132255064,113.08844478770209,113.08844478770209,1000000.0 -2022-09-22,GLD,113.08844478770209,114.13362698568747,112.52300256376358,113.56579799570893,113.56579799570893,1000000.0 -2022-09-23,GLD,113.56579799570893,114.30422199537554,112.99796900573038,113.73554427400552,113.73554427400552,1000000.0 -2022-09-26,GLD,113.73554427400552,114.30422199537554,112.34167177646097,112.90620279041303,112.90620279041303,1000000.0 -2022-09-27,GLD,112.90620279041303,113.47073380436508,111.11327760097247,111.67163577987182,111.67163577987182,1000000.0 -2022-09-28,GLD,111.67163577987182,112.22999395877117,110.23935646572124,110.79332308112689,110.79332308112689,1000000.0 -2022-09-29,GLD,110.79332308112689,111.46090237472269,110.23935646572124,110.90637052211214,110.90637052211214,1000000.0 -2022-09-30,GLD,110.90637052211214,111.46090237472269,110.07668267173631,110.62983183089077,110.62983183089077,1000000.0 -2022-10-03,GLD,110.62983183089077,111.85753971711235,110.07668267173631,111.3010345443904,111.3010345443904,1000000.0 -2022-10-04,GLD,111.3010345443904,113.63205255499847,110.74452937166845,113.06671896019749,113.06671896019749,1000000.0 -2022-10-05,GLD,113.06671896019749,114.22179561489418,112.5013853653965,113.65352797501909,113.65352797501909,1000000.0 -2022-10-06,GLD,113.65352797501909,115.07275755265636,113.08526033514399,114.50025627129988,114.50025627129988,1000000.0 +2022-09-20,GLD,110.29224403723768,113.19584817001343,109.7407828170515,112.63268474628204,112.63268474628204,1000000.0 +2022-09-21,GLD,112.63268474628204,113.6538870116406,112.06952132255064,113.08844478770207,113.08844478770207,1000000.0 +2022-09-22,GLD,113.08844478770207,114.13362698568748,112.52300256376358,113.56579799570892,113.56579799570892,1000000.0 +2022-09-23,GLD,113.56579799570892,114.30422199537554,112.99796900573038,113.73554427400552,113.73554427400552,1000000.0 +2022-09-26,GLD,113.73554427400552,114.30422199537554,112.34167177646096,112.90620279041303,112.90620279041303,1000000.0 +2022-09-27,GLD,112.90620279041303,113.47073380436508,111.11327760097248,111.67163577987182,111.67163577987182,1000000.0 +2022-09-28,GLD,111.67163577987182,112.22999395877116,110.23935646572124,110.79332308112689,110.79332308112689,1000000.0 +2022-09-29,GLD,110.79332308112689,111.46090237472268,110.23935646572124,110.90637052211214,110.90637052211214,1000000.0 +2022-09-30,GLD,110.90637052211214,111.46090237472268,110.07668267173632,110.62983183089077,110.62983183089077,1000000.0 +2022-10-03,GLD,110.62983183089077,111.85753971711236,110.07668267173632,111.3010345443904,111.3010345443904,1000000.0 +2022-10-04,GLD,111.3010345443904,113.63205255499848,110.74452937166843,113.06671896019748,113.06671896019748,1000000.0 +2022-10-05,GLD,113.06671896019748,114.22179561489418,112.5013853653965,113.65352797501907,113.65352797501907,1000000.0 +2022-10-06,GLD,113.65352797501907,115.07275755265636,113.085260335144,114.50025627129988,114.50025627129988,1000000.0 2022-10-07,GLD,114.50025627129988,115.07275755265636,113.54093966135072,114.11149714708615,114.11149714708615,1000000.0 -2022-10-10,GLD,114.11149714708615,114.68205463282158,112.18981188065128,112.75357977954903,112.75357977954903,1000000.0 -2022-10-11,GLD,112.75357977954903,115.55522476872738,112.18981188065128,114.98032315296258,114.98032315296258,1000000.0 -2022-10-12,GLD,114.98032315296258,116.30492103905586,114.40542153719777,115.72628959110037,115.72628959110037,1000000.0 -2022-10-13,GLD,115.72628959110037,117.55018209864815,115.14765814314487,116.96535532203798,116.96535532203798,1000000.0 -2022-10-14,GLD,116.96535532203798,117.55018209864815,115.96839299757944,116.55114874128587,116.55114874128587,1000000.0 -2022-10-17,GLD,116.55114874128587,117.13390448499229,114.17569601504088,114.74944323119686,114.74944323119686,1000000.0 -2022-10-18,GLD,114.74944323119686,116.70595135686375,114.17569601504088,116.12532473319777,116.12532473319777,1000000.0 -2022-10-19,GLD,116.12532473319777,116.70595135686375,113.28573238954719,113.8550074266806,113.8550074266806,1000000.0 -2022-10-20,GLD,113.8550074266806,114.424282463814,113.10136815573819,113.66971673943536,113.66971673943536,1000000.0 -2022-10-21,GLD,113.66971673943536,114.23806532313252,112.53844411199833,113.10396393165661,113.10396393165661,1000000.0 -2022-10-24,GLD,113.10396393165661,113.88705641217354,112.53844411199833,113.32045414146621,113.32045414146621,1000000.0 -2022-10-25,GLD,113.32045414146621,114.71957643554774,112.75385187075888,114.14883227417687,114.14883227417687,1000000.0 -2022-10-26,GLD,114.14883227417687,115.56932841943255,113.57808811280599,114.99435663625131,114.99435663625131,1000000.0 -2022-10-27,GLD,114.99435663625131,115.56932841943255,112.23540648036465,112.79940349785392,112.79940349785392,1000000.0 -2022-10-28,GLD,112.79940349785392,114.37871251427902,112.23540648036465,113.80966419331247,113.80966419331247,1000000.0 -2022-10-31,GLD,113.80966419331247,114.37871251427902,111.27687768552892,111.83605797540595,111.83605797540595,1000000.0 -2022-11-01,GLD,111.83605797540595,113.07525156973936,111.27687768552892,112.5126881290939,112.5126881290939,1000000.0 +2022-10-10,GLD,114.11149714708615,114.68205463282158,112.18981188065128,112.75357977954904,112.75357977954904,1000000.0 +2022-10-11,GLD,112.75357977954904,115.55522476872738,112.18981188065128,114.98032315296258,114.98032315296258,1000000.0 +2022-10-12,GLD,114.98032315296258,116.30492103905586,114.40542153719775,115.72628959110035,115.72628959110035,1000000.0 +2022-10-13,GLD,115.72628959110035,117.55018209864816,115.14765814314488,116.96535532203798,116.96535532203798,1000000.0 +2022-10-14,GLD,116.96535532203798,117.55018209864816,115.96839299757944,116.55114874128589,116.55114874128589,1000000.0 +2022-10-17,GLD,116.55114874128589,117.13390448499229,114.17569601504088,114.74944323119686,114.74944323119686,1000000.0 +2022-10-18,GLD,114.74944323119686,116.70595135686376,114.17569601504088,116.12532473319776,116.12532473319776,1000000.0 +2022-10-19,GLD,116.12532473319776,116.70595135686376,113.2857323895472,113.8550074266806,113.8550074266806,1000000.0 +2022-10-20,GLD,113.8550074266806,114.424282463814,113.1013681557382,113.66971673943536,113.66971673943536,1000000.0 +2022-10-21,GLD,113.66971673943536,114.23806532313252,112.53844411199832,113.1039639316566,113.1039639316566,1000000.0 +2022-10-24,GLD,113.1039639316566,113.88705641217354,112.53844411199832,113.3204541414662,113.3204541414662,1000000.0 +2022-10-25,GLD,113.3204541414662,114.71957643554774,112.75385187075888,114.14883227417688,114.14883227417688,1000000.0 +2022-10-26,GLD,114.14883227417688,115.56932841943257,113.578088112806,114.99435663625133,114.99435663625133,1000000.0 +2022-10-27,GLD,114.99435663625133,115.56932841943257,112.23540648036465,112.79940349785392,112.79940349785392,1000000.0 +2022-10-28,GLD,112.79940349785392,114.37871251427902,112.23540648036465,113.80966419331249,113.80966419331249,1000000.0 +2022-10-31,GLD,113.80966419331249,114.37871251427902,111.27687768552892,111.83605797540596,111.83605797540596,1000000.0 +2022-11-01,GLD,111.83605797540596,113.07525156973936,111.27687768552892,112.5126881290939,112.5126881290939,1000000.0 2022-11-02,GLD,112.5126881290939,113.07525156973936,111.48834086047302,112.04858377936986,112.04858377936986,1000000.0 -2022-11-03,GLD,112.04858377936986,112.6088266982667,110.32393042328651,110.87832203345378,110.87832203345378,1000000.0 -2022-11-04,GLD,110.87832203345378,111.43271364362104,109.77720162627793,110.32884585555571,110.32884585555571,1000000.0 -2022-11-07,GLD,110.32884585555571,110.88049008483348,108.44727012392951,108.99223128033117,108.99223128033117,1000000.0 -2022-11-08,GLD,108.99223128033117,109.53719243673281,108.42166662836834,108.96649912398829,108.96649912398829,1000000.0 +2022-11-03,GLD,112.04858377936986,112.6088266982667,110.32393042328653,110.87832203345378,110.87832203345378,1000000.0 +2022-11-04,GLD,110.87832203345378,111.43271364362104,109.77720162627791,110.32884585555573,110.32884585555573,1000000.0 +2022-11-07,GLD,110.32884585555573,110.88049008483348,108.44727012392951,108.99223128033115,108.99223128033115,1000000.0 +2022-11-08,GLD,108.99223128033115,109.5371924367328,108.42166662836834,108.96649912398829,108.96649912398829,1000000.0 2022-11-09,GLD,108.96649912398829,110.4430341797752,108.42166662836834,109.89356634803504,109.89356634803504,1000000.0 -2022-11-10,GLD,109.89356634803504,111.24108077733213,109.34409851629486,110.68764256450959,110.68764256450959,1000000.0 -2022-11-11,GLD,110.68764256450959,111.27330336017799,110.13420435168705,110.71970483599802,110.71970483599802,1000000.0 -2022-11-14,GLD,110.71970483599802,112.38504681016106,110.16610631181803,111.82591722404086,111.82591722404086,1000000.0 +2022-11-10,GLD,109.89356634803504,111.24108077733212,109.34409851629486,110.6876425645096,110.6876425645096,1000000.0 +2022-11-11,GLD,110.6876425645096,111.273303360178,110.13420435168705,110.71970483599802,110.71970483599802,1000000.0 +2022-11-14,GLD,110.71970483599802,112.38504681016106,110.16610631181804,111.82591722404086,111.82591722404086,1000000.0 2022-11-15,GLD,111.82591722404086,112.8928569826865,111.26678763792066,112.33120097779752,112.33120097779752,1000000.0 -2022-11-16,GLD,112.33120097779752,115.1283915854262,111.76954497290853,114.55561351783703,114.55561351783703,1000000.0 -2022-11-17,GLD,114.55561351783703,115.66410926134944,113.98283545024785,115.088665931691,115.088665931691,1000000.0 +2022-11-16,GLD,112.33120097779752,115.1283915854262,111.76954497290852,114.55561351783705,114.55561351783705,1000000.0 +2022-11-17,GLD,114.55561351783705,115.66410926134944,113.98283545024783,115.088665931691,115.088665931691,1000000.0 2022-11-18,GLD,115.088665931691,115.66410926134944,113.88178360017923,114.45405386952687,114.45405386952687,1000000.0 -2022-11-21,GLD,114.45405386952687,115.0263241388745,113.57042109904201,114.14112673270553,114.14112673270553,1000000.0 -2022-11-22,GLD,114.14112673270553,114.71183236636905,112.7750385623575,113.34174729885176,113.34174729885176,1000000.0 -2022-11-23,GLD,113.34174729885176,113.908456035346,112.46493811987561,113.03008856268906,113.03008856268906,1000000.0 -2022-11-24,GLD,113.03008856268906,113.59523900550249,112.3064168281467,112.87077068155446,112.87077068155446,1000000.0 -2022-11-25,GLD,112.87077068155446,114.13837092486503,112.3064168281467,113.57051833319905,113.57051833319905,1000000.0 -2022-11-28,GLD,113.57051833319905,114.94565071330413,113.00266574153305,114.37378180428273,114.37378180428273,1000000.0 -2022-11-29,GLD,114.37378180428273,114.9909404846046,113.80191289526131,114.41884625333792,114.41884625333792,1000000.0 -2022-11-30,GLD,114.41884625333792,115.42804507226772,113.84675202207123,114.85377619131118,114.85377619131118,1000000.0 -2022-12-01,GLD,114.85377619131118,115.42804507226772,113.50111285320163,114.07147020422275,114.07147020422275,1000000.0 -2022-12-02,GLD,114.07147020422275,114.64182755524385,112.9223524975863,113.48980150511186,113.48980150511186,1000000.0 -2022-12-05,GLD,113.48980150511186,114.05725051263741,110.39519535831784,110.94994508373652,110.94994508373652,1000000.0 +2022-11-21,GLD,114.45405386952687,115.0263241388745,113.570421099042,114.14112673270552,114.14112673270552,1000000.0 +2022-11-22,GLD,114.14112673270552,114.71183236636904,112.7750385623575,113.34174729885176,113.34174729885176,1000000.0 +2022-11-23,GLD,113.34174729885176,113.908456035346,112.4649381198756,113.03008856268906,113.03008856268906,1000000.0 +2022-11-25,GLD,112.87077068155446,114.13837092486504,112.3064168281467,113.57051833319905,113.57051833319905,1000000.0 +2022-11-28,GLD,113.57051833319905,114.94565071330412,113.00266574153304,114.37378180428271,114.37378180428271,1000000.0 +2022-11-29,GLD,114.37378180428271,114.9909404846046,113.80191289526132,114.41884625333792,114.41884625333792,1000000.0 +2022-11-30,GLD,114.41884625333792,115.42804507226772,113.84675202207124,114.85377619131118,114.85377619131118,1000000.0 +2022-12-01,GLD,114.85377619131118,115.42804507226772,113.50111285320163,114.07147020422276,114.07147020422276,1000000.0 +2022-12-02,GLD,114.07147020422276,114.64182755524384,112.9223524975863,113.48980150511186,113.48980150511186,1000000.0 +2022-12-05,GLD,113.48980150511186,114.0572505126374,110.39519535831784,110.94994508373652,110.94994508373652,1000000.0 2022-12-06,GLD,110.94994508373652,111.5046948091552,109.60222158450463,110.15298651709008,110.15298651709008,1000000.0 -2022-12-07,GLD,110.15298651709008,110.70375144967552,109.09285237287297,109.64105766117886,109.64105766117886,1000000.0 -2022-12-08,GLD,109.64105766117886,112.79835935538416,109.09285237287297,112.23717348794446,112.23717348794446,1000000.0 -2022-12-09,GLD,112.23717348794446,112.79835935538416,110.28962148670611,110.8438406901569,110.8438406901569,1000000.0 -2022-12-12,GLD,110.8438406901569,111.4525796452106,110.28962148670611,110.89808919921454,110.89808919921454,1000000.0 +2022-12-07,GLD,110.15298651709008,110.70375144967552,109.09285237287295,109.64105766117886,109.64105766117886,1000000.0 +2022-12-08,GLD,109.64105766117886,112.79835935538416,109.09285237287295,112.23717348794446,112.23717348794446,1000000.0 +2022-12-09,GLD,112.23717348794446,112.79835935538416,110.28962148670612,110.8438406901569,110.8438406901569,1000000.0 +2022-12-12,GLD,110.8438406901569,111.4525796452106,110.28962148670612,110.89808919921454,110.89808919921454,1000000.0 2022-12-13,GLD,110.89808919921454,111.4525796452106,109.99613662363537,110.54888102877926,110.54888102877926,1000000.0 -2022-12-14,GLD,110.54888102877926,111.10162543392315,108.40645880763743,108.95121488204768,108.95121488204768,1000000.0 -2022-12-15,GLD,108.95121488204768,109.74555763392428,108.40645880763743,109.19955983475053,109.19955983475053,1000000.0 -2022-12-16,GLD,109.19955983475053,109.74555763392428,108.65339070603002,109.19938764425127,109.19938764425127,1000000.0 -2022-12-19,GLD,109.19938764425127,109.74538458247252,107.198199814686,107.73688423586533,107.73688423586533,1000000.0 -2022-12-20,GLD,107.73688423586533,108.27556865704464,104.9227540421607,105.45000406247307,105.45000406247307,1000000.0 -2022-12-21,GLD,105.45000406247307,105.97725408278542,104.85504892006281,105.38195871363097,105.38195871363097,1000000.0 +2022-12-14,GLD,110.54888102877926,111.10162543392316,108.40645880763743,108.95121488204768,108.95121488204768,1000000.0 +2022-12-15,GLD,108.95121488204768,109.74555763392428,108.40645880763743,109.19955983475052,109.19955983475052,1000000.0 +2022-12-16,GLD,109.19955983475052,109.74555763392428,108.65339070603002,109.19938764425127,109.19938764425127,1000000.0 +2022-12-19,GLD,109.19938764425127,109.74538458247252,107.198199814686,107.73688423586532,107.73688423586532,1000000.0 +2022-12-20,GLD,107.73688423586532,108.27556865704464,104.9227540421607,105.45000406247308,105.45000406247308,1000000.0 +2022-12-21,GLD,105.45000406247308,105.97725408278542,104.8550489200628,105.38195871363097,105.38195871363097,1000000.0 2022-12-22,GLD,105.38195871363097,105.9088685071991,103.4790599829055,103.9990552592015,103.9990552592015,1000000.0 -2022-12-23,GLD,103.9990552592015,105.15433853011893,103.4790599829055,104.63118261703377,104.63118261703377,1000000.0 -2022-12-26,GLD,104.63118261703377,105.15433853011893,103.83531958907662,104.35710511464987,104.35710511464987,1000000.0 -2022-12-27,GLD,104.35710511464987,105.3833164554523,103.83531958907662,104.85902134870877,104.85902134870877,1000000.0 +2022-12-23,GLD,103.9990552592015,105.15433853011892,103.4790599829055,104.63118261703376,104.63118261703376,1000000.0 +2022-12-27,GLD,104.35710511464988,105.3833164554523,103.83531958907662,104.85902134870877,104.85902134870877,1000000.0 2022-12-28,GLD,104.85902134870877,105.3833164554523,104.037284230614,104.56008465388342,104.56008465388342,1000000.0 2022-12-29,GLD,104.56008465388342,106.57268711955334,104.037284230614,106.04247474582422,106.04247474582422,1000000.0 -2022-12-30,GLD,106.04247474582422,107.24696139610373,105.5122623720951,106.71339442398381,106.71339442398381,1000000.0 -2023-01-02,GLD,106.71339442398381,108.75627146039245,106.1798274518639,108.21519548297758,108.21519548297758,1000000.0 +2022-12-30,GLD,106.04247474582422,107.24696139610371,105.5122623720951,106.7133944239838,106.7133944239838,1000000.0 2023-01-03,GLD,108.21519548297758,108.87717859588568,107.67411950556269,108.33550109043352,108.33550109043352,1000000.0 2023-01-04,GLD,108.33550109043352,108.87717859588568,106.7945863998924,107.33124261295718,107.33124261295718,1000000.0 -2023-01-05,GLD,107.33124261295718,107.86789882602196,105.4242635572806,105.95403372591015,105.95403372591015,1000000.0 -2023-01-06,GLD,105.95403372591015,106.48380389453969,104.94922035273478,105.4766033695827,105.4766033695827,1000000.0 +2023-01-05,GLD,107.33124261295718,107.86789882602196,105.4242635572806,105.95403372591016,105.95403372591016,1000000.0 +2023-01-06,GLD,105.95403372591016,106.48380389453968,104.94922035273478,105.4766033695827,105.4766033695827,1000000.0 2023-01-09,GLD,105.4766033695827,106.29779357943718,104.94922035273478,105.76894883526089,105.76894883526089,1000000.0 -2023-01-10,GLD,105.76894883526089,106.29779357943718,104.64760735038051,105.17347472400051,105.17347472400051,1000000.0 -2023-01-11,GLD,105.17347472400051,108.1183972713572,104.64760735038051,107.58049479737035,107.58049479737035,1000000.0 -2023-01-12,GLD,107.58049479737035,108.1183972713572,106.996871132017,107.53454385127337,107.53454385127337,1000000.0 -2023-01-13,GLD,107.53454385127337,108.54857262620132,106.996871132017,108.00852997631974,108.00852997631974,1000000.0 -2023-01-16,GLD,108.00852997631974,108.65480104951578,107.46848732643814,108.11422990001572,108.11422990001572,1000000.0 +2023-01-10,GLD,105.76894883526089,106.29779357943718,104.64760735038053,105.17347472400051,105.17347472400051,1000000.0 +2023-01-11,GLD,105.17347472400051,108.1183972713572,104.64760735038053,107.58049479737036,107.58049479737036,1000000.0 +2023-01-12,GLD,107.58049479737036,108.1183972713572,106.996871132017,107.53454385127336,107.53454385127336,1000000.0 +2023-01-13,GLD,107.53454385127336,108.54857262620132,106.996871132017,108.00852997631974,108.00852997631974,1000000.0 2023-01-17,GLD,108.11422990001572,108.65480104951578,106.3652188458785,106.89971743304372,106.89971743304372,1000000.0 2023-01-18,GLD,106.89971743304372,108.50993181080062,106.3652188458785,107.97008140378172,107.97008140378172,1000000.0 -2023-01-19,GLD,107.97008140378172,109.18739753234415,107.43023099676282,108.64417664909867,108.64417664909867,1000000.0 -2023-01-20,GLD,108.64417664909867,110.09014514899619,108.10095576585317,109.54243298407582,109.54243298407582,1000000.0 -2023-01-23,GLD,109.54243298407582,110.09014514899619,108.09999755035511,108.64321361844735,108.64321361844735,1000000.0 -2023-01-24,GLD,108.64321361844735,109.24275290873844,108.09999755035511,108.6992566256104,108.6992566256104,1000000.0 -2023-01-25,GLD,108.6992566256104,109.24285299990875,108.15576034248235,108.69935621881469,108.69935621881469,1000000.0 -2023-01-26,GLD,108.69935621881469,110.46451574483568,108.15585943772061,109.9149410396375,109.9149410396375,1000000.0 +2023-01-19,GLD,107.97008140378172,109.18739753234416,107.43023099676282,108.64417664909868,108.64417664909868,1000000.0 +2023-01-20,GLD,108.64417664909868,110.0901451489962,108.10095576585316,109.54243298407582,109.54243298407582,1000000.0 +2023-01-23,GLD,109.54243298407582,110.0901451489962,108.09999755035513,108.64321361844736,108.64321361844736,1000000.0 +2023-01-24,GLD,108.64321361844736,109.24275290873844,108.09999755035513,108.6992566256104,108.6992566256104,1000000.0 +2023-01-25,GLD,108.6992566256104,109.24285299990876,108.15576034248237,108.69935621881469,108.69935621881469,1000000.0 +2023-01-26,GLD,108.69935621881469,110.46451574483568,108.1558594377206,109.9149410396375,109.9149410396375,1000000.0 2023-01-27,GLD,109.9149410396375,110.46451574483568,108.0297894896772,108.57265275344442,108.57265275344442,1000000.0 -2023-01-30,GLD,108.57265275344442,109.83770897340271,108.0297894896772,109.29125270985345,109.29125270985345,1000000.0 -2023-01-31,GLD,109.29125270985345,111.17418158852374,108.74479644630418,110.62107620748631,110.62107620748631,1000000.0 -2023-02-01,GLD,110.62107620748631,111.17418158852374,109.69578554141566,110.24702064463885,110.24702064463885,1000000.0 -2023-02-02,GLD,110.24702064463885,110.79825574786203,108.93187515922645,109.4792715168105,109.4792715168105,1000000.0 -2023-02-03,GLD,109.4792715168105,111.4304406197153,108.93187515922645,110.87606031812469,110.87606031812469,1000000.0 +2023-01-30,GLD,108.57265275344442,109.83770897340273,108.0297894896772,109.29125270985344,109.29125270985344,1000000.0 +2023-01-31,GLD,109.29125270985344,111.17418158852374,108.74479644630418,110.62107620748633,110.62107620748633,1000000.0 +2023-02-01,GLD,110.62107620748633,111.17418158852374,109.69578554141566,110.24702064463884,110.24702064463884,1000000.0 +2023-02-02,GLD,110.24702064463884,110.79825574786204,108.93187515922644,109.4792715168105,109.4792715168105,1000000.0 +2023-02-03,GLD,109.4792715168105,111.4304406197153,108.93187515922644,110.87606031812469,110.87606031812469,1000000.0 2023-02-06,GLD,110.87606031812469,111.73921255659606,110.32168001653406,111.183296076215,111.183296076215,1000000.0 -2023-02-07,GLD,111.183296076215,112.96606846643793,110.62737959583393,112.40404822531139,112.40404822531139,1000000.0 -2023-02-08,GLD,112.40404822531139,113.81525560161978,111.84202798418482,113.24901054887542,113.24901054887542,1000000.0 -2023-02-09,GLD,113.24901054887542,115.00683041204603,112.68276549613104,114.43465712641397,114.43465712641397,1000000.0 -2023-02-10,GLD,114.43465712641397,115.00683041204603,113.74047755183234,114.31203774053502,114.31203774053502,1000000.0 -2023-02-13,GLD,114.31203774053502,114.88359792923768,111.61099381587529,112.1718530812817,112.1718530812817,1000000.0 -2023-02-14,GLD,112.1718530812817,113.42236813823025,111.61099381587529,112.85807774948285,112.85807774948285,1000000.0 -2023-02-15,GLD,112.85807774948285,113.42236813823025,111.65928655990703,112.22038850241913,112.22038850241913,1000000.0 -2023-02-16,GLD,112.22038850241913,113.49703239476496,111.65928655990703,112.9323705420547,112.9323705420547,1000000.0 -2023-02-17,GLD,112.9323705420547,113.49703239476496,111.8973308540891,112.45962899908453,112.45962899908453,1000000.0 -2023-02-20,GLD,112.45962899908453,114.80452737399803,111.8973308540891,114.23336057114233,114.23336057114233,1000000.0 -2023-02-21,GLD,114.23336057114233,114.80452737399803,113.48078783043533,114.05104304566365,114.05104304566365,1000000.0 -2023-02-22,GLD,114.05104304566365,115.57827587758355,113.48078783043533,115.00325957968514,115.00325957968514,1000000.0 +2023-02-07,GLD,111.183296076215,112.96606846643792,110.62737959583392,112.4040482253114,112.4040482253114,1000000.0 +2023-02-08,GLD,112.4040482253114,113.81525560161978,111.84202798418482,113.24901054887542,113.24901054887542,1000000.0 +2023-02-09,GLD,113.24901054887542,115.00683041204604,112.68276549613104,114.43465712641397,114.43465712641397,1000000.0 +2023-02-10,GLD,114.43465712641397,115.00683041204604,113.74047755183234,114.31203774053502,114.31203774053502,1000000.0 +2023-02-13,GLD,114.31203774053502,114.88359792923768,111.61099381587528,112.1718530812817,112.1718530812817,1000000.0 +2023-02-14,GLD,112.1718530812817,113.42236813823024,111.61099381587528,112.85807774948285,112.85807774948285,1000000.0 +2023-02-15,GLD,112.85807774948285,113.42236813823024,111.65928655990704,112.22038850241913,112.22038850241913,1000000.0 +2023-02-16,GLD,112.22038850241913,113.49703239476496,111.65928655990704,112.9323705420547,112.9323705420547,1000000.0 +2023-02-17,GLD,112.9323705420547,113.49703239476496,111.8973308540891,112.45962899908452,112.45962899908452,1000000.0 +2023-02-21,GLD,114.23336057114231,114.80452737399804,113.48078783043532,114.05104304566365,114.05104304566365,1000000.0 +2023-02-22,GLD,114.05104304566365,115.57827587758356,113.48078783043532,115.00325957968514,115.00325957968514,1000000.0 2023-02-23,GLD,115.00325957968514,116.8458108397495,114.4282432817867,116.26448839776072,116.26448839776072,1000000.0 -2023-02-24,GLD,116.26448839776072,116.8458108397495,115.67869574427871,116.25999572289318,116.25999572289318,1000000.0 -2023-02-27,GLD,116.25999572289318,117.27438650569691,115.67869574427871,116.6909318464646,116.6909318464646,1000000.0 -2023-02-28,GLD,116.6909318464646,117.33821088277351,116.10747718723228,116.75443868932689,116.75443868932689,1000000.0 -2023-03-01,GLD,116.75443868932689,117.33821088277351,115.26383240062667,115.84304763882078,115.84304763882078,1000000.0 -2023-03-02,GLD,115.84304763882078,118.0938126745163,115.26383240062667,117.50628126817544,117.50628126817544,1000000.0 -2023-03-03,GLD,117.50628126817544,118.0938126745163,116.01547170757703,116.59846402771561,116.59846402771561,1000000.0 -2023-03-06,GLD,116.59846402771561,117.18145634785418,114.42970309738755,115.00472673104277,115.00472673104277,1000000.0 -2023-03-07,GLD,115.00472673104277,115.57975036469797,113.97096283268898,114.5436812388834,114.5436812388834,1000000.0 -2023-03-08,GLD,114.5436812388834,115.32659624676033,113.97096283268898,114.7528320863287,114.7528320863287,1000000.0 -2023-03-09,GLD,114.7528320863287,115.46907268289783,114.17906792589704,114.89459968447547,114.89459968447547,1000000.0 -2023-03-10,GLD,114.89459968447547,115.46907268289783,114.03153311828089,114.60455589776974,114.60455589776974,1000000.0 -2023-03-13,GLD,114.60455589776974,115.17757867725858,113.62309438702174,114.19406471057461,114.19406471057461,1000000.0 -2023-03-14,GLD,114.19406471057461,114.76503503412746,111.46459149138546,112.02471506671905,112.02471506671905,1000000.0 -2023-03-15,GLD,112.02471506671905,113.29922834405609,111.46459149138546,112.7355505911006,112.7355505911006,1000000.0 -2023-03-16,GLD,112.7355505911006,115.19041254443803,112.1718728381451,114.61732591486373,114.61732591486373,1000000.0 -2023-03-17,GLD,114.61732591486373,115.19041254443803,113.34020960776755,113.90975839976637,113.90975839976637,1000000.0 -2023-03-20,GLD,113.90975839976637,114.4793071917652,111.53049680669594,112.09095156451853,112.09095156451853,1000000.0 -2023-03-21,GLD,112.09095156451853,113.48903570034844,111.53049680669594,112.92441363218751,112.92441363218751,1000000.0 -2023-03-22,GLD,112.92441363218751,113.48903570034844,112.23893024886624,112.80294497373491,112.80294497373491,1000000.0 -2023-03-23,GLD,112.80294497373491,113.36695969860357,108.2363809653351,108.78028237722121,108.78028237722121,1000000.0 -2023-03-24,GLD,108.78028237722121,109.32418378910731,107.53949897114596,108.07989846346328,108.07989846346328,1000000.0 -2023-03-27,GLD,108.07989846346328,108.62029795578059,106.1544842512241,106.687923870577,106.687923870577,1000000.0 -2023-03-28,GLD,106.687923870577,107.22136348992987,105.05957168715611,105.58750923332272,105.58750923332272,1000000.0 -2023-03-29,GLD,105.58750923332272,108.14475295300561,105.05957168715611,107.6067193562245,107.6067193562245,1000000.0 +2023-02-24,GLD,116.26448839776072,116.8458108397495,115.67869574427873,116.25999572289318,116.25999572289318,1000000.0 +2023-02-27,GLD,116.25999572289318,117.27438650569692,115.67869574427873,116.6909318464646,116.6909318464646,1000000.0 +2023-02-28,GLD,116.6909318464646,117.33821088277352,116.10747718723228,116.75443868932688,116.75443868932688,1000000.0 +2023-03-01,GLD,116.75443868932688,117.33821088277352,115.26383240062668,115.84304763882078,115.84304763882078,1000000.0 +2023-03-02,GLD,115.84304763882078,118.0938126745163,115.26383240062668,117.50628126817544,117.50628126817544,1000000.0 +2023-03-03,GLD,117.50628126817544,118.0938126745163,116.01547170757703,116.5984640277156,116.5984640277156,1000000.0 +2023-03-06,GLD,116.5984640277156,117.18145634785418,114.42970309738756,115.00472673104277,115.00472673104277,1000000.0 +2023-03-07,GLD,115.00472673104277,115.57975036469796,113.97096283268898,114.5436812388834,114.5436812388834,1000000.0 +2023-03-08,GLD,114.5436812388834,115.32659624676032,113.97096283268898,114.7528320863287,114.7528320863287,1000000.0 +2023-03-09,GLD,114.7528320863287,115.46907268289785,114.17906792589704,114.89459968447548,114.89459968447548,1000000.0 +2023-03-10,GLD,114.89459968447548,115.46907268289785,114.03153311828088,114.60455589776974,114.60455589776974,1000000.0 +2023-03-13,GLD,114.60455589776974,115.17757867725858,113.62309438702174,114.1940647105746,114.1940647105746,1000000.0 +2023-03-14,GLD,114.1940647105746,114.76503503412746,111.46459149138546,112.02471506671904,112.02471506671904,1000000.0 +2023-03-15,GLD,112.02471506671904,113.29922834405608,111.46459149138546,112.7355505911006,112.7355505911006,1000000.0 +2023-03-16,GLD,112.7355505911006,115.19041254443805,112.1718728381451,114.61732591486371,114.61732591486371,1000000.0 +2023-03-17,GLD,114.61732591486371,115.19041254443805,113.34020960776756,113.90975839976636,113.90975839976636,1000000.0 +2023-03-20,GLD,113.90975839976636,114.4793071917652,111.53049680669594,112.09095156451852,112.09095156451852,1000000.0 +2023-03-21,GLD,112.09095156451852,113.48903570034844,111.53049680669594,112.92441363218752,112.92441363218752,1000000.0 +2023-03-22,GLD,112.92441363218752,113.48903570034844,112.23893024886624,112.80294497373492,112.80294497373492,1000000.0 +2023-03-23,GLD,112.80294497373492,113.36695969860357,108.2363809653351,108.7802823772212,108.7802823772212,1000000.0 +2023-03-24,GLD,108.7802823772212,109.32418378910732,107.53949897114596,108.07989846346328,108.07989846346328,1000000.0 +2023-03-27,GLD,108.07989846346328,108.6202979557806,106.1544842512241,106.687923870577,106.687923870577,1000000.0 +2023-03-28,GLD,106.687923870577,107.22136348992989,105.05957168715612,105.58750923332272,105.58750923332272,1000000.0 +2023-03-29,GLD,105.58750923332272,108.1447529530056,105.05957168715612,107.6067193562245,107.6067193562245,1000000.0 2023-03-30,GLD,107.6067193562245,109.19628134544506,107.06868575944338,108.65301626412445,108.65301626412445,1000000.0 -2023-03-31,GLD,108.65301626412445,109.19628134544506,108.01117266188047,108.55394237374921,108.55394237374921,1000000.0 -2023-04-03,GLD,108.55394237374921,109.17599213425277,108.01117266188047,108.63282799428137,108.63282799428137,1000000.0 -2023-04-04,GLD,108.63282799428137,110.847002008478,108.08966385430996,110.29552438654528,110.29552438654528,1000000.0 -2023-04-05,GLD,110.29552438654528,110.847002008478,108.70084126413285,109.2470766473697,109.2470766473697,1000000.0 -2023-04-06,GLD,109.2470766473697,110.62045024360513,108.70084126413285,110.07009974488074,110.07009974488074,1000000.0 -2023-04-07,GLD,110.07009974488074,110.62045024360513,108.9442954073423,109.49175417823346,109.49175417823346,1000000.0 +2023-03-31,GLD,108.65301626412445,109.19628134544506,108.01117266188048,108.5539423737492,108.5539423737492,1000000.0 +2023-04-03,GLD,108.5539423737492,109.17599213425277,108.01117266188048,108.63282799428136,108.63282799428136,1000000.0 +2023-04-04,GLD,108.63282799428136,110.847002008478,108.08966385430996,110.29552438654528,110.29552438654528,1000000.0 +2023-04-05,GLD,110.29552438654528,110.847002008478,108.70084126413283,109.2470766473697,109.2470766473697,1000000.0 +2023-04-06,GLD,109.2470766473697,110.62045024360512,108.70084126413283,110.07009974488074,110.07009974488074,1000000.0 2023-04-10,GLD,109.49175417823346,110.97004190828274,108.9442954073423,110.41795214754502,110.41795214754502,1000000.0 -2023-04-11,GLD,110.41795214754502,111.27455934686135,109.8658623868073,110.7209545739914,110.7209545739914,1000000.0 -2023-04-12,GLD,110.7209545739914,111.27455934686135,109.48281010933067,110.03297498425194,110.03297498425194,1000000.0 -2023-04-13,GLD,110.03297498425194,111.75827324471707,109.48281010933067,111.20226193504188,111.20226193504188,1000000.0 +2023-04-11,GLD,110.41795214754502,111.27455934686137,109.8658623868073,110.7209545739914,110.7209545739914,1000000.0 +2023-04-12,GLD,110.7209545739914,111.27455934686137,109.48281010933069,110.03297498425194,110.03297498425194,1000000.0 +2023-04-13,GLD,110.03297498425194,111.75827324471707,109.48281010933069,111.20226193504188,111.20226193504188,1000000.0 2023-04-14,GLD,111.20226193504188,112.40315827957556,110.64625062536666,111.84393858664237,111.84393858664237,1000000.0 -2023-04-17,GLD,111.84393858664237,114.42078881150985,111.28471889370915,113.85153115573121,113.85153115573121,1000000.0 -2023-04-18,GLD,113.85153115573121,114.42078881150985,112.93689259679968,113.50441467015044,113.50441467015044,1000000.0 +2023-04-17,GLD,111.84393858664237,114.42078881150984,111.28471889370915,113.8515311557312,113.8515311557312,1000000.0 +2023-04-18,GLD,113.8515311557312,114.42078881150984,112.93689259679968,113.50441467015044,113.50441467015044,1000000.0 2023-04-19,GLD,113.50441467015044,114.30104201684426,112.93689259679968,113.73238011626296,113.73238011626296,1000000.0 -2023-04-20,GLD,113.73238011626296,114.30104201684426,112.52160233224457,113.08703751984379,113.08703751984379,1000000.0 -2023-04-21,GLD,113.08703751984379,115.03048617971918,112.52160233224457,114.45819520370068,114.45819520370068,1000000.0 -2023-04-24,GLD,114.45819520370068,115.03048617971918,113.16769913887553,113.73638104409602,113.73638104409602,1000000.0 -2023-04-25,GLD,113.73638104409602,114.36877727159201,113.16769913887553,113.79977837969355,113.79977837969355,1000000.0 -2023-04-26,GLD,113.79977837969355,115.8457285903125,113.23077948779508,115.269381681903,115.269381681903,1000000.0 -2023-04-27,GLD,115.269381681903,116.49875037421279,114.69303477349348,115.91915460120677,115.91915460120677,1000000.0 -2023-04-28,GLD,115.91915460120677,116.89421978786872,115.33955882820074,116.31265650534202,116.31265650534202,1000000.0 -2023-05-01,GLD,116.31265650534202,117.26435724698077,115.7310932228153,116.68095248455799,116.68095248455799,1000000.0 -2023-05-02,GLD,116.68095248455799,117.71510568365936,116.0975477221352,117.12945839170087,117.12945839170087,1000000.0 -2023-05-03,GLD,117.12945839170087,118.5060284791219,116.54381109974236,117.91644624788249,117.91644624788249,1000000.0 -2023-05-04,GLD,117.91644624788249,118.5060284791219,117.07338197442179,117.66169042655457,117.66169042655457,1000000.0 -2023-05-05,GLD,117.66169042655457,118.24999887868734,115.00359309681676,115.58150059981584,115.58150059981584,1000000.0 -2023-05-08,GLD,115.58150059981584,117.68737286313453,115.00359309681676,117.1018635454075,117.1018635454075,1000000.0 -2023-05-09,GLD,117.1018635454075,117.68737286313453,115.44755933968871,116.02769782883287,116.02769782883287,1000000.0 -2023-05-10,GLD,116.02769782883287,118.69852328891652,115.44755933968871,118.10798337205625,118.10798337205625,1000000.0 -2023-05-11,GLD,118.10798337205625,119.09027425898972,117.51744345519597,118.49778533232809,118.49778533232809,1000000.0 -2023-05-12,GLD,118.49778533232809,122.7433018646574,117.90529640566645,122.13263867130091,122.13263867130091,1000000.0 -2023-05-15,GLD,122.13263867130091,124.17562466994487,121.52197547794441,123.55783549248247,123.55783549248247,1000000.0 -2023-05-16,GLD,123.55783549248247,124.74404770469779,122.94004631502006,124.12343055193811,124.12343055193811,1000000.0 -2023-05-17,GLD,124.12343055193811,125.89641777700109,123.50281339917842,125.27006743980209,125.27006743980209,1000000.0 -2023-05-18,GLD,125.27006743980209,125.89641777700109,123.10903315709666,123.72767151467,123.72767151467,1000000.0 -2023-05-19,GLD,123.72767151467,124.62533797662086,123.10903315709666,124.00531141952325,124.00531141952325,1000000.0 -2023-05-22,GLD,124.00531141952325,124.62533797662086,123.03963246736159,123.65792207775034,123.65792207775034,1000000.0 -2023-05-23,GLD,123.65792207775034,124.36681730714002,123.03963246736159,123.7480769225274,123.7480769225274,1000000.0 -2023-05-24,GLD,123.7480769225274,124.36681730714002,120.866040118798,121.47340715457085,121.47340715457085,1000000.0 -2023-05-25,GLD,121.47340715457085,122.08077419034369,118.47645304171259,119.0718121022237,119.0718121022237,1000000.0 -2023-05-26,GLD,119.0718121022237,120.52961432835086,118.47645304171259,119.92996450582176,119.92996450582176,1000000.0 -2023-05-29,GLD,119.92996450582176,120.63604893181542,119.33031468329264,120.03586958389596,120.03586958389596,1000000.0 -2023-05-30,GLD,120.03586958389596,120.63604893181542,118.99151032950101,119.58945761758896,119.58945761758896,1000000.0 -2023-05-31,GLD,119.58945761758896,120.1874049056769,118.93319797478597,119.5308522359658,119.5308522359658,1000000.0 -2023-06-01,GLD,119.5308522359658,120.12850649714561,118.2544070208034,118.84865027216422,118.84865027216422,1000000.0 -2023-06-02,GLD,118.84865027216422,119.44289352352503,117.76504880340653,118.35683296824777,118.35683296824777,1000000.0 -2023-06-05,GLD,118.35683296824777,119.74829546753854,117.76504880340653,119.15253280352094,119.15253280352094,1000000.0 +2023-04-20,GLD,113.73238011626296,114.30104201684426,112.52160233224456,113.0870375198438,113.0870375198438,1000000.0 +2023-04-21,GLD,113.0870375198438,115.03048617971918,112.52160233224456,114.45819520370068,114.45819520370068,1000000.0 +2023-04-24,GLD,114.45819520370068,115.03048617971918,113.16769913887552,113.73638104409602,113.73638104409602,1000000.0 +2023-04-25,GLD,113.73638104409602,114.368777271592,113.16769913887552,113.79977837969356,113.79977837969356,1000000.0 +2023-04-26,GLD,113.79977837969356,115.8457285903125,113.23077948779508,115.269381681903,115.269381681903,1000000.0 +2023-04-27,GLD,115.269381681903,116.4987503742128,114.69303477349348,115.91915460120676,115.91915460120676,1000000.0 +2023-04-28,GLD,115.91915460120676,116.89421978786872,115.33955882820074,116.31265650534202,116.31265650534202,1000000.0 +2023-05-01,GLD,116.31265650534202,117.26435724698077,115.7310932228153,116.680952484558,116.680952484558,1000000.0 +2023-05-02,GLD,116.680952484558,117.71510568365936,116.0975477221352,117.12945839170088,117.12945839170088,1000000.0 +2023-05-03,GLD,117.12945839170088,118.5060284791219,116.54381109974236,117.91644624788248,117.91644624788248,1000000.0 +2023-05-04,GLD,117.91644624788248,118.5060284791219,117.0733819744218,117.66169042655456,117.66169042655456,1000000.0 +2023-05-05,GLD,117.66169042655456,118.24999887868734,115.00359309681676,115.58150059981584,115.58150059981584,1000000.0 +2023-05-08,GLD,115.58150059981584,117.68737286313451,115.00359309681676,117.1018635454075,117.1018635454075,1000000.0 +2023-05-09,GLD,117.1018635454075,117.68737286313451,115.44755933968872,116.02769782883288,116.02769782883288,1000000.0 +2023-05-10,GLD,116.02769782883288,118.69852328891652,115.44755933968872,118.10798337205624,118.10798337205624,1000000.0 +2023-05-11,GLD,118.10798337205624,119.09027425898972,117.51744345519596,118.49778533232808,118.49778533232808,1000000.0 +2023-05-12,GLD,118.49778533232808,122.7433018646574,117.90529640566643,122.13263867130092,122.13263867130092,1000000.0 +2023-05-15,GLD,122.13263867130092,124.17562466994488,121.5219754779444,123.55783549248248,123.55783549248248,1000000.0 +2023-05-16,GLD,123.55783549248248,124.7440477046978,122.94004631502006,124.12343055193811,124.12343055193811,1000000.0 +2023-05-17,GLD,124.12343055193811,125.89641777700108,123.50281339917842,125.27006743980208,125.27006743980208,1000000.0 +2023-05-18,GLD,125.27006743980208,125.89641777700108,123.10903315709666,123.72767151467,123.72767151467,1000000.0 +2023-05-19,GLD,123.72767151467,124.62533797662086,123.10903315709666,124.00531141952324,124.00531141952324,1000000.0 +2023-05-22,GLD,124.00531141952324,124.62533797662086,123.0396324673616,123.65792207775034,123.65792207775034,1000000.0 +2023-05-23,GLD,123.65792207775034,124.36681730714002,123.0396324673616,123.7480769225274,123.7480769225274,1000000.0 +2023-05-24,GLD,123.7480769225274,124.36681730714002,120.866040118798,121.47340715457084,121.47340715457084,1000000.0 +2023-05-25,GLD,121.47340715457084,122.08077419034367,118.4764530417126,119.0718121022237,119.0718121022237,1000000.0 +2023-05-26,GLD,119.0718121022237,120.52961432835086,118.4764530417126,119.92996450582176,119.92996450582176,1000000.0 +2023-05-30,GLD,120.03586958389596,120.63604893181542,118.991510329501,119.58945761758896,119.58945761758896,1000000.0 +2023-05-31,GLD,119.58945761758896,120.1874049056769,118.93319797478595,119.5308522359658,119.5308522359658,1000000.0 +2023-06-01,GLD,119.5308522359658,120.1285064971456,118.2544070208034,118.84865027216422,118.84865027216422,1000000.0 +2023-06-02,GLD,118.84865027216422,119.44289352352504,117.76504880340651,118.35683296824776,118.35683296824776,1000000.0 +2023-06-05,GLD,118.35683296824776,119.74829546753854,117.76504880340651,119.15253280352094,119.15253280352094,1000000.0 2023-06-06,GLD,119.15253280352094,119.74829546753854,118.33279940512364,118.92743658806396,118.92743658806396,1000000.0 -2023-06-07,GLD,118.92743658806396,120.33866183705207,118.33279940512364,119.7399620269175,119.7399620269175,1000000.0 -2023-06-08,GLD,119.7399620269175,120.33866183705207,118.38851736377693,118.98343453645923,118.98343453645923,1000000.0 -2023-06-09,GLD,118.98343453645923,119.57835170914151,116.43307263324537,117.01816345049787,117.01816345049787,1000000.0 -2023-06-12,GLD,117.01816345049787,117.60325426775034,115.74584941502158,116.32748684926793,116.32748684926793,1000000.0 -2023-06-13,GLD,116.32748684926793,116.90912428351426,115.61579017706636,116.19677404730287,116.19677404730287,1000000.0 -2023-06-14,GLD,116.19677404730287,116.77775791753938,114.43474786179966,115.00979684602981,115.00979684602981,1000000.0 -2023-06-15,GLD,115.00979684602981,115.58484583025995,114.03996232391745,114.61302746122357,114.61302746122357,1000000.0 -2023-06-16,GLD,114.61302746122357,115.18609259852967,113.52162519082779,114.0920856189224,114.0920856189224,1000000.0 -2023-06-19,GLD,114.0920856189224,115.67611965202198,113.52162519082779,115.10061656917611,115.10061656917611,1000000.0 -2023-06-20,GLD,115.10061656917611,116.60417957679797,114.52511348633023,116.024059280396,116.024059280396,1000000.0 -2023-06-21,GLD,116.024059280396,117.81747399249787,115.44393898399402,117.23131740547052,117.23131740547052,1000000.0 -2023-06-22,GLD,117.23131740547052,117.81747399249787,115.82591663893558,116.40795642104078,116.40795642104078,1000000.0 -2023-06-23,GLD,116.40795642104078,117.37326646679858,115.82591663893558,116.78931986746129,116.78931986746129,1000000.0 -2023-06-26,GLD,116.78931986746129,118.80596801358739,116.20537326812398,118.21489354585812,118.21489354585812,1000000.0 -2023-06-27,GLD,118.21489354585812,119.01473551696415,117.62381907812883,118.42262240493946,118.42262240493946,1000000.0 -2023-06-28,GLD,118.42262240493946,119.01473551696415,117.76350939692544,118.35528582605572,118.35528582605572,1000000.0 -2023-06-29,GLD,118.35528582605572,118.94706225518598,116.9332933729281,117.52089786223931,117.52089786223931,1000000.0 -2023-06-30,GLD,117.52089786223931,119.04334238692586,116.9332933729281,118.45108695216504,118.45108695216504,1000000.0 -2023-07-03,GLD,118.45108695216504,119.24965922855718,117.85883151740421,118.65637734184796,118.65637734184796,1000000.0 -2023-07-04,GLD,118.65637734184796,120.37963903764863,118.06309545513872,119.78073536084442,119.78073536084442,1000000.0 -2023-07-05,GLD,119.78073536084442,120.37963903764863,119.02413912114604,119.62225037301108,119.62225037301108,1000000.0 +2023-06-07,GLD,118.92743658806396,120.33866183705209,118.33279940512364,119.7399620269175,119.7399620269175,1000000.0 +2023-06-08,GLD,119.7399620269175,120.33866183705209,118.38851736377693,118.98343453645924,118.98343453645924,1000000.0 +2023-06-09,GLD,118.98343453645924,119.57835170914151,116.43307263324536,117.01816345049788,117.01816345049788,1000000.0 +2023-06-12,GLD,117.01816345049788,117.60325426775034,115.74584941502158,116.32748684926791,116.32748684926791,1000000.0 +2023-06-13,GLD,116.32748684926791,116.90912428351426,115.61579017706636,116.19677404730288,116.19677404730288,1000000.0 +2023-06-14,GLD,116.19677404730288,116.77775791753938,114.43474786179966,115.0097968460298,115.0097968460298,1000000.0 +2023-06-15,GLD,115.0097968460298,115.58484583025997,114.03996232391744,114.61302746122357,114.61302746122357,1000000.0 +2023-06-16,GLD,114.61302746122357,115.18609259852968,113.5216251908278,114.0920856189224,114.0920856189224,1000000.0 +2023-06-20,GLD,115.10061656917613,116.60417957679796,114.52511348633024,116.024059280396,116.024059280396,1000000.0 +2023-06-21,GLD,116.024059280396,117.81747399249788,115.44393898399402,117.23131740547052,117.23131740547052,1000000.0 +2023-06-22,GLD,117.23131740547052,117.81747399249788,115.82591663893558,116.40795642104078,116.40795642104078,1000000.0 +2023-06-23,GLD,116.40795642104078,117.37326646679858,115.82591663893558,116.78931986746127,116.78931986746127,1000000.0 +2023-06-26,GLD,116.78931986746127,118.8059680135874,116.20537326812398,118.21489354585812,118.21489354585812,1000000.0 +2023-06-27,GLD,118.21489354585812,119.01473551696417,117.62381907812885,118.42262240493946,118.42262240493946,1000000.0 +2023-06-28,GLD,118.42262240493946,119.01473551696417,117.76350939692544,118.35528582605572,118.35528582605572,1000000.0 +2023-06-29,GLD,118.35528582605572,118.94706225518598,116.9332933729281,117.52089786223932,117.52089786223932,1000000.0 +2023-06-30,GLD,117.52089786223932,119.04334238692586,116.9332933729281,118.45108695216504,118.45108695216504,1000000.0 +2023-07-03,GLD,118.45108695216504,119.24965922855718,117.8588315174042,118.65637734184796,118.65637734184796,1000000.0 +2023-07-05,GLD,119.78073536084442,120.37963903764864,119.02413912114604,119.62225037301108,119.62225037301108,1000000.0 2023-07-06,GLD,119.62225037301108,122.56940477316192,119.02413912114604,121.9596067394646,121.9596067394646,1000000.0 2023-07-07,GLD,121.9596067394646,122.56940477316192,121.33086353796604,121.94056636981512,121.94056636981512,1000000.0 -2023-07-10,GLD,121.94056636981512,122.55026920166418,119.95718851768909,120.55998845998903,120.55998845998903,1000000.0 -2023-07-11,GLD,120.55998845998903,121.40456993326048,119.95718851768909,120.80056709777163,120.80056709777163,1000000.0 -2023-07-12,GLD,120.80056709777163,121.96371102875115,120.19656426228278,121.35692639676732,121.35692639676732,1000000.0 -2023-07-13,GLD,121.35692639676732,123.44576899355917,120.75014176478348,122.83161093886486,122.83161093886486,1000000.0 -2023-07-14,GLD,122.83161093886486,123.44576899355917,121.68552802416819,122.29701308961627,122.29701308961627,1000000.0 -2023-07-17,GLD,122.29701308961627,122.90849815506434,121.08070755463378,121.68915332123997,121.68915332123997,1000000.0 -2023-07-18,GLD,121.68915332123997,122.29759908784615,120.78631575037042,121.3932821611763,121.3932821611763,1000000.0 -2023-07-19,GLD,121.3932821611763,122.00024857198217,120.35339879863908,120.95818974737595,120.95818974737595,1000000.0 -2023-07-20,GLD,120.95818974737595,121.56298069611282,118.00550192676411,118.59849439875791,118.59849439875791,1000000.0 -2023-07-21,GLD,118.59849439875791,120.00211796865919,118.00550192676411,119.40509250612857,119.40509250612857,1000000.0 -2023-07-24,GLD,119.40509250612857,121.42135491161453,118.80806704359793,120.81726856877069,120.81726856877069,1000000.0 -2023-07-25,GLD,120.81726856877069,121.42135491161453,119.37085773417424,119.97071129062738,119.97071129062738,1000000.0 +2023-07-10,GLD,121.94056636981512,122.55026920166418,119.95718851768908,120.55998845998904,120.55998845998904,1000000.0 +2023-07-11,GLD,120.55998845998904,121.40456993326048,119.95718851768908,120.80056709777163,120.80056709777163,1000000.0 +2023-07-12,GLD,120.80056709777163,121.96371102875116,120.19656426228278,121.35692639676732,121.35692639676732,1000000.0 +2023-07-13,GLD,121.35692639676732,123.44576899355916,120.75014176478348,122.83161093886486,122.83161093886486,1000000.0 +2023-07-14,GLD,122.83161093886486,123.44576899355916,121.6855280241682,122.29701308961629,122.29701308961629,1000000.0 +2023-07-17,GLD,122.29701308961629,122.90849815506434,121.08070755463378,121.68915332123996,121.68915332123996,1000000.0 +2023-07-18,GLD,121.68915332123996,122.29759908784617,120.78631575037042,121.3932821611763,121.3932821611763,1000000.0 +2023-07-19,GLD,121.3932821611763,122.00024857198215,120.35339879863908,120.95818974737595,120.95818974737595,1000000.0 +2023-07-20,GLD,120.95818974737595,121.56298069611282,118.00550192676413,118.59849439875792,118.59849439875792,1000000.0 +2023-07-21,GLD,118.59849439875792,120.0021179686592,118.00550192676413,119.40509250612855,119.40509250612855,1000000.0 +2023-07-24,GLD,119.40509250612855,121.42135491161451,118.80806704359792,120.81726856877069,120.81726856877069,1000000.0 +2023-07-25,GLD,120.81726856877069,121.42135491161451,119.37085773417424,119.97071129062738,119.97071129062738,1000000.0 2023-07-26,GLD,119.97071129062738,120.5705648470805,118.28678651025494,118.88119247261804,118.88119247261804,1000000.0 -2023-07-27,GLD,118.88119247261804,119.47559843498112,118.13971089395135,118.73337778286567,118.73337778286567,1000000.0 -2023-07-28,GLD,118.73337778286567,119.32704467177999,117.2542670761149,117.84348449860794,117.84348449860794,1000000.0 -2023-07-31,GLD,117.84348449860794,119.32135819404637,117.2542670761149,118.72771959606605,118.72771959606605,1000000.0 -2023-08-01,GLD,118.72771959606605,119.32135819404637,117.4637628678389,118.05403303300392,118.05403303300392,1000000.0 -2023-08-02,GLD,118.05403303300392,119.33866278773327,117.4637628678389,118.74493809724704,118.74493809724704,1000000.0 -2023-08-03,GLD,118.74493809724704,119.33866278773327,116.80659896703447,117.39356680103967,117.39356680103967,1000000.0 -2023-08-04,GLD,117.39356680103967,117.98053463504485,115.29423034697524,115.87359833866859,115.87359833866859,1000000.0 -2023-08-07,GLD,115.87359833866859,116.45296633036192,113.65334684074354,114.22446918667693,114.22446918667693,1000000.0 -2023-08-08,GLD,114.22446918667693,114.7955915326103,112.17595615759319,112.7396544297419,112.7396544297419,1000000.0 -2023-08-09,GLD,112.7396544297419,113.77522307088915,112.17595615759319,113.20917718496435,113.20917718496435,1000000.0 -2023-08-10,GLD,113.20917718496435,114.6941467078782,112.64313129903952,114.1235290625654,114.1235290625654,1000000.0 -2023-08-11,GLD,114.1235290625654,114.86910935118449,113.55291141725257,114.2976212449597,114.2976212449597,1000000.0 +2023-07-27,GLD,118.88119247261804,119.47559843498112,118.13971089395136,118.73337778286567,118.73337778286567,1000000.0 +2023-07-28,GLD,118.73337778286567,119.32704467178,117.2542670761149,117.84348449860794,117.84348449860794,1000000.0 +2023-07-31,GLD,117.84348449860794,119.32135819404635,117.2542670761149,118.72771959606604,118.72771959606604,1000000.0 +2023-08-01,GLD,118.72771959606604,119.32135819404635,117.4637628678389,118.05403303300392,118.05403303300392,1000000.0 +2023-08-02,GLD,118.05403303300392,119.33866278773328,117.4637628678389,118.74493809724704,118.74493809724704,1000000.0 +2023-08-03,GLD,118.74493809724704,119.33866278773328,116.80659896703447,117.39356680103968,117.39356680103968,1000000.0 +2023-08-04,GLD,117.39356680103968,117.98053463504483,115.29423034697524,115.8735983386686,115.8735983386686,1000000.0 +2023-08-07,GLD,115.8735983386686,116.45296633036192,113.65334684074354,114.22446918667691,114.22446918667691,1000000.0 +2023-08-08,GLD,114.22446918667691,114.7955915326103,112.1759561575932,112.7396544297419,112.7396544297419,1000000.0 +2023-08-09,GLD,112.7396544297419,113.77522307088915,112.1759561575932,113.20917718496436,113.20917718496436,1000000.0 +2023-08-10,GLD,113.20917718496436,114.6941467078782,112.64313129903952,114.1235290625654,114.1235290625654,1000000.0 +2023-08-11,GLD,114.1235290625654,114.86910935118448,113.55291141725256,114.2976212449597,114.2976212449597,1000000.0 2023-08-14,GLD,114.2976212449597,115.11844014267766,113.7261331387349,114.5457115847539,114.5457115847539,1000000.0 -2023-08-15,GLD,114.5457115847539,115.11844014267766,111.67280713092443,112.23397701600445,112.23397701600445,1000000.0 -2023-08-16,GLD,112.23397701600445,113.99118574964731,111.67280713092443,113.42406542253465,113.42406542253465,1000000.0 -2023-08-17,GLD,113.42406542253465,113.99118574964731,111.58579908336107,112.14653174207143,112.14653174207143,1000000.0 -2023-08-18,GLD,112.14653174207143,114.10394524842849,111.58579908336107,113.53626392878458,113.53626392878458,1000000.0 -2023-08-21,GLD,113.53626392878458,116.72239363831898,112.96858260914065,116.1416852122577,116.1416852122577,1000000.0 +2023-08-15,GLD,114.5457115847539,115.11844014267766,111.67280713092444,112.23397701600445,112.23397701600445,1000000.0 +2023-08-16,GLD,112.23397701600445,113.99118574964731,111.67280713092444,113.42406542253464,113.42406542253464,1000000.0 +2023-08-17,GLD,113.42406542253464,113.99118574964731,111.58579908336108,112.14653174207145,112.14653174207145,1000000.0 +2023-08-18,GLD,112.14653174207145,114.10394524842847,111.58579908336108,113.53626392878458,113.53626392878458,1000000.0 +2023-08-21,GLD,113.53626392878458,116.72239363831898,112.96858260914064,116.1416852122577,116.1416852122577,1000000.0 2023-08-22,GLD,116.1416852122577,116.81213469447118,115.5609767861964,116.23097979549372,116.23097979549372,1000000.0 -2023-08-23,GLD,116.23097979549372,117.51363668298383,115.64982489651625,116.92899172436204,116.92899172436204,1000000.0 -2023-08-24,GLD,116.92899172436204,118.215992323551,116.34434676574023,117.62785305825972,117.62785305825972,1000000.0 -2023-08-25,GLD,117.62785305825972,118.215992323551,115.63930963312033,116.22041169157822,116.22041169157822,1000000.0 +2023-08-23,GLD,116.23097979549372,117.51363668298384,115.64982489651624,116.92899172436204,116.92899172436204,1000000.0 +2023-08-24,GLD,116.92899172436204,118.215992323551,116.34434676574024,117.62785305825972,117.62785305825972,1000000.0 +2023-08-25,GLD,117.62785305825972,118.215992323551,115.63930963312032,116.22041169157822,116.22041169157822,1000000.0 2023-08-28,GLD,116.22041169157822,116.8015137500361,114.45228530169722,115.02742241376605,115.02742241376605,1000000.0 -2023-08-29,GLD,115.02742241376605,115.60255952583486,113.74259076383359,114.31416157169205,114.31416157169205,1000000.0 -2023-08-30,GLD,114.31416157169205,115.07103491745022,113.74259076383359,114.49854220641814,114.49854220641814,1000000.0 +2023-08-29,GLD,115.02742241376605,115.60255952583486,113.7425907638336,114.31416157169204,114.31416157169204,1000000.0 +2023-08-30,GLD,114.31416157169204,115.07103491745022,113.7425907638336,114.49854220641814,114.49854220641814,1000000.0 2023-08-31,GLD,114.49854220641814,115.9968962564023,113.92604949538604,115.41979727005204,115.41979727005204,1000000.0 -2023-09-01,GLD,115.41979727005204,116.14455664856895,114.84269828370178,115.56672303340196,115.56672303340196,1000000.0 -2023-09-04,GLD,115.56672303340196,117.36373008550707,114.98888941823495,116.77983093085282,116.77983093085282,1000000.0 -2023-09-05,GLD,116.77983093085282,117.36373008550707,115.67070259412476,116.25196240615554,116.25196240615554,1000000.0 -2023-09-06,GLD,116.25196240615554,116.83322221818631,114.18383821246928,114.75762634419023,114.75762634419023,1000000.0 -2023-09-07,GLD,114.75762634419023,115.38631375898548,114.18383821246928,114.81225249650298,114.81225249650298,1000000.0 +2023-09-01,GLD,115.41979727005204,116.14455664856897,114.84269828370178,115.56672303340196,115.56672303340196,1000000.0 +2023-09-05,GLD,116.77983093085282,117.36373008550709,115.67070259412476,116.25196240615554,116.25196240615554,1000000.0 +2023-09-06,GLD,116.25196240615554,116.83322221818632,114.18383821246928,114.75762634419024,114.75762634419024,1000000.0 +2023-09-07,GLD,114.75762634419024,115.38631375898548,114.18383821246928,114.81225249650298,114.81225249650298,1000000.0 2023-09-08,GLD,114.81225249650298,115.38631375898548,114.202762383454,114.77664561151155,114.77664561151155,1000000.0 -2023-09-11,GLD,114.77664561151155,115.3505288395691,112.73004295600963,113.29652558392927,113.29652558392927,1000000.0 -2023-09-12,GLD,113.29652558392927,114.9797776067694,112.73004295600963,114.40773891220837,114.40773891220837,1000000.0 -2023-09-13,GLD,114.40773891220837,114.9797776067694,112.85426288967967,113.42136973837154,113.42136973837154,1000000.0 -2023-09-14,GLD,113.42136973837154,113.98847658706339,112.1225457512237,112.68597562937055,112.68597562937055,1000000.0 -2023-09-15,GLD,112.68597562937055,113.5082882037736,112.1225457512237,112.94357035201355,112.94357035201355,1000000.0 -2023-09-18,GLD,112.94357035201355,113.5082882037736,111.2869882062699,111.84621930278382,111.84621930278382,1000000.0 +2023-09-11,GLD,114.77664561151155,115.3505288395691,112.73004295600964,113.29652558392928,113.29652558392928,1000000.0 +2023-09-12,GLD,113.29652558392928,114.9797776067694,112.73004295600964,114.40773891220836,114.40773891220836,1000000.0 +2023-09-13,GLD,114.40773891220836,114.9797776067694,112.85426288967967,113.42136973837154,113.42136973837154,1000000.0 +2023-09-14,GLD,113.42136973837154,113.9884765870634,112.1225457512237,112.68597562937056,112.68597562937056,1000000.0 +2023-09-15,GLD,112.68597562937056,113.5082882037736,112.1225457512237,112.94357035201357,112.94357035201357,1000000.0 +2023-09-18,GLD,112.94357035201357,113.5082882037736,111.2869882062699,111.84621930278382,111.84621930278382,1000000.0 2023-09-19,GLD,111.84621930278382,113.07915800818942,111.2869882062699,112.5165751325268,112.5165751325268,1000000.0 -2023-09-20,GLD,112.5165751325268,113.5137309074048,111.95399225686417,112.94898597751724,112.94898597751724,1000000.0 +2023-09-20,GLD,112.5165751325268,113.5137309074048,111.95399225686415,112.94898597751724,112.94898597751724,1000000.0 2023-09-21,GLD,112.94898597751724,114.0207754678882,112.38424104762966,113.45350792824698,113.45350792824698,1000000.0 -2023-09-22,GLD,113.45350792824698,114.04628098055683,112.88624038860574,113.47888654781775,113.47888654781775,1000000.0 -2023-09-25,GLD,113.47888654781775,114.04628098055683,112.44317448511468,113.00821556292932,113.00821556292932,1000000.0 -2023-09-26,GLD,113.00821556292932,113.57325664074396,111.6822686665396,112.24348609702473,112.24348609702473,1000000.0 -2023-09-27,GLD,112.24348609702473,113.15762140538403,111.6822686665396,112.59464816456124,112.59464816456124,1000000.0 -2023-09-28,GLD,112.59464816456124,114.63481613738489,112.03167492373842,114.0644936690397,114.0644936690397,1000000.0 -2023-09-29,GLD,114.0644936690397,115.50937836883101,113.49417120069451,114.93470484460798,114.93470484460798,1000000.0 -2023-10-02,GLD,114.93470484460798,115.94553362222857,114.36003132038493,115.36869017137172,115.36869017137172,1000000.0 -2023-10-03,GLD,115.36869017137172,117.06908048888158,114.79184672051487,116.4866472526185,116.4866472526185,1000000.0 -2023-10-04,GLD,116.4866472526185,118.56667917489912,115.9042140163554,117.97679519890461,117.97679519890461,1000000.0 -2023-10-05,GLD,117.97679519890461,118.56667917489912,116.07636927567934,116.65966761374808,116.65966761374808,1000000.0 -2023-10-06,GLD,116.65966761374808,117.24770683766145,116.07636927567934,116.66438491309599,116.66438491309599,1000000.0 -2023-10-09,GLD,116.66438491309599,118.63495454393788,116.0810629885305,118.04473088949044,118.04473088949044,1000000.0 -2023-10-10,GLD,118.04473088949044,118.63495454393788,116.80387567520042,117.39082982432203,117.39082982432203,1000000.0 -2023-10-11,GLD,117.39082982432203,117.97778397344362,116.35558143593927,116.94028285019023,116.94028285019023,1000000.0 -2023-10-12,GLD,116.94028285019023,118.2559867489475,116.35558143593927,117.66764850641545,117.66764850641545,1000000.0 +2023-09-22,GLD,113.45350792824698,114.04628098055684,112.88624038860574,113.47888654781777,113.47888654781777,1000000.0 +2023-09-25,GLD,113.47888654781777,114.04628098055684,112.44317448511468,113.00821556292932,113.00821556292932,1000000.0 +2023-09-26,GLD,113.00821556292932,113.57325664074396,111.6822686665396,112.24348609702471,112.24348609702471,1000000.0 +2023-09-27,GLD,112.24348609702471,113.15762140538403,111.6822686665396,112.59464816456124,112.59464816456124,1000000.0 +2023-09-28,GLD,112.59464816456124,114.63481613738487,112.03167492373842,114.0644936690397,114.0644936690397,1000000.0 +2023-09-29,GLD,114.0644936690397,115.509378368831,113.49417120069452,114.93470484460798,114.93470484460798,1000000.0 +2023-10-02,GLD,114.93470484460798,115.94553362222857,114.36003132038492,115.36869017137172,115.36869017137172,1000000.0 +2023-10-03,GLD,115.36869017137172,117.06908048888158,114.79184672051488,116.4866472526185,116.4866472526185,1000000.0 +2023-10-04,GLD,116.4866472526185,118.56667917489912,115.9042140163554,117.9767951989046,117.9767951989046,1000000.0 +2023-10-05,GLD,117.9767951989046,118.56667917489912,116.07636927567934,116.65966761374808,116.65966761374808,1000000.0 +2023-10-06,GLD,116.65966761374808,117.24770683766144,116.07636927567934,116.664384913096,116.664384913096,1000000.0 +2023-10-09,GLD,116.664384913096,118.63495454393788,116.0810629885305,118.04473088949044,118.04473088949044,1000000.0 +2023-10-10,GLD,118.04473088949044,118.63495454393788,116.80387567520042,117.39082982432204,117.39082982432204,1000000.0 +2023-10-11,GLD,117.39082982432204,117.97778397344362,116.35558143593929,116.94028285019024,116.94028285019024,1000000.0 +2023-10-12,GLD,116.94028285019024,118.2559867489475,116.35558143593929,117.66764850641545,117.66764850641545,1000000.0 2023-10-13,GLD,117.66764850641545,118.2559867489475,116.90038147939948,117.48782058231104,117.48782058231104,1000000.0 -2023-10-16,GLD,117.48782058231104,119.46682748116581,116.90038147939948,118.87246515538888,118.87246515538888,1000000.0 -2023-10-17,GLD,118.87246515538888,119.46682748116581,118.00185928960512,118.59483345688957,118.59483345688957,1000000.0 -2023-10-18,GLD,118.59483345688957,119.91615585254833,118.00185928960512,119.31955806223715,119.31955806223715,1000000.0 -2023-10-19,GLD,119.31955806223715,122.80821181558728,118.72296027192597,122.19722568715154,122.19722568715154,1000000.0 -2023-10-20,GLD,122.19722568715154,124.19421020659375,121.58623955871579,123.5763285637749,123.5763285637749,1000000.0 -2023-10-23,GLD,123.5763285637749,124.19421020659375,121.49738606040098,122.10792568884521,122.10792568884521,1000000.0 -2023-10-24,GLD,122.10792568884521,122.71846531728943,120.40282498671608,121.00786430825737,121.00786430825737,1000000.0 -2023-10-25,GLD,121.00786430825737,121.61290362979864,119.35542567496691,119.95520168338383,119.95520168338383,1000000.0 -2023-10-26,GLD,119.95520168338383,120.56989890245958,119.35542567496691,119.97004865916378,119.97004865916378,1000000.0 +2023-10-16,GLD,117.48782058231104,119.4668274811658,116.90038147939948,118.87246515538888,118.87246515538888,1000000.0 +2023-10-17,GLD,118.87246515538888,119.4668274811658,118.00185928960512,118.59483345688956,118.59483345688956,1000000.0 +2023-10-18,GLD,118.59483345688956,119.91615585254831,118.00185928960512,119.31955806223716,119.31955806223716,1000000.0 +2023-10-19,GLD,119.31955806223716,122.80821181558728,118.72296027192596,122.19722568715154,122.19722568715154,1000000.0 +2023-10-20,GLD,122.19722568715154,124.19421020659377,121.5862395587158,123.5763285637749,123.5763285637749,1000000.0 +2023-10-23,GLD,123.5763285637749,124.19421020659377,121.49738606040098,122.1079256888452,122.1079256888452,1000000.0 +2023-10-24,GLD,122.1079256888452,122.71846531728944,120.40282498671608,121.00786430825735,121.00786430825735,1000000.0 +2023-10-25,GLD,121.00786430825735,121.61290362979864,119.35542567496692,119.95520168338383,119.95520168338383,1000000.0 +2023-10-26,GLD,119.95520168338383,120.56989890245958,119.35542567496692,119.97004865916378,119.97004865916378,1000000.0 2023-10-27,GLD,119.97004865916378,120.56989890245958,117.90032417282654,118.49278811339352,118.49278811339352,1000000.0 2023-10-30,GLD,118.49278811339352,119.76978609091154,117.90032417282654,119.1739165083697,119.1739165083697,1000000.0 diff --git a/data/raw/QQQ.csv b/data/raw/QQQ.csv index cdeeae1..5e94d42 100644 --- a/data/raw/QQQ.csv +++ b/data/raw/QQQ.csv @@ -1,529 +1,510 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume -2019-01-01,QQQ,100.29620722555916,100.79768826168694,99.79472618943136,100.29620722555916,100.29620722555916,1000000.0 -2019-01-02,QQQ,100.29620722555916,100.79768826168694,99.16841916252973,99.66675292716556,99.66675292716556,1000000.0 +2019-01-02,QQQ,100.29620722555916,100.79768826168694,99.16841916252972,99.66675292716556,99.66675292716556,1000000.0 2019-01-03,QQQ,99.66675292716556,100.16508669180138,98.68666020340888,99.18257306875265,99.18257306875265,1000000.0 -2019-01-04,QQQ,99.18257306875265,99.6784859340964,95.65143576165143,96.13209624286576,96.13209624286576,1000000.0 -2019-01-07,QQQ,96.13209624286576,98.94923943478892,95.65143576165143,98.45695466148152,98.45695466148152,1000000.0 +2019-01-04,QQQ,99.18257306875265,99.6784859340964,95.65143576165144,96.13209624286576,96.13209624286576,1000000.0 +2019-01-07,QQQ,96.13209624286576,98.94923943478892,95.65143576165144,98.45695466148152,98.45695466148152,1000000.0 2019-01-08,QQQ,98.45695466148152,100.48225472382504,97.96466988817411,99.98234300878114,99.98234300878114,1000000.0 -2019-01-09,QQQ,99.98234300878114,100.48225472382504,99.11200376321699,99.6100540333839,99.6100540333839,1000000.0 -2019-01-10,QQQ,99.6100540333839,101.170795252644,99.11200376321699,100.66745796282986,100.66745796282986,1000000.0 +2019-01-09,QQQ,99.98234300878114,100.48225472382504,99.112003763217,99.6100540333839,99.6100540333839,1000000.0 +2019-01-10,QQQ,99.6100540333839,101.170795252644,99.112003763217,100.66745796282986,100.66745796282986,1000000.0 2019-01-11,QQQ,100.66745796282986,101.59210947208864,100.1641206730157,101.0866760916305,101.0866760916305,1000000.0 -2019-01-14,QQQ,101.0866760916305,101.59210947208864,99.90963118283219,100.41168963098713,100.41168963098713,1000000.0 -2019-01-15,QQQ,100.41168963098713,102.25549654744187,99.90963118283219,101.74676273377301,101.74676273377301,1000000.0 -2019-01-16,QQQ,101.74676273377301,102.25549654744187,100.88055881072138,101.38749629218229,101.38749629218229,1000000.0 -2019-01-17,QQQ,101.38749629218229,101.89443377364319,100.50048167255706,101.0055092186503,101.0055092186503,1000000.0 -2019-01-18,QQQ,101.0055092186503,101.51053676474355,99.520595473618,100.02069896846031,100.02069896846031,1000000.0 -2019-01-21,QQQ,100.02069896846031,101.16766102445676,99.520595473618,100.66433932781769,100.66433932781769,1000000.0 +2019-01-14,QQQ,101.0866760916305,101.59210947208864,99.9096311828322,100.41168963098713,100.41168963098713,1000000.0 +2019-01-15,QQQ,100.41168963098713,102.25549654744188,99.9096311828322,101.746762733773,101.746762733773,1000000.0 +2019-01-16,QQQ,101.746762733773,102.25549654744188,100.88055881072138,101.38749629218228,101.38749629218228,1000000.0 +2019-01-17,QQQ,101.38749629218228,101.8944337736432,100.50048167255706,101.0055092186503,101.0055092186503,1000000.0 +2019-01-18,QQQ,101.0055092186503,101.51053676474356,99.520595473618,100.02069896846032,100.02069896846032,1000000.0 2019-01-22,QQQ,100.66433932781769,101.16766102445676,100.0819642418866,100.58488868531316,100.58488868531316,1000000.0 2019-01-23,QQQ,100.58488868531316,101.85786176135277,100.0819642418866,101.35110623020176,101.35110623020176,1000000.0 2019-01-24,QQQ,101.35110623020176,101.85786176135277,100.10151608059486,100.6045387744672,100.6045387744672,1000000.0 -2019-01-25,QQQ,100.6045387744672,101.32505113868307,100.10151608059486,100.82094640664982,100.82094640664982,1000000.0 -2019-01-28,QQQ,100.82094640664982,101.32505113868307,99.20952534605222,99.70806567442433,99.70806567442433,1000000.0 -2019-01-29,QQQ,99.70806567442433,101.35945501683678,99.20952534605222,100.85517912123063,100.85517912123063,1000000.0 +2019-01-25,QQQ,100.6045387744672,101.32505113868308,100.10151608059486,100.82094640664982,100.82094640664982,1000000.0 +2019-01-28,QQQ,100.82094640664982,101.32505113868308,99.20952534605222,99.70806567442432,99.70806567442432,1000000.0 +2019-01-29,QQQ,99.70806567442432,101.35945501683678,99.20952534605222,100.85517912123063,100.85517912123063,1000000.0 2019-01-30,QQQ,100.85517912123063,101.658343301349,100.35090322562448,101.15258039935226,101.15258039935226,1000000.0 2019-01-31,QQQ,101.15258039935226,102.14721310642328,100.6468174973555,101.63901801634158,101.63901801634158,1000000.0 -2019-02-01,QQQ,101.63901801634158,102.74514374069658,101.13082292625987,102.23397387133988,102.23397387133988,1000000.0 -2019-02-04,QQQ,102.23397387133988,102.74514374069658,100.44513505285941,100.94988447523559,100.94988447523559,1000000.0 -2019-02-05,QQQ,100.94988447523559,102.54411485766691,100.44513505285941,102.03394513200689,102.03394513200689,1000000.0 -2019-02-06,QQQ,102.03394513200689,105.37550739865007,101.52377540634686,104.85125114293541,104.85125114293541,1000000.0 -2019-02-07,QQQ,104.85125114293541,105.37550739865007,102.17943103291346,102.69289551046579,102.69289551046579,1000000.0 -2019-02-08,QQQ,102.69289551046579,103.20635998801811,99.95779496320621,100.46009544040825,100.46009544040825,1000000.0 -2019-02-11,QQQ,100.46009544040825,100.96239591761028,98.0703656789358,98.56318158687016,98.56318158687016,1000000.0 +2019-02-01,QQQ,101.63901801634158,102.74514374069658,101.13082292625988,102.23397387133988,102.23397387133988,1000000.0 +2019-02-04,QQQ,102.23397387133988,102.74514374069658,100.4451350528594,100.9498844752356,100.9498844752356,1000000.0 +2019-02-05,QQQ,100.9498844752356,102.54411485766693,100.4451350528594,102.03394513200688,102.03394513200688,1000000.0 +2019-02-06,QQQ,102.03394513200688,105.37550739865009,101.52377540634686,104.8512511429354,104.8512511429354,1000000.0 +2019-02-07,QQQ,104.8512511429354,105.37550739865009,102.17943103291346,102.6928955104658,102.6928955104658,1000000.0 +2019-02-08,QQQ,102.6928955104658,103.20635998801812,99.9577949632062,100.46009544040824,100.46009544040824,1000000.0 +2019-02-11,QQQ,100.46009544040824,100.96239591761028,98.0703656789358,98.56318158687016,98.56318158687016,1000000.0 2019-02-12,QQQ,98.56318158687016,100.19560116128012,98.0703656789358,99.69711558336331,99.69711558336331,1000000.0 -2019-02-13,QQQ,99.69711558336331,100.4135934125579,99.1986300054465,99.91402329607753,99.91402329607753,1000000.0 -2019-02-14,QQQ,99.91402329607753,101.88207758530409,99.41445317959715,101.37520157741702,101.37520157741702,1000000.0 -2019-02-15,QQQ,101.37520157741702,102.89485521894869,100.86832556952993,102.38294051636687,102.38294051636687,1000000.0 -2019-02-18,QQQ,102.38294051636687,103.22851027810023,101.87102581378504,102.71493560009975,102.71493560009975,1000000.0 -2019-02-19,QQQ,102.71493560009975,103.66220387705374,102.20136092209925,103.14647151945647,103.14647151945647,1000000.0 -2019-02-20,QQQ,103.14647151945647,103.66220387705374,102.45570956205962,102.97056237392927,102.97056237392927,1000000.0 -2019-02-21,QQQ,102.97056237392927,104.71272722243482,102.45570956205962,104.19176838053217,104.19176838053217,1000000.0 -2019-02-22,QQQ,104.19176838053217,104.71272722243482,102.21048634328109,102.72410687766944,102.72410687766944,1000000.0 -2019-02-25,QQQ,102.72410687766944,103.23772741205778,101.70231988581243,102.21338681991199,102.21338681991199,1000000.0 -2019-02-26,QQQ,102.21338681991199,103.10092906573473,101.70231988581243,102.58798912013407,102.58798912013407,1000000.0 -2019-02-27,QQQ,102.58798912013407,105.59667388225607,102.0750491745334,105.07131729577719,105.07131729577719,1000000.0 -2019-02-28,QQQ,105.07131729577719,105.59667388225607,103.5638941080296,104.0843156864619,104.0843156864619,1000000.0 -2019-03-01,QQQ,104.0843156864619,104.60473726489421,102.17233747587345,102.6857663074105,102.6857663074105,1000000.0 +2019-02-13,QQQ,99.69711558336331,100.4135934125579,99.1986300054465,99.91402329607752,99.91402329607752,1000000.0 +2019-02-14,QQQ,99.91402329607752,101.88207758530407,99.41445317959716,101.37520157741702,101.37520157741702,1000000.0 +2019-02-15,QQQ,101.37520157741702,102.89485521894868,100.86832556952992,102.38294051636689,102.38294051636689,1000000.0 +2019-02-19,QQQ,102.71493560009976,103.66220387705374,102.20136092209924,103.14647151945648,103.14647151945648,1000000.0 +2019-02-20,QQQ,103.14647151945648,103.66220387705374,102.45570956205962,102.97056237392928,102.97056237392928,1000000.0 +2019-02-21,QQQ,102.97056237392928,104.71272722243482,102.45570956205962,104.19176838053215,104.19176838053215,1000000.0 +2019-02-22,QQQ,104.19176838053215,104.71272722243482,102.21048634328108,102.72410687766944,102.72410687766944,1000000.0 +2019-02-25,QQQ,102.72410687766944,103.23772741205778,101.70231988581244,102.213386819912,102.213386819912,1000000.0 +2019-02-26,QQQ,102.213386819912,103.10092906573472,101.70231988581244,102.58798912013408,102.58798912013408,1000000.0 +2019-02-27,QQQ,102.58798912013408,105.59667388225608,102.0750491745334,105.0713172957772,105.0713172957772,1000000.0 +2019-02-28,QQQ,105.0713172957772,105.59667388225608,103.5638941080296,104.0843156864619,104.0843156864619,1000000.0 +2019-03-01,QQQ,104.0843156864619,104.6047372648942,102.17233747587343,102.6857663074105,102.6857663074105,1000000.0 2019-03-04,QQQ,102.6857663074105,103.19919513894754,101.47761548442594,101.98755325067934,101.98755325067934,1000000.0 2019-03-05,QQQ,101.98755325067934,103.84909741116763,101.47761548442594,103.33243523499269,103.33243523499269,1000000.0 -2019-03-06,QQQ,103.33243523499269,103.84909741116763,102.55340672323389,103.06875047561195,103.06875047561195,1000000.0 -2019-03-07,QQQ,103.06875047561195,105.43559564349303,102.55340672323389,104.9110404412866,104.9110404412866,1000000.0 -2019-03-08,QQQ,104.9110404412866,105.43559564349303,101.92704697032,102.43924318625126,102.43924318625126,1000000.0 +2019-03-06,QQQ,103.33243523499269,103.84909741116763,102.55340672323388,103.06875047561196,103.06875047561196,1000000.0 +2019-03-07,QQQ,103.06875047561196,105.43559564349304,102.55340672323388,104.9110404412866,104.9110404412866,1000000.0 +2019-03-08,QQQ,104.9110404412866,105.43559564349304,101.92704697032,102.43924318625126,102.43924318625126,1000000.0 2019-03-11,QQQ,102.43924318625126,104.52520262648432,101.92704697032,104.00517674277047,104.00517674277047,1000000.0 -2019-03-12,QQQ,104.00517674277047,105.99389275204538,103.48515085905662,105.46655995228397,105.46655995228397,1000000.0 -2019-03-13,QQQ,105.46655995228397,105.99389275204538,103.07313034061625,103.59108576946356,103.59108576946356,1000000.0 +2019-03-12,QQQ,104.00517674277047,105.99389275204538,103.48515085905662,105.46655995228396,105.46655995228396,1000000.0 +2019-03-13,QQQ,105.46655995228396,105.99389275204538,103.07313034061625,103.59108576946356,103.59108576946356,1000000.0 2019-03-14,QQQ,103.59108576946356,104.36928085467278,103.07313034061625,103.85003070116696,103.85003070116696,1000000.0 -2019-03-15,QQQ,103.85003070116696,106.08495514254895,103.33078054766112,105.55716929606862,105.55716929606862,1000000.0 -2019-03-18,QQQ,105.55716929606862,106.25939041445868,105.02938344958828,105.73073673080467,105.73073673080467,1000000.0 -2019-03-19,QQQ,105.73073673080467,107.70320074037312,105.20208304715064,107.16736392076929,107.16736392076929,1000000.0 -2019-03-20,QQQ,107.16736392076929,111.13579507318664,106.63152710116545,110.58288066983746,110.58288066983746,1000000.0 -2019-03-21,QQQ,110.58288066983746,111.58804850255187,110.02996626648827,111.03288408214118,111.03288408214118,1000000.0 -2019-03-22,QQQ,111.03288408214118,111.58804850255187,110.1308163249866,110.68423751254934,110.68423751254934,1000000.0 -2019-03-25,QQQ,110.68423751254934,111.23765870011208,109.08694452333258,109.63512012395233,109.63512012395233,1000000.0 -2019-03-26,QQQ,109.63512012395233,111.17106527573448,109.08694452333258,110.61797539874078,110.61797539874078,1000000.0 -2019-03-27,QQQ,110.61797539874078,111.17106527573448,109.83866083290833,110.39061390242044,110.39061390242044,1000000.0 -2019-03-28,QQQ,110.39061390242044,110.94256697193254,109.63853030214129,110.18947769059426,110.18947769059426,1000000.0 -2019-03-29,QQQ,110.18947769059426,110.74042507904721,109.54336403710654,110.09383320312216,110.09383320312216,1000000.0 +2019-03-15,QQQ,103.85003070116696,106.08495514254896,103.33078054766112,105.55716929606862,105.55716929606862,1000000.0 +2019-03-18,QQQ,105.55716929606862,106.25939041445868,105.02938344958828,105.73073673080468,105.73073673080468,1000000.0 +2019-03-19,QQQ,105.73073673080468,107.70320074037312,105.20208304715064,107.16736392076928,107.16736392076928,1000000.0 +2019-03-20,QQQ,107.16736392076928,111.13579507318664,106.63152710116545,110.58288066983746,110.58288066983746,1000000.0 +2019-03-21,QQQ,110.58288066983746,111.58804850255189,110.02996626648827,111.03288408214118,111.03288408214118,1000000.0 +2019-03-22,QQQ,111.03288408214118,111.58804850255189,110.1308163249866,110.68423751254934,110.68423751254934,1000000.0 +2019-03-25,QQQ,110.68423751254934,111.23765870011208,109.08694452333258,109.63512012395232,109.63512012395232,1000000.0 +2019-03-26,QQQ,109.63512012395232,111.17106527573448,109.08694452333258,110.61797539874078,110.61797539874078,1000000.0 +2019-03-27,QQQ,110.61797539874078,111.17106527573448,109.83866083290832,110.39061390242044,110.39061390242044,1000000.0 +2019-03-28,QQQ,110.39061390242044,110.94256697193254,109.63853030214128,110.18947769059426,110.18947769059426,1000000.0 +2019-03-29,QQQ,110.18947769059426,110.7404250790472,109.54336403710654,110.09383320312216,110.09383320312216,1000000.0 2019-04-01,QQQ,110.09383320312216,111.63882439408329,109.54336403710654,111.08340735729682,111.08340735729682,1000000.0 -2019-04-02,QQQ,111.08340735729682,111.63882439408329,109.060893258841,109.60893794858393,109.60893794858393,1000000.0 -2019-04-03,QQQ,109.60893794858393,110.15698263832684,106.96674434210654,107.50426567045884,107.50426567045884,1000000.0 -2019-04-04,QQQ,107.50426567045884,108.04178699881112,103.68708738665372,104.20812802678765,104.20812802678765,1000000.0 -2019-04-05,QQQ,104.20812802678765,106.42711594818181,103.68708738665372,105.89762780913614,105.89762780913614,1000000.0 -2019-04-08,QQQ,105.89762780913614,106.58253987237781,105.36813967009046,106.05227847997793,106.05227847997793,1000000.0 -2019-04-09,QQQ,106.05227847997793,108.74995537471261,105.52201708757804,108.20891082060957,108.20891082060957,1000000.0 -2019-04-10,QQQ,108.20891082060957,108.79167639205686,107.66786626650652,108.25042427070335,108.25042427070335,1000000.0 -2019-04-11,QQQ,108.25042427070335,108.79167639205686,106.72833956817283,107.26466288258575,107.26466288258575,1000000.0 -2019-04-12,QQQ,107.26466288258575,108.52709743160553,106.72833956817283,107.98716162348809,107.98716162348809,1000000.0 -2019-04-15,QQQ,107.98716162348809,108.52709743160553,107.39398286179033,107.93365111737722,107.93365111737722,1000000.0 +2019-04-02,QQQ,111.08340735729682,111.63882439408329,109.060893258841,109.60893794858391,109.60893794858391,1000000.0 +2019-04-03,QQQ,109.60893794858391,110.15698263832684,106.96674434210654,107.50426567045884,107.50426567045884,1000000.0 +2019-04-04,QQQ,107.50426567045884,108.04178699881112,103.68708738665372,104.20812802678763,104.20812802678763,1000000.0 +2019-04-05,QQQ,104.20812802678763,106.4271159481818,103.68708738665372,105.89762780913614,105.89762780913614,1000000.0 +2019-04-08,QQQ,105.89762780913614,106.5825398723778,105.36813967009046,106.05227847997791,106.05227847997791,1000000.0 +2019-04-09,QQQ,106.05227847997791,108.7499553747126,105.52201708757804,108.20891082060956,108.20891082060956,1000000.0 +2019-04-10,QQQ,108.20891082060956,108.79167639205686,107.66786626650652,108.25042427070336,108.25042427070336,1000000.0 +2019-04-11,QQQ,108.25042427070336,108.79167639205686,106.72833956817284,107.26466288258575,107.26466288258575,1000000.0 +2019-04-12,QQQ,107.26466288258575,108.52709743160553,106.72833956817284,107.98716162348808,107.98716162348808,1000000.0 +2019-04-15,QQQ,107.98716162348808,108.52709743160553,107.39398286179032,107.93365111737722,107.93365111737722,1000000.0 2019-04-16,QQQ,107.93365111737722,108.4733193729641,105.71002459064512,106.24123074436696,106.24123074436696,1000000.0 -2019-04-17,QQQ,106.24123074436696,106.77243689808878,104.55297312755813,105.07836495231973,105.07836495231973,1000000.0 -2019-04-18,QQQ,105.07836495231973,108.11138922269913,104.55297312755813,107.57352161462602,107.57352161462602,1000000.0 -2019-04-19,QQQ,107.57352161462602,108.66487828317908,107.03565400655289,108.12425699818814,108.12425699818814,1000000.0 -2019-04-22,QQQ,108.12425699818814,109.30931984207271,107.5836357131972,108.76549238017186,108.76549238017186,1000000.0 -2019-04-23,QQQ,108.76549238017186,109.30931984207271,107.88721806694892,108.42936489140595,108.42936489140595,1000000.0 -2019-04-24,QQQ,108.42936489140595,108.97151171586297,106.97766239453945,107.51523858747683,107.51523858747683,1000000.0 -2019-04-25,QQQ,107.51523858747683,109.36726902035117,106.97766239453945,108.82315325408078,108.82315325408078,1000000.0 -2019-04-26,QQQ,108.82315325408078,109.36726902035117,108.1859419793736,108.72958992901869,108.72958992901869,1000000.0 -2019-04-29,QQQ,108.72958992901869,109.27323787866376,107.17714499844249,107.71572361652511,107.71572361652511,1000000.0 -2019-04-30,QQQ,107.71572361652511,108.25430223460772,107.04393476997627,107.5818439899259,107.5818439899259,1000000.0 -2019-05-01,QQQ,107.5818439899259,108.11975320987551,105.8440174969244,106.37589698183356,106.37589698183356,1000000.0 -2019-05-02,QQQ,106.37589698183356,107.22582843742751,105.8440174969244,106.69236660440549,106.69236660440549,1000000.0 -2019-05-03,QQQ,106.69236660440549,108.8659289728479,106.15890477138346,108.32430743566957,108.32430743566957,1000000.0 -2019-05-06,QQQ,108.32430743566957,108.8659289728479,106.67085001303413,107.20688443521018,107.20688443521018,1000000.0 -2019-05-07,QQQ,107.20688443521018,109.81741034094179,106.67085001303413,109.27105506561374,109.27105506561374,1000000.0 -2019-05-08,QQQ,109.27105506561374,109.81741034094179,107.83906080390165,108.38096563206196,108.38096563206196,1000000.0 -2019-05-09,QQQ,108.38096563206196,109.19490223739969,107.83906080390165,108.65164401731313,108.65164401731313,1000000.0 -2019-05-10,QQQ,108.65164401731313,109.19490223739969,106.9927709805255,107.53042309600553,107.53042309600553,1000000.0 -2019-05-13,QQQ,107.53042309600553,108.06807521148555,106.73746422731335,107.27383339428478,107.27383339428478,1000000.0 -2019-05-14,QQQ,107.27383339428478,107.93061368600885,106.73746422731335,107.39364545871528,107.39364545871528,1000000.0 -2019-05-15,QQQ,107.39364545871528,107.93061368600885,106.3076838332027,106.84189329970121,106.84189329970121,1000000.0 -2019-05-16,QQQ,106.84189329970121,107.37610276619971,105.39340094392495,105.92301602404517,105.92301602404517,1000000.0 -2019-05-17,QQQ,105.92301602404517,106.45263110416538,104.52094730834996,105.0461781993467,105.0461781993467,1000000.0 -2019-05-20,QQQ,105.0461781993467,105.57140909034342,103.46283071599012,103.98274443818103,103.98274443818103,1000000.0 -2019-05-21,QQQ,103.98274443818103,104.50265816037192,101.4231516159575,101.93281569442965,101.93281569442965,1000000.0 -2019-05-22,QQQ,101.93281569442965,102.44247977290179,101.12756905839193,101.63574779737883,101.63574779737883,1000000.0 -2019-05-23,QQQ,101.63574779737883,102.72935615894424,101.12756905839193,102.2182648347704,102.2182648347704,1000000.0 -2019-05-24,QQQ,102.2182648347704,104.00168957489106,101.70717351059655,103.48426823372246,103.48426823372246,1000000.0 -2019-05-27,QQQ,103.48426823372246,104.9327302325993,102.96684689255385,104.41067684835753,104.41067684835753,1000000.0 -2019-05-28,QQQ,104.41067684835753,108.39314567039159,103.88862346411574,107.85387628894686,107.85387628894686,1000000.0 -2019-05-29,QQQ,107.85387628894686,108.89757008701135,107.31460690750212,108.35579113135458,108.35579113135458,1000000.0 -2019-05-30,QQQ,108.35579113135458,108.89757008701135,107.22991767437414,107.76876148178305,107.76876148178305,1000000.0 +2019-04-17,QQQ,106.24123074436696,106.77243689808878,104.55297312755812,105.07836495231972,105.07836495231972,1000000.0 +2019-04-18,QQQ,105.07836495231972,108.11138922269912,104.55297312755812,107.57352161462602,107.57352161462602,1000000.0 +2019-04-22,QQQ,108.12425699818814,109.30931984207272,107.5836357131972,108.76549238017186,108.76549238017186,1000000.0 +2019-04-23,QQQ,108.76549238017186,109.30931984207272,107.88721806694892,108.42936489140595,108.42936489140595,1000000.0 +2019-04-24,QQQ,108.42936489140595,108.97151171586296,106.97766239453944,107.51523858747684,107.51523858747684,1000000.0 +2019-04-25,QQQ,107.51523858747684,109.36726902035116,106.97766239453944,108.82315325408078,108.82315325408078,1000000.0 +2019-04-26,QQQ,108.82315325408078,109.36726902035116,108.1859419793736,108.72958992901869,108.72958992901869,1000000.0 +2019-04-29,QQQ,108.72958992901869,109.27323787866376,107.17714499844249,107.71572361652512,107.71572361652512,1000000.0 +2019-04-30,QQQ,107.71572361652512,108.25430223460772,107.04393476997627,107.5818439899259,107.5818439899259,1000000.0 +2019-05-01,QQQ,107.5818439899259,108.11975320987553,105.8440174969244,106.37589698183356,106.37589698183356,1000000.0 +2019-05-02,QQQ,106.37589698183356,107.22582843742752,105.8440174969244,106.69236660440548,106.69236660440548,1000000.0 +2019-05-03,QQQ,106.69236660440548,108.8659289728479,106.15890477138346,108.32430743566955,108.32430743566955,1000000.0 +2019-05-06,QQQ,108.32430743566955,108.8659289728479,106.67085001303413,107.20688443521018,107.20688443521018,1000000.0 +2019-05-07,QQQ,107.20688443521018,109.8174103409418,106.67085001303413,109.27105506561374,109.27105506561374,1000000.0 +2019-05-08,QQQ,109.27105506561374,109.8174103409418,107.83906080390165,108.38096563206196,108.38096563206196,1000000.0 +2019-05-09,QQQ,108.38096563206196,109.19490223739967,107.83906080390165,108.65164401731312,108.65164401731312,1000000.0 +2019-05-10,QQQ,108.65164401731312,109.19490223739967,106.9927709805255,107.53042309600552,107.53042309600552,1000000.0 +2019-05-13,QQQ,107.53042309600552,108.06807521148556,106.73746422731335,107.27383339428478,107.27383339428478,1000000.0 +2019-05-14,QQQ,107.27383339428478,107.93061368600884,106.73746422731335,107.39364545871528,107.39364545871528,1000000.0 +2019-05-15,QQQ,107.39364545871528,107.93061368600884,106.3076838332027,106.8418932997012,106.8418932997012,1000000.0 +2019-05-16,QQQ,106.8418932997012,107.37610276619972,105.39340094392496,105.92301602404515,105.92301602404515,1000000.0 +2019-05-17,QQQ,105.92301602404515,106.45263110416538,104.52094730834996,105.0461781993467,105.0461781993467,1000000.0 +2019-05-20,QQQ,105.0461781993467,105.57140909034342,103.46283071599012,103.98274443818104,103.98274443818104,1000000.0 +2019-05-21,QQQ,103.98274443818104,104.50265816037192,101.4231516159575,101.93281569442964,101.93281569442964,1000000.0 +2019-05-22,QQQ,101.93281569442964,102.4424797729018,101.12756905839191,101.63574779737884,101.63574779737884,1000000.0 +2019-05-23,QQQ,101.63574779737884,102.72935615894424,101.12756905839191,102.2182648347704,102.2182648347704,1000000.0 +2019-05-24,QQQ,102.2182648347704,104.00168957489106,101.70717351059656,103.48426823372246,103.48426823372246,1000000.0 +2019-05-28,QQQ,104.41067684835753,108.3931456703916,103.88862346411574,107.85387628894686,107.85387628894686,1000000.0 +2019-05-29,QQQ,107.85387628894686,108.89757008701136,107.31460690750212,108.35579113135458,108.35579113135458,1000000.0 +2019-05-30,QQQ,108.35579113135458,108.89757008701136,107.22991767437414,107.76876148178305,107.76876148178305,1000000.0 2019-05-31,QQQ,107.76876148178305,111.03068739255562,107.22991767437414,110.47829591299067,110.47829591299067,1000000.0 -2019-06-03,QQQ,110.47829591299067,111.03068739255562,108.49376517973595,109.03895997963411,109.03895997963411,1000000.0 -2019-06-04,QQQ,109.03895997963411,111.0280641799991,108.49376517973595,110.4756857512429,110.4756857512429,1000000.0 -2019-06-05,QQQ,110.4756857512429,111.0280641799991,108.62113854547435,109.16697341253703,109.16697341253703,1000000.0 -2019-06-06,QQQ,109.16697341253703,110.27415346154609,108.62113854547435,109.72552583238418,109.72552583238418,1000000.0 -2019-06-07,QQQ,109.72552583238418,110.27415346154609,106.47181776811215,107.00685202825342,107.00685202825342,1000000.0 -2019-06-10,QQQ,107.00685202825342,108.86090536661277,106.47181776811215,108.31930882250028,108.31930882250028,1000000.0 -2019-06-11,QQQ,108.31930882250028,108.86090536661277,107.61000938616367,108.15076320217455,108.15076320217455,1000000.0 -2019-06-12,QQQ,108.15076320217455,108.69151701818541,106.31791549975394,106.85217638166225,106.85217638166225,1000000.0 -2019-06-13,QQQ,106.85217638166225,109.81019101824641,106.31791549975394,109.26387165994669,109.26387165994669,1000000.0 -2019-06-14,QQQ,109.26387165994669,110.96368105083302,108.71755230164695,110.41162293615227,110.41162293615227,1000000.0 -2019-06-17,QQQ,110.41162293615227,111.08530857981454,109.85956482147151,110.5326453530493,110.5326453530493,1000000.0 +2019-06-03,QQQ,110.47829591299067,111.03068739255562,108.49376517973596,109.03895997963411,109.03895997963411,1000000.0 +2019-06-04,QQQ,109.03895997963411,111.0280641799991,108.49376517973596,110.4756857512429,110.4756857512429,1000000.0 +2019-06-05,QQQ,110.4756857512429,111.0280641799991,108.62113854547437,109.16697341253705,109.16697341253705,1000000.0 +2019-06-06,QQQ,109.16697341253705,110.27415346154608,108.62113854547437,109.72552583238418,109.72552583238418,1000000.0 +2019-06-07,QQQ,109.72552583238418,110.27415346154608,106.47181776811216,107.00685202825342,107.00685202825342,1000000.0 +2019-06-10,QQQ,107.00685202825342,108.86090536661276,106.47181776811216,108.31930882250028,108.31930882250028,1000000.0 +2019-06-11,QQQ,108.31930882250028,108.86090536661276,107.61000938616368,108.15076320217456,108.15076320217456,1000000.0 +2019-06-12,QQQ,108.15076320217456,108.6915170181854,106.31791549975394,106.85217638166225,106.85217638166225,1000000.0 +2019-06-13,QQQ,106.85217638166225,109.8101910182464,106.31791549975394,109.26387165994667,109.26387165994667,1000000.0 +2019-06-14,QQQ,109.26387165994667,110.96368105083302,108.71755230164696,110.41162293615228,110.41162293615228,1000000.0 +2019-06-17,QQQ,110.41162293615228,111.08530857981454,109.85956482147152,110.5326453530493,110.5326453530493,1000000.0 2019-06-18,QQQ,110.5326453530493,111.08530857981454,108.97381287455914,109.5214199744313,109.5214199744313,1000000.0 -2019-06-19,QQQ,109.5214199744313,110.06902707430343,108.96686997395905,109.51444218488346,109.51444218488346,1000000.0 -2019-06-20,QQQ,109.51444218488346,110.06201439580786,108.78928182344451,109.33596163160253,109.33596163160253,1000000.0 -2019-06-21,QQQ,109.33596163160253,110.97833417716018,108.78928182344451,110.42620316135343,110.42620316135343,1000000.0 -2019-06-24,QQQ,110.42620316135343,112.19180733552673,109.87407214554666,111.63363913982761,111.63363913982761,1000000.0 -2019-06-25,QQQ,111.63363913982761,112.19180733552673,110.17048787782126,110.72410841992087,110.72410841992087,1000000.0 -2019-06-26,QQQ,110.72410841992087,111.27772896202046,109.44118027157678,109.99113595133345,109.99113595133345,1000000.0 -2019-06-27,QQQ,109.99113595133345,110.5410916310901,108.74116107563981,109.28759907099479,109.28759907099479,1000000.0 -2019-06-28,QQQ,109.28759907099479,109.83403706634975,106.90378934802514,107.44099431962326,107.44099431962326,1000000.0 -2019-07-01,QQQ,107.44099431962326,107.97819929122136,106.13785749248377,106.6712135602852,106.6712135602852,1000000.0 -2019-07-02,QQQ,106.6712135602852,107.20456962808662,106.06344859321217,106.59643074694691,106.59643074694691,1000000.0 -2019-07-03,QQQ,106.59643074694691,108.15008575097798,106.06344859321217,107.61202562286367,107.61202562286367,1000000.0 -2019-07-04,QQQ,107.61202562286367,110.0775237193368,107.07396549474934,109.52987434759882,109.52987434759882,1000000.0 -2019-07-05,QQQ,109.52987434759882,110.0775237193368,107.89691697881307,108.43911254152067,108.43911254152067,1000000.0 -2019-07-08,QQQ,108.43911254152067,109.81960632953171,107.89691697881307,109.27324012888728,109.27324012888728,1000000.0 -2019-07-09,QQQ,109.27324012888728,109.81960632953171,108.15453187067543,108.69802198057832,108.69802198057832,1000000.0 -2019-07-10,QQQ,108.69802198057832,112.28480883308993,108.15453187067543,111.72617794337307,111.72617794337307,1000000.0 +2019-06-19,QQQ,109.5214199744313,110.06902707430343,108.96686997395904,109.51444218488346,109.51444218488346,1000000.0 +2019-06-20,QQQ,109.51444218488346,110.06201439580786,108.78928182344453,109.33596163160252,109.33596163160252,1000000.0 +2019-06-21,QQQ,109.33596163160252,110.97833417716018,108.78928182344453,110.42620316135344,110.42620316135344,1000000.0 +2019-06-24,QQQ,110.42620316135344,112.19180733552672,109.87407214554666,111.6336391398276,111.6336391398276,1000000.0 +2019-06-25,QQQ,111.6336391398276,112.19180733552672,110.17048787782126,110.72410841992088,110.72410841992088,1000000.0 +2019-06-26,QQQ,110.72410841992088,111.27772896202046,109.44118027157678,109.99113595133343,109.99113595133343,1000000.0 +2019-06-27,QQQ,109.99113595133343,110.5410916310901,108.7411610756398,109.2875990709948,109.2875990709948,1000000.0 +2019-06-28,QQQ,109.2875990709948,109.83403706634977,106.90378934802514,107.44099431962326,107.44099431962326,1000000.0 +2019-07-01,QQQ,107.44099431962326,107.97819929122136,106.13785749248376,106.6712135602852,106.6712135602852,1000000.0 +2019-07-02,QQQ,106.6712135602852,107.20456962808662,106.06344859321216,106.59643074694692,106.59643074694692,1000000.0 +2019-07-03,QQQ,106.59643074694692,108.15008575097798,106.06344859321216,107.61202562286368,107.61202562286368,1000000.0 +2019-07-05,QQQ,109.52987434759882,110.0775237193368,107.89691697881308,108.43911254152069,108.43911254152069,1000000.0 +2019-07-08,QQQ,108.43911254152069,109.81960632953172,107.89691697881308,109.27324012888728,109.27324012888728,1000000.0 +2019-07-09,QQQ,109.27324012888728,109.81960632953172,108.15453187067544,108.69802198057832,108.69802198057832,1000000.0 +2019-07-10,QQQ,108.69802198057832,112.28480883308993,108.15453187067544,111.72617794337307,111.72617794337307,1000000.0 2019-07-11,QQQ,111.72617794337307,112.28480883308993,111.1527176161562,111.71127398608662,111.71127398608662,1000000.0 -2019-07-12,QQQ,111.71127398608662,113.06160238834586,111.1527176161562,112.49910685407549,112.49910685407549,1000000.0 -2019-07-15,QQQ,112.49910685407549,113.06160238834586,110.6378918155165,111.19386112112211,111.19386112112211,1000000.0 -2019-07-16,QQQ,111.19386112112211,111.74983042672771,109.53275858636339,110.08317445865667,110.08317445865667,1000000.0 -2019-07-17,QQQ,110.08317445865667,110.97953244623942,109.53275858636339,110.42739546889496,110.42739546889496,1000000.0 +2019-07-12,QQQ,111.71127398608662,113.06160238834586,111.1527176161562,112.49910685407548,112.49910685407548,1000000.0 +2019-07-15,QQQ,112.49910685407548,113.06160238834586,110.6378918155165,111.19386112112213,111.19386112112213,1000000.0 +2019-07-16,QQQ,111.19386112112213,111.74983042672773,109.5327585863634,110.08317445865669,110.08317445865669,1000000.0 +2019-07-17,QQQ,110.08317445865669,110.97953244623942,109.5327585863634,110.42739546889496,110.42739546889496,1000000.0 2019-07-18,QQQ,110.42739546889496,110.97953244623942,109.3827282801242,109.9323902312806,109.9323902312806,1000000.0 2019-07-19,QQQ,109.9323902312806,111.04614501505546,109.3827282801242,110.493676631896,110.493676631896,1000000.0 2019-07-22,QQQ,110.493676631896,111.04614501505546,107.78538120065228,108.3270162820626,108.3270162820626,1000000.0 -2019-07-23,QQQ,108.3270162820626,109.8645437857335,107.78538120065228,109.31795401565523,109.31795401565523,1000000.0 -2019-07-24,QQQ,109.31795401565523,109.8645437857335,107.5606483150732,108.1011540855007,108.1011540855007,1000000.0 -2019-07-25,QQQ,108.1011540855007,111.15731457016106,107.5606483150732,110.60429310463789,110.60429310463789,1000000.0 -2019-07-26,QQQ,110.60429310463789,111.15731457016106,109.69417745195317,110.24540447432479,110.24540447432479,1000000.0 -2019-07-29,QQQ,110.24540447432479,112.44461818979924,109.69417745195317,111.88519222865598,111.88519222865598,1000000.0 -2019-07-30,QQQ,111.88519222865598,112.44461818979924,109.37976956127717,109.92941664449967,109.92941664449967,1000000.0 -2019-07-31,QQQ,109.92941664449967,111.15241145291422,109.37976956127717,110.59941438100918,110.59941438100918,1000000.0 -2019-08-01,QQQ,110.59941438100918,111.15241145291422,108.83770926293573,109.38463242506104,109.38463242506104,1000000.0 -2019-08-02,QQQ,109.38463242506104,109.93155558718634,108.20507930393396,108.74882342103915,108.74882342103915,1000000.0 -2019-08-05,QQQ,108.74882342103915,109.3821971768792,108.20507930393396,108.83800714117335,108.83800714117335,1000000.0 -2019-08-06,QQQ,108.83800714117335,109.3821971768792,107.90228913401313,108.44451169247552,108.44451169247552,1000000.0 -2019-08-07,QQQ,108.44451169247552,109.41647730103242,107.90228913401313,108.8721167174452,108.8721167174452,1000000.0 -2019-08-08,QQQ,108.8721167174452,110.54039440731621,108.32775613385797,109.99044219633456,109.99044219633456,1000000.0 +2019-07-23,QQQ,108.3270162820626,109.8645437857335,107.78538120065228,109.31795401565525,109.31795401565525,1000000.0 +2019-07-24,QQQ,109.31795401565525,109.8645437857335,107.5606483150732,108.1011540855007,108.1011540855007,1000000.0 +2019-07-25,QQQ,108.1011540855007,111.15731457016106,107.5606483150732,110.60429310463788,110.60429310463788,1000000.0 +2019-07-26,QQQ,110.60429310463788,111.15731457016106,109.69417745195317,110.2454044743248,110.2454044743248,1000000.0 +2019-07-29,QQQ,110.2454044743248,112.44461818979924,109.69417745195317,111.88519222865598,111.88519222865598,1000000.0 +2019-07-30,QQQ,111.88519222865598,112.44461818979924,109.37976956127716,109.92941664449968,109.92941664449968,1000000.0 +2019-07-31,QQQ,109.92941664449968,111.15241145291422,109.37976956127716,110.59941438100918,110.59941438100918,1000000.0 +2019-08-01,QQQ,110.59941438100918,111.15241145291422,108.83770926293572,109.38463242506104,109.38463242506104,1000000.0 +2019-08-02,QQQ,109.38463242506104,109.93155558718634,108.20507930393396,108.74882342103916,108.74882342103916,1000000.0 +2019-08-05,QQQ,108.74882342103916,109.3821971768792,108.20507930393396,108.83800714117336,108.83800714117336,1000000.0 +2019-08-06,QQQ,108.83800714117336,109.3821971768792,107.90228913401312,108.44451169247552,108.44451169247552,1000000.0 +2019-08-07,QQQ,108.44451169247552,109.41647730103242,107.90228913401312,108.8721167174452,108.8721167174452,1000000.0 +2019-08-08,QQQ,108.8721167174452,110.5403944073162,108.32775613385796,109.99044219633456,109.99044219633456,1000000.0 2019-08-09,QQQ,109.99044219633456,111.53820547536188,109.44048998535288,110.98328903021084,110.98328903021084,1000000.0 2019-08-12,QQQ,110.98328903021084,111.53820547536188,110.35802349780302,110.91258642995278,110.91258642995278,1000000.0 -2019-08-13,QQQ,110.91258642995278,111.46714936210253,108.47342834812409,109.01852095288854,109.01852095288854,1000000.0 -2019-08-14,QQQ,109.01852095288854,109.56361355765297,107.3983526589088,107.93804287327518,107.93804287327518,1000000.0 -2019-08-15,QQQ,107.93804287327518,108.47773308764154,107.17896559031762,107.71755335710313,107.71755335710313,1000000.0 -2019-08-16,QQQ,107.71755335710313,108.25614112388864,104.31511554455598,104.83931210508139,104.83931210508139,1000000.0 -2019-08-19,QQQ,104.83931210508139,106.45191770195552,104.31511554455598,105.92230617110003,105.92230617110003,1000000.0 -2019-08-20,QQQ,105.92230617110003,106.45191770195552,105.11742161199128,105.64564986129777,105.64564986129777,1000000.0 -2019-08-21,QQQ,105.64564986129777,106.17387811060425,104.82694792368648,105.35371650621757,105.35371650621757,1000000.0 +2019-08-13,QQQ,110.91258642995278,111.46714936210252,108.47342834812407,109.01852095288854,109.01852095288854,1000000.0 +2019-08-14,QQQ,109.01852095288854,109.56361355765296,107.3983526589088,107.93804287327518,107.93804287327518,1000000.0 +2019-08-15,QQQ,107.93804287327518,108.47773308764154,107.17896559031762,107.71755335710311,107.71755335710311,1000000.0 +2019-08-16,QQQ,107.71755335710311,108.25614112388864,104.31511554455598,104.8393121050814,104.8393121050814,1000000.0 +2019-08-19,QQQ,104.8393121050814,106.45191770195552,104.31511554455598,105.92230617110005,105.92230617110005,1000000.0 +2019-08-20,QQQ,105.92230617110005,106.45191770195552,105.11742161199128,105.64564986129776,105.64564986129776,1000000.0 +2019-08-21,QQQ,105.64564986129776,106.17387811060424,104.82694792368648,105.35371650621757,105.35371650621757,1000000.0 2019-08-22,QQQ,105.35371650621757,107.2475645704255,104.82694792368648,106.71399459743832,106.71399459743832,1000000.0 -2019-08-23,QQQ,106.71399459743832,108.23693710039106,106.18042462445112,107.69844487601101,107.69844487601101,1000000.0 -2019-08-26,QQQ,107.69844487601101,108.6026380591665,107.15995265163096,108.06232642703135,108.06232642703135,1000000.0 -2019-08-27,QQQ,108.06232642703135,108.6026380591665,106.18451397779769,106.71810450029919,106.71810450029919,1000000.0 -2019-08-28,QQQ,106.71810450029919,107.7392377778551,106.18451397779769,107.20322166950757,107.20322166950757,1000000.0 -2019-08-29,QQQ,107.20322166950757,108.27931630475086,106.66720556116003,107.74061323855808,107.74061323855808,1000000.0 -2019-08-30,QQQ,107.74061323855808,108.27931630475086,106.02893344676667,106.56174215755445,106.56174215755445,1000000.0 -2019-09-02,QQQ,106.56174215755445,108.6112671538776,106.02893344676667,108.070912590923,108.070912590923,1000000.0 -2019-09-03,QQQ,108.070912590923,109.38542830130399,107.53055802796838,108.84122219035224,108.84122219035224,1000000.0 -2019-09-04,QQQ,108.84122219035224,109.38542830130399,105.61205104470827,106.14276486905354,106.14276486905354,1000000.0 -2019-09-05,QQQ,106.14276486905354,106.89616157339724,105.61205104470827,106.36433987402711,106.36433987402711,1000000.0 -2019-09-06,QQQ,106.36433987402711,106.89616157339724,104.15880997529338,104.68222108069686,104.68222108069686,1000000.0 +2019-08-23,QQQ,106.71399459743832,108.23693710039106,106.18042462445112,107.698444876011,107.698444876011,1000000.0 +2019-08-26,QQQ,107.698444876011,108.6026380591665,107.15995265163096,108.06232642703137,108.06232642703137,1000000.0 +2019-08-27,QQQ,108.06232642703137,108.6026380591665,106.18451397779768,106.7181045002992,106.7181045002992,1000000.0 +2019-08-28,QQQ,106.7181045002992,107.7392377778551,106.18451397779768,107.20322166950756,107.20322166950756,1000000.0 +2019-08-29,QQQ,107.20322166950756,108.27931630475086,106.66720556116005,107.74061323855808,107.74061323855808,1000000.0 +2019-08-30,QQQ,107.74061323855808,108.27931630475086,106.02893344676669,106.56174215755443,106.56174215755443,1000000.0 +2019-09-03,QQQ,108.070912590923,109.385428301304,107.53055802796838,108.84122219035224,108.84122219035224,1000000.0 +2019-09-04,QQQ,108.84122219035224,109.385428301304,105.61205104470828,106.14276486905354,106.14276486905354,1000000.0 +2019-09-05,QQQ,106.14276486905354,106.89616157339724,105.61205104470828,106.36433987402712,106.36433987402712,1000000.0 +2019-09-06,QQQ,106.36433987402712,106.89616157339724,104.15880997529338,104.68222108069686,104.68222108069686,1000000.0 2019-09-09,QQQ,104.68222108069686,106.82467441482214,104.15880997529338,106.29320837295737,106.29320837295737,1000000.0 -2019-09-10,QQQ,106.29320837295737,108.0052996913627,105.76174233109258,107.46795989190319,107.46795989190319,1000000.0 -2019-09-11,QQQ,107.46795989190319,108.0052996913627,105.4601898227774,105.99014052540441,105.99014052540441,1000000.0 -2019-09-12,QQQ,105.99014052540441,106.52009122803142,104.30613583125886,104.83028726759684,104.83028726759684,1000000.0 -2019-09-13,QQQ,104.83028726759684,105.5467903469429,104.30613583125886,105.02168193725663,105.02168193725663,1000000.0 -2019-09-16,QQQ,105.02168193725663,105.5467903469429,104.22476208112325,104.74850460414396,104.74850460414396,1000000.0 +2019-09-10,QQQ,106.29320837295737,108.0052996913627,105.76174233109258,107.4679598919032,107.4679598919032,1000000.0 +2019-09-11,QQQ,107.4679598919032,108.0052996913627,105.4601898227774,105.9901405254044,105.9901405254044,1000000.0 +2019-09-12,QQQ,105.9901405254044,106.52009122803142,104.30613583125886,104.83028726759684,104.83028726759684,1000000.0 +2019-09-13,QQQ,104.83028726759684,105.5467903469429,104.30613583125886,105.02168193725664,105.02168193725664,1000000.0 +2019-09-16,QQQ,105.02168193725664,105.5467903469429,104.22476208112325,104.74850460414396,104.74850460414396,1000000.0 2019-09-17,QQQ,104.74850460414396,107.43408152798534,104.22476208112325,106.89958360993568,106.89958360993568,1000000.0 2019-09-18,QQQ,106.89958360993568,108.52820835418788,106.365085691886,107.98826701909243,107.98826701909243,1000000.0 -2019-09-19,QQQ,107.98826701909243,108.52820835418788,107.10808732123401,107.64631891581307,107.64631891581307,1000000.0 -2019-09-20,QQQ,107.64631891581307,109.05416843927287,107.10808732123401,108.5116103873362,108.5116103873362,1000000.0 -2019-09-23,QQQ,108.5116103873362,109.05416843927287,105.206491284529,105.73516712012965,105.73516712012965,1000000.0 -2019-09-24,QQQ,105.73516712012965,106.74612235402745,105.206491284529,106.21504711843528,106.21504711843528,1000000.0 -2019-09-25,QQQ,106.21504711843528,108.00922268001361,105.6839718828431,107.47186336319763,107.47186336319763,1000000.0 -2019-09-26,QQQ,107.47186336319763,108.00922268001361,106.73937896491852,107.27575775368695,107.27575775368695,1000000.0 -2019-09-27,QQQ,107.27575775368695,107.81213654245536,105.81130520423108,106.34302030575988,106.34302030575988,1000000.0 +2019-09-19,QQQ,107.98826701909243,108.52820835418788,107.108087321234,107.64631891581308,107.64631891581308,1000000.0 +2019-09-20,QQQ,107.64631891581308,109.05416843927289,107.108087321234,108.5116103873362,108.5116103873362,1000000.0 +2019-09-23,QQQ,108.5116103873362,109.05416843927289,105.206491284529,105.73516712012965,105.73516712012965,1000000.0 +2019-09-24,QQQ,105.73516712012965,106.74612235402743,105.206491284529,106.21504711843528,106.21504711843528,1000000.0 +2019-09-25,QQQ,106.21504711843528,108.0092226800136,105.6839718828431,107.47186336319764,107.47186336319764,1000000.0 +2019-09-26,QQQ,107.47186336319764,108.0092226800136,106.73937896491852,107.27575775368696,107.27575775368696,1000000.0 +2019-09-27,QQQ,107.27575775368696,107.81213654245536,105.81130520423108,106.34302030575988,106.34302030575988,1000000.0 2019-09-30,QQQ,106.34302030575988,107.71763217701758,105.81130520423108,107.18172355922148,107.18172355922148,1000000.0 -2019-10-01,QQQ,107.18172355922148,110.55081482084144,106.64581494142537,110.00081076700641,110.00081076700641,1000000.0 -2019-10-02,QQQ,110.00081076700641,110.90107348099187,109.45080671317137,110.34932684675809,110.34932684675809,1000000.0 -2019-10-03,QQQ,110.34932684675809,110.90107348099187,108.2636743748496,108.80771293954734,108.80771293954734,1000000.0 -2019-10-04,QQQ,108.80771293954734,111.48223230209773,108.2636743748496,110.92759433044552,110.92759433044552,1000000.0 -2019-10-07,QQQ,110.92759433044552,111.48223230209773,107.76404042594528,108.30556826728169,108.30556826728169,1000000.0 -2019-10-08,QQQ,108.30556826728169,109.61626562898131,107.76404042594528,109.07091107361326,109.07091107361326,1000000.0 -2019-10-09,QQQ,109.07091107361326,109.61626562898131,107.7614813492029,108.30299633085718,108.30299633085718,1000000.0 -2019-10-10,QQQ,108.30299633085718,110.22438389324331,107.7614813492029,109.67600387387395,109.67600387387395,1000000.0 -2019-10-11,QQQ,109.67600387387395,110.22438389324331,107.98311708038939,108.52574580943657,108.52574580943657,1000000.0 -2019-10-14,QQQ,108.52574580943657,111.01718358304254,107.98311708038939,110.4648592866095,110.4648592866095,1000000.0 -2019-10-15,QQQ,110.4648592866095,111.50713128420733,109.91253499017645,110.95236943702223,110.95236943702223,1000000.0 -2019-10-16,QQQ,110.95236943702223,111.50713128420733,108.00089725620806,108.54361533287242,108.54361533287242,1000000.0 +2019-10-01,QQQ,107.18172355922148,110.55081482084144,106.64581494142536,110.0008107670064,110.0008107670064,1000000.0 +2019-10-02,QQQ,110.0008107670064,110.90107348099188,109.45080671317136,110.34932684675807,110.34932684675807,1000000.0 +2019-10-03,QQQ,110.34932684675807,110.90107348099188,108.2636743748496,108.80771293954734,108.80771293954734,1000000.0 +2019-10-04,QQQ,108.80771293954734,111.48223230209771,108.2636743748496,110.92759433044552,110.92759433044552,1000000.0 +2019-10-07,QQQ,110.92759433044552,111.48223230209771,107.76404042594528,108.30556826728169,108.30556826728169,1000000.0 +2019-10-08,QQQ,108.30556826728169,109.61626562898132,107.76404042594528,109.07091107361326,109.07091107361326,1000000.0 +2019-10-09,QQQ,109.07091107361326,109.61626562898132,107.7614813492029,108.30299633085718,108.30299633085718,1000000.0 +2019-10-10,QQQ,108.30299633085718,110.22438389324331,107.7614813492029,109.67600387387397,109.67600387387397,1000000.0 +2019-10-11,QQQ,109.67600387387397,110.22438389324331,107.9831170803894,108.52574580943656,108.52574580943656,1000000.0 +2019-10-14,QQQ,108.52574580943656,111.01718358304254,107.9831170803894,110.4648592866095,110.4648592866095,1000000.0 +2019-10-15,QQQ,110.4648592866095,111.50713128420732,109.91253499017644,110.95236943702224,110.95236943702224,1000000.0 +2019-10-16,QQQ,110.95236943702224,111.50713128420732,108.00089725620806,108.54361533287242,108.54361533287242,1000000.0 2019-10-17,QQQ,108.54361533287242,109.08633340953678,105.31753880807818,105.84677267143536,105.84677267143536,1000000.0 -2019-10-18,QQQ,105.84677267143536,106.37600653479252,104.93810750052869,105.46543467389817,105.46543467389817,1000000.0 -2019-10-21,QQQ,105.46543467389817,107.84505903607727,104.93810750052869,107.30851645380824,107.30851645380824,1000000.0 -2019-10-22,QQQ,107.30851645380824,108.07732562552295,106.7719738715392,107.53962748808254,107.53962748808254,1000000.0 -2019-10-23,QQQ,107.53962748808254,108.22577107912352,107.00192935064213,107.68733440708809,107.68733440708809,1000000.0 -2019-10-24,QQQ,107.68733440708809,108.22577107912352,106.87422597682854,107.4112823887724,107.4112823887724,1000000.0 -2019-10-25,QQQ,107.4112823887724,109.04334573262659,106.87422597682854,108.50084152500159,108.50084152500159,1000000.0 -2019-10-28,QQQ,108.50084152500159,109.63434791585568,107.95833731737658,109.08890339886139,109.08890339886139,1000000.0 -2019-10-29,QQQ,109.08890339886139,110.08966995996026,108.54345888186708,109.54196015916445,109.54196015916445,1000000.0 +2019-10-18,QQQ,105.84677267143536,106.37600653479252,104.93810750052867,105.46543467389816,105.46543467389816,1000000.0 +2019-10-21,QQQ,105.46543467389816,107.84505903607727,104.93810750052867,107.30851645380824,107.30851645380824,1000000.0 +2019-10-22,QQQ,107.30851645380824,108.07732562552296,106.7719738715392,107.53962748808254,107.53962748808254,1000000.0 +2019-10-23,QQQ,107.53962748808254,108.22577107912352,107.00192935064211,107.68733440708807,107.68733440708807,1000000.0 +2019-10-24,QQQ,107.68733440708807,108.22577107912352,106.87422597682854,107.4112823887724,107.4112823887724,1000000.0 +2019-10-25,QQQ,107.4112823887724,109.0433457326266,106.87422597682854,108.5008415250016,108.5008415250016,1000000.0 +2019-10-28,QQQ,108.5008415250016,109.63434791585568,107.95833731737658,109.0889033988614,109.0889033988614,1000000.0 +2019-10-29,QQQ,109.0889033988614,110.08966995996026,108.54345888186708,109.54196015916445,109.54196015916445,1000000.0 2019-10-30,QQQ,109.54196015916445,110.08966995996026,107.6616817120314,108.20269518797126,108.20269518797126,1000000.0 2019-10-31,QQQ,108.20269518797126,110.4912033324325,107.6616817120314,109.94149585316669,109.94149585316669,1000000.0 -2019-11-01,QQQ,109.94149585316669,112.5228098486764,109.39178837390085,111.9629948743049,111.9629948743049,1000000.0 +2019-11-01,QQQ,109.94149585316669,112.5228098486764,109.39178837390084,111.9629948743049,111.9629948743049,1000000.0 2019-11-04,QQQ,111.9629948743049,112.5228098486764,109.58913936354926,110.13983855633091,110.13983855633091,1000000.0 2019-11-05,QQQ,110.13983855633091,114.10941813411604,109.58913936354926,113.54170958618512,113.54170958618512,1000000.0 2019-11-06,QQQ,113.54170958618512,115.24385734529211,112.9740010382542,114.6705048211862,114.6705048211862,1000000.0 -2019-11-07,QQQ,114.6705048211862,115.24385734529211,113.432304248981,114.00231582812161,114.00231582812161,1000000.0 -2019-11-08,QQQ,114.00231582812161,114.57232740726221,110.71392185996619,111.27027322609668,111.27027322609668,1000000.0 -2019-11-11,QQQ,111.27027322609668,112.84200794943736,110.71392185996619,112.2806049248133,112.2806049248133,1000000.0 +2019-11-07,QQQ,114.6705048211862,115.24385734529211,113.432304248981,114.0023158281216,114.0023158281216,1000000.0 +2019-11-08,QQQ,114.0023158281216,114.5723274072622,110.7139218599662,111.27027322609668,111.27027322609668,1000000.0 +2019-11-11,QQQ,111.27027322609668,112.84200794943736,110.7139218599662,112.2806049248133,112.2806049248133,1000000.0 2019-11-12,QQQ,112.2806049248133,113.71966358340524,111.71920190018923,113.15389411284104,113.15389411284104,1000000.0 -2019-11-13,QQQ,113.15389411284104,113.71966358340524,111.77557109556454,112.33725738247693,112.33725738247693,1000000.0 -2019-11-14,QQQ,112.33725738247693,112.89894366938931,109.25841311933267,109.80745037118862,109.80745037118862,1000000.0 -2019-11-15,QQQ,109.80745037118862,112.03755416696416,109.25841311933267,111.48015339996435,111.48015339996435,1000000.0 -2019-11-18,QQQ,111.48015339996435,112.03755416696416,110.13268767322668,110.68611826454944,110.68611826454944,1000000.0 -2019-11-19,QQQ,110.68611826454944,111.23954885587217,109.5140886501903,110.06441070370884,110.06441070370884,1000000.0 -2019-11-20,QQQ,110.06441070370884,114.84078303949923,109.5140886501903,114.26943586019824,114.26943586019824,1000000.0 +2019-11-13,QQQ,113.15389411284104,113.71966358340524,111.77557109556454,112.33725738247692,112.33725738247692,1000000.0 +2019-11-14,QQQ,112.33725738247692,112.89894366938933,109.25841311933269,109.80745037118862,109.80745037118862,1000000.0 +2019-11-15,QQQ,109.80745037118862,112.03755416696416,109.25841311933269,111.48015339996437,111.48015339996437,1000000.0 +2019-11-18,QQQ,111.48015339996437,112.03755416696416,110.13268767322668,110.68611826454944,110.68611826454944,1000000.0 +2019-11-19,QQQ,110.68611826454944,111.23954885587216,109.5140886501903,110.06441070370884,110.06441070370884,1000000.0 +2019-11-20,QQQ,110.06441070370884,114.84078303949924,109.5140886501903,114.26943586019824,114.26943586019824,1000000.0 2019-11-21,QQQ,114.26943586019824,118.12109325763336,113.69808868089726,117.53342612699838,117.53342612699838,1000000.0 -2019-11-22,QQQ,117.53342612699838,119.6545558168084,116.94575899636338,119.05925951921235,119.05925951921235,1000000.0 -2019-11-25,QQQ,119.05925951921235,119.6545558168084,117.82356965683722,118.41564789631882,118.41564789631882,1000000.0 -2019-11-26,QQQ,118.41564789631882,120.07678499126808,117.82356965683722,119.47938805101303,119.47938805101303,1000000.0 -2019-11-27,QQQ,119.47938805101303,120.07678499126808,115.46555064251584,116.04577954021693,116.04577954021693,1000000.0 -2019-11-28,QQQ,116.04577954021693,116.84604704392648,115.46555064251584,116.26472342679253,116.26472342679253,1000000.0 -2019-11-29,QQQ,116.26472342679253,116.84604704392648,114.60363354388407,115.17953119988348,115.17953119988348,1000000.0 +2019-11-22,QQQ,117.53342612699838,119.6545558168084,116.94575899636338,119.05925951921236,119.05925951921236,1000000.0 +2019-11-25,QQQ,119.05925951921236,119.6545558168084,117.82356965683722,118.41564789631882,118.41564789631882,1000000.0 +2019-11-26,QQQ,118.41564789631882,120.07678499126808,117.82356965683722,119.47938805101305,119.47938805101305,1000000.0 +2019-11-27,QQQ,119.47938805101305,120.07678499126808,115.46555064251584,116.04577954021693,116.04577954021693,1000000.0 +2019-11-29,QQQ,116.26472342679251,116.84604704392648,114.60363354388409,115.17953119988348,115.17953119988348,1000000.0 2019-12-02,QQQ,115.17953119988348,115.75542885588288,112.76056120578168,113.32719719174038,113.32719719174038,1000000.0 -2019-12-03,QQQ,113.32719719174038,113.89383317769907,110.51930208350846,111.07467546081253,111.07467546081253,1000000.0 -2019-12-04,QQQ,111.07467546081253,112.45094805797298,110.51930208350846,111.89149060494825,111.89149060494825,1000000.0 +2019-12-03,QQQ,113.32719719174038,113.89383317769908,110.51930208350846,111.07467546081251,111.07467546081251,1000000.0 +2019-12-04,QQQ,111.07467546081251,112.45094805797298,110.51930208350846,111.89149060494825,111.89149060494825,1000000.0 2019-12-05,QQQ,111.89149060494825,112.63787357311176,111.3320331519235,112.07748614239978,112.07748614239978,1000000.0 -2019-12-06,QQQ,112.07748614239978,112.92844880963301,111.51709871168778,112.36661573097813,112.36661573097813,1000000.0 -2019-12-09,QQQ,112.36661573097813,114.22901200117659,111.80478265232324,113.66070845888218,113.66070845888218,1000000.0 -2019-12-10,QQQ,113.66070845888218,114.22901200117659,111.91124907309961,112.47361715889409,112.47361715889409,1000000.0 -2019-12-11,QQQ,112.47361715889409,113.03598524468855,110.18310141478896,110.73678534149644,110.73678534149644,1000000.0 +2019-12-06,QQQ,112.07748614239978,112.928448809633,111.51709871168778,112.36661573097813,112.36661573097813,1000000.0 +2019-12-09,QQQ,112.36661573097813,114.2290120011766,111.80478265232324,113.66070845888218,113.66070845888218,1000000.0 +2019-12-10,QQQ,113.66070845888218,114.2290120011766,111.9112490730996,112.47361715889409,112.47361715889409,1000000.0 +2019-12-11,QQQ,112.47361715889409,113.03598524468856,110.18310141478896,110.73678534149644,110.73678534149644,1000000.0 2019-12-12,QQQ,110.73678534149644,111.2904692682039,107.7216720772439,108.26298701230542,108.26298701230542,1000000.0 -2019-12-13,QQQ,108.26298701230542,108.80430194736694,107.30693021202792,107.84616101711349,107.84616101711349,1000000.0 -2019-12-16,QQQ,107.84616101711349,109.51651380033738,107.30693021202792,108.97165552272376,108.97165552272376,1000000.0 +2019-12-13,QQQ,108.26298701230542,108.80430194736694,107.30693021202792,107.84616101711347,107.84616101711347,1000000.0 +2019-12-16,QQQ,107.84616101711347,109.51651380033738,107.30693021202792,108.97165552272376,108.97165552272376,1000000.0 2019-12-17,QQQ,108.97165552272376,109.51651380033738,108.27050371237644,108.8145765953532,108.8145765953532,1000000.0 -2019-12-18,QQQ,108.8145765953532,110.32907706268118,108.27050371237644,109.78017618177233,109.78017618177233,1000000.0 -2019-12-19,QQQ,109.78017618177233,110.32907706268118,107.62077743042134,108.16158535720739,108.16158535720739,1000000.0 -2019-12-20,QQQ,108.16158535720739,108.70239328399342,107.01993768064094,107.55772631220195,107.55772631220195,1000000.0 -2019-12-23,QQQ,107.55772631220195,108.09551494376295,106.01260198642126,106.5453286295691,106.5453286295691,1000000.0 -2019-12-24,QQQ,106.5453286295691,107.07805527271694,104.35818435095166,104.88259733763987,104.88259733763987,1000000.0 -2019-12-25,QQQ,104.88259733763987,108.04008346865226,104.35818435095166,107.5025706155744,107.5025706155744,1000000.0 -2019-12-26,QQQ,107.5025706155744,109.29822239782263,106.96505776249653,108.7544501470872,108.7544501470872,1000000.0 -2019-12-27,QQQ,108.7544501470872,109.29822239782263,107.19789808422138,107.73658098916721,107.73658098916721,1000000.0 -2019-12-30,QQQ,107.73658098916721,108.27526389411304,106.18877615657136,106.72238809705665,106.72238809705665,1000000.0 -2019-12-31,QQQ,106.72238809705665,107.25600003754192,106.16291606323547,106.69639805350299,106.69639805350299,1000000.0 -2020-01-01,QQQ,106.69639805350299,107.22988004377049,104.24335915295812,104.76719512860113,104.76719512860113,1000000.0 -2020-01-02,QQQ,104.76719512860113,105.94223513022453,104.24335915295812,105.41515933355676,105.41515933355676,1000000.0 -2020-01-03,QQQ,105.41515933355676,105.94223513022453,103.18967571881355,103.70821680282769,103.70821680282769,1000000.0 -2020-01-06,QQQ,103.70821680282769,104.22675788684182,102.74530695047397,103.26161502560198,103.26161502560198,1000000.0 +2019-12-18,QQQ,108.8145765953532,110.32907706268118,108.27050371237644,109.78017618177232,109.78017618177232,1000000.0 +2019-12-19,QQQ,109.78017618177232,110.32907706268118,107.62077743042134,108.1615853572074,108.1615853572074,1000000.0 +2019-12-20,QQQ,108.1615853572074,108.70239328399342,107.01993768064094,107.55772631220196,107.55772631220196,1000000.0 +2019-12-23,QQQ,107.55772631220196,108.09551494376296,106.01260198642126,106.5453286295691,106.5453286295691,1000000.0 +2019-12-24,QQQ,106.5453286295691,107.07805527271694,104.35818435095166,104.88259733763988,104.88259733763988,1000000.0 +2019-12-26,QQQ,107.5025706155744,109.29822239782264,106.96505776249651,108.7544501470872,108.7544501470872,1000000.0 +2019-12-27,QQQ,108.7544501470872,109.29822239782264,107.19789808422138,107.7365809891672,107.7365809891672,1000000.0 +2019-12-30,QQQ,107.7365809891672,108.27526389411304,106.18877615657136,106.72238809705664,106.72238809705664,1000000.0 +2019-12-31,QQQ,106.72238809705664,107.25600003754192,106.16291606323549,106.696398053503,106.696398053503,1000000.0 +2020-01-02,QQQ,104.76719512860112,105.94223513022452,104.24335915295812,105.41515933355676,105.41515933355676,1000000.0 +2020-01-03,QQQ,105.41515933355676,105.94223513022452,103.18967571881356,103.70821680282768,103.70821680282768,1000000.0 +2020-01-06,QQQ,103.70821680282768,104.22675788684182,102.74530695047396,103.26161502560198,103.26161502560198,1000000.0 2020-01-07,QQQ,103.26161502560198,103.77792310072998,101.43624608777124,101.94597596760929,101.94597596760929,1000000.0 2020-01-08,QQQ,101.94597596760929,102.45570584744732,100.8558687941053,101.36268220513097,101.36268220513097,1000000.0 -2020-01-09,QQQ,101.36268220513097,101.86949561615661,99.1751434326752,99.67351098761327,99.67351098761327,1000000.0 -2020-01-10,QQQ,99.67351098761327,100.17187854255133,97.21652684731244,97.7050521078517,97.7050521078517,1000000.0 -2020-01-13,QQQ,97.7050521078517,98.19357736839095,96.05115315261818,96.53382226393788,96.53382226393788,1000000.0 -2020-01-14,QQQ,96.53382226393788,97.01649137525756,95.25340550930751,95.73206583850002,95.73206583850002,1000000.0 +2020-01-09,QQQ,101.36268220513097,101.8694956161566,99.1751434326752,99.67351098761328,99.67351098761328,1000000.0 +2020-01-10,QQQ,99.67351098761328,100.17187854255133,97.21652684731244,97.7050521078517,97.7050521078517,1000000.0 +2020-01-13,QQQ,97.7050521078517,98.19357736839096,96.05115315261818,96.53382226393788,96.53382226393788,1000000.0 +2020-01-14,QQQ,96.53382226393788,97.01649137525756,95.25340550930753,95.73206583850002,95.73206583850002,1000000.0 2020-01-15,QQQ,95.73206583850002,96.21072616769251,94.93640099126334,95.41346833292798,95.41346833292798,1000000.0 -2020-01-16,QQQ,95.41346833292798,96.02456793461607,94.93640099126334,95.54683376578714,95.54683376578714,1000000.0 -2020-01-17,QQQ,95.54683376578714,97.99865206532439,95.06909959695821,97.51109658241234,97.51109658241234,1000000.0 -2020-01-20,QQQ,97.51109658241234,99.0884611310715,97.02354109950028,98.59548371250897,98.59548371250897,1000000.0 -2020-01-21,QQQ,98.59548371250897,99.0884611310715,97.82940778905889,98.32101285332551,98.32101285332551,1000000.0 -2020-01-22,QQQ,98.32101285332551,99.79940752409429,97.82940778905889,99.30289305880031,99.30289305880031,1000000.0 -2020-01-23,QQQ,99.30289305880031,99.79940752409429,97.97442731900371,98.46676112462684,98.46676112462684,1000000.0 -2020-01-24,QQQ,98.46676112462684,100.8906210640216,97.97442731900371,100.3886776756434,100.3886776756434,1000000.0 -2020-01-27,QQQ,100.3886776756434,101.66470277303605,99.88673428726517,101.15890823187668,101.15890823187668,1000000.0 -2020-01-28,QQQ,101.15890823187668,101.66470277303605,98.94401410505992,99.44122020609038,99.44122020609038,1000000.0 -2020-01-29,QQQ,99.44122020609038,100.97513225422817,98.94401410505992,100.47276841216734,100.47276841216734,1000000.0 -2020-01-30,QQQ,100.47276841216734,102.10313927959584,99.97040457010651,101.59516346228443,101.59516346228443,1000000.0 -2020-01-31,QQQ,101.59516346228443,102.10313927959584,97.6676755056104,98.15846784483458,98.15846784483458,1000000.0 -2020-02-03,QQQ,98.15846784483458,98.64926018405873,96.77081155595751,97.25709704116332,97.25709704116332,1000000.0 -2020-02-04,QQQ,97.25709704116332,97.74338252636913,96.22466546229042,96.70820649476424,96.70820649476424,1000000.0 -2020-02-05,QQQ,96.70820649476424,97.19174752723805,93.88917180000279,94.36097668341988,94.36097668341988,1000000.0 -2020-02-06,QQQ,94.36097668341988,94.83278156683697,92.64192156308226,93.10745885736911,93.10745885736911,1000000.0 -2020-02-07,QQQ,93.10745885736911,93.57299615165594,91.55254771666127,92.01261077051383,92.01261077051383,1000000.0 -2020-02-10,QQQ,92.01261077051383,93.13084391948671,91.55254771666127,92.66750638754897,92.66750638754897,1000000.0 -2020-02-11,QQQ,92.66750638754897,93.54208570721376,92.20416885561123,93.0767021962326,93.0767021962326,1000000.0 -2020-02-12,QQQ,93.0767021962326,96.83918434348543,92.61131868525143,96.35739735670192,96.35739735670192,1000000.0 -2020-02-13,QQQ,96.35739735670192,96.83918434348543,95.4331739881101,95.91273767649257,95.91273767649257,1000000.0 -2020-02-14,QQQ,95.91273767649257,97.897238324073,95.4331739881101,97.41018738713733,97.41018738713733,1000000.0 -2020-02-17,QQQ,97.41018738713733,98.04664591636823,96.92313645020164,97.55885165807786,97.55885165807786,1000000.0 -2020-02-18,QQQ,97.55885165807786,98.44178707342257,97.07105739978746,97.95202693872893,97.95202693872893,1000000.0 -2020-02-19,QQQ,97.95202693872893,98.44178707342257,96.05203811808279,96.53471167646512,96.53471167646512,1000000.0 -2020-02-20,QQQ,96.53471167646512,97.01738523484744,93.74219529713505,94.21326160516087,94.21326160516087,1000000.0 -2020-02-21,QQQ,94.21326160516087,94.68432791318666,92.85837512149715,93.32500012210768,93.32500012210768,1000000.0 -2020-02-24,QQQ,93.32500012210768,94.77292422750855,92.85837512149715,94.30141714179956,94.30141714179956,1000000.0 -2020-02-25,QQQ,94.30141714179956,96.79160177622327,93.82991005609057,96.31005151863013,96.31005151863013,1000000.0 -2020-02-26,QQQ,96.31005151863013,97.60337919018565,95.82850126103698,97.11779023899071,97.11779023899071,1000000.0 -2020-02-27,QQQ,97.11779023899071,98.28680316181449,96.63220128779575,97.79781409135771,97.79781409135771,1000000.0 -2020-02-28,QQQ,97.79781409135771,98.28680316181449,96.30167527417709,96.78560329063023,96.78560329063023,1000000.0 -2020-03-02,QQQ,96.78560329063023,99.2787567728924,96.30167527417709,98.78483260984319,98.78483260984319,1000000.0 -2020-03-03,QQQ,98.78483260984319,101.07886973284431,98.29090844679398,100.5759897839247,100.5759897839247,1000000.0 -2020-03-04,QQQ,100.5759897839247,101.07886973284431,99.43460529566559,99.9342766790609,99.9342766790609,1000000.0 -2020-03-05,QQQ,99.9342766790609,100.4339480624562,99.42438208443942,99.92400209491399,99.92400209491399,1000000.0 -2020-03-06,QQQ,99.92400209491399,100.42362210538855,98.1943522697168,98.68779122584603,98.68779122584603,1000000.0 -2020-03-09,QQQ,98.68779122584603,101.14169697866963,98.1943522697168,100.63850445638771,100.63850445638771,1000000.0 -2020-03-10,QQQ,100.63850445638771,101.7282536582438,100.13531193410577,101.22214294352618,101.22214294352618,1000000.0 -2020-03-11,QQQ,101.22214294352618,101.7282536582438,100.6419485334861,101.14768696832775,101.14768696832775,1000000.0 -2020-03-12,QQQ,101.14768696832775,102.24367527683886,100.6419485334861,101.73500027546156,101.73500027546156,1000000.0 -2020-03-13,QQQ,101.73500027546156,102.88244515867781,101.22632527408425,102.37059219768938,102.37059219768938,1000000.0 -2020-03-16,QQQ,102.37059219768938,102.88244515867781,101.3659754301906,101.87535219114633,101.87535219114633,1000000.0 -2020-03-17,QQQ,101.87535219114633,102.38472895210205,99.79291006967678,100.29438197957465,100.29438197957465,1000000.0 -2020-03-18,QQQ,100.29438197957465,100.79585388947251,98.19039987304613,98.68381896788556,98.68381896788556,1000000.0 -2020-03-19,QQQ,98.68381896788556,99.17723806272497,96.04431359815423,96.5269483398535,96.5269483398535,1000000.0 -2020-03-20,QQQ,96.5269483398535,97.64183682294848,96.04431359815423,97.15605654024725,97.15605654024725,1000000.0 -2020-03-23,QQQ,97.15605654024725,100.54919411797881,96.670276257546,100.04894937112321,100.04894937112321,1000000.0 -2020-03-24,QQQ,100.04894937112321,100.54919411797881,97.42268774431781,97.91224898926413,97.91224898926413,1000000.0 -2020-03-25,QQQ,97.91224898926413,98.62817167091015,97.42268774431781,98.13748424966185,98.13748424966185,1000000.0 -2020-03-26,QQQ,98.13748424966185,99.20612226789937,97.64679682841354,98.71255947054665,98.71255947054665,1000000.0 -2020-03-27,QQQ,98.71255947054665,99.20612226789937,97.0182894400015,97.50581853266482,97.50581853266482,1000000.0 -2020-03-30,QQQ,97.50581853266482,98.30325410491608,97.0182894400015,97.81418318897123,97.81418318897123,1000000.0 -2020-03-31,QQQ,97.81418318897123,98.30325410491608,95.99507301579837,96.4774603173853,96.4774603173853,1000000.0 -2020-04-01,QQQ,96.4774603173853,98.53405044914443,95.99507301579837,98.04383129268103,98.04383129268103,1000000.0 -2020-04-02,QQQ,98.04383129268103,98.53405044914443,96.85130432264427,97.33799429411484,97.33799429411484,1000000.0 -2020-04-03,QQQ,97.33799429411484,98.00164120630389,96.85130432264427,97.51407085204367,97.51407085204367,1000000.0 -2020-04-06,QQQ,97.51407085204367,98.92671921934551,97.02650049778346,98.43454648691097,98.43454648691097,1000000.0 -2020-04-07,QQQ,98.43454648691097,98.92671921934551,97.44534249349611,97.93501758140313,97.93501758140313,1000000.0 -2020-04-08,QQQ,97.93501758140313,98.42469266931013,95.250058702645,95.72870221371358,95.72870221371358,1000000.0 -2020-04-09,QQQ,95.72870221371358,96.20734572478213,94.64180641764499,95.11739338456782,95.11739338456782,1000000.0 -2020-04-10,QQQ,95.11739338456782,96.59416431300255,94.64180641764499,96.11359633134583,96.11359633134583,1000000.0 -2020-04-13,QQQ,96.11359633134583,96.59416431300255,95.57576853058272,96.056048774455,96.056048774455,1000000.0 -2020-04-14,QQQ,96.056048774455,96.87356390933202,95.57576853058272,96.39160587993237,96.39160587993237,1000000.0 -2020-04-15,QQQ,96.39160587993237,96.87356390933202,94.61928906343063,95.09476287781973,95.09476287781973,1000000.0 -2020-04-16,QQQ,95.09476287781973,95.79623061009123,94.61928906343063,95.31963244785197,95.31963244785197,1000000.0 -2020-04-17,QQQ,95.31963244785197,96.75572551627253,94.84303428561272,96.27435374753487,96.27435374753487,1000000.0 -2020-04-20,QQQ,96.27435374753487,96.84999448294509,95.7929819787972,96.36815371437324,96.36815371437324,1000000.0 -2020-04-21,QQQ,96.36815371437324,99.33197493196417,95.88631294580136,98.83778600195441,98.83778600195441,1000000.0 -2020-04-22,QQQ,98.83778600195441,99.33197493196417,98.12734978901294,98.62045204925924,98.62045204925924,1000000.0 -2020-04-23,QQQ,98.62045204925924,99.11355430950553,96.85631286789709,97.34302800793677,97.34302800793677,1000000.0 -2020-04-24,QQQ,97.34302800793677,97.82974314797644,94.88816612629734,95.36499108170587,95.36499108170587,1000000.0 -2020-04-27,QQQ,95.36499108170587,97.33660824700193,94.88816612629734,96.8523465144298,96.8523465144298,1000000.0 -2020-04-28,QQQ,96.8523465144298,97.33660824700193,96.34233508845527,96.82646742558319,96.82646742558319,1000000.0 -2020-04-29,QQQ,96.82646742558319,98.64810326557763,96.34233508845527,98.15731668216681,98.15731668216681,1000000.0 -2020-04-30,QQQ,98.15731668216681,99.28640976580067,97.66653009875597,98.79244752815988,98.79244752815988,1000000.0 +2020-01-16,QQQ,95.41346833292798,96.02456793461609,94.93640099126334,95.54683376578714,95.54683376578714,1000000.0 +2020-01-17,QQQ,95.54683376578714,97.9986520653244,95.0690995969582,97.51109658241234,97.51109658241234,1000000.0 +2020-01-21,QQQ,98.59548371250897,99.0884611310715,97.82940778905888,98.32101285332551,98.32101285332551,1000000.0 +2020-01-22,QQQ,98.32101285332551,99.79940752409428,97.82940778905888,99.30289305880032,99.30289305880032,1000000.0 +2020-01-23,QQQ,99.30289305880032,99.79940752409428,97.97442731900372,98.46676112462684,98.46676112462684,1000000.0 +2020-01-24,QQQ,98.46676112462684,100.8906210640216,97.97442731900372,100.3886776756434,100.3886776756434,1000000.0 +2020-01-27,QQQ,100.3886776756434,101.66470277303604,99.88673428726516,101.15890823187668,101.15890823187668,1000000.0 +2020-01-28,QQQ,101.15890823187668,101.66470277303604,98.94401410505992,99.44122020609038,99.44122020609038,1000000.0 +2020-01-29,QQQ,99.44122020609038,100.97513225422816,98.94401410505992,100.47276841216734,100.47276841216734,1000000.0 +2020-01-30,QQQ,100.47276841216734,102.10313927959584,99.97040457010652,101.59516346228445,101.59516346228445,1000000.0 +2020-01-31,QQQ,101.59516346228445,102.10313927959584,97.6676755056104,98.15846784483458,98.15846784483458,1000000.0 +2020-02-03,QQQ,98.15846784483458,98.64926018405872,96.77081155595752,97.25709704116332,97.25709704116332,1000000.0 +2020-02-04,QQQ,97.25709704116332,97.74338252636912,96.22466546229042,96.70820649476424,96.70820649476424,1000000.0 +2020-02-05,QQQ,96.70820649476424,97.19174752723804,93.8891718000028,94.36097668341988,94.36097668341988,1000000.0 +2020-02-06,QQQ,94.36097668341988,94.83278156683696,92.64192156308226,93.10745885736912,93.10745885736912,1000000.0 +2020-02-07,QQQ,93.10745885736912,93.57299615165594,91.55254771666128,92.01261077051385,92.01261077051385,1000000.0 +2020-02-10,QQQ,92.01261077051385,93.13084391948672,91.55254771666128,92.66750638754895,92.66750638754895,1000000.0 +2020-02-11,QQQ,92.66750638754895,93.54208570721376,92.20416885561124,93.0767021962326,93.0767021962326,1000000.0 +2020-02-12,QQQ,93.0767021962326,96.83918434348544,92.61131868525143,96.35739735670192,96.35739735670192,1000000.0 +2020-02-13,QQQ,96.35739735670192,96.83918434348544,95.4331739881101,95.91273767649255,95.91273767649255,1000000.0 +2020-02-14,QQQ,95.91273767649255,97.897238324073,95.4331739881101,97.41018738713731,97.41018738713731,1000000.0 +2020-02-18,QQQ,97.55885165807786,98.44178707342256,97.07105739978746,97.95202693872892,97.95202693872892,1000000.0 +2020-02-19,QQQ,97.95202693872892,98.44178707342256,96.0520381180828,96.53471167646512,96.53471167646512,1000000.0 +2020-02-20,QQQ,96.53471167646512,97.01738523484744,93.74219529713504,94.21326160516088,94.21326160516088,1000000.0 +2020-02-21,QQQ,94.21326160516088,94.68432791318666,92.85837512149716,93.32500012210768,93.32500012210768,1000000.0 +2020-02-24,QQQ,93.32500012210768,94.77292422750855,92.85837512149716,94.30141714179956,94.30141714179956,1000000.0 +2020-02-25,QQQ,94.30141714179956,96.79160177622327,93.82991005609055,96.31005151863012,96.31005151863012,1000000.0 +2020-02-26,QQQ,96.31005151863012,97.60337919018563,95.82850126103698,97.11779023899072,97.11779023899072,1000000.0 +2020-02-27,QQQ,97.11779023899072,98.28680316181448,96.63220128779577,97.79781409135772,97.79781409135772,1000000.0 +2020-02-28,QQQ,97.79781409135772,98.28680316181448,96.30167527417709,96.78560329063023,96.78560329063023,1000000.0 +2020-03-02,QQQ,96.78560329063023,99.2787567728924,96.30167527417709,98.7848326098432,98.7848326098432,1000000.0 +2020-03-03,QQQ,98.7848326098432,101.07886973284432,98.29090844679398,100.5759897839247,100.5759897839247,1000000.0 +2020-03-04,QQQ,100.5759897839247,101.07886973284432,99.4346052956656,99.9342766790609,99.9342766790609,1000000.0 +2020-03-05,QQQ,99.9342766790609,100.4339480624562,99.42438208443942,99.924002094914,99.924002094914,1000000.0 +2020-03-06,QQQ,99.924002094914,100.42362210538856,98.1943522697168,98.68779122584604,98.68779122584604,1000000.0 +2020-03-09,QQQ,98.68779122584604,101.14169697866964,98.1943522697168,100.63850445638772,100.63850445638772,1000000.0 +2020-03-10,QQQ,100.63850445638772,101.7282536582438,100.13531193410576,101.22214294352618,101.22214294352618,1000000.0 +2020-03-11,QQQ,101.22214294352618,101.7282536582438,100.6419485334861,101.14768696832776,101.14768696832776,1000000.0 +2020-03-12,QQQ,101.14768696832776,102.24367527683886,100.6419485334861,101.73500027546156,101.73500027546156,1000000.0 +2020-03-13,QQQ,101.73500027546156,102.8824451586778,101.22632527408425,102.37059219768938,102.37059219768938,1000000.0 +2020-03-16,QQQ,102.37059219768938,102.8824451586778,101.3659754301906,101.87535219114632,101.87535219114632,1000000.0 +2020-03-17,QQQ,101.87535219114632,102.38472895210204,99.79291006967678,100.29438197957464,100.29438197957464,1000000.0 +2020-03-18,QQQ,100.29438197957464,100.79585388947253,98.19039987304612,98.68381896788556,98.68381896788556,1000000.0 +2020-03-19,QQQ,98.68381896788556,99.17723806272495,96.04431359815425,96.5269483398535,96.5269483398535,1000000.0 +2020-03-20,QQQ,96.5269483398535,97.64183682294848,96.04431359815425,97.15605654024723,97.15605654024723,1000000.0 +2020-03-23,QQQ,97.15605654024723,100.5491941179788,96.670276257546,100.0489493711232,100.0489493711232,1000000.0 +2020-03-24,QQQ,100.0489493711232,100.5491941179788,97.4226877443178,97.91224898926411,97.91224898926411,1000000.0 +2020-03-25,QQQ,97.91224898926411,98.62817167091016,97.4226877443178,98.13748424966184,98.13748424966184,1000000.0 +2020-03-26,QQQ,98.13748424966184,99.20612226789936,97.64679682841354,98.71255947054664,98.71255947054664,1000000.0 +2020-03-27,QQQ,98.71255947054664,99.20612226789936,97.0182894400015,97.50581853266482,97.50581853266482,1000000.0 +2020-03-30,QQQ,97.50581853266482,98.30325410491608,97.0182894400015,97.81418318897124,97.81418318897124,1000000.0 +2020-03-31,QQQ,97.81418318897124,98.30325410491608,95.99507301579835,96.4774603173853,96.4774603173853,1000000.0 +2020-04-01,QQQ,96.4774603173853,98.53405044914444,95.99507301579835,98.04383129268103,98.04383129268103,1000000.0 +2020-04-02,QQQ,98.04383129268103,98.53405044914444,96.85130432264428,97.33799429411484,97.33799429411484,1000000.0 +2020-04-03,QQQ,97.33799429411484,98.00164120630389,96.85130432264428,97.51407085204367,97.51407085204367,1000000.0 +2020-04-06,QQQ,97.51407085204367,98.92671921934551,97.02650049778346,98.43454648691096,98.43454648691096,1000000.0 +2020-04-07,QQQ,98.43454648691096,98.92671921934551,97.44534249349611,97.93501758140312,97.93501758140312,1000000.0 +2020-04-08,QQQ,97.93501758140312,98.42469266931012,95.250058702645,95.72870221371358,95.72870221371358,1000000.0 +2020-04-09,QQQ,95.72870221371358,96.20734572478212,94.641806417645,95.11739338456782,95.11739338456782,1000000.0 +2020-04-13,QQQ,96.11359633134585,96.59416431300257,95.57576853058272,96.056048774455,96.056048774455,1000000.0 +2020-04-14,QQQ,96.056048774455,96.87356390933202,95.57576853058272,96.39160587993236,96.39160587993236,1000000.0 +2020-04-15,QQQ,96.39160587993236,96.87356390933202,94.61928906343064,95.09476287781972,95.09476287781972,1000000.0 +2020-04-16,QQQ,95.09476287781972,95.79623061009124,94.61928906343064,95.31963244785196,95.31963244785196,1000000.0 +2020-04-17,QQQ,95.31963244785196,96.75572551627252,94.84303428561272,96.27435374753487,96.27435374753487,1000000.0 +2020-04-20,QQQ,96.27435374753487,96.84999448294508,95.7929819787972,96.36815371437324,96.36815371437324,1000000.0 +2020-04-21,QQQ,96.36815371437324,99.33197493196415,95.88631294580136,98.8377860019544,98.8377860019544,1000000.0 +2020-04-22,QQQ,98.8377860019544,99.33197493196415,98.12734978901294,98.62045204925924,98.62045204925924,1000000.0 +2020-04-23,QQQ,98.62045204925924,99.11355430950552,96.85631286789707,97.34302800793677,97.34302800793677,1000000.0 +2020-04-24,QQQ,97.34302800793677,97.82974314797644,94.88816612629734,95.36499108170588,95.36499108170588,1000000.0 +2020-04-27,QQQ,95.36499108170588,97.33660824700192,94.88816612629734,96.8523465144298,96.8523465144298,1000000.0 +2020-04-28,QQQ,96.8523465144298,97.33660824700192,96.34233508845529,96.8264674255832,96.8264674255832,1000000.0 +2020-04-29,QQQ,96.8264674255832,98.64810326557765,96.34233508845529,98.1573166821668,98.1573166821668,1000000.0 +2020-04-30,QQQ,98.1573166821668,99.28640976580068,97.66653009875596,98.79244752815988,98.79244752815988,1000000.0 2020-05-01,QQQ,98.79244752815988,101.45697298192528,98.29848529051908,100.95221192231372,100.95221192231372,1000000.0 2020-05-04,QQQ,100.95221192231372,103.19229535149758,100.44745086270215,102.67890084726127,102.67890084726127,1000000.0 2020-05-05,QQQ,102.67890084726127,103.19229535149758,101.7161306058421,102.22726694054482,102.22726694054482,1000000.0 -2020-05-06,QQQ,102.22726694054482,103.15642105011753,101.7161306058421,102.64320502499258,102.64320502499258,1000000.0 +2020-05-06,QQQ,102.22726694054482,103.15642105011752,101.7161306058421,102.64320502499258,102.64320502499258,1000000.0 2020-05-07,QQQ,102.64320502499258,104.67458247496867,102.12998899986762,104.15381340792904,104.15381340792904,1000000.0 2020-05-08,QQQ,104.15381340792904,104.67458247496867,102.03207890565685,102.54480292025814,102.54480292025814,1000000.0 -2020-05-11,QQQ,102.54480292025814,105.07233249841431,102.03207890565685,104.54958457553664,104.54958457553664,1000000.0 -2020-05-12,QQQ,104.54958457553664,106.79433770480189,104.02683665265896,106.26302259184268,106.26302259184268,1000000.0 +2020-05-11,QQQ,102.54480292025814,105.07233249841433,102.03207890565685,104.54958457553664,104.54958457553664,1000000.0 +2020-05-12,QQQ,104.54958457553664,106.79433770480188,104.02683665265896,106.26302259184268,106.26302259184268,1000000.0 2020-05-13,QQQ,106.26302259184268,108.22181284993466,105.73170747888346,107.68339587058176,107.68339587058176,1000000.0 2020-05-14,QQQ,107.68339587058176,108.22181284993466,106.91891486385536,107.45619584307072,107.45619584307072,1000000.0 -2020-05-15,QQQ,107.45619584307072,108.47271416714715,106.91891486385536,107.93304892253448,107.93304892253448,1000000.0 -2020-05-18,QQQ,107.93304892253448,108.47271416714715,105.89779867422283,106.42994841630436,106.42994841630436,1000000.0 -2020-05-19,QQQ,106.42994841630436,107.09624896289334,105.89779867422283,106.56343180387398,106.56343180387398,1000000.0 -2020-05-20,QQQ,106.56343180387398,107.09624896289334,105.27668041190165,105.80570895668508,105.80570895668508,1000000.0 -2020-05-21,QQQ,105.80570895668508,106.50770723363577,105.27668041190165,105.97781814292118,105.97781814292118,1000000.0 -2020-05-22,QQQ,105.97781814292118,106.50770723363577,104.82445838258877,105.35121445486308,105.35121445486308,1000000.0 -2020-05-25,QQQ,105.35121445486308,106.32247098583926,104.82445838258877,105.79350346849678,105.79350346849678,1000000.0 -2020-05-26,QQQ,105.79350346849678,108.01909029051198,105.2645359511543,107.48168188110647,107.48168188110647,1000000.0 -2020-05-27,QQQ,107.48168188110647,108.01909029051198,105.19155787293202,105.72015866626334,105.72015866626334,1000000.0 +2020-05-15,QQQ,107.45619584307072,108.47271416714716,106.91891486385536,107.93304892253448,107.93304892253448,1000000.0 +2020-05-18,QQQ,107.93304892253448,108.47271416714716,105.89779867422284,106.42994841630436,106.42994841630436,1000000.0 +2020-05-19,QQQ,106.42994841630436,107.09624896289334,105.89779867422284,106.56343180387398,106.56343180387398,1000000.0 +2020-05-20,QQQ,106.56343180387398,107.09624896289334,105.27668041190164,105.80570895668508,105.80570895668508,1000000.0 +2020-05-21,QQQ,105.80570895668508,106.50770723363576,105.27668041190164,105.97781814292118,105.97781814292118,1000000.0 +2020-05-22,QQQ,105.97781814292118,106.50770723363576,104.82445838258876,105.35121445486308,105.35121445486308,1000000.0 +2020-05-26,QQQ,105.79350346849678,108.01909029051198,105.2645359511543,107.48168188110648,107.48168188110648,1000000.0 +2020-05-27,QQQ,107.48168188110648,108.01909029051198,105.19155787293202,105.72015866626334,105.72015866626334,1000000.0 2020-05-28,QQQ,105.72015866626334,107.58844069199522,105.19155787293202,107.0531748179057,107.0531748179057,1000000.0 -2020-05-29,QQQ,107.0531748179057,107.58844069199522,103.84709394207111,104.36893863524735,104.36893863524735,1000000.0 -2020-06-01,QQQ,104.36893863524735,104.89078332842357,101.02076938223094,101.52841143942808,101.52841143942808,1000000.0 -2020-06-02,QQQ,101.52841143942808,102.03605349662521,100.91457232347442,101.42168072710997,101.42168072710997,1000000.0 -2020-06-03,QQQ,101.42168072710997,101.98868804874198,100.91457232347442,101.4812816405393,101.4812816405393,1000000.0 +2020-05-29,QQQ,107.0531748179057,107.58844069199522,103.84709394207113,104.36893863524736,104.36893863524736,1000000.0 +2020-06-01,QQQ,104.36893863524736,104.89078332842357,101.02076938223094,101.52841143942808,101.52841143942808,1000000.0 +2020-06-02,QQQ,101.52841143942808,102.0360534966252,100.91457232347442,101.42168072710996,101.42168072710996,1000000.0 +2020-06-03,QQQ,101.42168072710996,101.98868804874198,100.91457232347442,101.4812816405393,101.4812816405393,1000000.0 2020-06-04,QQQ,101.4812816405393,102.62877143297298,100.9738752323366,102.11818053032138,102.11818053032138,1000000.0 -2020-06-05,QQQ,102.11818053032138,102.62877143297298,99.50443588224944,100.00445817311501,100.00445817311501,1000000.0 -2020-06-08,QQQ,100.00445817311501,100.50448046398057,99.47665788737105,99.97654059032266,99.97654059032266,1000000.0 -2020-06-09,QQQ,99.97654059032266,100.69215851640725,99.47665788737105,100.19120250388782,100.19120250388782,1000000.0 +2020-06-05,QQQ,102.11818053032138,102.62877143297298,99.50443588224944,100.004458173115,100.004458173115,1000000.0 +2020-06-08,QQQ,100.004458173115,100.50448046398056,99.47665788737103,99.97654059032266,99.97654059032266,1000000.0 +2020-06-09,QQQ,99.97654059032266,100.69215851640725,99.47665788737103,100.19120250388782,100.19120250388782,1000000.0 2020-06-10,QQQ,100.19120250388782,102.3998852220173,99.69024649136838,101.89043305673364,101.89043305673364,1000000.0 -2020-06-11,QQQ,101.89043305673364,102.3998852220173,101.2948523206035,101.80387167899849,101.80387167899849,1000000.0 -2020-06-12,QQQ,101.80387167899849,103.07043297674021,101.2948523206035,102.55764475297534,102.55764475297534,1000000.0 -2020-06-15,QQQ,102.55764475297534,103.07043297674021,100.80681418093687,101.31338108636871,101.31338108636871,1000000.0 -2020-06-16,QQQ,101.31338108636871,101.81994799180055,100.61161860762633,101.11720463078022,101.11720463078022,1000000.0 -2020-06-17,QQQ,101.11720463078022,101.62279065393412,100.08059235334787,100.58350990286218,100.58350990286218,1000000.0 -2020-06-18,QQQ,100.58350990286218,102.06591450355913,100.08059235334787,101.55812388413845,101.55812388413845,1000000.0 -2020-06-19,QQQ,101.55812388413845,102.06591450355913,99.80957207135364,100.31112770990316,100.31112770990316,1000000.0 -2020-06-22,QQQ,100.31112770990316,100.81268334845267,98.78224669777863,99.27863989726495,99.27863989726495,1000000.0 -2020-06-23,QQQ,99.27863989726495,100.57521231443832,98.78224669777863,100.07483812381923,100.07483812381923,1000000.0 +2020-06-11,QQQ,101.89043305673364,102.3998852220173,101.2948523206035,101.80387167899848,101.80387167899848,1000000.0 +2020-06-12,QQQ,101.80387167899848,103.0704329767402,101.2948523206035,102.55764475297534,102.55764475297534,1000000.0 +2020-06-15,QQQ,102.55764475297534,103.0704329767402,100.80681418093688,101.31338108636872,101.31338108636872,1000000.0 +2020-06-16,QQQ,101.31338108636872,101.81994799180056,100.61161860762633,101.11720463078022,101.11720463078022,1000000.0 +2020-06-17,QQQ,101.11720463078022,101.62279065393412,100.08059235334788,100.58350990286218,100.58350990286218,1000000.0 +2020-06-18,QQQ,100.58350990286218,102.06591450355911,100.08059235334788,101.55812388413844,101.55812388413844,1000000.0 +2020-06-19,QQQ,101.55812388413844,102.06591450355911,99.80957207135364,100.31112770990316,100.31112770990316,1000000.0 +2020-06-22,QQQ,100.31112770990316,100.81268334845268,98.78224669777865,99.27863989726497,99.27863989726497,1000000.0 +2020-06-23,QQQ,99.27863989726497,100.57521231443832,98.78224669777865,100.07483812381923,100.07483812381923,1000000.0 2020-06-24,QQQ,100.07483812381923,102.99377046185796,99.57446393320014,102.48136364363978,102.48136364363978,1000000.0 -2020-06-25,QQQ,102.48136364363978,103.55922266719372,101.96895682542159,103.04400265392411,103.04400265392411,1000000.0 -2020-06-26,QQQ,103.04400265392411,104.02070153553518,102.52878264065448,103.5031856074977,103.5031856074977,1000000.0 -2020-06-29,QQQ,103.5031856074977,104.02070153553518,102.57456506032807,103.0900151360081,103.0900151360081,1000000.0 -2020-06-30,QQQ,103.0900151360081,105.13440817549312,102.57456506032807,104.61135141840113,104.61135141840113,1000000.0 -2020-07-01,QQQ,104.61135141840113,105.9278847310443,104.08829466130912,105.40088032939732,105.40088032939732,1000000.0 -2020-07-02,QQQ,105.40088032939732,108.39587504666241,104.87387592775033,107.85659208623126,107.85659208623126,1000000.0 -2020-07-03,QQQ,107.85659208623126,108.91734423986965,107.31730912580011,108.37546690534295,108.37546690534295,1000000.0 -2020-07-06,QQQ,108.37546690534295,108.91734423986965,106.56513575984997,107.10063895462308,107.10063895462308,1000000.0 -2020-07-07,QQQ,107.10063895462308,107.63614214939618,105.83138944684143,106.36320547421249,106.36320547421249,1000000.0 -2020-07-08,QQQ,106.36320547421249,106.89502150158354,104.53384586845145,105.05914157633312,105.05914157633312,1000000.0 -2020-07-09,QQQ,105.05914157633312,105.58443728421477,102.89923735192157,103.41631894665484,103.41631894665484,1000000.0 -2020-07-10,QQQ,103.41631894665484,105.7077954795754,102.89923735192157,105.18188604932877,105.18188604932877,1000000.0 -2020-07-13,QQQ,105.18188604932877,106.87650627420719,104.65597661908213,106.34478236239522,106.34478236239522,1000000.0 -2020-07-14,QQQ,106.34478236239522,106.87650627420719,104.17493576808373,104.69842790762183,104.69842790762183,1000000.0 -2020-07-15,QQQ,104.69842790762183,107.7408506851096,104.17493576808373,107.20482655234788,107.20482655234788,1000000.0 -2020-07-16,QQQ,107.20482655234788,108.1379906076583,106.66880241958614,107.59999065438637,107.59999065438637,1000000.0 -2020-07-17,QQQ,107.59999065438637,108.1379906076583,105.79131457570718,106.32292922181625,106.32292922181625,1000000.0 -2020-07-20,QQQ,106.32292922181625,107.44610701540158,105.79131457570718,106.91154926905631,106.91154926905631,1000000.0 -2020-07-21,QQQ,106.91154926905631,107.44610701540158,106.33449971001606,106.86884392966438,106.86884392966438,1000000.0 -2020-07-22,QQQ,106.86884392966438,107.40318814931268,105.32441686418765,105.85368529064085,105.85368529064085,1000000.0 -2020-07-23,QQQ,105.85368529064085,106.38295371709405,102.74879496887725,103.26512057173593,103.26512057173593,1000000.0 -2020-07-24,QQQ,103.26512057173593,103.88292746361644,102.74879496887725,103.36609697872284,103.36609697872284,1000000.0 +2020-06-25,QQQ,102.48136364363978,103.55922266719372,101.9689568254216,103.04400265392412,103.04400265392412,1000000.0 +2020-06-26,QQQ,103.04400265392412,104.02070153553518,102.52878264065448,103.5031856074977,103.5031856074977,1000000.0 +2020-06-29,QQQ,103.5031856074977,104.02070153553518,102.57456506032808,103.0900151360081,103.0900151360081,1000000.0 +2020-06-30,QQQ,103.0900151360081,105.13440817549312,102.57456506032808,104.61135141840111,104.61135141840111,1000000.0 +2020-07-01,QQQ,104.61135141840111,105.9278847310443,104.08829466130912,105.40088032939732,105.40088032939732,1000000.0 +2020-07-02,QQQ,105.40088032939732,108.3958750466624,104.87387592775032,107.85659208623126,107.85659208623126,1000000.0 +2020-07-06,QQQ,108.37546690534296,108.91734423986964,106.56513575984997,107.10063895462308,107.10063895462308,1000000.0 +2020-07-07,QQQ,107.10063895462308,107.63614214939618,105.83138944684144,106.36320547421248,106.36320547421248,1000000.0 +2020-07-08,QQQ,106.36320547421248,106.89502150158354,104.53384586845144,105.05914157633312,105.05914157633312,1000000.0 +2020-07-09,QQQ,105.05914157633312,105.58443728421476,102.89923735192156,103.41631894665484,103.41631894665484,1000000.0 +2020-07-10,QQQ,103.41631894665484,105.7077954795754,102.89923735192156,105.18188604932875,105.18188604932875,1000000.0 +2020-07-13,QQQ,105.18188604932875,106.8765062742072,104.65597661908213,106.34478236239522,106.34478236239522,1000000.0 +2020-07-14,QQQ,106.34478236239522,106.8765062742072,104.17493576808371,104.69842790762183,104.69842790762183,1000000.0 +2020-07-15,QQQ,104.69842790762183,107.7408506851096,104.17493576808371,107.20482655234788,107.20482655234788,1000000.0 +2020-07-16,QQQ,107.20482655234788,108.1379906076583,106.66880241958614,107.59999065438636,107.59999065438636,1000000.0 +2020-07-17,QQQ,107.59999065438636,108.1379906076583,105.79131457570718,106.32292922181624,106.32292922181624,1000000.0 +2020-07-20,QQQ,106.32292922181624,107.44610701540158,105.79131457570718,106.91154926905632,106.91154926905632,1000000.0 +2020-07-21,QQQ,106.91154926905632,107.44610701540158,106.33449971001606,106.86884392966438,106.86884392966438,1000000.0 +2020-07-22,QQQ,106.86884392966438,107.40318814931268,105.32441686418764,105.85368529064084,105.85368529064084,1000000.0 +2020-07-23,QQQ,105.85368529064084,106.38295371709404,102.74879496887723,103.26512057173592,103.26512057173592,1000000.0 +2020-07-24,QQQ,103.26512057173592,103.88292746361644,102.74879496887723,103.36609697872284,103.36609697872284,1000000.0 2020-07-27,QQQ,103.36609697872284,104.33016287445808,102.84926649382922,103.81110733776924,103.81110733776924,1000000.0 -2020-07-28,QQQ,103.81110733776924,104.33016287445808,99.93624416827079,100.4384363500209,100.4384363500209,1000000.0 -2020-07-29,QQQ,100.4384363500209,101.72227472915434,99.93624416827079,101.21619376035258,101.21619376035258,1000000.0 -2020-07-30,QQQ,101.21619376035258,102.59359830032189,100.71011279155081,102.08318238838,102.08318238838,1000000.0 +2020-07-28,QQQ,103.81110733776924,104.33016287445808,99.9362441682708,100.4384363500209,100.4384363500209,1000000.0 +2020-07-29,QQQ,100.4384363500209,101.72227472915434,99.9362441682708,101.21619376035258,101.21619376035258,1000000.0 +2020-07-30,QQQ,101.21619376035258,102.59359830032189,100.7101127915508,102.08318238838,102.08318238838,1000000.0 2020-07-31,QQQ,102.08318238838,102.59359830032189,101.00480700580115,101.51236885005142,101.51236885005142,1000000.0 -2020-08-03,QQQ,101.51236885005142,102.21984048927325,101.00480700580115,101.71128406892862,101.71128406892862,1000000.0 -2020-08-04,QQQ,101.71128406892862,102.21984048927325,100.88458628790647,101.39154400794621,101.39154400794621,1000000.0 -2020-08-05,QQQ,101.39154400794621,101.89850172798593,99.84258743223194,100.34430897711752,100.34430897711752,1000000.0 -2020-08-06,QQQ,100.34430897711752,100.8460305220031,99.42604538308944,99.92567375184869,99.92567375184869,1000000.0 -2020-08-07,QQQ,99.92567375184869,102.25656911798222,99.42604538308944,101.74782996814153,101.74782996814153,1000000.0 -2020-08-10,QQQ,101.74782996814153,102.25656911798222,99.39749707770332,99.89698198764152,99.89698198764152,1000000.0 +2020-08-03,QQQ,101.51236885005142,102.21984048927324,101.00480700580115,101.71128406892862,101.71128406892862,1000000.0 +2020-08-04,QQQ,101.71128406892862,102.21984048927324,100.88458628790649,101.3915440079462,101.3915440079462,1000000.0 +2020-08-05,QQQ,101.3915440079462,101.89850172798592,99.84258743223194,100.34430897711752,100.34430897711752,1000000.0 +2020-08-06,QQQ,100.34430897711752,100.8460305220031,99.42604538308944,99.92567375184868,99.92567375184868,1000000.0 +2020-08-07,QQQ,99.92567375184868,102.25656911798222,99.42604538308944,101.74782996814152,101.74782996814152,1000000.0 +2020-08-10,QQQ,101.74782996814152,102.25656911798222,99.39749707770332,99.89698198764152,99.89698198764152,1000000.0 2020-08-11,QQQ,99.89698198764152,100.75688299134,99.39749707770332,100.25560496650748,100.25560496650748,1000000.0 -2020-08-12,QQQ,100.25560496650748,101.0488483395634,99.75432694167493,100.54611775080937,100.54611775080937,1000000.0 -2020-08-13,QQQ,100.54611775080937,101.0488483395634,99.17686482098142,99.67524102611198,99.67524102611198,1000000.0 -2020-08-14,QQQ,99.67524102611198,100.17361723124253,98.66007538440012,99.15585465768856,99.15585465768856,1000000.0 -2020-08-17,QQQ,99.15585465768856,99.651633930977,96.92863495736442,97.41571352498937,97.41571352498937,1000000.0 -2020-08-18,QQQ,97.41571352498937,97.9027920926143,95.90896661697572,96.39092122309117,96.39092122309117,1000000.0 -2020-08-19,QQQ,96.39092122309117,96.9401651744033,95.90896661697572,96.45787579542619,96.45787579542619,1000000.0 -2020-08-20,QQQ,96.45787579542619,97.40146397138679,95.97558641644906,96.91687957351921,96.91687957351921,1000000.0 -2020-08-21,QQQ,96.91687957351921,97.40146397138679,95.36029141684656,95.83948886115233,95.83948886115233,1000000.0 -2020-08-24,QQQ,95.83948886115233,100.27283235745443,95.36029141684656,99.7739625447308,99.7739625447308,1000000.0 -2020-08-25,QQQ,99.7739625447308,100.27283235745443,98.59102488537607,99.08645717123224,99.08645717123224,1000000.0 -2020-08-26,QQQ,99.08645717123224,99.79674877180787,98.59102488537607,99.3002475341372,99.3002475341372,1000000.0 -2020-08-27,QQQ,99.3002475341372,99.79674877180787,98.55728778196595,99.05255053463915,99.05255053463915,1000000.0 -2020-08-28,QQQ,99.05255053463915,99.54781328731234,98.09543621838519,98.58837810892983,98.58837810892983,1000000.0 -2020-08-31,QQQ,98.58837810892983,100.82182228139118,98.09543621838519,100.32022117551362,100.32022117551362,1000000.0 +2020-08-12,QQQ,100.25560496650748,101.0488483395634,99.75432694167492,100.54611775080936,100.54611775080936,1000000.0 +2020-08-13,QQQ,100.54611775080936,101.0488483395634,99.17686482098142,99.67524102611198,99.67524102611198,1000000.0 +2020-08-14,QQQ,99.67524102611198,100.17361723124252,98.66007538440012,99.15585465768856,99.15585465768856,1000000.0 +2020-08-17,QQQ,99.15585465768856,99.651633930977,96.92863495736442,97.41571352498936,97.41571352498936,1000000.0 +2020-08-18,QQQ,97.41571352498936,97.9027920926143,95.90896661697572,96.39092122309115,96.39092122309115,1000000.0 +2020-08-19,QQQ,96.39092122309115,96.9401651744033,95.90896661697572,96.4578757954262,96.4578757954262,1000000.0 +2020-08-20,QQQ,96.4578757954262,97.4014639713868,95.97558641644906,96.9168795735192,96.9168795735192,1000000.0 +2020-08-21,QQQ,96.9168795735192,97.4014639713868,95.36029141684656,95.83948886115232,95.83948886115232,1000000.0 +2020-08-24,QQQ,95.83948886115232,100.27283235745443,95.36029141684656,99.7739625447308,99.7739625447308,1000000.0 +2020-08-25,QQQ,99.7739625447308,100.27283235745443,98.59102488537609,99.08645717123224,99.08645717123224,1000000.0 +2020-08-26,QQQ,99.08645717123224,99.79674877180788,98.59102488537609,99.3002475341372,99.3002475341372,1000000.0 +2020-08-27,QQQ,99.3002475341372,99.79674877180788,98.55728778196595,99.05255053463917,99.05255053463917,1000000.0 +2020-08-28,QQQ,99.05255053463917,99.54781328731234,98.0954362183852,98.58837810892985,98.58837810892985,1000000.0 +2020-08-31,QQQ,98.58837810892985,100.82182228139118,98.0954362183852,100.32022117551362,100.32022117551362,1000000.0 2020-09-01,QQQ,100.32022117551362,100.82182228139118,99.24883602679992,99.74757389628132,99.74757389628132,1000000.0 2020-09-02,QQQ,99.74757389628132,102.05147366170964,99.24883602679992,101.54375488727328,101.54375488727328,1000000.0 -2020-09-03,QQQ,101.54375488727328,103.62938632696319,101.03603611283691,103.1138172407594,103.1138172407594,1000000.0 -2020-09-04,QQQ,103.1138172407594,103.62938632696319,101.2636195444872,101.7724819542585,101.7724819542585,1000000.0 -2020-09-07,QQQ,101.7724819542585,103.71935165306391,101.2636195444872,103.20333497817306,103.20333497817306,1000000.0 -2020-09-08,QQQ,103.20333497817306,103.71935165306391,101.87368986669615,102.38561795647854,102.38561795647854,1000000.0 -2020-09-09,QQQ,102.38561795647854,106.67723262132917,101.87368986669615,106.14650012072555,106.14650012072555,1000000.0 -2020-09-10,QQQ,106.14650012072555,106.67723262132917,105.1399037490309,105.66824497390041,105.66824497390041,1000000.0 -2020-09-11,QQQ,105.66824497390041,106.19658619876991,103.91360563838798,104.43578456119394,104.43578456119394,1000000.0 -2020-09-14,QQQ,104.43578456119394,104.9579634839999,101.41447651356152,101.92409699855429,101.92409699855429,1000000.0 -2020-09-15,QQQ,101.92409699855429,102.43371748354706,99.77386350270048,100.27523970120652,100.27523970120652,1000000.0 -2020-09-16,QQQ,100.27523970120652,100.77661589971254,99.07308641795352,99.57094112357137,99.57094112357137,1000000.0 -2020-09-17,QQQ,99.57094112357137,100.06879582918921,97.84488934277965,98.33657220379864,98.33657220379864,1000000.0 -2020-09-18,QQQ,98.33657220379864,98.82825506481763,96.34259467532296,96.82672831690749,96.82672831690749,1000000.0 -2020-09-21,QQQ,96.82672831690749,97.31086195849201,95.52338788567596,96.00340491022709,96.00340491022709,1000000.0 -2020-09-22,QQQ,96.00340491022709,96.48342193477822,95.31488564064182,95.7938549152179,95.7938549152179,1000000.0 +2020-09-03,QQQ,101.54375488727328,103.6293863269632,101.03603611283692,103.1138172407594,103.1138172407594,1000000.0 +2020-09-04,QQQ,103.1138172407594,103.6293863269632,101.2636195444872,101.7724819542585,101.7724819542585,1000000.0 +2020-09-08,QQQ,103.20333497817306,103.71935165306392,101.87368986669615,102.38561795647854,102.38561795647854,1000000.0 +2020-09-09,QQQ,102.38561795647854,106.67723262132915,101.87368986669615,106.14650012072556,106.14650012072556,1000000.0 +2020-09-10,QQQ,106.14650012072556,106.67723262132915,105.1399037490309,105.6682449739004,105.6682449739004,1000000.0 +2020-09-11,QQQ,105.6682449739004,106.19658619876992,103.91360563838798,104.43578456119394,104.43578456119394,1000000.0 +2020-09-14,QQQ,104.43578456119394,104.9579634839999,101.41447651356152,101.92409699855428,101.92409699855428,1000000.0 +2020-09-15,QQQ,101.92409699855428,102.43371748354706,99.77386350270048,100.27523970120652,100.27523970120652,1000000.0 +2020-09-16,QQQ,100.27523970120652,100.77661589971254,99.07308641795352,99.57094112357136,99.57094112357136,1000000.0 +2020-09-17,QQQ,99.57094112357136,100.0687958291892,97.84488934277964,98.33657220379864,98.33657220379864,1000000.0 +2020-09-18,QQQ,98.33657220379864,98.82825506481764,96.34259467532296,96.82672831690748,96.82672831690748,1000000.0 +2020-09-21,QQQ,96.82672831690748,97.310861958492,95.52338788567596,96.00340491022708,96.00340491022708,1000000.0 +2020-09-22,QQQ,96.00340491022708,96.48342193477822,95.31488564064182,95.7938549152179,95.7938549152179,1000000.0 2020-09-23,QQQ,95.7938549152179,98.15301076241327,95.31488564064182,97.66468732578436,97.66468732578436,1000000.0 -2020-09-24,QQQ,97.66468732578436,98.15301076241327,95.09578801207293,95.57365629354064,95.57365629354064,1000000.0 -2020-09-25,QQQ,95.57365629354064,96.05152457500833,94.46348384617765,94.93817471977654,94.93817471977654,1000000.0 -2020-09-28,QQQ,94.93817471977654,95.41286559337541,93.34597284100126,93.81504808140829,93.81504808140829,1000000.0 -2020-09-29,QQQ,93.81504808140829,94.28412332181533,91.57110802041707,92.03126434212771,92.03126434212771,1000000.0 -2020-09-30,QQQ,92.03126434212771,92.49142066383834,90.12125404685129,90.57412467020231,90.57412467020231,1000000.0 -2020-10-01,QQQ,90.57412467020231,91.02699529355331,89.11088363882078,89.55867702394049,89.55867702394049,1000000.0 +2020-09-24,QQQ,97.66468732578436,98.15301076241327,95.09578801207292,95.57365629354064,95.57365629354064,1000000.0 +2020-09-25,QQQ,95.57365629354064,96.05152457500832,94.46348384617764,94.93817471977654,94.93817471977654,1000000.0 +2020-09-28,QQQ,94.93817471977654,95.4128655933754,93.34597284100126,93.81504808140828,93.81504808140828,1000000.0 +2020-09-29,QQQ,93.81504808140828,94.28412332181531,91.57110802041709,92.03126434212771,92.03126434212771,1000000.0 +2020-09-30,QQQ,92.03126434212771,92.49142066383834,90.12125404685128,90.57412467020232,90.57412467020232,1000000.0 +2020-10-01,QQQ,90.57412467020232,91.02699529355333,89.11088363882078,89.55867702394049,89.55867702394049,1000000.0 2020-10-02,QQQ,89.55867702394049,90.89483017933486,89.11088363882078,90.44261709386554,90.44261709386554,1000000.0 2020-10-05,QQQ,90.44261709386554,90.89483017933486,89.8087433289757,90.26004354670924,90.26004354670924,1000000.0 -2020-10-06,QQQ,90.26004354670924,90.71134376444277,89.22743223481896,89.67581129127534,89.67581129127534,1000000.0 -2020-10-07,QQQ,89.67581129127534,90.88867481578342,89.22743223481896,90.43649235401337,90.43649235401337,1000000.0 -2020-10-08,QQQ,90.43649235401337,91.26664687583244,89.9843098922433,90.81258395605218,90.81258395605218,1000000.0 -2020-10-09,QQQ,90.81258395605218,91.26664687583244,90.14162539827132,90.59459839022243,90.59459839022243,1000000.0 -2020-10-12,QQQ,90.59459839022243,91.54932044586948,90.14162539827132,91.09385118991989,91.09385118991989,1000000.0 -2020-10-13,QQQ,91.09385118991989,91.54932044586948,89.71241831033127,90.163234482745,90.163234482745,1000000.0 -2020-10-14,QQQ,90.163234482745,91.40180649350279,89.71241831033127,90.94707113781374,90.94707113781374,1000000.0 -2020-10-15,QQQ,90.94707113781374,92.0042956025615,90.49233578212467,91.54656278861842,91.54656278861842,1000000.0 -2020-10-16,QQQ,91.54656278861842,92.0042956025615,90.64721628814382,91.10272993783299,91.10272993783299,1000000.0 -2020-10-19,QQQ,91.10272993783299,91.55824358752214,89.26612157157436,89.7146950468084,89.7146950468084,1000000.0 -2020-10-20,QQQ,89.7146950468084,92.00868347753048,89.26612157157436,91.55092883336367,91.55092883336367,1000000.0 -2020-10-21,QQQ,91.55092883336367,92.16749977419472,91.09317418919686,91.70895499919874,91.70895499919874,1000000.0 -2020-10-22,QQQ,91.70895499919874,95.94489500245595,91.25041022420274,95.46755721637409,95.46755721637409,1000000.0 -2020-10-23,QQQ,95.46755721637409,95.94489500245595,94.23386471535133,94.70740172397119,94.70740172397119,1000000.0 -2020-10-26,QQQ,94.70740172397119,96.29021051885752,94.23386471535133,95.81115474513187,95.81115474513187,1000000.0 -2020-10-27,QQQ,95.81115474513187,96.29021051885752,94.8428892422853,95.31948667566361,95.31948667566361,1000000.0 -2020-10-28,QQQ,95.31948667566361,96.1605791116757,94.8428892422853,95.6821682703241,95.6821682703241,1000000.0 -2020-10-29,QQQ,95.6821682703241,97.25667122910679,95.20375742897248,96.77280719314109,96.77280719314109,1000000.0 -2020-10-30,QQQ,96.77280719314109,97.25667122910679,95.34124364620561,95.82034537307096,95.82034537307096,1000000.0 +2020-10-06,QQQ,90.26004354670924,90.71134376444276,89.22743223481896,89.67581129127534,89.67581129127534,1000000.0 +2020-10-07,QQQ,89.67581129127534,90.88867481578342,89.22743223481896,90.43649235401335,90.43649235401335,1000000.0 +2020-10-08,QQQ,90.43649235401335,91.26664687583244,89.9843098922433,90.81258395605218,90.81258395605218,1000000.0 +2020-10-09,QQQ,90.81258395605218,91.26664687583244,90.14162539827132,90.59459839022244,90.59459839022244,1000000.0 +2020-10-12,QQQ,90.59459839022244,91.54932044586948,90.14162539827132,91.09385118991987,91.09385118991987,1000000.0 +2020-10-13,QQQ,91.09385118991987,91.54932044586948,89.71241831033127,90.163234482745,90.163234482745,1000000.0 +2020-10-14,QQQ,90.163234482745,91.4018064935028,89.71241831033127,90.94707113781374,90.94707113781374,1000000.0 +2020-10-15,QQQ,90.94707113781374,92.0042956025615,90.49233578212468,91.54656278861842,91.54656278861842,1000000.0 +2020-10-16,QQQ,91.54656278861842,92.0042956025615,90.64721628814382,91.102729937833,91.102729937833,1000000.0 +2020-10-19,QQQ,91.102729937833,91.55824358752214,89.26612157157436,89.7146950468084,89.7146950468084,1000000.0 +2020-10-20,QQQ,89.7146950468084,92.00868347753048,89.26612157157436,91.55092883336368,91.55092883336368,1000000.0 +2020-10-21,QQQ,91.55092883336368,92.16749977419472,91.09317418919686,91.70895499919874,91.70895499919874,1000000.0 +2020-10-22,QQQ,91.70895499919874,95.94489500245597,91.25041022420274,95.46755721637408,95.46755721637408,1000000.0 +2020-10-23,QQQ,95.46755721637408,95.94489500245597,94.23386471535132,94.7074017239712,94.7074017239712,1000000.0 +2020-10-26,QQQ,94.7074017239712,96.29021051885752,94.23386471535132,95.81115474513189,95.81115474513189,1000000.0 +2020-10-27,QQQ,95.81115474513189,96.29021051885752,94.8428892422853,95.3194866756636,95.3194866756636,1000000.0 +2020-10-28,QQQ,95.3194866756636,96.1605791116757,94.8428892422853,95.6821682703241,95.6821682703241,1000000.0 +2020-10-29,QQQ,95.6821682703241,97.2566712291068,95.20375742897248,96.77280719314108,96.77280719314108,1000000.0 +2020-10-30,QQQ,96.77280719314108,97.2566712291068,95.3412436462056,95.82034537307096,95.82034537307096,1000000.0 2020-11-02,QQQ,95.82034537307096,96.2994470999363,95.26488911666138,95.7436071524235,95.7436071524235,1000000.0 2020-11-03,QQQ,95.7436071524235,96.47208018983558,95.26488911666138,95.9921195918762,95.9921195918762,1000000.0 -2020-11-04,QQQ,95.9921195918762,96.66282415201033,95.51215899391683,96.18191457911476,96.18191457911476,1000000.0 -2020-11-05,QQQ,96.18191457911476,97.46396761191185,95.70100500621919,96.97907225065858,96.97907225065858,1000000.0 -2020-11-06,QQQ,96.97907225065858,98.8214877000794,96.49417688940528,98.32983850754171,98.32983850754171,1000000.0 -2020-11-09,QQQ,98.32983850754171,99.94821844796748,97.838189315004,99.45096362981839,99.45096362981839,1000000.0 -2020-11-10,QQQ,99.45096362981839,100.34070042860326,98.9537088116693,99.84149296378435,99.84149296378435,1000000.0 -2020-11-11,QQQ,99.84149296378435,100.34070042860326,98.9441419261517,99.44134866949919,99.44134866949919,1000000.0 -2020-11-12,QQQ,99.44134866949919,99.93855541284668,97.55044037024543,98.04064358818636,98.04064358818636,1000000.0 -2020-11-13,QQQ,98.04064358818636,98.53084680612727,96.67048189319074,97.15626320923693,97.15626320923693,1000000.0 -2020-11-16,QQQ,97.15626320923693,97.6420445252831,96.05653910933579,96.5392352857646,96.5392352857646,1000000.0 -2020-11-17,QQQ,96.5392352857646,97.93581678246443,96.05653910933579,97.44857391289995,97.44857391289995,1000000.0 -2020-11-18,QQQ,97.44857391289995,97.93581678246443,96.55234704183721,97.03753471541428,97.03753471541428,1000000.0 -2020-11-19,QQQ,97.03753471541428,97.52272238899134,95.05302689228083,95.53068029374957,95.53068029374957,1000000.0 -2020-11-20,QQQ,95.53068029374957,96.84168289448,95.05302689228083,96.35988347709454,96.35988347709454,1000000.0 -2020-11-23,QQQ,96.35988347709454,96.84168289448,94.92956234695004,95.40659532356787,95.40659532356787,1000000.0 -2020-11-24,QQQ,95.40659532356787,95.8836283001857,94.03616062166142,94.50870414237329,94.50870414237329,1000000.0 -2020-11-25,QQQ,94.50870414237329,94.98124766308514,93.80684871942724,94.27823991902235,94.27823991902235,1000000.0 -2020-11-26,QQQ,94.27823991902235,95.38194099105257,93.80684871942724,94.9074039711966,94.9074039711966,1000000.0 -2020-11-27,QQQ,94.9074039711966,95.38194099105257,91.5621332631269,92.02224448555467,92.02224448555467,1000000.0 -2020-11-30,QQQ,92.02224448555467,92.48235570798244,90.28207105659902,90.73574980562717,90.73574980562717,1000000.0 +2020-11-04,QQQ,95.9921195918762,96.66282415201032,95.51215899391684,96.18191457911476,96.18191457911476,1000000.0 +2020-11-05,QQQ,96.18191457911476,97.46396761191184,95.7010050062192,96.97907225065858,96.97907225065858,1000000.0 +2020-11-06,QQQ,96.97907225065858,98.8214877000794,96.49417688940528,98.32983850754172,98.32983850754172,1000000.0 +2020-11-09,QQQ,98.32983850754172,99.94821844796748,97.838189315004,99.4509636298184,99.4509636298184,1000000.0 +2020-11-10,QQQ,99.4509636298184,100.34070042860326,98.9537088116693,99.84149296378436,99.84149296378436,1000000.0 +2020-11-11,QQQ,99.84149296378436,100.34070042860326,98.9441419261517,99.4413486694992,99.4413486694992,1000000.0 +2020-11-12,QQQ,99.4413486694992,99.93855541284668,97.55044037024544,98.04064358818636,98.04064358818636,1000000.0 +2020-11-13,QQQ,98.04064358818636,98.53084680612729,96.67048189319074,97.15626320923693,97.15626320923693,1000000.0 +2020-11-16,QQQ,97.15626320923693,97.6420445252831,96.0565391093358,96.5392352857646,96.5392352857646,1000000.0 +2020-11-17,QQQ,96.5392352857646,97.93581678246444,96.0565391093358,97.44857391289996,97.44857391289996,1000000.0 +2020-11-18,QQQ,97.44857391289996,97.93581678246444,96.5523470418372,97.03753471541428,97.03753471541428,1000000.0 +2020-11-19,QQQ,97.03753471541428,97.52272238899134,95.05302689228084,95.53068029374955,95.53068029374955,1000000.0 +2020-11-20,QQQ,95.53068029374955,96.84168289448,95.05302689228084,96.35988347709454,96.35988347709454,1000000.0 +2020-11-23,QQQ,96.35988347709454,96.84168289448,94.92956234695004,95.40659532356788,95.40659532356788,1000000.0 +2020-11-24,QQQ,95.40659532356788,95.8836283001857,94.03616062166142,94.50870414237328,94.50870414237328,1000000.0 +2020-11-25,QQQ,94.50870414237328,94.98124766308514,93.80684871942724,94.27823991902235,94.27823991902235,1000000.0 +2020-11-27,QQQ,94.9074039711966,95.38194099105256,91.5621332631269,92.02224448555468,92.02224448555468,1000000.0 +2020-11-30,QQQ,92.02224448555468,92.48235570798244,90.28207105659902,90.73574980562717,90.73574980562717,1000000.0 2020-12-01,QQQ,90.73574980562717,91.44353100957773,90.28207105659902,90.98858806923158,90.98858806923158,1000000.0 -2020-12-02,QQQ,90.98858806923158,91.44353100957773,90.15112763527283,90.60414837715862,90.60414837715862,1000000.0 +2020-12-02,QQQ,90.98858806923158,91.44353100957773,90.15112763527284,90.60414837715862,90.60414837715862,1000000.0 2020-12-03,QQQ,90.60414837715862,91.0571691190444,89.1881674879374,89.63634923410794,89.63634923410794,1000000.0 -2020-12-04,QQQ,89.63634923410794,90.08453098027847,88.60687865734845,89.05213935411903,89.05213935411903,1000000.0 -2020-12-07,QQQ,89.05213935411903,91.16926897848337,88.60687865734845,90.71569052585411,90.71569052585411,1000000.0 -2020-12-08,QQQ,90.71569052585411,91.16926897848337,89.77381375506913,90.22493844730566,90.22493844730566,1000000.0 -2020-12-09,QQQ,90.22493844730566,91.56581967147429,89.77381375506913,91.11026832982517,91.11026832982517,1000000.0 -2020-12-10,QQQ,91.11026832982517,92.55956214907053,90.65471698817605,92.09906681499557,92.09906681499557,1000000.0 -2020-12-11,QQQ,92.09906681499557,92.55956214907053,91.1493523584792,91.60738930500423,91.60738930500423,1000000.0 -2020-12-14,QQQ,91.60738930500423,92.6439996568773,91.1493523584792,92.18308423569881,92.18308423569881,1000000.0 -2020-12-15,QQQ,92.18308423569881,92.6439996568773,91.23248371408548,91.69093840611606,91.69093840611606,1000000.0 -2020-12-16,QQQ,91.69093840611606,92.14939309814663,90.55664686672924,91.01170539369772,91.01170539369772,1000000.0 -2020-12-17,QQQ,91.01170539369772,91.46676392066621,89.28537140682654,89.73404161490105,89.73404161490105,1000000.0 +2020-12-04,QQQ,89.63634923410794,90.08453098027849,88.60687865734845,89.05213935411903,89.05213935411903,1000000.0 +2020-12-07,QQQ,89.05213935411903,91.16926897848336,88.60687865734845,90.71569052585411,90.71569052585411,1000000.0 +2020-12-08,QQQ,90.71569052585411,91.16926897848336,89.77381375506913,90.22493844730566,90.22493844730566,1000000.0 +2020-12-09,QQQ,90.22493844730566,91.56581967147427,89.77381375506913,91.11026832982516,91.11026832982516,1000000.0 +2020-12-10,QQQ,91.11026832982516,92.55956214907052,90.65471698817603,92.09906681499557,92.09906681499557,1000000.0 +2020-12-11,QQQ,92.09906681499557,92.55956214907052,91.1493523584792,91.60738930500423,91.60738930500423,1000000.0 +2020-12-14,QQQ,91.60738930500423,92.6439996568773,91.1493523584792,92.1830842356988,92.1830842356988,1000000.0 +2020-12-15,QQQ,92.1830842356988,92.6439996568773,91.23248371408548,91.69093840611606,91.69093840611606,1000000.0 +2020-12-16,QQQ,91.69093840611606,92.14939309814665,90.55664686672924,91.01170539369772,91.01170539369772,1000000.0 +2020-12-17,QQQ,91.01170539369772,91.4667639206662,89.28537140682654,89.73404161490105,89.73404161490105,1000000.0 2020-12-18,QQQ,89.73404161490105,91.25996345358432,89.28537140682654,90.80593378466102,90.80593378466102,1000000.0 2020-12-21,QQQ,90.80593378466102,91.25996345358432,89.93039842258982,90.38230997245208,90.38230997245208,1000000.0 2020-12-22,QQQ,90.38230997245208,90.83422152231432,89.33185730444187,89.78076110999183,89.78076110999183,1000000.0 2020-12-23,QQQ,89.78076110999183,90.22966491554178,88.0761151084347,88.51870865169317,88.51870865169317,1000000.0 2020-12-24,QQQ,88.51870865169317,91.1235659376383,88.0761151084347,90.6702148633217,90.6702148633217,1000000.0 -2020-12-25,QQQ,90.6702148633217,92.0525791089196,90.21686378900509,91.59460607852698,91.59460607852698,1000000.0 2020-12-28,QQQ,91.59460607852698,92.0525791089196,91.02492629610566,91.48233798603584,91.48233798603584,1000000.0 -2020-12-29,QQQ,91.48233798603584,91.93974967596601,90.25164620164514,90.70517206195493,90.70517206195493,1000000.0 +2020-12-29,QQQ,91.48233798603584,91.939749675966,90.25164620164514,90.70517206195493,90.70517206195493,1000000.0 2020-12-30,QQQ,90.70517206195493,91.1586979222647,88.51875385793609,88.96357171651869,88.96357171651869,1000000.0 2020-12-31,QQQ,88.96357171651869,89.79309673980993,88.51875385793609,89.34636491523376,89.34636491523376,1000000.0 -2021-01-01,QQQ,89.34636491523376,90.17548292386081,88.89963309065759,89.72684868045853,89.72684868045853,1000000.0 -2021-01-04,QQQ,89.72684868045853,90.17548292386081,87.70231448202024,88.1430296301711,88.1430296301711,1000000.0 +2021-01-04,QQQ,89.72684868045853,90.1754829238608,87.70231448202024,88.1430296301711,88.1430296301711,1000000.0 2021-01-05,QQQ,88.1430296301711,89.97650025445883,87.70231448202024,89.52885597458591,89.52885597458591,1000000.0 2021-01-06,QQQ,89.52885597458591,90.93418114843914,89.08121169471298,90.48177228700412,90.48177228700412,1000000.0 2021-01-07,QQQ,90.48177228700412,90.93418114843914,88.954848318635,89.40185760666834,89.40185760666834,1000000.0 @@ -533,27 +514,25 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-01-13,QQQ,88.58398312384068,89.02690303945988,88.01842077243427,88.4607243944063,88.4607243944063,1000000.0 2021-01-14,QQQ,88.4607243944063,88.90302801637831,86.20601125219497,86.63920728863816,86.63920728863816,1000000.0 2021-01-15,QQQ,86.63920728863816,88.80147514655125,86.20601125219497,88.35967676273758,88.35967676273758,1000000.0 -2021-01-18,QQQ,88.35967676273758,89.21197659458477,87.91787837892389,88.76813591500974,88.76813591500974,1000000.0 -2021-01-19,QQQ,88.76813591500974,90.73156855309742,88.32429523543469,90.28016771452481,90.28016771452481,1000000.0 -2021-01-20,QQQ,90.28016771452481,92.2699058931921,89.82876687595218,91.81085163501703,91.81085163501703,1000000.0 -2021-01-21,QQQ,91.81085163501703,92.2699058931921,91.15079725673216,91.60884146405242,91.60884146405242,1000000.0 +2021-01-19,QQQ,88.76813591500974,90.73156855309742,88.32429523543469,90.2801677145248,90.2801677145248,1000000.0 +2021-01-20,QQQ,90.2801677145248,92.2699058931921,89.82876687595218,91.81085163501704,91.81085163501704,1000000.0 +2021-01-21,QQQ,91.81085163501704,92.2699058931921,91.15079725673216,91.60884146405242,91.60884146405242,1000000.0 2021-01-22,QQQ,91.60884146405242,92.52333091564292,91.15079725673216,92.06301583646062,92.06301583646062,1000000.0 2021-01-25,QQQ,92.06301583646062,93.46036837441228,91.60270075727831,92.99539141732566,92.99539141732566,1000000.0 -2021-01-26,QQQ,92.99539141732566,94.44734215807878,92.53041446023903,93.97745488366049,93.97745488366049,1000000.0 +2021-01-26,QQQ,92.99539141732566,94.44734215807878,92.53041446023904,93.97745488366049,93.97745488366049,1000000.0 2021-01-27,QQQ,93.97745488366049,94.44734215807878,93.35518631044684,93.82430784969532,93.82430784969532,1000000.0 -2021-01-28,QQQ,93.82430784969532,94.29342938894378,92.6542677678961,93.11986710341317,93.11986710341317,1000000.0 -2021-01-29,QQQ,93.11986710341317,93.67523222666422,92.6542677678961,93.20918629518829,93.20918629518829,1000000.0 -2021-02-01,QQQ,93.20918629518829,93.67523222666422,91.82767147889204,92.28911706421312,92.28911706421312,1000000.0 -2021-02-02,QQQ,92.28911706421312,92.75056264953417,91.21200687297942,91.67035866631097,91.67035866631097,1000000.0 -2021-02-03,QQQ,91.67035866631097,92.12871045964252,88.8806280243978,89.32726434612843,89.32726434612843,1000000.0 +2021-01-28,QQQ,93.82430784969532,94.29342938894378,92.6542677678961,93.11986710341316,93.11986710341316,1000000.0 +2021-01-29,QQQ,93.11986710341316,93.67523222666422,92.6542677678961,93.20918629518827,93.20918629518827,1000000.0 +2021-02-01,QQQ,93.20918629518827,93.67523222666422,91.82767147889204,92.28911706421312,92.28911706421312,1000000.0 +2021-02-02,QQQ,92.28911706421312,92.75056264953416,91.21200687297942,91.67035866631096,91.67035866631096,1000000.0 +2021-02-03,QQQ,91.67035866631096,92.12871045964252,88.8806280243978,89.32726434612843,89.32726434612843,1000000.0 2021-02-04,QQQ,89.32726434612843,89.91651685464592,88.8806280243978,89.46917099964769,89.46917099964769,1000000.0 2021-02-05,QQQ,89.46917099964769,89.91651685464592,87.8852493228334,88.32688374154111,88.32688374154111,1000000.0 2021-02-08,QQQ,88.32688374154111,88.7685181602488,87.29612394729064,87.73479793697551,87.73479793697551,1000000.0 2021-02-09,QQQ,87.73479793697551,88.17347192666038,87.27996654574757,87.71855934245987,87.71855934245987,1000000.0 2021-02-10,QQQ,87.71855934245987,88.56182521642502,87.27996654574757,88.12121912082092,88.12121912082092,1000000.0 -2021-02-11,QQQ,88.12121912082092,90.80915728207493,87.68061302521681,90.35737042992531,90.35737042992531,1000000.0 -2021-02-12,QQQ,90.35737042992531,91.09630871232253,89.90558357777569,90.64309324609208,90.64309324609208,1000000.0 -2021-02-15,QQQ,90.64309324609208,91.09630871232253,88.5922831914549,89.03747054417578,89.03747054417578,1000000.0 +2021-02-11,QQQ,88.12121912082092,90.80915728207492,87.68061302521681,90.35737042992533,90.35737042992533,1000000.0 +2021-02-12,QQQ,90.35737042992533,91.09630871232253,89.90558357777569,90.64309324609208,90.64309324609208,1000000.0 2021-02-16,QQQ,89.03747054417578,89.67970994686027,88.5922831914549,89.23354223568187,89.23354223568187,1000000.0 2021-02-17,QQQ,89.23354223568187,89.67970994686027,88.14648432233258,88.58943147973125,88.58943147973125,1000000.0 2021-02-18,QQQ,88.58943147973125,89.54059695581812,88.14648432233258,89.09512134907277,89.09512134907277,1000000.0 @@ -572,7 +551,7 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-03-09,QQQ,85.77258016467036,88.05668431554356,85.34371726384701,87.61859135874982,87.61859135874982,1000000.0 2021-03-10,QQQ,87.61859135874982,88.77341257380405,87.18049840195607,88.33175380478016,88.33175380478016,1000000.0 2021-03-11,QQQ,88.33175380478016,88.79535530381024,87.89009503575626,88.35358736697538,88.35358736697538,1000000.0 -2021-03-12,QQQ,88.35358736697538,90.39894711564497,87.9118194301405,89.9492011100945,89.9492011100945,1000000.0 +2021-03-12,QQQ,88.35358736697538,90.39894711564496,87.9118194301405,89.9492011100945,89.9492011100945,1000000.0 2021-03-15,QQQ,89.9492011100945,92.26048042492502,89.49945510454404,91.8014730596269,91.8014730596269,1000000.0 2021-03-16,QQQ,91.8014730596269,92.26048042492502,90.82511849298574,91.28152612360375,91.28152612360375,1000000.0 2021-03-17,QQQ,91.28152612360375,91.97269913857777,90.82511849298574,91.5151235209729,91.5151235209729,1000000.0 @@ -587,7 +566,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-03-30,QQQ,87.90936010881713,88.65886492432347,87.46981330827305,88.21777604410296,88.21777604410296,1000000.0 2021-03-31,QQQ,88.21777604410296,88.65886492432347,87.18152918191561,87.61962731850815,87.61962731850815,1000000.0 2021-04-01,QQQ,87.61962731850815,88.05772545510068,85.77549570095103,86.20652834266434,86.20652834266434,1000000.0 -2021-04-02,QQQ,86.20652834266434,88.08361414527593,85.77549570095103,87.6453872092298,87.6453872092298,1000000.0 2021-04-05,QQQ,87.6453872092298,89.76646419093825,87.20716027318365,89.31986486660523,89.31986486660523,1000000.0 2021-04-06,QQQ,89.31986486660523,89.76646419093825,88.39395522067606,88.8381459504282,88.8381459504282,1000000.0 2021-04-07,QQQ,88.8381459504282,89.98131673344471,88.39395522067606,89.53364849098978,89.53364849098978,1000000.0 @@ -625,14 +603,13 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-05-21,QQQ,85.96208942127679,86.67267412773936,85.5322789741704,86.24146679377051,86.24146679377051,1000000.0 2021-05-24,QQQ,86.24146679377051,88.7760159791695,85.81025945980166,88.3343442578801,88.3343442578801,1000000.0 2021-05-25,QQQ,88.3343442578801,92.18863054912218,87.89267253659071,91.72998064589272,91.72998064589272,1000000.0 -2021-05-26,QQQ,91.72998064589272,92.18863054912218,91.12657561654676,91.58449810708217,91.58449810708217,1000000.0 -2021-05-27,QQQ,91.58449810708217,92.04242059761756,89.72151234713027,90.17237421822138,90.17237421822138,1000000.0 -2021-05-28,QQQ,90.17237421822138,91.69284463663303,89.72151234713027,91.23666132998312,91.23666132998312,1000000.0 -2021-05-31,QQQ,91.23666132998312,91.69284463663303,90.61261361578315,91.06795338269663,91.06795338269663,1000000.0 -2021-06-01,QQQ,91.06795338269663,91.5232931496101,90.12747877828662,90.58038068169509,90.58038068169509,1000000.0 -2021-06-02,QQQ,90.58038068169509,91.03328258510355,88.96938981827651,89.41647217917237,89.41647217917237,1000000.0 +2021-05-26,QQQ,91.72998064589272,92.18863054912218,91.12657561654676,91.58449810708215,91.58449810708215,1000000.0 +2021-05-27,QQQ,91.58449810708215,92.04242059761756,89.72151234713027,90.17237421822138,90.17237421822138,1000000.0 +2021-05-28,QQQ,90.17237421822138,91.69284463663304,89.72151234713027,91.23666132998312,91.23666132998312,1000000.0 +2021-06-01,QQQ,91.06795338269664,91.5232931496101,90.12747877828662,90.58038068169508,90.58038068169508,1000000.0 +2021-06-02,QQQ,90.58038068169508,91.03328258510356,88.96938981827651,89.41647217917237,89.41647217917237,1000000.0 2021-06-03,QQQ,89.41647217917237,91.90299868208172,88.96938981827651,91.44576983291714,91.44576983291714,1000000.0 -2021-06-04,QQQ,91.44576983291714,91.90299868208172,90.51167132805925,90.96650384729573,90.96650384729573,1000000.0 +2021-06-04,QQQ,91.44576983291714,91.90299868208172,90.51167132805924,90.96650384729573,90.96650384729573,1000000.0 2021-06-07,QQQ,90.96650384729573,91.4213363665322,88.92418966347124,89.3710448879108,89.3710448879108,1000000.0 2021-06-08,QQQ,89.3710448879108,89.81790011235034,88.45250817438318,88.8969931400836,88.8969931400836,1000000.0 2021-06-09,QQQ,88.8969931400836,89.341478105784,87.17533298139429,87.6133999813008,87.6133999813008,1000000.0 @@ -651,9 +628,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-06-28,QQQ,88.95065385988985,89.60299381329409,88.5059005905904,89.15720777442199,89.15720777442199,1000000.0 2021-06-29,QQQ,89.15720777442199,89.61647288138467,88.71142173554988,89.17061978247231,89.17061978247231,1000000.0 2021-06-30,QQQ,89.17061978247231,90.02477788606197,88.72476668355995,89.57689341896715,89.57689341896715,1000000.0 -2021-07-01,QQQ,89.57689341896715,90.80901832650417,89.12900895187231,90.35723216567581,90.35723216567581,1000000.0 -2021-07-02,QQQ,90.35723216567581,90.80901832650417,89.1285491504318,89.57643130696663,89.57643130696663,1000000.0 -2021-07-05,QQQ,89.57643130696663,90.02431346350144,88.61549772788698,89.06080173656983,89.06080173656983,1000000.0 +2021-07-01,QQQ,89.57689341896715,90.80901832650416,89.12900895187231,90.3572321656758,90.3572321656758,1000000.0 +2021-07-02,QQQ,90.3572321656758,90.80901832650416,89.1285491504318,89.57643130696663,89.57643130696663,1000000.0 2021-07-06,QQQ,89.06080173656983,89.60162891148154,88.61549772788698,89.15584966316572,89.15584966316572,1000000.0 2021-07-07,QQQ,89.15584966316572,89.60162891148154,87.98962400724002,88.43178292184928,88.43178292184928,1000000.0 2021-07-08,QQQ,88.43178292184928,89.07588195475931,87.98962400724002,88.6327183629446,88.6327183629446,1000000.0 @@ -665,531 +641,512 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-07-16,QQQ,89.88263648521675,91.415303401005,89.43322330279067,90.9605008965224,90.9605008965224,1000000.0 2021-07-19,QQQ,90.9605008965224,91.415303401005,89.24218331588223,89.6906364983741,89.6906364983741,1000000.0 2021-07-20,QQQ,89.6906364983741,90.13908968086596,88.31592892729974,88.75972756512536,88.75972756512536,1000000.0 -2021-07-21,QQQ,88.75972756512536,91.44891875870823,88.31592892729974,90.99394901364003,90.99394901364003,1000000.0 -2021-07-22,QQQ,90.99394901364003,93.31001291292803,90.53897926857184,92.84578399296322,92.84578399296322,1000000.0 -2021-07-23,QQQ,92.84578399296322,93.51236876333931,92.38155507299841,93.04713309785006,93.04713309785006,1000000.0 +2021-07-21,QQQ,88.75972756512536,91.44891875870825,88.31592892729974,90.99394901364003,90.99394901364003,1000000.0 +2021-07-22,QQQ,90.99394901364003,93.31001291292804,90.53897926857184,92.84578399296322,92.84578399296322,1000000.0 +2021-07-23,QQQ,92.84578399296322,93.51236876333932,92.3815550729984,93.04713309785006,93.04713309785006,1000000.0 2021-07-26,QQQ,93.04713309785006,94.97804403631918,92.58189743236082,94.50551645404894,94.50551645404894,1000000.0 2021-07-27,QQQ,94.50551645404894,94.97804403631918,92.27884428957348,92.74255707494822,92.74255707494822,1000000.0 -2021-07-28,QQQ,92.74255707494822,93.25447512833959,92.27884428957348,92.7905225157608,92.7905225157608,1000000.0 -2021-07-29,QQQ,92.7905225157608,94.20399282862664,92.32656990318199,93.7353162473897,93.7353162473897,1000000.0 +2021-07-28,QQQ,92.74255707494822,93.2544751283396,92.27884428957348,92.7905225157608,92.7905225157608,1000000.0 +2021-07-29,QQQ,92.7905225157608,94.20399282862664,92.326569903182,93.7353162473897,93.7353162473897,1000000.0 2021-07-30,QQQ,93.7353162473897,94.20399282862664,91.0840683886948,91.54177727507016,91.54177727507016,1000000.0 2021-08-02,QQQ,91.54177727507016,91.9994861614455,90.2067252413973,90.6600253682385,90.6600253682385,1000000.0 -2021-08-03,QQQ,90.6600253682385,92.09136479645119,90.2067252413973,91.633198802439,91.633198802439,1000000.0 -2021-08-04,QQQ,91.633198802439,93.50289219271659,91.1750328084268,93.03770367434487,93.03770367434487,1000000.0 -2021-08-05,QQQ,93.03770367434487,93.50289219271659,91.90719223456495,92.36903742167331,92.36903742167331,1000000.0 -2021-08-06,QQQ,92.36903742167331,93.22788701835415,91.90719223456495,92.76406668492952,92.76406668492952,1000000.0 -2021-08-09,QQQ,92.76406668492952,95.23582243247735,92.30024635150487,94.76201237062423,94.76201237062423,1000000.0 -2021-08-10,QQQ,94.76201237062423,95.23582243247735,93.8681317496688,94.33983090418975,94.33983090418975,1000000.0 -2021-08-11,QQQ,94.33983090418975,94.81153005871069,92.30120631528627,92.76503147264951,92.76503147264951,1000000.0 -2021-08-12,QQQ,92.76503147264951,93.22885663001274,92.27367707483181,92.73736389430333,92.73736389430333,1000000.0 -2021-08-13,QQQ,92.73736389430333,93.20105071377483,91.84917804221732,92.31073170072092,92.31073170072092,1000000.0 +2021-08-03,QQQ,90.6600253682385,92.0913647964512,90.2067252413973,91.633198802439,91.633198802439,1000000.0 +2021-08-04,QQQ,91.633198802439,93.5028921927166,91.1750328084268,93.03770367434488,93.03770367434488,1000000.0 +2021-08-05,QQQ,93.03770367434488,93.5028921927166,91.90719223456496,92.36903742167333,92.36903742167333,1000000.0 +2021-08-06,QQQ,92.36903742167333,93.22788701835417,91.90719223456496,92.76406668492952,92.76406668492952,1000000.0 +2021-08-09,QQQ,92.76406668492952,95.23582243247736,92.30024635150488,94.76201237062423,94.76201237062423,1000000.0 +2021-08-10,QQQ,94.76201237062423,95.23582243247736,93.8681317496688,94.33983090418975,94.33983090418975,1000000.0 +2021-08-11,QQQ,94.33983090418975,94.81153005871067,92.30120631528628,92.76503147264953,92.76503147264953,1000000.0 +2021-08-12,QQQ,92.76503147264953,93.22885663001274,92.2736770748318,92.73736389430331,92.73736389430331,1000000.0 +2021-08-13,QQQ,92.73736389430331,93.20105071377483,91.84917804221732,92.31073170072092,92.31073170072092,1000000.0 2021-08-16,QQQ,92.31073170072092,94.40193526511683,91.84917804221732,93.93227389563864,93.93227389563864,1000000.0 -2021-08-17,QQQ,93.93227389563864,94.64594890153231,93.46261252616046,94.175073533863,94.175073533863,1000000.0 -2021-08-18,QQQ,94.175073533863,94.64594890153231,92.77857709726352,93.24480110277742,93.24480110277742,1000000.0 -2021-08-19,QQQ,93.24480110277742,96.72993552354284,92.77857709726352,96.24869206322671,96.24869206322671,1000000.0 -2021-08-20,QQQ,96.24869206322671,96.72993552354284,95.19441948956167,95.67278340659465,95.67278340659465,1000000.0 -2021-08-23,QQQ,95.67278340659465,96.79188905144375,95.19441948956167,96.31033736462065,96.31033736462065,1000000.0 -2021-08-24,QQQ,96.31033736462065,97.69537411136385,95.82878567779754,97.2093274739939,97.2093274739939,1000000.0 -2021-08-25,QQQ,97.2093274739939,98.74595068047628,96.72328083662393,98.25467729400626,98.25467729400626,1000000.0 -2021-08-26,QQQ,98.25467729400626,99.01766849379648,97.76340390753623,98.52504327740944,98.52504327740944,1000000.0 +2021-08-17,QQQ,93.93227389563864,94.64594890153232,93.46261252616046,94.175073533863,94.175073533863,1000000.0 +2021-08-18,QQQ,94.175073533863,94.64594890153232,92.77857709726352,93.24480110277742,93.24480110277742,1000000.0 +2021-08-19,QQQ,93.24480110277742,96.72993552354284,92.77857709726352,96.24869206322673,96.24869206322673,1000000.0 +2021-08-20,QQQ,96.24869206322673,96.72993552354284,95.19441948956168,95.67278340659465,95.67278340659465,1000000.0 +2021-08-23,QQQ,95.67278340659465,96.79188905144376,95.19441948956168,96.31033736462064,96.31033736462064,1000000.0 +2021-08-24,QQQ,96.31033736462064,97.69537411136383,95.82878567779754,97.2093274739939,97.2093274739939,1000000.0 +2021-08-25,QQQ,97.2093274739939,98.74595068047628,96.72328083662391,98.25467729400626,98.25467729400626,1000000.0 +2021-08-26,QQQ,98.25467729400626,99.01766849379648,97.76340390753624,98.52504327740944,98.52504327740944,1000000.0 2021-08-27,QQQ,98.52504327740944,99.82563996020058,98.03241806102238,99.32899498527422,99.32899498527422,1000000.0 -2021-08-30,QQQ,99.32899498527422,99.82563996020058,98.79453137153975,99.29098630305502,99.29098630305502,1000000.0 -2021-08-31,QQQ,99.29098630305502,99.78744123457028,97.03705220351539,97.5246755814225,97.5246755814225,1000000.0 -2021-09-01,QQQ,97.5246755814225,98.0122989593296,96.80855311413545,97.29502825541252,97.29502825541252,1000000.0 -2021-09-02,QQQ,97.29502825541252,97.78150339668957,96.60746273387497,97.09292737072862,97.09292737072862,1000000.0 -2021-09-03,QQQ,97.09292737072862,97.57839200758225,96.43272634054773,96.9173129050731,96.9173129050731,1000000.0 -2021-09-06,QQQ,96.9173129050731,97.6231740955497,96.43272634054773,97.13748666223852,97.13748666223852,1000000.0 +2021-08-30,QQQ,99.32899498527422,99.82563996020058,98.79453137153976,99.29098630305502,99.29098630305502,1000000.0 +2021-08-31,QQQ,99.29098630305502,99.78744123457028,97.0370522035154,97.5246755814225,97.5246755814225,1000000.0 +2021-09-01,QQQ,97.5246755814225,98.0122989593296,96.80855311413544,97.29502825541252,97.29502825541252,1000000.0 +2021-09-02,QQQ,97.29502825541252,97.78150339668956,96.60746273387495,97.09292737072862,97.09292737072862,1000000.0 +2021-09-03,QQQ,97.09292737072862,97.57839200758224,96.43272634054772,96.9173129050731,96.9173129050731,1000000.0 2021-09-07,QQQ,97.13748666223852,97.6231740955497,95.9180023126949,96.40000232431647,96.40000232431647,1000000.0 -2021-09-08,QQQ,96.40000232431647,98.61231127132818,95.9180023126949,98.12170275754049,98.12170275754049,1000000.0 -2021-09-09,QQQ,98.12170275754049,100.69170352305247,97.63109424375278,100.19074977418157,100.19074977418157,1000000.0 -2021-09-10,QQQ,100.19074977418157,100.69170352305247,96.92704905234744,97.41411965060044,97.41411965060044,1000000.0 +2021-09-08,QQQ,96.40000232431647,98.61231127132818,95.9180023126949,98.12170275754048,98.12170275754048,1000000.0 +2021-09-09,QQQ,98.12170275754048,100.69170352305248,97.63109424375278,100.19074977418155,100.19074977418155,1000000.0 +2021-09-10,QQQ,100.19074977418155,100.69170352305248,96.92704905234744,97.41411965060044,97.41411965060044,1000000.0 2021-09-13,QQQ,97.41411965060044,97.9620291133996,96.92704905234744,97.47465583422846,97.47465583422846,1000000.0 -2021-09-14,QQQ,97.47465583422846,97.9620291133996,96.14352559988615,96.62665889435793,96.62665889435793,1000000.0 -2021-09-15,QQQ,96.62665889435793,97.10979218882972,92.98711759238527,93.4543895400857,93.4543895400857,1000000.0 -2021-09-16,QQQ,93.4543895400857,94.4709909313919,92.98711759238527,94.00098600138499,94.00098600138499,1000000.0 -2021-09-17,QQQ,94.00098600138499,94.4709909313919,92.82800152270208,93.2944738921629,93.2944738921629,1000000.0 -2021-09-20,QQQ,93.2944738921629,93.7609462616237,92.42188350425941,92.8863150796577,92.8863150796577,1000000.0 -2021-09-21,QQQ,92.8863150796577,93.35074665505599,91.6881615996632,92.14890613031478,92.14890613031478,1000000.0 -2021-09-22,QQQ,92.14890613031478,93.97458998910022,91.6881615996632,93.50705471552261,93.50705471552261,1000000.0 -2021-09-23,QQQ,93.50705471552261,95.5486270476756,93.039519441945,95.07326074395583,95.07326074395583,1000000.0 -2021-09-24,QQQ,95.07326074395583,95.94102133694997,94.59789444023605,95.4637028228358,95.4637028228358,1000000.0 -2021-09-27,QQQ,95.4637028228358,96.85781292634431,94.98638430872163,96.37593326004409,96.37593326004409,1000000.0 -2021-09-28,QQQ,96.37593326004409,96.85781292634431,95.80188926353448,96.28330579249696,96.28330579249696,1000000.0 -2021-09-29,QQQ,96.28330579249696,96.98967787182801,95.80188926353448,96.5071421610229,96.5071421610229,1000000.0 -2021-09-30,QQQ,96.5071421610229,96.98967787182801,95.15066300148312,95.62880703666646,95.62880703666646,1000000.0 -2021-10-01,QQQ,95.62880703666646,96.19820528731239,95.15066300148312,95.71960725105711,95.71960725105711,1000000.0 -2021-10-04,QQQ,95.71960725105711,96.19820528731239,94.40866901123152,94.88308443339852,94.88308443339852,1000000.0 -2021-10-05,QQQ,94.88308443339852,95.8689664351872,94.40866901123152,95.39200640317135,95.39200640317135,1000000.0 -2021-10-06,QQQ,95.39200640317135,96.95409623425668,94.9150463711555,96.47173754652407,96.47173754652407,1000000.0 -2021-10-07,QQQ,96.47173754652407,97.39561486033044,95.98937885879144,96.91105956251786,96.91105956251786,1000000.0 -2021-10-08,QQQ,96.91105956251786,98.95595636852104,96.42650426470527,98.46363817763289,98.46363817763289,1000000.0 -2021-10-11,QQQ,98.46363817763289,102.52924290817349,97.97131998674472,102.01914717231193,102.01914717231193,1000000.0 -2021-10-12,QQQ,102.01914717231193,102.52924290817349,98.81631036408685,99.31287473777573,99.31287473777573,1000000.0 -2021-10-13,QQQ,99.31287473777573,99.87712209512691,98.81631036408685,99.38022099017604,99.38022099017604,1000000.0 -2021-10-14,QQQ,99.38022099017604,99.87712209512691,98.20407593922538,98.69756375801545,98.69756375801545,1000000.0 -2021-10-15,QQQ,98.69756375801545,99.19105157680552,97.67158522610279,98.16239721216361,98.16239721216361,1000000.0 -2021-10-18,QQQ,98.16239721216361,98.65320919822442,96.01019689159394,96.49266019255671,96.49266019255671,1000000.0 -2021-10-19,QQQ,96.49266019255671,97.08990888392219,96.01019689159394,96.60687451136538,96.60687451136538,1000000.0 -2021-10-20,QQQ,96.60687451136538,97.08990888392219,95.84955399754612,96.33121004778505,96.33121004778505,1000000.0 -2021-10-21,QQQ,96.33121004778505,96.81286609802396,95.11187186785826,95.58982097272187,95.58982097272187,1000000.0 -2021-10-22,QQQ,95.58982097272187,96.06777007758546,94.84534363090962,95.32195339789912,95.32195339789912,1000000.0 -2021-10-25,QQQ,95.32195339789912,95.7985631648886,94.16437584756805,94.63756366589755,94.63756366589755,1000000.0 -2021-10-26,QQQ,94.63756366589755,95.11075148422702,91.38523742486602,91.84445972348344,91.84445972348344,1000000.0 +2021-09-14,QQQ,97.47465583422846,97.9620291133996,96.14352559988616,96.62665889435792,96.62665889435792,1000000.0 +2021-09-15,QQQ,96.62665889435792,97.10979218882972,92.98711759238527,93.4543895400857,93.4543895400857,1000000.0 +2021-09-16,QQQ,93.4543895400857,94.4709909313919,92.98711759238527,94.000986001385,94.000986001385,1000000.0 +2021-09-17,QQQ,94.000986001385,94.4709909313919,92.82800152270208,93.2944738921629,93.2944738921629,1000000.0 +2021-09-20,QQQ,93.2944738921629,93.7609462616237,92.4218835042594,92.8863150796577,92.8863150796577,1000000.0 +2021-09-21,QQQ,92.8863150796577,93.350746655056,91.6881615996632,92.14890613031478,92.14890613031478,1000000.0 +2021-09-22,QQQ,92.14890613031478,93.97458998910022,91.6881615996632,93.5070547155226,93.5070547155226,1000000.0 +2021-09-23,QQQ,93.5070547155226,95.5486270476756,93.039519441945,95.07326074395584,95.07326074395584,1000000.0 +2021-09-24,QQQ,95.07326074395584,95.94102133694996,94.59789444023605,95.4637028228358,95.4637028228358,1000000.0 +2021-09-27,QQQ,95.4637028228358,96.85781292634432,94.98638430872164,96.37593326004408,96.37593326004408,1000000.0 +2021-09-28,QQQ,96.37593326004408,96.85781292634432,95.80188926353448,96.28330579249696,96.28330579249696,1000000.0 +2021-09-29,QQQ,96.28330579249696,96.989677871828,95.80188926353448,96.5071421610229,96.5071421610229,1000000.0 +2021-09-30,QQQ,96.5071421610229,96.989677871828,95.15066300148312,95.62880703666646,95.62880703666646,1000000.0 +2021-10-01,QQQ,95.62880703666646,96.1982052873124,95.15066300148312,95.71960725105713,95.71960725105713,1000000.0 +2021-10-04,QQQ,95.71960725105713,96.1982052873124,94.40866901123152,94.88308443339852,94.88308443339852,1000000.0 +2021-10-05,QQQ,94.88308443339852,95.8689664351872,94.40866901123152,95.39200640317137,95.39200640317137,1000000.0 +2021-10-06,QQQ,95.39200640317137,96.95409623425668,94.9150463711555,96.47173754652408,96.47173754652408,1000000.0 +2021-10-07,QQQ,96.47173754652408,97.39561486033044,95.98937885879144,96.91105956251786,96.91105956251786,1000000.0 +2021-10-08,QQQ,96.91105956251786,98.95595636852104,96.42650426470529,98.46363817763287,98.46363817763287,1000000.0 +2021-10-11,QQQ,98.46363817763287,102.52924290817349,97.97131998674472,102.01914717231192,102.01914717231192,1000000.0 +2021-10-12,QQQ,102.01914717231192,102.52924290817349,98.81631036408685,99.31287473777572,99.31287473777572,1000000.0 +2021-10-13,QQQ,99.31287473777572,99.87712209512692,98.81631036408685,99.38022099017604,99.38022099017604,1000000.0 +2021-10-14,QQQ,99.38022099017604,99.87712209512692,98.20407593922538,98.69756375801543,98.69756375801543,1000000.0 +2021-10-15,QQQ,98.69756375801543,99.19105157680552,97.6715852261028,98.1623972121636,98.1623972121636,1000000.0 +2021-10-18,QQQ,98.1623972121636,98.65320919822442,96.01019689159394,96.49266019255673,96.49266019255673,1000000.0 +2021-10-19,QQQ,96.49266019255673,97.0899088839222,96.01019689159394,96.60687451136538,96.60687451136538,1000000.0 +2021-10-20,QQQ,96.60687451136538,97.0899088839222,95.84955399754612,96.33121004778504,96.33121004778504,1000000.0 +2021-10-21,QQQ,96.33121004778504,96.81286609802396,95.11187186785826,95.58982097272188,95.58982097272188,1000000.0 +2021-10-22,QQQ,95.58982097272188,96.06777007758546,94.84534363090962,95.32195339789912,95.32195339789912,1000000.0 +2021-10-25,QQQ,95.32195339789912,95.7985631648886,94.16437584756804,94.63756366589756,94.63756366589756,1000000.0 +2021-10-26,QQQ,94.63756366589756,95.11075148422702,91.38523742486602,91.84445972348344,91.84445972348344,1000000.0 2021-10-27,QQQ,91.84445972348344,93.65242746616582,91.38523742486602,93.18649499120978,93.18649499120978,1000000.0 -2021-10-28,QQQ,93.18649499120978,93.65242746616582,92.40167638665245,92.86600641874618,92.86600641874618,1000000.0 -2021-10-29,QQQ,92.86600641874618,93.33033645083991,91.31360910396417,91.77247146127053,91.77247146127053,1000000.0 -2021-11-01,QQQ,91.77247146127053,92.64576815492099,91.31360910396417,92.18484393524477,92.18484393524477,1000000.0 -2021-11-02,QQQ,92.18484393524477,92.64576815492099,90.98289244803851,91.44009291260151,91.44009291260151,1000000.0 +2021-10-28,QQQ,93.18649499120978,93.65242746616582,92.40167638665244,92.86600641874618,92.86600641874618,1000000.0 +2021-10-29,QQQ,92.86600641874618,93.33033645083992,91.31360910396415,91.77247146127053,91.77247146127053,1000000.0 +2021-11-01,QQQ,91.77247146127053,92.645768154921,91.31360910396415,92.18484393524476,92.18484393524476,1000000.0 +2021-11-02,QQQ,92.18484393524476,92.645768154921,90.98289244803853,91.44009291260151,91.44009291260151,1000000.0 2021-11-03,QQQ,91.44009291260151,91.89729337716452,88.49176758872669,88.93644983791627,88.93644983791627,1000000.0 -2021-11-04,QQQ,88.93644983791627,90.46285642586261,88.49176758872669,90.0127924635449,90.0127924635449,1000000.0 -2021-11-05,QQQ,90.0127924635449,90.46285642586261,87.67545958864326,88.11603978758117,88.11603978758117,1000000.0 +2021-11-04,QQQ,88.93644983791627,90.4628564258626,88.49176758872669,90.0127924635449,90.0127924635449,1000000.0 +2021-11-05,QQQ,90.0127924635449,90.4628564258626,87.67545958864326,88.11603978758117,88.11603978758117,1000000.0 2021-11-08,QQQ,88.11603978758117,88.55661998651907,87.47060078038415,87.91015153807452,87.91015153807452,1000000.0 2021-11-09,QQQ,87.91015153807452,88.53207009808929,87.47060078038415,88.09161203789981,88.09161203789981,1000000.0 -2021-11-10,QQQ,88.09161203789981,90.14419439754735,87.65115397771031,89.69571581845508,89.69571581845508,1000000.0 -2021-11-11,QQQ,89.69571581845508,91.59542609111125,89.2472372393628,91.13972745384206,91.13972745384206,1000000.0 -2021-11-12,QQQ,91.13972745384206,91.83890980393728,90.68402881657285,91.38199980491272,91.38199980491272,1000000.0 -2021-11-15,QQQ,91.38199980491272,91.83890980393728,90.07222856844412,90.52485283260715,90.52485283260715,1000000.0 -2021-11-16,QQQ,90.52485283260715,90.97747709677017,89.63135697934864,90.08176580839059,90.08176580839059,1000000.0 -2021-11-17,QQQ,90.08176580839059,91.86732626909665,89.63135697934864,91.41027489462354,91.41027489462354,1000000.0 -2021-11-18,QQQ,91.41027489462354,94.0728302324765,90.95322352015042,93.60480620146917,93.60480620146917,1000000.0 -2021-11-19,QQQ,93.60480620146917,94.78759292733622,93.13678217046183,94.31601286302113,94.31601286302113,1000000.0 -2021-11-22,QQQ,94.31601286302113,94.78759292733622,93.10756729412105,93.57544451670458,93.57544451670458,1000000.0 -2021-11-23,QQQ,93.57544451670458,95.35791923141038,93.10756729412105,94.88350172279641,94.88350172279641,1000000.0 -2021-11-24,QQQ,94.88350172279641,95.35791923141038,93.99665331853318,94.46899831008359,94.46899831008359,1000000.0 -2021-11-25,QQQ,94.46899831008359,94.941343301634,93.54635953370573,94.01644174241781,94.01644174241781,1000000.0 -2021-11-26,QQQ,94.01644174241781,94.4865239511299,93.25433494259626,93.72294969105151,93.72294969105151,1000000.0 -2021-11-29,QQQ,93.72294969105151,94.96444561714017,93.25433494259626,94.4919856886967,94.4919856886967,1000000.0 -2021-11-30,QQQ,94.4919856886967,95.35255600175829,94.01952576025322,94.8781651758789,94.8781651758789,1000000.0 -2021-12-01,QQQ,94.8781651758789,96.63949964536735,94.40377434999951,96.15870611479339,96.15870611479339,1000000.0 -2021-12-02,QQQ,96.15870611479339,98.33113086481207,95.67791258421943,97.84192125851948,97.84192125851948,1000000.0 -2021-12-03,QQQ,97.84192125851948,99.03891582941172,97.35271165222687,98.5461849048873,98.5461849048873,1000000.0 -2021-12-06,QQQ,98.5461849048873,99.03891582941172,95.1727206728154,95.65097555056825,95.65097555056825,1000000.0 -2021-12-07,QQQ,95.65097555056825,96.12923042832108,93.14315262848143,93.61120867184063,93.61120867184063,1000000.0 -2021-12-08,QQQ,93.61120867184063,94.32057162874479,93.14315262848143,93.8513150534774,93.8513150534774,1000000.0 -2021-12-09,QQQ,93.8513150534774,94.32057162874479,93.35020219332523,93.81929868675903,93.81929868675903,1000000.0 -2021-12-10,QQQ,93.81929868675903,97.02102121115149,93.35020219332523,96.53832956333483,96.53832956333483,1000000.0 -2021-12-13,QQQ,96.53832956333483,97.03142409286698,96.05563791551815,96.5486806894199,96.5486806894199,1000000.0 -2021-12-14,QQQ,96.5486806894199,97.03142409286698,95.92348796202467,96.40551553972328,96.40551553972328,1000000.0 -2021-12-15,QQQ,96.40551553972328,98.13278415393883,95.92348796202467,97.64456134720282,97.64456134720282,1000000.0 +2021-11-10,QQQ,88.09161203789981,90.14419439754737,87.65115397771031,89.69571581845508,89.69571581845508,1000000.0 +2021-11-11,QQQ,89.69571581845508,91.59542609111124,89.2472372393628,91.13972745384206,91.13972745384206,1000000.0 +2021-11-12,QQQ,91.13972745384206,91.83890980393728,90.68402881657283,91.38199980491272,91.38199980491272,1000000.0 +2021-11-15,QQQ,91.38199980491272,91.83890980393728,90.07222856844412,90.52485283260717,90.52485283260717,1000000.0 +2021-11-16,QQQ,90.52485283260717,90.97747709677016,89.63135697934864,90.0817658083906,90.0817658083906,1000000.0 +2021-11-17,QQQ,90.0817658083906,91.86732626909664,89.63135697934864,91.41027489462354,91.41027489462354,1000000.0 +2021-11-18,QQQ,91.41027489462354,94.0728302324765,90.95322352015042,93.60480620146916,93.60480620146916,1000000.0 +2021-11-19,QQQ,93.60480620146916,94.78759292733622,93.13678217046184,94.31601286302111,94.31601286302111,1000000.0 +2021-11-22,QQQ,94.31601286302111,94.78759292733622,93.10756729412104,93.57544451670458,93.57544451670458,1000000.0 +2021-11-23,QQQ,93.57544451670458,95.35791923141038,93.10756729412104,94.8835017227964,94.8835017227964,1000000.0 +2021-11-24,QQQ,94.8835017227964,95.35791923141038,93.99665331853318,94.4689983100836,94.4689983100836,1000000.0 +2021-11-26,QQQ,94.0164417424178,94.4865239511299,93.25433494259626,93.72294969105153,93.72294969105153,1000000.0 +2021-11-29,QQQ,93.72294969105153,94.96444561714016,93.25433494259626,94.4919856886967,94.4919856886967,1000000.0 +2021-11-30,QQQ,94.4919856886967,95.35255600175827,94.01952576025322,94.8781651758789,94.8781651758789,1000000.0 +2021-12-01,QQQ,94.8781651758789,96.63949964536737,94.40377434999952,96.1587061147934,96.1587061147934,1000000.0 +2021-12-02,QQQ,96.1587061147934,98.33113086481208,95.67791258421944,97.84192125851948,97.84192125851948,1000000.0 +2021-12-03,QQQ,97.84192125851948,99.03891582941172,97.35271165222689,98.5461849048873,98.5461849048873,1000000.0 +2021-12-06,QQQ,98.5461849048873,99.03891582941172,95.1727206728154,95.65097555056823,95.65097555056823,1000000.0 +2021-12-07,QQQ,95.65097555056823,96.12923042832108,93.14315262848145,93.61120867184064,93.61120867184064,1000000.0 +2021-12-08,QQQ,93.61120867184064,94.3205716287448,93.14315262848145,93.8513150534774,93.8513150534774,1000000.0 +2021-12-09,QQQ,93.8513150534774,94.3205716287448,93.35020219332525,93.81929868675904,93.81929868675904,1000000.0 +2021-12-10,QQQ,93.81929868675904,97.02102121115148,93.35020219332525,96.53832956333484,96.53832956333484,1000000.0 +2021-12-13,QQQ,96.53832956333484,97.03142409286698,96.05563791551816,96.5486806894199,96.5486806894199,1000000.0 +2021-12-14,QQQ,96.5486806894199,97.03142409286698,95.92348796202468,96.40551553972328,96.40551553972328,1000000.0 +2021-12-15,QQQ,96.40551553972328,98.13278415393884,95.92348796202468,97.64456134720282,97.64456134720282,1000000.0 2021-12-16,QQQ,97.64456134720282,99.54991099423982,97.1563385404668,99.05463780521376,99.05463780521376,1000000.0 -2021-12-17,QQQ,99.05463780521376,99.54991099423982,98.30734269776467,98.80134944498961,98.80134944498961,1000000.0 -2021-12-20,QQQ,98.80134944498961,99.95803338943364,98.30734269776467,99.46072974073,99.46072974073,1000000.0 +2021-12-17,QQQ,99.05463780521376,99.54991099423982,98.30734269776468,98.8013494449896,98.8013494449896,1000000.0 +2021-12-20,QQQ,98.8013494449896,99.95803338943364,98.30734269776468,99.46072974073,99.46072974073,1000000.0 2021-12-21,QQQ,99.46072974073,99.95803338943364,97.56455307139385,98.054827207431,98.054827207431,1000000.0 -2021-12-22,QQQ,98.054827207431,98.54510134346815,96.42382020265477,96.90836201271836,96.90836201271836,1000000.0 -2021-12-23,QQQ,96.90836201271836,99.01618314160503,96.42382020265477,98.52356531502988,98.52356531502988,1000000.0 -2021-12-24,QQQ,98.52356531502988,99.01618314160503,96.30213189208,96.78606220309548,96.78606220309548,1000000.0 -2021-12-27,QQQ,96.78606220309548,98.49251978902767,96.30213189208,98.00250725276386,98.00250725276386,1000000.0 -2021-12-28,QQQ,98.00250725276386,99.41402030422337,97.51249471650004,98.91942318828197,98.91942318828197,1000000.0 -2021-12-29,QQQ,98.91942318828197,100.46405191915456,98.42482607234055,99.96423076532793,99.96423076532793,1000000.0 +2021-12-22,QQQ,98.054827207431,98.54510134346816,96.42382020265477,96.90836201271836,96.90836201271836,1000000.0 +2021-12-23,QQQ,96.90836201271836,99.01618314160504,96.42382020265477,98.52356531502988,98.52356531502988,1000000.0 +2021-12-27,QQQ,96.78606220309548,98.49251978902768,96.30213189208,98.00250725276386,98.00250725276386,1000000.0 +2021-12-28,QQQ,98.00250725276386,99.41402030422336,97.51249471650004,98.91942318828195,98.91942318828195,1000000.0 +2021-12-29,QQQ,98.91942318828195,100.46405191915456,98.42482607234057,99.96423076532793,99.96423076532793,1000000.0 2021-12-30,QQQ,99.96423076532793,101.71418587485246,99.46440961150128,101.20814514910694,101.20814514910694,1000000.0 -2021-12-31,QQQ,101.20814514910694,102.51375657306971,100.7021044233614,102.00373788365147,102.00373788365147,1000000.0 -2022-01-03,QQQ,102.00373788365147,102.51375657306971,98.75657185220344,99.25283603236527,99.25283603236527,1000000.0 -2022-01-04,QQQ,99.25283603236527,99.74910021252708,97.52424544182536,98.01431702696016,98.01431702696016,1000000.0 -2022-01-05,QQQ,98.01431702696016,98.50438861209494,96.81325986041895,97.29975865368739,97.29975865368739,1000000.0 -2022-01-06,QQQ,97.29975865368739,99.00562993444015,96.81325986041895,98.51306461138324,98.51306461138324,1000000.0 -2022-01-07,QQQ,98.51306461138324,99.00562993444015,96.6421676995883,97.12780673325457,97.12780673325457,1000000.0 -2022-01-10,QQQ,97.12780673325457,97.61344576692083,95.80851164157532,96.28996144881943,96.28996144881943,1000000.0 -2022-01-11,QQQ,96.28996144881943,96.77141125606352,95.40538014588488,95.88480416671847,95.88480416671847,1000000.0 -2022-01-12,QQQ,95.88480416671847,98.07658652848912,95.40538014588488,97.58864331192947,97.58864331192947,1000000.0 +2021-12-31,QQQ,101.20814514910694,102.51375657306971,100.7021044233614,102.00373788365148,102.00373788365148,1000000.0 +2022-01-03,QQQ,102.00373788365148,102.51375657306971,98.75657185220344,99.25283603236528,99.25283603236528,1000000.0 +2022-01-04,QQQ,99.25283603236528,99.74910021252708,97.52424544182536,98.01431702696016,98.01431702696016,1000000.0 +2022-01-05,QQQ,98.01431702696016,98.50438861209494,96.81325986041897,97.2997586536874,97.2997586536874,1000000.0 +2022-01-06,QQQ,97.2997586536874,99.00562993444017,96.81325986041897,98.51306461138324,98.51306461138324,1000000.0 +2022-01-07,QQQ,98.51306461138324,99.00562993444017,96.6421676995883,97.12780673325456,97.12780673325456,1000000.0 +2022-01-10,QQQ,97.12780673325456,97.61344576692083,95.80851164157532,96.28996144881944,96.28996144881944,1000000.0 +2022-01-11,QQQ,96.28996144881944,96.77141125606352,95.40538014588488,95.88480416671848,95.88480416671848,1000000.0 +2022-01-12,QQQ,95.88480416671848,98.07658652848912,95.40538014588488,97.58864331192947,97.58864331192947,1000000.0 2022-01-13,QQQ,97.58864331192947,98.07658652848912,95.34624014615508,95.82536698106038,95.82536698106038,1000000.0 -2022-01-14,QQQ,95.82536698106038,96.30449381596567,93.7295787909547,94.20058169945196,94.20058169945196,1000000.0 -2022-01-17,QQQ,94.20058169945196,95.49434143225254,93.7295787909547,95.01924520622144,95.01924520622144,1000000.0 +2022-01-14,QQQ,95.82536698106038,96.30449381596569,93.7295787909547,94.20058169945196,94.20058169945196,1000000.0 2022-01-18,QQQ,95.01924520622144,95.49434143225254,92.01347630481573,92.47585558272937,92.47585558272937,1000000.0 -2022-01-19,QQQ,92.47585558272937,93.38687813943204,92.01347630481573,92.92226680540503,92.92226680540503,1000000.0 -2022-01-20,QQQ,92.92226680540503,93.38687813943204,91.95949690063482,92.42160492526112,92.42160492526112,1000000.0 -2022-01-21,QQQ,92.42160492526112,92.88371294988741,90.13854365250717,90.59150115829866,90.59150115829866,1000000.0 -2022-01-24,QQQ,90.59150115829866,91.04445866409014,89.69224697313868,90.14296178204893,90.14296178204893,1000000.0 -2022-01-25,QQQ,90.14296178204893,90.59367659095916,88.5756112711497,89.02071484537657,89.02071484537657,1000000.0 +2022-01-19,QQQ,92.47585558272937,93.38687813943204,92.01347630481573,92.92226680540504,92.92226680540504,1000000.0 +2022-01-20,QQQ,92.92226680540504,93.38687813943204,91.95949690063482,92.42160492526112,92.42160492526112,1000000.0 +2022-01-21,QQQ,92.42160492526112,92.8837129498874,90.13854365250717,90.59150115829866,90.59150115829866,1000000.0 +2022-01-24,QQQ,90.59150115829866,91.04445866409014,89.69224697313868,90.14296178204891,90.14296178204891,1000000.0 +2022-01-25,QQQ,90.14296178204891,90.59367659095916,88.5756112711497,89.02071484537657,89.02071484537657,1000000.0 2022-01-26,QQQ,89.02071484537657,89.7060161991214,88.5756112711497,89.25971761106607,89.25971761106607,1000000.0 2022-01-27,QQQ,89.25971761106607,90.17637943843552,88.81341902301074,89.72774073476172,89.72774073476172,1000000.0 2022-01-28,QQQ,89.72774073476172,91.98486242240946,89.27910203108792,91.5272262909547,91.5272262909547,1000000.0 -2022-01-31,QQQ,91.5272262909547,92.01557135225804,91.06959015949992,91.55778244005775,91.55778244005775,1000000.0 -2022-02-01,QQQ,91.55778244005775,92.18201183157913,91.09999352785746,91.72339485729267,91.72339485729267,1000000.0 -2022-02-02,QQQ,91.72339485729267,92.18201183157913,90.62769842532127,91.08311399529777,91.08311399529777,1000000.0 -2022-02-03,QQQ,91.08311399529777,91.55617471644314,90.62769842532127,91.10067135964492,91.10067135964492,1000000.0 -2022-02-04,QQQ,91.10067135964492,92.86042387609496,90.64516800284669,92.39843171750744,92.39843171750744,1000000.0 +2022-01-31,QQQ,91.5272262909547,92.01557135225804,91.06959015949992,91.55778244005776,91.55778244005776,1000000.0 +2022-02-01,QQQ,91.55778244005776,92.18201183157912,91.09999352785746,91.72339485729267,91.72339485729267,1000000.0 +2022-02-02,QQQ,91.72339485729267,92.18201183157912,90.62769842532128,91.08311399529777,91.08311399529777,1000000.0 +2022-02-03,QQQ,91.08311399529777,91.55617471644314,90.62769842532128,91.10067135964492,91.10067135964492,1000000.0 +2022-02-04,QQQ,91.10067135964492,92.86042387609496,90.64516800284667,92.39843171750744,92.39843171750744,1000000.0 2022-02-07,QQQ,92.39843171750744,92.86042387609496,89.70828782263135,90.15908323882547,90.15908323882547,1000000.0 2022-02-08,QQQ,90.15908323882547,91.4353274385054,89.70828782263135,90.98042531194568,90.98042531194568,1000000.0 -2022-02-09,QQQ,90.98042531194568,91.4353274385054,89.9428559612258,90.39483011178473,90.39483011178473,1000000.0 -2022-02-10,QQQ,90.39483011178473,90.97958959839205,89.9428559612258,90.5269548242707,90.5269548242707,1000000.0 +2022-02-09,QQQ,90.98042531194568,91.4353274385054,89.9428559612258,90.39483011178471,90.39483011178471,1000000.0 +2022-02-10,QQQ,90.39483011178471,90.97958959839204,89.9428559612258,90.5269548242707,90.5269548242707,1000000.0 2022-02-11,QQQ,90.5269548242707,91.41053861007276,90.07432005014935,90.95575981101769,90.95575981101769,1000000.0 -2022-02-14,QQQ,90.95575981101769,92.47316535065717,90.5009810119626,92.01309985140018,92.01309985140018,1000000.0 -2022-02-15,QQQ,92.01309985140018,92.80659835602285,91.55303435214319,92.3448739860924,92.3448739860924,1000000.0 -2022-02-16,QQQ,92.3448739860924,92.80659835602285,91.46121702278113,91.92082112842324,91.92082112842324,1000000.0 -2022-02-17,QQQ,91.92082112842324,92.8019019350682,91.46121702278113,92.34020093041613,92.34020093041613,1000000.0 -2022-02-18,QQQ,92.34020093041613,92.86264681189701,91.87849992576405,92.40064359392738,92.40064359392738,1000000.0 -2022-02-21,QQQ,92.40064359392738,92.86264681189701,91.74040502340223,92.20141208382134,92.20141208382134,1000000.0 -2022-02-22,QQQ,92.20141208382134,92.95633876473045,91.74040502340223,92.49386941764224,92.49386941764224,1000000.0 -2022-02-23,QQQ,92.49386941764224,94.06503959562956,92.03140007055403,93.59705432400952,93.59705432400952,1000000.0 -2022-02-24,QQQ,93.59705432400952,94.28147946492042,93.12906905238947,93.81241737803028,93.81241737803028,1000000.0 -2022-02-25,QQQ,93.81241737803028,94.51947240723851,93.34335529114013,94.04922627585923,94.04922627585923,1000000.0 -2022-02-28,QQQ,94.04922627585923,94.51947240723851,92.07174885294876,92.53442095773745,92.53442095773745,1000000.0 -2022-03-01,QQQ,92.53442095773745,92.99709306252613,91.96582180143695,92.42796160948437,92.42796160948437,1000000.0 -2022-03-02,QQQ,92.42796160948437,92.89010141753178,89.81596811728178,90.26730464048421,90.26730464048421,1000000.0 -2022-03-03,QQQ,90.26730464048421,91.61756398866846,89.81596811728178,91.16175521260544,91.16175521260544,1000000.0 -2022-03-04,QQQ,91.16175521260544,91.61756398866846,89.64994784848558,90.10045009898049,90.10045009898049,1000000.0 -2022-03-07,QQQ,90.10045009898049,92.13479447504531,89.64994784848558,91.67641241298041,91.67641241298041,1000000.0 -2022-03-08,QQQ,91.67641241298041,95.83877424715635,91.21803035091551,95.3619644250312,95.3619644250312,1000000.0 -2022-03-09,QQQ,95.3619644250312,95.83877424715635,94.44343381815683,94.91802393784606,94.91802393784606,1000000.0 -2022-03-10,QQQ,94.91802393784606,97.10436683608513,94.44343381815683,96.62126053341805,96.62126053341805,1000000.0 -2022-03-11,QQQ,96.62126053341805,97.66401143024922,96.13815423075096,97.17812082611863,97.17812082611863,1000000.0 -2022-03-14,QQQ,97.17812082611863,97.66401143024922,96.04263573383413,96.5252620440544,96.5252620440544,1000000.0 -2022-03-15,QQQ,96.5252620440544,97.00788835427467,95.04544714972916,95.52306246203935,95.52306246203935,1000000.0 -2022-03-16,QQQ,95.52306246203935,96.75291818403855,95.04544714972916,96.27156038212792,96.27156038212792,1000000.0 -2022-03-17,QQQ,96.27156038212792,98.57258472359459,95.79020258021728,98.08217385432299,98.08217385432299,1000000.0 -2022-03-18,QQQ,98.08217385432299,99.07640781640275,97.59176298505137,98.58349036457986,98.58349036457986,1000000.0 -2022-03-21,QQQ,98.58349036457986,99.07640781640275,96.22956030507724,96.713125934751,96.713125934751,1000000.0 +2022-02-14,QQQ,90.95575981101769,92.47316535065715,90.5009810119626,92.01309985140018,92.01309985140018,1000000.0 +2022-02-15,QQQ,92.01309985140018,92.80659835602285,91.5530343521432,92.3448739860924,92.3448739860924,1000000.0 +2022-02-16,QQQ,92.3448739860924,92.80659835602285,91.46121702278111,91.92082112842324,91.92082112842324,1000000.0 +2022-02-17,QQQ,91.92082112842324,92.8019019350682,91.46121702278111,92.34020093041612,92.34020093041612,1000000.0 +2022-02-18,QQQ,92.34020093041612,92.862646811897,91.87849992576405,92.40064359392738,92.40064359392738,1000000.0 +2022-02-22,QQQ,92.20141208382134,92.95633876473045,91.74040502340225,92.49386941764224,92.49386941764224,1000000.0 +2022-02-23,QQQ,92.49386941764224,94.06503959562956,92.03140007055404,93.59705432400952,93.59705432400952,1000000.0 +2022-02-24,QQQ,93.59705432400952,94.28147946492042,93.12906905238948,93.81241737803028,93.81241737803028,1000000.0 +2022-02-25,QQQ,93.81241737803028,94.51947240723852,93.34335529114011,94.04922627585924,94.04922627585924,1000000.0 +2022-02-28,QQQ,94.04922627585924,94.51947240723852,92.07174885294876,92.53442095773744,92.53442095773744,1000000.0 +2022-03-01,QQQ,92.53442095773744,92.99709306252612,91.96582180143696,92.42796160948436,92.42796160948436,1000000.0 +2022-03-02,QQQ,92.42796160948436,92.89010141753178,89.81596811728178,90.2673046404842,90.2673046404842,1000000.0 +2022-03-03,QQQ,90.2673046404842,91.61756398866846,89.81596811728178,91.16175521260544,91.16175521260544,1000000.0 +2022-03-04,QQQ,91.16175521260544,91.61756398866846,89.64994784848558,90.10045009898047,90.10045009898047,1000000.0 +2022-03-07,QQQ,90.10045009898047,92.13479447504533,89.64994784848558,91.6764124129804,91.6764124129804,1000000.0 +2022-03-08,QQQ,91.6764124129804,95.83877424715637,91.21803035091553,95.3619644250312,95.3619644250312,1000000.0 +2022-03-09,QQQ,95.3619644250312,95.83877424715637,94.44343381815683,94.91802393784606,94.91802393784606,1000000.0 +2022-03-10,QQQ,94.91802393784606,97.10436683608512,94.44343381815683,96.62126053341804,96.62126053341804,1000000.0 +2022-03-11,QQQ,96.62126053341804,97.66401143024922,96.13815423075096,97.17812082611864,97.17812082611864,1000000.0 +2022-03-14,QQQ,97.17812082611864,97.66401143024922,96.04263573383412,96.5252620440544,96.5252620440544,1000000.0 +2022-03-15,QQQ,96.5252620440544,97.00788835427468,95.04544714972916,95.52306246203936,95.52306246203936,1000000.0 +2022-03-16,QQQ,95.52306246203936,96.75291818403856,95.04544714972916,96.27156038212792,96.27156038212792,1000000.0 +2022-03-17,QQQ,96.27156038212792,98.5725847235946,95.79020258021728,98.082173854323,98.082173854323,1000000.0 +2022-03-18,QQQ,98.082173854323,99.07640781640276,97.59176298505136,98.58349036457986,98.58349036457986,1000000.0 +2022-03-21,QQQ,98.58349036457986,99.07640781640276,96.22956030507724,96.713125934751,96.713125934751,1000000.0 2022-03-22,QQQ,96.713125934751,97.19669156442475,94.9819825190918,95.4592789136601,95.4592789136601,1000000.0 -2022-03-23,QQQ,95.4592789136601,95.93657530822838,94.74826666627867,95.22438860932529,95.22438860932529,1000000.0 -2022-03-24,QQQ,95.22438860932529,97.45266714731784,94.74826666627867,96.96782800728144,96.96782800728144,1000000.0 -2022-03-25,QQQ,96.96782800728144,97.8613023581229,96.48298886724503,97.37443020708747,97.37443020708747,1000000.0 -2022-03-28,QQQ,97.37443020708747,98.32768247197713,96.88755805605203,97.8384900218678,97.8384900218678,1000000.0 -2022-03-29,QQQ,97.8384900218678,99.93721595630753,97.34929757175846,99.44001587692293,99.44001587692293,1000000.0 -2022-03-30,QQQ,99.44001587692293,99.93721595630753,98.72935554224802,99.22548295703318,99.22548295703318,1000000.0 -2022-03-31,QQQ,99.22548295703318,99.72161037181833,98.66436286460895,99.16016368302407,99.16016368302407,1000000.0 -2022-04-01,QQQ,99.16016368302407,99.65596450143917,98.48059823953272,98.97547561762083,98.97547561762083,1000000.0 -2022-04-04,QQQ,98.97547561762083,99.47035299570892,96.27859972105678,96.76241177995657,96.76241177995657,1000000.0 +2022-03-23,QQQ,95.4592789136601,95.93657530822838,94.74826666627868,95.22438860932527,95.22438860932527,1000000.0 +2022-03-24,QQQ,95.22438860932527,97.45266714731784,94.74826666627868,96.96782800728144,96.96782800728144,1000000.0 +2022-03-25,QQQ,96.96782800728144,97.8613023581229,96.48298886724504,97.37443020708749,97.37443020708749,1000000.0 +2022-03-28,QQQ,97.37443020708749,98.32768247197713,96.88755805605204,97.8384900218678,97.8384900218678,1000000.0 +2022-03-29,QQQ,97.8384900218678,99.93721595630753,97.34929757175846,99.44001587692291,99.44001587692291,1000000.0 +2022-03-30,QQQ,99.44001587692291,99.93721595630753,98.72935554224802,99.22548295703318,99.22548295703318,1000000.0 +2022-03-31,QQQ,99.22548295703318,99.72161037181831,98.66436286460896,99.16016368302408,99.16016368302408,1000000.0 +2022-04-01,QQQ,99.16016368302408,99.65596450143916,98.48059823953272,98.97547561762084,98.97547561762084,1000000.0 +2022-04-04,QQQ,98.97547561762084,99.47035299570892,96.27859972105678,96.76241177995657,96.76241177995657,1000000.0 2022-04-05,QQQ,96.76241177995657,97.88692412638696,96.27859972105678,97.39992450386762,97.39992450386762,1000000.0 -2022-04-06,QQQ,97.39992450386762,98.46034530424703,96.91292488134829,97.9704928400468,97.9704928400468,1000000.0 -2022-04-07,QQQ,97.9704928400468,99.76671720021518,97.48064037584656,99.27036537334845,99.27036537334845,1000000.0 -2022-04-08,QQQ,99.27036537334845,99.76671720021518,97.30776666526278,97.79675041734953,97.79675041734953,1000000.0 -2022-04-11,QQQ,97.79675041734953,98.28573416943627,96.3293053717877,96.81337223295246,96.81337223295246,1000000.0 -2022-04-12,QQQ,96.81337223295246,98.53588265397173,96.3293053717877,98.04565438206143,98.04565438206143,1000000.0 -2022-04-13,QQQ,98.04565438206143,99.00007164380017,97.55542611015112,98.50753397393052,98.50753397393052,1000000.0 -2022-04-14,QQQ,98.50753397393052,100.836047700581,98.01499630406087,100.33437582147364,100.33437582147364,1000000.0 -2022-04-15,QQQ,100.33437582147364,100.836047700581,98.76827059601928,99.26459356383847,99.26459356383847,1000000.0 +2022-04-06,QQQ,97.39992450386762,98.46034530424704,96.91292488134827,97.9704928400468,97.9704928400468,1000000.0 +2022-04-07,QQQ,97.9704928400468,99.76671720021518,97.48064037584656,99.27036537334844,99.27036537334844,1000000.0 +2022-04-08,QQQ,99.27036537334844,99.76671720021518,97.30776666526278,97.79675041734951,97.79675041734951,1000000.0 +2022-04-11,QQQ,97.79675041734951,98.28573416943628,96.3293053717877,96.81337223295246,96.81337223295246,1000000.0 +2022-04-12,QQQ,96.81337223295246,98.53588265397173,96.3293053717877,98.04565438206144,98.04565438206144,1000000.0 +2022-04-13,QQQ,98.04565438206144,99.00007164380015,97.55542611015112,98.50753397393052,98.50753397393052,1000000.0 +2022-04-14,QQQ,98.50753397393052,100.836047700581,98.01499630406089,100.33437582147364,100.33437582147364,1000000.0 2022-04-18,QQQ,99.26459356383847,100.07169986930548,98.76827059601928,99.57383071572686,99.57383071572686,1000000.0 2022-04-19,QQQ,99.57383071572686,100.07169986930548,98.25821672992711,98.75197661299208,98.75197661299208,1000000.0 2022-04-20,QQQ,98.75197661299208,99.69601983172082,98.25821672992711,99.20001973305556,99.20001973305556,1000000.0 2022-04-21,QQQ,99.20001973305556,99.69601983172082,98.13264550638291,98.62577437827429,98.62577437827429,1000000.0 2022-04-22,QQQ,98.62577437827429,99.86185139115722,98.13264550638291,99.36502625985794,99.36502625985794,1000000.0 -2022-04-25,QQQ,99.36502625985794,101.32624640664461,98.86820112855865,100.8221357280046,100.8221357280046,1000000.0 +2022-04-25,QQQ,99.36502625985794,101.3262464066446,98.86820112855864,100.8221357280046,100.8221357280046,1000000.0 2022-04-26,QQQ,100.8221357280046,102.27077879601468,100.31802504936458,101.7619689512584,101.7619689512584,1000000.0 2022-04-27,QQQ,101.7619689512584,102.27077879601468,98.95016666065554,99.4474036790508,99.4474036790508,1000000.0 -2022-04-28,QQQ,99.4474036790508,101.22458751433761,98.95016666065554,100.72098260133097,100.72098260133097,1000000.0 -2022-04-29,QQQ,100.72098260133097,101.86615391633774,100.21737768832432,101.35935713068433,101.35935713068433,1000000.0 -2022-05-02,QQQ,101.35935713068433,101.86615391633774,99.54406223771562,100.0442836559956,100.0442836559956,1000000.0 -2022-05-03,QQQ,100.0442836559956,100.54450507427556,98.45360508161568,98.94834681569415,98.94834681569415,1000000.0 -2022-05-04,QQQ,98.94834681569415,99.50356707945983,98.45360508161568,99.00852445717396,99.00852445717396,1000000.0 +2022-04-28,QQQ,99.4474036790508,101.2245875143376,98.95016666065554,100.72098260133096,100.72098260133096,1000000.0 +2022-04-29,QQQ,100.72098260133096,101.86615391633774,100.21737768832432,101.35935713068432,101.35935713068432,1000000.0 +2022-05-02,QQQ,101.35935713068432,101.86615391633774,99.54406223771562,100.0442836559956,100.0442836559956,1000000.0 +2022-05-03,QQQ,100.0442836559956,100.54450507427556,98.45360508161568,98.94834681569417,98.94834681569417,1000000.0 +2022-05-04,QQQ,98.94834681569417,99.50356707945984,98.45360508161568,99.00852445717396,99.00852445717396,1000000.0 2022-05-05,QQQ,99.00852445717396,100.2983631761995,98.5134818348881,99.79936634447712,99.79936634447712,1000000.0 2022-05-06,QQQ,99.79936634447712,100.2983631761995,98.7696311449788,99.26596094972744,99.26596094972744,1000000.0 -2022-05-09,QQQ,99.26596094972744,99.76229075447607,96.75650150969639,97.242715085122,97.242715085122,1000000.0 -2022-05-10,QQQ,97.242715085122,98.1363796225764,96.75650150969639,97.64813892793673,97.64813892793673,1000000.0 -2022-05-11,QQQ,97.64813892793673,98.40244510208943,97.15989823329704,97.91288069859645,97.91288069859645,1000000.0 -2022-05-12,QQQ,97.91288069859645,101.62609870877198,97.42331629510348,101.12049622763382,101.12049622763382,1000000.0 -2022-05-13,QQQ,101.12049622763382,101.62609870877198,98.99298847539414,99.49044067878809,99.49044067878809,1000000.0 -2022-05-16,QQQ,99.49044067878809,103.3744532894958,98.99298847539414,102.86015252686151,102.86015252686151,1000000.0 -2022-05-17,QQQ,102.86015252686151,105.79878074734624,102.3458517642272,105.27241865407588,105.27241865407588,1000000.0 -2022-05-18,QQQ,105.27241865407588,106.26963511899338,104.7460565608055,105.74093046666009,105.74093046666009,1000000.0 -2022-05-19,QQQ,105.74093046666009,108.64073023210368,105.21222581432679,108.10022908667034,108.10022908667034,1000000.0 -2022-05-20,QQQ,108.10022908667034,108.83159314505158,107.55972794123699,108.29014243288717,108.29014243288717,1000000.0 -2022-05-23,QQQ,108.29014243288717,110.27900394848453,107.74869172072273,109.73035218754681,109.73035218754681,1000000.0 -2022-05-24,QQQ,109.73035218754681,110.27900394848453,108.19925867421048,108.74297354192008,108.74297354192008,1000000.0 +2022-05-09,QQQ,99.26596094972744,99.76229075447608,96.7565015096964,97.242715085122,97.242715085122,1000000.0 +2022-05-10,QQQ,97.242715085122,98.1363796225764,96.7565015096964,97.64813892793671,97.64813892793671,1000000.0 +2022-05-11,QQQ,97.64813892793671,98.40244510208944,97.15989823329704,97.91288069859644,97.91288069859644,1000000.0 +2022-05-12,QQQ,97.91288069859644,101.62609870877198,97.42331629510348,101.12049622763382,101.12049622763382,1000000.0 +2022-05-13,QQQ,101.12049622763382,101.62609870877198,98.99298847539414,99.49044067878808,99.49044067878808,1000000.0 +2022-05-16,QQQ,99.49044067878808,103.3744532894958,98.99298847539414,102.86015252686153,102.86015252686153,1000000.0 +2022-05-17,QQQ,102.86015252686153,105.79878074734624,102.3458517642272,105.27241865407588,105.27241865407588,1000000.0 +2022-05-18,QQQ,105.27241865407588,106.26963511899338,104.7460565608055,105.74093046666007,105.74093046666007,1000000.0 +2022-05-19,QQQ,105.74093046666007,108.64073023210368,105.2122258143268,108.10022908667034,108.10022908667034,1000000.0 +2022-05-20,QQQ,108.10022908667034,108.83159314505158,107.559727941237,108.29014243288717,108.29014243288717,1000000.0 +2022-05-23,QQQ,108.29014243288717,110.27900394848452,107.74869172072272,109.7303521875468,109.7303521875468,1000000.0 +2022-05-24,QQQ,109.7303521875468,110.27900394848452,108.19925867421048,108.74297354192008,108.74297354192008,1000000.0 2022-05-25,QQQ,108.74297354192008,109.454689771979,108.19925867421048,108.91013907659604,108.91013907659604,1000000.0 2022-05-26,QQQ,108.91013907659604,109.454689771979,106.99914559134582,107.53682974004606,107.53682974004606,1000000.0 -2022-05-27,QQQ,107.53682974004606,108.07451388874628,104.88240045672663,105.40944769520264,105.40944769520264,1000000.0 -2022-05-30,QQQ,105.40944769520264,105.93649493367865,104.56492179507026,105.0903736633872,105.0903736633872,1000000.0 +2022-05-27,QQQ,107.53682974004606,108.07451388874628,104.88240045672664,105.40944769520264,105.40944769520264,1000000.0 2022-05-31,QQQ,105.0903736633872,105.61582553170412,103.72493293670102,104.24616375547842,104.24616375547842,1000000.0 -2022-06-01,QQQ,104.24616375547842,105.60325164997613,103.72493293670102,105.07786233828472,105.07786233828472,1000000.0 -2022-06-02,QQQ,105.07786233828472,107.41605016052418,104.55247302659329,106.88164195077034,106.88164195077034,1000000.0 -2022-06-03,QQQ,106.88164195077034,107.41605016052418,106.16670438835334,106.70020541543049,106.70020541543049,1000000.0 -2022-06-06,QQQ,106.70020541543049,107.36956308339452,106.16670438835334,106.83538615263137,106.83538615263137,1000000.0 -2022-06-07,QQQ,106.83538615263137,107.68601201053751,106.30120922186822,107.1502607070025,107.1502607070025,1000000.0 -2022-06-08,QQQ,107.1502607070025,107.68601201053751,104.36621651832337,104.89066986766169,104.89066986766169,1000000.0 -2022-06-09,QQQ,104.89066986766169,105.83742217718165,104.36621651832337,105.31086783799171,105.31086783799171,1000000.0 -2022-06-10,QQQ,105.31086783799171,105.90598010209847,104.78431349880175,105.37908467870496,105.37908467870496,1000000.0 -2022-06-13,QQQ,105.37908467870496,105.90598010209847,104.84147410180971,105.36831568021077,105.36831568021077,1000000.0 -2022-06-14,QQQ,105.36831568021077,105.89515725861182,103.40409494734494,103.92371351491954,103.92371351491954,1000000.0 -2022-06-15,QQQ,103.92371351491954,104.44333208249412,100.82437316539408,101.33102830692873,101.33102830692873,1000000.0 -2022-06-16,QQQ,101.33102830692873,101.83768344846337,100.54714673786354,101.0524087817724,101.0524087817724,1000000.0 -2022-06-17,QQQ,101.0524087817724,102.02693635743185,100.54714673786354,101.51933965913618,101.51933965913618,1000000.0 -2022-06-20,QQQ,101.51933965913618,103.87297203019618,101.0117429608405,103.35619107482208,103.35619107482208,1000000.0 -2022-06-21,QQQ,103.35619107482208,105.46445931634473,102.83941011944796,104.93976051377587,104.93976051377587,1000000.0 -2022-06-22,QQQ,104.93976051377587,107.53250219653707,104.41506171120699,106.99751462341997,106.99751462341997,1000000.0 -2022-06-23,QQQ,106.99751462341997,108.10494977773591,106.46252705030287,107.56711420670241,107.56711420670241,1000000.0 -2022-06-24,QQQ,107.56711420670241,109.7969432794292,107.0292786356689,109.25068983027782,109.25068983027782,1000000.0 +2022-06-01,QQQ,104.24616375547842,105.60325164997612,103.72493293670102,105.07786233828472,105.07786233828472,1000000.0 +2022-06-02,QQQ,105.07786233828472,107.41605016052418,104.55247302659328,106.88164195077034,106.88164195077034,1000000.0 +2022-06-03,QQQ,106.88164195077034,107.41605016052418,106.16670438835334,106.70020541543047,106.70020541543047,1000000.0 +2022-06-06,QQQ,106.70020541543047,107.36956308339452,106.16670438835334,106.83538615263136,106.83538615263136,1000000.0 +2022-06-07,QQQ,106.83538615263136,107.68601201053752,106.30120922186822,107.1502607070025,107.1502607070025,1000000.0 +2022-06-08,QQQ,107.1502607070025,107.68601201053752,104.36621651832336,104.89066986766169,104.89066986766169,1000000.0 +2022-06-09,QQQ,104.89066986766169,105.83742217718164,104.36621651832336,105.31086783799172,105.31086783799172,1000000.0 +2022-06-10,QQQ,105.31086783799172,105.90598010209848,104.78431349880177,105.37908467870496,105.37908467870496,1000000.0 +2022-06-13,QQQ,105.37908467870496,105.90598010209848,104.84147410180972,105.36831568021076,105.36831568021076,1000000.0 +2022-06-14,QQQ,105.36831568021076,105.89515725861182,103.40409494734494,103.92371351491954,103.92371351491954,1000000.0 +2022-06-15,QQQ,103.92371351491954,104.44333208249412,100.82437316539408,101.33102830692872,101.33102830692872,1000000.0 +2022-06-16,QQQ,101.33102830692872,101.83768344846337,100.54714673786354,101.0524087817724,101.0524087817724,1000000.0 +2022-06-17,QQQ,101.0524087817724,102.02693635743184,100.54714673786354,101.51933965913618,101.51933965913618,1000000.0 +2022-06-21,QQQ,103.35619107482208,105.46445931634472,102.83941011944796,104.93976051377588,104.93976051377588,1000000.0 +2022-06-22,QQQ,104.93976051377588,107.53250219653708,104.415061711207,106.99751462341996,106.99751462341996,1000000.0 +2022-06-23,QQQ,106.99751462341996,108.10494977773592,106.46252705030288,107.5671142067024,107.5671142067024,1000000.0 +2022-06-24,QQQ,107.5671142067024,109.7969432794292,107.0292786356689,109.25068983027782,109.25068983027782,1000000.0 2022-06-27,QQQ,109.25068983027782,109.7969432794292,108.4679203689123,109.01298529538926,109.01298529538926,1000000.0 -2022-06-28,QQQ,109.01298529538926,110.5120076809792,108.4679203689123,109.96219669749175,109.96219669749175,1000000.0 -2022-06-29,QQQ,109.96219669749175,111.91478920447076,109.41238571400429,111.35799920842864,111.35799920842864,1000000.0 +2022-06-28,QQQ,109.01298529538926,110.5120076809792,108.4679203689123,109.96219669749176,109.96219669749176,1000000.0 +2022-06-29,QQQ,109.96219669749176,111.91478920447076,109.41238571400427,111.35799920842864,111.35799920842864,1000000.0 2022-06-30,QQQ,111.35799920842864,115.9546299729246,110.8012092123865,115.37774126659164,115.37774126659164,1000000.0 2022-07-01,QQQ,115.37774126659164,116.3791259261609,114.80085256025868,115.8001252996626,115.8001252996626,1000000.0 -2022-07-04,QQQ,115.8001252996626,116.3791259261609,113.38733490478685,113.95712050732347,113.95712050732347,1000000.0 -2022-07-05,QQQ,113.95712050732347,116.65493595268185,113.38733490478685,116.07456313699689,116.07456313699689,1000000.0 -2022-07-06,QQQ,116.07456313699689,116.65493595268185,114.36937488622314,114.94409536303833,114.94409536303833,1000000.0 -2022-07-07,QQQ,114.94409536303833,115.5188158398535,114.33634937121744,114.9109038906708,114.9109038906708,1000000.0 -2022-07-08,QQQ,114.9109038906708,115.48545841012414,113.95683611436745,114.52948353202758,114.52948353202758,1000000.0 +2022-07-05,QQQ,113.95712050732348,116.65493595268184,113.38733490478684,116.07456313699689,116.07456313699689,1000000.0 +2022-07-06,QQQ,116.07456313699689,116.65493595268184,114.36937488622314,114.94409536303831,114.94409536303831,1000000.0 +2022-07-07,QQQ,114.94409536303831,115.5188158398535,114.33634937121744,114.9109038906708,114.9109038906708,1000000.0 +2022-07-08,QQQ,114.9109038906708,115.48545841012414,113.95683611436743,114.52948353202758,114.52948353202758,1000000.0 2022-07-11,QQQ,114.52948353202758,115.1021309496877,112.67627797196748,113.24249042408792,113.24249042408792,1000000.0 -2022-07-12,QQQ,113.24249042408792,115.85866432409941,112.67627797196748,115.2822530588054,115.2822530588054,1000000.0 -2022-07-13,QQQ,115.2822530588054,116.81831111951311,114.70584179351137,116.23712549205285,116.23712549205285,1000000.0 -2022-07-14,QQQ,116.23712549205285,116.81831111951311,114.79677801657114,115.37364624781019,115.37364624781019,1000000.0 -2022-07-15,QQQ,115.37364624781019,116.11312180444304,114.79677801657114,115.53544458153537,115.53544458153537,1000000.0 -2022-07-18,QQQ,115.53544458153537,116.11312180444304,114.36930814201581,114.94402828343297,114.94402828343297,1000000.0 -2022-07-19,QQQ,114.94402828343297,116.50455648445683,114.36930814201581,115.92493182533019,115.92493182533019,1000000.0 -2022-07-20,QQQ,115.92493182533019,116.50455648445683,114.83646331840788,115.41353097327425,115.41353097327425,1000000.0 -2022-07-21,QQQ,115.41353097327425,115.9905986281406,113.75521846394729,114.32685272758522,114.32685272758522,1000000.0 -2022-07-22,QQQ,114.32685272758522,114.89848699122314,112.86280159780347,113.42995135457635,113.42995135457635,1000000.0 -2022-07-25,QQQ,113.42995135457635,113.99710111134921,112.27978519456171,112.84400522066504,112.84400522066504,1000000.0 -2022-07-26,QQQ,112.84400522066504,113.89257678973668,112.27978519456171,113.32594705446436,113.32594705446436,1000000.0 -2022-07-27,QQQ,113.32594705446436,113.89257678973668,111.47610193070375,112.03628334744096,112.03628334744096,1000000.0 -2022-07-28,QQQ,112.03628334744096,112.59646476417815,111.40235079487846,111.96216160289292,111.96216160289292,1000000.0 -2022-07-29,QQQ,111.96216160289292,113.22931133877175,111.40235079487846,112.6659814316137,112.6659814316137,1000000.0 -2022-08-01,QQQ,112.6659814316137,116.78091330170312,112.10265152445564,116.19991373303795,116.19991373303795,1000000.0 -2022-08-02,QQQ,116.19991373303795,118.14714447547844,115.61891416437275,117.55934773679448,117.55934773679448,1000000.0 -2022-08-03,QQQ,117.55934773679448,118.14714447547844,112.57749237826623,113.14320842036807,113.14320842036807,1000000.0 -2022-08-04,QQQ,113.14320842036807,113.7089244624699,111.88793808169746,112.45018902683161,112.45018902683161,1000000.0 -2022-08-05,QQQ,112.45018902683161,113.01243997196576,111.36775012935485,111.92738706467824,111.92738706467824,1000000.0 -2022-08-08,QQQ,111.92738706467824,113.01300290803592,111.36775012935485,112.45074916222481,112.45074916222481,1000000.0 -2022-08-09,QQQ,112.45074916222481,115.40006287464631,111.88849541641369,114.8259332086033,114.8259332086033,1000000.0 -2022-08-10,QQQ,114.8259332086033,116.57698591122077,114.25180354256028,115.99700090668735,115.99700090668735,1000000.0 -2022-08-11,QQQ,115.99700090668735,116.57698591122077,112.06891170995611,112.63207207030766,112.63207207030766,1000000.0 -2022-08-12,QQQ,112.63207207030766,113.70470208803093,112.06891170995611,113.1390070527671,113.1390070527671,1000000.0 -2022-08-15,QQQ,113.1390070527671,113.92149563700232,112.57331201750327,113.35472202686799,113.35472202686799,1000000.0 -2022-08-16,QQQ,113.35472202686799,114.59203968332352,112.78794841673366,114.02193003315774,114.02193003315774,1000000.0 -2022-08-17,QQQ,114.02193003315774,116.69265946136383,113.45182038299195,116.11209896653119,116.11209896653119,1000000.0 -2022-08-18,QQQ,116.11209896653119,116.69265946136383,113.96170641530851,114.53437830684273,114.53437830684273,1000000.0 -2022-08-19,QQQ,114.53437830684273,116.82796622027138,113.96170641530851,116.24673255748397,116.24673255748397,1000000.0 -2022-08-22,QQQ,116.24673255748397,116.82796622027138,113.26182401421315,113.83097890875693,113.83097890875693,1000000.0 -2022-08-23,QQQ,113.83097890875693,114.53299417657998,113.26182401421315,113.96317828515421,113.96317828515421,1000000.0 -2022-08-24,QQQ,113.96317828515421,115.15864557334116,113.39336239372844,114.58571698839918,114.58571698839918,1000000.0 -2022-08-25,QQQ,114.58571698839918,118.68935567724374,114.0127884034572,118.09886137039179,118.09886137039179,1000000.0 -2022-08-26,QQQ,118.09886137039179,118.68935567724374,116.49859782881923,117.08401791841129,117.08401791841129,1000000.0 -2022-08-29,QQQ,117.08401791841129,117.66943800800334,114.01819931663195,114.59115509209241,114.59115509209241,1000000.0 -2022-08-30,QQQ,114.59115509209241,116.2555508624892,114.01819931663195,115.6771650373027,115.6771650373027,1000000.0 +2022-07-12,QQQ,113.24249042408792,115.8586643240994,112.67627797196748,115.2822530588054,115.2822530588054,1000000.0 +2022-07-13,QQQ,115.2822530588054,116.81831111951313,114.70584179351135,116.23712549205284,116.23712549205284,1000000.0 +2022-07-14,QQQ,116.23712549205284,116.81831111951313,114.79677801657114,115.3736462478102,115.3736462478102,1000000.0 +2022-07-15,QQQ,115.3736462478102,116.11312180444304,114.79677801657114,115.53544458153536,115.53544458153536,1000000.0 +2022-07-18,QQQ,115.53544458153536,116.11312180444304,114.3693081420158,114.94402828343296,114.94402828343296,1000000.0 +2022-07-19,QQQ,114.94402828343296,116.50455648445684,114.3693081420158,115.9249318253302,115.9249318253302,1000000.0 +2022-07-20,QQQ,115.9249318253302,116.50455648445684,114.83646331840788,115.41353097327423,115.41353097327423,1000000.0 +2022-07-21,QQQ,115.41353097327423,115.9905986281406,113.75521846394729,114.32685272758522,114.32685272758522,1000000.0 +2022-07-22,QQQ,114.32685272758522,114.89848699122314,112.86280159780348,113.42995135457636,113.42995135457636,1000000.0 +2022-07-25,QQQ,113.42995135457636,113.9971011113492,112.27978519456173,112.84400522066504,112.84400522066504,1000000.0 +2022-07-26,QQQ,112.84400522066504,113.89257678973668,112.27978519456173,113.32594705446436,113.32594705446436,1000000.0 +2022-07-27,QQQ,113.32594705446436,113.89257678973668,111.47610193070376,112.03628334744096,112.03628334744096,1000000.0 +2022-07-28,QQQ,112.03628334744096,112.59646476417817,111.40235079487846,111.96216160289292,111.96216160289292,1000000.0 +2022-07-29,QQQ,111.96216160289292,113.22931133877177,111.40235079487846,112.6659814316137,112.6659814316137,1000000.0 +2022-08-01,QQQ,112.6659814316137,116.78091330170312,112.10265152445564,116.19991373303796,116.19991373303796,1000000.0 +2022-08-02,QQQ,116.19991373303796,118.14714447547844,115.61891416437275,117.55934773679448,117.55934773679448,1000000.0 +2022-08-03,QQQ,117.55934773679448,118.14714447547844,112.57749237826624,113.14320842036808,113.14320842036808,1000000.0 +2022-08-04,QQQ,113.14320842036808,113.7089244624699,111.88793808169746,112.4501890268316,112.4501890268316,1000000.0 +2022-08-05,QQQ,112.4501890268316,113.01243997196576,111.36775012935485,111.92738706467824,111.92738706467824,1000000.0 +2022-08-08,QQQ,111.92738706467824,113.01300290803592,111.36775012935485,112.4507491622248,112.4507491622248,1000000.0 +2022-08-09,QQQ,112.4507491622248,115.40006287464632,111.88849541641368,114.8259332086033,114.8259332086033,1000000.0 +2022-08-10,QQQ,114.8259332086033,116.57698591122076,114.25180354256028,115.99700090668736,115.99700090668736,1000000.0 +2022-08-11,QQQ,115.99700090668736,116.57698591122076,112.06891170995613,112.63207207030766,112.63207207030766,1000000.0 +2022-08-12,QQQ,112.63207207030766,113.70470208803091,112.06891170995613,113.1390070527671,113.1390070527671,1000000.0 +2022-08-15,QQQ,113.1390070527671,113.92149563700232,112.57331201750328,113.354722026868,113.354722026868,1000000.0 +2022-08-16,QQQ,113.354722026868,114.59203968332352,112.78794841673366,114.02193003315774,114.02193003315774,1000000.0 +2022-08-17,QQQ,114.02193003315774,116.69265946136385,113.45182038299195,116.1120989665312,116.1120989665312,1000000.0 +2022-08-18,QQQ,116.1120989665312,116.69265946136385,113.96170641530853,114.53437830684273,114.53437830684273,1000000.0 +2022-08-19,QQQ,114.53437830684273,116.82796622027138,113.96170641530853,116.24673255748397,116.24673255748397,1000000.0 +2022-08-22,QQQ,116.24673255748397,116.82796622027138,113.26182401421316,113.83097890875692,113.83097890875692,1000000.0 +2022-08-23,QQQ,113.83097890875692,114.53299417657998,113.26182401421316,113.9631782851542,113.9631782851542,1000000.0 +2022-08-24,QQQ,113.9631782851542,115.15864557334116,113.39336239372844,114.58571698839918,114.58571698839918,1000000.0 +2022-08-25,QQQ,114.58571698839918,118.68935567724374,114.0127884034572,118.0988613703918,118.0988613703918,1000000.0 +2022-08-26,QQQ,118.0988613703918,118.68935567724374,116.49859782881924,117.08401791841128,117.08401791841128,1000000.0 +2022-08-29,QQQ,117.08401791841128,117.66943800800334,114.01819931663196,114.5911550920924,114.5911550920924,1000000.0 +2022-08-30,QQQ,114.5911550920924,116.2555508624892,114.01819931663196,115.6771650373027,115.6771650373027,1000000.0 2022-08-31,QQQ,115.6771650373027,116.2555508624892,114.17070118506014,114.74442330156798,114.74442330156798,1000000.0 -2022-09-01,QQQ,114.74442330156798,117.74538325329755,114.17070118506014,117.15958532666424,117.15958532666424,1000000.0 -2022-09-02,QQQ,117.15958532666424,117.74538325329755,115.5634470211881,116.14416786049055,116.14416786049055,1000000.0 -2022-09-05,QQQ,116.14416786049055,116.724888699793,114.31012947215216,114.88455223331874,114.88455223331874,1000000.0 +2022-09-01,QQQ,114.74442330156798,117.74538325329756,114.17070118506014,117.15958532666424,117.15958532666424,1000000.0 +2022-09-02,QQQ,117.15958532666424,117.74538325329756,115.5634470211881,116.14416786049055,116.14416786049055,1000000.0 2022-09-06,QQQ,114.88455223331874,117.40080318820054,114.31012947215216,116.8167195902493,116.8167195902493,1000000.0 2022-09-07,QQQ,116.8167195902493,117.59004144610743,116.23263599229806,117.00501636428602,117.00501636428602,1000000.0 -2022-09-08,QQQ,117.00501636428602,119.18948540093379,116.4199912824646,118.5965028865013,118.5965028865013,1000000.0 -2022-09-09,QQQ,118.5965028865013,119.18948540093379,115.8877050073803,116.4700552837993,116.4700552837993,1000000.0 -2022-09-12,QQQ,116.4700552837993,119.65180494082965,115.8877050073803,119.05652232918375,119.05652232918375,1000000.0 -2022-09-13,QQQ,119.05652232918375,119.65180494082965,117.61658414569207,118.20762225697696,118.20762225697696,1000000.0 -2022-09-14,QQQ,118.20762225697696,120.13100745364743,117.61658414569207,119.53334074989795,119.53334074989795,1000000.0 -2022-09-15,QQQ,119.53334074989795,120.13100745364743,117.4506890522152,118.04089351981426,118.04089351981426,1000000.0 -2022-09-16,QQQ,118.04089351981426,118.63109798741333,115.16427870826155,115.74299367664477,115.74299367664477,1000000.0 -2022-09-19,QQQ,115.74299367664477,116.32170864502798,114.83782460092239,115.41489909640441,115.41489909640441,1000000.0 -2022-09-20,QQQ,115.41489909640441,116.31348521915456,114.83782460092239,115.73481116333788,115.73481116333788,1000000.0 -2022-09-21,QQQ,115.73481116333788,118.78147143747242,115.15613710752119,118.19051884325616,118.19051884325616,1000000.0 -2022-09-22,QQQ,118.19051884325616,119.38290927525287,117.59956624903988,118.78896445298794,118.78896445298794,1000000.0 +2022-09-08,QQQ,117.00501636428602,119.1894854009338,116.4199912824646,118.5965028865013,118.5965028865013,1000000.0 +2022-09-09,QQQ,118.5965028865013,119.1894854009338,115.8877050073803,116.4700552837993,116.4700552837993,1000000.0 +2022-09-12,QQQ,116.4700552837993,119.65180494082963,115.8877050073803,119.05652232918376,119.05652232918376,1000000.0 +2022-09-13,QQQ,119.05652232918376,119.65180494082963,117.61658414569207,118.20762225697696,118.20762225697696,1000000.0 +2022-09-14,QQQ,118.20762225697696,120.13100745364744,117.61658414569207,119.53334074989796,119.53334074989796,1000000.0 +2022-09-15,QQQ,119.53334074989796,120.13100745364744,117.4506890522152,118.04089351981426,118.04089351981426,1000000.0 +2022-09-16,QQQ,118.04089351981426,118.63109798741333,115.16427870826156,115.74299367664476,115.74299367664476,1000000.0 +2022-09-19,QQQ,115.74299367664476,116.32170864502798,114.8378246009224,115.4148990964044,115.4148990964044,1000000.0 +2022-09-20,QQQ,115.4148990964044,116.31348521915456,114.8378246009224,115.73481116333788,115.73481116333788,1000000.0 +2022-09-21,QQQ,115.73481116333788,118.78147143747242,115.1561371075212,118.19051884325616,118.19051884325616,1000000.0 +2022-09-22,QQQ,118.19051884325616,119.38290927525289,117.59956624903988,118.78896445298794,118.78896445298794,1000000.0 2022-09-23,QQQ,118.78896445298794,120.55881880316863,118.195019630723,119.95902368474492,119.95902368474492,1000000.0 -2022-09-26,QQQ,119.95902368474492,121.65676560490195,119.35922856632119,121.05150806457907,121.05150806457907,1000000.0 -2022-09-27,QQQ,121.05150806457907,126.73973999973133,120.44625052425617,126.10919402958342,126.10919402958342,1000000.0 -2022-09-28,QQQ,126.10919402958342,126.73973999973133,124.21323750037986,124.83742462349736,124.83742462349736,1000000.0 -2022-09-29,QQQ,124.83742462349736,127.5893460568639,124.21323750037986,126.95457319090937,126.95457319090937,1000000.0 -2022-09-30,QQQ,126.95457319090937,127.5893460568639,126.13798977463905,126.77184901973774,126.77184901973774,1000000.0 -2022-10-03,QQQ,126.77184901973774,127.90229950761507,126.13798977463905,127.26596965931849,127.26596965931849,1000000.0 -2022-10-04,QQQ,127.26596965931849,127.90229950761507,125.79565728528729,126.42779626662039,126.42779626662039,1000000.0 -2022-10-05,QQQ,126.42779626662039,129.0590696934081,125.79565728528729,128.41698476956032,128.41698476956032,1000000.0 -2022-10-06,QQQ,128.41698476956032,129.0590696934081,125.77026156431411,126.40227292895891,126.40227292895891,1000000.0 -2022-10-07,QQQ,126.40227292895891,127.03428429360369,123.68862360936126,124.3101744817701,124.3101744817701,1000000.0 -2022-10-10,QQQ,124.3101744817701,124.93172535417894,123.41659128408635,124.03677515988578,124.03677515988578,1000000.0 -2022-10-11,QQQ,124.03677515988578,124.65695903568519,121.63333051680195,122.24455328321804,122.24455328321804,1000000.0 -2022-10-12,QQQ,122.24455328321804,124.04615661102865,121.63333051680195,123.42901155326236,123.42901155326236,1000000.0 -2022-10-13,QQQ,123.42901155326236,125.60453584746632,122.81186649549605,124.97963765917048,124.97963765917048,1000000.0 -2022-10-14,QQQ,124.97963765917048,125.60453584746632,123.20496269313199,123.82408310867537,123.82408310867537,1000000.0 -2022-10-17,QQQ,123.82408310867537,125.7806205682594,123.20496269313199,125.15484633657653,125.15484633657653,1000000.0 -2022-10-18,QQQ,125.15484633657653,125.7806205682594,123.52364816175442,124.14437001181349,124.14437001181349,1000000.0 -2022-10-19,QQQ,124.14437001181349,124.76509186187255,123.51438268015158,124.1350579700016,124.1350579700016,1000000.0 -2022-10-20,QQQ,124.1350579700016,124.82909858773917,123.51438268015158,124.20805829625789,124.20805829625789,1000000.0 -2022-10-21,QQQ,124.20805829625789,127.97558255791546,123.5870180047766,127.33888811732884,127.33888811732884,1000000.0 +2022-09-26,QQQ,119.95902368474492,121.65676560490196,119.3592285663212,121.05150806457908,121.05150806457908,1000000.0 +2022-09-27,QQQ,121.05150806457908,126.73973999973131,120.44625052425616,126.10919402958342,126.10919402958342,1000000.0 +2022-09-28,QQQ,126.10919402958342,126.73973999973131,124.21323750037986,124.83742462349736,124.83742462349736,1000000.0 +2022-09-29,QQQ,124.83742462349736,127.5893460568639,124.21323750037986,126.95457319090936,126.95457319090936,1000000.0 +2022-09-30,QQQ,126.95457319090936,127.5893460568639,126.13798977463904,126.77184901973774,126.77184901973774,1000000.0 +2022-10-03,QQQ,126.77184901973774,127.90229950761508,126.13798977463904,127.26596965931849,127.26596965931849,1000000.0 +2022-10-04,QQQ,127.26596965931849,127.90229950761508,125.79565728528728,126.4277962666204,126.4277962666204,1000000.0 +2022-10-05,QQQ,126.4277962666204,129.0590696934081,125.79565728528728,128.41698476956032,128.41698476956032,1000000.0 +2022-10-06,QQQ,128.41698476956032,129.0590696934081,125.77026156431413,126.40227292895892,126.40227292895892,1000000.0 +2022-10-07,QQQ,126.40227292895892,127.03428429360368,123.68862360936126,124.3101744817701,124.3101744817701,1000000.0 +2022-10-10,QQQ,124.3101744817701,124.93172535417894,123.41659128408637,124.03677515988578,124.03677515988578,1000000.0 +2022-10-11,QQQ,124.03677515988578,124.6569590356852,121.63333051680196,122.24455328321804,122.24455328321804,1000000.0 +2022-10-12,QQQ,122.24455328321804,124.04615661102864,121.63333051680196,123.42901155326236,123.42901155326236,1000000.0 +2022-10-13,QQQ,123.42901155326236,125.60453584746632,122.81186649549603,124.97963765917048,124.97963765917048,1000000.0 +2022-10-14,QQQ,124.97963765917048,125.60453584746632,123.204962693132,123.82408310867535,123.82408310867535,1000000.0 +2022-10-17,QQQ,123.82408310867535,125.7806205682594,123.204962693132,125.15484633657653,125.15484633657653,1000000.0 +2022-10-18,QQQ,125.15484633657653,125.7806205682594,123.52364816175442,124.14437001181348,124.14437001181348,1000000.0 +2022-10-19,QQQ,124.14437001181348,124.76509186187256,123.51438268015158,124.1350579700016,124.1350579700016,1000000.0 +2022-10-20,QQQ,124.1350579700016,124.82909858773915,123.51438268015158,124.20805829625787,124.20805829625787,1000000.0 +2022-10-21,QQQ,124.20805829625787,127.97558255791546,123.5870180047766,127.33888811732884,127.33888811732884,1000000.0 2022-10-24,QQQ,127.33888811732884,128.0388744995268,126.7021936767422,127.4018651736585,127.4018651736585,1000000.0 -2022-10-25,QQQ,127.4018651736585,128.0388744995268,125.33351951340087,125.96333619437274,125.96333619437274,1000000.0 -2022-10-26,QQQ,125.96333619437274,126.5931528753446,123.90934730355731,124.5320073402586,124.5320073402586,1000000.0 -2022-10-27,QQQ,124.5320073402586,127.18596053992061,123.90934730355731,126.5531945670852,126.5531945670852,1000000.0 -2022-10-28,QQQ,126.5531945670852,127.18596053992061,123.27936298625357,123.89885727261665,123.89885727261665,1000000.0 -2022-10-31,QQQ,123.89885727261665,124.51835155897972,122.58931370642816,123.2053404084705,123.2053404084705,1000000.0 -2022-11-01,QQQ,123.2053404084705,123.82136711051284,122.04588557474413,122.6591814821549,122.6591814821549,1000000.0 -2022-11-02,QQQ,122.6591814821549,123.27247738956567,118.27831094051734,118.87267431207773,118.87267431207773,1000000.0 -2022-11-03,QQQ,118.87267431207773,119.46703768363811,117.61775858558909,118.208802598582,118.208802598582,1000000.0 -2022-11-04,QQQ,118.208802598582,120.41789533013659,117.61775858558909,119.81880132351901,119.81880132351901,1000000.0 -2022-11-07,QQQ,119.81880132351901,120.83939441627999,119.21970731690142,120.23820339928358,120.23820339928358,1000000.0 -2022-11-08,QQQ,120.23820339928358,120.83939441627999,118.30337710108073,118.89786643324696,118.89786643324696,1000000.0 -2022-11-09,QQQ,118.89786643324696,120.62974225888513,118.30337710108073,120.0295942874479,120.0295942874479,1000000.0 -2022-11-10,QQQ,120.0295942874479,121.50324235024243,119.42944631601067,120.89874860720641,120.89874860720641,1000000.0 -2022-11-11,QQQ,120.89874860720641,124.80586411434328,120.29425486417038,124.18493941725701,124.18493941725701,1000000.0 -2022-11-14,QQQ,124.18493941725701,124.80586411434328,122.43851302199157,123.05378193164982,123.05378193164982,1000000.0 -2022-11-15,QQQ,123.05378193164982,123.66905084130806,121.88227346569417,122.49474720170268,122.49474720170268,1000000.0 -2022-11-16,QQQ,122.49474720170268,123.23458580216109,121.88227346569417,122.62147841011056,122.62147841011056,1000000.0 -2022-11-17,QQQ,122.62147841011056,123.23458580216109,121.84950567097088,122.46181474469435,122.46181474469435,1000000.0 -2022-11-18,QQQ,122.46181474469435,123.27402237009512,121.84950567097088,122.66071877621407,122.66071877621407,1000000.0 -2022-11-21,QQQ,122.66071877621407,123.27402237009512,121.93802410281849,122.5507779927824,122.5507779927824,1000000.0 -2022-11-22,QQQ,122.5507779927824,123.1635318827463,120.90189245060603,121.50943964885028,121.50943964885028,1000000.0 -2022-11-23,QQQ,121.50943964885028,122.11698684709451,118.46553391647197,119.060838107007,119.060838107007,1000000.0 -2022-11-24,QQQ,119.060838107007,119.65614229754202,117.66359646565544,118.25487081975422,118.25487081975422,1000000.0 -2022-11-25,QQQ,118.25487081975422,118.84614517385297,115.6104425350244,116.19139953268784,116.19139953268784,1000000.0 -2022-11-28,QQQ,116.19139953268784,116.77235653035126,114.09712746704369,114.67047986637557,114.67047986637557,1000000.0 -2022-11-29,QQQ,114.67047986637557,115.24383226570744,113.34355612652861,113.91312173520464,113.91312173520464,1000000.0 -2022-11-30,QQQ,113.91312173520464,115.29103623519391,113.34355612652861,114.71744899024272,114.71744899024272,1000000.0 +2022-10-25,QQQ,127.4018651736585,128.0388744995268,125.33351951340089,125.96333619437274,125.96333619437274,1000000.0 +2022-10-26,QQQ,125.96333619437274,126.5931528753446,123.90934730355733,124.5320073402586,124.5320073402586,1000000.0 +2022-10-27,QQQ,124.5320073402586,127.1859605399206,123.90934730355733,126.5531945670852,126.5531945670852,1000000.0 +2022-10-28,QQQ,126.5531945670852,127.1859605399206,123.27936298625356,123.89885727261664,123.89885727261664,1000000.0 +2022-10-31,QQQ,123.89885727261664,124.51835155897972,122.58931370642816,123.2053404084705,123.2053404084705,1000000.0 +2022-11-01,QQQ,123.2053404084705,123.82136711051284,122.04588557474412,122.6591814821549,122.6591814821549,1000000.0 +2022-11-02,QQQ,122.6591814821549,123.27247738956568,118.27831094051734,118.87267431207772,118.87267431207772,1000000.0 +2022-11-03,QQQ,118.87267431207772,119.46703768363813,117.61775858558909,118.208802598582,118.208802598582,1000000.0 +2022-11-04,QQQ,118.208802598582,120.4178953301366,117.61775858558909,119.818801323519,119.818801323519,1000000.0 +2022-11-07,QQQ,119.818801323519,120.83939441628,119.21970731690142,120.23820339928358,120.23820339928358,1000000.0 +2022-11-08,QQQ,120.23820339928358,120.83939441628,118.30337710108071,118.89786643324696,118.89786643324696,1000000.0 +2022-11-09,QQQ,118.89786643324696,120.62974225888512,118.30337710108071,120.0295942874479,120.0295942874479,1000000.0 +2022-11-10,QQQ,120.0295942874479,121.50324235024245,119.42944631601068,120.8987486072064,120.8987486072064,1000000.0 +2022-11-11,QQQ,120.8987486072064,124.80586411434328,120.29425486417038,124.184939417257,124.184939417257,1000000.0 +2022-11-14,QQQ,124.184939417257,124.80586411434328,122.43851302199155,123.05378193164982,123.05378193164982,1000000.0 +2022-11-15,QQQ,123.05378193164982,123.66905084130806,121.88227346569415,122.49474720170268,122.49474720170268,1000000.0 +2022-11-16,QQQ,122.49474720170268,123.23458580216108,121.88227346569415,122.62147841011056,122.62147841011056,1000000.0 +2022-11-17,QQQ,122.62147841011056,123.23458580216108,121.84950567097088,122.46181474469437,122.46181474469437,1000000.0 +2022-11-18,QQQ,122.46181474469437,123.27402237009512,121.84950567097088,122.66071877621408,122.66071877621408,1000000.0 +2022-11-21,QQQ,122.66071877621408,123.27402237009512,121.93802410281847,122.5507779927824,122.5507779927824,1000000.0 +2022-11-22,QQQ,122.5507779927824,123.1635318827463,120.90189245060604,121.50943964885028,121.50943964885028,1000000.0 +2022-11-23,QQQ,121.50943964885028,122.11698684709452,118.46553391647195,119.060838107007,119.060838107007,1000000.0 +2022-11-25,QQQ,118.25487081975422,118.84614517385296,115.6104425350244,116.19139953268784,116.19139953268784,1000000.0 +2022-11-28,QQQ,116.19139953268784,116.77235653035126,114.09712746704368,114.67047986637556,114.67047986637556,1000000.0 +2022-11-29,QQQ,114.67047986637556,115.24383226570744,113.3435561265286,113.91312173520464,113.91312173520464,1000000.0 +2022-11-30,QQQ,113.91312173520464,115.29103623519391,113.3435561265286,114.71744899024272,114.71744899024272,1000000.0 2022-12-01,QQQ,114.71744899024272,116.62695791386494,114.1438617452915,116.04672429240294,116.04672429240294,1000000.0 -2022-12-02,QQQ,116.04672429240294,116.62695791386494,113.96628783145762,114.53898274518353,114.53898274518353,1000000.0 -2022-12-05,QQQ,114.53898274518353,115.61536687231145,113.96628783145762,115.04016604210096,115.04016604210096,1000000.0 -2022-12-06,QQQ,115.04016604210096,116.87023636148231,114.46496521189046,116.2887923994849,116.2887923994849,1000000.0 -2022-12-07,QQQ,116.2887923994849,116.87023636148231,115.67843746008187,116.25973614078579,116.25973614078579,1000000.0 -2022-12-08,QQQ,116.25973614078579,116.8410348214897,113.27249337940533,113.84170188884958,113.84170188884958,1000000.0 -2022-12-09,QQQ,113.84170188884958,114.41091039829382,110.17459022241329,110.72823137930983,110.72823137930983,1000000.0 -2022-12-12,QQQ,110.72823137930983,112.4675793144398,110.17459022241329,111.9080391188456,111.9080391188456,1000000.0 -2022-12-13,QQQ,111.9080391188456,113.84437974852648,111.34849892325137,113.27798979952884,113.27798979952884,1000000.0 -2022-12-14,QQQ,113.27798979952884,114.28298635669857,112.7115998505312,113.71441428527223,113.71441428527223,1000000.0 -2022-12-15,QQQ,113.71441428527223,114.28298635669857,111.63242787008751,112.19339484430905,112.19339484430905,1000000.0 -2022-12-16,QQQ,112.19339484430905,114.92960217252067,111.63242787008751,114.35781310698576,114.35781310698576,1000000.0 -2022-12-19,QQQ,114.35781310698576,116.7822989390114,113.78602404145083,116.20129247662827,116.20129247662827,1000000.0 -2022-12-20,QQQ,116.20129247662827,116.7822989390114,115.57458458191002,116.15536138885429,116.15536138885429,1000000.0 -2022-12-21,QQQ,116.15536138885429,116.73613819579855,115.26214230256807,115.8413490478071,115.8413490478071,1000000.0 -2022-12-22,QQQ,115.8413490478071,116.42055579304612,112.38646089374173,112.9512169786349,112.9512169786349,1000000.0 -2022-12-23,QQQ,112.9512169786349,114.65069889685272,112.38646089374173,114.0802974098037,114.0802974098037,1000000.0 -2022-12-26,QQQ,114.0802974098037,114.65069889685272,111.17245520482909,111.73111075862221,111.73111075862221,1000000.0 -2022-12-27,QQQ,111.73111075862221,113.30683631899205,111.17245520482909,112.74312071541499,112.74312071541499,1000000.0 -2022-12-28,QQQ,112.74312071541499,114.40516389721869,112.17940511183791,113.83598397733203,113.83598397733203,1000000.0 -2022-12-29,QQQ,113.83598397733203,114.56348425334697,113.26680405744537,113.99351666999699,113.99351666999699,1000000.0 -2022-12-30,QQQ,113.99351666999699,116.95943335092664,113.423549086647,116.37754562281259,116.37754562281259,1000000.0 -2023-01-02,QQQ,116.37754562281259,116.95943335092664,115.3302241289262,115.90977299389569,115.90977299389569,1000000.0 -2023-01-03,QQQ,115.90977299389569,117.40523474852235,115.3302241289262,116.82112910300732,116.82112910300732,1000000.0 -2023-01-04,QQQ,116.82112910300732,118.64771422861173,116.23702345749228,118.05742709314602,118.05742709314602,1000000.0 -2023-01-05,QQQ,118.05742709314602,119.05205642358534,117.46713995768029,118.45975763540831,118.45975763540831,1000000.0 -2023-01-06,QQQ,118.45975763540831,119.05205642358534,117.24764290868782,117.83682704390736,117.83682704390736,1000000.0 +2022-12-02,QQQ,116.04672429240294,116.62695791386494,113.96628783145762,114.53898274518352,114.53898274518352,1000000.0 +2022-12-05,QQQ,114.53898274518352,115.61536687231144,113.96628783145762,115.04016604210096,115.04016604210096,1000000.0 +2022-12-06,QQQ,115.04016604210096,116.87023636148233,114.46496521189046,116.2887923994849,116.2887923994849,1000000.0 +2022-12-07,QQQ,116.2887923994849,116.87023636148233,115.67843746008188,116.2597361407858,116.2597361407858,1000000.0 +2022-12-08,QQQ,116.2597361407858,116.8410348214897,113.27249337940532,113.84170188884958,113.84170188884958,1000000.0 +2022-12-09,QQQ,113.84170188884958,114.41091039829382,110.17459022241329,110.72823137930985,110.72823137930985,1000000.0 +2022-12-12,QQQ,110.72823137930985,112.4675793144398,110.17459022241329,111.9080391188456,111.9080391188456,1000000.0 +2022-12-13,QQQ,111.9080391188456,113.84437974852648,111.34849892325136,113.27798979952884,113.27798979952884,1000000.0 +2022-12-14,QQQ,113.27798979952884,114.28298635669856,112.7115998505312,113.71441428527224,113.71441428527224,1000000.0 +2022-12-15,QQQ,113.71441428527224,114.28298635669856,111.63242787008753,112.19339484430904,112.19339484430904,1000000.0 +2022-12-16,QQQ,112.19339484430904,114.92960217252067,111.63242787008753,114.35781310698576,114.35781310698576,1000000.0 +2022-12-19,QQQ,114.35781310698576,116.7822989390114,113.78602404145084,116.20129247662828,116.20129247662828,1000000.0 +2022-12-20,QQQ,116.20129247662828,116.7822989390114,115.57458458191002,116.15536138885427,116.15536138885427,1000000.0 +2022-12-21,QQQ,116.15536138885427,116.73613819579856,115.26214230256808,115.8413490478071,115.8413490478071,1000000.0 +2022-12-22,QQQ,115.8413490478071,116.42055579304612,112.38646089374171,112.9512169786349,112.9512169786349,1000000.0 +2022-12-23,QQQ,112.9512169786349,114.65069889685272,112.38646089374171,114.0802974098037,114.0802974098037,1000000.0 +2022-12-27,QQQ,111.7311107586222,113.30683631899204,111.17245520482908,112.743120715415,112.743120715415,1000000.0 +2022-12-28,QQQ,112.743120715415,114.40516389721869,112.17940511183792,113.83598397733203,113.83598397733203,1000000.0 +2022-12-29,QQQ,113.83598397733203,114.56348425334696,113.26680405744536,113.993516669997,113.993516669997,1000000.0 +2022-12-30,QQQ,113.993516669997,116.95943335092664,113.423549086647,116.3775456228126,116.3775456228126,1000000.0 +2023-01-03,QQQ,115.90977299389569,117.40523474852236,115.3302241289262,116.82112910300732,116.82112910300732,1000000.0 +2023-01-04,QQQ,116.82112910300732,118.64771422861172,116.23702345749228,118.05742709314602,118.05742709314602,1000000.0 +2023-01-05,QQQ,118.05742709314602,119.05205642358534,117.46713995768027,118.45975763540832,118.45975763540832,1000000.0 +2023-01-06,QQQ,118.45975763540832,119.05205642358534,117.24764290868782,117.83682704390736,117.83682704390736,1000000.0 2023-01-09,QQQ,117.83682704390736,120.35600837324844,117.24764290868782,119.75722226193876,119.75722226193876,1000000.0 -2023-01-10,QQQ,119.75722226193876,121.04014422629753,119.15843615062907,120.4379544540274,120.4379544540274,1000000.0 -2023-01-11,QQQ,120.4379544540274,121.04014422629753,119.55672751901582,120.15751509448826,120.15751509448826,1000000.0 +2023-01-10,QQQ,119.75722226193876,121.04014422629751,119.15843615062909,120.4379544540274,120.4379544540274,1000000.0 +2023-01-11,QQQ,120.4379544540274,121.04014422629751,119.55672751901582,120.15751509448826,120.15751509448826,1000000.0 2023-01-12,QQQ,120.15751509448826,120.75830266996068,119.31983181031173,119.91942895508716,119.91942895508716,1000000.0 -2023-01-13,QQQ,119.91942895508716,120.74432746426703,119.31983181031173,120.14360941718114,120.14360941718114,1000000.0 -2023-01-16,QQQ,120.14360941718114,124.40007778826636,119.54289137009523,123.78117192862325,123.78117192862325,1000000.0 -2023-01-17,QQQ,123.78117192862325,124.59388744952768,123.16226606898013,123.97401736271412,123.97401736271412,1000000.0 -2023-01-18,QQQ,123.97401736271412,124.59388744952768,122.99283115464773,123.61088558256054,123.61088558256054,1000000.0 -2023-01-19,QQQ,123.61088558256054,124.22894001047334,121.32340819306981,121.93307356087418,121.93307356087418,1000000.0 -2023-01-20,QQQ,121.93307356087418,122.54273892867853,119.4358048513763,120.03598477525257,120.03598477525257,1000000.0 -2023-01-23,QQQ,120.03598477525257,120.63616469912883,116.54580406497175,117.13146137183091,117.13146137183091,1000000.0 -2023-01-24,QQQ,117.13146137183091,118.09691259057892,116.54580406497175,117.50936576177008,117.50936576177008,1000000.0 -2023-01-25,QQQ,117.50936576177008,118.16066070045753,116.92181893296123,117.57279671687317,117.57279671687317,1000000.0 -2023-01-26,QQQ,117.57279671687317,118.16066070045753,115.45031766623327,116.03047001631485,116.03047001631485,1000000.0 -2023-01-27,QQQ,116.03047001631485,116.61062236639641,113.2584017505459,113.82753944778483,113.82753944778483,1000000.0 -2023-01-30,QQQ,113.82753944778483,114.39667714502374,112.79639789755366,113.36321396739062,113.36321396739062,1000000.0 -2023-01-31,QQQ,113.36321396739062,113.93003003722757,112.0470058780028,112.6100561587968,112.6100561587968,1000000.0 -2023-02-01,QQQ,112.6100561587968,113.17310643959077,110.13351123753948,110.68694596737636,110.68694596737636,1000000.0 +2023-01-13,QQQ,119.91942895508716,120.74432746426704,119.31983181031173,120.14360941718114,120.14360941718114,1000000.0 +2023-01-17,QQQ,123.78117192862324,124.59388744952768,123.16226606898012,123.97401736271412,123.97401736271412,1000000.0 +2023-01-18,QQQ,123.97401736271412,124.59388744952768,122.99283115464772,123.61088558256054,123.61088558256054,1000000.0 +2023-01-19,QQQ,123.61088558256054,124.22894001047334,121.3234081930698,121.93307356087418,121.93307356087418,1000000.0 +2023-01-20,QQQ,121.93307356087418,122.54273892867852,119.4358048513763,120.03598477525256,120.03598477525256,1000000.0 +2023-01-23,QQQ,120.03598477525256,120.63616469912884,116.54580406497176,117.13146137183092,117.13146137183092,1000000.0 +2023-01-24,QQQ,117.13146137183092,118.09691259057892,116.54580406497176,117.50936576177008,117.50936576177008,1000000.0 +2023-01-25,QQQ,117.50936576177008,118.16066070045753,116.92181893296124,117.57279671687316,117.57279671687316,1000000.0 +2023-01-26,QQQ,117.57279671687316,118.16066070045753,115.45031766623327,116.03047001631484,116.03047001631484,1000000.0 +2023-01-27,QQQ,116.03047001631484,116.6106223663964,113.2584017505459,113.82753944778484,113.82753944778484,1000000.0 +2023-01-30,QQQ,113.82753944778484,114.39667714502374,112.79639789755366,113.36321396739062,113.36321396739062,1000000.0 +2023-01-31,QQQ,113.36321396739062,113.93003003722755,112.0470058780028,112.6100561587968,112.6100561587968,1000000.0 +2023-02-01,QQQ,112.6100561587968,113.17310643959075,110.13351123753948,110.68694596737636,110.68694596737636,1000000.0 2023-02-02,QQQ,110.68694596737636,111.69320920610834,110.13351123753948,111.13752159811776,111.13752159811776,1000000.0 -2023-02-03,QQQ,111.13752159811776,111.91575197949186,110.58183399012717,111.35895719352425,111.35895719352425,1000000.0 -2023-02-06,QQQ,111.35895719352425,111.91575197949186,110.74378993993429,111.30029139691888,111.30029139691888,1000000.0 -2023-02-07,QQQ,111.30029139691888,112.99900020281194,110.74378993993429,112.43681612220095,112.43681612220095,1000000.0 -2023-02-08,QQQ,112.43681612220095,112.99900020281194,110.94314886291707,111.50065212353475,111.50065212353475,1000000.0 -2023-02-09,QQQ,111.50065212353475,114.0706410964558,110.94314886291707,113.50312546911026,113.50312546911026,1000000.0 -2023-02-10,QQQ,113.50312546911026,114.0706410964558,111.46875677065849,112.0289012770437,112.0289012770437,1000000.0 -2023-02-13,QQQ,112.0289012770437,113.35754315213957,111.46875677065849,112.79357527576077,112.79357527576077,1000000.0 -2023-02-14,QQQ,112.79357527576077,114.31044987185604,112.22960739938196,113.74174116602592,113.74174116602592,1000000.0 -2023-02-15,QQQ,113.74174116602592,114.31044987185604,112.80279391965338,113.36964213030491,113.36964213030491,1000000.0 -2023-02-16,QQQ,113.36964213030491,114.47017704639285,112.80279391965338,113.90067367800285,113.90067367800285,1000000.0 -2023-02-17,QQQ,113.90067367800285,114.47017704639285,111.46618294576774,112.02631451835954,112.02631451835954,1000000.0 -2023-02-20,QQQ,112.02631451835954,112.58644609095133,109.31961694718237,109.86896175596218,109.86896175596218,1000000.0 -2023-02-21,QQQ,109.86896175596218,110.41830656474197,109.04425889829648,109.59221999828792,109.59221999828792,1000000.0 -2023-02-22,QQQ,109.59221999828792,110.14018109827934,108.0242594998378,108.56709497471135,108.56709497471135,1000000.0 -2023-02-23,QQQ,108.56709497471135,109.96256237253115,108.0242594998378,109.41548494779221,109.41548494779221,1000000.0 -2023-02-24,QQQ,109.41548494779221,110.0157148562126,108.86840752305325,109.46837299125633,109.46837299125633,1000000.0 -2023-02-27,QQQ,109.46837299125633,110.91174073822557,108.92103112630005,110.35994103306028,110.35994103306028,1000000.0 -2023-02-28,QQQ,110.35994103306028,111.38262916302808,109.80814132789497,110.8284867293812,110.8284867293812,1000000.0 -2023-03-01,QQQ,110.8284867293812,111.68825376626495,110.2743442957343,111.13259081220394,111.13259081220394,1000000.0 +2023-02-03,QQQ,111.13752159811776,111.91575197949186,110.58183399012717,111.35895719352423,111.35895719352423,1000000.0 +2023-02-06,QQQ,111.35895719352423,111.91575197949186,110.74378993993427,111.30029139691888,111.30029139691888,1000000.0 +2023-02-07,QQQ,111.30029139691888,112.99900020281194,110.74378993993427,112.43681612220097,112.43681612220097,1000000.0 +2023-02-08,QQQ,112.43681612220097,112.99900020281194,110.94314886291708,111.50065212353476,111.50065212353476,1000000.0 +2023-02-09,QQQ,111.50065212353476,114.0706410964558,110.94314886291708,113.50312546911026,113.50312546911026,1000000.0 +2023-02-10,QQQ,113.50312546911026,114.0706410964558,111.46875677065847,112.0289012770437,112.0289012770437,1000000.0 +2023-02-13,QQQ,112.0289012770437,113.35754315213956,111.46875677065847,112.79357527576076,112.79357527576076,1000000.0 +2023-02-14,QQQ,112.79357527576076,114.31044987185604,112.22960739938196,113.74174116602592,113.74174116602592,1000000.0 +2023-02-15,QQQ,113.74174116602592,114.31044987185604,112.80279391965338,113.36964213030492,113.36964213030492,1000000.0 +2023-02-16,QQQ,113.36964213030492,114.47017704639283,112.80279391965338,113.90067367800285,113.90067367800285,1000000.0 +2023-02-17,QQQ,113.90067367800285,114.47017704639283,111.46618294576774,112.02631451835954,112.02631451835954,1000000.0 +2023-02-21,QQQ,109.86896175596218,110.41830656474195,109.04425889829648,109.59221999828792,109.59221999828792,1000000.0 +2023-02-22,QQQ,109.59221999828792,110.14018109827934,108.0242594998378,108.56709497471137,108.56709497471137,1000000.0 +2023-02-23,QQQ,108.56709497471137,109.96256237253117,108.0242594998378,109.4154849477922,109.4154849477922,1000000.0 +2023-02-24,QQQ,109.4154849477922,110.0157148562126,108.86840752305324,109.46837299125632,109.46837299125632,1000000.0 +2023-02-27,QQQ,109.46837299125632,110.91174073822556,108.92103112630004,110.35994103306028,110.35994103306028,1000000.0 +2023-02-28,QQQ,110.35994103306028,111.38262916302808,109.80814132789496,110.8284867293812,110.8284867293812,1000000.0 +2023-03-01,QQQ,110.8284867293812,111.68825376626496,110.2743442957343,111.13259081220394,111.13259081220394,1000000.0 2023-03-02,QQQ,111.13259081220394,111.8334796908999,110.57692785814292,111.27709421980092,111.27709421980092,1000000.0 -2023-03-03,QQQ,111.27709421980092,111.8334796908999,110.1950192366692,110.74876305192885,110.74876305192885,1000000.0 -2023-03-06,QQQ,110.74876305192885,111.30250686718848,109.54073628925553,110.09119225050807,110.09119225050807,1000000.0 -2023-03-07,QQQ,110.09119225050807,113.61767067235222,109.54073628925553,113.0524086292062,113.0524086292062,1000000.0 +2023-03-03,QQQ,111.27709421980092,111.8334796908999,110.1950192366692,110.74876305192883,110.74876305192883,1000000.0 +2023-03-06,QQQ,110.74876305192883,111.30250686718848,109.54073628925552,110.09119225050809,110.09119225050809,1000000.0 +2023-03-07,QQQ,110.09119225050809,113.61767067235222,109.54073628925552,113.0524086292062,113.0524086292062,1000000.0 2023-03-08,QQQ,113.0524086292062,113.61767067235222,109.95016477388734,110.50267816471089,110.50267816471089,1000000.0 2023-03-09,QQQ,110.50267816471089,111.05519155553444,109.69240352216272,110.2436216303143,110.2436216303143,1000000.0 2023-03-10,QQQ,110.2436216303143,112.2590327658495,109.69240352216272,111.70053011527314,111.70053011527314,1000000.0 -2023-03-13,QQQ,111.70053011527314,112.88128742446176,111.14202746469677,112.31968897956395,112.31968897956395,1000000.0 -2023-03-14,QQQ,112.31968897956395,114.97641653600809,111.75809053466612,114.40439456319214,114.40439456319214,1000000.0 -2023-03-15,QQQ,114.40439456319214,115.80866968227427,113.83237259037618,115.23250714654158,115.23250714654158,1000000.0 -2023-03-16,QQQ,115.23250714654158,115.80866968227427,112.7964852784754,113.36330178741247,113.36330178741247,1000000.0 -2023-03-17,QQQ,113.36330178741247,113.93011829634952,112.32120774684931,112.88563592648173,112.88563592648173,1000000.0 +2023-03-13,QQQ,111.70053011527314,112.88128742446176,111.14202746469677,112.31968897956396,112.31968897956396,1000000.0 +2023-03-14,QQQ,112.31968897956396,114.97641653600807,111.75809053466612,114.40439456319214,114.40439456319214,1000000.0 +2023-03-15,QQQ,114.40439456319214,115.80866968227429,113.83237259037618,115.23250714654158,115.23250714654158,1000000.0 +2023-03-16,QQQ,115.23250714654158,115.80866968227429,112.7964852784754,113.36330178741248,113.36330178741248,1000000.0 +2023-03-17,QQQ,113.36330178741248,113.93011829634952,112.32120774684932,112.88563592648173,112.88563592648173,1000000.0 2023-03-20,QQQ,112.88563592648173,113.45006410611413,110.29505218878928,110.84929868220028,110.84929868220028,1000000.0 -2023-03-21,QQQ,110.84929868220028,112.84546806214357,110.29505218878928,112.28404782302843,112.28404782302843,1000000.0 -2023-03-22,QQQ,112.28404782302843,114.98822827667311,111.7226275839133,114.41614753897824,114.41614753897824,1000000.0 -2023-03-23,QQQ,114.41614753897824,114.98822827667311,113.19687413180449,113.76570264502963,113.76570264502963,1000000.0 -2023-03-24,QQQ,113.76570264502963,115.14765568905699,113.19687413180449,114.57478178015621,114.57478178015621,1000000.0 -2023-03-27,QQQ,114.57478178015621,115.14765568905699,113.39634415698997,113.96617503215072,113.96617503215072,1000000.0 -2023-03-28,QQQ,113.96617503215072,114.53600590731146,111.29556253978633,111.85483672340335,111.85483672340335,1000000.0 -2023-03-29,QQQ,111.85483672340335,112.41411090702036,110.65010206716664,111.20613273082074,111.20613273082074,1000000.0 +2023-03-21,QQQ,110.84929868220028,112.84546806214357,110.29505218878928,112.28404782302844,112.28404782302844,1000000.0 +2023-03-22,QQQ,112.28404782302844,114.98822827667313,111.7226275839133,114.41614753897824,114.41614753897824,1000000.0 +2023-03-23,QQQ,114.41614753897824,114.98822827667313,113.19687413180448,113.76570264502963,113.76570264502963,1000000.0 +2023-03-24,QQQ,113.76570264502963,115.147655689057,113.19687413180448,114.5747817801562,114.5747817801562,1000000.0 +2023-03-27,QQQ,114.5747817801562,115.147655689057,113.39634415698995,113.96617503215072,113.96617503215072,1000000.0 +2023-03-28,QQQ,113.96617503215072,114.53600590731146,111.29556253978632,111.85483672340337,111.85483672340337,1000000.0 +2023-03-29,QQQ,111.85483672340337,112.41411090702036,110.65010206716664,111.20613273082074,111.20613273082074,1000000.0 2023-03-30,QQQ,111.20613273082074,111.76216339447484,108.394716629091,108.93941369757889,108.93941369757889,1000000.0 2023-03-31,QQQ,108.93941369757889,109.48411076606676,107.59338825774282,108.1340585504953,108.1340585504953,1000000.0 -2023-04-03,QQQ,108.1340585504953,108.97001228548315,107.59338825774282,108.42787292087877,108.42787292087877,1000000.0 -2023-04-04,QQQ,108.42787292087877,109.99917321807546,107.88573355627437,109.45191364982634,109.45191364982634,1000000.0 +2023-04-03,QQQ,108.1340585504953,108.97001228548316,107.59338825774282,108.42787292087876,108.42787292087876,1000000.0 +2023-04-04,QQQ,108.42787292087876,109.99917321807546,107.88573355627436,109.45191364982634,109.45191364982634,1000000.0 2023-04-05,QQQ,109.45191364982634,111.86202729461397,108.9046540815772,111.3054997956358,111.3054997956358,1000000.0 -2023-04-06,QQQ,111.3054997956358,111.86202729461397,110.26398425092917,110.81807462404942,110.81807462404942,1000000.0 -2023-04-07,QQQ,110.81807462404942,111.70674289807582,110.26398425092917,111.1509879582844,111.1509879582844,1000000.0 -2023-04-10,QQQ,111.1509879582844,112.31210530793416,110.59523301849298,111.75333861485987,111.75333861485987,1000000.0 -2023-04-11,QQQ,111.75333861485987,113.11759682476765,111.19457192178557,112.5548227112116,112.5548227112116,1000000.0 -2023-04-12,QQQ,112.5548227112116,113.11759682476765,107.90770955947133,108.44995935625259,108.44995935625259,1000000.0 -2023-04-13,QQQ,108.44995935625259,108.99220915303384,106.81129490405645,107.34803507945372,107.34803507945372,1000000.0 -2023-04-14,QQQ,107.34803507945372,108.99586505393371,106.81129490405645,108.45359706859077,108.45359706859077,1000000.0 -2023-04-17,QQQ,108.45359706859077,108.99586505393371,106.33978201977901,106.8741527836975,106.8741527836975,1000000.0 -2023-04-18,QQQ,106.8741527836975,107.74126766007771,106.33978201977901,107.20524145281365,107.20524145281365,1000000.0 -2023-04-19,QQQ,107.20524145281365,108.90100594195272,106.66921524554958,108.35920989249028,108.35920989249028,1000000.0 +2023-04-06,QQQ,111.3054997956358,111.86202729461397,110.26398425092916,110.81807462404942,110.81807462404942,1000000.0 +2023-04-10,QQQ,111.1509879582844,112.31210530793416,110.59523301849298,111.75333861485989,111.75333861485989,1000000.0 +2023-04-11,QQQ,111.75333861485989,113.11759682476765,111.19457192178557,112.5548227112116,112.5548227112116,1000000.0 +2023-04-12,QQQ,112.5548227112116,113.11759682476765,107.90770955947131,108.4499593562526,108.4499593562526,1000000.0 +2023-04-13,QQQ,108.4499593562526,108.99220915303384,106.81129490405644,107.34803507945372,107.34803507945372,1000000.0 +2023-04-14,QQQ,107.34803507945372,108.99586505393373,106.81129490405644,108.45359706859077,108.45359706859077,1000000.0 +2023-04-17,QQQ,108.45359706859077,108.99586505393373,106.339782019779,106.8741527836975,106.8741527836975,1000000.0 +2023-04-18,QQQ,106.8741527836975,107.74126766007772,106.339782019779,107.20524145281364,107.20524145281364,1000000.0 +2023-04-19,QQQ,107.20524145281364,108.90100594195272,106.66921524554958,108.35920989249028,108.35920989249028,1000000.0 2023-04-20,QQQ,108.35920989249028,108.90100594195272,105.17642242754908,105.70494716336592,105.70494716336592,1000000.0 -2023-04-21,QQQ,105.70494716336592,106.23347189918273,104.89997275759475,105.42710829909021,105.42710829909021,1000000.0 -2023-04-24,QQQ,105.42710829909021,106.2641859283309,104.89997275759475,105.73550838639892,105.73550838639892,1000000.0 -2023-04-25,QQQ,105.73550838639892,106.2641859283309,105.20361257214745,105.73227394185673,105.73227394185673,1000000.0 -2023-04-26,QQQ,105.73227394185673,106.260935311566,103.11276605303769,103.63092065631929,103.63092065631929,1000000.0 -2023-04-27,QQQ,103.63092065631929,104.5994991888861,103.11276605303769,104.07910367053344,104.07910367053344,1000000.0 -2023-04-28,QQQ,104.07910367053344,104.5994991888861,102.78513587397539,103.30164409444762,103.30164409444762,1000000.0 -2023-05-01,QQQ,103.30164409444762,104.91176078943873,102.78513587397539,104.38981173078481,104.38981173078481,1000000.0 -2023-05-02,QQQ,104.38981173078481,105.47618998596025,103.86786267213088,104.951432821851,104.951432821851,1000000.0 +2023-04-21,QQQ,105.70494716336592,106.23347189918272,104.89997275759475,105.4271082990902,105.4271082990902,1000000.0 +2023-04-24,QQQ,105.4271082990902,106.2641859283309,104.89997275759475,105.73550838639892,105.73550838639892,1000000.0 +2023-04-25,QQQ,105.73550838639892,106.2641859283309,105.20361257214743,105.73227394185672,105.73227394185672,1000000.0 +2023-04-26,QQQ,105.73227394185672,106.260935311566,103.11276605303767,103.63092065631928,103.63092065631928,1000000.0 +2023-04-27,QQQ,103.63092065631928,104.5994991888861,103.11276605303767,104.07910367053344,104.07910367053344,1000000.0 +2023-04-28,QQQ,104.07910367053344,104.5994991888861,102.7851358739754,103.30164409444762,103.30164409444762,1000000.0 +2023-05-01,QQQ,103.30164409444762,104.91176078943872,102.7851358739754,104.3898117307848,104.3898117307848,1000000.0 +2023-05-02,QQQ,104.3898117307848,105.47618998596025,103.86786267213088,104.951432821851,104.951432821851,1000000.0 2023-05-03,QQQ,104.951432821851,105.47618998596025,103.65745352586426,104.17834525212488,104.17834525212488,1000000.0 -2023-05-04,QQQ,104.17834525212488,104.91159945499517,103.65745352586426,104.38965119900018,104.38965119900018,1000000.0 -2023-05-05,QQQ,104.38965119900018,104.91159945499517,102.91085742900958,103.42799741609004,103.42799741609004,1000000.0 -2023-05-08,QQQ,103.42799741609004,103.94513740317048,101.94806764578345,102.4603694932497,102.4603694932497,1000000.0 -2023-05-09,QQQ,102.4603694932497,102.97267134071593,99.84400097569183,100.34572962381088,100.34572962381088,1000000.0 -2023-05-10,QQQ,100.34572962381088,100.84745827192992,99.50133267680518,100.00133937367355,100.00133937367355,1000000.0 -2023-05-11,QQQ,100.00133937367355,101.52156956399574,99.50133267680518,101.01648712835399,101.01648712835399,1000000.0 -2023-05-12,QQQ,101.01648712835399,101.632049188293,100.51140469271222,101.12641710277911,101.12641710277911,1000000.0 -2023-05-15,QQQ,101.12641710277911,101.632049188293,100.54800574363749,101.05327210415828,101.05327210415828,1000000.0 -2023-05-16,QQQ,101.05327210415828,101.55853846467906,99.67964218579405,100.18054491034579,100.18054491034579,1000000.0 -2023-05-17,QQQ,100.18054491034579,100.68144763489751,98.94983969601661,99.44707507137348,99.44707507137348,1000000.0 -2023-05-18,QQQ,99.44707507137348,100.85937173775235,98.94983969601661,100.35758381865907,100.35758381865907,1000000.0 -2023-05-19,QQQ,100.35758381865907,100.85937173775235,97.87774145446645,98.36958940147382,98.36958940147382,1000000.0 +2023-05-04,QQQ,104.17834525212488,104.91159945499516,103.65745352586426,104.38965119900018,104.38965119900018,1000000.0 +2023-05-05,QQQ,104.38965119900018,104.91159945499516,102.91085742900958,103.42799741609004,103.42799741609004,1000000.0 +2023-05-08,QQQ,103.42799741609004,103.94513740317048,101.94806764578344,102.4603694932497,102.4603694932497,1000000.0 +2023-05-09,QQQ,102.4603694932497,102.97267134071592,99.84400097569184,100.34572962381088,100.34572962381088,1000000.0 +2023-05-10,QQQ,100.34572962381088,100.84745827192992,99.50133267680518,100.00133937367356,100.00133937367356,1000000.0 +2023-05-11,QQQ,100.00133937367356,101.52156956399574,99.50133267680518,101.016487128354,101.016487128354,1000000.0 +2023-05-12,QQQ,101.016487128354,101.632049188293,100.51140469271222,101.12641710277911,101.12641710277911,1000000.0 +2023-05-15,QQQ,101.12641710277911,101.632049188293,100.54800574363748,101.05327210415828,101.05327210415828,1000000.0 +2023-05-16,QQQ,101.05327210415828,101.55853846467906,99.67964218579404,100.1805449103458,100.1805449103458,1000000.0 +2023-05-17,QQQ,100.1805449103458,100.68144763489752,98.9498396960166,99.44707507137348,99.44707507137348,1000000.0 +2023-05-18,QQQ,99.44707507137348,100.85937173775235,98.9498396960166,100.35758381865908,100.35758381865908,1000000.0 +2023-05-19,QQQ,100.35758381865908,100.85937173775235,97.87774145446645,98.36958940147382,98.36958940147382,1000000.0 2023-05-22,QQQ,98.36958940147382,101.487215330951,97.87774145446645,100.98230381189155,100.98230381189155,1000000.0 -2023-05-23,QQQ,100.98230381189155,101.487215330951,100.33108799011026,100.83526431166861,100.83526431166861,1000000.0 -2023-05-24,QQQ,100.83526431166861,104.17502098862502,100.33108799011026,103.65673730211446,103.65673730211446,1000000.0 +2023-05-23,QQQ,100.98230381189155,101.487215330951,100.33108799011026,100.8352643116686,100.8352643116686,1000000.0 +2023-05-24,QQQ,100.8352643116686,104.17502098862502,100.33108799011026,103.65673730211446,103.65673730211446,1000000.0 2023-05-25,QQQ,103.65673730211446,105.70744643541542,103.1384536156039,105.1815387417069,105.1815387417069,1000000.0 -2023-05-26,QQQ,105.1815387417069,105.70744643541542,104.01625003796555,104.53894476177442,104.53894476177442,1000000.0 -2023-05-29,QQQ,104.53894476177442,105.99342466534436,104.01625003796555,105.46609419437252,105.46609419437252,1000000.0 -2023-05-30,QQQ,105.46609419437252,106.26848337444493,104.93876372340065,105.73978445218401,105.73978445218401,1000000.0 -2023-05-31,QQQ,105.73978445218401,106.82672563733355,105.2110855299231,106.29524939038166,106.29524939038166,1000000.0 -2023-06-01,QQQ,106.29524939038166,107.84914838632436,105.76377314342975,107.31258545902922,107.31258545902922,1000000.0 -2023-06-02,QQQ,107.31258545902922,107.84914838632436,105.93174822209073,106.46406856491531,106.46406856491531,1000000.0 -2023-06-05,QQQ,106.46406856491531,106.99638890773987,104.99449516369316,105.52210569215393,105.52210569215393,1000000.0 -2023-06-06,QQQ,105.52210569215393,106.04971622061468,104.33393984054742,104.85823099552505,104.85823099552505,1000000.0 -2023-06-07,QQQ,104.85823099552505,105.38252215050267,102.4046715802013,102.91926791980029,102.91926791980029,1000000.0 -2023-06-08,QQQ,102.91926791980029,103.43386425939929,101.96270125136313,102.47507663453581,102.47507663453581,1000000.0 -2023-06-09,QQQ,102.47507663453581,103.1988537669456,101.96270125136313,102.68542663377673,102.68542663377673,1000000.0 -2023-06-12,QQQ,102.68542663377673,103.43660982052366,102.17199950060784,102.92199982141659,102.92199982141659,1000000.0 -2023-06-13,QQQ,102.92199982141659,103.59584480907284,102.40738982230951,103.0804425960924,103.0804425960924,1000000.0 -2023-06-14,QQQ,103.0804425960924,103.59584480907284,101.93779445249332,102.45004467587269,102.45004467587269,1000000.0 -2023-06-15,QQQ,102.45004467587269,103.91661332526698,101.93779445249332,103.39961524902188,103.39961524902188,1000000.0 +2023-05-26,QQQ,105.1815387417069,105.70744643541542,104.01625003796556,104.53894476177442,104.53894476177442,1000000.0 +2023-05-30,QQQ,105.46609419437252,106.26848337444493,104.93876372340064,105.739784452184,105.739784452184,1000000.0 +2023-05-31,QQQ,105.739784452184,106.82672563733357,105.2110855299231,106.29524939038166,106.29524939038166,1000000.0 +2023-06-01,QQQ,106.29524939038166,107.84914838632436,105.76377314342976,107.31258545902922,107.31258545902922,1000000.0 +2023-06-02,QQQ,107.31258545902922,107.84914838632436,105.93174822209072,106.46406856491532,106.46406856491532,1000000.0 +2023-06-05,QQQ,106.46406856491532,106.99638890773988,104.99449516369316,105.52210569215391,105.52210569215391,1000000.0 +2023-06-06,QQQ,105.52210569215391,106.04971622061468,104.33393984054742,104.85823099552503,104.85823099552503,1000000.0 +2023-06-07,QQQ,104.85823099552503,105.38252215050268,102.4046715802013,102.91926791980028,102.91926791980028,1000000.0 +2023-06-08,QQQ,102.91926791980028,103.43386425939929,101.96270125136311,102.4750766345358,102.4750766345358,1000000.0 +2023-06-09,QQQ,102.4750766345358,103.1988537669456,101.96270125136311,102.68542663377671,102.68542663377671,1000000.0 +2023-06-12,QQQ,102.68542663377671,103.43660982052366,102.17199950060784,102.9219998214166,102.9219998214166,1000000.0 +2023-06-13,QQQ,102.9219998214166,103.59584480907284,102.40738982230953,103.0804425960924,103.0804425960924,1000000.0 +2023-06-14,QQQ,103.0804425960924,103.59584480907284,101.93779445249332,102.45004467587268,102.45004467587268,1000000.0 +2023-06-15,QQQ,102.45004467587268,103.91661332526698,101.93779445249332,103.39961524902188,103.39961524902188,1000000.0 2023-06-16,QQQ,103.39961524902188,103.91661332526698,100.07070853241576,100.5735764144882,100.5735764144882,1000000.0 -2023-06-19,QQQ,100.5735764144882,101.07644429656064,99.44895259407572,99.94869607444797,99.94869607444797,1000000.0 -2023-06-20,QQQ,99.94869607444797,100.4484395548202,98.91605177514174,99.41311736195149,99.41311736195149,1000000.0 -2023-06-21,QQQ,99.41311736195149,100.9674040370701,98.91605177514174,100.46507864385086,100.46507864385086,1000000.0 -2023-06-22,QQQ,100.46507864385086,101.7257721646251,99.96275325063161,101.21967379564687,101.21967379564687,1000000.0 -2023-06-23,QQQ,101.21967379564687,102.66860687478264,100.71357542666864,102.15781778585338,102.15781778585338,1000000.0 +2023-06-20,QQQ,99.94869607444797,100.4484395548202,98.91605177514174,99.41311736195148,99.41311736195148,1000000.0 +2023-06-21,QQQ,99.41311736195148,100.9674040370701,98.91605177514174,100.46507864385086,100.46507864385086,1000000.0 +2023-06-22,QQQ,100.46507864385086,101.7257721646251,99.9627532506316,101.21967379564688,101.21967379564688,1000000.0 +2023-06-23,QQQ,101.21967379564688,102.66860687478264,100.71357542666864,102.15781778585338,102.15781778585338,1000000.0 2023-06-26,QQQ,102.15781778585338,102.66860687478264,101.5426648044851,102.05292945174382,102.05292945174382,1000000.0 2023-06-27,QQQ,102.05292945174382,103.7073697589873,101.5426648044851,103.19141269550975,103.19141269550975,1000000.0 2023-06-28,QQQ,103.19141269550975,103.7073697589873,101.21531717005466,101.7239368543263,101.7239368543263,1000000.0 -2023-06-29,QQQ,101.7239368543263,102.23255653859792,101.17634553346909,101.68476938037094,101.68476938037094,1000000.0 -2023-06-30,QQQ,101.68476938037094,102.19319322727279,98.30326971047471,98.79725599042685,98.79725599042685,1000000.0 -2023-07-03,QQQ,98.79725599042685,101.38846849746491,98.30326971047471,100.884048256184,100.884048256184,1000000.0 -2023-07-04,QQQ,100.884048256184,103.36435145671823,100.37962801490308,102.85010095195844,102.85010095195844,1000000.0 -2023-07-05,QQQ,102.85010095195844,103.36435145671823,101.39951222592616,101.90905751349362,101.90905751349362,1000000.0 -2023-07-06,QQQ,101.90905751349362,102.41860280106108,100.14414632873573,100.64738324496054,100.64738324496054,1000000.0 -2023-07-07,QQQ,100.64738324496054,101.15062016118533,97.30922932126592,97.79822042338283,97.79822042338283,1000000.0 -2023-07-10,QQQ,97.79822042338283,98.28721152549973,95.2522754393911,95.7309300898403,95.7309300898403,1000000.0 +2023-06-29,QQQ,101.7239368543263,102.23255653859792,101.17634553346907,101.68476938037094,101.68476938037094,1000000.0 +2023-06-30,QQQ,101.68476938037094,102.1931932272728,98.30326971047472,98.79725599042683,98.79725599042683,1000000.0 +2023-07-03,QQQ,98.79725599042683,101.38846849746491,98.30326971047472,100.884048256184,100.884048256184,1000000.0 +2023-07-05,QQQ,102.85010095195844,103.36435145671824,101.39951222592616,101.90905751349362,101.90905751349362,1000000.0 +2023-07-06,QQQ,101.90905751349362,102.41860280106108,100.14414632873572,100.64738324496054,100.64738324496054,1000000.0 +2023-07-07,QQQ,100.64738324496054,101.15062016118532,97.30922932126592,97.79822042338284,97.79822042338284,1000000.0 +2023-07-10,QQQ,97.79822042338284,98.28721152549971,95.2522754393911,95.7309300898403,95.7309300898403,1000000.0 2023-07-11,QQQ,95.7309300898403,96.22178980026362,95.2522754393911,95.74307442812302,95.74307442812302,1000000.0 -2023-07-12,QQQ,95.74307442812302,96.22178980026362,94.9310343458876,95.40807471948501,95.40807471948501,1000000.0 -2023-07-13,QQQ,95.40807471948501,95.88511509308243,92.99148906456546,93.45878297946277,93.45878297946277,1000000.0 -2023-07-14,QQQ,93.45878297946277,93.92607689436007,92.25454462699513,92.7181353035127,92.7181353035127,1000000.0 -2023-07-17,QQQ,92.7181353035127,93.18172598003025,91.32733555893552,91.78626689340253,91.78626689340253,1000000.0 -2023-07-18,QQQ,91.78626689340253,92.67522990481231,91.32733555893552,92.21415910926599,92.21415910926599,1000000.0 -2023-07-19,QQQ,92.21415910926599,93.399451598608,91.75308831371966,92.93477771005772,92.93477771005772,1000000.0 +2023-07-12,QQQ,95.74307442812302,96.22178980026362,94.9310343458876,95.408074719485,95.408074719485,1000000.0 +2023-07-13,QQQ,95.408074719485,95.88511509308243,92.99148906456546,93.45878297946275,93.45878297946275,1000000.0 +2023-07-14,QQQ,93.45878297946275,93.92607689436008,92.25454462699513,92.7181353035127,92.7181353035127,1000000.0 +2023-07-17,QQQ,92.7181353035127,93.18172598003024,91.32733555893552,91.78626689340253,91.78626689340253,1000000.0 +2023-07-18,QQQ,91.78626689340253,92.67522990481233,91.32733555893552,92.214159109266,92.214159109266,1000000.0 +2023-07-19,QQQ,92.214159109266,93.399451598608,91.75308831371966,92.93477771005772,92.93477771005772,1000000.0 2023-07-20,QQQ,92.93477771005772,93.399451598608,92.07432038274466,92.53700540979362,92.53700540979362,1000000.0 -2023-07-21,QQQ,92.53700540979362,93.00606831929616,92.07432038274466,92.54335156148873,92.54335156148873,1000000.0 -2023-07-24,QQQ,92.54335156148873,93.00606831929616,91.94346557380833,92.40549303900335,92.40549303900335,1000000.0 -2023-07-25,QQQ,92.40549303900335,92.86752050419835,89.35315696688659,89.80216780591617,89.80216780591617,1000000.0 +2023-07-21,QQQ,92.53700540979362,93.00606831929616,92.07432038274466,92.54335156148872,92.54335156148872,1000000.0 +2023-07-24,QQQ,92.54335156148872,93.00606831929616,91.94346557380833,92.40549303900336,92.40549303900336,1000000.0 +2023-07-25,QQQ,92.40549303900336,92.86752050419835,89.35315696688659,89.80216780591617,89.80216780591617,1000000.0 2023-07-26,QQQ,89.80216780591617,90.33430951912104,89.35315696688659,89.88488509365278,89.88488509365278,1000000.0 2023-07-27,QQQ,89.88488509365278,90.33430951912104,89.23474444516617,89.68316024639816,89.68316024639816,1000000.0 2023-07-28,QQQ,89.68316024639816,90.8876005797256,89.23474444516617,90.43542346241354,90.43542346241354,1000000.0 @@ -1211,16 +1168,15 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-08-21,QQQ,85.58409078432628,87.00576521276665,85.15617033040465,86.57290070922055,86.57290070922055,1000000.0 2023-08-22,QQQ,86.57290070922055,89.39966554498073,86.14003620567445,88.95489108953308,88.95489108953308,1000000.0 2023-08-23,QQQ,88.95489108953308,89.39966554498073,88.28024226194341,88.72386156979238,88.72386156979238,1000000.0 -2023-08-24,QQQ,88.72386156979238,90.7815296254568,88.28024226194341,90.32988022433513,90.32988022433513,1000000.0 -2023-08-25,QQQ,90.32988022433513,92.01007625933647,89.87823082321346,91.55231468590695,91.55231468590695,1000000.0 -2023-08-28,QQQ,91.55231468590695,92.02290488356006,91.09455311247741,91.56507948612942,91.56507948612942,1000000.0 -2023-08-29,QQQ,91.56507948612942,92.4521857431574,91.10725408869877,91.99222462005713,91.99222462005713,1000000.0 -2023-08-30,QQQ,91.99222462005713,92.4521857431574,90.30695200942608,90.76075578836793,90.76075578836793,1000000.0 -2023-08-31,QQQ,90.76075578836793,91.21455956730976,89.47091042143734,89.92051298636919,89.92051298636919,1000000.0 +2023-08-24,QQQ,88.72386156979238,90.7815296254568,88.28024226194341,90.32988022433511,90.32988022433511,1000000.0 +2023-08-25,QQQ,90.32988022433511,92.01007625933649,89.87823082321346,91.55231468590696,91.55231468590696,1000000.0 +2023-08-28,QQQ,91.55231468590696,92.02290488356006,91.0945531124774,91.56507948612942,91.56507948612942,1000000.0 +2023-08-29,QQQ,91.56507948612942,92.4521857431574,91.10725408869877,91.99222462005712,91.99222462005712,1000000.0 +2023-08-30,QQQ,91.99222462005712,92.4521857431574,90.30695200942608,90.76075578836792,90.76075578836792,1000000.0 +2023-08-31,QQQ,90.76075578836792,91.21455956730976,89.47091042143734,89.92051298636919,89.92051298636919,1000000.0 2023-09-01,QQQ,89.92051298636919,90.580508601221,89.47091042143734,90.12985930469752,90.12985930469752,1000000.0 -2023-09-04,QQQ,90.12985930469752,90.580508601221,89.50961158832423,89.95940863148164,89.95940863148164,1000000.0 -2023-09-05,QQQ,89.95940863148164,91.83438771924477,89.50961158832423,91.37750021815401,91.37750021815401,1000000.0 -2023-09-06,QQQ,91.37750021815401,91.83438771924477,89.28313297299839,89.7317919326617,89.7317919326617,1000000.0 +2023-09-05,QQQ,89.95940863148164,91.83438771924476,89.50961158832423,91.377500218154,91.377500218154,1000000.0 +2023-09-06,QQQ,91.377500218154,91.83438771924476,89.28313297299839,89.7317919326617,89.7317919326617,1000000.0 2023-09-07,QQQ,89.7317919326617,90.180450892325,88.83018401364778,89.27656684788722,89.27656684788722,1000000.0 2023-09-08,QQQ,89.27656684788722,89.72294968212665,87.98480277784248,88.42693746516832,88.42693746516832,1000000.0 2023-09-11,QQQ,88.42693746516832,88.86907215249416,87.69982109239886,88.14052371095363,88.14052371095363,1000000.0 diff --git a/data/raw/SPY.csv b/data/raw/SPY.csv index 112deec..79fcdcb 100644 --- a/data/raw/SPY.csv +++ b/data/raw/SPY.csv @@ -1,242 +1,234 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume -2019-01-01,SPY,100.38632852427894,100.88826016690032,99.88439688165754,100.38632852427894,100.38632852427894,1000000.0 2019-01-02,SPY,100.38632852427894,101.76128740066422,99.88439688165754,101.25501233896938,101.25501233896938,1000000.0 -2019-01-03,SPY,101.25501233896938,102.13894800398002,100.74873727727453,101.63079403381097,101.63079403381097,1000000.0 -2019-01-04,SPY,101.63079403381097,102.13894800398002,99.85333565521694,100.35511121127331,100.35511121127331,1000000.0 -2019-01-07,SPY,100.35511121127331,101.81486427711494,99.85333565521694,101.30832266379596,101.30832266379596,1000000.0 -2019-01-08,SPY,101.30832266379596,102.31127210999709,100.80178105047698,101.80226080596726,101.80226080596726,1000000.0 -2019-01-09,SPY,101.80226080596726,102.31127210999709,100.79111813377236,101.29760616459534,101.29760616459534,1000000.0 -2019-01-10,SPY,101.29760616459534,102.43838566567598,100.79111813377236,101.92874195589651,101.92874195589651,1000000.0 -2019-01-11,SPY,101.92874195589651,102.85366257862322,101.41909824611703,102.34195281455047,102.34195281455047,1000000.0 -2019-01-14,SPY,102.34195281455047,103.19790488172676,101.83024305047772,102.68448246937987,102.68448246937987,1000000.0 -2019-01-15,SPY,102.68448246937987,103.26853936332982,102.17106005703297,102.75476553565157,102.75476553565157,1000000.0 -2019-01-16,SPY,102.75476553565157,103.87621019353361,102.24099170797331,103.35941312789414,103.35941312789414,1000000.0 -2019-01-17,SPY,103.35941312789414,103.87621019353361,102.12885286108057,102.64206317696541,102.64206317696541,1000000.0 -2019-01-18,SPY,102.64206317696541,103.15527349285023,102.00340345183784,102.51598336868125,102.51598336868125,1000000.0 -2019-01-21,SPY,102.51598336868125,103.02856328552464,101.55342216565106,102.06374087000107,102.06374087000107,1000000.0 -2019-01-22,SPY,102.06374087000107,103.23144768280397,101.55342216565106,102.71785839084974,102.71785839084974,1000000.0 -2019-01-23,SPY,102.71785839084974,103.31377878216375,102.2042690988955,102.79977988275002,102.79977988275002,1000000.0 -2019-01-24,SPY,102.79977988275002,103.31377878216375,102.02787930604082,102.54058221712646,102.54058221712646,1000000.0 -2019-01-25,SPY,102.54058221712646,103.05328512821208,101.27372685515192,101.78264005542907,101.78264005542907,1000000.0 -2019-01-28,SPY,101.78264005542907,102.2915532557062,101.05400687238662,101.56181595214736,101.56181595214736,1000000.0 -2019-01-29,SPY,101.56181595214736,102.11877540512673,101.05400687238662,101.61072179614601,101.61072179614601,1000000.0 -2019-01-30,SPY,101.61072179614601,102.11877540512673,100.86474774737087,101.37160577625212,101.37160577625212,1000000.0 -2019-01-31,SPY,101.37160577625212,103.24669376357943,100.86474774737087,102.73302862047706,102.73302862047706,1000000.0 -2019-02-01,SPY,102.73302862047706,104.33307781601322,102.21936347737467,103.81400777712759,103.81400777712759,1000000.0 -2019-02-04,SPY,103.81400777712759,104.33307781601322,100.57228721314684,101.07767559110235,101.07767559110235,1000000.0 -2019-02-05,SPY,101.07767559110235,101.58306396905785,98.72977890566484,99.22590844790436,99.22590844790436,1000000.0 -2019-02-06,SPY,99.22590844790436,99.72203799014387,98.59680834088499,99.09226968933164,99.09226968933164,1000000.0 -2019-02-07,SPY,99.09226968933164,99.5877310377783,98.22069997615564,98.71427133281975,98.71427133281975,1000000.0 -2019-02-08,SPY,98.71427133281975,99.45979583110761,98.22069997615564,98.9649709762265,98.9649709762265,1000000.0 -2019-02-11,SPY,98.9649709762265,99.71605726536487,98.47014612134537,99.219957477975,99.219957477975,1000000.0 -2019-02-12,SPY,99.219957477975,101.89115211598407,98.72385769058512,101.38423096117819,101.38423096117819,1000000.0 -2019-02-13,SPY,101.38423096117819,101.89115211598407,99.80165999642637,100.3031758758054,100.3031758758054,1000000.0 -2019-02-14,SPY,100.3031758758054,100.80469175518441,99.46529271096637,99.96511830247876,99.96511830247876,1000000.0 -2019-02-15,SPY,99.96511830247876,102.57934182697444,99.46529271096637,102.06899684276065,102.06899684276065,1000000.0 -2019-02-18,SPY,102.06899684276065,103.28618140742539,101.55865185854685,102.77231980838349,102.77231980838349,1000000.0 -2019-02-19,SPY,102.77231980838349,104.01490741873243,102.25845820934157,103.4974203171467,103.4974203171467,1000000.0 -2019-02-20,SPY,103.4974203171467,104.01490741873243,102.49295683189337,103.00799681597323,103.00799681597323,1000000.0 -2019-02-21,SPY,103.00799681597323,103.52303680005308,100.85797414893102,101.36479813962917,101.36479813962917,1000000.0 -2019-02-22,SPY,101.36479813962917,102.0831892182896,100.85797414893102,101.57531265501454,101.57531265501454,1000000.0 -2019-02-25,SPY,101.57531265501454,102.23542094671366,101.06743609173947,101.72678701165539,101.72678701165539,1000000.0 -2019-02-26,SPY,101.72678701165539,102.23542094671366,100.02344399404701,100.5260743658764,100.5260743658764,1000000.0 +2019-01-03,SPY,101.25501233896938,102.13894800398002,100.74873727727451,101.63079403381096,101.63079403381096,1000000.0 +2019-01-04,SPY,101.63079403381096,102.13894800398002,99.85333565521694,100.35511121127333,100.35511121127333,1000000.0 +2019-01-07,SPY,100.35511121127333,101.81486427711494,99.85333565521694,101.30832266379596,101.30832266379596,1000000.0 +2019-01-08,SPY,101.30832266379596,102.31127210999708,100.80178105047698,101.80226080596726,101.80226080596726,1000000.0 +2019-01-09,SPY,101.80226080596726,102.31127210999708,100.79111813377236,101.29760616459534,101.29760616459534,1000000.0 +2019-01-10,SPY,101.29760616459534,102.43838566567598,100.79111813377236,101.92874195589653,101.92874195589653,1000000.0 +2019-01-11,SPY,101.92874195589653,102.85366257862322,101.41909824611704,102.34195281455048,102.34195281455048,1000000.0 +2019-01-14,SPY,102.34195281455048,103.19790488172676,101.83024305047772,102.68448246937989,102.68448246937989,1000000.0 +2019-01-15,SPY,102.68448246937989,103.26853936332982,102.17106005703296,102.75476553565156,102.75476553565156,1000000.0 +2019-01-16,SPY,102.75476553565156,103.8762101935336,102.24099170797332,103.35941312789414,103.35941312789414,1000000.0 +2019-01-17,SPY,103.35941312789414,103.8762101935336,102.12885286108056,102.6420631769654,102.6420631769654,1000000.0 +2019-01-18,SPY,102.6420631769654,103.15527349285024,102.00340345183784,102.51598336868123,102.51598336868123,1000000.0 +2019-01-22,SPY,102.06374087000108,103.23144768280396,101.55342216565106,102.71785839084974,102.71785839084974,1000000.0 +2019-01-23,SPY,102.71785839084974,103.31377878216377,102.2042690988955,102.79977988275002,102.79977988275002,1000000.0 +2019-01-24,SPY,102.79977988275002,103.31377878216377,102.02787930604082,102.54058221712646,102.54058221712646,1000000.0 +2019-01-25,SPY,102.54058221712646,103.05328512821208,101.27372685515192,101.78264005542908,101.78264005542908,1000000.0 +2019-01-28,SPY,101.78264005542908,102.2915532557062,101.05400687238662,101.56181595214736,101.56181595214736,1000000.0 +2019-01-29,SPY,101.56181595214736,102.11877540512673,101.05400687238662,101.610721796146,101.610721796146,1000000.0 +2019-01-30,SPY,101.610721796146,102.11877540512673,100.86474774737088,101.37160577625212,101.37160577625212,1000000.0 +2019-01-31,SPY,101.37160577625212,103.24669376357944,100.86474774737088,102.73302862047706,102.73302862047706,1000000.0 +2019-02-01,SPY,102.73302862047706,104.33307781601322,102.21936347737469,103.8140077771276,103.8140077771276,1000000.0 +2019-02-04,SPY,103.8140077771276,104.33307781601322,100.57228721314684,101.07767559110236,101.07767559110236,1000000.0 +2019-02-05,SPY,101.07767559110236,101.58306396905785,98.72977890566484,99.22590844790436,99.22590844790436,1000000.0 +2019-02-06,SPY,99.22590844790436,99.72203799014387,98.596808340885,99.09226968933164,99.09226968933164,1000000.0 +2019-02-07,SPY,99.09226968933164,99.5877310377783,98.22069997615564,98.71427133281976,98.71427133281976,1000000.0 +2019-02-08,SPY,98.71427133281976,99.4597958311076,98.22069997615564,98.9649709762265,98.9649709762265,1000000.0 +2019-02-11,SPY,98.9649709762265,99.71605726536488,98.47014612134537,99.219957477975,99.219957477975,1000000.0 +2019-02-12,SPY,99.219957477975,101.89115211598408,98.72385769058512,101.3842309611782,101.3842309611782,1000000.0 +2019-02-13,SPY,101.3842309611782,101.89115211598408,99.80165999642637,100.3031758758054,100.3031758758054,1000000.0 +2019-02-14,SPY,100.3031758758054,100.8046917551844,99.46529271096637,99.96511830247876,99.96511830247876,1000000.0 +2019-02-15,SPY,99.96511830247876,102.57934182697444,99.46529271096637,102.06899684276064,102.06899684276064,1000000.0 +2019-02-19,SPY,102.77231980838349,104.01490741873243,102.25845820934155,103.4974203171467,103.4974203171467,1000000.0 +2019-02-20,SPY,103.4974203171467,104.01490741873243,102.49295683189337,103.00799681597324,103.00799681597324,1000000.0 +2019-02-21,SPY,103.00799681597324,103.52303680005308,100.85797414893102,101.36479813962916,101.36479813962916,1000000.0 +2019-02-22,SPY,101.36479813962916,102.0831892182896,100.85797414893102,101.57531265501454,101.57531265501454,1000000.0 +2019-02-25,SPY,101.57531265501454,102.23542094671366,101.06743609173948,101.7267870116554,101.7267870116554,1000000.0 +2019-02-26,SPY,101.7267870116554,102.23542094671366,100.023443994047,100.5260743658764,100.5260743658764,1000000.0 2019-02-27,SPY,100.5260743658764,101.02870473770577,99.3821312925276,99.88153898746492,99.88153898746492,1000000.0 2019-02-28,SPY,99.88153898746492,100.38094668240224,99.35029070239094,99.84953839436275,99.84953839436275,1000000.0 -2019-03-01,SPY,99.84953839436275,100.34878608633456,98.45547138486295,98.9502224973497,98.9502224973497,1000000.0 +2019-03-01,SPY,99.84953839436275,100.34878608633456,98.45547138486296,98.9502224973497,98.9502224973497,1000000.0 2019-03-04,SPY,98.9502224973497,99.44497360983644,98.39811812482412,98.892581029974,98.892581029974,1000000.0 -2019-03-05,SPY,98.892581029974,99.52178776806075,98.39811812482412,99.02665449558285,99.02665449558285,1000000.0 -2019-03-06,SPY,99.02665449558285,99.59704097943748,98.53152122310493,99.10153331287313,99.10153331287313,1000000.0 -2019-03-07,SPY,99.10153331287313,99.59704097943748,98.1473042949794,98.64050682912503,98.64050682912503,1000000.0 -2019-03-08,SPY,98.64050682912503,99.7639623334355,98.1473042949794,99.26762421237365,99.26762421237365,1000000.0 -2019-03-11,SPY,99.26762421237365,100.69726996539535,98.77128609131178,100.19628852278146,100.19628852278146,1000000.0 -2019-03-12,SPY,100.19628852278146,101.06129074321548,99.69530708016755,100.55849825195571,100.55849825195571,1000000.0 -2019-03-13,SPY,100.55849825195571,101.06129074321548,99.28006406796648,99.77895886227786,99.77895886227786,1000000.0 +2019-03-05,SPY,98.892581029974,99.52178776806076,98.39811812482412,99.02665449558285,99.02665449558285,1000000.0 +2019-03-06,SPY,99.02665449558285,99.59704097943748,98.53152122310492,99.10153331287312,99.10153331287312,1000000.0 +2019-03-07,SPY,99.10153331287312,99.59704097943748,98.1473042949794,98.64050682912504,98.64050682912504,1000000.0 +2019-03-08,SPY,98.64050682912504,99.7639623334355,98.1473042949794,99.26762421237363,99.26762421237363,1000000.0 +2019-03-11,SPY,99.26762421237363,100.69726996539536,98.77128609131178,100.19628852278146,100.19628852278146,1000000.0 +2019-03-12,SPY,100.19628852278146,101.06129074321548,99.69530708016757,100.55849825195573,100.55849825195573,1000000.0 +2019-03-13,SPY,100.55849825195573,101.06129074321548,99.28006406796648,99.77895886227786,99.77895886227786,1000000.0 2019-03-14,SPY,99.77895886227786,101.05464320714204,99.28006406796648,100.55188378820104,100.55188378820104,1000000.0 -2019-03-15,SPY,100.55188378820104,101.05464320714204,99.58852119227657,100.08896602238852,100.08896602238852,1000000.0 -2019-03-18,SPY,100.08896602238852,101.51825133170723,99.58852119227657,101.01318540468382,101.01318540468382,1000000.0 -2019-03-19,SPY,101.01318540468382,101.51825133170723,99.47642096839155,99.97630248079552,99.97630248079552,1000000.0 -2019-03-20,SPY,99.97630248079552,101.43978754087813,99.47642096839155,100.93511198097328,100.93511198097328,1000000.0 -2019-03-21,SPY,100.93511198097328,101.46001314623567,100.4304364210684,100.95523696142854,100.95523696142854,1000000.0 -2019-03-22,SPY,100.95523696142854,101.46001314623567,99.24357574588899,99.74228718179798,99.74228718179798,1000000.0 -2019-03-25,SPY,99.74228718179798,100.24099861770695,98.97212004353477,99.46946738043695,99.46946738043695,1000000.0 -2019-03-26,SPY,99.46946738043695,100.06093004339161,98.97212004353477,99.56311447103644,99.56311447103644,1000000.0 -2019-03-27,SPY,99.56311447103644,100.37440196738928,99.06529889868126,99.87502683322317,99.87502683322317,1000000.0 -2019-03-28,SPY,99.87502683322317,100.37440196738928,98.4437431693435,98.93843534607387,98.93843534607387,1000000.0 -2019-03-29,SPY,98.93843534607387,99.43312752280423,97.39856906438911,97.8880091099388,97.8880091099388,1000000.0 -2019-04-01,SPY,97.8880091099388,98.61342888005647,97.39856906438911,98.12281480602635,98.12281480602635,1000000.0 +2019-03-15,SPY,100.55188378820104,101.05464320714204,99.58852119227656,100.08896602238852,100.08896602238852,1000000.0 +2019-03-18,SPY,100.08896602238852,101.51825133170723,99.58852119227656,101.01318540468382,101.01318540468382,1000000.0 +2019-03-19,SPY,101.01318540468382,101.51825133170723,99.47642096839157,99.97630248079552,99.97630248079552,1000000.0 +2019-03-20,SPY,99.97630248079552,101.43978754087811,99.47642096839157,100.93511198097328,100.93511198097328,1000000.0 +2019-03-21,SPY,100.93511198097328,101.46001314623568,100.4304364210684,100.95523696142854,100.95523696142854,1000000.0 +2019-03-22,SPY,100.95523696142854,101.46001314623568,99.243575745889,99.74228718179798,99.74228718179798,1000000.0 +2019-03-25,SPY,99.74228718179798,100.24099861770696,98.97212004353476,99.46946738043695,99.46946738043695,1000000.0 +2019-03-26,SPY,99.46946738043695,100.0609300433916,98.97212004353476,99.56311447103644,99.56311447103644,1000000.0 +2019-03-27,SPY,99.56311447103644,100.37440196738928,99.06529889868126,99.87502683322316,99.87502683322316,1000000.0 +2019-03-28,SPY,99.87502683322316,100.37440196738928,98.4437431693435,98.93843534607387,98.93843534607387,1000000.0 +2019-03-29,SPY,98.93843534607387,99.43312752280424,97.39856906438912,97.8880091099388,97.8880091099388,1000000.0 +2019-04-01,SPY,97.8880091099388,98.61342888005647,97.39856906438912,98.12281480602635,98.12281480602635,1000000.0 2019-04-02,SPY,98.12281480602635,98.61342888005647,97.21644344170711,97.70496828312272,97.70496828312272,1000000.0 -2019-04-03,SPY,97.70496828312272,98.46439471163495,97.21644344170711,97.97452210112931,97.97452210112931,1000000.0 -2019-04-04,SPY,97.97452210112931,99.25479225097375,97.48464949062367,98.76098731440175,98.76098731440175,1000000.0 -2019-04-05,SPY,98.76098731440175,99.25479225097375,96.69892118106105,97.18484540810157,97.18484540810157,1000000.0 -2019-04-08,SPY,97.18484540810157,97.95872441862919,96.69892118106105,97.47136758072557,97.47136758072557,1000000.0 -2019-04-09,SPY,97.47136758072557,99.20542300871989,96.98401074282194,98.71186369026856,98.71186369026856,1000000.0 -2019-04-10,SPY,98.71186369026856,99.20542300871989,97.96569128484094,98.45798119079492,98.45798119079492,1000000.0 -2019-04-11,SPY,98.45798119079492,98.95027109674889,97.21346032731995,97.701970178211,97.701970178211,1000000.0 -2019-04-12,SPY,97.701970178211,98.97147766384585,97.21346032731995,98.47908225258294,98.47908225258294,1000000.0 -2019-04-15,SPY,98.47908225258294,99.26233256015566,97.98668684132002,98.76849010960763,98.76849010960763,1000000.0 -2019-04-16,SPY,98.76849010960763,100.19567259953915,98.27464765905958,99.69718666620813,99.69718666620813,1000000.0 -2019-04-17,SPY,99.69718666620813,100.19567259953915,98.89639229517951,99.39335909063267,99.39335909063267,1000000.0 -2019-04-18,SPY,99.39335909063267,99.89032588608582,97.48071630121771,97.97056914695247,97.97056914695247,1000000.0 -2019-04-19,SPY,97.97056914695247,98.46042199268722,97.4124931908365,97.90200320687086,97.90200320687086,1000000.0 -2019-04-22,SPY,97.90200320687086,98.3915132229052,97.01796695965207,97.50549443181113,97.50549443181113,1000000.0 -2019-04-23,SPY,97.50549443181113,98.79524828286992,97.01796695965207,98.30372963469645,98.30372963469645,1000000.0 -2019-04-24,SPY,98.30372963469645,99.02633627911361,97.81221098652297,98.53366793941653,98.53366793941653,1000000.0 -2019-04-25,SPY,98.53366793941653,99.02633627911361,96.493655697771,96.97854843997087,96.97854843997087,1000000.0 -2019-04-26,SPY,96.97854843997087,97.46344118217071,95.38540995899534,95.86473362713099,95.86473362713099,1000000.0 -2019-04-29,SPY,95.86473362713099,97.23819675641077,95.38540995899534,96.75442463324455,96.75442463324455,1000000.0 -2019-04-30,SPY,96.75442463324455,97.94060810337417,96.27065251007834,97.45334139639222,97.45334139639222,1000000.0 -2019-05-01,SPY,97.45334139639222,97.94060810337417,96.38578557464126,96.87013625592087,96.87013625592087,1000000.0 -2019-05-02,SPY,96.87013625592087,97.39241506771036,96.38578557464126,96.90787568926405,96.90787568926405,1000000.0 -2019-05-03,SPY,96.90787568926405,97.86647690666359,96.42333631081773,97.37957901160557,97.37957901160557,1000000.0 -2019-05-06,SPY,97.37957901160557,98.36530126648854,96.89268111654755,97.87592165819757,97.87592165819757,1000000.0 -2019-05-07,SPY,97.87592165819757,99.27070718450236,97.38654204990658,98.77682306915658,98.77682306915658,1000000.0 -2019-05-08,SPY,98.77682306915658,99.56546730812536,98.2829389538108,99.07011672450285,99.07011672450285,1000000.0 -2019-05-09,SPY,99.07011672450285,99.56546730812536,98.52073404776635,99.01581311333302,99.01581311333302,1000000.0 +2019-04-03,SPY,97.70496828312272,98.46439471163497,97.21644344170711,97.97452210112932,97.97452210112932,1000000.0 +2019-04-04,SPY,97.97452210112932,99.25479225097376,97.48464949062368,98.76098731440176,98.76098731440176,1000000.0 +2019-04-05,SPY,98.76098731440176,99.25479225097376,96.69892118106104,97.18484540810157,97.18484540810157,1000000.0 +2019-04-08,SPY,97.18484540810157,97.9587244186292,96.69892118106104,97.47136758072556,97.47136758072556,1000000.0 +2019-04-09,SPY,97.47136758072556,99.20542300871988,96.98401074282194,98.71186369026856,98.71186369026856,1000000.0 +2019-04-10,SPY,98.71186369026856,99.20542300871988,97.96569128484094,98.45798119079492,98.45798119079492,1000000.0 +2019-04-11,SPY,98.45798119079492,98.95027109674888,97.21346032731996,97.701970178211,97.701970178211,1000000.0 +2019-04-12,SPY,97.701970178211,98.97147766384585,97.21346032731996,98.47908225258294,98.47908225258294,1000000.0 +2019-04-15,SPY,98.47908225258294,99.26233256015566,97.98668684132002,98.76849010960764,98.76849010960764,1000000.0 +2019-04-16,SPY,98.76849010960764,100.19567259953917,98.27464765905958,99.69718666620813,99.69718666620813,1000000.0 +2019-04-17,SPY,99.69718666620813,100.19567259953917,98.89639229517952,99.39335909063269,99.39335909063269,1000000.0 +2019-04-18,SPY,99.39335909063269,99.89032588608582,97.48071630121773,97.97056914695249,97.97056914695249,1000000.0 +2019-04-22,SPY,97.90200320687086,98.3915132229052,97.01796695965209,97.50549443181112,97.50549443181112,1000000.0 +2019-04-23,SPY,97.50549443181112,98.79524828286992,97.01796695965209,98.30372963469644,98.30372963469644,1000000.0 +2019-04-24,SPY,98.30372963469644,99.0263362791136,97.81221098652296,98.53366793941652,98.53366793941652,1000000.0 +2019-04-25,SPY,98.53366793941652,99.0263362791136,96.493655697771,96.97854843997088,96.97854843997088,1000000.0 +2019-04-26,SPY,96.97854843997088,97.46344118217073,95.38540995899534,95.864733627131,95.864733627131,1000000.0 +2019-04-29,SPY,95.864733627131,97.23819675641076,95.38540995899534,96.75442463324455,96.75442463324455,1000000.0 +2019-04-30,SPY,96.75442463324455,97.94060810337416,96.27065251007834,97.45334139639222,97.45334139639222,1000000.0 +2019-05-01,SPY,97.45334139639222,97.94060810337416,96.38578557464126,96.87013625592088,96.87013625592088,1000000.0 +2019-05-02,SPY,96.87013625592088,97.39241506771036,96.38578557464126,96.90787568926405,96.90787568926405,1000000.0 +2019-05-03,SPY,96.90787568926405,97.8664769066636,96.42333631081772,97.37957901160556,97.37957901160556,1000000.0 +2019-05-06,SPY,97.37957901160556,98.36530126648854,96.89268111654756,97.87592165819756,97.87592165819756,1000000.0 +2019-05-07,SPY,97.87592165819756,99.27070718450236,97.38654204990658,98.77682306915658,98.77682306915658,1000000.0 +2019-05-08,SPY,98.77682306915658,99.56546730812536,98.2829389538108,99.07011672450284,99.07011672450284,1000000.0 +2019-05-09,SPY,99.07011672450284,99.56546730812536,98.52073404776635,99.01581311333302,99.01581311333302,1000000.0 2019-05-10,SPY,99.01581311333302,99.51089217889968,98.30535908581676,98.79935586514247,98.79935586514247,1000000.0 2019-05-13,SPY,98.79935586514247,100.38733507860962,98.30535908581676,99.8878956006066,99.8878956006066,1000000.0 2019-05-14,SPY,99.8878956006066,100.38733507860962,97.21523409713268,97.70375286143988,97.70375286143988,1000000.0 -2019-05-15,SPY,97.70375286143988,98.19227162574707,97.11937338551918,97.60741043770773,97.60741043770773,1000000.0 -2019-05-16,SPY,97.60741043770773,98.16708341253528,97.11937338551918,97.67868996272168,97.67868996272168,1000000.0 -2019-05-17,SPY,97.67868996272168,98.16708341253528,95.85315517212159,96.33482931871517,96.33482931871517,1000000.0 -2019-05-20,SPY,96.33482931871517,97.17812223314395,95.85315517212159,96.69464898820294,96.69464898820294,1000000.0 -2019-05-21,SPY,96.69464898820294,97.17812223314395,95.62484897195134,96.10537585120737,96.10537585120737,1000000.0 -2019-05-22,SPY,96.10537585120737,97.4614820513333,95.62484897195134,96.97659905605305,96.97659905605305,1000000.0 +2019-05-15,SPY,97.70375286143988,98.19227162574708,97.11937338551918,97.60741043770771,97.60741043770771,1000000.0 +2019-05-16,SPY,97.60741043770771,98.16708341253528,97.11937338551918,97.67868996272168,97.67868996272168,1000000.0 +2019-05-17,SPY,97.67868996272168,98.16708341253528,95.8531551721216,96.33482931871517,96.33482931871517,1000000.0 +2019-05-20,SPY,96.33482931871517,97.17812223314397,95.8531551721216,96.69464898820294,96.69464898820294,1000000.0 +2019-05-21,SPY,96.69464898820294,97.17812223314397,95.62484897195134,96.10537585120736,96.10537585120736,1000000.0 +2019-05-22,SPY,96.10537585120736,97.4614820513333,95.62484897195134,96.97659905605305,96.97659905605305,1000000.0 2019-05-23,SPY,96.97659905605305,97.4614820513333,96.40916212493286,96.89363027631444,96.89363027631444,1000000.0 -2019-05-24,SPY,96.89363027631444,98.07111273091506,96.40916212493286,97.58319674717917,97.58319674717917,1000000.0 -2019-05-27,SPY,97.58319674717917,99.31347797346005,97.09528076344327,98.81938106811947,98.81938106811947,1000000.0 -2019-05-28,SPY,98.81938106811947,99.73439351218971,98.32528416277887,99.23820249969127,99.23820249969127,1000000.0 -2019-05-29,SPY,99.23820249969127,99.73439351218971,97.92024225227217,98.41230377112781,98.41230377112781,1000000.0 -2019-05-30,SPY,98.41230377112781,98.90436528998345,96.48717581696704,96.9720359969518,96.9720359969518,1000000.0 -2019-05-31,SPY,96.9720359969518,99.2204389564315,96.48717581696704,98.72680493177265,98.72680493177265,1000000.0 -2019-06-03,SPY,98.72680493177265,99.2204389564315,98.1631632830869,98.6564455106401,98.6564455106401,1000000.0 -2019-06-04,SPY,98.6564455106401,99.14972773819329,97.52857150888337,98.01866483304862,98.01866483304862,1000000.0 -2019-06-05,SPY,98.01866483304862,98.69043485117182,97.52857150888337,98.19943766285753,98.19943766285753,1000000.0 -2019-06-06,SPY,98.19943766285753,98.69043485117182,97.56061076853136,98.05086509400137,98.05086509400137,1000000.0 -2019-06-07,SPY,98.05086509400137,99.42417961302323,97.56061076853136,98.92953195325696,98.92953195325696,1000000.0 -2019-06-10,SPY,98.92953195325696,99.49770927813773,98.43488429349067,99.00269579914203,99.00269579914203,1000000.0 -2019-06-11,SPY,99.00269579914203,99.55120325769235,98.50768232014632,99.05592363949489,99.05592363949489,1000000.0 -2019-06-12,SPY,99.05592363949489,99.55120325769235,97.89801140922252,98.38996121529901,98.38996121529901,1000000.0 -2019-06-13,SPY,98.38996121529901,99.3870676608412,97.89801140922252,98.89260463765294,98.89260463765294,1000000.0 -2019-06-14,SPY,98.89260463765294,99.3870676608412,97.42503891152145,97.91461197137835,97.91461197137835,1000000.0 -2019-06-17,SPY,97.91461197137835,99.10126720185642,97.42503891152145,98.60822607149893,98.60822607149893,1000000.0 -2019-06-18,SPY,98.60822607149893,100.66333211639729,98.11518494114144,100.16251951880328,100.16251951880328,1000000.0 +2019-05-24,SPY,96.89363027631444,98.07111273091506,96.40916212493286,97.58319674717916,97.58319674717916,1000000.0 +2019-05-28,SPY,98.81938106811948,99.73439351218973,98.32528416277889,99.23820249969128,99.23820249969128,1000000.0 +2019-05-29,SPY,99.23820249969128,99.73439351218973,97.92024225227216,98.4123037711278,98.4123037711278,1000000.0 +2019-05-30,SPY,98.4123037711278,98.90436528998345,96.48717581696704,96.9720359969518,96.9720359969518,1000000.0 +2019-05-31,SPY,96.9720359969518,99.2204389564315,96.48717581696704,98.72680493177263,98.72680493177263,1000000.0 +2019-06-03,SPY,98.72680493177263,99.2204389564315,98.1631632830869,98.6564455106401,98.6564455106401,1000000.0 +2019-06-04,SPY,98.6564455106401,99.14972773819328,97.52857150888336,98.01866483304862,98.01866483304862,1000000.0 +2019-06-05,SPY,98.01866483304862,98.69043485117182,97.52857150888336,98.19943766285752,98.19943766285752,1000000.0 +2019-06-06,SPY,98.19943766285752,98.69043485117182,97.56061076853136,98.05086509400137,98.05086509400137,1000000.0 +2019-06-07,SPY,98.05086509400137,99.42417961302324,97.56061076853136,98.92953195325696,98.92953195325696,1000000.0 +2019-06-10,SPY,98.92953195325696,99.49770927813772,98.43488429349068,99.00269579914205,99.00269579914205,1000000.0 +2019-06-11,SPY,99.00269579914205,99.55120325769236,98.50768232014632,99.05592363949488,99.05592363949488,1000000.0 +2019-06-12,SPY,99.05592363949488,99.55120325769236,97.89801140922252,98.389961215299,98.389961215299,1000000.0 +2019-06-13,SPY,98.389961215299,99.3870676608412,97.89801140922252,98.89260463765294,98.89260463765294,1000000.0 +2019-06-14,SPY,98.89260463765294,99.3870676608412,97.42503891152144,97.91461197137836,97.91461197137836,1000000.0 +2019-06-17,SPY,97.91461197137836,99.10126720185642,97.42503891152144,98.60822607149892,98.60822607149892,1000000.0 +2019-06-18,SPY,98.60822607149892,100.66333211639729,98.11518494114144,100.16251951880328,100.16251951880328,1000000.0 2019-06-19,SPY,100.16251951880328,100.66333211639729,98.19297349251498,98.68640552011556,98.68640552011556,1000000.0 2019-06-20,SPY,98.68640552011556,99.17983754771612,95.83925562226976,96.32085992187916,96.32085992187916,1000000.0 -2019-06-21,SPY,96.32085992187916,97.44043208714277,95.83925562226976,96.95565381805251,96.95565381805251,1000000.0 -2019-06-24,SPY,96.95565381805251,99.99500317588083,96.47087554896225,99.49751559789138,99.49751559789138,1000000.0 -2019-06-25,SPY,99.49751559789138,99.99500317588083,98.05326826028345,98.54599825154115,98.54599825154115,1000000.0 -2019-06-26,SPY,98.54599825154115,99.03872824279884,96.87329883313049,97.36009932977939,97.36009932977939,1000000.0 -2019-06-27,SPY,97.36009932977939,98.46426591419495,96.87329883313049,97.9743939444726,97.9743939444726,1000000.0 -2019-06-28,SPY,97.9743939444726,98.46426591419495,96.70705917054458,97.19302429200461,97.19302429200461,1000000.0 -2019-07-01,SPY,97.19302429200461,97.67898941346462,96.25742814138985,96.74113381044207,96.74113381044207,1000000.0 -2019-07-02,SPY,96.74113381044207,97.22483947949426,95.96129863994342,96.44351622104867,96.44351622104867,1000000.0 -2019-07-03,SPY,96.44351622104867,97.4817396883114,95.96129863994342,96.99675590876757,96.99675590876757,1000000.0 -2019-07-04,SPY,96.99675590876757,97.4817396883114,96.1598555178074,96.64307087216824,96.64307087216824,1000000.0 +2019-06-21,SPY,96.32085992187916,97.44043208714275,95.83925562226976,96.95565381805253,96.95565381805253,1000000.0 +2019-06-24,SPY,96.95565381805253,99.99500317588084,96.47087554896224,99.49751559789138,99.49751559789138,1000000.0 +2019-06-25,SPY,99.49751559789138,99.99500317588084,98.05326826028345,98.54599825154116,98.54599825154116,1000000.0 +2019-06-26,SPY,98.54599825154116,99.03872824279884,96.87329883313048,97.3600993297794,97.3600993297794,1000000.0 +2019-06-27,SPY,97.3600993297794,98.46426591419495,96.87329883313048,97.9743939444726,97.9743939444726,1000000.0 +2019-06-28,SPY,97.9743939444726,98.46426591419495,96.70705917054458,97.1930242920046,97.1930242920046,1000000.0 +2019-07-01,SPY,97.1930242920046,97.67898941346462,96.25742814138984,96.74113381044208,96.74113381044208,1000000.0 +2019-07-02,SPY,96.74113381044208,97.22483947949426,95.96129863994342,96.44351622104868,96.44351622104868,1000000.0 +2019-07-03,SPY,96.44351622104868,97.4817396883114,95.96129863994342,96.99675590876755,96.99675590876755,1000000.0 2019-07-05,SPY,96.64307087216824,97.4355252721771,96.1598555178074,96.9507714151016,96.9507714151016,1000000.0 -2019-07-08,SPY,96.9507714151016,97.4355252721771,96.3343992323701,96.81849169082423,96.81849169082423,1000000.0 -2019-07-09,SPY,96.81849169082423,97.30258414927835,95.56233461520786,96.0425473519677,96.0425473519677,1000000.0 -2019-07-10,SPY,96.0425473519677,96.52276008872752,95.29529990131168,95.77417075508711,95.77417075508711,1000000.0 -2019-07-11,SPY,95.77417075508711,96.25304160886253,94.43166902140946,94.90620002151705,94.90620002151705,1000000.0 -2019-07-12,SPY,94.90620002151705,95.42510767527877,94.43166902140946,94.95035589579977,94.95035589579977,1000000.0 -2019-07-15,SPY,94.95035589579977,95.42510767527877,93.4571442914704,93.92677818238231,93.92677818238231,1000000.0 -2019-07-16,SPY,93.92677818238231,94.39641207329421,92.47830141503685,92.94301649752448,92.94301649752448,1000000.0 -2019-07-17,SPY,92.94301649752448,94.8165279179883,92.47830141503685,94.34480389849583,94.34480389849583,1000000.0 -2019-07-18,SPY,94.34480389849583,94.8165279179883,93.86070426117698,94.33236609163515,94.33236609163515,1000000.0 -2019-07-19,SPY,94.33236609163515,94.80402792209331,93.84765662912594,94.31925289359391,94.31925289359391,1000000.0 -2019-07-22,SPY,94.31925289359391,95.31509960103895,93.84765662912594,94.8408951254119,94.8408951254119,1000000.0 -2019-07-23,SPY,94.8408951254119,95.31509960103895,94.00797203645783,94.48037390598776,94.48037390598776,1000000.0 +2019-07-08,SPY,96.9507714151016,97.4355252721771,96.3343992323701,96.81849169082425,96.81849169082425,1000000.0 +2019-07-09,SPY,96.81849169082425,97.30258414927836,95.56233461520786,96.0425473519677,96.0425473519677,1000000.0 +2019-07-10,SPY,96.0425473519677,96.52276008872752,95.29529990131168,95.77417075508713,95.77417075508713,1000000.0 +2019-07-11,SPY,95.77417075508713,96.25304160886252,94.43166902140946,94.90620002151704,94.90620002151704,1000000.0 +2019-07-12,SPY,94.90620002151704,95.42510767527877,94.43166902140946,94.95035589579976,94.95035589579976,1000000.0 +2019-07-15,SPY,94.95035589579976,95.42510767527877,93.4571442914704,93.92677818238232,93.92677818238232,1000000.0 +2019-07-16,SPY,93.92677818238232,94.3964120732942,92.47830141503684,92.94301649752448,92.94301649752448,1000000.0 +2019-07-17,SPY,92.94301649752448,94.8165279179883,92.47830141503684,94.34480389849584,94.34480389849584,1000000.0 +2019-07-18,SPY,94.34480389849584,94.8165279179883,93.86070426117698,94.33236609163517,94.33236609163517,1000000.0 +2019-07-19,SPY,94.33236609163517,94.80402792209333,93.84765662912594,94.31925289359393,94.31925289359393,1000000.0 +2019-07-22,SPY,94.31925289359393,95.31509960103897,93.84765662912594,94.8408951254119,94.8408951254119,1000000.0 +2019-07-23,SPY,94.8408951254119,95.31509960103897,94.00797203645784,94.48037390598776,94.48037390598776,1000000.0 2019-07-24,SPY,94.48037390598776,94.9527757755177,93.830900734386,94.3024127983779,94.3024127983779,1000000.0 -2019-07-25,SPY,94.3024127983779,95.21579144700918,93.830900734386,94.74208104180019,94.74208104180019,1000000.0 -2019-07-26,SPY,94.74208104180019,95.52327766873354,94.26837063659119,95.04803748132692,95.04803748132692,1000000.0 +2019-07-25,SPY,94.3024127983779,95.21579144700918,93.830900734386,94.7420810418002,94.7420810418002,1000000.0 +2019-07-26,SPY,94.7420810418002,95.52327766873354,94.2683706365912,95.04803748132692,95.04803748132692,1000000.0 2019-07-29,SPY,95.04803748132692,95.52327766873354,93.52014918698852,93.9900996854156,93.9900996854156,1000000.0 -2019-07-30,SPY,93.9900996854156,95.28862291177049,93.52014918698852,94.81455016096567,94.81455016096567,1000000.0 -2019-07-31,SPY,94.81455016096567,95.28862291177049,93.8226209956549,94.2940914529195,94.2940914529195,1000000.0 -2019-08-01,SPY,94.2940914529195,94.76556191018409,92.87413593187398,93.34084013253667,93.34084013253667,1000000.0 -2019-08-02,SPY,93.34084013253667,93.80754433319935,92.07840558609331,92.54111114180232,92.54111114180232,1000000.0 +2019-07-30,SPY,93.9900996854156,95.28862291177047,93.52014918698852,94.81455016096568,94.81455016096568,1000000.0 +2019-07-31,SPY,94.81455016096568,95.28862291177047,93.8226209956549,94.2940914529195,94.2940914529195,1000000.0 +2019-08-01,SPY,94.2940914529195,94.76556191018408,92.87413593187398,93.34084013253668,93.34084013253668,1000000.0 +2019-08-02,SPY,93.34084013253668,93.80754433319936,92.07840558609333,92.54111114180232,92.54111114180232,1000000.0 2019-08-05,SPY,92.54111114180232,93.00381669751133,91.75619410078497,92.21728050330148,92.21728050330148,1000000.0 -2019-08-06,SPY,92.21728050330148,94.23654722921857,91.75619410078497,93.76770868578963,93.76770868578963,1000000.0 -2019-08-07,SPY,93.76770868578963,94.23654722921857,92.24542044245966,92.70896526880368,92.70896526880368,1000000.0 +2019-08-06,SPY,92.21728050330148,94.23654722921856,91.75619410078497,93.76770868578964,93.76770868578964,1000000.0 +2019-08-07,SPY,93.76770868578964,94.23654722921856,92.24542044245966,92.70896526880368,92.70896526880368,1000000.0 2019-08-08,SPY,92.70896526880368,93.3591124372199,92.24542044245966,92.89463924101484,92.89463924101484,1000000.0 -2019-08-09,SPY,92.89463924101484,93.3591124372199,90.51134067062101,90.96617152826232,90.96617152826232,1000000.0 -2019-08-12,SPY,90.96617152826232,91.456145033636,90.51134067062101,91.00113933695125,91.00113933695125,1000000.0 -2019-08-13,SPY,91.00113933695125,92.31948573755854,90.5461336402665,91.86018481349109,91.86018481349109,1000000.0 -2019-08-14,SPY,91.86018481349109,92.31948573755854,91.2213085121023,91.679707047339,91.679707047339,1000000.0 +2019-08-09,SPY,92.89463924101484,93.3591124372199,90.511340670621,90.96617152826232,90.96617152826232,1000000.0 +2019-08-12,SPY,90.96617152826232,91.456145033636,90.511340670621,91.00113933695124,91.00113933695124,1000000.0 +2019-08-13,SPY,91.00113933695124,92.31948573755854,90.5461336402665,91.86018481349107,91.86018481349107,1000000.0 +2019-08-14,SPY,91.86018481349107,92.31948573755854,91.2213085121023,91.679707047339,91.679707047339,1000000.0 2019-08-15,SPY,91.679707047339,92.13810558257568,90.68527236801218,91.1409772542836,91.1409772542836,1000000.0 -2019-08-16,SPY,91.1409772542836,91.84571519003273,90.68527236801218,91.38877133336591,91.38877133336591,1000000.0 -2019-08-19,SPY,91.38877133336591,92.52803484487914,90.93182747669908,92.06769636306383,92.06769636306383,1000000.0 -2019-08-20,SPY,92.06769636306383,93.18141143840685,91.60735788124852,92.717822326773,92.717822326773,1000000.0 +2019-08-16,SPY,91.1409772542836,91.84571519003272,90.68527236801218,91.38877133336592,91.38877133336592,1000000.0 +2019-08-19,SPY,91.38877133336592,92.52803484487914,90.93182747669908,92.06769636306385,92.06769636306385,1000000.0 +2019-08-20,SPY,92.06769636306385,93.18141143840684,91.60735788124852,92.717822326773,92.717822326773,1000000.0 2019-08-21,SPY,92.717822326773,95.07565967431394,92.25423321513914,94.60264644210343,94.60264644210343,1000000.0 2019-08-22,SPY,94.60264644210343,95.31285267351522,94.12963320989292,94.83865937663208,94.83865937663208,1000000.0 2019-08-23,SPY,94.83865937663208,95.31285267351522,93.8446243894897,94.31620541657256,94.31620541657256,1000000.0 2019-08-26,SPY,94.31620541657256,94.7877864436554,93.76397221860368,94.23514795839566,94.23514795839566,1000000.0 -2019-08-27,SPY,94.23514795839566,94.70632369818763,93.73350524224753,94.2045278816558,94.2045278816558,1000000.0 -2019-08-28,SPY,94.2045278816558,94.81647323410061,93.73350524224753,94.34474948666728,94.34474948666728,1000000.0 -2019-08-29,SPY,94.34474948666728,94.82592898136532,93.87302573923395,94.35415819041327,94.35415819041327,1000000.0 -2019-08-30,SPY,94.35415819041327,95.02904138882238,93.88238739946121,94.55626008838048,94.55626008838048,1000000.0 -2019-09-02,SPY,94.55626008838048,95.02904138882238,92.56156213791007,93.02669561599002,93.02669561599002,1000000.0 -2019-09-03,SPY,93.02669561599002,94.30840656229601,92.56156213791007,93.83921050974729,93.83921050974729,1000000.0 -2019-09-04,SPY,93.83921050974729,94.30840656229601,92.87206088770226,93.3387546610073,93.3387546610073,1000000.0 +2019-08-27,SPY,94.23514795839566,94.70632369818765,93.73350524224752,94.2045278816558,94.2045278816558,1000000.0 +2019-08-28,SPY,94.2045278816558,94.8164732341006,93.73350524224752,94.34474948666728,94.34474948666728,1000000.0 +2019-08-29,SPY,94.34474948666728,94.82592898136532,93.87302573923397,94.35415819041329,94.35415819041329,1000000.0 +2019-08-30,SPY,94.35415819041329,95.02904138882238,93.8823873994612,94.55626008838048,94.55626008838048,1000000.0 +2019-09-03,SPY,93.02669561599002,94.308406562296,92.56156213791007,93.83921050974728,93.83921050974728,1000000.0 +2019-09-04,SPY,93.83921050974728,94.308406562296,92.87206088770226,93.3387546610073,93.3387546610073,1000000.0 2019-09-05,SPY,93.3387546610073,93.80544843431232,91.82561320702268,92.28704844926904,92.28704844926904,1000000.0 -2019-09-06,SPY,92.28704844926904,93.37922262109271,91.82561320702268,92.91464937422161,92.91464937422161,1000000.0 -2019-09-09,SPY,92.91464937422161,94.65532393105853,92.4500761273505,94.18440192145128,94.18440192145128,1000000.0 -2019-09-10,SPY,94.18440192145128,95.16121051379014,93.71347991184403,94.68777165551259,94.68777165551259,1000000.0 -2019-09-11,SPY,94.68777165551259,95.35282881632268,94.21433279723503,94.87843663315691,94.87843663315691,1000000.0 -2019-09-12,SPY,94.87843663315691,95.35282881632268,93.56549879635598,94.03567718226732,94.03567718226732,1000000.0 -2019-09-13,SPY,94.03567718226732,97.29790617037581,93.56549879635598,96.81383698544857,96.81383698544857,1000000.0 -2019-09-16,SPY,96.81383698544857,98.19743116959872,96.32976780052132,97.70888673591914,97.70888673591914,1000000.0 -2019-09-17,SPY,97.70888673591914,98.19743116959872,96.15745708476021,96.64066038669368,96.64066038669368,1000000.0 -2019-09-18,SPY,96.64066038669368,97.12386368862714,95.44886381549581,95.92850634723197,95.92850634723197,1000000.0 -2019-09-19,SPY,95.92850634723197,96.53064497783055,95.44886381549581,96.05039301276672,96.05039301276672,1000000.0 -2019-09-20,SPY,96.05039301276672,96.53064497783055,94.13341907599728,94.60645133266058,94.60645133266058,1000000.0 +2019-09-06,SPY,92.28704844926904,93.37922262109272,91.82561320702268,92.9146493742216,92.9146493742216,1000000.0 +2019-09-09,SPY,92.9146493742216,94.65532393105852,92.4500761273505,94.18440192145128,94.18440192145128,1000000.0 +2019-09-10,SPY,94.18440192145128,95.16121051379014,93.71347991184405,94.6877716555126,94.6877716555126,1000000.0 +2019-09-11,SPY,94.6877716555126,95.35282881632268,94.21433279723504,94.87843663315692,94.87843663315692,1000000.0 +2019-09-12,SPY,94.87843663315692,95.35282881632268,93.56549879635598,94.03567718226732,94.03567718226732,1000000.0 +2019-09-13,SPY,94.03567718226732,97.2979061703758,93.56549879635598,96.81383698544856,96.81383698544856,1000000.0 +2019-09-16,SPY,96.81383698544856,98.19743116959872,96.32976780052132,97.70888673591914,97.70888673591914,1000000.0 +2019-09-17,SPY,97.70888673591914,98.19743116959872,96.1574570847602,96.64066038669368,96.64066038669368,1000000.0 +2019-09-18,SPY,96.64066038669368,97.12386368862714,95.4488638154958,95.92850634723196,95.92850634723196,1000000.0 +2019-09-19,SPY,95.92850634723196,96.53064497783056,95.4488638154958,96.05039301276672,96.05039301276672,1000000.0 +2019-09-20,SPY,96.05039301276672,96.53064497783056,94.13341907599728,94.60645133266058,94.60645133266058,1000000.0 2019-09-23,SPY,94.60645133266058,95.27805537145204,94.13341907599728,94.80403519547468,94.80403519547468,1000000.0 2019-09-24,SPY,94.80403519547468,95.27805537145204,93.935531918614,94.40756976745126,94.40756976745126,1000000.0 2019-09-25,SPY,94.40756976745126,96.08867909598872,93.935531918614,95.61062596615794,95.61062596615794,1000000.0 -2019-09-26,SPY,95.61062596615794,97.05647759389255,95.13257283632716,96.57360954616175,96.57360954616175,1000000.0 -2019-09-27,SPY,96.57360954616175,97.05647759389255,93.55786431871542,94.02800434041751,94.02800434041751,1000000.0 -2019-09-30,SPY,94.02800434041751,94.57538333863525,93.55786431871542,94.10485904341817,94.10485904341817,1000000.0 -2019-10-01,SPY,94.10485904341817,94.57538333863525,92.16887253295542,92.6320326964376,92.6320326964376,1000000.0 +2019-09-26,SPY,95.61062596615794,97.05647759389257,95.13257283632716,96.57360954616176,96.57360954616176,1000000.0 +2019-09-27,SPY,96.57360954616176,97.05647759389257,93.55786431871542,94.02800434041752,94.02800434041752,1000000.0 +2019-09-30,SPY,94.02800434041752,94.57538333863523,93.55786431871542,94.10485904341816,94.10485904341816,1000000.0 +2019-10-01,SPY,94.10485904341816,94.57538333863523,92.16887253295542,92.6320326964376,92.6320326964376,1000000.0 2019-10-02,SPY,92.6320326964376,94.17162626051656,92.16887253295542,93.70311070698166,93.70311070698166,1000000.0 -2019-10-03,SPY,93.70311070698166,94.36780700283553,93.23459515344675,93.89831542570701,93.89831542570701,1000000.0 -2019-10-04,SPY,93.89831542570701,94.9247091388563,93.42882384857847,94.45244690433464,94.45244690433464,1000000.0 +2019-10-03,SPY,93.70311070698166,94.36780700283552,93.23459515344676,93.898315425707,93.898315425707,1000000.0 +2019-10-04,SPY,93.898315425707,94.9247091388563,93.42882384857847,94.45244690433464,94.45244690433464,1000000.0 2019-10-07,SPY,94.45244690433464,94.9247091388563,93.02169182401306,93.48913751157092,93.48913751157092,1000000.0 -2019-10-08,SPY,93.48913751157092,95.72859930148013,93.02169182401306,95.25233761341308,95.25233761341308,1000000.0 +2019-10-08,SPY,93.48913751157092,95.72859930148012,93.02169182401306,95.25233761341308,95.25233761341308,1000000.0 2019-10-09,SPY,95.25233761341308,97.72113203136632,94.776075925346,97.23495724514063,97.23495724514063,1000000.0 -2019-10-10,SPY,97.23495724514063,97.72113203136632,95.76239170775875,96.24360975654146,96.24360975654146,1000000.0 -2019-10-11,SPY,96.24360975654146,97.1249478308645,95.76239170775875,96.64173913518857,96.64173913518857,1000000.0 -2019-10-14,SPY,96.64173913518857,97.1249478308645,95.5514803914046,96.03163858432623,96.03163858432623,1000000.0 -2019-10-15,SPY,96.03163858432623,96.52765502911991,95.5514803914046,96.04741793942281,96.04741793942281,1000000.0 -2019-10-16,SPY,96.04741793942281,96.52765502911991,94.40302288960213,94.87740993929862,94.87740993929862,1000000.0 +2019-10-10,SPY,97.23495724514063,97.72113203136632,95.76239170775877,96.24360975654146,96.24360975654146,1000000.0 +2019-10-11,SPY,96.24360975654146,97.1249478308645,95.76239170775877,96.64173913518856,96.64173913518856,1000000.0 +2019-10-14,SPY,96.64173913518856,97.1249478308645,95.5514803914046,96.03163858432625,96.03163858432625,1000000.0 +2019-10-15,SPY,96.03163858432625,96.52765502911991,95.5514803914046,96.0474179394228,96.0474179394228,1000000.0 +2019-10-16,SPY,96.0474179394228,96.52765502911991,94.40302288960213,94.87740993929862,94.87740993929862,1000000.0 2019-10-17,SPY,94.87740993929862,97.18774597032878,94.40302288960213,96.7042248460983,96.7042248460983,1000000.0 2019-10-18,SPY,96.7042248460983,97.18774597032878,95.3307815475112,95.80983070101628,95.80983070101628,1000000.0 -2019-10-21,SPY,95.80983070101628,96.28887985452135,95.08696716308833,95.56479111868174,95.56479111868174,1000000.0 -2019-10-22,SPY,95.56479111868174,96.56407998911898,95.08696716308833,96.08366168071541,96.08366168071541,1000000.0 -2019-10-23,SPY,96.08366168071541,96.56407998911898,95.02415659139656,95.50166491597645,95.50166491597645,1000000.0 -2019-10-24,SPY,95.50166491597645,95.97917324055632,94.83495025457107,95.31150779353877,95.31150779353877,1000000.0 -2019-10-25,SPY,95.31150779353877,95.78806533250645,94.33965458119535,94.81372319718125,94.81372319718125,1000000.0 -2019-10-28,SPY,94.81372319718125,95.28779181316715,94.25152521751309,94.72515097237496,94.72515097237496,1000000.0 +2019-10-21,SPY,95.80983070101628,96.28887985452135,95.08696716308832,95.56479111868174,95.56479111868174,1000000.0 +2019-10-22,SPY,95.56479111868174,96.56407998911898,95.08696716308832,96.0836616807154,96.0836616807154,1000000.0 +2019-10-23,SPY,96.0836616807154,96.56407998911898,95.02415659139656,95.50166491597643,95.50166491597643,1000000.0 +2019-10-24,SPY,95.50166491597643,95.97917324055632,94.83495025457108,95.31150779353877,95.31150779353877,1000000.0 +2019-10-25,SPY,95.31150779353877,95.78806533250643,94.33965458119536,94.81372319718125,94.81372319718125,1000000.0 +2019-10-28,SPY,94.81372319718125,95.28779181316716,94.25152521751308,94.72515097237496,94.72515097237496,1000000.0 2019-10-29,SPY,94.72515097237496,95.19877672723683,93.1919721754718,93.66027354318774,93.66027354318774,1000000.0 -2019-10-30,SPY,93.66027354318774,94.12857491090367,92.82181631547544,93.28825760349291,93.28825760349291,1000000.0 -2019-10-31,SPY,93.28825760349291,93.75469889151036,92.66703246902662,93.13269594877048,93.13269594877048,1000000.0 +2019-10-30,SPY,93.66027354318774,94.12857491090368,92.82181631547544,93.28825760349292,93.28825760349292,1000000.0 +2019-10-31,SPY,93.28825760349292,93.75469889151036,92.66703246902662,93.13269594877048,93.13269594877048,1000000.0 2019-11-01,SPY,93.13269594877048,93.59835942851431,92.39524464886696,92.85954236067032,92.85954236067032,1000000.0 2019-11-04,SPY,92.85954236067032,93.41411848939178,92.39524464886696,92.94937163123562,92.94937163123562,1000000.0 -2019-11-05,SPY,92.94937163123562,93.41411848939178,92.2508401170949,92.71441217798483,92.71441217798483,1000000.0 -2019-11-06,SPY,92.71441217798483,93.92002171230953,92.2508401170949,93.45275792269607,93.45275792269607,1000000.0 -2019-11-07,SPY,93.45275792269607,93.92002171230953,92.7225355219064,93.1884779114637,93.1884779114637,1000000.0 -2019-11-08,SPY,93.1884779114637,93.65442030102102,92.63296285843057,93.09845513410107,93.09845513410107,1000000.0 -2019-11-11,SPY,93.09845513410107,93.56394740977157,92.05601360790374,92.51860664110929,92.51860664110929,1000000.0 +2019-11-05,SPY,92.94937163123562,93.41411848939178,92.2508401170949,92.71441217798484,92.71441217798484,1000000.0 +2019-11-06,SPY,92.71441217798484,93.92002171230952,92.2508401170949,93.45275792269608,93.45275792269608,1000000.0 +2019-11-07,SPY,93.45275792269608,93.92002171230952,92.7225355219064,93.1884779114637,93.1884779114637,1000000.0 +2019-11-08,SPY,93.1884779114637,93.65442030102102,92.63296285843056,93.09845513410109,93.09845513410109,1000000.0 +2019-11-11,SPY,93.09845513410109,93.56394740977156,92.05601360790374,92.51860664110929,92.51860664110929,1000000.0 2019-11-12,SPY,92.51860664110929,92.98119967431482,91.6092351435065,92.0695830588005,92.0695830588005,1000000.0 -2019-11-13,SPY,92.0695830588005,92.5299309740945,90.49432658151802,90.94907194122415,90.94907194122415,1000000.0 -2019-11-14,SPY,90.94907194122415,91.91599770986588,90.49432658151802,91.45870418892127,91.45870418892127,1000000.0 +2019-11-13,SPY,92.0695830588005,92.5299309740945,90.49432658151802,90.94907194122416,90.94907194122416,1000000.0 +2019-11-14,SPY,90.94907194122416,91.91599770986588,90.49432658151802,91.45870418892127,91.45870418892127,1000000.0 2019-11-15,SPY,91.45870418892127,91.91599770986588,90.0036142687924,90.4558937374798,90.4558937374798,1000000.0 -2019-11-18,SPY,90.4558937374798,90.90817320616719,89.37055487242702,89.81965313811762,89.81965313811762,1000000.0 -2019-11-19,SPY,89.81965313811762,90.62986494981739,89.37055487242702,90.1789700993208,90.1789700993208,1000000.0 -2019-11-20,SPY,90.1789700993208,91.03185749348637,89.72807524882418,90.57896268008595,90.57896268008595,1000000.0 -2019-11-21,SPY,90.57896268008595,91.03185749348637,89.80209429023031,90.25336109570885,90.25336109570885,1000000.0 -2019-11-22,SPY,90.25336109570885,90.7046279011874,88.04214661661685,88.48456946393652,88.48456946393652,1000000.0 +2019-11-18,SPY,90.4558937374798,90.9081732061672,89.37055487242702,89.81965313811762,89.81965313811762,1000000.0 +2019-11-19,SPY,89.81965313811762,90.6298649498174,89.37055487242702,90.1789700993208,90.1789700993208,1000000.0 +2019-11-20,SPY,90.1789700993208,91.03185749348636,89.72807524882418,90.57896268008597,90.57896268008597,1000000.0 +2019-11-21,SPY,90.57896268008597,91.03185749348636,89.80209429023031,90.25336109570884,90.25336109570884,1000000.0 +2019-11-22,SPY,90.25336109570884,90.7046279011874,88.04214661661685,88.48456946393652,88.48456946393652,1000000.0 2019-11-25,SPY,88.48456946393652,89.33745732440191,88.04214661661685,88.89299236258897,88.89299236258897,1000000.0 2019-11-26,SPY,88.89299236258897,89.6054809516402,88.44852740077603,89.15968253894549,89.15968253894549,1000000.0 2019-11-27,SPY,89.15968253894549,89.6054809516402,87.5047076784714,87.94442982760945,87.94442982760945,1000000.0 -2019-11-28,SPY,87.94442982760945,89.10325788003765,87.5047076784714,88.65995808958971,88.65995808958971,1000000.0 2019-11-29,SPY,88.65995808958971,89.10325788003765,87.63538167355361,88.07576047593328,88.07576047593328,1000000.0 2019-12-02,SPY,88.07576047593328,88.51613927831293,86.68864753710335,87.1242688815109,87.1242688815109,1000000.0 2019-12-03,SPY,87.1242688815109,87.67881657897588,86.68864753710335,87.24260356117004,87.24260356117004,1000000.0 @@ -255,12 +247,10 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2019-12-20,SPY,86.37766777428355,86.80955611315495,85.34058194753895,85.76942909300396,85.76942909300396,1000000.0 2019-12-23,SPY,85.76942909300396,86.82004916820365,85.34058194753895,86.38810862507827,86.38810862507827,1000000.0 2019-12-24,SPY,86.38810862507827,86.82004916820365,85.58743558877153,86.01752320479551,86.01752320479551,1000000.0 -2019-12-25,SPY,86.01752320479551,86.44761082081948,84.88026534564553,85.30679934235731,85.30679934235731,1000000.0 2019-12-26,SPY,85.30679934235731,85.83372787100217,84.88026534564553,85.40669439900714,85.40669439900714,1000000.0 2019-12-27,SPY,85.40669439900714,86.25106820026562,84.9796609270121,85.8219584082245,85.8219584082245,1000000.0 2019-12-30,SPY,85.8219584082245,86.25106820026562,85.23154092804543,85.65984012868888,85.65984012868888,1000000.0 2019-12-31,SPY,85.65984012868888,86.08813932933232,84.53336987277869,84.9581606761595,84.9581606761595,1000000.0 -2020-01-01,SPY,84.9581606761595,85.94815839595108,84.53336987277869,85.52055561786179,85.52055561786179,1000000.0 2020-01-02,SPY,85.52055561786179,85.94815839595108,83.64159413715568,84.06190365543284,84.06190365543284,1000000.0 2020-01-03,SPY,84.06190365543284,84.48221317371,82.81691206483113,83.23307745209159,83.23307745209159,1000000.0 2020-01-06,SPY,83.23307745209159,83.7157895916107,82.81691206483113,83.29929312598081,83.29929312598081,1000000.0 @@ -273,7 +263,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-01-15,SPY,81.82159529005807,82.53814649440899,81.41248731360778,82.1275089496607,82.1275089496607,1000000.0 2020-01-16,SPY,82.1275089496607,82.53814649440899,79.76500297012511,80.16583213077901,80.16583213077901,1000000.0 2020-01-17,SPY,80.16583213077901,83.13662637097305,79.76500297012511,82.72301131440105,82.72301131440105,1000000.0 -2020-01-20,SPY,82.72301131440105,83.13662637097305,81.76904628112983,82.17994601118576,82.17994601118576,1000000.0 2020-01-21,SPY,82.17994601118576,82.59084574124168,81.20691585315484,81.6149908071908,81.6149908071908,1000000.0 2020-01-22,SPY,81.6149908071908,82.76553846071684,81.20691585315484,82.35376961265358,82.35376961265358,1000000.0 2020-01-23,SPY,82.35376961265358,82.76567763817509,81.9420007645903,82.35390809768667,82.35390809768667,1000000.0 @@ -293,7 +282,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-02-12,SPY,79.55172990918594,80.78644119895947,79.15397125964,80.38451860592983,80.38451860592983,1000000.0 2020-02-13,SPY,80.38451860592983,80.93529418318546,79.98259601290017,80.53263102804524,80.53263102804524,1000000.0 2020-02-14,SPY,80.53263102804524,81.60339311628185,80.12996787290501,81.19740608585259,81.19740608585259,1000000.0 -2020-02-17,SPY,81.19740608585259,81.74601502897913,80.79141905542333,81.33931843679517,81.33931843679517,1000000.0 2020-02-18,SPY,81.33931843679517,81.994000006705,80.93262184461119,81.58606965841294,81.58606965841294,1000000.0 2020-02-19,SPY,81.58606965841294,81.994000006705,80.57722178383119,80.9821324460615,80.9821324460615,1000000.0 2020-02-20,SPY,80.9821324460615,81.9653468890899,80.57722178383119,81.5575590936218,81.5575590936218,1000000.0 @@ -332,7 +320,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-04-07,SPY,79.07935610502578,80.4163972429678,78.68395932450065,80.0163156646446,80.0163156646446,1000000.0 2020-04-08,SPY,80.0163156646446,80.46146009779326,79.61623408632137,80.06115432616247,80.06115432616247,1000000.0 2020-04-09,SPY,80.06115432616247,80.87577285605737,79.66084855453165,80.47340582692276,80.47340582692276,1000000.0 -2020-04-10,SPY,80.47340582692276,80.87577285605737,79.0406551214043,79.4378443431199,79.4378443431199,1000000.0 2020-04-13,SPY,79.4378443431199,80.37768615774905,79.0406551214043,79.97779717188962,79.97779717188962,1000000.0 2020-04-14,SPY,79.97779717188962,80.3852553034405,79.57790818603017,79.98532866013981,79.98532866013981,1000000.0 2020-04-15,SPY,79.98532866013981,80.80811961620897,79.58540201683911,80.40608917035719,80.40608917035719,1000000.0 @@ -363,7 +350,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-05-20,SPY,79.83118928746748,81.07367052518346,79.43203334103015,80.67031893053081,80.67031893053081,1000000.0 2020-05-21,SPY,80.67031893053081,81.07367052518346,79.71025672069696,80.11081077456981,80.11081077456981,1000000.0 2020-05-22,SPY,80.11081077456981,80.51136482844265,79.7013061763193,80.10181525258221,80.10181525258221,1000000.0 -2020-05-25,SPY,80.10181525258221,80.56590973681486,79.7013061763193,80.16508431523867,80.16508431523867,1000000.0 2020-05-26,SPY,80.16508431523867,81.56271651478554,79.76425889366247,81.156931855508,81.156931855508,1000000.0 2020-05-27,SPY,81.156931855508,82.17719520825058,80.75114719623046,81.76835344104536,81.76835344104536,1000000.0 2020-05-28,SPY,81.76835344104536,82.17719520825058,80.40569236512277,80.80974107047516,80.80974107047516,1000000.0 @@ -392,7 +378,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-06-30,SPY,84.59983471719919,85.63889253973115,84.1768355436132,85.21282839774244,85.21282839774244,1000000.0 2020-07-01,SPY,85.21282839774244,86.33592957054606,84.78676425575372,85.9063975826329,85.9063975826329,1000000.0 2020-07-02,SPY,85.9063975826329,86.33592957054606,85.41564700981573,85.84487136664897,85.84487136664897,1000000.0 -2020-07-03,SPY,85.84487136664897,86.2740957234822,85.22993152476869,85.65822263795847,85.65822263795847,1000000.0 2020-07-06,SPY,85.65822263795847,86.08651375114826,85.09894587991958,85.52657877378853,85.52657877378853,1000000.0 2020-07-07,SPY,85.52657877378853,85.95421166765746,83.70215506062708,84.12276890515284,84.12276890515284,1000000.0 2020-07-08,SPY,84.12276890515284,84.73699117499986,83.70215506062708,84.31541410447748,84.31541410447748,1000000.0 @@ -438,7 +423,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-09-02,SPY,87.01384215442187,87.99831679128498,86.57877294364975,87.56051422018406,87.56051422018406,1000000.0 2020-09-03,SPY,87.56051422018406,88.0766131207838,87.12271164908314,87.63842101570529,87.63842101570529,1000000.0 2020-09-04,SPY,87.63842101570529,88.0766131207838,86.27767408243866,86.7112302336067,86.7112302336067,1000000.0 -2020-09-07,SPY,86.7112302336067,87.14478638477472,85.34415734135791,85.77302245362604,85.77302245362604,1000000.0 2020-09-08,SPY,85.77302245362604,86.20188756589417,84.90532392467384,85.3319838438933,85.3319838438933,1000000.0 2020-09-09,SPY,85.3319838438933,85.75864376311276,84.3670345577514,84.79098950527778,84.79098950527778,1000000.0 2020-09-10,SPY,84.79098950527778,86.18359503567135,84.3670345577514,85.75482093101627,85.75482093101627,1000000.0 @@ -448,259 +432,250 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-09-16,SPY,88.1210972259733,88.56170271210316,87.34074251498436,87.77964071857724,87.77964071857724,1000000.0 2020-09-17,SPY,87.77964071857724,88.30193001946023,87.34074251498436,87.8626169347863,87.8626169347863,1000000.0 2020-09-18,SPY,87.8626169347863,89.12225109067028,87.42330385011238,88.6788568066371,88.6788568066371,1000000.0 -2020-09-21,SPY,88.6788568066371,91.07822542349852,88.23546252260391,90.62509992387913,90.62509992387913,1000000.0 -2020-09-22,SPY,90.62509992387913,91.9502573037029,90.17197442425974,91.49279333701782,91.49279333701782,1000000.0 +2020-09-21,SPY,88.6788568066371,91.07822542349852,88.23546252260391,90.62509992387912,90.62509992387912,1000000.0 +2020-09-22,SPY,90.62509992387912,91.9502573037029,90.17197442425974,91.49279333701782,91.49279333701782,1000000.0 2020-09-23,SPY,91.49279333701782,91.9502573037029,90.81673225522094,91.27309774394064,91.27309774394064,1000000.0 -2020-09-24,SPY,91.27309774394064,91.80106314516237,90.81673225522094,91.34434143797252,91.34434143797252,1000000.0 -2020-09-25,SPY,91.34434143797252,91.80106314516237,90.48628989124677,90.94099486557464,90.94099486557464,1000000.0 -2020-09-28,SPY,90.94099486557464,91.3956998399025,89.81700840575614,90.26835015653883,90.26835015653883,1000000.0 -2020-09-29,SPY,90.26835015653883,90.71969190732152,89.68754794512564,90.1382391408298,90.1382391408298,1000000.0 -2020-09-30,SPY,90.1382391408298,90.80262281249483,89.68754794512564,90.35086847014412,90.35086847014412,1000000.0 -2020-10-01,SPY,90.35086847014412,92.52206777976598,89.8991141277934,92.06175898484177,92.06175898484177,1000000.0 -2020-10-02,SPY,92.06175898484177,92.60725731900125,91.60145018991757,92.14652469552364,92.14652469552364,1000000.0 -2020-10-05,SPY,92.14652469552364,93.91039585868552,91.68579207204601,93.44317995889108,93.44317995889108,1000000.0 -2020-10-06,SPY,93.44317995889108,95.62324919597822,92.97596405909663,95.14751163778928,95.14751163778928,1000000.0 -2020-10-07,SPY,95.14751163778928,95.73692465614634,94.67177407960033,95.26062154840433,95.26062154840433,1000000.0 -2020-10-08,SPY,95.26062154840433,97.32754576328666,94.7843184406623,96.84332911769819,96.84332911769819,1000000.0 -2020-10-09,SPY,96.84332911769819,98.0650863392486,96.3591124721097,97.5772003375608,97.5772003375608,1000000.0 -2020-10-12,SPY,97.5772003375608,98.0650863392486,96.72345895776469,97.20950649021577,97.20950649021577,1000000.0 -2020-10-13,SPY,97.20950649021577,97.99415674488776,96.72345895776469,97.506623626754,97.506623626754,1000000.0 -2020-10-14,SPY,97.506623626754,98.05579270810418,97.01909050862024,97.56795294338725,97.56795294338725,1000000.0 -2020-10-15,SPY,97.56795294338725,98.05579270810418,96.89356968619363,97.38047204642577,97.38047204642577,1000000.0 -2020-10-16,SPY,97.38047204642577,97.86737440665789,96.7398429584473,97.2259728225601,97.2259728225601,1000000.0 +2020-09-24,SPY,91.27309774394064,91.80106314516236,90.81673225522094,91.34434143797252,91.34434143797252,1000000.0 +2020-09-25,SPY,91.34434143797252,91.80106314516236,90.48628989124676,90.94099486557464,90.94099486557464,1000000.0 +2020-09-28,SPY,90.94099486557464,91.3956998399025,89.81700840575614,90.26835015653884,90.26835015653884,1000000.0 +2020-09-29,SPY,90.26835015653884,90.71969190732152,89.68754794512564,90.1382391408298,90.1382391408298,1000000.0 +2020-09-30,SPY,90.1382391408298,90.80262281249485,89.68754794512564,90.35086847014412,90.35086847014412,1000000.0 +2020-10-01,SPY,90.35086847014412,92.52206777976598,89.8991141277934,92.06175898484176,92.06175898484176,1000000.0 +2020-10-02,SPY,92.06175898484176,92.60725731900123,91.60145018991756,92.14652469552364,92.14652469552364,1000000.0 +2020-10-05,SPY,92.14652469552364,93.91039585868552,91.685792072046,93.44317995889108,93.44317995889108,1000000.0 +2020-10-06,SPY,93.44317995889108,95.62324919597822,92.97596405909664,95.14751163778928,95.14751163778928,1000000.0 +2020-10-07,SPY,95.14751163778928,95.73692465614634,94.67177407960033,95.26062154840432,95.26062154840432,1000000.0 +2020-10-08,SPY,95.26062154840432,97.32754576328666,94.7843184406623,96.8433291176982,96.8433291176982,1000000.0 +2020-10-09,SPY,96.8433291176982,98.0650863392486,96.3591124721097,97.5772003375608,97.5772003375608,1000000.0 +2020-10-12,SPY,97.5772003375608,98.0650863392486,96.72345895776468,97.20950649021576,97.20950649021576,1000000.0 +2020-10-13,SPY,97.20950649021576,97.99415674488776,96.72345895776468,97.506623626754,97.506623626754,1000000.0 +2020-10-14,SPY,97.506623626754,98.05579270810418,97.01909050862024,97.56795294338724,97.56795294338724,1000000.0 +2020-10-15,SPY,97.56795294338724,98.05579270810418,96.89356968619364,97.38047204642577,97.38047204642577,1000000.0 +2020-10-16,SPY,97.38047204642577,97.86737440665787,96.7398429584473,97.2259728225601,97.2259728225601,1000000.0 2020-10-19,SPY,97.2259728225601,97.8922633558527,96.7398429584473,97.4052371700027,97.4052371700027,1000000.0 -2020-10-20,SPY,97.4052371700027,98.35755229104095,96.9182109841527,97.86821123486662,97.86821123486662,1000000.0 -2020-10-21,SPY,97.86821123486662,98.35755229104095,96.55959956225378,97.04482368065706,97.04482368065706,1000000.0 +2020-10-20,SPY,97.4052371700027,98.35755229104096,96.9182109841527,97.86821123486662,97.86821123486662,1000000.0 +2020-10-21,SPY,97.86821123486662,98.35755229104096,96.55959956225378,97.04482368065706,97.04482368065706,1000000.0 2020-10-22,SPY,97.04482368065706,97.5599085972589,96.55959956225378,97.07453591767056,97.07453591767056,1000000.0 -2020-10-23,SPY,97.07453591767056,97.5599085972589,95.16792882084283,95.64615961893752,95.64615961893752,1000000.0 -2020-10-26,SPY,95.64615961893752,96.41396355025998,95.16792882084283,95.93429208981094,95.93429208981094,1000000.0 +2020-10-23,SPY,97.07453591767056,97.5599085972589,95.16792882084285,95.64615961893752,95.64615961893752,1000000.0 +2020-10-26,SPY,95.64615961893752,96.41396355025998,95.16792882084285,95.93429208981094,95.93429208981094,1000000.0 2020-10-27,SPY,95.93429208981094,97.05589367373604,95.45462062936188,96.57302853108064,96.57302853108064,1000000.0 -2020-10-28,SPY,96.57302853108064,97.25404586660055,96.09016338842524,96.77019489213986,96.77019489213986,1000000.0 +2020-10-28,SPY,96.57302853108064,97.25404586660056,96.09016338842524,96.77019489213986,96.77019489213986,1000000.0 2020-10-29,SPY,96.77019489213986,97.57146812477546,96.28634391767916,97.08603793509998,97.08603793509998,1000000.0 -2020-10-30,SPY,97.08603793509998,98.18805288687822,96.60060774542448,97.69955511132163,97.69955511132163,1000000.0 -2020-11-02,SPY,97.69955511132163,98.18805288687822,96.60528551063194,97.09073920666528,97.09073920666528,1000000.0 -2020-11-03,SPY,97.09073920666528,97.5761929026986,96.41318504209734,96.89767340914305,96.89767340914305,1000000.0 -2020-11-04,SPY,96.89767340914305,97.9201421376505,96.41318504209734,97.43297725139354,97.43297725139354,1000000.0 +2020-10-30,SPY,97.08603793509998,98.18805288687822,96.60060774542448,97.69955511132164,97.69955511132164,1000000.0 +2020-11-02,SPY,97.69955511132164,98.18805288687822,96.60528551063194,97.09073920666528,97.09073920666528,1000000.0 +2020-11-03,SPY,97.09073920666528,97.5761929026986,96.41318504209734,96.89767340914304,96.89767340914304,1000000.0 +2020-11-04,SPY,96.89767340914304,97.9201421376505,96.41318504209734,97.43297725139354,97.43297725139354,1000000.0 2020-11-05,SPY,97.43297725139354,98.94569137847864,96.94581236513658,98.4534242571927,98.4534242571927,1000000.0 -2020-11-06,SPY,98.4534242571927,99.37696453580449,97.96115713590673,98.8825517769199,98.8825517769199,1000000.0 -2020-11-09,SPY,98.8825517769199,101.9873652910707,98.3881390180353,101.47996546375195,101.47996546375195,1000000.0 -2020-11-10,SPY,101.47996546375195,101.9873652910707,100.92071886451166,101.42785815528809,101.42785815528809,1000000.0 -2020-11-11,SPY,101.42785815528809,103.00030346506898,100.92071886451166,102.48786414434726,102.48786414434726,1000000.0 +2020-11-06,SPY,98.4534242571927,99.37696453580448,97.96115713590672,98.8825517769199,98.8825517769199,1000000.0 +2020-11-09,SPY,98.8825517769199,101.9873652910707,98.3881390180353,101.47996546375197,101.47996546375197,1000000.0 +2020-11-10,SPY,101.47996546375197,101.9873652910707,100.92071886451166,101.42785815528808,101.42785815528808,1000000.0 +2020-11-11,SPY,101.42785815528808,103.00030346506898,100.92071886451166,102.48786414434726,102.48786414434726,1000000.0 2020-11-12,SPY,102.48786414434726,104.35506921106482,101.97542482362552,103.83588976225356,103.83588976225356,1000000.0 -2020-11-13,SPY,103.83588976225356,104.35506921106482,103.21987580081822,103.73856864403841,103.73856864403841,1000000.0 -2020-11-16,SPY,103.73856864403841,104.25726148725859,102.41736728842564,102.9320274255534,102.9320274255534,1000000.0 +2020-11-13,SPY,103.83588976225356,104.35506921106482,103.21987580081822,103.7385686440384,103.7385686440384,1000000.0 +2020-11-16,SPY,103.7385686440384,104.2572614872586,102.41736728842564,102.9320274255534,102.9320274255534,1000000.0 2020-11-17,SPY,102.9320274255534,103.44668756268116,101.25380356704191,101.76261665029338,101.76261665029338,1000000.0 -2020-11-18,SPY,101.76261665029338,102.47851068124871,101.25380356704191,101.96866734452608,101.96866734452608,1000000.0 -2020-11-19,SPY,101.96866734452608,103.66411864828065,101.45882400780346,103.14837676445836,103.14837676445836,1000000.0 +2020-11-18,SPY,101.76261665029338,102.47851068124872,101.25380356704191,101.96866734452608,101.96866734452608,1000000.0 +2020-11-19,SPY,101.96866734452608,103.66411864828063,101.45882400780346,103.14837676445836,103.14837676445836,1000000.0 2020-11-20,SPY,103.14837676445836,103.98771095259583,102.63263488063608,103.47035915681178,103.47035915681178,1000000.0 -2020-11-23,SPY,103.47035915681178,104.20929085247505,102.95300736102772,103.69083666912941,103.69083666912941,1000000.0 -2020-11-24,SPY,103.69083666912941,104.20929085247505,102.82300035232457,103.33969884655735,103.33969884655735,1000000.0 -2020-11-25,SPY,103.33969884655735,104.48248476930213,102.82300035232457,103.96267141224094,103.96267141224094,1000000.0 -2020-11-26,SPY,103.96267141224094,104.48248476930213,101.29743130667174,101.80646362479573,101.80646362479573,1000000.0 -2020-11-27,SPY,101.80646362479573,102.59455585563614,101.29743130667174,102.08413517973747,102.08413517973747,1000000.0 -2020-11-30,SPY,102.08413517973747,102.6644735538124,101.57371450383879,102.15370502866907,102.15370502866907,1000000.0 -2020-12-01,SPY,102.15370502866907,102.6644735538124,100.29969426804949,100.80371283221055,100.80371283221055,1000000.0 -2020-12-02,SPY,100.80371283221055,103.57735339582611,100.29969426804949,103.06204317992649,103.06204317992649,1000000.0 -2020-12-03,SPY,103.06204317992649,103.57735339582611,101.17427184131193,101.68268526765019,101.68268526765019,1000000.0 -2020-12-04,SPY,101.68268526765019,102.19109869398842,100.12999469571041,100.63316049820142,100.63316049820142,1000000.0 -2020-12-07,SPY,100.63316049820142,101.13632630069242,98.97434327117966,99.47170178008007,99.47170178008007,1000000.0 -2020-12-08,SPY,99.47170178008007,101.1257122909743,98.97434327117966,100.62259929450181,100.62259929450181,1000000.0 -2020-12-09,SPY,100.62259929450181,101.1257122909743,99.27397847134627,99.77284268477013,99.77284268477013,1000000.0 -2020-12-10,SPY,99.77284268477013,100.98481252960241,99.27397847134627,100.48240052696758,100.48240052696758,1000000.0 -2020-12-11,SPY,100.48240052696758,101.62049488044289,99.97998852433274,101.11492027904767,101.11492027904767,1000000.0 -2020-12-14,SPY,101.11492027904767,101.92547817927779,100.60934567765243,101.4183862480376,101.4183862480376,1000000.0 -2020-12-15,SPY,101.4183862480376,101.92547817927779,99.64023688251787,100.14094159047022,100.14094159047022,1000000.0 +2020-11-23,SPY,103.47035915681178,104.20929085247504,102.95300736102772,103.6908366691294,103.6908366691294,1000000.0 +2020-11-24,SPY,103.6908366691294,104.20929085247504,102.82300035232456,103.33969884655735,103.33969884655735,1000000.0 +2020-11-25,SPY,103.33969884655735,104.48248476930212,102.82300035232456,103.96267141224094,103.96267141224094,1000000.0 +2020-11-27,SPY,101.80646362479573,102.59455585563614,101.29743130667174,102.08413517973749,102.08413517973749,1000000.0 +2020-11-30,SPY,102.08413517973749,102.6644735538124,101.5737145038388,102.15370502866908,102.15370502866908,1000000.0 +2020-12-01,SPY,102.15370502866908,102.6644735538124,100.29969426804948,100.80371283221056,100.80371283221056,1000000.0 +2020-12-02,SPY,100.80371283221056,103.57735339582612,100.29969426804948,103.06204317992648,103.06204317992648,1000000.0 +2020-12-03,SPY,103.06204317992648,103.57735339582612,101.17427184131192,101.6826852676502,101.6826852676502,1000000.0 +2020-12-04,SPY,101.6826852676502,102.19109869398842,100.1299946957104,100.63316049820142,100.63316049820142,1000000.0 +2020-12-07,SPY,100.63316049820142,101.13632630069242,98.97434327117966,99.47170178008008,99.47170178008008,1000000.0 +2020-12-08,SPY,99.47170178008008,101.1257122909743,98.97434327117966,100.6225992945018,100.6225992945018,1000000.0 +2020-12-09,SPY,100.6225992945018,101.1257122909743,99.27397847134628,99.77284268477013,99.77284268477013,1000000.0 +2020-12-10,SPY,99.77284268477013,100.9848125296024,99.27397847134628,100.48240052696758,100.48240052696758,1000000.0 +2020-12-11,SPY,100.48240052696758,101.62049488044288,99.97998852433274,101.11492027904768,101.11492027904768,1000000.0 +2020-12-14,SPY,101.11492027904768,101.9254781792778,100.60934567765244,101.4183862480376,101.4183862480376,1000000.0 +2020-12-15,SPY,101.4183862480376,101.9254781792778,99.64023688251788,100.14094159047022,100.14094159047022,1000000.0 2020-12-16,SPY,100.14094159047022,100.64164629842256,99.07181626795908,99.56966459091365,99.56966459091365,1000000.0 2020-12-17,SPY,99.56966459091365,101.7965523889787,99.07181626795908,101.2901018795808,101.2901018795808,1000000.0 2020-12-18,SPY,101.2901018795808,101.7965523889787,99.53094588223388,100.03110138917978,100.03110138917978,1000000.0 -2020-12-21,SPY,100.03110138917978,100.53125689612567,98.74611488769548,99.24232652029697,99.24232652029697,1000000.0 -2020-12-22,SPY,99.24232652029697,99.73853815289844,98.62547494995296,99.12108035171151,99.12108035171151,1000000.0 -2020-12-23,SPY,99.12108035171151,100.46602181346324,98.62547494995296,99.96619085916741,99.96619085916741,1000000.0 -2020-12-24,SPY,99.96619085916741,100.75944777088257,99.46635990487158,100.25815698595282,100.25815698595282,1000000.0 -2020-12-25,SPY,100.25815698595282,101.55052038391239,99.75686620102306,101.04529391434069,101.04529391434069,1000000.0 -2020-12-28,SPY,101.04529391434069,101.55052038391239,99.51251217485968,100.01257505011023,100.01257505011023,1000000.0 -2020-12-29,SPY,100.01257505011023,101.50735267779113,99.51251217485968,101.0023409729265,101.0023409729265,1000000.0 -2020-12-30,SPY,101.0023409729265,102.12744806966505,100.49732926806188,101.61935131309956,101.61935131309956,1000000.0 -2020-12-31,SPY,101.61935131309956,102.12744806966505,99.55156939853161,100.0518285412378,100.0518285412378,1000000.0 -2021-01-01,SPY,100.0518285412378,102.15334234787153,99.55156939853161,101.64511676405128,101.64511676405128,1000000.0 +2020-12-21,SPY,100.03110138917978,100.53125689612568,98.74611488769548,99.24232652029696,99.24232652029696,1000000.0 +2020-12-22,SPY,99.24232652029696,99.73853815289844,98.62547494995296,99.12108035171153,99.12108035171153,1000000.0 +2020-12-23,SPY,99.12108035171153,100.46602181346324,98.62547494995296,99.9661908591674,99.9661908591674,1000000.0 +2020-12-24,SPY,99.9661908591674,100.75944777088256,99.46635990487158,100.25815698595282,100.25815698595282,1000000.0 +2020-12-28,SPY,101.04529391434068,101.5505203839124,99.51251217485968,100.01257505011024,100.01257505011024,1000000.0 +2020-12-29,SPY,100.01257505011024,101.50735267779112,99.51251217485968,101.0023409729265,101.0023409729265,1000000.0 +2020-12-30,SPY,101.0023409729265,102.12744806966504,100.49732926806188,101.61935131309956,101.61935131309956,1000000.0 +2020-12-31,SPY,101.61935131309956,102.12744806966504,99.5515693985316,100.0518285412378,100.0518285412378,1000000.0 2021-01-04,SPY,101.64511676405128,104.5639681286407,101.13689118023102,104.04374938173204,104.04374938173204,1000000.0 2021-01-05,SPY,104.04374938173204,104.5639681286407,102.77202261426058,103.28846493895536,103.28846493895536,1000000.0 -2021-01-06,SPY,103.28846493895536,103.90424708432819,102.77202261426058,103.38731053166985,103.38731053166985,1000000.0 -2021-01-07,SPY,103.38731053166985,105.40857607731664,102.8703739790115,104.88415530081258,104.88415530081258,1000000.0 +2021-01-06,SPY,103.28846493895536,103.9042470843282,102.77202261426058,103.38731053166984,103.38731053166984,1000000.0 +2021-01-07,SPY,103.38731053166984,105.40857607731664,102.8703739790115,104.88415530081258,104.88415530081258,1000000.0 2021-01-08,SPY,104.88415530081258,105.40857607731664,102.86644682692815,103.38336364515392,103.38336364515392,1000000.0 -2021-01-11,SPY,103.38336364515392,103.90028046337967,100.88012300087968,101.38705829234138,101.38705829234138,1000000.0 +2021-01-11,SPY,103.38336364515392,103.90028046337969,100.88012300087968,101.38705829234138,101.38705829234138,1000000.0 2021-01-12,SPY,101.38705829234138,101.89399358380308,99.62005200842022,100.12065528484445,100.12065528484445,1000000.0 -2021-01-13,SPY,100.12065528484445,100.62125856126866,99.09636909341137,99.59434079739836,99.59434079739836,1000000.0 -2021-01-14,SPY,99.59434079739836,100.09231250138534,98.56422633793619,99.05952395772482,99.05952395772482,1000000.0 -2021-01-15,SPY,99.05952395772482,100.20137737388687,98.56422633793619,99.7028630585939,99.7028630585939,1000000.0 -2021-01-18,SPY,99.7028630585939,100.51101249114168,99.20434874330094,100.01095770262856,100.01095770262856,1000000.0 -2021-01-19,SPY,100.01095770262856,100.51101249114168,98.31793306821439,98.8119930333813,98.8119930333813,1000000.0 -2021-01-20,SPY,98.8119930333813,99.91105353967613,98.31793306821439,99.4139836215683,99.4139836215683,1000000.0 -2021-01-21,SPY,99.4139836215683,101.84185195118292,98.91691370346047,101.33517607082878,101.33517607082878,1000000.0 -2021-01-22,SPY,101.33517607082878,103.10951561656346,100.82850019047464,102.59653295180445,102.59653295180445,1000000.0 -2021-01-25,SPY,102.59653295180445,104.17818561106434,102.08355028704543,103.65988618016353,103.65988618016353,1000000.0 -2021-01-26,SPY,103.65988618016353,104.24089675237663,103.1415867492627,103.7222853257479,103.7222853257479,1000000.0 -2021-01-27,SPY,103.7222853257479,105.31341927111866,103.20367389911917,104.78947191156087,104.78947191156087,1000000.0 -2021-01-28,SPY,104.78947191156087,105.31341927111866,103.30434507869079,103.82346239064401,103.82346239064401,1000000.0 -2021-01-29,SPY,103.82346239064401,105.1712177842469,103.30434507869079,104.64797789477305,104.64797789477305,1000000.0 -2021-02-01,SPY,104.64797789477305,105.1712177842469,104.07555233294421,104.59854505823539,104.59854505823539,1000000.0 -2021-02-02,SPY,104.59854505823539,106.359646254886,104.07555233294421,105.83049378595624,105.83049378595624,1000000.0 +2021-01-13,SPY,100.12065528484445,100.62125856126866,99.09636909341135,99.59434079739836,99.59434079739836,1000000.0 +2021-01-14,SPY,99.59434079739836,100.09231250138534,98.5642263379362,99.05952395772482,99.05952395772482,1000000.0 +2021-01-15,SPY,99.05952395772482,100.20137737388688,98.5642263379362,99.7028630585939,99.7028630585939,1000000.0 +2021-01-19,SPY,100.01095770262856,100.51101249114168,98.3179330682144,98.8119930333813,98.8119930333813,1000000.0 +2021-01-20,SPY,98.8119930333813,99.91105353967612,98.3179330682144,99.4139836215683,99.4139836215683,1000000.0 +2021-01-21,SPY,99.4139836215683,101.84185195118292,98.91691370346048,101.33517607082878,101.33517607082878,1000000.0 +2021-01-22,SPY,101.33517607082878,103.10951561656346,100.82850019047464,102.59653295180443,102.59653295180443,1000000.0 +2021-01-25,SPY,102.59653295180443,104.17818561106434,102.08355028704544,103.65988618016353,103.65988618016353,1000000.0 +2021-01-26,SPY,103.65988618016353,104.24089675237664,103.1415867492627,103.7222853257479,103.7222853257479,1000000.0 +2021-01-27,SPY,103.7222853257479,105.31341927111866,103.20367389911917,104.78947191156088,104.78947191156088,1000000.0 +2021-01-28,SPY,104.78947191156088,105.31341927111866,103.3043450786908,103.823462390644,103.823462390644,1000000.0 +2021-01-29,SPY,103.823462390644,105.1712177842469,103.3043450786908,104.64797789477304,104.64797789477304,1000000.0 +2021-02-01,SPY,104.64797789477304,105.1712177842469,104.0755523329442,104.5985450582354,104.5985450582354,1000000.0 +2021-02-02,SPY,104.5985450582354,106.359646254886,104.0755523329442,105.83049378595624,105.83049378595624,1000000.0 2021-02-03,SPY,105.83049378595624,106.89920123852978,105.30134131702646,106.36736441644756,106.36736441644756,1000000.0 2021-02-04,SPY,106.36736441644756,106.89920123852978,104.73019427251154,105.2564766557905,105.2564766557905,1000000.0 -2021-02-05,SPY,105.2564766557905,105.96061597197375,104.73019427251154,105.43344872833211,105.43344872833211,1000000.0 -2021-02-08,SPY,105.43344872833211,107.30225790530226,104.90628148469045,106.7684158261714,106.7684158261714,1000000.0 -2021-02-09,SPY,106.7684158261714,107.30225790530226,105.08370994983291,105.61176879380191,105.61176879380191,1000000.0 +2021-02-05,SPY,105.2564766557905,105.96061597197377,104.73019427251154,105.43344872833212,105.43344872833212,1000000.0 +2021-02-08,SPY,105.43344872833212,107.30225790530226,104.90628148469044,106.7684158261714,106.7684158261714,1000000.0 +2021-02-09,SPY,106.7684158261714,107.30225790530226,105.08370994983292,105.61176879380191,105.61176879380191,1000000.0 2021-02-10,SPY,105.61176879380191,106.1398276377709,104.53959209441896,105.064916677808,105.064916677808,1000000.0 2021-02-11,SPY,105.064916677808,105.59024126119702,103.78060891480445,104.30211951236627,104.30211951236627,1000000.0 -2021-02-12,SPY,104.30211951236627,104.82363010992809,102.28087260111421,102.79484683529067,102.79484683529067,1000000.0 -2021-02-15,SPY,102.79484683529067,104.34841539923822,102.28087260111421,103.82926905396839,103.82926905396839,1000000.0 -2021-02-16,SPY,103.82926905396839,105.7677255100831,103.31012270869854,105.2415179204807,105.2415179204807,1000000.0 +2021-02-12,SPY,104.30211951236627,104.82363010992808,102.2808726011142,102.79484683529068,102.79484683529068,1000000.0 +2021-02-16,SPY,103.8292690539684,105.7677255100831,103.31012270869854,105.2415179204807,105.2415179204807,1000000.0 2021-02-17,SPY,105.2415179204807,106.65996244448166,104.7153103308783,106.1293158651559,106.1293158651559,1000000.0 -2021-02-18,SPY,106.1293158651559,106.96177805664699,105.59866928583013,106.42962990711145,106.42962990711145,1000000.0 -2021-02-19,SPY,106.42962990711145,106.96177805664699,105.88474159099087,106.41682571958881,106.41682571958881,1000000.0 -2021-02-22,SPY,106.41682571958881,107.24812180894527,105.88474159099087,106.71454906362715,106.71454906362715,1000000.0 -2021-02-23,SPY,106.71454906362715,107.24812180894527,105.44920870127613,105.97910422238807,105.97910422238807,1000000.0 -2021-02-24,SPY,105.97910422238807,107.5082879943258,105.44920870127613,106.97342088987642,106.97342088987642,1000000.0 +2021-02-18,SPY,106.1293158651559,106.961778056647,105.59866928583013,106.42962990711143,106.42962990711143,1000000.0 +2021-02-19,SPY,106.42962990711143,106.961778056647,105.88474159099088,106.4168257195888,106.4168257195888,1000000.0 +2021-02-22,SPY,106.4168257195888,107.24812180894529,105.88474159099088,106.71454906362716,106.71454906362716,1000000.0 +2021-02-23,SPY,106.71454906362716,107.24812180894529,105.44920870127612,105.97910422238807,105.97910422238807,1000000.0 +2021-02-24,SPY,105.97910422238807,107.5082879943258,105.44920870127612,106.97342088987642,106.97342088987642,1000000.0 2021-02-25,SPY,106.97342088987642,108.68163900454046,106.43855378542705,108.14093433287609,108.14093433287609,1000000.0 -2021-02-26,SPY,108.14093433287609,109.74377173721045,107.6002296612117,109.19778282309498,109.19778282309498,1000000.0 -2021-03-01,SPY,109.19778282309498,109.74377173721045,108.1196771409594,108.66299210146673,108.66299210146673,1000000.0 -2021-03-02,SPY,108.66299210146673,109.33568865281015,108.1196771409594,108.79173000279619,108.79173000279619,1000000.0 -2021-03-03,SPY,108.79173000279619,109.33568865281015,108.11170201195283,108.654976896435,108.654976896435,1000000.0 -2021-03-04,SPY,108.654976896435,111.21339150067612,108.11170201195283,110.6600910454489,110.6600910454489,1000000.0 -2021-03-05,SPY,110.6600910454489,111.4579916881101,110.10679059022165,110.90347431652748,110.90347431652748,1000000.0 -2021-03-08,SPY,110.90347431652748,111.4579916881101,108.33485844056095,108.8792547141316,108.8792547141316,1000000.0 -2021-03-09,SPY,108.8792547141316,109.90453345847044,108.33485844056095,109.35774473479647,109.35774473479647,1000000.0 -2021-03-10,SPY,109.35774473479647,112.0376274007739,108.81095601112249,111.48022626942678,111.48022626942678,1000000.0 +2021-02-26,SPY,108.14093433287609,109.74377173721044,107.6002296612117,109.19778282309498,109.19778282309498,1000000.0 +2021-03-01,SPY,109.19778282309498,109.74377173721044,108.1196771409594,108.66299210146671,108.66299210146671,1000000.0 +2021-03-02,SPY,108.66299210146671,109.33568865281016,108.1196771409594,108.7917300027962,108.7917300027962,1000000.0 +2021-03-03,SPY,108.7917300027962,109.33568865281016,108.11170201195284,108.654976896435,108.654976896435,1000000.0 +2021-03-04,SPY,108.654976896435,111.21339150067612,108.11170201195284,110.6600910454489,110.6600910454489,1000000.0 +2021-03-05,SPY,110.6600910454489,111.4579916881101,110.10679059022164,110.90347431652748,110.90347431652748,1000000.0 +2021-03-08,SPY,110.90347431652748,111.4579916881101,108.33485844056096,108.8792547141316,108.8792547141316,1000000.0 +2021-03-09,SPY,108.8792547141316,109.90453345847044,108.33485844056096,109.35774473479648,109.35774473479648,1000000.0 +2021-03-10,SPY,109.35774473479648,112.0376274007739,108.81095601112249,111.48022626942678,111.48022626942678,1000000.0 2021-03-11,SPY,111.48022626942678,112.85305011803148,110.92282513807965,112.29159215724526,112.29159215724526,1000000.0 -2021-03-12,SPY,112.29159215724526,113.89549621179987,111.73013419645903,113.32885195203968,113.32885195203968,1000000.0 -2021-03-15,SPY,113.32885195203968,113.98215952353068,112.76220769227949,113.41508410301562,113.41508410301562,1000000.0 -2021-03-16,SPY,113.41508410301562,113.98215952353068,110.68938690597281,111.24561498087719,111.24561498087719,1000000.0 -2021-03-17,SPY,111.24561498087719,111.80184305578156,108.74714154756137,109.29360959553907,109.29360959553907,1000000.0 -2021-03-18,SPY,109.29360959553907,109.84007764351675,107.44358372535952,107.9835012315171,107.9835012315171,1000000.0 -2021-03-19,SPY,107.9835012315171,108.52341873767467,107.35032876639193,107.88977765466525,107.88977765466525,1000000.0 -2021-03-22,SPY,107.88977765466525,108.80939210438838,107.35032876639193,108.26805184516257,108.26805184516257,1000000.0 -2021-03-23,SPY,108.26805184516257,109.60596483459982,107.72671158593676,109.060661526965,109.060661526965,1000000.0 -2021-03-24,SPY,109.060661526965,109.60596483459982,108.18980441912485,108.73347177801493,108.73347177801493,1000000.0 -2021-03-25,SPY,108.73347177801493,110.37200335266338,108.18980441912485,109.82288890812278,109.82288890812278,1000000.0 -2021-03-26,SPY,109.82288890812278,110.37200335266338,109.01210574516107,109.55990527151866,109.55990527151866,1000000.0 -2021-03-29,SPY,109.55990527151866,110.10770479787624,108.29013310480921,108.83430462794897,108.83430462794897,1000000.0 -2021-03-30,SPY,108.83430462794897,110.35810115455196,108.29013310480921,109.80905587517609,109.80905587517609,1000000.0 -2021-03-31,SPY,109.80905587517609,110.35810115455196,108.30875707046154,108.85302218136839,108.85302218136839,1000000.0 -2021-04-01,SPY,108.85302218136839,109.39728729227522,105.43505393109054,105.96487832270405,105.96487832270405,1000000.0 -2021-04-02,SPY,105.96487832270405,106.49470271431755,104.36512764043702,104.88957551802716,104.88957551802716,1000000.0 +2021-03-12,SPY,112.29159215724526,113.89549621179988,111.73013419645903,113.32885195203968,113.32885195203968,1000000.0 +2021-03-15,SPY,113.32885195203968,113.98215952353068,112.76220769227947,113.41508410301562,113.41508410301562,1000000.0 +2021-03-16,SPY,113.41508410301562,113.98215952353068,110.6893869059728,111.2456149808772,111.2456149808772,1000000.0 +2021-03-17,SPY,111.2456149808772,111.80184305578156,108.74714154756136,109.29360959553908,109.29360959553908,1000000.0 +2021-03-18,SPY,109.29360959553908,109.84007764351676,107.44358372535952,107.9835012315171,107.9835012315171,1000000.0 +2021-03-19,SPY,107.9835012315171,108.52341873767467,107.35032876639193,107.88977765466524,107.88977765466524,1000000.0 +2021-03-22,SPY,107.88977765466524,108.80939210438838,107.35032876639193,108.26805184516256,108.26805184516256,1000000.0 +2021-03-23,SPY,108.26805184516256,109.60596483459982,107.72671158593676,109.060661526965,109.060661526965,1000000.0 +2021-03-24,SPY,109.060661526965,109.60596483459982,108.18980441912484,108.73347177801492,108.73347177801492,1000000.0 +2021-03-25,SPY,108.73347177801492,110.37200335266338,108.18980441912484,109.82288890812278,109.82288890812278,1000000.0 +2021-03-26,SPY,109.82288890812278,110.37200335266338,109.01210574516108,109.55990527151866,109.55990527151866,1000000.0 +2021-03-29,SPY,109.55990527151866,110.10770479787624,108.2901331048092,108.83430462794897,108.83430462794897,1000000.0 +2021-03-30,SPY,108.83430462794897,110.35810115455196,108.2901331048092,109.80905587517609,109.80905587517609,1000000.0 +2021-03-31,SPY,109.80905587517609,110.35810115455196,108.30875707046154,108.8530221813684,108.8530221813684,1000000.0 +2021-04-01,SPY,108.8530221813684,109.39728729227522,105.43505393109054,105.96487832270404,105.96487832270404,1000000.0 2021-04-05,SPY,104.88957551802716,105.55554127894946,104.36512764043702,105.03038933228804,105.03038933228804,1000000.0 -2021-04-06,SPY,105.03038933228804,105.55554127894946,101.37590210421584,101.88532874795561,101.88532874795561,1000000.0 -2021-04-07,SPY,101.88532874795561,102.39475539169538,101.05490981236375,101.56272342951131,101.56272342951131,1000000.0 -2021-04-08,SPY,101.56272342951131,102.07053704665886,100.76016882829695,101.26650133497182,101.26650133497182,1000000.0 -2021-04-09,SPY,101.26650133497182,101.77283384164667,99.3728132143427,99.87217408476653,99.87217408476653,1000000.0 -2021-04-12,SPY,99.87217408476653,100.37153495519036,97.94964623517077,98.44185551273445,98.44185551273445,1000000.0 -2021-04-13,SPY,98.44185551273445,98.93406479029811,97.53671909568799,98.02685336250049,98.02685336250049,1000000.0 -2021-04-14,SPY,98.02685336250049,98.51698762931298,97.04336219177053,97.53101727816133,97.53101727816133,1000000.0 -2021-04-15,SPY,97.53101727816133,99.3011527700804,97.04336219177053,98.80711718415962,98.80711718415962,1000000.0 -2021-04-16,SPY,98.80711718415962,99.71397228202257,98.31308159823882,99.21788286768415,99.21788286768415,1000000.0 -2021-04-19,SPY,99.21788286768415,99.71397228202257,97.21229280032397,97.70079678424518,97.70079678424518,1000000.0 +2021-04-06,SPY,105.03038933228804,105.55554127894946,101.37590210421584,101.8853287479556,101.8853287479556,1000000.0 +2021-04-07,SPY,101.8853287479556,102.39475539169538,101.05490981236376,101.56272342951132,101.56272342951132,1000000.0 +2021-04-08,SPY,101.56272342951132,102.07053704665886,100.76016882829695,101.26650133497182,101.26650133497182,1000000.0 +2021-04-09,SPY,101.26650133497182,101.77283384164669,99.3728132143427,99.87217408476651,99.87217408476651,1000000.0 +2021-04-12,SPY,99.87217408476651,100.37153495519036,97.94964623517076,98.44185551273443,98.44185551273443,1000000.0 +2021-04-13,SPY,98.44185551273443,98.93406479029812,97.536719095688,98.02685336250047,98.02685336250047,1000000.0 +2021-04-14,SPY,98.02685336250047,98.51698762931298,97.04336219177053,97.53101727816131,97.53101727816131,1000000.0 +2021-04-15,SPY,97.53101727816131,99.3011527700804,97.04336219177053,98.80711718415962,98.80711718415962,1000000.0 +2021-04-16,SPY,98.80711718415962,99.71397228202255,98.31308159823882,99.21788286768415,99.21788286768415,1000000.0 +2021-04-19,SPY,99.21788286768415,99.71397228202255,97.21229280032397,97.70079678424518,97.70079678424518,1000000.0 2021-04-20,SPY,97.70079678424518,98.1893007681664,96.42356846736531,96.90810901242745,96.90810901242745,1000000.0 -2021-04-21,SPY,96.90810901242745,98.12107315668437,96.42356846736531,97.63290861361631,97.63290861361631,1000000.0 -2021-04-22,SPY,97.63290861361631,100.03918149573411,97.14474407054823,99.54147412510858,99.54147412510858,1000000.0 -2021-04-23,SPY,99.54147412510858,100.4667592587136,99.04376675448303,99.96692463553593,99.96692463553593,1000000.0 -2021-04-26,SPY,99.96692463553593,100.81972737659062,99.46709001235826,100.31813669312501,100.31813669312501,1000000.0 -2021-04-27,SPY,100.31813669312501,102.75438914788579,99.81654600965939,102.2431732814784,102.2431732814784,1000000.0 -2021-04-28,SPY,102.2431732814784,102.77219654256982,101.73195741507101,102.26089208215903,102.26089208215903,1000000.0 -2021-04-29,SPY,102.26089208215903,102.77219654256982,101.47614658758916,101.98607697245141,101.98607697245141,1000000.0 -2021-04-30,SPY,101.98607697245141,102.49600735731366,100.09215123945069,100.59512687381978,100.59512687381978,1000000.0 -2021-05-03,SPY,100.59512687381978,101.09810250818887,99.61981725557615,100.12041935233785,100.12041935233785,1000000.0 -2021-05-04,SPY,100.12041935233785,102.87846334432474,99.61981725557615,102.36663019335796,102.36663019335796,1000000.0 +2021-04-21,SPY,96.90810901242745,98.12107315668436,96.42356846736531,97.63290861361632,97.63290861361632,1000000.0 +2021-04-22,SPY,97.63290861361632,100.03918149573413,97.14474407054824,99.54147412510858,99.54147412510858,1000000.0 +2021-04-23,SPY,99.54147412510858,100.4667592587136,99.04376675448304,99.96692463553592,99.96692463553592,1000000.0 +2021-04-26,SPY,99.96692463553592,100.81972737659062,99.46709001235826,100.318136693125,100.318136693125,1000000.0 +2021-04-27,SPY,100.318136693125,102.7543891478858,99.8165460096594,102.2431732814784,102.2431732814784,1000000.0 +2021-04-28,SPY,102.2431732814784,102.77219654256982,101.731957415071,102.26089208215905,102.26089208215905,1000000.0 +2021-04-29,SPY,102.26089208215905,102.77219654256982,101.47614658758916,101.9860769724514,101.9860769724514,1000000.0 +2021-04-30,SPY,101.9860769724514,102.49600735731366,100.09215123945069,100.59512687381978,100.59512687381978,1000000.0 +2021-05-03,SPY,100.59512687381978,101.09810250818887,99.61981725557617,100.12041935233783,100.12041935233783,1000000.0 +2021-05-04,SPY,100.12041935233783,102.87846334432474,99.61981725557617,102.36663019335796,102.36663019335796,1000000.0 2021-05-05,SPY,102.36663019335796,102.87846334432474,100.45710562912691,100.96191520515268,100.96191520515268,1000000.0 -2021-05-06,SPY,100.96191520515268,101.51868062684889,100.45710562912691,101.01361256402876,101.01361256402876,1000000.0 -2021-05-07,SPY,101.01361256402876,101.51868062684889,99.14262495716564,99.64082910267904,99.64082910267904,1000000.0 +2021-05-06,SPY,100.96191520515268,101.51868062684888,100.45710562912691,101.01361256402876,101.01361256402876,1000000.0 +2021-05-07,SPY,101.01361256402876,101.51868062684888,99.14262495716564,99.64082910267904,99.64082910267904,1000000.0 2021-05-10,SPY,99.64082910267904,100.30788543109897,99.14262495716564,99.8088412249741,99.8088412249741,1000000.0 -2021-05-11,SPY,99.8088412249741,101.24271799546523,99.30979701884924,100.73902288105994,100.73902288105994,1000000.0 -2021-05-12,SPY,100.73902288105994,101.24271799546523,100.03140691563519,100.53407730214592,100.53407730214592,1000000.0 -2021-05-13,SPY,100.53407730214592,101.81808716619685,100.03140691563519,101.31152951860383,101.31152951860383,1000000.0 -2021-05-14,SPY,101.31152951860383,102.59571832366483,100.80497187101082,102.08529186434312,102.08529186434312,1000000.0 +2021-05-11,SPY,99.8088412249741,101.24271799546524,99.30979701884924,100.73902288105994,100.73902288105994,1000000.0 +2021-05-12,SPY,100.73902288105994,101.24271799546524,100.0314069156352,100.53407730214592,100.53407730214592,1000000.0 +2021-05-13,SPY,100.53407730214592,101.81808716619685,100.0314069156352,101.31152951860383,101.31152951860383,1000000.0 +2021-05-14,SPY,101.31152951860383,102.59571832366484,100.80497187101082,102.08529186434312,102.08529186434312,1000000.0 2021-05-17,SPY,102.08529186434312,103.096603660054,101.5748654050214,102.5836852338846,102.5836852338846,1000000.0 -2021-05-18,SPY,102.5836852338846,104.9232287826657,102.07076680771517,104.40122266931913,104.40122266931913,1000000.0 -2021-05-19,SPY,104.40122266931913,105.78590672812797,103.87921655597253,105.25960868470446,105.25960868470446,1000000.0 -2021-05-20,SPY,105.25960868470446,105.78590672812797,104.45617702395413,104.9810824361348,104.9810824361348,1000000.0 +2021-05-18,SPY,102.5836852338846,104.9232287826657,102.07076680771516,104.40122266931913,104.40122266931913,1000000.0 +2021-05-19,SPY,104.40122266931913,105.78590672812796,103.87921655597252,105.25960868470446,105.25960868470446,1000000.0 +2021-05-20,SPY,105.25960868470446,105.78590672812796,104.45617702395413,104.9810824361348,104.9810824361348,1000000.0 2021-05-21,SPY,104.9810824361348,105.50598784831546,103.78893744129,104.31048989074372,104.31048989074372,1000000.0 -2021-05-24,SPY,104.31048989074372,104.83204234019743,102.95647497549488,103.47384419647727,103.47384419647727,1000000.0 -2021-05-25,SPY,103.47384419647727,104.5290563109061,102.95647497549488,104.00901125463295,104.00901125463295,1000000.0 -2021-05-26,SPY,104.00901125463295,104.5290563109061,103.19576156535186,103.7143332315094,103.7143332315094,1000000.0 -2021-05-27,SPY,103.7143332315094,107.15962399219735,103.19576156535186,106.62649153452475,106.62649153452475,1000000.0 -2021-05-28,SPY,106.62649153452475,109.19595405889518,106.09335907685212,108.65269060586587,108.65269060586587,1000000.0 -2021-05-31,SPY,108.65269060586587,109.19595405889518,107.92091480421978,108.46323095901485,108.46323095901485,1000000.0 -2021-06-01,SPY,108.46323095901485,109.00554711380991,107.60933236790605,108.15008278181513,108.15008278181513,1000000.0 -2021-06-02,SPY,108.15008278181513,110.58773909802615,107.60933236790605,110.03755134131956,110.03755134131956,1000000.0 -2021-06-03,SPY,110.03755134131956,110.58773909802615,107.48760555207966,108.02774427344688,108.02774427344688,1000000.0 -2021-06-04,SPY,108.02774427344688,108.56788299481411,107.04614820709399,107.58406854984321,107.58406854984321,1000000.0 -2021-06-07,SPY,107.58406854984321,109.1985392054669,107.04614820709399,108.65526289101186,108.65526289101186,1000000.0 +2021-05-24,SPY,104.31048989074372,104.83204234019745,102.95647497549488,103.47384419647727,103.47384419647727,1000000.0 +2021-05-25,SPY,103.47384419647727,104.5290563109061,102.95647497549488,104.00901125463297,104.00901125463297,1000000.0 +2021-05-26,SPY,104.00901125463297,104.5290563109061,103.19576156535186,103.7143332315094,103.7143332315094,1000000.0 +2021-05-27,SPY,103.7143332315094,107.15962399219735,103.19576156535186,106.62649153452476,106.62649153452476,1000000.0 +2021-05-28,SPY,106.62649153452476,109.19595405889518,106.09335907685212,108.65269060586589,108.65269060586589,1000000.0 +2021-06-01,SPY,108.46323095901484,109.00554711380993,107.60933236790603,108.15008278181512,108.15008278181512,1000000.0 +2021-06-02,SPY,108.15008278181512,110.58773909802616,107.60933236790603,110.03755134131956,110.03755134131956,1000000.0 +2021-06-03,SPY,110.03755134131956,110.58773909802616,107.48760555207966,108.02774427344688,108.02774427344688,1000000.0 +2021-06-04,SPY,108.02774427344688,108.56788299481413,107.046148207094,107.5840685498432,107.5840685498432,1000000.0 +2021-06-07,SPY,107.5840685498432,109.1985392054669,107.046148207094,108.65526289101186,108.65526289101186,1000000.0 2021-06-08,SPY,108.65526289101186,109.1985392054669,107.1732438935164,107.71180290805668,107.71180290805668,1000000.0 -2021-06-09,SPY,107.71180290805668,108.25036192259695,106.70610009595346,107.24231165422458,107.24231165422458,1000000.0 -2021-06-10,SPY,107.24231165422458,107.77852321249568,106.37460946116083,106.90915523734756,106.90915523734756,1000000.0 -2021-06-11,SPY,106.90915523734756,108.19932022845389,106.37460946116083,107.66101515269044,107.66101515269044,1000000.0 -2021-06-14,SPY,107.66101515269044,108.39157371137301,107.12271007692699,107.85231215061992,107.85231215061992,1000000.0 -2021-06-15,SPY,107.85231215061992,108.87181998455426,107.31305058986682,108.33016913885997,108.33016913885997,1000000.0 -2021-06-16,SPY,108.33016913885997,109.56566655183552,107.78851829316567,109.02056373316968,109.02056373316968,1000000.0 -2021-06-17,SPY,109.02056373316968,109.64232164019307,108.47546091450383,109.09683745292844,109.09683745292844,1000000.0 -2021-06-18,SPY,109.09683745292844,110.91111428471322,108.55135326566379,110.35931769623207,110.35931769623207,1000000.0 -2021-06-21,SPY,110.35931769623207,110.91111428471322,109.0535925609296,109.60160056374835,109.60160056374835,1000000.0 -2021-06-22,SPY,109.60160056374835,110.14960856656708,107.54165074411738,108.0820610493642,108.0820610493642,1000000.0 -2021-06-23,SPY,108.0820610493642,108.62247135461101,105.6899177987812,106.22102291334794,106.22102291334794,1000000.0 -2021-06-24,SPY,106.22102291334794,106.75212802791467,104.03208960821642,104.5548639278557,104.5548639278557,1000000.0 -2021-06-25,SPY,104.5548639278557,105.07763824749497,103.50234279793521,104.02245507330173,104.02245507330173,1000000.0 -2021-06-28,SPY,104.02245507330173,105.66722831143608,103.50234279793521,105.1415207078966,105.1415207078966,1000000.0 -2021-06-29,SPY,105.1415207078966,105.89377489505904,104.61581310435712,105.36694019408861,105.36694019408861,1000000.0 -2021-06-30,SPY,105.36694019408861,105.89377489505904,104.25615696893507,104.78005725521113,104.78005725521113,1000000.0 -2021-07-01,SPY,104.78005725521113,105.30395754148718,103.18797889208108,103.70651144932772,103.70651144932772,1000000.0 -2021-07-02,SPY,103.70651144932772,104.22504400657435,102.68473880599059,103.20074251858351,103.20074251858351,1000000.0 -2021-07-05,SPY,103.20074251858351,103.71674623117642,102.31198712511662,102.82611771368504,102.82611771368504,1000000.0 +2021-06-09,SPY,107.71180290805668,108.25036192259697,106.70610009595346,107.24231165422458,107.24231165422458,1000000.0 +2021-06-10,SPY,107.24231165422458,107.77852321249568,106.37460946116084,106.90915523734756,106.90915523734756,1000000.0 +2021-06-11,SPY,106.90915523734756,108.19932022845389,106.37460946116084,107.66101515269044,107.66101515269044,1000000.0 +2021-06-14,SPY,107.66101515269044,108.391573711373,107.122710076927,107.85231215061992,107.85231215061992,1000000.0 +2021-06-15,SPY,107.85231215061992,108.87181998455426,107.31305058986682,108.33016913885996,108.33016913885996,1000000.0 +2021-06-16,SPY,108.33016913885996,109.56566655183552,107.78851829316568,109.02056373316968,109.02056373316968,1000000.0 +2021-06-17,SPY,109.02056373316968,109.64232164019307,108.47546091450384,109.09683745292844,109.09683745292844,1000000.0 +2021-06-18,SPY,109.09683745292844,110.91111428471322,108.5513532656638,110.35931769623208,110.35931769623208,1000000.0 +2021-06-21,SPY,110.35931769623208,110.91111428471322,109.0535925609296,109.60160056374836,109.60160056374836,1000000.0 +2021-06-22,SPY,109.60160056374836,110.14960856656708,107.54165074411738,108.0820610493642,108.0820610493642,1000000.0 +2021-06-23,SPY,108.0820610493642,108.622471354611,105.6899177987812,106.22102291334794,106.22102291334794,1000000.0 +2021-06-24,SPY,106.22102291334794,106.75212802791468,104.03208960821642,104.5548639278557,104.5548639278557,1000000.0 +2021-06-25,SPY,104.5548639278557,105.07763824749496,103.5023427979352,104.02245507330171,104.02245507330171,1000000.0 +2021-06-28,SPY,104.02245507330171,105.66722831143608,103.5023427979352,105.1415207078966,105.1415207078966,1000000.0 +2021-06-29,SPY,105.1415207078966,105.89377489505904,104.61581310435712,105.3669401940886,105.3669401940886,1000000.0 +2021-06-30,SPY,105.3669401940886,105.89377489505904,104.25615696893507,104.78005725521112,104.78005725521112,1000000.0 +2021-07-01,SPY,104.78005725521112,105.30395754148718,103.18797889208108,103.70651144932772,103.70651144932772,1000000.0 +2021-07-02,SPY,103.70651144932772,104.22504400657436,102.6847388059906,103.20074251858352,103.20074251858352,1000000.0 2021-07-06,SPY,102.82611771368504,103.34024830225346,101.77615555925368,102.28759352688812,102.28759352688812,1000000.0 -2021-07-07,SPY,102.28759352688812,102.79903149452255,100.36869995088452,100.87306527727087,100.87306527727087,1000000.0 -2021-07-08,SPY,100.87306527727087,101.37743060365722,99.93223706703766,100.43440911260066,100.43440911260066,1000000.0 -2021-07-09,SPY,100.43440911260066,102.00927817345793,99.93223706703766,101.50176932682382,101.50176932682382,1000000.0 -2021-07-12,SPY,101.50176932682382,102.00927817345793,98.8612744127674,99.35806473644965,99.35806473644965,1000000.0 -2021-07-13,SPY,99.35806473644965,99.85485506013188,98.66314891751367,99.15894363569213,99.15894363569213,1000000.0 -2021-07-14,SPY,99.15894363569213,99.65473835387057,96.63084808636636,97.11643023755413,97.11643023755413,1000000.0 +2021-07-07,SPY,102.28759352688812,102.79903149452257,100.36869995088452,100.87306527727088,100.87306527727088,1000000.0 +2021-07-08,SPY,100.87306527727088,101.37743060365722,99.93223706703766,100.43440911260066,100.43440911260066,1000000.0 +2021-07-09,SPY,100.43440911260066,102.00927817345791,99.93223706703766,101.50176932682382,101.50176932682382,1000000.0 +2021-07-12,SPY,101.50176932682382,102.00927817345791,98.8612744127674,99.35806473644963,99.35806473644963,1000000.0 +2021-07-13,SPY,99.35806473644963,99.85485506013188,98.66314891751368,99.15894363569213,99.15894363569213,1000000.0 +2021-07-14,SPY,99.15894363569213,99.65473835387056,96.63084808636636,97.11643023755413,97.11643023755413,1000000.0 2021-07-15,SPY,97.11643023755413,97.81995431202158,96.63084808636636,97.3332878726583,97.3332878726583,1000000.0 2021-07-16,SPY,97.3332878726583,97.81995431202158,96.16385994167716,96.64709541877102,96.64709541877102,1000000.0 -2021-07-19,SPY,96.64709541877102,97.13033089586486,94.216632717731,94.69008313339799,94.69008313339799,1000000.0 -2021-07-20,SPY,94.69008313339799,95.29752496711095,94.216632717731,94.82340792747358,94.82340792747358,1000000.0 -2021-07-21,SPY,94.82340792747358,97.08412715616369,94.34929088783622,96.60112154842159,96.60112154842159,1000000.0 -2021-07-22,SPY,96.60112154842159,97.2287848665457,96.11811594067947,96.74505956870219,96.74505956870219,1000000.0 -2021-07-23,SPY,96.74505956870219,98.40432172522627,96.26133427085868,97.91474798529978,97.91474798529978,1000000.0 -2021-07-26,SPY,97.91474798529978,98.40432172522627,97.3944858610151,97.88390538795487,97.88390538795487,1000000.0 -2021-07-27,SPY,97.88390538795487,98.37332491489464,95.35375260944384,95.83291719542095,95.83291719542095,1000000.0 -2021-07-28,SPY,95.83291719542095,96.82435904653957,95.35375260944384,96.34264581745232,96.34264581745232,1000000.0 -2021-07-29,SPY,96.34264581745232,96.82435904653957,95.4989955151873,95.97888996501237,95.97888996501237,1000000.0 -2021-07-30,SPY,95.97888996501237,96.45878441483742,94.15052958449692,94.623647823615,94.623647823615,1000000.0 +2021-07-19,SPY,96.64709541877102,97.13033089586486,94.216632717731,94.690083133398,94.690083133398,1000000.0 +2021-07-20,SPY,94.690083133398,95.29752496711096,94.216632717731,94.82340792747358,94.82340792747358,1000000.0 +2021-07-21,SPY,94.82340792747358,97.08412715616367,94.34929088783622,96.6011215484216,96.6011215484216,1000000.0 +2021-07-22,SPY,96.6011215484216,97.2287848665457,96.11811594067947,96.7450595687022,96.7450595687022,1000000.0 +2021-07-23,SPY,96.7450595687022,98.40432172522628,96.26133427085868,97.91474798529978,97.91474798529978,1000000.0 +2021-07-26,SPY,97.91474798529978,98.40432172522628,97.3944858610151,97.88390538795488,97.88390538795488,1000000.0 +2021-07-27,SPY,97.88390538795488,98.37332491489464,95.35375260944384,95.83291719542096,95.83291719542096,1000000.0 +2021-07-28,SPY,95.83291719542096,96.82435904653956,95.35375260944384,96.34264581745232,96.34264581745232,1000000.0 +2021-07-29,SPY,96.34264581745232,96.82435904653956,95.4989955151873,95.97888996501236,95.97888996501236,1000000.0 +2021-07-30,SPY,95.97888996501236,96.45878441483742,94.15052958449692,94.623647823615,94.623647823615,1000000.0 2021-08-02,SPY,94.623647823615,95.8844029583151,94.15052958449692,95.40736612767674,95.40736612767674,1000000.0 2021-08-03,SPY,95.40736612767674,96.19832113665124,94.93032929703836,95.7197225240311,95.7197225240311,1000000.0 2021-08-04,SPY,95.7197225240311,96.19832113665124,94.7799345483863,95.2562156265189,95.2562156265189,1000000.0 2021-08-05,SPY,95.2562156265189,96.60577321511118,94.7799345483863,96.12514747772258,96.12514747772258,1000000.0 2021-08-06,SPY,96.12514747772258,96.60577321511118,94.85199226651534,95.32863544373402,95.32863544373402,1000000.0 2021-08-09,SPY,95.32863544373402,96.46228541844306,94.85199226651534,95.98237355068962,95.98237355068962,1000000.0 -2021-08-10,SPY,95.98237355068962,96.46228541844306,94.64635998161884,95.12196983077271,95.12196983077271,1000000.0 -2021-08-11,SPY,95.12196983077271,96.14784416459558,94.64635998161884,95.66949668118964,95.66949668118964,1000000.0 -2021-08-12,SPY,95.66949668118964,96.14784416459558,94.18674754204086,94.66004778094559,94.66004778094559,1000000.0 -2021-08-13,SPY,94.66004778094559,96.59929593970587,94.18674754204086,96.11870242756804,96.11870242756804,1000000.0 +2021-08-10,SPY,95.98237355068962,96.46228541844306,94.64635998161884,95.12196983077273,95.12196983077273,1000000.0 +2021-08-11,SPY,95.12196983077273,96.14784416459558,94.64635998161884,95.66949668118964,95.66949668118964,1000000.0 +2021-08-12,SPY,95.66949668118964,96.14784416459558,94.18674754204086,94.6600477809456,94.6600477809456,1000000.0 +2021-08-13,SPY,94.6600477809456,96.59929593970588,94.18674754204086,96.11870242756804,96.11870242756804,1000000.0 2021-08-16,SPY,96.11870242756804,96.8840136799393,95.6381089154302,96.40200366163116,96.40200366163116,1000000.0 -2021-08-17,SPY,96.40200366163116,98.32155481384027,95.919993643323,97.83239284959231,97.83239284959231,1000000.0 -2021-08-18,SPY,97.83239284959231,98.32155481384027,96.58454471629719,97.06989418723336,97.06989418723336,1000000.0 +2021-08-17,SPY,96.40200366163116,98.32155481384028,95.919993643323,97.83239284959232,97.83239284959232,1000000.0 +2021-08-18,SPY,97.83239284959232,98.32155481384028,96.5845447162972,97.06989418723336,97.06989418723336,1000000.0 2021-08-19,SPY,97.06989418723336,97.55524365816952,96.1642543391321,96.64749179812272,96.64749179812272,1000000.0 -2021-08-20,SPY,96.64749179812272,97.9889231718283,96.1642543391321,97.50141609137145,97.50141609137145,1000000.0 -2021-08-23,SPY,97.50141609137145,97.9889231718283,94.42606889773332,94.9005717565159,94.9005717565159,1000000.0 +2021-08-20,SPY,96.64749179812272,97.9889231718283,96.1642543391321,97.50141609137144,97.50141609137144,1000000.0 +2021-08-23,SPY,97.50141609137144,97.9889231718283,94.42606889773332,94.9005717565159,94.9005717565159,1000000.0 2021-08-24,SPY,94.9005717565159,95.37507461529847,93.4614713324549,93.93112696729136,93.93112696729136,1000000.0 -2021-08-25,SPY,93.93112696729136,95.41932565289973,93.4614713324549,94.94460263970123,94.94460263970123,1000000.0 -2021-08-26,SPY,94.94460263970123,95.90251685815848,94.46987962650272,95.42538990861541,95.42538990861541,1000000.0 -2021-08-27,SPY,95.42538990861541,96.884536060241,94.94826295907234,96.40252344302588,96.40252344302588,1000000.0 -2021-08-30,SPY,96.40252344302588,96.884536060241,95.6621035025591,96.14281759051167,96.14281759051167,1000000.0 -2021-08-31,SPY,96.14281759051167,97.20890325302754,95.6621035025591,96.72527686868413,96.72527686868413,1000000.0 -2021-09-01,SPY,96.72527686868413,97.20890325302754,95.57906191923125,96.05935871279523,96.05935871279523,1000000.0 -2021-09-02,SPY,96.05935871279523,96.5396555063592,94.31442760535325,94.78836945261632,94.78836945261632,1000000.0 -2021-09-03,SPY,94.78836945261632,95.2623112998794,92.76655954081285,93.23272315659582,93.23272315659582,1000000.0 -2021-09-06,SPY,93.23272315659582,93.6988867723788,92.293076638756,92.75686094347337,92.75686094347337,1000000.0 -2021-09-07,SPY,92.75686094347337,93.22064524819072,91.82455221047114,92.28598212107653,92.28598212107653,1000000.0 -2021-09-08,SPY,92.28598212107653,92.7474120316819,91.28495076078015,91.74366910631171,91.74366910631171,1000000.0 +2021-08-25,SPY,93.93112696729136,95.41932565289972,93.4614713324549,94.94460263970124,94.94460263970124,1000000.0 +2021-08-26,SPY,94.94460263970124,95.90251685815848,94.46987962650272,95.4253899086154,95.4253899086154,1000000.0 +2021-08-27,SPY,95.4253899086154,96.884536060241,94.94826295907234,96.40252344302588,96.40252344302588,1000000.0 +2021-08-30,SPY,96.40252344302588,96.884536060241,95.6621035025591,96.14281759051168,96.14281759051168,1000000.0 +2021-08-31,SPY,96.14281759051168,97.20890325302754,95.6621035025591,96.72527686868412,96.72527686868412,1000000.0 +2021-09-01,SPY,96.72527686868412,97.20890325302754,95.57906191923124,96.05935871279524,96.05935871279524,1000000.0 +2021-09-02,SPY,96.05935871279524,96.5396555063592,94.31442760535325,94.78836945261632,94.78836945261632,1000000.0 +2021-09-03,SPY,94.78836945261632,95.2623112998794,92.76655954081284,93.23272315659582,93.23272315659582,1000000.0 +2021-09-07,SPY,92.75686094347336,93.22064524819072,91.82455221047114,92.28598212107651,92.28598212107651,1000000.0 +2021-09-08,SPY,92.28598212107651,92.7474120316819,91.28495076078016,91.74366910631171,91.74366910631171,1000000.0 2021-09-09,SPY,91.74366910631171,92.20238745184326,88.1374820560172,88.5803839758967,88.5803839758967,1000000.0 2021-09-10,SPY,88.5803839758967,89.02328589577617,86.42801366172382,86.86232528816464,86.86232528816464,1000000.0 2021-09-13,SPY,86.86232528816464,87.29663691460546,86.00328821339583,86.43546554110134,86.43546554110134,1000000.0 @@ -730,13 +705,13 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-10-15,SPY,87.42258516897087,88.7606876666151,86.98547224312601,88.31909220558717,88.31909220558717,1000000.0 2021-10-18,SPY,88.31909220558717,89.61979813514503,87.87749674455924,89.17392849268164,89.17392849268164,1000000.0 2021-10-19,SPY,89.17392849268164,90.80364013286348,88.72805885021823,90.3518807292174,90.3518807292174,1000000.0 -2021-10-20,SPY,90.3518807292174,90.81698895007125,89.9001213255713,90.36516313439927,90.36516313439927,1000000.0 -2021-10-21,SPY,90.36516313439927,91.10295011856714,89.91333731872727,90.64970161051457,90.64970161051457,1000000.0 -2021-10-22,SPY,90.64970161051457,91.10295011856714,89.71211611781499,90.16293077167336,90.16293077167336,1000000.0 +2021-10-20,SPY,90.3518807292174,90.81698895007123,89.9001213255713,90.36516313439928,90.36516313439928,1000000.0 +2021-10-21,SPY,90.36516313439928,91.10295011856714,89.91333731872727,90.64970161051455,90.64970161051455,1000000.0 +2021-10-22,SPY,90.64970161051455,91.10295011856714,89.71211611781499,90.16293077167336,90.16293077167336,1000000.0 2021-10-25,SPY,90.16293077167336,90.61374542553172,88.7209651191269,89.16679911470041,89.16679911470041,1000000.0 2021-10-26,SPY,89.16679911470041,90.05012737219032,88.7209651191269,89.60211678824908,89.60211678824908,1000000.0 -2021-10-27,SPY,89.60211678824908,90.32824635339489,89.15410620430784,89.87885209293025,89.87885209293025,1000000.0 -2021-10-28,SPY,89.87885209293025,90.32824635339489,88.48030644532074,88.92493110082486,88.92493110082486,1000000.0 +2021-10-27,SPY,89.60211678824908,90.32824635339487,89.15410620430784,89.87885209293025,89.87885209293025,1000000.0 +2021-10-28,SPY,89.87885209293025,90.32824635339487,88.48030644532074,88.92493110082486,88.92493110082486,1000000.0 2021-10-29,SPY,88.92493110082486,89.93016360838973,88.48030644532074,89.48274985909427,89.48274985909427,1000000.0 2021-11-01,SPY,89.48274985909427,89.93016360838973,87.5330163975554,87.97288080156322,87.97288080156322,1000000.0 2021-11-02,SPY,87.97288080156322,88.41274520557103,87.41143135191528,87.85068477579425,87.85068477579425,1000000.0 @@ -756,7 +731,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-11-22,SPY,88.02861355115124,88.46875661890698,87.08844092447922,87.52607128088363,87.52607128088363,1000000.0 2021-11-23,SPY,87.52607128088363,87.96370163728804,86.7899553703656,87.22608579936242,87.22608579936242,1000000.0 2021-11-24,SPY,87.22608579936242,87.86446676166682,86.7899553703656,87.42733011111127,87.42733011111127,1000000.0 -2021-11-25,SPY,87.42733011111127,87.86446676166682,85.7633551854301,86.19432681952775,86.19432681952775,1000000.0 2021-11-26,SPY,86.19432681952775,86.77668020075947,85.7633551854301,86.34495542364128,86.34495542364128,1000000.0 2021-11-29,SPY,86.34495542364128,86.9708411311519,85.91323064652308,86.53815037925563,86.53815037925563,1000000.0 2021-11-30,SPY,86.53815037925563,86.9708411311519,85.65866532358427,86.08911087797415,86.08911087797415,1000000.0 @@ -777,7 +751,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-12-21,SPY,84.33365777986754,84.75532606876686,82.46627744456173,82.88068084880575,82.88068084880575,1000000.0 2021-12-22,SPY,82.88068084880575,83.29508425304978,82.19543827316186,82.60848067654457,82.60848067654457,1000000.0 2021-12-23,SPY,82.60848067654457,83.02152307992729,80.43027685226242,80.83444909775118,80.83444909775118,1000000.0 -2021-12-24,SPY,80.83444909775118,81.23862134323993,80.24714823980194,80.65040024100698,80.65040024100698,1000000.0 2021-12-27,SPY,80.65040024100698,81.05365224221201,78.57061092626967,78.96543811685393,78.96543811685393,1000000.0 2021-12-28,SPY,78.96543811685393,79.3602653074382,78.39101582411178,78.7849405267455,78.7849405267455,1000000.0 2021-12-29,SPY,78.7849405267455,80.39520399879814,78.39101582411178,79.99522785950064,79.99522785950064,1000000.0 @@ -793,7 +766,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-01-12,SPY,79.13729995241877,79.53298645218085,78.56457139328872,78.95936823446102,78.95936823446102,1000000.0 2022-01-13,SPY,78.95936823446102,79.41464550131344,78.56457139328872,79.01954776250095,79.01954776250095,1000000.0 2022-01-14,SPY,79.01954776250095,80.5408509067106,78.62445002368844,80.14015015593095,80.14015015593095,1000000.0 -2022-01-17,SPY,80.14015015593095,82.01760069879971,79.7394494051513,81.60955293412907,81.60955293412907,1000000.0 2022-01-18,SPY,81.60955293412907,82.01760069879971,80.67170143223079,81.0770868665636,81.0770868665636,1000000.0 2022-01-19,SPY,81.0770868665636,81.48247230089642,79.75703977991714,80.15782892453984,80.15782892453984,1000000.0 2022-01-20,SPY,80.15782892453984,80.55861806916253,78.37825710824946,78.77211769673312,78.77211769673312,1000000.0 @@ -818,7 +790,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-02-16,SPY,76.81624158850454,77.20032279644705,75.75052700454295,76.13118291913864,76.13118291913864,1000000.0 2022-02-17,SPY,76.13118291913864,76.51183883373432,75.72811933342724,76.10866264666055,76.10866264666055,1000000.0 2022-02-18,SPY,76.10866264666055,76.48920595989384,75.25381781195097,75.63197770045323,75.63197770045323,1000000.0 -2022-02-21,SPY,75.63197770045323,76.01013758895549,75.13321001378593,75.51076383295069,75.51076383295069,1000000.0 2022-02-22,SPY,75.51076383295069,75.88831765211543,75.07499399030006,75.45225526663323,75.45225526663323,1000000.0 2022-02-23,SPY,75.45225526663323,76.31660728592855,75.07499399030006,75.93692267256573,75.93692267256573,1000000.0 2022-02-24,SPY,75.93692267256573,76.31660728592855,75.10801787348781,75.48544509898272,75.48544509898272,1000000.0 @@ -857,7 +828,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-04-12,SPY,77.74308596323893,78.38689853818664,77.35437053342272,77.99691396834493,77.99691396834493,1000000.0 2022-04-13,SPY,77.99691396834493,78.46820722254769,77.6069293985032,78.07781813188825,78.07781813188825,1000000.0 2022-04-14,SPY,78.07781813188825,79.47403840113452,77.68742904122881,79.07864517525825,79.07864517525825,1000000.0 -2022-04-15,SPY,79.07864517525825,80.75205472117372,78.68325194938195,80.350303205148,80.350303205148,1000000.0 2022-04-18,SPY,80.350303205148,80.75205472117372,79.66799931454335,80.06834101964157,80.06834101964157,1000000.0 2022-04-19,SPY,80.06834101964157,83.57834236665765,79.66799931454335,83.16252971806732,83.16252971806732,1000000.0 2022-04-20,SPY,83.16252971806732,83.58165152090909,82.74671706947699,83.16582240886477,83.16582240886477,1000000.0 @@ -888,22 +858,20 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-05-25,SPY,86.76123697851956,88.34222210778485,86.32743079362696,87.90270856496007,87.90270856496007,1000000.0 2022-05-26,SPY,87.90270856496007,89.11717789457333,87.46319502213527,88.67380885032173,88.67380885032173,1000000.0 2022-05-27,SPY,88.67380885032173,89.11717789457333,87.58239700827181,88.02250955605207,88.02250955605207,1000000.0 -2022-05-30,SPY,88.02250955605207,90.48971346629517,87.58239700827181,90.03951588686088,90.03951588686088,1000000.0 -2022-05-31,SPY,90.03951588686088,90.83959033793381,89.58931830742658,90.3876520775461,90.3876520775461,1000000.0 -2022-06-01,SPY,90.3876520775461,90.83959033793381,89.4397634625755,89.88920951012614,89.88920951012614,1000000.0 +2022-05-31,SPY,90.03951588686088,90.8395903379338,89.58931830742658,90.3876520775461,90.3876520775461,1000000.0 +2022-06-01,SPY,90.3876520775461,90.8395903379338,89.4397634625755,89.88920951012614,89.88920951012614,1000000.0 2022-06-02,SPY,89.88920951012614,90.33865555767676,89.16080988156013,89.60885415232174,89.60885415232174,1000000.0 2022-06-03,SPY,89.60885415232174,90.05689842308334,88.74777039657354,89.19373909203371,89.19373909203371,1000000.0 -2022-06-06,SPY,89.19373909203371,91.58465680507395,88.74777039657354,91.12901174634224,91.12901174634224,1000000.0 -2022-06-07,SPY,91.12901174634224,91.79663354021366,90.67336668761052,91.33993387085937,91.33993387085937,1000000.0 -2022-06-08,SPY,91.33993387085937,91.87866263085215,90.88323420150508,91.42155485656932,91.42155485656932,1000000.0 -2022-06-09,SPY,91.42155485656932,91.87866263085215,89.05041029390297,89.4978997928673,89.4978997928673,1000000.0 -2022-06-10,SPY,89.4978997928673,90.63503182403191,89.05041029390297,90.18411126769345,90.18411126769345,1000000.0 -2022-06-13,SPY,90.18411126769345,90.63503182403191,88.81235383917343,89.25864707454616,89.25864707454616,1000000.0 +2022-06-06,SPY,89.19373909203371,91.58465680507396,88.74777039657354,91.12901174634224,91.12901174634224,1000000.0 +2022-06-07,SPY,91.12901174634224,91.79663354021366,90.67336668761052,91.33993387085935,91.33993387085935,1000000.0 +2022-06-08,SPY,91.33993387085935,91.87866263085216,90.88323420150508,91.42155485656932,91.42155485656932,1000000.0 +2022-06-09,SPY,91.42155485656932,91.87866263085216,89.05041029390297,89.4978997928673,89.4978997928673,1000000.0 +2022-06-10,SPY,89.4978997928673,90.63503182403193,89.05041029390297,90.18411126769344,90.18411126769344,1000000.0 +2022-06-13,SPY,90.18411126769344,90.63503182403193,88.81235383917343,89.25864707454616,89.25864707454616,1000000.0 2022-06-14,SPY,89.25864707454616,89.70494030991888,87.8381743553713,88.27957221645356,88.27957221645356,1000000.0 2022-06-15,SPY,88.27957221645356,89.29021024069633,87.8381743553713,88.84598033900133,88.84598033900133,1000000.0 2022-06-16,SPY,88.84598033900133,89.29021024069633,87.66543426548472,88.1059640859143,88.1059640859143,1000000.0 2022-06-17,SPY,88.1059640859143,89.31380957666045,87.66543426548472,88.86946226533378,88.86946226533378,1000000.0 -2022-06-20,SPY,88.86946226533378,90.3458469122756,88.42511495400711,89.89636508684141,89.89636508684141,1000000.0 2022-06-21,SPY,89.89636508684141,90.3458469122756,87.88428594759857,88.3259155252247,88.3259155252247,1000000.0 2022-06-22,SPY,88.3259155252247,88.76754510285082,87.78844766665621,88.22959564488062,88.22959564488062,1000000.0 2022-06-23,SPY,88.22959564488062,88.67074362310501,86.44096857477524,86.87534530128165,86.87534530128165,1000000.0 @@ -913,7 +881,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-06-29,SPY,86.50036597605467,88.53764107617381,86.06786414617439,88.09715529967545,88.09715529967545,1000000.0 2022-06-30,SPY,88.09715529967545,88.53764107617381,87.40751734376352,87.84675109925982,87.84675109925982,1000000.0 2022-07-01,SPY,87.84675109925982,88.28598485475611,86.40154968002723,86.83572832163541,86.83572832163541,1000000.0 -2022-07-04,SPY,86.83572832163541,87.26990696324359,86.31578054768032,86.74952818862344,86.74952818862344,1000000.0 2022-07-05,SPY,86.74952818862344,87.89828281656735,86.31578054768032,87.46097792693269,87.46097792693269,1000000.0 2022-07-06,SPY,87.46097792693269,87.89828281656735,85.85422952282775,86.28565781188719,86.28565781188719,1000000.0 2022-07-07,SPY,86.28565781188719,86.73940478120066,85.85422952282775,86.30786545393102,86.30786545393102,1000000.0 @@ -958,7 +925,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-08-31,SPY,86.19220098733291,86.68152720649894,85.76123998239625,86.25027582736215,86.25027582736215,1000000.0 2022-09-01,SPY,86.25027582736215,88.34350365782625,85.81902444822533,87.90398373913061,87.90398373913061,1000000.0 2022-09-02,SPY,87.90398373913061,88.34350365782625,86.63269231055175,87.06803247291633,87.06803247291633,1000000.0 -2022-09-05,SPY,87.06803247291633,88.339550719938,86.63269231055175,87.90005046760001,87.90005046760001,1000000.0 2022-09-06,SPY,87.90005046760001,88.339550719938,87.06561040174611,87.50312603190564,87.50312603190564,1000000.0 2022-09-07,SPY,87.50312603190564,87.94064166206516,86.40601693526429,86.84021802539125,86.84021802539125,1000000.0 2022-09-08,SPY,86.84021802539125,87.8073798327382,86.40601693526429,87.37052719675444,87.37052719675444,1000000.0 @@ -1016,7 +982,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-11-21,SPY,83.29606484261076,83.7125451668238,81.80629874653374,82.21738567490829,82.21738567490829,1000000.0 2022-11-22,SPY,82.21738567490829,82.62847260328282,80.485381229165,80.88983038107035,80.88983038107035,1000000.0 2022-11-23,SPY,80.88983038107035,81.3008976649616,80.485381229165,80.89641558702647,80.89641558702647,1000000.0 -2022-11-24,SPY,80.89641558702647,81.3008976649616,80.25728857242396,80.66059153007433,80.66059153007433,1000000.0 2022-11-25,SPY,80.66059153007433,81.35159502900389,80.25728857242396,80.94686072537701,80.94686072537701,1000000.0 2022-11-28,SPY,80.94686072537701,81.35159502900389,79.73282934240261,80.13349682653529,80.13349682653529,1000000.0 2022-11-29,SPY,80.13349682653529,82.6296908625545,79.73282934240261,82.21859787318856,82.21859787318856,1000000.0 @@ -1038,12 +1003,10 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-12-21,SPY,85.65124854053612,87.33544261956871,85.22299229783343,86.90093792991912,86.90093792991912,1000000.0 2022-12-22,SPY,86.90093792991912,87.33544261956871,85.32982155091176,85.75861462403192,85.75861462403192,1000000.0 2022-12-23,SPY,85.75861462403192,86.18740769715207,84.94494230011692,85.37180130665017,85.37180130665017,1000000.0 -2022-12-26,SPY,85.37180130665017,85.7986603131834,84.60136285857297,85.02649533524921,85.02649533524921,1000000.0 2022-12-27,SPY,85.02649533524921,85.45162781192545,84.37064803736334,84.79462114307873,84.79462114307873,1000000.0 2022-12-28,SPY,84.79462114307873,85.21859424879412,82.38284453868378,82.79682868209426,82.79682868209426,1000000.0 2022-12-29,SPY,82.79682868209426,83.21081282550472,82.32717576998728,82.74088017084149,82.74088017084149,1000000.0 2022-12-30,SPY,82.74088017084149,83.1545845716957,80.0018311890915,80.40385044129799,80.40385044129799,1000000.0 -2023-01-02,SPY,80.40385044129799,81.07061310144957,80.0018311890915,80.66727671786028,80.66727671786028,1000000.0 2023-01-03,SPY,80.66727671786028,81.53146494966413,80.26394033427097,81.12583577081008,81.12583577081008,1000000.0 2023-01-04,SPY,81.12583577081008,82.04461971536801,80.72020659195603,81.63643752772937,81.63643752772937,1000000.0 2023-01-05,SPY,81.63643752772937,82.04461971536801,80.2793431711043,80.68275695588372,80.68275695588372,1000000.0 @@ -1053,7 +1016,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-01-11,SPY,79.84346400993289,80.24268132998255,78.69323452896255,79.08867791855532,79.08867791855532,1000000.0 2023-01-12,SPY,79.08867791855532,79.48412130814809,78.05290014424679,78.44512577311235,78.44512577311235,1000000.0 2023-01-13,SPY,78.44512577311235,78.9979562205737,78.05290014424679,78.6049315627599,78.6049315627599,1000000.0 -2023-01-16,SPY,78.6049315627599,79.67708976273568,78.2119069049461,79.28068633108029,79.28068633108029,1000000.0 2023-01-17,SPY,79.28068633108029,79.67708976273568,77.2681194884038,77.65640149588322,77.65640149588322,1000000.0 2023-01-18,SPY,77.65640149588322,78.04468350336262,76.87561102450587,77.26192062764409,77.26192062764409,1000000.0 2023-01-19,SPY,77.26192062764409,78.37683989633454,76.87561102450587,77.98690536948712,77.98690536948712,1000000.0 @@ -1078,7 +1040,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-02-15,SPY,80.73095252074614,81.13460728334987,79.72377246051438,80.12439443267778,80.12439443267778,1000000.0 2023-02-16,SPY,80.12439443267778,80.52501640484115,79.3522670943662,79.75102220539317,79.75102220539317,1000000.0 2023-02-17,SPY,79.75102220539317,81.45914992504369,79.3522670943662,81.05388052243154,81.05388052243154,1000000.0 -2023-02-20,SPY,81.05388052243154,81.63714185211403,80.64861111981938,81.23098691752641,81.23098691752641,1000000.0 2023-02-21,SPY,81.23098691752641,81.63714185211403,80.62415612363772,81.02930263682182,81.02930263682182,1000000.0 2023-02-22,SPY,81.02930263682182,81.43444915000593,79.74455635663205,80.14528277048447,80.14528277048447,1000000.0 2023-02-23,SPY,80.14528277048447,80.54600918433688,79.28371040268813,79.68212100772676,79.68212100772676,1000000.0 @@ -1112,7 +1073,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-04-04,SPY,80.72109298992872,82.84205611924185,80.31748752497907,82.42990658631031,82.42990658631031,1000000.0 2023-04-05,SPY,82.42990658631031,83.62095261491471,82.01775705337876,83.20492797503952,83.20492797503952,1000000.0 2023-04-06,SPY,83.20492797503952,83.62095261491471,82.48656290720754,82.90106824844979,82.90106824844979,1000000.0 -2023-04-07,SPY,82.90106824844979,84.43877205995369,82.48656290720754,84.0186786666206,84.0186786666206,1000000.0 2023-04-10,SPY,84.0186786666206,84.43877205995369,83.45349647990318,83.87286078382229,83.87286078382229,1000000.0 2023-04-11,SPY,83.87286078382229,84.29222508774139,82.82873893585659,83.24496375462974,83.24496375462974,1000000.0 2023-04-12,SPY,83.24496375462974,84.071028592019,82.82873893585659,83.65276476817812,83.65276476817812,1000000.0 @@ -1148,7 +1108,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-05-24,SPY,87.3815361818424,88.02686192056858,86.94462850093319,87.58891733389909,87.58891733389909,1000000.0 2023-05-25,SPY,87.58891733389909,88.28634103137877,87.15097274722959,87.84710550385948,87.84710550385948,1000000.0 2023-05-26,SPY,87.84710550385948,89.90305818264741,87.40786997634018,89.45577928621634,89.45577928621634,1000000.0 -2023-05-29,SPY,89.45577928621634,89.90305818264741,88.43716302606782,88.88157088047018,88.88157088047018,1000000.0 2023-05-30,SPY,88.88157088047018,89.35233896860917,88.43716302606782,88.90779996876536,88.90779996876536,1000000.0 2023-05-31,SPY,88.90779996876536,89.71392681956809,88.46326096892153,89.26758887519213,89.26758887519213,1000000.0 2023-06-01,SPY,89.26758887519213,89.71392681956809,87.33733759711343,87.77621869056627,87.77621869056627,1000000.0 @@ -1163,7 +1122,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-06-14,SPY,83.65914268095823,84.77341963564004,83.24084696755344,84.35166132899508,84.35166132899508,1000000.0 2023-06-15,SPY,84.35166132899508,84.77341963564004,82.80550413367159,83.22161219464482,83.22161219464482,1000000.0 2023-06-16,SPY,83.22161219464482,84.17086890323807,82.80550413367159,83.75210836143093,83.75210836143093,1000000.0 -2023-06-19,SPY,83.75210836143093,84.46876861530585,83.33334781962377,84.04852598537896,84.04852598537896,1000000.0 2023-06-20,SPY,84.04852598537896,86.22540767520353,83.62828335545207,85.7964255474662,85.7964255474662,1000000.0 2023-06-21,SPY,85.7964255474662,86.22540767520353,84.99729799441346,85.42442009488789,85.42442009488789,1000000.0 2023-06-22,SPY,85.42442009488789,86.97786764543422,84.99729799441346,86.54514193575545,86.54514193575545,1000000.0 @@ -1174,7 +1132,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-06-29,SPY,87.07353566535772,87.50890334368451,85.49830646275335,85.92794619372197,85.92794619372197,1000000.0 2023-06-30,SPY,85.92794619372197,86.35758592469057,85.12854504463644,85.55632667802658,85.55632667802658,1000000.0 2023-07-03,SPY,85.55632667802658,86.78156874765247,85.12854504463644,86.34981964940545,86.34981964940545,1000000.0 -2023-07-04,SPY,86.34981964940545,87.79054555083778,85.91807055115842,87.35377666750028,87.35377666750028,1000000.0 2023-07-05,SPY,87.35377666750028,87.79054555083778,86.21974940835803,86.65301448076184,86.65301448076184,1000000.0 2023-07-06,SPY,86.65301448076184,87.09324526590991,86.21974940835803,86.65994553821884,86.65994553821884,1000000.0 2023-07-07,SPY,86.65994553821884,87.09324526590991,86.03837878199161,86.47073244421267,86.47073244421267,1000000.0 @@ -1218,7 +1175,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-08-30,SPY,82.34283266571592,83.32604776787839,81.93111850238733,82.91149031629692,82.91149031629692,1000000.0 2023-08-31,SPY,82.91149031629692,84.933510248666,82.49693286471543,84.51095547130946,84.51095547130946,1000000.0 2023-09-01,SPY,84.51095547130946,84.933510248666,83.1665392753107,83.58446158322683,83.58446158322683,1000000.0 -2023-09-04,SPY,83.58446158322683,84.32096515286123,83.1665392753107,83.90145786354353,83.90145786354353,1000000.0 2023-09-05,SPY,83.90145786354353,84.32096515286123,83.29953483427623,83.71812546158415,83.71812546158415,1000000.0 2023-09-06,SPY,83.71812546158415,84.86809627318225,83.29953483427623,84.4458669384898,84.4458669384898,1000000.0 2023-09-07,SPY,84.4458669384898,85.2845909159449,84.02363760379735,84.8602894686019,84.8602894686019,1000000.0 @@ -1229,8 +1185,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-09-14,SPY,85.77138683435287,86.58516592297948,85.3425299001811,86.15439395321343,86.15439395321343,1000000.0 2023-09-15,SPY,86.15439395321343,86.96258238382663,85.72362198344736,86.52993272022552,86.52993272022552,1000000.0 2023-09-18,SPY,86.52993272022552,89.108730160307,86.09728305662439,88.66540314458409,88.66540314458409,1000000.0 -2023-09-19,SPY,88.66540314458409,90.65133267990673,88.22207612886118,90.20033102478283,90.20033102478283,1000000.0 -2023-09-20,SPY,90.20033102478283,90.65133267990673,88.73769506714504,89.18361313280909,89.18361313280909,1000000.0 +2023-09-19,SPY,88.66540314458409,90.65133267990672,88.22207612886118,90.20033102478284,90.20033102478284,1000000.0 +2023-09-20,SPY,90.20033102478284,90.65133267990672,88.73769506714504,89.18361313280909,89.18361313280909,1000000.0 2023-09-21,SPY,89.18361313280909,89.9799711606638,88.73769506714504,89.5323096126008,89.5323096126008,1000000.0 2023-09-22,SPY,89.5323096126008,89.9799711606638,87.94196699389565,88.38388642602578,88.38388642602578,1000000.0 2023-09-25,SPY,88.38388642602578,88.8258058581559,86.53252562089885,86.96736243306417,86.96736243306417,1000000.0 @@ -1258,4 +1214,4 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-10-25,SPY,86.55976511890006,88.85336329972124,86.12696629330556,88.4113067658918,88.4113067658918,1000000.0 2023-10-26,SPY,88.4113067658918,89.50878384550222,87.96925023206234,89.06346651293754,89.06346651293754,1000000.0 2023-10-27,SPY,89.06346651293754,91.2091756573824,88.61814918037285,90.7553986640621,90.7553986640621,1000000.0 -2023-10-30,SPY,90.7553986640621,92.65561662301288,90.30162167074178,92.19464340598297,92.19464340598297,1000000.0 +2023-10-30,SPY,90.7553986640621,92.65561662301288,90.30162167074178,92.19464340598296,92.19464340598296,1000000.0 diff --git a/data/raw/TLT.csv b/data/raw/TLT.csv index 6cbf151..7fe2b1f 100644 --- a/data/raw/TLT.csv +++ b/data/raw/TLT.csv @@ -1,344 +1,331 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume -2019-01-01,TLT,101.64613726003635,102.15436794633652,101.13790657373617,101.64613726003635,101.64613726003635,1000000.0 -2019-01-02,TLT,101.64613726003635,102.15436794633652,99.09110474216797,99.58904999212861,99.58904999212861,1000000.0 -2019-01-03,TLT,99.58904999212861,100.42232579299005,99.09110474216797,99.92271223183091,99.92271223183091,1000000.0 -2019-01-04,TLT,99.92271223183091,100.42232579299005,98.97252741939258,99.46987680340962,99.46987680340962,1000000.0 +2019-01-02,TLT,101.64613726003635,102.15436794633652,99.09110474216796,99.5890499921286,99.5890499921286,1000000.0 +2019-01-03,TLT,99.5890499921286,100.42232579299004,99.09110474216796,99.92271223183091,99.92271223183091,1000000.0 +2019-01-04,TLT,99.92271223183091,100.42232579299004,98.97252741939258,99.46987680340962,99.46987680340962,1000000.0 2019-01-07,TLT,99.46987680340962,99.96722618742666,98.61477679679204,99.11032843898698,99.11032843898698,1000000.0 -2019-01-08,TLT,99.11032843898698,99.60588008118191,98.44483486598519,98.93953252862833,98.93953252862833,1000000.0 -2019-01-09,TLT,98.93953252862833,99.43423019127147,96.86676233872836,97.35352998867172,97.35352998867172,1000000.0 -2019-01-10,TLT,97.35352998867172,97.84029763861507,96.6871966705828,97.17306198048522,97.17306198048522,1000000.0 -2019-01-11,TLT,97.17306198048522,97.65892729038764,96.02026726253317,96.50278116837504,96.50278116837504,1000000.0 -2019-01-14,TLT,96.50278116837504,99.59812767514173,96.02026726253317,99.10261460213108,99.10261460213108,1000000.0 -2019-01-15,TLT,99.10261460213108,99.77819364357696,98.60710152912043,99.28178471997708,99.28178471997708,1000000.0 +2019-01-08,TLT,99.11032843898698,99.60588008118192,98.4448348659852,98.93953252862832,98.93953252862832,1000000.0 +2019-01-09,TLT,98.93953252862832,99.43423019127148,96.86676233872836,97.35352998867172,97.35352998867172,1000000.0 +2019-01-10,TLT,97.35352998867172,97.84029763861508,96.6871966705828,97.17306198048522,97.17306198048522,1000000.0 +2019-01-11,TLT,97.17306198048522,97.65892729038764,96.02026726253315,96.50278116837504,96.50278116837504,1000000.0 +2019-01-14,TLT,96.50278116837504,99.59812767514173,96.02026726253315,99.10261460213108,99.10261460213108,1000000.0 +2019-01-15,TLT,99.10261460213108,99.77819364357696,98.60710152912044,99.28178471997708,99.28178471997708,1000000.0 2019-01-16,TLT,99.28178471997708,99.77819364357696,98.50709038466566,99.00210088911122,99.00210088911122,1000000.0 -2019-01-17,TLT,99.00210088911122,99.49711139355676,98.28566916911953,98.77956700414023,98.77956700414023,1000000.0 +2019-01-17,TLT,99.00210088911122,99.49711139355676,98.28566916911952,98.77956700414023,98.77956700414023,1000000.0 2019-01-18,TLT,98.77956700414023,99.27346483916092,97.76179524697086,98.25306054971946,98.25306054971946,1000000.0 -2019-01-21,TLT,98.25306054971946,98.74432585246805,96.94004112651886,97.42717701157675,97.42717701157675,1000000.0 -2019-01-22,TLT,97.42717701157675,97.91431289663461,96.63744029735365,97.1230555752298,97.1230555752298,1000000.0 -2019-01-23,TLT,97.1230555752298,97.98573367112068,96.63744029735365,97.49824245882657,97.49824245882657,1000000.0 -2019-01-24,TLT,97.49824245882657,97.98573367112068,96.82578967897587,97.31235143615665,97.31235143615665,1000000.0 -2019-01-25,TLT,97.31235143615665,98.55113337698948,96.82578967897587,98.06082923083532,98.06082923083532,1000000.0 -2019-01-28,TLT,98.06082923083532,98.55113337698948,97.41469127240133,97.90421233407169,97.90421233407169,1000000.0 -2019-01-29,TLT,97.90421233407169,98.41283116232374,97.41469127240133,97.9232150868893,97.9232150868893,1000000.0 -2019-01-30,TLT,97.9232150868893,99.63741636470465,97.43359901145486,99.14170782557677,99.14170782557677,1000000.0 -2019-01-31,TLT,99.14170782557677,100.07286839125562,98.64599928644888,99.57499342413496,99.57499342413496,1000000.0 -2019-02-01,TLT,99.57499342413496,100.07286839125562,98.67747379066405,99.1733404931297,99.1733404931297,1000000.0 -2019-02-04,TLT,99.1733404931297,99.66920719559533,98.53324259572449,99.02838451831607,99.02838451831607,1000000.0 -2019-02-05,TLT,99.02838451831607,99.95481800250806,98.53324259572449,99.4575303507543,99.4575303507543,1000000.0 -2019-02-06,TLT,99.4575303507543,101.51422822198566,98.96024269900053,101.0091823104335,101.0091823104335,1000000.0 +2019-01-22,TLT,97.42717701157676,97.9143128966346,96.63744029735363,97.1230555752298,97.1230555752298,1000000.0 +2019-01-23,TLT,97.1230555752298,97.98573367112068,96.63744029735363,97.49824245882655,97.49824245882655,1000000.0 +2019-01-24,TLT,97.49824245882655,97.98573367112068,96.82578967897588,97.31235143615665,97.31235143615665,1000000.0 +2019-01-25,TLT,97.31235143615665,98.55113337698948,96.82578967897588,98.06082923083532,98.06082923083532,1000000.0 +2019-01-28,TLT,98.06082923083532,98.55113337698948,97.41469127240131,97.90421233407169,97.90421233407169,1000000.0 +2019-01-29,TLT,97.90421233407169,98.41283116232374,97.41469127240131,97.9232150868893,97.9232150868893,1000000.0 +2019-01-30,TLT,97.9232150868893,99.63741636470463,97.43359901145486,99.14170782557676,99.14170782557676,1000000.0 +2019-01-31,TLT,99.14170782557676,100.07286839125562,98.64599928644888,99.57499342413496,99.57499342413496,1000000.0 +2019-02-01,TLT,99.57499342413496,100.07286839125562,98.67747379066404,99.1733404931297,99.1733404931297,1000000.0 +2019-02-04,TLT,99.1733404931297,99.66920719559532,98.53324259572447,99.02838451831607,99.02838451831607,1000000.0 +2019-02-05,TLT,99.02838451831607,99.95481800250806,98.53324259572447,99.4575303507543,99.4575303507543,1000000.0 +2019-02-06,TLT,99.4575303507543,101.51422822198566,98.96024269900052,101.0091823104335,101.0091823104335,1000000.0 2019-02-07,TLT,101.0091823104335,101.51422822198566,100.28758636276518,100.7915440831811,100.7915440831811,1000000.0 -2019-02-08,TLT,100.7915440831811,101.29550180359699,100.09236931525204,100.59534604547945,100.59534604547945,1000000.0 -2019-02-11,TLT,100.59534604547945,101.91223940542048,100.09236931525204,101.40521333872685,101.40521333872685,1000000.0 -2019-02-12,TLT,101.40521333872685,101.91223940542048,100.18518085591082,100.68862397578977,100.68862397578977,1000000.0 -2019-02-13,TLT,100.68862397578977,101.1920670956687,99.95164511633618,100.45391468978511,100.45391468978511,1000000.0 -2019-02-14,TLT,100.45391468978511,101.67148856047008,99.95164511633618,101.16566025917422,101.16566025917422,1000000.0 -2019-02-15,TLT,101.16566025917422,102.14462645316559,100.65983195787834,101.63644423200557,101.63644423200557,1000000.0 -2019-02-18,TLT,101.63644423200557,102.21943735137901,101.12826201084555,101.71088293669554,101.71088293669554,1000000.0 -2019-02-19,TLT,101.71088293669554,102.7688903197519,101.20232852201207,102.25760230821086,102.25760230821086,1000000.0 -2019-02-20,TLT,102.25760230821086,102.7688903197519,99.47012045018646,99.96997030169493,99.96997030169493,1000000.0 -2019-02-21,TLT,99.96997030169493,101.29406690139807,99.47012045018646,100.79011631979908,100.79011631979908,1000000.0 -2019-02-22,TLT,100.79011631979908,101.29406690139807,99.51920081322247,100.01929729972107,100.01929729972107,1000000.0 -2019-02-25,TLT,100.01929729972107,100.51939378621967,98.1995506813498,98.69301576015056,98.69301576015056,1000000.0 -2019-02-26,TLT,98.69301576015056,99.40608103585629,98.1995506813498,98.91152341876248,98.91152341876248,1000000.0 -2019-02-27,TLT,98.91152341876248,99.96475244431814,98.41696580166867,99.46741536748074,99.46741536748074,1000000.0 +2019-02-08,TLT,100.7915440831811,101.295501803597,100.09236931525204,100.59534604547945,100.59534604547945,1000000.0 +2019-02-11,TLT,100.59534604547945,101.91223940542048,100.09236931525204,101.40521333872684,101.40521333872684,1000000.0 +2019-02-12,TLT,101.40521333872684,101.91223940542048,100.18518085591082,100.68862397578977,100.68862397578977,1000000.0 +2019-02-13,TLT,100.68862397578977,101.1920670956687,99.95164511633618,100.45391468978512,100.45391468978512,1000000.0 +2019-02-14,TLT,100.45391468978512,101.67148856047008,99.95164511633618,101.16566025917422,101.16566025917422,1000000.0 +2019-02-15,TLT,101.16566025917422,102.1446264531656,100.65983195787834,101.63644423200556,101.63644423200556,1000000.0 +2019-02-19,TLT,101.71088293669554,102.7688903197519,101.20232852201208,102.25760230821086,102.25760230821086,1000000.0 +2019-02-20,TLT,102.25760230821086,102.7688903197519,99.47012045018646,99.96997030169491,99.96997030169491,1000000.0 +2019-02-21,TLT,99.96997030169491,101.29406690139808,99.47012045018646,100.79011631979908,100.79011631979908,1000000.0 +2019-02-22,TLT,100.79011631979908,101.29406690139808,99.51920081322248,100.01929729972107,100.01929729972107,1000000.0 +2019-02-25,TLT,100.01929729972107,100.51939378621968,98.1995506813498,98.69301576015056,98.69301576015056,1000000.0 +2019-02-26,TLT,98.69301576015056,99.40608103585627,98.1995506813498,98.91152341876248,98.91152341876248,1000000.0 +2019-02-27,TLT,98.91152341876248,99.96475244431814,98.41696580166868,99.46741536748074,99.46741536748074,1000000.0 2019-02-28,TLT,99.46741536748074,99.96475244431814,98.61855468726544,99.11412531383462,99.11412531383462,1000000.0 2019-03-01,TLT,99.11412531383462,99.60969594040378,97.77297196755686,98.2642934347305,98.2642934347305,1000000.0 -2019-03-04,TLT,98.2642934347305,98.77625685089784,97.77297196755686,98.28483268746055,98.28483268746055,1000000.0 -2019-03-05,TLT,98.28483268746055,98.77625685089784,97.7521505171224,98.24336735389186,98.24336735389186,1000000.0 -2019-03-06,TLT,98.24336735389186,99.85109911486374,97.7521505171224,99.35432747747637,99.35432747747637,1000000.0 -2019-03-07,TLT,99.35432747747637,100.44992366810004,98.85755584008899,99.95017280407966,99.95017280407966,1000000.0 -2019-03-08,TLT,99.95017280407966,100.60579461276747,99.45042194005926,100.10526827141042,100.10526827141042,1000000.0 +2019-03-04,TLT,98.2642934347305,98.77625685089784,97.77297196755686,98.28483268746056,98.28483268746056,1000000.0 +2019-03-05,TLT,98.28483268746056,98.77625685089784,97.7521505171224,98.24336735389186,98.24336735389186,1000000.0 +2019-03-06,TLT,98.24336735389186,99.85109911486374,97.7521505171224,99.35432747747636,99.35432747747636,1000000.0 +2019-03-07,TLT,99.35432747747636,100.44992366810004,98.857555840089,99.95017280407966,99.95017280407966,1000000.0 +2019-03-08,TLT,99.95017280407966,100.60579461276748,99.45042194005926,100.10526827141042,100.10526827141042,1000000.0 2019-03-11,TLT,100.10526827141042,101.5044786534705,99.60474193005336,100.99948124723434,100.99948124723434,1000000.0 -2019-03-12,TLT,100.99948124723434,101.5044786534705,100.32938813953737,100.83355591913303,100.83355591913303,1000000.0 -2019-03-13,TLT,100.83355591913303,101.33772369872868,99.58897421897584,100.08942132560385,100.08942132560385,1000000.0 -2019-03-14,TLT,100.08942132560385,101.06097096985388,99.58897421897584,100.55818006950636,100.55818006950636,1000000.0 -2019-03-15,TLT,100.55818006950636,101.53304530694402,100.05538916915883,101.02790577805376,101.02790577805376,1000000.0 +2019-03-12,TLT,100.99948124723434,101.5044786534705,100.32938813953736,100.83355591913303,100.83355591913303,1000000.0 +2019-03-13,TLT,100.83355591913303,101.33772369872868,99.58897421897584,100.08942132560384,100.08942132560384,1000000.0 +2019-03-14,TLT,100.08942132560384,101.06097096985388,99.58897421897584,100.55818006950636,100.55818006950636,1000000.0 +2019-03-15,TLT,100.55818006950636,101.53304530694402,100.05538916915884,101.02790577805376,101.02790577805376,1000000.0 2019-03-18,TLT,101.02790577805376,101.53304530694402,100.35015304953944,100.85442517541652,100.85442517541652,1000000.0 -2019-03-19,TLT,100.85442517541652,101.35869730129359,99.72367705526426,100.2248010605671,100.2248010605671,1000000.0 +2019-03-19,TLT,100.85442517541652,101.3586973012936,99.72367705526426,100.2248010605671,100.2248010605671,1000000.0 2019-03-20,TLT,100.2248010605671,100.91074833868754,99.72367705526426,100.40870481461448,100.40870481461448,1000000.0 -2019-03-21,TLT,100.40870481461448,100.91074833868754,97.93316035552301,98.42528678947036,98.42528678947036,1000000.0 -2019-03-22,TLT,98.42528678947036,99.46504642985542,97.93316035552301,98.97019545259246,98.97019545259246,1000000.0 -2019-03-25,TLT,98.97019545259246,99.85680765647012,98.4753444753295,99.36000761837825,99.36000761837825,1000000.0 -2019-03-26,TLT,99.36000761837825,99.85680765647012,97.5754861997087,98.06581527608915,98.06581527608915,1000000.0 +2019-03-21,TLT,100.40870481461448,100.91074833868754,97.933160355523,98.42528678947036,98.42528678947036,1000000.0 +2019-03-22,TLT,98.42528678947036,99.46504642985542,97.933160355523,98.97019545259246,98.97019545259246,1000000.0 +2019-03-25,TLT,98.97019545259246,99.85680765647012,98.4753444753295,99.36000761837823,99.36000761837823,1000000.0 +2019-03-26,TLT,99.36000761837823,99.85680765647012,97.5754861997087,98.06581527608915,98.06581527608915,1000000.0 2019-03-27,TLT,98.06581527608915,98.6045303490782,97.5754861997087,98.11396054634648,98.11396054634648,1000000.0 2019-03-28,TLT,98.11396054634648,98.6045303490782,96.87333754063258,97.36013823179154,97.36013823179154,1000000.0 -2019-03-29,TLT,97.36013823179154,98.4414719671143,96.87333754063258,97.95171340011373,97.95171340011373,1000000.0 -2019-04-01,TLT,97.95171340011373,98.4414719671143,95.8887587752996,96.37061183447197,96.37061183447197,1000000.0 -2019-04-02,TLT,96.37061183447197,96.85246489364432,95.18980069697614,95.66814140399612,95.66814140399612,1000000.0 -2019-04-03,TLT,95.66814140399612,96.69382312692049,95.18980069697614,96.21275933026915,96.21275933026915,1000000.0 -2019-04-04,TLT,96.21275933026915,97.59250838727841,95.73169553361781,97.10697351968003,97.10697351968003,1000000.0 -2019-04-05,TLT,97.10697351968003,97.59250838727841,94.96767834574115,95.44490286004135,95.44490286004135,1000000.0 -2019-04-08,TLT,95.44490286004135,95.92212737434154,94.5900496396369,95.06537652224814,95.06537652224814,1000000.0 +2019-03-29,TLT,97.36013823179154,98.4414719671143,96.87333754063258,97.95171340011372,97.95171340011372,1000000.0 +2019-04-01,TLT,97.95171340011372,98.4414719671143,95.8887587752996,96.37061183447196,96.37061183447196,1000000.0 +2019-04-02,TLT,96.37061183447196,96.85246489364432,95.18980069697614,95.66814140399612,95.66814140399612,1000000.0 +2019-04-03,TLT,95.66814140399612,96.69382312692048,95.18980069697614,96.21275933026917,96.21275933026917,1000000.0 +2019-04-04,TLT,96.21275933026917,97.5925083872784,95.7316955336178,97.10697351968004,97.10697351968004,1000000.0 +2019-04-05,TLT,97.10697351968004,97.5925083872784,94.96767834574116,95.44490286004137,95.44490286004137,1000000.0 +2019-04-08,TLT,95.44490286004137,95.92212737434154,94.5900496396369,95.06537652224814,95.06537652224814,1000000.0 2019-04-09,TLT,95.06537652224814,95.79174681290505,94.5900496396369,95.31517095811448,95.31517095811448,1000000.0 -2019-04-10,TLT,95.31517095811448,95.79174681290505,94.3775020163272,94.85176082042935,94.85176082042935,1000000.0 -2019-04-11,TLT,94.85176082042935,96.5468056922006,94.3775020163272,96.06647332557276,96.06647332557276,1000000.0 -2019-04-12,TLT,96.06647332557276,96.5468056922006,94.67954955158953,95.15532618250204,95.15532618250204,1000000.0 -2019-04-15,TLT,95.15532618250204,95.90272207019436,94.67954955158953,95.42559409969589,95.42559409969589,1000000.0 -2019-04-16,TLT,95.42559409969589,95.90272207019436,94.15544046104151,94.62858337793116,94.62858337793116,1000000.0 -2019-04-17,TLT,94.62858337793116,96.17744063604546,94.15544046104151,95.6989459065129,95.6989459065129,1000000.0 +2019-04-10,TLT,95.31517095811448,95.79174681290505,94.3775020163272,94.85176082042936,94.85176082042936,1000000.0 +2019-04-11,TLT,94.85176082042936,96.5468056922006,94.3775020163272,96.06647332557276,96.06647332557276,1000000.0 +2019-04-12,TLT,96.06647332557276,96.5468056922006,94.67954955158952,95.15532618250204,95.15532618250204,1000000.0 +2019-04-15,TLT,95.15532618250204,95.90272207019436,94.67954955158952,95.42559409969589,95.42559409969589,1000000.0 +2019-04-16,TLT,95.42559409969589,95.90272207019436,94.15544046104152,94.62858337793116,94.62858337793116,1000000.0 +2019-04-17,TLT,94.62858337793116,96.17744063604546,94.15544046104152,95.6989459065129,95.6989459065129,1000000.0 2019-04-18,TLT,95.6989459065129,96.17744063604546,95.20395948688936,95.6823713436074,95.6823713436074,1000000.0 -2019-04-19,TLT,95.6823713436074,96.16078320032543,94.92086340589996,95.39785266924619,95.39785266924619,1000000.0 -2019-04-22,TLT,95.39785266924619,95.87484193259242,93.62505675867696,94.09553443083112,94.09553443083112,1000000.0 -2019-04-23,TLT,94.09553443083112,95.84695837070697,93.62505675867696,95.37010783154923,95.37010783154923,1000000.0 +2019-04-22,TLT,95.3978526692462,95.87484193259242,93.62505675867696,94.09553443083112,94.09553443083112,1000000.0 +2019-04-23,TLT,94.09553443083112,95.84695837070696,93.62505675867696,95.37010783154923,95.37010783154923,1000000.0 2019-04-24,TLT,95.37010783154923,96.42591222471606,94.89325729239148,95.94618131812544,95.94618131812544,1000000.0 -2019-04-25,TLT,95.94618131812544,97.00897259532573,95.46645041153481,96.52634089087138,96.52634089087138,1000000.0 -2019-04-26,TLT,96.52634089087138,97.89608170365837,96.04370918641702,97.40903652105311,97.40903652105311,1000000.0 -2019-04-29,TLT,97.40903652105311,98.16996742720596,96.92199133844784,97.68155962906067,97.68155962906067,1000000.0 -2019-04-30,TLT,97.68155962906067,98.16996742720596,96.69737745672224,97.18329392635401,97.18329392635401,1000000.0 -2019-05-01,TLT,97.18329392635401,97.66921039598577,96.08030496775577,96.56312057060882,96.56312057060882,1000000.0 -2019-05-02,TLT,96.56312057060882,97.04593617346185,95.4672018176209,95.94693650012151,95.94693650012151,1000000.0 -2019-05-03,TLT,95.94693650012151,97.48937659811759,95.4672018176209,97.00435482399762,97.00435482399762,1000000.0 -2019-05-06,TLT,97.00435482399762,97.48937659811759,95.39825445364855,95.87764266698346,95.87764266698346,1000000.0 -2019-05-07,TLT,95.87764266698346,96.35703088031836,94.94419757417893,95.42130409465219,95.42130409465219,1000000.0 -2019-05-08,TLT,95.42130409465219,95.89841061512544,94.70050887321796,95.17639082735472,95.17639082735472,1000000.0 -2019-05-09,TLT,95.17639082735472,95.82430986868687,94.70050887321796,95.34757200864367,95.34757200864367,1000000.0 -2019-05-10,TLT,95.34757200864367,96.26638614617245,94.87083414860045,95.78744890166413,95.78744890166413,1000000.0 -2019-05-13,TLT,95.78744890166413,96.26638614617245,94.36085777455611,94.83503293925237,94.83503293925237,1000000.0 -2019-05-14,TLT,94.83503293925237,95.30920810394862,93.06388914411556,93.53154687850811,93.53154687850811,1000000.0 -2019-05-15,TLT,93.53154687850811,93.99920461290064,93.06060275690032,93.52824397678424,93.52824397678424,1000000.0 -2019-05-16,TLT,93.52824397678424,94.9128894145691,93.06060275690032,94.44068598464587,94.44068598464587,1000000.0 -2019-05-17,TLT,94.44068598464587,95.48947021558658,93.96848255472264,95.01439822446427,95.01439822446427,1000000.0 -2019-05-20,TLT,95.01439822446427,95.65435143444435,94.53932623334195,95.17845913875061,95.17845913875061,1000000.0 -2019-05-21,TLT,95.17845913875061,95.65435143444435,94.46258778602974,94.93727415681381,94.93727415681381,1000000.0 -2019-05-22,TLT,94.93727415681381,95.63604727845043,94.46258778602974,95.1602460482094,95.1602460482094,1000000.0 +2019-04-25,TLT,95.94618131812544,97.00897259532572,95.4664504115348,96.52634089087138,96.52634089087138,1000000.0 +2019-04-26,TLT,96.52634089087138,97.89608170365835,96.04370918641702,97.40903652105312,97.40903652105312,1000000.0 +2019-04-29,TLT,97.40903652105312,98.16996742720596,96.92199133844784,97.68155962906069,97.68155962906069,1000000.0 +2019-04-30,TLT,97.68155962906069,98.16996742720596,96.69737745672224,97.183293926354,97.183293926354,1000000.0 +2019-05-01,TLT,97.183293926354,97.66921039598576,96.08030496775577,96.56312057060882,96.56312057060882,1000000.0 +2019-05-02,TLT,96.56312057060882,97.04593617346184,95.4672018176209,95.94693650012152,95.94693650012152,1000000.0 +2019-05-03,TLT,95.94693650012152,97.4893765981176,95.4672018176209,97.00435482399762,97.00435482399762,1000000.0 +2019-05-06,TLT,97.00435482399762,97.4893765981176,95.39825445364856,95.87764266698346,95.87764266698346,1000000.0 +2019-05-07,TLT,95.87764266698346,96.35703088031836,94.94419757417892,95.4213040946522,95.4213040946522,1000000.0 +2019-05-08,TLT,95.4213040946522,95.89841061512544,94.70050887321796,95.17639082735472,95.17639082735472,1000000.0 +2019-05-09,TLT,95.17639082735472,95.82430986868688,94.70050887321796,95.34757200864368,95.34757200864368,1000000.0 +2019-05-10,TLT,95.34757200864368,96.26638614617244,94.87083414860044,95.78744890166412,95.78744890166412,1000000.0 +2019-05-13,TLT,95.78744890166412,96.26638614617244,94.36085777455612,94.83503293925236,94.83503293925236,1000000.0 +2019-05-14,TLT,94.83503293925236,95.30920810394862,93.06388914411556,93.53154687850812,93.53154687850812,1000000.0 +2019-05-15,TLT,93.53154687850812,93.99920461290064,93.06060275690032,93.52824397678424,93.52824397678424,1000000.0 +2019-05-16,TLT,93.52824397678424,94.9128894145691,93.06060275690032,94.44068598464588,94.44068598464588,1000000.0 +2019-05-17,TLT,94.44068598464588,95.48947021558658,93.96848255472264,95.01439822446429,95.01439822446429,1000000.0 +2019-05-20,TLT,95.01439822446429,95.65435143444437,94.53932623334197,95.1784591387506,95.1784591387506,1000000.0 +2019-05-21,TLT,95.1784591387506,95.65435143444437,94.46258778602974,94.9372741568138,94.9372741568138,1000000.0 +2019-05-22,TLT,94.9372741568138,95.63604727845043,94.46258778602974,95.1602460482094,95.1602460482094,1000000.0 2019-05-23,TLT,95.1602460482094,95.63604727845043,94.50030372808531,94.9751796262164,94.9751796262164,1000000.0 2019-05-24,TLT,94.9751796262164,96.07611909541562,94.50030372808531,95.59812845314988,95.59812845314988,1000000.0 -2019-05-27,TLT,95.59812845314988,96.07611909541562,94.51751133652114,94.99247370504636,94.99247370504636,1000000.0 -2019-05-28,TLT,94.99247370504636,95.5700154932722,94.51751133652114,95.09454277937533,95.09454277937533,1000000.0 -2019-05-29,TLT,95.09454277937533,95.5700154932722,94.53525190482864,95.01030342193833,95.01030342193833,1000000.0 -2019-05-30,TLT,95.01030342193833,95.90132127426925,94.53525190482864,95.42420027290474,95.42420027290474,1000000.0 +2019-05-28,TLT,94.99247370504636,95.5700154932722,94.51751133652114,95.09454277937532,95.09454277937532,1000000.0 +2019-05-29,TLT,95.09454277937532,95.5700154932722,94.53525190482864,95.01030342193832,95.01030342193832,1000000.0 +2019-05-30,TLT,95.01030342193832,95.90132127426924,94.53525190482864,95.42420027290474,95.42420027290474,1000000.0 2019-05-31,TLT,95.42420027290474,96.07382127824528,94.94707927154022,95.59584206790576,95.59584206790576,1000000.0 2019-06-03,TLT,95.59584206790576,98.05388207804944,95.11786285756624,97.56605181895468,97.56605181895468,1000000.0 2019-06-04,TLT,97.56605181895468,99.23654888390882,97.0782215598599,98.74283471035704,98.74283471035704,1000000.0 -2019-06-05,TLT,98.74283471035704,100.43193959152875,98.24912053680526,99.93227820052613,99.93227820052613,1000000.0 -2019-06-06,TLT,99.93227820052613,100.43193959152875,97.82343461265717,98.31500966096198,98.31500966096198,1000000.0 -2019-06-07,TLT,98.31500966096198,98.80658470926677,97.55746929793857,98.0477078371242,98.0477078371242,1000000.0 +2019-06-05,TLT,98.74283471035704,100.43193959152876,98.24912053680526,99.93227820052611,99.93227820052611,1000000.0 +2019-06-06,TLT,99.93227820052611,100.43193959152876,97.82343461265717,98.31500966096198,98.31500966096198,1000000.0 +2019-06-07,TLT,98.31500966096198,98.80658470926676,97.55746929793855,98.0477078371242,98.0477078371242,1000000.0 2019-06-10,TLT,98.0477078371242,98.5379463763098,97.08362768235334,97.5714851078928,97.5714851078928,1000000.0 -2019-06-11,TLT,97.5714851078928,98.47814095011444,97.08362768235334,97.98819995036263,97.98819995036263,1000000.0 -2019-06-12,TLT,97.98819995036263,98.47814095011444,95.73675686728932,96.2178460977782,96.2178460977782,1000000.0 -2019-06-13,TLT,96.2178460977782,97.61179941739323,95.73675686728932,97.12616857452063,97.12616857452063,1000000.0 -2019-06-14,TLT,97.12616857452063,98.44856689187537,96.64053773164802,97.95877302674168,97.95877302674168,1000000.0 -2019-06-17,TLT,97.95877302674168,98.44856689187537,96.45895663906467,96.94367501413534,96.94367501413534,1000000.0 -2019-06-18,TLT,96.94367501413534,97.42839338920601,95.7067864597296,96.18772508515538,96.18772508515538,1000000.0 +2019-06-11,TLT,97.5714851078928,98.47814095011444,97.08362768235334,97.98819995036264,97.98819995036264,1000000.0 +2019-06-12,TLT,97.98819995036264,98.47814095011444,95.73675686728932,96.2178460977782,96.2178460977782,1000000.0 +2019-06-13,TLT,96.2178460977782,97.61179941739324,95.73675686728932,97.12616857452063,97.12616857452063,1000000.0 +2019-06-14,TLT,97.12616857452063,98.44856689187536,96.64053773164802,97.95877302674168,97.95877302674168,1000000.0 +2019-06-17,TLT,97.95877302674168,98.44856689187536,96.45895663906468,96.94367501413534,96.94367501413534,1000000.0 +2019-06-18,TLT,96.94367501413534,97.428393389206,95.7067864597296,96.18772508515538,96.18772508515538,1000000.0 2019-06-19,TLT,96.18772508515538,96.66866371058114,95.0953273003919,95.57319326672553,95.57319326672553,1000000.0 -2019-06-20,TLT,95.57319326672553,96.08433393201983,95.0953273003919,95.60630241992024,95.60630241992024,1000000.0 +2019-06-20,TLT,95.57319326672553,96.08433393201985,95.0953273003919,95.60630241992024,95.60630241992024,1000000.0 2019-06-21,TLT,95.60630241992024,96.5782975377395,95.12827090782064,96.0978084952632,96.0978084952632,1000000.0 -2019-06-24,TLT,96.0978084952632,98.17358047695845,95.61731945278687,97.68515470344126,97.68515470344126,1000000.0 -2019-06-25,TLT,97.68515470344126,98.17358047695845,97.04332172061184,97.53097660363,97.53097660363,1000000.0 +2019-06-24,TLT,96.0978084952632,98.17358047695843,95.61731945278687,97.68515470344126,97.68515470344126,1000000.0 +2019-06-25,TLT,97.68515470344126,98.17358047695843,97.04332172061184,97.53097660363,97.53097660363,1000000.0 2019-06-26,TLT,97.53097660363,98.62231932179374,97.04332172061184,98.1316610167102,98.1316610167102,1000000.0 -2019-06-27,TLT,98.1316610167102,98.74501690268029,97.64100271162664,98.25374816187094,98.25374816187094,1000000.0 +2019-06-27,TLT,98.1316610167102,98.74501690268028,97.64100271162664,98.25374816187094,98.25374816187094,1000000.0 2019-06-28,TLT,98.25374816187094,100.14512170439522,97.76247942106158,99.64688726805495,99.64688726805495,1000000.0 -2019-07-01,TLT,99.64688726805495,100.74147822995813,99.14865283171468,100.2402768457295,100.2402768457295,1000000.0 +2019-07-01,TLT,99.64688726805495,100.74147822995812,99.14865283171468,100.2402768457295,100.2402768457295,1000000.0 2019-07-02,TLT,100.2402768457295,101.85049650551016,99.73907546150085,101.34377761742306,101.34377761742306,1000000.0 -2019-07-03,TLT,101.34377761742306,101.85049650551016,99.97143874850194,100.47380778743913,100.47380778743913,1000000.0 -2019-07-04,TLT,100.47380778743913,100.97617682637632,99.81780827077382,100.31940529726012,100.31940529726012,1000000.0 -2019-07-05,TLT,100.31940529726012,100.82100232374641,99.17008711226644,99.66842925855924,99.66842925855924,1000000.0 -2019-07-08,TLT,99.66842925855924,101.38002606820679,99.17008711226644,100.87564782906149,100.87564782906149,1000000.0 -2019-07-09,TLT,100.87564782906149,101.91480400466384,100.37126958991618,101.40776517876999,101.40776517876999,1000000.0 -2019-07-10,TLT,101.40776517876999,101.91480400466384,100.65471240056446,101.16051497544167,101.16051497544167,1000000.0 -2019-07-11,TLT,101.16051497544167,101.66631755031887,100.29102682149835,100.79500183065161,100.79500183065161,1000000.0 -2019-07-12,TLT,100.79500183065161,101.69250771935285,100.29102682149835,101.18657484512723,101.18657484512723,1000000.0 -2019-07-15,TLT,101.18657484512723,101.69250771935285,100.11720827197908,100.6203098210845,100.6203098210845,1000000.0 -2019-07-16,TLT,100.6203098210845,101.12341137018991,99.37463215129831,99.87400216210887,99.87400216210887,1000000.0 -2019-07-17,TLT,99.87400216210887,100.76057424636164,99.37463215129831,100.25927785707627,100.25927785707627,1000000.0 -2019-07-18,TLT,100.25927785707627,102.76575828025865,99.75798146779088,102.25448585100365,102.25448585100365,1000000.0 -2019-07-19,TLT,102.25448585100365,102.76575828025865,101.543071183525,102.05333787288944,102.05333787288944,1000000.0 -2019-07-22,TLT,102.05333787288944,102.56360456225389,101.09251916342359,101.60052177228502,101.60052177228502,1000000.0 +2019-07-03,TLT,101.34377761742306,101.85049650551016,99.97143874850194,100.47380778743911,100.47380778743911,1000000.0 +2019-07-05,TLT,100.31940529726012,100.8210023237464,99.17008711226644,99.66842925855924,99.66842925855924,1000000.0 +2019-07-08,TLT,99.66842925855924,101.3800260682068,99.17008711226644,100.87564782906148,100.87564782906148,1000000.0 +2019-07-09,TLT,100.87564782906148,101.91480400466384,100.37126958991618,101.40776517877,101.40776517877,1000000.0 +2019-07-10,TLT,101.40776517877,101.91480400466384,100.65471240056446,101.16051497544169,101.16051497544169,1000000.0 +2019-07-11,TLT,101.16051497544169,101.66631755031888,100.29102682149836,100.7950018306516,100.7950018306516,1000000.0 +2019-07-12,TLT,100.7950018306516,101.69250771935285,100.29102682149836,101.18657484512724,101.18657484512724,1000000.0 +2019-07-15,TLT,101.18657484512724,101.69250771935285,100.11720827197908,100.6203098210845,100.6203098210845,1000000.0 +2019-07-16,TLT,100.6203098210845,101.12341137018991,99.37463215129831,99.87400216210888,99.87400216210888,1000000.0 +2019-07-17,TLT,99.87400216210888,100.76057424636164,99.37463215129831,100.25927785707628,100.25927785707628,1000000.0 +2019-07-18,TLT,100.25927785707628,102.76575828025864,99.75798146779088,102.25448585100364,102.25448585100364,1000000.0 +2019-07-19,TLT,102.25448585100364,102.76575828025864,101.543071183525,102.05333787288944,102.05333787288944,1000000.0 +2019-07-22,TLT,102.05333787288944,102.56360456225389,101.0925191634236,101.60052177228502,101.60052177228502,1000000.0 2019-07-23,TLT,101.60052177228502,102.10852438114644,100.14978087981186,100.65304611036368,100.65304611036368,1000000.0 -2019-07-24,TLT,100.65304611036368,101.15631134091548,99.08586391694186,99.58378283109735,99.58378283109735,1000000.0 -2019-07-25,TLT,99.58378283109735,100.50291537049755,99.08586391694186,100.00290086616673,100.00290086616673,1000000.0 -2019-07-26,TLT,100.00290086616673,101.18931416287576,99.50288636183589,100.68588473917987,100.68588473917987,1000000.0 -2019-07-29,TLT,100.68588473917987,101.19674150515748,100.18245531548396,100.69327512950994,100.69327512950994,1000000.0 +2019-07-24,TLT,100.65304611036368,101.15631134091548,99.08586391694186,99.58378283109737,99.58378283109737,1000000.0 +2019-07-25,TLT,99.58378283109737,100.50291537049756,99.08586391694186,100.00290086616673,100.00290086616673,1000000.0 +2019-07-26,TLT,100.00290086616673,101.18931416287576,99.50288636183588,100.68588473917988,100.68588473917988,1000000.0 +2019-07-29,TLT,100.68588473917988,101.19674150515748,100.18245531548396,100.69327512950994,100.69327512950994,1000000.0 2019-07-30,TLT,100.69327512950994,101.4663448513705,100.1898087538624,100.9615371655428,100.9615371655428,1000000.0 -2019-07-31,TLT,100.9615371655428,101.560481544056,100.45672947971508,101.05520551647365,101.05520551647365,1000000.0 -2019-08-01,TLT,101.05520551647365,101.67319887117696,100.54992948889128,101.1673620608726,101.1673620608726,1000000.0 +2019-07-31,TLT,100.9615371655428,101.560481544056,100.45672947971508,101.05520551647363,101.05520551647363,1000000.0 +2019-08-01,TLT,101.05520551647363,101.67319887117696,100.54992948889128,101.1673620608726,101.1673620608726,1000000.0 2019-08-02,TLT,101.1673620608726,101.67319887117696,99.43999344784744,99.93969190738436,99.93969190738436,1000000.0 -2019-08-05,TLT,99.93969190738436,100.43939036692127,99.07621823874737,99.57408868215816,99.57408868215816,1000000.0 -2019-08-06,TLT,99.57408868215816,100.16124655615457,99.07621823874737,99.66293189667122,99.66293189667122,1000000.0 -2019-08-07,TLT,99.66293189667122,100.16124655615457,98.54526003096555,99.04046234267895,99.04046234267895,1000000.0 -2019-08-08,TLT,99.04046234267895,99.53566465439233,98.17037576489416,98.66369423607453,98.66369423607453,1000000.0 -2019-08-09,TLT,98.66369423607453,99.15701270725489,97.52917226311901,98.01926860614975,98.01926860614975,1000000.0 -2019-08-12,TLT,98.01926860614975,98.5093649491805,97.26931382825866,97.75810435000871,97.75810435000871,1000000.0 -2019-08-13,TLT,97.75810435000871,98.91971010870135,97.26931382825866,98.42757224746404,98.42757224746404,1000000.0 -2019-08-14,TLT,98.42757224746404,98.91971010870135,97.61740290199072,98.10794261506605,98.10794261506605,1000000.0 -2019-08-15,TLT,98.10794261506605,98.59848232814137,97.4973131808343,97.98724942797416,97.98724942797416,1000000.0 -2019-08-16,TLT,97.98724942797416,99.12033833615602,97.4973131808343,98.62720232453337,98.62720232453337,1000000.0 -2019-08-19,TLT,98.62720232453337,99.63293796972322,98.1340663129107,99.1372517111674,99.1372517111674,1000000.0 +2019-08-05,TLT,99.93969190738436,100.43939036692127,99.07621823874736,99.57408868215816,99.57408868215816,1000000.0 +2019-08-06,TLT,99.57408868215816,100.16124655615457,99.07621823874736,99.66293189667122,99.66293189667122,1000000.0 +2019-08-07,TLT,99.66293189667122,100.16124655615457,98.54526003096557,99.04046234267896,99.04046234267896,1000000.0 +2019-08-08,TLT,99.04046234267896,99.53566465439232,98.17037576489416,98.66369423607452,98.66369423607452,1000000.0 +2019-08-09,TLT,98.66369423607452,99.15701270725488,97.529172263119,98.01926860614977,98.01926860614977,1000000.0 +2019-08-12,TLT,98.01926860614977,98.5093649491805,97.26931382825866,97.75810435000872,97.75810435000872,1000000.0 +2019-08-13,TLT,97.75810435000872,98.91971010870137,97.26931382825866,98.42757224746404,98.42757224746404,1000000.0 +2019-08-14,TLT,98.42757224746404,98.91971010870137,97.61740290199072,98.10794261506604,98.10794261506604,1000000.0 +2019-08-15,TLT,98.10794261506604,98.59848232814136,97.4973131808343,97.98724942797416,97.98724942797416,1000000.0 +2019-08-16,TLT,97.98724942797416,99.12033833615602,97.4973131808343,98.62720232453336,98.62720232453336,1000000.0 +2019-08-19,TLT,98.62720232453336,99.63293796972322,98.1340663129107,99.1372517111674,99.1372517111674,1000000.0 2019-08-20,TLT,99.1372517111674,100.99332978214537,98.64156545261156,100.49087540511978,100.49087540511978,1000000.0 -2019-08-21,TLT,100.49087540511978,100.99332978214537,98.33013177682075,98.8242530420309,98.8242530420309,1000000.0 -2019-08-22,TLT,98.8242530420309,100.0015277959241,98.33013177682075,99.50400775713842,99.50400775713842,1000000.0 +2019-08-21,TLT,100.49087540511978,100.99332978214537,98.33013177682076,98.8242530420309,98.8242530420309,1000000.0 +2019-08-22,TLT,98.8242530420309,100.0015277959241,98.33013177682076,99.50400775713842,99.50400775713842,1000000.0 2019-08-23,TLT,99.50400775713842,100.0015277959241,98.62522966197508,99.12083383113074,99.12083383113074,1000000.0 -2019-08-26,TLT,99.12083383113074,99.72383600617323,98.62522966197508,99.22769751858034,99.22769751858034,1000000.0 -2019-08-27,TLT,99.22769751858034,100.39440521614846,98.73155903098744,99.89493056333181,99.89493056333181,1000000.0 -2019-08-28,TLT,99.89493056333181,101.26820638924275,99.39545591051515,100.76438446690823,100.76438446690823,1000000.0 -2019-08-29,TLT,100.76438446690823,102.11374219245438,100.26056254457369,101.60571362433272,101.60571362433272,1000000.0 -2019-08-30,TLT,101.60571362433272,102.24052903545787,101.09768505621106,101.73186968702277,101.73186968702277,1000000.0 -2019-09-02,TLT,101.73186968702277,103.56562466655369,101.22321033858765,103.05037280254099,103.05037280254099,1000000.0 -2019-09-03,TLT,103.05037280254099,103.56562466655369,102.30326510156986,102.81735186087424,102.81735186087424,1000000.0 +2019-08-26,TLT,99.12083383113074,99.72383600617324,98.62522966197508,99.22769751858034,99.22769751858034,1000000.0 +2019-08-27,TLT,99.22769751858034,100.39440521614846,98.73155903098744,99.8949305633318,99.8949305633318,1000000.0 +2019-08-28,TLT,99.8949305633318,101.26820638924276,99.39545591051515,100.76438446690824,100.76438446690824,1000000.0 +2019-08-29,TLT,100.76438446690824,102.11374219245438,100.26056254457367,101.60571362433272,101.60571362433272,1000000.0 +2019-08-30,TLT,101.60571362433272,102.24052903545788,101.09768505621106,101.73186968702277,101.73186968702277,1000000.0 +2019-09-03,TLT,103.050372802541,103.56562466655367,102.30326510156986,102.81735186087424,102.81735186087424,1000000.0 2019-09-04,TLT,102.81735186087424,103.3314386201786,102.18794686471747,102.70145413539444,102.70145413539444,1000000.0 -2019-09-05,TLT,102.70145413539444,103.87711634382165,102.18794686471747,103.36031476997181,103.36031476997181,1000000.0 -2019-09-06,TLT,103.36031476997181,103.87711634382165,102.39087162220176,102.90539861527814,102.90539861527814,1000000.0 -2019-09-09,TLT,102.90539861527814,105.22331463150554,102.39087162220176,104.69981555373687,104.69981555373687,1000000.0 -2019-09-10,TLT,104.69981555373687,106.08477623295286,104.17631647596818,105.55699127657002,105.55699127657002,1000000.0 -2019-09-11,TLT,105.55699127657002,107.94730083732428,105.02920632018717,107.4102495893774,107.4102495893774,1000000.0 -2019-09-12,TLT,107.4102495893774,107.94730083732428,106.8504673873255,107.38740440937235,107.38740440937235,1000000.0 -2019-09-13,TLT,107.38740440937235,107.92434143141921,106.52350314310539,107.05879712874913,107.05879712874913,1000000.0 -2019-09-16,TLT,107.05879712874913,107.73797425827111,106.52350314310539,107.20196443609066,107.20196443609066,1000000.0 +2019-09-05,TLT,102.70145413539444,103.87711634382164,102.18794686471747,103.3603147699718,103.3603147699718,1000000.0 +2019-09-06,TLT,103.3603147699718,103.87711634382164,102.39087162220176,102.90539861527814,102.90539861527814,1000000.0 +2019-09-09,TLT,102.90539861527814,105.22331463150554,102.39087162220176,104.69981555373688,104.69981555373688,1000000.0 +2019-09-10,TLT,104.69981555373688,106.08477623295286,104.17631647596818,105.55699127657002,105.55699127657002,1000000.0 +2019-09-11,TLT,105.55699127657002,107.94730083732428,105.02920632018716,107.4102495893774,107.4102495893774,1000000.0 +2019-09-12,TLT,107.4102495893774,107.94730083732428,106.8504673873255,107.38740440937237,107.38740440937237,1000000.0 +2019-09-13,TLT,107.38740440937237,107.9243414314192,106.5235031431054,107.05879712874912,107.05879712874912,1000000.0 +2019-09-16,TLT,107.05879712874912,107.73797425827112,106.5235031431054,107.20196443609066,107.20196443609066,1000000.0 2019-09-17,TLT,107.20196443609066,108.3729725246738,106.6659546139102,107.83380350713811,107.83380350713811,1000000.0 -2019-09-18,TLT,107.83380350713811,108.3729725246738,106.79159470176471,107.32823588117056,107.32823588117056,1000000.0 -2019-09-19,TLT,107.32823588117056,108.19302706282517,106.79159470176471,107.65475329634346,107.65475329634346,1000000.0 -2019-09-20,TLT,107.65475329634346,108.19302706282517,107.1020803761607,107.64028178508613,107.64028178508613,1000000.0 -2019-09-23,TLT,107.64028178508613,109.58581481841443,107.1020803761607,109.04061175961637,109.04061175961637,1000000.0 -2019-09-24,TLT,109.04061175961637,109.58581481841443,107.9217299246022,108.4640501754796,108.4640501754796,1000000.0 -2019-09-25,TLT,108.4640501754796,109.92250516026706,107.9217299246022,109.37562702514137,109.37562702514137,1000000.0 -2019-09-26,TLT,109.37562702514137,109.92250516026706,108.2696232245167,108.81369168293136,108.81369168293136,1000000.0 -2019-09-27,TLT,108.81369168293136,109.357760141346,107.44075225399307,107.98065553165132,107.98065553165132,1000000.0 +2019-09-18,TLT,107.83380350713811,108.3729725246738,106.79159470176472,107.32823588117056,107.32823588117056,1000000.0 +2019-09-19,TLT,107.32823588117056,108.19302706282517,106.79159470176472,107.65475329634346,107.65475329634346,1000000.0 +2019-09-20,TLT,107.65475329634346,108.19302706282517,107.1020803761607,107.64028178508612,107.64028178508612,1000000.0 +2019-09-23,TLT,107.64028178508612,109.58581481841443,107.1020803761607,109.04061175961635,109.04061175961635,1000000.0 +2019-09-24,TLT,109.04061175961635,109.58581481841443,107.9217299246022,108.4640501754796,108.4640501754796,1000000.0 +2019-09-25,TLT,108.4640501754796,109.92250516026706,107.9217299246022,109.37562702514136,109.37562702514136,1000000.0 +2019-09-26,TLT,109.37562702514136,109.92250516026706,108.2696232245167,108.81369168293136,108.81369168293136,1000000.0 +2019-09-27,TLT,108.81369168293136,109.357760141346,107.44075225399308,107.98065553165132,107.98065553165132,1000000.0 2019-09-30,TLT,107.98065553165132,108.52055880930956,106.8319627450597,107.36880677895446,107.36880677895446,1000000.0 -2019-10-01,TLT,107.36880677895446,107.90565081284923,105.8195909028069,106.35134764101196,106.35134764101196,1000000.0 -2019-10-02,TLT,106.35134764101196,107.00831671088044,105.8195909028069,106.47593702575169,106.47593702575169,1000000.0 -2019-10-03,TLT,106.47593702575169,107.89479630441114,105.94355734062293,107.35800627304593,107.35800627304593,1000000.0 -2019-10-04,TLT,107.35800627304593,108.03671932702596,106.8212162416807,107.49922321097112,107.49922321097112,1000000.0 -2019-10-07,TLT,107.49922321097112,108.57766941268274,106.96172709491627,108.0374820026694,108.0374820026694,1000000.0 -2019-10-08,TLT,108.0374820026694,110.00470719627833,107.49729459265606,109.45742009579935,109.45742009579935,1000000.0 -2019-10-09,TLT,109.45742009579935,110.24259749251182,108.91013299532035,109.69412685822073,109.69412685822073,1000000.0 -2019-10-10,TLT,109.69412685822073,110.41485357397703,109.14565622392962,109.86552594425577,109.86552594425577,1000000.0 -2019-10-11,TLT,109.86552594425577,110.41485357397703,109.07591339214478,109.62403355994451,109.62403355994451,1000000.0 -2019-10-14,TLT,109.62403355994451,110.17215372774422,107.68153862689148,108.22265188632309,108.22265188632309,1000000.0 -2019-10-15,TLT,108.22265188632309,109.42683467756495,107.68153862689148,108.88242256474126,108.88242256474126,1000000.0 -2019-10-16,TLT,108.88242256474126,109.42683467756495,106.82632474727201,107.3631404495196,107.3631404495196,1000000.0 -2019-10-17,TLT,107.3631404495196,108.46481963170791,106.82632474727201,107.92519366339097,107.92519366339097,1000000.0 -2019-10-18,TLT,107.92519366339097,108.46481963170791,107.3732660206849,107.91283017154261,107.91283017154261,1000000.0 -2019-10-21,TLT,107.91283017154261,109.43443100527247,107.3732660206849,108.88998109977362,108.88998109977362,1000000.0 -2019-10-22,TLT,108.88998109977362,109.43443100527247,108.28680627593182,108.83096108133851,108.83096108133851,1000000.0 -2019-10-23,TLT,108.83096108133851,109.37511588674519,107.57601560856442,108.11659860157228,108.11659860157228,1000000.0 +2019-10-01,TLT,107.36880677895446,107.90565081284925,105.8195909028069,106.35134764101196,106.35134764101196,1000000.0 +2019-10-02,TLT,106.35134764101196,107.00831671088044,105.8195909028069,106.47593702575168,106.47593702575168,1000000.0 +2019-10-03,TLT,106.47593702575168,107.89479630441114,105.94355734062292,107.35800627304592,107.35800627304592,1000000.0 +2019-10-04,TLT,107.35800627304592,108.03671932702596,106.8212162416807,107.49922321097112,107.49922321097112,1000000.0 +2019-10-07,TLT,107.49922321097112,108.57766941268274,106.96172709491628,108.0374820026694,108.0374820026694,1000000.0 +2019-10-08,TLT,108.0374820026694,110.00470719627832,107.49729459265606,109.45742009579936,109.45742009579936,1000000.0 +2019-10-09,TLT,109.45742009579936,110.24259749251182,108.91013299532035,109.69412685822071,109.69412685822071,1000000.0 +2019-10-10,TLT,109.69412685822071,110.41485357397704,109.14565622392962,109.86552594425577,109.86552594425577,1000000.0 +2019-10-11,TLT,109.86552594425577,110.41485357397704,109.07591339214478,109.62403355994452,109.62403355994452,1000000.0 +2019-10-14,TLT,109.62403355994452,110.17215372774422,107.68153862689148,108.22265188632308,108.22265188632308,1000000.0 +2019-10-15,TLT,108.22265188632308,109.42683467756495,107.68153862689148,108.88242256474126,108.88242256474126,1000000.0 +2019-10-16,TLT,108.88242256474126,109.42683467756495,106.826324747272,107.3631404495196,107.3631404495196,1000000.0 +2019-10-17,TLT,107.3631404495196,108.46481963170793,106.826324747272,107.92519366339096,107.92519366339096,1000000.0 +2019-10-18,TLT,107.92519366339096,108.46481963170793,107.3732660206849,107.9128301715426,107.9128301715426,1000000.0 +2019-10-21,TLT,107.9128301715426,109.43443100527249,107.3732660206849,108.88998109977362,108.88998109977362,1000000.0 +2019-10-22,TLT,108.88998109977362,109.43443100527249,108.28680627593182,108.83096108133851,108.83096108133851,1000000.0 +2019-10-23,TLT,108.83096108133851,109.3751158867452,107.57601560856442,108.11659860157228,108.11659860157228,1000000.0 2019-10-24,TLT,108.11659860157228,108.96874359346673,107.57601560856442,108.42661054076292,108.42661054076292,1000000.0 -2019-10-25,TLT,108.42661054076292,108.96874359346673,107.40203316445148,107.94174187382059,107.94174187382059,1000000.0 -2019-10-28,TLT,107.94174187382059,108.48145058318968,107.2466646045211,107.7855925673579,107.7855925673579,1000000.0 -2019-10-29,TLT,107.7855925673579,108.36081167129825,107.2466646045211,107.82170315552067,107.82170315552067,1000000.0 -2019-10-30,TLT,107.82170315552067,108.36081167129825,107.16717803050003,107.70570656331661,107.70570656331661,1000000.0 -2019-10-31,TLT,107.70570656331661,108.24423509613318,107.00543295648427,107.54314869998419,107.54314869998419,1000000.0 -2019-11-01,TLT,107.54314869998419,108.0808644434841,106.29519143829737,106.82933812894207,106.82933812894207,1000000.0 -2019-11-04,TLT,106.82933812894207,107.36348481958677,106.13448095778253,106.66782005807289,106.66782005807289,1000000.0 -2019-11-05,TLT,106.66782005807289,107.20115915836324,104.33430901722602,104.85860202736283,104.85860202736283,1000000.0 +2019-10-25,TLT,108.42661054076292,108.96874359346673,107.40203316445148,107.9417418738206,107.9417418738206,1000000.0 +2019-10-28,TLT,107.9417418738206,108.48145058318968,107.2466646045211,107.7855925673579,107.7855925673579,1000000.0 +2019-10-29,TLT,107.7855925673579,108.36081167129824,107.2466646045211,107.82170315552068,107.82170315552068,1000000.0 +2019-10-30,TLT,107.82170315552068,108.36081167129824,107.16717803050004,107.7057065633166,107.7057065633166,1000000.0 +2019-10-31,TLT,107.7057065633166,108.24423509613318,107.00543295648428,107.5431486999842,107.5431486999842,1000000.0 +2019-11-01,TLT,107.5431486999842,108.0808644434841,106.29519143829737,106.82933812894208,106.82933812894208,1000000.0 +2019-11-04,TLT,106.82933812894208,107.36348481958676,106.13448095778251,106.66782005807288,106.66782005807288,1000000.0 +2019-11-05,TLT,106.66782005807288,107.20115915836324,104.33430901722602,104.85860202736283,104.85860202736283,1000000.0 2019-11-06,TLT,104.85860202736283,105.38289503749964,104.20307054311584,104.726704063433,104.726704063433,1000000.0 -2019-11-07,TLT,104.726704063433,105.25033758375015,103.20911252208292,103.7277512784753,103.7277512784753,1000000.0 -2019-11-08,TLT,103.7277512784753,105.18485663854196,103.20911252208292,104.66154889407161,104.66154889407161,1000000.0 -2019-11-11,TLT,104.66154889407161,106.25891982955469,104.13824114960126,105.7302684871191,105.7302684871191,1000000.0 -2019-11-12,TLT,105.7302684871191,106.25891982955469,103.57235660041029,104.09282070392993,104.09282070392993,1000000.0 -2019-11-13,TLT,104.09282070392993,104.73463690939748,103.57235660041029,104.2135690640771,104.2135690640771,1000000.0 -2019-11-14,TLT,104.2135690640771,104.73463690939748,103.58773025553229,104.1082716136003,104.1082716136003,1000000.0 -2019-11-15,TLT,104.1082716136003,104.62881297166828,102.72393671695494,103.24013740397481,103.24013740397481,1000000.0 -2019-11-18,TLT,103.24013740397481,104.19857696078893,102.72393671695494,103.680176080387,103.680176080387,1000000.0 -2019-11-19,TLT,103.680176080387,104.19857696078893,102.78144830002547,103.29793798997535,103.29793798997535,1000000.0 -2019-11-20,TLT,103.29793798997535,103.81442767992522,101.33826506540538,101.84750257829687,101.84750257829687,1000000.0 -2019-11-21,TLT,101.84750257829687,102.35674009118834,101.1222991255905,101.63045138250301,101.63045138250301,1000000.0 -2019-11-22,TLT,101.63045138250301,102.13860363941552,101.00243665018357,101.50998658309906,101.50998658309906,1000000.0 -2019-11-25,TLT,101.50998658309906,102.10443614834914,101.00243665018357,101.59645387895438,101.59645387895438,1000000.0 -2019-11-26,TLT,101.59645387895438,102.10443614834914,100.09765164495893,100.60065491955672,100.60065491955672,1000000.0 -2019-11-27,TLT,100.60065491955672,101.60286735108203,100.09765164495893,101.09738044883785,101.09738044883785,1000000.0 -2019-11-28,TLT,101.09738044883785,102.20242581136105,100.59189354659367,101.69395603120503,101.69395603120503,1000000.0 -2019-11-29,TLT,101.69395603120503,102.20242581136105,100.26219572069293,100.76602584994264,100.76602584994264,1000000.0 -2019-12-02,TLT,100.76602584994264,101.26985597919234,99.73511956113235,100.23630106646468,100.23630106646468,1000000.0 -2019-12-03,TLT,100.23630106646468,100.73748257179699,99.67104054228312,100.17190004249561,100.17190004249561,1000000.0 -2019-12-04,TLT,100.17190004249561,100.67275954270808,99.22080957006922,99.71940660308465,99.71940660308465,1000000.0 -2019-12-05,TLT,99.71940660308465,101.62584537398446,99.22080957006922,101.12024415321838,101.12024415321838,1000000.0 +2019-11-07,TLT,104.726704063433,105.25033758375017,103.20911252208292,103.7277512784753,103.7277512784753,1000000.0 +2019-11-08,TLT,103.7277512784753,105.18485663854196,103.20911252208292,104.6615488940716,104.6615488940716,1000000.0 +2019-11-11,TLT,104.6615488940716,106.25891982955469,104.13824114960126,105.7302684871191,105.7302684871191,1000000.0 +2019-11-12,TLT,105.7302684871191,106.25891982955469,103.57235660041027,104.09282070392992,104.09282070392992,1000000.0 +2019-11-13,TLT,104.09282070392992,104.73463690939748,103.57235660041027,104.2135690640771,104.2135690640771,1000000.0 +2019-11-14,TLT,104.2135690640771,104.73463690939748,103.58773025553228,104.1082716136003,104.1082716136003,1000000.0 +2019-11-15,TLT,104.1082716136003,104.62881297166828,102.72393671695494,103.2401374039748,103.2401374039748,1000000.0 +2019-11-18,TLT,103.2401374039748,104.19857696078893,102.72393671695494,103.680176080387,103.680176080387,1000000.0 +2019-11-19,TLT,103.680176080387,104.19857696078893,102.78144830002547,103.29793798997537,103.29793798997537,1000000.0 +2019-11-20,TLT,103.29793798997537,103.81442767992522,101.33826506540538,101.84750257829688,101.84750257829688,1000000.0 +2019-11-21,TLT,101.84750257829688,102.35674009118834,101.1222991255905,101.630451382503,101.630451382503,1000000.0 +2019-11-22,TLT,101.630451382503,102.13860363941552,101.00243665018355,101.50998658309906,101.50998658309906,1000000.0 +2019-11-25,TLT,101.50998658309906,102.10443614834914,101.00243665018355,101.59645387895438,101.59645387895438,1000000.0 +2019-11-26,TLT,101.59645387895438,102.10443614834914,100.09765164495892,100.60065491955672,100.60065491955672,1000000.0 +2019-11-27,TLT,100.60065491955672,101.60286735108204,100.09765164495892,101.09738044883784,101.09738044883784,1000000.0 +2019-11-29,TLT,101.69395603120503,102.20242581136104,100.26219572069292,100.76602584994264,100.76602584994264,1000000.0 +2019-12-02,TLT,100.76602584994264,101.26985597919234,99.73511956113236,100.23630106646468,100.23630106646468,1000000.0 +2019-12-03,TLT,100.23630106646468,100.737482571797,99.67104054228312,100.1719000424956,100.1719000424956,1000000.0 +2019-12-04,TLT,100.1719000424956,100.67275954270808,99.22080957006922,99.71940660308464,99.71940660308464,1000000.0 +2019-12-05,TLT,99.71940660308464,101.62584537398446,99.22080957006922,101.12024415321838,101.12024415321838,1000000.0 2019-12-06,TLT,101.12024415321838,101.7953945916951,100.61464293245228,101.2889498424827,101.2889498424827,1000000.0 2019-12-09,TLT,101.2889498424827,101.7953945916951,99.97099797763244,100.47336480164064,100.47336480164064,1000000.0 -2019-12-10,TLT,100.47336480164064,100.97573162564883,99.34267651396131,99.84188594367971,99.84188594367971,1000000.0 -2019-12-11,TLT,99.84188594367971,100.3410953733981,99.29701301773679,99.79599298265003,99.79599298265003,1000000.0 -2019-12-12,TLT,99.79599298265003,102.15403324257404,99.29701301773679,101.64580422146672,101.64580422146672,1000000.0 -2019-12-13,TLT,101.64580422146672,102.15403324257404,100.99344422839572,101.50094897326203,101.50094897326203,1000000.0 -2019-12-16,TLT,101.50094897326203,102.11253616063345,100.99344422839572,101.60451359267012,101.60451359267012,1000000.0 -2019-12-17,TLT,101.60451359267012,102.53332460315823,101.09649102470676,102.02320856035647,102.02320856035647,1000000.0 -2019-12-18,TLT,102.02320856035647,102.53332460315823,101.48051128420263,101.99046360221371,101.99046360221371,1000000.0 -2019-12-19,TLT,101.99046360221371,104.38773036569017,101.48051128420263,103.86838842357233,103.86838842357233,1000000.0 -2019-12-20,TLT,103.86838842357233,104.38773036569017,102.91051872433556,103.42765700938247,103.42765700938247,1000000.0 -2019-12-23,TLT,103.42765700938247,104.56548948324766,102.91051872433556,104.04526316741062,104.04526316741062,1000000.0 -2019-12-24,TLT,104.04526316741062,104.69978584782285,103.52503685157357,104.17889139086851,104.17889139086851,1000000.0 -2019-12-25,TLT,104.17889139086851,105.2213938763502,103.65799693391418,104.69790435457733,104.69790435457733,1000000.0 -2019-12-26,TLT,104.69790435457733,105.2213938763502,103.19840695746825,103.71699191705352,103.71699191705352,1000000.0 -2019-12-27,TLT,103.71699191705352,105.72045582640376,103.19840695746825,105.19448340935699,105.19448340935699,1000000.0 -2019-12-30,TLT,105.19448340935699,105.72045582640376,104.56679961748584,105.09226092209633,105.09226092209633,1000000.0 -2019-12-31,TLT,105.09226092209633,105.64659213907588,104.56679961748584,105.12098720306058,105.12098720306058,1000000.0 -2020-01-01,TLT,105.12098720306058,105.64659213907588,103.81594731753445,104.3376354950095,104.3376354950095,1000000.0 +2019-12-10,TLT,100.47336480164064,100.97573162564883,99.34267651396132,99.84188594367971,99.84188594367971,1000000.0 +2019-12-11,TLT,99.84188594367971,100.3410953733981,99.2970130177368,99.79599298265003,99.79599298265003,1000000.0 +2019-12-12,TLT,99.79599298265003,102.15403324257404,99.2970130177368,101.64580422146672,101.64580422146672,1000000.0 +2019-12-13,TLT,101.64580422146672,102.15403324257404,100.99344422839572,101.50094897326204,101.50094897326204,1000000.0 +2019-12-16,TLT,101.50094897326204,102.11253616063344,100.99344422839572,101.60451359267012,101.60451359267012,1000000.0 +2019-12-17,TLT,101.60451359267012,102.53332460315823,101.09649102470676,102.02320856035648,102.02320856035648,1000000.0 +2019-12-18,TLT,102.02320856035648,102.53332460315823,101.48051128420263,101.99046360221372,101.99046360221372,1000000.0 +2019-12-19,TLT,101.99046360221372,104.38773036569016,101.48051128420263,103.86838842357233,103.86838842357233,1000000.0 +2019-12-20,TLT,103.86838842357233,104.38773036569016,102.91051872433556,103.42765700938249,103.42765700938249,1000000.0 +2019-12-23,TLT,103.42765700938249,104.56548948324766,102.91051872433556,104.04526316741062,104.04526316741062,1000000.0 +2019-12-24,TLT,104.04526316741062,104.69978584782284,103.52503685157356,104.17889139086851,104.17889139086851,1000000.0 +2019-12-26,TLT,104.69790435457732,105.2213938763502,103.19840695746824,103.71699191705352,103.71699191705352,1000000.0 +2019-12-27,TLT,103.71699191705352,105.72045582640376,103.19840695746824,105.194483409357,105.194483409357,1000000.0 +2019-12-30,TLT,105.194483409357,105.72045582640376,104.56679961748584,105.09226092209632,105.09226092209632,1000000.0 +2019-12-31,TLT,105.09226092209632,105.64659213907588,104.56679961748584,105.12098720306058,105.12098720306058,1000000.0 2020-01-02,TLT,104.3376354950095,104.85932367248454,103.2116807112829,103.73033237314864,103.73033237314864,1000000.0 -2020-01-03,TLT,103.73033237314864,104.72061879520217,103.2116807112829,104.19962069174346,104.19962069174346,1000000.0 +2020-01-03,TLT,103.73033237314864,104.72061879520216,103.2116807112829,104.19962069174346,104.19962069174346,1000000.0 2020-01-06,TLT,104.19962069174346,105.55034361054511,103.67862258828474,105.02521752293048,105.02521752293048,1000000.0 2020-01-07,TLT,105.02521752293048,106.1883142289131,104.50009143531582,105.6600141581225,105.6600141581225,1000000.0 2020-01-08,TLT,105.6600141581225,107.22032496740512,105.13171408733189,106.68689051483098,106.68689051483098,1000000.0 -2020-01-09,TLT,106.68689051483098,107.83494170821871,106.15345606225682,107.29844946091414,107.29844946091414,1000000.0 -2020-01-10,TLT,107.29844946091414,107.85949794490223,106.76195721360958,107.32288352726592,107.32288352726592,1000000.0 -2020-01-13,TLT,107.32288352726592,108.58373680638098,106.78626910962959,108.04351921032935,108.04351921032935,1000000.0 -2020-01-14,TLT,108.04351921032935,109.10060836575659,107.5033016142777,108.55781926940955,108.55781926940955,1000000.0 -2020-01-15,TLT,108.55781926940955,109.10060836575659,107.9281560037191,108.47050854645136,108.47050854645136,1000000.0 -2020-01-16,TLT,108.47050854645136,109.64791379668601,107.9281560037191,109.10240178774728,109.10240178774728,1000000.0 -2020-01-17,TLT,109.10240178774728,110.78152602851367,108.55688977880854,110.23037415772505,110.23037415772505,1000000.0 -2020-01-20,TLT,110.23037415772505,110.98961056393277,109.67922228693642,110.43742344669928,110.43742344669928,1000000.0 -2020-01-21,TLT,110.43742344669928,110.98961056393277,109.57254861439138,110.12316443657426,110.12316443657426,1000000.0 +2020-01-09,TLT,106.68689051483098,107.83494170821872,106.15345606225682,107.29844946091414,107.29844946091414,1000000.0 +2020-01-10,TLT,107.29844946091414,107.85949794490224,106.76195721360958,107.32288352726592,107.32288352726592,1000000.0 +2020-01-13,TLT,107.32288352726592,108.58373680638098,106.7862691096296,108.04351921032936,108.04351921032936,1000000.0 +2020-01-14,TLT,108.04351921032936,109.1006083657566,107.5033016142777,108.55781926940956,108.55781926940956,1000000.0 +2020-01-15,TLT,108.55781926940956,109.1006083657566,107.9281560037191,108.47050854645136,108.47050854645136,1000000.0 +2020-01-16,TLT,108.47050854645136,109.647913796686,107.9281560037191,109.10240178774728,109.10240178774728,1000000.0 +2020-01-17,TLT,109.10240178774728,110.78152602851368,108.55688977880854,110.23037415772504,110.23037415772504,1000000.0 +2020-01-21,TLT,110.43742344669928,110.98961056393276,109.57254861439138,110.12316443657426,110.12316443657426,1000000.0 2020-01-22,TLT,110.12316443657426,111.31195318569446,109.57254861439138,110.75816237382534,110.75816237382534,1000000.0 -2020-01-23,TLT,110.75816237382534,113.0174715803081,110.20437156195622,112.45519560229663,112.45519560229663,1000000.0 -2020-01-24,TLT,112.45519560229663,113.0174715803081,111.70459507065563,112.26592469412626,112.26592469412626,1000000.0 -2020-01-27,TLT,112.26592469412626,112.82725431759687,111.62213425043502,112.18304949792464,112.18304949792464,1000000.0 +2020-01-23,TLT,110.75816237382534,113.0174715803081,110.20437156195622,112.45519560229664,112.45519560229664,1000000.0 +2020-01-24,TLT,112.45519560229664,113.0174715803081,111.70459507065564,112.26592469412626,112.26592469412626,1000000.0 +2020-01-27,TLT,112.26592469412626,112.82725431759688,111.62213425043502,112.18304949792464,112.18304949792464,1000000.0 2020-01-28,TLT,112.18304949792464,112.74396474541426,111.50040081001954,112.06070433167793,112.06070433167793,1000000.0 2020-01-29,TLT,112.06070433167793,113.72831567184824,111.50040081001954,113.16250315606791,113.16250315606791,1000000.0 -2020-01-30,TLT,113.16250315606791,113.72831567184824,110.95389324098593,111.5114504934532,111.5114504934532,1000000.0 -2020-01-31,TLT,111.5114504934532,112.39748762431861,110.95389324098593,111.83829614360063,111.83829614360063,1000000.0 -2020-02-03,TLT,111.83829614360063,113.47467638071488,111.27910466288263,112.91012575195512,112.91012575195512,1000000.0 -2020-02-04,TLT,112.91012575195512,113.47467638071488,111.61690843413245,112.17779742123865,112.17779742123865,1000000.0 -2020-02-05,TLT,112.17779742123865,114.08963944523602,111.61690843413245,113.52202929874231,113.52202929874231,1000000.0 -2020-02-06,TLT,113.52202929874231,114.57927281168382,112.9544191522486,114.00922667829238,114.00922667829238,1000000.0 +2020-01-30,TLT,113.16250315606791,113.72831567184824,110.95389324098592,111.5114504934532,111.5114504934532,1000000.0 +2020-01-31,TLT,111.5114504934532,112.3974876243186,110.95389324098592,111.83829614360064,111.83829614360064,1000000.0 +2020-02-03,TLT,111.83829614360064,113.47467638071488,111.27910466288265,112.91012575195512,112.91012575195512,1000000.0 +2020-02-04,TLT,112.91012575195512,113.47467638071488,111.61690843413244,112.17779742123864,112.17779742123864,1000000.0 +2020-02-05,TLT,112.17779742123864,114.08963944523602,111.61690843413244,113.52202929874232,113.52202929874232,1000000.0 +2020-02-06,TLT,113.52202929874232,114.57927281168382,112.9544191522486,114.00922667829238,114.00922667829238,1000000.0 2020-02-07,TLT,114.00922667829238,114.57927281168382,112.94201571323626,113.50956353089072,113.50956353089072,1000000.0 2020-02-10,TLT,113.50956353089072,114.2635650048141,112.94201571323626,113.69508955702896,113.69508955702896,1000000.0 2020-02-11,TLT,113.69508955702896,114.2635650048141,111.74636816429368,112.30790770280773,112.30790770280773,1000000.0 -2020-02-12,TLT,112.30790770280773,112.86944724132175,111.24190618736455,111.8009107410699,111.8009107410699,1000000.0 -2020-02-13,TLT,111.8009107410699,114.01414035483128,111.24190618736455,113.44690582570279,113.44690582570279,1000000.0 -2020-02-14,TLT,113.44690582570279,114.01414035483128,112.07541166799727,112.63860469145455,112.63860469145455,1000000.0 -2020-02-17,TLT,112.63860469145455,114.88465446577452,112.07541166799727,114.31308902067117,114.31308902067117,1000000.0 -2020-02-18,TLT,114.31308902067117,114.88465446577452,113.66732135633198,114.2385139259618,114.2385139259618,1000000.0 -2020-02-19,TLT,114.2385139259618,115.72692658512265,113.66732135633198,115.15117073146534,115.15117073146534,1000000.0 +2020-02-12,TLT,112.30790770280773,112.86944724132177,111.24190618736456,111.8009107410699,111.8009107410699,1000000.0 +2020-02-13,TLT,111.8009107410699,114.01414035483128,111.24190618736456,113.4469058257028,113.4469058257028,1000000.0 +2020-02-14,TLT,113.4469058257028,114.01414035483128,112.07541166799729,112.63860469145456,112.63860469145456,1000000.0 +2020-02-18,TLT,114.31308902067116,114.88465446577452,113.66732135633198,114.2385139259618,114.2385139259618,1000000.0 +2020-02-19,TLT,114.2385139259618,115.72692658512264,113.66732135633198,115.15117073146534,115.15117073146534,1000000.0 2020-02-20,TLT,115.15117073146534,115.7675742620926,114.57541487780802,115.19161618118667,115.19161618118667,1000000.0 -2020-02-21,TLT,115.19161618118667,115.7675742620926,112.57870479707594,113.14442693173461,113.14442693173461,1000000.0 -2020-02-24,TLT,113.14442693173461,113.71014906639327,112.06413762416847,112.62727399413916,112.62727399413916,1000000.0 -2020-02-25,TLT,112.62727399413916,113.36245783841231,112.06413762416847,112.79846551085804,112.79846551085804,1000000.0 -2020-02-26,TLT,112.79846551085804,113.36245783841231,111.20580635200947,111.76462949950701,111.76462949950701,1000000.0 -2020-02-27,TLT,111.76462949950701,112.32345264700453,110.01352469232664,110.56635647470014,110.56635647470014,1000000.0 +2020-02-21,TLT,115.19161618118667,115.7675742620926,112.57870479707594,113.1444269317346,113.1444269317346,1000000.0 +2020-02-24,TLT,113.1444269317346,113.71014906639329,112.06413762416848,112.62727399413916,112.62727399413916,1000000.0 +2020-02-25,TLT,112.62727399413916,113.36245783841233,112.06413762416848,112.79846551085804,112.79846551085804,1000000.0 +2020-02-26,TLT,112.79846551085804,113.36245783841233,111.20580635200947,111.764629499507,111.764629499507,1000000.0 +2020-02-27,TLT,111.764629499507,112.32345264700452,110.01352469232664,110.56635647470014,110.56635647470014,1000000.0 2020-02-28,TLT,110.56635647470014,111.4451877280463,110.01352469232664,110.89073405775753,110.89073405775753,1000000.0 -2020-03-02,TLT,110.89073405775753,111.4451877280463,109.76453459064395,110.31611516647632,110.31611516647632,1000000.0 -2020-03-03,TLT,110.31611516647632,110.8676957423087,108.28545683235487,108.82960485663806,108.82960485663806,1000000.0 -2020-03-04,TLT,108.82960485663806,109.37375288092123,107.69443781509142,108.23561589456425,108.23561589456425,1000000.0 -2020-03-05,TLT,108.23561589456425,109.528404952902,107.69443781509142,108.98348751532538,108.98348751532538,1000000.0 -2020-03-06,TLT,108.98348751532538,109.528404952902,108.02836912176029,108.57122524800029,108.57122524800029,1000000.0 -2020-03-09,TLT,108.57122524800029,109.93311480068174,108.02836912176029,109.38618388127537,109.38618388127537,1000000.0 -2020-03-10,TLT,109.38618388127537,111.28453676399519,108.839252961869,110.73088235223403,110.73088235223403,1000000.0 -2020-03-11,TLT,110.73088235223403,111.31348821955935,110.17722794047286,110.75968977070583,110.75968977070583,1000000.0 -2020-03-12,TLT,110.75968977070583,111.31348821955935,109.21439629372188,109.76321235549938,109.76321235549938,1000000.0 +2020-03-02,TLT,110.89073405775753,111.4451877280463,109.76453459064396,110.31611516647632,110.31611516647632,1000000.0 +2020-03-03,TLT,110.31611516647632,110.8676957423087,108.28545683235488,108.82960485663806,108.82960485663806,1000000.0 +2020-03-04,TLT,108.82960485663806,109.37375288092124,107.69443781509142,108.23561589456423,108.23561589456423,1000000.0 +2020-03-05,TLT,108.23561589456423,109.528404952902,107.69443781509142,108.98348751532538,108.98348751532538,1000000.0 +2020-03-06,TLT,108.98348751532538,109.528404952902,108.02836912176028,108.57122524800027,108.57122524800027,1000000.0 +2020-03-09,TLT,108.57122524800027,109.93311480068174,108.02836912176028,109.38618388127536,109.38618388127536,1000000.0 +2020-03-10,TLT,109.38618388127536,111.2845367639952,108.839252961869,110.73088235223403,110.73088235223403,1000000.0 +2020-03-11,TLT,110.73088235223403,111.31348821955936,110.17722794047286,110.75968977070583,110.75968977070583,1000000.0 +2020-03-12,TLT,110.75968977070583,111.31348821955936,109.21439629372188,109.76321235549938,109.76321235549938,1000000.0 2020-03-13,TLT,109.76321235549938,110.31202841727686,108.08309917314988,108.62623032477374,108.62623032477374,1000000.0 2020-03-16,TLT,108.62623032477374,109.1693614763976,108.0604007735089,108.60341786282302,108.60341786282302,1000000.0 2020-03-17,TLT,108.60341786282302,109.91846525172828,108.0604007735089,109.37160721565004,109.37160721565004,1000000.0 2020-03-18,TLT,109.37160721565004,110.1552780015035,108.8247491795718,109.6072417925408,109.6072417925408,1000000.0 2020-03-19,TLT,109.6072417925408,110.63860138166784,109.0592055835781,110.08816057877398,110.08816057877398,1000000.0 2020-03-20,TLT,110.08816057877398,110.63860138166784,109.14990673521794,109.69839872886224,109.69839872886224,1000000.0 -2020-03-23,TLT,109.69839872886224,110.51217478507792,109.14990673521794,109.96236297022679,109.96236297022679,1000000.0 -2020-03-24,TLT,109.96236297022679,110.51217478507792,109.00079115193299,109.54853382103818,109.54853382103818,1000000.0 -2020-03-25,TLT,109.54853382103818,110.09627649014335,108.71722674104798,109.26354446336481,109.26354446336481,1000000.0 -2020-03-26,TLT,109.26354446336481,109.80986218568162,107.59910065442321,108.13979965268665,108.13979965268665,1000000.0 -2020-03-27,TLT,108.13979965268665,108.68049865095007,106.25422646257616,106.78816729907152,106.78816729907152,1000000.0 -2020-03-30,TLT,106.78816729907152,107.32210813556686,105.84533575573676,106.37722186506207,106.37722186506207,1000000.0 -2020-03-31,TLT,106.37722186506207,106.90910797438737,104.9116048711497,105.4387988654771,105.4387988654771,1000000.0 +2020-03-23,TLT,109.69839872886224,110.51217478507792,109.14990673521794,109.9623629702268,109.9623629702268,1000000.0 +2020-03-24,TLT,109.9623629702268,110.51217478507792,109.000791151933,109.54853382103818,109.54853382103818,1000000.0 +2020-03-25,TLT,109.54853382103818,110.09627649014335,108.71722674104798,109.2635444633648,109.2635444633648,1000000.0 +2020-03-26,TLT,109.2635444633648,109.80986218568162,107.5991006544232,108.13979965268663,108.13979965268663,1000000.0 +2020-03-27,TLT,108.13979965268663,108.68049865095008,106.25422646257616,106.78816729907152,106.78816729907152,1000000.0 +2020-03-30,TLT,106.78816729907152,107.32210813556686,105.84533575573676,106.37722186506208,106.37722186506208,1000000.0 +2020-03-31,TLT,106.37722186506208,106.90910797438735,104.9116048711497,105.4387988654771,105.4387988654771,1000000.0 2020-04-01,TLT,105.4387988654771,105.96599285980447,104.11001446901466,104.633180370869,104.633180370869,1000000.0 2020-04-02,TLT,104.633180370869,105.72238345963594,104.11001446901466,105.19640145237408,105.19640145237408,1000000.0 -2020-04-03,TLT,105.19640145237408,105.72238345963594,104.58079220034358,105.10632381944079,105.10632381944079,1000000.0 -2020-04-06,TLT,105.10632381944079,108.12338127095289,104.58079220034358,107.58545400094816,107.58545400094816,1000000.0 +2020-04-03,TLT,105.19640145237408,105.72238345963594,104.58079220034358,105.1063238194408,105.1063238194408,1000000.0 +2020-04-06,TLT,105.1063238194408,108.12338127095288,104.58079220034358,107.58545400094816,107.58545400094816,1000000.0 2020-04-07,TLT,107.58545400094816,108.92763153293062,107.04752673094342,108.38570301784142,108.38570301784142,1000000.0 2020-04-08,TLT,108.38570301784142,108.92763153293062,107.29887302824662,107.83806334497147,107.83806334497147,1000000.0 2020-04-09,TLT,107.83806334497147,109.05390362438278,107.29887302824662,108.51134688993314,108.51134688993314,1000000.0 -2020-04-10,TLT,108.51134688993314,109.36563517530602,107.96879015548348,108.82152753761795,108.82152753761795,1000000.0 -2020-04-13,TLT,108.82152753761795,109.36563517530602,107.68509574174733,108.22622687612797,108.22622687612797,1000000.0 -2020-04-14,TLT,108.22622687612797,109.7310125772623,107.68509574174733,109.18508714155455,109.18508714155455,1000000.0 -2020-04-15,TLT,109.18508714155455,109.7310125772623,107.96739880748284,108.50994855023401,108.50994855023401,1000000.0 -2020-04-16,TLT,108.50994855023401,109.05249829298518,105.35780971552047,105.8872459452467,105.8872459452467,1000000.0 -2020-04-17,TLT,105.8872459452467,107.09484782094813,105.35780971552047,106.56203763278423,106.56203763278423,1000000.0 -2020-04-20,TLT,106.56203763278423,107.09484782094813,105.50693799778954,106.03712361586888,106.03712361586888,1000000.0 +2020-04-13,TLT,108.82152753761795,109.36563517530602,107.68509574174732,108.22622687612797,108.22622687612797,1000000.0 +2020-04-14,TLT,108.22622687612797,109.7310125772623,107.68509574174732,109.18508714155456,109.18508714155456,1000000.0 +2020-04-15,TLT,109.18508714155456,109.7310125772623,107.96739880748284,108.509948550234,108.509948550234,1000000.0 +2020-04-16,TLT,108.509948550234,109.05249829298518,105.35780971552047,105.8872459452467,105.8872459452467,1000000.0 +2020-04-17,TLT,105.8872459452467,107.09484782094812,105.35780971552047,106.56203763278424,106.56203763278424,1000000.0 +2020-04-20,TLT,106.56203763278424,107.09484782094812,105.50693799778954,106.03712361586888,106.03712361586888,1000000.0 2020-04-21,TLT,106.03712361586888,107.84177396916196,105.50693799778954,107.30524773050942,107.30524773050942,1000000.0 2020-04-22,TLT,107.30524773050942,107.84177396916196,106.21524368715606,106.7489886303076,106.7489886303076,1000000.0 2020-04-23,TLT,106.7489886303076,107.28273357345913,105.21614962601345,105.74487399599342,105.74487399599342,1000000.0 @@ -347,164 +334,160 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-04-28,TLT,106.14573948750127,106.8501880459902,105.61501079006376,106.31859507063702,106.31859507063702,1000000.0 2020-04-29,TLT,106.31859507063702,108.09663107179932,105.78700209528382,107.55883688736252,107.55883688736252,1000000.0 2020-04-30,TLT,107.55883688736252,108.19569064982248,107.0210427029257,107.65740363166417,107.65740363166417,1000000.0 -2020-05-01,TLT,107.65740363166417,108.19569064982248,107.05008138915792,107.58802149664113,107.58802149664113,1000000.0 -2020-05-04,TLT,107.58802149664113,108.21459497284019,107.05008138915792,107.67621390332359,107.67621390332359,1000000.0 -2020-05-05,TLT,107.67621390332359,108.9434211424379,107.13783283380697,108.40141407207753,108.40141407207753,1000000.0 -2020-05-06,TLT,108.40141407207753,108.9434211424379,107.16547066711456,107.70399062021563,107.70399062021563,1000000.0 -2020-05-07,TLT,107.70399062021563,108.77258277301226,107.16547066711456,108.23142564478833,108.23142564478833,1000000.0 -2020-05-08,TLT,108.23142564478833,109.27590356373501,107.69026851656439,108.73224235197515,108.73224235197515,1000000.0 -2020-05-11,TLT,108.73224235197515,111.3343243517913,108.18858114021526,110.78042224058836,110.78042224058836,1000000.0 +2020-05-01,TLT,107.65740363166417,108.19569064982248,107.05008138915792,107.58802149664112,107.58802149664112,1000000.0 +2020-05-04,TLT,107.58802149664112,108.2145949728402,107.05008138915792,107.6762139033236,107.6762139033236,1000000.0 +2020-05-05,TLT,107.6762139033236,108.9434211424379,107.13783283380695,108.40141407207751,108.40141407207751,1000000.0 +2020-05-06,TLT,108.40141407207751,108.9434211424379,107.16547066711456,107.70399062021563,107.70399062021563,1000000.0 +2020-05-07,TLT,107.70399062021563,108.77258277301226,107.16547066711456,108.23142564478832,108.23142564478832,1000000.0 +2020-05-08,TLT,108.23142564478832,109.275903563735,107.6902685165644,108.73224235197516,108.73224235197516,1000000.0 +2020-05-11,TLT,108.73224235197516,111.3343243517913,108.18858114021526,110.78042224058836,110.78042224058836,1000000.0 2020-05-12,TLT,110.78042224058836,111.3343243517913,109.93016692526837,110.48257982439031,110.48257982439031,1000000.0 2020-05-13,TLT,110.48257982439031,111.03499272351225,109.12856462907084,109.67694937595058,109.67694937595058,1000000.0 2020-05-14,TLT,109.67694937595058,110.87597770822636,109.12856462907084,110.32435592858346,110.32435592858346,1000000.0 -2020-05-15,TLT,110.32435592858346,110.87597770822636,109.42451134126179,109.97438325754953,109.97438325754953,1000000.0 -2020-05-18,TLT,109.97438325754953,110.78018582608456,109.42451134126179,110.22904062296972,110.22904062296972,1000000.0 -2020-05-19,TLT,110.22904062296972,110.89774297075094,109.67789541985488,110.34601290621985,110.34601290621985,1000000.0 -2020-05-20,TLT,110.34601290621985,111.93918065197805,109.79428284168876,111.3822693054508,111.3822693054508,1000000.0 -2020-05-21,TLT,111.3822693054508,111.93918065197805,110.2445313348765,110.79852395464975,110.79852395464975,1000000.0 -2020-05-22,TLT,110.79852395464975,111.35251657442299,110.16317486143336,110.7167586547069,110.7167586547069,1000000.0 -2020-05-25,TLT,110.7167586547069,111.27034244798041,107.9367894886876,108.47918541576642,108.47918541576642,1000000.0 -2020-05-26,TLT,108.47918541576642,109.02158134284524,107.17091131027941,107.70945860329589,107.70945860329589,1000000.0 -2020-05-27,TLT,107.70945860329589,109.58666045440309,107.17091131027941,109.04145318846079,109.04145318846079,1000000.0 -2020-05-28,TLT,109.04145318846079,109.58666045440309,107.90632884518882,108.44857170370736,108.44857170370736,1000000.0 -2020-05-29,TLT,108.44857170370736,108.99081456222588,107.30247448375985,107.84168289825111,107.84168289825111,1000000.0 -2020-06-01,TLT,107.84168289825111,108.38089131274235,106.26207551720779,106.79605579618874,106.79605579618874,1000000.0 -2020-06-02,TLT,106.79605579618874,107.82906167080705,106.26207551720779,107.29259867741996,107.29259867741996,1000000.0 -2020-06-03,TLT,107.29259867741996,107.98271113564351,106.75613568403286,107.44548371705824,107.44548371705824,1000000.0 -2020-06-04,TLT,107.44548371705824,107.98271113564351,105.89797324306889,106.4301238623808,106.4301238623808,1000000.0 -2020-06-05,TLT,106.4301238623808,107.5349381001907,105.89797324306889,106.99993840814996,106.99993840814996,1000000.0 +2020-05-15,TLT,110.32435592858346,110.87597770822636,109.4245113412618,109.97438325754952,109.97438325754952,1000000.0 +2020-05-18,TLT,109.97438325754952,110.78018582608456,109.4245113412618,110.22904062296972,110.22904062296972,1000000.0 +2020-05-19,TLT,110.22904062296972,110.89774297075094,109.67789541985488,110.34601290621984,110.34601290621984,1000000.0 +2020-05-20,TLT,110.34601290621984,111.93918065197803,109.79428284168876,111.3822693054508,111.3822693054508,1000000.0 +2020-05-21,TLT,111.3822693054508,111.93918065197803,110.2445313348765,110.79852395464977,110.79852395464977,1000000.0 +2020-05-22,TLT,110.79852395464977,111.352516574423,110.16317486143336,110.7167586547069,110.7167586547069,1000000.0 +2020-05-26,TLT,108.47918541576642,109.02158134284524,107.1709113102794,107.70945860329589,107.70945860329589,1000000.0 +2020-05-27,TLT,107.70945860329589,109.58666045440307,107.1709113102794,109.0414531884608,109.0414531884608,1000000.0 +2020-05-28,TLT,109.0414531884608,109.58666045440307,107.90632884518882,108.44857170370736,108.44857170370736,1000000.0 +2020-05-29,TLT,108.44857170370736,108.99081456222588,107.30247448375984,107.84168289825112,107.84168289825112,1000000.0 +2020-06-01,TLT,107.84168289825112,108.38089131274236,106.2620755172078,106.79605579618874,106.79605579618874,1000000.0 +2020-06-02,TLT,106.79605579618874,107.82906167080704,106.2620755172078,107.29259867741996,107.29259867741996,1000000.0 +2020-06-03,TLT,107.29259867741996,107.98271113564353,106.75613568403286,107.44548371705824,107.44548371705824,1000000.0 +2020-06-04,TLT,107.44548371705824,107.98271113564353,105.89797324306888,106.4301238623808,106.4301238623808,1000000.0 +2020-06-05,TLT,106.4301238623808,107.5349381001907,105.89797324306888,106.99993840814996,106.99993840814996,1000000.0 2020-06-08,TLT,106.99993840814996,107.75893482833828,106.4649387161092,107.22282072471472,107.22282072471472,1000000.0 -2020-06-09,TLT,107.22282072471472,108.41093002616992,106.68670662109115,107.87157216534322,107.87157216534322,1000000.0 -2020-06-10,TLT,107.87157216534322,108.41093002616992,106.78281389810175,107.31941095286608,107.31941095286608,1000000.0 -2020-06-11,TLT,107.31941095286608,109.36611951947833,106.78281389810175,108.82200947211776,108.82200947211776,1000000.0 -2020-06-12,TLT,108.82200947211776,109.7253131215666,108.27789942475717,109.17941604135981,109.17941604135981,1000000.0 -2020-06-15,TLT,109.17941604135981,109.7253131215666,108.53198148674707,109.07736832838901,109.07736832838901,1000000.0 -2020-06-16,TLT,109.07736832838901,109.62275517003094,108.08243419317026,108.62556200318619,108.62556200318619,1000000.0 -2020-06-17,TLT,108.62556200318619,109.1686898132021,107.87094906385832,108.41301413453097,108.41301413453097,1000000.0 -2020-06-18,TLT,108.41301413453097,109.70621452473674,107.87094906385832,109.16041246242463,109.16041246242463,1000000.0 -2020-06-19,TLT,109.16041246242463,109.70621452473674,108.38115196058162,108.92578086490614,108.92578086490614,1000000.0 -2020-06-22,TLT,108.92578086490614,110.41869224334178,108.38115196058162,109.86934551576297,109.86934551576297,1000000.0 -2020-06-23,TLT,109.86934551576297,110.93282552728544,109.31999878818415,110.38092092267209,110.38092092267209,1000000.0 -2020-06-24,TLT,110.38092092267209,112.13424886645929,109.82901631805873,111.57636703130278,111.57636703130278,1000000.0 -2020-06-25,TLT,111.57636703130278,112.86694396648026,111.01848519614627,112.30541688206992,112.30541688206992,1000000.0 +2020-06-09,TLT,107.22282072471472,108.41093002616992,106.68670662109116,107.87157216534322,107.87157216534322,1000000.0 +2020-06-10,TLT,107.87157216534322,108.41093002616992,106.78281389810176,107.31941095286608,107.31941095286608,1000000.0 +2020-06-11,TLT,107.31941095286608,109.36611951947832,106.78281389810176,108.82200947211776,108.82200947211776,1000000.0 +2020-06-12,TLT,108.82200947211776,109.7253131215666,108.27789942475717,109.1794160413598,109.1794160413598,1000000.0 +2020-06-15,TLT,109.1794160413598,109.7253131215666,108.53198148674709,109.077368328389,109.077368328389,1000000.0 +2020-06-16,TLT,109.077368328389,109.62275517003094,108.08243419317026,108.6255620031862,108.6255620031862,1000000.0 +2020-06-17,TLT,108.6255620031862,109.1686898132021,107.87094906385832,108.41301413453095,108.41301413453095,1000000.0 +2020-06-18,TLT,108.41301413453095,109.70621452473674,107.87094906385832,109.16041246242465,109.16041246242465,1000000.0 +2020-06-19,TLT,109.16041246242465,109.70621452473674,108.38115196058162,108.92578086490614,108.92578086490614,1000000.0 +2020-06-22,TLT,108.92578086490614,110.41869224334178,108.38115196058162,109.86934551576296,109.86934551576296,1000000.0 +2020-06-23,TLT,109.86934551576296,110.93282552728544,109.31999878818417,110.38092092267208,110.38092092267208,1000000.0 +2020-06-24,TLT,110.38092092267208,112.13424886645927,109.82901631805872,111.57636703130278,111.57636703130278,1000000.0 +2020-06-25,TLT,111.57636703130278,112.86694396648026,111.01848519614629,112.30541688206992,112.30541688206992,1000000.0 2020-06-26,TLT,112.30541688206992,112.86694396648026,111.68763933276894,112.24888375152658,112.24888375152658,1000000.0 2020-06-29,TLT,112.24888375152658,112.8101281702842,109.9946051946801,110.5473419042011,110.5473419042011,1000000.0 2020-06-30,TLT,110.5473419042011,111.36576551229068,109.9946051946801,110.81170697740367,110.81170697740367,1000000.0 -2020-07-01,TLT,110.81170697740367,112.07099754878224,110.25764844251665,111.51343039679827,111.51343039679827,1000000.0 -2020-07-02,TLT,111.51343039679827,112.2171367516385,110.95586324481428,111.6588425389438,111.6588425389438,1000000.0 -2020-07-03,TLT,111.6588425389438,112.71777961160988,111.10054832624908,112.15699463841781,112.15699463841781,1000000.0 -2020-07-06,TLT,112.15699463841781,112.71777961160988,111.17595484366755,111.73462798358548,111.73462798358548,1000000.0 +2020-07-01,TLT,110.81170697740367,112.07099754878224,110.25764844251664,111.51343039679828,111.51343039679828,1000000.0 +2020-07-02,TLT,111.51343039679828,112.2171367516385,110.95586324481428,111.6588425389438,111.6588425389438,1000000.0 +2020-07-06,TLT,112.1569946384178,112.71777961160988,111.17595484366755,111.73462798358548,111.73462798358548,1000000.0 2020-07-07,TLT,111.73462798358548,112.2933011235034,110.83806966770854,111.39504489216938,111.39504489216938,1000000.0 -2020-07-08,TLT,111.39504489216938,111.95202011663021,110.00559102574651,110.55838294044875,110.55838294044875,1000000.0 -2020-07-09,TLT,110.55838294044875,112.32837245244761,110.00559102574651,111.76952482830609,111.76952482830609,1000000.0 -2020-07-10,TLT,111.76952482830609,112.47080003091723,111.21067720416455,111.91124381185796,111.91124381185796,1000000.0 -2020-07-13,TLT,111.91124381185796,112.47080003091723,110.9138374465578,111.47119341362594,111.47119341362594,1000000.0 -2020-07-14,TLT,111.47119341362594,112.02854938069406,109.73834392328149,110.2897928877201,110.2897928877201,1000000.0 -2020-07-15,TLT,110.2897928877201,110.84124185215869,109.60383693090284,110.15460998080688,110.15460998080688,1000000.0 -2020-07-16,TLT,110.15460998080688,111.99044053262045,109.60383693090284,111.43327416181141,111.43327416181141,1000000.0 -2020-07-17,TLT,111.43327416181141,112.4050526033515,110.87610779100235,111.84582348592191,111.84582348592191,1000000.0 -2020-07-20,TLT,111.84582348592191,112.4050526033515,110.64342382559991,111.19942093025116,111.19942093025116,1000000.0 -2020-07-21,TLT,111.19942093025116,113.04078417903789,110.64342382559991,112.47839221794816,112.47839221794816,1000000.0 -2020-07-22,TLT,112.47839221794816,114.34003326085143,111.91600025685841,113.77117737398153,113.77117737398153,1000000.0 -2020-07-23,TLT,113.77117737398153,115.24069788212593,113.20232148711162,114.66736107674222,114.66736107674222,1000000.0 +2020-07-08,TLT,111.39504489216938,111.9520201166302,110.00559102574653,110.55838294044877,110.55838294044877,1000000.0 +2020-07-09,TLT,110.55838294044877,112.3283724524476,110.00559102574653,111.76952482830607,111.76952482830607,1000000.0 +2020-07-10,TLT,111.76952482830607,112.47080003091725,111.21067720416455,111.91124381185796,111.91124381185796,1000000.0 +2020-07-13,TLT,111.91124381185796,112.47080003091725,110.9138374465578,111.47119341362594,111.47119341362594,1000000.0 +2020-07-14,TLT,111.47119341362594,112.02854938069406,109.73834392328148,110.2897928877201,110.2897928877201,1000000.0 +2020-07-15,TLT,110.2897928877201,110.84124185215867,109.60383693090284,110.15460998080688,110.15460998080688,1000000.0 +2020-07-16,TLT,110.15460998080688,111.99044053262044,109.60383693090284,111.4332741618114,111.4332741618114,1000000.0 +2020-07-17,TLT,111.4332741618114,112.4050526033515,110.87610779100235,111.84582348592193,111.84582348592193,1000000.0 +2020-07-20,TLT,111.84582348592193,112.4050526033515,110.64342382559992,111.19942093025116,111.19942093025116,1000000.0 +2020-07-21,TLT,111.19942093025116,113.04078417903789,110.64342382559992,112.47839221794816,112.47839221794816,1000000.0 +2020-07-22,TLT,112.47839221794816,114.34003326085144,111.9160002568584,113.77117737398152,113.77117737398152,1000000.0 +2020-07-23,TLT,113.77117737398152,115.24069788212591,113.20232148711162,114.66736107674222,114.66736107674222,1000000.0 2020-07-24,TLT,114.66736107674222,115.97022777427348,114.0940242713585,115.3932614669388,115.3932614669388,1000000.0 2020-07-27,TLT,115.3932614669388,117.04736890353674,114.8162951596041,116.4650436851112,116.4650436851112,1000000.0 -2020-07-28,TLT,116.4650436851112,117.55717225829159,115.88271846668565,116.97231070476776,116.97231070476776,1000000.0 -2020-07-29,TLT,116.97231070476776,118.28829162554646,116.38744915124393,117.6997926622353,117.6997926622353,1000000.0 +2020-07-28,TLT,116.4650436851112,117.5571722582916,115.88271846668565,116.97231070476776,116.97231070476776,1000000.0 +2020-07-29,TLT,116.97231070476776,118.28829162554646,116.38744915124391,117.6997926622353,117.6997926622353,1000000.0 2020-07-30,TLT,117.6997926622353,118.28829162554646,116.38277091836274,116.96760896317863,116.96760896317863,1000000.0 -2020-07-31,TLT,116.96760896317863,118.97115324223125,116.38277091836274,118.37925695744404,118.37925695744404,1000000.0 +2020-07-31,TLT,116.96760896317863,118.97115324223124,116.38277091836274,118.37925695744404,118.37925695744404,1000000.0 2020-08-03,TLT,118.37925695744404,119.19138702472203,117.78736067265682,118.59839504947468,118.59839504947468,1000000.0 -2020-08-04,TLT,118.59839504947468,121.13724875115847,118.0054030742273,120.53457587179948,120.53457587179948,1000000.0 -2020-08-05,TLT,120.53457587179948,121.13724875115847,117.98837738239348,118.58128380140049,118.58128380140049,1000000.0 -2020-08-06,TLT,118.58128380140049,119.84862368099394,117.98837738239348,119.25236187163577,119.25236187163577,1000000.0 -2020-08-07,TLT,119.25236187163577,120.79334216557072,118.65610006227759,120.19238026424948,120.19238026424948,1000000.0 -2020-08-10,TLT,120.19238026424948,120.79334216557072,119.26377362991417,119.86308907529062,119.86308907529062,1000000.0 -2020-08-11,TLT,119.86308907529062,120.89921480660372,119.26377362991417,120.29772617572512,120.29772617572512,1000000.0 -2020-08-12,TLT,120.29772617572512,121.62516221272257,119.6962375448465,121.02006190320655,121.02006190320655,1000000.0 -2020-08-13,TLT,121.02006190320655,122.18825464894289,120.41496159369052,121.5803528845203,121.5803528845203,1000000.0 -2020-08-14,TLT,121.5803528845203,122.18825464894289,120.45130566959926,121.05658861266258,121.05658861266258,1000000.0 -2020-08-17,TLT,121.05658861266258,121.66187155572588,120.31461051760327,120.91920655035504,120.91920655035504,1000000.0 -2020-08-18,TLT,120.91920655035504,122.51855223835351,120.31461051760327,121.90900720234181,121.90900720234181,1000000.0 -2020-08-19,TLT,121.90900720234181,123.77693397322308,121.2994621663301,123.16112833156527,123.16112833156527,1000000.0 -2020-08-20,TLT,123.16112833156527,123.87498118760476,122.54532268990744,123.25868774886047,123.25868774886047,1000000.0 -2020-08-21,TLT,123.25868774886047,123.95134388995643,122.64239431011616,123.3346705372701,123.3346705372701,1000000.0 -2020-08-24,TLT,123.3346705372701,124.15031776137651,122.71799718458375,123.53265448893187,123.53265448893187,1000000.0 -2020-08-25,TLT,123.53265448893187,125.18521906687083,122.91499121648721,124.56240703171228,124.56240703171228,1000000.0 -2020-08-26,TLT,124.56240703171228,125.18521906687083,122.89889211988273,123.51647449234446,123.51647449234446,1000000.0 -2020-08-27,TLT,123.51647449234446,124.13405686480617,121.59908871397536,122.21013941103051,122.21013941103051,1000000.0 -2020-08-28,TLT,122.21013941103051,122.94339896433657,121.59908871397536,122.33174026302147,122.33174026302147,1000000.0 -2020-08-31,TLT,122.33174026302147,122.94339896433657,120.64734245843965,121.25361051099462,121.25361051099462,1000000.0 -2020-09-01,TLT,121.25361051099462,121.85987856354959,120.08200557350912,120.6854327371951,120.6854327371951,1000000.0 +2020-08-04,TLT,118.59839504947468,121.13724875115848,118.0054030742273,120.53457587179948,120.53457587179948,1000000.0 +2020-08-05,TLT,120.53457587179948,121.13724875115848,117.98837738239348,118.58128380140047,118.58128380140047,1000000.0 +2020-08-06,TLT,118.58128380140047,119.84862368099394,117.98837738239348,119.25236187163576,119.25236187163576,1000000.0 +2020-08-07,TLT,119.25236187163576,120.79334216557072,118.6561000622776,120.19238026424948,120.19238026424948,1000000.0 +2020-08-10,TLT,120.19238026424948,120.79334216557072,119.26377362991416,119.86308907529062,119.86308907529062,1000000.0 +2020-08-11,TLT,119.86308907529062,120.89921480660372,119.26377362991416,120.29772617572512,120.29772617572512,1000000.0 +2020-08-12,TLT,120.29772617572512,121.62516221272256,119.6962375448465,121.02006190320655,121.02006190320655,1000000.0 +2020-08-13,TLT,121.02006190320655,122.18825464894287,120.41496159369052,121.5803528845203,121.5803528845203,1000000.0 +2020-08-14,TLT,121.5803528845203,122.18825464894287,120.45130566959926,121.05658861266258,121.05658861266258,1000000.0 +2020-08-17,TLT,121.05658861266258,121.66187155572588,120.31461051760328,120.91920655035504,120.91920655035504,1000000.0 +2020-08-18,TLT,120.91920655035504,122.51855223835352,120.31461051760328,121.9090072023418,121.9090072023418,1000000.0 +2020-08-19,TLT,121.9090072023418,123.77693397322308,121.2994621663301,123.16112833156528,123.16112833156528,1000000.0 +2020-08-20,TLT,123.16112833156528,123.87498118760476,122.54532268990744,123.25868774886048,123.25868774886048,1000000.0 +2020-08-21,TLT,123.25868774886048,123.95134388995643,122.64239431011616,123.3346705372701,123.3346705372701,1000000.0 +2020-08-24,TLT,123.3346705372701,124.15031776137651,122.71799718458377,123.53265448893188,123.53265448893188,1000000.0 +2020-08-25,TLT,123.53265448893188,125.18521906687084,122.9149912164872,124.56240703171228,124.56240703171228,1000000.0 +2020-08-26,TLT,124.56240703171228,125.18521906687084,122.89889211988272,123.51647449234446,123.51647449234446,1000000.0 +2020-08-27,TLT,123.51647449234446,124.13405686480615,121.59908871397536,122.21013941103053,122.21013941103053,1000000.0 +2020-08-28,TLT,122.21013941103053,122.94339896433657,121.59908871397536,122.33174026302147,122.33174026302147,1000000.0 +2020-08-31,TLT,122.33174026302147,122.94339896433657,120.64734245843964,121.25361051099462,121.25361051099462,1000000.0 +2020-09-01,TLT,121.25361051099462,121.8598785635496,120.08200557350912,120.6854327371951,120.6854327371951,1000000.0 2020-09-02,TLT,120.6854327371951,121.37262250467757,120.08200557350912,120.76877861161948,120.76877861161948,1000000.0 2020-09-03,TLT,120.76877861161948,121.84203099969326,120.16493471856138,121.23585174098834,121.23585174098834,1000000.0 -2020-09-04,TLT,121.23585174098834,121.84203099969326,119.59948791133041,120.20049036314614,120.20049036314614,1000000.0 -2020-09-07,TLT,120.20049036314614,121.57866316443831,119.59948791133041,120.97379419347097,120.97379419347097,1000000.0 -2020-09-08,TLT,120.97379419347097,121.57866316443831,118.67298515160411,119.26933181065739,119.26933181065739,1000000.0 -2020-09-09,TLT,119.26933181065739,120.3898344888881,118.67298515160411,119.79088008844589,119.79088008844589,1000000.0 -2020-09-10,TLT,119.79088008844589,120.3898344888881,117.69695708764691,118.28839908306222,118.28839908306222,1000000.0 -2020-09-11,TLT,118.28839908306222,118.87984107847751,116.50262135727219,117.0880616656002,117.0880616656002,1000000.0 -2020-09-14,TLT,117.0880616656002,118.27963776336256,116.50262135727219,117.69118185409211,117.69118185409211,1000000.0 -2020-09-15,TLT,117.69118185409211,118.27963776336256,116.72265507255179,117.3092010779415,117.3092010779415,1000000.0 -2020-09-16,TLT,117.3092010779415,117.89574708333119,115.98213666023952,116.5649614675774,116.5649614675774,1000000.0 -2020-09-17,TLT,116.5649614675774,117.14778627491528,115.57018073753055,116.15093541460357,116.15093541460357,1000000.0 -2020-09-18,TLT,116.15093541460357,117.16677122280689,115.57018073753055,116.58385196299194,116.58385196299194,1000000.0 -2020-09-21,TLT,116.58385196299194,117.16677122280689,115.95078771457617,116.53345498952379,116.53345498952379,1000000.0 -2020-09-22,TLT,116.53345498952379,117.1161222644714,115.92353527003448,116.5060655980246,116.5060655980246,1000000.0 -2020-09-23,TLT,116.5060655980246,118.08079453121007,115.92353527003448,117.49332789175133,117.49332789175133,1000000.0 -2020-09-24,TLT,117.49332789175133,118.08079453121007,116.23777544906379,116.82188487343095,116.82188487343095,1000000.0 -2020-09-25,TLT,116.82188487343095,117.40599429779809,115.79975067628271,116.3816589711384,116.3816589711384,1000000.0 -2020-09-28,TLT,116.3816589711384,116.96356726599409,115.31678255704387,115.89626387642599,115.89626387642599,1000000.0 -2020-09-29,TLT,115.89626387642599,117.57006254628314,115.31678255704387,116.98513686197329,116.98513686197329,1000000.0 -2020-09-30,TLT,116.98513686197329,118.50624719562279,116.40021117766342,117.9166638762416,117.9166638762416,1000000.0 -2020-10-01,TLT,117.9166638762416,119.73276374059253,117.3270805568604,119.1370783488483,119.1370783488483,1000000.0 -2020-10-02,TLT,119.1370783488483,119.73276374059253,118.50939052594796,119.10491510145525,119.10491510145525,1000000.0 -2020-10-05,TLT,119.10491510145525,119.70043967696252,118.36082312960673,118.95560113528315,118.95560113528315,1000000.0 -2020-10-06,TLT,118.95560113528315,120.36876222680415,118.36082312960673,119.76991266348674,119.76991266348674,1000000.0 -2020-10-07,TLT,119.76991266348674,120.36876222680415,118.7956360294454,119.39259902456824,119.39259902456824,1000000.0 +2020-09-04,TLT,121.23585174098834,121.84203099969326,119.5994879113304,120.20049036314614,120.20049036314614,1000000.0 +2020-09-08,TLT,120.97379419347097,121.57866316443832,118.67298515160412,119.2693318106574,119.2693318106574,1000000.0 +2020-09-09,TLT,119.2693318106574,120.3898344888881,118.67298515160412,119.79088008844587,119.79088008844587,1000000.0 +2020-09-10,TLT,119.79088008844587,120.3898344888881,117.69695708764692,118.28839908306222,118.28839908306222,1000000.0 +2020-09-11,TLT,118.28839908306222,118.87984107847753,116.5026213572722,117.0880616656002,117.0880616656002,1000000.0 +2020-09-14,TLT,117.0880616656002,118.27963776336256,116.5026213572722,117.69118185409212,117.69118185409212,1000000.0 +2020-09-15,TLT,117.69118185409212,118.27963776336256,116.7226550725518,117.3092010779415,117.3092010779415,1000000.0 +2020-09-16,TLT,117.3092010779415,117.8957470833312,115.98213666023952,116.5649614675774,116.5649614675774,1000000.0 +2020-09-17,TLT,116.5649614675774,117.14778627491528,115.57018073753056,116.15093541460357,116.15093541460357,1000000.0 +2020-09-18,TLT,116.15093541460357,117.16677122280689,115.57018073753056,116.58385196299194,116.58385196299194,1000000.0 +2020-09-21,TLT,116.58385196299194,117.16677122280689,115.95078771457617,116.5334549895238,116.5334549895238,1000000.0 +2020-09-22,TLT,116.5334549895238,117.1161222644714,115.92353527003448,116.5060655980246,116.5060655980246,1000000.0 +2020-09-23,TLT,116.5060655980246,118.08079453121007,115.92353527003448,117.49332789175132,117.49332789175132,1000000.0 +2020-09-24,TLT,117.49332789175132,118.08079453121007,116.2377754490638,116.82188487343096,116.82188487343096,1000000.0 +2020-09-25,TLT,116.82188487343096,117.40599429779807,115.79975067628271,116.3816589711384,116.3816589711384,1000000.0 +2020-09-28,TLT,116.3816589711384,116.96356726599409,115.31678255704388,115.896263876426,115.896263876426,1000000.0 +2020-09-29,TLT,115.896263876426,117.57006254628314,115.31678255704388,116.98513686197327,116.98513686197327,1000000.0 +2020-09-30,TLT,116.98513686197327,118.5062471956228,116.40021117766342,117.9166638762416,117.9166638762416,1000000.0 +2020-10-01,TLT,117.9166638762416,119.73276374059252,117.3270805568604,119.1370783488483,119.1370783488483,1000000.0 +2020-10-02,TLT,119.1370783488483,119.73276374059252,118.50939052594796,119.10491510145523,119.10491510145523,1000000.0 +2020-10-05,TLT,119.10491510145523,119.70043967696252,118.36082312960671,118.95560113528316,118.95560113528316,1000000.0 +2020-10-06,TLT,118.95560113528316,120.36876222680417,118.36082312960671,119.76991266348674,119.76991266348674,1000000.0 +2020-10-07,TLT,119.76991266348674,120.36876222680417,118.7956360294454,119.39259902456824,119.39259902456824,1000000.0 2020-10-08,TLT,119.39259902456824,120.78958763322996,118.7956360294454,120.1886444111741,120.1886444111741,1000000.0 -2020-10-09,TLT,120.1886444111741,121.49151545658178,119.58770118911822,120.88708005630029,120.88708005630029,1000000.0 -2020-10-12,TLT,120.88708005630029,121.49151545658178,119.61255384522038,120.21362195499535,120.21362195499535,1000000.0 -2020-10-13,TLT,120.21362195499535,120.8146900647703,119.53297131669365,120.13363951426497,120.13363951426497,1000000.0 -2020-10-14,TLT,120.13363951426497,120.73430771183628,119.41899029093501,120.01908571953268,120.01908571953268,1000000.0 -2020-10-15,TLT,120.01908571953268,122.30342440599405,119.41899029093501,121.69494965770554,121.69494965770554,1000000.0 -2020-10-16,TLT,121.69494965770554,124.86181290909437,121.086474909417,124.2406098597954,124.2406098597954,1000000.0 -2020-10-19,TLT,124.2406098597954,124.86181290909437,123.52062272259688,124.14132936944411,124.14132936944411,1000000.0 -2020-10-20,TLT,124.14132936944411,124.776074709888,123.52062272259688,124.15529821879403,124.15529821879403,1000000.0 -2020-10-21,TLT,124.15529821879403,124.776074709888,121.30013992915445,121.90968837100951,121.90968837100951,1000000.0 -2020-10-22,TLT,121.90968837100951,122.7267465506869,121.30013992915445,122.11616572207653,122.11616572207653,1000000.0 -2020-10-23,TLT,122.11616572207653,122.7267465506869,121.49874872245543,122.10929519844767,122.10929519844767,1000000.0 -2020-10-26,TLT,122.10929519844767,122.71984167443989,120.46041753882854,121.06574627017943,121.06574627017943,1000000.0 -2020-10-27,TLT,121.06574627017943,122.43815569186452,120.46041753882854,121.82901063867118,121.82901063867118,1000000.0 +2020-10-09,TLT,120.1886444111741,121.49151545658178,119.58770118911822,120.88708005630028,120.88708005630028,1000000.0 +2020-10-12,TLT,120.88708005630028,121.49151545658178,119.61255384522038,120.21362195499536,120.21362195499536,1000000.0 +2020-10-13,TLT,120.21362195499536,120.8146900647703,119.53297131669363,120.13363951426496,120.13363951426496,1000000.0 +2020-10-14,TLT,120.13363951426496,120.73430771183628,119.418990290935,120.01908571953268,120.01908571953268,1000000.0 +2020-10-15,TLT,120.01908571953268,122.30342440599404,119.418990290935,121.69494965770554,121.69494965770554,1000000.0 +2020-10-16,TLT,121.69494965770554,124.86181290909435,121.086474909417,124.2406098597954,124.2406098597954,1000000.0 +2020-10-19,TLT,124.2406098597954,124.86181290909435,123.52062272259688,124.14132936944412,124.14132936944412,1000000.0 +2020-10-20,TLT,124.14132936944412,124.776074709888,123.52062272259688,124.15529821879404,124.15529821879404,1000000.0 +2020-10-21,TLT,124.15529821879404,124.776074709888,121.30013992915444,121.90968837100952,121.90968837100952,1000000.0 +2020-10-22,TLT,121.90968837100952,122.7267465506869,121.30013992915444,122.11616572207652,122.11616572207652,1000000.0 +2020-10-23,TLT,122.11616572207652,122.7267465506869,121.49874872245545,122.10929519844768,122.10929519844768,1000000.0 +2020-10-26,TLT,122.10929519844768,122.71984167443988,120.46041753882854,121.06574627017945,121.06574627017945,1000000.0 +2020-10-27,TLT,121.06574627017945,122.43815569186452,120.46041753882854,121.82901063867118,121.82901063867118,1000000.0 2020-10-28,TLT,121.82901063867118,122.43815569186452,119.71093205633822,120.31249452898314,120.31249452898314,1000000.0 -2020-10-29,TLT,120.31249452898314,120.91405700162804,119.50534681241975,120.10587619338668,120.10587619338668,1000000.0 -2020-10-30,TLT,120.10587619338668,120.8405672828603,119.50534681241975,120.23937043070678,120.23937043070678,1000000.0 +2020-10-29,TLT,120.31249452898314,120.91405700162804,119.50534681241976,120.10587619338668,120.10587619338668,1000000.0 +2020-10-30,TLT,120.10587619338668,120.8405672828603,119.50534681241976,120.23937043070678,120.23937043070678,1000000.0 2020-11-02,TLT,120.23937043070678,120.8405672828603,118.44870150150987,119.0439211070451,119.0439211070451,1000000.0 2020-11-03,TLT,119.0439211070451,120.46195505549026,118.44870150150987,119.86264184625898,119.86264184625898,1000000.0 -2020-11-04,TLT,119.86264184625898,121.48350246642927,119.26332863702768,120.87910693177044,120.87910693177044,1000000.0 -2020-11-05,TLT,120.87910693177044,121.48350246642927,118.94807117990628,119.54580018081033,119.54580018081033,1000000.0 -2020-11-06,TLT,119.54580018081033,120.14352918171437,117.61590964879656,118.20694437064981,118.20694437064981,1000000.0 -2020-11-09,TLT,118.20694437064981,119.3717378656746,117.61590964879656,118.7778486225618,118.7778486225618,1000000.0 +2020-11-04,TLT,119.86264184625898,121.48350246642929,119.26332863702768,120.87910693177044,120.87910693177044,1000000.0 +2020-11-05,TLT,120.87910693177044,121.48350246642929,118.94807117990628,119.54580018081032,119.54580018081032,1000000.0 +2020-11-06,TLT,119.54580018081032,120.14352918171436,117.61590964879656,118.2069443706498,118.2069443706498,1000000.0 +2020-11-09,TLT,118.2069443706498,119.3717378656746,117.61590964879656,118.7778486225618,118.7778486225618,1000000.0 2020-11-10,TLT,118.7778486225618,119.3717378656746,118.15512884369213,118.74887320974084,118.74887320974084,1000000.0 -2020-11-11,TLT,118.74887320974084,119.34261757578953,117.01559156614356,117.60360961421463,117.60360961421463,1000000.0 -2020-11-12,TLT,117.60360961421463,118.1916276622857,116.73954862913526,117.32617952676911,117.32617952676911,1000000.0 -2020-11-13,TLT,117.32617952676911,117.91281042440295,116.63918021030605,117.22530674402618,117.22530674402618,1000000.0 -2020-11-16,TLT,117.22530674402618,117.8114332777463,116.5057849959259,117.09124120193557,117.09124120193557,1000000.0 -2020-11-17,TLT,117.09124120193557,118.45829064239982,116.5057849959259,117.86894591283566,117.86894591283566,1000000.0 -2020-11-18,TLT,117.86894591283566,118.82294249332517,117.27960118327148,118.23178357544795,118.23178357544795,1000000.0 -2020-11-19,TLT,118.23178357544795,120.5169271530522,117.64062465757071,119.91734045079822,119.91734045079822,1000000.0 -2020-11-20,TLT,119.91734045079822,121.84930804374204,119.31775374854423,121.24309258083787,121.24309258083787,1000000.0 -2020-11-23,TLT,121.24309258083787,122.5124786685487,120.63687711793368,121.9029638493022,121.9029638493022,1000000.0 -2020-11-24,TLT,121.9029638493022,123.57268512614988,121.29344903005568,122.95789564791033,122.95789564791033,1000000.0 -2020-11-25,TLT,122.95789564791033,124.88834321613209,122.34310616967078,124.26700817525582,124.26700817525582,1000000.0 -2020-11-26,TLT,124.26700817525582,125.59570310933385,123.64567313437955,124.97084886500882,124.97084886500882,1000000.0 -2020-11-27,TLT,124.97084886500882,125.59570310933385,123.13262431794683,123.75138122406717,123.75138122406717,1000000.0 -2020-11-30,TLT,123.75138122406717,125.22817103174165,123.13262431794683,124.60514530521559,124.60514530521559,1000000.0 -2020-12-01,TLT,124.60514530521559,125.30059647830737,123.9821195786895,124.6772104261765,124.6772104261765,1000000.0 +2020-11-11,TLT,118.74887320974084,119.34261757578952,117.01559156614356,117.60360961421463,117.60360961421463,1000000.0 +2020-11-12,TLT,117.60360961421463,118.1916276622857,116.73954862913526,117.32617952676912,117.32617952676912,1000000.0 +2020-11-13,TLT,117.32617952676912,117.91281042440296,116.63918021030604,117.22530674402618,117.22530674402618,1000000.0 +2020-11-16,TLT,117.22530674402618,117.8114332777463,116.5057849959259,117.09124120193556,117.09124120193556,1000000.0 +2020-11-17,TLT,117.09124120193556,118.45829064239982,116.5057849959259,117.86894591283566,117.86894591283566,1000000.0 +2020-11-18,TLT,117.86894591283566,118.82294249332516,117.27960118327148,118.23178357544796,118.23178357544796,1000000.0 +2020-11-19,TLT,118.23178357544796,120.5169271530522,117.64062465757073,119.91734045079822,119.91734045079822,1000000.0 +2020-11-20,TLT,119.91734045079822,121.84930804374204,119.31775374854423,121.24309258083788,121.24309258083788,1000000.0 +2020-11-23,TLT,121.24309258083788,122.5124786685487,120.63687711793368,121.9029638493022,121.9029638493022,1000000.0 +2020-11-24,TLT,121.9029638493022,123.57268512614988,121.29344903005568,122.95789564791032,122.95789564791032,1000000.0 +2020-11-25,TLT,122.95789564791032,124.88834321613209,122.34310616967078,124.26700817525582,124.26700817525582,1000000.0 +2020-11-27,TLT,124.97084886500882,125.59570310933384,123.13262431794683,123.75138122406716,123.75138122406716,1000000.0 +2020-11-30,TLT,123.75138122406716,125.22817103174164,123.13262431794683,124.6051453052156,124.6051453052156,1000000.0 +2020-12-01,TLT,124.6051453052156,125.30059647830736,123.9821195786895,124.6772104261765,124.6772104261765,1000000.0 2020-12-02,TLT,124.6772104261765,126.93385326240468,124.05382437404562,126.30234155463154,126.30234155463154,1000000.0 2020-12-03,TLT,126.30234155463154,127.0288090711395,125.67082984685838,126.39682494640748,126.39682494640748,1000000.0 2020-12-04,TLT,126.39682494640748,127.5644648410794,125.76484082167543,126.92981576226808,126.92981576226808,1000000.0 -2020-12-07,TLT,126.92981576226808,127.90131150395307,126.29516668345674,127.2649865710976,127.2649865710976,1000000.0 -2020-12-08,TLT,127.2649865710976,129.6331796157998,126.62866163824211,128.98823842368142,128.98823842368142,1000000.0 +2020-12-07,TLT,126.92981576226808,127.90131150395308,126.29516668345674,127.2649865710976,127.2649865710976,1000000.0 +2020-12-08,TLT,127.2649865710976,129.6331796157998,126.62866163824212,128.98823842368142,128.98823842368142,1000000.0 2020-12-09,TLT,128.98823842368142,129.8170595643223,128.343297231563,129.17120354658937,129.17120354658937,1000000.0 2020-12-10,TLT,129.17120354658937,129.8170595643223,128.2485044412048,128.892969287643,128.892969287643,1000000.0 2020-12-11,TLT,128.892969287643,129.8581421527588,128.2485044412048,129.2120817440386,129.2120817440386,1000000.0 @@ -517,12 +500,10 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2020-12-22,TLT,130.776439766759,131.46920292002352,130.1225575679252,130.8151272836055,130.8151272836055,1000000.0 2020-12-23,TLT,130.8151272836055,131.46920292002352,129.95348887427298,130.6065214816814,130.6065214816814,1000000.0 2020-12-24,TLT,130.6065214816814,131.9677512734156,129.95348887427298,131.31119529693095,131.31119529693095,1000000.0 -2020-12-25,TLT,131.31119529693095,131.9677512734156,129.4254324115971,130.0758114689418,130.0758114689418,1000000.0 2020-12-28,TLT,130.0758114689418,130.7261905262865,128.71016169359217,129.35694642572076,129.35694642572076,1000000.0 2020-12-29,TLT,129.35694642572076,130.00373115784936,128.6695064112794,129.31608684550693,129.31608684550693,1000000.0 2020-12-30,TLT,129.31608684550693,130.53174021711106,128.6695064112794,129.8823285742399,129.8823285742399,1000000.0 2020-12-31,TLT,129.8823285742399,130.53174021711106,129.18507734158035,129.83424858450286,129.83424858450286,1000000.0 -2021-01-01,TLT,129.83424858450286,130.48341982742537,128.66907446167508,129.3156527253016,129.3156527253016,1000000.0 2021-01-04,TLT,129.3156527253016,129.96223098892807,127.77989006452005,128.42200006484427,128.42200006484427,1000000.0 2021-01-05,TLT,128.42200006484427,129.5935843517129,127.77989006452005,128.94884015095812,128.94884015095812,1000000.0 2021-01-06,TLT,128.94884015095812,131.09744058672584,128.30409595020333,130.44521451415508,130.44521451415508,1000000.0 @@ -533,7 +514,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-01-13,TLT,130.45797853181827,131.11026842447734,129.48243442193285,130.13309992154055,130.13309992154055,1000000.0 2021-01-14,TLT,130.13309992154055,130.78376542114825,129.20519752232994,129.8544698716884,129.8544698716884,1000000.0 2021-01-15,TLT,129.8544698716884,130.81234639871622,129.20519752232994,130.16153870519028,130.16153870519028,1000000.0 -2021-01-18,TLT,130.16153870519028,130.81234639871622,129.47755389712023,130.12819487147763,130.12819487147763,1000000.0 2021-01-19,TLT,130.12819487147763,130.778835845835,128.22369255347917,128.8680327170645,128.8680327170645,1000000.0 2021-01-20,TLT,128.8680327170645,130.68348472840376,128.22369255347917,130.03331813771518,130.03331813771518,1000000.0 2021-01-21,TLT,130.03331813771518,132.4648755443183,129.3831515470266,131.80584631275454,131.80584631275454,1000000.0 @@ -549,24 +529,23 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-02-04,TLT,134.1437388437537,134.81445753797246,133.05770630100216,133.72633799095695,133.72633799095695,1000000.0 2021-02-05,TLT,133.72633799095695,134.39496968091171,132.90828375228057,133.57616457515635,133.57616457515635,1000000.0 2021-02-08,TLT,133.57616457515635,135.21103247076925,132.90828375228057,134.5383407669346,134.5383407669346,1000000.0 -2021-02-09,TLT,134.5383407669346,135.21103247076925,132.9443299813232,133.61239194102833,133.61239194102833,1000000.0 -2021-02-10,TLT,133.61239194102833,134.28045390073345,132.54590598868944,133.21196581777832,133.21196581777832,1000000.0 -2021-02-11,TLT,133.21196581777832,133.8780256468672,132.03560273700253,132.69909822814324,132.69909822814324,1000000.0 +2021-02-09,TLT,134.5383407669346,135.21103247076925,132.9443299813232,133.6123919410283,133.6123919410283,1000000.0 +2021-02-10,TLT,133.6123919410283,134.28045390073345,132.54590598868944,133.21196581777832,133.21196581777832,1000000.0 +2021-02-11,TLT,133.21196581777832,133.8780256468672,132.0356027370025,132.69909822814324,132.69909822814324,1000000.0 2021-02-12,TLT,132.69909822814324,133.36259371928395,130.23597753537518,130.89042968379417,130.89042968379417,1000000.0 -2021-02-15,TLT,130.89042968379417,132.92053954811107,130.23597753537518,132.2592433314538,132.2592433314538,1000000.0 2021-02-16,TLT,132.2592433314538,133.53753686447746,131.59794711479654,132.8731710094303,132.8731710094303,1000000.0 -2021-02-17,TLT,132.8731710094303,133.53753686447746,132.1862628081952,132.85051538512081,132.85051538512081,1000000.0 -2021-02-18,TLT,132.85051538512081,134.7635006079331,132.1862628081952,134.09303543077922,134.09303543077922,1000000.0 +2021-02-17,TLT,132.8731710094303,133.53753686447746,132.1862628081952,132.8505153851208,132.8505153851208,1000000.0 +2021-02-18,TLT,132.8505153851208,134.7635006079331,132.1862628081952,134.09303543077922,134.09303543077922,1000000.0 2021-02-19,TLT,134.09303543077922,134.7635006079331,132.40559125890923,133.07094598885348,133.07094598885348,1000000.0 2021-02-22,TLT,133.07094598885348,133.73630071879774,132.32781278374523,132.99277666708065,132.99277666708065,1000000.0 -2021-02-23,TLT,132.99277666708065,135.51543973022532,132.32781278374523,134.84123356241327,134.84123356241327,1000000.0 -2021-02-24,TLT,134.84123356241327,136.5816919570967,134.1670273946012,135.90218105183752,135.90218105183752,1000000.0 +2021-02-23,TLT,132.99277666708065,135.51543973022532,132.32781278374523,134.8412335624133,134.8412335624133,1000000.0 +2021-02-24,TLT,134.8412335624133,136.5816919570967,134.1670273946012,135.90218105183752,135.90218105183752,1000000.0 2021-02-25,TLT,135.90218105183752,136.5816919570967,132.83652340822422,133.504043626356,133.504043626356,1000000.0 2021-02-26,TLT,133.504043626356,134.17156384448776,129.34232535445406,129.99228678839603,129.99228678839603,1000000.0 2021-03-01,TLT,129.99228678839603,130.642248222338,128.27281251301704,128.9173995105699,128.9173995105699,1000000.0 2021-03-02,TLT,128.9173995105699,130.66668525528718,128.27281251301704,130.01660224406686,130.01660224406686,1000000.0 2021-03-03,TLT,130.01660224406686,130.66668525528718,128.8784927403583,129.52612335714403,129.52612335714403,1000000.0 -2021-03-04,TLT,129.52612335714403,130.17375397392973,128.65418794625643,129.3006914032728,129.3006914032728,1000000.0 +2021-03-04,TLT,129.52612335714403,130.1737539739297,128.65418794625643,129.3006914032728,129.3006914032728,1000000.0 2021-03-05,TLT,129.3006914032728,130.58108125034173,128.65418794625643,129.93142412969328,129.93142412969328,1000000.0 2021-03-08,TLT,129.93142412969328,130.58108125034173,128.3694769226019,129.01454967095668,129.01454967095668,1000000.0 2021-03-09,TLT,129.01454967095668,129.8806238916035,128.3694769226019,129.23445163343635,129.23445163343635,1000000.0 @@ -580,14 +559,13 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-03-19,TLT,131.78675552822358,135.1970591165696,131.12782175058246,134.52443693191006,134.52443693191006,1000000.0 2021-03-22,TLT,134.52443693191006,135.1970591165696,133.06185308220472,133.730505610256,133.730505610256,1000000.0 2021-03-23,TLT,133.730505610256,134.39915813830726,130.7671243185512,131.4242455462826,131.4242455462826,1000000.0 -2021-03-24,TLT,131.4242455462826,132.08136677401401,130.32281244494055,130.977700949689,130.977700949689,1000000.0 +2021-03-24,TLT,131.4242455462826,132.081366774014,130.32281244494055,130.977700949689,130.977700949689,1000000.0 2021-03-25,TLT,130.977700949689,132.03002902445624,130.32281244494055,131.37316320841418,131.37316320841418,1000000.0 2021-03-26,TLT,131.37316320841418,133.14389224055242,130.7162973923721,132.48148481647007,132.48148481647007,1000000.0 2021-03-29,TLT,132.48148481647007,134.2981816598488,131.81907739238773,133.63003150233715,133.63003150233715,1000000.0 2021-03-30,TLT,133.63003150233715,134.2981816598488,132.13060560018346,132.79457849264668,132.79457849264668,1000000.0 2021-03-31,TLT,132.79457849264668,133.4585513851099,131.2947751182917,131.9545478575796,131.9545478575796,1000000.0 2021-04-01,TLT,131.9545478575796,133.96565722508095,131.2947751182917,133.299161417991,133.299161417991,1000000.0 -2021-04-02,TLT,133.299161417991,133.96565722508095,131.47862525073725,132.13932186003743,132.13932186003743,1000000.0 2021-04-05,TLT,132.13932186003743,133.96334622895066,131.47862525073725,133.2968619193539,133.2968619193539,1000000.0 2021-04-06,TLT,133.2968619193539,133.96334622895066,132.38985810799585,133.05513377688024,133.05513377688024,1000000.0 2021-04-07,TLT,133.05513377688024,134.54279841012024,132.38985810799585,133.873431253851,133.873431253851,1000000.0 @@ -622,16 +600,15 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-05-18,TLT,141.09033970368677,141.7957914022052,140.3661953890192,141.07155315479315,141.07155315479315,1000000.0 2021-05-19,TLT,141.07155315479315,141.7769109205671,140.19008342670568,140.89455620774442,140.89455620774442,1000000.0 2021-05-20,TLT,140.89455620774442,141.59902898878312,140.0209740609001,140.72459704613075,140.72459704613075,1000000.0 -2021-05-21,TLT,140.72459704613075,141.4282200313614,137.39748612217375,138.08792575092838,138.08792575092838,1000000.0 -2021-05-24,TLT,138.08792575092838,139.62136256990868,137.39748612217375,138.92672892528228,138.92672892528228,1000000.0 +2021-05-21,TLT,140.72459704613075,141.4282200313614,137.39748612217377,138.08792575092838,138.08792575092838,1000000.0 +2021-05-24,TLT,138.08792575092838,139.62136256990868,137.39748612217377,138.92672892528228,138.92672892528228,1000000.0 2021-05-25,TLT,138.92672892528228,139.62136256990868,137.52974670135848,138.22085095613917,138.22085095613917,1000000.0 2021-05-26,TLT,138.22085095613917,139.0939394721192,137.52974670135848,138.4019298230042,138.4019298230042,1000000.0 2021-05-27,TLT,138.4019298230042,139.0939394721192,137.56001991272527,138.25127629419626,138.25127629419626,1000000.0 2021-05-28,TLT,138.25127629419626,139.91417188825866,137.56001991272527,139.2180814808544,139.2180814808544,1000000.0 -2021-05-31,TLT,139.2180814808544,139.91417188825866,138.19457057153787,138.8890156497868,138.8890156497868,1000000.0 2021-06-01,TLT,138.8890156497868,139.58346072803573,137.3179719000314,138.00801195983055,138.00801195983055,1000000.0 -2021-06-02,TLT,138.00801195983055,139.29007964652217,137.3179719000314,138.59709417564397,138.59709417564397,1000000.0 -2021-06-03,TLT,138.59709417564397,140.8644031643454,137.90410870476575,140.16358523815464,140.16358523815464,1000000.0 +2021-06-02,TLT,138.00801195983055,139.29007964652217,137.3179719000314,138.59709417564395,138.59709417564395,1000000.0 +2021-06-03,TLT,138.59709417564395,140.8644031643454,137.90410870476575,140.16358523815464,140.16358523815464,1000000.0 2021-06-04,TLT,140.16358523815464,140.8644031643454,139.10640991060018,139.80543709608057,139.80543709608057,1000000.0 2021-06-07,TLT,139.80543709608057,142.0528038727058,139.10640991060018,141.3460735051799,141.3460735051799,1000000.0 2021-06-08,TLT,141.3460735051799,142.35106737470943,140.639343137654,141.64285310916364,141.64285310916364,1000000.0 @@ -653,19 +630,18 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-06-30,TLT,139.22639462789908,139.92252660103856,138.3555030534628,139.05075683765105,139.05075683765105,1000000.0 2021-07-01,TLT,139.05075683765105,139.7460106218393,137.43515879886175,138.1257877375495,138.1257877375495,1000000.0 2021-07-02,TLT,138.1257877375495,138.81641667623722,136.66702368594946,137.35379264919544,137.35379264919544,1000000.0 -2021-07-05,TLT,137.35379264919544,138.0405616124414,136.58886894166534,137.27524516750285,137.27524516750285,1000000.0 2021-07-06,TLT,137.27524516750285,137.96162139334035,136.43868050778056,137.12430201786992,137.12430201786992,1000000.0 2021-07-07,TLT,137.12430201786992,137.80992352795926,136.19602837364283,136.8804305262742,136.8804305262742,1000000.0 2021-07-08,TLT,136.8804305262742,139.40976154778102,136.19602837364283,138.71618064455825,138.71618064455825,1000000.0 -2021-07-09,TLT,138.71618064455825,140.54196503885518,138.02259974133545,139.84275128244298,139.84275128244298,1000000.0 +2021-07-09,TLT,138.71618064455825,140.54196503885518,138.02259974133543,139.84275128244298,139.84275128244298,1000000.0 2021-07-12,TLT,139.84275128244298,141.9314229354859,139.14353752603077,141.2252964532198,141.2252964532198,1000000.0 2021-07-13,TLT,141.2252964532198,141.9314229354859,140.48751512849006,141.19348254119603,141.19348254119603,1000000.0 2021-07-14,TLT,141.19348254119603,141.899449953902,139.92144391428488,140.62456674802502,140.62456674802502,1000000.0 2021-07-15,TLT,140.62456674802502,141.32768958176513,138.30341856061625,138.99841061368468,138.99841061368468,1000000.0 2021-07-16,TLT,138.99841061368468,139.6934026667531,137.91876571179017,138.61182483597003,138.61182483597003,1000000.0 2021-07-19,TLT,138.61182483597003,139.30488396014985,135.6363236874025,136.31791325367084,136.31791325367084,1000000.0 -2021-07-20,TLT,136.31791325367084,136.99950281993918,135.00438817346293,135.68280218438485,135.68280218438485,1000000.0 -2021-07-21,TLT,135.68280218438485,137.19747038803496,135.00438817346293,136.51489590849252,136.51489590849252,1000000.0 +2021-07-20,TLT,136.31791325367084,136.99950281993918,135.00438817346293,135.68280218438483,135.68280218438483,1000000.0 +2021-07-21,TLT,135.68280218438483,137.19747038803496,135.00438817346293,136.51489590849252,136.51489590849252,1000000.0 2021-07-22,TLT,136.51489590849252,138.98882297850915,135.83232142895005,138.29733629702403,138.29733629702403,1000000.0 2021-07-23,TLT,138.29733629702403,140.06664919499627,137.60584961553892,139.36980019402617,139.36980019402617,1000000.0 2021-07-26,TLT,139.36980019402617,140.06664919499627,137.7203924559238,138.41245472957164,138.41245472957164,1000000.0 @@ -673,8 +649,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-07-28,TLT,138.9590073017665,139.6538023382753,137.39405679885064,138.08447919482475,138.08447919482475,1000000.0 2021-07-29,TLT,138.08447919482475,139.69158074510275,137.39405679885064,138.99659775632117,138.99659775632117,1000000.0 2021-07-30,TLT,138.99659775632117,139.69158074510275,138.1784581834814,138.8728222949562,138.8728222949562,1000000.0 -2021-08-02,TLT,138.8728222949562,140.52869590261278,138.1784581834814,139.82954816180379,139.82954816180379,1000000.0 -2021-08-03,TLT,139.82954816180379,143.4511951399493,139.13040042099476,142.73750760193963,142.73750760193963,1000000.0 +2021-08-02,TLT,138.8728222949562,140.52869590261278,138.1784581834814,139.8295481618038,139.8295481618038,1000000.0 +2021-08-03,TLT,139.8295481618038,143.4511951399493,139.13040042099476,142.73750760193963,142.73750760193963,1000000.0 2021-08-04,TLT,142.73750760193963,144.51380917677884,142.02382006392992,143.79483500177,143.79483500177,1000000.0 2021-08-05,TLT,143.79483500177,147.23569465872262,143.07586082676116,146.50317876489814,146.50317876489814,1000000.0 2021-08-06,TLT,146.50317876489814,147.44484981320707,145.77066287107365,146.71129334647472,146.71129334647472,1000000.0 @@ -687,18 +663,17 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-08-17,TLT,147.67148539373878,148.40984282070747,146.69816909485078,147.43534582397064,147.43534582397064,1000000.0 2021-08-18,TLT,147.43534582397064,149.22928454572647,146.69816909485078,148.48685029425522,148.48685029425522,1000000.0 2021-08-19,TLT,148.48685029425522,149.22928454572647,147.6423482964121,148.38426964463528,148.38426964463528,1000000.0 -2021-08-20,TLT,148.38426964463528,149.12619099285845,146.97154877991653,147.71009927629802,147.71009927629802,1000000.0 +2021-08-20,TLT,148.38426964463528,149.12619099285843,146.97154877991653,147.71009927629802,147.71009927629802,1000000.0 2021-08-23,TLT,147.71009927629802,148.4486497726795,145.32623374566356,146.05651632730005,146.05651632730005,1000000.0 2021-08-24,TLT,146.05651632730005,147.21987571946025,145.32623374566356,146.48743852682614,146.48743852682614,1000000.0 2021-08-25,TLT,146.48743852682614,148.16218935563663,145.755001334192,147.42506403545934,147.42506403545934,1000000.0 2021-08-26,TLT,147.42506403545934,148.8709097965249,146.68793871528206,148.1302585040049,148.1302585040049,1000000.0 2021-08-27,TLT,148.1302585040049,149.17529318924147,147.38960721148487,148.43312755148406,148.43312755148406,1000000.0 2021-08-30,TLT,148.43312755148406,149.17529318924147,146.19326404367592,146.92790356148333,146.92790356148333,1000000.0 -2021-08-31,TLT,146.92790356148333,149.75850426646141,146.19326404367592,149.01343708105614,149.01343708105614,1000000.0 -2021-09-01,TLT,149.01343708105614,149.75850426646141,148.20132462183375,148.94605489631533,148.94605489631533,1000000.0 +2021-08-31,TLT,146.92790356148333,149.7585042664614,146.19326404367592,149.01343708105614,149.01343708105614,1000000.0 +2021-09-01,TLT,149.01343708105614,149.7585042664614,148.20132462183375,148.94605489631533,148.94605489631533,1000000.0 2021-09-02,TLT,148.94605489631533,149.6907851707969,148.02975180801968,148.77361990755747,148.77361990755747,1000000.0 2021-09-03,TLT,148.77361990755747,150.39674831347713,148.02975180801968,149.64850578455437,149.64850578455437,1000000.0 -2021-09-06,TLT,149.64850578455437,150.39674831347713,146.34563951867415,147.08104474238607,147.08104474238607,1000000.0 2021-09-07,TLT,147.08104474238607,147.816449966098,146.2701829856401,147.00520903079408,147.00520903079408,1000000.0 2021-09-08,TLT,147.00520903079408,147.74023507594802,146.11020461305014,146.84442674678405,146.84442674678405,1000000.0 2021-09-09,TLT,146.84442674678405,148.01883237962082,146.11020461305014,147.2824202782297,147.2824202782297,1000000.0 @@ -713,8 +688,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-09-22,TLT,143.85399599512107,145.2096408258806,143.13472601514547,144.48720480187126,144.48720480187126,1000000.0 2021-09-23,TLT,144.48720480187126,145.60926657600277,143.7647687778619,144.88484236418188,144.88484236418188,1000000.0 2021-09-24,TLT,144.88484236418188,145.60926657600277,144.1358540786755,144.86015485294018,144.86015485294018,1000000.0 -2021-09-27,TLT,144.86015485294018,145.58445562720487,142.1599610782035,142.87433274191307,142.87433274191307,1000000.0 -2021-09-28,TLT,142.87433274191307,145.64110293910326,142.1599610782035,144.9165203374162,144.9165203374162,1000000.0 +2021-09-27,TLT,144.86015485294018,145.58445562720487,142.1599610782035,142.8743327419131,142.8743327419131,1000000.0 +2021-09-28,TLT,142.8743327419131,145.64110293910326,142.1599610782035,144.9165203374162,144.9165203374162,1000000.0 2021-09-29,TLT,144.9165203374162,145.64110293910326,140.88492790591604,141.5928923677548,141.5928923677548,1000000.0 2021-09-30,TLT,141.5928923677548,142.30085682959358,140.47515842366278,141.18106374237465,141.18106374237465,1000000.0 2021-10-01,TLT,141.18106374237465,144.2805920556215,140.47515842366278,143.56277816479752,143.56277816479752,1000000.0 @@ -723,7 +698,7 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-10-06,TLT,143.64853296482977,144.36677562965392,141.58834371398464,142.29984292862778,142.29984292862778,1000000.0 2021-10-07,TLT,142.29984292862778,144.57422782215065,141.58834371398464,143.85495305686632,143.85495305686632,1000000.0 2021-10-08,TLT,143.85495305686632,144.57422782215065,141.66314149617716,142.37501657907254,142.37501657907254,1000000.0 -2021-10-11,TLT,142.37501657907254,143.37842063299433,141.66314149617716,142.6650951572083,142.6650951572083,1000000.0 +2021-10-11,TLT,142.37501657907254,143.3784206329943,141.66314149617716,142.6650951572083,142.6650951572083,1000000.0 2021-10-12,TLT,142.6650951572083,143.5332446325598,141.95176968142226,142.81914888811923,142.81914888811923,1000000.0 2021-10-13,TLT,142.81914888811923,143.5332446325598,140.91555334382525,141.62367170233694,141.62367170233694,1000000.0 2021-10-14,TLT,141.62367170233694,143.34534474440215,140.91555334382525,142.6321838252758,142.6321838252758,1000000.0 @@ -731,7 +706,7 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-10-18,TLT,141.02561089678204,141.73073895126595,139.55532161472058,140.25660463791013,140.25660463791013,1000000.0 2021-10-19,TLT,140.25660463791013,140.95788766109968,139.5232809321419,140.22440294687627,140.22440294687627,1000000.0 2021-10-20,TLT,140.22440294687627,140.92552496161062,139.0636155367536,139.76242767512923,139.76242767512923,1000000.0 -2021-10-21,TLT,139.76242767512923,140.98789433288493,139.0636155367536,140.2864620227711,140.2864620227711,1000000.0 +2021-10-21,TLT,139.76242767512923,140.9878943328849,139.0636155367536,140.2864620227711,140.2864620227711,1000000.0 2021-10-22,TLT,140.2864620227711,142.0340005773138,139.58502971265725,141.32736375852122,141.32736375852122,1000000.0 2021-10-25,TLT,141.32736375852122,142.0340005773138,140.29062954520228,140.99560758311787,140.99560758311787,1000000.0 2021-10-26,TLT,140.99560758311787,141.70058562103344,139.68424303194217,140.3861739014494,140.3861739014494,1000000.0 @@ -740,8 +715,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-10-29,TLT,139.3984574829367,141.00581991674457,138.70146519552202,140.30429842462146,140.30429842462146,1000000.0 2021-11-01,TLT,140.30429842462146,141.00581991674457,139.3599845366787,140.06028596651126,140.06028596651126,1000000.0 2021-11-02,TLT,140.06028596651126,141.22037333820765,139.3599845366787,140.51778441612703,140.51778441612703,1000000.0 -2021-11-03,TLT,140.51778441612703,141.22037333820765,139.71327631382027,140.41535307921635,140.41535307921635,1000000.0 -2021-11-04,TLT,140.41535307921635,141.11742984461242,138.77259341620442,139.46994313186374,139.46994313186374,1000000.0 +2021-11-03,TLT,140.51778441612703,141.22037333820765,139.71327631382027,140.41535307921637,140.41535307921637,1000000.0 +2021-11-04,TLT,140.41535307921637,141.11742984461242,138.77259341620442,139.46994313186374,139.46994313186374,1000000.0 2021-11-05,TLT,139.46994313186374,141.13916875600475,138.77259341620442,140.43698383682067,140.43698383682067,1000000.0 2021-11-08,TLT,140.43698383682067,141.13916875600475,138.57919990475162,139.27557779372023,139.27557779372023,1000000.0 2021-11-09,TLT,139.27557779372023,139.97889599655417,138.57919990475162,139.28248357866087,139.28248357866087,1000000.0 @@ -751,12 +726,11 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-11-15,TLT,139.66042676994005,140.35872890378974,137.21144944568104,137.90095421676486,137.90095421676486,1000000.0 2021-11-16,TLT,137.90095421676486,138.59045898784868,136.65340536540168,137.34010589487607,137.34010589487607,1000000.0 2021-11-17,TLT,137.34010589487607,138.02680642435044,135.02919781202752,135.7077364945,135.7077364945,1000000.0 -2021-11-18,TLT,135.7077364945,136.3862751769725,134.63916359591238,135.31574230744963,135.31574230744963,1000000.0 -2021-11-19,TLT,135.31574230744963,135.99232101898687,134.0606028816984,134.73427425296322,134.73427425296322,1000000.0 +2021-11-18,TLT,135.7077364945,136.3862751769725,134.63916359591238,135.31574230744965,135.31574230744965,1000000.0 +2021-11-19,TLT,135.31574230744965,135.99232101898687,134.0606028816984,134.73427425296322,134.73427425296322,1000000.0 2021-11-22,TLT,134.73427425296322,135.5509811583123,134.0606028816984,134.87659816747495,134.87659816747495,1000000.0 2021-11-23,TLT,134.87659816747495,137.2799431368751,134.20221517663757,136.59695834514935,136.59695834514935,1000000.0 2021-11-24,TLT,136.59695834514935,137.59107997842372,135.9139735534236,136.90654724221267,136.90654724221267,1000000.0 -2021-11-25,TLT,136.90654724221267,138.3174621150215,136.2220145060016,137.62931553733483,137.62931553733483,1000000.0 2021-11-26,TLT,137.62931553733483,138.3174621150215,136.88563476027372,137.57350227163187,137.57350227163187,1000000.0 2021-11-29,TLT,137.57350227163187,138.26136978299002,136.61812233546604,137.30464556328246,137.30464556328246,1000000.0 2021-11-30,TLT,137.30464556328246,137.99116879109886,135.70966002233848,136.391618112903,136.391618112903,1000000.0 @@ -773,11 +747,10 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2021-12-15,TLT,134.50877757826112,135.1813214661524,133.5727152316496,134.2439349061805,134.2439349061805,1000000.0 2021-12-16,TLT,134.2439349061805,137.09008460739972,133.5727152316496,136.40804438547238,136.40804438547238,1000000.0 2021-12-17,TLT,136.40804438547238,137.09008460739972,135.50232089829217,136.18323708371074,136.18323708371074,1000000.0 -2021-12-20,TLT,136.18323708371074,136.86415326912928,134.38873910565928,135.06405940267265,135.06405940267265,1000000.0 -2021-12-21,TLT,135.06405940267265,135.739379699686,133.73942124157136,134.4114786347451,134.4114786347451,1000000.0 +2021-12-20,TLT,136.18323708371074,136.86415326912928,134.38873910565928,135.06405940267263,135.06405940267263,1000000.0 +2021-12-21,TLT,135.06405940267263,135.739379699686,133.73942124157136,134.4114786347451,134.4114786347451,1000000.0 2021-12-22,TLT,134.4114786347451,135.0835360279188,133.5011904516073,134.17205070513296,134.17205070513296,1000000.0 2021-12-23,TLT,134.17205070513296,134.84291095865862,131.68548362738466,132.34721972601474,132.34721972601474,1000000.0 -2021-12-24,TLT,132.34721972601474,133.14376582474858,131.68548362738466,132.48135902960058,132.48135902960058,1000000.0 2021-12-27,TLT,132.48135902960058,133.89217854811125,131.81895223445258,133.22604830657838,133.22604830657838,1000000.0 2021-12-28,TLT,133.22604830657838,134.23625389239876,132.55991806504548,133.5684118332326,133.5684118332326,1000000.0 2021-12-29,TLT,133.5684118332326,134.88292736426487,132.90056977406644,134.21186802414417,134.21186802414417,1000000.0 @@ -788,24 +761,23 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-01-05,TLT,133.5603975805287,134.22819956843134,131.26512519087737,131.92474893555516,131.92474893555516,1000000.0 2022-01-06,TLT,131.92474893555516,132.80345735208957,131.26512519087737,132.14274363392,132.14274363392,1000000.0 2022-01-07,TLT,132.14274363392,132.8305816368274,131.4820299157504,132.16973297196756,132.16973297196756,1000000.0 -2022-01-10,TLT,132.16973297196756,133.4108659836577,131.50888430710773,132.7471303319977,132.7471303319977,1000000.0 +2022-01-10,TLT,132.16973297196756,133.4108659836577,131.5088843071077,132.7471303319977,132.7471303319977,1000000.0 2022-01-11,TLT,132.7471303319977,133.63367316750103,132.08339468033773,132.9688290223891,132.9688290223891,1000000.0 2022-01-12,TLT,132.9688290223891,133.63367316750103,131.7735785110762,132.43575729756404,132.43575729756404,1000000.0 2022-01-13,TLT,132.43575729756404,133.306004593123,131.7735785110762,132.6427906399234,132.6427906399234,1000000.0 2022-01-14,TLT,132.6427906399234,133.47359218614608,131.97957668672376,132.80954446382697,132.80954446382697,1000000.0 -2022-01-17,TLT,132.80954446382697,133.47359218614608,132.09409935984755,132.75788880386688,132.75788880386688,1000000.0 2022-01-18,TLT,132.75788880386688,133.4216782478862,130.1509842066018,130.80500925286614,130.80500925286614,1000000.0 2022-01-19,TLT,130.80500925286614,131.45903429913045,127.06942432471472,127.70796414544192,127.70796414544192,1000000.0 -2022-01-20,TLT,127.70796414544192,128.3465039661691,126.26868724443099,126.90320326073467,126.90320326073467,1000000.0 -2022-01-21,TLT,126.90320326073467,127.53771927703833,124.6280751140373,125.2543468482787,125.2543468482787,1000000.0 +2022-01-20,TLT,127.70796414544192,128.3465039661691,126.268687244431,126.90320326073468,126.90320326073468,1000000.0 +2022-01-21,TLT,126.90320326073468,127.53771927703832,124.6280751140373,125.2543468482787,125.2543468482787,1000000.0 2022-01-24,TLT,125.2543468482787,126.13521703289686,124.6280751140373,125.50767863969838,125.50767863969838,1000000.0 -2022-01-25,TLT,125.50767863969838,126.85989091637637,124.88014024649989,126.22874718047402,126.22874718047402,1000000.0 +2022-01-25,TLT,125.50767863969838,126.85989091637636,124.88014024649988,126.22874718047402,126.22874718047402,1000000.0 2022-01-26,TLT,126.22874718047402,127.12720250450694,125.59760344457165,126.49472886020592,126.49472886020592,1000000.0 -2022-01-27,TLT,126.49472886020592,127.12720250450694,125.49072773083189,126.12133440284612,126.12133440284612,1000000.0 -2022-01-28,TLT,126.12133440284612,126.82342989822205,125.49072773083189,126.19246756041996,126.19246756041996,1000000.0 -2022-01-31,TLT,126.19246756041996,128.01334696853877,125.56150522261785,127.37646464531223,127.37646464531223,1000000.0 -2022-02-01,TLT,127.37646464531223,128.01334696853877,126.00278174787202,126.63596155565027,126.63596155565027,1000000.0 -2022-02-02,TLT,126.63596155565027,128.21165145283052,126.00278174787202,127.5737825401299,127.5737825401299,1000000.0 +2022-01-27,TLT,126.49472886020592,127.12720250450694,125.49072773083188,126.12133440284612,126.12133440284612,1000000.0 +2022-01-28,TLT,126.12133440284612,126.82342989822205,125.49072773083188,126.19246756041996,126.19246756041996,1000000.0 +2022-01-31,TLT,126.19246756041996,128.01334696853877,125.56150522261784,127.37646464531224,127.37646464531224,1000000.0 +2022-02-01,TLT,127.37646464531224,128.01334696853877,126.00278174787202,126.63596155565028,126.63596155565028,1000000.0 +2022-02-02,TLT,126.63596155565028,128.21165145283052,126.00278174787202,127.5737825401299,127.5737825401299,1000000.0 2022-02-03,TLT,127.5737825401299,128.93950694588116,126.93591362742924,128.2980168615733,128.2980168615733,1000000.0 2022-02-04,TLT,128.2980168615733,129.20972122072143,127.65652677726544,128.5668867867875,128.5668867867875,1000000.0 2022-02-07,TLT,128.5668867867875,130.7192993837348,127.92405235285356,130.0689546106814,130.0689546106814,1000000.0 @@ -813,13 +785,12 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-02-09,TLT,129.32416784279553,130.95447856472634,128.67754700358157,130.30296374599638,130.30296374599638,1000000.0 2022-02-10,TLT,130.30296374599638,130.95447856472634,128.69150097360915,129.33819193327554,129.33819193327554,1000000.0 2022-02-11,TLT,129.33819193327554,129.9848828929419,127.90423531530924,128.54697016613994,128.54697016613994,1000000.0 -2022-02-14,TLT,128.54697016613994,129.18970501697063,126.82448638197461,127.46179535876846,127.46179535876846,1000000.0 -2022-02-15,TLT,127.46179535876846,128.55680510692625,126.82448638197461,127.91721901186692,127.91721901186692,1000000.0 +2022-02-14,TLT,128.54697016613994,129.18970501697063,126.8244863819746,127.46179535876846,127.46179535876846,1000000.0 +2022-02-15,TLT,127.46179535876846,128.55680510692625,126.8244863819746,127.91721901186692,127.91721901186692,1000000.0 2022-02-16,TLT,127.91721901186692,128.55680510692625,126.3243374187595,126.9591330841804,126.9591330841804,1000000.0 -2022-02-17,TLT,126.9591330841804,127.64718215059554,126.3243374187595,127.01212154288115,127.01212154288115,1000000.0 -2022-02-18,TLT,127.01212154288115,127.64718215059554,125.23303604166588,125.86234778056873,125.86234778056873,1000000.0 -2022-02-21,TLT,125.86234778056873,127.57525605320286,125.23303604166588,126.94055328676903,126.94055328676903,1000000.0 -2022-02-22,TLT,126.94055328676903,129.46309011767337,126.30585052033518,128.81899514196357,128.81899514196357,1000000.0 +2022-02-17,TLT,126.9591330841804,127.64718215059554,126.3243374187595,127.01212154288116,127.01212154288116,1000000.0 +2022-02-18,TLT,127.01212154288116,127.64718215059554,125.23303604166588,125.86234778056873,125.86234778056873,1000000.0 +2022-02-22,TLT,126.94055328676905,129.46309011767335,126.30585052033518,128.81899514196357,128.81899514196357,1000000.0 2022-02-23,TLT,128.81899514196357,129.6974506332912,128.17490016625376,129.05218968486687,129.05218968486687,1000000.0 2022-02-24,TLT,129.05218968486687,131.4138309105145,128.40692873644255,130.76003075673086,130.76003075673086,1000000.0 2022-02-25,TLT,130.76003075673086,132.00458643194975,130.1062306029472,131.3478471959699,131.3478471959699,1000000.0 @@ -837,132 +808,127 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-03-15,TLT,130.29040525564056,131.6397892506827,129.63895322936236,130.98486492605244,130.98486492605244,1000000.0 2022-03-16,TLT,130.98486492605244,131.6397892506827,128.61018292888093,129.2564652551567,129.2564652551567,1000000.0 2022-03-17,TLT,129.2564652551567,129.9027475814325,127.06655610846845,127.7050815160487,127.7050815160487,1000000.0 -2022-03-18,TLT,127.7050815160487,128.34360692362893,126.49151196020837,127.12714769870188,127.12714769870188,1000000.0 -2022-03-21,TLT,127.12714769870188,127.76278343719537,124.55842446261103,125.18434619357892,125.18434619357892,1000000.0 -2022-03-22,TLT,125.18434619357892,125.81026792454679,124.44905459932899,125.07442673299396,125.07442673299396,1000000.0 -2022-03-23,TLT,125.07442673299396,125.69979886665891,124.40580546684507,125.03096026818599,125.03096026818599,1000000.0 -2022-03-24,TLT,125.03096026818599,125.6561150695269,123.6390453818146,124.26034711740161,124.26034711740161,1000000.0 -2022-03-25,TLT,124.26034711740161,124.8816488529886,123.099680931689,123.71827229315477,123.71827229315477,1000000.0 -2022-03-28,TLT,123.71827229315477,124.83406149350046,123.099680931689,124.21299651094574,124.21299651094574,1000000.0 -2022-03-29,TLT,124.21299651094574,126.26196353166891,123.59193152839102,125.63379455887456,125.63379455887456,1000000.0 -2022-03-30,TLT,125.63379455887456,127.7818112792644,125.00562558608019,127.14608087488996,127.14608087488996,1000000.0 +2022-03-18,TLT,127.7050815160487,128.34360692362893,126.49151196020836,127.12714769870188,127.12714769870188,1000000.0 +2022-03-21,TLT,127.12714769870188,127.76278343719537,124.55842446261104,125.18434619357892,125.18434619357892,1000000.0 +2022-03-22,TLT,125.18434619357892,125.8102679245468,124.449054599329,125.07442673299396,125.07442673299396,1000000.0 +2022-03-23,TLT,125.07442673299396,125.69979886665892,124.40580546684508,125.030960268186,125.030960268186,1000000.0 +2022-03-24,TLT,125.030960268186,125.6561150695269,123.6390453818146,124.2603471174016,124.2603471174016,1000000.0 +2022-03-25,TLT,124.2603471174016,124.8816488529886,123.099680931689,123.71827229315475,123.71827229315475,1000000.0 +2022-03-28,TLT,123.71827229315475,124.83406149350046,123.099680931689,124.21299651094574,124.21299651094574,1000000.0 +2022-03-29,TLT,124.21299651094574,126.26196353166893,123.59193152839102,125.63379455887456,125.63379455887456,1000000.0 +2022-03-30,TLT,125.63379455887456,127.7818112792644,125.0056255860802,127.14608087488996,127.14608087488996,1000000.0 2022-03-31,TLT,127.14608087488996,127.81688844904502,126.5103504705155,127.1809835313881,127.1809835313881,1000000.0 -2022-04-01,TLT,127.1809835313881,128.6950269866319,126.54507861373115,128.05475322052925,128.05475322052925,1000000.0 +2022-04-01,TLT,127.1809835313881,128.6950269866319,126.54507861373116,128.05475322052925,128.05475322052925,1000000.0 2022-04-04,TLT,128.05475322052925,128.6950269866319,126.6640805360722,127.3005834533389,127.3005834533389,1000000.0 2022-04-05,TLT,127.3005834533389,128.35941524264376,126.6640805360722,127.72081118671022,127.72081118671022,1000000.0 -2022-04-06,TLT,127.72081118671022,128.35941524264376,126.11028493466141,126.7440049594587,126.7440049594587,1000000.0 -2022-04-07,TLT,126.7440049594587,128.1233499854275,126.11028493466141,127.48592038350996,127.48592038350996,1000000.0 +2022-04-06,TLT,127.72081118671022,128.35941524264376,126.1102849346614,126.7440049594587,126.7440049594587,1000000.0 +2022-04-07,TLT,126.7440049594587,128.1233499854275,126.1102849346614,127.48592038350996,127.48592038350996,1000000.0 2022-04-08,TLT,127.48592038350996,128.1233499854275,125.59798095659475,126.22912658954246,126.22912658954246,1000000.0 -2022-04-11,TLT,126.22912658954246,126.86027222249015,124.63201482329356,125.25830635506891,125.25830635506891,1000000.0 -2022-04-12,TLT,125.25830635506891,125.88459788684425,124.62369502181772,125.24994474554545,125.24994474554545,1000000.0 -2022-04-13,TLT,125.24994474554545,126.43833940265479,124.62369502181772,125.80929293796498,125.80929293796498,1000000.0 -2022-04-14,TLT,125.80929293796498,126.43833940265479,123.7482517505847,124.37010226189417,124.37010226189417,1000000.0 -2022-04-15,TLT,124.37010226189417,124.99195277320364,123.21812581477606,123.83731237665936,123.83731237665936,1000000.0 +2022-04-11,TLT,126.22912658954246,126.86027222249017,124.63201482329356,125.25830635506892,125.25830635506892,1000000.0 +2022-04-12,TLT,125.25830635506892,125.88459788684423,124.62369502181772,125.24994474554543,125.24994474554543,1000000.0 +2022-04-13,TLT,125.24994474554543,126.4383394026548,124.62369502181772,125.80929293796498,125.80929293796498,1000000.0 +2022-04-14,TLT,125.80929293796498,126.4383394026548,123.7482517505847,124.37010226189416,124.37010226189416,1000000.0 2022-04-18,TLT,123.83731237665936,124.59113512360328,123.21812581477606,123.97127872995353,123.97127872995353,1000000.0 2022-04-19,TLT,123.97127872995353,124.81964152899126,123.35142233630376,124.1986482875535,124.1986482875535,1000000.0 -2022-04-20,TLT,124.1986482875535,124.81964152899126,122.75117642324153,123.36801650577038,123.36801650577038,1000000.0 -2022-04-21,TLT,123.36801650577038,124.08098959833468,122.75117642324153,123.46367124212408,123.46367124212408,1000000.0 +2022-04-20,TLT,124.1986482875535,124.81964152899126,122.75117642324152,123.36801650577038,123.36801650577038,1000000.0 +2022-04-21,TLT,123.36801650577038,124.08098959833468,122.75117642324152,123.46367124212408,123.46367124212408,1000000.0 2022-04-22,TLT,123.46367124212408,124.08098959833468,122.58400066366454,123.20000066699954,123.20000066699954,1000000.0 -2022-04-25,TLT,123.20000066699954,125.21437035682402,122.58400066366454,124.59141329037217,124.59141329037217,1000000.0 -2022-04-26,TLT,124.59141329037217,125.77346820053502,123.96845622392031,125.14772955277118,125.14772955277118,1000000.0 -2022-04-27,TLT,125.14772955277118,125.77346820053502,123.20992047899102,123.82906580803117,123.82906580803117,1000000.0 -2022-04-28,TLT,123.82906580803117,124.44821113707131,121.05888913752378,121.667225263843,121.667225263843,1000000.0 +2022-04-25,TLT,123.20000066699954,125.21437035682402,122.58400066366454,124.59141329037216,124.59141329037216,1000000.0 +2022-04-26,TLT,124.59141329037216,125.77346820053502,123.96845622392031,125.14772955277118,125.14772955277118,1000000.0 +2022-04-27,TLT,125.14772955277118,125.77346820053502,123.20992047899102,123.82906580803116,123.82906580803116,1000000.0 +2022-04-28,TLT,123.82906580803116,124.44821113707133,121.05888913752378,121.667225263843,121.667225263843,1000000.0 2022-04-29,TLT,121.667225263843,122.37330526185544,121.05888913752378,121.76448284761736,121.76448284761736,1000000.0 -2022-05-02,TLT,121.76448284761736,123.53603914042756,121.15566043337927,122.92143198052494,122.92143198052494,1000000.0 -2022-05-03,TLT,122.92143198052494,123.70786378949867,122.30682482062232,123.09240178059571,123.09240178059571,1000000.0 -2022-05-04,TLT,123.09240178059571,124.58389907712785,122.47693977169273,123.96407868370932,123.96407868370932,1000000.0 -2022-05-05,TLT,123.96407868370932,124.58389907712785,122.86693082152117,123.48435258444339,123.48435258444339,1000000.0 -2022-05-06,TLT,123.48435258444339,124.10177434736559,121.53357256229641,122.1442940324587,122.1442940324587,1000000.0 -2022-05-09,TLT,122.1442940324587,123.4923702975968,121.53357256229641,122.87798039561872,122.87798039561872,1000000.0 +2022-05-02,TLT,121.76448284761736,123.53603914042756,121.15566043337928,122.92143198052494,122.92143198052494,1000000.0 +2022-05-03,TLT,122.92143198052494,123.70786378949867,122.30682482062232,123.09240178059572,123.09240178059572,1000000.0 +2022-05-04,TLT,123.09240178059572,124.58389907712784,122.47693977169271,123.96407868370932,123.96407868370932,1000000.0 +2022-05-05,TLT,123.96407868370932,124.58389907712784,122.86693082152117,123.4843525844434,123.4843525844434,1000000.0 +2022-05-06,TLT,123.4843525844434,124.1017743473656,121.5335725622964,122.1442940324587,122.1442940324587,1000000.0 +2022-05-09,TLT,122.1442940324587,123.4923702975968,121.5335725622964,122.87798039561872,122.87798039561872,1000000.0 2022-05-10,TLT,122.87798039561872,123.59015792195514,122.26359049364062,122.97528151438324,122.97528151438324,1000000.0 -2022-05-11,TLT,122.97528151438324,124.26798174592318,122.36040510681133,123.64973308052059,123.64973308052059,1000000.0 -2022-05-12,TLT,123.64973308052059,124.26798174592318,121.67916259940847,122.29061567779746,122.29061567779746,1000000.0 -2022-05-13,TLT,122.29061567779746,123.08070480098473,121.67916259940847,122.46836298605447,122.46836298605447,1000000.0 -2022-05-16,TLT,122.46836298605447,123.08070480098473,121.24777577771509,121.85706108313074,121.85706108313074,1000000.0 -2022-05-17,TLT,121.85706108313074,123.25914138538192,121.24777577771509,122.64591182625068,122.64591182625068,1000000.0 -2022-05-18,TLT,122.64591182625068,123.25914138538192,121.46709434932225,122.07748175811281,122.07748175811281,1000000.0 -2022-05-19,TLT,122.07748175811281,122.68786916690337,120.87829808432176,121.48572671791132,121.48572671791132,1000000.0 -2022-05-20,TLT,121.48572671791132,122.09315535150087,120.31321598005012,120.9178050050755,120.9178050050755,1000000.0 -2022-05-23,TLT,120.9178050050755,122.75718457565837,120.31321598005012,122.14645231408794,122.14645231408794,1000000.0 +2022-05-11,TLT,122.97528151438324,124.26798174592318,122.36040510681131,123.6497330805206,123.6497330805206,1000000.0 +2022-05-12,TLT,123.6497330805206,124.26798174592318,121.67916259940849,122.29061567779746,122.29061567779746,1000000.0 +2022-05-13,TLT,122.29061567779746,123.08070480098472,121.67916259940849,122.46836298605449,122.46836298605449,1000000.0 +2022-05-16,TLT,122.46836298605449,123.08070480098472,121.24777577771508,121.85706108313074,121.85706108313074,1000000.0 +2022-05-17,TLT,121.85706108313074,123.25914138538192,121.24777577771508,122.64591182625068,122.64591182625068,1000000.0 +2022-05-18,TLT,122.64591182625068,123.25914138538192,121.46709434932224,122.0774817581128,122.0774817581128,1000000.0 +2022-05-19,TLT,122.0774817581128,122.68786916690335,120.87829808432176,121.48572671791132,121.48572671791132,1000000.0 +2022-05-20,TLT,121.48572671791132,122.09315535150088,120.31321598005012,120.9178050050755,120.9178050050755,1000000.0 +2022-05-23,TLT,120.9178050050755,122.75718457565836,120.31321598005012,122.14645231408794,122.14645231408794,1000000.0 2022-05-24,TLT,122.14645231408794,122.785534147954,121.5357200525175,122.17466084373534,122.17466084373534,1000000.0 2022-05-25,TLT,122.17466084373534,122.785534147954,120.82692480036648,121.43409527675024,121.43409527675024,1000000.0 -2022-05-26,TLT,121.43409527675024,122.04126575313398,119.84251126295663,120.44473493764485,120.44473493764485,1000000.0 -2022-05-27,TLT,120.44473493764485,121.04695861233306,118.8456384444508,119.44285270799075,119.44285270799075,1000000.0 -2022-05-30,TLT,119.44285270799075,121.4529079631721,118.8456384444508,120.84866463997224,120.84866463997224,1000000.0 -2022-05-31,TLT,120.84866463997224,121.4529079631721,118.51130056257216,119.10683473625343,119.10683473625343,1000000.0 -2022-06-01,TLT,119.10683473625343,119.70236890993469,117.70782082489173,118.29931741195148,118.29931741195148,1000000.0 -2022-06-02,TLT,118.29931741195148,119.4990791204259,117.70782082489173,118.90455633873225,118.90455633873225,1000000.0 -2022-06-03,TLT,118.90455633873225,120.63103999849343,118.3100335570386,120.03088557064025,120.03088557064025,1000000.0 -2022-06-06,TLT,120.03088557064025,120.63103999849343,119.28914410260984,119.88858703779883,119.88858703779883,1000000.0 -2022-06-07,TLT,119.88858703779883,121.24757627132657,119.28914410260984,120.64435449883241,120.64435449883241,1000000.0 -2022-06-08,TLT,120.64435449883241,121.98663364355001,120.04113272633825,121.37973496870649,121.37973496870649,1000000.0 -2022-06-09,TLT,121.37973496870649,122.95285325815604,120.77283629386295,122.34114752055329,122.34114752055329,1000000.0 -2022-06-10,TLT,122.34114752055329,122.99851661923937,121.72944178295053,122.3865837007357,122.3865837007357,1000000.0 +2022-05-26,TLT,121.43409527675024,122.04126575313398,119.84251126295663,120.44473493764484,120.44473493764484,1000000.0 +2022-05-27,TLT,120.44473493764484,121.04695861233306,118.8456384444508,119.44285270799077,119.44285270799077,1000000.0 +2022-05-31,TLT,120.84866463997224,121.4529079631721,118.51130056257216,119.10683473625345,119.10683473625345,1000000.0 +2022-06-01,TLT,119.10683473625345,119.70236890993468,117.70782082489173,118.29931741195148,118.29931741195148,1000000.0 +2022-06-02,TLT,118.29931741195148,119.4990791204259,117.70782082489173,118.90455633873223,118.90455633873223,1000000.0 +2022-06-03,TLT,118.90455633873223,120.63103999849343,118.3100335570386,120.03088557064024,120.03088557064024,1000000.0 +2022-06-06,TLT,120.03088557064024,120.63103999849343,119.28914410260984,119.88858703779884,119.88858703779884,1000000.0 +2022-06-07,TLT,119.88858703779884,121.24757627132657,119.28914410260984,120.6443544988324,120.6443544988324,1000000.0 +2022-06-08,TLT,120.6443544988324,121.98663364355,120.04113272633823,121.37973496870649,121.37973496870649,1000000.0 +2022-06-09,TLT,121.37973496870649,122.95285325815604,120.77283629386297,122.34114752055328,122.34114752055328,1000000.0 +2022-06-10,TLT,122.34114752055328,122.99851661923935,121.72944178295052,122.3865837007357,122.3865837007357,1000000.0 2022-06-13,TLT,122.3865837007357,124.63781359170002,121.77465078223202,124.01772496686571,124.01772496686571,1000000.0 -2022-06-14,TLT,124.01772496686571,124.85349914967479,123.39763634203139,124.23233746236299,124.23233746236299,1000000.0 -2022-06-15,TLT,124.23233746236299,126.40950085094383,123.61117577505118,125.78059786163567,125.78059786163567,1000000.0 -2022-06-16,TLT,125.78059786163567,126.40950085094383,124.07227431324718,124.69575307864038,124.69575307864038,1000000.0 -2022-06-17,TLT,124.69575307864038,126.28600819955578,124.07227431324718,125.65771960154805,125.65771960154805,1000000.0 -2022-06-20,TLT,125.65771960154805,126.28600819955578,123.80018594833004,124.42229743550757,124.42229743550757,1000000.0 -2022-06-21,TLT,124.42229743550757,125.0444089226851,122.34735792765707,122.96216877151464,122.96216877151464,1000000.0 -2022-06-22,TLT,122.96216877151464,124.09758987862378,122.34735792765707,123.48018893395403,123.48018893395403,1000000.0 -2022-06-23,TLT,123.48018893395403,124.09758987862378,122.07922656431904,122.692690014391,122.692690014391,1000000.0 +2022-06-14,TLT,124.01772496686571,124.8534991496748,123.3976363420314,124.232337462363,124.232337462363,1000000.0 +2022-06-15,TLT,124.232337462363,126.40950085094384,123.61117577505118,125.78059786163568,125.78059786163568,1000000.0 +2022-06-16,TLT,125.78059786163568,126.40950085094384,124.07227431324718,124.69575307864038,124.69575307864038,1000000.0 +2022-06-17,TLT,124.69575307864038,126.28600819955578,124.07227431324718,125.65771960154804,125.65771960154804,1000000.0 +2022-06-21,TLT,124.42229743550756,125.0444089226851,122.34735792765709,122.96216877151464,122.96216877151464,1000000.0 +2022-06-22,TLT,122.96216877151464,124.09758987862378,122.34735792765709,123.48018893395404,123.48018893395404,1000000.0 +2022-06-23,TLT,123.48018893395404,124.09758987862378,122.07922656431904,122.692690014391,122.692690014391,1000000.0 2022-06-24,TLT,122.692690014391,123.77758246869386,122.07922656431904,123.16177360069042,123.16177360069042,1000000.0 2022-06-27,TLT,123.16177360069042,123.77758246869386,121.53133430870017,122.14204453135696,122.14204453135696,1000000.0 -2022-06-28,TLT,122.14204453135696,124.37425584641187,121.53133430870017,123.75547845414118,123.75547845414118,1000000.0 -2022-06-29,TLT,123.75547845414118,124.41944972745297,123.13670106187048,123.80044749000297,123.80044749000297,1000000.0 -2022-06-30,TLT,123.80044749000297,124.41944972745297,122.10779778998833,122.72140481405862,122.72140481405862,1000000.0 +2022-06-28,TLT,122.14204453135696,124.37425584641188,121.53133430870017,123.75547845414118,123.75547845414118,1000000.0 +2022-06-29,TLT,123.75547845414118,124.41944972745296,123.13670106187048,123.80044749000297,123.80044749000297,1000000.0 +2022-06-30,TLT,123.80044749000297,124.41944972745296,122.10779778998833,122.72140481405862,122.72140481405862,1000000.0 2022-07-01,TLT,122.72140481405862,123.54798204444816,122.10779778998833,122.9333154671126,122.9333154671126,1000000.0 -2022-07-04,TLT,122.9333154671126,124.46471208826392,122.31864888977704,123.84548466493924,123.84548466493924,1000000.0 2022-07-05,TLT,123.84548466493924,124.46471208826392,122.5117650765888,123.12740208702392,123.12740208702392,1000000.0 -2022-07-06,TLT,123.12740208702392,124.30039142737931,122.5117650765888,123.68198151978042,123.68198151978042,1000000.0 -2022-07-07,TLT,123.68198151978042,126.39955097083825,123.06357161218152,125.77069748342116,125.77069748342116,1000000.0 -2022-07-08,TLT,125.77069748342116,127.32868518051053,125.14184399600406,126.69520913483636,126.69520913483636,1000000.0 -2022-07-11,TLT,126.69520913483636,127.36867058689737,126.06173308916217,126.73499560885311,126.73499560885311,1000000.0 -2022-07-12,TLT,126.73499560885311,127.36867058689737,125.57830534786737,126.20935210840942,126.20935210840942,1000000.0 -2022-07-13,TLT,126.20935210840942,127.4804090936592,125.57830534786737,126.84617820264599,126.84617820264599,1000000.0 -2022-07-14,TLT,126.84617820264599,127.4804090936592,124.74197073749825,125.36881481155604,125.36881481155604,1000000.0 -2022-07-15,TLT,125.36881481155604,125.9956588856138,121.79697821363501,122.40902333028644,122.40902333028644,1000000.0 -2022-07-18,TLT,122.40902333028644,123.02106844693786,121.20076305490223,121.80981211547963,121.80981211547963,1000000.0 -2022-07-19,TLT,121.80981211547963,122.41886117605702,120.2076470762788,120.8117056043003,120.8117056043003,1000000.0 -2022-07-20,TLT,120.8117056043003,123.17664221326822,120.2076470762788,122.56382309777933,122.56382309777933,1000000.0 -2022-07-21,TLT,122.56382309777933,123.36982271442598,121.95100398229043,122.75604250191641,122.75604250191641,1000000.0 -2022-07-22,TLT,122.75604250191641,123.36982271442598,121.23844483588218,121.84768325214289,121.84768325214289,1000000.0 -2022-07-25,TLT,121.84768325214289,122.45692166840358,120.17122044429321,120.77509592391277,120.77509592391277,1000000.0 -2022-07-26,TLT,120.77509592391277,121.92215627920717,120.17122044429321,121.31557838727083,121.31557838727083,1000000.0 -2022-07-27,TLT,121.31557838727083,121.92215627920717,119.96655628210797,120.56940329860097,120.56940329860097,1000000.0 -2022-07-28,TLT,120.56940329860097,121.17225031509396,119.46633286001233,120.06666619096717,120.06666619096717,1000000.0 -2022-07-29,TLT,120.06666619096717,120.666999521922,119.43731109179201,120.03749858471559,120.03749858471559,1000000.0 -2022-08-01,TLT,120.03749858471559,120.78288890057452,119.43731109179201,120.1819790055468,120.1819790055468,1000000.0 -2022-08-02,TLT,120.1819790055468,120.9381177871959,119.58106911051905,120.33643560915017,120.33643560915017,1000000.0 -2022-08-03,TLT,120.33643560915017,121.11183417729244,119.73475343110442,120.50928773859945,120.50928773859945,1000000.0 -2022-08-04,TLT,120.50928773859945,122.44439647034997,119.90674129990646,121.83522036850745,121.83522036850745,1000000.0 -2022-08-05,TLT,121.83522036850745,124.18923016873903,121.22604426666491,123.5713733022279,123.5713733022279,1000000.0 -2022-08-08,TLT,123.5713733022279,124.18923016873903,121.40439173255561,122.0144640528197,122.0144640528197,1000000.0 -2022-08-09,TLT,122.0144640528197,125.24820421321995,121.40439173255561,124.62507881912434,124.62507881912434,1000000.0 +2022-07-06,TLT,123.12740208702392,124.30039142737932,122.5117650765888,123.68198151978042,123.68198151978042,1000000.0 +2022-07-07,TLT,123.68198151978042,126.39955097083823,123.06357161218152,125.77069748342116,125.77069748342116,1000000.0 +2022-07-08,TLT,125.77069748342116,127.32868518051052,125.14184399600406,126.69520913483636,126.69520913483636,1000000.0 +2022-07-11,TLT,126.69520913483636,127.36867058689737,126.06173308916216,126.73499560885313,126.73499560885313,1000000.0 +2022-07-12,TLT,126.73499560885313,127.36867058689737,125.57830534786736,126.20935210840942,126.20935210840942,1000000.0 +2022-07-13,TLT,126.20935210840942,127.4804090936592,125.57830534786736,126.846178202646,126.846178202646,1000000.0 +2022-07-14,TLT,126.846178202646,127.4804090936592,124.74197073749824,125.36881481155604,125.36881481155604,1000000.0 +2022-07-15,TLT,125.36881481155604,125.9956588856138,121.796978213635,122.40902333028644,122.40902333028644,1000000.0 +2022-07-18,TLT,122.40902333028644,123.02106844693786,121.20076305490224,121.80981211547964,121.80981211547964,1000000.0 +2022-07-19,TLT,121.80981211547964,122.41886117605702,120.2076470762788,120.8117056043003,120.8117056043003,1000000.0 +2022-07-20,TLT,120.8117056043003,123.17664221326822,120.2076470762788,122.56382309777932,122.56382309777932,1000000.0 +2022-07-21,TLT,122.56382309777932,123.36982271442598,121.95100398229044,122.7560425019164,122.7560425019164,1000000.0 +2022-07-22,TLT,122.7560425019164,123.36982271442598,121.23844483588218,121.84768325214289,121.84768325214289,1000000.0 +2022-07-25,TLT,121.84768325214289,122.45692166840358,120.1712204442932,120.77509592391276,120.77509592391276,1000000.0 +2022-07-26,TLT,120.77509592391276,121.92215627920716,120.1712204442932,121.31557838727085,121.31557838727085,1000000.0 +2022-07-27,TLT,121.31557838727085,121.92215627920716,119.96655628210796,120.56940329860096,120.56940329860096,1000000.0 +2022-07-28,TLT,120.56940329860096,121.17225031509396,119.46633286001232,120.06666619096715,120.06666619096715,1000000.0 +2022-07-29,TLT,120.06666619096715,120.666999521922,119.437311091792,120.0374985847156,120.0374985847156,1000000.0 +2022-08-01,TLT,120.0374985847156,120.78288890057452,119.437311091792,120.1819790055468,120.1819790055468,1000000.0 +2022-08-02,TLT,120.1819790055468,120.9381177871959,119.58106911051904,120.33643560915016,120.33643560915016,1000000.0 +2022-08-03,TLT,120.33643560915016,121.11183417729244,119.73475343110442,120.50928773859944,120.50928773859944,1000000.0 +2022-08-04,TLT,120.50928773859944,122.44439647034996,119.90674129990646,121.83522036850744,121.83522036850744,1000000.0 +2022-08-05,TLT,121.83522036850744,124.18923016873904,121.22604426666491,123.5713733022279,123.5713733022279,1000000.0 +2022-08-08,TLT,123.5713733022279,124.18923016873904,121.4043917325556,122.0144640528197,122.0144640528197,1000000.0 +2022-08-09,TLT,122.0144640528197,125.24820421321996,121.4043917325556,124.62507881912434,124.62507881912434,1000000.0 2022-08-10,TLT,124.62507881912434,125.4478890542189,124.00195342502872,124.8237702032029,124.8237702032029,1000000.0 -2022-08-11,TLT,124.8237702032029,126.77442523631885,124.19965135218688,126.14370670280483,126.14370670280483,1000000.0 -2022-08-12,TLT,126.14370670280483,126.77442523631885,124.84381932831985,125.47117520434156,125.47117520434156,1000000.0 -2022-08-15,TLT,125.47117520434156,126.49997290031122,124.84381932831985,125.87061980130471,125.87061980130471,1000000.0 -2022-08-16,TLT,125.87061980130471,126.79126419678907,125.24126670229819,126.16046188735233,126.16046188735233,1000000.0 +2022-08-11,TLT,124.8237702032029,126.77442523631883,124.19965135218688,126.14370670280483,126.14370670280483,1000000.0 +2022-08-12,TLT,126.14370670280483,126.77442523631883,124.84381932831984,125.47117520434156,125.47117520434156,1000000.0 +2022-08-15,TLT,125.47117520434156,126.49997290031122,124.84381932831984,125.87061980130473,125.87061980130473,1000000.0 +2022-08-16,TLT,125.87061980130473,126.79126419678909,125.2412667022982,126.16046188735233,126.16046188735233,1000000.0 2022-08-17,TLT,126.16046188735233,126.9802028606037,125.52965957791557,126.34846055781463,126.34846055781463,1000000.0 -2022-08-18,TLT,126.34846055781463,127.92407260999349,125.71671825502555,127.28763443780448,127.28763443780448,1000000.0 -2022-08-19,TLT,127.28763443780448,128.6483596551982,126.65119626561547,128.00831806487383,128.00831806487383,1000000.0 -2022-08-22,TLT,128.00831806487383,128.6483596551982,126.42274165279018,127.05803181184943,127.05803181184943,1000000.0 -2022-08-23,TLT,127.05803181184943,127.69332197090867,126.37883283796553,127.0139023497141,127.0139023497141,1000000.0 -2022-08-24,TLT,127.0139023497141,128.47210400819458,126.37883283796553,127.83293931163642,127.83293931163642,1000000.0 +2022-08-18,TLT,126.34846055781463,127.92407260999347,125.71671825502555,127.28763443780448,127.28763443780448,1000000.0 +2022-08-19,TLT,127.28763443780448,128.6483596551982,126.65119626561548,128.00831806487383,128.00831806487383,1000000.0 +2022-08-22,TLT,128.00831806487383,128.6483596551982,126.42274165279018,127.05803181184945,127.05803181184945,1000000.0 +2022-08-23,TLT,127.05803181184945,127.69332197090868,126.37883283796552,127.0139023497141,127.0139023497141,1000000.0 +2022-08-24,TLT,127.0139023497141,128.47210400819458,126.37883283796552,127.83293931163642,127.83293931163642,1000000.0 2022-08-25,TLT,127.83293931163642,129.25757492361234,127.19377461507824,128.61450241155458,128.61450241155458,1000000.0 2022-08-26,TLT,128.61450241155458,129.25757492361234,127.70453574213418,128.3462670775218,128.3462670775218,1000000.0 -2022-08-29,TLT,128.3462670775218,128.98799841290938,127.49464944559547,128.13532607597534,128.13532607597534,1000000.0 +2022-08-29,TLT,128.3462670775218,128.98799841290938,127.49464944559548,128.13532607597534,128.13532607597534,1000000.0 2022-08-30,TLT,128.13532607597534,128.7760027063552,127.41439290078306,128.05466623194278,128.05466623194278,1000000.0 -2022-08-31,TLT,128.05466623194278,128.69493956310248,126.36973259398644,127.00475637586577,127.00475637586577,1000000.0 -2022-09-01,TLT,127.00475637586577,127.63978015774508,125.1501424075499,125.77903759552753,125.77903759552753,1000000.0 -2022-09-02,TLT,125.77903759552753,128.08206826134102,125.1501424075499,127.44484404113537,127.44484404113537,1000000.0 -2022-09-05,TLT,127.44484404113537,128.08206826134102,126.48358058907095,127.11917647142809,127.11917647142809,1000000.0 -2022-09-06,TLT,127.11917647142809,127.929336608604,126.48358058907095,127.29287224736719,127.29287224736719,1000000.0 -2022-09-07,TLT,127.29287224736719,127.929336608604,125.97660664198142,126.60965491656424,126.60965491656424,1000000.0 +2022-08-31,TLT,128.05466623194278,128.69493956310248,126.36973259398644,127.00475637586575,127.00475637586575,1000000.0 +2022-09-01,TLT,127.00475637586575,127.63978015774508,125.1501424075499,125.77903759552753,125.77903759552753,1000000.0 +2022-09-02,TLT,125.77903759552753,128.08206826134102,125.1501424075499,127.44484404113535,127.44484404113535,1000000.0 +2022-09-06,TLT,127.11917647142808,127.929336608604,126.48358058907095,127.2928722473672,127.2928722473672,1000000.0 +2022-09-07,TLT,127.2928722473672,127.929336608604,125.97660664198142,126.60965491656424,126.60965491656424,1000000.0 2022-09-08,TLT,126.60965491656424,128.64781300931585,125.97660664198142,128.00777413862275,128.00777413862275,1000000.0 -2022-09-09,TLT,128.00777413862275,130.54260027509403,127.36773526792963,129.89313460208362,129.89313460208362,1000000.0 +2022-09-09,TLT,128.00777413862275,130.54260027509403,127.36773526792965,129.89313460208362,129.89313460208362,1000000.0 2022-09-12,TLT,129.89313460208362,130.54260027509403,129.0942616386387,129.7429765212449,129.7429765212449,1000000.0 2022-09-13,TLT,129.7429765212449,131.00832753408176,129.0942616386387,130.3565448100316,130.3565448100316,1000000.0 2022-09-14,TLT,130.3565448100316,131.00832753408176,129.00143931157743,129.64968775032906,129.64968775032906,1000000.0 @@ -975,8 +941,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-09-23,TLT,134.21300730820624,135.35130537389094,133.54194227166522,134.67791579491637,134.67791579491637,1000000.0 2022-09-26,TLT,134.67791579491637,135.35130537389094,133.36895662384785,134.03915238577673,134.03915238577673,1000000.0 2022-09-27,TLT,134.03915238577673,134.7093481477056,132.87477357179043,133.54248600179943,133.54248600179943,1000000.0 -2022-09-28,TLT,133.54248600179943,134.9597906592931,132.87477357179043,134.28834891471953,134.28834891471953,1000000.0 -2022-09-29,TLT,134.28834891471953,134.98516931016954,133.61690717014594,134.3136013036513,134.3136013036513,1000000.0 +2022-09-28,TLT,133.54248600179943,134.9597906592931,132.87477357179043,134.2883489147195,134.2883489147195,1000000.0 +2022-09-29,TLT,134.2883489147195,134.98516931016954,133.61690717014594,134.3136013036513,134.3136013036513,1000000.0 2022-09-30,TLT,134.3136013036513,134.98516931016954,132.70894471101343,133.37582383016425,133.37582383016425,1000000.0 2022-10-03,TLT,133.37582383016425,136.31729512082654,132.70894471101343,135.639099622713,135.639099622713,1000000.0 2022-10-04,TLT,135.639099622713,136.31729512082654,134.81186536819644,135.48931192783562,135.48931192783562,1000000.0 @@ -1016,10 +982,9 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-11-21,TLT,131.74691560624336,133.78521404792238,131.08818102821215,133.11961596808197,133.11961596808197,1000000.0 2022-11-22,TLT,133.11961596808197,133.8462856251223,132.45401788824157,133.18038370658937,133.18038370658937,1000000.0 2022-11-23,TLT,133.18038370658937,133.8462856251223,132.05333921566324,132.71692383483744,132.71692383483744,1000000.0 -2022-11-24,TLT,132.71692383483744,133.83792701725855,132.05333921566324,133.17206668383938,133.17206668383938,1000000.0 2022-11-25,TLT,133.17206668383938,133.83792701725855,131.86594215971843,132.52858508514416,132.52858508514416,1000000.0 -2022-11-28,TLT,132.52858508514416,133.19122801056986,130.5764138186719,131.23257670218283,131.23257670218283,1000000.0 -2022-11-29,TLT,131.23257670218283,133.1245591011593,130.5764138186719,132.46224786185005,132.46224786185005,1000000.0 +2022-11-28,TLT,132.52858508514416,133.19122801056986,130.5764138186719,131.23257670218285,131.23257670218285,1000000.0 +2022-11-29,TLT,131.23257670218285,133.1245591011593,130.5764138186719,132.46224786185005,132.46224786185005,1000000.0 2022-11-30,TLT,132.46224786185005,133.1245591011593,131.42125368939364,132.0816619993906,132.0816619993906,1000000.0 2022-12-01,TLT,132.0816619993906,132.89283253481108,131.42125368939364,132.23167416399113,132.23167416399113,1000000.0 2022-12-02,TLT,132.23167416399113,133.6071622052608,131.57051579317118,132.9424499554834,132.9424499554834,1000000.0 @@ -1038,12 +1003,10 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2022-12-21,TLT,129.74383083583297,130.4609928407207,129.0951116816538,129.8119331748465,129.8119331748465,1000000.0 2022-12-22,TLT,129.8119331748465,131.89985489634384,129.16287350897227,131.24363671277996,131.24363671277996,1000000.0 2022-12-23,TLT,131.24363671277996,133.12219832185332,130.58741852921605,132.45989882771477,132.45989882771477,1000000.0 -2022-12-26,TLT,132.45989882771477,133.534878860341,131.7975993335762,132.87052622919504,132.87052622919504,1000000.0 2022-12-27,TLT,132.87052622919504,134.01489086735768,132.20617359804905,133.34815011677384,133.34815011677384,1000000.0 2022-12-28,TLT,133.34815011677384,134.01489086735768,132.40528663642965,133.0706398356077,133.0706398356077,1000000.0 2022-12-29,TLT,133.0706398356077,133.7359930347857,132.28579107062723,132.9505437895751,132.9505437895751,1000000.0 2022-12-30,TLT,132.9505437895751,134.2653551906267,132.28579107062723,133.5973683488823,133.5973683488823,1000000.0 -2023-01-02,TLT,133.5973683488823,134.2653551906267,132.8207591734476,133.48820017431922,133.48820017431922,1000000.0 2023-01-03,TLT,133.48820017431922,134.4953606433221,132.8207591734476,133.8262294958429,133.8262294958429,1000000.0 2023-01-04,TLT,133.8262294958429,134.4953606433221,131.99159307520264,132.65486741226397,132.65486741226397,1000000.0 2023-01-05,TLT,132.65486741226397,133.31814174932526,130.25833180711638,130.91289628855918,130.91289628855918,1000000.0 @@ -1053,15 +1016,14 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-01-11,TLT,130.11970792467335,132.0313197022178,129.46910938505,131.37444746489334,131.37444746489334,1000000.0 2023-01-12,TLT,131.37444746489334,132.0313197022178,129.69527290478305,130.34700794450558,130.34700794450558,1000000.0 2023-01-13,TLT,130.34700794450558,130.9987429842281,128.10640255994502,128.7501533265779,128.7501533265779,1000000.0 -2023-01-16,TLT,128.7501533265779,130.23395075922087,128.10640255994502,129.58602065594118,129.58602065594118,1000000.0 2023-01-17,TLT,129.58602065594118,130.7339497998504,128.93809055266146,130.08353213915464,130.08353213915464,1000000.0 2023-01-18,TLT,130.08353213915464,130.7339497998504,128.03941101725903,128.6828251429739,128.6828251429739,1000000.0 2023-01-19,TLT,128.6828251429739,130.44174141415132,128.03941101725903,129.79277752651873,129.79277752651873,1000000.0 2023-01-20,TLT,129.79277752651873,130.6404211764938,129.14381363888614,129.99046883233214,129.99046883233214,1000000.0 2023-01-23,TLT,129.99046883233214,132.04784084122798,129.3405164881705,131.3908864091821,131.3908864091821,1000000.0 2023-01-24,TLT,131.3908864091821,132.42653894393305,130.73393197713617,131.76770044172446,131.76770044172446,1000000.0 -2023-01-25,TLT,131.76770044172446,133.38978841997323,131.10886193951583,132.72615763181417,132.72615763181417,1000000.0 -2023-01-26,TLT,132.72615763181417,133.7497260884891,132.0625268436551,133.0843045656608,133.0843045656608,1000000.0 +2023-01-25,TLT,131.76770044172446,133.38978841997323,131.10886193951583,132.72615763181415,132.72615763181415,1000000.0 +2023-01-26,TLT,132.72615763181415,133.7497260884891,132.0625268436551,133.0843045656608,133.0843045656608,1000000.0 2023-01-27,TLT,133.0843045656608,133.7497260884891,131.4293021688971,132.08975092351466,132.08975092351466,1000000.0 2023-01-30,TLT,132.08975092351466,132.75019967813222,131.20040423277948,131.85970274651203,131.85970274651203,1000000.0 2023-01-31,TLT,131.85970274651203,132.51900126024458,130.50633984413656,131.16215059712218,131.16215059712218,1000000.0 @@ -1078,9 +1040,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-02-15,TLT,131.21960536424805,131.8757033910693,130.55303884773096,131.20908426907633,131.20908426907633,1000000.0 2023-02-16,TLT,131.20908426907633,131.8651296904217,128.33497028725213,128.97986963542928,128.97986963542928,1000000.0 2023-02-17,TLT,128.97986963542928,129.6273271031061,128.33497028725213,128.98241502796628,128.98241502796628,1000000.0 -2023-02-20,TLT,128.98241502796628,129.92716529602473,128.33750295282644,129.28076148858182,129.28076148858182,1000000.0 -2023-02-21,TLT,129.28076148858182,129.92716529602473,127.67163995114979,128.31320598105506,128.31320598105506,1000000.0 -2023-02-22,TLT,128.31320598105506,129.0885745351532,127.67163995114979,128.44634282104798,128.44634282104798,1000000.0 +2023-02-21,TLT,129.28076148858182,129.92716529602473,127.6716399511498,128.31320598105506,128.31320598105506,1000000.0 +2023-02-22,TLT,128.31320598105506,129.0885745351532,127.6716399511498,128.44634282104798,128.44634282104798,1000000.0 2023-02-23,TLT,128.44634282104798,130.33696506363722,127.80411110694274,129.68852245138032,129.68852245138032,1000000.0 2023-02-24,TLT,129.68852245138032,130.39697659924957,129.04007983912342,129.7482354221389,129.7482354221389,1000000.0 2023-02-27,TLT,129.7482354221389,131.5162716845443,129.09949424502818,130.86196187516848,130.86196187516848,1000000.0 @@ -1112,14 +1073,13 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-04-04,TLT,131.0527419737443,132.2442277162715,130.39747826387557,131.58629623509603,131.58629623509603,1000000.0 2023-04-05,TLT,131.58629623509603,132.2442277162715,129.72867987987044,130.38058279383964,130.38058279383964,1000000.0 2023-04-06,TLT,130.38058279383964,131.70439104713404,129.72867987987044,131.0491453205314,131.0491453205314,1000000.0 -2023-04-07,TLT,131.0491453205314,133.80540993593755,130.39389959392875,133.13971137904235,133.13971137904235,1000000.0 2023-04-10,TLT,133.13971137904235,133.80540993593755,131.6543046513151,132.31588407167348,132.31588407167348,1000000.0 2023-04-11,TLT,132.31588407167348,134.4040083246257,131.6543046513151,133.73533166629423,133.73533166629423,1000000.0 2023-04-12,TLT,133.73533166629423,135.48584792460542,133.06665500796277,134.8117889797069,134.8117889797069,1000000.0 2023-04-13,TLT,134.8117889797069,136.208542592963,134.13773003480836,135.530888152202,135.530888152202,1000000.0 2023-04-14,TLT,135.530888152202,136.208542592963,134.80514918478946,135.48256199476327,135.48256199476327,1000000.0 -2023-04-17,TLT,135.48256199476327,136.15997480473706,133.58070522779767,134.25196505306297,134.25196505306297,1000000.0 -2023-04-18,TLT,134.25196505306297,134.92322487832828,132.66888883430198,133.3355666676402,133.3355666676402,1000000.0 +2023-04-17,TLT,135.48256199476327,136.15997480473706,133.5807052277977,134.25196505306295,134.25196505306295,1000000.0 +2023-04-18,TLT,134.25196505306295,134.92322487832828,132.66888883430198,133.3355666676402,133.3355666676402,1000000.0 2023-04-19,TLT,133.3355666676402,134.84764243199282,132.66888883430198,134.17675863879884,134.17675863879884,1000000.0 2023-04-20,TLT,134.17675863879884,135.93249769212483,133.50587484560484,135.25621660907944,135.25621660907944,1000000.0 2023-04-21,TLT,135.25621660907944,137.29387116428524,134.57993552603403,136.6108170788908,136.6108170788908,1000000.0 @@ -1148,7 +1108,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-05-24,TLT,136.1385145383969,137.44099876124784,135.4578219657049,136.75721269775906,136.75721269775906,1000000.0 2023-05-25,TLT,136.75721269775906,137.60674716147292,136.07342663427028,136.92213647907755,136.92213647907755,1000000.0 2023-05-26,TLT,136.92213647907755,137.60674716147292,134.86396249453267,135.54167084877656,135.54167084877656,1000000.0 -2023-05-29,TLT,135.54167084877656,136.21937920302042,134.28007323092578,134.9548474682671,134.9548474682671,1000000.0 2023-05-30,TLT,134.9548474682671,136.2012569273049,134.28007323092578,135.52363873363674,135.52363873363674,1000000.0 2023-05-31,TLT,135.52363873363674,136.2012569273049,134.25639283808707,134.93104807847948,134.93104807847948,1000000.0 2023-06-01,TLT,134.93104807847948,137.72368046775543,134.25639283808707,137.03848802761735,137.03848802761735,1000000.0 @@ -1157,13 +1116,12 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-06-06,TLT,134.95530931971973,136.87327087050173,134.28053277312114,136.19230932388234,136.19230932388234,1000000.0 2023-06-07,TLT,136.19230932388234,136.87327087050173,134.8869713263585,135.56479530287285,135.56479530287285,1000000.0 2023-06-08,TLT,135.56479530287285,136.2426192793872,133.70306101715397,134.37493569563213,134.37493569563213,1000000.0 -2023-06-09,TLT,134.37493569563213,135.04681037411027,132.28939626921596,132.95416710473967,132.95416710473967,1000000.0 +2023-06-09,TLT,134.37493569563213,135.0468103741103,132.28939626921596,132.95416710473967,132.95416710473967,1000000.0 2023-06-12,TLT,132.95416710473967,133.61893794026335,131.5546603774887,132.21573907285295,132.21573907285295,1000000.0 2023-06-13,TLT,132.21573907285295,134.525018651985,131.5546603774887,133.8557399522239,133.8557399522239,1000000.0 2023-06-14,TLT,133.8557399522239,134.9994349519206,133.18646125246278,134.3277959720603,134.3277959720603,1000000.0 2023-06-15,TLT,134.3277959720603,135.5923842533024,133.6561569922,134.91779527691784,134.91779527691784,1000000.0 2023-06-16,TLT,134.91779527691784,135.5923842533024,133.7204293483778,134.3923913049023,134.3923913049023,1000000.0 -2023-06-19,TLT,134.3923913049023,136.0142845238175,133.7204293483778,135.33759654111196,135.33759654111196,1000000.0 2023-06-20,TLT,135.33759654111196,136.16544658986177,134.6609085584064,135.4880065570764,135.4880065570764,1000000.0 2023-06-21,TLT,135.4880065570764,136.16544658986177,133.8553534536644,134.52799342076824,134.52799342076824,1000000.0 2023-06-22,TLT,134.52799342076824,135.20063338787207,133.40642990498188,134.07681397485615,134.07681397485615,1000000.0 @@ -1174,7 +1132,6 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-06-29,TLT,136.18081419951977,139.12793019417433,135.49991012852217,138.4357514369894,138.4357514369894,1000000.0 2023-06-30,TLT,138.4357514369894,139.12793019417433,136.90276596265733,137.59071956045963,137.59071956045963,1000000.0 2023-07-03,TLT,137.59071956045963,138.2786731582619,135.58700598409786,136.2683477227114,136.2683477227114,1000000.0 -2023-07-04,TLT,136.2683477227114,138.06841557211038,135.58700598409786,137.38150803195063,137.38150803195063,1000000.0 2023-07-05,TLT,137.38150803195063,138.59089742168527,136.69460049179088,137.9013904693386,137.9013904693386,1000000.0 2023-07-06,TLT,137.9013904693386,138.59089742168527,137.1414287877795,137.8305816962608,137.8305816962608,1000000.0 2023-07-07,TLT,137.8305816962608,138.85058264409892,137.1414287877795,138.15978372547158,138.15978372547158,1000000.0 @@ -1189,14 +1146,14 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-07-20,TLT,136.60814610174376,138.0965244223335,135.92510537123505,137.40947703714775,137.40947703714775,1000000.0 2023-07-21,TLT,137.40947703714775,138.0965244223335,136.71534641575613,137.40235820679007,137.40235820679007,1000000.0 2023-07-24,TLT,137.40235820679007,138.089369997824,136.16264274484115,136.84687713049362,136.84687713049362,1000000.0 -2023-07-25,TLT,136.84687713049362,137.80200101052895,136.16264274484115,137.11641891594923,137.11641891594923,1000000.0 -2023-07-26,TLT,137.11641891594923,137.80200101052895,135.8954817237826,136.57837359174133,136.57837359174133,1000000.0 +2023-07-25,TLT,136.84687713049362,137.80200101052895,136.16264274484115,137.11641891594925,137.11641891594925,1000000.0 +2023-07-26,TLT,137.11641891594925,137.80200101052895,135.8954817237826,136.57837359174133,136.57837359174133,1000000.0 2023-07-27,TLT,136.57837359174133,137.2612654597,134.2521083209051,134.9267420310604,134.9267420310604,1000000.0 2023-07-28,TLT,134.9267420310604,135.6013757412157,133.07697176021924,133.74570026152688,133.74570026152688,1000000.0 2023-07-31,TLT,133.74570026152688,134.4144287628345,132.26596111601393,132.93061418694867,132.93061418694867,1000000.0 2023-08-01,TLT,132.93061418694867,133.5952672578834,131.80952986205108,132.47188930859406,132.47188930859406,1000000.0 -2023-08-02,TLT,132.47188930859406,133.27929160549022,131.80952986205108,132.61621055272659,132.61621055272659,1000000.0 -2023-08-03,TLT,132.61621055272659,134.2960608944924,131.95312949996296,133.62792128805216,133.62792128805216,1000000.0 +2023-08-02,TLT,132.47188930859406,133.27929160549022,131.80952986205108,132.6162105527266,132.6162105527266,1000000.0 +2023-08-03,TLT,132.6162105527266,134.2960608944924,131.95312949996296,133.62792128805216,133.62792128805216,1000000.0 2023-08-04,TLT,133.62792128805216,134.93288669788467,132.9597816816119,134.26157880386535,134.26157880386535,1000000.0 2023-08-07,TLT,134.26157880386535,136.9125588322069,133.59027090984603,136.23140182309146,136.23140182309146,1000000.0 2023-08-08,TLT,136.23140182309146,138.36168819807835,135.550244813976,137.67332159012773,137.67332159012773,1000000.0 @@ -1217,8 +1174,7 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-08-29,TLT,145.5476064085459,146.2753444405886,142.929885518201,143.64812614894572,143.64812614894572,1000000.0 2023-08-30,TLT,143.64812614894572,144.80792026592908,142.929885518201,144.08748285167076,144.08748285167076,1000000.0 2023-08-31,TLT,144.08748285167076,146.3186535331847,143.3670454374124,145.5907000330196,145.5907000330196,1000000.0 -2023-09-01,TLT,145.5907000330196,147.05495899752665,144.86274653285452,146.3233422860962,146.3233422860962,1000000.0 -2023-09-04,TLT,146.3233422860962,147.08041778476897,145.5917255746657,146.34867441270546,146.34867441270546,1000000.0 +2023-09-01,TLT,145.5907000330196,147.05495899752663,144.86274653285452,146.3233422860962,146.3233422860962,1000000.0 2023-09-05,TLT,146.34867441270546,147.77172572968465,145.61693104064193,147.0365430146116,147.0365430146116,1000000.0 2023-09-06,TLT,147.0365430146116,147.77172572968465,145.36982653982477,146.1003281807284,146.1003281807284,1000000.0 2023-09-07,TLT,146.1003281807284,146.83082982163205,144.4605661647321,145.18649865802223,145.18649865802223,1000000.0 @@ -1226,8 +1182,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-09-11,TLT,144.82493158343678,145.54905624135395,143.9848190443856,144.70836084862876,144.70836084862876,1000000.0 2023-09-12,TLT,144.70836084862876,146.61549075877875,143.9848190443856,145.8860604564963,145.8860604564963,1000000.0 2023-09-13,TLT,145.8860604564963,146.61549075877875,144.17637420440818,144.90087859739515,144.90087859739515,1000000.0 -2023-09-14,TLT,144.90087859739515,146.85117679466077,144.17637420440818,146.12057392503561,146.12057392503561,1000000.0 -2023-09-15,TLT,146.12057392503561,146.85117679466077,143.62603070146176,144.3477695492078,144.3477695492078,1000000.0 +2023-09-14,TLT,144.90087859739515,146.85117679466077,144.17637420440818,146.1205739250356,146.1205739250356,1000000.0 +2023-09-15,TLT,146.1205739250356,146.85117679466077,143.62603070146176,144.3477695492078,144.3477695492078,1000000.0 2023-09-18,TLT,144.3477695492078,145.06950839695384,142.77822756722222,143.49570609771078,143.49570609771078,1000000.0 2023-09-19,TLT,143.49570609771078,144.22482863051786,142.77822756722222,143.50729216966954,143.50729216966954,1000000.0 2023-09-20,TLT,143.50729216966954,145.62136071578843,142.7897557088212,144.89687633411785,144.89687633411785,1000000.0 @@ -1252,8 +1208,8 @@ timestamp,symbol,open,high,low,close,adjusted_close,volume 2023-10-17,TLT,145.65230303150122,146.38056454665872,144.52404029406625,145.2502917528304,145.2502917528304,1000000.0 2023-10-18,TLT,145.2502917528304,145.97654321159453,144.00115951431337,144.7247834314707,144.7247834314707,1000000.0 2023-10-19,TLT,144.7247834314707,145.44840734862805,143.65957548403227,144.3814828985249,144.3814828985249,1000000.0 -2023-10-20,TLT,144.3814828985249,145.93858752937805,143.65957548403227,145.2125249048538,145.2125249048538,1000000.0 -2023-10-23,TLT,145.2125249048538,145.93858752937805,143.79290290496223,144.5154803064947,144.5154803064947,1000000.0 +2023-10-20,TLT,144.3814828985249,145.93858752937803,143.65957548403227,145.2125249048538,145.2125249048538,1000000.0 +2023-10-23,TLT,145.2125249048538,145.93858752937803,143.79290290496223,144.5154803064947,144.5154803064947,1000000.0 2023-10-24,TLT,144.5154803064947,147.17653469249234,143.79290290496223,146.44431312685808,146.44431312685808,1000000.0 2023-10-25,TLT,146.44431312685808,147.17653469249234,145.66797281457778,146.3999726779676,146.3999726779676,1000000.0 2023-10-26,TLT,146.3999726779676,147.13197254135744,145.54952744311365,146.2809321036318,146.2809321036318,1000000.0 diff --git a/docs/api.md b/docs/api.md index 842265f..1d6ad31 100644 --- a/docs/api.md +++ b/docs/api.md @@ -8,7 +8,7 @@ than from private modules or names beginning with an underscore. | --- | --- | | `quantlab.config` | `ExperimentConfig` and its validated configuration models | | `quantlab.data` | `DataLoader`, `DataValidator`, `DataCleaner`, `ParquetStorage`, `Universe`, schema and resampling helpers | -| `quantlab.features` | Return, momentum, volatility, mean-reversion and technical features; optional `FeaturePipeline` | +| `quantlab.features` | Return, momentum, volatility, mean-reversion, cross-sectional and technical features; stationarity/cointegration (`adf_test`, `cointegration_test`, `hurst_exponent`), correlation (`correlation_matrix`) and pairs-trading diagnostics (`compute_pair_diagnostics`); optional `FeaturePipeline` | | `quantlab.strategies` | Built-in strategies, registry helpers and `BaseStrategy` | | `quantlab.portfolio` | Allocators, constraints, rebalancing and volatility targeting | | `quantlab.execution` | Commission, spread, slippage and aggregate execution models | diff --git a/docs/architecture.md b/docs/architecture.md index f57145d..82591f4 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -8,8 +8,9 @@ optional `FeaturePipeline` helper; the engine does not require that helper. ```mermaid flowchart LR - A[Market Data Sources] --> B[Data Cleaning] - B --> C[Data Validation] + A[Market Data Sources] --> A2[Raw Data Inspection] + A2 --> B[Data Cleaning] + B --> C[Final Validation] C --> E[Strategy using Feature Functions] E --> F[Portfolio Allocation] F --> G[Constraints] @@ -49,9 +50,12 @@ src/quantlab/ ## Why this separation -- **Strategies emit signals only** (`[-1, 1]` per asset per date). They never - compute weights, costs, or returns. This makes each strategy trivially - unit-testable against a synthetic dataset with an obvious expected signal. +- **Strategies emit bounded position intents** (`[-1, 1]` per asset per + date — a pair's two legs additionally encode their relative hedge-ratio + weighting in the signal magnitude, not a pure directional flag). They + never compute final portfolio weights, costs, or returns. This makes + each strategy trivially unit-testable against a synthetic dataset with + an obvious expected signal. - **Allocators turn signals into weights** and know nothing about execution costs or accounting. - **The execution model computes costs** from weight *changes*, independent of @@ -59,13 +63,11 @@ src/quantlab/ - **Signals, weights, costs and returns are combined in one fixed order**, with the weight-shift step as a hard, tested barrier against look-ahead bias. `BacktestEngine.run()` is that assembly for a single backtest. - `WalkForwardValidator._build_oos_result()` (`quantlab.validation. - walk_forward`) independently assembles the same stitched-OOS-series case, - reusing the same underlying accounting/trade-log/benchmark/metrics - functions in the same order rather than calling `BacktestEngine.run()` - itself — a fix that touches how that assembly step works (e.g. which - execution-model fields the trade log or metadata reads) currently needs - applying at both call sites, not one shared entry point. + **Known architectural duplication**: `WalkForwardValidator. + _build_oos_result()` independently re-assembles the same stitched-OOS- + series case from the same underlying functions rather than calling + `BacktestEngine.run()` itself — a fix to that assembly step currently + needs applying at both call sites, not one shared entry point. ## Data flow shapes diff --git a/docs/backtesting.md b/docs/backtesting.md index ed2b8b3..e7915d8 100644 --- a/docs/backtesting.md +++ b/docs/backtesting.md @@ -14,22 +14,36 @@ The CLI and `BacktestEngine.run()` together execute this sequence: 8. Apply any additional execution-delay stress assumption. 9. **Shift held weights one period before computing returns** — the look-ahead barrier. -10. Solve turnover, costs, gross/net returns and equity, including +10. When `portfolio.model_weight_drift` is enabled, evolve the shifted, + executed weights forward by organic price drift between real trades + (see [Weight drift](#weight-drift)) — otherwise unchanged, holding + them constant until the next scheduled rebalance. +11. Solve turnover, costs, gross/net returns and equity, including equity-dependent volume slippage. -11. Build the benchmark and trade log from the same dates and cost assumptions. -12. Compute metrics and assemble the `BacktestResult`. +12. Build the benchmark and trade log from the same dates and cost assumptions. +13. Compute metrics and assemble the `BacktestResult`. ## Preventing look-ahead bias The return earned in period `t` must come from a position **decided before** -`t`. `quantlab.backtesting.accounting.run_accounting` enforces this with one -line: +`t`. `quantlab.backtesting.accounting.run_accounting` enforces this via +`quantlab.execution.orders.executed_weights`: ```python -executed_weights = held_weights.shift(1).fillna(0.0) +executed_weights = compute_executed_weights( + held_weights, + tradable=tradable, +) gross_returns = (executed_weights * asset_returns).sum(axis=1) ``` +For a single-calendar universe (`tradable=None`) this reduces to the simple +mental model `held_weights.shift(1)` (first row zeroed); when a per-symbol +`tradable` mask is given, the shift is per-symbol tradability-aware instead +— a decision made right before a closure lands on that symbol's own next +tradable row, not the raw next row, so it is never misattributed as trading +during the closure itself. + This is directly unit-tested (`tests/unit/test_accounting.py`): a signal that turns long on date *i* must show zero gain on date *i* and only starts capturing returns from date *i + 1*. @@ -41,8 +55,41 @@ equity_0 = initial_capital equity_t = equity_{t-1} * (1 + net_return_t) ``` -Gross and net equity curves are both kept so cost drag is always visible: -`result.gross_net_comparison()`. +Gross and net equity curves are both retained, so cost drag can be +inspected via `result.gross_net_comparison()`. + +## Stop-loss / take-profit + +A strategy's `stop_loss_pct`/`take_profit_pct` (fractional, e.g. `0.10` = +10%, `None` by default on every built-in strategy -- disabled with +strictly no change to accounting's numbers) force-flatten a position when +its cumulative return since entry breaches the configured threshold. This +operates on the **real executed position** (`accounting.executed_weights`, +after the allocator, portfolio constraints, rebalancing schedule and +turnover cap), never on a strategy's raw signal -- a signal is not +necessarily a realized position. For a symbol/group `G`, at each date: + +``` +gross_exposure = sum(|executed_weight| for each symbol in G) +group_return = sum(executed_weight * asset_return for each symbol in G) / gross_exposure +``` + +`group_return` is per unit of the group's *own* realized exposure that +date, not a dollar contribution to total portfolio equity — this makes it +correct regardless of a static or dynamic hedge ratio, rebalancing, +weight changes, long/short direction or partial fills. For a single +symbol it reduces to `sign(executed_weight) * asset_return`, the standard +price-based stop-loss/take-profit. A strategy declares a multi-symbol +group via `BaseStrategy.position_groups()` (e.g. `pairs_trading`'s two +legs, so a stop-loss triggers on the pair's *combined* P&L, not either +leg's own return in isolation) — every symbol not covered by a declared +group is its own independent group. Thresholds are evaluated on **gross +(pre-cost) return**: QuantLab's execution cost model is portfolio-level +only (no per-symbol/per-group cost decomposition), so an exact net-of-cost +trigger is not presently computable — a deliberate, disclosed design +convention. Once triggered, no immediate re-entry at a rebased price: the +position stays flat until its next real flat-to-non-flat transition. See +`quantlab.backtesting.accounting._detect_stop_loss_take_profit`. ## Missing data @@ -74,11 +121,23 @@ breakdown. ## Rebalancing & turnover +This section describes `rebalancing.py`'s own DECISION-timeline output -- +the *rebalance target* the strategy/allocator/constraints machinery +decides to chase. It is not automatically the *weight actually held*, and +its own turnover formula is not automatically the *real trade* executed: +when `portfolio.model_weight_drift` is enabled (the default -- see +[Weight drift](#weight-drift) below), organic price drift between real +trades changes the weight actually held continuously, and a genuine trade +is instead reported by `apply_weight_drift`'s own `trade_changes` output -- +exactly zero on a pure-drift row (price moved; nothing was traded) and the +real size on an anchor or a landed compliance/turnover-cap correction. + Daily allocator output is a *target*; `apply_rebalancing` samples it on rebalance dates (daily / weekly / monthly / quarterly) and holds it constant -between them — trades, and therefore costs, only occur at rebalances. -Turnover is `sum(|held_t - held_{t-1}|)` (`w_{-1} = 0`), directly matching the -manual example of capital 100k, turnover 0.5, 10 bps → cost 50. +between them — at THIS decision-timeline layer, trades, and therefore +costs, only occur at rebalances. Turnover is `sum(|held_t - held_{t-1}|)` +(`w_{-1} = 0`), directly matching the manual example of capital 100k, +turnover 0.5, 10 bps → cost 50. For a mixed-calendar portfolio, `rebalance_and_cap_turnover` is tradability-aware: a closed instrument (per its own calendar, see @@ -102,3 +161,94 @@ the accounting layer before it affects executed weights, turnover or costs — a target that resolves in `held_weights` on a symbol's reopening day therefore does not reach the accounting layer until that symbol's *next* tradable session, not the reopening day itself. + +## Weight drift + +Everything above describes `rebalancing.py`'s own output: a decision-timeline +step function, constant between rebalance dates. A real portfolio does not +actually stay constant between trades — each asset's own price move drifts +its dollar exposure, and therefore its weight, continuously. When +`portfolio.model_weight_drift` is `True` (the default), +`quantlab.backtesting.accounting.apply_weight_drift` evolves the already +shifted, executed weights forward between genuine trades, via a per-column +dollar exposure and a single shared relative equity `E` (`weight[i] = +dollar[i] / E`). + +Conceptually, two independent kinds of debt drive every row's output, in +priority order: a hard-risk-limit breach (`maximum_weight`/`maximum_gross_ +exposure`/`maximum_net_exposure`/`long_only`) is corrected first, via a +genuine linear program — never a "clip and scale toward zero" heuristic, +which can move exposure in the wrong direction — that finds the minimal- +turnover point restoring compliance; an ordinary fresh rebalance decision +is applied second, turnover-capped like a decision-level rebalance. See +[Weight-drift mechanics and the compliance-restoration LP](drift_compliance.md) +for the full per-column debt priority order, anchor detection, the +bankruptcy guard, and the LP's exact formulation — this section only +states the invariants and limitations a caller needs to know: + +- Output is always a *pre-period* value — the weight held going into a + row, before that row's own return is applied — never the post-period + value, which would double-count that row's own return. +- A declared position group (e.g. `pairs_trading`'s two legs, see + `BaseStrategy.position_groups()`) is always corrected as one coherent + unit via a single shared scaling factor, never one leg moving alone. +- The correction is sign/support-preserving: an existing long may shrink + or grow further long, an existing short may shrink or grow further + short, but neither crosses zero, and a column already at exactly zero + is never opened into a brand-new position — it can never invent a hedge + the strategy's own signal never asked for. +- A closed asset's dollar exposure does not move, but its weight still + drifts purely through `E`'s own movement from every other tradable + asset's real return. +- A hard risk-limit breach detected using row `t`'s own drift cannot + execute until row `t+1` at the earliest — the same look-ahead barrier + as every other decision in this module — and if the responsible + exposure sits in a currently-closed column, full correction may be + impossible until it reopens; the LP applies the best achievable fix + meanwhile, carrying the residual as a pending breach rather than + raising or silently dropping it. +- Never produces `inf`/`NaN`: a bankrupt anchor-episode (relative `E <= + EPSILON`) is force-flattened and logged instead of dividing by + (near-)zero. +- `model_weight_drift=False` remains available as an optional constant- + weight compatibility mode (byte-identical to the step function described + above), not the recommended path. +- The compliance-restoration LP's own basis is gross/pre-cost, the same + disclosed convention already used by `stop_loss_pct`/`take_profit_pct`. + +## Trade-log reason attribution + +`quantlab.backtesting.trade_log._classify_reason` assigns every fill's +`trigger_reason_code`/`adjustment_reason_codes` from real, per-layer +provenance signals — never deduced after the fact from `new != desired`. +`execution_delay`/the rebalance-sampling frequency are not adjustments: +they are uniform timing conventions baked into every comparison below, so +they shift *when* a trigger is consumed, never *what* explains one trade's +execution differing from another's. + +**Trigger** — the single most-upstream event currently consumed that +initiated the target change (not exhaustive: when `strategy_signal` is the +trigger, a downstream layer subsequently recomputing the target is a +mechanical consequence of that same event, not separately lost +information): + +| Priority | Code | Fires when | +| --- | --- | --- | +| 1 | `strategy_signal` | The strategy's own decision changed since the last rebalance (from its diagnostic decision proxy — `decision_signal()` when provided, else the raw signal). | +| 2 | `portfolio_rebalance` | Only the allocator's output changed. | +| 3 | `volatility_target_adjustment` | Only vol-targeting changed the target. | +| — | *(none)* | Nothing above changed. | + +**Adjustment(s)** — collected independently of trigger, from each layer's +own real provenance signal; a higher-priority cause fully explains the row +and suppresses lower-priority ones (the precise clip value of a lower +layer becomes moot once a higher one applies). Priorities 1–3 never touch +`trigger`, which keeps reflecting what the strategy actually wanted: + +| Priority | Code(s) | Fires when | +| --- | --- | --- | +| 1 | `forced_liquidation` | Portfolio ruin (`AccountingResult.ruined`) — overrides everything else. | +| 2 | `stop_loss` / `take_profit` | A real force-flatten (`AccountingResult.stop_loss_triggered`/`take_profit_triggered`) — overrides ordinary constraints, overridden by `forced_liquidation`. | +| 3 | `drift_compliance` / `drift_compliance_pending` | The drift-compliance LP restored (or attempted to restore) a hard risk limit breached by organic drift — overrides ordinary constraint/tradability/turnover_cap adjustments, overridden by a stop-loss/take-profit breach on that same corrected weight. | +| 4 | Constraint name(s), `tradability`, `turnover_cap` | A contributing constraint (direct/redistribution), a closure catch-up/feasibility limit, or the turnover budget itself. | +| 5 | `position_rescaling` / `deferred_catchup` | Last-resort fallback: the target is still drifting with no known trigger (e.g. pairs_trading's price/beta rescaling), or the causal layer is genuinely unknown — reached only when nothing above explains the row. diff --git a/docs/data_pipeline.md b/docs/data_pipeline.md index 7542851..b6e53ae 100644 --- a/docs/data_pipeline.md +++ b/docs/data_pipeline.md @@ -24,9 +24,11 @@ consistency, coverage gaps). `DataConfig.instruments` is a list of `InstrumentConfig` entries (`symbol`/`source`/`calendar`), each fully explicit — no global source or -calendar for the whole experiment. A single portfolio can freely mix sources -and calendars, e.g. US equities from Yahoo (`XNYS`) alongside crypto from -Binance (`24/7`). `ExperimentConfig.benchmark` is itself an `InstrumentConfig`; +calendar for the whole experiment. A portfolio can mix sources and +calendars, e.g. US equities from Yahoo (`XNYS`) alongside crypto from +Binance (`24/7`), at daily frequency; see +[Limitations](limitations.md#data) for what that support does and does not +cover (rebalancing, windowed-feature dilution, intraday). `ExperimentConfig.benchmark` is itself an `InstrumentConfig`; if its symbol duplicates a tradable instrument it must match that instrument's source/calendar exactly and is never re-downloaded, and it never contaminates the tradable universe's own timeline (an external 24/7 benchmark cannot inject diff --git a/docs/drift_compliance.md b/docs/drift_compliance.md new file mode 100644 index 0000000..9cf9f8b --- /dev/null +++ b/docs/drift_compliance.md @@ -0,0 +1,212 @@ +# Weight-drift mechanics and the compliance-restoration LP + +Detailed internal mechanism behind `portfolio.model_weight_drift` — see +[Weight drift](backtesting.md#weight-drift) for the conceptual summary, +invariants and disclosed limitations. This page is for contributors +modifying `quantlab.backtesting.accounting.apply_weight_drift` or +`quantlab.portfolio.drift_compliance.restore_drift_compliance`, not +general usage. + +## Per-column debt + +`apply_weight_drift` evolves the already shifted, executed weights forward +between genuine trades via a per-column dollar exposure and a single +shared relative equity `E`: + +``` +weight[i] = dollar[i] / E +``` + +Output is always a *pre-period* value — the weight held going into a row, +before that row's own return is applied — consistent with `executed_weights += held.shift(1)` elsewhere in the accounting module; returning the +post-period value instead would double-count that row's own return once +inside this recursion and again when it is multiplied by `asset_return` a +second time. + +Two independent kinds of per-column debt drive every row's output, in this +priority order: + +1. **Compliance debt** — a hard-risk-limit breach (`maximum_weight`/ + `maximum_gross_exposure`/`maximum_net_exposure`/`long_only`), corrected + via the minimal-L1-turnover linear program described below — never a + "clip and scale toward zero" heuristic, which can move exposure in the + wrong direction whenever some of the breaching exposure sits in a + currently-untradable column. A declared position group (e.g. + `pairs_trading`'s two legs, see `BaseStrategy.position_groups()`) is + corrected as one coherent unit via a single shared scaling factor, + never one leg moving alone. Re-solved fresh every row it is + outstanding, from that row's own current weights, never a stale stored + target — a column the LP leaves untouched (fixed/untradable, or simply + not needing to move) always reflects its own current, continued drift + when the correction lands, not a snapshot from whenever the breach was + first detected. Any improvement the LP finds is applied immediately, + whether or not it fully resolves the breach — a tradability-blocked + best-effort partial fix (recorded via `drift_compliance_pending`) still + lands, rather than being recomputed and silently discarded every day + while nothing improves. Never subject to `maximum_turnover` — a hard + risk-limit override, not an ordinary rebalance — and always takes + priority over ordinary debt below. +2. **Ordinary rebalance debt** — a fresh per-column decision (a column + anchors when its own value in the executed book changed since the + previous row — the same `EPSILON`-based "did a real trade happen" + convention the trade log itself uses — or `rebalance_date.loc[t, + column]` is `True`, a `dates x symbols` frame threaded down from + `engine.py`, itself gated by that column's own tradability; the second + condition catches a scheduled rebalance whose freshly-decided target + happens to numerically equal the immediately preceding one, otherwise + invisible to value-diffing alone), turnover-capped like a decision-level + rebalance when `PortfolioConfig.maximum_turnover` is set. A column's own + fresh decision replaces only that column's own debt, never any other + column's — an unrelated column's own outstanding debt, including one + belonging to a currently-closed instrument, survives untouched. All + columns with outstanding debt that are also currently tradable share + one combined per-row turnover budget; the unresolved remainder is + retried against a fresh budget each subsequent row. A column with debt + that is not currently tradable never trades — its debt simply waits, + exactly like a decision-level pending-due-to-closure debt — whether + that is its own fresh anchor or a multi-row catch-up already in + progress. A column compliance debt (1) already moved this row is + excluded from ordinary-debt eligibility this same row too — its landed + value already reflects the higher-priority correction; ordinary debt + resumes toward it again starting next row — which is what keeps the + two kinds of debt from fighting over the same cell within a single row. + +A closed asset's dollar exposure does not move (its return is `0` on a +synthetic closure bar), but its weight still drifts purely through `E`'s +own movement from every other tradable asset's real return — it is never +force-reset to a stale decision just because another column anchors or a +compliance correction lands the same row. Combining this row's own +just-decided/corrected columns with another column's frozen or +still-drifting value can itself create a new violation neither component +had alone (mixing is not a convex combination the way interpolating along +one line is) — unlike organic drift, which needs a one-row lag before +reacting to it (correcting it retroactively would be look-ahead), every +input to this combination is already known before the row's own output is +finalized, so it is checked and, where achievable, resolved in the same +row. A row where nothing was decided (pure drift) keeps the ordinary +one-row-lag detect-then-queue behavior: a breach found there is recorded +in `drift_compliance_pending` and only lands starting the next row, via +compliance debt (1) above. + +If `E` (this anchor-episode's own relative equity — gross, pre-cost, +distinct from the portfolio's absolute equity curve) falls to zero or +below, that episode's positions are force-flattened and a warning is +logged, mirroring `ruined`'s own "flatten and continue" handling rather +than aborting. + +Whenever the row-walk believes no compliance debt remains outstanding for +a row (no `drift_compliance_pending`), that row's landed weights are +re-verified against the same constraints one more time before being +finalized. A violation there is treated as an internal or numerical +failure and raises `BacktestError` — a numerical solver failure remains +possible in principle, so this is not asserted as strictly unreachable, +but it should never be a legitimate, expected runtime outcome (mirrors +`rebalancing._assert_holdings_compliant`'s identical "never trust the +invariant blindly" philosophy). + +`model_weight_drift=False` remains available as an optional constant- +weight compatibility mode (byte-identical to the step function described +in [Rebalancing & turnover](backtesting.md#rebalancing-turnover)), not +the recommended path. + +## Compliance-restoration LP + +`quantlab.portfolio.drift_compliance.restore_drift_compliance` is the +minimal-L1-turnover linear program compliance debt (above) uses. Internal, +low-level primitive: its sole caller, `apply_weight_drift`, is the +validated public entry point (frame shape/dtype/finiteness, tradable mask, +etc.) — this function only asserts array shapes match `columns`, trusting +`drifted`/`tradable_row` are otherwise already clean numeric/boolean data. + +**Free variables**: `w_i` for every independent (singleton-group) column, +and one shared scalar `k_g` per multi-column group with `w_i := k_g * +drifted_i` for every leg `i` of group `g` (a declared position group — +e.g. `pairs_trading`'s two legs — must move together, preserving its +current relative composition/hedge ratio exactly; `k_g` is bounded only by +`k_g >= 0`, never capped at `1`, since the L1 objective already penalizes +any movement away from `drifted` and capping would incorrectly exclude a +genuine minimal-L1 solution that requires growing a group). An untradable +independent column, or any group with at least one untradable leg, is +fixed at its drifted value (cannot move this row) — eligibility is the AND +of every member's own native tradability, mirroring +`quantlab.strategies.pairs_trading`'s own "both legs open" gate. + +**Sign/support-preserving bounds**: an independent column's own +free-variable bound never invents a position the drifted book does not +already hold — a currently-long column (`drifted_i > 0`) may shrink toward +0 or grow further long, a currently-short column (`drifted_i < 0`) may +shrink toward 0 or grow further short, and a column already exactly at 0 +is fixed there. `long_only` additionally clamps every tradable independent +column's lower bound to 0 (grouped legs are assumed already non-negative +under `long_only` by construction — every anchor this module drifts from +was itself validated compliant, and a positive quantity cannot cross zero +under any return greater than -100%, so `k_g >= 0` alone keeps a grouped +leg non-negative too). That assumption covers every ordinary case, but is +not itself enforced by anything for a fixed (untradable) column/group — an +explicit `w_i >= 0` (`k_g * drifted_i >= 0` for a group leg) inequality is +added for every independent column/group leg regardless of tradability, so +a fixed value that does turn out negative under `long_only` is correctly +reported as LP-infeasible (driving the tradability diagnosis below) +instead of the strict LP trivially "succeeding" over a value that still +violates `long_only`. + +**Objective**: `minimize sum_i |w_i - drifted_i|` subject to `|w_i| <= +maximum_weight`, `sum_i |w_i| <= maximum_gross_exposure` (or +`maximum_leverage`, whichever is tighter — the caller passes the +already-combined cap), `|sum_i w_i| <= maximum_net_exposure`, and the +sign-preserving bounds above. This is **not** solved by a "clip each +weight, then scale the tradable columns toward 0" heuristic — that is +provably wrong in general: e.g. a large untradable long position plus an +already-open, tradable short position breaching `maximum_net_exposure` on +the long side needs that short pushed more negative, not scaled toward 0 +(scaling toward 0 moves net exposure the wrong direction). The linear +program gets this right because `w_i` for that tradable short is free to +move further in its own direction (not constrained to move only toward +zero) — while still never opening a brand-new position on a column the +drifted book held at exactly zero. + +**Infeasibility handling**: if that LP is infeasible, first diagnoses +whether this is explained entirely by the fixed (untradable) columns'/ +groups' own values already violating a constraint on their own +(tradability-caused, expected) rather than a genuinely contradictory +constraint configuration (a bug — `_validate_target_row_compliant` should +already have rejected that at the target itself, before drift ever ran). +Tradability-caused infeasibility re-solves via an always-feasible, +two-stage lexicographic LP instead of requiring zero violation: stage 1 +minimizes the sum of four non-negative slacks (one added to each cap's own +right-hand side — `maximum_weight`, gross, net, `long_only`), finding the +smallest unavoidable violation; stage 2 then fixes those slacks at their +stage-1-optimal values and re-solves for the minimal-L1-deviation point +among every solution achieving that same minimal violation — so an +already-compliant, uninvolved free column is never moved (e.g. liquidated) +just because nothing in a single-stage "minimize slack only" objective +would have penalized doing so. The result is the best achievable +correction using only what is currently tradable, returned with +`pending=True`. A genuine misconfiguration instead raises `BacktestError`, +mirroring `_assert_holdings_compliant`'s "a violation here means a bug in +the algorithm" philosophy. + +**Two deliberate, disclosed scope limits**: + +- Only the hard risk limits above are enforced here — `target_minimum_ + weight`/`target_maximum_positions` are not, consistent with + `PortfolioConfig`'s own documented convention that these non-convex, + target-portfolio-only constraints may be temporarily violated by any + transitional weights (turnover-capped rebalancing already has this exact + property; an off-schedule drift correction is the same kind of + transitional state, not a new target). +- Sign/support-preservation means the strict LP can, in principle, be + infeasible in a case an unrestricted LP (free to open a brand-new + position) would still solve — in practice this never actually happens: + shrinking every free tradable column toward 0 always drives + `maximum_weight`/gross/net exposure back toward whatever the fixed + (untradable) columns alone already produce, so whenever the fixed + columns alone are compliant, a fully sign-preserving compliant point is + always reachable by shrinking alone — the strict LP never spuriously + fails for this reason. When the LP has multiple equally-minimal-L1-cost + optimal solutions, HiGHS returns some optimal vertex with no secondary + preference toward leaving more columns untouched — the returned row is + always a genuinely minimal-L1, fully compliant, sign-preserving + correction, but is not guaranteed to be the unique one that moves the + fewest columns among several equally-cheap alternatives. diff --git a/docs/index.md b/docs/index.md index f91a37c..d46e7a0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -5,7 +5,8 @@ QuantLab turns a financial hypothesis into a reproducible, bias-aware experiment: download data, clean and validate it, build features and signals, run a vectorised backtest — with a delayed-execution barrier that prevents common -look-ahead leakage — with realistic costs, measure performance and risk, +look-ahead leakage — with configurable transaction-cost assumptions, +measure performance and risk, validate out-of-sample, and generate an honest research report — all driven by one YAML config. Custom strategies remain responsible for their own causal feature and signal construction. @@ -21,6 +22,8 @@ feature and signal construction. - [Data pipeline](data_pipeline.md) — sources, canonical schema, cleaning, validation, storage. - [Strategies](strategies.md) — the strategy contract and how to add a new one. +- [Strategy Explorer](strategy_explorer.md) — the dashboard's interactive + research/education mode for understanding each strategy. - [Backtesting](backtesting.md) — the accounting model and look-ahead-bias prevention. - [Validation](validation.md) — walk-forward, sensitivity, bootstrap, stress diff --git a/docs/limitations.md b/docs/limitations.md index 22303e1..6e59d2a 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -2,8 +2,9 @@ QuantLab is a research and education platform. It is designed to make its own limitations visible rather than hide them — every generated report includes -this list automatically. Read this before drawing conclusions from any -result. +an automatically generated limitations section (see `research_summary. +STANDARD_LIMITATIONS`); this page provides the more complete, project-level +discussion. Read this before drawing conclusions from any result. ## Data @@ -15,47 +16,60 @@ result. them implicitly assume today's constituents existed throughout the period, which can overstate results. - **Single venue** for crypto data (Binance): no consolidated tape. -- **Per-instrument calendars are an approximation, not a live feed**: each +- **Multi-calendar support is an approximation, not a live feed.** Each instrument declares its own source and calendar (any name recognised by - `pandas_market_calendars`, or `24/7`), and a mixed-calendar portfolio (e.g. - US equities alongside crypto) is supported at daily frequency — closed + `pandas_market_calendars`, or `24/7`); a mixed-calendar portfolio (e.g. US + equities alongside crypto) is supported at daily frequency — closed sessions are detected per symbol, valued at the last known price with zero - return and zero volume, and never traded. Weekly/monthly bucket settlement - and Yahoo's daily timestamps resolve against each instrument's own - calendar rather than a UTC-midnight approximation — but weekly/monthly - **rebalancing** only does so when every instrument in the portfolio shares - one calendar; a genuinely mixed-calendar portfolio (e.g. equities - alongside crypto) still buckets rebalance dates against the raw UTC - boundary, the same documented approximation used elsewhere for a - mixed-calendar universe. This coverage is deliberately narrower than "fully - supported" for two reasons: it does not extend to every calendar detail - (e.g. an official intraday session break, like XHKG's lunch recess, is - handled for hourly cache-coverage and gap-detection checks but not modelled - anywhere else), and it has not been exercised against every calendar - `pandas_market_calendars` recognises — only the ones this project's own - test suite covers (XNYS, XHKG, XASX, XSAU, 24/7). But the calendar is - still a static, - best-effort schedule (holidays, weekends), not a real-time venue-status - feed, so an unscheduled closure (an exchange halt, an outage) is not - detected as a closure and instead falls under the ordinary - `missing_value_policy` handling for gaps. Intraday (`1h`) frequency does - not support mixed calendars at all yet and is rejected at config load — - verified-closure handling only operates at daily frequency. -- **Rolling-window features are diluted in a mixed-calendar universe**: a - verified closure's synthetic bar is exactly flat (zero return, zero - volume), but momentum lookbacks, volatility windows, ADV windows and - technical indicators all still count it as one more *period* — for a - session-bound instrument sharing a combined timeline with an always-open - one (e.g. equities alongside crypto), a "252-period" window therefore spans - *more* than 252 real trading sessions, and the flat bars pull volatility/ADV - estimates down. QuantLab warns about this at config load - (`DataConfig._warn_if_mixed_calendars_dilute_windowed_features`) but does - not correct it: doing so properly would mean computing every instrument's - features on its own native calendar before aligning signals, a - substantially larger redesign than today's shared-timeline architecture. - Prefer a single shared calendar per experiment when window-based estimates - need to be precise; treat mixed-calendar results as directionally - informative rather than exact until this is addressed. + return and zero volume, and never traded. + + *Handled on each instrument's own native calendar*: every built-in + strategy's own signal generation (momentum lookbacks, technical + indicators, every mean-reversion indicator), a pairs-trading spread's + hedge fit and indicator (computed on the intersection of both legs' own + native session dates), and `runner.py`'s ADV computation — each symbol is + sliced to its own verified native session rows before computing, then + reindexed/forward-filled back onto the combined timeline + (`quantlab.features.native_calendar.compute_native_then_align`), so a + session-bound instrument sharing a timeline with an always-open one is + never diluted by the always-open instrument's own extra sessions in what + actually gets traded. Weekly/monthly bucket settlement and Yahoo's daily + timestamps also resolve against each instrument's own calendar rather + than a UTC-midnight approximation. + + *Still computed on the combined, closure-padded timeline, not yet + native-calendar-aware*: the `inverse_volatility`/`volatility_targeting` + portfolio allocators' realized-volatility estimate, so a mixed-calendar + universe's allocator weights can still be diluted by the always-open + instrument's own extra sessions; weekly/monthly **rebalancing** buckets + against the raw UTC boundary whenever the portfolio's instruments do not + all share one calendar; and the Strategy Explorer's Results-tab + diagnostics, which recompute their own illustrative indicators + independently of the live strategy's signal path for `pairs_trading` + (hedge fit, spread, indicator, and rolling ADF p-value — the last calls + the same `periodic_stationarity_pvalues` function the live entry gate + uses, but on the combined timeline rather than the native intersection + the live gate itself feeds it, so calling the same function does not by + itself make the result match), `time_series_momentum`, + `cross_sectional_momentum` and `trend_following`. `mean_reversion`'s own + Results-tab diagnostic is the one exception, wired onto the same + native-calendar path the live strategy uses. The Strategy Explorer's + interactive labs additionally assume the XNYS calendar for any Yahoo or + CSV symbol (not detected/configurable there the way the main dashboard's + per-instrument table is), so a non-US instrument may not be represented + faithfully in a lab. + + *Not yet supported at all*: an official intraday session break (e.g. + XHKG's lunch recess) is handled for hourly cache-coverage/gap-detection + checks but not modelled anywhere else; coverage has only been exercised + against the calendars this project's own test suite covers (XNYS, XHKG, + XASX, XSAU, 24/7), not every calendar `pandas_market_calendars` + recognises; the calendar itself is a static, best-effort schedule + (holidays, weekends), not a real-time venue-status feed, so an + unscheduled closure (an exchange halt, an outage) is not detected as a + closure and instead falls under the ordinary `missing_value_policy` + handling for gaps; and intraday (`1h`) frequency does not support mixed + calendars at all and is rejected at config load. ## Execution @@ -73,9 +87,18 @@ result. ## Methodology -- **Rebalancing is a step function**: weights are held constant between - rebalance dates, ignoring intra-period drift from price moves — the - standard simplification for a vectorised backtest. +- **Weight drift between rebalances is modeled by default** + (`portfolio.model_weight_drift`, `True` by default): each asset's own + price move drifts its executed weight between real trades, rather than + holding it constant until the next scheduled rebalance, with hard + portfolio-level risk limits re-checked every row and restored via a + linear-programming projection (never a "clip and scale" heuristic) if + breached off-schedule — see [Weight drift](backtesting.md#weight-drift) + and [the compliance-restoration LP](drift_compliance.md) for the full + mechanism. `model_weight_drift=False` remains available as an optional + constant-weight compatibility mode, not the recommended path. + The compliance-restoration LP's own basis is gross/pre-cost, the same + disclosed convention already used by `stop_loss_pct`/`take_profit_pct`. - **Possible data-snooping**: trying many strategies, parameters or universes and reporting only the best one overstates expected performance. QuantLab's walk-forward and sensitivity tooling exists to mitigate this, but no diff --git a/docs/strategies.md b/docs/strategies.md index 7989624..16e9c42 100644 --- a/docs/strategies.md +++ b/docs/strategies.md @@ -21,9 +21,9 @@ misaligned axes, and finite values outside `[-1, 1]` are rejected. | `buy_and_hold` | `BuyAndHoldStrategy` | Constant long signal for every asset with a valid price; the allocator and rebalance cadence determine the actual portfolio. | | `time_series_momentum` | `TimeSeriesMomentumStrategy` | Per-asset trailing momentum; binary, continuous, or volatility-adjusted scaling. | | `cross_sectional_momentum` | `CrossSectionalMomentumStrategy` | Ranks assets by trailing momentum, longs the top fraction, and can short a disjoint bottom fraction. | -| `mean_reversion` | `MeanReversionStrategy` | Rolling z-score with a stateful entry, exit, and stop machine. | +| `mean_reversion` | `MeanReversionStrategy` | A stateful entry/exit/stop machine driven by a chosen `indicator` (rolling z-score, Bollinger %B, RSI, distance to moving average, or percentile rank). | | `trend_following` | `TrendFollowingStrategy` | Trailing MA-crossover direction; sizing belongs to the portfolio allocator. | -| `pairs_trading` | `PairsTradingStrategy` | Trailing regression residual, ADF-gated entries, and price-adjusted dollar hedge legs. | +| `pairs_trading` | `PairsTradingStrategy` | Trailing regression residual, ADF-gated entries, and legs sized by the relative dollar-notional hedge ratio implied by beta and current prices. | ## Signals and allocators @@ -33,9 +33,11 @@ An allocator decides how signal values become portfolio weights: - `signal_proportional` preserves relative signed magnitudes. - `inverse_volatility` and `volatility_targeting` add their own risk sizing. -Consequently, non-binary time-series momentum cannot use `equal_weight`, and -its `volatility_adjusted` mode cannot be combined with another inverse- -volatility allocator. Pairs trading requires `signal_proportional`; per-leg +Consequently, non-binary time-series or cross-sectional momentum signals +require an allocator that preserves signal magnitudes and cannot use +`equal_weight`; time-series momentum's `volatility_adjusted` mode +additionally cannot be combined with another inverse-volatility allocator. +Pairs trading requires `signal_proportional`; per-leg weight/minimum-size constraints that would distort or remove one hedge leg are rejected by configuration validation. A portfolio-level volatility target is still valid because it scales both pair legs together. @@ -45,6 +47,20 @@ The pairs regression estimates a share hedge ratio from the preceding the current prices. The ADF test is run periodically on the complete trailing formation-window residual and an inconclusive test prevents a new entry. +## Stop-loss / take-profit + +Every strategy except `buy_and_hold` accepts optional `stop_loss_pct`/ +`take_profit_pct` parameters (fractional, `None` by default). Unlike +`mean_reversion`'s `stop_threshold` (an indicator-level stop) or +`pairs_trading`'s own `stop_threshold` (a spread-indicator-level stop), +these operate on +the position actually executed by the allocator/constraints/rebalancing/ +execution pipeline, not on the strategy's own signal or indicator — see +[Backtesting: Stop-loss / take-profit](backtesting.md#stop-loss-take-profit) +for the exact mechanism, including how `pairs_trading`'s two legs are +force-flattened together on their combined P&L via +`BaseStrategy.position_groups()`. + ## Adding a new strategy ```python @@ -88,6 +104,12 @@ relations without constructing the strategy. Constructors should still validate direct Python use, and `_freeze_parameters()` prevents later public parameter mutation. +To also give it a dashboard [Strategy Explorer](strategy_explorer.md) page +(research content, an interactive lab, optionally its own Results-tab/report +diagnostics), see that document's "Adding Strategy Explorer support for a +new strategy" section — a separate, optional step from registering the +strategy itself. + ## Look-ahead safety Every built-in strategy uses trailing windows only. `forward_returns` exists diff --git a/docs/strategy_explorer.md b/docs/strategy_explorer.md new file mode 100644 index 0000000..f095148 --- /dev/null +++ b/docs/strategy_explorer.md @@ -0,0 +1,196 @@ +# Strategy Explorer + +The dashboard's **Strategies** mode is a research/education surface, separate +from Backtest/Walk-forward: a gallery of the built-in strategies, each +opening a detail page that explains how it works and lets you interact with +its own dedicated laboratory, picked via each lab's own "Data source" +control (see `shared_components.render_symbol_and_source_picker`): a +downloaded Yahoo or Binance symbol, a local CSV file, or QuantLab's +bundled, offline, synthetic demo dataset (not real historical prices, +used by default so a lab keeps working with zero setup). Switch source or +symbol, or turn off "Allow bundled synthetic demo data" once real local +files exist under `data/raw`, to research an actual instrument instead. + +The laboratories reuse QuantLab's `DataLoader` and `ExperimentConfig` +machinery, but construct a simplified experiment where every selected +symbol shares ONE calendar (unlike the main dashboard's own per-instrument +calendar table). A Yahoo symbol's calendar is auto-detected from its +suffix where possible (e.g. `.HK` -> `XHKG`) and editable; a CSV symbol +carries no such signal and defaults to `XNYS`, also editable. Selecting +symbols that would need more than one calendar is rejected with a clear +error, since a lab computes on a single shared price matrix and cannot +represent more than one calendar at once (see +[Limitations](limitations.md)). + +Launch it with `quantlab dashboard`, then switch the top **Mode** selector to +**Strategies**. The sidebar is hidden in this mode — everything happens on +the full-width gallery/detail pages instead. + +## What a strategy's page shows + +Each registered strategy gets a title and up to ten collapsible sections: +Overview, Economic intuition, Mathematical definition & signals, +Assumptions, Diagnostics, Parameters, Interactive laboratory, Interpretation, +Limitations & failure modes, and an optional References / Further reading +section at the end. The **Parameters** section documents every constructor +parameter the strategy accepts — including `price_type`/`periods_per_year` +where the runner injects them structurally — with what it is, where in the +signal pipeline it acts, why it exists, its default/typical range, the +effect of increasing/decreasing it, its trade-offs, and its interactions +with other parameters. + +The **Interactive laboratory** is strategy-specific, not a generic template: +it lets you pick real data, adjust several of the strategy's own parameters +with widgets, and immediately recomputes and re-plots the relevant +indicator/signal/spread/threshold — Streamlit's rerun-on-interaction model +is what makes a parameter's effect observable without a bespoke "impact" +widget (see `quantlab.dashboard.explorer.shared_components. +render_price_chart`). Coverage varies by strategy and by parameter -- not +every constructor parameter necessarily has its own interactive control +yet; the Parameters section's text is the documented reference regardless. +Every lab loads its price data through one shared, bounded cache +(`shared_components.load_explorer_prices_cached`, `max_entries=32`), and +each strategy's Interactive laboratory expander only actually runs its body +once opened (a stateful/lazy `st.expander`, not a plain one) -- visiting the +page, or interacting with any OTHER widget on it, does not re-trigger the +lab's own computation. + +## Architecture + +Two parallel registries mirror each other: + +- `quantlab.strategies.base` — the trading logic itself (`available_strategies()`, + `strategy_parameter_names()`). +- `quantlab.dashboard.explorer.profile` — the *pedagogical content* for each + strategy (`available_profiles()`, `get_profile()`), kept deliberately + separate so UI/content and trading logic never mix. + +The Strategy Explorer's own dispatch — the gallery/detail pages and the +optional Results-tab/report diagnostics described below — is profile-driven +and never names a specific strategy: it is built entirely from +`available_strategies()` + `get_profile()`, so a strategy without a +registered profile still gets a gallery card, with a "documentation coming +soon" placeholder, instead of silently disappearing. This does NOT mean +`app.py`/`cli.py` are strategy-name-free everywhere: the regular Backtest/ +Walk-forward sidebar still branches on a strategy's name to render its own +config widgets (unrelated to the Strategy Explorer); `reporting/ +html_report.py` is the one file that genuinely never names a strategy +(enforced by a dedicated regression test). Each strategy's own file under +`dashboard/explorer/profiles/` registers a `StrategyProfile` (markdown +fields, a `ParameterDoc` per parameter, and a `lab` callback) at import +time, the same pattern `register_strategy` already uses for trading logic +(see [Strategies](strategies.md#adding-a-new-strategy)). + +### Results-tab / report diagnostics (optional, per strategy) + +A strategy can optionally declare extra diagnostics that show up in the +regular Backtest Results tab and in the generated HTML report — every +built-in strategy except `buy_and_hold` currently does (its signal is +price availability alone, with no indicator/spread/ranking of its own to +diagnose). `pairs_trading` is the richest example, surfacing correlation/ +hedge-ratio/spread/cointegration diagnostics under a "Pair relationship +diagnostics" section; the others surface a diagnostic suited to their own +signal (e.g. `mean_reversion`'s centered indicator and state-signal +charts). This is declared once, per strategy, on its own profile, via +`StrategyProfile.results_diagnostics`: + +```python +ResultsDiagnostics( + key="pair_diagnostics", # robustness-dict / session_state key + compute=..., # (price_frame, ExperimentConfig) -> structured result + render=..., # (st, structured_result) -> None, for the Results tab + report_section=..., # structured_result -> DiagnosticsSection, for the HTML report +) +``` + +`app.py` (Backtest mode's Results tab and downloaded report, not +Walk-forward, see below) and `cli.py` (`backtest`/`report`, not +`walk-forward`) each just ask "does the current strategy's profile declare +`results_diagnostics`" and, if so, call `compute`/`render`/`report_section` +generically — neither contains a strategy name, and the HTML report +renders it under its own "Strategy diagnostics" heading, kept structurally +separate from "Robustness" (a correlation/spread/ADF diagnostic describes +whether the strategy's own assumptions hold, not whether the backtest +result is robust to cost/parameter/regime perturbation). A strategy +without `results_diagnostics` (the default) renders nothing — never an +empty section. If a diagnostic's own computation raises, that failure is +surfaced as a visible warning (Results tab and report) rather than the +section silently vanishing. + +**Not shown for Walk-forward**: each fold can select different strategy +parameters than the base config and covers only that fold's own slice of +history, so a diagnostic computed once, on the full history with the base +config's parameters, would not accurately describe what any individual +fold actually traded. Walk-forward mode shows an explanatory caption +instead, for a strategy whose profile declares `results_diagnostics`. + +## Reusable analytics layer + +The statistics themselves live under `quantlab.features.*`, independent of +Streamlit, and are reused identically by the dashboard, the HTML report, and +(for pairs trading) `notebooks/04_pairs_trading_research.ipynb`: + +- `features/stationarity.py` — `adf_test()`, `cointegration_test()`, + `hurst_exponent()`, returning structured `ADFResult`/`CointegrationResult` + dataclasses (statistic/p-value/critical values/H0-H1 verdict/plain-language + interpretation), never a bare float. +- `features/correlation.py` — `correlation_matrix()`, used directly by the + Pairs Trading lab's universe-screening step (not by the Results tab or + report, which instead get a single pair's `correlation`/ + `rolling_correlation` from `PairDiagnostics` below). +- `features/pairs_diagnostics.py` — `compute_pair_diagnostics()` centralizes + the pair diagnostics used by the lab, the Results tab and the report: a + pair's hedge ratio, spread, configured spread indicator (zscore/rsi/ + percentile via `indicator`, defaulting to zscore but not always it -- see + `PairDiagnostics.indicator`), half-life, and time-stability diagnostics. + Its `adf_result`/`cointegration_result` are exploratory (one test over + the whole sample). Its `rolling_adf_pvalue` calls the same underlying + `periodic_stationarity_pvalues()` function the live strategy's entry gate + uses (never a separately-computed approximation), but the diagnostic and + the live strategy can receive different timelines in a mixed-calendar + experiment (the live gate evaluates on the intersection of both legs' + native session dates; this diagnostic uses the full combined timeline), + so their results are not guaranteed to match exactly -- see + [Limitations](limitations.md). + +`dashboard/explorer/shared_components.py` holds the UI building blocks on +top of these (price/indicator charts, a correlation heatmap, a stationarity- +result card) — presentation only, no calculation, reused across every lab +and the Results-tab component in `dashboard/components.py`. + +## Adding Strategy Explorer support for a new strategy + +1. Register the strategy itself first (see + [Strategies](strategies.md#adding-a-new-strategy)). +2. Add `dashboard/explorer/profiles/.py`: write the markdown fields, a + `ParameterDoc` for every entry in `strategy_parameter_names("")` + (including any structurally-injected ones your strategy reads), a `lab` + callback, and call `register_profile(StrategyProfile(...))` at module + level. +3. Add `dashboard/explorer/labs/.py`: an interactive `render(st)` + loading data through the shared, bounded cache + (`shared_components.load_explorer_prices_cached`) and the strategy's own + `quantlab.features.*` functions — never a second implementation of the + strategy's math. Prefer calling the strategy class itself + (`quantlab.strategies..Strategy(...).generate_signals(data)`) + over reimplementing its state machine when a lab needs the strategy's + real signal (its own entry/exit/stop state machine's output -- still + upstream of the allocator, portfolio constraints, rebalancing schedule, + execution delay and accounting that together determine the actual + executed position), not just an intermediate indicator (see the Mean + Reversion lab's "State machine" section for an example). +4. Register the profile module in `dashboard/explorer/profiles/__init__.py`. +5. Only if the strategy needs its own Results-tab/report section: declare + `results_diagnostics` on the profile, following the pairs_trading example + above. +6. Add it to `tests/unit/test_dashboard_explorer_profiles.py`'s coverage + (parametrized over `available_strategies()`, so a new strategy is picked + up automatically) and, if it declares `results_diagnostics`, a Results-tab + assertion alongside the existing pairs_trading one in `test_dashboard.py`. + +For the Results-tab/report diagnostics mechanism specifically, no change to +`app.py`, `cli.py`, or `reporting/html_report.py` is needed for a strategy +that doesn't declare `results_diagnostics` (that machinery is entirely +generic). This does not extend to the rest of `app.py`: a genuinely new +strategy still needs its own sidebar config widgets added to the regular +Backtest/Walk-forward mode, alongside its Strategy Explorer profile. diff --git a/docs/validation.md b/docs/validation.md index 0b1e7b7..641732e 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -17,7 +17,8 @@ strategy and its parameters were fixed without consulting that block. ## Walk-forward validation `WalkForwardValidator.run(data, parameter_grid, train_window, -validation_window, test_window, expanding=True)` performs these steps: +validation_window, test_window, expanding=True, step=None)` performs these +steps: 1. Evaluate candidate parameters on each validation block -- each candidate is its own fresh backtest, restarted from cash on that block alone, not @@ -30,6 +31,17 @@ validation_window, test_window, expanding=True)` performs these steps: selection within a fold never sees that chained state. 5. Report the stitched OOS curve as `WalkForwardResult.oos_result`. +`step` controls how far each fold's train window advances relative to the +previous one. It defaults to `test_window` (contiguous, non-overlapping test +blocks -- the still-recommended default). A smaller `step` overlaps test +blocks for denser evaluation (more folds, more compute); on an overlapping +date the stitched OOS curve keeps the most recent fold's decision, and two +folds whose test blocks collapse onto the same first execution date are +rejected outright rather than silently misattributing observations. `step` +must not exceed `test_window`: a larger step would leave gaps in the +stitched OOS curve that CAGR/annualisation (which assume regularly spaced +observations) cannot account for, so this is rejected at run time too. + The Python API accepts an explicit `parameter_grid`. A YAML experiment can set the same candidates under `validation.parameter_grid`. The CLI and momentum research notebook both call `parameter_grid_for_config(config)`, which returns @@ -44,6 +56,7 @@ validation: train_window: 1000 validation_window: 252 test_window: 126 + step: 126 # optional; defaults to test_window optimization_metric: sharpe parameter_grid: lookback_period: [126, 189, 252] @@ -92,6 +105,12 @@ sampling retains dependence within each sampled block, but does not reproduce the complete time-series process. These are historical sampling estimates, not forecasts. +`BootstrapResult.summary(confidence_level=0.90)` reports each statistic's +median plus a `p_lower`/`p_upper` percentile band at the requested confidence +level (0.90 -> the 5th/95th percentiles, the default). Set +`robustness.bootstrap.confidence_level` in YAML to change it for a saved +experiment's own bootstrap run. + ## Random-sign test `monte_carlo_permutation` randomly flips the sign of per-period excess returns @@ -103,10 +122,31 @@ strategy is genuine, profitable or likely to work in the future. ## Stress tests -`run_stress_tests(data, config)` evaluates higher commissions and slippage, an -extra execution-delay period, removal of the ten best days, and—when the -universe is large enough—a reduced tradable universe. Expected scenario -failures are retained in the table instead of being silently omitted. +`run_stress_tests(data, config)` evaluates elevated commissions and +slippage, an extra execution-delay period, removal of the best days, and a +reduced tradable universe. Every scenario -- including one whose universe is +too small to leave at least 2 tradable symbols, or that fails for any other +reason -- keeps its own row in the table with `status="failed"` and an error +message, rather than being silently omitted or aborting the whole run. + +Every scenario's magnitude comes from `robustness.stress_test` in YAML, each +a list so more than one magnitude can be evaluated per scenario type (e.g. +`execution_delays: [1, 2, 5]` adds a scenario row per delay); an empty list +disables that scenario type entirely. `commission_multipliers`/ +`slippage_multipliers` must be strictly greater than 1.0 -- these model +elevated, adverse costs, not a cheaper-than-baseline scenario. The default +configuration evaluates: + +```yaml +robustness: + stress_test: + enabled: true + commission_multipliers: [2.0, 5.0] + slippage_multipliers: [2.0] + execution_delays: [1] + best_days_removed: [10] + reduce_universe_by: [1] # symbols dropped from the tail of the universe +``` ## What none of this proves diff --git a/mkdocs.yml b/mkdocs.yml index 9a1e42c..ede98bf 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -26,8 +26,10 @@ nav: - Architecture: architecture.md - Data pipeline: data_pipeline.md - Strategies: strategies.md + - Strategy Explorer: strategy_explorer.md - Python API: api.md - Backtesting: backtesting.md + - Drift compliance: drift_compliance.md - Validation: validation.md - Limitations: limitations.md diff --git a/notebooks/01_data_quality.ipynb b/notebooks/01_data_quality.ipynb index 6346ba6..784aa9f 100644 --- a/notebooks/01_data_quality.ipynb +++ b/notebooks/01_data_quality.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "40a6f9df", + "id": "294ff22c", "metadata": {}, "source": [ "# 01 — Data Quality\n", @@ -19,13 +19,13 @@ { "cell_type": "code", "execution_count": 1, - "id": "57a8a355", + "id": "c0583fac", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:00:15.680817Z", - "iopub.status.busy": "2026-08-25T17:00:15.680451Z", - "iopub.status.idle": "2026-08-25T17:00:17.783976Z", - "shell.execute_reply": "2026-08-25T17:00:17.783093Z" + "iopub.execute_input": "2026-09-02T10:35:07.472343Z", + "iopub.status.busy": "2026-09-02T10:35:07.472008Z", + "iopub.status.idle": "2026-09-02T10:35:10.106856Z", + "shell.execute_reply": "2026-09-02T10:35:10.105773Z" } }, "outputs": [ @@ -58,13 +58,13 @@ { "cell_type": "code", "execution_count": 2, - "id": "fe90d389", + "id": "5606f0ba", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:00:17.786816Z", - "iopub.status.busy": "2026-08-25T17:00:17.786432Z", - "iopub.status.idle": "2026-08-25T17:00:32.406673Z", - "shell.execute_reply": "2026-08-25T17:00:32.405717Z" + "iopub.execute_input": "2026-09-02T10:35:10.109129Z", + "iopub.status.busy": "2026-09-02T10:35:10.108834Z", + "iopub.status.idle": "2026-09-02T10:35:25.870343Z", + "shell.execute_reply": "2026-09-02T10:35:25.869439Z" } }, "outputs": [ @@ -85,7 +85,7 @@ }, { "cell_type": "markdown", - "id": "90b5bd90", + "id": "f79e6743", "metadata": {}, "source": [ "## Coverage per symbol" @@ -94,13 +94,13 @@ { "cell_type": "code", "execution_count": 3, - "id": "b06d871b", + "id": "7d1eb7e1", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:00:32.408736Z", - "iopub.status.busy": "2026-08-25T17:00:32.408421Z", - "iopub.status.idle": "2026-08-25T17:00:32.423814Z", - "shell.execute_reply": "2026-08-25T17:00:32.422875Z" + "iopub.execute_input": "2026-09-02T10:35:25.872344Z", + "iopub.status.busy": "2026-09-02T10:35:25.872128Z", + "iopub.status.idle": "2026-09-02T10:35:25.888207Z", + "shell.execute_reply": "2026-09-02T10:35:25.887482Z" } }, "outputs": [ @@ -213,7 +213,7 @@ }, { "cell_type": "markdown", - "id": "7b7b5d58", + "id": "b9575e9a", "metadata": {}, "source": [ "## Adjusted close price series" @@ -222,13 +222,13 @@ { "cell_type": "code", "execution_count": 4, - "id": "66c62244", + "id": "40dceada", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:00:32.425826Z", - "iopub.status.busy": "2026-08-25T17:00:32.425548Z", - "iopub.status.idle": "2026-08-25T17:00:32.799662Z", - "shell.execute_reply": "2026-08-25T17:00:32.798389Z" + "iopub.execute_input": "2026-09-02T10:35:25.890377Z", + "iopub.status.busy": "2026-09-02T10:35:25.890047Z", + "iopub.status.idle": "2026-09-02T10:35:26.241886Z", + "shell.execute_reply": "2026-09-02T10:35:26.240821Z" } }, "outputs": [ @@ -255,7 +255,7 @@ }, { "cell_type": "markdown", - "id": "d6cac569", + "id": "43c207d6", "metadata": {}, "source": [ "## Daily return summary statistics" @@ -264,13 +264,13 @@ { "cell_type": "code", "execution_count": 5, - "id": "819408fb", + "id": "cc68d844", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:00:32.802329Z", - "iopub.status.busy": "2026-08-25T17:00:32.801884Z", - "iopub.status.idle": "2026-08-25T17:00:32.903061Z", - "shell.execute_reply": "2026-08-25T17:00:32.902068Z" + "iopub.execute_input": "2026-09-02T10:35:26.243879Z", + "iopub.status.busy": "2026-09-02T10:35:26.243556Z", + "iopub.status.idle": "2026-09-02T10:35:26.363763Z", + "shell.execute_reply": "2026-09-02T10:35:26.362918Z" } }, "outputs": [ @@ -279,78 +279,78 @@ "text/html": [ "\n", - "\n", + "
\n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", - " \n", - " \n", - " \n", - " \n", - " \n", + " \n", + " \n", + " \n", + " \n", + " \n", " \n", " \n", "
 meanstdminmaxmeanstdminmax
EEM0.00030.0175-0.16170.2277EEM0.00030.0175-0.16170.2277
EFA0.00030.0138-0.11160.1589EFA0.00030.0138-0.11160.1589
GLD0.00040.0110-0.08780.1129GLD0.00040.0110-0.08780.1129
IWM0.00040.0157-0.13270.0915IWM0.00040.0157-0.13270.0915
QQQ0.00070.0142-0.11980.1216QQQ0.00070.0142-0.11980.1216
SPY0.00050.0126-0.10940.1452SPY0.00050.0126-0.10940.1452
TLT0.00010.0097-0.06670.0752TLT0.00010.0097-0.06670.0752
VNQ0.00040.0189-0.19510.1701VNQ0.00040.0189-0.19510.1701
\n" ], "text/plain": [ - "" + "" ] }, "execution_count": 5, @@ -365,7 +365,7 @@ }, { "cell_type": "markdown", - "id": "1a2df1ff", + "id": "8d23abf3", "metadata": {}, "source": [ "## Takeaways\n", @@ -397,9 +397,9 @@ "version": "3.13.7" }, "quantlab": { - "code_hash": "01f453a13e89b4a77bb5e85538cd9963479ca9a60745ac9d35d77a2a58d5c3ba", + "code_hash": "9a7df03157f6e44d966d928f188e54f4fe7167e529588986b31ffb50ae228cbb", "config_hashes": { - "configs/momentum_sp500.yaml": "e2be7ba15eca4cf73930a4a991a252549c7a921d2c09d50bedc7c5c8f6552a7e" + "configs/momentum_sp500.yaml": "6fcd3ba14c9d49484b9d135237db69a828672a4e126d6c57f106a38ec47cac7f" }, "generator": "scripts/build_notebooks.py" } diff --git a/notebooks/02_momentum_research.ipynb b/notebooks/02_momentum_research.ipynb index ccbfd6e..bac0ad6 100644 --- a/notebooks/02_momentum_research.ipynb +++ b/notebooks/02_momentum_research.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "c8f87181", + "id": "0ba05980", "metadata": {}, "source": [ "# 02 — Cross-Sectional Momentum Research (Example)\n", @@ -26,13 +26,13 @@ { "cell_type": "code", "execution_count": 1, - "id": "727bb76a", + "id": "5940801e", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:00:38.494118Z", - "iopub.status.busy": "2026-08-25T17:00:38.493845Z", - "iopub.status.idle": "2026-08-25T17:00:56.254000Z", - "shell.execute_reply": "2026-08-25T17:00:56.253108Z" + "iopub.execute_input": "2026-09-02T11:15:51.142740Z", + "iopub.status.busy": "2026-09-02T11:15:51.142307Z", + "iopub.status.idle": "2026-09-02T11:16:20.219097Z", + "shell.execute_reply": "2026-09-02T11:16:20.217940Z" } }, "outputs": [ @@ -62,7 +62,7 @@ }, { "cell_type": "markdown", - "id": "b8aa2a64", + "id": "a0f84d25", "metadata": {}, "source": [ "## Run the backtest" @@ -71,13 +71,13 @@ { "cell_type": "code", "execution_count": 2, - "id": "59e9a1ea", + "id": "e67c2419", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:00:56.256165Z", - "iopub.status.busy": "2026-08-25T17:00:56.255862Z", - "iopub.status.idle": "2026-08-25T17:01:14.120228Z", - "shell.execute_reply": "2026-08-25T17:01:14.119367Z" + "iopub.execute_input": "2026-09-02T11:16:20.222134Z", + "iopub.status.busy": "2026-09-02T11:16:20.221780Z", + "iopub.status.idle": "2026-09-02T11:17:00.538747Z", + "shell.execute_reply": "2026-09-02T11:17:00.537592Z" } }, "outputs": [ @@ -90,20 +90,20 @@ "Symbols : SPY, QQQ, IWM, EFA, EEM, TLT, GLD, VNQ\n", "Period : 2008-01-02 -> 2025-12-31\n", "------------------------------------------------\n", - "Total return : 172.95%\n", - "CAGR : 5.75%\n", - "Volatility (ann.) : 9.00%\n", - "Sharpe : 0.44\n", - "Sortino : 0.62\n", - "Calmar : 0.36\n", - "Max drawdown : -15.82%\n", - "Hit rate (non-zero periods): 54.55%\n", - "Total costs (currency units): 5938.76\n", - "Number of trades : 277\n", + "Total return : 163.60%\n", + "CAGR : 5.54%\n", + "Volatility (ann.) : 8.85%\n", + "Sharpe : 0.43\n", + "Sortino : 0.59\n", + "Calmar : 0.35\n", + "Max drawdown : -15.76%\n", + "Hit rate (non-zero periods): 54.41%\n", + "Total costs (currency units): 6082.80\n", + "Number of fills : 1514\n", "------------------------------------------------\n", - "Beta : 0.25\n", - "Alpha (ann.) : 1.40%\n", - "Information ratio : -0.39\n" + "Beta : 0.24\n", + "Alpha (ann.) : 1.27%\n", + "Information ratio : -0.40\n" ] } ], @@ -114,7 +114,7 @@ }, { "cell_type": "markdown", - "id": "8848883f", + "id": "a2bb5790", "metadata": {}, "source": [ "## Equity curve vs benchmark" @@ -123,19 +123,19 @@ { "cell_type": "code", "execution_count": 3, - "id": "0ccdfa2c", + "id": "836c8319", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:01:14.122002Z", - "iopub.status.busy": "2026-08-25T17:01:14.121801Z", - "iopub.status.idle": "2026-08-25T17:01:14.293194Z", - "shell.execute_reply": "2026-08-25T17:01:14.292370Z" + "iopub.execute_input": "2026-09-02T11:17:00.542088Z", + "iopub.status.busy": "2026-09-02T11:17:00.541622Z", + "iopub.status.idle": "2026-09-02T11:17:00.806369Z", + "shell.execute_reply": "2026-09-02T11:17:00.804947Z" } }, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA1oAAAHBCAYAAABwnMo3AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAqEJJREFUeJzs3Qd4U1UbB/B/96CDUSijZe+9kb33kiVDZYgCigPBhaICCjhARQURQUXFBYLAx56y996bsjdtGd35nvfcZjVpm5a0Tdr/73ny3HVy781N0t4355z3uOh0Oh2IiIiIiIjIblzttysiIiIiIiJioEVERERERJQBWKNFRERERERkZwy0iIiIiIiI7IyBFhERERERkZ0x0CIiIiIiIrIzBlpERERERER2xkCLiIiIiIjIzhhoERFlA3Xr1sWAAQPgKBztfMhxlStXDi+99BIcmTOcY3Y4Z6LshoEWEWWqCxcu4M0330Tt2rVRqFAhlCxZEs2bN8dHH32Eq1ev8t1IQdWqVTF48GCr2yIiIvDgwQOHuX6Odj7O+r7mBOHh4Xj48CEcmTOcY3Y4Z6Lsxj2rT4CIco758+erWo569eqpwEpuMOPi4rB582ZMmDABv/76K06ePJnVp+mwUgpedu3aBTc3t0w/J3p8DEqJiLInBlpElCn27duHp59+Gl26dMFff/0FV1djhXqxYsXQs2dPjBkzhu9GOvn7+/PaERERORA2HSSiTCE1WAkJCZg6dapZkKXn5eWFzz//3LD8/vvvIygoCNHR0XjrrbdQunRpVKpUybB99uzZaNCgAYKDg1GqVCk8//zzCAsLM9vnpk2b0LlzZ9U8sUSJEmp+w4YNaS6THFufu3LlSnTo0AFFihRRjyeffFIFnkmtWrVK7aNo0aJqn/369cOJEyfUtty5c6vXt2DBAjUvj8aNG6faJ8qW66TvyyG1iXJ8adIptY0//fSTxf46depkOH6+fPlQvnx59dxr164hPfTHPnz4MNq3b4/ChQujWbNmhutz8OBBde1kfZ06dbB27Vqr+0nL63zcY9nyftp6TVN6X+Pj49Wy1PZaex9at26dIa/PVNu2bVUzX2v69u2r3n/5Xj/ud0kcOnQI7dq1U9eqZs2a6j3N6Otvy3fPlC37s8f7YOv3TH+so0ePqh+x5LzGjRuX7DWeO3eu+rsqzbf17xsRZSAdEVEGi42N1eXKlUtXr149m58zatQonZubm27w4MG62bNn686ePav74osv1LaRI0eqbZ988olav3nzZl316tV1BQoU0J0/f16VOXLkiM7Ly0v3yiuv6E6ePKm7ePGibtmyZboWLVrobt26ZXOZ5Nj63K+//lrn6uqqe/vtt3VHjx7VHT9+XDd06FCdj4+PbufOnYZy8tpcXFx0L7/8sm7fvn3qdfzxxx+6J598Um2/e/euLjQ0VNe9e3c1L4+IiAjD88uVK6fr0aOH2Tnacp1EcHCwrmPHjrouXbrotmzZojt37pzu9ddf18m/iPXr15vtMzIy0nD8a9eu6VavXq2rVq2arlatWrqYmJgUz8caOXb79u3VseV6nDp1Ss0HBQXpDhw4oGvXrp1u+/btutOnT+u6deum8/X11d24cSPdr/Nxj2Xr+2nrNU3pfZXvjZR/7733LK5b06ZNLb5P9nh9Sc2cOVOdw9atW83WX79+Xefh4aF76623Hvu7JOct59a6dWt1reQ9HDdunPo+fPbZZxl6/W357qV1f/Z4H2z9numPJddu06ZN6n34/fffDdsGDBhgKPvhhx+qa/fpp5+m+H4Qkf0w0CKiDHflyhV1Q9K7d+80BVryHH1wpSc3cXJT9Oqrr5qtv3TpkrrR69+/v1qeNm2aev6dO3eSPYYtZR7nuZcvX9Z5enrqhgwZYrGtbt266iZUhIWFqZvWQYMGpXjMYsWKJXsNkwY2tl4n/Q2Z3OzJDZ1eXFycrkiRIrpevXrpUrN7926LG860BFpJbzQvXLig9le4cGHd1atXzc5d1k+ZMuWxXmd6j2Xr+5nWa5rc+5qeQOtxXp81EvTJjyTyg4cpCYDk+SdOnHjs75Kct1xXub6mnnnmGRVASbCRUdff1u9eWt7PjHgfkvueybHk/GU/1s5ZAq2oqChd37591TktWLAg1eMQkf2w6SARZThpAiWsNRlMTY8ePcyWV69eLT8QoU+fPmbrpQlRkyZNVLMifZMaIc1q9uzZo56TlC1lZJu+CY88pImerc9dtmwZYmJiVDMka02yNm7ciNjYWCxfvlxNBw0aBHux9Trp1a9fXzW705PEGlWqVMHp06fNykkztxdffBEVK1ZUTZDkmrRo0UJtO3XqVLrOVY6dP39+w7I035I+Z2XKlEHBggXNzj1Pnjxmx0nP60zvsWx9P9N6Te3pcV6fNfLcp556Cn///bdZIhZpMidNHMuWLWvz9yG185amdKbkuI8ePVJNEjPq+qflu5eW9/Nx34e0fM+eeOIJtR9rbt68qbK6ShNOuT7dunVL9XUSkf0w0CKiDFegQAF4enri0qVLaXqei4uLxc2X3DiIpOv1627cuKHmW7ZsiWnTpmHHjh2qj4nczMhNhml/CFvKSHpkSZOsf0iGOFufe+XKFTWV9XKzJH0t8ubNqx6TJ09WGRfv3r1rSGsfGhoKe7H1OpmuSyowMFCdn969e/fUDeTOnTvx9ddf48CBAzh//jz+++8/tV3606WHtWMHBAQku17Ow56v09Zj2fp+puWapldygczjvL7kSOr5yMhIFWyJrVu34tixY2Yp6W35PqRE+hYlt07/HmbE9U/Ldy8t7+fjvA9p/Z6FhIQke86S0XXbtm3o378/atWqleprJCL7YqBFRBlOgqymTZuqFOS23NjpSQ2Yu7t5clT5Zdf0BtuU3JDJDZ6e/Lp+9uxZnDlzRiXhkI7krVq1UrUgtpaZPn26upHSP+Q12Ppc/bkuWbJE/eot5aS8POSmUfYnv3rLDaNIb0IJa9JynVKqbTS9oV+6dKk6b7km8jrlV3Q5zuMGDskd25ZzstfrTMuxUns/07LPlMhn38fHx2pK/+Q+K4/z+pLTsGFDVKhQAT/++KNalqnUzvTq1cusnC3ft+RYe//06/TvYUZc/7R899JyDR/nfUjr90z+viZHgtLx48fj008/xXvvvZdsOSLKGAy0iChTjB49Wv0S++GHHyZ7oyE3Z6mRgE1/M2JKArgtW7YYtpuSLGKSkU//nDVr1thcxtfX16zpoLU06sk9V5ozSa3cunXrzPZh+pDtkj1OpvPmzUvxtcu5mDaNsvd1So3+2PIrvqk//vgDWSUjXmdybH0/0yql97V48eKq9siUPsjITFJ7JbUje/fuVcMzSMZBOW9rbPm+JSU1YUl/hJFmfRJsSjbJjLr+tn73MpO9v2eSwfXbb7/FpEmTVDDMbINEmYeBFhFlCukn8OWXX6p/+M899xyOHz9u6L8lTZHkhuebb75JdT/Vq1dXfTckFbwMgCw3JZcvX1b9NmReH8hJ0PbJJ5/g3Llz6sZC+nbIgMhCBky2tUxybHmu9FuR1PQff/yxagKkv5GUX89/+eUXvPLKK4Zyr7/+unr9X3zxhSonzaDk5nP48OGGY0qK5/3796smjPa6TmkhQYu3t7dKHy1NyaSmRc7ZnjVxaZURrzM5tr6faZXS+yp9h6RG6N9//1WfM0kv/vbbb6u+QZlJmp55eHigd+/euH//vlmzwcf9LunfxyFDhqimfPJcSUM+c+ZMvPzyy4Y+TRlx/W397mWmjPieyWuR9+OHH37As88+q14jEWU8BlpElGlee+01VcsgNw/SHEnGzpIaIrkpll/u5ZdyW8gNg+xLbqzkV3V5blRUlOrDUK1aNVVGf0MoAVyuXLnUr8M///wz5syZg+7du9tcJjm2PlduPmfNmqVuBKWZkjQFk74S0jl96NChhnJTpkxRN49SVvqcSHOpUaNGmTXPkhtMuV6yn6TjaKX3OqWFjI0kv/xLrYacnzRpkrGGJIDOSvZ+nSmx9f1Mi5TeV3ldMiaY1CDJsSTgkXG1ktZ2ZDRpkifjNEltWuXKlQ1JYfQe57sk9GOfSZAhz5cAa+TIkarvVUZff1u+e5kpo75nMmD8woUL1ZhtMvaYJBohoozlIqkHM/gYRERWyc2w/Eou2busbZOmhqndUMrNggRsKWU0lF/I5TgpNSuypczjPldq7+SXZDnflEhNjLwea9dFfzx53VJG35RRglcpn1xzrpSukyT4kCZaSZ8riUDkfKWzflLy3uhfh/wbkdoYeb6+v0hq55PasdO6/nFeZ3qPldL7mZ5rau191ZNaIvlc6I8lQY3w8/PLsNdn7T3XX18JcOz1XUp6HvLdlxqd1Nj7+qf03UvL/uz5PqT2PUvpucltkxoyeZ3y2UnaB5aI7IuBFhERERERkZ2x6SAREREREZGdMdAiIiIiIiKyMwZaREREREREdsZAi4iIiIiIyM4YaBEREREREdkZ83raQNLqXrlyRaXbTU/qZyIiIiIiyh5kuAUZxqRw4cIpDi/DQMsGEmSFhoba8/0hIiIiIiIndvHiRYSEhCS7nYGWDfQDR8rFTG6QQyIiIiIiyv4iIiJUJUzSweWTYqBlA31zQQmyGGgREREREZFLKl2KmAyDiIiIiIjIzhhoERERERER2RkDLSIiIiIiIjtjHy07poCPiYmx1+6I7M7T0zPFFKREREREZD8MtOxAAqxz586pYIvIUUmQVaJECRVwEREREVHGYqBlhwHLrl69Cjc3N5XmkTUG5MiDbstntWjRohx4m4iIiCiDMdB6THFxcXj48KEaGdrX19c+7wpRBsifP78KtuQz6+HhwWtMRERElIHYYeMxxcfHqymbY5Gj039G9Z9ZIiIiIso4DLQyacAyoqzGzygRERFR5mGgRUREREREZGcMtHI4SeZx584dNU3q3r17apu92Ht/RERERESOioFWDhUeHo7nn38efn5+KFOmDPLkyYN+/frh+PHjhjLvvPMOhgwZYrdj2nt/RERERESOioFWDvXSSy9h27ZtOHz4MG7fvq3Sfnfr1g1Lly5V2yMiIvDgwQM8evQIly5dUo/o6GjcvXtXPYRsv379upqPjIw0lJP5pJLbn2n68Vu3blmtWdO7f/++er5+f1Jev/7atWsW5SUbpGTZIyIiIiLKbEzvnkOtWbMGr7/+uhrAVvj4+KBXr16G7d9//z0WLlyoAqAnnnhCrfvzzz8xa9YsFZS5u7tj8+bNqFq1KjZt2oSffvoJn332maGJYKFChTBt2jS0adMmxf01bNgQEydOxJQpU9S62NhYDBo0CJ9//jm8vLwMAdPAgQPxzz//wN/fH5UqVUJISIiqlVuxYgUOHDiApk2bIiwsTKXZ1xs5cqSqoduwYUOmXVciIiIiIsEaLTuTGpmHMXFZ8kipNigpGVx5yZIlydb4vPnmm3jmmWfQoUMHQw1Uo0aN1LZVq1ahe/fuKqCSIEu8+uqrZjVab7zxBp566ilVW5bS/iZPnozffvsN27dvV/23Tp48qfY5fvx4w7l89NFH2LVrl9omxxw+fDj+/vtvw3YJ1sqWLYs5c+YY1knNlwRygwcPTse7SEREREQOIS4GmNkcWPEunA1rtOzsUWw8Kn6wElnh6Pi28PW07S2dMWMG+vTpo2qGpIaofv36ePLJJ1UglJpatWolG8BIjZQ06evYsSM+/PBDbN26FZ07d7ZaVgJDCbSkXEBAgKopk3VSe/Xtt99iwoQJqtzMmTPVfKlSpdSy9CWTGjJT0t9MXtPo0aPVstR+yb569Ohh0/UgIiIiIgeTkADcPAZc2as92nwEuLrBWbBGK4eqXbu2qiGSflrSVO/y5cvo1KmTClhSU7p0aYt10tercePGyJUrl2pOKM0DpTZLaq6Sc+PGDfUYN26cOp86deqgbt26qtmgflBdqeWSR5UqVcyeK8cw1b9/f1y4cMFQw/bjjz+qQNLX19fma0JEREREWejhHSA+1ri8/mPg+ybafIUuMioonAlrtOzMx8NN1Sxl1bHTwtXVFfXq1VMP6c8k/aSkyd/YsWNVTVdypH9WUlLL1KBBAyxfvlxlMtQ3T9QHTNa4uWnnO3v2bBXkWePp6ammMTExZutNE2mIoKAgdO3aVQVYcu7SL2vSpEkpvn4iIiIichARV4EvygM1BwBdvtbW7f7JuL10S7l5hTNhoGVnLi4uNjffy0oSAOkDHb3y5cubBTWSjCIuLs6mfR05cgRff/21Icg6ffq0qiUzlXR/EhxJ7di///5rEWhJ0gwJBGV/xYsXV00QmzdvbtguNXFFihQxe84LL7ygMidKqvqKFSuqAJKIiIiInMDF7dp07xwt0Aq/BDwyGX/VxQ2IfQR4+MBZOH5EQBlCmuhJlkFJSFGwYEGcOHFC1WZJrVTJkiVVGRlfa9GiRTh48CDy5s2L/PnzW92XBGw1atTAl19+iQIFCuDmzZsYMWKERTlr+5NMhZI0Izg4GL1790ZUVBTWrl2LM2fOqAyH4u2331YP6aMlTQhl/dGjRy1q3Vq1aqX2+dVXX6m+X0RERETkJKLCjfPxccBXJt1GfIO0QCy0HpC/LJwFA60cSjIOfvfddyp5hIxBJQGSBDqmAZIkpdi3b5/q6yTjVkkWPwmQrNVyyTbJLNilSxfky5cPQ4cOVev0NVzJ7U9qoCTVvDRb/Ouvv1QtV+vWrc0CpWHDhqkxuCQhhoeHB5o1a6b2JX23ktYmSl8taTL47LPPZti1IyIiIiI7K93KOL9jBqBLMC63Hg/UeNrpLrmLLi05wXMoCQoCAwPVuE2SHc+U1MCcO3dOjUfl7e2dZeeYnclHVIIoU5JsQwKuTz75xGy91I5JU0bJOkjm+FklIiIih6XTAQteAA7N05ZzFwXuhQG++YDGbwD1X4IzxAamWKNFDk8yGk6fPl1lR5R+XpLwYv/+/WbjZklzRRlrS/p7cYBiIiIiIicT8wBIMEmiVqUXEFwZOP4/YNcPDhVo2YqBFjk86ZdVs2ZNjBo1SqWMr1ChAnbs2IFy5coZysi4XtLPTGq4pJ8ZERERETmRteOAIwuMy5umAE3eAu7f0NK+OyEGWuQUJKOgPJKzePHiTD0fIiIiIrKjqAjLdZ6+wHltjFRnxECLiIiIiIiyxq1TwKnVQLS1QMuYVM0ZMdAiIiIiIqKs8W3t5Ld5+WvjZ7maj/3qLBhoERERERFR5kswSX6hV7kHkLcksPFzwDMX8O4Vp31nXLP6BIiIiIiIKIeJfQSMz2e5vuePQIGKWnp3n7yAh7f2cEIMtIiIiIiIKHO5SsO6JMP5dvkWiIsBbp8G2kwAijd06neFTQeJiIiIiChzuXloyS7cPIFHienb3b0AFxdg/QRteWy4U78rrNEihzBmzBiMHj0a2eG84uPj0b59ezWocmbR6XTo2LEjdu7cmWnHJCIiIkq3exeBmPvGIEvfZ0sCML3o+059gRlo5VDffPMNKleubHg0bNgQQ4cOxcmTJ7PkfC5duoSLFy/C0aTnvL7//ns8evQI1atXN6ybP38+unXrhrp166JHjx745ZdfEBcXZ/X9qFatGjp06IBvv/0W4eHhaN68Ob788kuL4/zzzz+oXbs2rly5AhcXF7X/V1999TFfMREREVEm+L6J5bqSzcyX42Oc+q1g08Ec6vr164iIiMCyZcvU8p07dzB58mQ0btwYJ06cQO7cubP6FJ1SQkKCuo6ffvqpYd2kSZPUQ9bpAyMZYHnfvn2GAMr0/ZAasT179uD111/H8ePH8corr6Bv375o0aKFCsLEtWvXVGD89ttvo3Dhwmpdv379MHLkSGzcuBFNmlj540VERETkKB6Z1GS5ewMlmgABhbTlIrWAy3u0rINOjIFWDubp6alqUPQKFSqEsmXLqiZvzZoZf1HYvn27CgjOnDmD0NBQPP/886qZmmnzOqmdKV68OBYtWoT79++jc+fOGDVqFNzcjOMe7NixA1999RVOnTqljvP++++jQoUKZs3fZsyYkew+5DjR0dEoUKAA1q5dq2p7evXqhZdfflmd35IlS9RrGjJkCPr06WPY78yZM/H111+r+cDAQNSsWVPtKzg42Ow1xMbGIiQkBPPmzYO/vz+WLl1qcc3CwsLw7LPPolOnTnjzzTcttv/333+4fPmyOnfTGq4RI0bgxRdfNKzr2rWrei3JvR8SUF24cAFTpkxRtV3yeuS4u3btgpeXF1544QVUrFhRXR89X19f1WRxzpw5DLSIiIjIcd06bZwfuAz4uQPgFWBcF1BEC7Sk/5YTY6BFBhK8yM1+mTJlDOtWrVqlbvLHjRunbuqPHTuG/v37Y/r06ejdu7ehed3vv/+ualTee+89VWMjwY6fnx9eeuklVWb58uWGpm0SGMlzJPDYsGGD4VgS4Hh4eCS7D3nO3LlzMWDAAHzwwQc4evSoqtX58ccfVWDx2WefqVqip59+GuXKlUONGjXU8+S4DRo0MNTcSZM8CSQPHToEd3d3s9fQs2dPTJw40SwI05PX3qZNGxXcvfHGG1Y/OfJ6JEjy9jamIZVmfdaaH0rAlJJ8+fIhKioKMTExKlCsWrWqCk7ltUlAd+DAAbi6mrf+rV+/PqZOncpPNRERETmu2IfatN4wIEBrmYPbp4zbjy3WppIYw4kx0Moo0+pZX9/ndyBfKeD2GeDPftbLDN+hTU+vAVa+Z7k9Twmg35/a/M4fgF2zjM9JA7n519eg3L17Fw8ePFB9iYoUKWIoIwGF3NxL8zUhfYyk7CeffGIItESpUqXw008/qaBCbN68WdUI6YMkCdIGDRqkgiG97t27m51PavsQEgT+8MMPqowET7/99puq2ZLAT8g62ceKFSsMgVb+/PnVQ0/KSK2YBCstW7Y0rJcmeNJ3Sh98mZIkE9JvSprzSSCYnHPnzhma8ulJ+WHDhqkgsHXr1ioYkuNK7Vpy5BpLzZRcbwnI5CGvq23btmpegqkSJUpYPE/eO6l1k+aHprWJRERERA6jUFVjRsGIq9q0YBVkNwy0cjCptfnzTy1gi4yMVDU60iRNApzSpUvj3r17qtZn2rRp6qZfmvbJQ/oS3bx502xfUoujD5CEBBvbtm0zBA1SGyR9l0xJ7ZWt+9CTWh3TMgULFlRNFk3Juhs3bhiW5bVJjZDUNsl6CUIkqJSgyJQ0KbQWZEmAJIGRnL/UoKVEap+kVtCUNLWU4E5q7LZs2YLvvvtOvQaZPvPMMxaBr5yfNBuUZBo///yzYbv00ZImiadPn8bgwYOtHl+CMOknJs0gGWgRERGRwwsoBLx9AfDyN657eQ8QdQ/OjoFWRkmthklqtVIrU7qV9khJ3Re0hx36aElNy8qVK1V/JwmuJHOekPTm9eqZ19CZBjvCWoAiQZmQ5m/6PkQpSWkfKZVJ7XnS1FGaIr7zzjsoWrSoCkbatWtnOC+95M5PmgFKUCh9r1IjQZ406UtK+lN9+OGHal6uqwRf8pDASV+zpQ98JUCSINNajVdAQIB6JEcCYElkYtp0kYiIiMhhPLoHbJgERFwG2n0CBIYAPkmSsAWVRnbAQIvMyE36rVu31Lw0r5Ob/atXr5oFZGklAYTsV2qGTJNsZAapHZJMftJHTGqE9DVcpjVeqZGEHdJcsVWrVirAlP5qyZGmftL8UAK9pMGono+PDwYOHKhqECV40wdUSQPf9Ni7d68KmImIiIgc0peVgZhIbb7tRGRnHEeLDDZt2qQyDjZt2lQtS82KJK+QJnOyTU8CJskeaPOHzNVVJb6Q/ln6QXwl457088po8hry5s2rmkMKaVL32muvqWlaSLPC1atXqyaI+popayTrn9SUmQ4cLMczHbxYMirOmjVLBbKmiUfsQc4xad83IiIiIocQ+8gYZInAUGRnrNHKwUyTYUgtj9RkSVBgmoZcggqpFZJEEJLyXAKkkiVLqrTjaTF+/HjVZE5qWyQxhQQb0pQvM0hfKKlBkuBGPwCwNCFMq1q1aqlARhJaSG3V2LFjLcrkyZNHNVWUTIj65pbymqXvm4xPJsGV1GJJfzRJ9JG0n9rjkP5fklVRsj8SEREROZRzm4A5nYzLJZs7fVbB1LjoknaCIQuS/EGad8lNetL+MVJ7IUkVJAOcM/WLkaZzps3nJI26jCFlrb+TPsmDZLMLCgqyGMxYAgf5GMnz9SRok+slmQRNPXz4UPWXKlasmFmQYcs+rJWRtOxSY2aa6U/OU5rhSX8pPQkQJbCU2i15yFhekj5d5pPbd3LrZbBgOTdpUmgt4YQMPlylShU15pW8Tj0JLmWbNKWU621K3gt5ranVcMn5yHthLeOgZCSUVPaS4dAaZ/2sEhERUTawbgKw0Zh9Gq0/Ahq+iuwWG5hioPWYF5M3r2SNBHtSAyg1XJlBgkEZV0yCv6Rja+nxs0pERERZZsf3wPK3tPkKXYAOkwF/y3FLs1OgxaaDRBkgPU0TH4c0ZaxUqVKmHpOIiIjIZjEPjPP1X3baICstGGgREREREVHGuHkSWPIq4JvPuM4tZ4QgzDpIREREREQZ48xaIGwbcO0QEFxFW/fwTo642mkOtCRznKTplvGQJHubjC+U1LZt21SKacnS1qdPHxw+fNjhyxARERERkZ1FhWvTexe0QYobvwGUapkjLnOaAi3J3NayZUs10Oobb7yhxkGS8YHmzZtnNsaSBGClS5dWYy1JdrVGjRrh/PnzDlvGHpi8kRwdP6NERESU6R7dNZm/A7R8XwZZzRFvRJqyDkpN1oQJE1RqbBkPSE8Gf9Wn6u7Ro4cay2f9+vVqWXZfvnx5NfbQt99+65BlHieziLz206dPq/TiUobIUcnnV1Lry48O9hy/i4iIiChZm6YAa8dr814BwOiLTn+xMiTroNRkPfnkk2ZBljC9aZOAZvTo0WbZ0GSw21WrVjlsmcch4075+vri5s2b6jokl1qbKCslJCSoz6h8VpMbK42IiIjI7hqPAu7fAHbMALxzVqVEmu64jh8/rvo4vfXWWyqAkYCrZ8+eGDhwoApgHjx4gLt376JQoUJmz5NlGSxWOFqZ5JpIysM0ak2OvG7ZnwwEe+HChWTLEWU1+RFA0s7LZ5aIiIgoQ906DSx+BQitCxz5V1v31JwcddFtDrSkyV1MTAw+/vhjjBw5UjW7kwFSX3/9dRVgjB07FnFxcaqsp6en2XO9vLxUEzvhaGWsmTRpEsaNG2frpVH7L1OmjLo+RI5KPqescSUiIqJM8W0tbRq21eRmxD9HXXybAy35FTxfvnyoUqUKxo/X2lnWq1cPYWFhmD59ugq0/P391c3c7du3zZ4ry0FBQWre0cpYI00NJZg0rdEKDQ1N8frIDay3t3eKZYiIiIiIcizPXMhJ0tShqE6dOirYMiUBy/3791WNlwQb1atXx44dO8zKbN26VaVWVwd0sDLWSI2XdGwzfRARERERUToUrqlNTQctzgHSFGi99NJLWLlyJQ4dOqSWJZvf7Nmz0a5dO0O/j6FDh2L+/PnYs2ePWpakExs2bMCQIUMM+3G0MkREREREZEf5ywPFGwNjw4HBq4DRlwCPnNX6K03JMDp16qSaCMo4VFKzde3aNbRq1QozZswwlHnuuedw4sQJNGzYUJWRZBTS50ky/TlqGSIiIiIisqPhJi3K3Dy0Rw6TpnG09KKionDp0iUULFhQDQBsTWRkpArEihQpolJKO0OZx82VT0RERERE2ZutsUG6Aq2choEWEREREZGNji8F9vwMtBoHBFfMsbEBR9clIiIiIiL7uXUKOLUKiH2Uo68qAy0iIiIiIrKUkACsfA+4tDttVyfmvjb1st7FKKdgoEVERERERJZksOFt3wKzWqbt6sQ80KaeDLSIiIiIiIjMbf1Wm/rkSduViY7IkQMUP1Z6dyIiIiIiysbiYoATy4CgMkD4JW1dj1lp20dUYqDl5Y+cjIEWEREREVFOdXkPcPY/oNHrgIsL8PA2MG+AcXuRWkDpVrbv78d2QK4goPV4wNUNORkDLSIiIiKinOj4MuDPvtp8xa5AvlLGZn96gaG27+/qASBsmzbfaSpyOibDICIiIiLKiW6fMs6v+RC4fUar0TJ19F/g99627c+/kHH+/jXkdAy0iIiIiIhyoiP/GuePLQHmDwLunLUs9+iebfvzK2Cc/9uk+WEOxUCLiIiIiCinCb8MXNmbZN0lrVbLVP7yxqQYqZkYYpy/ew45HQMtIiIiIqKcIiEe+LkTMP0J47pciTVRwZW1BBZ5imvLgUWBIrWBiEvAg1vJ73P7d8DM5kBMpHFdmwnI6ZgMg4iIiIgop7h5HDi/SZtvPgao0Bn4rbu2HBcFtBijPeLjtCyEy9/Stl0/DJRsZn2fK94B/IKNyx/cyfEZBwVrtIiIiIiIcopHd43z1foABcoDEZe15Ys7gGVvApu+ANzctWApNkrbFrYj+X26ewP3rwMurkC7TxlkJWKgRURERESUU+QrDfjk1ea/qmy5fedMYO0443L94dpUl2B9f3HRWk2YvkzdF+x+ys6KgRYRERERUU7hXxB46yyQtyRQoJK2ztUj+fJxj4xp3q1JmpFQXztGDLSIiIiIiHKciCtAQGFtvkxr8236ZBjizjlj3y5rwi+aL+cuatfTdGas0SIiIiIiykljZ43LrTX3c/M0Nv8zJYkw9G6fTnl/kgTDPzFgq/ikvc/WqTHQIiIiIiLK7qIitIdpUOWfmCnwzFrzsvEmZSr3SHm/uUOBRq8n7q+Q3U43O2B6dyIiIiKi7O6TUAAuQNdvteV8ZYDW47X5F7cBUeFaMosdM7SU73pBZYzzkde0Pl5JhdbVpr6JSTZIYaBFRERERJSd6XT6GWBRYhbBDp8BXv7afHBFY9niDZPfT4JJk0K940uB+YO1+dtn7HbK2QGbDhIRERERZWfxsZbr9P2z0kLG1Dq12nzdg5taZsLOU4EuX6f/HLMhBlpERERERNmZDDxcqXuSdSmkdE+qSi9tOrcHMLcncGadcZt+QOOgsoC7lz3ONttg00EiIiIiouxs/+/AkQVaMBQYCjR/D8hf1vbn3zyhTe+eN/bVSjrOFoMsC6zRIiIiIiLKriTL4Pbp2nz7z4BnFwAhtYz9s2yhzyqo5xVgWaPl7mOPs81WGGgREREREWVXa8cDN45q824ewJLXgM1fmY+VlRoPX5MFF6BcB8saLQ9vO51w9sGmg0RERERE2TXb4LbEdO7CwwfY87M2X7k7kLuobfvxTAy02n8OVOsNuJrU1VTtDRSsqg1cTGZYo0VERERElB2tfNd82dOkuaB37rTXaC1/E/iiErDFJLtgcCWgSk/AM9fjnm22wxotIiIiIqLs6Pxm82XvQKDik0CeYoC3ST+r1EhNmF5MJLD6faB6P+DcRmD+IG392HA7nXT2wUCLiIiIiCg7KtkUuHYQ6DUH8M0H+AcDT81J+37ylba+/vKexz7F7IyBFhERERFRdh6oOLQuEFA4/fuxmrrdBUiIT/8+cwD20SIiIiIiclYnVwJ/Pg3cPKktx8UAYdu1RBjFGwN9/gB88j7eMRISLNfpEoCY+9q8m+fj7T+bYo0WEREREZEzkjGsfn9Km683TBuEeO0480yDnac+fup1nbWaKx0Q80CbHZU4oDGZYaBFREREROSMrh4wzscmjmdlGmSJBzcf/zgy/pZe3lJAkzcBvwJajZa7N+D7mDVm2RQDLSIiIiIiZ5S3hHE+9qH1Mp5+9j3mq3uN8xW7AoWq2Xf/2QgDLSIiIiIiZySZBPVunwLioi3L2Gt8q+E7gagI4PJe4OZxLciq8Yx99p1NMRkGEREREZEzunEUqPOCNr/uYyD8kjYfXNlysOHHlb8cEFoHOPwP8O+LwD/Pa320JOkGWcVAi4iIiIjIGS0ZAez6wbgcdQ+oOwSo1ifjmg66uGjTE8uAiYUt+4SRAZsOEhERERE5o8irQIFKwI0j2vIPLYCx4UDkNeDwAqBUC6BYffse0yVJPY0kwyCrGGgRERERETkbabIXFa416ZPHkQXGbf4FgSHrM+a4SQMt04yEZIZNB4mIiIiInMmh+cC43Fp6de/cQIsxxm0/tgMubAPuXgDunLU+2PBjSWw6qOfKepvk8MoQERERETmTteON8w9vazVbemHbtABsalVt+b3rgKsdm/cFFDZfdmWNVnJYo0VERERE5EwCQ4zzHT4HTq/V5qsmJsFw9zJu97BzH6q6LwBBZY3Lrm723X82whotIiIiIiJn6pslTQIlCcZLW7V16z/WprI+MxJU3DqpTWsOAMq2zdhjOTEGWkREREREziL2EZAQBxSqZrnt0k5t6uYJFG0A+Afb//hnTJJsePlrD3r8QCsyMhLh4SZtQKW20NUVhQsnaasJIC4uDnfv3kW+fPlUGWscrQwRERERkUPz9AXePG2+rv1nwPK3jMtSo/Xc8ow5/qVdxvkbx4CoCMA7IGOO5eTSFHFMnToVpUqVwhNPPGF4tG7d2qLcpEmTkDdvXpQoUQL58+fHDz/84PBliIiIiIicUr2hQLV+2nybj7X07hlFP2CxOLMWuLw7447l5NJctVOjRg1cunTJ8DhyJHGAtER//PEHxo8fj3///Rf379/Hd999h2HDhmH9+vUOW4aIiIiIyCksGg58VVUblNiUu6c2rf404JM7A08gaXp3Zh1MTrra0N27dw+PHj2yum3atGno1q0bWrRooZafeuopNGrUCNOnT3fYMkRERERETmHfb8C9C4BLkmx/+gQY98Iy9vhJByzmOFr2C7R27dqF0NBQBAYGolq1ali3bp1hW0JCAnbv3o2GDRuaPadx48bYuXOnQ5YhIiIiInKajIMSYMkgxX75zbfpU7rPG5Cx52DadFC4sUbLLoFWmTJlsGHDBkRERKikGC1btkTHjh1x7NgxQ7KM6OhoBAUFmT1Plm/evOmQZayR58hrNH0QEREREWUJSTgxNhAYlxvQxQOFa1iWqTVIm+YumrHnUqCi+TJrtOwTaPXu3VvVBrm4uMDHxweTJ09GwYIFMWfOHG1nidn8JMOfqdjYWLi5uTlkGWskeYbU2OkfUoNHRERERJQlIq6YL3vmsiyTp7g29Q7M2HORcbPGmmQhZ6CVrMfKcy6BTPHixXH+/Hm17O/vj4CAAFy7Zt457/r16yhSpIhDlrFm9OjRqsZO/7h48WKarw0RERERkV3oEsyXy3eyLBOdyS2wQuoCfgWBgpUz97jZNdCSPk+mJIvf4cOHUbJkScO6Jk2aYPXq1WblVqxYodY7apmkvLy8VIBm+iAiIiIiyhJxUcZ533xAmTaWZXZ8r00v78vYczm+TGvGKIMjR5mPr0uPMWCx9Ml64YUXVIr3W7duYdy4cdDpdHjxxRcNZd59910VxEycOBGdO3fGzz//jHPnzuGff/5x2DJERERERA6R7GL6E0CZ1tp4WHox97Vp+8+BekNSHkg44lLGnqNpVsO4R8DDO4Bv3ow9Zk6o0ZKBfteuXYtevXph1KhRKF++PA4dOmTWh6l+/fpYunSpqkV68skn1Thb8pxy5co5bBkiIiIioiwnzf9uHge2fqPVGukVqg4MWgGU75j8c70SW2CVapm56d0f3s7Y4zkxF51USVGKJOugJMWQ/lpsRkhEREREGeLuBWBqVfMAa+h/wIPbQK58KT935w/AsjeAthOB+sMz7g3SH0d0mAzUfQE5TYSNsUGamg4SEREREVEGuXXSfPnqfiAhHvitGzBwKeDln/xz6zyvBWYhtTNvHK0cGGRlWtZBIiIiIiKyk7k9tWnuYuap3a8e0JoTphYAhdaxHFA4o5sOUrJYo0VERERElNWiI43zHj7aNLAocPecNu/jIAknitQG3H0ytnliNsFAi4iIiIgoq60YbZzvvwg4sw6o3g/4d7j5gMRZrVBVYIz5GLVkHQMtIiIiIqKstu9X47x3oDZ21o/tgbCtxnXkVNjIkoiIiIgoKx2cZ5wfcUhrOhh+2RhkCQ9vOIS4aC31vGn6ebKKgRYRERERUVZa8LxxPndRbepXwLyMbxAcgqtHVp+B02CgRURERETkCJq9a5zPld98W+5QOARXhg+2Yh8tIiIiIqKsci9Mm5ZsBjR727g+aY2WjKfl6gaH4O4NlGuf1Wfh8BhoERERERFlhUt7gFkttPln/zXflitJoBUf6ziB1nvXMn68rmyAdX9ERERERFnhxDLzQMpUQGHj/LDNjpMMQzDIsgkDLSIiIiKirBBz3zi/6wfzbV5+QI/ZQNO3gYJVMv3U6PGx6SARERERUVaICjfO+wVbbq/SM1NPh+yLgRYRERERUWaLeQhU7wfU7A+E1AXceFue3bDpIBERERFRZjv0NzCnM3B+C4OsbIqBFhERERFRZlvymja9fZrXPptioEVERERElJl0OuN8riBe+2yKgRYRERERUWa6f904H1yJ1z6bYqBFRERERJSZ7p43zrs70PhYZFcMtIiIiIiIMlNoPaDR69p8fAyvfTbFPJJERERERJnJxQVoNhoIKgdU6cVrn00x0CIiIiIiykxn/wPiooDqfXndszEGWkREREREmZFpUGqyIq8Dv3TR1o0N53XPxthHi4iIiIgoo51cCcxqDVzezWudQzDQIiIiIiLK6NqsQ/OASzuBG8d4rXMIBlpERERERBlJgqvD87X5dR9p07wlec2zOQZaREREREQZ6e/+5ss+eYCX2YQwu2OgRURERESUkfKVNl/2zQe4uvGaZ3MMtIiIiIiIMpJvXm06dCNQtj3Q53de7xyAgRYRERER2S4+Flj8KnD/Bq+araIjtWlwFaDfn0D+crx2OQADLSIiIiKy3cbJwN45wIzGvGq2avIm0PcvwJW33jkJBywmIiIiItvFPtSm96/xqtmqUFXtQTkKw2oiIiIist2Dm8b5izt55VJzajVwfCkQG8VrlcMw0CIiIiIi28Q8BKLCjcuP7vLKpWZuT+DPfsDO73mtchgGWkRERERkXVw0cOAv4OsawLH/ARMLASeWAT1/1LZHXs05V05qpHS69D/fhenccxoGWkRERERk3YxGwMIhwJ2zwF9Pm9xBemjTJa/lnFqteQOBcbmBB7dsD1LHBhqXvU3mKUdgoEVEREREluJigFsnrV+Zu+eM8+GXcsbVO7lcm17db1v5qweN891nAdX7Zcx5kcNioEVERERElh4kjpNVqgXQdpL5tv2/AwWraPMRyTQfPDgPmBQKPLzj/Ff36CLj/G89tOmt08DdC8k/5/ohbdptJlC1F+DKpoM5DQMtIiIiIrIUeV2blmgCrBxtvs03CGjxQcpp3hc8D0RHAGfWOf/V/bu/+fLJlcC3tYCpKaRsv7hLm5ZulbHnRg6LgRYRERERWYq5r039Clpuyx0K+Ceuj7wGxMcCMQ/My5TvpE2v7HP+TItJrRpjnJd+WMeXWXlepGTAAHzzZuz5kcNioEVERERElqQmq49JE0FT7t7GQOv+deDXbsDEwkBCvGWgte1b5766Ufe0afVnjOuS9l0L22r5PGlyWed5wMUlg0+QHBUDLSIiIiKydGwxkK80ULAy0O9v822H5gG58gNvnwdKNgfOb9LWx8cYy7h7Ov9VlZq6TV9o84FFgOG7AL9gy3Jbv7FcV/s5oOPkjD9HclgMtIiIiIjI3M2TWr+kaXW1ZReTW8YqTwH9/tJqanzyaDVaeqaB1sJhzn9VjywEdv2gzXsFAPnLAvWGmdfsWRMfByx/G7h7PnPOkxwSAy0iIiKinOxeGHDzhPm6RcPNl+OijPM9fgCKN9Lmz6wHlo40bnP30aYysG9CnDbvnRtOS/qf6ZVqrk29/I3r6g8HKnbV5k2bTUrTwh0zgDmdM+tMyQEx0CIiIiLKqSSBxVdVtJqr+4np3E1rpt65aAycrFk41Djf5C1jc0FJpKFL0OZjH8EpLXwRWP2+Nv/0P0BwJcvAsWZ/wNPfeC2jIoD7N4Ere7V1xRpm9llTdgi0bty4gRkzZmD16tUW2xISErBu3Tr89NNP2Lx5s9XnO1oZIiIiohznROIgvPqxsfTcPLW+SN4B2nLJpkDuokCvOebPd/cyzt88Ljdd2vzeX4zr46O1caisZe9z5L5ZEZeNy9JkUE/GxJImk/qMjF5+xuDyq8rA5NLGTIvXEsfSohwpXYGWTqdD//79MXLkSHz//fdm2x4+fIhmzZph4MCBWLFiBXr27Iknn3wScXFxDluGiIiIKEcy7UMkSR/0tU9tJwJdpxu3eQcCIw4BlZ40f76+qaA+eYY++97Kd83LSX+vhUPgNK4eAM79Z1z2L2S+/c2zwIf3AA9vIKgMkK+MNl5YVLi2XYJSEWQSoFGOk65A6/PPP4ebmxtatGhhse2zzz7DmTNnsHfvXvz111/Ytm0b1q5di9mzZztsGSIiIqIcJ2w7sH+uFkRUfBKIDgf+elbbFloHKGPDQLsSaJia00WbFm+sTRuZ9N+6tAdO49Fd47wkv3DzMN/u6mpM2161D/Dgpnm/tvzlgWp9gc5fZdIJU7YItHbu3Imvv/5aNcOz5s8//0Tv3r0RFBSklkuUKIGOHTuq9Y5ahoiIiChHkZqrH9sCd84CIbWBo/9q60+vBq4eBO5dtK2pX9KsezqThBDSl6mGydhTrcfDaZj2VyvWIOWykvRDP9aWeG4V8HtvrSmh1ARSjpWmQCsiIgJ9+/ZVfbMKFChgsT02NhanTp1CxYoVzdbL8pEjRxyyjDXR0dHqtZo+iIiIiLKFuBhgyWvG5fwVzGuezm/W+hodSjJ2ljXSRM43CKg10Hy9JNOQIMyvgPX+XI7u9iltOnynMaugLcFmcBWgUDWJOAEP34w9R8pegdaQIUPQrl07dOqUONJ3Evfv31eJJ3LnNk/jmSdPHkOw4mhlrJk0aRICAwMNj9DQ0FSvDREREZFTBFkf5wcO/mVcd/8a8MSLxmV9MzlbAoUW7wONRgDlOhjXjQ3Ugqpi9c1ToUsNj7O4fVobOyxPidTLmgaQ1w8BE4JTHmOLcgybA63169dj4cKFKF26tKrRkkdYWBjOnj2r5iMjI+Hjo3WIlHlTEtT4+mpfVkcrY83o0aMRHh5ueFy8mJjalIiIiMiZXT9suS5vKa3mqUJi/6p9v2pTD5NEF8mRpoerxgAXtgAlmhjXS4DS62dt/t0r2vTcJjgNaTrpX9iYrj4l0ldLf+1M2XL9KFtzt7Vg3rx5MWjQIJw4YRzQToIQd3d37N+/H7169YK/vz9CQkJw7tw5s+fKsgRowtvb26HKWOPl5aUeRERERNnG5T3AhW3G5b5/Ag9va0kbhL6fkWTcS5pRMDleienft0wFyrY3rjftsySp4kVCrO3nKuNRXdgKlGuHTLf3V22A5i5f2/6cJm9qWRdNMdDK8Wyu0apWrZqhJkv/qFKlCmrUqKHm8+XLp8p16dIF8+fPR0xMjKH2aPHixWq9nqOVISIiIsr2fmgBrHpPm+82EyjXXktW4eqmrTu3MeWMgtaYNg3s9KVxXsbN2pIYqLgm/q4vwYsM6muLn9oDf/QGrh9Fpgu/pI0JljSle0p880rVFlCymck67d6Yci4XnQyKlU7SV0tqjSSQ0bt69Srq1auHkiVLom3btliwYIEay0rSqgcEBDhkmdRIcCZ9taQGz9bnEBERETkMud0bl9hnvUxboMPnQJ5i5mVOrwV+667NS/+k929racxTy144oSBQ5wWg42RtAOQ/+mjbijUEBi0z9tvSk/26pdKoSl9+xGEgdyb3lV/8ijbg8msHgDzF0/bc2Chg/cdAxW5ASK2MOkPKYrbGBukaR0uvQ4cOaNOmjdm6QoUKYd++fejcuTOuXbuGAQMGYMeOHWYn4WhliIiIiHIMScGeNMgSpVsa5594KfUgS988bmy4FmQ9vKOte+oX8yaDScVHp75fNy+gUHXAJw8y3dkN2jQ9WQOlFlBSuh9bZPfTohxWo5VTsEaLiIiInNqDW8ChecCKd4CCVYFhySSmGCu1XjrgmX+A0jYMWGzqxAqtuZ9esUbAoKWWNVrvhKU+vpS+vIy91dAkFX1GO7YE+Ctx7K/Rl8ybRtpKf+6jTgD+Be17fuRUsYHNyTCIiIiIyAlJv6jvGgKxiQMQXzuoDchrOsaV3th7QOR1wD8xRXlayMC9Zsc1yfocEAJEXNLm41NJipFgMuhxVDgyJQiV/mtPzwMu7tDW1R2SviDLlCQaYaCVoz1W00EiIiIicmAJCcCu2dpYWfVfNq7XJ6iwJj1Blj7NuSldgnHetAmgDGacmg6TtemmKVpQ+DgOzQc2fm4ewOmt/Qj4vBRw74I2iPPWb7T1AYXx2NKSTIOyJQZaRERERNmVNBVc/b42L0kl+v2t1dZkRN+nks2Bog2My6bz+syG+v5XKZGytQaZDx78OP2t/hkMrPsYmFrNfNvtM8CmxIBOhJmkvo+8lv5jPjkDqPJU1vQvI4fCQIuIiIgou9r5vXE+qBxQNjHjYNLaJ3vw9AWeW249uYbUGOnlygesm6D1ZZJU6taCoyt7jcuPE7CcWWecD79onJfmi9/UTP55pVqk/5jV+wI9fsiYa0xOhYEWERERUXbpi7VmrJb978Ft4Kuq5tsL18jc85GgTq9aP+P8o7vAxs+0+S8rWTZ1/KUrMLu1fc4hoIj1gO3WSZMyIcb50CeA59eanztROjHQIiIiIsoONnwCbP4S+KwE8HlJ81okr0Db0rXbQ8cpQOM3zNfVNmkKeDxxbC1rKeAlM2JSkqBi0xfpOxfTBB1dpxnno+8b51t9aJxvMQYIqZ2+YxElwayDRERERM5ORuu5sNVy/ct7gHvngTwlMu9c6jxvuS4u2rxGK7nEGNcPGee7z9Iy90mSCn2wZq0ZoexbUrLXHgyUa2e+TZoo6hWoCMTFaH3AQuoAoy9r8xd3Gsvkym/zyyRKDQMtIiIiIme3fy5webfl+qDS2iOrFawMVO4JHJ4PHP+fcX2RJLVHXoljEg3boj1HrHhbmyY39Ou9MODUKu0hgyebin2gTesNA35qD0ReBcp1APr+AXj5adtKNtXGzLpxHChQ3h6vlkhh00EiIiIiZya1NIuGW64vWAUOpUwby+x+CbHWa77cvY3rag1MLGslPbtprZhvPvP1MYnjhqltQVqQpU/Sce8icGq11pdNyJhZoXXS8aKIksdAi4iIiMiZ7Z5tnC9Syzg/bDMcimcuy3XxSQY5jovSpu6ewLH/AZ+XAU4kZjLUJRNo/dhem7q4ASvfM64/u16blu8E5C9nXH9+M/BVZWBuT+Dagcd5RUQpYtNBIiIiImemr6npNQeo9KSWNt0RmQZa3WYCHj7m42uZ1k7JWFsFKgAPEgcrrveiVutkTXRic0Epu+1boM3HWmr1S7u09SWbAT65jeWPLDTOe/ja45URWcVAi4iIiMiZ7f7JfNwqSaXu7YDBVr7SQKFqwNUDgJs7ULGLZZmqvYGCVbXzl0BMLzrSskYsKhz4pKjlPqT5oYc34F8YqNQNqNxDS6IhzQcf3kq9lo3ITth0kIiIiMiZ1RqgjRflmZjcodt3QPtP4HByhwJ3E1POz39Oa8KXlKRWr/msFijpX48wDbr0dAnWjyPND/f9Bix/Uzueb16thqvGM47fj42yFQZaRERERM6s1TjglT1aMOHIJGtg1D3j8s8dzQdVfnALmPuUMSW76bhfu34Abp8x35+1VO/qOAnAuU2W6eMbJqaJ1wsy6bdFlAEYaBERERE5q4QErZ+TtRofR2MtEIxJTL8utk8HTq0ENn5m/flh282XI64AuYsZm0uKdp9oNViP7mjL1w8by8v6xqOMy0MSk2UQZRAGWkRERETO6tgi4NMSwOk1cEqm6d39gi23919knL99ynzbnXPAvQtAu0+BzlOBvn9q42VJzZmMqWWVSbDH/lmUwRhoERERETmra4e12pvkxphyZCWaAtH3gb+eBc6sAyIuW5YxHRsrXxnj/OJXgZ87aPOSWEPSwfvkBTZ+bt73q/Eblv3Z9JkIiTIYAy0iIiIiZ7VpsjZ184BT6Pe3Ni3XQWvuKGNjHVsM/NoN2DJV29Z1mnk/KkntLpaY9LHaO8c475qYRDtsK7B+AnDrhLbc4FWg5fvmx89dFBgbbl5TRpRBGGgREREROZubJ4CZJrUyRWrDKZRqCQSGAv6FAHdvbZ0smypa3zgvNVWSQbFQdSCgkLZOmgaack0MMvX7W5rYD6tIzQx6EUS24ThaRERERM7k4DytT9aVfdpyrgKAdwCcgjTze+2AlsDj1mmg8UhgyQgg/KKxjLuX5fNkYGF908KEOPNt+hqtwBDz9Zd2a+NoEWURBlpEREREzuB/I7Xmdtu+NV+fNG25o5MgSwSVBh7cBq7sNW7LU8J6Ugx53bGPtHn9VE8/CLEMhmwqV377njdRGrHpIBEREZGjkzTou2dbBlnFGwMVu8IpRUcCR/81X3f3nPX+ZirQeqg1G5QBiU2F1DH2v+r4hXF9tb4ZcdZENmOgRUREROTobhy3XOcVAAz8H5A7SR8nZ7H7R2DpSNvKStNBGYg4Lhp4cDP5cuU7adMWYwB/KzVjRJmIgRYRERGRI9v8FTCrhTbf5mPjei9/ODVPP+N8+8RBiit1t15WxseSsbJmtdKuR/4Kxm36vmpCgqsxN4BGJgMTE2UR9tEiIiIiclQyztSaD837MOnFx8CpSe2Unpsn8NY58+DLVEgtbXr9kJaJcMASYHJpbZ13bvOy1pJpEGUB1mgREREROaI7Z4FJRczX+eQxzjd5C07t+mHjfP5ygG9eLYiyRvpm3T6tzV/eo/VX06v6VAafKFH6MNAiIiIickSP7lquM+2PdXYDnFq59sb50Hopl900BVg4xLi8YZI2bfIm4OKSQSdI9HgYaBERERE5oiKJzeVMSWY9fTY9SQ7hzEKf0Ka1BxtTvidHsg4mNXSjlvSCyEEx0CIiIiJyRJf2ADWeNS5X7aNNW40DSjYHOiQmkHBWMnixuH0q9bK5i1mui3lo/3MisiMGWkRERESO5uEdLdPgvl+BrtO0dQf/NGbW6/+vVrvlzFwSa7HObUy9bHAly3VJBygmcjDMOkhERETkSC7uBA78YVwuXBPZkmQYrNAZKGvSVystNVqevhlyWkT2wkCLiIiIyFFs+BTYMNF8nV8BbRropAMTJ8fVFej9m+1lSzSxrfaLyEEw0CIiIiJyBJLCPGmQVakb4JNXG2Mqp48P5e6d1WdAlCYMtIiIiIgcwYOb5svP/guUaq7NyxhTOZ2+Zs+WdPBEDoCBFhEREZEjuH3GOF93iDHIIk37z7VBmo//D6g7lFeFHB4DLSIiIiJHcP+6Nu0wGaj7QlafjeOR5BeexYD6w7P6TIhswkCLiIiIKCslJAARlwFdvLZcvDHfD6JsgIEWERERUVaKvAp8VVmbb/AKUKA83w+ibIADFhMRERFltEPzgXObrG87s844H5nYfJCInB5rtIiIiIgy2j+DtenYcMttGz83zhd9gu8FUTbBGi0iIiKijB4fKyUVu2pTdx+g1iC+F0TZBGu0iIiIiDKSiwuQuxjgHWh9e8QVoEwboNfPgCt/AyfKLhhoEREREWWkE8uBexfM1/31rNY3a8Bi4PB8wK8g4JmL7wNRNsJAi4iIiCgj7ftNm+YrbVx3bLE2PbFCm1bozPeAKJth/TQRERFRRvL006ZD/rPss3XtYGIZ1mYRIafXaK1fvx6LFi3CrVu3ULZsWQwePBhFihQxK3P+/HlMnz4dFy5cQJkyZfDqq6+iQIECDl2GiIiIKMPGyfIKALwSA67Yh8ZtJxNrtDx8ePGJcnKN1rvvvosvvvhCBStt27bFrl27UKVKFZw8edJQ5ty5c6hVqxbOnj2Ldu3aYefOnahdu7YKzBy1DBEREVGGuX0GiI4AVozWlqMjLcu4e/ENIMpmXHS61HKOGklwEhQUZFhOSEhA4cKFVQ2RBGFi0KBBOHjwoArCXF1dER0djdKlS+PZZ5/FxIkTHbJMaiIiIhAYGIjw8HAEBASk5foSERFRTvbwDvBZCeNy9afl9gvYn9hvS6/vn0C59pl+ekSUdrbGBmmq0TINssTVq1cRGRmJUqVKGdYtX74c3bp1U0GN8PLyQufOndV6Ry1DRERElCGu7AMGLgMKVtWW9881BllFamvTqr0ZZBFlQ2lOhhEWFoYnn3wSrVu3Ro0aNTBhwgT07t1bbXv06BGuX7+O0NBQs+fIsjThc8Qy1kitl0Sqpg8iIiKiNLl2GPitO7BlKlAoMdAy1ed3bRofywtLlA2lOdDKkycPBg4ciKeffhp169bFV199hdOnTxsCFOHr62v2HD8/P0RFRTlkGWsmTZqkqgP1j6SBGhEREWVTe34GxgYCl/c8/r5+aK5Nz/0HuHlabvdLTMx1ZAFwcefjH4+InDvQ8vf3VzVaEmwtWbIE+fPnx/jx4w0BjJubG+7evWv2nNu3b6sAzRHLWDN69GjV5lL/uHjxYlovExERETmjZW9q023TH39f8THatHxHIK+xm4VS/RnAxcW4/ODm4x+PiLLPgMUuLi4qsYQ+EHF3d0fFihVx4MABs3L79+9H1apVHbKMNdKPSx5ERESUg9w5ZwyO2k16/P29dx3Y+BnQ4BXAOzcQ8wDYkJiIq2JX87IcR4soZ9do/fTTT4iPjzcsS1r3VatWoWnTpoZ1ks3v77//Vn25xKFDh7By5Uq13lHLEBERUQ4WtkNrLvh1deO6Ve8//n49vIGWHwA+ebTaq0YjLJsN6gWVffzjEZHz1mhJYFWyZElViyWJJqRmqF+/fnjnnXcMZUaMGIHt27ejWrVqqtZoz549KqiRPl2OWoaIiIhysGOLjfMNRwBh24GDfwJdvgHcrfStSs3RxcDfzwI1ngFajQNyBVmOlaWvwSrdCji9BvAv9LivgoiceRwtIf2dpCmeh4cHypYtq/poWXPkyBFViyRBmQxw7AxlksNxtIiIiLKx7d8BKxJ/NB60HDj4N7DnJ+DNs0CufGnf39TqwN3E7MYvbgWCKxm3LX8H2PEd8PoRIDAESIgHEuI4YDGRE7E1NkhzoJUTMdAiIiLKps7+B3gHAGvHA2fWaetqDdSyD766D8hbMvV9nFgOhNTVgjK5rRqX27jtnYva/vWiI7WmiiWbAW6P1VWeiLLTgMVERERE2UbEFeCXLsDMZkCgyVAuvom1WFE2jKMpQdMffbSmgmp5u/l20yBLePkDZVoxyCLKARhoERERUc5056xx3jRRhb6/VLQNgdbtU9o0KLFbwv1r2lTSuQ9cZr9zJSKnwzprIiIiynkirwG6BG2+XAetiWDVPkDh6kDhmkCj14GAIqnv5/4NbVqzv9YscN5Abfmlbex3RZTDMdAiIiKinGdOF+DWCfNkFd2/N24PqWXbfuKitamHLxB937jeNMMgEeVIbDpIREREOYvUQt0+DRSoaJ4RMD3iEwOt6U8APiZJMIgox2OgRURERDnHkteAyWUAXTxQb6j1Mhe2AtOeADZ8kvr+pNmh3oSCQKXuwNsX7He+RDlcVGw8Sr27DE/N2AZnS5bOQIuIiIhyhvs3tbTtevnLWy8X+xC4eQzYMAk4vyXlfYbWNV8+sgDwSj7dMxGlzZ87wxCfoMPO83dw+0EMnAkDLSIiIsr+7oUBk0ubr5OkF9a4eRrnZTDhlPw73HKdK2+viOzlemRi81wAf+26CGfCvwRERESU/QWEAMUba/Ov7AXGhgPuJgGVKTeTRBbxqfyCHrbNjidJlDNFRMWi9/fb8NOWc7h45yFWHrmmmgnuPn8H3204Yyh34OI9OBNmHSQiIqLs7eIu4Pj/gF4/A7dOAflKpVzeNACTmrDkXD0I3DHeBCpegY95skQ5z3cbzmDHuTvq8fnKE3gYE49qobnNAqu2lYLxeuuycCYMtIiIiCh7WzMWuLBZq9Eq0yr18qZNB28cS77cf58a5wOLAuFhQJWej3myRDnPdya1VhJkCdMgq3xBf8x4phZcXFzgTNh0kIiIiLIvGdtKgixhS5AlTAcqjotKvlxwZW06dBPQ/19tPjAk3adKRJYmdKuMFSOaOF2QJVijRURERM4nIQE49DdQqiXgl9+4PjZKGyxYf1N2ZGHa9206HlbJZsmX880LFKoG+OQBcocCb5wCcpmcCxGl6vcdyTfPfalZKTxdrxicFWu0iIiIyLns/lEbIHjfb8CCF8xrryYEA+NyA6fXauuOLtKmb51L37Eq97C+/sZxYPlbQJm2WpAl/AoYAzwissm7Cw8lu61fvaJwZqzRIiIiIufyv9e16a0TgE9e4/pbJ43zv3UHGrwCnF6t1UpJ7ZOtYh8lvy0uBri6H9gyVVu+uCPNp09Emtj4BJg6Nr4dDl8JR41QrVbZ3c2564Sc++yJiIgoZ9HpgKByxuVHd7R1omAV4CWTwGfrN9q0399pO4aHD1DneW1+42TzbavfB2a31rIYiiv70vEiiEgsOXAFeu91qAAfTzfUKZ5XBVjOHmQJ538FRERElP1JMLVqjNYsUDL7Ve0D+BfStt0+rU3dPIAC5YG+fxqfJzVe0mcrreoO0abxxsFSlXObzJf7zE37vokIMl7WyL8PqCvRt24oXmhSMttdFQZaRERE5FiiwoE/+pnXFt09b6yhkrGtun8PVOmlLX9bGxgbqD22TQeKNzI+r2b/9J2DPsV70maEbia9LtpOBEo0Sd/+iXK4DxcfMcz3qJk9s3Uy0CIiIiLHsmsWcGIpMNMk419AYeO8f0Ft6pvP8rnrJwIeuYzLUsuVHvpasG3fGtf9Oxy4qv0Cr5zfkr59E+Vwdx/EYN3xG2rezdUFtYrlQXbEQIuIiIjSRlKoH/gLuLIfiHlov6t3aL5WK7V2vOW2m8eBZxZo6dT1zfqsZQTsMQtwNbm90TcvTCs3K80N9/9mvizBIBGl2YyNxgGKN7zRzCnHyLIFsw4SERGRbW6fAR7cBG6eAJa8qq2r1B3o9dPjX8Fbp4F/BhuXXVwBXQJweIEWLP3UTguyhm40lrFWo1WunTZ9bhUQ9wgo0TR95+Oe2HRQ79G99O2HiMyEP4rFT5vPq/nZA2ojNK8vsisGWkRERGSb7xpqwUvoEyYrEzP+PY7oSOC6yVg6798CFg4FDv8DzB8ENH7DGNSZenjbOC8ZB+uYjKlVtN7jnZNp80OREGeclwGKH90F8pd/vGMQ5QAJCTrE63TwSMwiuPbYdcTEJ6BoXl+0KF8A2RkDLSIiIkqdBBYSZImL27XpkA1A4RqPf/UmhQAubsB714CEeK1fVcsPtUDLtJ9UrQHmz4szyQhYrZ/l9sdh2vxQxMcY56Vmre9fgKub/Y5HlE3ciIhCdFwC8vl5wtfTHe/9ewiL919B8aBcOHIlwlCuQal82bbJoB4DLSIiIkrZvt+ADZ9o841HAQ1HAFH3gNxFtbTr8kgamKSVLh5w9dDGsBLXDhq3xUVp27y1QUwNgkoDo05ozQxz5c/Yd9E00AquDHh4Z+zxiJxoLKxCgd6oXTwvpqw6gW/WnTZ+VQK8cD1C+0HENMgSL7cojeyOgRYREREl79JuYNFw47I03/MO0B5nNwC/dAU6fQnUfi59VzHiqnH+547A4JXafGCoZVlrv37rMxBmRiZEvYAimXNMIge358IdvPKHNgxD2WA/nLx+32z79cQgy5qQPNm3b5Yesw4SERFlJ3cv2G9fJ1cBS0cal4fvBApWthxratmbQJxJjY+Ij01536fXao8vTPo5lW1jnC9cHRi82hhwJaSyv4x255zJvDFjGlF2tvHkTVT5cCX+2hVmse1mZDR6fLfNsJw0yErJ1ndaICdgjRYREVF2EXEFmFpVm+/7J1Cuffr39Xsf4ORy43LZdoBfAesp0CVRxJ6ftDGmClXX+i5JgPbiNiC4ouW+T60B5iZJzf7SdqBABfN1oXW1por/GwF0mIxMJ8fMFaTNxzzQEmR0/gqo2DXzz4UoE209cwsF/L3R/8edavntfw6hV61QuLoaa5VnmqRoN/V2u/IonNsb7y08jA86V0SZAn6Ytv40zt58gGWvNYa3R87p28hAi4iIKLvY+Llx/tQqY6AVfR/wzGW96Z1p+nLph1WpG5CnuHmQVf1p4MnpyQ/qq44RCRz8C3h4Bzi7Xlt3aaf1QMu0KaI+TXvSIEuvWh+gbFvrqdwzmozdtWwWUKErEPsQ8PIDqj6V+edBlMHp1u88iEGJIC3T5pEr4ej3ww6Lcgv2XUb3GkVU0LTuxA3sC7Mc8uDTHlXQu05RNd+1urGJ7awBdVT2QdNALSdgoEVERJQdSKCz+0fjsoxBde+iVht0cRfw4mYteUVyFgwBTq3UAqP8ZY3rx4Yn/xzTQGvdR9rUNEDzS6b/1P1r2vT5dcCsFkCrsckfQ5Jj6BNkZDZ9vywZmFgGZvbI/n1KKGeR4KfexDWIik3A30PrY+WRa5i92aSZrIlJy47hjXkHLNaPal0W3288i951Qg1BljWuOSzIEgy0iIiIsoNzJgP5itgo4NduwO1Tids3ATWeTrnZoVj8qjZI8Mu7LbP8JaXvo5Uc0/5celK7JiToC6kFjLlhHrA5oqhwIPaBVitI5OR0Oh2WH76mMgX+vPW8CrLEU98b+1tZc/tBkn6YAF5rWQavtCyDYc1KGcbJIiMGWkRERNmBjGdV/2UgtB7wd38tMNAHWULSkcu4U6ZBTdgO4EeTBBTS9+joIq3p3/oJQK+fUz6mfyGtH9aDm8AvXYzr23wM1BumjYdlSpoVflZCmy9aX5s6epClv7ZS6yZp5Imc3OIDV/Dan/ttKvt13xrwdHPBsN/2Wt3er55Wg8Ugyzr+xSAiIsoOAgoDbScAFbsAXv5aU8K8pYzb5z8H/NFXmz80H7i0B9g+zXwfppkDjyxM/ZjunlpTQ9MgS8jxJeugNLczdfOEMaDr9j2cxs4fgLDtQPlOWX0mRI/t5PVIm8qd/6QjulQrjHaVC6mEFnrF8xmb0AYHcDy5lDDQIiIicna3TgP/vKA1DxQNXtEyAt6/YV7uxjGtVuufwcDqD4AqiYkdijfWsgp2+Rro84e2btjmtJ9H8/e06ZLXgImFgOn1pBOIsTbrp3bavGQmTCkxh6ORjIrbpzvXORNZIQMKT1tvni1w57stUTK/1iz2qdoh6FUrBD/0r21WZt6w+qhZNDfeaFMWQX5OUAvtINh0kIiIyFlJEDM+j3FZsvPpa6NuHDWuD64CRFwG8pUCds3W1l3YDDR42TLZRfkOKSfASEm9oVqTQ717YcCRBUCVnsDaccb1zhKwyODMcv56i18BunyTlWdElG6/bjuPb9adNls3f1h9FAjwxqoRTeDq4pJsworcvp5Y8FJDNb/l9G2+CzZijRYREZGzurzHfFkfaJkGWSWbA81HA4/uAOc3AStHG7f90ceyeV96jDoBvLQD8A603BYdoU0fJt6c1XsRqPMCnEKFzubLtzlQMTmfradv4dMVx/H+oiNm67/pWwO1i+dV8+5urjZnBaxUOCBDzjM7Yo0WERGRM9rwKXDrJND6I8C/oJYEQ/pGmZLmg/3/TXk/m78AWox5vHOR48tDvHsFuLxXy2K4cIg2yO+dc8CxJdr29p/AaVzanbYsi0QO5szN++g3y3JMrJJBudC0XP507fPlFqVV1sLKRRhwpYaBFhERkbNJiAc2TNTmm7xhOdhvwxHAlq+ST+fe/jPg7H9A+EWgzvP2PTdJgV6isXaOMrivNBNcn3iuziZpshAGWuSAtp+9jd93hOH9ThWR398Lc3dcwIeLjmDFiCYY9pt5rff4rpXQv37xxzqeNCPc9FZzp2kBnJUYaBERETmbbdNSvvkv3kgLtApVM657bpUxlXuVXlp/qozk6macl2x9Vw9o6eedmQzoTORA9obdRZ+Z29W8u6uLCrbeW3hYLbf64j9DuY5VC+HFpqVQuYiV5r3pkBMHH04PBlpERETOQPpSrXgH2DvHuK7rNC3BhUXZxEGBPY0pmVG0HuBfGIi8kvpAxPZydLGW5bBqL6DfX3B6pVpk9RkQmRkwe6dhfsG+y7jz0HJQYf3AwmWDkzQtpgzHQIuIiMjRXT8CfNfAfF1gUaB6Mk0DizYAqvYGSrc0Xy8p2x/dlZ+jkSk2fKINnBwdDpRuDeQpBqfSY7aWCl+v45SsPBsig7XHruOVP/bhYUy82VXZcOKmxVXqUTOEQVYWYaBFRETk6Fw9jPPPLAACQ4E8xZNPk+4fDHSfabk+Vz7tkVkCCgGn1wBLR2nL6U0bn1WK1jfOO9u5U7YVFRuPwXOSJGpJol+9oqrflnijbdlMOjNKioEWERGRo5JBfi9s0fo4df4a8M1rWUvlyLycPCuZjAOmJ2OTVeqWlWdDpKw5dt3sSsjgwi/8Yh541S+ZD60rBMPP2x2FAn145bIIAy0iIiJHdG4TMKeTNv/0fKDWADgdyUDozKSZpd6DW1l5JkSIjovHpGXH8fPW8+pq1CiaG1N6VUPJ/H74vGdVvDn/oOEq1S+VD0F+XrxqWYwDFhMRETmaG8eNQZYMAuysSRhMk3G0+xROp0QT61kUiTLZ+VsPUG7MCkOQJT7tUVUFWaJX7VCz8gyyHAMDLSIioqwiY02tfA8YGwj80hXY+i1w/waw5FVte+GawFO/Ou9NflBpbRpSF3hiGJyOlx/Qb55lPzmiDOp7tf/iPRR/Z6nhMXHZMbXth01nzcpWKRJokeCicZkgvi/OHGgdOXIEAwYMQPHixVGkSBE8+eSTOHxYy9Vv6u+//0bVqlURGBiI2rVrY9WqVQ5fhoiIKFNERQAH/gR0OmDJa8C2b7X1ZzcAq94DJpcB8pQAXN2B59cAJZs67xsjmQ9FkBN3xj/4pzZ1Y6BFGWfPhTso//4KPDlti9n6mRvPYv2JG5ibmNgiyM8Tw5qWwp9DnrDYx8+D6uKbvjVwdHxbvlXOGGiNGDECrVq1woYNG7B9+3YEBASgWbNmuHr1qqHMmjVr8PTTT+Pll1/G8ePH0b17d3Tu3BkHDhxw2DJERESZIi4G+CQUWDgUGJcb2Pertn7QcmPNiShSC3jtgPPWZOlF33f+vlqH/9GmEvjmMDqdDs/O3oFmn6/H8WsRZtuuhUfh1PXILDu37EKuY78ftqPHd9uSLTPop12G+bWjmuGd9uWRy8vy8+jm6oLO1QrD1zPnfVYdlYtOvkXpFBUVBX9/f8yaNUvVdIm2bdvC29sbixYtMpSTWqRKlSphzpw5DlkmNREREao2LDw8XAWXREREaSL/anfNAoo3AuY+BYSbZLOr2R/o8o3WjPDTEtqYU29fAHwyaVDhzEjoUbgGMGQDnNLU6sDdc8Cr+4C8JeHMiRTCH8WigL+3zc+ZvfkcPvrfUePygNpoWSFYjeGkTy++YkRjlC/Ie6P0iIlLQNkxy61us5ZJ8JUWpTGqTbl0HYvsy9bYwPVxDxIfHw8/P60jnsRsW7ZsQfPmzc3KtWzZUq13xDJERESPLbnfLGV9bBRwYSuw7A1g+hNA/3+BPr8DtZ/Taq3aTNDKSu3Vm6eB929ljyBLhNTWUtO3/wxOq1A1+V0aCAiBszpyJVwlUqg7YS0OXrpnsf2vXWGYtemsum/Si4iKNQuyhARXDT9ZZzaG08FLHF8sPR7GxFkNso6Nb4fzn3RE64rB+OW5umbbyiTpk0WO77HqFkeNGoXChQurWiMRGRmJBw8eoECBAmblZPnatWsOWcaa6Oho9TANKImIiKy6dQr4tjZQtj1QuDrQ9G1tIOGrB4HvGwPFGmpjYQkZCytfKe1RvqPlvtw9s9dF9vAB+syFUwsoIhEzcP8akLsonEl8gg6l3l1mtq7Lt1uw9Z0WKJxbG1tp9/k7ePufQ4ZMdU/WkNcLPG8STJUp4IdTN7RmoJfvPTLb31vzD6JHzRDVbC0lhy+Ho0RQLqtN3nKi1/7cb7Ys1176V/l4GpsLly9kDKwGNSyOTlUKZeo50uNL96d9/PjxWLBgAVavXm2o0dJzdXW1WE7aQtHRypiaNGkSxo0bl+x2IiIiRf6X/Jo4iO3J5dpjwySg/yJg3cfa+rLtjIGWM46FldNd2adNjy/L8syJJ69HYu72C6ofTu3ieVMt/9qfieeeRINP1uGjJysjKiYeExKz2okRf+3HrM1n0bNmCHaeu2NYv2JEE4uAzdTKI9fQwUoQ8N/Jm/h23SnsOn/XkBXv18H1kFNduP0A7/xzCKUL+GH1UeOgwwMbFMfYLpUsykszT2mu6e/tgbolUn+/KZsEWp988gk+/fRT/O9//0ODBg0M66W/lq+vL27evGlW/saNGwgODnbIMtaMHj0aI0eONKvRCg01H5+AiIgIy94Ewi9qF8LNC4hPbA1x9j/gUmIH9ideAs5t1JrRkfPp9RPw32daX7os9vY/B7Ev7B7mbLugmpiZ1n7ITfxnK0+o8ZaerF4E3WoWwf8OGpOVJfX+v5ZZo8XhyxE4fNnYZPCPF55ItbZKgjJrgdaAH3eaLW86dUv90O0iNb45jCQTaffVJjW/7extw/ovnqpm9drpSZ84cl5p7qMlAZbUZi1evNii75N8cerVq4f//vvPbP369etRv359hyxjjZeXl+rYZvogIiKyqM3a/7s2/8peYPQl47bYh8Z5N3fgmflAs3d4AZ2Rf0Gg0xeAp2+WnoYkoJAgS2/72dto/Nk6zNx4Bp+vPI6mn2/A0oNXceRKhKqlaj7ZmHxkap/q2PhmcwxtkrZkHt8/Wwv1S+VT85WLGO+FJnSrjO2jW2JWf+3HAxlEVz/u0/3oOEOiB2uG/roHjmTL6VtYcfgawh/GYtWRa9hx9rbVVk8/bTmHkqOXqj5qUt4WCQk6vDX/gLou+iDLlNRMdq8ZAm8PJ88uSvap0ZoyZYoKsqQmq0UL66PUv/766+jRo4cau6pTp04qs9/u3bsxdepUhy1DRESUJnfPA4/uaTfhZVprfa7E2MTEADdPaoMNV0scR4ooHb5ZewpTVp+Et4cromLNA5dBP2s1phOXHbf63MgoLeB5r0MFdK2u9bsa3aECjl6NUDVLpioVDsD/XmmEcUuOqqBJr2nZ/Ib5t9qWR/8fd6Jj1UJ4ul4xtU4nfdeS+HXbBbzYrJRZoofJvarhjXnasDqrjl7HvYcxyO2b9f0RN5y4gYEmqdP1An08sOPdlhjx537ULp4HbSsVVNdG30dt2G97cGZih1Rr+kYvOIS/d5v8AGOif/1iGN+1sp1eCWWL9O4+Pj6IiYmBh4f5oH1jxoxRD72ZM2fio48+wuXLl1GyZEnV1LBnz55mz3G0MilhenciIlJiEmuqZFBhyUT39nktW2AObApFtpOaje83nkXVkEA0LB2UYlm5LTt0ORxj/j1sNaNfAX8v3Ig0JuxKzbbRLVAoUEt8IaJi4/HjlnOIj9ehe60QzNhwRiVaKJlf628vNTrRcQloYhJk6UmQUTDA2xBgWEu2YY1k0fti9Ul8vfaUWh7TsQKea1gCrqkEKvYgNWzymhqVCYKXu7Hm6FFMPCp8sOKx9i39pvR92cZ2roiBDUuYbZearKRkMOH7UXHI7++VI5tQZhe2xgZpCrQkE5+14u7u7uqRVEJCgkUiCkcvYw0DLSKiHOzQfOCfwZbrPf2B0RcZZFGKN/kJOh2+/+8Mpq0/Ywg6xNXwR7hyL0rVJj0zaweqhATiw86V8Mny45jxn1bWmmn9amL473st1kvN1bP1i+HMzfvo+PVmte7PIU/giZJa07+MYi2YMLVyRBOUK+ivEnm0+XKjWte9ZhEs2HtZzb/fqSIGNzIPUOxl3fHreO7n3RZjUEmzxoofrEBcgvk9bc9aIZi/x3oNlAjN64OLd8yzLpqSMcXm7b6E4c1L47+TN/D6X1ot3rpRTbH7/F00K58/TeOYkeOyNTZIU9NB6buUFrYENY5WhoiIyCAu2jzI8grUBhQWXaYyyMqmroVHwcvdFXlypb95W1x8AlpM3mBR+yTBl5+Xu0oUcfL6fbSpGIzdF+6qx7Yzt3H8WqShbK1iebDngpaxT0imwKblLGuahKROl74+pfL7oXCgN0Ly+GZ4kJU0aLJGgix9ing90/IyVlfSQEtqyp77eZd6TVL75e6W9vu3Hzefw3iTccC+WXcaXaoVVu/p8Ll7DUHWux3K4/lGJQ21ax2rFDI0y/R0dzXrayZJRiT9fcsp5n3/9fT9sGSgZ1PF8+Uy1BhSzsLBDIiIiJIjrTgqdQOOLAQ6TwVqDQQS4oH4GG2MKMp25CZZbv7LBvup2pj0Nu86f/uh1SZ+lT9ciSdK5lVBlr7Pkp5pkCW1J/OH1cfDmHhVG1Q9NHeK59KygjZmqARbG95sDvdMaJYnBtQvrgKn8gX9VUCx4ogxUUQjk2aScu6vtyqLL9ectNiHNGfUJ4SQAFUCJEkNL48qRQLRo1bqg0Wfu/UA/t7uajwqfcr5pFon1qiZ6lu3qFkTxublC+D4R+3g6eaq1su5yXlcufcI/eoVVc0PR7Qqg6/WnDI0GVx84Ar2miQqMfXzoDqZ0kSSHBMDLSIiImtOrQHm9gCeWwX0+tm4XvpkuTLIyo4km5wEWUICoVv3Y1RfmrSSZoGDfjZPbW5q+1njGFXWtK4YjC97V1fBiQzwW6NoHrPtU3pVw6h5B1Asny/e71gRNYqaB2FSE5NZqoXmxurXm6BgoLfq93TvUQzqlsiHIrm9VVY9U2WCrdfq9J+9UwUxUls0de0p/LLtgmGbvE6pxdMHUElJlxYZTLntVxvV7yKTuldRNWE7EvtOSWbEdxceshr0HhnX1uoAyqZZAGVekmEkDS5lf6F5fDGgQXEUy5fLUAtmqm7xvGhWTguAKWdioEVERDmH3IlFXgW8c6ecrvv6US3IEpf3AEVz7iCrzkCami05cEUNuDuydVm82lKSldhOAoTGn63HrfvmN+PXI6LSHGiNX3JUJZtISpJLbDxpPq5nUsObl1I1YZ90r6KaFyZHanhaVQhGoK95crKsUiZYax4oA+v+OST54XNMmw+KIrl9VIKNnefvqIfUSP28xZj1UK/2x2vQsnwBfNOvBnw9jdfl7M37aJGkGZ9k+kta07ezYis1QPALv2j9tcSbbctZDbJsIc0PJ3arYlYLNqp1WZUh0lTh3OyPldMx0CIiopwhbDvwY1vj8lvnAN+8wOm1wNF/gQ6TAXcvrWngdyY3i6WsD2fiqCKiYhEdm5CumhhndPHOQxUk6Ul2u6J5ffHP3ku4+zAGc59/QqXrTkmLKRssgixxNTwKlYsEWvSxkv5bHlb6Dck+rAVZfeqEYkK3Kli0/zJG/q0lSBAHPmyDauNWqfnFLzdE1ZDcNr5qOEyQlRalC/ipmqvfd4SpQOV/B6+oQEtv8BxjICR9p0xT1689fkMFqu0qGwf33XzaPE19UpIhUV/TJ7WEswfUVglCq4XkRr5kasjS65WWZTCsWSnExesM2Qw7VTWv0aOcJ01ZB3MqZh0kInJi8wYCt04BBSoAh+YZ17t7A3FR5mWL1AYqdgFWf6Atf3BXMinBmeizwC1/rTEqFApQtTVzd1xQ/WdaVQxGdiOD9qaUCU5Skb/YtBRi4xPwVrvyalmuiY+nm2p29tH/jlkER9IvSFKsi4+6VsKz9YursjLmkvTXER2qFMSyQ9fwdd8aaFspGFvP3MagJGMySf+cvLk8Ub5ggKE53+Cfd2FP2F0sHt4IRfP54kF0HB7ExOXIbHQpZSw88XE71e/KdKBfSbzRoFSQCpokeH5+zm6sOWbs45aU9LXKisGA5TMiY4Xpxy+j7CdD0rvnVAy0iIicVHQkMCmxI32e4kD0fWDUceDmcWBGo+SfN2i5Fpj5mPeNcXTbz95Gn5nbzZpHfb7yhGF553stH+uGftr601i47zKmP11T9YOxVquTmeQWpsTo1MdxslWp/LlUs8O9F+5ijkk/Ial9kX4+tkoprbqMqfUwNj7FpoE5RfPJG1QwJSTZx/6LWkKJb/rWMPTvevWPfSrZRGpp1vVp83efv6MGd65eNDevMWV5bOBcP9MRERGl5sp+4KTWHAu7f9Sm7T8Hhu8CRh4F3DyAgsb+FajzAjD6EvDkd8Z1p1Y5XZAltTSmQZYwDbJE3QlrLZ6n/7213VcbVQ3D4cSaHP02ybommeCk2Zbs7/SN+2o8pDLvLVflZYyorCIp0fV+f6Geqn1KrzUjm2DtqGaqFiJpdr+0BFm/Dq6bYlp1yUDHIEvz91CtiW7v2qFY8GIDDG1SUo0HZppEY2qf6pjxTC2za5g0yJLBj/VqF8+rBifmNSZHwBotG7BGi4jIgTMD+hcEClbWli/tBma11OaHbTbWWr1xGvBLMv5QzEPg1gmgYDWteaD0zYp9qNWAeeQC3jP/Fd2RSfOzSh+utKmsZKyTAXGl+ZU8z7TmRk/Smnf+drPZGEIp0dcmZCQJ+CYsPYa/dl1ETHyCSo4Q4OOhath61QrB572qqTKD5+xSWeCkFuphTBzmbL2AT1cY+/ok9WqL0qp/jWntnGQfHPbbHqvlN7/dHOuP31ApyP944Qn0nLHNbjWGlLymn6/HhdsPs+zzR5ThAxYTEVEOFB+rBSEPbwFfJtYYjDgEbJsO7J4NjDgM+AdLmyjgzlkgqLQWxCSX1U8GAV4wBKjWByjTBlg7DqjWV2uqlxZHFwN/P6vNl++kjXf1yFjDYdY00NdKDYOcX+Ea5mnbvfyBukOAKGOtjjPQ9xsS+XJ54vaDGLPtQX6eKlW5Pl22kJTYyZFU2WkZoPbQpXBUCTFPGvE4lh68iqlrT+Kd9uXRorzWr2z6hjP4dfsFs+QIer1qh6qp9MeR5Bd6kqHuxWalMKRJSby/6DAeRsfhkx5VU+23I1kMrdn7fmvV50r6bMlDSLAlfeAk2yGDrIzTp05Ri4A5t68H3u2Qxr8bRJmINVo2YI0WEWV7t04D39aWxmLAm2cAD19joDQ2lRvo1h8BeUsCN48B6z7W1uUuCjyzUAu6kpJ06T9YyeT39Hzg7AagSk/zACip2EfAhILJn4v0xdIHYPnKAN2+B0LMmx5lJxJkDfhRG7MpOMAL295piZLvGvst/Ta4HioU8ketj9ekuq9m5fJjw4nkU5B/1bu6GutIr8XkDTib2MdGanpk0NamZfKnOSOeNFEc8+9hzN0RZjUl+MTuVdRrlMF7k5IU4XLs9A4sbE1kVCxaf7ER1yKMyVLaVy6I75I0YaPMI33bpqw+oUZokGyDPWqGoH/9YnZ934lsxWQYdsRAi4iyjdgo4PxmoERjLZW5CL9krKkyJcGT1FClV5dvgZqJAY/exV3AT+21fUuzPUmdfmad5XPHJlOjJGV/7Wa+rmh9IGyb+XOPLQH+egao+CSinpyN6Hgd5u2+iDYVC6pMb9mBpMaeuz0M287eNqz7d3hDlVTANJubvllVyykbcOamFhSZWjS8IcoV9Fep0AsF+pg9d8e7LVUCDEmX/nzjkhbP/WbtKYuxgyRRwdd9aqjmieO6VFYD2aZm0rJj+H6j7Z+1T3tUUbVH+y7ew0vNSmVIZjnpl1b6veWG5c97VjXUnBFRzhbBpoNERKQ8uKWlN5f+R+s+Aq7s08aMqvM81KAyRxdp5fwLAx0nA3/205b1QVbZdsDJFUD7z4DyHYHAEODhHa32Kl8pYOW75hc6f3ktq9+jO5ZvwKmVQEIsUKUXsP5jIKSu9UArObokfYb0Y2FdO6Q1FWz4mra+XGKfjaP/oubeLngI7Wb/46XH8PGTlfF0vaJYeeSa6ssjKdCdhdT8xCXo8MysHdhxzvz6NiiVTwVZps0HpbZHTxI9SB+msDsPUTq/n5oWzu1jSDsuQZYY37USPlh0BJN7VUNwgDfGd03s/2bF/Zg4i3WSqKDb9K1qPrePJz7tWdWiWd5Xa07im3WnUTbYTx1j06mUx0PSk2Z60gxSP0CuDBSbUdzdXFUK8fBHsWq5TvG8GXYsIsqe2HTQBqzRIiKnJU3xfukK9JsH/N7LfFvr8Vpgsuhl4NB84PXDwOelzMvkKQG8tj/lY3wcrI1HJQknYh9ofbqkGWLjN4CW71tv8td9FrDgeW1+8BpgditjuWbvAk3f0oJAU1JLlRAH+BcCIq5ofbJSajaU2OSxeNRc+XeX4kuQwGTP+63hyIMQ34yMRssp/1ndvvr1JobgQ0jmwKlrT+GttuXM1ttbSkkjko5lJE2/rkdGof4k64F1Hl8P7PugjVmN0uzN5zBpubFfzrHx7dT4V5ll65lb6PfDDjV/8uP2hqCUiHK2CNZoERHlcGf/04IscXq15XYZlFc/MG+hakCuIKD6M8D+34COXyA+VzDiyrRDeEQUouMSEJo3mSZ3rx0AYh4kZvXLD0QmDiAqCSWkQ4U065Oaq3sXjc8p2RSo/zJQsrnWH0v6dBVrBHQzSbFu6u55rSmgeHk3UNSY8CA5EwtOxbUwadaWeh8Oqf2R4KRyEfsldHhcEmhExSVgztbzFmnahdS2DGxQXCWnkJo5U/I6fugvfe4ylgzUO/PZWhjya/LB1i/bzmNIk1KYuOwYZm02HxjYNKnBr4PrWdQoDW1aCoMalsDIv/erQYQzM8gSVUNyw8vdVdUMMsgiorRi1kEiouzgxnEtWNEnsLi0B/ilizZfsKrWnE+4uAHtPwWWvWH+fGlKKDpPxVSXfvjyH23gUMCYMnzGMzXRtlJBlfygRL5cajwgxb+gqm15+fttqjmbF2Jwwltir7vwPLdRO4+SzbTaNdF2EuBXAGg7wXj8Vw8YU6zfv6H1H5MmgaavT8/K+Fax8QlwdXGBm6sLLt19iEafrteCPuTH/15pZBZADf99r8pql5RkjpvU3byZmz1Jjc6KI9fg7eFqyKQnY1L1+G6rap7WqHQQpj1dE0cuh6PfLK0WJTlNyubH5F5VszzLnSQiaFMpmcQkib5acwp5c3mZBVnyPp2e0B4z/juLkvlzqc9VciTA+bZfTWQFGYtp95hW8HLP3ACPiLIHNh20AZsOEpFDunEMyFcaWDMW2Pattu6DO1qa8r/7a32vqvXTaomiI7XxoUo0BQYsBma3AS4ab+Z179/C1cg4FAzwNstYl5yaRXPjq941kKDTqZquUkme84vHJHgWq4Mnrvyq9cnSkxqsZxZoQZUV987tQ+45zYAKnbWmjdJPrFAN4PPERAxP/QJUTKylS3Tu1gPVZ+nyvUcY0aqMurHXk9ezbXQLq5nJftpyDuOWHLV6Hq+1LIPXW5eFPWqlLt19hJ3n7+DHzedw/FrkY+2vVP5cKuGFv3fasvplNBnMeOTfB/D9s7VU4CikBuqQyeDHenVL5FXJMmxJkkFE5IiYdTALLiYRUYaJigA+Scx4Jk3uJJnEX88CXb8xNg8UFboAHT4H7oUBRxYCrcYaswt+lB+IjwHeuw48vI09v3+A6ReLqUQR2xKsZB18DF1dN2Oq53SL9Wtar8SEbVEqOFozsikexcSrgXEliYOvpxtOnzmNnd7DtcLSFysySc3T0E1AIa3Wac+FOxj00y5ERFkmZNCnOp/WryZqp5LEYG/YXXRPTN5gSp/BL72OXAlHx683wx4WvNQANYta1uQ5Iqm5kwyG0rTRNGufWDuqKUrl98uycyMisgf20SIiyg6p2PfPBZaOBELqGNfvmqWlaA8P0zIKymC/UjsltT/HFmuPUSeBdpPM99d/Me4dW49Fu66p/jLRcd2TPbQ0b5Mmd9L351p4FBp/Jk3xUlaneB7MG9ZADSp753/zDevjKnZHdIILch3/B8OXXEU0PNX6Vl8YEzvsv6g1VXSHSeKGpEGW1GYVqorTNyLR6ovkB9S1Nt5TSqom0y9r+aGrqQZax65GqP5T0nSyV+0Qla1PtChfAOtMBtRNyaCGxTG6fQWUHWMMSqRmbkSrsmq/Ir9/YrDsBKRJaT4/7XwleDYd+4pBFhHlJOyjRUTkiK7sB2Y2NS4HhgKXdmnzw7YA3yYOnFq5hzbArxiXx5j+fEpZYOhGleTCdEBboJrUtaR6+L+G1EfpAlrQI00D977fGvP3XFTJAST4GPjTTmw/ewc+Hm54FKvdSH/eU/YNFArwxrPuCwz7qrS3CzZ4jUQuFxiCrOTEwR1b4yuigZt5kz5dSF1scm+AXatOqLTgSTUtmx8lgnJhzbHrWPpK4zQNmCtJF2SsqZi4BDyMicNnK0/g9x1hOH/bcswpvdv3o1VwOHjObsM602ZypkHW8Oal8MvWC6rJ3OyBxoD5+LUInL35AB2qFFLLZyZ2wLYzt1WfJCnrbAFWapa/1jirT4GIKFOxj5YN2HSQiDLdzRPAtLravFcA8MYpwMNby+I3LrGWpdU4xNV/VTXDK5nfD273r2p9s/QB2Ztn8eXW2yrNd3JmPFMLZ27eV8FUs3L5VV+nZmXzY2Sbcuk+dZW974eiar5B1Ne4gdw47d0fMTo3/Np6j6r5OXEtEv1+2K4GwpWEAwcuaUGKJEbId2sP5nmNN9vn6uDn8cKFFhYp2Ze80kj1D+pUrbDaj71Tlr/TvjyGNC5pSPwRGRWLT1ccx2/bw2zalwRMfw+tr8a/StBpSSBykqe+34adieN96QdOJiJyduyjlQUXk4josUkCC+9ALUvflqlaTVblJE38EmuuRlVYj3/2ac3r2lQMxkxJ5y3NDScE46FPIVS8OyXFZn7Tn66VITUm0XHx2D++Aeq5Hkf5qJ9QoWgw3C5uR9GQUEx5qZchMcX1iCiV1lsyukkNUYCPB1YfvY6X5u7FePefUMf1BD6Oexrj3OdgUlxfrE3QavFaVQjGO+3LGWrc7E2aSj4xaa3ZOknvPf3pmug6bYtF+S+eqqYSQYjyBf2x7NXGKng9di0SnaoUMmZnzIGu3HuEV//Yh4ENi6NT1cJZfTpERHbBQMuOGGgRkd1J36rIa0BAYW1exrD6rIRxe58/gPIdDDerk1edwIK9l9WypE9vXjoAK05HWez2+0rH0PbMR7ikC0Kj6K8N61eOaKLSmks68cxISFDunYXwxyN8NbgNGpUJUjU61jL/JWVMzW7d131roEu1jL9hX7T/Ml77M5WBmgEVfLWvrKUmvxYRhUKBPhl+bkRElLWYDIOIyAFtX/qzSiv+vedX5hsajjBbTLiwFYf9GmDX+bv46H/m/ZWkn5MEWVJR8mpL83TmnxzNjbZewAexA9XydxIIJPYB+mdYA9x5GKP6MmW01W+2xcnrkWhYOp9atiXIEiF5khkUWVpCti2HzlW115LRulYvglrF8lgN+vrUCVXBXoPSQWbrGWQREZEp9tGyAWu0iChNbp0Cfu8N3DmD+265sbT5MvSoWQRXpjRB0fgLyT/vlb1A2Hasf1gczy25Ax2sjzWl16lqITWQ66ZTN/HsbH2yC6O8uTxVEgtnI/2gWk75DzcSM+6J0Lw+2PSWeR+tzLL7/B30mbkdcQk6vNisFN5ulzj4MxER5UgRNnYrYqBlx4tJRCSZ6+ZPH4N+d6YZLkaFqB/xXshBPHNLq8WSZn0doiehndtO1HA5jb7u63FPlwvVo3+Ah5sLYuN1ZhcyJI8PPuhUUdWgxMYlqAFhg/y9MGdQXfh4uqkyUbHxWHboqqGvkFj1ehOUDc6YfkyZpd1XG9Ugv6Y1c1lB0qxLn7LKyaSCJyKinCPCxtiA6d2JiNJJBrrN6+uJa1cvIvCfvqiAs4jQBWBK9Kd44N4BL7gvU+VGus9H7uu3ATfgw9gBeOntzzD54j0M+TUXtqAySrlewfuxg1RZ0yCre80ieLl5aZVR0MALWPdGM4tz8fZwQ/eaIZi47Dhu3Y9W2e3KFPDLFinBpe9TwQDvLD0PSRqSnVKtExFRxmONlg1Yo0VEZn8TomJRdewqGd0JXohFeZcwFUw1dTuotsfpXFE6+jd4IA7fe3yBFm5aUoWSUb/h8Pj28PXUfuNKaeDd5uXy46dBiend00ASZ4z4az9eb1UW9Utp/aOIiIjIflijRURk56Zjk5YdUwPiRkTFwRvROO6t1UKJulHTMBZz0MFtJz6N64PdY1ohyM8Lj66VB2ZoAVPVIgGGIEtIenIZY+m9hYfwQpOSqBoSqAbKlfTljZIkWrBV4dw+ap9ERESUtVijZQPWaBHlHKMXHMQfOy8alv8d3hBlg/1Q8YOVZuXquRzDX14fGZYPPHMI1UoXxdlz5xBcsBBy+RibukXNbAfvK9twf/Bm+IVWyaRXQkRERBmBNVpERMmYu+MCPlx0RGWRm/t8PXi5u6JMAX8M+20Ptp29bVb2ycQBaovgJqZ7TsW42P44piuKGLgjfMQFBJxZBJfgSqgWUlSVK1nCZCysRN5PzwXOrodfSGW+J0RERDkEa7RswBotouwjuYFoKxUOwJErEWowYBmnaojbEjznvgKtoj/Hffhiovss9HNfZ/6kvn8B5dpl3skTERGR08QGKQ/SQkSUjVy4/cBqkCUkyCrqch0nvAfi/BA/vOvxBwq63MWH7r8gEPeNQVZIHeOTClXLpDMnIiIiZ8NAi4iyvQlLj6L4O0vR9PMNarmV6x6cDhiGE35D4YeHKOFyFZ6IxXvuc7UnXNgCPD1fzfZy34jtjfZp66s8ZQyuXN2BgKwb14mIiIgcG8fRInLSQXGFp7vlbyVHroTj332X0axcAZX1rlxBf6eqcYqMikv3oLAJCTrcfRgDFxcXzN58FtPWn7FarnPBe3C/E6H+AB72fl6tWxjfEE29TwPueYGmbwOu2kDAwmf3d4BXANB5KuDmASTEaUEXERERUTIYaBE5mYcxcWjy2Xp4ubth8csNkc/PC7HxCfBwc8WD6Dg8PWsH7j2MxQ+bzsHTzRVLX22EMsH+uBEZhbsPYuHh5oJb92NQp3geFZDcfRCDf/dfRq/aofDzSvlPQnyCDq4uUM+T8ZpkEFlXWZEGOp1ONdN7Y94BRMclYM3Ipmpw3R82nsWEZcdUmfc6VMCz9YupQXhTci08CtvP3lbjRtnq+VL30KltX2DNUSBsm2F9N7ctQKwMYDXBGGS9cRr4dxjw1C/AjeOAp6+2XgIuIiIiohQwGYYNmAyDskp0XLwKqPTi4hPQ8NN1uB4RbVjn7+WOyOg4NT+saSnM+M+8FqdXrRB81rMqGn26HpfvPTKs/3FgbQT6eOCV3/fhSniUxbE3vdUcoXkTAwsAyw9dxYtz96r5mkVzY2/YPfSsFYLJvVLvpyTB3JhFh7Hz3B01HlVS3WoUwcJ9l60+V/YvxzF14lokxvx7CLvO34Ut6hT2xIBCYQjMmx+NNz0LBJUFXt4FbP0GWDXGvPA7YYB3+mrUiIiIKPuLsDEZBgMtO15MovQ2d9t94a4arFZqcKTGp8u3W3Docrjani+Xpxr8dsy/hzF3R5jdLnLD0vmw5bR5KvOkPn6yMp4omRdDft2DszcfWC3TonwBfN6zKjacuKlqpO48iFHr21QMxqqj1+12vmM6VsAPm86aBZkpaVepIGY8UxO4fRr4tTsQbnLtKj4JPDVHm4+PA24cASKvAaH1AJ/cdjtnIiIiyn4YaGXBxSRKLaC69SAa+f28VNM7cebmfbSc8p+hTN+6RbFg7yXVpC4l0jdL30/Lmm/61kC9knlRd8Jah3xTJnWvgvaVC+KzlSfwe2Lw+EabshjevDRu3o/GtHWnsebYDbMauORM7FYF/eppY1jpSa1Z0JZxcNk+zfqT3joH+Oa1z4shIiKiHCWCNVqZfzGJUvLqH/uw+MAVFRQMaVwSzSZrGfDSSmpp2lYqqPo5Xbj9UCV/6FS1EKqPX20Iwg6NbaOaHPb8bquqLdMb1bosShfwMzQB1JvWryY6Vi2katPEr9sv4INFRyzGmRrYoLjqy6XvK1bxg5UW5+fj4YZHsfFm6/rUCcWABsVRLtjfok/X6RuRiE+A1aQd5289QL8ftltt2vhO+/Kqj1jX6oUNgavBxZ3A7NbG7ICSvELv9aNAYBGL/RERERHZgoGWHTHQovSKio1H+fdXpPl5E7pVRpMy+VXtTvfpWw3rT37c3mqmQdNaM9NA5rsNZ/DpiuNq/vhH7eDl7qpqy0zP6dj4dvDxtEw6cfhyOC7dfQiJvdpVLmgZzJgM/hsc4IVcXu6Y0qsaahTNo7ZJ0BYTn2DWxyw9ZD8dv96Mo1cjUK9EXvw1tL5lobgY4JeuQNhWLVHFkte09S+sBwpW0TIF3j4DRFwBSjR+rPMhIiKinC2CNVqZfzHJ+d17GIM2X27EDZOEDQfHtkGAt0ea9qMPMsqNST3Imj2gNlpWCFbPuRoehQL+XnB3MwZTW0/fwsZTtzCyddkUgyxrJHnG1LWnUKtYHpXu3fR1ztl6QfULa17euN5RSQAp/b3ql8yHQN8k70V8LPD7U8CZxAGF3byA+GgtHfvoi1lyvkRERJR9MdDKgotJzq/uhDVmQZbe9tEtUTDQ26aAQJr0df52c6plZzxTS9UUkdkFlDDVbAyrVF0/AnzXwLg8bAvwW3eg+w9Ayaa8vERERJQlsQHH0aIcQTLhSVM4GWuqbLCfGnvKlIzpNH/PpWSf//Y/B/HzoDq4/SAGHq6uFrUqkphCxqf6fuNZQ1M9vf71i2F818pqXs7h951heLVFGZsCtxxnbk/g/GagWH2g7SRjwJS0ud+p1cCaccDQjUBQOaBYI6DfX4CXn7b9jZNZcvpEREREekzvbgPWaDknGfdp3p5LWHf8hsW2PWNaGYKthfsu4fW/Dphtz+/vZXW8J70ve1dDp6qFVQBXb2Lymf06VimEaU/XfKzXkWMcmg/8M9i4XLwxcH6TNt/3T6BceyD6PhB1D/iykrFc37+Acu0y/3yJiIgoR4rIyBqtmzdv4siRI6hQoQKCg4Otlrl8+TLCwsJQqlQpFChQwCnKUPYQG5+AnjO24cDFe8mWOXDpHpqXK6D6RJkGWT1qhmB810oqscMHiw7jl20XrD5fnpM0ODP17/CGqB6aQ8ZjkmwZB/8GJFlGlV7a1FYPbgF/9gMu7gA6famtC64CXD8EtP/U2CTwjz7ASzuA6fWA/BWAhq8BW6Zq20wzChIRERE5iDT1rD927BiefvppVK9eHc2bN8fq1Vo6aVMJCQkYPHgwSpcujWHDhqFo0aJ4++23HboMZR/RcfEo895yiyBLmgsuf83Y/Oy5n3erpBcNPtESKOTydMPpCe0x5alqKsgSo9qUU4GXLTzdXBHgrT1vUU4KssT274CFQ4AFL2gB1ze1gbGBwA3zJpQWrh0CPi+lBVmiYFXg7QvAi5uBseFAcCXgqV+1bdI8cEYjbd43H9B6vNakUGq9SrfM4BdIRERElHZpqtE6deoU2rdvjx9//BHe3tb7l0ybNg0LFizAgQMHULZsWezYsQONGzdGnTp10LNnT4csQ9lHt2nGVOji1ZZlMLRJSUPw9Fa7cvhsxQk1f+rGfUO5yb2qmWX6E4E+Hirw+mevse+WZARsUja/WX+uhqXzYe7zTyBHkVqsdR8DFToDe38xrpeAS2/F20D/RcZlafZ3YjlwaB5wLwy4eUxbX7Y9ULUXkL+8sY+VXsUuWtA1szmQEKut6/uHNq36lPYgIiIiyk59tGRMnV9//RXPPPOM2fqaNWuidu3amDlzpmFdx44d1XTp0qUOWSY17KP1eKQfk5+Xe5pTk9vqy9UnVQpzGUz3563nDetPTWivkl8kVfydpVYH602ODKh792GsSoUuSfHkdQyeswtnbt7Hslcbwz+Nqd+zXV+qwWu0AOl/rwNh24zrpdZJmviZDiCsbxZo6rUDQJ7iKR/z3CZgTieg4Qig9Th7vhoiIiIix886GB8fj0OHDmHo0KFm62vVqoVZs2Y5ZBlroqOj1cP0YjqCyKhYTF55Armjr+CeV2E4opA8vhjcqITqpnM/Og5Vxq5S64vm9cWnPapi25lbqFEsD6oUCURQksx/tpIxoKZvOIOZG8+ardcHWf7e7jj4YRurA+yKI+PaotKHK9X830Pro26JvCker3QBf4t1Pw2so6bJHSNTHFsC7PgeeOoXIPwSUKiqtl7Gk5Japi7fAF6W555uj+5qNVmX95ivv7IXqDdUC6qK1geKNQQCCgP5y2kDB+/52VjWNMgacRjIFQR4+KR+bMk6OOIQEGBbU04iIiKirGbXQCsyMhJxcXHIm9f8xjUoKAh37951yDLWTJo0CePGOd6v5o9i4/Hf9u34yeMzNI9JTBxgg6Iu11EQd/Cux1z8ENcJSxMytpnbhGWJTcJMhN15iL4/bDcslwv2x5hOFVA4tw9KBuXCxTuPcPtBNPL4eqJ4UC6L50vQ5uPhpmqRpG9VSt7vWDHFAEiaEUp/LKmlkuyC6ZHhAdbGycB/nwGdvgCKNQDylgSWvw3cOasFJp7+wP7ftLKflQCCKwP9FwMe3sCv3bT1RxYCI48DAcnX1tksbAfwYxvAvzAwYAkQGArUeAY4thio1k8rI1kB5aF3+B/zIEtv6Cbg4W0gd2jaziF30cd8EUREREROGmh5enqq6aNHj8zWP3z40LDN0cpYM3r0aIwcOdKsRis0NI03hRnA19MdswsuRIm713Heux+m1VkNj4RHiPRKftDb4ne3osex1w3L0zy/Rt/QaOwINWn6ZSd7w+5iy+nbNpU9cT0Sz87eaXWbNONbevCqzcf9pm8NdKpaCLvO38XV8EfoWr1Iqs+R/ljpDbIy3LxBwJEF2vyi4dpUkkLsmGFeruUHwNrx2nx0JOCTB7h9Gnj9iDH9+cKhWj+plAJDaQ8ZfhHIU8z6dqkh0wdvkVe0Y9R/SVuWYCs5e+YkOd8PgYJVjDVvRERERNmYXQMtX19fVVskqdRNyXKxYsUcsow1Xl5e6uFopJ+TX/ESwN3Nann4tTGApx/wzD/Asje0rG21BmiF9/6qJQ/Yagyy4B0IRIWj0cXv0ah8iNbcy91+rzMqNh69v9+GA5fCDetcXYA/XngCvWdqtVluri6IT0i5W6AtQdYrLUrjxWalVPCpl1oTQIcWFwNMrabV2lw01vwZ/P0sMHwnsG0asHcOUP1poPEo4ImXtKx/8jkYn0crW3MAkEc+J+eAvCWAle8B7SYmf2zZ3/9GaPMDlwHFGwIJ8YAuQat50gdZJZsBz/5re/r2nj8CV/cD/zyvZQdsbPzxgoiIiCi7s3syjH79+uHcuXPYtk3rFC9N98qUKYPu3btjypQpDlnGqZJhxEUDP3UAitQCdn5vuf3De8DiV4B9v2r9ZfTJCV7ars1v/UZrfiYK1wCGbMjc85es3uFR+G37BXi5u+LrdacQG69Dy/IFsO3sbTyMiU/2eRK0vdC4JN5qV14FbNlCfCxwZj3wey9tudm7wIaJQLtPgWq9gYd3gG8SBzyW1Oc+ubWxp6TvlWmQvHQUsCux36F8Nl7Q0tarNOvi/VuAWzJJO/RlDMvhwA8tgcu7gRbvAzeOAf4FgbYT0vcaYx8Bru7JH5+IiIjIidgaG6Qp0JJ+TZImXcg4Wu+++y5at26NQoUKoVy5cmr98ePHVer0p556Cp06dcJvv/2GzZs3Y//+/aqcI5ax18XMVB8VAOKNCTuUp+drtRtn1mrL5TtpfXeavgW4uhnL/dEPOLHUmDEuVEvsYCY+DnCza4WnTRISdLgeGYX8fl4W6dazHbnGH+UzX/fmGcA7N+DiCrgmvv55A7X+Vq8fBQKLJB/M/NJVG7y39UdarZRpEPXKXiBfKcvnSdAmY1mZkuevft+4/NY5wNeJawuJiIiI7MjW2CBNd7Jnz57F2LFj1aNp06bYsmWLml+8eLGhTPny5dVYVa6urpgxY4YKZmTZNKhxtDJOqWBlbRpUVqvBeHk3MLenMcgS0rSs+WjzIEt0/VbrK6Mf60hfUyb9fXb+oN2cSwBgLZFBBnN1dUGhQJ/sH2Tps/jlyq/N95un1SRJFj4JcPVBlnjyOy3jXnJBlpAEGYNXaTVZ+iBLtPlYm0oTQHHzJHDzhLE2zTTIqv6M1s/LNMgSDLKIiIiIMq/pYE7ikDVaUsMhTc66fG29CdhLO4AC5ZN/vrzt0gRR+vFIk66y7YCdxrHGDCQAKNsGOc7qD4Ht04FnFmipxR+X/msmAe2EYMArEBh5FDi7ASjbNuOa1R34U0uI0e174OZxYHNitsrus7S+YPrmhlKTJkHetHpauTJttWaKofWAOvZPnEJERETkrDKk6WBO5ZCBljVSAyXjFkl2N1sTDyTtn2NN5Z5Az9nIEaRPlD5lut67VwFP3/Q3D5xWF7hzxvp2qcXKSBKM//pkymX0CTCEJL6QLINp+QwRERER5SARGdF0kBxcrYHajXtabpDrmgzm3PgNoOdP2j4kqYbe4fnA1YPI1sK2a0GnBFirxphvm1gIiI1K2/4kZfqje8D3TYxBljTLa5rYVFOflS+jlWqeepmQ2uZNDeu8ANQelKGnRURERJTdMdDK6dpN0qbFGgEt3wcqd9eWJYV34cRsd2LNWDi9TVO0YOpHk0F1xfGlwI9tjculWmjB5mta4hfl6KLk93v9qFaTqK8NEwteAKaUBx7e0pY7TgHePAs0f1fbtzwq90Cmk9ckzUr1ijYwz14YXAnoOFkLComIiIgo3TI/rRw5FkmUIWnD3b0ttw38HzC1OvDghtZ0TlqZ2jqGUlaTc5VxoGQqg+zu/x3YkBhUhm3VBviVFOli2ZvG8ac6TzW+RjeTAOTcRi0dvow5dfUA0GMW4Besbfu7P3D7FHD/JtBtBjCnk1ZG9F4MhNZFlur7J3BhC1CwGpCnuLZu9GXg7HrA3Sdrz42IiIgom2IfrezURyuj6PtxjTqhjafkDMbl0QItUa6jFiSt/9jYfE8GdK7aC6j/MnDrJHBhK1D3BfN9hF8CvqwE5CsN3D5tvq3uEODcJuDmMaDHbOCfxIQRASFAxCVtftByIPQJ8wyCRERERJQjYgPWaJHtHtx0zEDr9hktEUiFzlrK8txFjUGWiHsE1OyvNYuUsaQk096NI8CaI0DYDqDfn1qTuaQCQ4A3TgOn1wD/DjPfZpqhUYIsaQZ4+B9jkNXlG6BYg4x6xURERETk4BhoUeqK1AYu7wbCLxvH33IUP3cCzm/S5rd+bRxbTAZvlsF7pQZLxhlT6dMTm/rFPDQ+P7VmfX75gZJNtXmPXMCre4FV7wOH/jaW6fM7UKYNULQ+kLeENghwlV72fZ1ERERE5FTYdNAGOb7p4MVdwOoPgCZvAKVbIkud3wKcXK71KSvVEvipnbbeOxCICrc9bfq+ucD1w0Dbic7T74yIiIiIshzH0cqCi0kZ6MRy4J8XgJhI4zoZVFcG2d0/F+g2U2vq92c/YPAqIH85vh1EREREZHfso0X27wf1R1/gqTla8og8JYBCVTPnKp9eC/zRx3J9q7FAgQpAs9FA7lBt3TsXMueciIiIiIhSwD5aZJtvEsfUmv6E7c3z7EEG/v2tu7Gv2KBlWpO/iCtAcEVtvT7IIiIiIiJyEMw7TekjCSesiYtJ+75koF8Z7+rYEmB6Ay2ZRFw0cPOENvCvOl45rUmgDK4ryS0kwyARERERkYNioEXpI80HZdBfUxs+BT7ODxxdZPt+Tq4CPisB/NIV+OsZLe36tmnAxwWAaXUBF1dtLKoX1mqDKxMREREROQEGWpR+RxcDGydrAxpf2q0FSOLv/rbv4/fENOjn/jOu2/yFcV7Gpur7B+Dlz3eKiIiIiJwG+2hR+i16yTg/yyTte9XewIG/tPGtCtcA6gw2f540E7y0C7gXZlxXrR9w4Heg1xygZDNgyavaeFcy8K9vXr5LRERERORUOI6WDZjeXWqWFgDrJwC3T2vB05V9lhcqd1GgylNAg1eAT4sZ1486CfibDBb87zDz5oVSvvVHQEI84MbYn4iIiIgcF9O7k31V7g5U7AqsHacNFPxLF+O22s8BbT4Gou9rtU8fBZk/d/9vQONRQHwcMLGQ+bZcBYBGI7VBgxlkEREREVE2wT5alIZPixvQejxQsinQ+Wvjekm17plLq7Vy8zCu7/a9Nj2zXpvu+9V8f6/sBd48xaaBRERERJTtsJ0WpY8EVqLhCKDRCPNtT/0C3L0AVOujNTk8vUZL4f6/xHIVumhlpBaLiIiIiCgbYqBF6fPwtjaNfQj45DHfJk0M9Wr2B/KVAs5vBtpMAFa9B7QYwyCLiIiIiLI1Nh2k9CnZXJuWbp1yuQqdtKDs72e1+bHhQP5yvOpERERElK0x0KL0yV9WC5rKtrG9meHUakDEVV5xIiIiIsr2GGhRxntiuHFemhoSEREREWVz7KNFGS+otJalUMbeylOCV5yIiIiIsj0GWpQ5ag3QHkREREREOQCbDhIREREREdkZAy0iIiIiIiI7Y6BFRERERERkZwy0iIiIiIiI7IyBFhERERERkZ0x0CIiIiIiIrIzBlpERERERER2xkCLiIiIiIjIzhhoERERERER2RkDLSIiIiIiIjtjoEVERERERGRnDLSIiIiIiIjsjIEWERERERGRnTHQIiIiIiIisjN3e+8wO9LpdGoaERGR1adCRERERERZSB8T6GOE5DDQskFkZKSahoaG2uO9ISIiIiKibBAjBAYGJrvdRZdaKEZISEjAlStX4O/vDxcXlyyPoCXgu3jxIgICAvju8Lrx8+ag+F3ldePnzTnwu8rrxs+bc4hwoHtgCZ8kyCpcuDBcXZPvicUaLRvIBQwJCYEjkQ9YVn/InBGvG68bP3POgd9VXjd+5pwDv6u8bjn1MxeYQk2WHpNhEBERERER2RkDLSIiIiIiIgZaOZuXlxc+/PBDNSVeN37eHBe/q7xu/Lw5B35Xed34eXMOXk54D8xkGERERERERHbGpoNERERERER2xkCLiIiIiIjIzhhoERERERER2RnH0coCt27dwt69e1VnvurVq1vNwx8fH4/t27fj5s2bqFatGkqUKJGuMvfv38eePXsQHh6O4sWLo2rVqnBWMTEx2Ldvn7p+FSpUQMmSJa2WO336NA4fPozg4GDUq1fP6kBy169fx3///YcyZcqgRo0a6d6Pszhy5AjOnj2rBvqTz5w1cl23bdsGb29vNGrUCD4+PhZlHj16hFWrVqltbdq0SfexnIV8TuQz5+vrqz4nMmh5UnFxcdi6dSvu3r2LmjVrqtdtjXxXz58/j44dO1rdj55cu507d6rvtHzOnZH83ZG/cfJ5kb85hQoVslruwIED6vXKd1lerzVnzpzB7t27Ubt2bZQqVcpqGTmOfHblb2KDBg2QK1cuOCMZAFP+5sjnpFixYsn+vb5x44b6PMnrbNiwofrOJvXgwQOsXr1afdZatmyZ7N+448ePq+fLdzUoKAjO6sKFCzh06JB6DfI99PT0tCjz8OFDbNmyBdHR0ahfvz7y5cuXYftxpgFg5bsaGxurPm/y/84aKSPXRv5nVq5cOV37sfVYzkBeg/z9unbtGsqVK6euizXnzp1T5fLnz48nnngCbm5u6dqP3sKFC9W0W7ducFbnzp1T9wlyTeQ75uHhYfXv1+bNm9X/V/mbnidPnnTtR8j/GPm7Kv9nrH12M5yOMk1MTIzuueee0xUuXFjXpk0bXb169XSBgYG633//3azczZs3ddWrV9cVLVpU17JlS52vr69u/PjxaS6zfPlyXZ48eXQ1a9bUdenSRRcUFKRr0qSJLiIiQudsfv31V12JEiV0devW1XXo0EHn5+ene+aZZ3RxcXFm5d5++21drly5dK1bt1bX+YknntDdu3fPsP3y5cu6Pn366IoUKaKu/WuvvWb1eKntx1ns2LFDV6NGDV2lSpV0nTp1Uq+lTp06umvXrpmV+/vvv9XrbdiwoSor5Q4cOGDYHh8frxsxYoSuUKFCupCQEF2tWrXSfSxn8ODBA/X5ks9J27ZtdbVr19blzZtXt3DhQrNy8nmqWLGi+my2aNFC5+Pjo5s8ebJZmX/++UdXpUoVXZkyZXTyJ/fYsWPJHvf+/ftqf+7u7rpJkybpnNHEiRPVdWvcuLH6/sg1GTNmjFmZ2NhYXc+ePdXfJ/lbKFNZlvV68vmTa1+qVCl1Pb777jurx1u0aJEuX7586jPZuXNndf327t2rczabN2/WVa1aVX1W5HUULFhQV79+fd2tW7fMyv3222/quyrXt0KFCrrQ0FDd0aNHzf7PvPLKK+q7Ku+DfKeTkr+bTz/9tPo72rFjR12jRo3U/5AZM2bonM2lS5d07du31xUvXly9lvLly+uKFSum27lzp1m5ffv2qWsi11euibx2+W7aez/O5P3331d/p5s2bWr4+yXfX1NRUVHqesj9g3xX5f9m//791f+EtOzHljLOYt68eervkvxfkGsTEBCg69Gjhy46OtqsnNyTyfdK7tHkeyr/H+XeLa370fvqq690np6e6u+dMzp//rz6n1CyZEl1jyD/E2VevlOm5DtXoEABXbVq1dTfQH9/f92SJUvSvB/57D777LPq+XJ95Z67b9++uszGQCuTb95mz55tdjPx2Wef6by8vHQ3btwwrBswYID6hys3XWLFihXqBm3btm1pKlO2bFkV2OnJMeQD98UXX+iczZ9//qm7cuWKYfnUqVPqZmPatGmGdatXr9a5uLjotmzZopYlMJI/Yi+//LKhjNyQSGArf8jkxsxaoGXLfpzFunXrdPv37zf7DMoNgukfG/lcyM3C559/rpYTEhJ03bp1U4G86c2bfG5u376trpm1QMuWYzmLO3fuqODeNJD/4IMP1D9N0x8q5J+iBP/yB13/j1M+OwcPHjSUkc+bBA27du1KNdAaNGiQbtSoUbrg4GCnDbTkb1x4eLjZ50Je99q1aw3rvvnmG13u3Ll1586dU8tnzpxRNxnffvutoczGjRt1y5YtUzd08l23FmgdOnRI3XhMnTrVsE7+TiS9OXYG8ndHXo+efM7kZl8+E6avTW5Q5foJuTbyw5P8EGT6vZPrcffuXd3QoUOtBlpyXeU9Mf0xRT5vci31n2VnId8n+VFRT66J/JgmN1+mKleurOvdu7dh+aOPPlKfOfmbZs/9OJOZM2ca7iHE4sWL1edi+/btZp8LuemVH5X0/0PlM/jzzz+naT+2lHEW8+fP14WFhRmWL1y4oH4s+vTTTw3rtm7dql6ffK+FvHb5EXLgwIFp2o+e/Hgkwdobb7zhtIHWoUOHDNdD/x2Tew25TzBdJ985ucfVe++999QPnfr/vbbsR7z44ovqml28eNHsh7nMxkAri8kNhnwZ//vvP7UsAYD8ETMNIIT8Squ/0beljJBf40xrueQGWn7h/OSTT3TZgfzS8cILLxiW5Ysp60zJL2byh0tee1LJBVpp3Y+zefPNN9Uv4Xo//PCDztvb2+yfoNzkyufy8OHDFs9PLtCy5VjOTG5K5Zrs2bNHLcsffalpmTNnjlk5+d698847Fs9PLdCSgEx+wZMbXWcOtKyR786UKVMMy/LLomkAIeRXctOAwVRygZZ8V+XvXnYlNVPyK7ieBFjyo4hpMLRq1Sr1uZIfn5JKLtCSmln5QcA0IP7rr7/U5zkyMlLn7OS1yDXR/02TX7plWWrd9eQHNAksTQOGjNqPM5HX8v333xuWJTiQz6Gp7t2761q1apWm/aS3jLOQGhbTHxWHDx9uceP/9ddfqx/rkquxsrYfId/JcuXKqVod+UHUWQMta+T/p6urq6ECQioLkv4IJD8Gu7m5qR/cbd2PPMfDw0MF+FnNeTudZBNr1qxRbXbLly9v6I8g/Q2StiOtUqWKajduaxnxzTff4Mcff8SECRPw008/oVevXihdujSGDh0KZyf90g4ePGh2DeS1W7sm0nfm8uXLNu/bXvtxRPLjyrp16yyum/TvM+3XIq9Xv82ex3L276r0q9S3oZe+LdJ+POnrk+W0Xjf5To8YMQJz5851qoEYbSF9MuS7Y8t3Na3XbePGjaqvoPRZWrx4seo3I38bswPpb7ZhwwaL6yafP9PPiP67Kn0QbNWpUyf1/6Br166YPXs2vvzyS4wZMwZTp06Fn58fssN31fRvmv5zZXotpW+09KdM6TNnr/04C/k+SV9o/euTv2/Hjh1L83c16X7SW8ZZ3Lt3T/UjteVvnPTvkz5Dtu5HvPTSS2jRooX63mY3a9asUX3T3N3dDdfNxcUFlSpVMpSRPlgFCxZM9btquh/psyv93+T/w65du7BkyRKcPHkSWYHJMLLQiRMn8Pbbb2PUqFEoUKCAWidJK0TevHnNykpnW7mxs7WMkI5/8g9g3rx5aiodLvv16+e0HcVNb0D69++PwoULY/DgwYb1cl2sXRP9H7CQkBCb9m+v/Tii8ePH4+jRo5gzZ06Krzd37tzqBwB5vfY8lrOS784HH3yA9957z5DIIqXvoQROtpJ/Bn369FE3uqb/XLID6fz+7LP/b+/cXataojg89x+wsxLEyiK9dr5AMQhKUNFCxbf4FkVFbRJQRHyAhW+7KL6IEiG+Ixba2KUU7VS0UZuAWnkv34J1mLOzdzInbOOZ6++DiJ6zzxz3ZGb2WmvW+s0aE2NYsGBBw3jD2CjrNwqged8flmOBg8U6OnPmTDNi6HeEOO7evRtmzJgRcobxQLF3X19f8tqUCv2LiAMOFu0PDw+bIEZHR0fIHQwqnMebN2829RuiFojaFPuuqt/qaicXvn79GjZs2BCWLFli4gPAXPr161fpmKu637J2xnNNLhBQxA7huYBDFI8VD4KkzNWqdq5fv26OAgGr/xt9fX12f/39/U39NmnSpBGiIaONubJ2eDbgsB04cCC8e/fO7EWce8bctWvXJlTcTI7WHwJVKQwPfo4fP9543SOVLHAx/NuVpVKuIVLU2dkZli1bFs6ePdtY3FD6QTGuu7s75AiL/tq1a203C9XA2GmkX8r6BMpUuaqoq5124/z58zbWMEJjg77sfn/+/GkO7Xjvt+q7cgRDnrnEDgDGr5MyD1P76vPnzxa1u3XrVqP/GeMoTOWqLoXThMIihinBHh56buTzEC3rN95LdbKAfn79+rXt5qBsyPqwYsUKM+Jy3mE4c+aMrdvs0k2fPr32tenGjRvh0KFDpqjpypYXL1603xdKhFUqke0OO4ArV64Mx44ds3EQ9xvPRIIasTJZ1Vytq51cwLhljUNpEYN1vGtcVTutXpMLOEdbt24NL1++DC9evLAA5XjmalU77M5v27bNHK/79+/ba0NDQzYGeVaghlymNp0DT58+DatXr7a1Ducn7jeeHUWqxlxVO1xLvzLO7ty503iWo67K7mAcpP/dKHXwD4BE6ty5cy0KywMv9tyZNBgk79+/H/EZlzNPuYbI7sePH804jCMC8+bNs4mcIxhR69evt3Q07oE0yBikn8v6BMNt6tSpyd9TVzvtBEbUvn37zOAtph9wv4wV+jcOBECVhP54vys3SDVgUSYgQnTbnQVwqfHR5mEKRNqQ0yca5z/IRiNb++jRo5Czk8WOFikdRWle+qes31o1GvgdEBF3x4AoJY4pfYezmiM4WDj0ONm+CzjW2gStjDmCVETb4+MDeFaw04jjmiPcE+vNkSNHwuHDh5ve87n64cOHxmsEkkgFL/ZbXe3kAnOU9CrmzpMnT5qOniAoy9xKWeNGa6eVa3IBI37Hjh3h3r174fnz5yMCilVzlWdIvM6N1g7vsY7yOX82sLbxfODvxfZz4dmzZ6GrqyscPXo07N27d0S/kdXw6dOnxms4lsjfF8fcWO0Amw0OqYWkZXLk0YTyp4vE/jZQlUE+FrWyWH0whuJlpI5j2VmKlJH1Tb2GImeKnRE6cBByQMQAWd/cQFWGwndkdd+8eVN6DcXyFJrG6k8UlnZ2dpZeXyWG0Wo77Q6SzShb9vf3l76PQh5LweDgYOO1np4eU/kpK9odTQxjrO/Kibdv35ocMfKwsZRxDOIVsYoUggTMu7L7T1EddHIWw0D1DglnVFFjKePiGEJZCjVLYJyh7MkRAq2IYaD4RpF4/PtBoQqVtBxBLRBhGlQBy/AxhBS8g/AKUvDFoy5GE8Og3yZPnvzv9+/fRwjg8B25wf+dMVI84sT58eOHqVzGQlADAwM2V+P5WFc7uYCdgDANyqlVx5ds3LjR1jkfX4wZ1sX4yIaUdlKuyYnt27eb5H0s2hCDai1iDLFacldXlx2l0Eo7RXIXwxgcHDQxNxS3y0D4A8EfpOxjdUaELhCPS20H+5p1MVayZeyy7k30sQL/8MfEunZ/L+TBs21JNOLEiRNNKTJEZX23hCI+dp7YDuWQ1EuXLlnBLZE23/1KuYZdBaLwePrUaA0MDFgUic9WHQ7arnAvRHp7enqaUmmmTJkSZs2aZX+nXz3fm21hDpEl6sOhd35wLikfpLMB9TZEOKj3IuK+cOHC5HZygXslUk1tXry7RDrX0qVLG/9GIIXUBPKZv337Fk6dOhUuX75sO4jO48ePLUe6t7fXtuARWYHly5fbWE79rhygD5gjpAeRPhTnc8+ePdt2oYDdVdJgNm3aZLsD586ds3lMOoPvflFM7gfzMuZIceDz1BBVHcBL4S/iGKR35caiRYtsx/n06dNNB7ki+OPzh+gkBxATvSUiye4N9XwUgnPvnupMxBLWrVtna938+fPtIF/qi3xNJX2GSCc7WeTiI+hAOibpgzlBdsOqVatszsWHgbOrgGiFQ+o0kW9qezlQm/FEHSTzznnw4IH1Des/uy+sm0A6HOOSz/G7YPyx/rHTwPrKmOc5Ee/ctjuMG8YAO3S7d+9ueo91yMU9rl69Gnbu3BkOHjxoz8qTJ09aWiBzts52coK1jPRR5ir34hD1d0EGdk2Yq/QNc/v27dv2GnPVa7dS2km5Jheo12UXhV3PuA6LWnsyIIBdmTlz5tjcIi2QGivEjkhL5eDi1HaK0H/Yj1++fAm5MTQ0ZAesM55IiYxhjWOtcyE35hcaBtRDcr+s59x7K+2QVbN582azgbEVqc1i7LKjVaw7/J3I0Zpgpbxdu3aVvrdnz56G8QBsD6MYiLGBI4UhXMxPTbnm4cOHlrqDgcx2NQYLTlduYCjEQh8Op4EzIeOUJVLXqM/g1HkmWXzSOqkxZQbYtGnTbDKntpMLFHJ7bncMtW0YYQ7xFgw9HAdypDEcSG+NwQmLU2YcjDw+k/pdOUAq5f79+0vfY/Fnvjks+vQByno4T4wVnEuHOhv6tgjOGY5DGTyYMWrinPNc2LJlixkXRRYvXmyOhIOxzxzDAcVR4oHJXHOoFYpr4hyCILEhzHcRFGA95PM4/jkKYRDAYL0uQr0GgTSHFF8MBoJqGCEIqZB6GoOTjjNbhHHoQQPGK88P1lWeGxh/tFUsQm93SHVE1KMMnO54TLG+UThPMI25x/26U1lXOzmBk01aVhHmED8OqZGMQYxUnoPM1TiIktJO6nflALYC634RxGRwnhzSl1mbcDDpLwK3seBMajsxBEJ4ply5ciXkxqtXryoDEhcuXGhyfghWkk6Jw0oQPC6DaaUd5jXrHjYdgT2euxOdsipHSwghhBBCCCFqRmIYQgghhBBCCFEzcrSEEEIIIYQQombkaAkhhBBCCCFEzcjREkIIIYQQQoiakaMlhBBCCCGEEDUjR0sIIYQQQgghakaOlhBCCCGEEELUjBwtIYQQQgghhKgZOVpCCCGEEEIIUTNytIQQQgghhBCiZuRoCSGEEEIIIUTNyNESQgghhBBCiFAv/wGkwHxkIoHVMgAAAABJRU5ErkJggg==", + "image/png": "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", "text/plain": [ "
" ] @@ -163,7 +163,7 @@ }, { "cell_type": "markdown", - "id": "851e1be0", + "id": "e60b67ad", "metadata": {}, "source": [ "## Performance by year" @@ -172,13 +172,13 @@ { "cell_type": "code", "execution_count": 4, - "id": "bcf0cda6", + "id": "e7bbed6d", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:01:14.295380Z", - "iopub.status.busy": "2026-08-25T17:01:14.295092Z", - "iopub.status.idle": "2026-08-25T17:01:14.363387Z", - "shell.execute_reply": "2026-08-25T17:01:14.362377Z" + "iopub.execute_input": "2026-09-02T11:17:00.809206Z", + "iopub.status.busy": "2026-09-02T11:17:00.808909Z", + "iopub.status.idle": "2026-09-02T11:17:00.917174Z", + "shell.execute_reply": "2026-09-02T11:17:00.916020Z" } }, "outputs": [ @@ -217,13 +217,13 @@ " \n", " 0\n", " Full sample\n", - " 1.729543\n", - " 0.057462\n", - " 0.443716\n", - " -0.158208\n", - " 0.089985\n", - " 67.055796\n", - " 277\n", + " 1.635962\n", + " 0.055411\n", + " 0.427824\n", + " -0.157609\n", + " 0.088476\n", + " 69.768469\n", + " 1514\n", " \n", " \n", " 1\n", @@ -239,189 +239,189 @@ " \n", " 2\n", " 2009\n", - " 0.092668\n", - " 0.092668\n", - " 0.801381\n", - " -0.056493\n", - " 0.090761\n", - " 1.801406\n", - " 15\n", + " 0.095022\n", + " 0.095022\n", + " 0.830386\n", + " -0.056402\n", + " 0.090113\n", + " 2.053810\n", + " 77\n", " \n", " \n", " 3\n", " 2010\n", - " 0.093298\n", - " 0.093298\n", - " 0.615134\n", - " -0.091070\n", - " 0.125198\n", - " 4.190302\n", - " 28\n", + " 0.090933\n", + " 0.090933\n", + " 0.605746\n", + " -0.092496\n", + " 0.123149\n", + " 4.223507\n", + " 59\n", " \n", " \n", " 4\n", " 2011\n", - " 0.004023\n", - " 0.004023\n", - " -0.109156\n", - " -0.073369\n", - " 0.100413\n", - " 6.713500\n", - " 25\n", + " -0.002498\n", + " -0.002498\n", + " -0.182903\n", + " -0.074265\n", + " 0.097250\n", + " 6.897623\n", + " 91\n", " \n", " \n", " 5\n", " 2012\n", - " 0.020651\n", - " 0.020817\n", - " 0.041289\n", - " -0.065115\n", - " 0.063776\n", - " 3.600000\n", - " 12\n", + " 0.018649\n", + " 0.018799\n", + " 0.009690\n", + " -0.064629\n", + " 0.063098\n", + " 3.839803\n", + " 85\n", " \n", " \n", " 6\n", " 2013\n", - " 0.168489\n", - " 0.168489\n", - " 1.773823\n", - " -0.043644\n", - " 0.078258\n", - " 6.600000\n", - " 22\n", + " 0.168726\n", + " 0.168726\n", + " 1.785126\n", + " -0.043570\n", + " 0.077859\n", + " 6.727745\n", + " 125\n", " \n", " \n", " 7\n", " 2014\n", - " 0.067877\n", - " 0.067877\n", - " 0.611583\n", - " -0.057080\n", - " 0.079898\n", - " 3.000000\n", - " 10\n", + " 0.067057\n", + " 0.067057\n", + " 0.605993\n", + " -0.056584\n", + " 0.079287\n", + " 3.134767\n", + " 118\n", " \n", " \n", " 8\n", " 2015\n", - " 0.020909\n", - " 0.020909\n", - " 0.049578\n", - " -0.072616\n", - " 0.082531\n", - " 4.800000\n", - " 16\n", + " 0.018298\n", + " 0.018298\n", + " 0.017501\n", + " -0.072169\n", + " 0.081181\n", + " 4.989966\n", + " 80\n", " \n", " \n", " 9\n", " 2016\n", - " -0.050045\n", - " -0.050045\n", - " -0.820584\n", - " -0.124595\n", - " 0.082768\n", - " 4.800000\n", - " 16\n", + " -0.050989\n", + " -0.050989\n", + " -0.844310\n", + " -0.123632\n", + " 0.081721\n", + " 4.910285\n", + " 78\n", " \n", " \n", " 10\n", " 2017\n", - " 0.138595\n", - " 0.139184\n", - " 1.773051\n", - " -0.026354\n", - " 0.063363\n", - " 4.200000\n", - " 14\n", + " 0.136336\n", + " 0.136915\n", + " 1.751759\n", + " -0.026207\n", + " 0.062981\n", + " 4.339895\n", + " 102\n", " \n", " \n", " 11\n", " 2018\n", - " -0.062911\n", - " -0.063154\n", - " -0.653702\n", - " -0.148574\n", - " 0.119488\n", - " 1.481822\n", - " 12\n", + " -0.063587\n", + " -0.063832\n", + " -0.677365\n", + " -0.146689\n", + " 0.116840\n", + " 1.565970\n", + " 72\n", " \n", " \n", " 12\n", " 2019\n", - " 0.114516\n", - " 0.114516\n", - " 1.315085\n", - " -0.044113\n", - " 0.069060\n", - " 2.947272\n", - " 12\n", + " 0.114846\n", + " 0.114846\n", + " 1.326906\n", + " -0.044216\n", + " 0.068646\n", + " 3.120137\n", + " 89\n", " \n", " \n", " 13\n", " 2020\n", - " 0.084728\n", - " 0.084380\n", - " 0.584587\n", - " -0.117249\n", - " 0.115865\n", - " 4.223959\n", - " 20\n", + " 0.074482\n", + " 0.074176\n", + " 0.518577\n", + " -0.119517\n", + " 0.111400\n", + " 4.419294\n", + " 84\n", " \n", " \n", " 14\n", " 2021\n", - " 0.063758\n", - " 0.063758\n", - " 0.461815\n", - " -0.059802\n", - " 0.101710\n", - " 4.233533\n", - " 19\n", + " 0.062024\n", + " 0.062024\n", + " 0.450489\n", + " -0.059692\n", + " 0.100340\n", + " 4.391078\n", + " 77\n", " \n", " \n", " 15\n", " 2022\n", - " -0.108013\n", - " -0.108419\n", - " -1.090869\n", - " -0.155209\n", - " 0.117235\n", - " 4.264002\n", - " 19\n", + " -0.109274\n", + " -0.109685\n", + " -1.123430\n", + " -0.155344\n", + " 0.115299\n", + " 4.380334\n", + " 71\n", " \n", " \n", " 16\n", " 2023\n", - " 0.085133\n", - " 0.085842\n", - " 0.896149\n", - " -0.066147\n", - " 0.072519\n", - " 1.800000\n", - " 7\n", + " 0.083344\n", + " 0.084038\n", + " 0.879825\n", + " -0.066065\n", + " 0.071925\n", + " 1.953579\n", + " 83\n", " \n", " \n", " 17\n", " 2024\n", - " 0.180563\n", - " 0.180563\n", - " 1.778603\n", - " -0.055256\n", - " 0.084094\n", - " 4.200000\n", - " 14\n", + " 0.179865\n", + " 0.179865\n", + " 1.785168\n", + " -0.054600\n", + " 0.083422\n", + " 4.378457\n", + " 121\n", " \n", " \n", " 18\n", " 2025\n", - " 0.175104\n", - " 0.176622\n", - " 1.573983\n", - " -0.064234\n", - " 0.093412\n", - " 4.200000\n", - " 16\n", + " 0.169145\n", + " 0.170608\n", + " 1.544399\n", + " -0.063341\n", + " 0.091787\n", + " 4.442217\n", + " 102\n", " \n", " \n", "\n", @@ -429,46 +429,46 @@ ], "text/plain": [ " Period Return CAGR Sharpe Max Drawdown Volatility \\\n", - "0 Full sample 1.729543 0.057462 0.443716 -0.158208 0.089985 \n", + "0 Full sample 1.635962 0.055411 0.427824 -0.157609 0.088476 \n", "1 2008 0.000000 0.000000 0.000000 0.000000 0.000000 \n", - "2 2009 0.092668 0.092668 0.801381 -0.056493 0.090761 \n", - "3 2010 0.093298 0.093298 0.615134 -0.091070 0.125198 \n", - "4 2011 0.004023 0.004023 -0.109156 -0.073369 0.100413 \n", - "5 2012 0.020651 0.020817 0.041289 -0.065115 0.063776 \n", - "6 2013 0.168489 0.168489 1.773823 -0.043644 0.078258 \n", - "7 2014 0.067877 0.067877 0.611583 -0.057080 0.079898 \n", - "8 2015 0.020909 0.020909 0.049578 -0.072616 0.082531 \n", - "9 2016 -0.050045 -0.050045 -0.820584 -0.124595 0.082768 \n", - "10 2017 0.138595 0.139184 1.773051 -0.026354 0.063363 \n", - "11 2018 -0.062911 -0.063154 -0.653702 -0.148574 0.119488 \n", - "12 2019 0.114516 0.114516 1.315085 -0.044113 0.069060 \n", - "13 2020 0.084728 0.084380 0.584587 -0.117249 0.115865 \n", - "14 2021 0.063758 0.063758 0.461815 -0.059802 0.101710 \n", - "15 2022 -0.108013 -0.108419 -1.090869 -0.155209 0.117235 \n", - "16 2023 0.085133 0.085842 0.896149 -0.066147 0.072519 \n", - "17 2024 0.180563 0.180563 1.778603 -0.055256 0.084094 \n", - "18 2025 0.175104 0.176622 1.573983 -0.064234 0.093412 \n", + "2 2009 0.095022 0.095022 0.830386 -0.056402 0.090113 \n", + "3 2010 0.090933 0.090933 0.605746 -0.092496 0.123149 \n", + "4 2011 -0.002498 -0.002498 -0.182903 -0.074265 0.097250 \n", + "5 2012 0.018649 0.018799 0.009690 -0.064629 0.063098 \n", + "6 2013 0.168726 0.168726 1.785126 -0.043570 0.077859 \n", + "7 2014 0.067057 0.067057 0.605993 -0.056584 0.079287 \n", + "8 2015 0.018298 0.018298 0.017501 -0.072169 0.081181 \n", + "9 2016 -0.050989 -0.050989 -0.844310 -0.123632 0.081721 \n", + "10 2017 0.136336 0.136915 1.751759 -0.026207 0.062981 \n", + "11 2018 -0.063587 -0.063832 -0.677365 -0.146689 0.116840 \n", + "12 2019 0.114846 0.114846 1.326906 -0.044216 0.068646 \n", + "13 2020 0.074482 0.074176 0.518577 -0.119517 0.111400 \n", + "14 2021 0.062024 0.062024 0.450489 -0.059692 0.100340 \n", + "15 2022 -0.109274 -0.109685 -1.123430 -0.155344 0.115299 \n", + "16 2023 0.083344 0.084038 0.879825 -0.066065 0.071925 \n", + "17 2024 0.179865 0.179865 1.785168 -0.054600 0.083422 \n", + "18 2025 0.169145 0.170608 1.544399 -0.063341 0.091787 \n", "\n", " Turnover (x) Number of Trades \n", - "0 67.055796 277 \n", + "0 69.768469 1514 \n", "1 0.000000 0 \n", - "2 1.801406 15 \n", - "3 4.190302 28 \n", - "4 6.713500 25 \n", - "5 3.600000 12 \n", - "6 6.600000 22 \n", - "7 3.000000 10 \n", - "8 4.800000 16 \n", - "9 4.800000 16 \n", - "10 4.200000 14 \n", - "11 1.481822 12 \n", - "12 2.947272 12 \n", - "13 4.223959 20 \n", - "14 4.233533 19 \n", - "15 4.264002 19 \n", - "16 1.800000 7 \n", - "17 4.200000 14 \n", - "18 4.200000 16 " + "2 2.053810 77 \n", + "3 4.223507 59 \n", + "4 6.897623 91 \n", + "5 3.839803 85 \n", + "6 6.727745 125 \n", + "7 3.134767 118 \n", + "8 4.989966 80 \n", + "9 4.910285 78 \n", + "10 4.339895 102 \n", + "11 1.565970 72 \n", + "12 3.120137 89 \n", + "13 4.419294 84 \n", + "14 4.391078 77 \n", + "15 4.380334 71 \n", + "16 1.953579 83 \n", + "17 4.378457 121 \n", + "18 4.442217 102 " ] }, "execution_count": 4, @@ -484,7 +484,7 @@ }, { "cell_type": "markdown", - "id": "f9f03cae", + "id": "2d165dd5", "metadata": {}, "source": [ "## Out-of-sample validation (walk-forward)\n", @@ -499,13 +499,13 @@ { "cell_type": "code", "execution_count": 5, - "id": "e4cd3c88", + "id": "53f12641", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:01:14.365438Z", - "iopub.status.busy": "2026-08-25T17:01:14.365147Z", - "iopub.status.idle": "2026-08-25T17:35:08.180540Z", - "shell.execute_reply": "2026-08-25T17:35:08.179616Z" + "iopub.execute_input": "2026-09-02T11:17:00.919554Z", + "iopub.status.busy": "2026-09-02T11:17:00.919218Z", + "iopub.status.idle": "2026-09-02T12:26:45.246283Z", + "shell.execute_reply": "2026-09-02T12:26:45.244264Z" } }, "outputs": [ @@ -551,234 +551,234 @@ " 0\n", " 189\n", " 21\n", - " 0.407720\n", - " 0.002839\n", - " -0.133096\n", + " 0.395150\n", + " 0.002246\n", + " -0.148399\n", " \n", " \n", " 1\n", " 1\n", " 252\n", " 21\n", - " 0.377440\n", - " 0.086610\n", - " 2.145034\n", + " 0.381388\n", + " 0.086119\n", + " 2.141740\n", " \n", " \n", " 2\n", " 2\n", " 252\n", " 21\n", - " 1.499158\n", - " 0.033276\n", - " 0.583157\n", + " 1.509166\n", + " 0.032850\n", + " 0.577043\n", " \n", " \n", " 3\n", " 3\n", " 126\n", " 21\n", - " 1.792590\n", - " 0.037070\n", - " 1.118928\n", + " 1.791084\n", + " 0.036955\n", + " 1.126486\n", " \n", " \n", " 4\n", " 4\n", " 189\n", " 21\n", - " 1.279034\n", - " 0.008293\n", - " -0.007815\n", + " 1.263127\n", + " 0.007165\n", + " -0.040030\n", " \n", " \n", " 5\n", " 5\n", " 189\n", " 21\n", - " 1.389653\n", - " -0.043895\n", - " -0.978858\n", + " 1.362616\n", + " -0.046294\n", + " -1.043426\n", " \n", " \n", " 6\n", " 6\n", " 252\n", " 21\n", - " -0.151705\n", - " 0.065673\n", - " 1.240997\n", + " -0.186246\n", + " 0.063801\n", + " 1.217540\n", " \n", " \n", " 7\n", " 7\n", " 126\n", " 21\n", - " 0.271631\n", - " -0.050807\n", - " -1.400648\n", + " 0.269069\n", + " -0.051130\n", + " -1.423099\n", " \n", " \n", " 8\n", " 8\n", " 126\n", " 0\n", - " 0.805464\n", - " 0.052652\n", - " 1.356497\n", + " 0.798420\n", + " 0.051993\n", + " 1.344901\n", " \n", " \n", " 9\n", " 9\n", " 189\n", " 21\n", - " 1.404681\n", - " 0.079879\n", - " 2.206585\n", + " 1.260655\n", + " 0.078582\n", + " 2.180407\n", " \n", " \n", " 10\n", " 10\n", " 252\n", " 21\n", - " 1.805294\n", - " -0.006467\n", - " -0.232787\n", + " 1.783855\n", + " -0.006399\n", + " -0.236018\n", " \n", " \n", " 11\n", " 11\n", " 189\n", " 0\n", - " 0.957852\n", - " -0.061822\n", - " -1.261396\n", + " 0.958278\n", + " -0.062078\n", + " -1.306505\n", " \n", " \n", " 12\n", " 12\n", " 189\n", " 0\n", - " -0.318594\n", - " 0.043414\n", - " 1.135982\n", + " -0.322698\n", + " 0.043979\n", + " 1.165922\n", " \n", " \n", " 13\n", " 13\n", " 189\n", " 21\n", - " 0.403091\n", - " 0.008597\n", - " -0.019312\n", + " 0.412977\n", + " 0.007426\n", + " -0.057245\n", " \n", " \n", " 14\n", " 14\n", " 189\n", " 21\n", - " 2.000383\n", - " -0.049931\n", - " -0.422043\n", + " 2.002463\n", + " -0.057625\n", + " -0.536678\n", " \n", " \n", " 15\n", " 15\n", - " 252\n", + " 189\n", " 0\n", - " 0.623380\n", - " 0.077301\n", - " 1.298620\n", + " 0.569079\n", + " 0.090356\n", + " 1.520040\n", " \n", " \n", " 16\n", " 16\n", " 189\n", " 0\n", - " 0.924852\n", - " 0.071764\n", - " 1.128256\n", + " 0.918630\n", + " 0.069706\n", + " 1.107110\n", " \n", " \n", " 17\n", " 17\n", " 126\n", " 21\n", - " 1.697381\n", - " 0.041611\n", - " 0.756638\n", + " 1.740712\n", + " 0.041169\n", + " 0.754846\n", " \n", " \n", " 18\n", " 18\n", " 189\n", " 21\n", - " 1.116374\n", - " -0.114931\n", - " -2.123303\n", + " 1.099372\n", + " -0.115773\n", + " -2.185809\n", " \n", " \n", " 19\n", " 19\n", " 189\n", " 21\n", - " -0.830122\n", - " -0.041382\n", - " -0.945299\n", + " -0.864776\n", + " -0.040545\n", + " -0.950149\n", " \n", " \n", " 20\n", " 20\n", " 252\n", " 0\n", - " -0.994465\n", - " 0.055433\n", - " 1.417782\n", + " -1.022764\n", + " 0.054483\n", + " 1.399485\n", " \n", " \n", " 21\n", " 21\n", " 252\n", " 0\n", - " 0.500976\n", - " 0.021741\n", - " 0.340016\n", + " 0.488519\n", + " 0.020724\n", + " 0.316222\n", " \n", " \n", " 22\n", " 22\n", " 126\n", " 0\n", - " 1.234343\n", - " 0.080233\n", - " 1.802894\n", + " 1.221196\n", + " 0.079943\n", + " 1.809288\n", " \n", " \n", " 23\n", " 23\n", " 252\n", " 21\n", - " 1.492340\n", - " 0.077561\n", - " 1.438909\n", + " 1.471373\n", + " 0.077291\n", + " 1.447278\n", " \n", " \n", " 24\n", " 24\n", " 252\n", " 21\n", - " 1.842031\n", - " 0.043352\n", - " 0.578693\n", + " 1.847743\n", + " 0.039285\n", + " 0.522792\n", " \n", " \n", " 25\n", " 25\n", " 252\n", " 21\n", - " 1.094989\n", - " 0.125760\n", - " 3.414156\n", + " 1.073871\n", + " 0.124408\n", + " 3.379076\n", " \n", " \n", "\n", @@ -786,60 +786,60 @@ ], "text/plain": [ " fold param_lookback_period param_skip_period validation_score \\\n", - "0 0 189 21 0.407720 \n", - "1 1 252 21 0.377440 \n", - "2 2 252 21 1.499158 \n", - "3 3 126 21 1.792590 \n", - "4 4 189 21 1.279034 \n", - "5 5 189 21 1.389653 \n", - "6 6 252 21 -0.151705 \n", - "7 7 126 21 0.271631 \n", - "8 8 126 0 0.805464 \n", - "9 9 189 21 1.404681 \n", - "10 10 252 21 1.805294 \n", - "11 11 189 0 0.957852 \n", - "12 12 189 0 -0.318594 \n", - "13 13 189 21 0.403091 \n", - "14 14 189 21 2.000383 \n", - "15 15 252 0 0.623380 \n", - "16 16 189 0 0.924852 \n", - "17 17 126 21 1.697381 \n", - "18 18 189 21 1.116374 \n", - "19 19 189 21 -0.830122 \n", - "20 20 252 0 -0.994465 \n", - "21 21 252 0 0.500976 \n", - "22 22 126 0 1.234343 \n", - "23 23 252 21 1.492340 \n", - "24 24 252 21 1.842031 \n", - "25 25 252 21 1.094989 \n", + "0 0 189 21 0.395150 \n", + "1 1 252 21 0.381388 \n", + "2 2 252 21 1.509166 \n", + "3 3 126 21 1.791084 \n", + "4 4 189 21 1.263127 \n", + "5 5 189 21 1.362616 \n", + "6 6 252 21 -0.186246 \n", + "7 7 126 21 0.269069 \n", + "8 8 126 0 0.798420 \n", + "9 9 189 21 1.260655 \n", + "10 10 252 21 1.783855 \n", + "11 11 189 0 0.958278 \n", + "12 12 189 0 -0.322698 \n", + "13 13 189 21 0.412977 \n", + "14 14 189 21 2.002463 \n", + "15 15 189 0 0.569079 \n", + "16 16 189 0 0.918630 \n", + "17 17 126 21 1.740712 \n", + "18 18 189 21 1.099372 \n", + "19 19 189 21 -0.864776 \n", + "20 20 252 0 -1.022764 \n", + "21 21 252 0 0.488519 \n", + "22 22 126 0 1.221196 \n", + "23 23 252 21 1.471373 \n", + "24 24 252 21 1.847743 \n", + "25 25 252 21 1.073871 \n", "\n", " test_return test_sharpe \n", - "0 0.002839 -0.133096 \n", - "1 0.086610 2.145034 \n", - "2 0.033276 0.583157 \n", - "3 0.037070 1.118928 \n", - "4 0.008293 -0.007815 \n", - "5 -0.043895 -0.978858 \n", - "6 0.065673 1.240997 \n", - "7 -0.050807 -1.400648 \n", - "8 0.052652 1.356497 \n", - "9 0.079879 2.206585 \n", - "10 -0.006467 -0.232787 \n", - "11 -0.061822 -1.261396 \n", - "12 0.043414 1.135982 \n", - "13 0.008597 -0.019312 \n", - "14 -0.049931 -0.422043 \n", - "15 0.077301 1.298620 \n", - "16 0.071764 1.128256 \n", - "17 0.041611 0.756638 \n", - "18 -0.114931 -2.123303 \n", - "19 -0.041382 -0.945299 \n", - "20 0.055433 1.417782 \n", - "21 0.021741 0.340016 \n", - "22 0.080233 1.802894 \n", - "23 0.077561 1.438909 \n", - "24 0.043352 0.578693 \n", - "25 0.125760 3.414156 " + "0 0.002246 -0.148399 \n", + "1 0.086119 2.141740 \n", + "2 0.032850 0.577043 \n", + "3 0.036955 1.126486 \n", + "4 0.007165 -0.040030 \n", + "5 -0.046294 -1.043426 \n", + "6 0.063801 1.217540 \n", + "7 -0.051130 -1.423099 \n", + "8 0.051993 1.344901 \n", + "9 0.078582 2.180407 \n", + "10 -0.006399 -0.236018 \n", + "11 -0.062078 -1.306505 \n", + "12 0.043979 1.165922 \n", + "13 0.007426 -0.057245 \n", + "14 -0.057625 -0.536678 \n", + "15 0.090356 1.520040 \n", + "16 0.069706 1.107110 \n", + "17 0.041169 0.754846 \n", + "18 -0.115773 -2.185809 \n", + "19 -0.040545 -0.950149 \n", + "20 0.054483 1.399485 \n", + "21 0.020724 0.316222 \n", + "22 0.079943 1.809288 \n", + "23 0.077291 1.447278 \n", + "24 0.039285 0.522792 \n", + "25 0.124408 3.379076 " ] }, "execution_count": 5, @@ -870,13 +870,13 @@ { "cell_type": "code", "execution_count": 6, - "id": "0f90c07f", + "id": "9a2ebc0f", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:08.182410Z", - "iopub.status.busy": "2026-08-25T17:35:08.182211Z", - "iopub.status.idle": "2026-08-25T17:35:08.195239Z", - "shell.execute_reply": "2026-08-25T17:35:08.194346Z" + "iopub.execute_input": "2026-09-02T12:26:45.304281Z", + "iopub.status.busy": "2026-09-02T12:26:45.303457Z", + "iopub.status.idle": "2026-09-02T12:26:45.323869Z", + "shell.execute_reply": "2026-09-02T12:26:45.323132Z" } }, "outputs": [ @@ -884,9 +884,9 @@ "name": "stdout", "output_type": "stream", "text": [ - "Out-of-sample Sharpe: 0.32\n", - "Out-of-sample CAGR: 4.69%\n", - "Parameter stability (coefficient of variation): {'lookback_period': 0.23018039969328674, 'skip_period': 0.6666666666666667}\n" + "Out-of-sample Sharpe: 0.31\n", + "Out-of-sample CAGR: 4.56%\n", + "Parameter stability (coefficient of variation): {'lookback_period': 0.227497050075337, 'skip_period': 0.6666666666666667}\n" ] } ], @@ -900,19 +900,19 @@ { "cell_type": "code", "execution_count": 7, - "id": "938b43e2", + "id": "03e04ac1", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:08.197065Z", - "iopub.status.busy": "2026-08-25T17:35:08.196849Z", - "iopub.status.idle": "2026-08-25T17:35:08.310244Z", - "shell.execute_reply": "2026-08-25T17:35:08.309278Z" + "iopub.execute_input": "2026-09-02T12:26:45.326231Z", + "iopub.status.busy": "2026-09-02T12:26:45.325958Z", + "iopub.status.idle": "2026-09-02T12:26:45.469419Z", + "shell.execute_reply": "2026-09-02T12:26:45.468402Z" } }, "outputs": [ { "data": { - "image/png": "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LVumbqMR0Lp1a+2OO+7w+vOQezimrV692no7NzdX6969u3b55Zdb78P3DxcxjN+Zyy67TAVcFXkeT5Yh93D8M55rcC7DxUAcp3Q4fuGig/GC1JgxY9RxT2+DePI8xu2Ei8AvvPACA60KSk1NVecn4wWixx57TAVCevsCLr30UnVRST/3fPXVV+rYhjZhRZ4H7RtcSETbRPfnn39qBw8e1KoTAy2qUn///bc6GOlXwdPS0lTDEAGZ8Qo8DnoTJkxwenx5gdaHH36oes5wYGSgdeLQKMf6Rg+V3rhGo2LKlCl2y2Gd33zzzU6Pdxdo4WrtiBEjvPAuyQhX/R599FHrbVzhu/jii+2WGTt2rAqWzbgKtEaOHKmdfvrpThc10AChysNFplGjRllvX3/99eo+o+eee05ddcexsaqfh9wbP368uuCge//997Xw8HC7Bt0ff/yhjnv//POPx89T2WXIvVdeeUVlZujmzZunts2uXbus9+3YsUPd99NPP3n8PDp852699VZt06ZNDLS8YO3atWo9oq0I+F4hOwkXFY2QffHQQw95/DzQokULdcGwpjF5m6rUggULJDIy0jqmZ9OmTVJSUqLGluiQk47b69evr9BzI5/6/vvvl08++YTjfLy4vZC/3KlTJ3V79+7d1rFARhinVZHtlZ2drcYVnXPOObJjxw6ZPXu2GlOHfYEqD98BbCPj9sF2MdtemzdvVuN8PDV+/Hg1DuX222+XmTNnyrhx49QYh+eff56brJIwVmDdunUebS+MTdi/f3+VPg+5h4vRv/76q9N6bt68ud14EKxn/W+ePk9lliHPzmGO2wvbCttM17JlSzWG3N05zPF54KuvvpLffvtNpkyZwk3hJQsWLFDjHNu2batuY4wcxmc5rvvy2oiOz4PxxxhDhzYHzn3fffedGs+F71l1YzEMqjJoWD/55JPy2GOPqYMaZGRkqP8xKNEIg0kr0hhAg/GKK66QJ554Qjp06ODldx6YEADde++9cscdd1iLk7jbXihk4ikM4McBbt68efLMM8+ohgkOenieH3/8URo3buzlTxMYRTGuueYa6dmzp1xyySXW+7HNzLYX1j+CZldFShzVrVtXDRyeO3eu+m7iIgmKZjRq1MjrnyUQ4KLC//73P7X+Ro4cWe72AnzHHAsFeet5qHzPPfecrF27Vt5++2236xmFfVAk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", + "image/png": "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eUU6fvy4tGnTJql79+5S7969pbi4uAK32dy5c8XrP/3009KRI0ekHTt2iMeHhYVJ//77r3m5KlWqSPfcc4/V4++77z6pQoUKDm8nW/bt2ydFRkaKx/L2O3bsmPTyyy+L9/bGG2+IZdLS0sT74/WdN2+eeG5+PVseeughqWLFitKqVaukixcviu3I23vixInmZXJzc83ryT+8DV5//XXJ19dXWrx4sXk53j/8urw/Zs2aJZ08eVLsh/DwcLGe/MPPzff/+uuvUkREhPT4449r1kd5jpEjR4rX4P31999/S02bNpUqVaokXb582bzszz//LJZV7wPejjfddJNUt25d8Rpnz56VVq5cKVWvXl3q27ev3e3r6GNPnTolPkPt2rWTtmzZIvbDiy++KI0YMUKsz5IlS8zLvvbaa+K+lJQUzWtt3rxZ3M/vwdb7yczMlD777DNx319//WXe/tnZ2eLvN998sziG9MaNGycFBARotpVefHy81KBBA/HeeH35c7Zt2zZpwoQJ0tdff+30uiv7jY91fhxvk/nz54v1rV27tnTrrbdarcOzzz6rWc+i7Ls///zTatsrmjRpIt15553m22vXrhWfGX7tH374wXz/sGHDpBtvvNHp497oOFS2x8GDB8VtPo4bNmwo1a9fX2wbezIyMsTy/PgpU6Zozg+8Tfhcwvuejwn+LM2cOVOc88aPH1/g/uDnKe7tYe+1+dji1+LP/eHDh8W5tE+fPtKgQYPEY/i928LH4ZAhQ6TKlStr1kPB54nQ0FCpX79+Yl/w53T27NlSYGCgeH3l+4/fE5+/+bX5nMePe/DBB8Ux58jrGBk4cKBUs2ZNqX///uJ44M8TbxM+X/N9avxa6u8DxtudtyNvMz7P79mzR3xP8n5dsWKF5jgPCQmRevToIW3cuFEcD/w9ybevXbtmXhf+zCl4ffi7ivensn0d2Q62OLquvB7czhg+fLj4rubv+08//VQsx8eFvW3C3+X8GvydrZaXlyees3379lbrxeeNbt262V138AwItMDjcaOaAww/Pz/RaOcv6O+++040vvQ4COLAx4g+0LK3PAcA3Djt0qWL3XXjx/IXMTf49fe3bt3afDs9PV0qX768dPvtt1s9x+DBg8XJmPGXO3/5ciNY7fz581JQUFCBgRY/nr+k+Mtb/35uuOEGTQPS0UCroO1qCzdI+Itb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", "text/plain": [ "
" ] @@ -932,7 +932,7 @@ }, { "cell_type": "markdown", - "id": "a6022e64", + "id": "af1eb54e", "metadata": {}, "source": [ "## Conclusion\n", @@ -965,9 +965,9 @@ "version": "3.13.7" }, "quantlab": { - "code_hash": "01f453a13e89b4a77bb5e85538cd9963479ca9a60745ac9d35d77a2a58d5c3ba", + "code_hash": "9a7df03157f6e44d966d928f188e54f4fe7167e529588986b31ffb50ae228cbb", "config_hashes": { - "configs/momentum_sp500.yaml": "e2be7ba15eca4cf73930a4a991a252549c7a921d2c09d50bedc7c5c8f6552a7e" + "configs/momentum_sp500.yaml": "6fcd3ba14c9d49484b9d135237db69a828672a4e126d6c57f106a38ec47cac7f" }, "generator": "scripts/build_notebooks.py" } diff --git a/notebooks/03_mean_reversion_research.ipynb b/notebooks/03_mean_reversion_research.ipynb index 7ebec20..7ee4e72 100644 --- a/notebooks/03_mean_reversion_research.ipynb +++ b/notebooks/03_mean_reversion_research.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "5c1da949", + "id": "43f951da", "metadata": {}, "source": [ "# 03 — Mean Reversion Research (Example)\n", @@ -15,13 +15,13 @@ { "cell_type": "code", "execution_count": 1, - "id": "23e21402", + "id": "af3966eb", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:11.296482Z", - "iopub.status.busy": "2026-08-25T17:35:11.296101Z", - "iopub.status.idle": "2026-08-25T17:35:32.461383Z", - "shell.execute_reply": "2026-08-25T17:35:32.460597Z" + "iopub.execute_input": "2026-09-02T11:15:54.718450Z", + "iopub.status.busy": "2026-09-02T11:15:54.718181Z", + "iopub.status.idle": "2026-09-02T11:16:13.985621Z", + "shell.execute_reply": "2026-09-02T11:16:13.984398Z" } }, "outputs": [ @@ -52,7 +52,7 @@ }, { "cell_type": "markdown", - "id": "d03f23c1", + "id": "92099976", "metadata": {}, "source": [ "## Comparing indicators on SPY" @@ -61,13 +61,13 @@ { "cell_type": "code", "execution_count": 2, - "id": "570ee0a5", + "id": "28120824", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:32.463517Z", - "iopub.status.busy": "2026-08-25T17:35:32.463286Z", - "iopub.status.idle": "2026-08-25T17:35:32.912694Z", - "shell.execute_reply": "2026-08-25T17:35:32.911773Z" + "iopub.execute_input": "2026-09-02T11:16:13.988839Z", + "iopub.status.busy": "2026-09-02T11:16:13.988469Z", + "iopub.status.idle": "2026-09-02T11:16:14.712692Z", + "shell.execute_reply": "2026-09-02T11:16:14.711453Z" } }, "outputs": [ @@ -108,18 +108,20 @@ }, { "cell_type": "markdown", - "id": "222f096d", + "id": "5b241374", "metadata": {}, "source": [ "The three indicators broadly agree on *when* SPY is stretched (their extremes\n", "line up in time), but disagree on magnitude — this is exactly why the\n", - "strategy config exposes `entry_zscore` / `exit_zscore` as tunable parameters\n", - "rather than hard-coding one indicator's convention.\n" + "strategy config exposes `entry_threshold` / `exit_threshold` as tunable\n", + "parameters (on whichever `indicator` is selected — z-score, Bollinger %B, RSI,\n", + "distance to a moving average, or percentile rank) rather than hard-coding\n", + "one indicator's convention.\n" ] }, { "cell_type": "markdown", - "id": "fa2d57b8", + "id": "7ae60b84", "metadata": {}, "source": [ "## Full backtest: rolling z-score mean reversion" @@ -128,13 +130,13 @@ { "cell_type": "code", "execution_count": 3, - "id": "b39b0f55", + "id": "77565009", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:32.914696Z", - "iopub.status.busy": "2026-08-25T17:35:32.914481Z", - "iopub.status.idle": "2026-08-25T17:35:37.019726Z", - "shell.execute_reply": "2026-08-25T17:35:37.018906Z" + "iopub.execute_input": "2026-09-02T11:16:14.716941Z", + "iopub.status.busy": "2026-09-02T11:16:14.716556Z", + "iopub.status.idle": "2026-09-02T11:16:22.100195Z", + "shell.execute_reply": "2026-09-02T11:16:22.099010Z" } }, "outputs": [ @@ -147,20 +149,20 @@ "Symbols : SPY, QQQ, IWM, EFA, EEM\n", "Period : 2010-01-04 -> 2025-12-31\n", "------------------------------------------------\n", - "Total return : 99.03%\n", - "CAGR : 4.41%\n", + "Total return : 98.69%\n", + "CAGR : 4.39%\n", "Volatility (ann.) : 12.65%\n", "Sharpe : 0.25\n", "Sortino : 0.35\n", "Calmar : 0.13\n", - "Max drawdown : -34.61%\n", - "Hit rate (non-zero periods): 48.91%\n", - "Total costs (currency units): 25981.17\n", - "Number of trades : 1180\n", + "Max drawdown : -34.63%\n", + "Hit rate (non-zero periods): 48.84%\n", + "Total costs (currency units): 26188.01\n", + "Number of fills : 3351\n", "------------------------------------------------\n", "Beta : 0.51\n", - "Alpha (ann.) : -3.33%\n", - "Information ratio : -0.76\n" + "Alpha (ann.) : -3.34%\n", + "Information ratio : -0.77\n" ] } ], @@ -172,19 +174,19 @@ { "cell_type": "code", "execution_count": 4, - "id": "1983e527", + "id": "bf70f6f9", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:37.022034Z", - "iopub.status.busy": "2026-08-25T17:35:37.021709Z", - "iopub.status.idle": "2026-08-25T17:35:37.264185Z", - "shell.execute_reply": "2026-08-25T17:35:37.263245Z" + "iopub.execute_input": "2026-09-02T11:16:22.103342Z", + "iopub.status.busy": "2026-09-02T11:16:22.102926Z", + "iopub.status.idle": "2026-09-02T11:16:22.508188Z", + "shell.execute_reply": "2026-09-02T11:16:22.507005Z" } }, "outputs": [ { "data": { - "image/png": "iVBORw0KGgoAAAANSUhEUgAAA94AAAJOCAYAAABBfN/cAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAzJ1JREFUeJzs3QeUG9XVwPGrsqvt625jY+OCaQZMbyaAAQOhhyTUhECABEIgQCq9BAJJ+ICEJBBCAqEl1NBCJ/QOpndwwdi429ubynfuk0Y7kkZaaVdd/985sne1s9JIK43mvnvffa5QKBQSAAAAAACQE+7c3CwAAAAAACDwBgAAAAAgx8h4AwAAAACQQwTeAAAAAADkEIE3AAAAAAA5ROANAAAAAEAOEXgDAAAAAJBDBN4AAAAAAOQQgTcAAAAAADlE4A0AQAXaf//9ZYsttpBKVMmPHQBQGATeAICse+SRR8TlcpnLX//6V8dtvve970W3aW9v56+Agtpzzz1lm2224a8AAMgJAm8AQM7U19fLjTfemHC9Btp33323+TkK48EHH5S33nqrIp/+Sn7sAIDCIPAGAOTMIYccIi+//LJ8/PHHMdffcccd0tfXJwcccADPPgAAKHsE3gCAnNl7771lnXXWSch633DDDXLggQfKyJEjHX9v6dKlcuKJJ8q6664r1dXVMmnSJPnFL34h3d3d0W2efvrpaKm6Xurq6mSrrbZKKG2/9tprzc/nzZsn559/vtkf3Vb3Ta9Lh3Ubn332mZx99tkyfvx48721P0899ZTMmTNHmpubpaamxpQs33PPPdHfP/bYY6WhocGxpP7UU081v7NmzZrodQPdntp5553NRR/DfvvtJ42NjfKtb33L/OyVV16Rr3/96zJmzBhzvf7+9ddfL4FAYMB5zjfffLNsvfXWUltbK01NTbLXXnvJSy+95HjfixYtMrejlQtjx441fyP7faSSzmNUV111lUybNs3sz7bbbivPP/+8eW0MGzYsZrvJkyfL4YcfnvD7xx9/vIwaNSrmuvjHvv7668uTTz4pb7zxRvT1pH8vdcIJJ5ivW1paEm775JNPTvjbJXus++yzj4wYMcL8PXbZZRd57LHHBrXvyf7uuXiNAQCyh8AbAJAzHo9Hvvvd75pgLhgMmus+/fRTEzwdc8wxSYPu7bbbTl577TW58847ZfXq1XLrrbea0vRvfOMb0e122203CYVC0cvChQvNbWowdMsttyTc7q9//WuZOHGifPDBByaQ1CD6yCOPzOjxnHvuuWYQ4N133zWDCRqg/etf/zLzgzfZZBN555135KuvvpKjjz5aDj30UPO4rQCqo6NDbr/99pjb6+npkdtuu81UBgwfPtxcl87tWfQ2f/zjH8s555wj8+fPl29+85uyYsUKEyxrIKyB5PLly03Qrc/nm2++mfLx/e53vzNz73VQRJ9PLcfWwGzXXXc1Ax12nZ2dctppp5nnRP9ml156qVx++eVyzTXXDPg8pvsY9W/2s5/9zPxNv/zyS/Ozyy67zDzWbNLXwh577GEGHKzXkxXA6n3r8/zPf/4z5nf057o/+pq0/nZOdBu9bX3tafXHkiVL5De/+Y388Y9/HPT+Ov3dc/UaAwBkSQgAgCx7+OGHQ/oR869//Sv0wQcfmK8feeQR87OzzjortM4664T8fn/o5JNPNj9ra2uL/u4JJ5wQqqurC3355Zcxt/nEE0+YbR9//PGU933wwQeHdt555+j311xzjfk9vV+7v//97+b6t99+e8DHY93G6aefHnN9Z2dnaOTIkaF999034XeOOeYY8zgDgYD5fqONNgrtuOOOMdv8+9//Nrerjy3T25s1a5b53ddffz1mO32e9fpXXnkl5WPab7/9QjNnzox+v2bNGvO8H3LIITHb9fT0hCZOnBjaeuuto9fpfbtcrtB7770Xs+3s2bNDm222Wcr7Tfcx6v7U1taGvve978Vss3LlylB9fX2oubk55vr11lsvdNhhhyXc5nHHHWfuL9VjV3vssUfMY7TTv9vGG2/s+JpI9Xpsb28PDRs2LDRnzpyk22S678n+7rl4jQEAsoeMNwAgpzbeeGOTwdYMsWa9b7rpJpMF12y4kwceeEB22mknmTBhQsz1mnWtqqqSZ555xnyvWcmrr77aZCm1xNYqEb733ntNBjOeluXabbrppuZ/q9x8wYIFMaXrevn3v/8d8zuaCbZ78cUXZdWqVSZTGE8zippJ1Ay/Ou6440ym/cMPP4xu849//MOUGe++++4Z357S50gfv91GG21kyvO1vPj++++XtrY2SYdmYzWLrZlRO70tfdyaPV+7dm30et3vGTNmJDynA5Xvp/sY9bnq6upKeM51esLXvvY1ySfNeuvfTcuzLZrZ1+dAs9mpHqs+Z0cddVRW98fp756r1xgAIDsIvAEAOacl4BoQa+m4lgwnKzNXy5YtM/NtvV6vCc714na7TdCtDdk0aFAXXnihnH766abEVgMFv99vgvHvfOc7Zrt4OrfbTucvK3swOZD4wQAtsVbf//73o/ur+6oX3Q9l7a+WcOtj0EBI6fzoJ554wvyuBvmZ3p7T/qj11ltP/vvf/5r70iBa50Jvv/32ct1110XL/Z1Ytztu3LiEn1nX2e87/vm0nlMtd9a/RTLpPkbrvrRkPp7Tdcnoa2Kovv3tb8vo0aOjZfQvvPCCKdHWedXW386Jlvkn+zsNZd+T3V4uXmMAgOwg8AYA5NwRRxxhgoiTTjrJBIGaBU9GM5oHHXSQCd60UZdeNGC05t7+5S9/Mdtp5nzfffc1t6lBoJVBTzb/N1WApDQraJ8zrpf4hlca1NhZja+0S7u1v7qv9v3V7L3SwE27uOscWt1WG8wp+yBEJrfntD/2zOVzzz1nmmk9/PDDpjnZD3/4w5TzirXxlzXwEc+6zt4Mb6DnM5l0H6N1X6n2x07nojtl9xcvXixDpVl/HeDRwSPNCOtrUAPVVANI1t88nX3IdN+T/d1z8RoDAGQHgTcAIOc063rwwQebQHCgYEUDB23kZWULU/H5fDHfa4m5lkzni3aX1scW39AqGQ3eNGjUEnANirQJmjbdGuztDUQ7Xut9aHMtDbieffbZpNvuuOOOpnP4f/7zn5jrtXpAy/+1tDm+k/hgpPsYdX+027bet50229PmfPF0cEEb59mzxLptfEf2ZLQzuzYiS0Y7qWtwqo3R7rrrLtMRXBvtpTJr1izzWPX5T2Wo+17I1xgAID0E3gCAvND50hpYaACTyiWXXGIygLoc1v/+9z9pbW01QbjOr9XMuRV06dxfDco0wNDyZg24tVQ21ZzbbNNgTbOf2nFd5wHreuU6L/nzzz83XbDj55XrEmYaBGk3cJ1TrnNyh3J7TjTI0+dYgzZdAkufPw3AtHx49uzZSX9PgzHtUK73rX8D7Y6u+6jPqWZeteN5NqT7GHV/fvWrX5ns7R/+8AcTiOq2OnDjlJHVZb90fy+++GLzuDWQ1SBUA/h06Px03Yf333/fscRbg2zdtz/96U/S29ub8LdL9li1yuDxxx+XH/zgB/LJJ5+Ybuhaqq5LmmVr3/P9GgMADEIWG7UBAJDQ1TwVp67masWKFaHTTjstNG3atFB1dXVo3LhxoT333DN0++23m27oqqOjI3TqqaeaLszajXuXXXYxnZ7jO0Fb3afnz58fcx8ffvihuf6GG24Y8K+W7DYsL7zwQujAAw8MjRo1KuTz+ULTp083+/HOO+8kbHvuueea29JttWP4YG9Pu1vrJV5XV1fouuuuMz/Tzt/aVXu77bYLXX/99QN29lb6fGy55ZbmfhsaGky37+eeey5mm2T3ffbZZ5vH1tfX5/i4Mn2M6v/+7/9CkydPNttstdVWoaeffjr0wx/+MKGrubriiitMB3bddqeddgq98cYbaXc1X7VqVeiAAw4INTU1mcegndPjPfroo+ZnenvJ/nZOtKO4vn4bGxvN7etrVW9rMPue7LnPxWsMAJA9Lv1nMAE7AABAIWhGXysoMmmMlw1aVaFZaM0mX3nllXm9bwBAaaPUHAAAIA3WvGgtAwcAIBME3gAAAAPQud+6Fr02CYxfvxwAgIF4B9wCAACggml3dV0+bZdddpFrr7220LsDAChBzPEGAAAAACCHKDUHAAAAACCHCLwBAAAAAMgh5ninIRgMypIlS6SxsdHM8QIAAAAAVLZQKCRtbW0yfvx4cbtT57QJvNOgQffEiROz9fcBAAAAAJSJRYsWybrrrptyGwLvNGim23pCm5qasvPXAQAAAACUrNbWVpOgteLFVAi802CVl2vQTeANAAAAALCkMx2Z5moAAAAAAOQQgTcAAAAAADlE4A0AAAAAQA4ReAMAAAAAUGyBt3ZvW7NmTcpturu7TRfw3t7ektkGAAAAAICCBt533323bL311qZl+tSpU2X99deXBx98MGG7888/X0aMGCFbbLGFjBo1Sq6++uqi3wYAAAAAgJwIZeDkk08OzZ07NxQMBs3l4osvDvl8vtDHH38c3eamm24K1dbWhl544QXz/X333RfyeDyhxx57rGi3GUhLS0tInyr9HwAAAACAlgziRJf+M9igPRAISG1trfzlL3+R448/3ly30047ybRp0+Tmm2+Objd79mwZPny43HPPPUW5TTql9c3NzdLS0sI63gAAAAAAySROHFJztXnz5klfX59MmDDBfB8MBmXu3Lmy4447xmy38847y+uvv16U2wAAAAAAkEvewf6i3++XE044QWbOnClz5swx17W1tUlPT4+MHDkyZludV71y5cqi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XSETeD8TF3SruIr0pLy25PXPv1U/9B0HKgiBNwAAKGpfLA/KJf/uiLmuIS7w1iBD1xbeYSPnrspAOau1LZOtAbayysgt40e6Y6ZraHV5put40yMNyGPgHQgE5L///a/MnTtXDjnkENl8880dt/vkk0/kscceM80bDjzwQJk4cWLMz7u6uuQ///mPLFy4UKZPny4HH3yweL3egm0DAACKk5bIxhvdHBspnHpQnTz3fotMGpNmBAGUkWBkPW5138vd5n+d621X54trtDbcLV+tDmYUUKcboANIlNHb59FHH5X1119frrvuOrnwwgvlnXfecdzukksukS233FJef/11WbBggey///7y0ksvRX++Zs0a2W677eSyyy6TpUuXyplnnim77babdHd3F2QbAABQXPyBkCxcFpAlqwLS0hFOy1XZ4ohJo2NPYRrrwpHD2gEaPwHlvB63+suD4Q7/O23SX2auauMCb19V7PztdKRbkg4gUUZp37Fjx8ozzzwjkyZNSrpUx8MPPyznnnuuPPHEE7L77rub6y644AJZtmxZdJvf/OY30tHRYQL3hoYGOfvss2XDDTeUa665Rk4//fS8bwMAAAo7h1uDhdbOoPT4Rb4/p1aefa9XLrottrx8l82q5cm3es3X7rhAYcwwtwkknnm3V5auCci44XRSRmVmvC0bToh9D9TGlZ7bS9HTjLvFle6GAIaW8d5iiy1M0J3KVVddJXvssUc06Fb19fUyderU6Pd33323HHrooSYQVmPGjJEDDjhA7rrrroJsAwAACufDRQH5w32dcsPj3XLbU92y51lr5L0F4S7lY4e7ZfeZ1fKtnX2y6XrJ8wUNNW45aEef9PTRYA2VJxAXeD/12+HSWOdOGXiPbur/eXWarRG2mBp+Dx45u38pPwDpyfpE51deeUV+9rOfycsvv2yy4xro7r333jJ+/Hjz897eXpk/f76Za22n3z/00EN538ZJT0+PuVhaW1sH+WwAAICBXHVvbGZbLYnMPb3vvGEyojEcIPz5gc6UtzO8we0YhADlLmCvNdcBq2GJuTV7abnZZnj/NvVxZeip3mMf/HWkeKk5BzLmznapWEtLi9x///3yox/9SFauXGkyy1raraXnSsu+VXNz/xqDatiwYdLe3p73bZxceuml5nesS3xjOAAAkD1POyxv9MlivwkUhjf0BwS9/tTz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AECRmjhxognGv/vd78qFF15ognLL559/bgJ0FQwG5YMPPpC5c+ea75csWWK21csLL7wg77zzjnR3d8fctmbT9ec9PT3R6/Rrve7dd9+N2XbhwoXmdkKhUMz1q1evltdff93c1kD0tt9++22zjx0dHVnbl/jn4b333jMX/RoAgGJB4A0AQJE766yzzP9//etfo9f9/ve/l3322ccEplqi/u1vf9sE6EoD01/96lfm8vOf/1z2339/GTlyZEzWfN68efK1r31Nnnjiieh1+rVet+eee8YE2aeddpocd9xxZv650qD2pz/9qYwdO1YOPfRQ2XHHHc3cbqdgV2/nkksuMfev+6Hb69daPt/X1zfkfbGeB60O2GqrreSoo44y+7PBBhvIJ598kqW/AAAAQ0PgDQBAkdtoo41kxIgR8uqrr8Zcr3PBL7roIhOkvv/++2auuNIg3Mp4v/jii/LFF1/IP//5T/n1r38t//nPf8w22223nTQ1Ncljjz0Wvb3HH39c1llnHVm+fLnJTqtAICBPPfWUCYAtl112mVx55ZVy6623mqBZLxr8//3vf0/Y98svv1zOOeccueKKK0zTtc8++0zuuOMOufrqq+VnP/vZkPfFeh70senv67Z6HxrUa5AOAEAxIPAGAKAEjBo1SlauXBlznZZs//CHPzSZZ7XlllvG/Fy311JwzYCPGzfOzBd/8MEHzc+0W/rs2bMTgt3jjz/edCTXr5UG+y0tLdHmbprV1iDayl5b9Pe0E7udbvvb3/7W3M8PfvCD6PUHHnigyUxfc801plx9sPtifx5OOukkGTNmjPlen49jjjlGHn74Yenq6hrkMw4AQPYQeAMAUAJ0beyampqE62fNmpVwnWZ8tRu6ZoyPPvpoU26uZedr1qwxWWeLBrAfffSRuU7nhes8cb1OL1YQrEGv1+s1gbE1p3rVqlWy8847J9xv/HWaCddtd9lll4Rtdf80K/3mm28Oel8sbrfbZM3tpkyZYv63P14AAAqF5cQAAChymrXVtb532GGHmOsbGxultrY2YftvfvObJqjV37Gy4WrDDTeMmYdtZY41sNWAVm9P72Px4sVy7LHHmvvVYNcqBVednZ3R+47X3Nwc8721bfz19uusRmuD2Rf781BVVRVznTVIYe0DAACFRMYbAIAi98ADD5h5zPvtt19CpjeezonWLuaa6bYH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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAA94AAAJOCAYAAABBfN/cAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAy1NJREFUeJzs3Qd4W+XVwPEjyba8ncTZi4RMCDNsEgJhFMr+oKUUSqHsFspsoYyyaTqBQguUUqCUFihl7xFm2XuEVUZCBgnZ3kPje84rXflKvpIlW1v/3/M4seVrbd17z3vOe15XMBgMCgAAAAAAyAh3Zq4WAAAAAAAQeAMAAAAAkGFkvAEAAAAAyCACbwAAAAAAMojAGwAAAACADCLwBgAAAAAggwi8AQAAAADIIAJvAAAAAAAyiMAbAAAAAIAMIvAGAKAE7bvvvrLFFltIKSrlxw4AyA0CbwBA2j322GPicrnM11/+8hfHbY488sjINi0tLbwKyKndd99dtt56a14FAEBGEHgDADKmpqZGbrnlll6Xa6B99913m98jNx566CF55513SvLpL+XHDgDIDQJvAEDGHHTQQfLKK6/IJ598EnX5v//9b+nu7pb99tuPZx8AABQ9Am8AQMbsueeeMmrUqF5Z75tvvln2339/aWxsdPy75cuXy4knnihjx46ViooKGT9+vJx11lnS0dER2ebZZ5+NlKrrV3V1tcycObNXafv1119vfv/FF1/IhRdeaO6Pbqv3TS9LhnUdn332mZx33nkyevRo87N1f5555hnZY489pKGhQSorK03J8j333BP5+x/96EdSW1vrWFJ/yimnmL9Zu3Zt5LK+rk/Nnj3bfOlj2GeffaSurk6+853vmN+9+uqr8u1vf1uGDx9uLte/v/HGG8Xv9/c5z/kf//iHbLXVVlJVVSX19fXyrW99S15++WXH2168eLG5Hq1cGDFihHmN7LeRSDKPUV111VUyadIkc3+22WYb+e9//2veG4MGDYrabsKECXLooYf2+vtjjz1Whg4dGnVZ7GOfPHmyzJ8/X958883I+0lfL3XccceZ79evX9/ruk866aRer128x7rXXnvJkCFDzOsxZ84ceeKJJ/p13+O97pl4jwEA0ofAGwCQMR6PR4444ggTzAUCAXPZ//73PxM8HXXUUXGD7m233VZef/11ueuuu2TNmjXyz3/+05Sm/9///V9ku1122UWCwWDka9GiReY6NRi67bbbel3vpZdeKuPGjZMPP/zQBJIaRB922GEpPZ5f/vKXZhDg/fffN4MJGqDdfvvtZn7wxhtvLO+99558/fXX8sMf/lAOOeQQ87itAKq1tVXuvPPOqOvr7OyUf/3rX6YyYPDgweayZK7Potd58skny/nnny9ffvmlHHzwwbJy5UoTLGsgrIHkN998Y4JufT7ffvvthI/vt7/9rZl7r4Mi+nxqObYGZjvvvLMZ6LBra2uT0047zTwn+prNmzdPfv/738t1113X5/OY7GPU1+xnP/uZeU2XLFlifvfrX//aPNZ00vfCbrvtZgYcrPeTFcDqbevz/Pe//z3qb/T3en/0PWm9dk50G71ufe9p9ceyZcvkV7/6lVx99dX9vr9Or3um3mMAgDQJAgCQZo8++mhQDzG333578MMPPzTfP/bYY+Z35557bnDUqFFBn88XPOmkk8zvmpubI3973HHHBaurq4NLliyJus6nnnrKbPvkk08mvO0DDzwwOHv27MjP1113nfk7vV27v/3tb+byd999t8/HY13H6aefHnV5W1tbsLGxMbj33nv3+pujjjrKPE6/329+nj59enCHHXaI2uaOO+4w16uPLdXrmzVrlvnbN954I2o7fZ718ldffTXhY9pnn32Cm2++eeTntWvXmuf9oIMOitqus7MzOG7cuOBWW20VuUxv2+VyBT/44IOobefOnRvcdNNNE95uso9R709VVVXwyCOPjNpm1apVwZqammBDQ0PU5RtssEHwe9/7Xq/rPOaYY8ztJXrsarfddot6jHb6um200UaO74lE78eWlpbgoEGDgnvssUfcbVK97/Fe90y8xwAA6UPGGwCQURtttJHJYGuGWLPet956q8mCazbcyYMPPig77rijjBkzJupyzbqWl5fLc889Z37WrOQ111xjspRaYmuVCN93330mgxlLy3LtNtlkE/O/VW6+cOHCqNJ1/brjjjui/kYzwXYvvfSSrF692mQKY2lGUTOJmuFXxxxzjMm0f/TRR5FtbrrpJlNmvOuuu6Z8fUqfI338dtOnTzfl+Vpe/MADD0hzc7MkQ7OxmsXWzKidXpc+bs2er1u3LnK53u8ZM2b0ek77Kt9P9jHqc9Xe3t7rOdfpCTvttJNkk2a99XXT8myLZvb1OdBsdqLHqs/Z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AAAAADKk2xeUYDDOYtxmjnf2Ql53zG1V2AJxF23NM4rmagAAAACQAS980CXHXNVkluqK7WaeSlfzTLGH4cTdmUXGGwAAAABSkCiDbfnqG78JupWumT1hhEfGDu0dZU8alR+R94zx5GQziWcXAAAAQMn72+Nt8sRbXbLJBmV9Nhq7dX6HbDKhTC74fo28sKBbxg1z95oj/dBrnZHv77tgkFmDe3VTQHY4Y01U0D11TO5CshGDevKwR+7RM98b6UfgDQAAAKDk/eauNvMcvP15dNfxeD5Y6JM/Pdgmz3/Q3ee2U8eEstrV3ujwvKE6dy3NDptbKbVVPYF3eew6Z8h94K2lFevXr5eamhopL49pjWfT0dEhlZWJR07a29ulqqoqb7YBAAAAUHrqq13S1BaUf57V0Oe2h/92vfl/VVPA/H/yflVRQaxlg+Fu2XZquZSFg9oqr0vOP7RGLruj1fxcXpa7YPf597uiystZTiyP5nivWbNGfve738nUqVNl8ODBcuedd/baprm5WX70ox9JfX29DBo0SIYMGSI//elPpaurK2q7a665RoYPHy51dXUyduxY+ec//9nrurK5DQAAAIDSpc3PRg52yzZTy/v82n56KPn44Vd+8/+Je1fL0d+q6vW12xZeqauODrl+uHuVDGtwZX0psVgdXUGC7SxK6aW+++67ZcWKFfLII4/E3ebnP/+5PPvss/L222+bjPfTTz8t//rXv+RXv/pVZJt7771Xfvazn8mNN95ostCXXHKJHHnkkfLiiy/mZBsAAAAApUuD0LUtQfHGL+aNUlUR/XNZiv3RPOEW4rks7talxey3T8Y7jwLv4447Tn7/+9/LlClT4m6zYMEC2W233WTSpEnm5y222EK23357+fDDDyPbXHXVVXLggQfK/vvvb0rVjz76aNluu+1MZjoX2wAAAAAoXX+4J1T6veibUOl4X+ZsWpFwfey+WIG6P7mby4gNR3qigm1meGdW2osbjj32WHn44YflmWeeMdnxBx54QF5++WVTfm7ND3/99ddlp512ivq7nXfeWV599dWsbwMAAACgtN31QkdK22uH8oGwGpl1+/pelizdRgwOhYDlnugsNxnvAutqfsQRR5gy81133VUqKirE5/OZ8u5vf/vbkTngWvI9dOjQqL/TOdjffPNN1rdx0tnZab4sTU2h9fcAAAAAFIbO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eijhNj//+c9N1rq9vd38HNtITX+2fteX119/XU499VS58MILZfLkyXG3mzdvnlx88cVJXScAAEBByadgN5/uCwAUa+A9btw42X777RNuU19fb/6vra01ncjXrl0b9fs1a9ZEtknknXfeMU3VtAv6L3/5y4TbnnPOOXLGGWdEftaMt95XAACAgpcPwS6l5gBKTE4D76222sp8JUPnZWtTtA8//DDq8gULFsgmm2yS8G/fe+892X333eX73/++XH311X3eltfrNV8AAADFxlVWJu6KilzfjfwoeQeALCmYOd5KA+c777xTVq5caX7+/PPP5ZFHHjGXWx544AE55ZRTIj+///77sttuu8mhhx4qf/rTn3JyvwEAAPJF2fDh4sp14B0IsJwYgJJSUMuJ6XzvZ555RrbcckvZbrvt5IUXXpA999xTjj766Mg2b731ltx6662RzLaWl2tjNv069thjI9vtv//+5gsAAAA5QMYbQAkpqMC7qqpK5s+fbwLur776ygTisXPENZieNGlS5OfLL79cfD5fr+saO3ZsVu4zAABAXsmXOd56P7SzOQCUgIIKvJU2WEu0FNjMmTPNl+Woo47K0j0DAABAUmiuBqDEMMwIAACArApaGe98yL4DQBYQeAMAACC7gkFxud3iIvAGUCIIvAEAAEpMMMe3371okYjHo+vF5vieAEB2EHgDAAAgJ2uJk/EGUCoKrrkaAAAACkv30qUS7OqKzOk264iT7QZQQgi8AQAASkmW51VrI7Vgd7dUTJwonqFDzWW+lSvF09CQ1fsBALlE4A0AAIDM8fvF5fGId8YMKRsxgmcaQElijjcAAEApyWLGOxgISNfHH4vL62XpMAAljYw3AABAKQkEJNjRkfGbCfr90vXJJ6Z7uc7p9jQ2Zvw2ASBfEXgDAACUEN+yZeZ/nXftymCDM9/XX4tn+HBTZm59AUCpIvAGAAAoMbqMV6CtTQItLRkLvv1r10r5+PHaXS2jAT4AFAICbwAAgFLjcol/zRrxDB4s7pqatF99sLPTBNu6XnfQ5zPl5gBQygi8AQAASo3LZUq/dUmvmt12S/vVd370kfhWrTLBt871ljJOOQGUNrqaAwAAlGJn82AwY53G3dXV4q6rE9Gst9ttStsBoJQReAMAACCtXNXV4qmpkYoNNxRXZSWl5gBKHnU/AAAApcbtzmjGW0vY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9T2niXOt2PXSxK9W7kTSjlYq6Ip4LKO4WozSXtPLLrvM93j16tV275X2Srq0h1H7wPW7cGVUO/EsWGy0L79QoUK+56pWrWr3Zv/bPg18L6RN+4C1h1T77N3YKA7e/b469rVXNdLYzJw503z66afm3XffJQzpjI32wLm1K9T/qnWgvVwuxUXxiSQ2qqugfXs33nij+f77783UqVPt/ntEFhvtA1ZdCTc24h17tFdOn61IYqN9dUOHDjXPPvusee2118yIESPMLbfcYm699Va7HxzhxaZw4cKmZMmSIWOjfta++bRik1HtwL9P9V2jz4Vo3NGe+cDvbJ0PnC023nbS+xr495fGLLeehGooHT9+PF2x8bbj7ut++OGHzdixY+3+e0R+XituLLQfe+vWrRHHJrAdUb2Rxo0b233iU6ZMMYsXLzYnTpwwsYTiajHojTfesAePfrwFBbyJnegEVidLKpQWroxqJ169+uqr9oOsgkNp9am+IPPmzfuv+jTYeyG0zz//3Hz00Udm8uTJfrFRYRQleF46AY0kNnv27DH33HOPGT9+vP2s7N69m1BE4OOPP7Y/Soq9sVFfBhYPjDQ2KoSjMaxHjx62mJsSi2+++cbcdttt5oMPPghapBIp9D2jsWbIkCG+z4liI4HjWqSxEU2sqHjnZ599Zosa/vLLL+aRRx4JWmgP/ubNm2fj8vbbb/s+J+mJTUa1gxQqGvnkk0+aPn362D47W5/++uuvYbeTntcgxZo1a8zTTz9t+8wt1plWbFSQM9x2NMl79913m379+tmCaojM7t27zYMPPmjatm3r67/0jEXB2lERUBXwVFE1XSDR84qtzsd17hErRT1JvGPMmDFjTO/evc3o0aNthVHvTLQOqkCqRBrJjFtGtROPdBKvAViznFdffXWafaptHBoA0tunod4LwalSfPv27W0C0aJFC7/Y6NgOpHhFEpu+ffua8uXLm127dtkE0v2imD9/vm1H1c0R3IwZM0znzp3tib/3LgsZFRu99vTp03bViSZfZN26dbYabaNGjUynTp0ITQiqDqvPi67edOvWzS82biy8d7zQY/eKaThUlfnmm282AwcONI8//rh9Tom3qtFqZQOxCW3p0qV2ZYD6rUuXLkFj4/2c6LF3JVtGt4MUmuDTVTWNZzq2g/VpOGNaqHYifQ1SaHxp0qSJPeYHDBiQ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", "text/plain": [ "
" ] @@ -207,7 +209,7 @@ }, { "cell_type": "markdown", - "id": "b4bb7483", + "id": "f62eab44", "metadata": {}, "source": [ "## Gross vs net performance" @@ -216,13 +218,13 @@ { "cell_type": "code", "execution_count": 5, - "id": "d14cbc71", + "id": "db87c7ba", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:37.266155Z", - "iopub.status.busy": "2026-08-25T17:35:37.265892Z", - "iopub.status.idle": "2026-08-25T17:35:37.284828Z", - "shell.execute_reply": "2026-08-25T17:35:37.283934Z" + "iopub.execute_input": "2026-09-02T11:16:22.510955Z", + "iopub.status.busy": "2026-09-02T11:16:22.510595Z", + "iopub.status.idle": "2026-09-02T11:16:22.538350Z", + "shell.execute_reply": "2026-09-02T11:16:22.536898Z" } }, "outputs": [ @@ -260,17 +262,17 @@ " \n", " 1\n", " Net total return\n", - " 99.03%\n", + " 98.69%\n", " \n", " \n", " 2\n", " Cost drag\n", - " 41.59%\n", + " 41.92%\n", " \n", " \n", " 3\n", " Total cost (currency units)\n", - " 25,981.17\n", + " 26,188.01\n", " \n", " \n", " 4\n", @@ -289,9 +291,9 @@ "text/plain": [ " Metric Value\n", "0 Gross total return 140.62%\n", - "1 Net total return 99.03%\n", - "2 Cost drag 41.59%\n", - "3 Total cost (currency units) 25,981.17\n", + "1 Net total return 98.69%\n", + "2 Cost drag 41.92%\n", + "3 Total cost (currency units) 26,188.01\n", "4 Gross Sharpe 0.34\n", "5 Net Sharpe 0.25" ] @@ -309,7 +311,7 @@ }, { "cell_type": "markdown", - "id": "6547b644", + "id": "74213445", "metadata": {}, "source": [ "## Takeaways\n", @@ -341,9 +343,9 @@ "version": "3.13.7" }, "quantlab": { - "code_hash": "01f453a13e89b4a77bb5e85538cd9963479ca9a60745ac9d35d77a2a58d5c3ba", + "code_hash": "9a7df03157f6e44d966d928f188e54f4fe7167e529588986b31ffb50ae228cbb", "config_hashes": { - "configs/mean_reversion_etfs.yaml": "7a22a958b6bb28890ea951eeabacbc403d062452ba8458c23fc8b59d33e57284" + "configs/mean_reversion_etfs.yaml": "de8a755a0241a9fad73fa3295a010428520d1de5b5c1fc98c56612314f2a895f" }, "generator": "scripts/build_notebooks.py" } diff --git a/notebooks/04_pairs_trading_research.ipynb b/notebooks/04_pairs_trading_research.ipynb index a07e5ab..52e37a3 100644 --- a/notebooks/04_pairs_trading_research.ipynb +++ b/notebooks/04_pairs_trading_research.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "50253b77", + "id": "fb6d1f06", "metadata": {}, "source": [ "# 04 — Pairs Trading Research (Example)\n", @@ -15,13 +15,13 @@ { "cell_type": "code", "execution_count": 1, - "id": "af3fdb5f", + "id": "d2bc5f21", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:39.661804Z", - "iopub.status.busy": "2026-08-25T17:35:39.661290Z", - "iopub.status.idle": "2026-08-25T17:35:53.720738Z", - "shell.execute_reply": "2026-08-25T17:35:53.719847Z" + "iopub.execute_input": "2026-09-02T11:15:58.641393Z", + "iopub.status.busy": "2026-09-02T11:15:58.641040Z", + "iopub.status.idle": "2026-09-02T11:16:12.409104Z", + "shell.execute_reply": "2026-09-02T11:16:12.407947Z" } }, "outputs": [ @@ -58,7 +58,7 @@ }, { "cell_type": "markdown", - "id": "48551ad1", + "id": "3cb8c8e9", "metadata": {}, "source": [ "## Hedge ratio and spread" @@ -67,13 +67,13 @@ { "cell_type": "code", "execution_count": 2, - "id": "351fce04", + "id": "b4dcb4c7", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:53.722672Z", - "iopub.status.busy": "2026-08-25T17:35:53.722383Z", - "iopub.status.idle": "2026-08-25T17:35:54.447978Z", - "shell.execute_reply": "2026-08-25T17:35:54.447144Z" + "iopub.execute_input": "2026-09-02T11:16:12.412698Z", + "iopub.status.busy": "2026-09-02T11:16:12.412172Z", + "iopub.status.idle": "2026-09-02T11:16:13.616178Z", + "shell.execute_reply": "2026-09-02T11:16:13.614925Z" } }, "outputs": [ @@ -103,7 +103,7 @@ }, { "cell_type": "markdown", - "id": "5a8c491d", + "id": "67f16f42", "metadata": {}, "source": [ "## Stationarity of the spread (Augmented Dickey-Fuller)\n", @@ -133,13 +133,13 @@ { "cell_type": "code", "execution_count": 3, - "id": "13ec7fd4", + "id": "7d1d6183", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:54.449982Z", - "iopub.status.busy": "2026-08-25T17:35:54.449691Z", - "iopub.status.idle": "2026-08-25T17:35:55.135033Z", - "shell.execute_reply": "2026-08-25T17:35:55.134203Z" + "iopub.execute_input": "2026-09-02T11:16:13.619032Z", + "iopub.status.busy": "2026-09-02T11:16:13.618543Z", + "iopub.status.idle": "2026-09-02T11:16:14.173475Z", + "shell.execute_reply": "2026-09-02T11:16:14.172038Z" } }, "outputs": [ @@ -164,13 +164,13 @@ { "cell_type": "code", "execution_count": 4, - "id": "c17fbd68", + "id": "1fee7165", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:55.137113Z", - "iopub.status.busy": "2026-08-25T17:35:55.136652Z", - "iopub.status.idle": "2026-08-25T17:35:55.237022Z", - "shell.execute_reply": "2026-08-25T17:35:55.236142Z" + "iopub.execute_input": "2026-09-02T11:16:14.176713Z", + "iopub.status.busy": "2026-09-02T11:16:14.176146Z", + "iopub.status.idle": "2026-09-02T11:16:14.346781Z", + "shell.execute_reply": "2026-09-02T11:16:14.345590Z" } }, "outputs": [ @@ -207,7 +207,7 @@ }, { "cell_type": "markdown", - "id": "415c15e3", + "id": "073cc0a1", "metadata": {}, "source": [ "The two p-values above can differ by orders of magnitude, and that is\n", @@ -226,7 +226,7 @@ }, { "cell_type": "markdown", - "id": "356bb1ea", + "id": "73657500", "metadata": {}, "source": [ "## Spread z-score and trading thresholds" @@ -235,13 +235,13 @@ { "cell_type": "code", "execution_count": 5, - "id": "21a4e239", + "id": "330d05a6", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:55.239189Z", - "iopub.status.busy": "2026-08-25T17:35:55.238851Z", - "iopub.status.idle": "2026-08-25T17:35:55.372202Z", - "shell.execute_reply": "2026-08-25T17:35:55.371218Z" + "iopub.execute_input": "2026-09-02T11:16:14.350474Z", + "iopub.status.busy": "2026-09-02T11:16:14.350124Z", + "iopub.status.idle": "2026-09-02T11:16:14.571355Z", + "shell.execute_reply": "2026-09-02T11:16:14.569843Z" } }, "outputs": [ @@ -257,7 +257,7 @@ } ], "source": [ - "z = rolling_zscore(spread, window=config.strategy.parameters[\"zscore_window\"])\n", + "z = rolling_zscore(spread, window=config.strategy.parameters[\"indicator_window\"])\n", "fig, ax = plt.subplots(figsize=(10, 4))\n", "ax.plot(z.index, z.to_numpy(dtype=float), color=\"#2563eb\", lw=0.8)\n", "ax.axhline(2, color=\"#dc2626\", lw=0.8, ls=\"--\")\n", @@ -269,7 +269,7 @@ }, { "cell_type": "markdown", - "id": "6f832f5f", + "id": "7b896aae", "metadata": {}, "source": [ "## Full backtest" @@ -278,13 +278,13 @@ { "cell_type": "code", "execution_count": 6, - "id": "3ab5f7f4", + "id": "faaeb655", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:55.374323Z", - "iopub.status.busy": "2026-08-25T17:35:55.374033Z", - "iopub.status.idle": "2026-08-25T17:35:56.572737Z", - "shell.execute_reply": "2026-08-25T17:35:56.571824Z" + "iopub.execute_input": "2026-09-02T11:16:14.574908Z", + "iopub.status.busy": "2026-09-02T11:16:14.574520Z", + "iopub.status.idle": "2026-09-02T11:16:20.077518Z", + "shell.execute_reply": "2026-09-02T11:16:20.076047Z" } }, "outputs": [ @@ -297,19 +297,19 @@ "Symbols : EWA, EWC\n", "Period : 2010-01-04 -> 2025-12-31\n", "------------------------------------------------\n", - "Total return : 15.24%\n", - "CAGR : 0.89%\n", + "Total return : 15.06%\n", + "CAGR : 0.88%\n", "Volatility (ann.) : 3.05%\n", "Sharpe : -0.35\n", - "Sortino : -0.57\n", + "Sortino : -0.58\n", "Calmar : 0.11\n", - "Max drawdown : -8.01%\n", + "Max drawdown : -8.04%\n", "Hit rate (non-zero periods): 47.95%\n", - "Total costs (currency units): 4615.58\n", - "Number of trades : 1314\n", + "Total costs (currency units): 4793.74\n", + "Number of fills : 1314\n", "------------------------------------------------\n", "Beta : -0.01\n", - "Alpha (ann.) : -0.97%\n", + "Alpha (ann.) : -0.98%\n", "Information ratio : -0.77\n" ] } @@ -321,7 +321,7 @@ }, { "cell_type": "markdown", - "id": "b90a834b", + "id": "a7f63e54", "metadata": {}, "source": [ "## Takeaways\n", @@ -354,9 +354,9 @@ "version": "3.13.7" }, "quantlab": { - "code_hash": "01f453a13e89b4a77bb5e85538cd9963479ca9a60745ac9d35d77a2a58d5c3ba", + "code_hash": "9a7df03157f6e44d966d928f188e54f4fe7167e529588986b31ffb50ae228cbb", "config_hashes": { - "configs/pairs_trading.yaml": "f312e13ab0ddb313e6987aa2bd11155db31090fd992757ee8317a3fa0aad76ff" + "configs/pairs_trading.yaml": "5d2bc492ca02c25bdaef82b20575262dcc812e89f0186a75a15794fa11d80441" }, "generator": "scripts/build_notebooks.py" } diff --git a/notebooks/05_robustness_analysis.ipynb b/notebooks/05_robustness_analysis.ipynb index 44928d2..ccdb025 100644 --- a/notebooks/05_robustness_analysis.ipynb +++ b/notebooks/05_robustness_analysis.ipynb @@ -2,7 +2,7 @@ "cells": [ { "cell_type": "markdown", - "id": "9bed088f", + "id": "8393038d", "metadata": {}, "source": [ "# 05 — Robustness Analysis (Example)\n", @@ -15,20 +15,20 @@ { "cell_type": "code", "execution_count": 1, - "id": "1df1c1e5", + "id": "f3dd4e2a", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:35:59.104260Z", - "iopub.status.busy": "2026-08-25T17:35:59.103963Z", - "iopub.status.idle": "2026-08-25T17:36:32.806814Z", - "shell.execute_reply": "2026-08-25T17:36:32.805985Z" + "iopub.execute_input": "2026-09-02T11:16:02.787872Z", + "iopub.status.busy": "2026-09-02T11:16:02.787403Z", + "iopub.status.idle": "2026-09-02T11:17:08.670147Z", + "shell.execute_reply": "2026-09-02T11:17:08.668127Z" } }, "outputs": [ { "data": { "text/plain": [ - "(0.44371649849802863, 0.05747479854942217)" + "(0.42782384701897996, 0.05542366502629115)" ] }, "execution_count": 1, @@ -49,6 +49,7 @@ ")\n", "from quantlab.validation.bootstrap import bootstrap_returns\n", "from quantlab.validation.robustness import run_stress_tests, monte_carlo_permutation\n", + "from quantlab.reporting.tables import format_bootstrap_summary\n", "\n", "config = ExperimentConfig.from_yaml(\"../configs/momentum_sp500.yaml\")\n", "data, report = DataLoader().load(config)\n", @@ -58,7 +59,7 @@ }, { "cell_type": "markdown", - "id": "80059699", + "id": "26a25c17", "metadata": {}, "source": [ "## Parameter sensitivity: lookback period x top fraction" @@ -67,13 +68,13 @@ { "cell_type": "code", "execution_count": 2, - "id": "07b9d419", + "id": "4c6f87ab", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:36:32.808911Z", - "iopub.status.busy": "2026-08-25T17:36:32.808585Z", - "iopub.status.idle": "2026-08-25T17:42:33.432983Z", - "shell.execute_reply": "2026-08-25T17:42:33.432017Z" + "iopub.execute_input": "2026-09-02T11:17:08.673504Z", + "iopub.status.busy": "2026-09-02T11:17:08.673139Z", + "iopub.status.idle": "2026-09-02T11:29:06.023726Z", + "shell.execute_reply": "2026-09-02T11:29:06.023028Z" } }, "outputs": [ @@ -114,31 +115,31 @@ " \n", " \n", " 0.250\n", - " 0.282722\n", - " 0.346163\n", - " 0.443716\n", - " 0.420907\n", + " 0.265235\n", + " 0.326487\n", + " 0.427824\n", + " 0.405189\n", " \n", " \n", " 0.375\n", - " 0.501479\n", - " 0.553154\n", - " 0.688104\n", - " 0.650963\n", + " 0.488043\n", + " 0.567303\n", + " 0.664074\n", + " 0.621332\n", " \n", " \n", " 0.500\n", - " 0.526569\n", - " 0.484626\n", - " 0.686852\n", - " 0.694514\n", + " 0.525404\n", + " 0.485858\n", + " 0.680699\n", + " 0.689028\n", " \n", " \n", " 0.625\n", - " 0.553919\n", - " 0.576572\n", - " 0.690520\n", - " 0.704114\n", + " 0.553863\n", + " 0.578929\n", + " 0.689700\n", + " 0.702609\n", " \n", " \n", "\n", @@ -147,10 +148,10 @@ "text/plain": [ "lookback_period 126 189 252 315\n", "top_fraction \n", - "0.250 0.282722 0.346163 0.443716 0.420907\n", - "0.375 0.501479 0.553154 0.688104 0.650963\n", - "0.500 0.526569 0.484626 0.686852 0.694514\n", - "0.625 0.553919 0.576572 0.690520 0.704114" + "0.250 0.265235 0.326487 0.427824 0.405189\n", + "0.375 0.488043 0.567303 0.664074 0.621332\n", + "0.500 0.525404 0.485858 0.680699 0.689028\n", + "0.625 0.553863 0.578929 0.689700 0.702609" ] }, "execution_count": 2, @@ -171,19 +172,19 @@ { "cell_type": "code", "execution_count": 3, - "id": "69aa745b", + "id": "d5281e93", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:42:33.434787Z", - "iopub.status.busy": "2026-08-25T17:42:33.434535Z", - "iopub.status.idle": "2026-08-25T17:42:33.591255Z", - "shell.execute_reply": "2026-08-25T17:42:33.590378Z" + "iopub.execute_input": "2026-09-02T11:29:06.025890Z", + "iopub.status.busy": "2026-09-02T11:29:06.025613Z", + "iopub.status.idle": "2026-09-02T11:29:06.206499Z", + "shell.execute_reply": "2026-09-02T11:29:06.205612Z" } }, "outputs": [ { "data": { - "image/png": "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sGFuCQHdevXqJZkpywc0b775prlItWvXlsTERHnggQfkggsukHfffdfr8Tly5JBJkyaZIEa7prS7SS/8gQMH0mVplAY52p0FAEA06QinHGFuwY6SmjNnjrRu3VouvfRS6d+/v6xbt06uuuoqOXXqlM/76Oel9mx8/fXX8n//939y1113yYsvvii//vqr85jNmzebEpBXX33VuWmwlJmydA3N3r17Zfv27dKoUSO3/Y0bN5ZVq1YF3I5eeHXeeee57deuq/nz50tKSorUq1dP3njjDbnssssidPYAAPwnIl1GQd7/sccek+7du8sTTzzh/PzUcoyJEyfKvffe6/U+Q4YMkdOnT5uAJm/evGafBkWef/yXKFHCZGayiiydobHSYsWLF3fbr98HkjJTJ0+eNFmd5s2bm6yNRQOYH374wXRLaVuVKlWSa665Rnbv3u2zLX2BNQ3nugEAkBXt379ffv75Z2nbtq1zn9aVaobGV4mFlm1MnjxZevTo4QxmlNXj4WrJkiXms7VTp04mIXD27FnJTFk6oNEuJuV5kVJTU03XUkY0mrzppptMUKMvkCvtxtLCYVWoUCEZO3asCVimTZvms73hw4ebY62tXLlyIT4zAEB2E8miYM8/rk+fPp3u8bSHQ51//vlu+/V76zZPWk967NgxqVq1qjz44IMmYLn99ttl8eLFbscVLFhQ7rjjDpMB0uyNdklprarD4ZDMkqUDmjJlyph/PbMme/bscd6WUTDzxx9/mGImz+4mTxqJahpu27ZtPo8ZPHiwyehY244dO4J6PgCA7D1sOxKb0j+oXf/AHj58eLrH0z/+lWdmRT/vrNs8WbU12rOhn4naVVW5cmVp0aKFfP75587jtGZG61O1Z+Puu+82tTqa9dF/M0uWrqFJTk42xcBa56Ijm6xszTfffCN9+/Z1Hnfo0CETwFhzyOgLpVXZGzZskEWLFpmh3p40inSdz+aff/4xEat2Pfmibwpvc9oAAGAn/YNaPyMtSV4+m4oWLWr+9Rzhq0W/WtDrjXWfdu3amelNlAYz69evl9GjR0v79u29Pt4ll1xiPoN/+uknueGGGyQzZOkMjdLoUIuX3nnnHdMXqNXWqk+fPs5jBgwYIC1btnTOW3PrrbfKihUrzGgnZQ3L1q4npam5hg0bymeffSa///67CZi0j1EDn27dumXK8wQAxDct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F8rq1asdEyZMcKSmpprvjx8/7mjRooX5Re7q8ccfdxQrVszx888/m+/T0tIcb775pk3PIr6u+aFDhxw5cuRwvPTSS859U6dO1aXpnNdXX5fExETHlClTnNf7oYceMsGNBpcI/j2sH5z6moTbDhyOs2fPOkaMGGGukfX+fPTRR80fPKdOnQr4ZyGQ1wUIFwFNnPH3C8Vb9kA/TK1f7IcPH3bkzZvX8corr0T5LLPHNde/+DV4+e6775z7duzYYfYtXLjQfP/iiy+aDIGrP/74wxyzePFiG84+tnm+h60PzhdeeMEELpqNCbUdeDd37lzz/vR2bTMKaIJ9XYBgUBScja1evdrUyFj94cuWLZOUlBRp27atWXRz7dq1ZoE/hEbrZe655x555JFH5PPPP5e5c+dKz5495YYbbpCmTZuaY4oWLSrHjx93u867du0y/65Zs4ZLH+R72PLcc89Jr169pHLlynLVVVeZGqZQ2sG/tm7dKkuXLpWpU6fKgAED5IEHHpBSpUoFfXmCfV2AYBDQZFOLFi2Sd955R5544gnnPg1itBj1zTfflHr16kn37t3NL62BAwdm6rnGsh49esiJEyfMh4BuGzdulPvuu8+5mm+nTp2kZMmS0qFDB5k9e7ZMmTJF7r//fsmbN68cPHgws08/5t7DSmfBPnDggKxbt062b99u9t18881y7ty5oNrBf2bNmmV+D2hwru/d2267LejLE+zrAgQtqHwO4qLL6YcffjAFkf369XPbP3HiRJNK7ty5szP1/v333zty5sxpaj8Q3DXftm2bI0+ePI7Ro0c7982cOdPU1Wi9gUXT7wMGDHC0bt3acdNNNzmWL19uiiufe+45LnmQ72Fvli5dat7XGzZsCKsd/FtDo0XCWty+a9eusLq8/b0uQCjI0GQzP/74o1xzzTUmczBy5Ei326zhk9otYqXedfjlpZdeatLNCM6CBQvMcNc+ffo49914441miPCcOXOc+zQL9uKLL8qXX34pn376qcnYHDp0SGrWrMklD/I97I3VNWJ15YXaDsRkcPv372+yjkuWLAnrkvh6XYBQEdBkI5rq1V/gt99+u4waNSrd7Q0aNJDChQu7/YLRuVP27Nlj5upAcPSaaTr9n3/+cZv7RLuSXK+n1i25euutt+S8886Ta6+9lkse5HtYP2g9zZs3zzm/T6DtwPf11PmS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", + "image/png": "iVBORw0KGgoAAAANSUhEUgAAAjQAAAHVCAYAAADme2G5AAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjExLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvctoD+AAAAAlwSFlzAAAPYQAAD2EBqD+naQAAWUNJREFUeJzt3Qd8U2X3wPHTFiizTBkCsmSoKKAgU4aCWxFEEVwgQ/AVXhRQ+DvAiSjCKwoOZCkCgjIEcYIoW0BwAA5k771XafP/nMf35k3SJM28bdLf18+17c3Nk5ubtDmc84wEh8PhEAAAgBiWmNUnAAAAEC4CGgAAEPMIaAAAQMwjoAEAADGPgAYAAMQ8AhoAABDzCGgAAEDMI6ABAAAxj4DGBps3b5YaNWrIzJkz7Xi4uHHgwAFz3T766KOsPhUgJr3xxhvmdygtLS3TY8ePH2+OPX78uC3nll3PAbGLgCYM586dk/fee0/atGkjV111lVx33XXSu3dvWbVqldtxZ8+elT/++EOOHj0a7usVd3bu3Gn+gH388ccZbjt//ry5bocPH86Sc0Nse+edd8x769SpU5JT7d+/3/wOBTIh/MGDB82xgQQ/0Xpd7DoHxKdcWX0CsUp/GRs3bizbt2+XZ599Vho0aCCnT5+WsWPHSr169eTDDz+U++67L6tPM9tLTU31GbRccMEFsmHDBildunSWnBtiP8On76309PSsPpWY8NBDD8mtt94qKSkpWfa62HUOiE8ENGGkRteuXSvTp0+Xdu3aOfc3a9bM/EJqVgbhSUpKMv+SAxB9xYoVM1tOPwfELkpOIdq6dav5evnll2e47e6775aOHTt6vd/KlSvljjvukNq1a0v79u1NBsKTfohb2xVXXCG33HKLCaBc08au/XKWLVsmbdu2lZo1a8rixYvdbvvhhx9MgKWPp+e0fv16r+f16aefmtJZrVq1TObpueeekxMnTvi9Bv7OIZDn8eOPP5oynRo8eLDz2CFDhvjtQ6OlqFGjRkmrVq1Mu/r17bffNvszE8i1tejz0mC1Tp060rJlS3n99dedgWpmz10zd3369JH69eub++u1X7p0qVv7J0+elFdffdWcvx6j1/+DDz5w+5drIMf46zuhGbBhw4ZJw4YNzbkMGjQoQ6p/zpw5zmtyySWXSN26daVz586yevVqn22OHDlSmjZtKo0aNQq5jeHDh5v7X3PNNTJ58mRzuz4vvc76HtTz1cfxRsu3L730kjRv3ty8jvoe1/ew5ZlnnjGPpbQcbJ3bunXrAm4js+fsy7Fjx8x7WN/b+nunfw8WLFjgdkwg749IXSvr2MzeB976r1jnoL9bo0ePNo+lGeinnnpKzpw543b/QN4Dmb0uvvrQBHO9AjnXUH+vkM3patsI3ocffqifgI7/+7//c6Snp/s9dsOGDebYXr16Oe68807HN99841iwYIGjTp06jlKlSjlOnDiR4XhrW7FihWPYsGGOfPnyOZ599tkMbT788MOO22+/3fH11187Jk+e7Pjuu++ct3Xv3t3c9u2335rbmzdv7ihUqJDj559/dnu8nj17OvLmzet44YUXHMuXL3d89tlnjssuu8xx5ZVXOk6dOpXp8/J2DoE8j5MnT5pz0zYGDx7sPHbv3r3m9t27d5vb3nzzTedjpqWlOW6++WZHgQIFzH5td+TIkY78+fObc9DbM3stMru2Sq9drly5zOv7ww8/OObPn+/o37+/2TJ77r///rujePHi5vWdO3euuf99993nSExMdHz00UfOx2jdurWjYsWKjunTpzvWrl3rmDNnjqNz586O//znP0Ed481TTz1lzu/f//634/nnnzfPdfz48Y5ixYo5mjZt6jh//rzz2KNHjzqvyfr1681rcvfddzuSk5Mdv/76a4Y2H3vsMfN6LV261DFgwICQ2ujXr59j6NCh5rz0fZeQkOCYOXOm+R0ZMmSI2f/yyy+b/ePGjXN7bnv27HFcfPHFjmrVqjmmTp3qWLlypeP111837+EXX3zR+d7R566PtXr1aue5nT59OuA2MnvO3uzatctRpUoVR4UKFRwTJkww7X7yySeOli1bmuekAn1/ROJaBfM+eO2118yxhw8fznB//aqPrfcfM2aM+X3r0aOH22MF8h7I7HXxdg7BXq9AzjXU3ytkbwQ0IdIPzrZt25pfIP3DqEHBO++849i4cWOGY60PPw0SXD9w16xZY/brL11m9I+s/mGw/gBZbepju/5R0uDKuq1q1aput+kfjTJlyjiuvfZa57558+aZY/XcXW3dutU8nv6B8cXfOQT6PDZv3mzaePvttzMc6y2g0T9gum/ixIlux+ofad0/ZcoUn48d6DlpQOf5uJZz585l+txvuukmR0pKiuPQoUNu973uuusc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THwtcZ7ZehE6LDuQVT+zct0JAEAOy9CEO8qJDE1kAxpderxjx46ydetWr7dng6QPAADZSiRGKTHKKcIBjS41rjP86vLhRYsWDaUJAACArA1o/vzzT7N2EvU7AAACw8R62TCg0UntdPQRAQ0AAIFJSEg0WzjCvX88C+nKaKlJh0P/+uuvZvFHHYXkugEAAGT7DI211pHO4+INnYIBAHCns9Dof+G2gQgGNLraNQAACBwlp2wY0DRp0iTyZwIAAJAVSx/oWkg64klLTNWrV5fixYuH0xwAAHE+yinceWhY+sCXkK6sdgR++OGHzSy7jRo1ksaNG5vvdZ/eBgAA3P2zklP4G7wL6croStULFiyQTz75RHbv3i179uwx3+vijnobAABAti85ffzxx/L1119LnTp1nPvuuOMOqVChgplB+K233orkOQIAEPNY+iAbBjTHjx83wYsn3aerigIAAHeRKBlRcvItpCt75ZVXyquvvuo234x+/8orr8hVV10VSpMAAAD2ZmiGDRsmN954o3z66adSr149s2/lypWyd+9e+fLLL0M/GwAA4hQlp2yYodGRTX/99Zfce++9kpqaKufPnzff6z69DQAAeJ9YL9wNEZ6HRodpDx48ONS7AwCAHG7jxo3y888/m6WUqlatKidPnpSkpCTJmzdv0G0FHOodOHDAbK7f+9oAAIDnB25k/osXjz/+uJmUVys833//vdmnX9u3bx/dDM0FF1zg7Pxrfe8Li1MCAOCOtZz+54svvpAZM2aY1QaGDBni3H/zzTeb+ezWr18vl156qUQloFmzZo3X7wEAAIKhmZgePXpIlSpVJMFjOYeaNWvKTz/9FL2Apnbt2s7vdXj21KlTvR53zz33+LwNAICcilFO/3PmzBlJTPynfOYZ0OgKBPnz55dgJYY6U7CvUtO0adNCaRIAgBwwsV5SmFt89KFp3ry5TJo0SU6cOOEW0EyZMsVMA9OkSZOg2wzqyhw5csRsrt9b26FDh2Tu3LlSpkyZoE8CAABEx+TJk+Waa66RGjVqSIcOHWTz5s1+j3/sscekYsWKblubNm3CbtdV69atzagmve8333wj48aNk7p160rHjh3l5ZdflpIlS0Z32HbRokW9fm/R9NHQoUODPgkAAHJCp2AtO4XbRjAmT54snTp1klGjRpn1F7UDbtOmTWXdunWSkpLi9T77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XSk7PcwtyNW2Bw0aJA8//LD07NlTGjRoIJMnTzbBybhx4/ze7/DhwybrMn78eClQoEDE2o0mAhoAAOKw5LRnzx75448/5IYbbnDuy58/vzRr1kwWLlzo975du3aVe+65x2RdItluNNEpGACAGHPs2DG3n5OTk83mavv27eZr6dKl3fbrz7/88ovPtkeNGiXbtm2TqVOner091HajjQwNAAAxlqEpX768FC5c2LkNGTLEy8P9c2zu3Lnd9ufJk0fS0tK8nuKvv/5qykkfffRRhvuF064dyNAAABBjE+tpliQlJcW5O9kjO6NKlChhvnp26D1w4IDzNk+ff/65nD59Wm6++WbnPs3WjB49WmbOnCnr1q0LqV07kKEBACDGaDDjuiV7CWgqVaokxYsXl2XLljn3ORwO83PdunW9ttujRw/5+eef5csvv3RuZcqUkQ4dOsicOXNCbtcOBDQAAMRhp+DExETp3r276ROzadMmE3TofDL79++Xhx56yHlcnz595K677jLfFylSxMw747ppaalo0aJSuXLloNq1GyUnAABs4EhzmC3cNoLx3HPPmVFJNWrUkIIFC5p+LjqHjAYqFr3d6ugbyXbtRkADAECcyp07t5kbZuTIkXLkyBFTPkpKSnI75o033pDU1FSfbSxYsMAELcG2azcCGgAA4ny17YIFC2YISiylSpXye9+LLroopHbtRkADAIAdtFwUZskp7PvHMToFAwCAmEeGBgAAG+hoIEeYJSdtA94R0AAAYAedRDfsklOkTib+UHICAAAxjwwNAAB2SEv/Zwu3DXhFQAMAgA20/0zYfWjCHfYdxyg5AQCAmEeGBgAAOzAPTVQR0AAAEOczBecElJwAAEDMI0MDAECcrradkxDQAABgB0e6SHp6+G3AK0pOAAAg5pGhAQDADoxyiioCmgjYdmK/FEhIjkRTCMDCnZ9ynWyWejqVa26zs8fPcc3tdO581B+CifWii5ITAACIeWRoAACwAyWnqCKgAQDADgQ0UUXJCQAAxDwyNAAA2IBOwdFFQAMAgB3S0v/Zwm0DXlFyAgAAMY8MDQAANnA4HKbsFG4b8I6ABgAAOzDKKaooOQEAgJhHhgYAADtouSnMklPY949jBDQAANjAkaZbmH1o0iJ2OnGHkhMAAIh5ZGgAALADJaeoIqABAMAOTKwXVZScAABAzCOgAQDA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Q7MzVnCjw7VvuummDJ17tYzUvHlzU9KaNm2a35KUpUCBAnLgwAGft2sA5BoEAQAQKDoFx2mGRgOD6667zgQbrnPHLFiwQG655RbnvuXLl8vcuXOdP992222yfv16t7b0Z9dykhXMXHrppfLJJ59kmKNGy1Y6VNzVypUrTQlKR0gBABBplJzieNj20KFDpXHjxtKhQwdp2LChmRtGMyqdOnVyHvP++++boMaa8O6FF14wQceDDz5o7rtixQr5/PPP5ZtvvjG3a6ddDZS0w66ObnINmPQxdDSUjni69tprTTlKOwprIKPDvFu3bi1t27bNgisBAABiNqDRie/Wrl0rY8eONZ1xNZDRSfFcMyoahFxwwQXOnzUTo/cZM2aMme1Xf96wYYNUqlTJ3J6amip169Y132u2x5VOvKcBjZa6tCPwhx9+KGvWrDEdkydPnmxKVwAARIXDYcpO4bYB7xIcOiEMQqJZIB3tNGtdHylQiL41dtl8zPsoOERPr4nuczgh+s4eP8dlttO58yLjVpsBHykpKVH5rFhdp5oUTEoKq60TaWly1Zo/o3KesS7L+tAAAADEzeKUAADkmE7BwlpO0UJAAwCAXTMFh7k4pbYB7yg5AQCAmEeGBgAAG6Sni6QnhN8GvCOgAQDABgQ00UXJCQCAODZ79mwzz5rO0datWzczm74/usaiTnzbqlUrsxbiI488Ihs3bgy73WgjoAEAwK4MTQS2YMyYMUPatWsn119/vQwbNswEHbo488mTJ33eR2fiP336tDz11FPy0ksvyb59+6RBgwaye/fusNqNNkpOAADYQAcohTtIKdj7P/PMM9K1a1d57LHHzM+aTSlVqpSMHz9eHn30Ua/3mTJlituM/VdddZUUKlRIFi9eLHfddVfI7UYbGRoAAOLQvn37zOLNN998s3NfwYIFTSblu+++83k/zwWd582bZ/bVrl07rHajjQwNAAAx1ilYl1NwlZycbDZX27dvN1/LlCnjtl9//u233/w+ji74rNmXI0eOmEWfv/jiC6latWrY7UYTGRoAAOwqOYXbh+a/Jafy5cub9aGsbciQIRke7/z5814zLhr46ELO/tSvX1+mTp0qH3zwgcm86OLRO3bsCLvdaCJDAwBAjNEsievilMke2RlVvHhx8/XgwYNu+/XnEiVK+G1f265Zs6b5vlmzZlKpUiV5++23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UaNG8u677zqDLIt23g1GINdV++joMVZHaaVZnS+//DKoxwKQPZGhASLo9ddfNx1MNVugH57ar0NH8mhn1GCOcQ0mxowZI127djVlGA1KrLKKZk2uu+466dGjh2zZskVGjBhhghPNQqhbb73V9Cl57LHHTFDxxhtvmPu4evPNN00bt912m9m09PPNN99kmB3Y33n4k9k5jh492tyu10M7A2sJTDsMa9t6bpG+9i+//LJcf/315vF12LeO5tKOyABiHxkaIAw6QkY7rFo046GdbosVK2ZGA+mHv44uci0nBXKMDqHW41yDiQcffNBkMzTzoLc/99xzMnz4cNm4caMpm8ybN086dOjgbEM73GoAolkSLb3oUGsNLFz7zFx55ZVmBFG9evXMcdqh1jVjkdl5ZEaHUfs7Rx12rkOntWPujz/+KLt27ZLevXu7BTOu5xDutdcM06JFi0xGSB9XO0SPHDkyQ58dALEnweGZ6wWACOjUqZNzJBMARBslJwBB0zKNlnR8YV4XAHYjoAEQNC03uXau9aSlJC0V0T8FgF0oOQEAgJhHp2AAABDzCGgAAEDMI6ABAAAxj4AGAADEPAIaAAAQ8whoAABAzCOgAQAAMY+ABgAAxDwCGgAAILHu/wGGy4cdA6MKDAAAAABJRU5ErkJggg==", "text/plain": [ "
" ] @@ -205,7 +206,7 @@ }, { "cell_type": "markdown", - "id": "0d70001c", + "id": "05c4852e", "metadata": {}, "source": [ "The goal here is a broad plateau of reasonable Sharpe ratios, not a single\n", @@ -215,7 +216,7 @@ }, { "cell_type": "markdown", - "id": "61222517", + "id": "d314eb27", "metadata": {}, "source": [ "## Bootstrap distribution of CAGR, Sharpe, maximum drawdown\n", @@ -240,13 +241,13 @@ { "cell_type": "code", "execution_count": 4, - "id": "94cee54b", + "id": "9dbed34f", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:42:33.593012Z", - "iopub.status.busy": "2026-08-25T17:42:33.592762Z", - "iopub.status.idle": "2026-08-25T17:42:35.818234Z", - "shell.execute_reply": "2026-08-25T17:42:35.817523Z" + "iopub.execute_input": "2026-09-02T11:29:06.208877Z", + "iopub.status.busy": "2026-09-02T11:29:06.208498Z", + "iopub.status.idle": "2026-09-02T11:29:09.044546Z", + "shell.execute_reply": "2026-09-02T11:29:09.043608Z" } }, "outputs": [ @@ -273,8 +274,8 @@ " \n", " statistic\n", " median\n", - " p05\n", - " p95\n", + " p_lower\n", + " p_upper\n", " mean\n", " std\n", " \n", @@ -282,56 +283,50 @@ " \n", " \n", " 0\n", - " cagr\n", - " 0.056941\n", - " 0.024305\n", - " 0.091509\n", - " 0.057132\n", - " 0.020172\n", + " CAGR\n", + " 5.49%\n", + " 2.22%\n", + " 8.88%\n", + " 5.51%\n", + " 2.00%\n", " \n", " \n", " 1\n", - " sharpe\n", - " 0.440494\n", - " 0.089659\n", - " 0.809244\n", - " 0.440895\n", - " 0.216179\n", + " Sharpe\n", + " 0.43\n", + " 0.07\n", + " 0.79\n", + " 0.42\n", + " 0.22\n", " \n", " \n", " 2\n", - " max_drawdown\n", - " -0.181920\n", - " -0.288049\n", - " -0.124776\n", - " -0.190264\n", - " 0.050865\n", + " Max Drawdown\n", + " -18.31%\n", + " -28.97%\n", + " -12.50%\n", + " -19.12%\n", + " 5.16%\n", " \n", " \n", " 3\n", - " final_value\n", - " 270550.906800\n", - " 153970.547350\n", - " 482428.885302\n", - " 287009.475263\n", - " 103309.798596\n", + " Final Value\n", + " 261,246.26\n", + " 148,376.54\n", + " 461,585.16\n", + " 277,007.31\n", + " 99,029.24\n", " \n", " \n", "\n", "" ], "text/plain": [ - " statistic median p05 p95 mean \\\n", - "0 cagr 0.056941 0.024305 0.091509 0.057132 \n", - "1 sharpe 0.440494 0.089659 0.809244 0.440895 \n", - "2 max_drawdown -0.181920 -0.288049 -0.124776 -0.190264 \n", - "3 final_value 270550.906800 153970.547350 482428.885302 287009.475263 \n", - "\n", - " std \n", - "0 0.020172 \n", - "1 0.216179 \n", - "2 0.050865 \n", - "3 103309.798596 " + " statistic median p_lower p_upper mean std\n", + "0 CAGR 5.49% 2.22% 8.88% 5.51% 2.00%\n", + "1 Sharpe 0.43 0.07 0.79 0.42 0.22\n", + "2 Max Drawdown -18.31% -28.97% -12.50% -19.12% 5.16%\n", + "3 Final Value 261,246.26 148,376.54 461,585.16 277,007.31 99,029.24" ] }, "execution_count": 4, @@ -345,21 +340,23 @@ " periods_per_year=config.periods_per_year, initial_capital=config.initial_capital,\n", " risk_free_rate=config.risk_free_rate,\n", ")\n", - "boot.summary()\n" + "format_bootstrap_summary(boot.summary())\n" ] }, { "cell_type": "markdown", - "id": "a353fc69", + "id": "2c48b6d2", "metadata": {}, "source": [ "The median represents the typical result across the 1,000 synthetic histories.\n", - "The `p05` and `p95` columns contain the middle 90% of the simulated outcomes:\n", + "The `p_lower` and `p_upper` columns contain the middle 90% of the simulated\n", + "outcomes (the 5th/95th percentiles by default; configurable via\n", + "`robustness.bootstrap.confidence_level`):\n", "\n", - "- for CAGR, Sharpe and final value, `p05` is the less favourable boundary and\n", - " `p95` is the more favourable boundary;\n", + "- for CAGR, Sharpe and final value, `p_lower` is the less favourable boundary\n", + " and `p_upper` is the more favourable boundary;\n", "- for maximum drawdown, a more negative value represents a worse loss, so\n", - " `p05` is the more severe drawdown scenario.\n", + " `p_lower` is the more severe drawdown scenario.\n", "\n", "A narrow range suggests that the result is relatively insensitive to the\n", "historical ordering of returns. A wide range means that performance depends\n", @@ -372,7 +369,7 @@ }, { "cell_type": "markdown", - "id": "f2762934", + "id": "2b503279", "metadata": {}, "source": [ "## Stress tests" @@ -381,13 +378,13 @@ { "cell_type": "code", "execution_count": 5, - "id": "76e10d58", + "id": "0423320b", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:42:35.820033Z", - "iopub.status.busy": "2026-08-25T17:42:35.819798Z", - "iopub.status.idle": "2026-08-25T17:44:19.355911Z", - "shell.execute_reply": "2026-08-25T17:44:19.355040Z" + "iopub.execute_input": "2026-09-02T11:29:09.046979Z", + "iopub.status.busy": "2026-09-02T11:29:09.046511Z", + "iopub.status.idle": "2026-09-02T11:32:40.455226Z", + "shell.execute_reply": "2026-09-02T11:32:40.454262Z" } }, "outputs": [ @@ -425,70 +422,70 @@ " \n", " 0\n", " baseline\n", - " 1.729543\n", - " 0.057462\n", - " 0.443716\n", - " -0.158208\n", + " 1.635962\n", + " 0.055411\n", + " 0.427824\n", + " -0.157609\n", " ok\n", " None\n", " \n", " \n", " 1\n", " commission x2\n", - " 1.693180\n", - " 0.056673\n", - " 0.435414\n", - " -0.158825\n", + " 1.599435\n", + " 0.054592\n", + " 0.419038\n", + " -0.158248\n", " ok\n", " None\n", " \n", " \n", " 2\n", " commission x5\n", - " 1.586945\n", - " 0.054309\n", - " 0.410480\n", - " -0.160673\n", + " 1.492836\n", + " 0.052138\n", + " 0.392657\n", + " -0.160164\n", " ok\n", " None\n", " \n", " \n", " 3\n", " slippage x2\n", - " 1.693180\n", - " 0.056673\n", - " 0.435414\n", - " -0.158825\n", + " 1.599435\n", + " 0.054592\n", + " 0.419038\n", + " -0.158248\n", " ok\n", " None\n", " \n", " \n", " 4\n", " execution delay +1\n", - " 1.681519\n", - " 0.056418\n", - " 0.432201\n", - " -0.149058\n", + " 1.571346\n", + " 0.053955\n", + " 0.411195\n", + " -0.149843\n", " ok\n", " None\n", " \n", " \n", " 5\n", " best 10 days removed\n", - " 1.057687\n", - " 0.040967\n", - " 0.274617\n", - " -0.168912\n", + " 1.017310\n", + " 0.039820\n", + " 0.264549\n", + " -0.166871\n", " ok\n", " None\n", " \n", " \n", " 6\n", - " reduced universe\n", - " 0.758890\n", - " 0.031919\n", - " 0.229232\n", - " -0.105496\n", + " reduced universe (-1)\n", + " 0.715095\n", + " 0.030472\n", + " 0.208463\n", + " -0.104675\n", " ok\n", " None\n", " \n", @@ -497,14 +494,14 @@ "" ], "text/plain": [ - " scenario total_return cagr sharpe max_drawdown \\\n", - "0 baseline 1.729543 0.057462 0.443716 -0.158208 \n", - "1 commission x2 1.693180 0.056673 0.435414 -0.158825 \n", - "2 commission x5 1.586945 0.054309 0.410480 -0.160673 \n", - "3 slippage x2 1.693180 0.056673 0.435414 -0.158825 \n", - "4 execution delay +1 1.681519 0.056418 0.432201 -0.149058 \n", - "5 best 10 days removed 1.057687 0.040967 0.274617 -0.168912 \n", - "6 reduced universe 0.758890 0.031919 0.229232 -0.105496 \n", + " scenario total_return cagr sharpe max_drawdown \\\n", + "0 baseline 1.635962 0.055411 0.427824 -0.157609 \n", + "1 commission x2 1.599435 0.054592 0.419038 -0.158248 \n", + "2 commission x5 1.492836 0.052138 0.392657 -0.160164 \n", + "3 slippage x2 1.599435 0.054592 0.419038 -0.158248 \n", + "4 execution delay +1 1.571346 0.053955 0.411195 -0.149843 \n", + "5 best 10 days removed 1.017310 0.039820 0.264549 -0.166871 \n", + "6 reduced universe (-1) 0.715095 0.030472 0.208463 -0.104675 \n", "\n", " status error \n", "0 ok None \n", @@ -528,7 +525,7 @@ }, { "cell_type": "markdown", - "id": "d498daae", + "id": "e08d4f12", "metadata": {}, "source": [ "## Monte Carlo permutation test\n", @@ -555,13 +552,13 @@ { "cell_type": "code", "execution_count": 6, - "id": "e5064f61", + "id": "f7ef01f2", "metadata": { "execution": { - "iopub.execute_input": "2026-08-25T17:44:19.357865Z", - "iopub.status.busy": "2026-08-25T17:44:19.357588Z", - "iopub.status.idle": "2026-08-25T17:44:19.675001Z", - "shell.execute_reply": "2026-08-25T17:44:19.674147Z" + "iopub.execute_input": "2026-09-02T11:32:40.457395Z", + "iopub.status.busy": "2026-09-02T11:32:40.457137Z", + "iopub.status.idle": "2026-09-02T11:32:40.874787Z", + "shell.execute_reply": "2026-09-02T11:32:40.873685Z" } }, "outputs": [ @@ -569,7 +566,7 @@ "name": "stdout", "output_type": "stream", "text": [ - "Real Sharpe: 0.666\n", + "Real Sharpe: 0.654\n", "Empirical p-value (fraction of random sign-flips scoring >= real): 0.001\n" ] } @@ -583,7 +580,7 @@ }, { "cell_type": "markdown", - "id": "8364b5f4", + "id": "1444fcf1", "metadata": {}, "source": [ "## Interpretation\n", @@ -622,9 +619,9 @@ "version": "3.13.7" }, "quantlab": { - "code_hash": "01f453a13e89b4a77bb5e85538cd9963479ca9a60745ac9d35d77a2a58d5c3ba", + "code_hash": "9a7df03157f6e44d966d928f188e54f4fe7167e529588986b31ffb50ae228cbb", "config_hashes": { - "configs/momentum_sp500.yaml": "e2be7ba15eca4cf73930a4a991a252549c7a921d2c09d50bedc7c5c8f6552a7e" + "configs/momentum_sp500.yaml": "6fcd3ba14c9d49484b9d135237db69a828672a4e126d6c57f106a38ec47cac7f" }, "generator": "scripts/build_notebooks.py" } diff --git a/scripts/generate_report.py b/scripts/generate_report.py index a0d3a17..20cbb2e 100644 --- a/scripts/generate_report.py +++ b/scripts/generate_report.py @@ -19,8 +19,12 @@ import argparse from pathlib import Path -from quantlab.backtesting.result import save_with_walk_forward_reuse +from quantlab.backtesting.result import ( + resolve_experiment_directory, + save_with_walk_forward_reuse, +) from quantlab.backtesting.runner import run_backtest_from_config +from quantlab.cli import _strategy_diagnostics_robustness from quantlab.config import ExperimentConfig from quantlab.constants import GENERATED_REPORTS_DIR from quantlab.data.loader import DataLoader @@ -36,6 +40,16 @@ def main() -> int: configure_logging() config = ExperimentConfig.from_yaml(args.config) + # This script's entire purpose is producing an HTML report, so it always + # renders one -- regardless of output.save_html_report/save_figures, + # which only govern whether *other* runs render the presentation layer. + config = config.revalidated_copy( + update={ + "output": config.output.revalidated_copy( + update={"save_html_report": True, "save_figures": True} + ) + } + ) data, report = DataLoader().load(config) if report.warnings: # Keep data-quality warnings visible for script users, matching the CLI. @@ -43,10 +57,13 @@ def main() -> int: for message in report.warnings: print(f" - {message}") result = run_backtest_from_config(data, config, data_quality_report=report) - out_dir = GENERATED_REPORTS_DIR / config.experiment_name + out_dir = resolve_experiment_directory(config, default_root=GENERATED_REPORTS_DIR) # Preserve compatible walk-forward evidence while regenerating the report. # Reuse them only when the same compatibility checks as `quantlab report` pass. - out = save_with_walk_forward_reuse(result, out_dir) + robustness_extra = _strategy_diagnostics_robustness(data, config) + out = save_with_walk_forward_reuse( + result, out_dir, robustness_extra=robustness_extra + ) if result.save_warnings: # Numeric artefacts may still be saved when optional report rendering fails. # Keep the status marker ASCII-only for legacy Windows console encodings. diff --git a/scripts/notebook_cells.py b/scripts/notebook_cells.py index 3bdbb5e..c47b130 100644 --- a/scripts/notebook_cells.py +++ b/scripts/notebook_cells.py @@ -289,8 +289,10 @@ """\ The three indicators broadly agree on *when* SPY is stretched (their extremes line up in time), but disagree on magnitude — this is exactly why the -strategy config exposes `entry_zscore` / `exit_zscore` as tunable parameters -rather than hard-coding one indicator's convention. +strategy config exposes `entry_threshold` / `exit_threshold` as tunable +parameters (on whichever `indicator` is selected — z-score, Bollinger %B, RSI, +distance to a moving average, or percentile rank) rather than hard-coding +one indicator's convention. """, ), ("md", "## Full backtest: rolling z-score mean reversion"), @@ -473,7 +475,7 @@ ( "code", """\ -z = rolling_zscore(spread, window=config.strategy.parameters["zscore_window"]) +z = rolling_zscore(spread, window=config.strategy.parameters["indicator_window"]) fig, ax = plt.subplots(figsize=(10, 4)) ax.plot(z.index, z.to_numpy(dtype=float), color="#2563eb", lw=0.8) ax.axhline(2, color="#dc2626", lw=0.8, ls="--") @@ -533,6 +535,7 @@ ) from quantlab.validation.bootstrap import bootstrap_returns from quantlab.validation.robustness import run_stress_tests, monte_carlo_permutation +from quantlab.reporting.tables import format_bootstrap_summary config = ExperimentConfig.from_yaml("../configs/momentum_sp500.yaml") data, report = DataLoader().load(config) @@ -604,19 +607,21 @@ periods_per_year=config.periods_per_year, initial_capital=config.initial_capital, risk_free_rate=config.risk_free_rate, ) -boot.summary() +format_bootstrap_summary(boot.summary()) """, ), ( "md", """\ The median represents the typical result across the 1,000 synthetic histories. -The `p05` and `p95` columns contain the middle 90% of the simulated outcomes: +The `p_lower` and `p_upper` columns contain the middle 90% of the simulated +outcomes (the 5th/95th percentiles by default; configurable via +`robustness.bootstrap.confidence_level`): -- for CAGR, Sharpe and final value, `p05` is the less favourable boundary and - `p95` is the more favourable boundary; +- for CAGR, Sharpe and final value, `p_lower` is the less favourable boundary + and `p_upper` is the more favourable boundary; - for maximum drawdown, a more negative value represents a worse loss, so - `p05` is the more severe drawdown scenario. + `p_lower` is the more severe drawdown scenario. A narrow range suggests that the result is relatively insensitive to the historical ordering of returns. A wide range means that performance depends diff --git a/src/quantlab/backtesting/accounting.py b/src/quantlab/backtesting/accounting.py index 6b566cb..bfc43c8 100644 --- a/src/quantlab/backtesting/accounting.py +++ b/src/quantlab/backtesting/accounting.py @@ -8,18 +8,23 @@ from __future__ import annotations +from collections.abc import Sequence from dataclasses import dataclass from numbers import Real import numpy as np import pandas as pd +from pandas.api.types import is_bool_dtype from quantlab.constants import EPSILON from quantlab.exceptions import BacktestError from quantlab.execution.execution_model import ExecutionCosts, ExecutionModel from quantlab.execution.orders import executed_weights as compute_executed_weights +from quantlab.execution.orders import validate_execution_frame from quantlab.execution.orders import weight_changes as compute_weight_changes from quantlab.logging_config import get_logger +from quantlab.portfolio.drift_compliance import restore_drift_compliance +from quantlab.portfolio.rebalancing import _compliance_violations from quantlab.risk.exposure import average_gross_exposure, average_net_exposure logger = get_logger(__name__) @@ -47,6 +52,27 @@ class AccountingResult: # Net-equity estimate used to size volume-dependent slippage. Reuse it in # the trade log to keep per-fill and aggregate costs consistent. equity_for_costs: pd.Series + # True at every date from which prior-period equity was <= 0 -- trading + # stops there (see run_accounting's docstring/logging). Real provenance + # for the trade log's forced_liquidation adjustment, not a reconstruction: + # this is the exact same boolean condition run_accounting already uses to + # decide when to force positions flat. + ruined: pd.Series + # True on every (date, symbol) cell whose position was force-flattened + # by a stop-loss/take-profit breach on the REAL executed position (see + # `_detect_stop_loss_take_profit`) -- real provenance for the trade + # log's stop_loss/take_profit adjustments, mirroring how `ruined` + # already documents `forced_liquidation`. All-``False`` (never ``None``) + # when neither `stop_loss_pct` nor `take_profit_pct` was configured. + stop_loss_triggered: pd.DataFrame + take_profit_triggered: pd.DataFrame + # Real provenance from `apply_weight_drift` -- see `DriftProvenance`'s + # own field docs. All-``False`` (never ``None``) when + # `model_weight_drift` was not enabled for this run. + drift_compliance_forced: pd.DataFrame + drift_compliance_pending: pd.DataFrame + drift_turnover_actively_limited: pd.DataFrame + drift_turnover_touched: pd.DataFrame def portfolio_metrics_from_accounting( @@ -99,6 +125,7 @@ def _run_accounting_steps( *, force_flat: pd.Series | None = None, cost_equity: pd.Series | None = None, + weight_changes_override: pd.DataFrame | None = None, ) -> AccountingResult: """Compute turnover, costs, returns and equity for one executed book. @@ -112,9 +139,23 @@ def _run_accounting_steps( bankruptcy, preventing a closing trade with no remaining capital. cost_equity: Previous net-equity estimate used to size volume-dependent slippage. The first pass uses gross equity. + weight_changes_override: When given, used as turnover/cost input + INSTEAD OF ``executed``'s own row-to-row diff -- required when + ``executed`` is not a plain step function (weight drift is + active), since consecutive rows then genuinely differ from + organic price movement alone, never a real trade; a naive diff + would charge phantom turnover/costs for every drifting row. + ``None`` (the default) computes turnover from ``executed``'s + own plain row-to-row diff. """ - # Turnover is the L1 change in the executed book. - weight_changes = compute_weight_changes(executed) + # Turnover is the L1 change in the executed book -- from the real- + # trade-only override when given (see the docstring above), else the + # plain diff (correct on its own for a step-function `executed`). + weight_changes = ( + compute_weight_changes(executed) + if weight_changes_override is None + else weight_changes_override + ) if force_flat is not None: weight_changes = weight_changes.copy() weight_changes.loc[force_flat, :] = 0.0 @@ -174,6 +215,28 @@ def _run_accounting_steps( equity=equity, gross_equity=gross_equity, equity_for_costs=equity_for_costs, + # Overwritten by run_accounting (the only real caller of this + # internal helper) with the actual ruin/stop-loss/take-profit + # provenance -- these placeholders are never observed externally. + ruined=pd.Series(False, index=gross_returns.index), + stop_loss_triggered=pd.DataFrame( + False, index=executed.index, columns=executed.columns + ), + take_profit_triggered=pd.DataFrame( + False, index=executed.index, columns=executed.columns + ), + drift_compliance_forced=pd.DataFrame( + False, index=executed.index, columns=executed.columns + ), + drift_compliance_pending=pd.DataFrame( + False, index=executed.index, columns=executed.columns + ), + drift_turnover_actively_limited=pd.DataFrame( + False, index=executed.index, columns=executed.columns + ), + drift_turnover_touched=pd.DataFrame( + False, index=executed.index, columns=executed.columns + ), ) @@ -184,6 +247,7 @@ def _solve_accounting( initial_capital: float, *, force_flat: pd.Series | None = None, + weight_changes_override: pd.DataFrame | None = None, ) -> AccountingResult: """Solve equity-dependent costs to a self-consistent equity curve. @@ -192,7 +256,12 @@ def _solve_accounting( into the cost model until the maximum difference reaches the tolerance. """ result = _run_accounting_steps( - executed, asset_returns, execution_model, initial_capital, force_flat=force_flat + executed, + asset_returns, + execution_model, + initial_capital, + force_flat=force_flat, + weight_changes_override=weight_changes_override, ) if not len(result.equity): return result @@ -206,6 +275,7 @@ def _solve_accounting( initial_capital, force_flat=force_flat, cost_equity=result.equity, + weight_changes_override=weight_changes_override, ) residual = float((next_result.equity - result.equity).abs().max()) result = next_result @@ -221,6 +291,858 @@ def _solve_accounting( return result +def _resolve_position_groups( + columns: pd.Index, position_groups: Sequence[tuple[str, ...]] | None +) -> list[tuple[str, ...]]: + """Expand ``position_groups`` into a complete partition of ``columns``. + + A symbol not mentioned in any declared group (or ``position_groups`` + being ``None`` entirely) means "its own independent group" -- a + caller only needs to declare GENUINE multi-symbol groups (e.g. + pairs_trading's two legs via ``BaseStrategy.position_groups()``), + never every symbol individually. + + Raises: + BacktestError: If any declared group is empty, repeats a symbol + within itself, references a symbol absent from ``columns``, or + overlaps a symbol already claimed by another declared group -- + each would otherwise silently corrupt the stop-loss/take-profit + and weight-drift-compliance walks (double-processing a symbol + under two different entry timings, or applying the group-return + formula to a nonexistent column). + """ + grouped: set[str] = set() + groups: list[tuple[str, ...]] = [] + if position_groups is not None: + available = set(columns) + for group in position_groups: + members = tuple(group) + if not members: + raise BacktestError( + "position_groups entries must be non-empty; got an empty group." + ) + if len(set(members)) != len(members): + raise BacktestError( + f"position_groups entry {members!r} repeats a symbol within itself." + ) + unknown = [symbol for symbol in members if symbol not in available] + if unknown: + raise BacktestError( + f"position_groups entry {members!r} references symbol(s) " + f"{unknown} not present among the executed weights columns." + ) + overlap = grouped & set(members) + if overlap: + raise BacktestError( + f"position_groups entry {members!r} overlaps symbol(s) " + f"{sorted(overlap)} already claimed by another declared " + "group -- every symbol may belong to at most one group." + ) + groups.append(members) + grouped.update(members) + for column in columns: + if column not in grouped: + groups.append((column,)) + return groups + + +def _walk_group_stop_loss_take_profit( + gross_exposure: np.ndarray, + group_return: np.ndarray, + stop_loss_pct: float | None, + take_profit_pct: float | None, + reversed_without_flat: np.ndarray | None = None, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Walk one position-group's own return path since its last entry. + + ``group_return[t]`` is this group's realized return for period t, + per unit of gross exposure it represented that period (see + :func:`_detect_stop_loss_take_profit`'s docstring for the exact + formula and why it is correct under a static or dynamic hedge ratio, + rebalancing, long/short and partial entries/exits). "Entry" is the + first date after the group was fully flat (``gross_exposure <= + EPSILON``) that it becomes non-flat again, OR a date any leg's sign + flips directly (long to short or vice versa) without an intermediate + flat row (``reversed_without_flat[t]``) -- a same-bar reversal is + economically a close-then-reopen, so the new direction must start its + own fresh cumulative-return episode rather than silently inheriting + the old (opposite-direction) position's running total, which would + misattribute a break/breach to a position that was never actually + held. + + A breach detected using periods THROUGH t (inclusive) force-flattens + period t+1 onward -- never period t itself, since t's own return has + already been realized by the time this decision could be made (no + look-ahead). Once force-flattened, the group stays flat until its + next flat-to-non-flat transition or same-bar reversal (no immediate + re-entry at a rebased price -- the same convention `mean_reversion`'s + own indicator-based stop uses, for consistency). + + Returns ``(force_flat, stop_loss_triggered, take_profit_triggered)``, + each a boolean array aligned to ``gross_exposure``. The trigger + arrays mark the FIRST force-flattened date for their respective + cause (an "exit" event), not the date the breach was internally + detected. + """ + n = len(gross_exposure) + force_flat = np.zeros(n, dtype=bool) + stop_loss_triggered = np.zeros(n, dtype=bool) + take_profit_triggered = np.zeros(n, dtype=bool) + reversed_flags = ( + np.zeros(n, dtype=bool) + if reversed_without_flat is None + else reversed_without_flat + ) + was_flat = True + cumulative = 1.0 + stopped = False + stopped_reason: str | None = None + trigger_marked = False + for t in range(n): + if gross_exposure[t] <= EPSILON: + was_flat = True + stopped = False + stopped_reason = None + trigger_marked = False + cumulative = 1.0 + continue + if was_flat or reversed_flags[t]: + cumulative = 1.0 + stopped = False + stopped_reason = None + trigger_marked = False + was_flat = False + if stopped: + force_flat[t] = True + if not trigger_marked: + if stopped_reason == "stop_loss": + stop_loss_triggered[t] = True + else: + take_profit_triggered[t] = True + trigger_marked = True + continue + r = group_return[t] + if np.isfinite(r): + cumulative *= 1.0 + r + total_return = cumulative - 1.0 + if stop_loss_pct is not None and total_return <= -stop_loss_pct: + stopped = True + stopped_reason = "stop_loss" + elif take_profit_pct is not None and total_return >= take_profit_pct: + stopped = True + stopped_reason = "take_profit" + return force_flat, stop_loss_triggered, take_profit_triggered + + +def _detect_stop_loss_take_profit( + executed: pd.DataFrame, + asset_returns: pd.DataFrame, + position_groups: Sequence[tuple[str, ...]] | None, + stop_loss_pct: float | None, + take_profit_pct: float | None, + weight_changes: pd.DataFrame | None = None, +) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame, pd.DataFrame | None]: + """Detect and gate stop-loss/take-profit breaches on the REAL executed position. + + Operates on ``executed`` (the actual post-shift, post-constraint, + post-rebalance/turnover-cap position a real portfolio would hold), + never on a strategy's raw signal -- a signal is not necessarily a + realized position (the allocator, portfolio constraints, rebalancing + schedule and turnover cap all sit between them), so gating on the + signal directly could force-flatten a position that was never + actually opened, or miss one that was. + + For a position group ``G`` (one symbol, or e.g. pairs_trading's two + legs via ``position_groups``), at each date:: + + gross_exposure[t] = sum(|executed[s][t]| for s in G) + group_return[t] = sum(executed[s][t] * asset_returns[s][t] for s in G) + / gross_exposure[t] + + ``group_return`` is the group's return per unit of ITS OWN gross + exposure at that date -- not a dollar contribution to total portfolio + equity (which would depend on how much capital was allocated to it, + irrelevant to "has this position itself moved against me by X%"). + This normalization by the ACTUAL exposure held each period (not the + exposure at entry) is what makes the formula correct regardless of a + static or dynamic hedge ratio, weight changes, rebalancing, long/ + short direction, or partial entries/exits: every period contributes + its realized return weighted by whatever was really held that + period, using EXACTLY the same ``executed``/``asset_returns`` this + module already computes internally (never a second, potentially + diverging calculation). For a single-symbol group this reduces + exactly to ``sign(executed[t]) * asset_returns[t]`` -- the standard + definition of a price-based stop-loss/take-profit. + + Thresholds are evaluated on GROSS (pre-cost) return: QuantLab's + execution cost model is portfolio-level only (no per-symbol/per-group + cost decomposition exists), so an exact net-of-cost trigger is not + presently computable. This is a deliberate, disclosed design + convention -- not "the" universal definition of a stop-loss/take- + profit -- documented on ``stop_loss_pct``/``take_profit_pct`` + themselves; a net-of-cost variant could be added separately if + per-position cost attribution is ever built. + + Returns ``(gated_executed, stop_loss_triggered, take_profit_triggered, + gated_weight_changes)`` -- the trigger frames are booleans broadcast + across every column of the breaching group (matching the trade log's + row-per-symbol grain), all ``False`` when neither threshold is + configured. ``gated_weight_changes`` mirrors ``weight_changes`` (the + caller's own real-trade-only turnover series, e.g. from + :func:`apply_weight_drift`) with the forced flatten's own turnover + patched in correctly -- ``None`` in, ``None`` out (the caller then + falls back to plain re-diffing ``gated_executed``, which is already + exactly correct when every row-to-row change genuinely is a trade, + i.e. weight drift is not active). + """ + if stop_loss_pct is None and take_profit_pct is None: + empty = pd.DataFrame(False, index=executed.index, columns=executed.columns) + return executed, empty, empty.copy(), weight_changes + + groups = _resolve_position_groups(executed.columns, position_groups) + gated = executed.copy() + gated_weight_changes = None if weight_changes is None else weight_changes.copy() + # What was genuinely HELD immediately before whatever (if anything) this + # row itself already traded -- derived generically from the pre-gating + # (executed, weight_changes) pair, correct whether that row was a pure + # drift row (weight_changes == 0, so this is just `executed` itself) or + # a real trade/anchor row (subtracting that row's own delta recovers + # the pre-trade state) -- never a second, potentially diverging + # recomputation of the drift trajectory. + before_state = None if weight_changes is None else executed - weight_changes + stop_loss_triggered = pd.DataFrame( + False, index=executed.index, columns=executed.columns + ) + take_profit_triggered = stop_loss_triggered.copy() + for group in groups: + columns = list(group) + group_executed = executed[columns] + gross_exposure = group_executed.abs().sum(axis=1).to_numpy(dtype=float) + with np.errstate(invalid="ignore", divide="ignore"): + group_return = ( + (group_executed * asset_returns[columns]).sum(axis=1) / gross_exposure + ).to_numpy(dtype=float) + # A leg that flips sign directly (long to short or back) without an + # intermediate flat row is economically a close-then-reopen -- the + # new direction must start a fresh episode, never inherit the old + # (opposite) position's running cumulative return. + signs = np.sign(group_executed.to_numpy(dtype=float)) + previous_signs = np.vstack([np.zeros((1, signs.shape[1])), signs[:-1]]) + reversed_without_flat = np.any( + (previous_signs != 0.0) & (signs != 0.0) & (previous_signs != signs), + axis=1, + ) + force_flat, sl, tp = _walk_group_stop_loss_take_profit( + gross_exposure, + group_return, + stop_loss_pct, + take_profit_pct, + reversed_without_flat, + ) + if force_flat.any(): + gated.loc[force_flat, columns] = 0.0 + stop_loss_triggered.loc[sl, columns] = True + take_profit_triggered.loc[tp, columns] = True + if gated_weight_changes is not None and before_state is not None: + force_flat_series = pd.Series(force_flat, index=executed.index) + transition = force_flat_series & ~force_flat_series.shift( + 1, fill_value=False + ) + # No organic drift-turnover is credited while flat, only the + # real closing trade on the first forced-flat row. + gated_weight_changes.loc[force_flat_series, columns] = 0.0 + gated_weight_changes.loc[transition, columns] = ( + 0.0 - before_state.loc[transition, columns] + ) + return gated, stop_loss_triggered, take_profit_triggered, gated_weight_changes + + +@dataclass(frozen=True) +class DriftProvenance: + """Real, cell-level provenance from :func:`apply_weight_drift`. + + ``drift_compliance_forced`` is True on the row a queued compliance + correction actually LANDED -- a fresh anchor, exactly like any other + real trade (turnover/costs/the trade log already pick this up + generically, with no special-casing, since it is just another row-to- + row change in the frame ``run_accounting`` is given). + ``drift_compliance_pending`` is True on every row a breach is known + and not yet (fully) resolved -- including the very row it was first + detected on, and every later row it is retried while blocked by + tradability. Mutually exclusive with ``drift_compliance_forced`` for + every (date, symbol) cell -- enforced in ``__post_init__`` below, since + the two masks are written by two independent, same-row `_try_restore` + calls in :func:`apply_weight_drift` with no shared memory of each + other's own verdict. + ``drift_turnover_actively_limited``/``drift_turnover_touched`` are the + exact analogue of :class:`~quantlab.portfolio.rebalancing. + TurnoverProvenance`'s own identically-named fields, but for ordinary + rebalance debt capped HERE (the only place ``maximum_turnover`` is + enforced once weight drift is active -- see ``engine.py``'s own + decision-level call). + """ + + drift_compliance_forced: pd.DataFrame + drift_compliance_pending: pd.DataFrame + drift_turnover_actively_limited: pd.DataFrame + drift_turnover_touched: pd.DataFrame + + def __post_init__(self) -> None: + """Enforce that forced/pending are never both True for the same cell.""" + overlap = self.drift_compliance_forced & self.drift_compliance_pending + if overlap.to_numpy().any(): + raise BacktestError( + "drift_compliance_forced and drift_compliance_pending must " + "be mutually exclusive per cell -- this indicates a bug in " + "apply_weight_drift's same-row compliance handling." + ) + + +def _validate_drift_and_risk_options( + *, + long_only: bool, + maximum_weight: float | None, + maximum_gross_exposure: float | None, + maximum_net_exposure: float | None, + model_weight_drift: bool | None = None, + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, + maximum_turnover: float | None = None, +) -> None: + """Validate the same invariants ``PortfolioConfig`` already enforces. + + A strategy-driven caller (``engine.py``) only ever gets here with + already-validated values (``PortfolioConfig``'s own field constraints, + ``quantlab.strategies.base.validate_risk_control_parameters``) -- but + ``run_accounting``/``apply_weight_drift`` are BOTH public, directly + callable functions (tests, scripts, a future programmatic caller) + that bypass both, so this module must not silently accept a truthy + non-bool ``model_weight_drift``, a negative ``stop_loss_pct``, or a + negative ``maximum_weight`` reaching the drift-compliance LP as a + genuinely infeasible constraint and raising a confusing "bug in the + algorithm" error instead of a clear, immediate input-validation one. + """ + flags: list[tuple[str, object]] = [("long_only", long_only)] + if model_weight_drift is not None: + flags.append(("model_weight_drift", model_weight_drift)) + for flag_name, flag_value in flags: + if not isinstance(flag_value, (bool, np.bool_)): + raise BacktestError(f"{flag_name} must be a boolean, got {flag_value!r}.") + for pct_name, pct_value in ( + ("stop_loss_pct", stop_loss_pct), + ("take_profit_pct", take_profit_pct), + ): + if pct_value is None: + continue + if isinstance(pct_value, (bool, np.bool_)) or not isinstance(pct_value, Real): + raise BacktestError( + f"{pct_name} must be a finite number, got {pct_value!r}." + ) + if not np.isfinite(float(pct_value)) or float(pct_value) <= 0.0: + raise BacktestError( + f"{pct_name} must be strictly positive, got {pct_value!r}." + ) + for cap_name, cap_value, strict, upper in ( + ("maximum_weight", maximum_weight, True, 1.0), + ("maximum_gross_exposure", maximum_gross_exposure, True, None), + ("maximum_net_exposure", maximum_net_exposure, False, None), + ("maximum_turnover", maximum_turnover, True, None), + ): + if cap_value is None: + continue + if isinstance(cap_value, (bool, np.bool_)) or not isinstance(cap_value, Real): + raise BacktestError( + f"{cap_name} must be a finite number, got {cap_value!r}." + ) + value = float(cap_value) + out_of_range = not np.isfinite(value) or ( + value <= 0.0 if strict else value < 0.0 + ) + if out_of_range: + bound = "> 0" if strict else ">= 0" + raise BacktestError( + f"{cap_name} must be a finite number {bound}, got {cap_value!r}." + ) + if upper is not None and value > upper: + raise BacktestError(f"{cap_name} must not exceed {upper}.") + + +def _validate_tradable_mask( + tradable: pd.DataFrame, reference: pd.DataFrame, *, reference_name: str +) -> pd.DataFrame: + """Validate and axis-align a strictly boolean ``tradable`` mask. + + Shared by :func:`run_accounting` and :func:`apply_weight_drift` -- both + are directly callable public functions, so neither may silently accept + a mask on different axes than the frame it is meant to gate, or a + non-boolean column (e.g. the string ``"False"``, which would otherwise + coerce to truthy on the plain ``.to_numpy(dtype=bool)`` cast every + caller of this mask ultimately performs). + """ + if not isinstance(tradable, pd.DataFrame): + raise BacktestError("tradable must be a pandas DataFrame.") + if not tradable.index.is_unique: + raise BacktestError("tradable index must not contain duplicate labels.") + if set(tradable.index) != set(reference.index) or set(tradable.columns) != set( + reference.columns + ): + raise BacktestError( + f"tradable must have the same dates and symbols as {reference_name}." + ) + if tradable.isna().to_numpy().any(): + raise BacktestError("tradable must not contain missing values.") + non_bool_columns = [ + column for column, dtype in tradable.dtypes.items() if not is_bool_dtype(dtype) + ] + if non_bool_columns: + raise BacktestError( + f"tradable must contain only boolean values; column(s) " + f"{non_bool_columns} are not boolean dtype (e.g. a string " + "'False' would otherwise silently coerce to True)." + ) + return tradable.reindex(index=reference.index, columns=reference.columns) + + +def apply_weight_drift( + executed: pd.DataFrame, + asset_returns: pd.DataFrame, + tradable: pd.DataFrame | None, + position_groups: Sequence[tuple[str, ...]] | None, + *, + maximum_weight: float | None, + maximum_gross_exposure: float | None, + maximum_net_exposure: float | None, + long_only: bool, + rebalance_date: pd.DataFrame | None = None, + maximum_turnover: float | None = None, +) -> tuple[pd.DataFrame, pd.DataFrame, DriftProvenance]: + """Evolve ``executed`` forward by organic price drift between real trades. + + Walks ``executed`` -- the ALREADY shift-respecting-tradability, look- + ahead-barrier-applied real executed book (see :func:`quantlab. + execution.orders.executed_weights`), never the pre-shift decision + timeline :mod:`quantlab.portfolio.rebalancing` produces -- forward + between genuine trades via a per-column ``dollar[i]`` exposure and a + single shared relative equity ``E`` (``weight[i] = dollar[i] / E``). + Full mechanism -- the two kinds of per-column debt (hard-risk-limit + compliance debt via :func:`~quantlab.portfolio.drift_compliance. + restore_drift_compliance`'s LP, and turnover-capped ordinary rebalance + debt), their priority order, anchor detection, same-row combination + checks, and the bankruptcy guard -- is documented in + docs/backtesting.md#weight-drift, not repeated here. + + Returns ``(pre_period_weights, trade_changes, provenance)``. + ``pre_period_weights`` is the weight HELD GOING INTO each row, BEFORE + that row's own return is applied (consistent with ``executed_weights + = held.shift(1)`` elsewhere in this module -- returning the + post-return value would double-count it). ``trade_changes`` is the + real per-row trade delta -- zero on a pure-drift row, the actual size + on a row that lands a rebalance or a compliance correction -- required + so a naive diff of ``pre_period_weights`` never sees organic drift + itself as a "trade" (see ``_run_accounting_steps``'s own + ``weight_changes_override``). Never raises or produces ``inf``/ + ``NaN`` from this recursion itself -- a bankrupt anchor-episode + (relative ``E <= EPSILON``) is force-flattened and logged instead, + mirroring ``ruined``'s own handling. (A believed-fully-restored row + that still violates a constraint DOES raise -- see the compliance + re-check right before each row is finalized -- since that specific + case is a genuine bug in the LP, not a legitimate runtime outcome.) + + ``tradable``/``position_groups`` are the same frames ``run_accounting`` + already threads through elsewhere (tradability-aware shifting, stop- + loss/take-profit position groups) -- ``tradable is None`` treats every + column as always tradable (single-calendar short-circuit). + ``rebalance_date`` is a boolean, ``dates x symbols`` :class:`pandas. + DataFrame` matching ``executed``'s own index and columns exactly, + already shifted onto the executed timeline exactly like ``executed`` + itself (see ``run_accounting``'s own docstring and ``engine.py``'s + construction of it) -- a column must never be marked ``True`` on a + date it is not itself genuinely tradable. ``None`` falls back to + value-diff-only anchor detection. + """ + _validate_drift_and_risk_options( + long_only=long_only, + maximum_weight=maximum_weight, + maximum_gross_exposure=maximum_gross_exposure, + maximum_net_exposure=maximum_net_exposure, + maximum_turnover=maximum_turnover, + ) + # A directly-callable public function (see the module docstring's own + # "run_accounting/apply_weight_drift are BOTH public" note) must not + # silently accept a malformed `executed`/`asset_returns`/`tradable` -- + # unlike `run_accounting`, which is reached only through its own + # up-front validation, a caller can invoke this function directly with + # entirely unvalidated data. + executed = validate_execution_frame(executed, name="executed") + if not isinstance(asset_returns, pd.DataFrame): + raise BacktestError("asset_returns must be a pandas DataFrame.") + if not asset_returns.index.is_unique: + raise BacktestError("asset_returns index must not contain duplicate labels.") + if not asset_returns.columns.is_unique: + raise BacktestError("asset_returns columns must not contain duplicate labels.") + missing_dates = executed.index.difference(asset_returns.index) + missing_symbols = executed.columns.difference(asset_returns.columns) + if len(missing_dates) or len(missing_symbols): + raise BacktestError( + "asset_returns must cover every executed date and symbol " + f"(missing dates: {list(missing_dates)[:5]}, missing symbols: " + f"{list(missing_symbols)[:5]})." + ) + asset_returns = asset_returns.reindex_like(executed) + try: + returns_values = asset_returns.to_numpy(dtype=float) + except (TypeError, ValueError) as exc: + raise BacktestError("asset_returns must contain only numeric values.") from exc + if np.isinf(returns_values).any(): + raise BacktestError("asset_returns must not contain Infinity.") + finite_returns = returns_values[np.isfinite(returns_values)] + if (finite_returns < -1.0).any(): + raise BacktestError( + "asset_returns must not contain simple returns below -1.0 (-100%)." + ) + # Row 0 is exempt: it is always this series' own anchor (`previous_row + # is None`), with no prior row to have earned a return during -- a + # caller may legitimately start `executed` already non-zero (assuming + # a pre-existing position) with no return recorded for how it got + # there, mirroring `run_accounting`'s own `executed = held.shift(1)` + # convention, which always makes the production pipeline's row 0 + # exactly 0 regardless of `held`. A genuinely MISSING return on an + # ALREADY-established held position from row 1 onward is still real, + # unambiguous missing data and must still raise. + missing_held_returns = np.isnan(returns_values) & ( + np.abs(executed.to_numpy(dtype=float)) > EPSILON + ) + if missing_held_returns.shape[0] > 0: + missing_held_returns[0, :] = False + if missing_held_returns.any(): + bad = np.argwhere(missing_held_returns)[0] + raise BacktestError( + "asset_returns is missing a return for a held position: " + f"{executed.index[bad[0]]!r}/{executed.columns[bad[1]]!r}." + ) + if tradable is not None: + tradable = _validate_tradable_mask( + tradable, executed, reference_name="executed" + ) + columns = list(executed.columns) + n_rows, n_cols = executed.shape + groups = _resolve_position_groups(executed.columns, position_groups) + if rebalance_date is not None: + if not isinstance(rebalance_date, pd.DataFrame): + raise BacktestError("rebalance_date must be a pandas DataFrame.") + if not rebalance_date.index.is_unique: + raise BacktestError( + "rebalance_date index must not contain duplicate labels." + ) + if set(rebalance_date.index) != set(executed.index) or set( + rebalance_date.columns + ) != set(executed.columns): + raise BacktestError( + "rebalance_date must have the same dates and symbols as executed." + ) + if rebalance_date.isna().to_numpy().any(): + raise BacktestError("rebalance_date must not contain missing values.") + non_bool_columns = [ + column + for column, dtype in rebalance_date.dtypes.items() + if not is_bool_dtype(dtype) + ] + if non_bool_columns: + raise BacktestError( + f"rebalance_date must contain only boolean values; column(s) " + f"{non_bool_columns} are not boolean dtype (e.g. a string " + "'False' would otherwise silently coerce to True)." + ) + rebalance_date_np = rebalance_date.reindex( + index=executed.index, columns=executed.columns + ).to_numpy(dtype=bool) + else: + rebalance_date_np = None + + executed_np = executed.to_numpy(dtype=float) + returns_np = returns_values + if tradable is not None: + tradable_np = tradable.reindex( + index=executed.index, columns=executed.columns + ).to_numpy(dtype=bool) + else: + tradable_np = np.ones((n_rows, n_cols), dtype=bool) + + out = np.zeros((n_rows, n_cols)) + trade_changes = np.zeros((n_rows, n_cols)) + drift_compliance_forced = np.zeros((n_rows, n_cols), dtype=bool) + drift_compliance_pending = np.zeros((n_rows, n_cols), dtype=bool) + drift_turnover_actively_limited = np.zeros((n_rows, n_cols), dtype=bool) + drift_turnover_touched = np.zeros((n_rows, n_cols), dtype=bool) + + def _violations(row: np.ndarray) -> list[str]: + return _compliance_violations( + row, + maximum_weight=maximum_weight, + maximum_gross_exposure=maximum_gross_exposure, + maximum_net_exposure=maximum_net_exposure, + long_only=long_only, + ) + + def _try_restore( + row: np.ndarray, row_tradable: np.ndarray + ) -> tuple[np.ndarray, np.ndarray, bool] | None: + """``None`` if already compliant; else (corrected, relevant_mask, pending). + + ``relevant_mask`` is the set of columns the caller must track for + this correction: every column the LP actually moved, plus -- + ONLY when full compliance was not achievable (``pending=True``) + -- every currently-untradable column still holding a non-zero + position. The latter matters because a single untradable column + that IS the entire breach (nothing free exists to move at all) + would otherwise report an EMPTY moved-set despite a real, + unresolved violation -- silently losing both the provenance + marking and the "wait for this column to reopen" eligibility + check the pending state depends on. + """ + if not _violations(row): + return None + result = restore_drift_compliance( + row, + columns, + row_tradable, + groups, + maximum_weight=maximum_weight, + maximum_gross_exposure=maximum_gross_exposure, + maximum_net_exposure=maximum_net_exposure, + long_only=long_only, + ) + moved = np.abs(result.corrected - row) > EPSILON + if result.pending: + still_held_and_closed = (~row_tradable) & (np.abs(row) > EPSILON) + moved = moved | still_held_and_closed + return result.corrected, moved, result.pending + + dollar = np.zeros(n_cols) + equity = 1.0 + previous_row: np.ndarray | None = None + # Per-column ordinary rebalance debt: `ordinary_target[i]` is the + # decided value column `i` is being walked toward, meaningful only + # where `ordinary_mask[i]` is True. A fresh per-column decision (a + # value-diff or scheduled-rebalance anchor) overwrites BOTH for that + # column alone -- it never touches any OTHER column's own outstanding + # debt. + ordinary_mask = np.zeros(n_cols, dtype=bool) + ordinary_target = np.zeros(n_cols) + # Whether a hard-risk-limit breach is currently outstanding anywhere + # in the portfolio -- see the docstring's "compliance debt" section. + compliance_pending = False + + for t in range(n_rows): + row = executed_np[t] + row_tradable = tradable_np[t] + if previous_row is None: + fresh = np.ones(n_cols, dtype=bool) + else: + fresh = np.abs(row - previous_row) > EPSILON + if rebalance_date_np is not None: + fresh = fresh | rebalance_date_np[t] + previous_row = row + + # `dollar/equity` currently hold the state finalized at the END of + # row (t-1)'s own advance -- the weight HELD GOING INTO row t, + # before row t's own return. Every subsequent step below measures + # a real trade against this PRE-transaction value. + weight_prev = dollar / equity if equity > EPSILON else np.zeros(n_cols) + + # Debt already outstanding BEFORE this row's own fresh decisions are + # folded in -- used below only to distinguish "this row's move is + # continuing a catch-up that was already running" from "this is a + # brand-new decision" for turnover-touched provenance; it plays no + # role in what actually executes. + carried_in_mask = ordinary_mask.copy() + ordinary_target = np.where(fresh, row, ordinary_target) + ordinary_mask = ordinary_mask | fresh + + landed = weight_prev + landed_compliance_mask = np.zeros(n_cols, dtype=bool) + + # --- Compliance debt: highest priority, exempt from + # `maximum_turnover`, re-solved fresh from THIS row's own weights + # (never a stale stored target -- see the docstring). A hard-limit + # correction is ORTHOGONAL to ordinary rebalance debt -- it never + # clears it, even for a column it moves: `ordinary_target` is + # always itself a validated-compliant value (upstream constraint + # enforcement, or an earlier compliance-restored point), so once + # this correction lands, resuming the walk toward that target is + # always safe, and a still-more-conservative target must not be + # silently abandoned just because compliance intervened first. + if compliance_pending: + restored = _try_restore(landed, row_tradable) + if restored is None: + compliance_pending = False + else: + corrected, moved, still_pending = restored + landed = np.where(moved, corrected, landed) + landed_compliance_mask = moved + if still_pending: + drift_compliance_pending[t, moved] = True + else: + drift_compliance_forced[t, moved] = True + compliance_pending = still_pending + + # --- Ordinary rebalance debt: turnover-capped, for whatever + # columns are both owed a decision AND actually tradable this + # row -- a closed column's own debt simply waits, untouched, for + # a later row it reopens on, whether it is a fresh anchor or a + # multi-row catch-up. + eligible = ordinary_mask & row_tradable & ~landed_compliance_mask + desired = np.where(ordinary_mask, ordinary_target, landed) + change = np.where(eligible, desired - landed, 0.0) + requested = float(np.abs(change).sum()) + if maximum_turnover is None or requested <= maximum_turnover + EPSILON: + fraction = 1.0 + else: + fraction = maximum_turnover / requested + landed = landed + fraction * change + # Real, cell-level turnover-limiting provenance -- the exact + # analogue of `quantlab.portfolio.rebalancing.cap_turnover`'s own + # `turnover_actively_limited`/`turnover_touched` frames, since + # this is now the ONLY place `maximum_turnover` is actually + # enforced when weight drift is active (see `engine.py`'s own + # decision-level call, which passes `maximum_turnover=None` in + # that case for exactly this reason). + actively_limited = eligible & (fraction < 1.0 - EPSILON) + drift_turnover_actively_limited[t] = actively_limited + drift_turnover_touched[t] = eligible & (actively_limited | carried_in_mask) + if fraction >= 1.0 - EPSILON: + ordinary_mask = ordinary_mask & ~eligible + + # --- A genuinely NEW violation can emerge purely from COMBINING + # this row's just-decided/corrected columns with another column's + # frozen or still-drifting value -- unlike organic drift (an + # exogenous price move needing a one-row lag to react to), every + # input to this combination is already known before finalizing + # this row's own output, so there is no look-ahead concern in + # resolving it immediately. Only runs when something was actually + # decided this row; a pure, undisturbed drift row instead keeps + # the ordinary one-row-lag detect-then-queue behavior below. + decided_this_row = bool(landed_compliance_mask.any() or eligible.any()) + if decided_this_row and not compliance_pending: + restored = _try_restore(landed, row_tradable) + if restored is not None: + corrected, moved, still_pending = restored + landed = np.where(moved, corrected, landed) + # This is a SECOND, independent `_try_restore` call (Step 1 + # above may have already run its own on this same row) -- + # its own write to `landed` for `moved` cells supersedes + # whatever Step 1 already recorded for the SAME cell + # earlier this row, since Step 1's `landed` value for + # those cells has just been overwritten above. Clear + # first so `forced`/`pending` can never both be True for + # the same cell in the same row -- this call's own + # verdict is authoritative for any cell it touches. + drift_compliance_forced[t, moved] = False + drift_compliance_pending[t, moved] = False + if still_pending: + drift_compliance_pending[t, moved] = True + compliance_pending = True + else: + drift_compliance_forced[t, moved] = True + elif not decided_this_row and not compliance_pending: + # Pure organic drift newly breaching a limit -- queued, never + # applied to this row's own output (the portfolio genuinely + # held the breaching value for one row; correcting it + # retroactively would be look-ahead). Lands starting the next + # row via the compliance-debt branch above. + restored = _try_restore(landed, row_tradable) + if restored is not None: + _corrected, moved, _still_pending = restored + drift_compliance_pending[t, moved] = True + compliance_pending = True + + if not compliance_pending: + # Defensive check, mirroring `rebalancing._assert_holdings_ + # compliant`'s own "never trust the invariant blindly" + # philosophy: whenever the row-walk believes no compliance + # debt remains outstanding for THIS row, `landed` must + # actually be compliant -- checked on the row's final, + # fully-assembled value (not inside `_try_restore` itself, + # which can legitimately return `pending=False` for an + # INTERMEDIATE state that a later, independent `_try_restore` + # call in this same row -- see the "combining this row's + # just-decided columns" branch above -- is specifically + # responsible for re-checking against a value it hadn't seen + # yet). A violation here would mean a bug in the LP + # formulation (or in how this loop combines its calls), not a + # bad input -- but a silent violation would be an expensive, + # hard-to-diagnose out-of-mandate position, so this fails + # loudly rather than reporting a clean "compliance restored" + # trade-log event over a row that still breaches a limit. + remaining = _violations(landed) + if remaining: + raise BacktestError( + "apply_weight_drift produced a row believed fully " + f"compliant (no pending debt) that still violates: " + f"{', '.join(remaining)} -- this indicates a bug in " + "the drift-compliance restoration, not a legitimate " + "runtime condition." + ) + out[t] = landed + trade_changes[t] = landed - weight_prev + touched = np.abs(landed - weight_prev) > EPSILON + dollar = np.where(touched, landed * equity, dollar) + if not ordinary_mask.any() and not compliance_pending: + # Pure numerical hygiene, never a behavior change: `weight = + # dollar / equity` is invariant under uniformly rescaling + # both, so renormalizing to `E = 1.0` is always safe whenever + # no debt of any kind remains outstanding. + dollar = landed.copy() + equity = 1.0 + + # === Advance state using row t's OWN return, with out[t] (this + # row's just-finalized output) as the base weight. `dollar/equity` + # already equal `out[t]` here. The result becomes `weight_prev` -- + # and so, by default, `out[t+1]` -- for the next row. === + r = returns_np[t] + r = np.where(np.isfinite(r), r, 0.0) + gross_return_t = float(np.sum(out[t] * r)) + dollar = dollar * (1.0 + r) + equity = equity * (1.0 + gross_return_t) + + if equity <= EPSILON: + dollar = np.zeros(n_cols) + equity = 1.0 + ordinary_mask = np.zeros(n_cols, dtype=bool) + compliance_pending = False + logger.warning( + "Weight-drift equity (relative to its own last anchor, " + "gross/pre-cost) reached zero or below at %s -- " + "flattening this episode's drifted positions. Check " + "leverage and cost configuration.", + executed.index[t], + ) + + pre_period_weights = pd.DataFrame( + out, index=executed.index, columns=executed.columns + ) + trade_changes_frame = pd.DataFrame( + trade_changes, index=executed.index, columns=executed.columns + ) + provenance = DriftProvenance( + drift_compliance_forced=pd.DataFrame( + drift_compliance_forced, index=executed.index, columns=executed.columns + ), + drift_compliance_pending=pd.DataFrame( + drift_compliance_pending, index=executed.index, columns=executed.columns + ), + drift_turnover_actively_limited=pd.DataFrame( + drift_turnover_actively_limited, + index=executed.index, + columns=executed.columns, + ), + drift_turnover_touched=pd.DataFrame( + drift_turnover_touched, index=executed.index, columns=executed.columns + ), + ) + return pre_period_weights, trade_changes_frame, provenance + + def run_accounting( held_weights: pd.DataFrame, asset_returns: pd.DataFrame, @@ -228,6 +1150,16 @@ def run_accounting( initial_capital: float, *, tradable: pd.DataFrame | None = None, + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, + position_groups: Sequence[tuple[str, ...]] | None = None, + model_weight_drift: bool = False, + maximum_weight: float | None = None, + maximum_gross_exposure: float | None = None, + maximum_net_exposure: float | None = None, + long_only: bool = False, + rebalance_date: pd.DataFrame | None = None, + maximum_turnover: float | None = None, ) -> AccountingResult: """Run the vectorised accounting loop. @@ -244,7 +1176,58 @@ def run_accounting( tradable row, not the raw next row, so it is never misattributed as trading during the closure itself (e.g. a weekend row that only exists because another, always-open instrument shares the - same combined index). + same combined index). Also the tradability `apply_weight_drift` + (when enabled) uses for its own compliance-restoration LP. + stop_loss_pct: Fractional (e.g. 0.10 = 10%) gross-return threshold + that force-flattens a position/group -- see + :func:`_detect_stop_loss_take_profit` for the exact formula + and why it operates on the real executed position rather than + a raw strategy signal. ``None`` (default) disables it, with + strictly no change to accounting's own numbers. + take_profit_pct: Same, on the favorable side. + position_groups: Column groups (e.g. pairs_trading's two legs) + whose combined P&L, not each column's own, drives the stop- + loss/take-profit check -- see :func:`_resolve_position_groups`. + A column absent from every group is its own independent group. + Also the groups `apply_weight_drift`'s compliance-restoration + LP moves coherently via one shared scalar. + model_weight_drift: When ``True``, evolve ``executed`` forward by + organic price drift between real trades (see + :func:`apply_weight_drift`) instead of holding it constant + until the next scheduled rebalance -- the constant-weight + step function is a documented approximation this flag + replaces with a materially more accurate one. ``False`` + (default here) is strictly a no-op: ``executed`` passes + through unchanged. + maximum_weight: Per-asset hard cap, re-enforced on every drift row + when `model_weight_drift` is enabled (see + :func:`quantlab.portfolio.drift_compliance. + restore_drift_compliance`). Ignored when `model_weight_drift` + is ``False``. + maximum_gross_exposure: Portfolio-level gross cap, same treatment. + maximum_net_exposure: Portfolio-level net cap, same treatment. + long_only: Same treatment. + rebalance_date: Ignored unless `model_weight_drift` is `True`. + Boolean, ``dates x symbols`` DataFrame matching `held_weights`' + own dates and symbols, aligned to the EXECUTED timeline + exactly like `tradable` -- `True` on a (row, column) whose + underlying decision for THAT symbol was made on a genuine + scheduled rebalance date; a column must never be marked + `True` on a date it is not itself tradable. `None` falls back + to anchor detection from `executed`'s own row-to-row diff + alone. See :func:`apply_weight_drift`'s own docstring and + docs/backtesting.md#weight-drift for why this matters (a + constant-target schedule is otherwise invisible to value- + diffing) and the full anchor-detection mechanics. + maximum_turnover: Forwarded to :func:`apply_weight_drift` when + `model_weight_drift` is enabled, bounding the L1 size of an + ordinary anchor's catch-up trade (exempting a hard-risk-limit + drift-compliance correction -- see docs/backtesting.md#weight- + drift for the exact mechanics). Ignored when `model_weight_ + drift` is `False` (the decision-level `rebalance_and_cap_ + turnover` cap already applies there, unaffected by this + parameter either way). `None` (default) leaves anchor catch- + ups uncapped. Returns: A populated :class:`AccountingResult`. @@ -265,6 +1248,21 @@ def run_accounting( ) from exc if not np.isfinite(capital) or capital <= 0.0: raise BacktestError("initial_capital must be a finite number greater than 0.") + if not isinstance(execution_model, ExecutionModel): + raise BacktestError( + f"execution_model must be an ExecutionModel instance, got " + f"{execution_model!r}." + ) + _validate_drift_and_risk_options( + long_only=long_only, + maximum_weight=maximum_weight, + maximum_gross_exposure=maximum_gross_exposure, + maximum_net_exposure=maximum_net_exposure, + model_weight_drift=model_weight_drift, + stop_loss_pct=stop_loss_pct, + take_profit_pct=take_profit_pct, + maximum_turnover=maximum_turnover, + ) for name, frame in ( ("held_weights", held_weights), @@ -290,24 +1288,19 @@ def run_accounting( ) if tradable is not None: - # Exact same *set* of dates and symbols, no missing values -- unlike - # asset_returns (which may legitimately come from a wider price - # matrix), tradable is only ever built internally from held_weights' - # own (date, symbol) grid, never user input. A mismatched set always - # means an upstream wiring bug, so it must raise loudly rather than + # tradable is only ever built internally from held_weights' own + # (date, symbol) grid, never user input, so a mismatched axis set + # always means an upstream wiring bug -- raise loudly rather than # silently default an unrecognized cell to "tradable" and risk # trading a symbol that should have stayed closed. Axis *order* # alone is not a mismatch: a caller may build tradable from a # declared symbol list while held_weights comes from an - # alphabetically-pivoted price matrix. - if set(tradable.index) != set(held_weights.index) or set( - tradable.columns - ) != set(held_weights.columns): - raise BacktestError( - "tradable must have the same dates and symbols as held_weights." - ) - if tradable.isna().to_numpy().any(): - raise BacktestError("tradable must not contain missing values.") + # alphabetically-pivoted price matrix. Strictly boolean dtype + # (never e.g. the string "False", which would otherwise coerce to + # truthy) is enforced by the shared helper. + tradable = _validate_tradable_mask( + tradable, held_weights, reference_name="held_weights" + ) held = held_weights.sort_index() if tradable is not None: @@ -330,7 +1323,65 @@ def run_accounting( # sorted alongside it, so it needs no further alignment here. executed = compute_executed_weights(held, tradable=tradable) - result = _solve_accounting(executed, asset_returns, execution_model, capital) + # Weight drift operates on this ALREADY shift-respecting-tradability, + # look-ahead-barrier-applied real executed book -- see + # apply_weight_drift's own docstring for why this must be the post- + # shift executed timeline, never the pre-shift decision timeline + # rebalancing.py produces. `False` (default) is a provable no-op. + drift_provenance: DriftProvenance | None = None + # Real-trade-only turnover/cost input (see apply_weight_drift's own + # docstring): `None` outside drift, where `executed` is already a + # plain step function and every row-to-row diff genuinely IS a trade. + weight_changes_override: pd.DataFrame | None = None + if model_weight_drift: + executed, weight_changes_override, drift_provenance = apply_weight_drift( + executed, + asset_returns, + tradable, + position_groups, + maximum_weight=maximum_weight, + maximum_gross_exposure=maximum_gross_exposure, + maximum_net_exposure=maximum_net_exposure, + long_only=long_only, + rebalance_date=rebalance_date, + maximum_turnover=maximum_turnover, + ) + + result = _solve_accounting( + executed, + asset_returns, + execution_model, + capital, + weight_changes_override=weight_changes_override, + ) + + # Detect stop-loss/take-profit breaches on the REAL executed position + # from this initial pass, then -- exactly like the ruin handling below + # -- gate the affected cells and re-solve so turnover, costs, returns + # and equity are all self-consistent with the forced flatten. Checked + # BEFORE ruin: a stop-loss is a strategy-level risk control, not the + # portfolio-wide catastrophe ruin represents (which still overrides + # it below if both occur). + gated_executed, stop_loss_triggered, take_profit_triggered, gated_weight_changes = ( + _detect_stop_loss_take_profit( + result.executed_weights, + asset_returns, + position_groups, + stop_loss_pct, + take_profit_pct, + weight_changes=weight_changes_override, + ) + ) + if stop_loss_triggered.to_numpy().any() or take_profit_triggered.to_numpy().any(): + executed = gated_executed + weight_changes_override = gated_weight_changes + result = _solve_accounting( + executed, + asset_returns, + execution_model, + capital, + weight_changes_override=weight_changes_override, + ) # After equity reaches zero, flatten later positions and recompute so # returns, turnover, costs and the trade log contain no post-ruin trades. @@ -352,7 +1403,41 @@ def run_accounting( execution_model, capital, force_flat=ruined, + weight_changes_override=weight_changes_override, + ) + result.ruined = ruined + # `_solve_accounting` above (both the stop-loss/take-profit re-run and + # this ruin re-run) returns a fresh AccountingResult whose stop_loss_ + # triggered/take_profit_triggered are all-False placeholders -- restore + # the real provenance detected earlier. `forced_liquidation` still wins + # over these in the trade log's own reason-priority ordering when both + # coincide on the same cell, so no need to clear them on ruined dates. + result.stop_loss_triggered = stop_loss_triggered + result.take_profit_triggered = take_profit_triggered + if drift_provenance is not None: + result.drift_compliance_forced = drift_provenance.drift_compliance_forced + result.drift_compliance_pending = drift_provenance.drift_compliance_pending + result.drift_turnover_actively_limited = ( + drift_provenance.drift_turnover_actively_limited ) + result.drift_turnover_touched = drift_provenance.drift_turnover_touched + # `DriftProvenance.__post_init__` already enforced forced/pending + # mutual exclusion at construction time, but `AccountingResult` is + # a plain mutable dataclass with no invariant of its own -- that + # guarantee does not survive being flattened onto its separately- + # mutable fields above. Cheap re-check here too, so a future + # change to this flattening step (the only place that does it) + # can't silently reintroduce an overlap nothing downstream would + # otherwise catch. + overlap = result.drift_compliance_forced & result.drift_compliance_pending + if overlap.to_numpy().any(): + raise BacktestError( + "AccountingResult.drift_compliance_forced and " + "drift_compliance_pending are not mutually exclusive after " + "being attached in run_accounting -- this indicates a bug " + "in that flattening step, not in DriftProvenance's own " + "construction." + ) logger.info( "Accounting: %d periods, final equity %.2f (gross %.2f), avg turnover %.4f", diff --git a/src/quantlab/backtesting/benchmark.py b/src/quantlab/backtesting/benchmark.py index 3301e22..d05ecf2 100644 --- a/src/quantlab/backtesting/benchmark.py +++ b/src/quantlab/backtesting/benchmark.py @@ -118,6 +118,15 @@ def _align_returns( ~is_session_day(calendar, pd.DatetimeIndex(combined_index)), index=combined_index, ) + # `combined_index[0]` can precede the benchmark's own first date + # (e.g. a 24/7 portfolio instrument trading on a date the + # benchmark's calendar marks as a holiday closure): that leading + # slot has nothing to forward-fill from, so seed it exactly like a + # closure -- but *only* when it genuinely is one. A leading date + # that is missing for any other reason (a real gap) must still + # raise below, unmasked by this. + if pd.isna(equity_on_combined.iloc[0]) and bool(closure.iloc[0]): + equity_on_combined.iloc[0] = 1.0 fillable = equity_on_combined.isna() & closure equity_on_combined = equity_on_combined.mask( fillable, equity_on_combined.ffill() diff --git a/src/quantlab/backtesting/engine.py b/src/quantlab/backtesting/engine.py index 147e886..a1445fe 100644 --- a/src/quantlab/backtesting/engine.py +++ b/src/quantlab/backtesting/engine.py @@ -34,6 +34,7 @@ from numbers import Integral from pathlib import Path +import numpy as np import pandas as pd from quantlab.backtesting.accounting import ( @@ -48,6 +49,7 @@ from quantlab.constants import ( CALENDAR_DAYS_PER_YEAR, CRYPTO_FREQUENCY_TO_PERIODS_PER_YEAR, + EPSILON, FREQUENCY_TO_PERIODS_PER_YEAR, SYMBOL, TIMESTAMP, @@ -60,6 +62,7 @@ from quantlab.exceptions import BacktestError, QuantLabError from quantlab.execution.execution_model import ExecutionModel from quantlab.execution.orders import ( + executed_weights, shift_respecting_tradability, validate_execution_frame, ) @@ -70,12 +73,17 @@ ) from quantlab.logging_config import get_logger from quantlab.portfolio.allocator import PortfolioAllocator, build_allocator -from quantlab.portfolio.constraints import constraints_from_config -from quantlab.portfolio.rebalancing import rebalance_and_cap_turnover +from quantlab.portfolio.constraints import ConstraintTouch, constraints_from_config +from quantlab.portfolio.rebalancing import ( + apply_rebalancing, + rebalance_and_cap_turnover, + rebalance_dates, +) from quantlab.portfolio.volatility_targeting import apply_volatility_target from quantlab.risk.metrics import compute_metrics from quantlab.strategies.base import ( BaseStrategy, + SignalReasons, build_strategy, strategy_parameter_names, ) @@ -548,10 +556,52 @@ def run( ) asset_returns = compute_asset_returns(prices) + # Engine-injected context (never a user-configured constructor + # hyperparameter, see BaseStrategy.symbol_calendars's own + # docstring), set before any strategy method that might compute a + # rolling-window feature is called, so every native-calendar call + # site (quantlab.features.native_calendar.compute_native_then_ + # align) can compute on each symbol's own calendar rather than a + # closure-padded combined timeline. + symbol_calendars = { + instrument.symbol: instrument.calendar + for instrument in config.data.instruments + } + strategy.symbol_calendars = symbol_calendars + # Require the strategy to cover the exact tradable panel. signals = strategy.generate_signals(tradable_data) signals = strategy._validate_signals(signals, prices) + # Optional, strategy-specific explanation of `signals` -- a pure, + # deterministic recomputation from the SAME tradable_data (see + # BaseStrategy.explain_signals's own docstring), never a cache of + # the call above. None for strategies that don't implement it + # (the default): the generic strategy_signal reason still works, + # just without a strategy-specific sub-code. + raw_signal_reasons = strategy.explain_signals(tradable_data) + signal_reasons: SignalReasons | None = ( + None + if raw_signal_reasons is None + else BaseStrategy._validate_signal_reasons( + raw_signal_reasons.detail_code, raw_signal_reasons.details, prices + ) + ) + + # Diagnostic decision proxy (optional, see BaseStrategy.decision_ + # signal's own docstring): defaults to the raw signal itself, which + # is already a faithful decision proxy for every built-in strategy + # except pairs_trading (whose raw signal mixes a discrete decision + # with mechanical price/beta rescaling). Used ONLY for trigger + # detection and position_strategy_origin tracking below -- never + # substituted for `signals` in allocation, constraints or execution. + raw_decision_proxy = strategy.decision_signal(tradable_data) + decision_proxy = ( + signals + if raw_decision_proxy is None + else BaseStrategy._validate_decision_signal(raw_decision_proxy, prices) + ) + # Convert signals to target weights. allocated = validate_execution_frame( allocator.allocate(signals, tradable_data), name="allocator output" @@ -583,9 +633,23 @@ def run( periods_per_year=config.periods_per_year, ) - # Enforce portfolio constraints. + # Fully-desired weights, post-allocation and post-volatility-target + # but pre-constraint -- captured under its own stable name before + # `constrained` (below) overwrites what `target_weights` means, so + # the trade log can later tell "didn't reach its desired size" (a + # constraint) apart from "the desired size itself changed" (signal/ + # rebalance/vol-target). See build_trade_log's own docstring. + desired_target = target_weights + + # Enforce portfolio constraints. apply_with_provenance runs the + # exact same computation as apply() (see its own docstring) and + # additionally records, per constraint, which cells it actually + # changed -- real provenance from the real computation, not a + # parallel reconstruction. constraints = constraints_from_config(config.portfolio) - constrained = constraints.apply(target_weights) + constrained, constraint_touches = constraints.apply_with_provenance( + target_weights + ) # Apply the shared stateful rebalancing/turnover pipeline once over # the full index so its state remains continuous. A closed symbol @@ -598,40 +662,477 @@ def run( # real holiday set need not match every quirk of whatever data # happens to be loaded for a lone calendar. tradable = None - symbol_calendars = { - instrument.symbol: instrument.calendar - for instrument in config.data.instruments - } shared_calendar = uniform_calendar(symbol_calendars.values()) if config.data.frequency == DAILY_FREQUENCY and shared_calendar is None: + # `prices.columns` (not `config.symbols`) is the order every + # other frame in this method is built and validated against + # (`allocated.columns.equals(prices.columns)` above, and + # transitively `constrained`/`held_weights`/the diagnostic + # frames below) -- `pivot_field` sorts symbols alphabetically, + # which need not match the instrument declaration order in + # `config.symbols`. `executed_weights(..., tradable=tradable)` + # (used throughout this method) requires its `tradable` frame + # to have EXACTLY matching columns, in the same order, so + # `tradable` must be built against `prices.columns` here too. tradable = tradable_mask_for( - pd.DatetimeIndex(prices.index), config.symbols, symbol_calendars + pd.DatetimeIndex(prices.index), list(prices.columns), symbol_calendars + ) + # Position-group-coherent tradability (e.g. pairs_trading's two + # legs): a per-column tradable mask alone only guarantees each + # LEG's own eligibility independently -- a declared group must + # be eligible to move as ONE unit, on a date every member is + # tradable, never a date only some of its legs are (no legging + # risk is modeled). `_rebalance_tradability_aware`'s per-column + # "pending debt, retried every day" scheduling then operates on + # this group-collapsed mask directly. A symbol never in any + # declared group keeps its own independent tradability. + groups = strategy.position_groups() + if groups: + for group in groups: + members = [symbol for symbol in group if symbol in tradable.columns] + if len(members) > 1: + group_tradable = tradable[members].all(axis=1) + for symbol in members: + tradable[symbol] = group_tradable + + # Diagnostic (not real-execution) frames used only to attribute a + # trade's reason -- signals/allocated/desired_target/constrained are + # recomputed fresh every row (never forward-filled the way + # held_weights is), so comparing them "yesterday vs today" would + # measure normal day-to-day drift, not "since the last rebalance + # decision". Resampling them the same way rebalance_and_cap_turnover + # itself samples `constrained` (via apply_rebalancing, using the + # same frequency/calendar) gives them the same rebalance-date + # cadence as held_weights, so a plain shift(1) inside + # build_trade_log correctly reads "value as of the previous + # rebalance decision". Never used to recompute an executed weight, cost or + # PnL figure -- only build_trade_log's reason classifier reads + # these. Centralised in one local helper so these frames can never + # drift out of sync with each other or with held_weights' own shift. + def _rebalance_diagnostic_frame(frame: pd.DataFrame) -> pd.DataFrame: + return apply_rebalancing( + frame, config.portfolio.rebalance_frequency, calendar=shared_calendar + ) + + # `rebalanced_signal` is built from `decision_proxy`, not `signals` + # directly: this is what prevents `strategy_signal` from firing on + # a strategy's purely mechanical rescaling (e.g. pairs_trading's + # price/beta drift at constant discrete state) that isn't a real + # new decision. `rebalanced_constrained` (new) is used only to + # build `target_episode_id` below. + rebalanced_signal = _rebalance_diagnostic_frame(decision_proxy) + rebalanced_allocated = _rebalance_diagnostic_frame(allocated) + rebalanced_desired = _rebalance_diagnostic_frame(desired_target) + rebalanced_constrained = _rebalance_diagnostic_frame(constrained) + + # `target_episode_id`: a cell-level, monotonically increasing + # integer identifying which upstream decision produced the target + # currently being chased at each rebalance date -- the real + # identity a turnover-capped/tradability-deferred debt is scoped + # to (see rebalancing.py's `episode_id`), never a bare calendar + # counter (row-level) nor the target's own numeric value (two + # distinct decisions can coincidentally produce the same number). + # Increments a cell's counter only when a REAL upstream event + # concerns that cell: the exact same three diagnostic comparisons + # `_classify_reason` uses for trigger detection (signal/allocator/ + # vol-target changed since the last rebalance), or -- when none of + # those fired -- the pre-turnover target itself still drifting + # (the same condition that drives the position_rescaling fallback + # below), which is how a continuously-rescaling target (pairs_ + # trading) still gets a fresh episode per genuine change. A plain + # periodic re-sampling of the SAME still-pursued target increments + # nothing, so a turnover/tradability debt against it survives + # across rebalance dates as required. + def _changed_since_last_rebalance(frame: pd.DataFrame) -> pd.DataFrame: + values = frame.to_numpy() + previous = np.vstack([np.zeros((1, values.shape[1])), values[:-1]]) + return pd.DataFrame( + np.abs(values - previous) > EPSILON, + index=frame.index, + columns=frame.columns, ) - held_weights = rebalance_and_cap_turnover( - constrained, config.portfolio, tradable=tradable, calendar=shared_calendar + + signal_changed = _changed_since_last_rebalance(rebalanced_signal) + allocator_changed = _changed_since_last_rebalance(rebalanced_allocated) + vol_target_changed = _changed_since_last_rebalance(rebalanced_desired) + constrained_changed = _changed_since_last_rebalance(rebalanced_constrained) + no_trigger = ~(signal_changed | allocator_changed | vol_target_changed) + new_episode_this_row = ( + signal_changed + | allocator_changed + | vol_target_changed + | (no_trigger & constrained_changed) + ) + target_episode_id = new_episode_this_row.astype(int).cumsum() + + # When weight drift is enabled, `apply_weight_drift` (below, via + # run_accounting) is the SOLE place `maximum_turnover` is applied -- + # capping it here too would make this decision-level step produce + # an INTERMEDIATE, not-yet-fully-walked target (e.g. 0.2 while the + # true schedule target is 1.0), which the drift layer would then + # treat as "the" target, capable of trading the portfolio BACKWARD + # toward that stale intermediate value even while organic price + # drift has already carried it past it. Scheduling and + # tradability-aware pending-debt-carrying are UNCHANGED (`None` + # is already this module's own "uncapped" convention, not a + # separate code path) -- only the cap itself is turned off here. + decision_portfolio_config = ( + config.portfolio.revalidated_copy(update={"maximum_turnover": None}) + if config.portfolio.model_weight_drift + else config.portfolio ) - if delay > 0: + held_weights, turnover_provenance = rebalance_and_cap_turnover( + constrained, + decision_portfolio_config, + tradable=tradable, + calendar=shared_calendar, + episode_id=target_episode_id, + return_provenance=True, + ) + + def _apply_extra_delay(frame: pd.DataFrame) -> pd.DataFrame: + if delay <= 0: + return frame if tradable is not None: # A raw row-count shift would delay execution onto a date a # symbol can't actually trade on (see - # shift_respecting_tradability's docstring) -- exactly the - # same bug the mandatory look-ahead-barrier shift below - # avoids, so the extra configured delay must avoid it too. - held_weights = shift_respecting_tradability( - held_weights, delay, tradable - ).fillna(0.0) + # shift_respecting_tradability's docstring) -- the mandatory + # look-ahead-barrier shift below avoids exactly this, and the + # extra configured delay must avoid it too. + # Applied identically to held_weights and every reason + # frame, so they all stay aligned to the same decision date. + return shift_respecting_tradability(frame, delay, tradable).fillna(0.0) + return frame.shift(delay).fillna(0.0) + + held_weights = _apply_extra_delay(held_weights) + desired_target_aligned = _apply_extra_delay(desired_target) + constrained_aligned = _apply_extra_delay(constrained) + rebalanced_signal = _apply_extra_delay(rebalanced_signal) + rebalanced_allocated = _apply_extra_delay(rebalanced_allocated) + rebalanced_desired = _apply_extra_delay(rebalanced_desired) + + # constraint_touches (from apply_with_provenance above) is already + # at the same raw daily cadence as `constrained` itself -- not a + # rebalance-sampled diagnostic frame -- so it only needs the same + # delay+executed_weights alignment as executed_constrained, never + # _rebalance_diagnostic_frame. Boolean frames are round-tripped + # through float so they can reuse the exact same real numeric + # functions, then thresholded back to bool (exact, since the only + # values ever produced are 0.0/1.0). + def _align_bool(frame: pd.DataFrame) -> pd.DataFrame: + # `executed_weights` is built for *weights*, where a closed row + # correctly repeats the last tradable row's value (frozen, no + # reallocation while closed). Applied to a boolean flag, that + # same repetition would keep the flag True for every row a + # column stays closed after it lands True once -- wrong for a + # flag, which must describe THIS row's own event, never a + # carried-forward one. AND with `tradable` (when given) so a + # closed row's flag is always False, matching + # apply_weight_drift's own documented precondition; with no + # tradable mask at all, every row is implicitly tradable and + # this repetition concern cannot arise. + flag = ( + executed_weights( + _apply_extra_delay(frame.astype(float)), tradable=tradable + ) + > 0.5 + ) + return flag & tradable if tradable is not None else flag + + # Genuine scheduled rebalance dates, aligned onto the executed + # timeline the same way held_weights becomes executed (extra + # delay, then the mandatory look-ahead-barrier shift) -- + # apply_weight_drift's own anchor detection needs this, not just + # value-diffing `executed` against its own previous row: a + # rebalance whose freshly-decided target happens to numerically + # match the immediately preceding one (a constant-target + # schedule, or an unchanged signal) must still be treated as a + # real trade back to target, never silently absorbed into + # ongoing drift. See apply_weight_drift's own docstring. + schedule_dates = rebalance_dates( + pd.DatetimeIndex(constrained.index), + config.portfolio.rebalance_frequency, + calendar=shared_calendar, + ) + is_rebalance_date_frame = pd.DataFrame( + np.broadcast_to( + constrained.index.isin(schedule_dates)[:, None], constrained.shape + ), + index=constrained.index, + columns=constrained.columns, + ) + # Per-column, NOT collapsed with `.any(axis=1)`: a closed + # instrument must never be forced to anchor (and therefore trade) + # just because some OTHER instrument's own schedule/value-change + # fires the same row -- see apply_weight_drift's own docstring. + # `_align_bool` already routes this per-column through the exact + # same tradability-aware shift real weight values get, so a + # closed column's own flag correctly stays tied to ITS OWN next + # genuinely tradable session, never another column's. + rebalance_date = _align_bool(is_rebalance_date_frame) + + aligned_constraint_touches: dict[str, ConstraintTouch] = { + name: ConstraintTouch( + touched=_align_bool(touch.touched), + before=executed_weights( + _apply_extra_delay(touch.before), tradable=tradable + ), + after=executed_weights( + _apply_extra_delay(touch.after), tradable=tradable + ), + direct=_align_bool(touch.direct), + ) + for name, touch in constraint_touches.items() + } + # Split each redistribution-capable constraint's provenance into + # two entries -- the base name keeps only the directly-clipped + # cells, "*_redistribution" the touched-but-not-direct ones -- so + # build_trade_log can attribute each honestly, never a single + # winning constraint. Both entries share the SAME before/after + # (the real value immediately around this constraint's own + # effect); only which cells count as "touched" differs. Every + # other constraint (no redistribution concept, `direct == touched` + # by construction) passes through as a single entry unchanged. + redistribution_capable = frozenset( + {"maximum_weight", "minimum_weight", "maximum_positions"} + ) + executed_constraint_touches: dict[str, ConstraintTouch] = {} + for name, touch in aligned_constraint_touches.items(): + if name in redistribution_capable: + executed_constraint_touches[name] = ConstraintTouch( + touched=touch.direct, + before=touch.before, + after=touch.after, + direct=touch.direct, + ) + redistribution_mask = touch.touched & ~touch.direct + executed_constraint_touches[f"{name}_redistribution"] = ConstraintTouch( + touched=redistribution_mask, + before=touch.before, + after=touch.after, + direct=pd.DataFrame( + False, index=touch.touched.index, columns=touch.touched.columns + ), + ) else: - held_weights = held_weights.shift(delay).fillna(0.0) + executed_constraint_touches[name] = touch + + # Real, cell-level turnover-cap/tradability provenance from + # rebalancing.py, aligned to executed_weights' index the same way + # as everything else above. + executed_turnover_actively_limited = _align_bool( + turnover_provenance.turnover_actively_limited + ) + executed_turnover_touched = _align_bool(turnover_provenance.turnover_touched) + executed_tradability_touched = _align_bool( + turnover_provenance.tradability_touched + ) + executed_tradability_compliance_limited = _align_bool( + turnover_provenance.tradability_compliance_limited + ) - # Accounting contains the one-period look-ahead barrier. + # Two seeds built from `decision_proxy` (never `signal_reasons. + # detail_code.notna()`, which would wrongly gate detection on + # whether a strategy-specific detail happens to exist -- a real + # transition without one must still be detected): + # - `trigger_detail_seed`: a plain, unbounded row-index pointer to + # the MOST RECENT transition (any magnitude change > EPSILON), + # never cleared -- consulted only when `strategy_signal` is + # itself the trigger, so a stale pointer on non-firing rows is + # harmless; reading the strategy's own detail_code/details AT + # that exact row (rather than forward-filling the text itself) + # means a detail-less transition correctly clears any earlier + # detail rather than letting it leak forward. + # - `position_origin_seed`: a REGIME-based (flat/long/short via + # sign) pointer for `position_strategy_origin` -- a continuous + # signal's own magnitude drift never creates a new origin, only + # a flat<->non-flat regime change does; a transition into flat + # clears it, a transition out of flat (fresh entry or reversal) + # replaces it. Independent of `trigger_detail_seed`: a + # downstream layer holding the executed weight flat does not, + # by itself, move this seed, since it only ever looks at + # `decision_proxy`. + decision_values = decision_proxy.to_numpy() + row_index_1based = np.arange(1, len(signals.index) + 1, dtype=float) + decision_prev_values = np.vstack( + [np.zeros((1, decision_values.shape[1])), decision_values[:-1]] + ) + has_transition_magnitude = ( + np.abs(decision_values - decision_prev_values) > EPSILON + ) + trigger_detail_seed = ( + pd.DataFrame( + np.where(has_transition_magnitude, row_index_1based[:, None], np.nan), + index=signals.index, + columns=signals.columns, + ) + .ffill() + .fillna(0.0) + ) + + def _regime(values: np.ndarray) -> np.ndarray: + return np.where(values > EPSILON, 1, np.where(values < -EPSILON, -1, 0)) + + regime_now = _regime(decision_values) + regime_prev = np.vstack( + [np.zeros((1, regime_now.shape[1]), dtype=int), regime_now[:-1]] + ) + has_regime_transition = regime_now != regime_prev + position_origin_candidate = np.where( + has_regime_transition, + np.where(regime_now == 0, 0.0, row_index_1based[:, None]), + np.nan, + ) + position_origin_seed = ( + pd.DataFrame( + position_origin_candidate, index=signals.index, columns=signals.columns + ) + .ffill() + .fillna(0.0) + ) + + def _source_row(seed: pd.DataFrame) -> np.ndarray: + executed_positions = executed_weights( + _apply_extra_delay(_rebalance_diagnostic_frame(seed)), + tradable=tradable, + ) + return executed_positions.round().to_numpy().astype(int) - 1 + + trigger_source_row = _source_row(trigger_detail_seed) + position_origin_source_row = _source_row(position_origin_seed) + + def _gather(raw: pd.DataFrame, source_row: np.ndarray) -> pd.DataFrame: + raw_values = raw.to_numpy(dtype=object) + gathered = np.full(source_row.shape, None, dtype=object) + for column_index in range(source_row.shape[1]): + valid = source_row[:, column_index] >= 0 + gathered[valid, column_index] = raw_values[ + source_row[valid, column_index], column_index + ] + return pd.DataFrame( + gathered, index=raw.index, columns=raw.columns, dtype=object + ) + + executed_strategy_reason_code: pd.DataFrame | None = None + executed_strategy_reason_details: pd.DataFrame | None = None + if signal_reasons is not None: + executed_strategy_reason_code = _gather( + signal_reasons.detail_code, trigger_source_row + ) + executed_strategy_reason_details = _gather( + signal_reasons.details, trigger_source_row + ) + executed_position_strategy_origin_code = _gather( + signal_reasons.detail_code, position_origin_source_row + ) + executed_position_strategy_origin_details = _gather( + signal_reasons.details, position_origin_source_row + ) + else: + _empty_object = pd.DataFrame( + None, index=signals.index, columns=signals.columns, dtype=object + ) + executed_position_strategy_origin_code = _empty_object + executed_position_strategy_origin_details = _empty_object.copy() + + # Origin timestamp: independent of whether explain_signals() exists + # at all -- a strategy without strategy-specific detail codes still + # gets a temporally correct origin (point 2: the temporal tracking + # of position_strategy_origin is not gated on detail_code + # existing). + _dates = signals.index.to_numpy() + _origin_gathered = np.full( + position_origin_source_row.shape, pd.NaT, dtype=object + ) + for _column_index in range(position_origin_source_row.shape[1]): + _valid = position_origin_source_row[:, _column_index] >= 0 + _origin_gathered[_valid, _column_index] = _dates[ + position_origin_source_row[_valid, _column_index] + ] + executed_position_strategy_origin_timestamp = pd.DataFrame( + _origin_gathered, index=signals.index, columns=signals.columns, dtype=object + ) + + # Accounting contains the one-period look-ahead barrier. A + # strategy's stop_loss_pct/take_profit_pct/position_groups() are + # read generically here (default None/None/None disables the + # check entirely) -- the mechanism itself lives in accounting.py, + # operating on the REAL executed position, never this strategy's + # raw `signals` above. accounting = run_accounting( held_weights, asset_returns, execution_model, config.initial_capital, tradable=tradable, + stop_loss_pct=strategy.stop_loss_pct, + take_profit_pct=strategy.take_profit_pct, + position_groups=strategy.position_groups(), + model_weight_drift=config.portfolio.model_weight_drift, + maximum_weight=config.portfolio.maximum_weight, + # Same combined-cap convention as rebalancing.py's own + # gross_cap = min(gross_caps): maximum_leverage always has a + # real (non-None) value, so it must be folded in here too, or + # the drift-compliance LP would silently miss it whenever + # maximum_gross_exposure itself is left unset. + maximum_gross_exposure=( + min( + config.portfolio.maximum_gross_exposure, + config.portfolio.maximum_leverage, + ) + if config.portfolio.maximum_gross_exposure is not None + else config.portfolio.maximum_leverage + ), + maximum_net_exposure=config.portfolio.maximum_net_exposure, + long_only=config.portfolio.long_only, + rebalance_date=rebalance_date, + maximum_turnover=config.portfolio.maximum_turnover, + ) + + # `maximum_turnover` is enforced at the decision level above ONLY + # when weight drift is disabled -- when it's enabled, + # `apply_weight_drift` is the sole place it's actually applied (see + # `decision_portfolio_config` above), so the decision-level + # `turnover_provenance` computed from it is empty in that case. + # OR-merging in `accounting`'s own turnover provenance keeps the + # trade log's `turnover_cap` attribution accurate either way -- a + # no-op when drift is disabled (accounting's own frames are then + # all-``False`` placeholders), the real signal when it's enabled. + # Already at `accounting.executed_weights`' own index -- no further + # alignment needed. + executed_turnover_actively_limited = ( + executed_turnover_actively_limited + | accounting.drift_turnover_actively_limited + ) + executed_turnover_touched = ( + executed_turnover_touched | accounting.drift_turnover_touched ) + # Real, row-broadcast provenance: forced liquidation affects every + # column simultaneously and unconditionally once the portfolio is + # ruined -- a legitimate broadcast, not an approximation (see + # AccountingResult.ruined's own docstring). Already at + # accounting.executed_weights' own final index -- no further + # alignment needed. + executed_forced_liquidation = pd.DataFrame( + dict.fromkeys(accounting.executed_weights.columns, accounting.ruined), + index=accounting.executed_weights.index, + ) + + # Align every reason-attribution frame to accounting.executed_weights' + # own index using the exact same shift function run_accounting uses + # internally, so they can never misalign with the trade log's own + # date index. executed_desired/executed_constrained are real + # pipeline frames (just re-aligned, not resampled); the *_diag ones + # are the rebalance-sampled diagnostic frames from above. + executed_desired = executed_weights(desired_target_aligned, tradable=tradable) + executed_constrained = executed_weights(constrained_aligned, tradable=tradable) + executed_signal_diag = executed_weights(rebalanced_signal, tradable=tradable) + executed_allocated_diag = executed_weights( + rebalanced_allocated, tradable=tradable + ) + executed_desired_diag = executed_weights(rebalanced_desired, tradable=tradable) + # Align the benchmark to the simulated portfolio dates. benchmark_data = ( data if config.benchmark_kind is BenchmarkKind.SYMBOL else tradable_data @@ -664,6 +1165,35 @@ def run( spread_bps=execution_model.spread.spread_bps, slippage_model=execution_model.slippage, slippage_equity=accounting.equity_for_costs, + executed_desired=executed_desired, + executed_constrained=executed_constrained, + executed_signal_diag=executed_signal_diag, + executed_allocated_diag=executed_allocated_diag, + executed_desired_diag=executed_desired_diag, + tradable=tradable, + executed_strategy_reason_code=executed_strategy_reason_code, + executed_strategy_reason_details=executed_strategy_reason_details, + constraint_provenance=executed_constraint_touches, + executed_turnover_actively_limited=executed_turnover_actively_limited, + executed_turnover_touched=executed_turnover_touched, + executed_tradability_touched=executed_tradability_touched, + executed_tradability_compliance_limited=( + executed_tradability_compliance_limited + ), + executed_forced_liquidation=executed_forced_liquidation, + executed_stop_loss_triggered=accounting.stop_loss_triggered, + executed_take_profit_triggered=accounting.take_profit_triggered, + executed_drift_compliance_forced=accounting.drift_compliance_forced, + executed_drift_compliance_pending=accounting.drift_compliance_pending, + executed_position_strategy_origin_timestamp=( + executed_position_strategy_origin_timestamp + ), + executed_position_strategy_origin_code=( + executed_position_strategy_origin_code + ), + executed_position_strategy_origin_details=( + executed_position_strategy_origin_details + ), ) # Compute performance, risk and portfolio metrics. diff --git a/src/quantlab/backtesting/result.py b/src/quantlab/backtesting/result.py index 4652117..5d928ed 100644 --- a/src/quantlab/backtesting/result.py +++ b/src/quantlab/backtesting/result.py @@ -83,6 +83,58 @@ "sensitivity": "sensitivity_run_params", } +#: Maps each `_ROBUSTNESS_ARTIFACT_FILES` key to its own section name inside +#: the consolidated `resolved_config` metadata (see `cli._update_resolved_ +#: config`) -- only "stress_tests" differs from its resolved_config section +#: name ("stress_test", matching `robustness.stress_test` in YAML). +_ROBUSTNESS_RESOLVED_CONFIG_KEYS: dict[str, str] = { + "stress_tests": "stress_test", + "bootstrap": "bootstrap", + "permutation_test": "permutation_test", + "sensitivity": "sensitivity", +} + + +def _restore_resolved_config_section( + result: BacktestResult, old_metadata: Mapping[str, Any], section: str +) -> None: + """Recover one `resolved_config` section from a prior save's metadata. + + Never overwrites a section this run already computed fresh -- mirrors + the same "recovered value only fills a gap" precedence + `load_previous_robustness_artifacts` already applies to run-params + keys, so a technique actually recomputed this run always keeps its own + freshly resolved values. + """ + old_resolved_config = old_metadata.get("resolved_config") + if not isinstance(old_resolved_config, Mapping): + return + value = old_resolved_config.get(section) + if value is None: + return + resolved_config = result.metadata.setdefault("resolved_config", {}) + resolved_config.setdefault(section, value) + + +def resolve_experiment_directory( + config: ExperimentConfig, *, default_root: Path | None = None +) -> Path: + """Return where a run's bundle belongs absent an explicit call-time override. + + ``config.output.directory`` when set, else ``default_root`` (or the + documented default ``reports/generated/``) joined with + ``experiment_name``. Shared by :meth:`BacktestResult.save` and every + caller (CLI commands, the dashboard) that needs to know this same + location before a result even exists yet -- e.g. to place a checkpoint + file alongside where the eventual save will land. Callers that expose + their own ``GENERATED_REPORTS_DIR`` binding (so it can be redirected in + tests) should pass it as ``default_root``. + """ + if config.output.directory is not None: + return Path(config.output.directory) + root = GENERATED_REPORTS_DIR if default_root is None else default_root + return root / config.experiment_name + def _bundle_lock_path(output_directory: Path) -> Path: """Return the persistent sibling lock used to serialize bundle saves.""" @@ -197,7 +249,7 @@ def summary(self) -> str: f"Max drawdown : {m.get('max_drawdown', 0):>10.2%}", f"Hit rate (non-zero periods): {m.get('hit_rate', 0):>5.2%}", f"Total costs (currency units): {self.total_costs():>7.2f}", - f"Number of trades : {self.number_of_trades():>10d}", + f"Number of fills : {self.number_of_trades():>10d}", ] if self.benchmark_returns is not None and "beta" in m: lines += [ @@ -351,11 +403,14 @@ def save( """Persist the result's managed outputs to a reproducible directory. Writes ``config.yaml``, ``metadata.json``, ``metrics.json`` and CSVs for - the equity curve, benchmark, trades, positions and costs, plus a - ``figures/`` folder and an HTML report. + the equity curve, benchmark, trades, positions and costs + unconditionally. The ``figures/`` folder and the HTML report are + each written only when ``config.output.save_figures`` / + ``.save_html_report`` is true (both default true). Args: output_directory: Destination. Defaults to + ``config.output.directory`` when set, else ``reports/generated//``. robustness: Extra sections (e.g. walk-forward / stress tables) to fold into the saved report's Robustness section. @@ -384,7 +439,7 @@ def save( out = Path( output_directory if output_directory is not None - else GENERATED_REPORTS_DIR / self.config.experiment_name + else resolve_experiment_directory(self.config) ) artifacts = dict(validation_artifacts or {}) unsupported = set(artifacts) - _VALIDATION_ARTIFACTS @@ -497,41 +552,51 @@ def _save_locked( } # Render once, then reuse the same images on disk and in the HTML. + # `save_html_report`/`save_figures` (config.output) skip only this + # presentation layer when disabled -- every artefact written above + # is unconditional, so `quantlab report` can always regenerate the + # HTML afterwards even from a run that skipped it here. self.save_warnings = [] rendered_figures: dict[str, str] = {} - try: - from quantlab.reporting.charts import report_figures - - rendered_figures = report_figures(self, self.save_warnings) - except Exception as exc: # pragma: no cover - rendering is optional - msg = f"Could not render figures: {exc}" - logger.warning(msg) - self.save_warnings.append(msg) - try: - from quantlab.reporting.charts import save_figures - - save_figures( - self, - out / "figures", - self.save_warnings, - rendered=rendered_figures, - ) - except Exception as exc: # pragma: no cover - rendering is optional - msg = f"Could not save figures: {exc}" - logger.warning(msg) - self.save_warnings.append(msg) - try: - # Embedded chart failures append to the same warning collector. - self.to_html( - out / "report.html", - robustness=robustness, - warnings=self.save_warnings, - figures=rendered_figures, - ) - except Exception as exc: # pragma: no cover - rendering is optional - msg = f"Could not render HTML report: {exc}" - logger.warning(msg) - self.save_warnings.append(msg) + render_figures = ( + self.config.output.save_html_report or self.config.output.save_figures + ) + if render_figures: + try: + from quantlab.reporting.charts import report_figures + + rendered_figures = report_figures(self, self.save_warnings) + except Exception as exc: # pragma: no cover - rendering is optional + msg = f"Could not render figures: {exc}" + logger.warning(msg) + self.save_warnings.append(msg) + if self.config.output.save_figures: + try: + from quantlab.reporting.charts import save_figures + + save_figures( + self, + out / "figures", + self.save_warnings, + rendered=rendered_figures, + ) + except Exception as exc: # pragma: no cover - rendering is optional + msg = f"Could not save figures: {exc}" + logger.warning(msg) + self.save_warnings.append(msg) + if self.config.output.save_html_report: + try: + # Embedded chart failures append to the same warning collector. + self.to_html( + out / "report.html", + robustness=robustness, + warnings=self.save_warnings, + figures=rendered_figures, + ) + except Exception as exc: # pragma: no cover - rendering is optional + msg = f"Could not render HTML report: {exc}" + logger.warning(msg) + self.save_warnings.append(msg) # Explicit, persisted methodology marker: different CLI commands in # walk-forward mode save fundamentally different `self` objects to @@ -544,10 +609,18 @@ def _save_locked( # last. Recording which one `self.metrics` actually is removes the # ambiguity for anyone reading the bundle later, without needing to # know the CLI's own save conventions. + from quantlab.backtesting.trade_log import TRADE_LOG_SCHEMA_VERSION from quantlab.reporting.research_summary import out_of_sample_scope self.metadata["result_scope"] = out_of_sample_scope(self) or "full_sample" self.metadata["save_warnings"] = self.save_warnings + self.metadata["trade_log_schema_version"] = TRADE_LOG_SCHEMA_VERSION + # Always known (unlike the walk-forward/robustness fields other + # callers add to this same dict) -- a consolidated, always-current + # record of concrete values actually used, for full reproducibility. + self.metadata.setdefault("resolved_config", {})["signal_price_type"] = ( + self.config.strategy.signal_price_type + ) _write_text_atomic( out / "metrics.json", json.dumps( @@ -755,15 +828,22 @@ def load_previous_walk_forward_robustness( if old_checksums: result.metadata["walk_forward_csv_checksums"] = old_checksums + _restore_resolved_config_section(result, old_metadata, "walk_forward") robustness: dict[str, Any] = { "walk_forward": pd.read_csv(exp_dir / "walk_forward_results.csv") } if stress_path.is_file(): robustness["stress_tests"] = pd.read_csv(stress_path) + _restore_resolved_config_section(result, old_metadata, "stress_test") return robustness -def save_with_walk_forward_reuse(result: BacktestResult, exp_dir: str | Path) -> Path: +def save_with_walk_forward_reuse( + result: BacktestResult, + exp_dir: str | Path, + *, + robustness_extra: Mapping[str, Any] | None = None, +) -> Path: """Save a result while preserving compatible walk-forward artefacts. Also preserves compatible bootstrap/permutation-test/sensitivity @@ -772,6 +852,16 @@ def save_with_walk_forward_reuse(result: BacktestResult, exp_dir: str | Path) -> not that) would delete still-valid evidence a `bootstrap`/ `permutation-test`/`sensitivity` run had just saved, since neither of those techniques is itself part of "walk-forward" evidence. + + Args: + result: The freshly run backtest to save. + exp_dir: Destination directory for the saved bundle. + robustness_extra: Freshly computed sections (e.g. a Strategy + Explorer results diagnostic) to fold in on top of any reused + walk-forward/stress-test/bootstrap/permutation-test/sensitivity + evidence -- always wins on key overlap, matching how a + recomputed technique already takes precedence over a merely + recovered one below. """ exp_dir = Path(exp_dir) exp_dir.mkdir(parents=True, exist_ok=True) @@ -800,7 +890,13 @@ def save_with_walk_forward_reuse(result: BacktestResult, exp_dir: str | Path) -> # `robustness`'s own keys (from walk-forward reuse above) win on any # overlap -- same precedence `save_with_robustness_reuse` gives a # freshly-computed technique over a merely-recovered one. - merged_robustness = {**previous_robustness_artifacts, **(robustness or {})} + # `robustness_extra` (e.g. a Strategy Explorer diagnostic) is + # freshest of all, so it wins over both. + merged_robustness = { + **previous_robustness_artifacts, + **(robustness or {}), + **(robustness_extra or {}), + } validation_artifacts: dict[str, pd.Series | pd.DataFrame] = {} for key, frame in previous_robustness_artifacts.items(): filename = _ROBUSTNESS_ARTIFACT_FILES[key] @@ -960,6 +1056,14 @@ def load_previous_robustness_artifacts( and run_params_key not in result.metadata ): result.metadata[run_params_key] = old_metadata[run_params_key] + # Recover this technique's resolved_config section the same way -- + # otherwise the surviving CSV's actual settings (stress-test + # magnitudes, sensitivity axes, ...) disappear from the + # consolidated view even though the raw CSV and its checksum are + # still right here. + _restore_resolved_config_section( + result, old_metadata, _ROBUSTNESS_RESOLVED_CONFIG_KEYS[key] + ) return recovered diff --git a/src/quantlab/backtesting/runner.py b/src/quantlab/backtesting/runner.py index aa9c8db..29fe301 100644 --- a/src/quantlab/backtesting/runner.py +++ b/src/quantlab/backtesting/runner.py @@ -23,6 +23,7 @@ from quantlab.data.calendar import is_247, uniform_calendar from quantlab.data.validator import DataQualityReport from quantlab.execution.execution_model import ExecutionModel +from quantlab.features.native_calendar import compute_native_then_align from quantlab.portfolio.allocator import PortfolioAllocator, build_allocator from quantlab.strategies.base import ( BaseStrategy, @@ -58,12 +59,16 @@ def build_strategy_from_config(config: ExperimentConfig) -> BaseStrategy: """Instantiate the configured strategy with its parameter dict. Injects the experiment's annualisation factor when the strategy accepts - ``periods_per_year`` and the YAML does not override it. + ``periods_per_year`` and the YAML does not override it, and likewise + injects ``strategy.signal_price_type`` under the ``price_type`` keyword + every built-in strategy accepts. """ parameters = dict(config.strategy_parameters) accepted = strategy_parameter_names(config.strategy_name) if "periods_per_year" in accepted and "periods_per_year" not in parameters: parameters["periods_per_year"] = config.periods_per_year + if "price_type" in accepted and "price_type" not in parameters: + parameters["price_type"] = config.strategy.signal_price_type return build_strategy(config.strategy_name, parameters) @@ -77,8 +82,16 @@ def build_execution_from_config( frequency and market calendar determine the bars per day; an explicit metrics annualisation override does not alter this physical conversion. - When instruments trade on different calendars, this window falls back to - the equity (252-day, non-24/7) convention — a documented, accepted + The window's CONTENT is computed on each symbol's own native calendar + (see :func:`quantlab.features.native_calendar.compute_native_then_ + align`): a closure-padded row (e.g. a session-bound equity's weekend + sharing the timeline with an always-open crypto instrument) never + dilutes that symbol's own trailing-volume estimate. The window's SIZE + in bars is a separate, smaller approximation that remains: when + instruments trade on different calendars, there is no single "bars per + day" that is simultaneously correct for every symbol sharing one + ``.rolling(window)`` call, so the bar count itself still falls back to + the equity (252-day, non-24/7) convention -- a documented, accepted approximation, since it only sizes a nominal liquidity window for volume-based slippage rather than multiplying directly into reported metrics the way ``periods_per_year`` does. @@ -102,8 +115,17 @@ def build_execution_from_config( ) bars_per_day = frequency_table[str(config.frequency)] / days_per_year window = max(1, round(21 * bars_per_day)) + symbol_calendars = { + instrument.symbol: instrument.calendar + for instrument in config.data.instruments + } adv = ( - bar_dollar_volume.rolling(window, min_periods=1).mean().shift(1) + compute_native_then_align( + lambda v: v.rolling(window, min_periods=1).mean(), + bar_dollar_volume, + symbol_calendars, + pd.DatetimeIndex(bar_dollar_volume.index), + ).shift(1) * bars_per_day ) return ExecutionModel.from_config(config.execution, average_daily_volume=adv) diff --git a/src/quantlab/backtesting/trade_log.py b/src/quantlab/backtesting/trade_log.py index 2834681..183a155 100644 --- a/src/quantlab/backtesting/trade_log.py +++ b/src/quantlab/backtesting/trade_log.py @@ -2,10 +2,39 @@ Each row records a non-zero change, its estimated notional and modelled costs. It does not simulate share quantities or partial order execution. + +Reason attribution uses four independent concepts, which may legitimately +differ on the same row without contradiction: + +- ``action`` -- what actually happened to the executed weight + (``_classify_action``). +- ``trigger`` -- the single most-upstream event CURRENTLY + consumed that initiated the target change (``strategy_signal`` > + ``portfolio_rebalance`` > ``volatility_target_adjustment`` > none). Not + an exhaustive list of every transformed layer -- a downstream + recomputation triggered by the same upstream event is not a separate + cause. +- ``adjustment(s)`` -- the downstream execution/portfolio layer(s) that + materially modified, delayed, redistributed, constrained or forced the + path from the upstream target to the currently executed position, + including a known execution debt whose catch-up this row represents + (turnover cap, a prior symbol closure) -- computed INDEPENDENTLY of + ``trigger`` and always from each layer's own real provenance, never + deduced from ``new != desired``. ``position_rescaling``/ + ``deferred_catchup`` are a strict last-resort fallback, used only when + no real adjustment layer and no trigger explain the row. +- ``position_strategy_origin`` -- the origin of the currently active + strategic regime/stance relevant to this row (this symbol/leg), driven + purely by the strategy's own decision state (see ``engine.py``'s + ``decision_proxy``) -- NOT necessarily the origin of an executed-weight + episode (a downstream layer can hold the executed weight flat while the + strategic stance stays active), and NOT an execution timestamp. """ from __future__ import annotations +from collections.abc import Iterable, Mapping, Sequence +from dataclasses import dataclass from numbers import Real import numpy as np @@ -22,6 +51,14 @@ SlippageModel, validate_slippage_cost_frame, ) +from quantlab.portfolio.constraints import ConstraintTouch + +#: Schema version of :data:`TRADE_LOG_COLUMNS`. A bare 12-column layout with +#: a single ``reason_code`` and none of the trigger/adjustment/position_ +#: strategy_origin columns is version 1. Recorded in report/export metadata +#: (never as a column -- a bare CSV stays self-describing via its column +#: names instead, see docs on ``render_trade_table``/report generation). +TRADE_LOG_SCHEMA_VERSION = 2 #: Column order of the trade log. TRADE_LOG_COLUMNS = [ @@ -31,6 +68,15 @@ "new_weight", "weight_change", "side", + "action", + "trigger_reason_code", + "trigger_reason_detail_code", + "trigger_reason_details", + "adjustment_reason_codes", + "adjustment_reason_details", + "position_strategy_origin_timestamp", + "position_strategy_origin_code", + "position_strategy_origin_details", "reference_price", "traded_notional", "commission", @@ -38,6 +84,413 @@ "slippage_cost", "total_cost", ] +assert len(TRADE_LOG_COLUMNS) == 21 + +#: `reason_code`/`reason_detail_code` frames -- all-or-nothing kwargs of +#: `build_trade_log` that enable reason attribution. Keyed by the kwarg +#: name so validation and array-extraction can iterate them uniformly. +_REASON_FRAME_KWARGS = ( + "executed_desired", + "executed_constrained", + "executed_signal_diag", + "executed_allocated_diag", + "executed_desired_diag", +) + +#: Canonical, exhaustive order of every value `adjustment_reason_codes` +#: can ever contain -- constraint names (with their `_redistribution` +#: variant right after the base name, for the 3 constraints that have a +#: redistribution concept), the execution-layer causes, and the two +#: last-resort fallbacks. The single source of truth for how multiple +#: codes are combined into one `adjustment_reason_codes` string -- nothing +#: else in the codebase (including the dashboard) should build or parse +#: that convention on its own; use serialize_adjustment_codes/ +#: parse_adjustment_codes instead. Every code `_classify_reason` can ever +#: emit MUST appear here, or `serialize_adjustment_codes` raises. +ADJUSTMENT_ORDER = ( + "long_only", + "maximum_positions", + "maximum_positions_redistribution", + "minimum_weight", + "minimum_weight_redistribution", + "maximum_weight", + "maximum_weight_redistribution", + "maximum_gross_exposure", + "maximum_leverage", + "maximum_net_exposure", + "tradability", + "turnover_cap", + "drift_compliance", + "drift_compliance_pending", + "stop_loss", + "take_profit", + "forced_liquidation", + "position_rescaling", + "deferred_catchup", +) + +#: Redistribution-specific detail text, keyed by the constraint's BASE +#: name (not the `_redistribution`-suffixed code) -- a single generic +#: "another position was capped" sentence is wrong for minimum_weight +#: (dust removal) and maximum_positions (cardinality drop), so each gets +#: its own honest wording. +_REDISTRIBUTION_DETAIL_TEXT = { + "maximum_weight": "redistribution after another position was capped", + "minimum_weight": "redistribution after dust/small positions were removed", + "maximum_positions": ( + "redistribution after positions were dropped to satisfy maximum_positions" + ), +} + + +def serialize_adjustment_codes(codes: Iterable[str]) -> str: + """Join adjustment names into adjustment_reason_codes' stable format. + + Names are deduplicated and ordered per ADJUSTMENT_ORDER (the real + pipeline's own order), not by input/call order, so the result is + deterministic and causally meaningful (e.g. + "maximum_weight+turnover_cap"). Raises ``BacktestError`` on a code + absent from ADJUSTMENT_ORDER -- a wiring bug between `_classify_ + reason` and this canonical list, never dropped silently. + """ + present = set(codes) + unknown = present - set(ADJUSTMENT_ORDER) + if unknown: + raise BacktestError( + f"Unknown adjustment code(s) {sorted(unknown)}; not present in " + "ADJUSTMENT_ORDER." + ) + return "+".join(name for name in ADJUSTMENT_ORDER if name in present) + + +def parse_adjustment_codes(value: str, *, strict: bool = True) -> list[str]: + """Inverse of serialize_adjustment_codes. + + ``strict=True`` (the default, used everywhere internally and in + tests) raises ``BacktestError`` on a code absent from + ADJUSTMENT_ORDER. ``strict=False`` preserves an unrecognized code + as-is instead of raising -- reserved for displaying an artifact + potentially produced by a future schema version; a code is never + silently dropped in either mode. + """ + codes = value.split("+") + if strict: + unknown = set(codes) - set(ADJUSTMENT_ORDER) + if unknown: + raise BacktestError( + f"Unknown adjustment code(s) {sorted(unknown)}; not present in " + "ADJUSTMENT_ORDER." + ) + return codes + + +def stop_loss_take_profit_trigger_counts(trade_log: pd.DataFrame) -> dict[str, int]: + """Count trade-log rows carrying the ``stop_loss``/``take_profit`` code. + + Generic across every strategy: whenever ``stop_loss_pct``/ + ``take_profit_pct`` are configured (any of the 5 directional + strategies), a triggered force-flatten shows up here via the SAME + ``adjustment_reason_codes`` column ``build_trade_log`` already + populates -- never a second, strategy-specific recomputation. Returns + ``{"stop_loss": 0, "take_profit": 0}`` on an empty log or one with no + ``adjustment_reason_codes`` column (e.g. neither threshold was + configured). + """ + if trade_log.empty or "adjustment_reason_codes" not in trade_log.columns: + return {"stop_loss": 0, "take_profit": 0} + parsed = ( + trade_log["adjustment_reason_codes"] + .dropna() + .apply(lambda value: parse_adjustment_codes(value, strict=False)) + ) + return { + "stop_loss": int(parsed.apply(lambda codes: "stop_loss" in codes).sum()), + "take_profit": int(parsed.apply(lambda codes: "take_profit" in codes).sum()), + } + + +def _classify_action(previous: float, new: float) -> str: + """Classify what happened to a position from its weight before/after. + + Long/flat/short thresholds reuse the project-wide EPSILON, matching + every other "is this effectively zero" check in the codebase. Total + over every reachable input -- including the sub-epsilon corner case + where ``previous`` and ``new`` individually read as flat yet differ by + more than EPSILON (e.g. +0.6e-12 / -0.6e-12): noise-level floating + point residue, not a real position, named explicitly as + ``"flat_to_flat"`` rather than folded into an entry/exit label that + would misrepresent a pair of weights indistinguishable from zero. + """ + was_flat = abs(previous) <= EPSILON + was_long = previous > EPSILON + was_short = previous < -EPSILON + now_flat = abs(new) <= EPSILON + now_long = new > EPSILON + now_short = new < -EPSILON + + if was_flat and now_long: + return "entry_long" + if was_flat and now_short: + return "entry_short" + if was_long and now_flat: + return "exit_long" + if was_short and now_flat: + return "exit_short" + if was_long and now_short: + return "reverse_long_to_short" + if was_short and now_long: + return "reverse_short_to_long" + if was_long and now_long: + return "increase_long" if new > previous else "reduce_long" + if was_short and now_short: + return "increase_short" if new < previous else "reduce_short" + return "flat_to_flat" + + +def _compose_details(generic: str, specific: str | None) -> str: + """Enrich the generic pipeline-level explanation with a business-specific one. + + The specific detail never REPLACES the generic text, it only adds to + it (explicit product rule: a more precise piece of information must + never remove a correct one already available). + """ + if specific is None: + return generic + return f"{generic}; {specific}" + + +@dataclass(frozen=True) +class TradeReason: + """Trigger + adjustment attribution for one fill (see module docstring).""" + + trigger_code: str | None + trigger_detail_code: str | None + trigger_details: str | None + adjustment_codes: str | None + adjustment_details: str | None + + +def _classify_reason( + *, + new: float, + previous: float, + executed_desired: float, + executed_desired_prev: float, + executed_constrained: float, + signal_now: float, + signal_prev: float, + allocated_now: float, + allocated_prev: float, + desired_diag_now: float, + desired_diag_prev: float, + strategy_detail_code: str | None = None, + strategy_details: str | None = None, + contributing_constraints: Sequence[str] = (), + constraint_before: Mapping[str, float] | None = None, + constraint_after: Mapping[str, float] | None = None, + tradability_touched: bool = False, + tradability_compliance_limited: bool = False, + turnover_touched: bool = False, + turnover_actively_limited: bool = False, + stop_loss_triggered: bool = False, + take_profit_triggered: bool = False, + forced_liquidation: bool = False, + drift_compliance_forced: bool = False, + drift_compliance_pending: bool = False, +) -> TradeReason: + """Return the trigger + adjustment attribution for one fill. + + Both are assigned from real, per-layer provenance signals only, never + deduced from `new != desired`. Full trigger/adjustment priority order: + see docs/backtesting.md#trade-log-reason-attribution. ``signal_now``/ + ``signal_prev`` must already come from the strategy's own diagnostic + decision proxy (``decision_signal()`` when provided, else the raw + signal) -- see ``engine.py`` -- never the raw signal directly for a + strategy whose raw signal mixes decision state with mechanical + rescaling. + """ + # TRIGGER + if abs(signal_now - signal_prev) > EPSILON: + generic = f"signal {signal_prev:.4f} -> {signal_now:.4f} since last rebalance" + trigger_code: str | None = "strategy_signal" + trigger_detail_code = strategy_detail_code + trigger_details: str | None = _compose_details(generic, strategy_details) + elif abs(allocated_now - allocated_prev) > EPSILON: + trigger_code = "portfolio_rebalance" + trigger_detail_code = None + trigger_details = ( + f"allocator output {allocated_prev:.4f} -> {allocated_now:.4f} " + "since last rebalance" + ) + elif abs(desired_diag_now - desired_diag_prev) > EPSILON: + trigger_code = "volatility_target_adjustment" + trigger_detail_code = None + trigger_details = ( + f"target {desired_diag_prev:.4f} -> {desired_diag_now:.4f} " + "since last rebalance" + ) + else: + trigger_code, trigger_detail_code, trigger_details = None, None, None + + # ADJUSTMENT(S) + adjustment_codes_list: list[str] = [] + adjustment_clauses: list[str] = [] + if forced_liquidation: + # Overrides every other adjustment: once ruined, no other layer's + # specific clip value still explains the executed weight. + adjustment_codes_list = ["forced_liquidation"] + adjustment_clauses = [ + "portfolio equity reached zero -- position forcibly flattened, " + "no margin call modeled" + ] + elif stop_loss_triggered or take_profit_triggered: + # Overrides every ordinary constraint adjustment (a real + # stop-loss/take-profit force-flatten fully explains the executed + # weight regardless of what a constraint would otherwise have + # clipped it to), but is itself overridden above by + # forced_liquidation -- portfolio ruin is more severe than a + # single position's own risk control. + adjustment_codes_list = [] + adjustment_clauses = [] + if stop_loss_triggered: + adjustment_codes_list.append("stop_loss") + adjustment_clauses.append( + "cumulative return since entry breached the configured " + "stop_loss_pct -- position forcibly closed" + ) + if take_profit_triggered: + adjustment_codes_list.append("take_profit") + adjustment_clauses.append( + "cumulative return since entry reached the configured " + "take_profit_pct -- position forcibly closed" + ) + elif drift_compliance_forced or drift_compliance_pending: + # Overrides every ordinary constraint/tradability/turnover_cap + # adjustment: this row's magnitude comes from the drift-compliance + # LP restoring a hard risk limit organic price drift breached + # off-schedule, not from the ordinary decision pipeline at all -- + # but is itself overridden above by forced_liquidation/stop_loss/ + # take_profit, each a still more specific or more severe cause. + adjustment_codes_list = [] + adjustment_clauses = [] + if drift_compliance_forced: + adjustment_codes_list.append("drift_compliance") + adjustment_clauses.append( + "organic price drift breached a hard portfolio risk limit " + "between rebalances -- position corrected back toward " + "compliance by the drift-compliance linear program" + ) + if drift_compliance_pending: + adjustment_codes_list.append("drift_compliance_pending") + adjustment_clauses.append( + "drift-caused breach not yet fully resolved -- the " + "responsible symbol/group is still untradable, best " + "achievable correction applied, retried each day it " + "remains blocked" + ) + else: + for name in contributing_constraints: + adjustment_codes_list.append(name) + assert constraint_before is not None + assert constraint_after is not None + before_value = constraint_before[name] + after_value = constraint_after[name] + if name.endswith("_redistribution"): + base_name = name.removesuffix("_redistribution") + cause_text = _REDISTRIBUTION_DETAIL_TEXT.get( + base_name, "redistribution after another position was adjusted" + ) + adjustment_clauses.append( + f"{name}: {before_value:.4f} -> {after_value:.4f} ({cause_text})" + ) + else: + adjustment_clauses.append( + f"{name}: {before_value:.4f} -> {after_value:.4f}" + ) + + # tradability/turnover_cap may share the same executed magnitude + # (they can both apply to the same cell in the same pass, without + # a clean per-mechanism split) -- only the FIRST cause to fire + # (ADJUSTMENT_ORDER: tradability before turnover_cap) carries the + # magnitude, the other stays purely causal rather than repeating a + # possibly-misleading shared number. + magnitude_already_shown = False + if tradability_touched: + adjustment_codes_list.append("tradability") + cause_text = ( + "rebalancing feasibility limit reached while another " + "symbol remained closed" + if tradability_compliance_limited + else "catching up a delta previously blocked while the " + "symbol was closed" + ) + if not magnitude_already_shown: + adjustment_clauses.append( + f"tradability: desired {executed_constrained:.4f}, " + f"executed {new:.4f} ({cause_text})" + ) + magnitude_already_shown = True + else: + adjustment_clauses.append(f"tradability: {cause_text}") + if turnover_touched: + adjustment_codes_list.append("turnover_cap") + cause_text = ( + "turnover-capped this period" + if turnover_actively_limited + else "catching up a target previously deferred by turnover cap" + ) + if not magnitude_already_shown: + adjustment_clauses.append( + f"turnover_cap: desired {executed_constrained:.4f}, " + f"executed {new:.4f} ({cause_text})" + ) + magnitude_already_shown = True + else: + adjustment_clauses.append( + f"turnover_cap: {cause_text} " + "(see above for the shared executed magnitude)" + ) + + # Strict fallback: reached only when NOTHING real above explains + # this row's movement, and no trigger explains it either. + if ( + not adjustment_codes_list + and trigger_code is None + and abs(new - previous) > EPSILON + ): + if abs(executed_desired - executed_desired_prev) > EPSILON: + adjustment_codes_list = ["position_rescaling"] + adjustment_clauses = [ + f"target continued drifting {executed_desired_prev:.4f} -> " + f"{executed_desired:.4f} with no new upstream decision" + ] + else: + adjustment_codes_list = ["deferred_catchup"] + adjustment_clauses = [ + f"{previous:.4f} -> {new:.4f} with no new upstream driver" + ] + + if adjustment_codes_list: + adjustment_codes: str | None = serialize_adjustment_codes(adjustment_codes_list) + adjustment_details: str | None = "; ".join(adjustment_clauses) + else: + adjustment_codes, adjustment_details = None, None + + if trigger_code is None and adjustment_codes is None: + # Safety net, should not normally happen. + trigger_code, trigger_detail_code, trigger_details = ( + "unknown", + None, + "no upstream driver identified", + ) + + return TradeReason( + trigger_code=trigger_code, + trigger_detail_code=trigger_detail_code, + trigger_details=trigger_details, + adjustment_codes=adjustment_codes, + adjustment_details=adjustment_details, + ) def _non_negative_rate(value: object, name: str) -> float: @@ -60,6 +513,54 @@ def _validate_unique_axes(frame: pd.DataFrame, name: str) -> None: raise BacktestError(f"{name} columns must not contain duplicate labels.") +def _validate_matching_frame( + frame: object, name: str, reference: pd.DataFrame +) -> pd.DataFrame: + """Validate a frame is a DataFrame sharing ``reference``'s exact axes.""" + if not isinstance(frame, pd.DataFrame): + raise BacktestError(f"{name} must be a pandas DataFrame.") + _validate_unique_axes(frame, name) + if not frame.index.equals(reference.index) or not frame.columns.equals( + reference.columns + ): + raise BacktestError( + f"{name} must have exactly the same index and columns as executed_weights." + ) + return frame + + +def _validate_optional_group( + names: tuple[str, ...], + frames: Mapping[str, pd.DataFrame | None], + *, + reference: pd.DataFrame, + requires: bool, + requires_label: str, +) -> bool: + """Validate an all-or-nothing optional kwarg group; return whether supplied. + + Every frame in ``names`` must be supplied together or not at all. When + supplied, ``requires`` (typically ``attribute_reasons``) must already + be true, or the group is rejected as meaningless on its own. + """ + supplied = [name for name in names if frames[name] is not None] + if not supplied: + return False + if len(supplied) != len(names): + missing = sorted(set(names) - set(supplied)) + raise BacktestError( + f"{'/'.join(names)} must be supplied all together or not at " + f"all; missing: {missing}." + ) + if not requires: + raise BacktestError(f"{'/'.join(names)} requires {requires_label}.") + for name in names: + frame = frames[name] + assert frame is not None + _validate_matching_frame(frame, name, reference) + return True + + def build_trade_log( executed_weights: pd.DataFrame, weight_changes: pd.DataFrame, @@ -70,6 +571,27 @@ def build_trade_log( spread_bps: float, slippage_model: SlippageModel, slippage_equity: pd.Series | None = None, + executed_desired: pd.DataFrame | None = None, + executed_constrained: pd.DataFrame | None = None, + executed_signal_diag: pd.DataFrame | None = None, + executed_allocated_diag: pd.DataFrame | None = None, + executed_desired_diag: pd.DataFrame | None = None, + tradable: pd.DataFrame | None = None, + executed_strategy_reason_code: pd.DataFrame | None = None, + executed_strategy_reason_details: pd.DataFrame | None = None, + constraint_provenance: dict[str, ConstraintTouch] | None = None, + executed_turnover_actively_limited: pd.DataFrame | None = None, + executed_turnover_touched: pd.DataFrame | None = None, + executed_tradability_touched: pd.DataFrame | None = None, + executed_tradability_compliance_limited: pd.DataFrame | None = None, + executed_forced_liquidation: pd.DataFrame | None = None, + executed_stop_loss_triggered: pd.DataFrame | None = None, + executed_take_profit_triggered: pd.DataFrame | None = None, + executed_drift_compliance_forced: pd.DataFrame | None = None, + executed_drift_compliance_pending: pd.DataFrame | None = None, + executed_position_strategy_origin_timestamp: pd.DataFrame | None = None, + executed_position_strategy_origin_code: pd.DataFrame | None = None, + executed_position_strategy_origin_details: pd.DataFrame | None = None, ) -> pd.DataFrame: """Build the trade log from executed weight changes. @@ -84,6 +606,125 @@ def build_trade_log( slippage_equity: Per-date equity passed to the slippage model's ``equity`` argument. Pass ``AccountingResult.equity_for_costs`` to reproduce volume-based accounting costs. + executed_desired: The fully-desired, pre-constraint target weights + (post-allocator, post-volatility-targeting), aligned to + ``executed_weights``' own index via the same + ``executed_weights()`` shift -- a *real* pipeline frame, not a + reconstruction. Required (with the 4 arguments below) to + populate the trigger/adjustment columns; when omitted, those + columns are ``None``/``NaT`` for every row (e.g. walk-forward's + own call site, which rebuilds trades from a stitched + out-of-sample weight series with no per-fold diagnostic frames + surviving the stitch -- attribution is genuinely unavailable + there, not merely unwired) while ``action`` is still always + computed. + executed_constrained: The post-``ConstraintSet``, pre-turnover-cap + target weights, aligned the same way as ``executed_desired``. + executed_signal_diag: The strategy's decision proxy (``decision_ + signal()`` when the strategy provides one, else the raw + signal), resampled to rebalance dates and aligned to + ``executed_weights``' index -- a *diagnostic* frame (see + ``engine.py``'s ``_rebalance_diagnostic_frame``), used only to + detect "did the strategy's decision change since the last + rebalance", never to recompute an executed weight. + executed_allocated_diag: The allocator's raw output, diagnostic- + sampled the same way as ``executed_signal_diag``. + executed_desired_diag: ``executed_desired``, diagnostic-sampled the + same way, used to detect a volatility-targeting-driven change + since the last rebalance (as opposed to ``executed_desired`` + itself, which is used unsampled here to detect a still-drifting + target for the position_rescaling/deferred_catchup fallback). + tradable: Per-symbol tradability mask, real and already computed + by the engine. ``None`` means every symbol was always tradable + (no per-symbol calendar closures modeled) -- distinct from a + per-cell ``False``. + executed_strategy_reason_code: The strategy's own per-cell + ``trigger_reason_detail_code`` (``str | None``), aligned to + ``executed_weights``' index the same way as the other reason + frames. Requires the 5 arguments above; both this and + ``executed_strategy_reason_details`` must be supplied + together or not at all. When present and a row resolves to + trigger ``"strategy_signal"``, overrides that branch's + generic text with the strategy's own attribution. + executed_strategy_reason_details: The strategy's own per-cell + free-text explanation, paired with + ``executed_strategy_reason_code``. + constraint_provenance: Per-constraint provenance from + :meth:`ConstraintSet.apply_with_provenance`, keyed by + constraint name (including a ``"*_redistribution"`` entry for + a constraint split into direct/redistribution -- both keys + carry the SAME ``before``/``after`` as the base constraint, + only ``touched`` differs), with each ``ConstraintTouch``'s + frames already aligned to ``executed_weights``' index the same + way as the other reason frames. Requires the 5 arguments + above. Every constraint whose ``touched`` mask is set for a + given cell contributes its own entry (with its own + before/after text) to ``adjustment_reason_codes`` -- never a + single winning constraint. + executed_turnover_actively_limited: Real, cell-level provenance + from ``rebalancing.py``'s turnover-cap tracking (see + ``cap_turnover``/``rebalance_and_cap_turnover``'s + ``return_provenance``): True where the turnover budget itself + bound this row's move for that cell. Requires the 5 arguments + above; must be supplied together with + ``executed_turnover_touched`` or not at all. + executed_turnover_touched: The broader, episode-scoped turnover + provenance from the same source -- also True on a later row + still catching up a debt from an earlier turnover-limited move + toward the SAME still-unresolved upstream decision. + executed_tradability_touched: Real, cell-level provenance from + ``rebalancing.py``'s closure-catchup tracking: True where this + row's move is (at least partly) catching up a delta previously + blocked by a closure, or a feasibility limit reached only + because another symbol stayed closed. Must be supplied + together with ``executed_tradability_compliance_limited`` or + not at all; requires the 5 arguments above. + executed_tradability_compliance_limited: Sub-case of the above, + distinguishing the feasibility-limit case for detail text. + executed_forced_liquidation: Real, row-broadcast provenance from + ``AccountingResult.ruined``: True on every date the portfolio + was ruined and positions were forcibly flattened. Requires the + 5 arguments above. Highest-priority adjustment -- see + docs/backtesting.md#trade-log-reason-attribution for the full + override order. Currently unreachable via the real pipeline + (``run_accounting`` zeroes ``weight_changes`` on every ruined + date), wired anyway so `_classify_reason` never falls back to + `unknown`/`deferred_catchup` should that ever change. + executed_stop_loss_triggered: Real, row-broadcast provenance from + ``AccountingResult.stop_loss_triggered``: True on every cell + whose position was force-flattened by a stop-loss breach on + the real executed position (see ``quantlab.backtesting. + accounting._detect_stop_loss_take_profit``). Unlike + ``executed_forced_liquidation``, this branch IS reachable in + practice -- a stop-loss-forced exit is a real, non-zero + weight change. Requires the 5 reason-attribution arguments + above. + executed_take_profit_triggered: Same as + ``executed_stop_loss_triggered``, for the favorable-side + threshold. + executed_drift_compliance_forced: Real, row-broadcast provenance + from ``AccountingResult.drift_compliance_forced``: True on + every cell whose executed weight was set by the drift- + compliance linear program landing a correction for a hard + risk limit organic price drift breached off-schedule (see + ``quantlab.backtesting.accounting.apply_weight_drift``). + Requires the 5 reason-attribution arguments above. + executed_drift_compliance_pending: Same as ``executed_drift_ + compliance_forced``, for a still-unresolved breach (the + responsible symbol/group remains untradable) -- the best + achievable correction, retried every day it stays blocked. + executed_position_strategy_origin_timestamp: The timestamp of the + most recent strategic regime transition (per the strategy's + own decision proxy) still active for this cell -- ``NaT`` when + no strategic position is currently active (the decision proxy + is flat). Must be supplied together with the 2 arguments below + or not at all; requires the 5 arguments above. + executed_position_strategy_origin_code: The strategy's own + ``explain_signals()`` detail code at that origin transition, + or ``None`` when unavailable -- a temporally correct origin is + tracked independently of whether a specific code exists for it. + executed_position_strategy_origin_details: Free text paired with + the above. Returns: A DataFrame with :data:`TRADE_LOG_COLUMNS`, one row per non-zero fill. @@ -101,6 +742,178 @@ def build_trade_log( if not isinstance(slippage_model, SlippageModel): raise BacktestError("slippage_model must implement SlippageModel.") + reason_frames = { + "executed_desired": executed_desired, + "executed_constrained": executed_constrained, + "executed_signal_diag": executed_signal_diag, + "executed_allocated_diag": executed_allocated_diag, + "executed_desired_diag": executed_desired_diag, + } + supplied_reason_frames = [ + name for name in _REASON_FRAME_KWARGS if reason_frames[name] is not None + ] + if supplied_reason_frames and len(supplied_reason_frames) != len( + _REASON_FRAME_KWARGS + ): + missing = sorted(set(_REASON_FRAME_KWARGS) - set(supplied_reason_frames)) + raise BacktestError( + "executed_desired/executed_constrained/executed_signal_diag/" + "executed_allocated_diag/executed_desired_diag must be supplied " + f"all together or not at all; missing: {missing}." + ) + attribute_reasons = bool(supplied_reason_frames) + if attribute_reasons: + for name in _REASON_FRAME_KWARGS: + frame = reason_frames[name] + assert frame is not None # narrowed by attribute_reasons above + _validate_matching_frame(frame, name, executed_weights) + if tradable is not None: + _validate_matching_frame(tradable, "tradable", executed_weights) + + attribute_strategy_reasons = _validate_optional_group( + ("executed_strategy_reason_code", "executed_strategy_reason_details"), + { + "executed_strategy_reason_code": executed_strategy_reason_code, + "executed_strategy_reason_details": executed_strategy_reason_details, + }, + reference=executed_weights, + requires=attribute_reasons, + requires_label="the reason-attribution frames (executed_desired etc.)", + ) + + if constraint_provenance is not None: + if not attribute_reasons: + raise BacktestError( + "constraint_provenance requires the reason-attribution frames " + "(executed_desired etc.) to also be supplied." + ) + if not isinstance(constraint_provenance, dict): + raise BacktestError("constraint_provenance must be a dict.") + for constraint_name, touch in constraint_provenance.items(): + if not isinstance(touch, ConstraintTouch): + raise BacktestError( + f"constraint_provenance[{constraint_name!r}] must be a " + "ConstraintTouch." + ) + for field_name, frame in ( + ("touched", touch.touched), + ("before", touch.before), + ("after", touch.after), + ("direct", touch.direct), + ): + label = f"constraint_provenance[{constraint_name!r}].{field_name}" + _validate_matching_frame(frame, label, executed_weights) + + attribute_turnover = _validate_optional_group( + ("executed_turnover_actively_limited", "executed_turnover_touched"), + { + "executed_turnover_actively_limited": executed_turnover_actively_limited, + "executed_turnover_touched": executed_turnover_touched, + }, + reference=executed_weights, + requires=attribute_reasons, + requires_label="the reason-attribution frames (executed_desired etc.)", + ) + attribute_tradability = _validate_optional_group( + ( + "executed_tradability_touched", + "executed_tradability_compliance_limited", + ), + { + "executed_tradability_touched": executed_tradability_touched, + "executed_tradability_compliance_limited": ( + executed_tradability_compliance_limited + ), + }, + reference=executed_weights, + requires=attribute_reasons, + requires_label="the reason-attribution frames (executed_desired etc.)", + ) + attribute_forced_liquidation = False + if executed_forced_liquidation is not None: + if not attribute_reasons: + raise BacktestError( + "executed_forced_liquidation requires the reason-attribution " + "frames (executed_desired etc.) to also be supplied." + ) + _validate_matching_frame( + executed_forced_liquidation, "executed_forced_liquidation", executed_weights + ) + attribute_forced_liquidation = True + attribute_stop_loss = False + if executed_stop_loss_triggered is not None: + if not attribute_reasons: + raise BacktestError( + "executed_stop_loss_triggered requires the reason-attribution " + "frames (executed_desired etc.) to also be supplied." + ) + _validate_matching_frame( + executed_stop_loss_triggered, + "executed_stop_loss_triggered", + executed_weights, + ) + attribute_stop_loss = True + attribute_take_profit = False + if executed_take_profit_triggered is not None: + if not attribute_reasons: + raise BacktestError( + "executed_take_profit_triggered requires the reason-attribution " + "frames (executed_desired etc.) to also be supplied." + ) + _validate_matching_frame( + executed_take_profit_triggered, + "executed_take_profit_triggered", + executed_weights, + ) + attribute_take_profit = True + attribute_drift_compliance_forced = False + if executed_drift_compliance_forced is not None: + if not attribute_reasons: + raise BacktestError( + "executed_drift_compliance_forced requires the reason-" + "attribution frames (executed_desired etc.) to also be supplied." + ) + _validate_matching_frame( + executed_drift_compliance_forced, + "executed_drift_compliance_forced", + executed_weights, + ) + attribute_drift_compliance_forced = True + attribute_drift_compliance_pending = False + if executed_drift_compliance_pending is not None: + if not attribute_reasons: + raise BacktestError( + "executed_drift_compliance_pending requires the reason-" + "attribution frames (executed_desired etc.) to also be supplied." + ) + _validate_matching_frame( + executed_drift_compliance_pending, + "executed_drift_compliance_pending", + executed_weights, + ) + attribute_drift_compliance_pending = True + attribute_position_origin = _validate_optional_group( + ( + "executed_position_strategy_origin_timestamp", + "executed_position_strategy_origin_code", + "executed_position_strategy_origin_details", + ), + { + "executed_position_strategy_origin_timestamp": ( + executed_position_strategy_origin_timestamp + ), + "executed_position_strategy_origin_code": ( + executed_position_strategy_origin_code + ), + "executed_position_strategy_origin_details": ( + executed_position_strategy_origin_details + ), + }, + reference=executed_weights, + requires=attribute_reasons, + requires_label="the reason-attribution frames (executed_desired etc.)", + ) + commission_rate = _non_negative_rate(commission_bps, "commission_bps") spread_rate = _non_negative_rate(spread_bps, "spread_bps") @@ -130,9 +943,164 @@ def build_trade_log( changes = validate_execution_frame(weight_changes, name="weight_changes") executed_values = executed.to_numpy() change_values = changes.to_numpy() - previous_values = np.vstack( - [np.zeros((1, executed_values.shape[1])), executed_values[:-1]] - ) + # NOT the previous ROW's own executed value (`executed_values[:-1]`, + # shifted) -- that only coincides with the value actually held right + # before THIS row's trade when `weight_changes` is a plain row-to-row + # diff of `executed` (true when model_weight_drift=False). With drift + # active, `weight_changes` is `apply_weight_drift`'s own real per-row + # TRADE delta (zero on a pure-drift row, the landed trade's true size + # otherwise) -- the position can have drifted organically between the + # previous row's own reported value and this row's trade, so the two + # diverge. `executed - change` is correct in BOTH cases: it is, by + # construction, exactly the value this row's own trade delta was + # computed against (`new - previous == change` holds by definition, + # not just as a property to verify), and reduces to the identical + # previous-row-shifted value whenever `weight_changes` genuinely is a + # plain diff (the non-drift path, unchanged from before this fix). + previous_values = executed_values - change_values + + # Placeholder defaults for every reason-attribution lookup array, always + # bound regardless of which `attribute_*` flags are set. Each one is + # only ever read far below, inside the per-row/per-column loop, under + # the exact same `attribute_*` flag that gates its real assignment here + # -- so a placeholder value is never actually consulted at runtime. This + # is for static analysis only (a linter cannot follow "guarded by the + # same unchanged boolean flag" across the loop boundary in between); + # it changes no behaviour. + empty_float = np.empty(executed_values.shape, dtype=float) + empty_object = np.empty(executed_values.shape, dtype=object) + empty_bool = np.empty(executed_values.shape, dtype=bool) + desired_values = empty_float + constrained_values = empty_float + signal_diag_values = empty_float + allocated_diag_values = empty_float + desired_diag_values = empty_float + desired_prev_values = empty_float + signal_diag_prev_values = empty_float + allocated_diag_prev_values = empty_float + desired_diag_prev_values = empty_float + strategy_detail_code_values = empty_object + strategy_details_values = empty_object + constraint_names: list[str] = [] + constraint_touched_values: dict[str, np.ndarray] = {} + constraint_before_values: dict[str, np.ndarray] = {} + constraint_after_values: dict[str, np.ndarray] = {} + tradability_touched_values = empty_bool + tradability_compliance_limited_values = empty_bool + turnover_touched_values = empty_bool + turnover_actively_limited_values = empty_bool + forced_liquidation_values = empty_bool + stop_loss_triggered_values = empty_bool + take_profit_triggered_values = empty_bool + drift_compliance_forced_values = empty_bool + drift_compliance_pending_values = empty_bool + position_origin_timestamp_values = empty_object + position_origin_code_values = empty_object + position_origin_details_values = empty_object + + if attribute_reasons: + assert executed_desired is not None + assert executed_constrained is not None + assert executed_signal_diag is not None + assert executed_allocated_diag is not None + assert executed_desired_diag is not None + # Axes were already validated exactly equal to executed_weights' + # (same order too) above -- validate_execution_frame doesn't reorder + # anything, so a plain .to_numpy() lines up with executed_values + # cell-for-cell, no reindex needed. + desired_values = executed_desired.to_numpy() + constrained_values = executed_constrained.to_numpy() + signal_diag_values = executed_signal_diag.to_numpy() + allocated_diag_values = executed_allocated_diag.to_numpy() + desired_diag_values = executed_desired_diag.to_numpy() + # Same "no prior rebalance -> flat" convention as previous_values: + # np.vstack, never .shift(1) (which would put NaN in row 0 and + # silently break the very first trade's comparison). + zeros_row = np.zeros((1, executed_values.shape[1])) + desired_prev_values = np.vstack([zeros_row, desired_values[:-1]]) + signal_diag_prev_values = np.vstack([zeros_row, signal_diag_values[:-1]]) + allocated_diag_prev_values = np.vstack([zeros_row, allocated_diag_values[:-1]]) + desired_diag_prev_values = np.vstack([zeros_row, desired_diag_values[:-1]]) + + if attribute_strategy_reasons: + assert executed_strategy_reason_code is not None + assert executed_strategy_reason_details is not None + strategy_detail_code_values = executed_strategy_reason_code.to_numpy( + dtype=object + ) + strategy_details_values = executed_strategy_reason_details.to_numpy( + dtype=object + ) + + if constraint_provenance is not None: + constraint_names = [ + name for name in ADJUSTMENT_ORDER if name in constraint_provenance + ] + constraint_touched_values = { + name: constraint_provenance[name].touched.to_numpy(dtype=bool) + for name in constraint_names + } + constraint_before_values = { + name: constraint_provenance[name].before.to_numpy() + for name in constraint_names + } + constraint_after_values = { + name: constraint_provenance[name].after.to_numpy() + for name in constraint_names + } + + if attribute_turnover: + assert executed_turnover_actively_limited is not None + assert executed_turnover_touched is not None + turnover_actively_limited_values = ( + executed_turnover_actively_limited.to_numpy(dtype=bool) + ) + turnover_touched_values = executed_turnover_touched.to_numpy(dtype=bool) + if attribute_tradability: + assert executed_tradability_touched is not None + assert executed_tradability_compliance_limited is not None + tradability_touched_values = executed_tradability_touched.to_numpy( + dtype=bool + ) + tradability_compliance_limited_values = ( + executed_tradability_compliance_limited.to_numpy(dtype=bool) + ) + if attribute_forced_liquidation: + assert executed_forced_liquidation is not None + forced_liquidation_values = executed_forced_liquidation.to_numpy(dtype=bool) + if attribute_stop_loss: + assert executed_stop_loss_triggered is not None + stop_loss_triggered_values = executed_stop_loss_triggered.to_numpy( + dtype=bool + ) + if attribute_take_profit: + assert executed_take_profit_triggered is not None + take_profit_triggered_values = executed_take_profit_triggered.to_numpy( + dtype=bool + ) + if attribute_drift_compliance_forced: + assert executed_drift_compliance_forced is not None + drift_compliance_forced_values = executed_drift_compliance_forced.to_numpy( + dtype=bool + ) + if attribute_drift_compliance_pending: + assert executed_drift_compliance_pending is not None + drift_compliance_pending_values = ( + executed_drift_compliance_pending.to_numpy(dtype=bool) + ) + if attribute_position_origin: + assert executed_position_strategy_origin_timestamp is not None + assert executed_position_strategy_origin_code is not None + assert executed_position_strategy_origin_details is not None + position_origin_timestamp_values = ( + executed_position_strategy_origin_timestamp.to_numpy(dtype=object) + ) + position_origin_code_values = ( + executed_position_strategy_origin_code.to_numpy(dtype=object) + ) + position_origin_details_values = ( + executed_position_strategy_origin_details.to_numpy(dtype=object) + ) previous_equity_series = equity_before_period(equity, changes.index) previous_equity = previous_equity_series.to_numpy() @@ -185,14 +1153,161 @@ def build_trade_log( "A positive finite prior-period reference price is required " f"for {symbol!r} on {timestamp!r}." ) + previous = float(previous_values[row_number, column_index]) + new = float(executed_values[row_number, column_index]) + if attribute_reasons: + contributing_constraints = [ + name + for name in constraint_names + if constraint_touched_values[name][row_number, column_index] + ] + constraint_before = { + name: float( + constraint_before_values[name][row_number, column_index] + ) + for name in contributing_constraints + } + constraint_after = { + name: float(constraint_after_values[name][row_number, column_index]) + for name in contributing_constraints + } + if attribute_strategy_reasons: + strategy_detail_code = strategy_detail_code_values[ + row_number, column_index + ] + strategy_details = strategy_details_values[row_number, column_index] + else: + strategy_detail_code, strategy_details = None, None + reason = _classify_reason( + new=new, + previous=previous, + executed_desired=float(desired_values[row_number, column_index]), + executed_desired_prev=float( + desired_prev_values[row_number, column_index] + ), + executed_constrained=float( + constrained_values[row_number, column_index] + ), + signal_now=float(signal_diag_values[row_number, column_index]), + signal_prev=float( + signal_diag_prev_values[row_number, column_index] + ), + allocated_now=float( + allocated_diag_values[row_number, column_index] + ), + allocated_prev=float( + allocated_diag_prev_values[row_number, column_index] + ), + desired_diag_now=float( + desired_diag_values[row_number, column_index] + ), + desired_diag_prev=float( + desired_diag_prev_values[row_number, column_index] + ), + strategy_detail_code=strategy_detail_code, + strategy_details=strategy_details, + contributing_constraints=contributing_constraints, + constraint_before=constraint_before, + constraint_after=constraint_after, + tradability_touched=( + bool(tradability_touched_values[row_number, column_index]) + if attribute_tradability + else False + ), + tradability_compliance_limited=( + bool( + tradability_compliance_limited_values[ + row_number, column_index + ] + ) + if attribute_tradability + else False + ), + turnover_touched=( + bool(turnover_touched_values[row_number, column_index]) + if attribute_turnover + else False + ), + turnover_actively_limited=( + bool(turnover_actively_limited_values[row_number, column_index]) + if attribute_turnover + else False + ), + stop_loss_triggered=( + bool(stop_loss_triggered_values[row_number, column_index]) + if attribute_stop_loss + else False + ), + take_profit_triggered=( + bool(take_profit_triggered_values[row_number, column_index]) + if attribute_take_profit + else False + ), + forced_liquidation=( + bool(forced_liquidation_values[row_number, column_index]) + if attribute_forced_liquidation + else False + ), + drift_compliance_forced=( + bool(drift_compliance_forced_values[row_number, column_index]) + if attribute_drift_compliance_forced + else False + ), + drift_compliance_pending=( + bool(drift_compliance_pending_values[row_number, column_index]) + if attribute_drift_compliance_pending + else False + ), + ) + trigger_reason_code = reason.trigger_code + trigger_reason_detail_code = reason.trigger_detail_code + trigger_reason_details = reason.trigger_details + adjustment_reason_codes = reason.adjustment_codes + adjustment_reason_details = reason.adjustment_details + if attribute_position_origin: + position_strategy_origin_timestamp = ( + position_origin_timestamp_values[row_number, column_index] + ) + position_strategy_origin_code = position_origin_code_values[ + row_number, column_index + ] + position_strategy_origin_details = position_origin_details_values[ + row_number, column_index + ] + else: + position_strategy_origin_timestamp = pd.NaT + position_strategy_origin_code = None + position_strategy_origin_details = None + else: + trigger_reason_code = None + trigger_reason_detail_code = None + trigger_reason_details = None + adjustment_reason_codes = None + adjustment_reason_details = None + position_strategy_origin_timestamp = pd.NaT + position_strategy_origin_code = None + position_strategy_origin_details = None records.append( { "timestamp": timestamp, "symbol": symbol, - "previous_weight": float(previous_values[row_number, column_index]), - "new_weight": float(executed_values[row_number, column_index]), + "previous_weight": previous, + "new_weight": new, "weight_change": delta, "side": "buy" if delta > 0 else "sell", + "action": _classify_action(previous, new), + "trigger_reason_code": trigger_reason_code, + "trigger_reason_detail_code": trigger_reason_detail_code, + "trigger_reason_details": trigger_reason_details, + "adjustment_reason_codes": adjustment_reason_codes, + "adjustment_reason_details": adjustment_reason_details, + "position_strategy_origin_timestamp": ( + position_strategy_origin_timestamp + ), + "position_strategy_origin_code": position_strategy_origin_code, + "position_strategy_origin_details": ( + position_strategy_origin_details + ), "reference_price": price, "traded_notional": notional, "commission": commission, @@ -203,4 +1318,21 @@ def build_trade_log( ) if not records: return pd.DataFrame(columns=TRADE_LOG_COLUMNS) - return pd.DataFrame.from_records(records)[TRADE_LOG_COLUMNS] + result = pd.DataFrame.from_records(records)[TRADE_LOG_COLUMNS] + # Cast every str|None reason column to plain "object" dtype explicitly: + # pandas' default string-dtype inference can otherwise silently take + # over a column that mixes real strings with `None` across rows + # (missing values become NaN instead of None), which every `is None` + # check elsewhere in this pipeline relies on not happening. + for column in ( + "trigger_reason_code", + "trigger_reason_detail_code", + "trigger_reason_details", + "adjustment_reason_codes", + "adjustment_reason_details", + "position_strategy_origin_code", + "position_strategy_origin_details", + ): + column_values = result[column].astype(object) + result[column] = column_values.where(column_values.notna(), None) + return result diff --git a/src/quantlab/cli.py b/src/quantlab/cli.py index b5c58f2..6da6799 100644 --- a/src/quantlab/cli.py +++ b/src/quantlab/cli.py @@ -176,6 +176,25 @@ def _load_config(config_path: Path) -> ExperimentConfig: return ExperimentConfig.from_yaml(config_path) +def _resolve_output_directory( + cfg: ExperimentConfig, cli_override: Path | None = None +) -> Path: + """Resolve the bundle directory: CLI flag > ``output.directory`` > default. + + Every command below except ``backtest`` has no CLI flag of its own + (``cli_override`` stays ``None``), so for them this is just + :func:`~quantlab.backtesting.result.resolve_experiment_directory` -- + the same resolution :meth:`~quantlab.backtesting.result.BacktestResult. + save` itself falls back to for callers (like ``backtest``) that pass + ``output_directory`` straight through instead of pre-resolving it here. + """ + if cli_override is not None: + return cli_override + from quantlab.backtesting.result import resolve_experiment_directory + + return resolve_experiment_directory(cfg, default_root=GENERATED_REPORTS_DIR) + + def _echo_data_warnings(report: DataQualityReport, *, limit: int = 10) -> None: """Print a limited number of data-quality warnings. @@ -268,7 +287,9 @@ def _run_active_validation( ) grid = _default_grid(cfg) - train_window, validation_window, test_window = resolve_walk_forward_windows(cfg) + train_window, validation_window, test_window, step = ( + resolve_walk_forward_windows(cfg) + ) _echo_step("Running walk-forward validation") _echo_parameter_grid(cfg.strategy_name, grid) wf = WalkForwardValidator(cfg).run( @@ -278,6 +299,7 @@ def _run_active_validation( validation_window=validation_window, test_window=test_window, expanding=cfg.validation.expanding, + step=step, on_progress=_make_cli_progress_callback("Walk-forward"), checkpoint_path=checkpoint_path, ) @@ -313,6 +335,24 @@ def _walk_forward_validation_artifacts( } +def _update_resolved_config(result: BacktestResult, **fields: Any) -> None: + """Merge fields into ``result.metadata["resolved_config"]``. + + A single consolidated, always-current record of the CONCRETE values a + run actually used after applying every default/fallback (e.g. a walk- + forward window left unset in YAML, or the default parameter grid) -- + for full reproducibility even as this project's own defaults change + over time. Additive only: never replaces the whole dict, since a + single ``robustness`` command run can populate this from several + independent techniques (stress-test, bootstrap, permutation-test, + sensitivity) on the same result. Does not replace or rename any of the + existing, narrower keys (``bootstrap_run_params``, ``walk_forward_ + parameter_grid``, etc.) that other code already reads by their exact + name (e.g. ``load_previous_walk_forward_robustness``). + """ + result.metadata.setdefault("resolved_config", {}).update(fields) + + def _attach_walk_forward_evidence( result: BacktestResult, wf: WalkForwardResult | None, @@ -334,6 +374,41 @@ def _attach_walk_forward_evidence( result.metadata["walk_forward_oos_metrics"] = dict(result.metrics) +def _strategy_diagnostics_robustness( + data: pd.DataFrame, cfg: ExperimentConfig +) -> dict[str, object]: + """Return the current strategy's report diagnostics, keyed by section. + + Returns ``{}`` when its Strategy Explorer profile declares none. + Generic by profile lookup (``quantlab.dashboard.explorer.profile``) -- + no strategy name appears here, mirroring the dashboard's own dispatch. + Cheap relative to bootstrap/stress-test techniques, so always + recomputed fresh rather than persisted/reused across runs. A failed + computation still contributes a status note (never silently omitted -- + the report would otherwise look identical to a strategy with no + diagnostics declared at all). + """ + import quantlab.dashboard.explorer.profiles # noqa: F401 (registration) + from quantlab.dashboard.explorer.profile import get_profile + + profile = get_profile(cfg.strategy_name) + if profile is None or profile.results_diagnostics is None: + return {} + try: + diagnostics = profile.results_diagnostics.compute(data, cfg) + section = profile.results_diagnostics.report_section(diagnostics) + except Exception as exc: + logger.exception("Strategy Explorer results diagnostics failed") + import pandas as pd + + from quantlab.reporting.sections import DiagnosticsSection + + section = DiagnosticsSection( + table=pd.DataFrame({"Status": [f"Could not compute: {exc}"]}), + ) + return {profile.results_diagnostics.key: section} + + def _compute_stress_tests( data: pd.DataFrame, cfg: ExperimentConfig, @@ -363,6 +438,18 @@ def _compute_stress_tests( ) +def _stress_test_resolved_config(cfg: ExperimentConfig) -> dict[str, list[float | int]]: + """Return the stress-test magnitude lists this run actually used.""" + settings = cfg.robustness.stress_test + return { + "commission_multipliers": list(settings.commission_multipliers), + "slippage_multipliers": list(settings.slippage_multipliers), + "execution_delays": list(settings.execution_delays), + "best_days_removed": list(settings.best_days_removed), + "reduce_universe_by": list(settings.reduce_universe_by), + } + + def _compute_bootstrap( cfg: ExperimentConfig, result: BacktestResult, @@ -403,7 +490,16 @@ def _compute_bootstrap( "n_iterations": effective_n_iterations, "block_size": effective_block_size, } - return boot.summary() + _update_resolved_config( + result, + bootstrap={ + "n_iterations": effective_n_iterations, + "block_size": effective_block_size, + "confidence_level": cfg.robustness.bootstrap.confidence_level, + "seed": cfg.random_seed, + }, + ) + return boot.summary(confidence_level=cfg.robustness.bootstrap.confidence_level) def _compute_permutation_test( @@ -432,6 +528,13 @@ def _compute_permutation_test( result.metadata["permutation_test_run_params"] = { "n_iterations": effective_n_iterations, } + _update_resolved_config( + result, + permutation_test={ + "n_iterations": effective_n_iterations, + "seed": cfg.random_seed, + }, + ) return outcome @@ -625,7 +728,8 @@ def backtest( result = run_backtest_from_config(data, cfg, data_quality_report=report) _echo_step("Saving results") - out_dir = result.save(output) + robustness_extra = _strategy_diagnostics_robustness(data, cfg) + out_dir = result.save(output, robustness=robustness_extra or None) typer.echo("") typer.echo(result.summary()) @@ -659,7 +763,7 @@ def walk_forward( resolve_walk_forward_windows, ) - out = GENERATED_REPORTS_DIR / cfg.experiment_name + out = _resolve_output_directory(cfg) wf_checkpoint = out / ".checkpoint_walk_forward.pkl" stress_checkpoint = out / ".checkpoint_stress_test.pkl" if fresh: @@ -675,18 +779,21 @@ def walk_forward( grid = _default_grid(cfg) _echo_parameter_grid(cfg.strategy_name, grid) validator = WalkForwardValidator(cfg) - train_window, validation_window, test_window = resolve_walk_forward_windows(cfg) + train_window, validation_window, test_window, step = ( + resolve_walk_forward_windows(cfg) + ) if not ( cfg.validation.train_window and cfg.validation.validation_window and cfg.validation.test_window + and cfg.validation.step ): # Apply the documented CLI defaults and make every fallback visible. typer.secho( " using default window(s) " f"(train={train_window}, validation={validation_window}, " - f"test={test_window}) — set validation.train_window / " - "validation_window / test_window explicitly to override.", + f"test={test_window}, step={step}) — set validation.train_window / " + "validation_window / test_window / step explicitly to override.", fg=typer.colors.YELLOW, ) wf = validator.run( @@ -696,6 +803,7 @@ def walk_forward( validation_window=validation_window, test_window=test_window, expanding=cfg.validation.expanding, + step=step, on_progress=_make_cli_progress_callback("Walk-forward"), checkpoint_path=wf_checkpoint, ) @@ -756,12 +864,20 @@ def walk_forward( # Save the numerical bundle and its validation artefacts under one marker # and cross-process lock. BacktestResult computes checksums only after each # CSV has been atomically replaced. + # No strategy-diagnostics section here (unlike `backtest`/`report`): + # each walk-forward fold can select different parameters than + # `cfg`'s own, and covers only that fold's slice of history -- a + # diagnostic computed once, on the full history with the base + # config's parameters, would not actually describe what any + # individual fold traded (see the dashboard's equivalent choice in + # `_render_walk_forward_diagnostics_note`). + robustness_extra: dict[str, Any] = { + "walk_forward": wf.summary_table(), + "stress_tests": stress, + } result.save( out, - robustness={ - "walk_forward": wf.summary_table(), - "stress_tests": stress, - }, + robustness=robustness_extra, validation_artifacts={ "walk_forward_results.csv": wf.summary_table(), "walk_forward_oos_returns.csv": wf.oos_returns.rename("return"), @@ -805,7 +921,7 @@ def stress_test( from quantlab.validation.checkpoint import clear_checkpoint from quantlab.validation.robustness import stress_test_checkpoint_paths - out = GENERATED_REPORTS_DIR / cfg.experiment_name + out = _resolve_output_directory(cfg) wf_checkpoint = out / ".checkpoint_walk_forward.pkl" stress_checkpoint = out / ".checkpoint_stress_test.pkl" if fresh: @@ -834,6 +950,7 @@ def stress_test( _attach_walk_forward_evidence( result, wf, validation_artifacts, robustness_extra ) + _update_resolved_config(result, stress_test=_stress_test_resolved_config(cfg)) _echo_step("Saving results") from quantlab.backtesting.result import save_with_robustness_reuse @@ -882,9 +999,7 @@ def bootstrap( from quantlab.data.loader import DataLoader from quantlab.validation.checkpoint import clear_checkpoint - wf_checkpoint = ( - GENERATED_REPORTS_DIR / cfg.experiment_name / ".checkpoint_walk_forward.pkl" - ) + wf_checkpoint = _resolve_output_directory(cfg) / ".checkpoint_walk_forward.pkl" if fresh: clear_checkpoint(wf_checkpoint) @@ -911,12 +1026,14 @@ def bootstrap( out_dir = save_with_robustness_reuse( result, - GENERATED_REPORTS_DIR / cfg.experiment_name, + _resolve_output_directory(cfg), robustness=robustness_extra, validation_artifacts=validation_artifacts, ) + from quantlab.reporting.tables import format_bootstrap_summary + typer.echo("") - typer.echo(summary.to_string(index=False)) + typer.echo(format_bootstrap_summary(summary).to_string(index=False)) typer.echo("") _echo_save_outcome(result, f"Saved to {out_dir}") except QuantLabError as exc: @@ -947,9 +1064,7 @@ def permutation_test( from quantlab.data.loader import DataLoader from quantlab.validation.checkpoint import clear_checkpoint - wf_checkpoint = ( - GENERATED_REPORTS_DIR / cfg.experiment_name / ".checkpoint_walk_forward.pkl" - ) + wf_checkpoint = _resolve_output_directory(cfg) / ".checkpoint_walk_forward.pkl" if fresh: clear_checkpoint(wf_checkpoint) @@ -977,7 +1092,7 @@ def permutation_test( out_dir = save_with_robustness_reuse( result, - GENERATED_REPORTS_DIR / cfg.experiment_name, + _resolve_output_directory(cfg), robustness=robustness_extra, validation_artifacts=validation_artifacts, ) @@ -1029,7 +1144,7 @@ def sensitivity( from quantlab.data.loader import DataLoader from quantlab.validation.checkpoint import clear_checkpoint - out = GENERATED_REPORTS_DIR / cfg.experiment_name + out = _resolve_output_directory(cfg) wf_checkpoint = out / ".checkpoint_walk_forward.pkl" sensitivity_checkpoint = out / ".checkpoint_sensitivity.pkl" if fresh: @@ -1068,6 +1183,12 @@ def sensitivity( "parameter_y": y_name, "values_y": y_values, } + _update_resolved_config( + result, + sensitivity={ + "parameters": {x_name: x_values, y_name: y_values}, + }, + ) validation_artifacts: dict[str, Any] = {"sensitivity.csv": sens} robustness_extra: dict[str, Any] = {"sensitivity": sens} @@ -1117,7 +1238,7 @@ def robustness( from quantlab.validation.checkpoint import clear_checkpoint from quantlab.validation.robustness import stress_test_checkpoint_paths - out = GENERATED_REPORTS_DIR / cfg.experiment_name + out = _resolve_output_directory(cfg) wf_checkpoint = out / ".checkpoint_walk_forward.pkl" stress_checkpoint = out / ".checkpoint_stress_test.pkl" sensitivity_checkpoint = out / ".checkpoint_sensitivity.pkl" @@ -1139,6 +1260,13 @@ def robustness( _attach_walk_forward_evidence( result, wf, validation_artifacts, robustness_extra ) + if wf is None: + # Mirrors `backtest`/`report`'s own inclusion of this section -- + # omitted only in walk-forward mode (each fold can select + # different parameters than this config's base values, so a + # single full-history diagnostic would misrepresent any + # individual fold; see `_render_walk_forward_diagnostics_note`). + robustness_extra.update(_strategy_diagnostics_robustness(data, cfg)) ran_any = False if cfg.robustness.stress_test.enabled: @@ -1153,6 +1281,9 @@ def robustness( ) validation_artifacts["stress_tests.csv"] = stress robustness_extra["stress_tests"] = stress + _update_resolved_config( + result, stress_test=_stress_test_resolved_config(cfg) + ) typer.echo("") typer.echo(stress.to_string(index=False)) @@ -1162,8 +1293,10 @@ def robustness( boot_summary = _compute_bootstrap(cfg, result) validation_artifacts["bootstrap_summary.csv"] = boot_summary robustness_extra["bootstrap"] = boot_summary + from quantlab.reporting.tables import format_bootstrap_summary + typer.echo("") - typer.echo(boot_summary.to_string(index=False)) + typer.echo(format_bootstrap_summary(boot_summary).to_string(index=False)) if cfg.robustness.permutation_test.enabled: ran_any = True @@ -1201,6 +1334,9 @@ def robustness( ) validation_artifacts["sensitivity.csv"] = sens robustness_extra["sensitivity"] = sens + _update_resolved_config( + result, sensitivity={"parameters": {x_name: x_values, y_name: y_values}} + ) typer.echo("") typer.echo(sens.to_string(index=False)) @@ -1229,57 +1365,121 @@ def robustness( @app.command() def report( - experiment: str = typer.Option( - ..., + experiment: str | None = typer.Option( + None, "--experiment", "-e", help=( - "Regenerate a report from a previously saved experiment. If none exists, " - "run a bundled QuantLab config with the same experiment name." + "Regenerate a report from an experiment saved under the default " + "reports/generated/ directory. If no matching saved config exists " + "there, falls back to a bundled QuantLab config of the same " + "experiment name. Mutually exclusive with --config/" + "--shipped-config, which instead point directly at a config file " + "-- use one of those when the experiment's own output.directory " + "is not reports/generated/, since this by-name lookup cannot find " + "it otherwise." + ), + ), + config: Path | None = typer.Option( + None, + "--config", + help=( + "Regenerate a report directly from this config file, bypassing " + "the --experiment by-name lookup under reports/generated/ -- the " + "way to target a config whose own output.directory points " + "elsewhere. Mutually exclusive with --experiment/--shipped-config." + ), + ), + shipped_config: str | None = typer.Option( + None, + "--shipped-config", + help=( + "Name of a config bundled with the installed package, as a " + "direct alternative to --experiment (see --config). Mutually " + "exclusive with --experiment/--config." ), ), ) -> None: - """Regenerate a report from a saved experiment or matching bundled config.""" + """Regenerate a report from a saved experiment, or directly from a config.""" configure_logging() try: - reports_root = GENERATED_REPORTS_DIR.resolve() - exp_dir = (GENERATED_REPORTS_DIR / experiment).resolve() - if not exp_dir.is_relative_to(reports_root): + direct_config_requested = config is not None or shipped_config is not None + if experiment is not None and direct_config_requested: raise QuantLabError( - f"Invalid --experiment {experiment!r}: must not escape the " - f"generated-reports directory ({GENERATED_REPORTS_DIR})." + "--experiment is mutually exclusive with --config/--shipped-config." ) - config_path = exp_dir / "config.yaml" - if not config_path.is_file(): - # Fall back to a shipped config of the same name. - from quantlab.constants import CONFIGS_DIR - - candidates = sorted( - path - for pattern in ("*.yaml", "*.yml") - for path in CONFIGS_DIR.glob(pattern) - ) - for candidate in candidates: - from quantlab.config import ExperimentConfig + if direct_config_requested: + config_path = _resolve_config_path(config, shipped_config) + cfg = _load_config(config_path) + elif experiment is not None: + reports_root = GENERATED_REPORTS_DIR.resolve() + exp_dir = (GENERATED_REPORTS_DIR / experiment).resolve() + if not exp_dir.is_relative_to(reports_root): + raise QuantLabError( + f"Invalid --experiment {experiment!r}: must not escape the " + f"generated-reports directory ({GENERATED_REPORTS_DIR})." + ) + config_path = exp_dir / "config.yaml" + if not config_path.is_file(): + # Fall back to a shipped config of the same name. + from quantlab.constants import CONFIGS_DIR - if ExperimentConfig.from_yaml(candidate).experiment_name == experiment: - config_path = candidate - break - if not config_path.is_file(): - raise QuantLabError( - f"No saved or bundled config found for experiment {experiment!r}. " - "Run `quantlab backtest` first or check the experiment name." - ) - cfg = _load_config(config_path) - if cfg.experiment_name != experiment: + candidates = sorted( + path + for pattern in ("*.yaml", "*.yml") + for path in CONFIGS_DIR.glob(pattern) + ) + for candidate in candidates: + from quantlab.config import ExperimentConfig + + if ( + ExperimentConfig.from_yaml(candidate).experiment_name + == experiment + ): + config_path = candidate + break + if not config_path.is_file(): + raise QuantLabError( + f"No saved or bundled config found for experiment " + f"{experiment!r}. Run `quantlab backtest` first, check " + "the experiment name, or pass --config/--shipped-config " + "directly if it was saved outside reports/generated/." + ) + cfg = _load_config(config_path) + if cfg.experiment_name != experiment: + raise QuantLabError( + f"Config {config_path} declares experiment_name=" + f"{cfg.experiment_name!r}, but --experiment was " + f"{experiment!r}." + ) + else: raise QuantLabError( - f"Config {config_path} declares experiment_name=" - f"{cfg.experiment_name!r}, but --experiment was {experiment!r}." + "One of --experiment, --config, or --shipped-config is required." ) - from quantlab.backtesting.result import save_with_walk_forward_reuse + from quantlab.backtesting.result import ( + resolve_experiment_directory, + save_with_walk_forward_reuse, + ) from quantlab.backtesting.runner import run_backtest_from_config from quantlab.data.loader import DataLoader + # This command's entire purpose is producing an HTML report, so it + # always renders one -- regardless of output.save_html_report/ + # save_figures, which only govern whether *other* commands' own + # runs render the presentation layer. The saved bundle's real + # location follows output.directory when the config sets one, + # which may differ from the by-name lookup above (that lookup only + # has to find *a* config describing this experiment, not the + # directory its own prior runs actually saved to). + cfg = cfg.revalidated_copy( + update={ + "output": cfg.output.revalidated_copy( + update={"save_html_report": True, "save_figures": True} + ) + } + ) + exp_dir = resolve_experiment_directory(cfg, default_root=GENERATED_REPORTS_DIR) + _echo_step("Reloading data and re-running for the report") data, report = DataLoader().load(cfg) _echo_data_warnings(report) @@ -1287,7 +1487,10 @@ def report( # A report-only run does not recompute walk-forward validation. # Reuse earlier OOS artefacts only when their provenance checks pass. - out = save_with_walk_forward_reuse(result, exp_dir) + robustness_extra = _strategy_diagnostics_robustness(data, cfg) + out = save_with_walk_forward_reuse( + result, exp_dir, robustness_extra=robustness_extra + ) _echo_save_outcome(result, f"Report at {out / 'report.html'}") except QuantLabError as exc: typer.secho(f"[ERROR] {exc}", fg=typer.colors.RED, err=True) diff --git a/src/quantlab/config.py b/src/quantlab/config.py index d6d1294..99ea2c5 100644 --- a/src/quantlab/config.py +++ b/src/quantlab/config.py @@ -19,12 +19,20 @@ from datetime import date from enum import StrEnum from pathlib import Path -from typing import Any, Self, Union, get_args, get_origin +from typing import Any, Literal, Self, Union, get_args, get_origin import numpy as np import pandas as pd import yaml -from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictFloat, + StrictInt, + field_validator, + model_validator, +) from yaml.constructor import ConstructorError from yaml.nodes import MappingNode @@ -535,35 +543,37 @@ def _check_intraday_frequency_requires_uniform_calendar(self) -> DataConfig: return self @model_validator(mode="after") - def _warn_if_mixed_calendars_dilute_windowed_features(self) -> DataConfig: - """Warn (never reject) that rolling-window features may be diluted. - - A verified closure's synthetic bar (:mod:`quantlab.data.closures`) - is exactly flat -- zero return, zero volume -- so any trailing - window counted in raw *periods* (a momentum lookback, a volatility - window, an ADV window, a technical indicator's own window, ...) - spans MORE real trading sessions than its configured length for a - session-bound instrument sharing a combined timeline with an - always-open one (e.g. equities alongside crypto): those flat bars - silently dilute volatility/ADV estimates, and a "252-period" - lookback stretches across more than 252 real equity sessions. - Computing every instrument's own features on its native calendar - before aligning signals would remove this entirely, but that is a - substantially larger redesign than a single validator can express - -- this only makes the existing, structural limitation visible at - config load instead of a silent distortion (see - docs/limitations.md). + def _note_mixed_calendars_use_native_calendar_features(self) -> DataConfig: + """Informational: a mixed-calendar universe uses native-calendar features. + + Every built-in strategy's own rolling-window signal (momentum + lookback, technical indicator, mean-reversion indicator, a pairs + spread's own hedge fit) and the ADV window are computed on each + instrument's own native session dates before aligning back onto + the combined timeline (see :func:`quantlab.features. + native_calendar.compute_native_then_align`) -- a session-bound + instrument sharing a combined timeline with an always-open one + (e.g. equities alongside crypto) is not diluted by the always-open + instrument's own extra sessions in what actually gets traded. Not + yet covered: the inverse-volatility/volatility-targeting + allocators, and most Strategy Explorer diagnostics (see + docs/limitations.md for the precise, still-open list). Kept at + ``logger.warning`` (surfaced in the dashboard sidebar) purely so a + user configuring a mixed-calendar portfolio is still made aware of + both what is covered and what remains open. """ calendars = {instrument.calendar for instrument in self.instruments} if len(calendars) > 1: logger.warning( - "Instruments span more than one calendar (%s): any " - "rolling-window feature (momentum lookback, volatility " - "window, ADV window, technical indicators, ...) counts raw " - "periods, not real trading sessions per instrument -- a " - "session-bound instrument's estimates are diluted by the " - "flat, zero-return/zero-volume closure bars inserted to " - "keep the combined timeline dense. See docs/limitations.md.", + "Instruments span more than one calendar (%s): every " + "built-in strategy's own rolling-window signal (momentum " + "lookback, technical indicator, mean-reversion indicator, " + "a pairs spread's own hedge fit) and the ADV window are " + "computed on each instrument's own native calendar before " + "aligning onto the combined timeline. Not yet covered: " + "the inverse-volatility/volatility-targeting allocators, " + "and most Strategy Explorer diagnostics. See " + "docs/limitations.md.", sorted(calendars), ) return self @@ -599,6 +609,21 @@ class StrategyConfig(_StrictModel): name: str parameters: dict[str, Any] = Field(default_factory=dict) + #: Price series used to generate signals -- execution/costs always use + #: the raw close regardless of this setting (see docs/data_pipeline.md); + #: this only controls what a strategy's own generate_signals() sees. + signal_price_type: Literal["adjusted_close", "close"] = "adjusted_close" + + @model_validator(mode="after") + def _reject_price_type_in_parameters(self) -> StrategyConfig: + if "price_type" in self.parameters: + raise ValueError( + "strategy.parameters must not set 'price_type' directly -- " + "use strategy.signal_price_type instead, so the value the " + "strategy actually uses always matches what is recorded in " + "resolved_config." + ) + return self class PortfolioConfig(_StrictModel): @@ -618,8 +643,24 @@ class PortfolioConfig(_StrictModel): volatility_window: int = Field(default=63, gt=1) maximum_leverage: float = Field(default=1.0, gt=0.0) rebalance_frequency: RebalanceFrequency = RebalanceFrequency.MONTHLY - # Maximum L1 weight change allowed at each rebalance. + # Maximum L1 weight change allowed on any single row. A rebalance whose + # full target exceeds this in one step lands partially and keeps + # closing the remaining gap over subsequent rows, so a whole rebalance + # can take several rows to fully execute. maximum_turnover: float | None = Field(default=None, gt=0.0) + # Evolve executed weights forward by organic price drift between real + # trades (see quantlab.backtesting.accounting.apply_weight_drift) + # instead of holding them constant until the next scheduled rebalance -- + # a real portfolio's weights genuinely do drift with each asset's own + # price move between trades; holding them constant is only ever exactly + # correct when the schedule itself rebalances every single period. A + # genuinely scheduled rebalance still always trades toward its + # freshly-decided target regardless of drift -- landing there in one + # row unless maximum_turnover caps the move, in which case it lands + # partially and keeps closing the gap on subsequent rows. `False` + # remains available as an explicit legacy/reproducibility escape + # hatch, not the standard path. + model_weight_drift: bool = True @model_validator(mode="after") def _reject_unimplemented_custom_rebalancing(self) -> PortfolioConfig: @@ -738,6 +779,11 @@ class ValidationConfig(_StrictModel): train_window: int | None = Field(default=None, gt=0) validation_window: int | None = Field(default=None, gt=0) test_window: int | None = Field(default=None, gt=0) + # Advance between consecutive folds' train windows. None defaults to + # test_window (contiguous, non-overlapping test blocks). A smaller step + # overlaps test blocks for denser evaluation; step must not exceed + # test_window (see walk_forward_windows()'s docstring for why). + step: int | None = Field(default=None, gt=0) expanding: bool = True optimization_metric: OptimizationMetric = OptimizationMetric.SHARPE # Optional strategy-parameter candidates for walk-forward selection. When @@ -758,6 +804,18 @@ def _check_ratios(self) -> ValidationConfig: "validation_ratio has no effect without a positive test_ratio " "when validation.method is 'holdout'." ) + if self.method is ValidationMethod.WALK_FORWARD and self.step is not None: + # Mirrors resolve_walk_forward_windows()'s own default (126) -- + # kept duplicated rather than imported since that function lives + # in quantlab.validation.walk_forward, which itself imports this + # config module. + effective_test_window = self.test_window or 126 + if self.step > effective_test_window: + raise ValueError( + f"step ({self.step}) must not exceed test_window " + f"({effective_test_window}) -- a larger step would skip " + "dates between consecutive folds' test blocks entirely." + ) if self.method is ValidationMethod.WALK_FORWARD and ( self.validation_ratio is not None or self.test_ratio is not None ): @@ -777,6 +835,30 @@ def _check_ratios(self) -> ValidationConfig: raise ValueError( f"parameter_grid.{name} must contain at least one candidate." ) + if name == "price_type": + raise ValueError( + "parameter_grid must not include 'price_type' -- it is a " + "structural choice, set strategy.signal_price_type instead." + ) + # Caught here, at YAML load time, rather than deep inside + # walk_forward.py's own execution-time duplicate check -- + # same "reject at the door" convention as StressTestSettings. + _no_duplicate_values(candidates, name=f"parameter_grid.{name}") + if self.method is not ValidationMethod.WALK_FORWARD: + window_fields = { + "train_window": self.train_window, + "validation_window": self.validation_window, + "test_window": self.test_window, + "step": self.step, + } + provided = sorted(k for k, v in window_fields.items() if v is not None) + if provided: + verb = "apply" if len(provided) > 1 else "applies" + pronoun = "them" if len(provided) > 1 else "it" + raise ValueError( + f"{', '.join(provided)} {verb} only to validation.method " + f"'walk_forward'; remove {pronoun} or set method: walk_forward." + ) return self @@ -786,10 +868,56 @@ class ReproducibilityConfig(_StrictModel): random_seed: int = Field(default=42, ge=0) +def _no_duplicate_values(values: list[Any], *, name: str) -> list[Any]: + """Reject a candidate list containing the same value more than once.""" + seen: list[Any] = [] + for value in values: + if value in seen: + raise ValueError(f"{name} must not contain duplicate values.") + seen.append(value) + return values + + class StressTestSettings(_StrictModel): - """Toggle for the ``robustness stress-test`` / orchestrator run.""" + """Scenario magnitudes for the ``robustness stress-test`` / orchestrator run. + + Each list is a set of scenario magnitudes to evaluate independently + (one row per value) -- an empty list disables that scenario TYPE + entirely, the same "empty means nothing to run" convention as + ``validation.parameter_grid``. Defaults reproduce exactly the fixed + scenario set this project ran before these became configurable. + """ enabled: bool = False + commission_multipliers: list[StrictFloat] = Field( + default_factory=lambda: [2.0, 5.0] + ) + slippage_multipliers: list[StrictFloat] = Field(default_factory=lambda: [2.0]) + execution_delays: list[StrictInt] = Field(default_factory=lambda: [1]) + best_days_removed: list[StrictInt] = Field(default_factory=lambda: [10]) + #: Number of symbols dropped from the tail of the universe, per + #: scenario. A scenario whose universe is too small for that count is + #: recorded with status="failed", never silently omitted. + reduce_universe_by: list[StrictInt] = Field(default_factory=lambda: [1]) + + @model_validator(mode="after") + def _check_magnitudes(self) -> StressTestSettings: + # Multipliers below are genuine stress scenarios (elevated costs), + # not arbitrary perturbations -- 1.0 or below would silently test + # cheaper-than-baseline execution instead. Delays/day-counts/removed + # symbols are plain positive counts. + for name, values, gt in ( + ("commission_multipliers", self.commission_multipliers, 1.0), + ("slippage_multipliers", self.slippage_multipliers, 1.0), + ("execution_delays", self.execution_delays, 0), + ("best_days_removed", self.best_days_removed, 0), + ("reduce_universe_by", self.reduce_universe_by, 0), + ): + for value in values: + if value <= gt: + raise ValueError(f"{name} entries must be greater than {gt}.") + _no_duplicate_values(values, name=name) + return self class BootstrapSettings(_StrictModel): @@ -798,6 +926,9 @@ class BootstrapSettings(_StrictModel): enabled: bool = False n_iterations: int = Field(default=1000, gt=0) block_size: int = Field(default=1, gt=0) + #: Central percentile interval width reported by BootstrapResult.summary() + #: (0.90 -> the 5th/95th percentiles). + confidence_level: float = Field(default=0.90, gt=0.0, lt=1.0) class PermutationTestSettings(_StrictModel): @@ -845,6 +976,19 @@ def _check_parameters_shape(self) -> SensitivitySettings: f"robustness.sensitivity.parameters.{name} must " "contain at least one candidate value." ) + if name == "price_type": + raise ValueError( + "robustness.sensitivity.parameters must not include " + "'price_type' -- it is a structural choice, set " + "strategy.signal_price_type instead." + ) + # Caught here, at YAML load time, rather than deep inside + # parameter_sensitivity.py's own execution-time duplicate + # check -- same "reject at the door" convention as + # StressTestSettings/ValidationConfig.parameter_grid. + _no_duplicate_values( + candidates, name=f"robustness.sensitivity.parameters.{name}" + ) return self @@ -863,6 +1007,37 @@ class RobustnessConfig(_StrictModel): sensitivity: SensitivitySettings = Field(default_factory=SensitivitySettings) +class OutputConfig(_StrictModel): + """Where and what a run saves (``output:``). + + ``directory`` overrides the default ``reports/generated/`` location used by every CLI command (``backtest``'s own + ``--output`` flag still takes priority when given). The two artefact + toggles skip only the presentation layer -- metrics.json/trades.csv/ + equity_curve.csv and every other numeric artefact are always written + regardless. ``quantlab report`` does NOT depend on those saved + artefacts to regenerate the HTML report later: it reloads the data and + re-runs the backtest from this same config, and only reuses previously + saved walk-forward/stress/bootstrap/permutation-test/sensitivity + EVIDENCE when its own provenance check against the fresh run still + passes. + """ + + directory: str | None = None + save_html_report: bool = True + save_figures: bool = True + + @field_validator("directory") + @classmethod + def _reject_blank_directory(cls, value: str | None) -> str | None: + if value is not None and not value.strip(): + raise ValueError( + "output.directory must not be empty or whitespace-only -- omit " + "the field entirely to use the default location instead." + ) + return value + + class ExperimentConfig(_StrictModel): """Top-level, reproducible description of a single experiment. @@ -881,6 +1056,25 @@ class ExperimentConfig(_StrictModel): default_factory=ReproducibilityConfig ) robustness: RobustnessConfig = Field(default_factory=RobustnessConfig) + output: OutputConfig = Field(default_factory=OutputConfig) + #: Optional overrides for the HTML report's auto-generated research + #: question/hypothesis text (quantlab.reporting.research_summary). When + #: unset, that text is synthesized from the strategy name/config as + #: before -- unchanged behaviour for every experiment that doesn't set + #: these. + research_question: str | None = None + hypothesis: str | None = None + + @field_validator("research_question", "hypothesis") + @classmethod + def _reject_blank_research_text(cls, value: str | None) -> str | None: + if value is not None and not value.strip(): + raise ValueError( + "research_question/hypothesis must not be empty or " + "whitespace-only -- omit the field entirely to use the " + "auto-generated text instead." + ) + return value @field_validator("experiment_name") @classmethod @@ -982,7 +1176,8 @@ def _check_component_names(self) -> ExperimentConfig: if self.portfolio.allocator != "signal_proportional": raise ValueError( "pairs_trading requires portfolio.allocator " - "'signal_proportional' so its dollar hedge ratio is preserved." + "'signal_proportional' so the relative dollar-notional " + "hedge ratio implied by beta and current prices is preserved." ) if ( self.portfolio.maximum_weight is not None @@ -1027,6 +1222,13 @@ def _check_component_names(self) -> ExperimentConfig: f"its top/bottom selections ({top_count} + {bottom_count} " f"requested, {available} configured)." ) + scaling = parameters.get("signal_scaling", "binary") + if scaling != "binary" and self.portfolio.allocator == "equal_weight": + raise ValueError( + "Non-binary cross_sectional_momentum signals require an " + "allocator that preserves signal magnitude; equal_weight " + "keeps only signs." + ) if self.strategy.name == "time_series_momentum": scaling = self.strategy.parameters.get("signal_scaling", "binary") if scaling != "binary" and self.portfolio.allocator == "equal_weight": diff --git a/src/quantlab/dashboard/app.py b/src/quantlab/dashboard/app.py index 0ebfe39..e67b8a2 100644 --- a/src/quantlab/dashboard/app.py +++ b/src/quantlab/dashboard/app.py @@ -20,23 +20,33 @@ import pandas as pd import streamlit as st +import quantlab.dashboard.explorer.profiles # noqa: F401 (registration side effect) from quantlab.config import DataSourceName, compatible_frequencies_for_sources from quantlab.dashboard.components import ( + binance_universe_labels, + entry_threshold_bounds, + exit_threshold_bounds, + mean_reversion_slider_bounds, + parse_symbols, render_charts, render_exposure_and_cost_charts, render_gross_net_comparison, render_metric_cards, render_sensitivity_heatmap, render_trade_table, + symbols_picker, + yahoo_universe_labels, ) +from quantlab.dashboard.explorer import detail as explorer_detail +from quantlab.dashboard.explorer import gallery as explorer_gallery +from quantlab.dashboard.explorer.profile import get_profile from quantlab.dashboard.state import ( - binance_trading_symbols, build_config_from_inputs, default_end_date, detect_calendar, detect_source, estimate_walk_forward_backtest_count, - run_dashboard_backtest, + run_dashboard_backtest_with_data, run_dashboard_bootstrap, run_dashboard_permutation_test, run_dashboard_sensitivity, @@ -44,15 +54,19 @@ run_dashboard_walk_forward, run_dashboard_walk_forward_sensitivity, run_dashboard_walk_forward_stress_tests, - yahoo_common_symbols, ) from quantlab.logging_config import configure_logging, get_logger from quantlab.progress import ProgressReporter +from quantlab.reporting.sections import DiagnosticsSection +from quantlab.reporting.tables import format_bootstrap_summary from quantlab.strategies.base import ( available_strategies, strategy_parameter_names, strategy_sweepable_parameter_names, ) +from quantlab.strategies.mean_reversion import ( + UI_INDICATORS as MEAN_REVERSION_UI_INDICATORS, +) from quantlab.validation.parameter_grid import parse_parameter_grid_values from quantlab.validation.parameter_sensitivity import ( infer_sensitivity_parameter_columns, @@ -60,7 +74,7 @@ if TYPE_CHECKING: from quantlab.backtesting.result import BacktestResult - from quantlab.data.base import SymbolSuggestion + from quantlab.config import ExperimentConfig from quantlab.validation.walk_forward import WalkForwardResult # Streamlit runs this file in its own process (`quantlab dashboard` launches @@ -75,118 +89,61 @@ st.set_page_config(page_title="QuantLab", page_icon="📈", layout="wide") -def _parse_symbols(raw: str) -> list[str]: - """Normalise symbols and remove duplicates while preserving their order.""" - return list(dict.fromkeys(s.strip().upper() for s in raw.split(",") if s.strip())) - - -@st.cache_data(ttl=3600, show_spinner="Loading Binance's symbol list…") -def _cached_binance_universe() -> list[SymbolSuggestion]: - """Binance's full active spot-symbol universe, refreshed hourly. - - Fetched once per hour (per Streamlit cache entry, no arguments) rather - than per keystroke: once loaded, picking symbols from it is instant, - client-side dropdown filtering — no server round trip per character. - """ - return binance_trading_symbols() - - -def _label_for(suggestion: SymbolSuggestion) -> str: - if suggestion.description: - return f"{suggestion.symbol} — {suggestion.description}" - return suggestion.symbol - - -def _binance_universe_labels() -> dict[str, str]: - """Binance's cached universe as ``{symbol: display label}``.""" - return {s.symbol: _label_for(s) for s in _cached_binance_universe()} - - -def _yahoo_universe_labels() -> dict[str, str]: - """The bundled S&P 500 + major-ETF reference list as ``{symbol: label}``. - - Yahoo has no downloadable "every symbol" endpoint the way Binance does, - so this static, bundled list stands in as an instant, offline universe — - covering what most dashboard users will look for, not every symbol Yahoo - can actually serve. - """ - return {s.symbol: _label_for(s) for s in yahoo_common_symbols()} - - -#: Shown in the widget's help tooltip whenever a symbol picker's preloaded -#: list isn't a complete universe — currently only Yahoo's, since Binance's -#: list genuinely is complete. -_INCOMPLETE_LIST_NOTE = ( - "Not every symbol is suggested — if yours is missing, type its exact " - "ticker and it'll still be accepted." +_PAIRS_TRADING_SYMBOLS_HELP = ( + "pairs_trading needs at least two symbols; its two legs are then picked below." ) -def _symbols_picker( - label_by_symbol: dict[str, str], - key: str, - default_symbols: tuple[str, ...], - *, - accept_new_options: bool = False, -) -> list[str]: - """A single instant, client-side-filtered dropdown over a preloaded universe. - - Typing filters the already-loaded option list in the browser (like a - search-engine dropdown) — no server round trip per character. When - ``accept_new_options`` is set, a symbol absent from the preloaded list - (Yahoo's bundled universe is large but not exhaustive — Yahoo has no - downloadable "every symbol" list to preload the way Binance does) can - still be typed and added directly. - """ - help_text = "Tradable universe — start typing to filter" - help_text += ( - ", or enter an exact symbol not in the list. " if accept_new_options else ". " - ) - help_text += ( - "pairs_trading needs at least two symbols; its two legs are then picked below." - ) - if accept_new_options: - help_text += " " + _INCOMPLETE_LIST_NOTE - - if key not in st.session_state: - st.session_state[key] = [ - label_by_symbol[s] for s in default_symbols if s in label_by_symbol - ] - # No `label_visibility="collapsed"` here: Streamlit hides the help - # tooltip icon along with a collapsed label, and that icon is the only - # place the market-calendar/incomplete-list notes above are surfaced. - with st.container(border=True): - picked_labels = st.multiselect( - "Symbols", - options=list(label_by_symbol.values()), - key=key, - placeholder=( - "Type to find or add any symbol…" - if accept_new_options - else "Type to find a symbol…" - ), - accept_new_options=accept_new_options, - help=help_text, - ) - symbol_by_label = {label: symbol for symbol, label in label_by_symbol.items()} - return [ - symbol_by_label.get(label, label.strip().upper()) for label in picked_labels - ] - - def _binance_symbols_picker() -> list[str]: + # A plain `st.expander` still runs its body every rerun even while + # collapsed, so this cannot simply live inside one -- that would + # silently call Binance's API on every dashboard load, regardless of + # whether the user ever opens this section (`cached_binance_universe` + # is itself cached, but the FIRST network round trip per hour still + # happens unconditionally). Gating the fetch behind an explicit button + # avoids that, and (unlike a stateful/lazy expander) needs no + # label<->symbol remapping to preserve an already-made selection across + # reruns -- the underlying multiselect's own widget state persists + # normally once it exists. + if not st.session_state.get("binance_universe_load_requested", False): + st.caption( + "Loading the tradable symbol list calls Binance's public API. " + "Click below to fetch it (cached for an hour after that)." + ) + if st.button("Load Binance symbols", key="binance_universe_load_button"): + # No `st.rerun()` here: the click already triggers Streamlit's + # own full top-to-bottom rerun. An extra manual `st.rerun()` + # would abort THIS run right here, before the CSV picker (and + # any other widget further down the sidebar) ever executes -- + # and a widget that doesn't render on a given run loses its + # keyed session-state value, silently resetting it back to + # its default (this is exactly how the CSV symbols field used + # to reset itself whenever this button was clicked). Setting + # the flag and falling through to the picker below instead + # renders it immediately, in this same run, with no abort. + st.session_state["binance_universe_load_requested"] = True + else: + return [] # Empty by default: all three pickers are visible simultaneously now, and # a non-empty default here would immediately conflict with CSV's bundled # demo default below (see `_combine_instrument_picks`). - return _symbols_picker(_binance_universe_labels(), "binance_symbols", ()) + return symbols_picker( + st, + binance_universe_labels(st), + "binance_symbols", + (), + help_suffix=_PAIRS_TRADING_SYMBOLS_HELP, + ) def _yahoo_symbols_picker() -> list[str]: - return _symbols_picker( - _yahoo_universe_labels(), + return symbols_picker( + st, + yahoo_universe_labels(), "yahoo_symbols", (), accept_new_options=True, + help_suffix=_PAIRS_TRADING_SYMBOLS_HELP, ) @@ -194,6 +151,7 @@ def _csv_symbols_picker() -> list[str]: raw = st.text_input( "CSV symbols (comma-separated)", "SPY, QQQ, TLT, GLD", + key="csv_symbols_input", help=( "Local files under data/raw, one CSV per symbol. When 'Allow " "bundled synthetic demo data' below is enabled, QuantLab falls " @@ -201,7 +159,7 @@ def _csv_symbols_picker() -> list[str]: "requested local file is absent." ), ) - return _parse_symbols(raw) + return parse_symbols(raw) def _combine_instrument_picks( @@ -293,6 +251,75 @@ def _instrument_table( return records +def _risk_control_pct_inputs(key_prefix: str) -> tuple[float | None, float | None]: + """Render the shared stop_loss_pct/take_profit_pct widget pair. + + Operates on the REAL executed position (post-allocator/constraints/ + rebalancing/execution) -- see `quantlab.backtesting.accounting. + _detect_stop_loss_take_profit`. Shared across every strategy that + accepts these two parameters so the widget behavior/help text can + never drift between strategies. + """ + col_stop, col_take = st.columns(2) + stop_loss_pct: float | None = None + take_profit_pct: float | None = None + with col_stop: + if st.checkbox( + "Enable stop-loss", + value=False, + key=f"{key_prefix}_enable_stop_loss_pct", + help=( + "Force-flatten the REAL executed position (or, for a " + "strategy that declares a multi-asset group such as " + "pairs_trading's two legs, the group's COMBINED position) " + "once its gross (pre-cost) return since entry drops below " + "-this value -- independent of any indicator-based stop " + "the strategy itself may already have." + ), + ): + stop_loss_pct = ( + st.slider( + "Stop-loss (%)", + 1.0, + 50.0, + 10.0, + 1.0, + key=f"{key_prefix}_stop_loss_pct", + help="Gross-return loss, since entry, that force-flattens " + "the position.", + ) + / 100.0 + ) + with col_take: + if st.checkbox( + "Enable take-profit", + value=False, + key=f"{key_prefix}_enable_take_profit_pct", + help=( + "Force-flatten the REAL executed position (or, for a " + "strategy that declares a multi-asset group such as " + "pairs_trading's two legs, the group's COMBINED position) " + "once its gross (pre-cost) return since entry rises above " + "this value -- locks in a gain instead of waiting for the " + "strategy's own ordinary exit condition." + ), + ): + take_profit_pct = ( + st.slider( + "Take-profit (%)", + 1.0, + 50.0, + 10.0, + 1.0, + key=f"{key_prefix}_take_profit_pct", + help="Gross-return gain, since entry, that force-flattens " + "the position.", + ) + / 100.0 + ) + return stop_loss_pct, take_profit_pct + + def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: """Render strategy-specific parameter widgets and return their values. @@ -365,6 +392,23 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: "the top fraction above." ), ) + params["signal_scaling"] = st.selectbox( + "Signal scaling", + ["binary", "continuous"], + index=0, + help=( + "binary weights every selected asset identically. continuous " + "weights each selected asset by its RANK within its own " + "selected leg, divided by that leg's own selected count " + "(e.g. the weakest of 4 selected longs gets 0.25, the " + "strongest gets 1.0) — which assets are selected is " + "unchanged, only their relative size." + ), + ) + with st.expander("Advanced parameters"): + params["stop_loss_pct"], params["take_profit_pct"] = ( + _risk_control_pct_inputs("csm") + ) elif strategy_name == "time_series_momentum": lookback_period = st.slider( "Lookback (periods)", @@ -425,6 +469,10 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: "section below." ), ) + with st.expander("Advanced parameters"): + params["stop_loss_pct"], params["take_profit_pct"] = ( + _risk_control_pct_inputs("tsm") + ) elif strategy_name == "mean_reversion": params["lookback_period"] = st.slider( "Lookback (periods)", @@ -433,67 +481,129 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: 20, 1, help=( - "Trailing window used to compute the rolling mean and " - "standard deviation that the price z-score is measured " + "Trailing window used to compute the rolling mean/std (or " + "RSI/percentile window) the chosen indicator is measured " "against." ), ) - entry_zscore = st.slider( - "Entry z-score", - 1.0, - 3.0, - 2.0, - 0.1, - help=( - "Open a position once the price z-score moves beyond ± this " - "threshold, betting on reversion back toward the trailing mean." - ), - ) - params["entry_zscore"] = entry_zscore - params["exit_zscore"] = st.slider( - "Exit z-score", - 0.0, - min(1.5, round(entry_zscore - 0.1, 1)), - 0.5, - 0.1, + indicator = st.selectbox( + "Indicator", + list(MEAN_REVERSION_UI_INDICATORS), + key="mr_indicator", help=( - "Close the position once the z-score reverts back inside " - "± this threshold." + "Which zero-centered indicator drives entries/exits/stop: " + "a rolling z-score, RSI, or a trailing percentile rank. " + "Switching indicator resets the thresholds below to that " + "indicator's own defaults — a threshold tuned for one " + "indicator's scale is not meaningful on another's." ), ) - params["long_only"] = st.checkbox( - "Long only", + params["indicator"] = indicator + ( + entry_min, + entry_max, + entry_default, + exit_default, + stop_max, + stop_default, + step, + ) = mean_reversion_slider_bounds(indicator) + # Asked BEFORE the entry slider (not after) so entry's own bounds + # can already know whether the stop slider will even be rendered + # -- see entry_threshold_bounds's own docstring for why this + # matters: entry must never lose reachable values (e.g. RSI's own + # 50, percentile's own 0.49) to guard a slider that doesn't exist. + enable_stop = st.checkbox( + "Enable stop threshold", value=True, + key="mr_enable_stop", help=( - "Off: also open short positions when price rises above the " - "entry z-score, not only long positions on a drop below it — " - "separate from the portfolio-level 'Long only' below." + "Force the position flat when the indicator moves past this " + "threshold, e.g. because the trailing mean itself has shifted " + "and the entry threshold is no longer expected to revert." ), ) - if st.checkbox( - "Enable stop z-score", - value=True, - key="mr_enable_stop", + # Reserved right here (directly below the checkbox it belongs to) + # and filled in further down, once entry_threshold's own value is + # known -- the stop slider's lower bound is entry_threshold + step, + # so it cannot be COMPUTED before the entry slider renders, but a + # placeholder lets it still be DISPLAYED here. + stop_threshold_slot = st.empty() + entry_min, entry_max = entry_threshold_bounds( + entry_min, entry_max, stop_max, step, stop_enabled=enable_stop + ) + entry_default = min(max(entry_default, entry_min), entry_max) + entry_threshold = st.slider( + "Entry threshold", + entry_min, + entry_max, + entry_default, + step, + key=f"mr_entry_threshold_{indicator}", help=( - "Force the position flat when the z-score moves past this " - "threshold, e.g. because the trailing mean itself has shifted " - "and the entry z-score is no longer expected to revert." + "Open a position once the indicator moves beyond " + "± this threshold, betting on reversion back toward normal. " + "On this indicator's own scale — see Indicator's help above." ), - ): - params["stop_zscore"] = st.slider( - "Stop z-score", - entry_zscore + 0.1, - 6.0, - max(4.0, entry_zscore + 0.5), - 0.1, + ) + params["entry_threshold"] = entry_threshold + exit_bounds = exit_threshold_bounds(entry_threshold, step) + if exit_bounds is None: + st.caption( + "Exit threshold: 0.0 (the only value possible this close to " + "zero — entry threshold minus one step is not positive)." + ) + params["exit_threshold"] = 0.0 + else: + exit_min, exit_max = exit_bounds + params["exit_threshold"] = st.slider( + "Exit threshold", + exit_min, + exit_max, + min(exit_default, exit_max), + step, + key=f"mr_exit_threshold_{indicator}", help=( - "|z-score| beyond which the position is forced flat " - "instead of waiting for reversion — protects against a " - "move that keeps extending instead of reverting." + "Close the position once the indicator reverts back " + "inside ± this threshold." ), ) + params["long_only"] = st.checkbox( + "Long only", + value=True, + help=( + "Off: also open short positions when the indicator rises " + "above the entry threshold, not only long positions on a " + "drop below it — separate from the portfolio-level 'Long " + "only' below." + ), + ) + if enable_stop: + with stop_threshold_slot: + params["stop_threshold"] = st.slider( + "Stop threshold", + entry_threshold + step, + stop_max, + max(stop_default, entry_threshold + step), + step, + key=f"mr_stop_threshold_{indicator}", + help=( + "Indicator magnitude beyond which the position is " + "forced flat instead of waiting for reversion — " + "protects against a move that keeps extending instead " + "of reverting." + ), + ) else: - params["stop_zscore"] = None + params["stop_threshold"] = None + with st.expander("Advanced parameters"): + st.caption( + "Independent of the indicator-based stop_threshold above — " + "either or both can be active." + ) + params["stop_loss_pct"], params["take_profit_pct"] = ( + _risk_control_pct_inputs("mr") + ) elif strategy_name == "trend_following": fast_window = st.slider( "Fast window", @@ -528,8 +638,16 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: "separate from the portfolio-level 'Long only' below." ), ) + with st.expander("Advanced parameters"): + params["stop_loss_pct"], params["take_profit_pct"] = ( + _risk_control_pct_inputs("tf") + ) elif strategy_name == "pairs_trading": - st.caption("Pairs trading needs exactly two symbols (symbol_a, symbol_b).") + st.caption( + "Pairs trading needs at least two symbols loaded above; exactly " + "two of them (symbol_a, symbol_b) are then chosen below as the " + "pair's legs." + ) if len(symbols) < 2: st.error("Enter at least two symbols above to configure a pairs trade.") else: @@ -559,66 +677,121 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: "to run the ADF cointegration test that gates entries." ), ) - params["zscore_window"] = st.slider( - "Z-score window (periods)", + pt_indicator = st.selectbox( + "Indicator", + list(MEAN_REVERSION_UI_INDICATORS), + key="pt_indicator", + help=( + "Which zero-centered indicator of the spread drives " + "entries/exits/stop -- same three choices as Mean " + "Reversion's own Indicator, applied to the spread " + "residual instead of a raw price." + ), + ) + params["indicator"] = pt_indicator + ( + pt_entry_min, + pt_entry_max, + pt_entry_default, + pt_exit_default, + pt_stop_max, + pt_stop_default, + pt_step, + ) = mean_reversion_slider_bounds(pt_indicator) + # Asked BEFORE the entry slider (not after, and moved out of + # the "Advanced pairs parameters" expander below to make that + # possible) so entry's own bounds can already know whether the + # stop slider will even be rendered -- see entry_threshold_ + # bounds's own docstring and the mean_reversion block above. + pt_enable_stop = st.checkbox( + "Enable stop threshold", + value=True, + key="pt_enable_stop", + help=( + "Force the pair flat when the indicator moves past " + "this threshold, e.g. because the hedge relationship " + "itself has broken down." + ), + ) + # Reserved right here (directly below the checkbox it belongs + # to) and filled in further down, once pt_entry's own value is + # known -- the stop slider's lower bound is pt_entry + pt_step, + # so it cannot be COMPUTED before the entry slider renders, but + # a placeholder lets it still be DISPLAYED here, outside the + # "Advanced pairs parameters" expander below. + pt_stop_threshold_slot = st.empty() + pt_entry_min, pt_entry_max = entry_threshold_bounds( + pt_entry_min, + pt_entry_max, + pt_stop_max, + pt_step, + stop_enabled=pt_enable_stop, + ) + pt_entry_default = min(max(pt_entry_default, pt_entry_min), pt_entry_max) + params["indicator_window"] = st.slider( + "Indicator window (periods)", 10, 150, 63, 1, help=( - "Trailing window used to compute the spread's rolling " - "mean and standard deviation that the entry/exit/stop " - "z-scores are measured against." + "Trailing window used to compute the spread's centered " + "indicator, and the cadence of the ADF gate re-check." ), ) - entry_zscore = st.slider( - "Entry z-score", - 1.0, - 4.0, - 2.0, - 0.1, - help=( - "Open the pair once the spread z-score moves beyond ± " - "this threshold, betting the spread reverts toward its " - "trailing mean." - ), - ) - params["entry_zscore"] = entry_zscore - params["exit_zscore"] = st.slider( - "Exit z-score", - 0.0, - round(entry_zscore - 0.1, 1), - 0.5, - 0.1, + pt_entry = st.slider( + "Entry threshold", + pt_entry_min, + pt_entry_max, + pt_entry_default, + pt_step, + key=f"pt_entry_threshold_{pt_indicator}", help=( - "Close the pair once the spread z-score reverts back " - "inside ± this threshold." + "Open the pair once the spread's indicator moves beyond " + "± this threshold, betting the spread reverts toward " + "normal." ), ) - with st.expander("Advanced pairs parameters"): - if st.checkbox( - "Enable stop z-score", - value=True, - key="pt_enable_stop", - help=( - "Force the pair flat when the spread z-score moves " - "past this threshold, e.g. because the hedge " - "relationship itself has broken down." - ), - ): - params["stop_zscore"] = st.slider( - "Stop z-score", - entry_zscore + 0.1, - 6.0, - max(4.0, entry_zscore + 0.5), - 0.1, + params["entry_threshold"] = pt_entry + if pt_enable_stop: + with pt_stop_threshold_slot: + params["stop_threshold"] = st.slider( + "Stop threshold", + pt_entry + pt_step, + pt_stop_max, + max(pt_stop_default, pt_entry + pt_step), + pt_step, + key=f"pt_stop_threshold_{pt_indicator}", help=( - "|z-score| beyond which the pair is forced flat " - "instead of waiting for reversion." + "Indicator magnitude beyond which the pair is " + "forced flat instead of waiting for reversion." ), ) - else: - params["stop_zscore"] = None + else: + params["stop_threshold"] = None + pt_exit_bounds = exit_threshold_bounds(pt_entry, pt_step) + if pt_exit_bounds is None: + st.caption( + "Exit threshold: 0.0 (the only value possible this close " + "to zero — entry threshold minus one step is not " + "positive)." + ) + params["exit_threshold"] = 0.0 + else: + pt_exit_min, pt_exit_max = pt_exit_bounds + params["exit_threshold"] = st.slider( + "Exit threshold", + pt_exit_min, + pt_exit_max, + min(pt_exit_default, pt_exit_max), + pt_step, + key=f"pt_exit_threshold_{pt_indicator}", + help=( + "Close the pair once the indicator reverts back " + "inside ± this threshold." + ), + ) + with st.expander("Advanced pairs parameters"): params["dynamic_hedge_ratio"] = st.checkbox( "Dynamic hedge ratio", value=True, @@ -627,19 +800,30 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: "instead of fixing it once at the formation window." ), ) - params["adf_pvalue_threshold"] = st.slider( - "ADF p-value threshold (entry gate)", - 0.01, - 0.50, - 0.10, - 0.01, + if st.checkbox( + "Enable ADF stationarity gate", + value=True, + key="pt_enable_adf_gate", help=( "New entries require an Augmented Dickey-Fuller test " - "on the trailing spread to reach this p-value or " - "below — lower is a stricter mean-reversion filter " - "and rejects more entries. Open positions are exempt: " - "they still exit only on the z-score exit/stop rules." + "on the trailing spread to produce a p-value at or " + "below the selected threshold -- lower is a " + "stricter mean-reversion filter and rejects more " + "entries. Open positions are exempt: they still " + "exit only on the entry/exit/stop threshold rules." ), + ): + params["adf_pvalue_threshold"] = st.slider( + "ADF p-value threshold (entry gate)", + 0.01, + 0.50, + 0.10, + 0.01, + ) + else: + params["adf_pvalue_threshold"] = None + params["stop_loss_pct"], params["take_profit_pct"] = ( + _risk_control_pct_inputs("pt") ) return params @@ -672,7 +856,7 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: mode = st.segmented_control( "Mode", - ["Backtest", "Walk-forward"], + ["Backtest", "Walk-forward", "Strategies"], default="Backtest", key="dashboard_mode", help=( @@ -680,7 +864,9 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: "chronological holdout. Walk-forward: repeatedly select parameters " "on a validation block and evaluate them out-of-sample on the " "following test block, stitched across the whole history — this is " - "QuantLab's grid-search mechanism." + "QuantLab's grid-search mechanism. Strategies: research each " + "strategy's own economics, mathematics and parameters, with an " + "interactive lab -- no experiment configuration needed." ), ) if mode is None: @@ -689,758 +875,793 @@ def _strategy_param_inputs(strategy_name: str, symbols: list[str]) -> dict: # --------------------------------------------------------------------------- # # Sidebar configuration # --------------------------------------------------------------------------- # -with st.sidebar: - st.header("Experiment configuration") - st.subheader("Instruments") - st.caption( - "Pick symbols from any of the three sources below — they all " - "combine into one multi-market universe." - ) - with st.expander("Yahoo Finance", expanded=False): - yahoo_symbols = _yahoo_symbols_picker() - with st.expander("Binance", expanded=False): - binance_symbols = _binance_symbols_picker() - with st.expander("CSV (local files)", expanded=True): - csv_symbols = _csv_symbols_picker() - - symbols, provenance, conflicts = _combine_instrument_picks( - yahoo_symbols, binance_symbols, csv_symbols - ) - if conflicts: - st.error( - "Picked from more than one source, so source/calendar would be " - "ambiguous — remove the duplicate from one picker: " - + ", ".join(sorted(conflicts)) - ) - if not symbols: - st.warning("Pick at least one symbol above to configure an instrument.") - - instrument_rows = _instrument_table(symbols, provenance) - use_bundled_demo_data = False - if any(row["Source"] == "csv" for row in instrument_rows): - use_bundled_demo_data = st.toggle( - "Allow bundled synthetic demo data", - value=False, - help=( - "If every requested CSV is absent from data/raw, use the " - "bundled synthetic SPY/QQQ/TLT/GLD files instead. QuantLab " - "never mixes local and bundled files, and unsupported " - "symbols still fail explicitly." - ), - ) - - instrument_calendars = {row["Calendar"] for row in instrument_rows} - periods_per_year: int | None = None - if len(instrument_calendars) > 1: - st.warning( - "Instruments span more than one calendar " - f"({', '.join(sorted(instrument_calendars))}), so QuantLab " - "cannot infer a single annualisation factor automatically — " - "set one explicitly." - ) - # Default to the 24/7 convention as soon as any instrument actually - # trades continuously -- a mixed portfolio that includes one is - # closer to a "always-open" annualisation than a pure business-day - # one, and 252 silently understates volatility/Sharpe for it. - default_periods_per_year = 365 if "24/7" in instrument_calendars else 252 - periods_per_year = int( - st.number_input( - "Periods per year (annualisation factor)", - min_value=1, - value=default_periods_per_year, - step=1, - help=( - "Used to annualise Sharpe, volatility and vol-targeting " - "across the whole portfolio. 252 for a business-day " - "equity convention, 365 for a continuous 24/7 one." - ), - ) - ) - - # Sidebar date fields need the full width to keep labels and values readable. - start_date = st.date_input( - "Start date", - value=date(2019, 1, 1), - help=( - "Requested start of the sample. The actually observed range " - "after data-quality filtering is reported once the backtest runs." - ), - ) - end_date = st.date_input( - "End date", - value=default_end_date(), - help="Requested end of the sample, same caveat as Start date above.", - ) - - # Offer only frequencies compatible with every selected instrument's - # source — the same intersection ExperimentConfig itself validates, so - # the picker can never offer something the config would then reject. - instrument_sources = {DataSourceName(row["Source"]) for row in instrument_rows} - frequency_options = sorted(compatible_frequencies_for_sources(instrument_sources)) - # '1h' has no verified-closure handling for a mixed-calendar universe - # (that machinery only operates at daily frequency) -- offering it would - # let the config validator reject the run only after "Run backtest" is - # clicked, so it's excluded here too, matching what ExperimentConfig - # itself would refuse. - intraday_blocked_by_mixed_calendar = ( - len(instrument_calendars) > 1 and "1h" in frequency_options - ) - if intraday_blocked_by_mixed_calendar: - frequency_options = [f for f in frequency_options if f != "1h"] - frequency = st.selectbox( - "Frequency", - frequency_options, - index=0 if frequency_options else None, - help=( - "Bar size requested from the data source; also sets the " - "annualisation factor used by every risk metric." - ), - ) - if not frequency_options: - st.error( - "No frequency is compatible with every selected source — remove " - "one of the conflicting sources above." - ) - elif intraday_blocked_by_mixed_calendar: +run = False +if mode != "Strategies": + with st.sidebar: + st.header("Experiment configuration") + st.subheader("Instruments") st.caption( - "'1h' is unavailable for a mixed-calendar universe — verified " - "closures only work at daily frequency." + "Pick symbols from any of the three sources below — they all " + "combine into one multi-market universe." ) - if any(row["Source"] == "csv" for row in instrument_rows): - st.caption( - "For CSV data, frequency controls annualisation and data-quality " - "checks but does not resample the file. A mismatch with the " - "observed timestamps is reported after the run." - ) - with st.expander("Advanced data settings"): - missing_value_policy = st.selectbox( - "Missing value policy", - ["drop", "forward_fill", "raise", "none"], - index=0, - help=( - "How the cleaner treats missing canonical market data bars: " - "drop removes affected rows; forward_fill fills up to the " - "limit below; raise fails the run on any gap; none leaves " - "gaps as-is." - ), + with st.expander("Yahoo Finance", expanded=False): + yahoo_symbols = _yahoo_symbols_picker() + with st.expander("Binance", expanded=False): + binance_symbols = _binance_symbols_picker() + with st.expander("CSV (local files)", expanded=True): + csv_symbols = _csv_symbols_picker() + + symbols, provenance, conflicts = _combine_instrument_picks( + yahoo_symbols, binance_symbols, csv_symbols ) - if missing_value_policy == "forward_fill": - forward_fill_limit = st.number_input( - "Forward-fill limit (consecutive bars)", - min_value=1, - value=1, - step=1, + if conflicts: + st.error( + "Picked from more than one source, so source/calendar would be " + "ambiguous — remove the duplicate from one picker: " + + ", ".join(sorted(conflicts)) + ) + if not symbols: + st.warning("Pick at least one symbol above to configure an instrument.") + + instrument_rows = _instrument_table(symbols, provenance) + use_bundled_demo_data = False + if any(row["Source"] == "csv" for row in instrument_rows): + use_bundled_demo_data = st.toggle( + "Allow bundled synthetic demo data", + value=False, help=( - "Maximum consecutive missing bars filled per symbol " - "before the gap is left as-is." + "If every requested CSV is absent from data/raw, use the " + "bundled synthetic SPY/QQQ/TLT/GLD files instead. QuantLab " + "never mixes local and bundled files, and unsupported " + "symbols still fail explicitly." ), ) - else: - forward_fill_limit = 1 - strategy_options = available_strategies() - if mode == "Walk-forward": - # buy_and_hold has no parameters (BuyAndHoldStrategy._freeze_parameters()), - # so there is nothing for fold-by-fold validation-block selection to - # select — walk-forward would just repeat the same signal on every - # fold for no benefit over a single backtest. - strategy_options = [name for name in strategy_options if name != "buy_and_hold"] - strategy_name = st.selectbox( - "Strategy", - strategy_options, - index=0, - help=( - "Signal-generation method: time_series_momentum / " - "cross_sectional_momentum (trend continuation); trend_following " - "(moving-average trend); mean_reversion / pairs_trading " - "(reversion to a trailing mean or spread)." - + ( - "" - if mode == "Walk-forward" - else " buy_and_hold (no signal, baseline exposure) is also " - "available here, but has no parameters to select, so it is " - "hidden in Walk-forward mode." + instrument_calendars = {row["Calendar"] for row in instrument_rows} + periods_per_year: int | None = None + if len(instrument_calendars) > 1: + st.warning( + "Instruments span more than one calendar " + f"({', '.join(sorted(instrument_calendars))}), so QuantLab " + "cannot infer a single annualisation factor automatically — " + "set one explicitly." + ) + # Default to the 24/7 convention as soon as any instrument actually + # trades continuously -- a mixed portfolio that includes one is + # closer to a "always-open" annualisation than a pure business-day + # one, and 252 silently understates volatility/Sharpe for it. + default_periods_per_year = 365 if "24/7" in instrument_calendars else 252 + periods_per_year = int( + st.number_input( + "Periods per year (annualisation factor)", + min_value=1, + value=default_periods_per_year, + step=1, + help=( + "Used to annualise Sharpe, volatility and vol-targeting " + "across the whole portfolio. 252 for a business-day " + "equity convention, 365 for a continuous 24/7 one." + ), + ) ) - + " Its own parameters appear below." - ), - ) - if strategy_name != "buy_and_hold": - st.subheader("Strategy parameters") - strategy_parameters = _strategy_param_inputs(strategy_name, symbols) - - st.subheader("Portfolio") - signal_scaling = strategy_parameters.get("signal_scaling") - # Mirror ExperimentConfig's validators so an invalid combination can never - # be selected in the first place, instead of failing only after "Run - # backtest": equal_weight discards signal magnitude (breaks non-binary - # time-series scaling), and volatility_adjusted already divides by - # volatility itself (an inverse-volatility allocator would apply that - # sizing a second time). - allocator_note: str | None - if strategy_name == "pairs_trading": - allocator_options = ["signal_proportional"] - allocator_note = ( - "Only signal_proportional is offered: pairs trading needs its " - "signed hedge magnitude preserved exactly, not re-sized or " - "reduced to a sign, to keep the pair's two legs offsetting." - ) - elif ( - strategy_name == "time_series_momentum" - and signal_scaling == "volatility_adjusted" - ): - allocator_options = ["signal_proportional"] - allocator_note = ( - "Only signal_proportional is offered: volatility_adjusted " - "scaling above already divides the signal by trailing " - "volatility, so inverse_volatility or volatility_targeting " - "would apply that sizing a second time." - ) - elif strategy_name == "time_series_momentum" and signal_scaling == "continuous": - allocator_options = [ - "signal_proportional", - "inverse_volatility", - "volatility_targeting", - ] - allocator_note = ( - "equal_weight is not offered: it discards signal magnitude and " - "keeps only its sign, which would throw away the continuous " - "scaling selected above." - ) - else: - allocator_options = [ - "equal_weight", - "signal_proportional", - "inverse_volatility", - "volatility_targeting", - ] - allocator_note = None - preferred_default = ( - "inverse_volatility" - if "inverse_volatility" in allocator_options - else allocator_options[0] - ) - allocator = st.selectbox( - "Allocator", - allocator_options, - index=allocator_options.index(preferred_default), - help=( - "equal_weight: same absolute weight on every active signal. " - "signal_proportional: weight scales with signed signal magnitude. " - "inverse_volatility: weight scales inversely with each asset's " - "trailing volatility. volatility_targeting: inverse-volatility " - "weights further scaled so total exposure tracks the target " - "volatility below. Only allocators compatible with the selected " - "strategy (and its signal scaling, for time_series_momentum) are " - "listed." - ), - ) - if allocator_note is not None: - st.caption(allocator_note) - if strategy_name == "pairs_trading": - maximum_weight = None - long_only = False - st.caption( - "Per-asset weight caps and long-only are disabled to preserve the " - "pair hedge (a pairs trade always holds one long and one short leg)." - ) - else: - maximum_weight = st.slider( - "Max weight per asset", - 0.05, - 1.0, - 0.30, - 0.05, + # Sidebar date fields need the full width to keep labels and values readable. + start_date = st.date_input( + "Start date", + value=date(2019, 1, 1), help=( - "Hard cap on any single asset's absolute target weight, " - "enforced as a portfolio constraint regardless of the " - "allocator chosen above." + "Requested start of the sample. The actually observed range " + "after data-quality filtering is reported once the backtest runs." ), ) - long_only = st.checkbox( - "Long only (portfolio)", - value=False, + end_date = st.date_input( + "End date", + value=default_end_date(), + help="Requested end of the sample, same caveat as Start date above.", + ) + + # Offer only frequencies compatible with every selected instrument's + # source — the same intersection ExperimentConfig itself validates, so + # the picker can never offer something the config would then reject. + instrument_sources = {DataSourceName(row["Source"]) for row in instrument_rows} + frequency_options = sorted( + compatible_frequencies_for_sources(instrument_sources) + ) + # '1h' has no verified-closure handling for a mixed-calendar universe + # (that machinery only operates at daily frequency) -- offering it would + # let the config validator reject the run only after "Run backtest" is + # clicked, so it's excluded here too, matching what ExperimentConfig + # itself would refuse. + intraday_blocked_by_mixed_calendar = ( + len(instrument_calendars) > 1 and "1h" in frequency_options + ) + if intraday_blocked_by_mixed_calendar: + frequency_options = [f for f in frequency_options if f != "1h"] + frequency = st.selectbox( + "Frequency", + frequency_options, + index=0 if frequency_options else None, help=( - "Reject any negative target weight at the portfolio level, on " - "top of whatever the strategy's own signals already allow." + "Bar size requested from the data source; also sets the " + "annualisation factor used by every risk metric." ), ) - rebalance_frequency = st.selectbox( - "Rebalance frequency", - ["daily", "weekly", "monthly", "quarterly"], - index=2, - help=( - "How often target weights are recomputed and traded toward. " - "Between rebalances, QuantLab carries the previous target weights " - "forward unchanged; it does not model price-driven weight drift." - ), - ) - if allocator == "volatility_targeting": - enable_volatility_targeting = True - st.caption("Volatility targeting is inherent to this allocator.") - else: - enable_volatility_targeting = st.toggle( - "Enable volatility targeting", - value=True, - help=( - "Scale the portfolio's overall exposure toward the annual " - "volatility target. Disable this to keep the allocator's " - "unscaled weights." - ), - ) - if allocator in {"inverse_volatility", "volatility_targeting"} or ( - enable_volatility_targeting - ): - volatility_window = st.slider( - "Volatility window (periods)", - 10, - 252, - 63, - 1, + if not frequency_options: + st.error( + "No frequency is compatible with every selected source — remove " + "one of the conflicting sources above." + ) + elif intraday_blocked_by_mixed_calendar: + st.caption( + "'1h' is unavailable for a mixed-calendar universe — verified " + "closures only work at daily frequency." + ) + if any(row["Source"] == "csv" for row in instrument_rows): + st.caption( + "For CSV data, frequency controls annualisation and data-quality " + "checks but does not resample the file. A mismatch with the " + "observed timestamps is reported after the run." + ) + with st.expander("Advanced data settings"): + missing_value_policy = st.selectbox( + "Missing value policy", + ["drop", "forward_fill", "raise", "none"], + index=0, + help=( + "How the cleaner treats missing canonical market data bars: " + "drop removes affected rows; forward_fill fills up to the " + "limit below; raise fails the run on any gap; none leaves " + "gaps as-is." + ), + ) + if missing_value_policy == "forward_fill": + forward_fill_limit = st.number_input( + "Forward-fill limit (consecutive bars)", + min_value=1, + value=1, + step=1, + help=( + "Maximum consecutive missing bars filled per symbol " + "before the gap is left as-is." + ), + ) + else: + forward_fill_limit = 1 + + strategy_options = available_strategies() + if mode == "Walk-forward": + # buy_and_hold has no parameters (BuyAndHoldStrategy._freeze_parameters()), + # so there is nothing for fold-by-fold validation-block selection to + # select — walk-forward would just repeat the same signal on every + # fold for no benefit over a single backtest. + strategy_options = [ + name for name in strategy_options if name != "buy_and_hold" + ] + strategy_name = st.selectbox( + "Strategy", + strategy_options, + index=0, help=( - "Trailing window used to estimate realised volatility, for " - "both inverse-volatility sizing and volatility targeting." + "Signal-generation method: time_series_momentum / " + "cross_sectional_momentum (trend continuation); trend_following " + "(moving-average trend); mean_reversion / pairs_trading " + "(reversion to a trailing mean or spread)." + + ( + "" + if mode == "Walk-forward" + else " buy_and_hold (no signal, baseline exposure) is also " + "available here, but has no parameters to select, so it is " + "hidden in Walk-forward mode." + ) + + " Its own parameters appear below." ), ) - else: - volatility_window = 63 - if enable_volatility_targeting: - target_volatility: float | None = st.slider( - "Target volatility (annual)", - 0.05, - 0.40, - 0.12, - 0.01, + + if strategy_name != "buy_and_hold": + st.subheader("Strategy parameters") + strategy_parameters = _strategy_param_inputs(strategy_name, symbols) + + st.subheader("Portfolio") + signal_scaling = strategy_parameters.get("signal_scaling") + # Mirror ExperimentConfig's validators so an invalid combination can never + # be selected in the first place, instead of failing only after "Run + # backtest": equal_weight discards signal magnitude (breaks non-binary + # time-series scaling), and volatility_adjusted already divides by + # volatility itself (an inverse-volatility allocator would apply that + # sizing a second time). + allocator_note: str | None + if strategy_name == "pairs_trading": + allocator_options = ["signal_proportional"] + allocator_note = ( + "Only signal_proportional is offered: pairs trading needs its " + "signed hedge magnitude preserved exactly, not re-sized or " + "reduced to a sign, to keep the pair's two legs offsetting." + ) + elif ( + strategy_name == "time_series_momentum" + and signal_scaling == "volatility_adjusted" + ): + allocator_options = ["signal_proportional"] + allocator_note = ( + "Only signal_proportional is offered: volatility_adjusted " + "scaling above already divides the signal by trailing " + "volatility, so inverse_volatility or volatility_targeting " + "would apply that sizing a second time." + ) + elif ( + strategy_name in ("time_series_momentum", "cross_sectional_momentum") + and signal_scaling == "continuous" + ): + allocator_options = [ + "signal_proportional", + "inverse_volatility", + "volatility_targeting", + ] + allocator_note = ( + "equal_weight is not offered: it discards signal magnitude and " + "keeps only its sign, which would throw away the continuous " + "scaling selected above." + ) + else: + allocator_options = [ + "equal_weight", + "signal_proportional", + "inverse_volatility", + "volatility_targeting", + ] + allocator_note = None + preferred_default = ( + "inverse_volatility" + if "inverse_volatility" in allocator_options + else allocator_options[0] + ) + allocator = st.selectbox( + "Allocator", + allocator_options, + index=allocator_options.index(preferred_default), help=( - "Desired annualised portfolio volatility. Exposure is " - "scaled, up to 'Max leverage' below, toward this target " - "using the volatility window above." + "equal_weight: same absolute weight on every active signal. " + "signal_proportional: weight scales with signed signal magnitude. " + "inverse_volatility: weight scales inversely with each asset's " + "trailing volatility. volatility_targeting: inverse-volatility " + "weights further scaled so total exposure tracks the target " + "volatility below. Only allocators compatible with the selected " + "strategy (and its signal scaling, for time_series_momentum) are " + "listed." ), ) - maximum_leverage = st.slider( - "Max leverage", - 1.0, - 3.0, - 1.5, - 0.1, + if allocator_note is not None: + st.caption(allocator_note) + if strategy_name == "pairs_trading": + maximum_weight = None + long_only = False + st.caption( + "Per-asset weight caps and long-only are disabled to preserve the " + "pair hedge (a pairs trade always holds one long and one short leg)." + ) + else: + maximum_weight = st.slider( + "Max weight per asset", + 0.05, + 1.0, + 0.30, + 0.05, + help=( + "Hard cap on any single asset's absolute target weight, " + "enforced as a portfolio constraint regardless of the " + "allocator chosen above." + ), + ) + long_only = st.checkbox( + "Long only (portfolio)", + value=False, + help=( + "Reject any negative target weight at the portfolio level, on " + "top of whatever the strategy's own signals already allow." + ), + ) + rebalance_frequency = st.selectbox( + "Rebalance frequency", + ["daily", "weekly", "monthly", "quarterly"], + index=2, help=( - "Ceiling on the volatility-targeting scale-up, e.g. 1.5 " - "allows up to 150% gross exposure even if hitting the " - "target volatility would ask for more." + "How often target weights are recomputed and traded toward. " + "Between rebalances, each asset's own price move drifts its " + "executed weight (model_weight_drift, on by default); a " + "scheduled rebalance still always trades toward its " + "freshly-decided target, even if that target happens to " + "numerically match the previous one." ), ) - else: - target_volatility = None - maximum_leverage = 1.0 - - if strategy_name == "pairs_trading": - target_minimum_weight = None - maximum_gross_exposure = None - maximum_net_exposure = None - target_maximum_positions = None - maximum_turnover = None - st.caption( - "Advanced portfolio constraints are disabled for pairs_trading: " - "a minimum position size, position count cap, or exposure cap " - "could drop one leg and break the pair hedge." - ) - else: - # Set by the non-pairs_trading branch above whenever this branch runs. - assert maximum_weight is not None - with st.expander("Advanced portfolio constraints"): - if st.checkbox( - "Enable minimum position size", - value=False, + if allocator == "volatility_targeting": + enable_volatility_targeting = True + st.caption("Volatility targeting is inherent to this allocator.") + else: + enable_volatility_targeting = st.toggle( + "Enable volatility targeting", + value=True, help=( - "Reject any target weight smaller than this instead of " - "holding a near-zero position." + "Scale the portfolio's overall exposure toward the annual " + "volatility target. Disable this to keep the allocator's " + "unscaled weights." ), - ): - target_minimum_weight = st.slider( - "Minimum position size", - 0.0, - maximum_weight, - min(0.02, maximum_weight), - 0.01, - help="Smallest allowed non-zero target weight per asset.", - ) - else: - target_minimum_weight = None - if st.checkbox( - "Cap gross exposure", - value=False, + ) + if allocator in {"inverse_volatility", "volatility_targeting"} or ( + enable_volatility_targeting + ): + volatility_window = st.slider( + "Volatility window (periods)", + 10, + 252, + 63, + 1, help=( - "Limit total absolute exposure (sum of |weight|) across all assets." + "Trailing window used to estimate realised volatility, for " + "both inverse-volatility sizing and volatility targeting." ), - ): - maximum_gross_exposure = st.slider( - "Max gross exposure", - 0.1, - 3.0, - 1.0, - 0.1, - help="Ceiling on gross exposure, enforced on top of Max leverage.", - ) - else: - maximum_gross_exposure = None - if st.checkbox( - "Cap net exposure", - value=False, - help="Limit net directional exposure (sum of signed weights).", - ): - maximum_net_exposure = st.slider( - "Max net exposure", - 0.0, - 3.0, - 1.0, - 0.1, - help="Ceiling on |long weight - short weight| across all assets.", - ) - else: - maximum_net_exposure = None - if st.checkbox( - "Cap number of positions", - value=False, + ) + else: + volatility_window = 63 + if enable_volatility_targeting: + target_volatility: float | None = st.slider( + "Target volatility (annual)", + 0.05, + 0.40, + 0.12, + 0.01, help=( - "Limit how many assets can be held with a non-zero " - "target weight at once." + "Desired annualised portfolio volatility. Exposure is " + "scaled, up to 'Max leverage' below, toward this target " + "using the volatility window above." ), - ): - target_maximum_positions = st.number_input( - "Max number of positions", - min_value=1, - value=min(10, max(1, len(symbols))), - step=1, - help="Largest number of simultaneously non-zero target weights.", - ) - else: - target_maximum_positions = None - if st.checkbox( - "Cap turnover per rebalance", - value=False, + ) + maximum_leverage = st.slider( + "Max leverage", + 1.0, + 3.0, + 1.5, + 0.1, help=( - "Limit how much total weight can change at each " - "rebalance, spreading large shifts over several periods." + "Ceiling on the volatility-targeting scale-up, e.g. 1.5 " + "allows up to 150% gross exposure even if hitting the " + "target volatility would ask for more." ), - ): - maximum_turnover = st.slider( - "Max turnover per rebalance", - 0.05, - 2.0, - 0.5, - 0.05, - help="Maximum L1 weight change allowed at each rebalance.", - ) - else: - maximum_turnover = None - - st.subheader("Validation") - validation_ratio: float | None = None - test_ratio: float | None = None - train_window = 500 - validation_window = 126 - test_window = 126 - expanding = True - optimization_metric = "sharpe" - parameter_grid: dict[str, list] = {} - if mode == "Walk-forward": - st.caption( - "Select parameters on each fold's validation block and evaluate " - "them out-of-sample on the following test block, repeated and " - "stitched across the whole history. This mode's own Results " - "tab shows that stitched out-of-sample evidence, not a " - "full-sample fit." - ) - train_window = st.number_input( - "Train window (periods)", - min_value=10, - value=500, - step=10, - help="Training periods per fold, used to fit the strategy state.", - ) - validation_window = st.number_input( - "Validation window (periods)", - min_value=5, - value=126, - step=5, - help="Validation periods per fold, used to select parameters.", - ) - test_window = st.number_input( - "Test window (periods)", - min_value=5, - value=126, - step=5, - help="Out-of-sample test periods per fold, stitched into the OOS series.", - ) - expanding = st.toggle( - "Expanding training window", - value=True, - help=( - "On: each fold's training block grows to include everything " - "before it. Off: training slides forward, always Train " - "window periods long." - ), - ) - optimization_metric = st.selectbox( - "Optimization metric", - ["sharpe", "sortino", "calmar", "total_return"], - index=0, - help="Metric used to pick the best parameter combination on each fold.", - ) - st.caption( - "Parameters to search below — leave empty to use a compact, " - "strategy-specific default grid at run time." - ) - grid_param_names = sorted(strategy_parameter_names(strategy_name)) - selected_grid_params = st.multiselect( - "Grid parameters", - grid_param_names, - help="Strategy parameters to vary across candidate values.", - ) - for parameter_name in selected_grid_params: - raw_values = st.text_input( - f"Candidate values for {parameter_name} (comma-separated)", - key=f"wf_grid_{parameter_name}", ) - parameter_grid[parameter_name] = parse_parameter_grid_values(raw_values) - # A rough estimate only (used to warn about a slow configuration - # before data is loaded) -- treat the universe as 24/7 only when - # every instrument genuinely is, otherwise fall back to the - # business-day convention. - is_247_market = bool(instrument_rows) and all( - row["Calendar"] == "24/7" for row in instrument_rows - ) - estimated_backtests = estimate_walk_forward_backtest_count( - start_date=start_date, - end_date=end_date or default_end_date(), - is_247_market=is_247_market, - train_window=train_window, - validation_window=validation_window, - test_window=test_window, - expanding=expanding, - parameter_grid=parameter_grid, - ) - if estimated_backtests <= 0: - st.warning( - "No walk-forward fold fits the requested date range and " - "windows — widen the date range or shorten the windows.", - icon="⚠️", + else: + target_volatility = None + maximum_leverage = 1.0 + + if strategy_name == "pairs_trading": + target_minimum_weight = None + maximum_gross_exposure = None + maximum_net_exposure = None + target_maximum_positions = None + maximum_turnover = None + st.caption( + "Advanced portfolio constraints are disabled for pairs_trading: " + "a minimum position size, position count cap, or exposure cap " + "could drop one leg and break the pair hedge." ) - else: - enable_holdout = st.checkbox( - "Chronological holdout (train / validation / test)", - value=False, - help=( - "Split one continuous backtest chronologically and report " - "each block separately. No fitting or parameter tuning " - "happens here. Treat the trailing test block as " - "out-of-sample only if you fixed the strategy and " - "parameters before inspecting it; the headline metric " - "cards still describe the full sample." - ), - ) - if enable_holdout: - validation_ratio = st.slider( - "Validation fraction", - 0.05, - 0.4, - 0.2, - 0.05, + else: + # Set by the non-pairs_trading branch above whenever this branch runs. + assert maximum_weight is not None + with st.expander("Advanced portfolio constraints"): + if st.checkbox( + "Enable minimum position size", + value=False, + help=( + "Reject any target weight smaller than this instead of " + "holding a near-zero position." + ), + ): + target_minimum_weight = st.slider( + "Minimum position size", + 0.0, + maximum_weight, + min(0.02, maximum_weight), + 0.01, + help="Smallest allowed non-zero target weight per asset.", + ) + else: + target_minimum_weight = None + if st.checkbox( + "Cap gross exposure", + value=False, + help=( + "Limit total absolute exposure (sum of |weight|) " + "across all assets." + ), + ): + maximum_gross_exposure = st.slider( + "Max gross exposure", + 0.1, + 3.0, + 1.0, + 0.1, + help=( + "Ceiling on gross exposure, enforced on top of " + "Max leverage." + ), + ) + else: + maximum_gross_exposure = None + if st.checkbox( + "Cap net exposure", + value=False, + help="Limit net directional exposure (sum of signed weights).", + ): + maximum_net_exposure = st.slider( + "Max net exposure", + 0.0, + 3.0, + 1.0, + 0.1, + help=( + "Ceiling on |long weight - short weight| across all assets." + ), + ) + else: + maximum_net_exposure = None + if st.checkbox( + "Cap number of positions", + value=False, + help=( + "Limit how many assets can be held with a non-zero " + "target weight at once." + ), + ): + target_maximum_positions = st.number_input( + "Max number of positions", + min_value=1, + value=min(10, max(1, len(symbols))), + step=1, + help=( + "Largest number of simultaneously non-zero target weights." + ), + ) + else: + target_maximum_positions = None + if st.checkbox( + "Cap turnover per period", + value=False, + help=( + "Limit how much total weight can change on any single " + "row, spreading a large rebalance over several periods." + ), + ): + maximum_turnover = st.slider( + "Max turnover per period", + 0.05, + 2.0, + 0.5, + 0.05, + help="Maximum L1 weight change allowed on any single row.", + ) + else: + maximum_turnover = None + + st.subheader("Validation") + validation_ratio: float | None = None + test_ratio: float | None = None + train_window = 500 + validation_window = 126 + test_window = 126 + expanding = True + optimization_metric = "sharpe" + parameter_grid: dict[str, list] = {} + if mode == "Walk-forward": + st.caption( + "Select parameters on each fold's validation block and evaluate " + "them out-of-sample on the following test block, repeated and " + "stitched across the whole history. This mode's own Results " + "tab shows that stitched out-of-sample evidence, not a " + "full-sample fit." + ) + train_window = st.number_input( + "Train window (periods)", + min_value=10, + value=500, + step=10, + help="Training periods per fold, used to fit the strategy state.", + ) + validation_window = st.number_input( + "Validation window (periods)", + min_value=5, + value=126, + step=5, + help="Validation periods per fold, used to select parameters.", + ) + test_window = st.number_input( + "Test window (periods)", + min_value=5, + value=126, + step=5, help=( - "Middle chronological slice reported separately for " - "manual assessment. This dashboard backtest does not " - "tune or select parameters automatically." + "Out-of-sample test periods per fold, stitched into the OOS series." ), ) - test_ratio = st.slider( - "Test fraction", - 0.05, - 0.4, - 0.2, - 0.05, + expanding = st.toggle( + "Expanding training window", + value=True, help=( - "Final chronological slice, reported separately as the " - "'Test' block. Genuinely out-of-sample only if the " - "strategy and parameters were fixed before it was ever " - "inspected — this dashboard has no way to verify that." + "On: each fold's training block grows to include everything " + "before it. Off: training slides forward, always Train " + "window periods long." ), ) + optimization_metric = st.selectbox( + "Optimization metric", + ["sharpe", "sortino", "calmar", "total_return"], + index=0, + help="Metric used to pick the best parameter combination on each fold.", + ) + st.caption( + "Parameters to search below — leave empty to use a compact, " + "strategy-specific default grid at run time." + ) + grid_param_names = sorted(strategy_parameter_names(strategy_name)) + selected_grid_params = st.multiselect( + "Grid parameters", + grid_param_names, + help="Strategy parameters to vary across candidate values.", + ) + for parameter_name in selected_grid_params: + raw_values = st.text_input( + f"Candidate values for {parameter_name} (comma-separated)", + key=f"wf_grid_{parameter_name}", + ) + parameter_grid[parameter_name] = parse_parameter_grid_values(raw_values) + # A rough estimate only (used to warn about a slow configuration + # before data is loaded) -- treat the universe as 24/7 only when + # every instrument genuinely is, otherwise fall back to the + # business-day convention. + is_247_market = bool(instrument_rows) and all( + row["Calendar"] == "24/7" for row in instrument_rows + ) + estimated_backtests = estimate_walk_forward_backtest_count( + start_date=start_date, + end_date=end_date or default_end_date(), + is_247_market=is_247_market, + train_window=train_window, + validation_window=validation_window, + test_window=test_window, + expanding=expanding, + parameter_grid=parameter_grid, + ) + if estimated_backtests <= 0: + st.warning( + "No walk-forward fold fits the requested date range and " + "windows — widen the date range or shorten the windows.", + icon="⚠️", + ) + else: + enable_holdout = st.checkbox( + "Chronological holdout (train / validation / test)", + value=False, + help=( + "Split one continuous backtest chronologically and report " + "each block separately. No fitting or parameter tuning " + "happens here. Treat the trailing test block as " + "out-of-sample only if you fixed the strategy and " + "parameters before inspecting it; the headline metric " + "cards still describe the full sample." + ), + ) + if enable_holdout: + validation_ratio = st.slider( + "Validation fraction", + 0.05, + 0.4, + 0.2, + 0.05, + help=( + "Middle chronological slice reported separately for " + "manual assessment. This dashboard backtest does not " + "tune or select parameters automatically." + ), + ) + test_ratio = st.slider( + "Test fraction", + 0.05, + 0.4, + 0.2, + 0.05, + help=( + "Final chronological slice, reported separately as the " + "'Test' block. Genuinely out-of-sample only if the " + "strategy and parameters were fixed before it was ever " + "inspected — this dashboard has no way to verify that." + ), + ) - st.subheader("Costs & capital") - initial_capital = st.number_input( - "Initial capital", - 1_000.0, - value=100_000.0, - step=1_000.0, - help=( - "Starting portfolio value in currency units. Scales every " - "currency-denominated figure (costs, traded notional) but not " - "percentage returns." - ), - ) - risk_free_rate_percent = float( - st.number_input( - "Risk-free rate (annual %)", - value=2.0, - step=0.1, - format="%.2f", + st.subheader("Costs & capital") + initial_capital = st.number_input( + "Initial capital", + 1_000.0, + value=100_000.0, + step=1_000.0, help=( - "Annual rate used for excess-return metrics and the cash " - "benchmark. Enter 2 for 2%." + "Starting portfolio value in currency units. Scales every " + "currency-denominated figure (costs, traded notional) but not " + "percentage returns." ), ) - ) - benchmark_kind = st.selectbox( - "Benchmark", - options=["symbol", "equal_weight", "first_asset", "cash"], - index=0, - format_func={ - "symbol": "Symbol", - "equal_weight": "Equal weight", - "first_asset": "First asset", - "cash": "Cash", - }.__getitem__, - help=( - "Compare the strategy with an external symbol, an equal-weight " - "portfolio, the first universe asset, or cash earning the " - "configured risk-free rate." - ), - ) - # The benchmark symbol is itself an instrument (source + calendar), with - # the same provenance rule as the table above: if it duplicates a - # tradable instrument, its source/calendar are reused verbatim rather - # than letting the user configure an inconsistency the config would - # reject anyway (source/calendar must match exactly when they overlap). - if benchmark_kind == "symbol": - benchmark_symbol = ( - st.text_input( - "Benchmark symbol", - "SPY", - help="External symbol to compare the strategy against.", + risk_free_rate_percent = float( + st.number_input( + "Risk-free rate (annual %)", + value=2.0, + step=0.1, + format="%.2f", + help=( + "Annual rate used for excess-return metrics and the cash " + "benchmark. Enter 2 for 2%." + ), ) - .strip() - .upper() ) - matching_instrument = next( - (row for row in instrument_rows if row["Instrument"] == benchmark_symbol), - None, + benchmark_kind = st.selectbox( + "Benchmark", + options=["symbol", "equal_weight", "first_asset", "cash"], + index=0, + format_func={ + "symbol": "Symbol", + "equal_weight": "Equal weight", + "first_asset": "First asset", + "cash": "Cash", + }.__getitem__, + help=( + "Compare the strategy with an external symbol, an equal-weight " + "portfolio, the first universe asset, or cash earning the " + "configured risk-free rate." + ), ) - if matching_instrument is not None: - benchmark_source = matching_instrument["Source"] - benchmark_calendar = matching_instrument["Calendar"] - st.caption( - f"{benchmark_symbol} is already a tradable instrument — its " - f"source ({benchmark_source}) and calendar " - f"({benchmark_calendar}) are reused as-is." + # The benchmark symbol is itself an instrument (source + calendar), with + # the same provenance rule as the table above: if it duplicates a + # tradable instrument, its source/calendar are reused verbatim rather + # than letting the user configure an inconsistency the config would + # reject anyway (source/calendar must match exactly when they overlap). + if benchmark_kind == "symbol": + benchmark_symbol = ( + st.text_input( + "Benchmark symbol", + "SPY", + help="External symbol to compare the strategy against.", + ) + .strip() + .upper() ) - elif benchmark_symbol: - detected_source = detect_source(benchmark_symbol) - source_options = ["yahoo", "binance", "csv"] - benchmark_source = st.selectbox( - "Benchmark source", - source_options, - index=source_options.index( - detected_source.value if detected_source else "csv" + matching_instrument = next( + ( + row + for row in instrument_rows + if row["Instrument"] == benchmark_symbol ), - key="benchmark_source_select", - help="Data source for the external benchmark symbol.", - ) - benchmark_calendar = st.text_input( - "Benchmark calendar", - detect_calendar(benchmark_symbol, DataSourceName(benchmark_source)) - or "XNYS", - key="benchmark_calendar_input", - help="'24/7' for a continuous market, or a " - "pandas_market_calendars name such as XNYS, XHKG, XLON.", + None, ) + if matching_instrument is not None: + benchmark_source = matching_instrument["Source"] + benchmark_calendar = matching_instrument["Calendar"] + st.caption( + f"{benchmark_symbol} is already a tradable instrument — its " + f"source ({benchmark_source}) and calendar " + f"({benchmark_calendar}) are reused as-is." + ) + elif benchmark_symbol: + detected_source = detect_source(benchmark_symbol) + source_options = ["yahoo", "binance", "csv"] + benchmark_source = st.selectbox( + "Benchmark source", + source_options, + index=source_options.index( + detected_source.value if detected_source else "csv" + ), + key="benchmark_source_select", + help="Data source for the external benchmark symbol.", + ) + benchmark_calendar = st.text_input( + "Benchmark calendar", + detect_calendar(benchmark_symbol, DataSourceName(benchmark_source)) + or "XNYS", + key="benchmark_calendar_input", + help="'24/7' for a continuous market, or a " + "pandas_market_calendars name such as XNYS, XHKG, XLON.", + ) + else: + benchmark_source = "csv" + benchmark_calendar = "XNYS" else: + benchmark_symbol = "" benchmark_source = "csv" benchmark_calendar = "XNYS" - else: - benchmark_symbol = "" - benchmark_source = "csv" - benchmark_calendar = "XNYS" - if benchmark_kind == "first_asset": - first_symbol = symbols[0] if symbols else "the first universe symbol" - st.caption(f"Benchmark asset: {first_symbol}") - commission_bps = st.slider( - "Commission (bps)", - 0.0, - 20.0, - 2.0, - 0.5, - help="Broker commission charged per unit of traded notional, in basis points.", - ) - spread_bps = st.slider( - "Spread (bps)", - 0.0, - 20.0, - 3.0, - 0.5, - help=( - "Full quoted bid-ask spread in basis points; half is charged " - "whenever a trade crosses it." - ), - ) - slippage_bps = st.slider( - "Slippage (bps)", - 0.0, - 20.0, - 2.0, - 0.5, - help=( - "Additional execution cost beyond commission and spread, " - "modelling market impact under the constant slippage model " - "below." - ), - ) - with st.expander("Advanced execution settings"): - slippage_model = st.selectbox( - "Slippage model", - ["constant", "volume"], - index=0, + if benchmark_kind == "first_asset": + first_symbol = symbols[0] if symbols else "the first universe symbol" + st.caption(f"Benchmark asset: {first_symbol}") + commission_bps = st.slider( + "Commission (bps)", + 0.0, + 20.0, + 2.0, + 0.5, + help=( + "Broker commission charged per unit of traded notional, " + "in basis points." + ), + ) + spread_bps = st.slider( + "Spread (bps)", + 0.0, + 20.0, + 3.0, + 0.5, help=( - "constant applies the slippage bps above uniformly to every " - "trade. volume instead scales slippage with each trade's " - "size relative to average daily volume, using the impact " - "coefficient below." + "Full quoted bid-ask spread in basis points; half is charged " + "whenever a trade crosses it." ), ) - if slippage_model == "volume": - impact_coefficient = st.number_input( - "Volume impact coefficient", - min_value=0.0, - value=0.1, - step=0.01, + slippage_bps = st.slider( + "Slippage (bps)", + 0.0, + 20.0, + 2.0, + 0.5, + help=( + "Additional execution cost beyond commission and spread, " + "modelling market impact under the constant slippage model " + "below." + ), + ) + with st.expander("Advanced execution settings"): + slippage_model = st.selectbox( + "Slippage model", + ["constant", "volume"], + index=0, help=( - "Multiplies sqrt(order size / average daily volume) — " - "added on top of the slippage bps above. For liquid " - "instruments (e.g. SPY, QQQ) and a modest position size " - "relative to their average daily volume, that square " - "root is tiny, so even a large coefficient can leave " - "results looking identical to the constant model — this " - "term is built to matter for large orders in thin " - "markets, not small ones in deep markets." + "constant applies the slippage bps above uniformly to every " + "trade. volume instead scales slippage with each trade's " + "size relative to average daily volume, using the impact " + "coefficient below." ), ) + if slippage_model == "volume": + impact_coefficient = st.number_input( + "Volume impact coefficient", + min_value=0.0, + value=0.1, + step=0.01, + help=( + "Multiplies sqrt(order size / average daily volume) — " + "added on top of the slippage bps above. For liquid " + "instruments (e.g. SPY, QQQ) and a modest position size " + "relative to their average daily volume, that square " + "root is tiny, so even a large coefficient can leave " + "results looking identical to the constant model — this " + "term is built to matter for large orders in thin " + "markets, not small ones in deep markets." + ), + ) + else: + impact_coefficient = 0.1 + + submission_blocked = bool(conflicts) or not symbols or not frequency_options + # Same stable key for both -- exactly one of the two ever renders in + # a given run (mutually exclusive on `mode`), and a stable key lets + # callers (dashboard tests) find this specific button regardless of + # how many OTHER sidebar buttons render before it. + if mode == "Walk-forward": + run = st.button( + "Run walk-forward", + type="primary", + width="stretch", + disabled=submission_blocked, + key="run_button", + ) else: - impact_coefficient = 0.1 - - submission_blocked = bool(conflicts) or not symbols or not frequency_options - if mode == "Walk-forward": - run = st.button( - "Run walk-forward", - type="primary", - width="stretch", - disabled=submission_blocked, - ) - else: - run = st.button( - "Run backtest", - type="primary", - width="stretch", - disabled=submission_blocked, - ) + run = st.button( + "Run backtest", + type="primary", + width="stretch", + disabled=submission_blocked, + key="run_button", + ) def _run_and_store( @@ -1494,15 +1715,17 @@ def _on_progress(done: int, total: int) -> None: return _on_progress -def _render_bootstrap_interpretation() -> None: +def _render_bootstrap_interpretation(confidence_level: float = 0.90) -> None: """Explain how to read the bootstrap summary table's columns.""" + pct = f"{confidence_level:.0%}" st.caption( - "How to read this: p05/p95 form a 90% interval across resamples of " - "these same, already-realised returns. If a statistic's p05 sits on " - "the wrong side of zero (e.g. a negative CAGR or Sharpe), ordinary " - "resampling variation in this exact history could plausibly have " - "produced a loss — the result isn't robust to resampling yet, " - "regardless of how good the point estimate looks." + f"How to read this: p_lower/p_upper form a {pct} interval across " + "resamples of these same, already-realised returns. If a " + "statistic's p_lower sits on the wrong side of zero (e.g. a " + "negative CAGR or Sharpe), ordinary resampling variation in this " + "exact history could plausibly have produced a loss — the result " + "isn't robust to resampling yet, regardless of how good the point " + "estimate looks." ) @@ -1653,8 +1876,14 @@ def _render_robustness_tab(result: BacktestResult) -> None: ) bootstrap_summary = st.session_state.get("bootstrap_summary") if bootstrap_summary is not None: - st.dataframe(bootstrap_summary, width="stretch", hide_index=True) - _render_bootstrap_interpretation() + st.dataframe( + format_bootstrap_summary(bootstrap_summary), + width="stretch", + hide_index=True, + ) + _render_bootstrap_interpretation( + result.config.robustness.bootstrap.confidence_level + ) st.markdown("#### Permutation Monte Carlo") st.caption( @@ -1921,8 +2150,14 @@ def _render_walk_forward_robustness_tab(wf: WalkForwardResult) -> None: ) wf_bootstrap_summary = st.session_state.get("wf_bootstrap_summary") if wf_bootstrap_summary is not None: - st.dataframe(wf_bootstrap_summary, width="stretch", hide_index=True) - _render_bootstrap_interpretation() + st.dataframe( + format_bootstrap_summary(wf_bootstrap_summary), + width="stretch", + hide_index=True, + ) + _render_bootstrap_interpretation( + oos_result.config.robustness.bootstrap.confidence_level + ) st.markdown("#### Permutation Monte Carlo") st.caption( @@ -2068,7 +2303,16 @@ def _run_wf_sensitivity() -> None: def _collect_backtest_robustness_evidence() -> tuple[ dict[str, object], tuple[object, ...] ]: - """Gather every on-demand Backtest-mode robustness result for the report.""" + """Gather every on-demand Backtest-mode robustness result for the report. + + Also folds in the current strategy's own results diagnostics (e.g. + pairs trading's correlation/spread/ADF section), when its profile + declares any -- otherwise a report downloaded from the dashboard would + show that section live in the Results tab but silently drop it from + the exported HTML. A failed diagnostics computation still contributes + a status note rather than vanishing without a trace (mirrors the + Results tab's own warning; see `_render_strategy_diagnostics`). + """ evidence: dict[str, object] = {} cache_parts: list[object] = [] for session_key, label in ( @@ -2081,6 +2325,40 @@ def _collect_backtest_robustness_evidence() -> tuple[ cache_parts.append(id(value) if value is not None else None) if value is not None: evidence[label] = value + + result = st.session_state.get("result") + diagnostics = st.session_state.get("strategy_diagnostics") + diagnostics_error = st.session_state.get("strategy_diagnostics_error") + diagnostics_cache_part: object = diagnostics_error + if result is not None: + profile = get_profile(result.config.strategy_name) + if profile is not None and profile.results_diagnostics is not None: + if diagnostics is not None: + section = profile.results_diagnostics.report_section(diagnostics) + evidence[profile.results_diagnostics.key] = section + # A live Results-tab widget belonging to this diagnostic + # (e.g. its own forward-return horizon or window slider) + # can change this section's content without changing + # `diagnostics` itself -- `report_section` reads such a + # widget's current value straight from session_state (see + # `live_widget_value`). Cache on the section's own + # rendered content, not `id(diagnostics)`, so the exported + # report is correctly regenerated when the user moves one + # of those widgets instead of silently reusing a stale + # cached HTML report. + diagnostics_cache_part = ( + tuple(section.table.columns), + tuple(section.table.itertuples(index=False, name=None)), + section.chart_data_uri, + section.note, + ) + elif diagnostics_error is not None: + evidence[profile.results_diagnostics.key] = DiagnosticsSection( + table=pd.DataFrame( + {"Status": [f"Could not compute: {diagnostics_error}"]} + ), + ) + cache_parts.append(diagnostics_cache_part) return evidence, tuple(cache_parts) @@ -2219,6 +2497,8 @@ def _clear_backtest_result_state() -> None: "permutation_test", "sensitivity", "report_html", + "strategy_diagnostics", + "strategy_diagnostics_error", ): st.session_state.pop(key, None) @@ -2238,21 +2518,96 @@ def _clear_walk_forward_result_state() -> None: st.session_state.pop(key, None) +def _compute_strategy_diagnostics(data: pd.DataFrame, config: ExperimentConfig) -> Any: + """Compute the current strategy's results diagnostics, if it declares any. + + Generic by design -- reads ``profile.results_diagnostics`` off whichever + profile is registered for ``config.strategy_name`` instead of branching + on a strategy name, so a strategy without diagnostics (the common case) + costs nothing beyond one dict lookup. ``data`` must be the EXACT frame + the displayed backtest itself ran on (see + ``run_dashboard_backtest_with_data``) -- reloading it separately could + silently observe different data for a remote source between the two + calls. + """ + profile = get_profile(config.strategy_name) + if profile is None or profile.results_diagnostics is None: + return None + return profile.results_diagnostics.compute(data, config) + + +def _render_strategy_diagnostics(result: BacktestResult) -> None: + """Render the Results-tab diagnostics declared by the current strategy's profile. + + Renders nothing for a strategy without a declared ``results_diagnostics`` + (the default) -- never an empty section, per the Strategy Explorer + architecture's generic, name-free dispatch. When the strategy DOES + declare one but computing it raised, surfaces that failure as a visible + warning instead of the diagnostics silently vanishing from the page. + """ + profile = get_profile(result.config.strategy_name) + if profile is None or profile.results_diagnostics is None: + return + error = st.session_state.get("strategy_diagnostics_error") + if error is not None: + st.warning( + f"Backtest completed, but strategy diagnostics could not be " + f"computed: {error}" + ) + return + diagnostics = st.session_state.get("strategy_diagnostics") + if diagnostics is None: + return + profile.results_diagnostics.render(st, diagnostics) + + +def _render_walk_forward_diagnostics_note(oos_result: BacktestResult) -> None: + """Explain why Strategy Explorer results diagnostics aren't shown here. + + Each walk-forward fold can select different strategy parameters than + the base config and covers only that fold's slice of history -- a + diagnostic computed once, on the full history with the base config's + parameters, would not actually describe what any individual fold + traded. Shown only for a strategy that DOES declare + ``results_diagnostics`` (so a strategy without one, the common case, + never gets a stray note here). + """ + profile = get_profile(oos_result.config.strategy_name) + if profile is None or profile.results_diagnostics is None: + return + st.caption( + "This strategy has Strategy Explorer results diagnostics (see " + "Backtest mode) -- not shown here because each walk-forward fold " + "can select different parameters than this run's base config, so " + "a single full-history diagnostic would not accurately describe " + "every fold's own selection." + ) + + def _execute_backtest() -> None: inputs = _collect_inputs() _clear_backtest_result_state() with st.spinner("Running backtest…"): try: config = build_config_from_inputs(inputs) - result, warnings = run_dashboard_backtest(config) + result, warnings, data = run_dashboard_backtest_with_data(config) except Exception as exc: logger.exception("Dashboard backtest failed") st.error(f"Backtest failed: {exc}") return + diagnostics: Any = None + diagnostics_error: str | None = None + try: + diagnostics = _compute_strategy_diagnostics(data, config) + except Exception as exc: + logger.exception("Strategy Explorer results diagnostics failed") + diagnostics_error = str(exc) st.session_state["result"] = result st.session_state["result_inputs"] = inputs st.session_state["warnings"] = warnings + st.session_state["strategy_diagnostics"] = diagnostics + st.session_state["strategy_diagnostics_error"] = diagnostics_error def _execute_walk_forward() -> None: @@ -2330,6 +2685,7 @@ def _execute_walk_forward() -> None: render_charts(st, oos_result) render_gross_net_comparison(st, oos_result) render_exposure_and_cost_charts(st, oos_result) + _render_walk_forward_diagnostics_note(oos_result) if tab_trades.open: with tab_trades: render_trade_table(st, oos_result) @@ -2347,6 +2703,12 @@ def _execute_walk_forward() -> None: cache_key_extra=wf_cache_parts, session_key="wf_report_html", ) +elif mode == "Strategies": + selected_strategy = st.session_state.get("explorer_strategy") + if selected_strategy is None: + explorer_gallery.render(st) + else: + explorer_detail.render(st, selected_strategy) else: result = st.session_state.get("result") if result is None: @@ -2388,6 +2750,7 @@ def _execute_walk_forward() -> None: render_charts(st, result) render_gross_net_comparison(st, result) render_exposure_and_cost_charts(st, result) + _render_strategy_diagnostics(result) if tab_trades.open: with tab_trades: render_trade_table(st, result) diff --git a/src/quantlab/dashboard/components.py b/src/quantlab/dashboard/components.py index ad7fa34..38fa936 100644 --- a/src/quantlab/dashboard/components.py +++ b/src/quantlab/dashboard/components.py @@ -6,11 +6,13 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast import numpy as np import pandas as pd +from quantlab.backtesting.trade_log import parse_adjustment_codes +from quantlab.dashboard.state import binance_trading_symbols, yahoo_common_symbols from quantlab.reporting.charts import ( ACCENT, BENCHMARK, @@ -25,6 +27,206 @@ if TYPE_CHECKING: from quantlab.backtesting.result import BacktestResult + from quantlab.data.base import SymbolSuggestion + from quantlab.features.pairs_diagnostics import PairDiagnostics + +#: Shown in a symbol picker's help tooltip whenever its preloaded list isn't +#: a complete universe -- currently only Yahoo's, since Binance's list +#: genuinely is complete. +INCOMPLETE_LIST_NOTE = ( + "Not every symbol is suggested — if yours is missing, type its exact " + "ticker and it'll still be accepted." +) + + +def parse_symbols(raw: str) -> list[str]: + """Normalise symbols and remove duplicates while preserving their order.""" + return list(dict.fromkeys(s.strip().upper() for s in raw.split(",") if s.strip())) + + +def label_for(suggestion: SymbolSuggestion) -> str: + """A symbol's display label -- ``"SYMBOL — description"`` when known.""" + if suggestion.description: + return f"{suggestion.symbol} — {suggestion.description}" + return suggestion.symbol + + +def cached_binance_universe(st: Any) -> list[SymbolSuggestion]: + """Binance's full active spot-symbol universe, refreshed hourly. + + Fetched once per hour (per Streamlit cache entry, no arguments) rather + than per keystroke: once loaded, picking symbols from it is instant, + client-side dropdown filtering — no server round trip per character. + """ + + @st.cache_data(ttl=3600, show_spinner="Loading Binance's symbol list…") + def _load() -> list[SymbolSuggestion]: + return binance_trading_symbols() + + return cast("list[SymbolSuggestion]", _load()) + + +def binance_universe_labels(st: Any) -> dict[str, str]: + """Binance's cached universe as ``{symbol: display label}``.""" + return {s.symbol: label_for(s) for s in cached_binance_universe(st)} + + +def yahoo_universe_labels() -> dict[str, str]: + """The bundled S&P 500 + major-ETF reference list as ``{symbol: label}``. + + Yahoo has no downloadable "every symbol" endpoint the way Binance does, + so this static, bundled list stands in as an instant, offline universe — + covering what most dashboard users will look for, not every symbol Yahoo + can actually serve. + """ + return {s.symbol: label_for(s) for s in yahoo_common_symbols()} + + +#: (entry_min, entry_max, stop_max, step) UI ranges per mean_reversion +#: `indicator` -- the entry/exit/stop DEFAULTS come from `quantlab. +#: strategies.mean_reversion.INDICATOR_DEFAULT_THRESHOLDS` (the strategy's +#: own single source of truth), not duplicated here. +_MEAN_REVERSION_INDICATOR_UI_RANGES: dict[str, tuple[float, float, float, float]] = { + "zscore": (0.5, 4.0, 8.0, 0.1), + "bollinger": (0.1, 3.0, 4.0, 0.05), + "rsi": (5.0, 50.0, 50.0, 1.0), + "distance_ma": (0.01, 0.3, 0.4, 0.01), + "percentile": (0.01, 0.49, 0.49, 0.01), +} + + +def mean_reversion_slider_bounds( + indicator: str, +) -> tuple[float, float, float, float, float, float, float]: + """UI slider bounds for one `mean_reversion` `indicator`'s thresholds. + + Returns ``(entry_min, entry_max, entry_default, exit_default, + stop_max, stop_default, step)``. The three defaults come from + ``quantlab.strategies.mean_reversion.INDICATOR_DEFAULT_THRESHOLDS`` -- + shared by the main dashboard sidebar and the Strategy Explorer lab so + neither can silently drift from what the strategy itself resolves to + when a threshold is left unset. + """ + from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS + + entry_min, entry_max, stop_max, step = _MEAN_REVERSION_INDICATOR_UI_RANGES[ + indicator + ] + entry_default, exit_default, stop_default = INDICATOR_DEFAULT_THRESHOLDS[indicator] + return ( + entry_min, + entry_max, + entry_default, + exit_default, + stop_max, + stop_default, + step, + ) + + +def entry_threshold_bounds( + entry_min: float, + entry_max: float, + stop_max: float, + step: float, + *, + stop_enabled: bool, +) -> tuple[float, float]: + """Entry threshold's own ``(min, max)``, narrowed only as needed. + + The low end keeps ``entry_min`` UNCHANGED -- entry never loses a + reachable value (e.g. ``distance_ma``'s/``percentile``'s own + step-sized minimum, ``0.01``) just to dodge the exit slider's own + degenerate near-zero case; see :func:`exit_threshold_bounds`, which + handles that case directly (fixing ``exit_threshold = 0.0`` instead of + rendering a slider at all) rather than narrowing entry's domain to + avoid it ever arising. + + The high end is narrowed only for the stop slider, and only when it + actually exists: its own min is ``entry + step`` (its max is + ``stop_max``), but it is only rendered when ``stop_enabled`` -- so + entry only needs to stay 2 steps below ``stop_max`` while that slider + actually exists. With stop disabled, entry keeps its FULL original + upper range (e.g. RSI can still reach 50, percentile can still reach + 0.49) rather than silently losing reachable values for a slider that + was never going to be built. + """ + hi = min(entry_max, stop_max - 2.0 * step) if stop_enabled else entry_max + return entry_min, hi + + +def exit_threshold_bounds( + entry_threshold: float, step: float +) -> tuple[float, float] | None: + """Exit threshold's own ``(min, max)``, or ``None`` when only 0.0 is valid. + + The exit slider's max is ``entry_threshold - step`` (its min is fixed + at ``0.0``). When ``entry_threshold <= step``, that max is ``<= 0.0`` + -- Streamlit's slider rejects ``min == max``, so there is no slider + to render at all: the only mathematically valid ``exit_threshold`` at + that point is exactly ``0.0``, and the caller should show that + directly (a caption or a disabled widget) instead of calling + ``st.slider`` with a degenerate range. + """ + exit_max = round(entry_threshold - step, 10) + if exit_max <= 0.0: + return None + return 0.0, exit_max + + +def symbols_picker( + st: Any, + label_by_symbol: dict[str, str], + key: str, + default_symbols: tuple[str, ...], + *, + accept_new_options: bool = False, + help_suffix: str = "", +) -> list[str]: + """A single instant, client-side-filtered dropdown over a preloaded universe. + + Typing filters the already-loaded option list in the browser (like a + search-engine dropdown) — no server round trip per character. When + ``accept_new_options`` is set, a symbol absent from the preloaded list + (Yahoo's bundled universe is large but not exhaustive — Yahoo has no + downloadable "every symbol" list to preload the way Binance does) can + still be typed and added directly. ``help_suffix`` lets a caller add + context-specific guidance (e.g. the main sidebar's pairs_trading note) + without this function assuming any particular caller's workflow. + """ + help_text = "Tradable universe — start typing to filter" + help_text += ( + ", or enter an exact symbol not in the list. " if accept_new_options else ". " + ) + help_text += help_suffix + if accept_new_options: + help_text += " " + INCOMPLETE_LIST_NOTE + + if key not in st.session_state: + st.session_state[key] = [ + label_by_symbol[s] for s in default_symbols if s in label_by_symbol + ] + # No `label_visibility="collapsed"` here: Streamlit hides the help + # tooltip icon along with a collapsed label, and that icon is the only + # place the market-calendar/incomplete-list notes above are surfaced. + with st.container(border=True): + picked_labels = st.multiselect( + "Symbols", + options=list(label_by_symbol.values()), + key=key, + placeholder=( + "Type to find or add any symbol…" + if accept_new_options + else "Type to find a symbol…" + ), + accept_new_options=accept_new_options, + help=help_text, + ) + symbol_by_label = {label: symbol for symbol, label in label_by_symbol.items()} + return [ + symbol_by_label.get(str(label), str(label).strip().upper()) + for label in picked_labels + ] def render_metric_cards(st: Any, result: BacktestResult) -> None: @@ -52,7 +254,13 @@ def formatted_metric(key: str, spec: str) -> str: "Cumulative modelled transaction costs, expressed in the same " "currency units as initial capital.", ), - ("Number of trades", f"{result.number_of_trades()}", None), + ( + "Number of fills", + f"{result.number_of_trades()}", + "Trade-log rows, one per symbol per executed order -- a " + "declared multi-symbol position (e.g. a pairs_trading hedge) " + "that trades both legs contributes one row per leg.", + ), ] cols = st.columns(4) for i, (label, value, help_text) in enumerate(cards): @@ -293,33 +501,376 @@ def render_exposure_and_cost_charts(st: Any, result: BacktestResult) -> None: col2.plotly_chart(fig_cost, width="stretch") +def render_pair_diagnostics( + st: Any, + diagnostics: PairDiagnostics, + *, + entry_threshold: float, + exit_threshold: float, + stop_threshold: float | None, + adf_pvalue_threshold: float | None, +) -> None: + """Render a pairs-trading result's correlation/hedge-ratio/spread diagnostics. + + Shown only for a pairs_trading backtest -- the pairs_trading Strategy + Explorer profile's own ``results_diagnostics`` is the single place + that decides whether this section is computed at all (see + ``quantlab.dashboard.explorer.profile.ResultsDiagnostics``); nothing + here or in its caller checks the strategy's name. + + The centered-indicator chart mirrors the interactive lab's own + "Trading thresholds" section exactly (same overlay helper, same viable- + entry gating rule: threshold crossed AND, if configured, the causal + rolling ADF p-value at that bar is <= ``adf_pvalue_threshold`` -- + :meth:`~quantlab.strategies.pairs_trading.PairsTradingStrategy. + _stationarity_gate`'s own condition, using the SAME rolling ADF series + already shown above, never a second, potentially diverging computation). + """ + from quantlab.dashboard.explorer.shared_components import ( + VIABLE_ENTRY_MARKER_COLOR, + centered_indicator_threshold_overlay, + render_price_chart, + render_stationarity_card, + ) + + st.subheader("Pair relationship diagnostics") + st.caption( + f"{diagnostics.symbol_a} / {diagnostics.symbol_b} -- return " + f"correlation {diagnostics.correlation:.2f}." + ) + render_price_chart( + st, + {"Hedge ratio (beta)": diagnostics.hedge_ratio}, + title="Rolling hedge ratio", + yaxis_title="Beta", + ) + render_price_chart( + st, + {"Spread": diagnostics.spread}, + title=f"{diagnostics.symbol_a}/{diagnostics.symbol_b} spread", + yaxis_title="Spread", + ) + + indicator = diagnostics.spread_indicator + threshold_series, line_colors = centered_indicator_threshold_overlay( + indicator, + f"{diagnostics.indicator} indicator", + entry_threshold=entry_threshold, + exit_threshold=exit_threshold, + stop_threshold=stop_threshold, + ) + crosses_entry = (indicator > entry_threshold) | (indicator < -entry_threshold) + if adf_pvalue_threshold is not None: + gate_open = ( + diagnostics.rolling_adf_pvalue.reindex(indicator.index) + <= adf_pvalue_threshold + ).fillna(False) + else: + gate_open = pd.Series(True, index=indicator.index) + viable = crosses_entry & gate_open + viable_marker = indicator.where(viable) + markers = ( + {"Viable entry (threshold crossed + ADF gate open)": viable_marker} + if viable.any() + else None + ) + marker_colors = ( + {"Viable entry (threshold crossed + ADF gate open)": VIABLE_ENTRY_MARKER_COLOR} + if markers + else {} + ) + render_price_chart( + st, + threshold_series, + title=f"Centered '{diagnostics.indicator}' indicator of the spread " + "with entry/exit/stop thresholds", + yaxis_title="Centered indicator", + markers=markers, + colors={**line_colors, **marker_colors}, + ) + gate_text = ( + f"ADF gate at p <= {adf_pvalue_threshold:g}" + if adf_pvalue_threshold is not None + else "ADF gate disabled" + ) + n_viable = int(viable.sum()) + if stop_threshold is not None: + stop_breaches = int( + ((indicator > stop_threshold) | (indicator < -stop_threshold)).sum() + ) + stop_clause = ( + f" **{stop_breaches}** bar(s) cross the stop threshold " + f"({stop_threshold:g})." + ) + else: + stop_clause = " Stop threshold is disabled." + st.caption( + f"**{int(crosses_entry.sum())}** bar(s) cross the entry threshold; " + f"**{n_viable}** of those are viable ({gate_text})." + + stop_clause + + " Not an entry count: current state, rebalancing and execution can " + "still prevent or delay a trade." + ) + + render_stationarity_card(st, diagnostics.adf_result, label="ADF (spread)") + render_stationarity_card( + st, diagnostics.cointegration_result, label="Engle-Granger cointegration" + ) + if diagnostics.rolling_adf_pvalue.notna().any(): + render_price_chart( + st, + {"Rolling ADF p-value": diagnostics.rolling_adf_pvalue}, + title="Stationarity stability over time", + yaxis_title="p-value", + ) + half_life_text = ( + f"{diagnostics.half_life:.1f} periods" + if np.isfinite(diagnostics.half_life) + else "not mean-reverting over this sample (no finite half-life)" + ) + hedge_stability_text = ( + f"{diagnostics.hedge_ratio_stability:.4f}" + if np.isfinite(diagnostics.hedge_ratio_stability) + else "n/a" + ) + col_hl, col_stab = st.columns(2) + col_hl.metric("Half-life", half_life_text) + col_stab.metric("Hedge-ratio stability (std of beta)", hedge_stability_text) + st.caption( + "A larger hedge-ratio std means the fitted slope changes more in " + "this sample. It is measured in beta's scale-dependent units, so " + "compare it across settings for the same ordered pair, not across pairs." + ) + + +#: Fixed display schema for `render_trade_table` -- the SAME 15 columns, in +#: this exact order, for every strategy. Only the values differ; a column is +#: never added, dropped or reordered based on which strategy produced the +#: result (`test_dashboard.py` asserts this across several strategies). +_TRADE_TABLE_DISPLAY_COLUMNS = [ + "Timestamp", + "Symbol", + "Action", + "Previous weight", + "New weight", + "Weight change", + "Trigger", + "Trigger detail", + "Adjustments", + "Position origin", + "Position origin date", + "Details", + "Reference price", + "Traded notional", + "Total cost", +] + + +def _is_missing(value: object) -> bool: + """True for None/NaN/NaT/`pd.NA`, without raising on a non-scalar value. + + A plain ``isinstance(value, float) and pd.isna(value)`` check misses + ``pd.NA`` (not a ``float`` subclass) and + non-float-64 NaN scalars like ``numpy.float32('nan')``/ + ``numpy.float16('nan')`` (also not ``float`` subclasses -- only + ``numpy.float64`` is, via CPython's numpy integration) -- either would + then reach ``str(value)`` -> ``parse_adjustment_codes()`` as a bogus + ``""``/``"nan"`` code and raise ``BacktestError`` on an unknown + adjustment code, instead of being treated as simply missing. + """ + if not pd.api.types.is_scalar(value): + return False + return bool(pd.isna(cast(Any, value))) + + +def _format_adjustments(value: object) -> object: + """Cosmetically re-space the "+"-joined machine codes for readability. + + Still the exact same codes, never a fusion of different columns' + data. `None` (no adjustment layer acted on this trade) stays `None`, + never a blanked-away real value. + """ + if _is_missing(value): + return None + return " + ".join(parse_adjustment_codes(str(value))) + + +def _compose_details(trigger_details: object, adjustment_details: object) -> object: + """Human-readable summary combining the trigger's and adjustment(s)' text. + + E.g. "Trigger: oversold entry; Adjustment: maximum_weight 0.62 -> + 0.50". Purely a reading aid -- the raw, machine-readable columns + this is built from are untouched in `result.trades`/the CSV export + below. + """ + parts = [] + if not _is_missing(trigger_details): + parts.append(f"Trigger: {trigger_details}") + if not _is_missing(adjustment_details): + parts.append(f"Adjustment: {adjustment_details}") + return "; ".join(parts) if parts else None + + def render_trade_table(st: Any, result: BacktestResult) -> None: - """Render the trade table with a CSV download.""" + """Render a uniform trade table with a CSV download. + + The same 15 display columns (`_TRADE_TABLE_DISPLAY_COLUMNS`), in the + same order, are shown for every strategy -- only the VALUES differ. + Missing information (no adjustment on this trade, no reason + attribution available at all for this run, e.g. a walk-forward + out-of-sample result) renders as a blank cell; the column itself + always stays, it is never conditionally dropped. + + `Position origin`/`Position origin date` are always shown, on every + row, even when they duplicate `Trigger` -- blanking them + conditionally would make an empty cell ambiguous between "no origin" + and "hidden because redundant", exactly the ambiguity this design + avoids: blank means either that no strategic position is currently + active (the decision proxy is flat) or that position-origin + attribution was unavailable for this result -- notably for a stitched + walk-forward out-of-sample result, which does not carry it -- never a + value hidden for brevity. + + This is a read-only presentation view derived from `result.trades`. + The CSV download button below always exports the raw, full + 21-column `result.trades` frame untouched, regardless of what is + shown here -- the visible table's schema never drives the export's. + """ trades = result.trades if trades.empty: st.info("No trades were recorded for this configuration.") return - display_cols = [ - "timestamp", - "symbol", - "side", - "weight_change", - "traded_notional", - "total_cost", + from quantlab.backtesting.trade_log import stop_loss_take_profit_trigger_counts + from quantlab.dashboard.explorer.shared_components import strong + + trigger_counts = stop_loss_take_profit_trigger_counts(trades) + strategy_params = result.config.strategy_parameters + stop_loss_enabled = strategy_params.get("stop_loss_pct") is not None + take_profit_enabled = strategy_params.get("take_profit_pct") is not None + # Gated on whether each was actually CONFIGURED, not on whether it + # fired -- a configured-but-never-triggered stop/target (count 0) is a + # meaningful fact worth showing, and independent of the other: enabling + # only stop-loss must never imply take-profit was also active at 0. + if stop_loss_enabled or take_profit_enabled: + parts = [] + if stop_loss_enabled: + parts.append( + f"Stop-loss affected {strong(str(trigger_counts['stop_loss']))} " + "symbol-position exit(s)" + ) + if take_profit_enabled: + label = "take-profit" if parts else "Take-profit" + parts.append( + f"{label} affected {strong(str(trigger_counts['take_profit']))} " + "symbol-position exit(s)" + ) + st.caption( + "; ".join(parts) + " -- counted per trade-log row, so a " + "declared multi-symbol position (e.g. a pairs_trading hedge) " + "that force-flattens contributes one row per leg, not " + "necessarily one distinct stop-loss/take-profit EVENT.", + unsafe_allow_html=True, + ) + details = [ + _compose_details(trigger_detail, adjustment_detail) + for trigger_detail, adjustment_detail in zip( + trades["trigger_reason_details"], + trades["adjustment_reason_details"], + strict=True, + ) ] + display_trades = pd.DataFrame( + { + "Timestamp": trades["timestamp"], + "Symbol": trades["symbol"], + "Action": trades["action"], + "Previous weight": trades["previous_weight"], + "New weight": trades["new_weight"], + "Weight change": trades["weight_change"], + "Trigger": trades["trigger_reason_code"], + "Trigger detail": trades["trigger_reason_detail_code"], + "Adjustments": trades["adjustment_reason_codes"].map(_format_adjustments), + "Position origin": trades["position_strategy_origin_code"], + "Position origin date": trades["position_strategy_origin_timestamp"], + "Details": details, + "Reference price": trades["reference_price"], + "Traded notional": trades["traded_notional"], + "Total cost": trades["total_cost"], + } + ) + assert list(display_trades.columns) == _TRADE_TABLE_DISPLAY_COLUMNS st.dataframe( - trades[display_cols], + display_trades, width="stretch", height=320, hide_index=True, column_config={ - "weight_change": st.column_config.NumberColumn( + "Timestamp": st.column_config.DatetimeColumn( + "Timestamp", format="YYYY-MM-DD HH:mm" + ), + "Previous weight": st.column_config.NumberColumn( + "Previous weight", format="percent" + ), + "New weight": st.column_config.NumberColumn("New weight", format="percent"), + "Weight change": st.column_config.NumberColumn( "Weight change", format="percent" ), - "traded_notional": st.column_config.NumberColumn( + "Trigger": st.column_config.TextColumn( + "Trigger", + help="The single most-upstream event that initiated the " + "target change. Blank when reason attribution wasn't " + "available for this run (e.g. a walk-forward " + "out-of-sample result) or when nothing upstream changed.", + ), + "Trigger detail": st.column_config.TextColumn( + "Trigger detail", + help="The strategy's own precise sub-cause for the " + "trigger, when identifiable. Blank is not a bug -- " + "portfolio_rebalance/volatility_target_adjustment " + "legitimately have no sub-cause.", + ), + "Adjustments": st.column_config.TextColumn( + "Adjustments", + help="Every downstream layer that modified, delayed, " + "redistributed, constrained or forced the executed size " + "-- can combine several causes on the same trade (e.g. " + "a constraint AND turnover_cap together). Blank means no " + "adjustment layer acted on this trade.", + ), + "Position origin": st.column_config.TextColumn( + "Position origin", + help="Origin of the currently active strategic regime " + "for this symbol/leg -- not necessarily the origin of " + "the currently executed weight, nor an execution " + "timestamp. Always shown, even when it duplicates " + "Trigger. Blank means no strategic position is " + "currently active (flat), never a value hidden for " + "brevity.", + ), + "Position origin date": st.column_config.DatetimeColumn( + "Position origin date", + format="YYYY-MM-DD HH:mm", + help="Date of the strategic transition that created the " + "currently active regime -- can be much earlier than " + "this trade's own date.", + ), + "Details": st.column_config.TextColumn( + "Details", + help="Trigger/adjustment free text combined for quick " + "reading. The machine-readable columns this is built " + "from remain intact in the CSV export below.", + ), + "Reference price": st.column_config.NumberColumn( + "Reference price", + format="%.2f", + help="The prior-period price used by the backtest to " + "value this trade and compute its notional/costs -- not " + "a real fill price from an order book.", + ), + "Traded notional": st.column_config.NumberColumn( "Traded notional (currency units)", format="localized" ), - "total_cost": st.column_config.NumberColumn( + "Total cost": st.column_config.NumberColumn( "Total cost (currency units)", format="localized" ), }, diff --git a/src/quantlab/dashboard/explorer/__init__.py b/src/quantlab/dashboard/explorer/__init__.py new file mode 100644 index 0000000..5fb7747 --- /dev/null +++ b/src/quantlab/dashboard/explorer/__init__.py @@ -0,0 +1,30 @@ +"""Strategy Explorer: the dashboard's per-strategy research/education mode. + +The registry in :mod:`quantlab.dashboard.explorer.profile` is this +package's public extension point: a new strategy gains a gallery card and +detail page purely by registering a :class:`StrategyProfile` in its own +``explorer/profiles/.py`` module (mirroring how +``quantlab.strategies.base.register_strategy`` works for trading logic +itself) -- nothing in ``app.py``/``cli.py``/``html_report.py`` needs to +change or name the new strategy. +""" + +from __future__ import annotations + +from quantlab.dashboard.explorer.profile import ( + ParameterDoc, + ResultsDiagnostics, + StrategyProfile, + available_profiles, + get_profile, + register_profile, +) + +__all__ = [ + "ParameterDoc", + "ResultsDiagnostics", + "StrategyProfile", + "available_profiles", + "get_profile", + "register_profile", +] diff --git a/src/quantlab/dashboard/explorer/detail.py b/src/quantlab/dashboard/explorer/detail.py new file mode 100644 index 0000000..0612a05 --- /dev/null +++ b/src/quantlab/dashboard/explorer/detail.py @@ -0,0 +1,93 @@ +"""Strategy Explorer detail page: full profile + interactive lab. + +Section layout intentionally merges "Mathematical definition" and +"Signals" into one expander -- every bundled profile documents its signal +pipeline as part of the same walk-through, so a separate, identically +worded section would just be a duplicate, not new information. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from quantlab.dashboard.explorer.profile import ParameterDoc + + +def render(st: Any, strategy_name: str) -> None: + """Render the detail page for one strategy, or a fallback if unregistered.""" + from quantlab.dashboard.explorer.profile import get_profile + + if st.button("<- Back to gallery", key="explorer_back"): + st.session_state.pop("explorer_strategy", None) + st.rerun() + return + + profile = get_profile(strategy_name) + if profile is None: + st.warning( + f"No Strategy Explorer content is registered yet for " + f"'{strategy_name}'. It is still fully usable in Backtest/" + "Walk-forward mode -- only this page's documentation is " + "missing." + ) + return + + st.title(profile.display_name) + st.caption(profile.category) + + with st.expander("Overview", expanded=True): + st.markdown(profile.overview_md) + with st.expander("Economic intuition"): + st.markdown(profile.economic_intuition_md) + with st.expander("Mathematical definition & signals"): + st.markdown(profile.mathematical_definition_md) + with st.expander("Assumptions"): + st.markdown(profile.assumptions_md) + with st.expander("Parameters"): + _render_parameters(st, profile.parameters) + with st.expander("Diagnostics"): + st.markdown(profile.diagnostics_md) + # A plain `st.expander` still runs its body every rerun even while + # collapsed -- this one does real work (data loads, OLS fits, ADF/ + # cointegration tests, chart builds), so it uses the stateful/lazy + # variant (`key` + `on_change="rerun"`) instead: `.open` reports + # whether it is actually expanded, and the lab only runs then. Simply + # visiting this page (or interacting with any OTHER widget on it) no + # longer silently re-triggers the lab's full computation. + lab_expander = st.expander( + "Interactive laboratory", + key=f"explorer_lab_expander_{strategy_name}", + on_change="rerun", + ) + if lab_expander.open: + with lab_expander: + profile.lab(st) + with st.expander("Interpretation"): + st.markdown(profile.interpretation_md) + with st.expander("Limitations & failure modes"): + st.markdown(profile.limitations_md) + if profile.references_md: + with st.expander("References / Further reading"): + st.markdown(profile.references_md) + + +def _render_parameters(st: Any, parameters: list[ParameterDoc]) -> None: + if not parameters: + st.caption("This strategy has no configurable parameters.") + return + for index, parameter in enumerate(parameters): + st.markdown(f"**`{parameter.name}`** -- default: `{parameter.default}`") + st.markdown( + f"- **What**: {parameter.what}\n" + f"- **Where**: {parameter.where}\n" + f"- **Why**: {parameter.why}\n" + f"- **Typical range**: {parameter.typical_range}\n" + f"- **Increasing it**: {parameter.effect_increase}\n" + f"- **Decreasing it**: {parameter.effect_decrease}\n" + f"- **Trade-offs**: {parameter.tradeoffs}" + ) + if parameter.interactions: + st.markdown(f"- **Interactions**: {parameter.interactions}") + if index < len(parameters) - 1: + st.divider() diff --git a/src/quantlab/dashboard/explorer/gallery.py b/src/quantlab/dashboard/explorer/gallery.py new file mode 100644 index 0000000..df2805d --- /dev/null +++ b/src/quantlab/dashboard/explorer/gallery.py @@ -0,0 +1,83 @@ +"""Strategy Explorer gallery: a card per registered strategy. + +Always driven by ``available_strategies()``, never a hard-coded list -- a +strategy without a registered profile still gets a card (with a +"documentation coming soon" placeholder) instead of silently disappearing. + +Every card is the same fixed height (so a short overview and a long one +produce identically-sized cards), with a visible "Open" button that +navigates to that strategy's detail page. An earlier design instead +stretched an invisible ``st.button`` to cover the whole card via absolute +positioning -- unreliable across Streamlit versions/themes since it +depended on the exact DOM structure of Streamlit-internal containers, not +a documented API -- a plain, visible button is simpler and actually works. + +The fixed height is applied via this CSS, NOT ``st.container``'s own +``height=`` parameter: that parameter always renders as a scrollable region +(``overflow-y: auto``) regardless of whether the content actually overflows, +which is exactly the stray scrollbar this design avoids -- ``overflow: +hidden`` here clips instead of scrolling (the truncated summary text below +is already sized to fit, so clipping is not expected to ever trigger in +practice). +""" + +from __future__ import annotations + +from typing import Any + +_CARD_HEIGHT = 230 +_SUMMARY_CHAR_LIMIT = 150 + +_CARD_HEIGHT_CSS = f""" + +""" + + +def _truncate(text: str, limit: int) -> str: + """Truncate on a word boundary. + + Keeps every card's summary the same rough length regardless of how + long that strategy's overview is. + """ + text = text.strip() + if len(text) <= limit: + return text + return text[:limit].rsplit(" ", 1)[0].rstrip(",.;:-") + "..." + + +def render(st: Any) -> None: + """Render the gallery of strategy cards.""" + from quantlab.dashboard.explorer.profile import get_profile + from quantlab.strategies.base import available_strategies + + st.subheader("Strategies") + st.caption( + "Pick a strategy to explore its economics, mathematics, " + "assumptions, diagnostics and an interactive research lab." + ) + st.html(_CARD_HEIGHT_CSS) + strategies = available_strategies() + columns = st.columns(3) + for index, name in enumerate(strategies): + profile = get_profile(name) + column = columns[index % 3] + with ( + column, + st.container(key=f"explorer_card_{name}", border=True), + ): + if profile is not None: + st.markdown(f"##### {profile.display_name}") + st.caption(profile.category) + summary = profile.overview_md.strip().split("\n\n")[0] + st.write(_truncate(summary, _SUMMARY_CHAR_LIMIT)) + else: + st.markdown(f"##### {name}") + st.caption("Documentation coming soon.") + if st.button("Open", key=f"explorer_open_{name}", width="stretch"): + st.session_state["explorer_strategy"] = name + st.rerun() diff --git a/src/quantlab/dashboard/explorer/labs/__init__.py b/src/quantlab/dashboard/explorer/labs/__init__.py new file mode 100644 index 0000000..a18d279 --- /dev/null +++ b/src/quantlab/dashboard/explorer/labs/__init__.py @@ -0,0 +1,3 @@ +"""Per-strategy interactive lab render functions, referenced by profiles.""" + +from __future__ import annotations diff --git a/src/quantlab/dashboard/explorer/labs/buy_and_hold.py b/src/quantlab/dashboard/explorer/labs/buy_and_hold.py new file mode 100644 index 0000000..e4f3cee --- /dev/null +++ b/src/quantlab/dashboard/explorer/labs/buy_and_hold.py @@ -0,0 +1,114 @@ +"""Interactive Buy & Hold lab. + +Price explorer, plus the strategy's own counter-intuitive point: a +"buy and hold" *signal* does not, in a real portfolio held at fixed share +counts, mean *static weights* once more than one asset is involved -- this +lab illustrates that theoretical drift. QuantLab's own accounting engine +does NOT currently reproduce it (see the caption near the chart below and +docs/limitations.md's "Rebalancing is a step function" note): it holds +weights constant between rebalance dates by construction, a deliberate +vectorised-backtest simplification, not (yet) a price-driven recomputation. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + + +def render(st: Any) -> None: + """Render the Buy & Hold interactive lab.""" + from quantlab.dashboard.explorer.shared_components import ( + load_explorer_prices_cached, + render_price_chart, + render_symbol_and_source_picker, + ) + from quantlab.dashboard.state import default_end_date + from quantlab.data.base import price_matrix + + st.markdown("#### Price explorer") + picker_result = render_symbol_and_source_picker( + st, + key_prefix="explorer_bh", + default_symbols=("SPY", "QQQ"), + ) + if picker_result is None: + st.info("Pick at least one symbol above to continue.") + return + symbols, source, calendar, use_bundled_demo_data = picker_result + col_start, col_end = st.columns(2) + start_date = col_start.date_input( + "Start date", value=date(2019, 1, 1), key="explorer_bh_start" + ) + end_date = col_end.date_input( + "End date", value=default_end_date(), key="explorer_bh_end" + ) + + try: + data = load_explorer_prices_cached( + st, + symbols, + source=source, + calendar=calendar, + start_date=start_date, + end_date=end_date, + use_bundled_demo_data=use_bundled_demo_data, + ) + except Exception as exc: + logger.exception("Buy & Hold lab: could not load data") + st.error(f"Could not load data: {exc}") + return + prices = price_matrix(data) + available = [symbol for symbol in symbols if symbol in prices.columns] + if not available: + st.warning("None of the selected symbols have data in this range.") + return + render_price_chart( + st, {symbol: prices[symbol] for symbol in available}, title="Price" + ) + + if len(available) < 2: + st.info( + "Pick a second symbol below to see why 'always invested' does " + "not mean 'static weights' once more than one asset is held." + ) + return + + st.markdown("#### Why weights would drift even though the signal never changes") + st.caption( + "buy_and_hold's own signal is simply 'invested wherever price data " + "exists' -- it never rebalances by itself. In a REAL portfolio held " + "at fixed share counts, each asset's OWN return would move its " + "share of the total value, so realized weights would drift away " + "from equal (or whatever the initial split was) purely from price " + "divergence, well before any portfolio-level rebalance schedule " + "intervenes." + ) + initial_weight = 1.0 / len(available) + normalized = prices[available] / prices[available].iloc[0] + drifted_value = normalized * initial_weight + drifted_weights = drifted_value.div(drifted_value.sum(axis=1), axis=0) + render_price_chart( + st, + {symbol: drifted_weights[symbol] for symbol in available}, + title="Theoretical weight drift with no rebalancing (starting " + f"equal at {initial_weight:.0%} each) -- illustrative, not what " + "QuantLab's accounting currently reproduces", + yaxis_title="Weight", + ) + st.caption( + "**This chart is a theoretical illustration of share-count drift, " + "not a preview of a QuantLab backtest.** QuantLab's own accounting " + "engine currently holds weights CONSTANT between rebalance dates " + "by construction (a deliberate vectorised-backtest simplification " + "-- see docs/limitations.md, 'Rebalancing is a step function'), so " + "it does not (yet) reproduce the drift shown above. In a real " + "portfolio, the configured `rebalance_frequency` would periodically " + "reset this drift back toward target -- less often means more " + "drift between resets, more often means closer to the target split " + "but more turnover/costs." + ) diff --git a/src/quantlab/dashboard/explorer/labs/cross_sectional_momentum.py b/src/quantlab/dashboard/explorer/labs/cross_sectional_momentum.py new file mode 100644 index 0000000..7aa871f --- /dev/null +++ b/src/quantlab/dashboard/explorer/labs/cross_sectional_momentum.py @@ -0,0 +1,305 @@ +"""Interactive Cross-Sectional Momentum lab. + +Momentum formation -> asset ranking on a chosen date -> momentum +persistence -> parameter comparison. Uses the exact same +``quantlab.features.momentum``/``cross_sectional`` functions +``CrossSectionalMomentumStrategy`` itself calls. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +import pandas as pd + +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + + +def render(st: Any) -> None: + """Render the Cross-Sectional Momentum interactive lab.""" + from quantlab.dashboard.explorer.shared_components import ( + load_explorer_prices_cached, + render_price_chart, + render_symbol_and_source_picker, + strong, + ) + from quantlab.dashboard.state import default_end_date + from quantlab.data.base import price_matrix + from quantlab.features.cross_sectional import select_top_bottom + from quantlab.features.momentum import ( + cross_sectional_momentum_persistence, + momentum, + momentum_persistence, + ) + + st.markdown("#### Universe and momentum formation") + picker_result = render_symbol_and_source_picker( + st, + key_prefix="explorer_csmom", + default_symbols=("SPY", "QQQ", "TLT", "GLD"), + ) + if picker_result is None: + st.info("Pick at least two symbols above to continue.") + return + symbols, source, calendar, use_bundled_demo_data = picker_result + col_start, col_end = st.columns(2) + start_date = col_start.date_input( + "Start date", value=date(2019, 1, 1), key="explorer_csmom_start" + ) + end_date = col_end.date_input( + "End date", value=default_end_date(), key="explorer_csmom_end" + ) + if len(symbols) < 2: + st.info("Pick at least two symbols above to continue.") + return + + try: + data = load_explorer_prices_cached( + st, + symbols, + source=source, + calendar=calendar, + start_date=start_date, + end_date=end_date, + use_bundled_demo_data=use_bundled_demo_data, + ) + except Exception as exc: + logger.exception("Cross-Sectional Momentum lab: could not load data") + st.error(f"Could not load data: {exc}") + return + prices = price_matrix(data) + available = [symbol for symbol in symbols if symbol in prices.columns] + if len(available) < 2: + st.warning("Fewer than two selected symbols have data in this range.") + return + prices = prices[available] + + col_look, col_skip = st.columns(2) + lookback = col_look.slider( + "lookback_period", 21, 504, 252, key="explorer_csmom_lookback" + ) + skip = col_skip.slider( + "skip_period", 0, min(63, lookback - 1), 21, key="explorer_csmom_skip" + ) + scores = momentum(prices, lookback, skip) + render_price_chart( + st, + {symbol: scores[symbol] for symbol in available}, + title="Momentum score per symbol", + yaxis_title="Momentum score", + ) + + st.markdown("#### Asset ranking on a chosen date") + valid_dates = scores.dropna(how="all").index + if len(valid_dates) == 0: + st.info("No date has a defined momentum score yet -- widen the range.") + return + chosen_date = st.select_slider( + "Date", + options=list(valid_dates), + value=valid_dates[-1], + key="explorer_csmom_date", + format_func=lambda d: d.strftime("%Y-%m-%d"), + ) + col_top, col_short, col_bottom = st.columns(3) + top_fraction = col_top.slider( + "top_fraction", 0.1, 1.0, 0.25, 0.05, key="explorer_csmom_top" + ) + long_short = col_short.checkbox( + "long_short (also short the bottom fraction)", + value=False, + key="explorer_csmom_long_short", + ) + bottom_fraction = ( + col_bottom.slider( + "bottom_fraction", + 0.0, + 1.0 - top_fraction, + min(0.25, 1.0 - top_fraction), + 0.05, + key="explorer_csmom_bottom", + ) + if long_short + else 0.0 + ) + signal_scaling = st.selectbox( + "signal_scaling", + ["binary", "continuous"], + key="explorer_csmom_signal_scaling", + help="binary weights every selected asset identically. continuous " + "weights each selected asset by its RANK within its own selected " + "leg, divided by that leg's own selected count -- illustrated on " + "the stop-loss/take-profit chart below via the strategy's real " + "generate_signals().", + ) + row = scores.loc[[chosen_date]] + selection = select_top_bottom(row, top_fraction, bottom_fraction) + ranking = pd.DataFrame( + { + "Momentum score": row.iloc[0], + "Rank": row.iloc[0].rank(ascending=False), + "Selected": selection.iloc[0].map({1.0: "top", 0.0: "no", -1.0: "bottom"}), + } + ).sort_values("Rank") + st.dataframe(ranking, width="stretch") + + st.markdown("#### Cross-sectional momentum persistence") + st.caption( + "The question this strategy actually trades: on each date, do " + "assets ranked higher on momentum go on to earn higher subsequent " + "returns than assets ranked lower, RELATIVE TO EACH OTHER? A " + "single asset's own serial correlation (see the time-series " + "diagnostic below) is neither necessary nor sufficient for this. " + "When long_short is disabled, the bottom group below is a research " + "comparison only -- it is not a short book held by the strategy." + ) + holding_period = st.slider( + "holding_period (for the future return)", + 1, + 126, + 21, + key="explorer_csmom_holding", + ) + # When long_short is disabled, top_fraction alone can still legitimately + # reach 1.0 (a valid strategy configuration -- select the whole universe + # as "top"). Reusing it verbatim as the comparison bottom fraction would + # then push top_fraction + bottom_fraction past 1 and make + # select_top_bottom() raise. comparison_bottom_fraction is capped to + # what actually fits, and exists purely for this diagnostic comparison + # -- it never governs a real short book (see the caption above). + comparison_bottom_fraction = min(top_fraction, max(0.0, 1.0 - top_fraction)) + effective_bottom = bottom_fraction if long_short else comparison_bottom_fraction + persistence = cross_sectional_momentum_persistence( + prices, + lookback, + skip, + holding_period, + top_fraction=top_fraction, + bottom_fraction=effective_bottom, + ) + if persistence.empty: + st.info( + "Not enough dates with at least 3 scored assets to compute " + "cross-sectional persistence yet -- widen the date range, " + "shorten lookback_period, or add more symbols to the universe." + ) + else: + render_price_chart( + st, + {"Rank correlation": persistence["rank_correlation"]}, + title="Spearman rank correlation: momentum score vs. subsequent " + "return, across the universe", + yaxis_title="Rank correlation", + ) + render_price_chart( + st, + {"Top - bottom spread return": persistence["top_minus_bottom"]}, + title=f"Realized top({top_fraction:.0%}) minus bottom" + f"({effective_bottom:.0%}) {holding_period}-period return", + yaxis_title="Return", + ) + mean_corr = persistence["rank_correlation"].mean() + mean_spread = persistence["top_minus_bottom"].mean() + st.caption( + f"Mean rank correlation over this sample: {strong(f'{mean_corr:.3f}')}. " + f"Mean top-minus-bottom spread: {strong(f'{mean_spread:.3%}')}. " + "Descriptive sample evidence, not a hypothesis test -- overlapping " + "holding periods across consecutive dates are not independent " + "observations.", + unsafe_allow_html=True, + ) + + st.markdown("#### Time-series diagnostic (for comparison)") + st.caption( + "A single asset's own past-momentum-vs-future-return relationship " + "-- this is the TIME-SERIES momentum question (see the Time-Series " + "Momentum strategy page), not what cross-sectional momentum " + "actually trades. Shown here only as a point of comparison." + ) + persistence_symbol = st.selectbox( + "Symbol", available, key="explorer_csmom_persist_symbol" + ) + paired = momentum_persistence( + prices[persistence_symbol], lookback, skip, holding_period + ) + if paired.empty: + st.info("Not enough history to pair momentum with a future return yet.") + else: + import plotly.graph_objects as go + + fig = go.Figure( + go.Scatter( + x=paired["past_momentum"], + y=paired["future_return"], + mode="markers", + marker={"size": 5, "opacity": 0.5}, + ) + ) + fig.update_layout( + title=f"{persistence_symbol}: past momentum vs. subsequent " + f"{holding_period}-period return (time-series diagnostic)", + xaxis_title="Past momentum score", + yaxis_title="Future return", + height=380, + ) + st.plotly_chart(fig, width="stretch") + correlation = paired["past_momentum"].corr(paired["future_return"]) + st.caption( + f"Correlation between the two columns above: " + f"{strong(f'{correlation:.3f}')}.", + unsafe_allow_html=True, + ) + + st.markdown("#### Parameter comparison") + compare_symbol = st.selectbox( + "Symbol", available, key="explorer_csmom_compare_symbol" + ) + lookbacks_to_compare = st.multiselect( + "lookback_period values to compare", + options=[63, 126, 189, 252, 378, 504], + default=[126, 252, 504], + key="explorer_csmom_compare_lookbacks", + ) + if lookbacks_to_compare: + series = { + f"lookback={lb}": momentum(prices[compare_symbol], lb, skip) + for lb in lookbacks_to_compare + } + render_price_chart( + st, + series, + title=f"{compare_symbol}: momentum score at different lookbacks", + yaxis_title="Momentum score", + ) + + try: + from quantlab.dashboard.explorer.shared_components import ( + render_stop_loss_take_profit_illustration, + ) + from quantlab.strategies.momentum import CrossSectionalMomentumStrategy + + cs_strategy = CrossSectionalMomentumStrategy( + lookback_period=lookback, + skip_period=skip, + top_fraction=top_fraction, + bottom_fraction=bottom_fraction, + long_short=long_short, + signal_scaling=signal_scaling, + ) + cs_signals = cs_strategy.generate_signals(data) + except Exception as exc: + logger.exception( + "Cross-Sectional Momentum lab: could not compute signals for " + "the stop-loss/take-profit illustration" + ) + st.error(f"Could not compute the selection signal for this illustration: {exc}") + else: + render_stop_loss_take_profit_illustration( + st, + cs_signals[compare_symbol], + prices[compare_symbol], + key_prefix="explorer_csmom", + ) diff --git a/src/quantlab/dashboard/explorer/labs/mean_reversion.py b/src/quantlab/dashboard/explorer/labs/mean_reversion.py new file mode 100644 index 0000000..8ca8986 --- /dev/null +++ b/src/quantlab/dashboard/explorer/labs/mean_reversion.py @@ -0,0 +1,318 @@ +"""Interactive Mean Reversion lab. + +Price explorer -> indicator comparison (the three primary indicators +`MeanReversionStrategy` offers in the main UI, on the SAME data) -> the +real backtestable state machine (calling `MeanReversionStrategy` directly, +for whichever `indicator` is selected) -> stationarity diagnostics (ADF, +half-life, Hurst). Every indicator call is the exact function +`MeanReversionStrategy` itself uses -- nothing here is a second +implementation. +""" + +from __future__ import annotations + +import math +from datetime import date +from typing import Any + +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + + +def render(st: Any) -> None: + """Render the Mean Reversion interactive lab (see module docstring).""" + from quantlab.dashboard.explorer.shared_components import ( + centered_indicator_threshold_overlay, + load_explorer_prices_cached, + render_price_chart, + render_stationarity_card, + render_stop_loss_take_profit_illustration, + render_symbol_and_source_picker, + strong, + ) + from quantlab.dashboard.state import default_end_date + from quantlab.data.base import price_matrix + from quantlab.features.mean_reversion import ( + half_life, + rolling_percentile_rank, + rolling_zscore, + rsi, + ) + from quantlab.features.stationarity import adf_test, hurst_exponent + + st.markdown("#### Price explorer") + picker_result = render_symbol_and_source_picker( + st, + key_prefix="explorer_mr", + default_symbols=("SPY", "QQQ", "TLT", "GLD"), + ) + if picker_result is None: + st.info("Pick at least one symbol above to load price data.") + return + symbols, source, calendar, use_bundled_demo_data = picker_result + symbol = st.selectbox("Symbol to analyze", symbols, key="explorer_mr_symbol") + col_start, col_end = st.columns(2) + start_date = col_start.date_input( + "Start date", value=date(2019, 1, 1), key="explorer_mr_start" + ) + end_date = col_end.date_input( + "End date", value=default_end_date(), key="explorer_mr_end" + ) + + try: + data = load_explorer_prices_cached( + st, + symbols, + source=source, + calendar=calendar, + start_date=start_date, + end_date=end_date, + use_bundled_demo_data=use_bundled_demo_data, + ) + except Exception as exc: + logger.exception("Mean Reversion lab: could not load data for %s", symbol) + st.error(f"Could not load data for {symbol}: {exc}") + return + prices_frame = price_matrix(data) + if symbol not in prices_frame.columns: + st.warning(f"No data for {symbol} in this range.") + return + prices = prices_frame[symbol] + render_price_chart(st, {symbol: prices}, title=f"{symbol} price") + + st.markdown("#### Indicator comparison") + st.caption( + "Three different ways of asking 'how far from normal is this price " + "right now' -- the three primary indicators `MeanReversionStrategy` " + "offers via `indicator` -- compared on the same data. (Two further " + "indicators, Bollinger Bands and distance-to-MA, are also " + "implemented and usable programmatically -- see the Mathematical " + "definition below -- and not shown here: Bollinger's %B is a close " + "affine variant of the rolling z-score, while distance-to-MA " + "normalizes by price level rather than volatility and can diverge " + "from the z-score materially.)" + ) + col_rsi, col_z, col_pct = st.columns(3) + use_rsi = col_rsi.checkbox("RSI", value=True, key="explorer_mr_use_rsi") + use_zscore = col_z.checkbox( + "Rolling z-score", value=True, key="explorer_mr_use_zscore" + ) + use_percentile = col_pct.checkbox( + "Percentile rank", value=True, key="explorer_mr_use_percentile" + ) + + if use_rsi: + rsi_window = st.slider("RSI window", 2, 60, 14, key="explorer_mr_rsi_window") + render_price_chart( + st, + {"RSI": rsi(prices, rsi_window)}, + title="RSI (30/70 conventionally mark oversold/overbought)", + yaxis_title="RSI", + ) + + if use_zscore: + z_window = st.slider( + "Z-score window", 2, 200, 20, key="explorer_mr_zscore_window" + ) + zscore = rolling_zscore(prices, z_window) + render_price_chart( + st, + {"Z-score": zscore}, + title="Rolling z-score (this is exactly what MeanReversionStrategy " + "trades when indicator='zscore', its default -- see the State " + "machine section below for entry/exit/stop thresholds overlaid " + "on whichever indicator is selected there)", + yaxis_title="Z-score", + ) + + if use_percentile: + pct_window = st.slider( + "Percentile window", 5, 200, 20, key="explorer_mr_pct_window" + ) + percentile = rolling_percentile_rank(prices, pct_window) + render_price_chart( + st, + {"Percentile rank": percentile}, + title="Trailing percentile rank (0 = lowest in window, " + "1 = highest, 0.5 = middle)", + yaxis_title="Percentile", + ) + + st.markdown("#### State machine (indicator / entry / exit / stop / long_only)") + st.caption( + "The strategy state emitted by `MeanReversionStrategy` on this " + "data for the parameters below -- computed by calling the real " + "strategy class directly, for whichever indicator is selected. It " + "is still a signal: allocator, constraints, rebalancing and " + "execution determine the final portfolio weight." + ) + from quantlab.dashboard.components import ( + entry_threshold_bounds, + exit_threshold_bounds, + mean_reversion_slider_bounds, + ) + from quantlab.strategies.mean_reversion import UI_INDICATORS + + sm_indicator = st.selectbox( + "indicator", + list(UI_INDICATORS), + key="explorer_mr_sm_indicator", + help="Selecting a different indicator resets the thresholds below " + "to that indicator's own defaults -- a threshold tuned for one " + "indicator's scale is not meaningful on another's.", + ) + ( + entry_min, + entry_max, + entry_default, + exit_default, + stop_max, + stop_default, + step, + ) = mean_reversion_slider_bounds(sm_indicator) + # Asked BEFORE the entry slider (not after) so entry's own bounds can + # already know whether the stop slider will even be rendered -- see + # entry_threshold_bounds's own docstring. + sm_use_stop = st.checkbox( + "stop_threshold enabled", + value=True, + key=f"explorer_mr_sm_use_stop_{sm_indicator}", + ) + entry_min, entry_max = entry_threshold_bounds( + entry_min, entry_max, stop_max, step, stop_enabled=sm_use_stop + ) + entry_default = min(max(entry_default, entry_min), entry_max) + + col_entry, col_exit = st.columns(2) + sm_entry = col_entry.slider( + "entry_threshold", + entry_min, + entry_max, + entry_default, + step, + key=f"explorer_mr_sm_entry_{sm_indicator}", + ) + sm_exit_bounds = exit_threshold_bounds(sm_entry, step) + if sm_exit_bounds is None: + col_exit.caption( + "exit_threshold: 0.0 (the only value possible this close to zero)" + ) + sm_exit = 0.0 + else: + sm_exit_min, sm_exit_max = sm_exit_bounds + sm_exit = col_exit.slider( + "exit_threshold", + sm_exit_min, + sm_exit_max, + min(exit_default, sm_exit_max), + step, + key=f"explorer_mr_sm_exit_{sm_indicator}", + ) + sm_stop = ( + st.slider( + "stop_threshold (limits the indicator's own deviation " + "tolerated, not the realized monetary loss)", + sm_entry + step, + stop_max, + max(stop_default, sm_entry + step), + step, + key=f"explorer_mr_sm_stop_{sm_indicator}", + ) + if sm_use_stop + else None + ) + sm_long_only = st.checkbox( + "long_only (short entries never trigger when True)", + value=True, + key="explorer_mr_sm_long_only", + ) + sm_lookback = st.slider( + "lookback_period (for this state machine)", + 2, + 200, + 20, + key="explorer_mr_sm_lookback", + ) + try: + from quantlab.strategies.mean_reversion import ( + MeanReversionStrategy, + _centered_indicator, + ) + + strategy = MeanReversionStrategy( + lookback_period=sm_lookback, + indicator=sm_indicator, + entry_threshold=sm_entry, + exit_threshold=sm_exit, + stop_threshold=sm_stop, + long_only=sm_long_only, + ) + state = strategy.generate_signals(data)[symbol] + indicator = _centered_indicator( + prices_frame[[symbol]], sm_indicator, sm_lookback, 2.0 + )[symbol] + except Exception as exc: + logger.exception("Mean Reversion lab: could not compute the state machine") + st.error(f"Could not compute the state machine for these parameters: {exc}") + else: + threshold_series, line_colors = centered_indicator_threshold_overlay( + indicator, + f"{sm_indicator} indicator", + entry_threshold=sm_entry, + exit_threshold=sm_exit, + stop_threshold=sm_stop, + ) + render_price_chart( + st, + threshold_series, + title=f"Centered '{sm_indicator}' indicator with entry/exit/stop " + "thresholds", + yaxis_title="Centered indicator", + colors=line_colors, + ) + render_price_chart( + st, + {"Position (state)": state}, + title="MeanReversionStrategy state signal for these parameters", + yaxis_title="Signal state", + ) + time_in_position = float((state != 0.0).mean()) + stop_text = "disabled" if sm_stop is None else f"at {sm_stop:g}" + st.caption( + f"Time in position: {strong(f'{time_in_position:.1%}')} of bars. " + f"{'Long-only' if sm_long_only else 'Long/short'} -- " + f"stop_threshold {strong(stop_text)}.", + unsafe_allow_html=True, + ) + render_stop_loss_take_profit_illustration( + st, state, prices, key_prefix="explorer_mr" + ) + + st.markdown("#### Stationarity tests") + st.caption( + "Is this sample consistent with mean reversion, or does the selected " + "test fail to reject a unit-root model? The answer depends on the " + "sample and test specification; it does not validate profitability." + ) + adf_window = st.slider( + "Test on the trailing N periods", 30, 1000, 252, key="explorer_mr_adf_window" + ) + tested_series = prices.dropna().iloc[-adf_window:] + render_stationarity_card( + st, adf_test(tested_series), label=f"ADF (last {adf_window} periods)" + ) + hl = half_life(tested_series) + hl_text = f"{hl:.1f} periods" if math.isfinite(hl) else "no finite estimate" + hurst = hurst_exponent(tested_series) + hurst_text = f"{hurst:.3f}" if math.isfinite(hurst) else "n/a (too little data)" + col_hl, col_hurst = st.columns(2) + col_hl.metric("Half-life", hl_text) + col_hurst.metric("Hurst exponent", hurst_text) + st.caption( + "H < 0.5 indicates anti-persistence under this estimator and " + "sample (~0.5 a random walk, > 0.5 a trending/persistent series). " + "It is neither a stationarity test nor proof of exploitable mean " + "reversion -- a descriptive estimate on this sample, not a " + "hypothesis test." + ) diff --git a/src/quantlab/dashboard/explorer/labs/pairs_trading.py b/src/quantlab/dashboard/explorer/labs/pairs_trading.py new file mode 100644 index 0000000..371cb1b --- /dev/null +++ b/src/quantlab/dashboard/explorer/labs/pairs_trading.py @@ -0,0 +1,410 @@ +"""Interactive Pairs Trading lab. + +Workflow: universe selection -> correlation screening -> pair inspection -> +hedge ratio -> spread -> stationarity/cointegration -> mean-reversion +characteristics -> trading thresholds. Every number here comes from +``quantlab.features.pairs_diagnostics.compute_pair_diagnostics`` -- the +exact function the Results tab and the HTML report also use, so this lab +shows the same hedge ratio and ADF p-value as a real backtest of the same +pair whenever the data range, symbols, price type and parameters match +(this lab's own controls let a user explore different ones on purpose). +""" + +from __future__ import annotations + +import math +from datetime import date +from typing import Any + +import pandas as pd + +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + + +def render(st: Any) -> None: + """Render the Pairs Trading interactive lab (see module docstring).""" + from quantlab.dashboard.explorer.shared_components import ( + VIABLE_ENTRY_MARKER_COLOR, + centered_indicator_threshold_overlay, + load_explorer_prices_cached, + render_correlation_matrix, + render_price_chart, + render_stationarity_card, + render_symbol_and_source_picker, + strong, + ) + from quantlab.dashboard.state import default_end_date + from quantlab.data.base import price_matrix + from quantlab.features.correlation import correlation_matrix + from quantlab.features.pairs_diagnostics import compute_pair_diagnostics + + st.markdown("#### 1. Universe selection") + picker_result = render_symbol_and_source_picker( + st, + key_prefix="explorer_pairs", + default_symbols=("SPY", "QQQ", "TLT", "GLD"), + ) + if picker_result is None: + st.info("Pick at least two symbols above to continue.") + return + symbols, source, calendar, use_bundled_demo_data = picker_result + col_start, col_end = st.columns(2) + start_date = col_start.date_input( + "Start date", value=date(2019, 1, 1), key="explorer_pairs_start" + ) + end_date = col_end.date_input( + "End date", value=default_end_date(), key="explorer_pairs_end" + ) + + if len(symbols) < 2: + st.info("Pick at least two symbols above to continue.") + return + + try: + data = load_explorer_prices_cached( + st, + symbols, + source=source, + calendar=calendar, + start_date=start_date, + end_date=end_date, + use_bundled_demo_data=use_bundled_demo_data, + ) + except Exception as exc: + logger.exception("Pairs Trading lab: could not load data for %s", symbols) + st.error(f"Could not load data for {symbols}: {exc}") + return + prices = price_matrix(data) + available = [symbol for symbol in symbols if symbol in prices.columns] + if len(available) < 2: + st.warning( + "Fewer than two of the selected symbols have data in this range " + "-- widen the date range or pick different symbols." + ) + return + + st.markdown("#### 2. Correlation analysis") + st.caption( + "Correlation alone is not sufficient for pairs trading: two assets " + "can be highly correlated in returns yet never form a stable, " + "tradable spread. Use this to screen candidates, not to pick a pair." + ) + render_correlation_matrix(st, correlation_matrix(prices[available])) + + st.markdown("#### 3. Pair inspection") + col_a, col_b = st.columns(2) + symbol_a = col_a.selectbox("Symbol A", available, key="explorer_pairs_a") + remaining = [symbol for symbol in available if symbol != symbol_a] or available + symbol_b = col_b.selectbox("Symbol B", remaining, key="explorer_pairs_b") + normalized = prices[[symbol_a, symbol_b]] / prices[[symbol_a, symbol_b]].iloc[0] + render_price_chart( + st, + {symbol_a: normalized[symbol_a], symbol_b: normalized[symbol_b]}, + title="Normalized prices (both start at 1.0)", + yaxis_title="Normalized level", + ) + + st.markdown("#### 4-5. Hedge ratio and spread") + st.caption( + "The hedge ratio (beta) comes from a trailing OLS fit of A on B " + "over `formation_window`; the spread is A minus that fitted line. " + "The sliders below default smaller than PairsTradingStrategy's own " + "defaults (formation_window=252, indicator_window=63) so this lab " + "produces a usable spread on the shorter bundled offline demo " + "date ranges -- widen them to match a real backtest's config." + ) + formation_window = st.slider( + "formation_window (periods)", 20, 500, 100, key="explorer_pairs_formation" + ) + indicator_window = st.slider( + "indicator_window (periods)", 5, 200, 20, key="explorer_pairs_indicator_window" + ) + dynamic = st.checkbox( + "dynamic_hedge_ratio (refit the OLS every period vs. once at formation)", + value=True, + key="explorer_pairs_dynamic", + ) + + try: + diagnostics = compute_pair_diagnostics( + prices, + symbol_a, + symbol_b, + formation_window=formation_window, + indicator_window=indicator_window, + dynamic_hedge_ratio=dynamic, + ) + except Exception as exc: + logger.exception( + "Pairs Trading lab: could not compute diagnostics for %s/%s", + symbol_a, + symbol_b, + ) + st.error(f"Could not compute diagnostics for {symbol_a}/{symbol_b}: {exc}") + return + + render_price_chart( + st, + {"Hedge ratio (beta)": diagnostics.hedge_ratio}, + title="Rolling hedge ratio", + yaxis_title="Beta", + ) + render_price_chart( + st, + {"Spread": diagnostics.spread}, + title=f"{symbol_a}/{symbol_b} spread (A - intercept - beta*B)", + yaxis_title="Spread", + ) + + st.markdown("#### 6. Stationarity and cointegration") + st.caption( + "Distinct questions: is the spread itself stationary (ADF), and are " + "A and B cointegrated as a pair (Engle-Granger)? A pair can pass one " + "and not the other. The displayed full-sample ADF is exploratory; " + "with a dynamic hedge it tests an adaptively assembled rolling spread." + ) + render_stationarity_card(st, diagnostics.adf_result, label="ADF (spread)") + render_stationarity_card( + st, diagnostics.cointegration_result, label="Engle-Granger cointegration" + ) + if diagnostics.rolling_adf_pvalue.notna().any(): + render_price_chart( + st, + {"Rolling ADF p-value": diagnostics.rolling_adf_pvalue}, + title="Causal periodic ADF gate used for new entries", + yaxis_title="p-value", + ) + else: + st.info( + "Not enough history yet for a rolling stationarity check " + "-- widen the date range or shrink formation_window." + ) + + st.markdown("#### 7. Mean-reversion characteristics") + col_hl, col_stab = st.columns(2) + half_life_text = ( + f"{diagnostics.half_life:.1f} periods" + if math.isfinite(diagnostics.half_life) + else "no finite estimate for this sample" + ) + col_hl.metric("Half-life", half_life_text) + col_stab.metric( + "Hedge-ratio stability (std of beta)", + f"{diagnostics.hedge_ratio_stability:.4f}" + if math.isfinite(diagnostics.hedge_ratio_stability) + else "n/a", + ) + st.caption( + "A larger hedge-ratio std means the fitted slope changes more in " + "this sample. It is measured in beta's scale-dependent units, so " + "compare it across settings for the same ordered pair, not across pairs." + ) + + st.markdown("#### 8. Trading thresholds") + st.caption( + "The same three-indicator choice as Mean Reversion (see that " + "strategy's own page), applied to this pair's spread instead of a " + "raw price." + ) + from quantlab.dashboard.components import ( + entry_threshold_bounds, + exit_threshold_bounds, + mean_reversion_slider_bounds, + ) + from quantlab.strategies.pairs_trading import INDICATORS as PAIRS_INDICATORS + from quantlab.strategies.pairs_trading import _centered_spread_indicator + + indicator_choice = st.selectbox( + "indicator", list(PAIRS_INDICATORS), key="explorer_pairs_indicator" + ) + ( + entry_min, + entry_max, + entry_default, + exit_default, + stop_max, + stop_default, + step, + ) = mean_reversion_slider_bounds(indicator_choice) + # Asked BEFORE the entry slider (not after) so entry's own bounds can + # already know whether the stop slider will even be rendered -- see + # entry_threshold_bounds's own docstring. + use_stop = st.checkbox( + "stop_threshold enabled", + value=True, + key=f"explorer_pairs_use_stop_{indicator_choice}", + ) + entry_min, entry_max = entry_threshold_bounds( + entry_min, entry_max, stop_max, step, stop_enabled=use_stop + ) + entry_default = min(max(entry_default, entry_min), entry_max) + entry = st.slider( + "entry_threshold", + entry_min, + entry_max, + entry_default, + step, + key=f"explorer_pairs_entry_{indicator_choice}", + ) + pairs_exit_bounds = exit_threshold_bounds(entry, step) + if pairs_exit_bounds is None: + st.caption("exit_threshold: 0.0 (the only value possible this close to zero)") + exit_ = 0.0 + else: + pairs_exit_min, pairs_exit_max = pairs_exit_bounds + exit_ = st.slider( + "exit_threshold", + pairs_exit_min, + pairs_exit_max, + min(exit_default, pairs_exit_max), + step, + key=f"explorer_pairs_exit_{indicator_choice}", + ) + stop = ( + st.slider( + "stop_threshold (force-closes a position regardless of direction " + "-- limits the indicator's own deviation tolerated, not the " + "realized monetary loss)", + entry + step, + stop_max, + max(stop_default, entry + step), + step, + key=f"explorer_pairs_stop_{indicator_choice}", + ) + if use_stop + else None + ) + use_adf_gate = st.checkbox( + "Require the ADF stationarity gate for a viable entry", + value=True, + key="explorer_pairs_use_adf_gate", + ) + adf_threshold = ( + st.slider( + "adf_pvalue_threshold", + 0.01, + 0.50, + 0.10, + 0.01, + key="explorer_pairs_adf_threshold", + ) + if use_adf_gate + else None + ) + + indicator = _centered_spread_indicator( + diagnostics.spread, indicator_choice, indicator_window + ) + threshold_series, line_colors = centered_indicator_threshold_overlay( + indicator, + f"{indicator_choice} indicator", + entry_threshold=entry, + exit_threshold=exit_, + stop_threshold=stop, + ) + + # A date is a VIABLE entry only when the indicator actually crosses the + # entry threshold AND (if the gate is enabled) the rolling ADF p-value + # at that date is <= adf_threshold -- mirrors PairsTradingStrategy. + # _stationarity_gate's own condition exactly, using the same rolling + # ADF series already displayed in step 6 above (never a second, + # potentially diverging ADF computation). + crosses_entry = (indicator > entry) | (indicator < -entry) + if adf_threshold is not None: + gate_open = diagnostics.rolling_adf_pvalue.reindex(indicator.index) <= ( + adf_threshold + ) + gate_open = gate_open.fillna(False) + else: + gate_open = pd.Series(True, index=indicator.index) + viable = crosses_entry & gate_open + viable_marker = indicator.where(viable) + markers = ( + {"Viable entry (threshold crossed + ADF gate open)": viable_marker} + if viable.any() + else None + ) + marker_colors = ( + {"Viable entry (threshold crossed + ADF gate open)": VIABLE_ENTRY_MARKER_COLOR} + if markers + else {} + ) + render_price_chart( + st, + threshold_series, + title=f"'{indicator_choice}' indicator of the spread, with threshold " + "overlays (not simulated trades)", + yaxis_title="Centered indicator", + markers=markers, + colors={**line_colors, **marker_colors}, + ) + n_viable = int(viable.sum()) + st.caption( + f"{strong(str(n_viable))} bar(s) in this sample are a viable entry: " + "the entry threshold is crossed AND" + + ( + " the ADF gate is open." + if adf_threshold is not None + else " (gate disabled)." + ) + + " Not an entry count: current state, rebalancing and execution " + "can still prevent or delay a trade.", + unsafe_allow_html=True, + ) + if stop is not None: + stop_breaches = int(((indicator > stop) | (indicator < -stop)).sum()) + st.caption( + f"{strong(str(stop_breaches))} bar(s) in this sample cross the " + "stop threshold. If a position were open, the strategy state " + "would request flat; rebalancing and execution determine when " + "the weight changes. This limits further indicator-distance " + "exposure but NOT the realized monetary loss on the " + "way there (gaps/execution delay/costs can still exceed what " + "the indicator distance alone suggests).", + unsafe_allow_html=True, + ) + breaches = int(crosses_entry.sum()) + st.caption( + f"{strong(str(breaches))} bar(s) in this sample cross the entry " + "threshold (before the ADF gate). A higher entry_threshold means " + "fewer threshold breaches; a lower exit_threshold means each trade is " + "held closer to full mean reversion before closing (more time in " + "the trade, less residual edge left uncaptured).", + unsafe_allow_html=True, + ) + + try: + from quantlab.dashboard.explorer.shared_components import ( + render_stop_loss_take_profit_illustration, + ) + from quantlab.strategies.pairs_trading import PairsTradingStrategy + + pair_strategy = PairsTradingStrategy( + symbol_a=symbol_a, + symbol_b=symbol_b, + formation_window=formation_window, + indicator_window=indicator_window, + indicator=indicator_choice, + entry_threshold=entry, + exit_threshold=exit_, + stop_threshold=stop, + dynamic_hedge_ratio=dynamic, + adf_pvalue_threshold=adf_threshold, + ) + pair_signals = pair_strategy.generate_signals(data) + except Exception as exc: + logger.exception( + "Pairs Trading lab: could not compute signals for the " + "stop-loss/take-profit illustration" + ) + st.error(f"Could not compute the pair's signal for this illustration: {exc}") + else: + render_stop_loss_take_profit_illustration( + st, + {symbol_a: pair_signals[symbol_a], symbol_b: pair_signals[symbol_b]}, + {symbol_a: prices[symbol_a], symbol_b: prices[symbol_b]}, + key_prefix="explorer_pairs", + position_groups=((symbol_a, symbol_b),), + ) diff --git a/src/quantlab/dashboard/explorer/labs/time_series_momentum.py b/src/quantlab/dashboard/explorer/labs/time_series_momentum.py new file mode 100644 index 0000000..3a5d0fd --- /dev/null +++ b/src/quantlab/dashboard/explorer/labs/time_series_momentum.py @@ -0,0 +1,210 @@ +"""Interactive Time-Series Momentum lab. + +Momentum formation for a single asset -> comparing the three +`signal_scaling` modes on the SAME score -> momentum persistence -> +volatility diagnostic (for the volatility_adjusted mode). +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +import numpy as np + +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + + +def _periods_per_year_for_calendar(calendar: str) -> int: + """Annualisation factor matching the real strategy's own convention. + + ``TimeSeriesMomentumStrategy``'s own ``periods_per_year`` is injected + from the experiment's own data frequency, not fixed: 365 for a 24/7 + market, 252 for a session-bound one (see its ``ParameterDoc``'s + ``typical_range``). Extracted as its own function so this derivation + is directly testable without needing a Streamlit runtime. + """ + from quantlab.data.calendar import is_247 + + return 365 if is_247(calendar) else 252 + + +def render(st: Any) -> None: + """Render the Time-Series Momentum interactive lab.""" + from quantlab.dashboard.explorer.shared_components import ( + load_explorer_prices_cached, + render_price_chart, + render_stop_loss_take_profit_illustration, + render_symbol_and_source_picker, + strong, + ) + from quantlab.dashboard.state import default_end_date + from quantlab.data.base import price_matrix + from quantlab.features.momentum import ( + momentum, + momentum_persistence, + volatility_adjusted_momentum, + ) + from quantlab.features.returns import simple_returns + from quantlab.features.volatility import realized_volatility + + st.markdown("#### Momentum formation") + picker_result = render_symbol_and_source_picker( + st, + key_prefix="explorer_tsmom", + default_symbols=("SPY", "QQQ", "TLT", "GLD"), + ) + if picker_result is None: + st.info("Pick at least one symbol above to load price data.") + return + symbols, source, calendar, use_bundled_demo_data = picker_result + symbol = st.selectbox("Symbol to analyze", symbols, key="explorer_tsmom_symbol") + col_start, col_end = st.columns(2) + start_date = col_start.date_input( + "Start date", value=date(2019, 1, 1), key="explorer_tsmom_start" + ) + end_date = col_end.date_input( + "End date", value=default_end_date(), key="explorer_tsmom_end" + ) + + try: + data = load_explorer_prices_cached( + st, + symbols, + source=source, + calendar=calendar, + start_date=start_date, + end_date=end_date, + use_bundled_demo_data=use_bundled_demo_data, + ) + except Exception as exc: + logger.exception("Time-Series Momentum lab: could not load data for %s", symbol) + st.error(f"Could not load data for {symbol}: {exc}") + return + prices_frame = price_matrix(data) + if symbol not in prices_frame.columns: + st.warning(f"No data for {symbol} in this range.") + return + prices = prices_frame[symbol] + + col_look, col_skip = st.columns(2) + lookback = col_look.slider( + "lookback_period", 21, 504, 252, key="explorer_tsmom_lookback" + ) + skip = col_skip.slider( + "skip_period", 0, min(63, lookback - 1), 21, key="explorer_tsmom_skip" + ) + score = momentum(prices, lookback, skip) + render_price_chart( + st, {"Momentum score": score}, title=f"{symbol}: raw momentum score" + ) + + st.markdown("#### Comparing signal_scaling modes") + st.caption( + "The same underlying score, mapped to a strategy signal three " + "different ways. These are pre-long_only, pre-allocator values: " + "portfolio construction and execution still determine the final weight." + ) + binary_signal = np.sign(score) + vol_window = st.slider( + "volatility_window (for the volatility_adjusted mode only -- " + "continuous uses its own rolling dispersion of the score, over " + "lookback_period, not this window)", + 5, + 200, + 63, + key="explorer_tsmom_vol_window", + ) + dispersion = score.rolling(lookback, min_periods=min(20, lookback)).std(ddof=1) + continuous_signal = (score / dispersion).clip(-1.0, 1.0) + # Derived from the selected calendar -- not a tunable knob exposed + # here, but must still match the real strategy's own convention (see + # _periods_per_year_for_calendar's own docstring). Fixing this at 252 + # unconditionally used to silently mis-annualise the volatility_ + # adjusted panel below for a 24/7 (e.g. Binance) selection. + periods_per_year = _periods_per_year_for_calendar(calendar) + vol = realized_volatility( + simple_returns(prices), window=vol_window, periods_per_year=periods_per_year + ) + # Calls the exact same public helper TimeSeriesMomentumStrategy itself + # delegates to for this mode, rather than reimplementing the division + # -- a zero-volatility window is masked to NaN there, never silently + # producing a false +-1.0 signal via a stray inf/-inf before `.clip()`. + vol_adjusted_signal = volatility_adjusted_momentum( + prices, lookback, skip, vol_window, periods_per_year + ).clip(-1.0, 1.0) + render_price_chart( + st, + { + "binary": binary_signal, + "continuous": continuous_signal, + "volatility_adjusted": vol_adjusted_signal, + }, + title=f"{symbol}: the same score under each signal_scaling mode", + yaxis_title="Signal", + ) + illustration_mode = st.selectbox( + "Illustrate stop-loss/take-profit on which signal_scaling mode", + ["binary", "continuous", "volatility_adjusted"], + key="explorer_tsmom_illustration_mode", + ) + illustration_signal = { + "binary": binary_signal, + "continuous": continuous_signal, + "volatility_adjusted": vol_adjusted_signal, + }[illustration_mode] + render_stop_loss_take_profit_illustration( + st, illustration_signal.fillna(0.0), prices, key_prefix="explorer_tsmom" + ) + render_price_chart( + st, + {"Annualized volatility": vol}, + title="Volatility used by the volatility_adjusted mode " + f"(annualised at {periods_per_year} periods/year)", + yaxis_title="Volatility", + ) + + st.markdown("#### Momentum persistence") + st.caption( + "Does a high past momentum score actually predict a higher " + "subsequent return for THIS asset, on this data?" + ) + holding_period = st.slider( + "holding_period (for the future return)", + 1, + 126, + 21, + key="explorer_tsmom_holding", + ) + paired = momentum_persistence(prices, lookback, skip, holding_period) + if paired.empty: + st.info("Not enough history to pair momentum with a future return yet.") + return + + import plotly.graph_objects as go + + fig = go.Figure( + go.Scatter( + x=paired["past_momentum"], + y=paired["future_return"], + mode="markers", + marker={"size": 5, "opacity": 0.5}, + ) + ) + fig.update_layout( + title=f"{symbol}: past momentum vs. subsequent {holding_period}-period return", + xaxis_title="Past momentum score", + yaxis_title="Future return", + height=380, + ) + st.plotly_chart(fig, width="stretch") + correlation = paired["past_momentum"].corr(paired["future_return"]) + st.caption( + f"Correlation between the two columns above: " + f"{strong(f'{correlation:.3f}')}. Descriptive sample evidence, not a " + "hypothesis test -- overlapping holding periods across consecutive " + "dates are not independent observations.", + unsafe_allow_html=True, + ) diff --git a/src/quantlab/dashboard/explorer/labs/trend_following.py b/src/quantlab/dashboard/explorer/labs/trend_following.py new file mode 100644 index 0000000..1ec6382 --- /dev/null +++ b/src/quantlab/dashboard/explorer/labs/trend_following.py @@ -0,0 +1,192 @@ +"""Interactive Trend Following lab. + +Fast/slow moving-average crossover -> whipsaw diagnostic (how often the +signal flips) -> trend-strength diagnostic (Efficiency Ratio) -> +parameter comparison. +""" + +from __future__ import annotations + +from datetime import date +from typing import Any + +import numpy as np + +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + + +def render(st: Any) -> None: + """Render the Trend Following interactive lab.""" + from quantlab.dashboard.explorer.shared_components import ( + ENTRY_LINE_COLOR, + EXIT_LINE_COLOR, + load_explorer_prices_cached, + render_price_chart, + render_stop_loss_take_profit_illustration, + render_symbol_and_source_picker, + strong, + ) + from quantlab.dashboard.state import default_end_date + from quantlab.data.base import price_matrix + from quantlab.features.momentum import ma_crossover_signal, moving_average + from quantlab.features.technical import efficiency_ratio + + st.markdown("#### Fast/slow moving-average crossover") + picker_result = render_symbol_and_source_picker( + st, + key_prefix="explorer_trend", + default_symbols=("SPY", "QQQ", "TLT", "GLD"), + ) + if picker_result is None: + st.info("Pick at least one symbol above to load price data.") + return + symbols, source, calendar, use_bundled_demo_data = picker_result + symbol = st.selectbox("Symbol to analyze", symbols, key="explorer_trend_symbol") + col_start, col_end = st.columns(2) + start_date = col_start.date_input( + "Start date", value=date(2019, 1, 1), key="explorer_trend_start" + ) + end_date = col_end.date_input( + "End date", value=default_end_date(), key="explorer_trend_end" + ) + + try: + data = load_explorer_prices_cached( + st, + symbols, + source=source, + calendar=calendar, + start_date=start_date, + end_date=end_date, + use_bundled_demo_data=use_bundled_demo_data, + ) + except Exception as exc: + logger.exception("Trend Following lab: could not load data for %s", symbol) + st.error(f"Could not load data for {symbol}: {exc}") + return + prices_frame = price_matrix(data) + if symbol not in prices_frame.columns: + st.warning(f"No data for {symbol} in this range.") + return + prices = prices_frame[symbol] + + col_fast, col_slow = st.columns(2) + fast_window = col_fast.slider("fast_window", 2, 100, 20, key="explorer_trend_fast") + slow_window = col_slow.slider( + "slow_window", + fast_window + 1, + 300, + max(fast_window + 1, 100), + key="explorer_trend_slow", + ) + fast_ma = moving_average(prices, fast_window) + slow_ma = moving_average(prices, slow_window) + render_price_chart( + st, + { + "Price": prices, + f"Fast MA ({fast_window})": fast_ma, + f"Slow MA ({slow_window})": slow_ma, + }, + title=f"{symbol}: fast/slow moving-average crossover", + # Price is left at Plotly's own default first-trace color; Fast/ + # Slow MA get explicit, visibly distinct colors (matching Results) + # so neither is ever mistaken for the price line itself. + colors={ + f"Fast MA ({fast_window})": ENTRY_LINE_COLOR, + f"Slow MA ({slow_window})": EXIT_LINE_COLOR, + }, + ) + signal = ma_crossover_signal(prices, fast_window, slow_window) + long_only = st.checkbox( + "long_only (a downtrend goes flat instead of short)", + value=True, + key="explorer_trend_long_only", + ) + executable_signal = signal.clip(lower=0.0) if long_only else signal + render_price_chart( + st, + { + "Executable signal (post warm-up fill)": executable_signal.fillna(0.0), + }, + title="Crossover signal -- what generate_signals() actually returns " + f"({'long_only' if long_only else 'long/short'})", + yaxis_title="Signal", + ) + render_stop_loss_take_profit_illustration( + st, executable_signal.fillna(0.0), prices, key_prefix="explorer_trend" + ) + + st.markdown("#### Whipsaw diagnostic") + st.caption( + "How often does the raw crossover direction change? Frequent changes " + "indicate whipsaw pressure, but they create trades and costs only if " + "they change a target sampled at a rebalance date. With long_only=True, " + "this raw diagnostic can also count movements within the clipped-flat " + "region, so it is an upper-bound indicator rather than executed turnover." + ) + flips = signal.diff().fillna(0.0).ne(0.0) + window = st.slider( + "Count flips over the trailing N periods", + 20, + 504, + 126, + key="explorer_trend_flip_window", + ) + rolling_flips = flips.rolling(window, min_periods=1).sum() + render_price_chart( + st, + {f"Raw crossover changes in trailing {window} periods": rolling_flips}, + title="Raw crossover-change frequency over time", + yaxis_title="Flip count", + ) + + st.markdown("#### Trend-strength diagnostic (Efficiency Ratio)") + st.caption( + "Near 1: price moved efficiently in one direction (a clean trend, " + "favourable for this strategy). Near 0: the same net move took a " + "much choppier path (noise dominating -- unfavourable). A perfectly " + "flat window has an undefined 0/0 ratio; QuantLab displays 0.5 for " + "that special case as a neutral convention, not as trend evidence." + ) + er_window = st.slider( + "Efficiency Ratio window", + 5, + 200, + min(slow_window, 200), + key="explorer_trend_er_window", + ) + er = efficiency_ratio(prices, er_window) + render_price_chart( + st, + {"Efficiency Ratio": er}, + title="Kaufman's Efficiency Ratio", + yaxis_title="ER", + ) + st.caption( + f"Median Efficiency Ratio over this sample: " + f"{strong(f'{np.nanmedian(er.to_numpy()):.2f}')}.", + unsafe_allow_html=True, + ) + + st.markdown("#### Parameter comparison") + combos = st.multiselect( + "(fast_window, slow_window) combinations to compare", + options=["(10, 50)", "(20, 100)", "(50, 200)"], + default=["(10, 50)", "(20, 100)", "(50, 200)"], + key="explorer_trend_compare", + ) + if combos: + series = {} + for combo in combos: + fast_str, slow_str = combo.strip("()").split(",") + fast, slow = int(fast_str), int(slow_str) + series[combo] = ma_crossover_signal(prices, fast, slow) + render_price_chart( + st, + series, + title=f"{symbol}: crossover signal at different windows", + yaxis_title="Signal", + ) diff --git a/src/quantlab/dashboard/explorer/profile.py b/src/quantlab/dashboard/explorer/profile.py new file mode 100644 index 0000000..24000d0 --- /dev/null +++ b/src/quantlab/dashboard/explorer/profile.py @@ -0,0 +1,225 @@ +"""Strategy Explorer content registry. + +Mirrors ``quantlab.strategies.base``'s registration pattern: each strategy +declares its own pedagogical profile in ``explorer/profiles/.py``, +registered by calling :func:`register_profile` at import time. The Strategy +Explorer's own dispatch -- the gallery/detail pages and the optional +Results-tab/report diagnostics -- only ever asks "does the current +strategy's profile declare X", never special-cases a strategy by name. This +does not extend to the rest of the dashboard: the regular Backtest/ +Walk-forward sidebar in ``app.py`` still branches on a strategy's name to +render its own config widgets, unrelated to this registry. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pandas as pd + +if TYPE_CHECKING: + from quantlab.config import ExperimentConfig + from quantlab.reporting.sections import DiagnosticsSection + + +@dataclass(frozen=True) +class ParameterDoc: + """Full explanation of one strategy constructor parameter. + + ``what``/``where``/``why`` answer "what is it", "where in the signal + pipeline does it act" and "why does it exist". ``effect_increase``/ + ``effect_decrease`` describe the observable consequence of moving it + in each direction -- the strategy's interactive lab should let a user + actually see this happen, not just read about it (see + :attr:`StrategyProfile.lab`). + """ + + name: str + what: str + where: str + why: str + default: str + typical_range: str + effect_increase: str + effect_decrease: str + tradeoffs: str + interactions: str = "" + + +@dataclass(frozen=True) +class ResultsDiagnostics: + """A strategy's own extra Results-tab/report diagnostics, if any. + + ``compute`` takes the already-loaded canonical price/OHLCV frame and + the experiment config and returns a strategy-specific structured + result (e.g. ``PairDiagnostics``). ``render`` displays that result in + the dashboard Results tab (``st`` injected as the first argument, the + structured result as the second). ``report_section`` turns the same + structured result into a generic :class:`~quantlab.reporting.sections. + DiagnosticsSection` for the HTML report. ``key`` doubles as the + ``robustness`` dict key the CLI attaches it under and the + ``st.session_state`` key the dashboard stores it under -- must be + unique across every registered profile. + """ + + key: str + compute: Callable[[pd.DataFrame, ExperimentConfig], Any] + render: Callable[[Any, Any], None] + report_section: Callable[[Any], DiagnosticsSection] + + +@dataclass(frozen=True) +class StrategyProfile: + """The Strategy Explorer's complete content for one registered strategy. + + ``strategy_name`` must match a name in ``quantlab.strategies.base. + available_strategies()``. Every markdown field is plain text/Markdown + rendered inside a collapsible ``st.expander`` section on the detail + page; none are required to be exhaustive on their own -- the + ``parameters`` list and the interactive ``lab`` carry the bulk of the + "make every parameter genuinely understood" requirement. + """ + + strategy_name: str + display_name: str + category: str + overview_md: str + economic_intuition_md: str + mathematical_definition_md: str + assumptions_md: str + diagnostics_md: str + interpretation_md: str + limitations_md: str + parameters: list[ParameterDoc] + lab: Callable[[Any], None] + references_md: str | None = None + results_diagnostics: ResultsDiagnostics | None = None + + +_REGISTRY: dict[str, StrategyProfile] = {} + +#: Text fields every profile must actually fill in -- an empty one would +#: silently render as a blank expander section rather than fail loudly at +#: registration time. +_REQUIRED_MARKDOWN_FIELDS = ( + "overview_md", + "economic_intuition_md", + "mathematical_definition_md", + "assumptions_md", + "diagnostics_md", + "interpretation_md", + "limitations_md", +) + + +def register_profile(profile: StrategyProfile, *, replace: bool = False) -> None: + """Register a strategy's Strategy Explorer content. + + Raises if a profile is already registered for ``profile.strategy_name`` + unless ``replace=True`` (mirrors ``register_strategy``'s own guard + against accidental double-registration). Also enforces the contracts + ``StrategyProfile``/``ResultsDiagnostics`` document but did not + previously check: ``strategy_name`` names a real registered strategy, + every markdown field actually has content, no ``ParameterDoc`` name is + duplicated, ``compute``/``render``/``report_section`` are callable when + ``results_diagnostics`` is set, and (when it is) its ``key`` does not + collide with another already-registered profile's -- that key doubles + as the ``robustness`` dict key and the dashboard ``st.session_state`` + key, so a collision would silently let two strategies clobber each + other's diagnostics. + """ + if not isinstance(profile, StrategyProfile): + raise TypeError("profile must be a StrategyProfile.") + if profile.strategy_name in _REGISTRY and not replace: + raise ValueError( + f"A profile is already registered for '{profile.strategy_name}'." + ) + # Local import (not module-level): forces `quantlab.strategies` to + # finish registering every built-in strategy right here if a caller + # hasn't already imported it, so this check is correct regardless of + # import order rather than only when this module happens to run after + # `quantlab.strategies` elsewhere. + from quantlab.strategies.base import available_strategies + + if profile.strategy_name not in available_strategies(): + raise ValueError( + f"Profile strategy_name {profile.strategy_name!r} is not a " + f"registered strategy. Registered: {available_strategies()}." + ) + for field in _REQUIRED_MARKDOWN_FIELDS: + if not getattr(profile, field).strip(): + raise ValueError( + f"Profile '{profile.strategy_name}': {field} must not be empty." + ) + if not profile.display_name.strip(): + raise ValueError(f"Profile '{profile.strategy_name}': display_name is empty.") + if not profile.category.strip(): + raise ValueError(f"Profile '{profile.strategy_name}': category is empty.") + parameter_names = [parameter.name for parameter in profile.parameters] + duplicate_parameters = { + name for name in parameter_names if parameter_names.count(name) > 1 + } + if duplicate_parameters: + raise ValueError( + f"Profile '{profile.strategy_name}': duplicate ParameterDoc " + f"name(s) {sorted(duplicate_parameters)}." + ) + for parameter in profile.parameters: + # `interactions` is deliberately excluded: "" is its documented + # default for a parameter that genuinely has none to report. + for parameter_field in ( + "name", + "what", + "where", + "why", + "default", + "typical_range", + "effect_increase", + "effect_decrease", + "tradeoffs", + ): + if not getattr(parameter, parameter_field).strip(): + raise ValueError( + f"Profile '{profile.strategy_name}': ParameterDoc " + f"{parameter.name!r}'s {parameter_field} must not be empty." + ) + if profile.results_diagnostics is not None: + for callback_name in ("compute", "render", "report_section"): + if not callable(getattr(profile.results_diagnostics, callback_name)): + raise ValueError( + f"Profile '{profile.strategy_name}': " + f"results_diagnostics.{callback_name} must be callable." + ) + if not profile.results_diagnostics.key.strip(): + raise ValueError( + f"Profile '{profile.strategy_name}': results_diagnostics.key " + "must not be empty." + ) + key = profile.results_diagnostics.key + colliding = [ + name + for name, existing in _REGISTRY.items() + if name != profile.strategy_name + and existing.results_diagnostics is not None + and existing.results_diagnostics.key == key + ] + if colliding: + raise ValueError( + f"Profile '{profile.strategy_name}': results_diagnostics.key " + f"{key!r} collides with already-registered profile(s) " + f"{colliding} -- this key must be unique across every " + "registered profile." + ) + _REGISTRY[profile.strategy_name] = profile + + +def get_profile(strategy_name: str) -> StrategyProfile | None: + """Return the registered profile for a strategy, or ``None``.""" + return _REGISTRY.get(strategy_name) + + +def available_profiles() -> list[str]: + """Return the names of every strategy with a registered profile.""" + return sorted(_REGISTRY) diff --git a/src/quantlab/dashboard/explorer/profiles/__init__.py b/src/quantlab/dashboard/explorer/profiles/__init__.py new file mode 100644 index 0000000..c7d51a8 --- /dev/null +++ b/src/quantlab/dashboard/explorer/profiles/__init__.py @@ -0,0 +1,21 @@ +"""Importing this package registers every strategy's Strategy Explorer profile. + +Mirrors how ``quantlab.strategies`` triggers each strategy's own +``@register_strategy`` decorator by importing every strategy module -- +``get_profile()``/``available_profiles()`` (``quantlab.dashboard.explorer. +profile``) are the actual public entry points, so nothing here needs +re-exporting. +""" + +from __future__ import annotations + +# Imported for their registration side effects (each module calls +# register_profile() at import time). +from quantlab.dashboard.explorer.profiles import ( + buy_and_hold, # noqa: F401 + cross_sectional_momentum, # noqa: F401 + mean_reversion, # noqa: F401 + pairs_trading, # noqa: F401 + time_series_momentum, # noqa: F401 + trend_following, # noqa: F401 +) diff --git a/src/quantlab/dashboard/explorer/profiles/buy_and_hold.py b/src/quantlab/dashboard/explorer/profiles/buy_and_hold.py new file mode 100644 index 0000000..4d46154 --- /dev/null +++ b/src/quantlab/dashboard/explorer/profiles/buy_and_hold.py @@ -0,0 +1,164 @@ +"""Strategy Explorer profile for ``buy_and_hold``.""" + +from __future__ import annotations + +from typing import Any + +from quantlab.dashboard.explorer.profile import ( + ParameterDoc, + StrategyProfile, + register_profile, +) + +_OVERVIEW = """ +The simplest strategy in QuantLab, and the essential baseline every other +strategy should be compared against: emit a long eligibility signal for +every configured instrument while its price is available, with no timing +decision at all. It has no view on direction, no entry/exit logic, and +(with more than one asset) is not literally "hold forever at fixed +weights" the way the name might suggest -- see the lab below for why. + +Typical horizon: the entire backtest period. Data needed: whatever the +configured instruments have. +""" + +_ECONOMIC_INTUITION = """ +Buy and hold exists as a strategy for two reasons: it can be a long-term +investment approach in its own right (broad, diversified market exposure +seeks to capture a long-run risk premium without the additional turnover +associated with active timing), and it serves as the essential passive +baseline against which every actively timed strategy in this project +should be evaluated. +An active strategy should demonstrate an objective benefit -- for example, +higher risk-adjusted performance or lower drawdown -- after accounting for +its extra transaction costs, turnover, execution risk and model risk. +""" + +_MATH = """ +`generate_signals()` is a single line: `signal = prices.notna().astype +(float)` -- exactly `1.0` wherever a valid price is available for a +symbol and `0.0` otherwise. There is no lookback window, threshold, +ranking rule, or strategy state to track. + +Crucially, these signals are not portfolio weights. They only indicate +which assets are eligible to be held. The actual realized portfolio +weights are determined downstream by the configured allocator and the +`rebalance_frequency`. + +With a single asset, an eligibility signal that remains at `1.0` asks the +allocator to hold that asset. It produces full exposure only in the +absence of downstream scaling or constraints; volatility targeting, +weight/exposure limits, turnover limits and execution timing can all +reduce or delay the final weight. With multiple +assets, however, an eligibility signal of 1.0 for every asset does not +imply a weight of 1.0 in every asset; the allocator determines how +capital is distributed among the eligible assets (see Assumptions / lab +below). +""" + +_ASSUMPTIONS = """ +**Economic**: the configured instrument(s) are assumed to be suitable for +long-term exposure with no active timing view -- more plausible for a broad, +long-term-appropriate holding, less appropriate for something +mean-reverting, cyclical, or otherwise unsuited to indefinite exposure. + +**Portfolio construction**: with more than one instrument, "buy and hold" is +implicitly also a statement about the *rebalance schedule* -- the +strategy itself is silent on target weights, so the configured allocator and +`rebalance_frequency` entirely determine how closely realized weights stay +to any intended allocation. +""" + +_DIAGNOSTICS = """ +The lab below is mostly a price explorer -- there is little to diagnose +about a strategy with no timing parameters. Its main +diagnostic, with two or more symbols selected, illustrates how much a +real portfolio's weights drift away from an equal split purely +because of differences in each asset's return between rebalances -- the +concrete illustration of why "always invested" is not the same claim as +"static weights". This matches what a real QuantLab backtest itself now +does by default (`portfolio.model_weight_drift=True` -- see +docs/backtesting.md's Weight drift section); it stays a standalone, +theoretical illustration only in that it never runs the actual +accounting/cost/compliance-correction pipeline, so it can still diverge +in the details from a genuine backtest of this strategy. +""" + +_INTERPRETATION = """ +As a baseline: any actively timed strategy backtested over the same +instruments and period should be compared against this one's Sharpe, +CAGR and drawdown -- underperforming buy and hold net of costs is a +meaningful signal that the added complexity is not earning its keep on +this data. As a strategy in its own right with multiple assets: the +weight-drift chart shows why `rebalance_frequency` matters even here, +despite this strategy having no parameters of its own -- QuantLab's own +accounting now models this same intra-period drift by default (see +docs/backtesting.md's Weight drift section), so `rebalance_frequency` +genuinely shapes the realized backtest, not only a real portfolio it is +meant to approximate. +""" + +_LIMITATIONS = """ +**No strategy-level risk management**: the signal itself remains long +through a sustained decline -- it has no stop or de-risking rule. The +allocator, volatility target and portfolio constraints may still scale +the final exposure. **No timing skill claim**: by construction it cannot +outperform through market timing because there is none -- its reported +performance instead reflects the returns of the selected instruments +together with the configured allocation, constraints, rebalancing and +execution rules. **Weight drift is modeled by default** +(`portfolio.model_weight_drift=True`) -- each asset's own price move +drifts its executed weight between real trades, matching a real +portfolio held at fixed share counts (illustrated in the lab above); +`model_weight_drift=False` remains available as an explicit legacy/ +reproducibility flag that reproduces the constant-weight step function +instead (see docs/backtesting.md's Weight drift section). +""" + +_PARAMETERS = [ + ParameterDoc( + name="price_type", + what="Which price series ('adjusted_close' or 'close') " + "generate_signals() reads to decide whether a symbol is eligible " + "to be 'held' on a given date. Execution/costs always use the " + "raw close regardless.", + where="The sole input to the strategy's one-line signal.", + why="Determines only WHETHER a price exists on a date (affecting " + "the earliest date a delayed listing becomes eligible), not the " + "position's magnitude -- both price types are non-missing on the " + "same dates for a normal listing, so this rarely changes anything " + "in practice.", + default="adjusted_close", + typical_range="adjusted_close (recommended) or close.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="Essentially none for this strategy specifically; kept " + "for consistency with every other strategy's own signal_price_" + "type configuration.", + interactions="None -- this strategy has no other parameters to interact with.", + ), +] + + +def _lab(st: Any) -> None: + from quantlab.dashboard.explorer.labs.buy_and_hold import render + + render(st) + + +register_profile( + StrategyProfile( + strategy_name="buy_and_hold", + display_name="Buy & Hold", + category="Baseline", + overview_md=_OVERVIEW, + economic_intuition_md=_ECONOMIC_INTUITION, + mathematical_definition_md=_MATH, + assumptions_md=_ASSUMPTIONS, + diagnostics_md=_DIAGNOSTICS, + interpretation_md=_INTERPRETATION, + limitations_md=_LIMITATIONS, + parameters=_PARAMETERS, + lab=_lab, + ) +) diff --git a/src/quantlab/dashboard/explorer/profiles/cross_sectional_momentum.py b/src/quantlab/dashboard/explorer/profiles/cross_sectional_momentum.py new file mode 100644 index 0000000..7328bcf --- /dev/null +++ b/src/quantlab/dashboard/explorer/profiles/cross_sectional_momentum.py @@ -0,0 +1,608 @@ +"""Strategy Explorer profile for ``cross_sectional_momentum``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pandas as pd + +from quantlab.dashboard.explorer.profile import ( + ParameterDoc, + ResultsDiagnostics, + StrategyProfile, + register_profile, +) + +if TYPE_CHECKING: + from quantlab.config import ExperimentConfig + from quantlab.reporting.sections import DiagnosticsSection + +_OVERVIEW = """ +Cross-sectional momentum ranks a universe of assets by their recent +performance and emits long signals for the strongest performers +(optionally short signals for the weakest). Unlike time-series momentum -- +which asks "is THIS asset's own trend up or down" -- cross-sectional +momentum only ever asks a relative +question: "is this asset outperforming the OTHERS in the universe right +now". An asset can have a positive score in a falling universe, or a +negative score in a rising one. + +The score horizon is set by `lookback_period`; the configured rebalance +frequency controls when rankings can change executed targets. Data needed: +a reasonably broad universe of comparable assets (a sector, an asset +class, a country set) with a shared history at least `lookback_period` +long. +""" + +_ECONOMIC_INTUITION = """ +Relative winner-minus-loser momentum has been documented historically over +medium-term horizons (roughly 3-12 months) in several markets. Proposed drivers +include investor underreaction to new information (prices adjust slowly, +not instantly, to genuinely good/bad news) and herding/trend-following +behaviour among market participants. This is a *relative*, not an +absolute, bet: the strategy buys the best performers in the universe, +whatever the universe as a whole is doing. +""" + +_MATH = """ +`generate_signals()`'s pipeline, in order: + +1. **Score** -- `score = momentum(prices, lookback_period, skip_period)` = + `P_{t-skip} / P_{t-lookback} - 1` for every symbol, at every date. +2. **Selection** -- `select_top_bottom(score, top_fraction, bottom_fraction + if long_short else 0.0)` picks the top `top_fraction` of the universe + (by score, that date) as `+1`, and -- only when `long_short=True` -- the + bottom `bottom_fraction` as `-1`. Every other asset is `0`. Selections + are disjoint: an asset is never both top and bottom. The ranking is + computed on every bar, but only values sampled by the portfolio's + rebalance schedule can alter executed targets. +3. **`skip_period`** excludes the most recent periods from the lookback + window -- the classic "12-1" convention (12-month lookback, skip the + most recent month) exists because very recent short-term returns have + sometimes *reversed* rather than continued, which would otherwise partly cancel + out the momentum effect being captured. + +4. **`signal_scaling`** -- `binary` (default) emits the discrete + `{-1, 0, +1}` selection unchanged; `continuous` additionally scales + each selected asset's signal by its RANK within its own selected leg + (see the `signal_scaling` parameter below), still guaranteed monotone + in score and never zero for a selected asset. Either way, how much + capital each selected asset actually gets still also depends on the + portfolio allocator's own job downstream -- only the + `signal_proportional` allocator actually consumes a `continuous` + signal's magnitude; `equal_weight` discards it via `np.sign`. +""" + +_ASSUMPTIONS = """ +**Economic**: the strategy assumes recent relative winners are more likely +to outperform over its horizon in this universe; that may be absent or +sample-specific. +**Statistical**: momentum scores computed the same way across the whole +universe are comparable (broadly similar volatility/liquidity regimes -- +comparing a mega-cap ETF's momentum score directly against a thinly-traded +micro-cap's is less meaningful). **Implementation**: `top_fraction` (and +`bottom_fraction`) leave enough assets in the selection to diversify +idiosyncratic risk, given the configured universe size. +""" + +_DIAGNOSTICS = """ +The lab below shows the momentum score itself for every universe member, +a full ranking + selection snapshot on any chosen date, the actual +cross-sectional test this strategy depends on (does a higher-RANKED +asset go on to earn a higher subsequent return than a lower-ranked one, +across the universe, at each date -- via a Spearman rank correlation and +the realized top-minus-bottom spread return), a single-asset time-series +diagnostic shown only for comparison, and a side-by-side comparison of +the score at several different lookback windows. +""" + +_INTERPRETATION = """ +A positive, reasonably stable rank correlation (and a positive top-minus- +bottom spread) across most dates is descriptive support for the strategy's +core premise on this data; a value near zero or negative weighs against it, +regardless of how a particular backtest's aggregate performance looks. This +is descriptive sample evidence, not a hypothesis test (overlapping holding periods are +not independent observations). The single-asset time-series scatter +answering a DIFFERENT question (does this one asset's own past predict +its own future) can look positive even when the cross-sectional ranking +signal is weak, or vice versa -- they are not substitutes for each other. +In long-only mode the displayed bottom group is a diagnostic comparison, +not a short book the strategy actually trades. +The ranking snapshot is useful for sanity-checking `top_fraction`/ +`bottom_fraction` against the actual universe size -- e.g. +`top_fraction=0.25` on a 4-symbol universe selects exactly one asset, +which is a very different portfolio than the same fraction on 50 symbols. +""" + +_LIMITATIONS = """ +**Momentum crashes**: momentum has historically suffered sharp, sudden +reversals -- most notoriously around the 2009 market bottom -- when +previously beaten-down assets rebound violently, hurting exactly the +long-winners/short-losers positioning momentum takes. **Turnover**: +changes in the top/bottom selection can generate meaningful turnover and +transaction costs when they alter weights at rebalance dates, especially +with a short `skip_period` or a volatile universe where rankings shuffle +often. **Crowding**: momentum +is one of the most widely traded factors; crowded positioning can amplify +the crash risk above. **Small universes**: with few symbols, +`top_fraction`/`bottom_fraction` select very few assets, concentrating +idiosyncratic risk that a "diversified factor" framing usually assumes +away. +""" + +_REFERENCES = ( + 'Jegadeesh & Titman (1993), ["Returns to Buying Winners and Selling ' + 'Losers: Implications for Stock Market Efficiency"](' + "https://doi.org/10.1111/j.1540-6261.1993.tb04702.x), *Journal of " + "Finance* 48(1), is the foundational academic study of cross-sectional/" + "relative momentum -- it examines 3-12 month formation/holding periods " + '(and a 1-week-skip variant), not a literal "12-1" specification; the ' + "specific 12-month-lookback/skip-one-month convention used by default " + "here is a common later variant in the literature/practice, not a direct " + "reproduction of the paper's methodology or evidence for this particular " + "universe." +) + +_PARAMETERS = [ + ParameterDoc( + name="lookback_period", + what="Total look-back window (periods) the momentum score is measured over.", + where="Step 1.", + why="Sets the horizon over which 'recent performance' is defined.", + default="252", + typical_range="126-252 periods (roughly 6-12 months of daily data).", + effect_increase="Captures a longer-horizon trend, less sensitive to " + "short-term noise, but slower to pick up a genuinely new trend.", + effect_decrease="More responsive to a recent shift in relative " + "performance, but noisier and more exposed to short-term reversal.", + tradeoffs="Horizon length vs. responsiveness -- 3-12 month " + "formation/holding horizons are common in the cited foundational study.", + interactions="Must exceed skip_period; interacts with the " + "portfolio's rebalance frequency (a lookback much shorter than the " + "rebalance interval largely resets the ranking every rebalance).", + ), + ParameterDoc( + name="skip_period", + what="Most recent periods excluded from the lookback window.", + where="Step 1, subtracted from the window used for the score.", + why="Very recent short-term returns have sometimes shown reversal; " + "skipping them reduces their influence on the momentum score.", + default="21", + typical_range="0-21 periods (0 to about 1 month of daily data).", + effect_increase="Excludes more recent history from the score -- " + "cleaner separation from short-term reversal, but the score reacts " + "more slowly to a genuine, very recent shift.", + effect_decrease="Includes more recent history -- more responsive, " + "but more exposed to the short-term-reversal effect canceling out " + "part of the momentum signal.", + tradeoffs="Purity of the momentum signal vs. responsiveness.", + interactions="Must be strictly less than lookback_period.", + ), + ParameterDoc( + name="top_fraction", + what="Fraction of the universe (by score, each date) selected as " + "the long side. The count is rounded down, with at least one asset " + "selected whenever the fraction is positive and data is available.", + where="Step 2.", + why="Controls concentration: how many of the best-ranked assets " + "actually get a position.", + default="0.25", + typical_range="0.1-0.5.", + effect_increase="More assets held -- more diversified, closer to " + "the whole universe's own behaviour, weaker tilt toward the very " + "best performers.", + effect_decrease="Fewer assets held -- more concentrated, a purer " + "bet on the top performers specifically, but more idiosyncratic " + "risk per position.", + tradeoffs="Diversification vs. concentration in the strongest signal.", + interactions="With long_short=True, top_fraction + bottom_fraction " + "must not exceed 1; with a small universe, a large fraction can " + "select nearly everyone, diluting the selection to almost nothing.", + ), + ParameterDoc( + name="bottom_fraction", + what="Fraction of the universe selected as the short side -- only " + "used when long_short=True, with the same floor/minimum-one count " + "rule as top_fraction.", + where="Step 2.", + why="Symmetric counterpart to top_fraction for the short leg.", + default="0.25", + typical_range="0.1-0.5.", + effect_increase="More short positions -- more diversified short " + "book, weaker conviction per short.", + effect_decrease="Fewer, higher-conviction short positions.", + tradeoffs="Same as top_fraction, applied to the short side.", + interactions="Ignored entirely when long_short=False; combined " + "with top_fraction must not exceed 1.", + ), + ParameterDoc( + name="long_short", + what="Whether the bottom-ranked assets are actively shorted " + "(True) or simply not held (False, long-only).", + where="Step 2 -- gates whether bottom_fraction has any effect at all.", + why="Many portfolios/mandates cannot or should not short; shorting " + "also adds financing, borrow and short-side risk.", + default="False", + typical_range="Boolean.", + effect_increase="N/A (boolean).", + effect_decrease="N/A (boolean).", + tradeoffs="True represents the classic long-winners/short-losers " + "construction but adds short-specific costs/risks (borrow and " + "unbounded theoretical loss on a runaway short); False is simpler " + "and avoids those, but no longer captures the short leg.", + interactions="bottom_fraction only matters when this is True.", + ), + ParameterDoc( + name="signal_scaling", + what="How the discrete top/bottom selection is expressed as a " + "signal magnitude: 'binary' (every selected asset gets identical " + "+1/-1 weight) or 'continuous' (each selected asset's weight is " + "its RANK within its own selected leg, divided by that leg's own " + "selected count -- e.g. the weakest of 4 selected longs gets " + "0.25, the strongest gets 1.0; the short leg mirrors this on the " + "most-negative-score side).", + where="Final signal output, after the Step 2 selection above.", + why="binary treats every selected name as an equally-strong bet; " + "continuous instead lets the strongest-ranked name in each leg " + "carry a larger weight than one that just barely qualified -- " + "WHICH assets are selected is unchanged either way, only their " + "relative size. Ranked within the leg rather than standardized " + "against the whole cross-section's mean/dispersion, which is NOT " + "guaranteed monotone in score when a leg straddles the " + "cross-sectional mean.", + default="binary", + typical_range="One of 'binary', 'continuous'.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="binary is simplest and treats every selected name " + "identically; continuous adds conviction-weighting within the " + "selection at the cost of a slightly less interpretable weight.", + interactions="Position SIZE within the selection is otherwise " + "entirely the portfolio allocator's responsibility, not this " + "parameter's -- continuous only reshapes the SIGNAL handed to it, " + "and only actually changes sizing under an allocator that reads " + "signal magnitude (e.g. 'signal_proportional'); config validation " + "rejects pairing non-binary scaling with 'equal_weight', which " + "would otherwise silently discard it back down to binary sizing.", + ), + ParameterDoc( + name="price_type", + what="Which price series ('adjusted_close' or 'close') " + "generate_signals() reads. Execution/costs always use the raw " + "close regardless.", + where="Feeds the momentum score in step 1, for every universe member.", + why="A split or large dividend on any one symbol shows up as a " + "price jump in raw close but not in adjusted close -- unadjusted, " + "it would distort that symbol's momentum score and its ranking " + "against the rest of the universe.", + default="adjusted_close", + typical_range="adjusted_close (recommended) or close.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="adjusted_close keeps corporate actions from distorting " + "relative rankings across the universe; close matches what was " + "literally quoted, useful mainly for auditing.", + interactions="Matters more here than for a single-asset strategy: " + "one mis-adjusted symbol distorts not just its own score but its " + "relative RANK against every other universe member.", + ), + ParameterDoc( + name="stop_loss_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens ONE symbol's REAL executed position -- ranking " + "membership itself has no persistent state, but this operates on " + "the actual position held after the allocator/constraints/" + "rebalancing/execution, which can span several rebalances.", + where="Applied downstream of generate_signals() entirely -- see " + "`quantlab.backtesting.accounting._detect_stop_loss_take_profit`. " + "generate_signals() itself is unchanged by this parameter.", + why="A symbol can stay selected across several rebalances while " + "its own price moves sharply against the position -- this bounds " + "the realized loss on that specific holding, independent of " + "whether it is still ranked in the top/bottom fraction.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="More room before a forced exit -- fewer stop-outs " + "on ordinary volatility, larger potential realized loss per trade.", + effect_decrease="Tighter monetary risk control, more prone to " + "being stopped out by a temporary adverse move.", + tradeoffs="Realized-loss protection vs. premature exits. Evaluated " + "on GROSS (pre-cost) return -- QuantLab's execution cost model is " + "portfolio-level only, so an exact net-of-cost trigger is not " + "presently computable; this is a disclosed design convention, not " + "a universal definition.", + interactions="Applies independently per symbol (no position_" + "groups declared). Once triggered, no immediate re-entry at a " + "rebased price -- flat until that symbol's next real entry (a " + "fresh flat-to-non-flat transition of the executed weight), even " + "if it re-qualifies for the top/bottom fraction sooner.", + ), + ParameterDoc( + name="take_profit_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens one symbol's REAL executed position on the " + "favorable side -- locks in a gain directly rather than waiting " + "for it to drop out of the ranking.", + where="Same mechanism as stop_loss_pct, opposite direction.", + why="Realizes a gain directly once a target is reached, instead " + "of depending on the symbol eventually falling out of the top/" + "bottom fraction.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="Lets more of a favorable move run before locking it in.", + effect_decrease="Locks in gains earlier, potentially forfeiting " + "further outperformance.", + tradeoffs="Locking in gains early vs. capturing more outperformance.", + interactions="Independent of stop_loss_pct; see its own doc for " + "the shared gross-return/re-entry conventions.", + ), +] + + +def _lab(st: Any) -> None: + from quantlab.dashboard.explorer.labs.cross_sectional_momentum import render + + render(st) + + +#: Fixed default forward-return horizon for this diagnostic -- matching +#: the interactive lab's own default. Deliberately INDEPENDENT of +#: ``skip_period`` (a strategy parameter meaning "how much of the recent +#: past to exclude from the score", not "how long to hold looking +#: forward"): ``skip_period=0`` is a perfectly valid strategy config, but +#: ``holding_period=0`` is rejected by ``cross_sectional_momentum_ +#: persistence`` (must be >= 1), and a `skip_period` above 252 (also +#: valid -- only constrained to be < lookback_period) would put the +#: Results-tab slider's default value outside its own 1-252 range. Never +#: reuse ``skip_period`` here again. +_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD = 21 + + +@dataclass(frozen=True) +class CrossSectionalMomentumDiagnostics: + """Cross-sectional rank-correlation/spread persistence over the sample. + + ``persistence``/``mean_rank_correlation``/``mean_top_minus_bottom`` are + computed at ``holding_period`` (fixed to + ``_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD`` -- see that constant's own + docstring for why it is independent of ``skip_period``) for the + exported HTML report, which has no interactivity. ``prices`` and the + other resolved parameters are carried alongside so the Results tab can + recompute this SAME diagnostic at a user-chosen holding_period on + demand -- a cheap, purely local recomputation, not a backtest re-run + (see ``_render_diagnostics``). + """ + + holding_period: int + lookback_period: int + skip_period: int + top_fraction: float + effective_bottom_fraction: float + long_short: bool + prices: pd.DataFrame + mean_rank_correlation: float + mean_top_minus_bottom: float + persistence: pd.DataFrame + + +def _persistence_table( + prices: pd.DataFrame, + lookback_period: int, + skip_period: int, + holding_period: int, + *, + top_fraction: float, + bottom_fraction: float, +) -> tuple[pd.DataFrame, float, float]: + from quantlab.features.momentum import cross_sectional_momentum_persistence + + persistence = cross_sectional_momentum_persistence( + prices, + lookback_period, + skip_period, + holding_period, + top_fraction=top_fraction, + bottom_fraction=bottom_fraction, + ) + mean_corr = ( + float(persistence["rank_correlation"].mean()) + if not persistence.empty + else float("nan") + ) + mean_spread = ( + float(persistence["top_minus_bottom"].mean()) + if not persistence.empty + else float("nan") + ) + return persistence, mean_corr, mean_spread + + +def _compute_diagnostics( + data: pd.DataFrame, cfg: ExperimentConfig +) -> CrossSectionalMomentumDiagnostics: + from quantlab.data.base import price_matrix + + params = cfg.strategy_parameters + lookback = int(params.get("lookback_period", 252)) + skip = int(params.get("skip_period", 21)) + top_fraction = float(params.get("top_fraction", 0.25)) + long_short = bool(params.get("long_short", False)) + bottom_fraction = float(params.get("bottom_fraction", 0.25)) + # When long_short=False there is no traded short book -- a comparison + # bottom fraction (capped to what fits alongside top_fraction) is used + # purely to compute the diagnostic, exactly like the interactive lab. + comparison_bottom_fraction = min(top_fraction, max(0.0, 1.0 - top_fraction)) + effective_bottom = bottom_fraction if long_short else comparison_bottom_fraction + + price_type = cfg.strategy.signal_price_type + prices = price_matrix(data, adjusted=price_type != "close") + # Fixed, skip_period-INDEPENDENT default (see + # _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD's own docstring for why). The + # Results tab lets the user override it independently (see + # _render_diagnostics); the exported HTML report always uses this + # fixed value so the report stays stable and reproducible. + holding_period = _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD + persistence, mean_corr, mean_spread = _persistence_table( + prices, + lookback, + skip, + holding_period, + top_fraction=top_fraction, + bottom_fraction=effective_bottom, + ) + return CrossSectionalMomentumDiagnostics( + holding_period=holding_period, + lookback_period=lookback, + skip_period=skip, + top_fraction=top_fraction, + effective_bottom_fraction=effective_bottom, + long_short=long_short, + prices=prices, + mean_rank_correlation=mean_corr, + mean_top_minus_bottom=mean_spread, + persistence=persistence, + ) + + +def _render_diagnostics(st: Any, result: CrossSectionalMomentumDiagnostics) -> None: + from quantlab.dashboard.explorer.shared_components import ( + render_price_chart, + strong, + ) + + st.subheader("Cross-sectional momentum persistence") + holding_period = st.slider( + "Forward-return horizon (periods) for this diagnostic", + 1, + 252, + result.holding_period, + key="csmom_results_diag_holding_period", + help=( + "Diagnostic setting only -- changing this does not rerun or " + "alter the backtest, only how many periods ahead this " + "persistence check looks." + ), + ) + st.caption( + "Forward-return horizon used only for this diagnostic; it does not " + "change the strategy or its backtest. Longer horizons produce " + "overlapping forward-return windows across consecutive dates, so " + "the apparent number of observations overstates the independent " + "information actually available -- treat this as descriptive " + "sample evidence, not a hypothesis test." + ) + if holding_period == result.holding_period: + persistence = result.persistence + mean_corr = result.mean_rank_correlation + mean_spread = result.mean_top_minus_bottom + else: + persistence, mean_corr, mean_spread = _persistence_table( + result.prices, + result.lookback_period, + result.skip_period, + holding_period, + top_fraction=result.top_fraction, + bottom_fraction=result.effective_bottom_fraction, + ) + st.caption( + "The question this strategy actually trades: do higher-ranked " + "assets go on to earn higher subsequent returns, RELATIVE TO EACH " + f"OTHER, over a {holding_period}-period horizon? Mean " + f"rank correlation: {strong(f'{mean_corr:.3f}')}. Mean " + f"top-minus-bottom spread: {strong(f'{mean_spread:.3%}')}." + + ( + "" + if result.long_short + else " (long_short is disabled -- the bottom group here is a " + "research comparison only, not a short book this backtest " + "actually held.)" + ), + unsafe_allow_html=True, + ) + if persistence.empty: + st.info("Not enough dates with at least 3 scored assets in this result.") + return + render_price_chart( + st, + {"Rank correlation": persistence["rank_correlation"]}, + title="Spearman rank correlation: momentum score vs. subsequent return", + yaxis_title="Rank correlation", + ) + render_price_chart( + st, + {"Top - bottom spread return": persistence["top_minus_bottom"]}, + title=f"Realized top-minus-bottom {holding_period}-period return", + yaxis_title="Return", + ) + + +def _report_section(result: CrossSectionalMomentumDiagnostics) -> DiagnosticsSection: + from quantlab.dashboard.explorer.shared_components import live_widget_value + from quantlab.reporting.sections import DiagnosticsSection + + # Reflects the user's own live Results-tab slider choice (see + # _render_diagnostics), not always result.holding_period -- falls back + # to it when the dashboard isn't running at all (e.g. the CLI's own + # report generation) or that slider was never rendered this session. + holding_period = live_widget_value( + "csmom_results_diag_holding_period", result.holding_period + ) + if holding_period == result.holding_period: + mean_corr = result.mean_rank_correlation + mean_spread = result.mean_top_minus_bottom + else: + _persistence, mean_corr, mean_spread = _persistence_table( + result.prices, + result.lookback_period, + result.skip_period, + holding_period, + top_fraction=result.top_fraction, + bottom_fraction=result.effective_bottom_fraction, + ) + table = pd.DataFrame( + [ + ("Holding period (periods)", holding_period), + ("Long/short", result.long_short), + ("Mean rank correlation", mean_corr), + ("Mean top-minus-bottom spread", mean_spread), + ], + columns=["Metric", "Value"], + ) + return DiagnosticsSection( + table=table, + note=( + "Cross-sectional momentum persistence: does a higher-ranked " + "asset earn a higher subsequent return than a lower-ranked one, " + "across the universe? Descriptive sample evidence, not a " + "hypothesis test -- overlapping holding periods are not " + "independent." + ), + ) + + +register_profile( + StrategyProfile( + strategy_name="cross_sectional_momentum", + display_name="Cross-Sectional Momentum", + category="Trend / momentum", + overview_md=_OVERVIEW, + economic_intuition_md=_ECONOMIC_INTUITION, + mathematical_definition_md=_MATH, + assumptions_md=_ASSUMPTIONS, + diagnostics_md=_DIAGNOSTICS, + interpretation_md=_INTERPRETATION, + limitations_md=_LIMITATIONS, + references_md=_REFERENCES, + parameters=_PARAMETERS, + lab=_lab, + results_diagnostics=ResultsDiagnostics( + key="cross_sectional_momentum_diagnostics", + compute=_compute_diagnostics, + render=_render_diagnostics, + report_section=_report_section, + ), + ) +) diff --git a/src/quantlab/dashboard/explorer/profiles/mean_reversion.py b/src/quantlab/dashboard/explorer/profiles/mean_reversion.py new file mode 100644 index 0000000..89c0b25 --- /dev/null +++ b/src/quantlab/dashboard/explorer/profiles/mean_reversion.py @@ -0,0 +1,603 @@ +"""Strategy Explorer profile for ``mean_reversion``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pandas as pd + +from quantlab.dashboard.explorer.profile import ( + ParameterDoc, + ResultsDiagnostics, + StrategyProfile, + register_profile, +) + +if TYPE_CHECKING: + from quantlab.config import ExperimentConfig + from quantlab.reporting.sections import DiagnosticsSection + +_OVERVIEW = """ +Mean reversion bets that a price which has moved unusually far from its +own recent average will tend to move back toward it. Unlike pairs +trading (which trades a *relationship* between two assets), this is a +single-asset strategy: it watches one symbol's own trailing statistics +and trades against short-term overreaction in that symbol alone. + +Typical horizon: several bars to a few dozen bars (often days to a few weeks +on daily data), set mostly by `lookback_period` (how "recent" is measured) +and how quickly a given instrument tends to snap back. Data needed: prices +for the traded symbol, long enough to build a stable rolling mean/std before +trading starts. +""" + +_ECONOMIC_INTUITION = """ +Some short-term price moves may be driven by order flow, liquidity +conditions or overreaction rather than a permanent repricing. The strategy +bets that sufficiently unusual deviations will move back toward a recent +statistical reference level. This premise is more plausible +on liquid, range-bound-ish instruments; it works poorly on an asset that +is genuinely re-rating to a new regime (see Limitations). +""" + +_MATH = """ +`generate_signals()`'s pipeline, in order: + +1. **Centered indicator** -- one of three primary indicators + (`indicator`), each turned into a zero-centered series where negative + means "below normal" (a long candidate) and positive means "above + normal" (a short candidate): + - `zscore`: `(price - rolling_mean) / rolling_std` over `lookback_period`. + - `rsi`: Wilder's RSI over `lookback_period`, minus `50` (RSI's own + neutral midpoint) so oversold/overbought reads as negative/positive. + - `percentile`: the price's trailing percentile rank within + `lookback_period` (0 = lowest, 1 = highest), minus `0.5`. + + Two further indicators, `bollinger` and `distance_ma`, are implemented + and fully usable (Python/YAML, robustness sweeps) but not offered in + this UI: `bollinger` is `(price - rolling_mean) / (bollinger_num_std * + rolling_std)` -- the same rolling mean/std construction as `zscore`, + merely rescaled, so it rarely produces a materially different + backtest. `distance_ma` is `(price - rolling_mean) / rolling_mean` -- + normalized by the mean's own level rather than volatility, so unlike + `bollinger` it CAN diverge from `zscore` materially when the + volatility regime shifts, even though it is excluded from this UI for + the same "not offered as a primary choice" reason. +2. **State machine**, walked one bar at a time per symbol, identical + regardless of which indicator fed it (it only ever compares a + threshold against the indicator's absolute value): + - If the indicator is undefined (insufficient trailing history), force + flat. + - If `|indicator| > stop_threshold` (when set), force flat regardless + of current state. + - Flat state: enter long when `indicator < -entry_threshold`; enter + short when `indicator > entry_threshold` AND `long_only=False` -- + `long_only` gates the short-entry branch directly inside this state + machine, so a short position is never entered in the first place + when it is `True`. + - In a position: exit when the indicator crosses back through + `-exit_threshold` (long) or `exit_threshold` (short). + +`entry_threshold`/`exit_threshold`/`stop_threshold` are on the CHOSEN +indicator's own scale -- a threshold of `2.0` means very different things +for `zscore` (2 standard deviations) vs. `rsi` (would mean RSI 48-52, +barely oversold at all) vs. `percentile` (meaningless above `0.5`). Left +unset, each defaults to a value sized for that specific indicator (see +each parameter's own doc below); switching `indicator` on an otherwise- +unchanged config silently keeps whatever thresholds were explicitly set, +which may no longer make sense on the new indicator's scale -- always +re-check thresholds after changing `indicator`. + +The state is a strategy signal, not a final portfolio weight: allocation, +constraints, rebalancing and execution still act downstream. Unlike pairs +trading, this single-asset mean-reversion strategy has no hedge ratio or second +instrument, and it does not apply a stationarity test before opening a +position. It therefore relies on the selected indicator to identify potential +mean-reversion opportunities without first verifying that the underlying +series is stationary. +""" + +_ASSUMPTIONS = """ +**Economic**: the price's short-term deviations are assumed to be +temporary around a comparatively stable recent level, rather than the start of a +sustained re-rating. **Statistical**: the price series (or at least its +short-term behaviour) is closer to mean-reverting than to a random walk +or a trend -- see the lab's own ADF/Hurst diagnostics for whether that +actually holds on the chosen instrument and period. **Implementation**: +`lookback_period` is long enough to give a stable rolling mean/std, but +short enough that "recent average" still means something economically +(a 5-year lookback on a stock that re-rated 2 years ago is not a useful +reference level). +""" + +_DIAGNOSTICS = """ +The lab below compares the three primary indicators (RSI, rolling z-score, +rolling percentile rank) on the same price series so their differences on +identical data are directly visible -- Bollinger Bands and distance to +moving average are also implemented (see Mathematical definition) but not +shown in this comparison: Bollinger rarely diverges materially from +zscore (the same mean/std construction, merely rescaled), while distance +to moving average is left out for a different reason -- it is not +volatility-normalized, so it can diverge from zscore materially when the +volatility regime shifts -- plus the actual backtestable state machine +overlaid on the currently selected `indicator` and thresholds, plus +stationarity diagnostics (ADF, +half-life, Hurst exponent) on the actual instrument and period being +considered -- asking whether this sample is consistent with mean +reversion before looking at trade-level performance. These diagnostics +are sensitive to their estimator and test specification; they do not +validate the strategy. +""" + +_INTERPRETATION = """ +With QuantLab's default constant-only ADF regression and AIC lag selection, +a low p-value is evidence against a unit root on the tested sample; it is +not proof of stationarity. A Hurst estimate below 0.5 is a separate, +descriptive indication of anti-persistence, not a hypothesis test. A finite +half-life much longer than `lookback_period` suggests that the indicator's +reference window may be short relative to the estimated speed of reversion. +The entry/exit thresholds do not impose a maximum holding period: a position +can remain open until its exit, stop or missing-data condition is reached. +""" + +_LIMITATIONS = """ +**Regime shift**: an asset can permanently re-rate (a real fundamental +change) rather than mean-revert -- indicator-based entries have no way to +tell "overreaction" apart from "the mean has genuinely moved", and +`stop_threshold` only limits the indicator's own deviation tolerated +before an exit is requested when that happens, not the realized monetary +loss (gaps, execution delay, and a moving mean/volatility or costs can +still produce a larger loss than the indicator distance alone would +suggest). +**Trending markets**: mean reversion structurally underperforms during +sustained trends, since every "extreme" reading keeps getting more +extreme instead of reverting -- see the Hurst/ADF diagnostics to assess +whether the instrument is currently more likely to be mean-reverting or +trending. +**Transaction costs**: frequent small round trips (a natural consequence +of tight `entry_threshold`/`exit_threshold` gaps) are especially +vulnerable to transaction costs, which can quickly erode the typically +modest edge per trade. +**Parameter instability**: the "right" lookback/thresholds can drift over +time as an instrument's own volatility regime changes, and thresholds +tuned for one `indicator` are not portable to another (see Mathematical +definition). +""" + +_REFERENCES = ( + "The [statsmodels ADF documentation](https://www.statsmodels.org/stable/" + "generated/statsmodels.tsa.stattools.adfuller.html) specifies the unit-" + "root null, constant/trend choices and AIC lag selection used by " + "QuantLab's wrapper. It supports interpretation of the test, not the " + "claim that a price-level mean-reversion strategy is profitable.\n\n" + "Ernest P. Chan's [*Algorithmic Trading: Winning Strategies and Their " + "Rationale*](https://onlinelibrary.wiley.com/doi/book/10.1002/" + "9781118676998) (Wiley, 2013) gives a practical treatment of mean-" + "reversion research and implementation, including stationarity, " + "half-life and trading-rule considerations. It is practical guidance, " + "not evidence that a particular configuration will remain profitable." +) + +_PARAMETERS = [ + ParameterDoc( + name="lookback_period", + what="Trailing window (periods) used for the rolling mean/std (or " + "RSI/percentile window) that defines the centered indicator.", + where="Step 1 -- every subsequent decision is a function of this indicator.", + why="Defines what 'normal' means: too short and it chases noise, " + "too long and it stops describing the current regime.", + default="20", + typical_range="10-60 periods.", + effect_increase="A smoother, more stable reference level, but " + "slower to adapt if the instrument's own typical range has " + "genuinely shifted.", + effect_decrease="Faster adaptation to a shifting regime, but a " + "noisier indicator more prone to false signals.", + tradeoffs="Stability of the reference level vs. responsiveness to " + "genuine regime change.", + interactions="Sets the scale the lab's ADF/half-life diagnostics " + "should be compared against -- a half-life much longer than " + "lookback_period means the window is too short to capture a full " + "reversion cycle.", + ), + ParameterDoc( + name="indicator", + what="Which zero-centered indicator drives the state machine. " + "Primary choices: 'zscore', 'rsi' or 'percentile' (see " + "Mathematical definition for each formula). 'bollinger' and " + "'distance_ma' are also accepted (Python/YAML) but not offered in " + "this UI: 'bollinger' is a close variant of zscore (the same " + "rolling mean/std construction, merely rescaled), so it rarely " + "diverges from it materially, but 'distance_ma' normalizes by the " + "rolling mean's own level rather than volatility and so CAN " + "diverge from zscore materially when the volatility regime shifts.", + where="Step 1 -- determines what feeds every subsequent decision.", + why="Different indicators make different bets about what 'unusual' " + "means: a z-score is scale-free relative to recent volatility, RSI " + "is a bounded oscillator based on the ratio of recent average " + "gains to average losses, and percentile rank is a purely " + "non-parametric 'how extreme relative to recent history'.", + default="zscore", + typical_range="One of 'zscore', 'rsi', 'percentile'.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="z-score standardizes deviations using the rolling mean " + "and volatility, so its interpretation is most natural when the " + "local distribution and volatility regime are reasonably stable. " + "RSI and percentile are bounded and less sensitive to the asset's " + "absolute price scale, but they compress information about the " + "magnitude of deviations; percentile, in particular, captures " + "rank rather than how far beyond an extreme the observation lies.", + interactions="entry_threshold/exit_threshold/stop_threshold are on " + "THIS indicator's own scale -- changing indicator without " + "re-checking thresholds can silently produce a state machine that " + "almost never trades (thresholds too wide for the new scale) or " + "trades constantly (too narrow). bollinger_num_std only applies " + "when indicator='bollinger'.", + ), + ParameterDoc( + name="bollinger_num_std", + what="Number of standard deviations the Bollinger bands extend " + "from the rolling mean -- only used when indicator='bollinger', " + "which is available (Python/YAML) but not offered in this UI's " + "Indicator choice; see that parameter's own doc.", + where="Step 1, bollinger branch only -- rescales the centered " + "indicator so a threshold of 1.0 means 'price outside the bands'.", + why="Sets how wide 'the bands' are, independent of the entry/exit/" + "stop thresholds themselves.", + default="2.0", + typical_range="1.5-3.0.", + effect_increase="Wider bands -- a given entry_threshold now " + "requires a larger absolute price move to trigger.", + effect_decrease="Narrower bands -- more sensitive entries for the " + "same entry_threshold.", + tradeoffs="Conventional Bollinger practice (2.0) vs. a " + "deliberately wider/narrower band for this instrument's own " + "volatility character.", + interactions="Has no effect at all unless indicator='bollinger'.", + ), + ParameterDoc( + name="entry_threshold", + what="Indicator magnitude (on the chosen indicator's own scale) " + "that opens a new position.", + where="State machine, flat-state entry condition.", + why="Sets how unusual a deviation has to be before it's worth trading.", + default="Indicator-specific: 2.0 (zscore), 1.0 (bollinger), 20.0 " + "(rsi, i.e. RSI below 30 or above 70), 0.05 (distance_ma, a 5% " + "move), 0.45 (percentile, i.e. below the 5th or above the 95th " + "percentile) -- applied only when left unset (None).", + typical_range="Depends on indicator; see default above.", + effect_increase="Fewer, more extreme entries -- higher conviction " + "per trade, lower turnover.", + effect_decrease="More frequent entries on smaller deviations -- " + "more trades, more exposure to noise.", + tradeoffs="Trade frequency vs. conviction per trade.", + interactions="Must exceed exit_threshold; must be below " + "stop_threshold when set. Its practical meaning changes entirely " + "with indicator -- see that parameter's own doc.", + ), + ParameterDoc( + name="exit_threshold", + what="Indicator magnitude (crossed on the way back toward zero) " + "that closes an open position.", + where="State machine, in-position exit condition.", + why="Decides how much of the reversion to capture before closing.", + default="Indicator-specific: 0.5 (zscore), 0.2 (bollinger), 10.0 " + "(rsi), 0.01 (distance_ma), 0.10 (percentile) -- applied only " + "when left unset (None).", + typical_range="Depends on indicator; see default above.", + effect_increase="Exits earlier, leaving more of a full reversion " + "uncaptured but reducing time-in-trade.", + effect_decrease="Holds for a more complete reversion, at the cost " + "of more time exposed to a reversal.", + tradeoffs="Captured reversion vs. time-in-trade risk.", + interactions="Must be strictly below entry_threshold.", + ), + ParameterDoc( + name="stop_threshold", + what="Indicator magnitude that force-closes a position regardless " + "of direction -- protection against a deviation that keeps " + "widening instead of reverting (may indicate a regime shift or " + "model breakdown rather than an ordinary fluctuation).", + where="State machine, after unavailable-data handling and before " + "entry or normal exit logic.", + why="Limits the indicator's own deviation tolerated before " + "requesting an exit when the mean-reversion premise itself has " + "broken down for this instrument -- it does not cap the realized " + "monetary loss (gaps, execution delay, and a moving mean/" + "volatility or costs can still produce a larger loss than the " + "indicator distance alone would suggest).", + default="Indicator-specific: 4.0 (zscore), 1.5 (bollinger), 45.0 " + "(rsi), 0.15 (distance_ma), 0.49 (percentile) -- applied when this " + "parameter is left out entirely. Pass an explicit stop_threshold=" + "None to disable the stop altogether (the state machine then never " + "force-closes on indicator magnitude, only on the ordinary exit " + "condition or missing data).", + typical_range="Typically 1.5-2x entry_threshold; omit the " + "parameter to use the indicator-specific default, or pass None to " + "disable it.", + effect_increase="More room before an exit is requested -- fewer " + "stop-outs on noise, larger potential loss per trade.", + effect_decrease="Tighter risk control, more prone to being stopped " + "out by a temporary overshoot that would otherwise have reverted.", + tradeoffs="Downside protection vs. premature stop-outs.", + interactions="Must be strictly greater than entry_threshold.", + ), + ParameterDoc( + name="long_only", + what="Whether short entries are structurally disabled.", + where="Gates the short-entry branch directly inside the state " + "machine (step 2) -- a short position is never entered in the " + "first place when True, there is no separate final clip.", + why="Many portfolios/mandates cannot or should not short. " + "Enabling long_only also changes turnover and exposure because " + "only the negative-indicator entry side can generate positions.", + default="True", + typical_range="Boolean.", + effect_increase="N/A (boolean).", + effect_decrease="N/A (boolean).", + tradeoffs="True removes short-side opportunities but avoids " + "short-specific costs/constraints (borrow, uptick rules); False " + "represents reversion in both directions but adds short-side risk.", + interactions="With False, entry_threshold/exit_threshold/" + "stop_threshold apply symmetrically to both the long and short side.", + ), + ParameterDoc( + name="stop_loss_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens this symbol's REAL executed position -- an " + "additional, independent risk control on top of stop_threshold " + "(which limits the INDICATOR's own deviation, not a monetary " + "loss).", + where="Applied after the backtest allocator/constraints/" + "rebalancing/execution -- on the position actually held, not on " + "this strategy's raw signal (a signal is not necessarily a " + "realized position). See `quantlab.backtesting.accounting." + "_detect_stop_loss_take_profit`.", + why="stop_threshold protects against the mean-reversion premise " + "itself breaking down (the indicator keeps widening); stop_loss_pct " + "protects against realized monetary loss regardless of what the " + "indicator says, e.g. from gaps, execution delay or costs.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="More room before a forced exit -- fewer stop-outs " + "on ordinary volatility, larger potential realized loss per trade.", + effect_decrease="Tighter monetary risk control, more prone to " + "being stopped out by a temporary adverse move.", + tradeoffs="Realized-loss protection vs. premature exits. Evaluated " + "on GROSS (pre-cost) return -- QuantLab's execution cost model is " + "portfolio-level only, so an exact net-of-cost trigger is not " + "presently computable; this is a disclosed design convention, not " + "a universal definition.", + interactions="Independent of entry_threshold/exit_threshold/" + "stop_threshold -- both mechanisms can be active at once, or " + "either alone. Once triggered, no immediate re-entry at a rebased " + "price -- flat until the position's next real entry.", + ), + ParameterDoc( + name="take_profit_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens this symbol's REAL executed position on the " + "favorable side -- locks in a gain rather than waiting for " + "exit_threshold's ordinary mean-reversion exit.", + where="Same mechanism as stop_loss_pct, opposite direction.", + why="Realizes a gain directly once a target is reached, instead " + "of depending on the indicator reverting all the way back through " + "exit_threshold (which may give back some of the gain first).", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="Lets more of a favorable move run before locking it in.", + effect_decrease="Locks in gains earlier, potentially forfeiting " + "further upside.", + tradeoffs="Locking in gains early vs. capturing a larger reversion.", + interactions="Independent of stop_loss_pct and the entry/exit/" + "stop_threshold family; see stop_loss_pct's own doc for the " + "shared gross-return/re-entry conventions.", + ), + ParameterDoc( + name="price_type", + what="Which price series ('adjusted_close' or 'close') " + "generate_signals() reads. Execution/costs always use the raw " + "close regardless.", + where="Feeds the centered indicator computation in step 1.", + why="A split or large dividend shows up as a price jump in raw " + "close but not in adjusted close -- unadjusted, it would look " + "exactly like an extreme z-score deviation.", + default="adjusted_close", + typical_range="adjusted_close (recommended) or close.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="adjusted_close avoids false signals from corporate " + "actions; close matches what was literally quoted, useful mainly " + "for auditing.", + interactions="A dividend/split near the current date would " + "otherwise register as a large, entirely spurious entry signal.", + ), +] + + +def _lab(st: Any) -> None: + from quantlab.dashboard.explorer.labs.mean_reversion import render + + render(st) + + +@dataclass(frozen=True) +class MeanReversionDiagnostics: + """State-machine activity + stationarity, one row per traded symbol. + + ``indicators``/``states`` carry the full per-symbol series for the + Results-tab chart -- the report's own table (``_report_section``) is a + snapshot only, mirroring ``PairDiagnostics``'s own table-vs-chart split. + """ + + indicator: str + summary: pd.DataFrame + indicators: dict[str, pd.Series] + states: dict[str, pd.Series] + entry_threshold: float + exit_threshold: float + stop_threshold: float | None + + +def _compute_diagnostics( + data: pd.DataFrame, cfg: ExperimentConfig +) -> MeanReversionDiagnostics: + from quantlab.data.base import price_matrix + from quantlab.features.mean_reversion import half_life as compute_half_life + from quantlab.features.stationarity import adf_test, hurst_exponent + from quantlab.strategies.mean_reversion import ( + MeanReversionStrategy, + _centered_indicator, + ) + + # Built from the SAME constructor call the real backtest made (not a + # re-derivation of defaults/threshold-resolution here) -- see + # MeanReversionStrategy's own None-vs-unset stop_threshold semantics, + # easy to get subtly wrong by hand. + strategy = MeanReversionStrategy(**cfg.strategy_parameters) + # Engine-injected context (see BaseStrategy.symbol_calendars's own + # docstring) -- set here too, exactly as BacktestEngine.run() does, + # so this diagnostic's indicator/signals are computed on each symbol's + # own native calendar rather than silently falling back to the + # closure-padded combined timeline under a mixed-calendar universe. + strategy.symbol_calendars = { + instrument.symbol: instrument.calendar for instrument in cfg.data.instruments + } + price_type = cfg.strategy.signal_price_type + prices = price_matrix(data, adjusted=price_type != "close") + indicator = _centered_indicator( + prices, + strategy.indicator, + strategy.lookback_period, + strategy.bollinger_num_std, + strategy.symbol_calendars, + ) + state = strategy.generate_signals(data) + reasons = strategy.explain_signals(data) + + rows = [] + indicators: dict[str, pd.Series] = {} + states: dict[str, pd.Series] = {} + for symbol in prices.columns: + symbol_state = state[symbol] + symbol_detail = reasons.detail_code[symbol] + tested = prices[symbol].dropna() + adf = adf_test(tested) if len(tested) >= 2 else None + rows.append( + { + "Symbol": symbol, + "Time in position": float((symbol_state != 0.0).mean()), + "Entries": int( + symbol_detail.isin(["oversold_entry", "overbought_entry"]).sum() + ), + "Stop exits": int((symbol_detail == "stop_loss_exit").sum()), + "ADF p-value": adf.pvalue if adf is not None else float("nan"), + # Same wording/threshold as render_stationarity_card's own + # verdict, using ADFResult's own reject_null (never a second, + # independently-chosen significance level). + "Verdict": ( + ("Reject H0" if adf.reject_null else "Cannot reject H0") + if adf is not None + else "n/a" + ), + "Half-life": compute_half_life(tested) + if len(tested) >= 2 + else float("inf"), + "Hurst": hurst_exponent(tested) if len(tested) >= 2 else float("nan"), + } + ) + indicators[symbol] = indicator[symbol] + states[symbol] = symbol_state + summary = pd.DataFrame(rows).set_index("Symbol") + return MeanReversionDiagnostics( + indicator=strategy.indicator, + summary=summary, + indicators=indicators, + states=states, + entry_threshold=strategy.entry_threshold, + exit_threshold=strategy.exit_threshold, + stop_threshold=strategy.stop_threshold, + ) + + +def _render_diagnostics(st: Any, result: MeanReversionDiagnostics) -> None: + from quantlab.dashboard.explorer.shared_components import ( + centered_indicator_threshold_overlay, + render_price_chart, + ) + + st.subheader("Stationarity diagnostics & State machine") + st.caption( + f"indicator = **{result.indicator}**. Full-sample ADF/half-life/Hurst " + "per symbol -- descriptive, not a validated backtest result on " + "their own. Verdict uses the same H0 (unit root) rejection rule as " + "the interactive lab's own stationarity card." + ) + st.dataframe(result.summary, width="stretch") + symbol = st.selectbox( + "Symbol", list(result.indicators), key="mr_results_diag_symbol" + ) + threshold_series, line_colors = centered_indicator_threshold_overlay( + result.indicators[symbol], + f"{result.indicator} indicator", + entry_threshold=result.entry_threshold, + exit_threshold=result.exit_threshold, + stop_threshold=result.stop_threshold, + ) + render_price_chart( + st, + threshold_series, + title=f"{symbol}: Centered '{result.indicator}' indicator with " + "entry/exit/stop thresholds", + yaxis_title="Centered indicator", + colors=line_colors, + ) + render_price_chart( + st, + {"Position (state)": result.states[symbol]}, + title=f"{symbol}: state signal", + yaxis_title="Signal state", + ) + + +def _report_section(result: MeanReversionDiagnostics) -> DiagnosticsSection: + from quantlab.reporting.sections import DiagnosticsSection + + table = result.summary.reset_index() + return DiagnosticsSection( + table=table, + note=( + f"Mean reversion state-machine activity (indicator={result.indicator}) " + "and full-sample stationarity diagnostics, one row per traded " + "symbol. Descriptive, not a validation of profitability." + ), + ) + + +register_profile( + StrategyProfile( + strategy_name="mean_reversion", + display_name="Mean Reversion", + category="Mean reversion", + overview_md=_OVERVIEW, + economic_intuition_md=_ECONOMIC_INTUITION, + mathematical_definition_md=_MATH, + assumptions_md=_ASSUMPTIONS, + diagnostics_md=_DIAGNOSTICS, + interpretation_md=_INTERPRETATION, + limitations_md=_LIMITATIONS, + references_md=_REFERENCES, + parameters=_PARAMETERS, + lab=_lab, + results_diagnostics=ResultsDiagnostics( + key="mean_reversion_diagnostics", + compute=_compute_diagnostics, + render=_render_diagnostics, + report_section=_report_section, + ), + ) +) diff --git a/src/quantlab/dashboard/explorer/profiles/pairs_trading.py b/src/quantlab/dashboard/explorer/profiles/pairs_trading.py new file mode 100644 index 0000000..645d4ec --- /dev/null +++ b/src/quantlab/dashboard/explorer/profiles/pairs_trading.py @@ -0,0 +1,635 @@ +"""Strategy Explorer profile for ``pairs_trading``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pandas as pd + +from quantlab.dashboard.explorer.profile import ( + ParameterDoc, + ResultsDiagnostics, + StrategyProfile, + register_profile, +) + +if TYPE_CHECKING: + from quantlab.config import ExperimentConfig + from quantlab.features.pairs_diagnostics import PairDiagnostics + from quantlab.reporting.sections import DiagnosticsSection + +_OVERVIEW = """ +Pairs trading trades the *relationship* between two related assets, not +either asset's own direction. When the price of A drifts away from what +B's own movement would predict, the strategy bets on that gap ("the +spread") closing again. For the usual positive fitted beta this creates +opposite-signed legs; with a negative beta, the leg signs need not be one +long and one short. The signal construction encodes the fitted OLS slope +between its legs; it is NOT necessarily dollar-neutral, and QuantLab's +weight-based accounting does not simulate literal share quantities -- see +Mathematical definition. It seeks statistical +arbitrage / mean reversion in a relationship, the classic example being +two economically linked instruments (two banks, two miners, an ETF and +its underlying index) whose prices tend to move together. + +Typical horizon: several bars to a few dozen bars per trade (often days to +weeks on daily data), depending on `indicator_window` and how quickly the +spread mean-reverts. Data needed: aligned prices +for both legs over a long enough history to fit a reliable relationship +(`formation_window` periods) before trading starts. +""" + +_ECONOMIC_INTUITION = """ +Two assets exposed to the same underlying economic driver (an industry, +a currency, an index) should move together over time. When one +temporarily overreacts -- to flow, sentiment, a stock-specific headline -- +while the underlying driver hasn't actually changed, the gap between them +is expected to close as both assets re-anchor to that shared driver. This +is intended as a relative-value bet: it does not directly forecast whether the +*market* goes up or down, only that this specific pair's relationship is +temporarily distorted and will normalize. +""" + +_MATH = """ +`generate_signals()`'s pipeline, in order: + +1. **Hedge ratio** -- `rolling_hedge_parameters(a, b, formation_window, + dynamic_hedge_ratio)` fits `a = intercept + beta * b` by trailing OLS. + If `dynamic_hedge_ratio=False`, this fit happens once on the first + `formation_window` observations and is held constant afterward; if + `True`, it is refit on every trailing `formation_window`-length window + (so `beta`/`intercept` can drift as the relationship itself drifts). +2. **Spread** -- `spread = a - intercept - beta * b`, the residual of that + fit: how far A actually sits from what the fitted relationship predicts. +3. **Centered indicator** -- one of three indicators (`indicator`, same + choice and defaults as `mean_reversion`), applied to the spread residual + instead of a raw price: `zscore` (`rolling_zscore(spread, + indicator_window)`, the default), `rsi`, or `percentile`. Each is + zero-centered the same way mean_reversion's indicator is (negative = + spread below normal, positive = above normal). +4. **Stationarity gate** -- every `indicator_window` bars, the full + trailing `formation_window` residual is ADF-tested; new entries are + only allowed while the resulting p-value stays `<= adf_pvalue_threshold` + (open positions are unaffected -- the gate blocks new entries only). + Set `adf_pvalue_threshold=None` to disable the gate entirely (every + date becomes tradable, subject only to the entry/exit/stop thresholds + below). +5. **State machine** -- flat: enter long when `indicator < -entry_threshold` + (and the gate is open), enter short when `indicator > entry_threshold`. + In a position: exit when the indicator crosses back through + `-exit_threshold` (long) / `exit_threshold` (short); force-flat if + `|indicator| > stop_threshold` or the indicator becomes undefined. +6. **OLS-scaled legs** -- the discrete state (`{-1, 0, 1}`) is applied as + `symbol_a: state * a`, `symbol_b: -state * beta * b`, then BOTH legs + are divided by whichever one is larger in absolute value, so EACH leg + individually is bounded to `[-1, 1]` -- this bounds each leg's own + signal magnitude, it does NOT force the two legs' dollar exposures to + be equal and opposite (that would require intercept == 0, generally + false); combined gross exposure before portfolio-level allocation can + run up to roughly 2. + +This formula encodes the fitted share ratio in signal space, but QuantLab +accounts for portfolio weights and does not create or round share orders. +These are still strategy signals. The required `signal_proportional` +allocator converts them to target weights; constraints, volatility +targeting, rebalancing and execution then determine the weights actually +traded. +""" + +_ASSUMPTIONS = """ +**Economic**: the strategy assumes the two assets share a durable common +driver rather than only coincidental historical correlation. **Statistical**: +the fitted residual is assumed to be stationary and that property is +assumed to persist beyond each formation sample. **Implementation**: the hedge ratio +estimated over `formation_window` (or refit on every bar, if dynamic) +remains a useful description of the relationship; +transaction costs on both legs are small relative to the typical spread +move being captured. +""" + +_DIAGNOSTICS = """ +The lab below (and the Results tab, for an actual backtest) reports: return +correlation and a rolling version of it (a screening signal, not proof of +tradability); the hedge ratio series and its own stability (std of beta -- +a relationship whose slope keeps changing makes the spread harder to +interpret, because both the spread itself and the hedge ratio used to +construct it are varying over time; this raw standard deviation is in +beta's own units, which depend on the pair's price scales and which symbol +is A vs. B -- compare it across different +formation_window/dynamic_hedge_ratio settings for the SAME pair, not +across different pairs); an exploratory full-sample ADF on the adaptively +constructed spread and Engle-Granger cointegration between the raw series +(two related but distinct questions); a *rolling* ADF +p-value so stationarity is checked throughout the sample, not only once +over the full history; and the spread's mean-reversion half-life. +""" + +_INTERPRETATION = """ +A low p-value is sample evidence against the relevant null, not a guarantee +that the relationship will persist. The full-sample ADF is exploratory and, +when `dynamic_hedge_ratio=True`, tests one series assembled from many rolling +regressions; that adaptive construction is not the same as a standard +single-regression residual test and can make the result look more stable. +The rolling ADF series uses the same formulas as the strategy's periodic +entry gate, but only matches its RESULT exactly for a single-calendar +pair -- under a mixed-calendar universe the live gate evaluates on the +intersection of both legs' own native session dates while this diagnostic +uses the full combined timeline, so the two can genuinely differ (see +docs/limitations.md). A shorter finite half-life relative to +`indicator_window` is more compatible with completing threshold round +trips, but does not guarantee that they occur or survive costs. +""" + +_LIMITATIONS = """ +**Structural break**: a merger, a regulatory change, or an index +reconstitution can permanently sever a relationship that looked stable for +years -- the strategy has no way to distinguish "temporarily wide spread" +from "the relationship is gone" except waiting for the ADF gate to close +new entries (already-open positions still follow their own exit/stop -- +`stop_threshold` limits the indicator's own deviation tolerated before an +exit is requested, it does not cap the realized monetary loss: gaps, +execution delay, and a moving mean/volatility/hedge-ratio or costs can +still produce a larger loss than the indicator distance alone would +suggest). +**Crowding**: a well-known, liquid pair attracts other pairs traders, +which can compress the very edge the spread is supposed to capture. +**Costs**: two legs can mean two sets of transaction costs. QuantLab models +its configured commission, spread and slippage, but does not separately +model stock-borrow fees, financing rates or locate availability; those +short-side costs and constraints remain outside the result unless the user +approximates them in the configured costs. +**Weight-based execution**: the OLS-scaled leg formula is converted to target +weights; QuantLab does not maintain literal share counts or guarantee exact +share neutrality, particularly when adjusted signal prices differ from raw +execution reference prices. +**Unstable hedge ratio**: with `dynamic_hedge_ratio=True`, a beta that +swings a lot between refits makes the spread itself a moving target, +undermining the whole premise of trading a *stable* residual. +""" + +_REFERENCES = ( + 'Engle & Granger (1987), ["Co-integration and Error Correction: ' + 'Representation, Estimation, and Testing"](https://doi.org/10.2307/' + "1913236), *Econometrica* 55(2), 251-276, develops the cointegration and " + "error-correction framework. QuantLab specifically calls the augmented " + "Engle-Granger test documented by [statsmodels](https://www.statsmodels." + "org/stable/generated/statsmodels.tsa.stattools.coint.html). These sources " + "support the statistical tests, not QuantLab's thresholds or the " + "profitability of a pair.\n\n" + "Ernest P. Chan's [*Algorithmic Trading: Winning Strategies and Their " + "Rationale*](https://onlinelibrary.wiley.com/doi/book/10.1002/" + "9781118676998) (Wiley, 2013) provides a practical discussion of mean-" + "reverting spreads, stationarity, cointegration and hedge-ratio " + "construction. It complements the statistical sources above but does " + "not establish that a particular pair or configuration is profitable." +) + +_PARAMETERS = [ + ParameterDoc( + name="symbol_a", + what="The first leg of the pair.", + where="Defines `a` in every equation above.", + why="Pairs trading needs two named instruments to relate.", + default="(required)", + typical_range="A symbol present in the configured universe, with " + "timestamps compatible with symbol_b.", + effect_increase="N/A -- a selection, not a magnitude.", + effect_decrease="N/A -- a selection, not a magnitude.", + tradeoffs="Choosing a genuinely economically-related pair matters far " + "more than any other parameter here.", + interactions="Must differ from symbol_b; both must be present in the " + "configured universe.", + ), + ParameterDoc( + name="symbol_b", + what="The second leg of the pair.", + where="Defines `b` in every equation above.", + why="See symbol_a.", + default="(required)", + typical_range="A symbol present in the configured universe, with " + "timestamps compatible with symbol_a.", + effect_increase="N/A -- a selection, not a magnitude.", + effect_decrease="N/A -- a selection, not a magnitude.", + tradeoffs="See symbol_a.", + interactions="Must differ from symbol_a.", + ), + ParameterDoc( + name="formation_window", + what="Trailing window (periods) used to fit the hedge ratio and " + "run the periodic ADF stationarity test.", + where="Step 1 (hedge ratio fit) and step 4 (stationarity gate).", + why="A relationship needs enough history to estimate reliably, but " + "not so much that it stops describing the CURRENT relationship.", + default="252", + typical_range="~60-500 periods (roughly 3 months to 2 years of daily data).", + effect_increase="A smoother, more stable hedge-ratio estimate, but " + "slower to adapt if the relationship is genuinely changing; more " + "data required before the strategy can trade at all.", + effect_decrease="Faster adaptation to a changing relationship, but a " + "noisier hedge-ratio estimate and a less powerful stationarity test " + "(fewer observations per ADF run).", + tradeoffs="Stability of the estimate vs. responsiveness to genuine " + "regime change.", + interactions="Must be smaller than the available history; interacts " + "with dynamic_hedge_ratio (a short window refit every period reacts " + "fast but noisily).", + ), + ParameterDoc( + name="indicator_window", + what="Trailing window (periods) used to compute the spread's " + "centered indicator, and the cadence of the stationarity gate " + "re-check.", + where="Step 3 (centered indicator) and step 4 (gate re-check interval).", + why="The indicator needs its own recent window to be a meaningful " + "'how unusual is this right now' measure.", + default="63", + typical_range="~10-90 periods.", + effect_increase="A smoother indicator, less sensitive to short-lived " + "noise, but slower to flag a genuine new dislocation.", + effect_decrease="A twitchier indicator that reacts fast to a fresh " + "dislocation, but more prone to false entries from noise.", + tradeoffs="Signal smoothness vs. responsiveness.", + interactions="Should generally be smaller than formation_window (the " + "indicator describes short-run deviation from a longer-run " + "relationship, not the other way around).", + ), + ParameterDoc( + name="indicator", + what="Which zero-centered indicator of the spread residual drives " + "the state machine: 'zscore' (default), 'rsi' or 'percentile' -- " + "same three choices and defaults as mean_reversion's own " + "`indicator`, applied here to the spread instead of a raw price.", + where="Step 3 -- determines what feeds every subsequent decision.", + why="Same rationale as mean_reversion's `indicator`: a z-score is " + "scale-free relative to the spread's own recent volatility, RSI is " + "a bounded oscillator, percentile rank is purely non-parametric.", + default="zscore", + typical_range="One of 'zscore', 'rsi', 'percentile'.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="See mean_reversion's `indicator` doc for the full " + "zscore/rsi/percentile tradeoff discussion -- it applies " + "identically here.", + interactions="entry_threshold/exit_threshold/stop_threshold are on " + "THIS indicator's own scale -- changing indicator without " + "re-checking thresholds can silently produce a state machine that " + "rarely or constantly trades.", + ), + ParameterDoc( + name="entry_threshold", + what="Indicator magnitude (on the chosen indicator's own scale) " + "that opens a new position.", + where="Step 5, flat-state entry condition.", + why="Defines how large a dislocation has to be before it's worth " + "trading (net of costs and estimation noise).", + default="Indicator-specific: 2.0 (zscore), 20.0 (rsi), 0.45 " + "(percentile) -- applied only when left unset (None).", + typical_range="Depends on indicator; see default above.", + effect_increase="Fewer, larger, more extreme entries -- higher " + "conviction per trade, fewer trades overall (lower turnover/costs).", + effect_decrease="More frequent entries on smaller dislocations -- " + "more trades, more exposure to noise being mistaken for a genuine " + "opportunity.", + tradeoffs="Trade frequency and turnover vs. conviction per trade.", + interactions="Must exceed exit_threshold (validated); if " + "stop_threshold is set, must be below it.", + ), + ParameterDoc( + name="exit_threshold", + what="Indicator magnitude that closes an open position as the spread reverts.", + where="Step 5, in-position exit condition.", + why="Decides how much of the reversion to actually capture before " + "closing, versus how long to stay exposed hoping for more.", + default="Indicator-specific: 0.5 (zscore), 10.0 (rsi), 0.10 " + "(percentile) -- applied only when left unset (None).", + typical_range="Depends on indicator; see default above.", + effect_increase="Exits earlier, leaving more of a full reversion " + "uncaptured but reducing time-in-trade and reversal risk.", + effect_decrease="Holds longer for a more complete reversion, at the " + "cost of more time exposed to the spread reversing direction again " + "before exit.", + tradeoffs="Captured reversion vs. time-in-trade risk.", + interactions="Must be strictly below entry_threshold.", + ), + ParameterDoc( + name="stop_threshold", + what="Indicator magnitude that force-closes a position regardless " + "of direction -- a circuit breaker for a spread that keeps widening " + "instead of reverting.", + where="Step 5, checked before every other branch.", + why="Limits the indicator's own deviation tolerated before " + "requesting an exit on a relationship that may have broken down " + "rather than merely dislocated; it does not cap the realized " + "monetary loss -- gaps, execution delay, and a moving mean/" + "volatility/hedge-ratio or costs can still produce a larger loss " + "than the indicator distance alone would suggest.", + default="Indicator-specific: 4.0 (zscore), 45.0 (rsi), 0.49 " + "(percentile) -- applied when this parameter is left out entirely. " + "Pass an explicit stop_threshold=None to disable the stop " + "altogether.", + typical_range="Typically 1.5-2x entry_threshold; omit the " + "parameter to use the indicator-specific default, or pass None to " + "disable it.", + effect_increase="More room for the spread to widen before an exit " + "is requested -- fewer stop-outs on noise, but larger potential " + "loss per trade.", + effect_decrease="Tighter risk control, but more prone to being " + "stopped out by a temporary overshoot that would otherwise have " + "reverted.", + tradeoffs="Downside protection vs. premature stop-outs.", + interactions="Must exceed entry_threshold when set; an explicit " + "None disables the stop entirely (positions then only exit via " + "exit_threshold).", + ), + ParameterDoc( + name="dynamic_hedge_ratio", + what="Whether the hedge ratio is refit every period (True) or fit " + "once at formation and held constant (False).", + where="Step 1.", + why="A relationship's slope can itself drift over time; this " + "decides whether the strategy tracks that drift or assumes it " + "away.", + default="True", + typical_range="Boolean.", + effect_increase="N/A (boolean).", + effect_decrease="N/A (boolean).", + tradeoffs="True adapts to a genuinely drifting relationship but " + "makes the spread noisier (a moving hedge ratio adds its own " + "variance); False is a simpler, more stable spread definition but " + "can go stale if the true relationship shifts materially after " + "formation.", + interactions="The stationarity gate keeps the same recheck cadence; " + "this parameter changes whether its coefficients are refit or held static.", + ), + ParameterDoc( + name="adf_pvalue_threshold", + what="Maximum ADF p-value (from the periodic stationarity gate) at " + "which a NEW entry is still allowed -- optional: pass None to " + "disable the gate entirely (every date becomes tradable, subject " + "only to the entry/exit/stop thresholds).", + where="Step 4, compared against the gate's own p-value.", + why="Refuses to open a fresh position on a relationship the data no " + "longer supports as stationary, even if the indicator looks " + "attractive.", + default="0.10 (gate enabled by default).", + typical_range="0.05-0.10, or None to disable.", + effect_increase="Looser gate -- more candidate entries pass, " + "including weaker statistical evidence of stationarity.", + effect_decrease="Stricter gate -- fewer entries pass, but each one " + "clears a higher statistical bar.", + tradeoffs="Opportunity (more entries) vs. statistical rigor (fewer, " + "better-supported entries). Never affects an already-open " + "position's own exit/stop.", + interactions="Interacts with formation_window (more observations can " + "improve test precision but do not guarantee greater power) and " + "indicator_window (sets how often the gate is re-evaluated).", + ), + ParameterDoc( + name="price_type", + what="Which price series ('adjusted_close' or 'close') " + "generate_signals() reads to compute the hedge ratio, spread and " + "z-score. Execution/costs always use the raw close regardless.", + where="Every step above operates on whichever price series this selects.", + why="A split or large dividend on either leg shows up as a price " + "jump in raw close but not in adjusted close -- unadjusted, it " + "would look exactly like a spread dislocation.", + default="adjusted_close", + typical_range="adjusted_close (recommended) or close.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="adjusted_close avoids false dislocations from corporate " + "actions; close matches what was literally quoted at the time, " + "useful mainly for auditing against raw market data.", + interactions="A split on only one leg while using close would " + "corrupt the hedge ratio and spread for a long stretch after the " + "split -- adjusted_close is the safer default for exactly this " + "reason.", + ), + ParameterDoc( + name="stop_loss_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens BOTH legs together, based on the PAIR's combined " + "economic P&L -- not either leg's own return in isolation.", + where="Applied after the backtest allocator/constraints/" + "rebalancing/execution, on the position actually held -- see " + "`PairsTradingStrategy.position_groups()` (declares the two legs " + "as one group) and `quantlab.backtesting.accounting.`" + "`_detect_stop_loss_take_profit`.", + why="A hedge leg's own gain can OFFSET the pair's real loss (or " + "vice versa) -- a per-leg stop would misjudge risk entirely; the " + "pair's own combined return, per unit of ITS OWN gross exposure " + "at each date, is correct regardless of a static or dynamic " + "hedge ratio, rebalancing, or partial fills.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="More room before the pair is forced flat -- " + "fewer stop-outs on ordinary spread noise, larger potential " + "realized loss.", + effect_decrease="Tighter monetary risk control on the pair as a " + "whole, more prone to being stopped out by a temporary spread move.", + tradeoffs="Realized-loss protection vs. premature exits. Evaluated " + "on GROSS (pre-cost) return -- QuantLab's execution cost model is " + "portfolio-level only, so an exact net-of-cost trigger is not " + "presently computable; this is a disclosed design convention, not " + "a universal definition.", + interactions="Independent of entry_threshold/exit_threshold/" + "stop_threshold (the spread's own indicator-based stop). Once " + "triggered, no immediate re-entry at a rebased price -- both legs " + "stay flat until the pair's next real entry.", + ), + ParameterDoc( + name="take_profit_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens both legs together on the favorable side, based " + "on the pair's combined P&L.", + where="Same mechanism as stop_loss_pct, opposite direction.", + why="Realizes a gain directly once the pair's own combined return " + "target is reached, instead of depending on the spread reverting " + "all the way back through exit_threshold.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="Lets more of a favorable spread move run before " + "locking it in.", + effect_decrease="Locks in gains earlier, potentially forfeiting " + "further convergence.", + tradeoffs="Locking in gains early vs. capturing a larger reversion.", + interactions="Independent of stop_loss_pct and the entry/exit/" + "stop indicator-threshold family; see stop_loss_pct's own doc for " + "the shared combined-P&L/gross-return/re-entry conventions.", + ), +] + + +@dataclass(frozen=True) +class PairsTradingDiagnostics: + """Pair relationship diagnostics, plus trading-threshold breach counts. + + ``diagnostics`` is the existing correlation/hedge-ratio/ADF/ + cointegration/half-life view (unchanged). The trading-threshold fields + add the "which BARS satisfy the entry condition under the ADF filter" + view from the interactive lab's own Trading thresholds section, + computed on the SAME centered indicator the real strategy trades + (`indicator`, resolved exactly as `PairsTradingStrategy` itself + resolves it). ``viable_bars`` counts every bar the threshold+gate + condition holds, NOT distinct trade-entry events -- the live + strategy's own state machine (`_walk_pairs_positions_with_reasons`) + only opens a position on the FIRST such bar after being flat, so this + count is generally larger than the real number of entries a backtest + would make. + """ + + diagnostics: PairDiagnostics + indicator: str + entry_threshold: float + exit_threshold: float + stop_threshold: float | None + adf_pvalue_threshold: float | None + entry_breaches: int + viable_bars: int + stop_breaches: int + + +def _compute_diagnostics( + data: pd.DataFrame, cfg: ExperimentConfig +) -> PairsTradingDiagnostics: + from quantlab.data.base import price_matrix + from quantlab.features.pairs_diagnostics import compute_pair_diagnostics + from quantlab.strategies.pairs_trading import PairsTradingStrategy + + params = cfg.strategy_parameters + # `strategy.parameters.price_type` is rejected at config validation + # (see `StrategyConfig._reject_price_type_in_parameters`) -- the + # strategy's own price series is always `strategy.signal_price_type`. + # Diagnostics computed on the wrong price series would show a + # different hedge ratio/spread than the one actually traded. + price_type = cfg.strategy.signal_price_type + prices = price_matrix(data, adjusted=price_type != "close") + # Built from the SAME constructor call the real backtest made -- see + # MeanReversionStrategy's identical rationale for stop_threshold's + # None-vs-unset semantics, shared by this strategy. + strategy = PairsTradingStrategy(**params) + diagnostics = compute_pair_diagnostics( + prices, + strategy.symbol_a, + strategy.symbol_b, + formation_window=strategy.formation_window, + indicator_window=strategy.indicator_window, + dynamic_hedge_ratio=strategy.dynamic_hedge_ratio, + indicator=strategy.indicator, + ) + indicator = diagnostics.spread_indicator + entry = strategy.entry_threshold + stop = strategy.stop_threshold + adf_threshold = strategy.adf_pvalue_threshold + crosses_entry = (indicator > entry) | (indicator < -entry) + if adf_threshold is not None: + gate_open = ( + diagnostics.rolling_adf_pvalue.reindex(indicator.index) <= adf_threshold + ).fillna(False) + else: + gate_open = pd.Series(True, index=indicator.index) + viable = crosses_entry & gate_open + stop_breaches = ( + int(((indicator > stop) | (indicator < -stop)).sum()) if stop is not None else 0 + ) + return PairsTradingDiagnostics( + diagnostics=diagnostics, + indicator=strategy.indicator, + entry_threshold=entry, + exit_threshold=strategy.exit_threshold, + stop_threshold=stop, + adf_pvalue_threshold=adf_threshold, + entry_breaches=int(crosses_entry.sum()), + viable_bars=int(viable.sum()), + stop_breaches=stop_breaches, + ) + + +def _render_diagnostics(st: Any, result: PairsTradingDiagnostics) -> None: + from quantlab.dashboard.components import render_pair_diagnostics + + render_pair_diagnostics( + st, + result.diagnostics, + entry_threshold=result.entry_threshold, + exit_threshold=result.exit_threshold, + stop_threshold=result.stop_threshold, + adf_pvalue_threshold=result.adf_pvalue_threshold, + ) + + +def _report_section(result: PairsTradingDiagnostics) -> DiagnosticsSection: + from quantlab.reporting.charts import fig_to_base64, pair_spread_chart + from quantlab.reporting.sections import DiagnosticsSection + from quantlab.reporting.tables import pair_diagnostics_summary_table + + table = pair_diagnostics_summary_table(result.diagnostics) + threshold_rows = pd.DataFrame( + [ + ("Indicator", result.indicator), + ("Entry threshold", result.entry_threshold), + ("Exit threshold", result.exit_threshold), + ( + "Stop threshold", + result.stop_threshold + if result.stop_threshold is not None + else "disabled", + ), + ( + "ADF p-value threshold", + result.adf_pvalue_threshold + if result.adf_pvalue_threshold is not None + else "disabled", + ), + ("Entry threshold breaches (bar count)", result.entry_breaches), + ("Viable bars (threshold + ADF gate)", result.viable_bars), + ("Stop threshold breaches", result.stop_breaches), + ], + columns=["Metric", "Value"], + ) + table = pd.concat([table, threshold_rows], ignore_index=True) + return DiagnosticsSection( + table=table, + chart_data_uri=fig_to_base64(pair_spread_chart(result.diagnostics)), + note=( + "Pair relationship diagnostics (correlation, hedge ratio, " + "spread stationarity, cointegration) plus trading-threshold " + "breach counts on the configured indicator's centered series, " + "including how many crossings were also viable under the ADF " + "stationarity gate." + ), + ) + + +def _lab(st: Any) -> None: + from quantlab.dashboard.explorer.labs.pairs_trading import render + + render(st) + + +register_profile( + StrategyProfile( + strategy_name="pairs_trading", + display_name="Pairs Trading", + category="Relative value", + overview_md=_OVERVIEW, + economic_intuition_md=_ECONOMIC_INTUITION, + mathematical_definition_md=_MATH, + assumptions_md=_ASSUMPTIONS, + diagnostics_md=_DIAGNOSTICS, + interpretation_md=_INTERPRETATION, + limitations_md=_LIMITATIONS, + references_md=_REFERENCES, + parameters=_PARAMETERS, + lab=_lab, + results_diagnostics=ResultsDiagnostics( + key="pair_diagnostics", + compute=_compute_diagnostics, + render=_render_diagnostics, + report_section=_report_section, + ), + ) +) diff --git a/src/quantlab/dashboard/explorer/profiles/time_series_momentum.py b/src/quantlab/dashboard/explorer/profiles/time_series_momentum.py new file mode 100644 index 0000000..a0d416c --- /dev/null +++ b/src/quantlab/dashboard/explorer/profiles/time_series_momentum.py @@ -0,0 +1,516 @@ +"""Strategy Explorer profile for ``time_series_momentum``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pandas as pd + +from quantlab.dashboard.explorer.profile import ( + ParameterDoc, + ResultsDiagnostics, + StrategyProfile, + register_profile, +) + +if TYPE_CHECKING: + from quantlab.config import ExperimentConfig + from quantlab.reporting.sections import DiagnosticsSection + +_OVERVIEW = """ +Time-series momentum emits a directional signal from each asset's own +trend: positive momentum produces a long signal; negative momentum +produces a flat or short signal. Unlike cross-sectional momentum, there is +no universe ranking -- each symbol is judged purely against its own past, +independent of how any other asset is doing. Also called absolute momentum or trend +following on returns. + +Typical horizon: tens to hundreds of bars (often weeks to months on daily +data), set by `lookback_period`. Data needed: one asset's own price history, +long enough to build a stable score before trading starts. +""" + +_ECONOMIC_INTUITION = """ +Some asset-price trends have historically persisted for a while before +reversing. Proposed explanations include gradual information diffusion, +institutional flows that take time to execute, and behavioural +trend-following or herding. Trading in the direction of an established trend is +a bet that whatever is driving it (an improving/deteriorating +fundamental picture, sustained buying/selling pressure) has not yet fully +played out. +""" + +_MATH = """ +`generate_signals()`'s pipeline, in order: + +1. **Score** -- `score = momentum(prices, lookback_period, skip_period)` = + `P_{t-skip} / P_{t-lookback} - 1`, exactly as in cross-sectional + momentum, but evaluated for one asset in isolation (no ranking against + others). +2. **Scaling** (`signal_scaling`) turns that score into a signal in + `[-1, 1]`: + - `binary`: `sign(score)` -- emits `+1` or `-1` (or `0` if exactly + zero), regardless of how strong the trend is. + - `continuous`: `clip(score / rolling_std(score, lookback_period), -1, + 1)` -- scales the signal by how unusual the current score is relative + to its own recent dispersion. + - `volatility_adjusted`: `clip(score / realized_volatility(returns, + volatility_window, periods_per_year), -1, 1)` -- scales down in + high-volatility regimes and up in low-volatility ones, for a given + raw score. +3. **`long_only`** clips the result to `>= 0` when set, removing short + signals entirely. + +These values are strategy signals, not final portfolio weights. The +allocator decides how signal magnitude is translated into target weights; +portfolio constraints, volatility targeting, rebalancing and execution can +then modify or delay those targets further. +""" + +_ASSUMPTIONS = """ +**Economic**: this specific asset's own recent trend is assumed to contain +information about its near-term future direction (trend persistence), not merely +backward-looking noise. **Statistical**: the chosen `signal_scaling` mode +matches how the underlying trend actually behaves -- e.g. +`volatility_adjusted` assumes recent realised volatility is a reasonable +guide to near-term risk, which can fail sharply around a volatility +regime change. **Implementation**: `lookback_period` should be long enough +to reduce noise but short enough to react to a change in direction; the +data cannot guarantee either property. +""" + +_DIAGNOSTICS = """ +The lab below plots the raw momentum score, then all three +`signal_scaling` modes side by side on that SAME score (the clearest way +to see what changing this one parameter actually does), the realised +volatility series that drives the `volatility_adjusted` mode specifically, +and a past-score-vs-future-return persistence scatter for the chosen +asset. +""" + +_INTERPRETATION = """ +Compare the three scaling-mode lines: `binary` is a step function, while +`continuous` and `volatility_adjusted` vary the signal magnitude with the +score (and, for the latter, with trailing volatility). The latter mode is a +heuristic signal-scaling rule, not a portfolio-level volatility target: it +divides a lookback return by an annualised trailing volatility estimate and +clips the result. If the modes diverge, +`signal_scaling` would have changed the input supplied to the allocator; +the final exposure also depends on all downstream portfolio and execution +settings. A flat or negative persistence correlation on the chosen +asset is evidence this strategy's core premise does not hold well for it, +regardless of what a specific historical backtest shows. +""" + +_LIMITATIONS = """ +**Whipsaws**: in a choppy, range-bound market with no sustained +direction, trend-following signals can repeatedly flip. Those flips create +costs only when they change executed weights at rebalance dates -- see the +Trend Following strategy's own `efficiency_ratio` diagnostic for a direct +measure of this failure mode, equally applicable here. **Sharp reversals**: +a fast trend reversal (a market correction or a shock) can hurt before the +signal has time to catch up, since it is inherently backward-looking over +`lookback_period`. +**Regime dependence for `volatility_adjusted`**: trailing-volatility +sizing can react slowly to sudden volatility spikes, leaving positions +temporarily sized for a calmer regime than the one in which risk is +actually realized. +""" + +_REFERENCES = ( + 'Moskowitz, Ooi & Pedersen (2012), ["Time Series Momentum"](' + "https://doi.org/10.1016/j.jfineco.2011.11.003), *Journal of Financial " + "Economics* 104(2), 228-250, documents time-series return predictability " + "across 58 liquid futures contracts. It supports the broad " + "time-series-momentum premise, not QuantLab's specific asset universe, " + "parameter choices, or signal-scaling implementations." +) + +_PARAMETERS = [ + ParameterDoc( + name="lookback_period", + what="Total look-back window (periods) the momentum score is measured over.", + where="Step 1.", + why="Sets the horizon over which 'the trend' is defined for this one asset.", + default="252", + typical_range="63-252 periods.", + effect_increase="Captures a longer, smoother trend; slower to " + "react to a genuine new trend.", + effect_decrease="More responsive to a recent shift, but noisier.", + tradeoffs="Horizon length vs. responsiveness.", + interactions="Must exceed skip_period; also the window " + "`continuous` scaling uses for the score's own rolling dispersion.", + ), + ParameterDoc( + name="skip_period", + what="Most recent periods excluded from the lookback window.", + where="Step 1.", + why="Very recent short-term returns have sometimes shown reversal " + "rather than continuation.", + default="21", + typical_range="0-21 periods.", + effect_increase="Cleaner separation from short-term reversal, " + "slower reaction to a genuinely very recent shift.", + effect_decrease="More responsive, more exposed to short-term reversal.", + tradeoffs="Signal purity vs. responsiveness.", + interactions="Must be strictly less than lookback_period.", + ), + ParameterDoc( + name="signal_scaling", + what="How the raw momentum score is mapped to a signal magnitude " + "-- binary/continuous/volatility_adjusted.", + where="Step 2 -- determines how the value supplied to the allocator " + "varies with signal strength.", + why="Different scaling modes make very different bets: full signal " + "magnitude on any nonzero score (binary) vs. graded sizing by " + "conviction (continuous) vs. graded sizing by conviction AND " + "current risk (volatility_adjusted).", + default="binary", + typical_range="One of the three modes.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="binary is simplest and ignores trend magnitude; " + "continuous scales with the score but needs a stable " + "rolling dispersion estimate; volatility_adjusted additionally " + "adjusts the signal using a risk estimate but depends on volatility_window/" + "periods_per_year being well-chosen.", + interactions="continuous and volatility_adjusted both need " + "reliable rolling statistics -- noisy with too little history. " + "The latter is separate from any portfolio-level " + "target_volatility setting.", + ), + ParameterDoc( + name="volatility_window", + what="Trailing window (periods) for realised volatility, used " + "only by the volatility_adjusted scaling mode.", + where="Step 2, volatility_adjusted branch only.", + why="Defines what 'current risk' means for sizing purposes.", + default="63", + typical_range="21-126 periods.", + effect_increase="Smoother, slower-changing risk estimate.", + effect_decrease="Faster-reacting risk estimate, noisier.", + tradeoffs="Stability vs. responsiveness of the risk estimate.", + interactions="Only matters when signal_scaling='volatility_" + "adjusted'; interacts with periods_per_year (annualisation).", + ), + ParameterDoc( + name="long_only", + what="Whether short signals are structurally disabled.", + where="Applied as a final clip to >= 0.", + why="Many portfolios/mandates cannot or should not short.", + default="True", + typical_range="Boolean.", + effect_increase="N/A (boolean).", + effect_decrease="N/A (boolean).", + tradeoffs="True avoids short-specific costs/constraints but " + "forfeits potential gains from downtrends; False represents both " + "directions but adds short-side and whipsaw risk.", + interactions="When False the signal is symmetric around zero, but " + "the allocator and constraints still determine final long/short weights.", + ), + ParameterDoc( + name="periods_per_year", + what="Annualisation factor used to convert per-period volatility " + "into annualised volatility for the volatility_adjusted scaling " + "mode.", + where="Step 2, volatility_adjusted branch only (via realized_volatility).", + why="Volatility is naturally a per-period quantity; annualising " + "it makes the number comparable across different bar frequencies " + "and to conventional risk figures.", + default="252 (injected from the experiment's own data frequency; " + "not usually set explicitly per-strategy).", + typical_range="252 for daily equities, 365 for daily crypto (24/7 " + "markets), or the bars-per-year implied by the configured " + "frequency.", + effect_increase="Scales the reported/used volatility level up for " + "the same raw return dispersion -- shifts how aggressively " + "volatility_adjusted sizes down in a given regime.", + effect_decrease="Scales it down -- less aggressive de-risking for " + "the same raw dispersion.", + tradeoffs="Getting this wrong for the data's actual frequency " + "silently mis-scales every volatility_adjusted signal; it should " + "match the experiment's own annualisation, not be tuned as a free " + "parameter.", + interactions="Has no effect at all unless signal_scaling=" + "'volatility_adjusted'.", + ), + ParameterDoc( + name="price_type", + what="Which price series ('adjusted_close' or 'close') " + "generate_signals() reads. Execution/costs always use the raw " + "close regardless.", + where="Feeds the momentum score in step 1.", + why="A split or large dividend shows up as a price jump in raw " + "close but not in adjusted close -- unadjusted, it would look " + "exactly like a genuine trend move.", + default="adjusted_close", + typical_range="adjusted_close (recommended) or close.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="adjusted_close avoids false trend signals from " + "corporate actions; close matches what was literally quoted.", + interactions="A split near the current date would otherwise " + "register as a large, entirely spurious trend signal.", + ), + ParameterDoc( + name="stop_loss_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens this symbol's REAL executed position -- unlike " + "this strategy's own momentum score, which is memoryless (no " + "notion of 'since entry'), this operates on the actual position " + "held after the allocator/constraints/rebalancing/execution.", + where="Applied downstream of generate_signals() entirely -- see " + "`quantlab.backtesting.accounting._detect_stop_loss_take_profit`. " + "generate_signals() itself is unchanged by this parameter.", + why="A trend can reverse sharply before the trailing momentum " + "score itself catches up (it is backward-looking over " + "lookback_period) -- this bounds the realized loss directly, " + "independent of the score.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="More room before a forced exit -- fewer stop-outs " + "on ordinary volatility, larger potential realized loss per trade.", + effect_decrease="Tighter monetary risk control, more prone to " + "being stopped out by a temporary adverse move before the trend " + "score itself reverses.", + tradeoffs="Realized-loss protection vs. premature exits. Evaluated " + "on GROSS (pre-cost) return -- QuantLab's execution cost model is " + "portfolio-level only, so an exact net-of-cost trigger is not " + "presently computable; this is a disclosed design convention, not " + "a universal definition.", + interactions="Once triggered, no immediate re-entry at a rebased " + "price -- flat until the position's next real entry (a fresh " + "flat-to-non-flat transition of the executed weight).", + ), + ParameterDoc( + name="take_profit_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens this symbol's REAL executed position on the " + "favorable side -- locks in a gain directly rather than waiting " + "for the momentum score to fade.", + where="Same mechanism as stop_loss_pct, opposite direction.", + why="Realizes a gain directly once a target is reached, instead " + "of depending on the trend persisting (and then reversing) before " + "the score itself signals an exit.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="Lets more of a favorable trend run before locking it in.", + effect_decrease="Locks in gains earlier, potentially forfeiting " + "further trend continuation.", + tradeoffs="Locking in gains early vs. capturing a longer trend.", + interactions="Independent of stop_loss_pct; see its own doc for " + "the shared gross-return/re-entry conventions.", + ), +] + + +def _lab(st: Any) -> None: + from quantlab.dashboard.explorer.labs.time_series_momentum import render + + render(st) + + +#: Fixed default forward-return horizon for this diagnostic -- matching +#: the interactive lab's own default. Deliberately INDEPENDENT of +#: ``skip_period`` (a strategy parameter meaning "how much of the recent +#: past to exclude from the score", not "how long to hold looking +#: forward"): ``skip_period=0`` is a perfectly valid strategy config, but +#: ``holding_period=0`` is rejected by ``momentum_persistence`` (must be +#: >= 1), and a `skip_period` above 252 (also valid -- only constrained to +#: be < lookback_period) would put the Results-tab slider's default value +#: outside its own 1-252 range. Never reuse ``skip_period`` here again. +_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD = 21 + + +@dataclass(frozen=True) +class TimeSeriesMomentumDiagnostics: + """Past-momentum-vs-future-return persistence, one row per symbol. + + ``summary``/``paired`` are computed at ``holding_period`` (fixed to + ``_DEFAULT_DIAGNOSTIC_HOLDING_PERIOD`` -- a stable, documented default + independent of any UI widget AND of ``skip_period``, see that + constant's own docstring) for the exported HTML report, which has no + interactivity. ``prices``/``lookback_period``/``skip_period`` are + carried alongside so the Results tab can recompute this SAME + diagnostic at a user-chosen holding_period on demand -- a cheap, + purely local recomputation, not a backtest re-run (see + ``_render_diagnostics``). + """ + + holding_period: int + lookback_period: int + skip_period: int + prices: dict[str, pd.Series] + summary: pd.DataFrame + paired: dict[str, pd.DataFrame] + + +def _persistence_tables( + prices: dict[str, pd.Series], + lookback_period: int, + skip_period: int, + holding_period: int, +) -> tuple[pd.DataFrame, dict[str, pd.DataFrame]]: + from quantlab.features.momentum import momentum_persistence + + rows = [] + paired_by_symbol: dict[str, pd.DataFrame] = {} + for symbol, series in prices.items(): + paired = momentum_persistence( + series, lookback_period, skip_period, holding_period + ) + correlation = ( + float(paired["past_momentum"].corr(paired["future_return"])) + if not paired.empty + else float("nan") + ) + rows.append( + {"Symbol": symbol, "Correlation": correlation, "Observations": len(paired)} + ) + paired_by_symbol[symbol] = paired + summary = pd.DataFrame(rows).set_index("Symbol") + return summary, paired_by_symbol + + +def _compute_diagnostics( + data: pd.DataFrame, cfg: ExperimentConfig +) -> TimeSeriesMomentumDiagnostics: + from quantlab.data.base import price_matrix + + params = cfg.strategy_parameters + lookback = int(params.get("lookback_period", 252)) + skip = int(params.get("skip_period", 21)) + price_type = cfg.strategy.signal_price_type + price_frame = price_matrix(data, adjusted=price_type != "close") + prices = {symbol: price_frame[symbol] for symbol in price_frame.columns} + # Fixed, skip_period-INDEPENDENT default (see + # _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD's own docstring for why). The + # Results tab lets the user override it independently (see + # _render_diagnostics); the exported HTML report always uses this + # fixed value so the report stays stable and reproducible. + holding_period = _DEFAULT_DIAGNOSTIC_HOLDING_PERIOD + summary, paired = _persistence_tables(prices, lookback, skip, holding_period) + return TimeSeriesMomentumDiagnostics( + holding_period=holding_period, + lookback_period=lookback, + skip_period=skip, + prices=prices, + summary=summary, + paired=paired, + ) + + +def _render_diagnostics(st: Any, result: TimeSeriesMomentumDiagnostics) -> None: + st.subheader("Momentum persistence diagnostics") + holding_period = st.slider( + "Forward-return horizon (periods) for this diagnostic", + 1, + 252, + result.holding_period, + key="tsmom_results_diag_holding_period", + help=( + "Diagnostic setting only -- changing this does not rerun or " + "alter the backtest, only how many periods ahead this " + "persistence check looks." + ), + ) + st.caption( + "Forward-return horizon used only for this diagnostic; it does not " + "change the strategy or its backtest. Longer horizons produce " + "overlapping forward-return windows across consecutive dates, so " + "the apparent number of observations overstates the independent " + "information actually available -- treat this as descriptive " + "sample evidence, not a hypothesis test." + ) + if holding_period == result.holding_period: + summary, paired_by_symbol = result.summary, result.paired + else: + summary, paired_by_symbol = _persistence_tables( + result.prices, result.lookback_period, result.skip_period, holding_period + ) + st.caption( + f"Does past momentum score predict the subsequent " + f"{holding_period}-period return, per symbol?" + ) + st.dataframe(summary, width="stretch") + symbol = st.selectbox( + "Symbol", list(paired_by_symbol), key="tsmom_results_diag_symbol" + ) + paired = paired_by_symbol[symbol] + if paired.empty: + st.info("Not enough history to pair momentum with a future return yet.") + return + import plotly.graph_objects as go + + fig = go.Figure( + go.Scatter( + x=paired["past_momentum"], + y=paired["future_return"], + mode="markers", + marker={"size": 5, "opacity": 0.5}, + ) + ) + fig.update_layout( + title=f"{symbol}: past momentum vs. subsequent {holding_period}-period return", + xaxis_title="Past momentum score", + yaxis_title="Future return", + height=380, + ) + st.plotly_chart(fig, width="stretch") + + +def _report_section(result: TimeSeriesMomentumDiagnostics) -> DiagnosticsSection: + from quantlab.dashboard.explorer.shared_components import live_widget_value + from quantlab.reporting.sections import DiagnosticsSection + + # Reflects the user's own live Results-tab slider choice (see + # _render_diagnostics), not always result.holding_period -- falls back + # to it when the dashboard isn't running at all (e.g. the CLI's own + # report generation) or that slider was never rendered this session. + holding_period = live_widget_value( + "tsmom_results_diag_holding_period", result.holding_period + ) + if holding_period == result.holding_period: + summary = result.summary + else: + summary, _ = _persistence_tables( + result.prices, result.lookback_period, result.skip_period, holding_period + ) + table = summary.reset_index() + return DiagnosticsSection( + table=table, + note=( + "Momentum persistence: correlation between each symbol's past " + f"momentum score and its subsequent {holding_period}-" + "period return. Descriptive sample evidence, not a hypothesis " + "test -- overlapping holding periods are not independent." + ), + ) + + +register_profile( + StrategyProfile( + strategy_name="time_series_momentum", + display_name="Time-Series Momentum", + category="Trend / momentum", + overview_md=_OVERVIEW, + economic_intuition_md=_ECONOMIC_INTUITION, + mathematical_definition_md=_MATH, + assumptions_md=_ASSUMPTIONS, + diagnostics_md=_DIAGNOSTICS, + interpretation_md=_INTERPRETATION, + limitations_md=_LIMITATIONS, + references_md=_REFERENCES, + parameters=_PARAMETERS, + lab=_lab, + results_diagnostics=ResultsDiagnostics( + key="time_series_momentum_diagnostics", + compute=_compute_diagnostics, + render=_render_diagnostics, + report_section=_report_section, + ), + ) +) diff --git a/src/quantlab/dashboard/explorer/profiles/trend_following.py b/src/quantlab/dashboard/explorer/profiles/trend_following.py new file mode 100644 index 0000000..b44c0df --- /dev/null +++ b/src/quantlab/dashboard/explorer/profiles/trend_following.py @@ -0,0 +1,509 @@ +"""Strategy Explorer profile for ``trend_following``.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any + +import pandas as pd + +from quantlab.dashboard.explorer.profile import ( + ParameterDoc, + ResultsDiagnostics, + StrategyProfile, + register_profile, +) + +if TYPE_CHECKING: + from quantlab.config import ExperimentConfig + from quantlab.reporting.sections import DiagnosticsSection + +_OVERVIEW = """ +The simplest possible trend signal: two moving averages of the same +price, a fast one and a slow one. When the fast average is above the +slow one, the recent price action is running "hot" relative to the +longer-term level -- treated as an uptrend, and vice versa. This is a +classic technical trend-following construction, distinct from +time-series momentum's return-based score (though both aim to capture +the same underlying trend-persistence effect via a different lens). + +Typical horizon: set by `slow_window` -- tens to hundreds of bars (often +weeks to months on daily data) for conventional 20/100 or 50/200-style +pairings. Data needed: one asset's own price history, at least +`slow_window` long before a signal exists. +""" + +_ECONOMIC_INTUITION = """ +Same underlying premise as time-series momentum -- some price trends may +persist for a while, potentially because of gradual information diffusion +and trend-following flows -- +expressed through a moving-average crossover instead of a raw return +score. A crossover is a simpler, more interpretable trend detector: it +directly asks "is the recent average price above or below the +longer-term average", which is easy to reason about and has a long +history in technical analysis. +""" + +_MATH = """ +`generate_signals()`'s pipeline: + +1. **Two moving averages** -- `fast = moving_average(prices, + fast_window)`, `slow = moving_average(prices, slow_window)`, each a + simple trailing mean. +2. **Crossover sign** -- `signal = sign(fast - slow)`: `+1` when the fast + average sits above the slow one, `-1` when below, `0` on an exact tie, + `NaN` during warm-up (before `slow_window` observations exist) -- + `_validate_signals()` then converts that warm-up `NaN` into an actual + flat (`0.0`) position before this becomes the strategy's real output, + exactly like every other built-in strategy's warm-up period. +3. **`long_only`** clips the result to `>= 0` when set, so a downtrend + simply goes flat instead of short. + +That's the entire strategy signal -- no smoothing beyond the two moving +averages themselves and no separate confirmation step. It is not yet a +portfolio weight: the allocator, constraints, rebalancing schedule and +execution model determine the weight that is ultimately traded. +""" + +_ASSUMPTIONS = """ +**Economic**: the asset is assumed to exhibit trend persistence that a +moving-average crossover can detect, rather than only noise around a stable level. +**Statistical**: the price series has enough directional +persistence (see the lab's Efficiency Ratio diagnostic) that a crossover +signal isn't dominated by whipsaws. **Implementation**: `fast_window`/ +`slow_window` are set to a horizon where a real trend, once established, +lasts noticeably longer than the lag the crossover itself introduces +(a slow-moving average by construction reacts to a trend change well +after it has already started). +""" + +_DIAGNOSTICS = """ +The lab below shows the crossover itself, a whipsaw diagnostic (how many +times the raw crossover direction changes within a trailing window -- an +upper-bound source of potential turnover when `long_only=True`, and a source +of actual turnover only when sampled targets change at rebalance dates), +Kaufman's Efficiency Ratio (a 0-1 measure of how +"clean" vs. "choppy" the recent price path has been, independent of +direction), and a side-by-side comparison of a few conventional +fast/slow window pairings. A perfectly flat window makes the usual ratio +mathematically undefined (`0 / 0`); QuantLab displays `0.5` for that special +case, so interpret it as a neutral implementation convention rather than +evidence of a moderately efficient trend. +""" + +_INTERPRETATION = """ +A high, stable Efficiency Ratio alongside few raw crossover changes describes a +market this strategy is well-suited to (clean, sustained trends). A low +Efficiency Ratio alongside frequent raw crossover changes describes a choppy, +range-bound +market -- exactly where trend following can struggle. Signal changes only +create trades and transaction costs when they alter the executed target at +a rebalance after downstream allocation, constraints and execution. Compare +these two diagnostics across different periods on the same instrument to +see how much the strategy's own suitability changes over time, not just +across instruments. +""" + +_LIMITATIONS = """ +**Whipsaws**: the strategy's single biggest failure mode -- a choppy, +range-bound market repeatedly triggers crossovers in both directions +without ever capturing a sustained move. Crossovers that change executed +weights can generate turnover and transaction costs. **Lag**: a +moving-average crossover only confirms a trend change after it has partly +already happened (more so for a slower `slow_window`) +-- some of the early, most profitable part of a new trend is structurally +missed. **No magnitude information**: the signal only knows "above" or +"below", not "by how much" -- a fast average barely above the slow one +and a fast average far above it produce the identical `+1` signal. +""" + +_REFERENCES = ( + "Perry J. Kaufman, [*Trading Systems and Methods*, 5th ed.](https://doi." + "org/10.1002/9781119202561), is the specific source used here for " + "Kaufman's Efficiency Ratio and broader trend-system context. Zakamulin " + '& Giner (2023), ["Optimal trend-following with transaction costs"](' + "https://doi.org/10.1016/j.irfa.2023.102928), studies the relationship " + "between trend models, transaction costs and simple moving-average " + "crossover rules. Neither source establishes that QuantLab's specific " + "parameter choices or lookback windows will remain profitable." +) + +_PARAMETERS = [ + ParameterDoc( + name="fast_window", + what="Trailing window (periods) for the fast moving average.", + where="Step 1.", + why="Sets how quickly the 'current' side of the crossover reacts " + "to new prices.", + default="20", + typical_range="5-50 periods.", + effect_increase="A smoother fast average, closer to the slow one " + "-- fewer, later crossovers.", + effect_decrease="A twitchier fast average -- more, earlier " + "crossovers, more whipsaw risk in a choppy market.", + tradeoffs="Responsiveness vs. whipsaw frequency.", + interactions="Must be strictly less than slow_window; the gap " + "between the two mainly sets the pair's relative smoothing/lag, " + "not a price-move-size threshold -- both a small sustained drift " + "and a single sharp move can flip the crossover, depending on how " + "the two averages evolve.", + ), + ParameterDoc( + name="slow_window", + what="Trailing window (periods) for the slow moving average.", + where="Step 1.", + why="Defines the longer-term reference level the fast average is " + "compared against.", + default="100", + typical_range="50-200 periods.", + effect_increase="A more stable long-term reference, but a slower, " + "later-confirming signal -- more of an established trend is " + "missed before the crossover fires.", + effect_decrease="A faster-reacting reference, closer to " + "fast_window -- less lag, but a noisier, whipsaw-prone signal.", + tradeoffs="Confirmation lag vs. responsiveness.", + interactions="Must be strictly greater than fast_window.", + ), + ParameterDoc( + name="long_only", + what="Whether a downtrend (fast below slow) emits a flat signal " + "(True) or a short signal (False).", + where="Final clip to >= 0.", + why="Many portfolios/mandates cannot or should not short.", + default="True", + typical_range="Boolean.", + effect_increase="N/A (boolean).", + effect_decrease="N/A (boolean).", + tradeoffs="True avoids short-specific costs/constraints but " + "forfeits potential gains from downtrends; False represents both " + "directions but introduces short-side risk and additional ways for " + "a noisy crossover to create an adverse target.", + interactions="The allocator, portfolio constraints and rebalance " + "schedule decide whether that short signal becomes an executed short.", + ), + ParameterDoc( + name="price_type", + what="Which price series ('adjusted_close' or 'close') " + "generate_signals() reads. Execution/costs always use the raw " + "close regardless.", + where="Feeds both moving averages in step 1.", + why="A split or large dividend shows up as a price jump in raw " + "close but not in adjusted close -- unadjusted, it would look " + "exactly like a real crossover.", + default="adjusted_close", + typical_range="adjusted_close (recommended) or close.", + effect_increase="N/A -- a choice, not a magnitude.", + effect_decrease="N/A -- a choice, not a magnitude.", + tradeoffs="adjusted_close avoids false crossovers from corporate " + "actions; close matches what was literally quoted.", + interactions="A split near the current date would otherwise " + "trigger a spurious crossover in both moving averages at once.", + ), + ParameterDoc( + name="stop_loss_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens this symbol's REAL executed position -- unlike " + "the crossover itself, which is memoryless (no notion of 'since " + "entry'), this operates on the actual position held after the " + "allocator/constraints/rebalancing/execution.", + where="Applied downstream of generate_signals() entirely -- see " + "`quantlab.backtesting.accounting._detect_stop_loss_take_profit`. " + "generate_signals() itself is unchanged by this parameter.", + why="A crossover only confirms a trend change after it has partly " + "already happened (more so for a slower slow_window) -- this " + "bounds the realized loss directly while waiting for the slower " + "crossover to catch up.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="More room before a forced exit -- fewer stop-outs " + "on ordinary whipsaw, larger potential realized loss per trade.", + effect_decrease="Tighter monetary risk control, more prone to " + "being stopped out by a temporary adverse move before the " + "crossover itself reverses.", + tradeoffs="Realized-loss protection vs. premature exits. Evaluated " + "on GROSS (pre-cost) return -- QuantLab's execution cost model is " + "portfolio-level only, so an exact net-of-cost trigger is not " + "presently computable; this is a disclosed design convention, not " + "a universal definition.", + interactions="Once triggered, no immediate re-entry at a rebased " + "price -- flat until the position's next real entry (a fresh " + "flat-to-non-flat transition of the executed weight), even if the " + "raw crossover itself has not flipped back.", + ), + ParameterDoc( + name="take_profit_pct", + what="Fractional (e.g. 0.10 = 10%) gross-return threshold that " + "force-flattens this symbol's REAL executed position on the " + "favorable side -- locks in a gain directly rather than waiting " + "for the crossover to reverse.", + where="Same mechanism as stop_loss_pct, opposite direction.", + why="Realizes a gain directly once a target is reached, instead " + "of depending on the trend persisting (and then reversing) before " + "the crossover itself signals an exit.", + default="None (disabled) -- enabling it changes no existing " + "behavior unless explicitly set.", + typical_range="0.05-0.20, or None to disable.", + effect_increase="Lets more of a favorable trend run before locking it in.", + effect_decrease="Locks in gains earlier, potentially forfeiting " + "further trend continuation.", + tradeoffs="Locking in gains early vs. capturing a longer trend.", + interactions="Independent of stop_loss_pct; see its own doc for " + "the shared gross-return/re-entry conventions.", + ), +] + + +def _lab(st: Any) -> None: + from quantlab.dashboard.explorer.labs.trend_following import render + + render(st) + + +#: Fixed windows for the two diagnostics below, matching the interactive +#: lab's own defaults -- see time_series_momentum's identically-motivated +#: constants for why these are fixed rather than strategy parameters. +_WHIPSAW_WINDOW = 126 +_EFFICIENCY_RATIO_WINDOW = 200 +#: The Results-tab Efficiency Ratio slider's own declared bounds -- must +#: match the literal min/max passed to st.slider() below exactly. +_ER_SLIDER_MIN = 5 +_ER_SLIDER_MAX = _EFFICIENCY_RATIO_WINDOW + + +def _default_er_window(slow_window: int) -> int: + """Clamp the ER-window default into the slider's own declared range. + + `slow_window` can legitimately be as low as 2 (`fast_window=1, + slow_window=2` is a valid strategy config -- only `fast_window < + slow_window` and both `>= 1` are enforced), which would otherwise put + `min(slow_window, _EFFICIENCY_RATIO_WINDOW)` below the slider's + declared minimum of 5. + """ + return min(_ER_SLIDER_MAX, max(_ER_SLIDER_MIN, slow_window)) + + +@dataclass(frozen=True) +class TrendFollowingDiagnostics: + """Crossover/whipsaw/trend-strength diagnostics, one row per symbol. + + ``summary`` is computed at the default whipsaw/Efficiency Ratio + windows (``_WHIPSAW_WINDOW``, ``min(slow_window, + _EFFICIENCY_RATIO_WINDOW)``). ``prices``/``signal``/``slow_window`` + are carried alongside so the Results tab -- and the exported HTML + report, which reflects the same live widget choice (see + ``_report_section``) -- can recompute both diagnostics at a + user-chosen window on demand, a cheap, purely local recomputation, + not a backtest re-run (see ``_render_diagnostics``). + """ + + summary: pd.DataFrame + prices: dict[str, pd.Series] + fast_ma: dict[str, pd.Series] + slow_ma: dict[str, pd.Series] + signal: dict[str, pd.Series] + slow_window: int + + +def _whipsaw_and_efficiency_ratio_table( + prices: dict[str, pd.Series], + signal: dict[str, pd.Series], + whipsaw_window: int, + er_window: int, +) -> pd.DataFrame: + import numpy as np + + from quantlab.features.technical import efficiency_ratio + + rows = [] + for symbol, series in prices.items(): + flips = signal[symbol].diff().fillna(0.0).ne(0.0) + rolling_flips = flips.rolling(whipsaw_window, min_periods=1).sum() + er = efficiency_ratio(series, er_window) + rows.append( + { + "Symbol": symbol, + f"Whipsaw (flips / {whipsaw_window}p, latest)": float( + rolling_flips.iloc[-1] + ) + if len(rolling_flips) + else float("nan"), + "Median Efficiency Ratio": float(np.nanmedian(er.to_numpy())) + if len(er) + else float("nan"), + } + ) + return pd.DataFrame(rows).set_index("Symbol") + + +def _compute_diagnostics( + data: pd.DataFrame, cfg: ExperimentConfig +) -> TrendFollowingDiagnostics: + from quantlab.data.base import price_matrix + from quantlab.features.momentum import ma_crossover_signal, moving_average + + params = cfg.strategy_parameters + fast_window = int(params.get("fast_window", 20)) + slow_window = int(params.get("slow_window", 100)) + er_window = _default_er_window(slow_window) + price_type = cfg.strategy.signal_price_type + price_frame = price_matrix(data, adjusted=price_type != "close") + + prices: dict[str, pd.Series] = {} + fast_ma: dict[str, pd.Series] = {} + slow_ma: dict[str, pd.Series] = {} + signal: dict[str, pd.Series] = {} + for symbol in price_frame.columns: + series = price_frame[symbol] + prices[symbol] = series + fast_ma[symbol] = moving_average(series, fast_window) + slow_ma[symbol] = moving_average(series, slow_window) + signal[symbol] = ma_crossover_signal(series, fast_window, slow_window) + summary = _whipsaw_and_efficiency_ratio_table( + prices, signal, _WHIPSAW_WINDOW, er_window + ) + return TrendFollowingDiagnostics( + summary=summary, + prices=prices, + fast_ma=fast_ma, + slow_ma=slow_ma, + signal=signal, + slow_window=slow_window, + ) + + +def _render_diagnostics(st: Any, result: TrendFollowingDiagnostics) -> None: + from quantlab.dashboard.explorer.shared_components import ( + ENTRY_LINE_COLOR, + EXIT_LINE_COLOR, + render_price_chart, + ) + + st.subheader("Crossover / whipsaw / trend-strength diagnostics") + st.caption( + "Near 1 Efficiency Ratio: a clean, sustained trend (favourable for " + "this strategy). Near 0: a choppy path (noise dominating). A high " + "whipsaw count alongside a low Efficiency Ratio describes a market " + "this strategy struggles with." + ) + col_w, col_er = st.columns(2) + whipsaw_window = col_w.slider( + "Count flips over the trailing N periods", + 20, + 504, + _WHIPSAW_WINDOW, + key="tf_results_whipsaw_window", + ) + default_er_window = _default_er_window(result.slow_window) + er_window = col_er.slider( + "Efficiency Ratio window", + _ER_SLIDER_MIN, + _ER_SLIDER_MAX, + default_er_window, + key="tf_results_er_window", + ) + st.caption( + "Diagnostic settings only -- changing these values does not rerun " + "or alter the backtest; they only change how the already-computed " + "signal is analyzed. These are NOT strategy parameters: they affect " + "neither the signal nor the executed trades." + ) + if whipsaw_window == _WHIPSAW_WINDOW and er_window == default_er_window: + summary = result.summary + else: + summary = _whipsaw_and_efficiency_ratio_table( + result.prices, result.signal, whipsaw_window, er_window + ) + st.dataframe(summary, width="stretch") + symbol = st.selectbox("Symbol", list(result.signal), key="tf_results_diag_symbol") + render_price_chart( + st, + { + "Price": result.prices[symbol], + "Fast MA": result.fast_ma[symbol], + "Slow MA": result.slow_ma[symbol], + }, + title=f"{symbol}: fast/slow moving-average crossover", + # Price is left at Plotly's own default first-trace color; Fast/ + # Slow MA get explicit, visibly distinct colors so neither is ever + # mistaken for the price line itself. + colors={"Fast MA": ENTRY_LINE_COLOR, "Slow MA": EXIT_LINE_COLOR}, + ) + render_price_chart( + st, + {"Crossover signal": result.signal[symbol]}, + title=f"{symbol}: raw crossover signal", + yaxis_title="Signal", + ) + flips = result.signal[symbol].diff().fillna(0.0).ne(0.0) + rolling_flips = flips.rolling(whipsaw_window, min_periods=1).sum() + render_price_chart( + st, + {f"Raw crossover changes in trailing {whipsaw_window} periods": rolling_flips}, + title=f"{symbol}: raw crossover-change frequency over time", + yaxis_title="Flip count", + ) + from quantlab.features.technical import efficiency_ratio + + er_series = efficiency_ratio(result.prices[symbol], er_window) + render_price_chart( + st, + {"Efficiency Ratio": er_series}, + title=f"{symbol}: Kaufman's Efficiency Ratio", + yaxis_title="ER", + ) + + +def _report_section(result: TrendFollowingDiagnostics) -> DiagnosticsSection: + from quantlab.dashboard.explorer.shared_components import live_widget_value + from quantlab.reporting.sections import DiagnosticsSection + + default_er_window = _default_er_window(result.slow_window) + # Reflects the user's own live Results-tab slider choices (see + # _render_diagnostics), not always the fixed defaults -- falls back to + # them when the dashboard isn't running at all (e.g. the CLI's own + # report generation) or those sliders were never rendered this session. + whipsaw_window = live_widget_value("tf_results_whipsaw_window", _WHIPSAW_WINDOW) + er_window = live_widget_value("tf_results_er_window", default_er_window) + if whipsaw_window == _WHIPSAW_WINDOW and er_window == default_er_window: + summary = result.summary + else: + summary = _whipsaw_and_efficiency_ratio_table( + result.prices, result.signal, whipsaw_window, er_window + ) + table = summary.reset_index() + return DiagnosticsSection( + table=table, + note=( + "Trend-following diagnostics per symbol: the most recent " + f"whipsaw (raw crossover flip) count over a trailing " + f"{whipsaw_window}-period window, and the median Kaufman " + f"Efficiency Ratio over a trailing {er_window}-period window " + "(near 1 = clean trend, near 0 = choppy noise)." + ), + ) + + +register_profile( + StrategyProfile( + strategy_name="trend_following", + display_name="Trend Following", + category="Trend / momentum", + overview_md=_OVERVIEW, + economic_intuition_md=_ECONOMIC_INTUITION, + mathematical_definition_md=_MATH, + assumptions_md=_ASSUMPTIONS, + diagnostics_md=_DIAGNOSTICS, + interpretation_md=_INTERPRETATION, + limitations_md=_LIMITATIONS, + references_md=_REFERENCES, + parameters=_PARAMETERS, + lab=_lab, + results_diagnostics=ResultsDiagnostics( + key="trend_following_diagnostics", + compute=_compute_diagnostics, + render=_render_diagnostics, + report_section=_report_section, + ), + ) +) diff --git a/src/quantlab/dashboard/explorer/shared_components.py b/src/quantlab/dashboard/explorer/shared_components.py new file mode 100644 index 0000000..20242f1 --- /dev/null +++ b/src/quantlab/dashboard/explorer/shared_components.py @@ -0,0 +1,593 @@ +"""Reusable Strategy Explorer UI components -- presentation only, no calculation. + +Every function takes ``st`` (the Streamlit module, or a compatible fake for +tests) as its first argument, mirroring ``quantlab.dashboard.components``'s +own convention. The actual numbers always come from ``quantlab.features.*`` +or a lab's own recomputation -- nothing here fits a statistic or builds a +signal; it only draws what it is given. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import date +from typing import Any, cast + +import pandas as pd + +from quantlab.constants import EPSILON +from quantlab.features.stationarity import ADFResult, CointegrationResult +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + +#: Caps the shared lab price cache below -- a long dashboard session +#: trying many symbol/date/source combinations across every lab must not +#: grow it without bound. +_PRICE_CACHE_MAX_ENTRIES = 32 + +#: Shared "one color per role" palette for entry/exit/stop threshold lines +#: across labs -- both the positive and negative side of a threshold get +#: the SAME color (they are the same concept, mirrored), and different +#: roles get visibly different colors. Deliberately avoids blue: the +#: underlying price/indicator series these overlay is typically blue +#: (Plotly's own default first trace color). +ENTRY_LINE_COLOR = "#FF8C00" # orange +EXIT_LINE_COLOR = "#2CA02C" # green +STOP_LINE_COLOR = "#D62728" # red +VIABLE_ENTRY_MARKER_COLOR = "#9467BD" # purple, distinct from the 3 roles above + + +def live_widget_value(key: str, default: Any) -> Any: + """Read a widget's CURRENT value from ``st.session_state``, if any. + + Used so a Results-tab report (generated separately from the + interactive render pass -- see ``quantlab.dashboard.explorer.profile. + ResultsDiagnostics``) can reflect a user's live diagnostic-only widget + choice (e.g. a forward-return horizon slider) without threading that + value through the ``compute``/``render``/``report_section`` pipeline + explicitly. Falls back to ``default`` whenever that widget's value + isn't available -- not just when Streamlit itself isn't installed, but + also the ordinary case of the CLI's own report generation (``quantlab. + cli``), which calls ``report_section`` directly with no Streamlit + runtime/session at all, and the case where this particular widget was + never rendered this session (e.g. its section was never opened). + + ``streamlit`` missing entirely is the one case narrowed to a silent + fallback (documented, expected -- e.g. a stripped-down test + environment). Reading ``st.session_state`` itself failing for any + OTHER reason is unexpected (empirically, it degrades gracefully with + no exception even with no active script run context) -- logged rather + than swallowed silently, so an unrelated bug degrading a dashboard + value still leaves a trace instead of vanishing without one. + """ + try: + import streamlit as st + except ImportError: + return default + try: + return st.session_state.get(key, default) + except Exception: + logger.exception( + "live_widget_value(%r) could not read st.session_state -- " + "falling back to default %r", + key, + default, + ) + return default + + +def strong(value: str) -> str: + """Wrap a standout value in a heavier-than-markdown-bold inline span. + + For use inside an ``st.caption``/``st.markdown`` string passed with + ``unsafe_allow_html=True``. Plain markdown ``**bold**`` renders at a + single fixed weight that a caption's own small, muted styling can + wash out -- this pushes the weight further so a key computed number + still reads as emphasized against the surrounding caption text. + """ + return f'{value}' + + +def centered_indicator_threshold_overlay( + indicator: pd.Series, + indicator_name: str, + *, + entry_threshold: float, + exit_threshold: float, + stop_threshold: float | None, +) -> tuple[dict[str, pd.Series], dict[str, str]]: + """Build the named +/-entry/+/-exit/+/-stop series and their line colors. + + Shared by every "centered indicator with entry/exit/stop thresholds" + chart (mean_reversion, pairs_trading -- both their interactive labs and + their Results-tab diagnostics), so the naming/coloring convention stays + identical everywhere this chart appears. Returns ``(series, colors)``, + both keyed by the same threshold labels, ready to pass straight into + :func:`render_price_chart` (``series`` merged with the caller's own + indicator entry, ``colors`` merged with any of the caller's own, e.g. + a viable-entry marker color). + """ + series: dict[str, pd.Series] = { + indicator_name: indicator, + f"+entry {entry_threshold:g}": pd.Series( + entry_threshold, index=indicator.index + ), + f"-entry {entry_threshold:g}": pd.Series( + -entry_threshold, index=indicator.index + ), + f"+exit {exit_threshold:g}": pd.Series(exit_threshold, index=indicator.index), + f"-exit {exit_threshold:g}": pd.Series(-exit_threshold, index=indicator.index), + } + colors: dict[str, str] = { + f"+entry {entry_threshold:g}": ENTRY_LINE_COLOR, + f"-entry {entry_threshold:g}": ENTRY_LINE_COLOR, + f"+exit {exit_threshold:g}": EXIT_LINE_COLOR, + f"-exit {exit_threshold:g}": EXIT_LINE_COLOR, + } + if stop_threshold is not None: + series[f"+stop {stop_threshold:g}"] = pd.Series( + stop_threshold, index=indicator.index + ) + series[f"-stop {stop_threshold:g}"] = pd.Series( + -stop_threshold, index=indicator.index + ) + colors[f"+stop {stop_threshold:g}"] = STOP_LINE_COLOR + colors[f"-stop {stop_threshold:g}"] = STOP_LINE_COLOR + return series, colors + + +def load_explorer_prices_cached( + st: Any, + symbols: Sequence[str], + *, + source: str, + calendar: str, + start_date: date, + end_date: date, + use_bundled_demo_data: bool = False, +) -> pd.DataFrame: + """Load Strategy Explorer lab price data through one shared, bounded cache. + + Every lab shares this one ``@st.cache_data`` cache, keyed the same way + and bounded with ``max_entries`` so a long session trying many symbol/ + date/source combinations cannot grow it without limit. ``source``/ + ``calendar``/``use_bundled_demo_data`` are real cache-key arguments + (not closed-over constants) so two calls that differ only in one of + them can never collide. + """ + from quantlab.dashboard.state import load_explorer_prices + + @st.cache_data( + show_spinner="Loading price data...", max_entries=_PRICE_CACHE_MAX_ENTRIES + ) + def _load( + symbols: tuple[str, ...], + source: str, + calendar: str, + start: date, + end: date, + use_bundled_demo_data: bool, + ) -> pd.DataFrame: + return load_explorer_prices( + list(symbols), + source=source, + calendar=calendar, + start_date=start, + end_date=end, + use_bundled_demo_data=use_bundled_demo_data, + ) + + return cast( + pd.DataFrame, + _load( + tuple(sorted(symbols)), + source, + calendar, + start_date, + end_date, + use_bundled_demo_data, + ), + ) + + +def render_symbol_and_source_picker( + st: Any, + *, + key_prefix: str, + default_symbols: Sequence[str], + default_calendar: str = "XNYS", +) -> tuple[list[str], str, str, bool] | None: + """Let a lab pick real data via the same data sources backtest/walk-forward use. + + Unlike the main dashboard's own per-instrument ``InstrumentConfig`` + table, every symbol here shares ONE calendar, since a lab computes on + a single flat price matrix, not individually-configured instruments. + + Returns ``(symbols, source, calendar, use_bundled_demo_data)``, or + ``None`` when nothing is selected yet (the caller should show a message + rather than proceed) OR when the selected symbols would need more than + one calendar (a lab cannot represent that -- the caller shows an error + instead of silently picking one calendar for all of them). Every widget + key is prefixed by ``key_prefix`` so two labs -- or a lab and the main + sidebar -- never collide over the same ``session_state`` entry, notably + the Binance "Load Binance symbols" gate, whose flag would otherwise be a + single dashboard-wide switch shared by everything that uses it. + """ + from quantlab.config import DataSourceName + from quantlab.dashboard.components import ( + binance_universe_labels, + parse_symbols, + symbols_picker, + yahoo_universe_labels, + ) + from quantlab.data.resolution import detect_calendar + + source = st.radio( + "Data source", + ["csv", "yahoo", "binance"], + key=f"{key_prefix}_source", + horizontal=True, + help=( + "csv: local files under data/raw (bundled synthetic demo " + "data available as an offline fallback below). yahoo/" + "binance: the same data sources backtest/walk-forward use." + ), + ) + if source == "yahoo": + symbols = symbols_picker( + st, + yahoo_universe_labels(), + f"{key_prefix}_yahoo_symbols", + tuple(default_symbols), + accept_new_options=True, + ) + if not symbols: + return None + # Best-effort per-symbol guess (e.g. "1211.HK" -> XHKG) -- a bare + # US ticker with no recognized suffix falls back to XNYS, exactly + # like the main dashboard's own per-instrument table default. + detected = { + detect_calendar(symbol, DataSourceName.YAHOO) or default_calendar + for symbol in symbols + } + if len(detected) > 1: + st.error( + "Selected symbols need different calendars " + f"({', '.join(sorted(detected))}) -- this lab computes on a " + "single shared price matrix and cannot represent more than " + "one calendar at once. Pick symbols on the same market, or " + "use the main dashboard's Backtest mode (each instrument " + "gets its own calendar there)." + ) + return None + # A keyed st.text_input only honours its `value=` argument the + # FIRST time that key is created -- once session_state holds a + # value for it, passing a freshly-detected default on a later + # rerun is silently ignored by Streamlit itself, leaving the field + # stuck on a stale guess after the symbol selection changes (e.g. + # AAPL -> XNYS auto-filled, then swapped for 1211.HK, still + # showing XNYS). Detected here explicitly: only when the symbol + # SET actually changed since the guess was last made is the + # session_state value overwritten -- an unrelated rerun (a + # different widget elsewhere) never clobbers the user's own + # manual edit. + calendar_key = f"{key_prefix}_yahoo_calendar" + symbols_for_key = f"{calendar_key}_for_symbols" + symbols_tuple = tuple(symbols) + if st.session_state.get(symbols_for_key) != symbols_tuple: + st.session_state[calendar_key] = next(iter(detected)) + st.session_state[symbols_for_key] = symbols_tuple + calendar = st.text_input( + "Calendar", + key=calendar_key, + help="Auto-detected from the symbol suffix where possible " + "(e.g. '.HK' -> XHKG) -- edit if the guess is wrong. '24/7' " + "for a continuous market, or a pandas_market_calendars name " + "such as XNYS, XHKG, XLON.", + ).strip() + if not calendar: + return None + return symbols, source, calendar, False + if source == "binance": + load_flag_key = f"{key_prefix}_binance_universe_load_requested" + if not st.session_state.get(load_flag_key, False): + st.caption( + "Loading the tradable symbol list calls Binance's public " + "API. Click below to fetch it (cached for an hour after " + "that)." + ) + if st.button( + "Load Binance symbols", + key=f"{key_prefix}_binance_universe_load_button", + ): + # See app.py's `_binance_symbols_picker` for why this + # deliberately does NOT call st.rerun(): that would abort + # this run right here, before finishing, dropping this + # widget's own keyed session-state value back to its + # default on the very next rerun. + st.session_state[load_flag_key] = True + else: + return None + symbols = symbols_picker( + st, binance_universe_labels(st), f"{key_prefix}_binance_symbols", () + ) + if not symbols: + return None + return symbols, source, "24/7", False + # source == "csv" + raw = st.text_input( + "CSV symbols (comma-separated)", + ", ".join(default_symbols), + key=f"{key_prefix}_csv_symbols_input", + help=( + "Local files under data/raw, one CSV per symbol. Falls back " + "to QuantLab's bundled synthetic demo data below when every " + "requested local file is absent." + ), + ) + symbols = parse_symbols(raw) + use_bundled_demo_data = st.toggle( + "Allow bundled synthetic demo data", + value=True, + key=f"{key_prefix}_use_bundled_demo_data", + help=( + "On by default here (unlike the main dashboard) so this lab " + "keeps working offline with no setup. Turn off once you have " + "real local files under data/raw for these symbols." + ), + ) + # A bare local filename carries no calendar information at all -- + # defaults to XNYS (matching the main dashboard's own per-instrument + # table default) but is always editable, since these could just as + # well be futures, a non-XNYS index, or anything else. + calendar = st.text_input( + "Calendar", + default_calendar, + key=f"{key_prefix}_csv_calendar", + help="'24/7' for a continuous market, or a pandas_market_calendars " + "name such as XNYS, XHKG, XLON -- CSV data carries no calendar " + "information, so this cannot be auto-detected.", + ).strip() + if not symbols or not calendar: + return None + return symbols, source, calendar, use_bundled_demo_data + + +def render_price_chart( + st: Any, + series: Mapping[str, pd.Series], + *, + title: str, + height: int = 360, + yaxis_title: str = "Price", + markers: Mapping[str, pd.Series] | None = None, + marker_size: int = 9, + colors: Mapping[str, str] | None = None, +) -> None: + """Plot one or more named series on a shared time axis. + + The generic building block every lab uses for "show me this price + series plus whatever indicator/threshold/overlay is currently + selected" -- when a widget changes a parameter, the lab recomputes + the relevant series and calls this again with the new values, which + is how Streamlit's own rerun-on-interaction model makes a parameter's + effect immediately visible without a bespoke "impact" widget. + + ``markers``, if given, are drawn as discrete point markers (not + connected lines) layered on top of ``series`` -- for a sparse, + date-indexed callout (e.g. "entries actually viable under a filter") + that would be unreadable as its own connected line. Each series may + contain ``NaN``/be missing dates freely; Plotly simply skips them. + + ``colors``, if given, maps a series/marker name to an explicit CSS + color, overriding Plotly's default per-trace color cycling -- lets a + caller give two differently-named series (e.g. a positive and a + negative threshold line) the SAME color deliberately, which the + default cycling (assigns by trace order, not by intent) cannot express. + """ + import plotly.graph_objects as go + + color_map = colors or {} + fig = go.Figure() + for name, values in series.items(): + line = {"color": color_map[name]} if name in color_map else None + fig.add_trace( + go.Scatter(x=values.index, y=values, mode="lines", name=name, line=line) + ) + for name, values in (markers or {}).items(): + marker = {"size": marker_size, "symbol": "circle"} + if name in color_map: + marker["color"] = color_map[name] + fig.add_trace( + go.Scatter( + x=values.index, y=values, mode="markers", name=name, marker=marker + ) + ) + fig.update_layout( + title=title, height=height, xaxis_title="Date", yaxis_title=yaxis_title + ) + st.plotly_chart(fig, width="stretch") + + +def render_price_explorer(st: Any, prices: pd.Series, symbol: str) -> None: + """Plot one symbol's own price series.""" + render_price_chart(st, {symbol: prices}, title=f"{symbol} price") + + +def render_correlation_matrix(st: Any, matrix: pd.DataFrame) -> None: + """Render a symbol x symbol correlation matrix as a heatmap + table. + + Shared by the Pairs Trading lab and the Results/report pair-diagnostics + section -- never reimplemented separately in either place. + """ + import plotly.graph_objects as go + + values = matrix.to_numpy() + fig = go.Figure( + go.Heatmap( + z=values, + x=[str(c) for c in matrix.columns], + y=[str(r) for r in matrix.index], + colorscale="RdBu", + zmid=0.0, + zmin=-1.0, + zmax=1.0, + colorbar={"title": "corr"}, + text=[[f"{v:.2f}" for v in row] for row in values], + texttemplate="%{text}", + ) + ) + fig.update_layout(title="Correlation matrix (of returns)", height=380) + st.plotly_chart(fig, width="stretch") + st.dataframe(matrix.round(3), width="stretch") + + +def render_stationarity_card( + st: Any, + result: ADFResult | CointegrationResult | None, + *, + label: str, +) -> None: + """Display one stationarity/cointegration test result in plain language. + + ``None`` (an inconclusive test -- too little data, or a numerical + failure) is rendered as an explicit message, never silently skipped, + so a missing result always reads as "inconclusive", not as "the + section vanished". + """ + if result is None: + st.info(f"{label}: inconclusive (too little data, or a numerical failure).") + return + verdict = "Reject H0" if result.reject_null else "Cannot reject H0" + columns = st.columns(3) + columns[0].metric("Statistic", f"{result.statistic:.4f}") + columns[1].metric("p-value", f"{result.pvalue:.4f}") + columns[2].metric("Verdict", verdict) + critical = ", ".join(f"{k}={v:.3f}" for k, v in result.critical_values.items()) + st.caption(f"**{label}** -- critical values: {critical}. {result.interpretation}") + + +def render_stop_loss_take_profit_illustration( + st: Any, + positions: pd.Series | Mapping[str, pd.Series], + prices: pd.Series | Mapping[str, pd.Series], + *, + key_prefix: str, + position_groups: Sequence[tuple[str, ...]] | None = None, +) -> None: + """Illustrate stop-loss/take-profit on the position(s) ALREADY shown above. + + A simplified illustration only, clearly labeled as such: QuantLab's + real mechanism (``quantlab.backtesting.accounting. + _detect_stop_loss_take_profit``) operates on the REAL executed + position(s) after the allocator/constraints/rebalancing/execution -- + this lab has no access to that pipeline. It instead applies the EXACT + SAME function directly to the position(s)/price(s) already displayed + above, which is mathematically identical to the real formula's own + reduction for that case -- not a second, approximate implementation. + + A single ``pd.Series`` pair (the common case: one symbol) is treated + as one independent group. A ``Mapping[str, pd.Series]`` (e.g. + pairs_trading's two legs) is treated as one COMBINED group unless + ``position_groups`` says otherwise -- mirroring + ``BaseStrategy.position_groups()``'s own default/override convention. + """ + from quantlab.backtesting.accounting import _detect_stop_loss_take_profit + + if isinstance(positions, pd.Series): + assert isinstance(prices, pd.Series) + position_map = {"asset": positions} + price_map: Mapping[str, pd.Series] = {"asset": prices} + default_groups = None + else: + assert not isinstance(prices, pd.Series) + position_map = dict(positions) + price_map = prices + default_groups = (tuple(position_map),) if len(position_map) > 1 else None + + st.markdown("#### Stop-loss / take-profit illustration") + st.caption( + "Simplified: applies QuantLab's exact stop-loss/take-profit " + "formula directly to the position(s)/price(s) shown above. " + "QuantLab's real backtest instead operates on the actual " + "EXECUTED position after the allocator/constraints/rebalancing/" + "execution, which this lab does not model -- treat this as " + "illustrative, not a preview of real backtest numbers." + ) + col_sl, col_tp = st.columns(2) + stop_loss_pct = col_sl.slider( + "stop_loss_pct (0 = disabled)", + 0.0, + 0.5, + 0.0, + 0.01, + key=f"{key_prefix}_illustration_stop_loss", + ) + take_profit_pct = col_tp.slider( + "take_profit_pct (0 = disabled)", + 0.0, + 0.5, + 0.0, + 0.01, + key=f"{key_prefix}_illustration_take_profit", + ) + if stop_loss_pct <= 0.0 and take_profit_pct <= 0.0: + return + + aligned_positions = { + symbol: series.reindex(price_map[symbol].index).fillna(0.0) + for symbol, series in position_map.items() + } + # `fill_method=None`: never let pandas' own version-dependent default + # forward-fill a gap before computing the return -- a genuine internal + # missing price must surface as NaN here, not silently vanish before + # this function even sees it. Only the very FIRST observation (no + # prior price to compare against at all -- not a "missing" price, a + # structurally absent one) is explicitly zeroed; every OTHER NaN is a + # real gap, reported below when it coincides with a held position + # rather than silently treated as a flat 0% return. + returns: dict[str, pd.Series] = {} + missing_while_held: list[str] = [] + for symbol, series in price_map.items(): + pct = series.pct_change(fill_method=None) + if len(pct): + pct.iloc[0] = 0.0 + held = aligned_positions[symbol].abs() > EPSILON + gap_dates = pct.index[pct.isna() & held] + if len(gap_dates): + shown = ", ".join(str(d.date()) for d in gap_dates[:5]) + if len(gap_dates) > 5: + shown += f", +{len(gap_dates) - 5} more" + missing_while_held.append(f"{symbol}: {shown}") + returns[symbol] = pct.fillna(0.0) + if missing_while_held: + st.warning( + "Missing price return(s) while a position was held -- treated " + "as 0% for this illustration only, which can hide a real " + "stop-loss/take-profit trigger on that date: " + + "; ".join(missing_while_held) + ) + stop_loss_result = _detect_stop_loss_take_profit( + pd.DataFrame(aligned_positions), + pd.DataFrame(returns), + position_groups if position_groups is not None else default_groups, + stop_loss_pct if stop_loss_pct > 0.0 else None, + take_profit_pct if take_profit_pct > 0.0 else None, + ) + gated, stop_loss_triggered, take_profit_triggered = stop_loss_result[:3] + chart_series = {} + for symbol in position_map: + chart_series[f"{symbol}: position (as displayed above)"] = aligned_positions[ + symbol + ] + chart_series[f"{symbol}: position after stop-loss/take-profit"] = gated[symbol] + render_price_chart( + st, + chart_series, + title="Illustrative effect of stop-loss/take-profit on this position", + yaxis_title="Position", + ) + n_stop = int(stop_loss_triggered.any(axis=1).sum()) + n_take = int(take_profit_triggered.any(axis=1).sum()) + st.caption(f"Stop-loss fired on {n_stop} date(s); take-profit on {n_take} date(s).") diff --git a/src/quantlab/dashboard/state.py b/src/quantlab/dashboard/state.py index dade34a..2a1d7e5 100644 --- a/src/quantlab/dashboard/state.py +++ b/src/quantlab/dashboard/state.py @@ -14,7 +14,6 @@ from quantlab.backtesting.result import BacktestResult from quantlab.backtesting.runner import run_backtest_from_config from quantlab.config import ExperimentConfig -from quantlab.constants import GENERATED_REPORTS_DIR from quantlab.data.base import SymbolSuggestion from quantlab.data.binance import BinanceDataSource from quantlab.data.loader import DataLoader @@ -29,7 +28,9 @@ "detect_calendar", "detect_source", "estimate_walk_forward_backtest_count", + "load_explorer_prices", "run_dashboard_backtest", + "run_dashboard_backtest_with_data", "run_dashboard_bootstrap", "run_dashboard_permutation_test", "run_dashboard_sensitivity", @@ -138,19 +139,37 @@ def run_dashboard_backtest( return result, report.warnings +def run_dashboard_backtest_with_data( + config: ExperimentConfig, +) -> tuple[BacktestResult, list[str], pd.DataFrame]: + """Like :func:`run_dashboard_backtest`, but also returns the loaded frame. + + A Strategy Explorer results diagnostic needing raw prices (e.g. pairs + trading) reuses this EXACT frame rather than reloading separately -- a + second, independent load could observe different data for a remote + source (Yahoo/Binance) if it changed, or a cache refreshed, between the + two calls, making the diagnostics silently describe different data than + the displayed result. + """ + data, report = DataLoader().load(config) + result = run_backtest_from_config(data, config, data_quality_report=report) + return result, report.warnings, data + + def _checkpoint_path(config: ExperimentConfig, technique: str) -> Path: """Return the on-disk checkpoint path for one technique of ``config``. - Same convention the CLI uses (``GENERATED_REPORTS_DIR / - experiment_name / ".checkpoint_.pkl"``), so an interrupted - dashboard run (e.g. the Streamlit server process itself restarting) and - a same-named CLI run can resume each other's progress. Two different - dashboard configs that happen to share a default ``experiment_name`` - don't collide unsafely: ``compute_provenance``'s config/data/code match - still gates whether a checkpoint is actually reused. + Same convention the CLI uses (``resolve_experiment_directory(config) / + ".checkpoint_.pkl"``), so an interrupted dashboard run (e.g. + the Streamlit server process itself restarting) and a same-named CLI + run can resume each other's progress. Two different dashboard configs + that happen to share a default ``experiment_name`` don't collide + unsafely: ``compute_provenance``'s config/data/code match still gates + whether a checkpoint is actually reused. """ - experiment_dir = GENERATED_REPORTS_DIR / config.experiment_name - return experiment_dir / f".checkpoint_{technique}.pkl" + from quantlab.backtesting.result import resolve_experiment_directory + + return resolve_experiment_directory(config) / f".checkpoint_{technique}.pkl" def run_dashboard_walk_forward( @@ -180,7 +199,9 @@ def run_dashboard_walk_forward( data, report = DataLoader().load(config) validator = WalkForwardValidator(config) - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, step = resolve_walk_forward_windows( + config + ) result = validator.run( data, parameter_grid=parameter_grid_for_config(config), @@ -188,6 +209,7 @@ def run_dashboard_walk_forward( validation_window=validation_window, test_window=test_window, expanding=config.validation.expanding, + step=step, on_progress=on_progress, checkpoint_path=_checkpoint_path(config, "walk_forward"), ) @@ -316,7 +338,7 @@ def run_dashboard_bootstrap( periods_per_year=config.periods_per_year, initial_capital=config.initial_capital, risk_free_rate=config.risk_free_rate, - ).summary() + ).summary(confidence_level=config.robustness.bootstrap.confidence_level) def run_dashboard_permutation_test( @@ -404,8 +426,61 @@ def run_dashboard_walk_forward_sensitivity( def default_end_date() -> date: - """A safe default end date that does not depend on wall-clock time.""" - return date(2024, 12, 31) + """A safe default end date that does not depend on wall-clock time. + + Matches the bundled synthetic demo CSVs' own last observation (also + ``demo_offline.yaml``'s ``end_date``) -- the sidebar's Start/End date + widgets default to this range regardless of which instrument source is + picked, and the CSV picker's own default symbols (SPY/QQQ/TLT/GLD) are + exactly the bundled demo data. A later date here would make the very + first "just click Run" experience -- untouched defaults, no instrument + source changed -- immediately warn that data ends hundreds of days + before the requested end, for every one of those four symbols. + """ + return date(2023, 10, 30) + + +def load_explorer_prices( + symbols: list[str], + *, + source: str, + calendar: str, + start_date: date, + end_date: date, + use_bundled_demo_data: bool = False, +) -> pd.DataFrame: + """Load canonical OHLCV data for a Strategy Explorer interactive lab. + + A minimal, throwaway config -- ``data:`` is the only section that + matters for loading; ``strategy`` is a fixed placeholder ``DataLoader. + load()`` never inspects. Callers wrap this in ``st.cache_data`` (see + the labs) since every widget interaction reruns the whole script. + Exactly the same ``DataLoader``/``ExperimentConfig`` path backtest and + walk-forward use -- Yahoo/Binance/CSV all behave identically here. + ``use_bundled_demo_data`` mirrors the main dashboard's own toggle (see + ``app.py``'s "Allow bundled synthetic demo data"): only consulted for + ``source="csv"``, and only as a fallback when every requested local + file under ``data/raw`` is absent. + """ + data_config: dict[str, object] = { + "instruments": [ + {"symbol": symbol, "source": source, "calendar": calendar} + for symbol in symbols + ], + "start_date": start_date, + "end_date": end_date, + } + if use_bundled_demo_data: + data_config["use_bundled_demo_data"] = True + config = ExperimentConfig.from_dict( + { + "experiment_name": "strategy_explorer_lab", + "data": data_config, + "strategy": {"name": "buy_and_hold"}, + } + ) + data, _ = DataLoader().load(config) + return data @lru_cache(maxsize=1) diff --git a/src/quantlab/features/__init__.py b/src/quantlab/features/__init__.py index 08ed47a..e3e71a0 100644 --- a/src/quantlab/features/__init__.py +++ b/src/quantlab/features/__init__.py @@ -6,6 +6,7 @@ from __future__ import annotations +from quantlab.features.correlation import correlation_matrix from quantlab.features.cross_sectional import ( cross_sectional_demean, cross_sectional_percentile, @@ -18,17 +19,24 @@ distance_to_moving_average, half_life, normalized_distance_to_mean, + rolling_percentile_rank, rolling_zscore, rsi, ) from quantlab.features.momentum import ( + cross_sectional_momentum_persistence, ma_crossover_signal, momentum, + momentum_persistence, moving_average, price_above_ma, rate_of_change, volatility_adjusted_momentum, ) +from quantlab.features.pairs_diagnostics import ( + PairDiagnostics, + compute_pair_diagnostics, +) from quantlab.features.pipeline import FeaturePipeline, FeatureSpec from quantlab.features.returns import ( cumulative_returns, @@ -37,8 +45,16 @@ log_returns, simple_returns, ) +from quantlab.features.stationarity import ( + ADFResult, + CointegrationResult, + adf_test, + cointegration_test, + hurst_exponent, +) from quantlab.features.technical import ( donchian_position, + efficiency_ratio, exponential_moving_average, macd, rolling_max, @@ -69,6 +85,8 @@ "ma_crossover_signal", "price_above_ma", "volatility_adjusted_momentum", + "momentum_persistence", + "cross_sectional_momentum_persistence", # volatility "historical_volatility", "realized_volatility", @@ -82,6 +100,7 @@ "rolling_zscore", "distance_to_moving_average", "normalized_distance_to_mean", + "rolling_percentile_rank", "rsi", "bollinger_bands", "half_life", @@ -97,6 +116,18 @@ "rolling_max", "rolling_min", "donchian_position", + "efficiency_ratio", + # correlation + "correlation_matrix", + # stationarity / cointegration + "ADFResult", + "CointegrationResult", + "adf_test", + "cointegration_test", + "hurst_exponent", + # pairs diagnostics + "PairDiagnostics", + "compute_pair_diagnostics", # pipeline "FeaturePipeline", "FeatureSpec", diff --git a/src/quantlab/features/_validation.py b/src/quantlab/features/_validation.py index bb5090d..cce77d0 100644 --- a/src/quantlab/features/_validation.py +++ b/src/quantlab/features/_validation.py @@ -61,6 +61,13 @@ def boolean(value: object, *, name: str) -> bool: return bool(value) +def choice(value: object, *, name: str, options: frozenset[str]) -> str: + """Return a string restricted to a fixed set of accepted values.""" + if not isinstance(value, str) or value not in options: + raise ValueError(f"{name} must be one of {sorted(options)}, got {value!r}.") + return value + + def numeric_pandas( data: PandasT, *, diff --git a/src/quantlab/features/correlation.py b/src/quantlab/features/correlation.py new file mode 100644 index 0000000..18adb07 --- /dev/null +++ b/src/quantlab/features/correlation.py @@ -0,0 +1,32 @@ +"""Multi-asset correlation diagnostics.""" + +from __future__ import annotations + +from typing import Literal + +import pandas as pd + +from quantlab.features._validation import numeric_pandas + +_CorrelationMethod = Literal["pearson", "kendall", "spearman"] + + +def correlation_matrix( + prices: pd.DataFrame, *, method: _CorrelationMethod = "pearson" +) -> pd.DataFrame: + """Return the symbol x symbol correlation matrix of simple returns. + + Computed on returns, not raw price levels -- price-level correlation is + routinely inflated by a shared trend even between economically + unrelated assets, while return correlation reflects actual co-movement. + ``method`` is passed straight through to ``DataFrame.corr``. + """ + if not isinstance(prices, pd.DataFrame): + raise TypeError("prices must be a pandas DataFrame.") + validated = numeric_pandas(prices, name="prices", strictly_positive=True) + if method not in {"pearson", "kendall", "spearman"}: + raise ValueError( + f"method must be one of 'pearson'/'kendall'/'spearman', got {method!r}." + ) + returns = validated.pct_change(fill_method=None) + return returns.corr(method=method) diff --git a/src/quantlab/features/mean_reversion.py b/src/quantlab/features/mean_reversion.py index 6b97e9e..773fe67 100644 --- a/src/quantlab/features/mean_reversion.py +++ b/src/quantlab/features/mean_reversion.py @@ -38,12 +38,47 @@ def normalized_distance_to_mean(prices: PandasT, window: int) -> PandasT: return (validated - ma) / ma -def rsi(prices: PandasT, window: int = 14) -> PandasT: +def rolling_percentile_rank( + prices: PandasT, window: int, *, strictly_positive: bool = True +) -> PandasT: + """Trailing percentile rank of the current price within its own window. + + ``[0, 1]``: ``0`` when the current price is the lowest in the trailing + window, ``1`` when it is the highest, ``0.5`` in the middle. Ties are + averaged (pandas' default rank behavior). ``strictly_positive`` defaults + to ``True`` (the usual price-series case); pass ``False`` for a series + that can legitimately be zero or negative (e.g. a pairs-trading spread + residual) -- the rank computation itself is sign-agnostic, only the + input validation differs. + """ + validated = numeric_pandas( + prices, name="prices", strictly_positive=strictly_positive + ) + length = positive_int(window, name="window", minimum=2) + + def _percentile_of_last(window_values: np.ndarray) -> float: + return float(pd.Series(window_values).rank(pct=True).iloc[-1]) + + return validated.rolling(length, min_periods=length).apply( + _percentile_of_last, raw=True + ) + + +def rsi( + prices: PandasT, window: int = 14, *, strictly_positive: bool = True +) -> PandasT: """Relative Strength Index using Wilder-style exponential smoothing. Returns values in ``[0, 100]``; a flat window is neutral at ``50``. + ``strictly_positive`` defaults to ``True`` (the usual price-series + case); pass ``False`` for a series that can legitimately be zero or + negative (e.g. a pairs-trading spread residual) -- RSI is computed + from period-over-period changes, which are sign-agnostic; only the + input validation differs. """ - validated = numeric_pandas(prices, name="prices", strictly_positive=True) + validated = numeric_pandas( + prices, name="prices", strictly_positive=strictly_positive + ) length = positive_int(window, name="window", minimum=2) delta = validated.diff() gain = delta.clip(lower=0.0) diff --git a/src/quantlab/features/momentum.py b/src/quantlab/features/momentum.py index 5c13707..eeb6ed0 100644 --- a/src/quantlab/features/momentum.py +++ b/src/quantlab/features/momentum.py @@ -18,7 +18,8 @@ numeric_pandas, positive_int, ) -from quantlab.features.returns import simple_returns +from quantlab.features.cross_sectional import select_top_bottom +from quantlab.features.returns import forward_returns, simple_returns from quantlab.features.volatility import realized_volatility PandasT = TypeVar("PandasT", pd.Series, pd.DataFrame) @@ -114,3 +115,100 @@ def volatility_adjusted_momentum( raise TypeError("Momentum and volatility have incompatible pandas types.") result = raw / vol.where(vol > 0.0, np.nan) return cast(PandasT, result) # type: ignore[redundant-cast] + + +def momentum_persistence( + prices: pd.Series, + lookback_period: int, + skip_period: int, + holding_period: int, +) -> pd.DataFrame: + """Pair each date's trailing momentum score with its subsequent return. + + Returns a two-column ``(past_momentum, future_return)`` DataFrame, one + row per date where both are defined -- the basic building block for + checking whether momentum actually persists on a given series (does a + high past score tend to be followed by a high subsequent return, on + this data): a positive relationship is descriptive-sample evidence FOR + the strategy's premise, a flat or negative one against it -- not a + hypothesis test (rows from overlapping holding periods are not + independent observations, so this is not a significance claim). + ``future_return`` looks strictly ahead of each row's own date -- these + pairs describe the data, they are never a tradable signal themselves + (see ``forward_returns``). This asks the TIME-SERIES question (does + THIS asset's own past predict its own future); for the cross-sectional + question (do higher-RANKED assets outperform lower-ranked ones), see + :func:`cross_sectional_momentum_persistence`. + """ + if not isinstance(prices, pd.Series): + raise TypeError("prices must be a pandas Series.") + past = momentum(prices, lookback_period, skip_period) + horizon = positive_int(holding_period, name="holding_period") + future = forward_returns(prices, horizon) + return pd.concat({"past_momentum": past, "future_return": future}, axis=1).dropna() + + +def cross_sectional_momentum_persistence( + prices: pd.DataFrame, + lookback_period: int, + skip_period: int, + holding_period: int, + *, + top_fraction: float = 0.25, + bottom_fraction: float | None = None, +) -> pd.DataFrame: + """Date-by-date evidence for CROSS-SECTIONAL momentum persistence. + + Unlike :func:`momentum_persistence` (a single asset's own past-vs- + future relationship -- the TIME-SERIES momentum question), this asks + the question cross-sectional momentum actually trades: on each date, + do assets ranked higher on trailing momentum go on to earn higher + subsequent returns than assets ranked lower, RELATIVE TO EACH OTHER? + An asset's own serial autocorrelation is neither necessary nor + sufficient for that. + + Returns one row per date with at least 3 assets scored, containing the + Spearman rank correlation between that date's momentum scores and + subsequent ``holding_period``-period returns across the universe + (``rank_correlation``), plus the realized ``top_return``/ + ``bottom_return``/``top_minus_bottom`` spread for the + ``top_fraction``/``bottom_fraction`` selection (mirroring + ``CrossSectionalMomentumStrategy``'s own selection via + :func:`~quantlab.features.cross_sectional.select_top_bottom`) over the + same horizon. Descriptive sample evidence, not a hypothesis test -- + overlapping holding periods across consecutive dates are not + independent observations. + """ + if not isinstance(prices, pd.DataFrame): + raise TypeError("prices must be a pandas DataFrame.") + bottom = bottom_fraction if bottom_fraction is not None else top_fraction + scores = momentum(prices, lookback_period, skip_period) + horizon = positive_int(holding_period, name="holding_period") + future = forward_returns(prices, horizon) + selection = select_top_bottom(scores, top_fraction, bottom) + + rows: list[dict[str, object]] = [] + for date in scores.index: + score_row = scores.loc[date] + future_row = future.loc[date] + valid = score_row.notna() & future_row.notna() + if int(valid.sum()) < 3: + continue + rank_correlation = score_row[valid].corr(future_row[valid], method="spearman") + top_mask = valid & (selection.loc[date] == 1.0) + bottom_mask = valid & (selection.loc[date] == -1.0) + top_return = future_row[top_mask].mean() if top_mask.any() else np.nan + bottom_return = future_row[bottom_mask].mean() if bottom_mask.any() else np.nan + rows.append( + { + "date": date, + "rank_correlation": rank_correlation, + "top_return": top_return, + "bottom_return": bottom_return, + "top_minus_bottom": top_return - bottom_return, + } + ) + columns = ["rank_correlation", "top_return", "bottom_return", "top_minus_bottom"] + if not rows: + return pd.DataFrame(columns=columns).rename_axis("date") + return pd.DataFrame(rows).set_index("date")[columns] diff --git a/src/quantlab/features/native_calendar.py b/src/quantlab/features/native_calendar.py new file mode 100644 index 0000000..2fb2c10 --- /dev/null +++ b/src/quantlab/features/native_calendar.py @@ -0,0 +1,87 @@ +"""Compute a rolling-window feature on each symbol's own native calendar. + +A closure-bar-padded multi-calendar universe (see +``quantlab.data.closures``) shares one combined timeline across every +symbol, including days a given symbol's own calendar has no session for +(e.g. a 24/7 crypto instrument's weekend rows appearing alongside a +session-bound equity's own calendar). A rolling window computed directly +on that padded timeline therefore spans more real calendar days than +periods for any session-bound symbol sharing it with an always-open one, +diluting the estimate. :func:`compute_native_then_align` removes this: +each symbol is sliced to its own verified native session rows before the +feature is computed, and the result is reindexed/forward-filled back onto +the combined timeline afterward -- the same "nothing changed while closed" +convention the raw OHLCV closure padding itself already uses. +""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping + +import pandas as pd + +from quantlab.data.calendar import is_session_day, uniform_calendar +from quantlab.data.closures import verified_closure_mask + + +def compute_native_then_align( + compute_fn: Callable[[pd.DataFrame], pd.DataFrame], + prices: pd.DataFrame, + symbol_calendars: Mapping[str, str] | None, + combined_index: pd.DatetimeIndex, +) -> pd.DataFrame: + """Run ``compute_fn`` per symbol on its own native calendar, then align. + + ``compute_fn`` must be a plain, calendar-agnostic rolling-window + feature (e.g. ``momentum``, ``rolling_zscore``) that treats each of + ``prices``'s columns independently -- it is called once per symbol on + a single-column frame, never on the full multi-symbol matrix, so any + function with genuine cross-column interaction is out of scope here. + + Dilution only exists when the universe genuinely spans more than one + calendar (a symbol's own window is only stretched by a closure that + exists BECAUSE a differently-scheduled symbol shares its timeline) -- + so ``symbol_calendars=None``, or every ``prices`` column sharing the + exact same one calendar (even one with its own ordinary closures, + e.g. a single-calendar equity universe's weekends/holidays -- nothing + dilutes there, since every column is equally subject to the same + closures and `prices.index` already reflects exactly that calendar's + own sessions), short-circuits straight to a single vectorized + ``compute_fn(prices)`` call. A column with no + verified closure at all in ``prices.index`` for its own calendar + short-circuits individually to ``compute_fn`` on its own untouched + column. + """ + if not symbol_calendars: + return compute_fn(prices) + calendars = { + symbol: symbol_calendars[symbol] + for symbol in prices.columns + if symbol in symbol_calendars + } + if not calendars: + return compute_fn(prices) + if len(calendars) == len(prices.columns) and ( + uniform_calendar(calendars.values()) is not None + ): + return compute_fn(prices) + closure = verified_closure_mask( + pd.DatetimeIndex(prices.index), list(calendars), calendars + ) + if not bool(closure.to_numpy().any()): + return compute_fn(prices) + + columns: dict[str, pd.Series] = {} + for symbol in prices.columns: + calendar = calendars.get(symbol) + if calendar is None or not bool(closure[symbol].any()): + columns[symbol] = compute_fn(prices[[symbol]]).iloc[:, 0] + continue + native_index = prices.index[~closure[symbol].to_numpy()] + native_result = compute_fn(prices.loc[native_index, [symbol]]).iloc[:, 0] + aligned = native_result.reindex(combined_index) + combined_closure = ~is_session_day(calendar, pd.DatetimeIndex(combined_index)) + fillable = aligned.isna() & combined_closure + aligned = aligned.mask(fillable, aligned.ffill()) + columns[symbol] = aligned + return pd.DataFrame(columns, index=combined_index)[list(prices.columns)] diff --git a/src/quantlab/features/pairs_diagnostics.py b/src/quantlab/features/pairs_diagnostics.py new file mode 100644 index 0000000..892eb7f --- /dev/null +++ b/src/quantlab/features/pairs_diagnostics.py @@ -0,0 +1,231 @@ +"""Shared diagnostics for a two-asset relationship (pairs trading). + +The single source of truth for "is this pair a good candidate, and does its +relationship still hold" -- reused identically by the Strategy Explorer's +Pairs Trading lab, the dashboard Results tab, and the generated HTML report, +so all three agree on the same pair's hedge ratio, spread and stationarity +whenever the data range, symbols, price type and parameters they're each +given are identical (the lab lets a user explore different ones by design, +so its numbers can legitimately differ from a specific backtest's). +This extends to the PERIODIC stationarity check +(``PairDiagnostics.rolling_adf_pvalue``): it calls +``quantlab.strategies.pairs_trading.periodic_stationarity_pvalues``, the +exact same FUNCTION the live strategy's own entry gate uses, rather than a +separately-computed approximation of it -- but calling the same function is +not the same as reproducing the same result: under a mixed-calendar +universe the live gate feeds it each leg sliced to the INTERSECTION of both +legs' own native session dates, while this module (see +:func:`compute_pair_diagnostics`'s own docstring) feeds it the full +combined, closure-padded timeline. The two match exactly for a +single-calendar pair (the intersection IS the combined timeline there), and +can genuinely diverge for a mixed-calendar one -- disclosed in +docs/limitations.md, never silently assumed equivalent. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + +from quantlab.features._validation import ( + choice, + numeric_pandas, + positive_int, + same_axes, +) +from quantlab.features.mean_reversion import half_life +from quantlab.features.returns import simple_returns +from quantlab.features.stationarity import ( + ADFResult, + CointegrationResult, + adf_test, + cointegration_test, +) +from quantlab.features.volatility import rolling_correlation + + +def spread( + a: pd.Series, b: pd.Series, intercept: pd.Series, beta: pd.Series +) -> pd.Series: + """Residual of the trailing-OLS relationship ``a = intercept + beta * b``. + + The strategy and every diagnostic consumer share this one formula. + """ + validated_a = numeric_pandas(a, name="a") + validated_b = numeric_pandas(b, name="b") + validated_intercept = numeric_pandas(intercept, name="intercept") + validated_beta = numeric_pandas(beta, name="beta") + same_axes( + validated_a, + validated_b, + validated_intercept, + validated_beta, + names=("b", "intercept", "beta"), + ) + return validated_a - validated_intercept - validated_beta * validated_b + + +@dataclass(frozen=True) +class PairDiagnostics: + """A snapshot AND a time-history of a two-asset relationship's quality. + + ``adf_result``/``cointegration_result``/``half_life`` are EXPLORATORY: + a single ADF/Engle-Granger test over the whole sample's spread/series, + useful for an initial read on the relationship but not what the live + strategy actually gates trading decisions on. ``rolling_adf_pvalue`` is + the CAUSAL one: it reproduces, bar for bar, + :meth:`~quantlab.strategies.pairs_trading.PairsTradingStrategy. + _stationarity_gate`'s own periodic recheck (a fresh single-window + regression fit every ``rolling_adf_stride`` periods, exactly like the + strategy) via the shared + :func:`~quantlab.strategies.pairs_trading.periodic_stationarity_pvalues` + -- so this field can never show a different picture than what actually + gated (or would have gated) new entries in a real backtest of this + pair. ``hedge_ratio_stability`` likewise describes stability over time + rather than a one-off snapshot. + """ + + symbol_a: str + symbol_b: str + correlation: float + rolling_correlation: pd.Series + hedge_ratio: pd.Series + intercept: pd.Series + spread: pd.Series + # Which indicator `spread_indicator` was actually computed with -- + # zscore, rsi or percentile (see + # `quantlab.strategies.pairs_trading.INDICATORS`), NOT always zscore. + # A consumer must label charts/tables from this field rather than + # hardcoding "Z-score", or the display would misrepresent an + # rsi/percentile-configured pair as a zscore-driven one. + indicator: str + spread_indicator: pd.Series + adf_result: ADFResult | None + cointegration_result: CointegrationResult | None + half_life: float + hedge_ratio_stability: float + rolling_adf_pvalue: pd.Series + + +def compute_pair_diagnostics( + prices: pd.DataFrame, + symbol_a: str, + symbol_b: str, + *, + formation_window: int, + indicator_window: int, + dynamic_hedge_ratio: bool, + indicator: str = "zscore", + correlation_window: int | None = None, + rolling_adf_stride: int | None = None, +) -> PairDiagnostics: + """Compute the full diagnostic picture for one candidate pair. + + ``indicator`` selects the SAME zscore/rsi/percentile series + :class:`~quantlab.strategies.pairs_trading.PairsTradingStrategy` itself + can be configured with (via + :func:`~quantlab.strategies.pairs_trading._centered_spread_indicator`, + the exact function the live strategy uses) -- defaults to ``"zscore"`` + only for a caller that has no strategy instance to read a configured + indicator from (e.g. exploring a candidate pair before choosing one). + ``correlation_window``/``rolling_adf_stride`` default to + ``indicator_window`` -- the same cadence the strategy itself already + uses for its own periodic stationarity gate. + + Under a mixed-calendar universe, ``hedge_ratio``/``spread``/ + ``spread_indicator``/``rolling_adf_pvalue`` here are ALL computed on + the FULL combined, closure-padded timeline, NOT the intersection of + both legs' own native session dates + :meth:`~quantlab.strategies.pairs_trading.PairsTradingStrategy. + _native_pair_context` uses for the live strategy -- disclosed in + docs/limitations.md rather than silently assumed equivalent. + ``rolling_adf_pvalue`` calls the exact same gated + ``periodic_stationarity_pvalues`` FUNCTION the live entry gate uses + (see :class:`PairDiagnostics`'s own docstring), but that alone does + not make its RESULT match: the live gate feeds that function the + native-intersection-sliced series, this function feeds it the + combined-timeline ``a``/``b`` above -- the two match exactly for a + single-calendar pair and can genuinely diverge for a mixed-calendar + one, same as every other diagnostic here. + """ + from quantlab.strategies.pairs_trading import ( + INDICATORS, + _centered_spread_indicator, + periodic_stationarity_pvalues, + rolling_hedge_parameters, + ) + + if not isinstance(prices, pd.DataFrame): + raise TypeError("prices must be a pandas DataFrame.") + for symbol in (symbol_a, symbol_b): + if symbol not in prices.columns: + raise ValueError(f"prices is missing symbol {symbol!r}.") + validated_indicator = choice( + indicator, name="indicator", options=frozenset(INDICATORS) + ) + a = numeric_pandas( + prices[symbol_a], name="prices[symbol_a]", strictly_positive=True + ) + b = numeric_pandas( + prices[symbol_b], name="prices[symbol_b]", strictly_positive=True + ) + same_axes(a, b, names=("prices[symbol_b]",)) + corr_window = positive_int( + correlation_window if correlation_window is not None else indicator_window, + name="correlation_window", + minimum=2, + ) + stride = positive_int( + rolling_adf_stride if rolling_adf_stride is not None else indicator_window, + name="rolling_adf_stride", + minimum=1, + ) + + returns_a = simple_returns(a) + returns_b = simple_returns(b) + correlation = float(returns_a.corr(returns_b)) + rolling_corr = rolling_correlation(returns_a, returns_b, corr_window) + + intercept, beta = rolling_hedge_parameters( + a, b, formation_window, dynamic_hedge_ratio + ) + spread_series = spread(a, b, intercept, beta) + indicator_series = _centered_spread_indicator( + spread_series, validated_indicator, indicator_window + ) + + clean_spread = spread_series.dropna() + adf_result = adf_test(clean_spread) + cointegration_result = cointegration_test(a, b) + half_life_estimate = half_life(clean_spread) + + clean_beta = beta.dropna() + hedge_ratio_stability = ( + float(clean_beta.std(ddof=1)) if len(clean_beta) > 1 else float("nan") + ) + + rolling_pvalue = periodic_stationarity_pvalues( + a, + b, + formation_window=formation_window, + stride=stride, + dynamic_hedge_ratio=dynamic_hedge_ratio, + ) + + return PairDiagnostics( + symbol_a=symbol_a, + symbol_b=symbol_b, + correlation=correlation, + rolling_correlation=rolling_corr, + hedge_ratio=beta, + intercept=intercept, + spread=spread_series, + indicator=validated_indicator, + spread_indicator=indicator_series, + adf_result=adf_result, + cointegration_result=cointegration_result, + half_life=half_life_estimate, + hedge_ratio_stability=hedge_ratio_stability, + rolling_adf_pvalue=rolling_pvalue, + ) diff --git a/src/quantlab/features/stationarity.py b/src/quantlab/features/stationarity.py new file mode 100644 index 0000000..0134a6c --- /dev/null +++ b/src/quantlab/features/stationarity.py @@ -0,0 +1,229 @@ +"""Stationarity, long-run-relationship and persistence diagnostics. + +Structured results (never a bare float) so a caller always has the +statistic, the null/alternative hypotheses and a plain-language +interpretation available, not just a pass/fail number. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass + +import numpy as np +import pandas as pd + +from quantlab.features._validation import ( + finite_real, + numeric_pandas, + positive_int, + same_axes, +) +from quantlab.logging_config import get_logger + +logger = get_logger(__name__) + +#: Below this many non-missing observations, both ADF and Engle-Granger are +#: considered inconclusive rather than numerically unstable. +_MIN_TEST_OBSERVATIONS = 20 + + +@dataclass(frozen=True) +class ADFResult: + """Augmented Dickey-Fuller stationarity test outcome for one series. + + H0: the series has a unit root (is non-stationary). H1: the series is + stationary. A low ``pvalue`` is evidence against H0 for the sample + tested -- it does not prove stationarity, and says nothing about + whether that property will hold going forward. + """ + + statistic: float + pvalue: float + n_lags: int + n_obs: int + critical_values: dict[str, float] + significance: float + reject_null: bool + interpretation: str + + +def adf_test(series: pd.Series, *, significance: float = 0.05) -> ADFResult | None: + """Run an Augmented Dickey-Fuller test; ``None`` when inconclusive. + + Wraps ``statsmodels.tsa.stattools.adfuller`` with ``autolag="AIC"``. + Returns ``None`` (never a raised error) for fewer than 20 observations, + a constant series, or a numerical failure inside statsmodels -- these + are "cannot conclude anything" cases, not test failures to propagate. + """ + if not isinstance(series, pd.Series): + raise TypeError("series must be a pandas Series.") + validated = numeric_pandas(series, name="series") + level = _significance(significance) + values = validated.dropna().to_numpy(dtype=float) + if len(values) < _MIN_TEST_OBSERVATIONS or np.allclose(values, values[0]): + return None + try: + from statsmodels.tsa.stattools import adfuller + + statistic, pvalue, n_lags, n_obs, critical_values, _ = adfuller( + values, autolag="AIC" + ) + except Exception as exc: # pragma: no cover - third-party numerical failures + logger.warning("ADF test failed: %s", exc) + return None + if not np.isfinite(pvalue) or not np.isfinite(statistic): + return None + finite_critical_values = { + key: float(value) for key, value in critical_values.items() + } + if not all(np.isfinite(value) for value in finite_critical_values.values()): + return None + reject_null = bool(pvalue <= level) + return ADFResult( + statistic=float(statistic), + pvalue=float(pvalue), + n_lags=int(n_lags), + n_obs=int(n_obs), + critical_values=finite_critical_values, + significance=level, + reject_null=reject_null, + interpretation=_adf_interpretation(pvalue, level, reject_null), + ) + + +def _adf_interpretation(pvalue: float, level: float, reject_null: bool) -> str: + verdict = ( + "reject the unit-root null -- evidence of stationarity" + if reject_null + else "cannot reject the unit-root null -- no evidence of stationarity" + ) + return f"ADF p-value {pvalue:.4f} at the {level:g} level: {verdict}." + + +@dataclass(frozen=True) +class CointegrationResult: + """Engle-Granger cointegration test outcome for two price series. + + H0: the two series are not cointegrated (no stable long-run linear + relationship). H1: they are cointegrated. Distinct from correlation + (a short-run co-movement measure) and from running ADF on a spread + built from an already-fitted hedge ratio -- this test fits and checks + the relationship in one step. + """ + + statistic: float + pvalue: float + critical_values: dict[str, float] + significance: float + reject_null: bool + interpretation: str + + +def cointegration_test( + a: pd.Series, b: pd.Series, *, significance: float = 0.05 +) -> CointegrationResult | None: + """Engle-Granger cointegration test between two price series. + + Wraps ``statsmodels.tsa.stattools.coint`` (Engle & Granger 1987's + two-step method; regresses ``a`` on ``b`` and tests the residual for a + unit root -- asymmetric in principle, though the two directions rarely + disagree in practice). Assumes both series are individually I(1) + (integrated of order one); the test is not meaningful otherwise. + Returns ``None`` (never a raised error) for fewer than 20 paired + observations, a numerical failure inside statsmodels, a non-finite + statistic/p-value, or when the two series are (near-)perfectly + collinear -- statsmodels' own ``CollinearityWarning`` flags this last + case as numerically unreliable (e.g. a spurious ``statistic=-inf``, + ``pvalue=0.0`` "confident" result for ``b = 2 * a``), so it is treated + as inconclusive here rather than surfaced as a confident verdict. + """ + if not isinstance(a, pd.Series) or not isinstance(b, pd.Series): + raise TypeError("a and b must be pandas Series.") + validated_a = numeric_pandas(a, name="a") + validated_b = numeric_pandas(b, name="b") + same_axes(validated_a, validated_b, names=("b",)) + level = _significance(significance) + paired = pd.concat({"a": validated_a, "b": validated_b}, axis=1).dropna() + if len(paired) < _MIN_TEST_OBSERVATIONS: + return None + try: + from statsmodels.tools.sm_exceptions import CollinearityWarning + from statsmodels.tsa.stattools import coint + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always", CollinearityWarning) + statistic, pvalue, critical_values = coint( + paired["a"].to_numpy(dtype=float), paired["b"].to_numpy(dtype=float) + ) + if any(issubclass(w.category, CollinearityWarning) for w in caught): + logger.warning( + "Cointegration test: near-perfectly collinear series -- " + "result is not numerically reliable, treating as inconclusive." + ) + return None + except Exception as exc: # pragma: no cover - third-party numerical failures + logger.warning("Cointegration test failed: %s", exc) + return None + if not np.isfinite(pvalue) or not np.isfinite(statistic): + return None + finite_critical_values = dict( + zip( + ("1%", "5%", "10%"), + (float(value) for value in critical_values), + strict=True, + ) + ) + if not all(np.isfinite(value) for value in finite_critical_values.values()): + return None + reject_null = bool(pvalue <= level) + verdict = ( + "reject the no-cointegration null -- evidence of a stable long-run relationship" + if reject_null + else "cannot reject the no-cointegration null -- no evidence of a " + "stable long-run relationship" + ) + return CointegrationResult( + statistic=float(statistic), + pvalue=float(pvalue), + critical_values=finite_critical_values, + significance=level, + reject_null=reject_null, + interpretation=f"Engle-Granger p-value {pvalue:.4f} at the {level:g} level: " + f"{verdict}.", + ) + + +def hurst_exponent(series: pd.Series, *, max_lag: int = 20) -> float: + """Estimate the Hurst exponent via the variance-of-differences method. + + Regresses ``log(std(x[t+lag] - x[t]))`` on ``log(lag)`` for + ``lag in [2, max_lag]``; the slope is the estimate. ``H < 0.5`` + suggests mean reversion, ``H ~= 0.5`` a random walk, ``H > 0.5`` a + trending/persistent series -- a descriptive estimate on the sample + given, not a hypothesis test with a p-value. Returns ``nan`` when + there are too few observations (``< 2 * max_lag``) or the series is + degenerate (e.g. constant) after dropping missing values. + """ + if not isinstance(series, pd.Series): + raise TypeError("series must be a pandas Series.") + validated = numeric_pandas(series, name="series") + length = positive_int(max_lag, name="max_lag", minimum=2) + values = validated.dropna().to_numpy(dtype=float) + if len(values) < 2 * length: + return float("nan") + lags = np.arange(2, length + 1) + spreads = np.array( + [np.std(values[lag:] - values[:-lag]) for lag in lags], dtype=float + ) + if not np.all(spreads > 0): + return float("nan") + slope, _ = np.polyfit(np.log(lags.astype(float)), np.log(spreads), 1) + return float(slope) + + +def _significance(value: object) -> float: + level = finite_real(value, name="significance", minimum=0.0, strict=True) + if level >= 1.0: + raise ValueError("significance must be strictly between 0 and 1.") + return level diff --git a/src/quantlab/features/technical.py b/src/quantlab/features/technical.py index 2e31a3a..8d12d23 100644 --- a/src/quantlab/features/technical.py +++ b/src/quantlab/features/technical.py @@ -61,6 +61,42 @@ def rolling_min(prices: PandasT, window: int) -> PandasT: return validated.rolling(valid_window, min_periods=valid_window).min() +def efficiency_ratio(prices: PandasT, window: int) -> PandasT: + """Kaufman's Efficiency Ratio: net directional move over total path length. + + ``|P_t - P_{t-window}| / sum(|ΔP_i|)`` over the trailing window, in + ``[0, 1]``. Near 1 means price moved efficiently in one direction (a + clean trend); near 0 means the same net move took a much longer, + choppier path (noise dominating direction -- the classic failure mode + for a trend-following signal). A flat window (net move and path + length both zero) is neutral at ``0.5`` rather than an undefined + division by zero. + """ + validated = numeric_pandas(prices, name="prices", strictly_positive=True) + length = positive_int(window, name="window") + net_move = (validated - validated.shift(length)).abs() + path_length = validated.diff().abs().rolling(length, min_periods=length).sum() + if isinstance(validated, pd.Series): + if not isinstance(net_move, pd.Series) or not isinstance( + path_length, pd.Series + ): + raise TypeError( + "Efficiency ratio inputs produced incompatible pandas objects." + ) + ratio = net_move / path_length.where(path_length.abs() > EPSILON) + result = ratio.mask(path_length.abs() <= EPSILON, 0.5) + else: + if not isinstance(net_move, pd.DataFrame) or not isinstance( + path_length, pd.DataFrame + ): + raise TypeError( + "Efficiency ratio inputs produced incompatible pandas objects." + ) + ratio = net_move / path_length.where(path_length.abs() > EPSILON) + result = ratio.mask(path_length.abs() <= EPSILON, 0.5) + return cast(PandasT, result) # type: ignore[redundant-cast] + + def donchian_position(prices: PandasT, window: int) -> PandasT: """Return the price position in its trailing channel. diff --git a/src/quantlab/portfolio/allocator.py b/src/quantlab/portfolio/allocator.py index 695106d..df51b07 100644 --- a/src/quantlab/portfolio/allocator.py +++ b/src/quantlab/portfolio/allocator.py @@ -131,7 +131,16 @@ def allocate(self, signals: pd.DataFrame, data: pd.DataFrame) -> pd.DataFrame: @register_allocator("inverse_volatility") class InverseVolatilityAllocator(PortfolioAllocator): - """Allocate inversely to trailing annualised volatility.""" + """Allocate inversely to trailing annualised volatility. + + Computed on the combined, closure-padded timeline (via ``self. + _returns``/``price_matrix``), NOT each symbol's own native calendar -- + unlike every built-in strategy's own signal generation (see + ``quantlab.features.native_calendar.compute_native_then_align``), a + mixed-calendar universe's realized-volatility estimate here can still + be diluted by an always-open instrument's extra sessions. Disclosed in + docs/limitations.md rather than silently assumed away. + """ def __init__( self, @@ -175,7 +184,11 @@ def allocate(self, signals: pd.DataFrame, data: pd.DataFrame) -> pd.DataFrame: @register_allocator("volatility_targeting") class VolatilityTargetingAllocator(PortfolioAllocator): - """Scale inverse-volatility weights toward an annual volatility target.""" + """Scale inverse-volatility weights toward an annual volatility target. + + Same combined-timeline (not native-calendar) volatility estimate as + `InverseVolatilityAllocator` -- see that class's own docstring. + """ def __init__( self, diff --git a/src/quantlab/portfolio/constraints.py b/src/quantlab/portfolio/constraints.py index a2aafb4..c6c834f 100644 --- a/src/quantlab/portfolio/constraints.py +++ b/src/quantlab/portfolio/constraints.py @@ -24,6 +24,86 @@ ) +@dataclass(frozen=True) +class ConstraintTouch: + """Per-constraint provenance from :meth:`ConstraintSet.apply_with_provenance`. + + ``touched`` is True at every ``(date, symbol)`` cell this constraint + changed by more than ``EPSILON`` at ANY point during constraint + resolution -- including repeated passes inside the dust-cleanup + fixed-point loop (a cumulative OR across every application). + + ``before`` holds the weight immediately before the FIRST pass that + ever changed a given cell; ``after`` holds the weight immediately + after the LAST pass that actually changed it -- never a snapshot from + a later pass that left the cell untouched (its value may have moved + for an unrelated reason between two passes of THIS constraint, and + attributing that movement to this constraint would be wrong). Both + are used only to build human-readable reason text, never to + redetermine ``touched`` itself. + + ``direct`` is a cumulative OR (same convention as ``touched``) + restricted to cells whose OWN value triggered this constraint's + clip/drop decision at some point, as opposed to a cell only + redimensioned as a downstream consequence (redistribution/rescaling + of the survivors). For constraints with no redistribution concept + (``maximum_gross_exposure``, ``maximum_leverage``, + ``maximum_net_exposure``, ``long_only`` -- uniform whole-row + rescales), ``direct == touched`` always. + """ + + touched: pd.DataFrame + before: pd.DataFrame + after: pd.DataFrame + direct: pd.DataFrame + + +def _mark_touched( + touched: dict[str, ConstraintTouch] | None, + name: str, + before: pd.DataFrame, + after: pd.DataFrame, + *, + direct_this_pass: pd.DataFrame | None = None, +) -> None: + """Record one constraint's effect, cumulatively, when tracking is on. + + ``touched`` (the mask) is a cumulative OR across every application of + this constraint during the call. ``before`` is refreshed only the + first time a cell is ever touched; ``after`` is refreshed only on a + pass that actually retouches the cell -- a later no-op pass (e.g. + once the dust-cleanup loop has converged, or a pass where some OTHER + constraint moved this cell instead) must not overwrite an earlier, + informative before/after pair with an unrelated snapshot. + + ``direct_this_pass``, when given, is the real (peek-based, no + reconstructed threshold) predicate for which cells THIS constraint's + own clip/drop decision fired on, at exactly this pass -- it is + combined with ``changed`` before being OR-ed into the cumulative + ``direct`` mask. ``None`` (the default) means every changed cell is + direct (constraints with no redistribution concept). + """ + if touched is None: + return + changed = (after - before).abs() > EPSILON + direct_this_pass_mask = ( + changed if direct_this_pass is None else (direct_this_pass & changed) + ) + if name in touched: + existing = touched[name] + first_touch_this_pass = changed & ~existing.touched + touched[name] = ConstraintTouch( + touched=existing.touched | changed, + before=existing.before.where(~first_touch_this_pass, before), + after=existing.after.where(~changed, after), + direct=existing.direct | direct_this_pass_mask, + ) + else: + touched[name] = ConstraintTouch( + touched=changed, before=before, after=after, direct=direct_this_pass_mask + ) + + @dataclass(frozen=True) class ConstraintSet: """Immutable collection of optional target-portfolio constraints.""" @@ -70,27 +150,82 @@ def __post_init__(self) -> None: def apply(self, weights: pd.DataFrame) -> pd.DataFrame: """Return finite weights satisfying every configured constraint.""" + return self._apply_impl(weights, None) + + def apply_with_provenance( + self, weights: pd.DataFrame + ) -> tuple[pd.DataFrame, dict[str, ConstraintTouch]]: + """Same computation as :meth:`apply`, plus per-constraint provenance. + + For each configured constraint, records which cells it actually + changed. The weight computation itself is identical to + :meth:`apply` -- both delegate to the same ``_apply_impl``, which + only records provenance when asked to. This is real provenance + captured directly from the actual execution, not a parallel + reconstruction, so it can never diverge from what ``apply()`` + itself would have produced. + """ + touched: dict[str, ConstraintTouch] = {} + result = self._apply_impl(weights, touched) + return result, touched + + def _apply_impl( + self, weights: pd.DataFrame, touched: dict[str, ConstraintTouch] | None + ) -> pd.DataFrame: out = validate_frame(weights, name="weights").copy().astype(float) if self.long_only: + before = out out = out.clip(lower=0.0) + _mark_touched(touched, "long_only", before, out) if self.maximum_positions is not None: + before = out pre_drop_gross = gross_exposure(out) - out = _cap_positions(out, self.maximum_positions) - out = _redistribute_to_target(out, pre_drop_gross) + after_cap = _cap_positions(out, self.maximum_positions) + direct_this_pass = after_cap.ne(before) + out = _redistribute_to_target(after_cap, pre_drop_gross) + _mark_touched( + touched, + "maximum_positions", + before, + out, + direct_this_pass=direct_this_pass, + ) if self.minimum_weight is not None: + before = out pre_drop_gross = gross_exposure(out) - out = out.where(out.abs() >= self.minimum_weight, 0.0) - out = _redistribute_to_target(out, pre_drop_gross) + after_drop = out.where(out.abs() >= self.minimum_weight, 0.0) + direct_this_pass = after_drop.ne(before) + out = _redistribute_to_target(after_drop, pre_drop_gross) + _mark_touched( + touched, + "minimum_weight", + before, + out, + direct_this_pass=direct_this_pass, + ) if self.maximum_weight is not None: + before = out pre_cap_gross = gross_exposure(out) + after_clip = out.astype(float).clip( + -self.maximum_weight, self.maximum_weight + ) + direct_this_pass = after_clip.ne(before) out = renormalize_within_cap( out, target_gross=pre_cap_gross, cap=self.maximum_weight ) + _mark_touched( + touched, + "maximum_weight", + before, + out, + direct_this_pass=direct_this_pass, + ) pre_exposure_cap = out.copy() - out = self._apply_exposure_caps(out) + out = self._apply_exposure_caps(out, touched) if self.minimum_weight is not None: - out = self._clean_dust_to_fixed_point(out) + out = self._clean_dust_to_fixed_point(out, touched) + before = out out = _rescue_needless_full_liquidation( out, pre_exposure_cap, @@ -100,34 +235,70 @@ def apply(self, weights: pd.DataFrame) -> pd.DataFrame: maximum_leverage=self.maximum_leverage, maximum_net_exposure=self.maximum_net_exposure, ) + _mark_touched(touched, "minimum_weight", before, out) self._assert_satisfied(out) return out - def _apply_exposure_caps(self, weights: pd.DataFrame) -> pd.DataFrame: + def _apply_exposure_caps( + self, + weights: pd.DataFrame, + touched: dict[str, ConstraintTouch] | None = None, + ) -> pd.DataFrame: out = weights if self.maximum_gross_exposure is not None: + before = out out = _cap_gross(out, self.maximum_gross_exposure) + _mark_touched(touched, "maximum_gross_exposure", before, out) if self.maximum_leverage is not None: + before = out out = _cap_gross(out, self.maximum_leverage) + _mark_touched(touched, "maximum_leverage", before, out) if self.maximum_net_exposure is not None: + before = out out = _cap_net(out, self.maximum_net_exposure) + _mark_touched(touched, "maximum_net_exposure", before, out) return out - def _clean_dust_to_fixed_point(self, weights: pd.DataFrame) -> pd.DataFrame: + def _clean_dust_to_fixed_point( + self, + weights: pd.DataFrame, + touched: dict[str, ConstraintTouch] | None = None, + ) -> pd.DataFrame: """Repeat dust removal because exposure caps can create new dust.""" assert self.minimum_weight is not None out = weights for _ in range(max(out.shape[1] + 1, 1)): before = out.copy() + step_before = out pre_drop_gross = gross_exposure(out) - out = out.where(out.abs() >= self.minimum_weight, 0.0) - out = _redistribute_to_target(out, pre_drop_gross) + after_drop = out.where(out.abs() >= self.minimum_weight, 0.0) + direct_this_pass = after_drop.ne(step_before) + out = _redistribute_to_target(after_drop, pre_drop_gross) + _mark_touched( + touched, + "minimum_weight", + step_before, + out, + direct_this_pass=direct_this_pass, + ) if self.maximum_weight is not None: + step_before = out pre_cap_gross = gross_exposure(out) + after_clip = out.astype(float).clip( + -self.maximum_weight, self.maximum_weight + ) + direct_this_pass = after_clip.ne(step_before) out = renormalize_within_cap( out, target_gross=pre_cap_gross, cap=self.maximum_weight ) - out = self._apply_exposure_caps(out) + _mark_touched( + touched, + "maximum_weight", + step_before, + out, + direct_this_pass=direct_this_pass, + ) + out = self._apply_exposure_caps(out, touched) if np.allclose(out.to_numpy(), before.to_numpy(), atol=1e-10, rtol=0.0): return out raise InvalidConfigurationError( diff --git a/src/quantlab/portfolio/drift_compliance.py b/src/quantlab/portfolio/drift_compliance.py new file mode 100644 index 0000000..18ff79a --- /dev/null +++ b/src/quantlab/portfolio/drift_compliance.py @@ -0,0 +1,606 @@ +"""Minimal-L1-turnover restoration of a drift-breached row's compliance. + +Between rebalances, organic price drift (see +:func:`quantlab.backtesting.accounting.apply_weight_drift`) can push a row +past a hard portfolio-level risk limit (``maximum_weight``, +``maximum_gross_exposure``/``maximum_leverage``, ``maximum_net_exposure``, +``long_only``) even though the last REAL decision was itself fully +compliant. Restoring compliance off-schedule is a genuine constrained +optimization, not a heuristic: a naive "clip then scale toward 0" fix can +move exposure in the WRONG direction whenever some of the breaching +exposure sits in a currently-untradable column (see +:func:`restore_drift_compliance`'s own docstring for the exact +counterexample this module exists to avoid). +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass + +import numpy as np +from scipy.optimize import linprog + +from quantlab.constants import EPSILON +from quantlab.exceptions import BacktestError + +#: scipy.optimize.linprog's HiGHS status code for "solved to optimality". +_LINPROG_OPTIMAL = 0 +#: HiGHS status code for "provably infeasible" -- the ONLY non-optimal +#: status treated as an ordinary, expected outcome (tradability-caused). +#: Every other non-optimal status (1: iteration limit, 3: unbounded, 4: +#: numerical difficulties) is a genuine solver failure, never folded into +#: the same "infeasible" bucket -- see `_solve`'s own docstring. +_LINPROG_INFEASIBLE = 2 + + +@dataclass(frozen=True) +class DriftComplianceResult: + """One row's outcome from :func:`restore_drift_compliance`. + + ``corrected`` is the row's new weights. ``pending`` is ``True`` only + when full compliance was genuinely unachievable given which columns + are currently tradable (the slack-relaxation fallback fired) -- + ``corrected`` is then the best achievable correction, not a fully + compliant row, and the caller is expected to retry this row's + successor once the responsible column(s) reopen. + """ + + corrected: np.ndarray + pending: bool + + +def restore_drift_compliance( + drifted: np.ndarray, + columns: Sequence[str], + tradable_row: np.ndarray, + groups: Sequence[tuple[str, ...]], + *, + maximum_weight: float | None, + maximum_gross_exposure: float | None, + maximum_net_exposure: float | None, + long_only: bool, +) -> DriftComplianceResult: + """Return the minimal-L1-turnover row that restores compliance. + + Internal, low-level primitive: its sole caller, + :func:`quantlab.backtesting.accounting.apply_weight_drift`, is the + validated public entry point (frame shape/dtype/finiteness, tradable + mask, etc.). This function only asserts array shapes match ``columns`` + below -- it trusts ``drifted``/``tradable_row`` are otherwise already + clean numeric/boolean data, and is not meant to be called directly on + unvalidated input. Full LP formulation (free variables, objective, + constraints, the infeasibility-diagnosis/slack-relaxation fallback, + and the two disclosed scope limits): see + docs/drift_compliance.md#compliance-restoration-lp. + + Key invariants a caller relies on: a declared position group (e.g. + pairs_trading's two legs, via ``groups``) always moves as one + coherent unit via a single shared scaling factor, never one leg + alone; an untradable column/group is always returned bit-for-bit + unchanged from ``drifted``; the result never invents a brand-new + position (long or short) on a column/group the drifted book did not + already hold, even when that would be the cheapest fix. ``pending`` + is ``True`` only when full compliance was genuinely unachievable + given current tradability (``corrected`` is then the best achievable + partial fix, not a fully compliant row) -- a constraint configuration + that is infeasible for any OTHER reason raises ``BacktestError`` + instead of returning a result, since that indicates a bug (a + contradictory configuration ``_validate_target_row_compliant`` should + already have rejected upstream), never a legitimate runtime outcome. + """ + n = len(columns) + if drifted.shape != (n,) or tradable_row.shape != (n,): + raise BacktestError( + "drifted and tradable_row must be 1-D arrays matching columns." + ) + column_index = {name: i for i, name in enumerate(columns)} + + indep_columns: list[str] = [] + group_legs: list[list[int]] = [] # column indices per multi-column group + for group in groups: + if len(group) == 1: + indep_columns.append(group[0]) + else: + group_legs.append([column_index[symbol] for symbol in group]) + + indep_idx = [column_index[symbol] for symbol in indep_columns] + indep_tradable = [bool(tradable_row[i]) for i in indep_idx] + group_tradable = [all(bool(tradable_row[i]) for i in legs) for legs in group_legs] + group_l1_norm = [float(np.sum(np.abs(drifted[legs]))) for legs in group_legs] + group_net = [float(np.sum(drifted[legs])) for legs in group_legs] + + gross_cap = maximum_gross_exposure + + solved = _solve( + drifted, + indep_idx, + indep_tradable, + group_legs, + group_tradable, + group_l1_norm, + group_net, + maximum_weight=maximum_weight, + gross_cap=gross_cap, + maximum_net_exposure=maximum_net_exposure, + long_only=long_only, + allow_slack=False, + ) + if solved is not None: + return DriftComplianceResult(corrected=solved, pending=False) + + # Diagnose: is infeasibility explained by the fixed (untradable) + # positions alone? Build the row that WOULD result from every fixed + # position at its drifted value and every free position at exactly + # its own drifted value too (i.e. "no correction at all") and check + # whether the fixed subset's own contribution already breaches a cap + # that no amount of free-column movement could ever repair (a + # portfolio-level cap breached by the fixed positions alone, or a + # per-asset cap breached by a fixed position's own value). + fixed_only_violation = _fixed_positions_alone_violate( + drifted, + indep_idx, + indep_tradable, + group_legs, + group_tradable, + maximum_weight=maximum_weight, + gross_cap=gross_cap, + maximum_net_exposure=maximum_net_exposure, + long_only=long_only, + ) + if not fixed_only_violation: # pragma: no cover - defensive, should be unreachable + # 0 is always a feasible point for every free (tradable) variable + # under any valid, non-negative constraint configuration (see + # test_always_feasible_and_never_pending_when_everything_is_ + # tradable's own reasoning) -- so a strict-LP infeasibility not + # explained by the fixed/untradable subset alone should never + # actually happen for input `_validate_drift_and_risk_options`/ + # `_validate_target_row_compliant` have already validated. Kept as + # a loud, explicit guard rather than silently reaching the slack- + # relaxation path for a reason tradability doesn't actually explain. + raise BacktestError( + "Drift-compliance restoration is infeasible for a reason other " + "than tradability -- this indicates a bug in the algorithm " + "(e.g. a contradictory constraint configuration that " + "_validate_target_row_compliant should already have rejected " + "at the target), not a legitimate runtime condition." + ) + + relaxed = _solve( + drifted, + indep_idx, + indep_tradable, + group_legs, + group_tradable, + group_l1_norm, + group_net, + maximum_weight=maximum_weight, + gross_cap=gross_cap, + maximum_net_exposure=maximum_net_exposure, + long_only=long_only, + allow_slack=True, + ) + if relaxed is None: # pragma: no cover - defensive, should be unreachable + raise BacktestError( + "Drift-compliance slack-relaxation LP unexpectedly infeasible " + "despite a tradability-caused diagnosis -- this indicates a " + "bug in the algorithm." + ) + return DriftComplianceResult(corrected=relaxed, pending=True) + + +def _fixed_positions_alone_violate( + drifted: np.ndarray, + indep_idx: list[int], + indep_tradable: list[bool], + group_legs: list[list[int]], + group_tradable: list[bool], + *, + maximum_weight: float | None, + gross_cap: float | None, + maximum_net_exposure: float | None, + long_only: bool, +) -> bool: + """Whether fixed positions alone already violate a constraint. + + True when the untradable columns'/groups' own drifted values alone + already violate a constraint no amount of free-column movement could + fix -- the signature of a tradability-caused (expected) infeasibility + rather than a genuine bug. + """ + if maximum_weight is not None: + for i, tradable in zip(indep_idx, indep_tradable, strict=True): + if not tradable and abs(drifted[i]) > maximum_weight + EPSILON: + return True + for legs, tradable in zip(group_legs, group_tradable, strict=True): + if not tradable and any( + abs(drifted[i]) > maximum_weight + EPSILON for i in legs + ): + return True + if long_only: + for i, tradable in zip(indep_idx, indep_tradable, strict=True): + if not tradable and drifted[i] < -EPSILON: + return True + for legs, tradable in zip(group_legs, group_tradable, strict=True): + if not tradable and any(drifted[i] < -EPSILON for i in legs): + return True + fixed_gross = sum( + abs(drifted[i]) + for i, tradable in zip(indep_idx, indep_tradable, strict=True) + if not tradable + ) + sum( + sum(abs(drifted[i]) for i in legs) + for legs, tradable in zip(group_legs, group_tradable, strict=True) + if not tradable + ) + if gross_cap is not None and fixed_gross > gross_cap + EPSILON: + return True + fixed_net = sum( + drifted[i] + for i, tradable in zip(indep_idx, indep_tradable, strict=True) + if not tradable + ) + sum( + sum(drifted[i] for i in legs) + for legs, tradable in zip(group_legs, group_tradable, strict=True) + if not tradable + ) + return ( + maximum_net_exposure is not None + and abs(fixed_net) > maximum_net_exposure + EPSILON + ) + + +def _solve( + drifted: np.ndarray, + indep_idx: list[int], + indep_tradable: list[bool], + group_legs: list[list[int]], + group_tradable: list[bool], + group_l1_norm: list[float], + group_net: list[float], + *, + maximum_weight: float | None, + gross_cap: float | None, + maximum_net_exposure: float | None, + long_only: bool, + allow_slack: bool, +) -> np.ndarray | None: + """Build and solve one LP (strict or slack-relaxed); ``None`` if infeasible. + + Variable layout: ``w_i`` (independent columns), ``k_g`` (groups), + ``u_i``/``v_g`` (``|w_i - drifted_i|``/``|k_g - 1|`` L1-deviation + auxiliaries, ALWAYS present -- see below), ``p_i``/``q_g`` + (``|w_i|``/``|k_g|`` auxiliaries, only wired into a constraint row + when a gross-exposure cap is configured), and, in slack mode only, + four shared slack variables (``s_mw``, ``s_gross``, ``s_net``, + ``s_long``) added to each cap's own right-hand side -- ``s_long`` + relaxes the ``long_only`` inequality (below), the same way the other + three relax their own numeric cap. + + Strict mode (``allow_slack=False``) minimizes the L1 deviation + directly in one solve -- every constraint is satisfied exactly, no + slack variables exist at all. + + Slack mode (``allow_slack=True``) is a genuine two-stage LEXICOGRAPHIC + solve, not a single relaxed objective: stage 1 minimizes ONLY + ``s_mw + s_gross + s_net + s_long`` (the unavoidable violation, exactly + what the tradability-caused-infeasibility diagnosis already proved is + nonzero); stage 2 then FIXES those slacks at their stage-1-optimal + values (via tight bounds) and re-solves for the MINIMAL L1 deviation + among every point achieving that same minimal violation. A single- + stage "minimize slack only" solve would leave every OTHER free + column's own objective coefficient at zero, so the solver is free to + move an already-compliant, uninvolved column to an arbitrary value + (e.g. liquidating it to 0) with no penalty for doing so, since nothing + in that objective discourages it -- stage 2 is what rules that out. + + Returns ``None`` ONLY when the solve is provably infeasible (HiGHS + status ``_LINPROG_INFEASIBLE``) -- the caller's own tradability + diagnosis treats this as the ordinary, expected outcome. Any OTHER + non-optimal status (an iteration limit, an unbounded problem, or a + numerical-difficulties report) is a genuine solver failure, never + folded into the same "infeasible, diagnose via tradability" bucket -- + it raises `BacktestError` immediately with the solver's own status and + message, since silently treating it as an ordinary infeasibility could + misreport a real solver hiccup as a tradability-caused breach (or vice + versa), and a caller has no way to tell the two apart from `None` alone. + """ + n_i = len(indep_idx) + n_g = len(group_legs) + w_at = list(range(n_i)) + k_at = list(range(n_i, n_i + n_g)) + u_at = list(range(n_i + n_g, 2 * n_i + n_g)) + v_at = list(range(2 * n_i + n_g, 2 * n_i + 2 * n_g)) + p_at = list(range(2 * n_i + 2 * n_g, 3 * n_i + 2 * n_g)) + q_at = list(range(3 * n_i + 2 * n_g, 3 * n_i + 3 * n_g)) + n_vars = 3 * (n_i + n_g) + (4 if allow_slack else 0) + # Only ever read/written inside an `if allow_slack:` guard below, so + # these remain valid indices even though `n_vars` excludes them when + # `allow_slack` is False (kept as plain ints, not `int | None`, so + # every use site stays a simple, unconditional index expression). + s_mw = 3 * (n_i + n_g) + s_gross = s_mw + 1 + s_net = s_mw + 2 + s_long = s_mw + 3 + + c_deviation = np.zeros(n_vars) + for pos in u_at: + c_deviation[pos] = 1.0 + for pos, norm in zip(v_at, group_l1_norm, strict=True): + c_deviation[pos] = norm + # Allocated unconditionally (only ever READ inside `if allow_slack:` + # below, mirroring s_mw/s_gross/s_net's own "harmless when unused" + # convention above) so every use site stays a simple, unconditional + # expression rather than requiring a definite-assignment analysis that + # spans two separate `if allow_slack`/`if not allow_slack` statements. + c_slack = np.zeros(n_vars) + if allow_slack: + c_slack[s_mw] = 1.0 + c_slack[s_gross] = 1.0 + c_slack[s_net] = 1.0 + c_slack[s_long] = 1.0 + + bounds: list[tuple[float, float]] = [(0.0, 0.0)] * n_vars + for k, (i, tradable) in enumerate(zip(indep_idx, indep_tradable, strict=True)): + if not tradable: + bounds[w_at[k]] = (float(drifted[i]), float(drifted[i])) + else: + # Sign/support-preserving: a currently-LONG column may shrink + # toward 0 or grow further long, a currently-SHORT column may + # shrink toward 0 or grow further short, but neither may CROSS + # zero, and a column already AT zero stays fixed there -- the + # LP is never allowed to invent a brand-new position (long or + # short) on an asset the drifted book doesn't already hold. + # See restore_drift_compliance's own docstring: the closed- + # long/tradable-short counterexample this module exists to + # solve correctly only ever needs an ALREADY-nonzero column + # free to move FURTHER in its own direction, never a zero + # column becoming nonzero, so this loses no real solution. + drifted_i = float(drifted[i]) + if drifted_i > EPSILON: + lo, hi = 0.0, np.inf + elif drifted_i < -EPSILON: + lo, hi = -np.inf, 0.0 + else: + lo = hi = 0.0 + if long_only: + lo = max(lo, 0.0) + bounds[w_at[k]] = (lo, hi) + for k, tradable in enumerate(group_tradable): + bounds[k_at[k]] = (1.0, 1.0) if not tradable else (0.0, np.inf) + for pos in u_at: + bounds[pos] = (0.0, np.inf) + for pos in v_at: + bounds[pos] = (0.0, np.inf) + if gross_cap is not None: + for pos in p_at: + bounds[pos] = (0.0, np.inf) + for pos in q_at: + bounds[pos] = (0.0, np.inf) + if allow_slack: + bounds[s_mw] = (0.0, np.inf) + bounds[s_gross] = (0.0, np.inf) + bounds[s_net] = (0.0, np.inf) + bounds[s_long] = (0.0, np.inf) + + a_ub: list[np.ndarray] = [] + b_ub: list[float] = [] + + def _row() -> np.ndarray: + return np.zeros(n_vars) + + # Deviation: u_i >= |w_i - drifted_i|; v_g >= |k_g - 1|. Built + # UNCONDITIONALLY (not just in strict mode) so slack mode's stage 2 + # (below) can minimize this same L1 deviation once the unavoidable + # violation has been pinned at its stage-1-optimal value -- otherwise + # a free, already-compliant column has no cost keeping it near its own + # drifted value and the solver may move it anywhere. + for k, i in enumerate(indep_idx): + row = _row() + row[w_at[k]] = 1.0 + row[u_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(float(drifted[i])) + row = _row() + row[w_at[k]] = -1.0 + row[u_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(-float(drifted[i])) + for k in range(n_g): + row = _row() + row[k_at[k]] = 1.0 + row[v_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(1.0) + row = _row() + row[k_at[k]] = -1.0 + row[v_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(-1.0) + + if long_only: + # `long_only` is otherwise only baked into a TRADABLE independent + # column's own lower bound (`lo = max(lo, 0.0)` above) -- silently + # a no-op for an UNTRADABLE (fixed) column or group, whose bound + # is pinned at its drifted value regardless of sign. Without an + # explicit inequality here, a fixed column/group already negative + # under long_only (which "shouldn't happen" upstream, per this + # function's own sign-preservation docstring, but is not actually + # enforced anywhere for a FIXED value) would let the strict LP + # trivially "succeed" over a value that still violates long_only -- + # exactly the kind of formulation bug this module must never + # produce. Redundant (never binding) for every TRADABLE column, + # whose own bounds already enforce this; only a FIXED column/group + # can make it bind, correctly turning that into LP infeasibility + # so the normal tradability diagnosis + slack-relaxation path + # handles it like any other fixed-value violation (see + # `_fixed_positions_alone_violate`'s own long_only check). + for k in range(n_i): + row = _row() + row[w_at[k]] = -1.0 + if allow_slack: + row[s_long] = -1.0 + a_ub.append(row) + b_ub.append(0.0) + for k, legs in enumerate(group_legs): + for i in legs: + d = float(drifted[i]) + row = _row() + row[k_at[k]] = -d + if allow_slack: + row[s_long] = -1.0 + a_ub.append(row) + b_ub.append(0.0) + + if maximum_weight is not None: + for k in range(n_i): + row = _row() + row[w_at[k]] = 1.0 + if allow_slack: + row[s_mw] = -1.0 + a_ub.append(row) + b_ub.append(maximum_weight) + row = _row() + row[w_at[k]] = -1.0 + if allow_slack: + row[s_mw] = -1.0 + a_ub.append(row) + b_ub.append(maximum_weight) + for k, legs in enumerate(group_legs): + for i in legs: + d = float(drifted[i]) + row = _row() + row[k_at[k]] = d + if allow_slack: + row[s_mw] = -1.0 + a_ub.append(row) + b_ub.append(maximum_weight) + row = _row() + row[k_at[k]] = -d + if allow_slack: + row[s_mw] = -1.0 + a_ub.append(row) + b_ub.append(maximum_weight) + + if gross_cap is not None: + # p_i >= |w_i|; q_g >= |k_g|. + for k in range(n_i): + row = _row() + row[w_at[k]] = 1.0 + row[p_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(0.0) + row = _row() + row[w_at[k]] = -1.0 + row[p_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(0.0) + for k in range(n_g): + row = _row() + row[k_at[k]] = 1.0 + row[q_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(0.0) + row = _row() + row[k_at[k]] = -1.0 + row[q_at[k]] = -1.0 + a_ub.append(row) + b_ub.append(0.0) + row = _row() + for pos in p_at: + row[pos] = 1.0 + for pos, norm in zip(q_at, group_l1_norm, strict=True): + row[pos] = norm + if allow_slack: + row[s_gross] = -1.0 + a_ub.append(row) + b_ub.append(gross_cap) + + if maximum_net_exposure is not None: + row = _row() + for pos in w_at: + row[pos] = 1.0 + for pos, net in zip(k_at, group_net, strict=True): + row[pos] = net + row_pos = row.copy() + row_neg = -row.copy() + if allow_slack: + row_pos[s_net] = -1.0 + row_neg[s_net] = -1.0 + a_ub.append(row_pos) + b_ub.append(maximum_net_exposure) + a_ub.append(row_neg) + b_ub.append(maximum_net_exposure) + + a_ub_arr = np.array(a_ub) if a_ub else None + b_ub_arr = np.array(b_ub) if b_ub else None + + if not allow_slack: + result = linprog( + c_deviation, A_ub=a_ub_arr, b_ub=b_ub_arr, bounds=bounds, method="highs" + ) + if result.status == _LINPROG_INFEASIBLE: + return None + if result.status != _LINPROG_OPTIMAL: + raise BacktestError( + "Drift-compliance strict LP failed with a non-infeasible, " + f"non-optimal HiGHS status ({result.status}: " + f"{result.message}) -- this is a genuine solver failure, " + "not a tradability-caused infeasibility, and must be " + "investigated directly." + ) + x = result.x + else: + # Stage 1: the unavoidable violation alone. + stage1 = linprog( + c_slack, A_ub=a_ub_arr, b_ub=b_ub_arr, bounds=bounds, method="highs" + ) + if stage1.status == _LINPROG_INFEASIBLE: + return None + if stage1.status != _LINPROG_OPTIMAL: + raise BacktestError( + "Drift-compliance slack-relaxation stage 1 failed with a " + f"non-infeasible, non-optimal HiGHS status ({stage1.status}: " + f"{stage1.message}) -- this is a genuine solver failure, " + "not a tradability-caused infeasibility, and must be " + "investigated directly." + ) + # Stage 2: pin that violation at its stage-1-optimal value, then + # minimize the L1 deviation among every point achieving it -- this + # is what keeps an already-compliant, uninvolved free column at + # (or near) its own drifted value instead of moving it arbitrarily. + stage2_bounds = list(bounds) + for pos in (s_mw, s_gross, s_net, s_long): + pinned = float(stage1.x[pos]) + stage2_bounds[pos] = (pinned, pinned) + stage2 = linprog( + c_deviation, + A_ub=a_ub_arr, + b_ub=b_ub_arr, + bounds=stage2_bounds, + method="highs", + ) + # Stage 1's own solution is always feasible for stage 2 (same + # constraints, slacks pinned at the value it itself produced), so + # a non-optimal status here is always a genuine bug -- never + # silently substituted with stage 1's own solution, which has no + # penalty on any OTHER free column and so could be an arbitrary, + # needlessly destructive correction (see this function's own + # docstring on why stage 2 exists at all). + if stage2.status != _LINPROG_OPTIMAL: + raise BacktestError( + "Drift-compliance slack-relaxation stage 2 unexpectedly " + f"failed (HiGHS status {stage2.status}: {stage2.message}) " + "despite stage 1's own solution being feasible for stage 2 " + "by construction -- this indicates a bug in the algorithm." + ) + x = stage2.x + + out = drifted.copy() + for k, i in enumerate(indep_idx): + out[i] = x[w_at[k]] + for k, legs in enumerate(group_legs): + kg = x[k_at[k]] + for i in legs: + out[i] = kg * drifted[i] + return out diff --git a/src/quantlab/portfolio/rebalancing.py b/src/quantlab/portfolio/rebalancing.py index b12e0fb..e00b62e 100644 --- a/src/quantlab/portfolio/rebalancing.py +++ b/src/quantlab/portfolio/rebalancing.py @@ -1,8 +1,12 @@ """Rebalancing schedules and stateful turnover limits. Targets are sampled on rebalance dates and represented as constant portfolio -weights between them. This vectorised approximation does not model weight -drift caused by relative asset-price moves between rebalances. +weights between them -- this module's own output is a decision-timeline +step function. Real, price-driven weight drift between genuine trades is +modeled separately and downstream, on the EXECUTED timeline, by +:func:`quantlab.backtesting.accounting.apply_weight_drift` (gated by +``PortfolioConfig.model_weight_drift``); this module's own output is +identical regardless of whether that gate is on or off. Timing convention: every function in this module produces *decided* weights, not executed ones -- including a row where a closed symbol's pending target @@ -20,6 +24,9 @@ from __future__ import annotations +from dataclasses import dataclass +from typing import Literal, overload + import numpy as np import pandas as pd from pandas.api.types import is_bool_dtype @@ -35,6 +42,29 @@ validate_frame, ) + +@dataclass(frozen=True) +class TurnoverProvenance: + """Cell-level, real provenance from a turnover-capped rebalance. + + ``turnover_actively_limited`` is True where the turnover budget itself + bound this row's move for that cell. ``turnover_touched`` is the + broader, *episode-scoped* provenance -- also True on a later row that + is still catching up a debt created by an earlier turnover-limited + move toward the SAME upstream decision (see ``episode_id`` on + :func:`cap_turnover`/:func:`rebalance_and_cap_turnover`), even when + that later row is no longer itself actively binding. ``tradability_ + touched``/``tradability_compliance_limited`` are always all-``False`` + for :func:`cap_turnover` (no tradability concept); populated for + :func:`_rebalance_tradability_aware`. + """ + + turnover_actively_limited: pd.DataFrame + turnover_touched: pd.DataFrame + tradability_touched: pd.DataFrame + tradability_compliance_limited: pd.DataFrame + + _PERIOD_ALIAS = { RebalanceFrequency.WEEKLY: "W", RebalanceFrequency.MONTHLY: "M", @@ -128,13 +158,39 @@ def compute_turnover(held_weights: pd.DataFrame) -> pd.Series: return (validated - previous).abs().sum(axis=1) +@overload def rebalance_and_cap_turnover( target_weights: pd.DataFrame, portfolio_config: PortfolioConfig, *, tradable: pd.DataFrame | None = None, calendar: str | None = None, -) -> pd.DataFrame: + episode_id: pd.DataFrame | None = None, + return_provenance: Literal[False] = False, +) -> pd.DataFrame: ... + + +@overload +def rebalance_and_cap_turnover( + target_weights: pd.DataFrame, + portfolio_config: PortfolioConfig, + *, + tradable: pd.DataFrame | None = None, + calendar: str | None = None, + episode_id: pd.DataFrame | None = None, + return_provenance: Literal[True], +) -> tuple[pd.DataFrame, TurnoverProvenance]: ... + + +def rebalance_and_cap_turnover( + target_weights: pd.DataFrame, + portfolio_config: PortfolioConfig, + *, + tradable: pd.DataFrame | None = None, + calendar: str | None = None, + episode_id: pd.DataFrame | None = None, + return_provenance: bool = False, +) -> pd.DataFrame | tuple[pd.DataFrame, TurnoverProvenance]: """Apply the stateful schedule and turnover cap over one continuous index. Minimum-weight and position-count constraints apply to targets upstream. @@ -161,17 +217,40 @@ def rebalance_and_cap_turnover( real trading sessions instead of raw UTC dates. Only meaningful when every instrument shares one calendar; omit for a mixed universe (see :func:`rebalance_dates`). + episode_id: Forwarded to :func:`cap_turnover`/the tradability-aware + path -- see :func:`cap_turnover`'s own docstring. + return_provenance: Forwarded the same way -- see :func:`cap_turnover`. """ if tradable is not None: + # Branched (rather than forwarding the plain `bool` variable + # directly) so mypy can select the correct @overload -- a + # non-literal bool cannot match either `Literal[True]`/ + # `Literal[False]` overload variant. + if return_provenance: + return _rebalance_tradability_aware( + target_weights, + portfolio_config, + tradable, + calendar=calendar, + episode_id=episode_id, + return_provenance=True, + ) return _rebalance_tradability_aware( - target_weights, portfolio_config, tradable, calendar=calendar + target_weights, + portfolio_config, + tradable, + calendar=calendar, + episode_id=episode_id, + return_provenance=False, ) held = apply_rebalancing( target_weights, portfolio_config.rebalance_frequency, calendar=calendar ) if portfolio_config.maximum_turnover is None: - return held + if not return_provenance: + return held + return held, _no_provenance(held) gross_caps = [portfolio_config.maximum_leverage] if portfolio_config.maximum_gross_exposure is not None: @@ -182,6 +261,18 @@ def rebalance_and_cap_turnover( portfolio_config.rebalance_frequency, calendar=calendar, ) + if return_provenance: + return cap_turnover( + held, + portfolio_config.maximum_turnover, + rebalance_index=dates, + maximum_weight=portfolio_config.maximum_weight, + maximum_gross_exposure=effective_gross_cap, + maximum_net_exposure=portfolio_config.maximum_net_exposure, + long_only=portfolio_config.long_only, + episode_id=episode_id, + return_provenance=True, + ) return cap_turnover( held, portfolio_config.maximum_turnover, @@ -190,9 +281,12 @@ def rebalance_and_cap_turnover( maximum_gross_exposure=effective_gross_cap, maximum_net_exposure=portfolio_config.maximum_net_exposure, long_only=portfolio_config.long_only, + episode_id=episode_id, + return_provenance=False, ) +@overload def cap_turnover( held_weights: pd.DataFrame, maximum_turnover: float, @@ -202,13 +296,75 @@ def cap_turnover( maximum_gross_exposure: float | None = None, maximum_net_exposure: float | None = None, long_only: bool = False, -) -> pd.DataFrame: + episode_id: pd.DataFrame | None = None, + return_provenance: Literal[False] = False, +) -> pd.DataFrame: ... + + +@overload +def cap_turnover( + held_weights: pd.DataFrame, + maximum_turnover: float, + *, + rebalance_index: pd.DatetimeIndex | None = None, + maximum_weight: float | None = None, + maximum_gross_exposure: float | None = None, + maximum_net_exposure: float | None = None, + long_only: bool = False, + episode_id: pd.DataFrame | None = None, + return_provenance: Literal[True], +) -> tuple[pd.DataFrame, TurnoverProvenance]: ... + + +def cap_turnover( + held_weights: pd.DataFrame, + maximum_turnover: float, + *, + rebalance_index: pd.DatetimeIndex | None = None, + maximum_weight: float | None = None, + maximum_gross_exposure: float | None = None, + maximum_net_exposure: float | None = None, + long_only: bool = False, + episode_id: pd.DataFrame | None = None, + return_provenance: bool = False, +) -> pd.DataFrame | tuple[pd.DataFrame, TurnoverProvenance]: """Partially move toward each scheduled target within an L1 budget. The result is a straight-line interpolation from the previous holding to an already-compliant target. Per-asset, gross, net and long-only bounds are convex, so compliant endpoints keep every intermediate point compliant. Cardinality and minimum-position-size constraints remain target-only. + + Args: + held_weights: Scheduled targets to move toward, one row per date + (constant between rebalance dates -- see :func:`apply_rebalancing`). + maximum_turnover: Maximum L1 weight change allowed on any single row + (a rebalance whose full target exceeds this lands partially and + keeps closing the gap over subsequent rows). + rebalance_index: Dates on which a new target is actually chased; + every other date holds the previous value. ``None`` treats + every date as a rebalance date. + maximum_weight: Optional per-asset cap, enforced on the target + (upstream) and therefore on every intermediate point. + maximum_gross_exposure: Optional gross exposure cap, same convexity + argument. + maximum_net_exposure: Optional net exposure cap, same convexity + argument. + long_only: When True, rejects a target with any negative weight. + episode_id: Required when ``return_provenance`` is True. A ``dates + x symbols`` integer frame identifying, per cell, which upstream + decision produced the target currently being chased -- two + cells sharing the same value are the SAME still-unresolved + decision, even if the target happens to repeat a prior numeric + value; a different value always means a genuinely different + upstream decision. Built by the caller (see ``engine.py``), + never reconstructed here from the target's own numeric value + (which cannot tell two decisions with the same target apart). + return_provenance: When True, also return a :class:`TurnoverProvenance` + with real, cell-level attribution of which trades were caused + (directly or as an episode-scoped catch-up) by the turnover + cap. Does not affect the computed weights in any way -- the + numeric branch below is identical whether or not this is set. """ validated = validate_frame(held_weights, name="held_weights") turnover_cap = finite_real(maximum_turnover, name="maximum_turnover", minimum=0.0) @@ -224,6 +380,27 @@ def cap_turnover( row_count, column_count = targets.shape output = np.zeros((row_count, column_count), dtype=float) previous = np.zeros(column_count, dtype=float) + + # Always bound with cheap placeholders, even though they are only ever + # read (below and by the caller) under `if return_provenance:` -- the + # same unchanged flag that guards their real assignment just below. A + # static analyzer cannot follow "guarded by the same boolean flag" + # across the loop in between; this changes no behaviour. + episode_values = np.empty((row_count, column_count), dtype=float) + pending_episode_id = np.full(column_count, -1.0) + actively_limited_out = np.zeros((row_count, column_count), dtype=bool) + touched_out = np.zeros((row_count, column_count), dtype=bool) + if return_provenance: + if episode_id is None: + raise BacktestError( + "episode_id is required when return_provenance is True." + ) + episode_values = ( + validate_frame(episode_id, name="episode_id") + .reindex(index=validated.index, columns=validated.columns) + .to_numpy(dtype=float) + ) + for row_number in range(row_count): if not is_rebalance_date[row_number]: output[row_number] = previous @@ -243,9 +420,50 @@ def cap_turnover( current = target else: current = previous + (turnover_cap / requested_turnover) * change + if return_provenance: + row_actively_limited = requested_turnover > turnover_cap + EPSILON + changed_this_row = np.abs(change) > EPSILON + generation = episode_values[row_number] + debt_still_relevant = (pending_episode_id != -1.0) & ( + pending_episode_id == generation + ) + actively_limited_out[row_number] = changed_this_row & row_actively_limited + touched_out[row_number] = changed_this_row & ( + row_actively_limited | debt_still_relevant + ) + still_outstanding = np.abs(current - target) > EPSILON + pending_episode_id = np.where(still_outstanding, generation, -1.0) output[row_number] = current previous = current - return pd.DataFrame(output, index=validated.index, columns=validated.columns) + result = pd.DataFrame(output, index=validated.index, columns=validated.columns) + if not return_provenance: + return result + provenance = TurnoverProvenance( + turnover_actively_limited=pd.DataFrame( + actively_limited_out, index=validated.index, columns=validated.columns + ), + turnover_touched=pd.DataFrame( + touched_out, index=validated.index, columns=validated.columns + ), + tradability_touched=pd.DataFrame( + False, index=validated.index, columns=validated.columns + ), + tradability_compliance_limited=pd.DataFrame( + False, index=validated.index, columns=validated.columns + ), + ) + return result, provenance + + +def _no_provenance(frame: pd.DataFrame) -> TurnoverProvenance: + """All-``False`` provenance for a path where nothing can be attributed.""" + empty = pd.DataFrame(False, index=frame.index, columns=frame.columns) + return TurnoverProvenance( + turnover_actively_limited=empty, + turnover_touched=empty, + tradability_touched=empty, + tradability_compliance_limited=empty, + ) def _compliance_violations( @@ -376,13 +594,39 @@ def _assert_holdings_compliant( ) +@overload def _rebalance_tradability_aware( target_weights: pd.DataFrame, portfolio_config: PortfolioConfig, tradable: pd.DataFrame, *, calendar: str | None = None, -) -> pd.DataFrame: + episode_id: pd.DataFrame | None = None, + return_provenance: Literal[False] = False, +) -> pd.DataFrame: ... + + +@overload +def _rebalance_tradability_aware( + target_weights: pd.DataFrame, + portfolio_config: PortfolioConfig, + tradable: pd.DataFrame, + *, + calendar: str | None = None, + episode_id: pd.DataFrame | None = None, + return_provenance: Literal[True], +) -> tuple[pd.DataFrame, TurnoverProvenance]: ... + + +def _rebalance_tradability_aware( + target_weights: pd.DataFrame, + portfolio_config: PortfolioConfig, + tradable: pd.DataFrame, + *, + calendar: str | None = None, + episode_id: pd.DataFrame | None = None, + return_provenance: bool = False, +) -> pd.DataFrame | tuple[pd.DataFrame, TurnoverProvenance]: """Rebalance while respecting per-symbol tradability. A symbol that is closed on a rebalance date never trades that date; its @@ -391,6 +635,8 @@ def _rebalance_tradability_aware( executed — even if that takes several sessions under a turnover cap. A symbol that is always tradable is completely unaffected: its cadence (rebalance-date-only catch-up) is byte-identical to :func:`cap_turnover`. + + See :func:`cap_turnover` for ``episode_id``/``return_provenance``. """ validated = validate_frame( target_weights, name="target_weights", require_datetime_index=True @@ -455,8 +701,33 @@ def _rebalance_tradability_aware( pending_target = np.zeros(column_count, dtype=float) pending_due_to_closure = np.zeros(column_count, dtype=bool) + # Always bound with cheap placeholders, even though they are only ever + # read (below and by the caller) under `if return_provenance:` -- the + # same unchanged flag that guards their real assignment. A static + # analyzer cannot follow "guarded by the same boolean flag" across the + # loop in between; this changes no behaviour. + episode_values = np.empty((row_count, column_count), dtype=float) + pending_turnover_episode_id = np.full(column_count, -1.0) + actively_limited_out = np.zeros((row_count, column_count), dtype=bool) + turnover_touched_out = np.zeros((row_count, column_count), dtype=bool) + tradability_touched_out = np.zeros((row_count, column_count), dtype=bool) + compliance_limited_out = np.zeros((row_count, column_count), dtype=bool) + pending_before = np.zeros(column_count, dtype=bool) + if return_provenance: + if episode_id is None: + raise BacktestError( + "episode_id is required when return_provenance is True." + ) + episode_values = ( + validate_frame(episode_id, name="episode_id") + .reindex(index=validated.index, columns=validated.columns) + .to_numpy(dtype=float) + ) + for row_number in range(row_count): row_tradable = tradable_np[row_number] + if return_provenance: + pending_before = pending_due_to_closure.copy() if is_rebalance_date[row_number]: target_row = targets[row_number] _validate_target_row_compliant( @@ -510,9 +781,58 @@ def _rebalance_tradability_aware( pending_due_to_closure | (eligible & unresolved & compliance_limited) ) & unresolved + if return_provenance: + changed_this_row = np.abs(change) > EPSILON + # tradability: this row's move is (at least partly) a catch-up + # of a delta previously blocked by a closure (pending_before), + # or a feasibility limit reached only because another column + # stayed frozen by a closure (compliance_limited, proven in + # _max_feasible_fraction's own docstring to be a tradability + # artifact, never a turnover-budget one). + tradability_touched_out[row_number] = changed_this_row & ( + pending_before | compliance_limited + ) + compliance_limited_out[row_number] = changed_this_row & compliance_limited + # turnover: independent of tradability, scoped to the same + # episode-id convention as cap_turnover. `~pending_before` + # avoids double-counting a cell whose shortfall this row is + # already explained by tradability's own domain. + generation = episode_values[row_number] + debt_still_relevant = ( + (pending_turnover_episode_id != -1.0) + & (pending_turnover_episode_id == generation) + & ~pending_before + ) + row_turnover_limited = fraction_from_turnover < 1.0 - EPSILON + actively_limited_out[row_number] = changed_this_row & row_turnover_limited + turnover_touched_out[row_number] = changed_this_row & ( + row_turnover_limited | debt_still_relevant + ) + still_outstanding_turnover = eligible & unresolved + pending_turnover_episode_id = np.where( + still_outstanding_turnover, generation, -1.0 + ) + output[row_number] = current previous = current - return pd.DataFrame(output, index=validated.index, columns=validated.columns) + result = pd.DataFrame(output, index=validated.index, columns=validated.columns) + if not return_provenance: + return result + provenance = TurnoverProvenance( + turnover_actively_limited=pd.DataFrame( + actively_limited_out, index=validated.index, columns=validated.columns + ), + turnover_touched=pd.DataFrame( + turnover_touched_out, index=validated.index, columns=validated.columns + ), + tradability_touched=pd.DataFrame( + tradability_touched_out, index=validated.index, columns=validated.columns + ), + tradability_compliance_limited=pd.DataFrame( + compliance_limited_out, index=validated.index, columns=validated.columns + ), + ) + return result, provenance def _rebalance_mask( diff --git a/src/quantlab/reporting/__init__.py b/src/quantlab/reporting/__init__.py index 13f02cc..3a7eac7 100644 --- a/src/quantlab/reporting/__init__.py +++ b/src/quantlab/reporting/__init__.py @@ -8,6 +8,7 @@ save_figures, ) from quantlab.reporting.html_report import render_html_report +from quantlab.reporting.sections import DiagnosticsSection from quantlab.reporting.tables import ( gross_net_table, metrics_table, @@ -17,6 +18,7 @@ ) __all__ = [ + "DiagnosticsSection", "equity_and_drawdown_figure", "gross_net_table", "metrics_table", diff --git a/src/quantlab/reporting/charts.py b/src/quantlab/reporting/charts.py index e0f928e..9fa862b 100644 --- a/src/quantlab/reporting/charts.py +++ b/src/quantlab/reporting/charts.py @@ -26,6 +26,7 @@ if TYPE_CHECKING: from quantlab.backtesting.result import BacktestResult + from quantlab.features.pairs_diagnostics import PairDiagnostics logger = get_logger(__name__) @@ -248,6 +249,90 @@ def sensitivity_heatmap_chart( return fig +def correlation_heatmap_chart(matrix: pd.DataFrame) -> Figure: + """Plot a symbol x symbol correlation matrix as a static heatmap. + + Mirrors the dashboard's interactive Plotly heatmap + (``dashboard.explorer.shared_components.render_correlation_matrix``) + in a static form for the HTML report. + """ + width = max(4.0, 0.6 * len(matrix.columns) + 2.0) + height = max(3.0, 0.6 * len(matrix.index) + 1.0) + fig, ax = _new_figure((width, height)) + values = matrix.to_numpy(dtype=float) + image = ax.imshow( + values, cmap=colormaps["RdBu"], vmin=-1.0, vmax=1.0, aspect="auto" + ) + ax.set_xticks(range(len(matrix.columns))) + ax.set_xticklabels([str(c) for c in matrix.columns], rotation=45, ha="right") + ax.set_yticks(range(len(matrix.index))) + ax.set_yticklabels([str(r) for r in matrix.index]) + ax.set_title("Correlation matrix (of returns)", fontsize=11, fontweight="bold") + fig.colorbar(image, ax=ax, fraction=0.025, pad=0.02) + for row in range(values.shape[0]): + for column in range(values.shape[1]): + value = values[row, column] + colour = "white" if abs(value) > 0.6 else "#111827" + ax.text( + column, + row, + f"{value:.2f}", + ha="center", + va="center", + fontsize=7, + color=colour, + ) + return fig + + +def pair_spread_chart(diagnostics: PairDiagnostics) -> Figure: + """Plot a pair's spread, indicator and rolling stationarity p-value. + + The three panels answer, respectively: what does the residual look + like, how far is it currently from its own recent behaviour (per + ``diagnostics.indicator`` -- zscore, rsi or percentile, whichever the + pair was actually diagnosed with), and has the relationship stayed + stationary throughout the sample rather than only when tested once + over the whole history (see ``PairDiagnostics.rolling_adf_pvalue``). + """ + fig = Figure(figsize=(9, 7.5)) + FigureCanvasAgg(fig) + ax_spread, ax_indicator, ax_pvalue = fig.subplots(3, 1, sharex=True) + + spread = diagnostics.spread + ax_spread.plot(spread.index, spread.to_numpy(), color=STRATEGY, lw=1.2) + ax_spread.axhline(0.0, color=_GRID, lw=1.0) + _style_axes( + ax_spread, f"{diagnostics.symbol_a}/{diagnostics.symbol_b} spread", "Spread" + ) + + indicator = diagnostics.spread_indicator + indicator_label = f"{diagnostics.indicator} indicator" + ax_indicator.plot(indicator.index, indicator.to_numpy(), color=ACCENT, lw=1.2) + ax_indicator.axhline(0.0, color=_GRID, lw=1.0) + _style_axes(ax_indicator, f"Spread {indicator_label}", indicator_label) + + pvalue = diagnostics.rolling_adf_pvalue.dropna() + if len(pvalue): + ax_pvalue.plot( + pvalue.index, pvalue.to_numpy(), color=NEGATIVE, marker="o", ms=3, lw=1.0 + ) + else: + ax_pvalue.text( + 0.5, + 0.5, + "Not enough history for a rolling stationarity check.", + ha="center", + va="center", + transform=ax_pvalue.transAxes, + color=BENCHMARK, + ) + ax_pvalue.axhline(0.05, color=_GRID, lw=1.0, ls="--") + _style_axes(ax_pvalue, "Rolling ADF p-value (stability over time)", "p-value") + fig.tight_layout() + return fig + + def adaptive_rolling_window(n_observations: int) -> int: """Shrink the rolling Sharpe/volatility window for short samples. diff --git a/src/quantlab/reporting/html_report.py b/src/quantlab/reporting/html_report.py index cadf2b3..d82ca2a 100644 --- a/src/quantlab/reporting/html_report.py +++ b/src/quantlab/reporting/html_report.py @@ -14,9 +14,11 @@ import numpy as np import pandas as pd +from quantlab.backtesting.trade_log import stop_loss_take_profit_trigger_counts from quantlab.logging_config import get_logger from quantlab.reporting import research_summary as rs from quantlab.reporting.charts import report_figures +from quantlab.reporting.sections import DiagnosticsSection from quantlab.reporting.tables import gross_net_table, metrics_table, subperiod_table if TYPE_CHECKING: @@ -104,7 +106,17 @@ def _finite_number(value: object) -> float | None: def _format_cell(value: object, column: object) -> object: if value is None or value is pd.NA or value is pd.NaT: return "n/a" - if isinstance(value, Real) and _finite_number(value) is None: + # A bool IS a `Real` (bool subclasses int in Python), but it is always + # finite and displayable as-is ("True"/"False") -- excluding it here + # from the "not finite -> n/a" fallback is a DIFFERENT concern from + # `_finite_number`'s own bool exclusion below (scoped to designated + # percent/number/integer columns only, where a stray bool genuinely + # should not be formatted as 0.00/1.00). + if ( + isinstance(value, Real) + and not isinstance(value, (bool, np.bool_)) + and _finite_number(value) is None + ): return "n/a" key = _normalise_label(column) if key not in _PERCENT_COLUMNS | _NUMBER_COLUMNS | _INTEGER_COLUMNS: @@ -187,7 +199,37 @@ def image(name: str, alt: str) -> str: limitations_html = "".join( f"
  • {html.escape(item)}
  • " for item in rs.limitations(result) ) - robustness_html = _render_robustness(robustness, warnings) + # A strategy's own results diagnostics (e.g. pairs trading's + # correlation/spread/ADF section) describe whether the strategy's + # ASSUMPTIONS hold on this data -- distinct from Robustness, which + # tests whether the RESULT survives cost/parameter/regime perturbation. + # Split them out of the same merged dict into their own section + # (rendered via `_render_strategy_diagnostics`) rather than nesting a + # correlation table or a full-sample ADF test under "Robustness". + diagnostics_sections = { + key: value + for key, value in (robustness or {}).items() + if isinstance(value, DiagnosticsSection) + } + robustness_only = { + key: value + for key, value in (robustness or {}).items() + if key not in diagnostics_sections + } + strategy_diagnostics_html = _render_strategy_diagnostics(diagnostics_sections) + trigger_counts = stop_loss_take_profit_trigger_counts(result.trades) + stop_loss_take_profit_html = ( + f"

    Stop-loss triggered on {trigger_counts['stop_loss']} " + "symbol-position(s); take-profit on " + f"{trigger_counts['take_profit']} symbol-position(s), " + "counted per trade-log row -- a declared multi-symbol position " + "(e.g. a hedge with more than one leg) that force-flattens " + "contributes one row per leg, so this is not necessarily the " + "count of distinct stop-loss/take-profit EVENTS.

    " + if trigger_counts["stop_loss"] or trigger_counts["take_profit"] + else "" + ) + robustness_html = _render_robustness(robustness_only, warnings) data_quality_html = _render_data_quality(result.metadata.get("data_quality")) # A walk-forward OOS result's `metrics` *are* the stitched out-of-sample # series (see WalkForwardValidator._build_oos_result) — labelling the @@ -265,8 +307,15 @@ def image(name: str, alt: str) -> str: {image("exposure", "Exposure")} {image("cumulative_costs", "Cumulative costs")} {image("returns_distribution", "Return distribution")} + {stop_loss_take_profit_html} + { + f"

    Strategy diagnostics

    {strategy_diagnostics_html}
    " + if diagnostics_sections + else "" + } +

    Robustness

    {robustness_html}

    Limitations

      {limitations_html}
    @@ -328,26 +377,58 @@ def _render_sensitivity_heatmap( return f'Parameter sensitivity heatmap' +def _render_strategy_diagnostics(sections: Mapping[str, DiagnosticsSection]) -> str: + """Render each strategy-declared results diagnostic in its own subsection. + + Kept structurally separate from :func:`_render_robustness` -- a + correlation, spread or full-sample ADF test describes whether the + STRATEGY's own assumptions hold on this data, not evidence the backtest + RESULT is robust to cost/parameter/regime perturbation, so it must never + appear under the "Robustness" heading. Dispatched purely by the type of + each ``robustness`` dict value (see :func:`render_html_report`), never + by strategy name -- a future profile's own section needs no change here. + """ + parts: list[str] = [] + for key, section in sections.items(): + heading = str(key).replace("_", " ").title() + parts.append(f"

    {html.escape(heading)}

    ") + if section.note: + parts.append(f"

    {html.escape(section.note)}

    ") + if section.chart_data_uri: + parts.append(f'') + parts.append(_table_html(_format_report_table(section.table))) + return "".join(parts) + + def _render_robustness( robustness: dict[str, Any] | None, warnings: list[str] | None = None ) -> str: - """Render supplied validation artefacts or explain how to generate them.""" + """Render supplied validation artefacts or explain how to generate them. + + Never receives a `DiagnosticsSection` value -- `render_html_report()` + filters those into `_render_strategy_diagnostics` before calling this. + """ if not robustness: return ( "

    No robustness evidence is attached to this run.

    " "
      " "
    • quantlab walk-forward — out-of-sample folds plus " "stress-test evidence.
    • " - "
    • quantlab.validation.parameter_sensitivity." - "run_parameter_sensitivity — stability across parameter " - "choices (Python API).
    • " - "
    • quantlab.validation.bootstrap.bootstrap_returns " - "— resampled return-path uncertainty (Python API).
    • " - "
    • quantlab.validation.robustness.monte_carlo_permutation" - " — significance against a random-sign null (Python API).
    • " + "
    • quantlab stress-test — cost/delay/universe " + "perturbations.
    • " + "
    • quantlab sensitivity — stability across " + "parameter choices.
    • " + "
    • quantlab bootstrap — resampled return-path " + "uncertainty.
    • " + "
    • quantlab permutation-test — significance " + "against a random-sign null.
    • " + "
    • quantlab robustness — runs every technique " + "enabled under a config's robustness: block in one " + "pass.
    • " "
    " "

    See notebooks/05_robustness_analysis.ipynb for " - "a worked example of all four.

    " + "a worked example, or the Python API " + "(quantlab.validation.*) to call these directly.

    " ) parts: list[str] = [] for key, value in robustness.items(): @@ -360,6 +441,17 @@ def _render_robustness( "low p-value is evidence against that specific null, not a " "probability of future profitability.

    " ) + elif key == "bootstrap": + parts.append( + "

    p_lower/p_upper are the boundaries of a central " + "percentile interval over resampled histories (width set by " + "robustness.bootstrap.confidence_level; 0.90 -> the 5th/95th " + "percentiles).

    " + ) + if isinstance(value, pd.DataFrame) and len(value): + from quantlab.reporting.tables import format_bootstrap_summary + + value = format_bootstrap_summary(value) elif key == "sensitivity" and isinstance(value, pd.DataFrame) and len(value): parts.append(_render_sensitivity_heatmap(value, warnings)) parts.append(_render_robustness_value(value)) diff --git a/src/quantlab/reporting/research_summary.py b/src/quantlab/reporting/research_summary.py index 516e193..7d1b7e0 100644 --- a/src/quantlab/reporting/research_summary.py +++ b/src/quantlab/reporting/research_summary.py @@ -123,8 +123,15 @@ def executive_summary(result: BacktestResult) -> str: def research_question(result: BacktestResult) -> str: - """Return a question that names only configured and attached evidence.""" + """Return a question that names only configured and attached evidence. + + Returns ``result.config.research_question`` verbatim when set -- + every experiment that doesn't set it keeps the auto-generated text + below unchanged. + """ cfg = result.config + if cfg.research_question is not None: + return cfg.research_question strategy = _actually_used(result, "strategy", cfg.strategy_name) portfolio = cfg.portfolio volatility_targeted = ( @@ -173,7 +180,14 @@ def research_question(result: BacktestResult) -> str: def hypothesis(result: BacktestResult) -> str: - """Return H1/H0 and state whether OOS evidence is attached to the run.""" + """Return H1/H0 and state whether OOS evidence is attached to the run. + + Returns ``result.config.hypothesis`` verbatim when set -- every + experiment that doesn't set it keeps the auto-generated text below + unchanged. + """ + if result.config.hypothesis is not None: + return result.config.hypothesis oos = _oos_metrics(result) oos_status = ( f"attached to this run: {oos[1]}." @@ -259,7 +273,7 @@ def _portfolio_methodology(result: BacktestResult) -> str: (portfolio.target_minimum_weight, "target minimum weight", ".2%"), (portfolio.maximum_gross_exposure, "maximum gross exposure", ".2f"), (portfolio.maximum_net_exposure, "maximum absolute net exposure", ".2f"), - (portfolio.maximum_turnover, "maximum L1 turnover per rebalance", ".2f"), + (portfolio.maximum_turnover, "maximum L1 turnover per period", ".2f"), ) for value, label, spec in optional: if value is not None: diff --git a/src/quantlab/reporting/sections.py b/src/quantlab/reporting/sections.py new file mode 100644 index 0000000..36fde23 --- /dev/null +++ b/src/quantlab/reporting/sections.py @@ -0,0 +1,28 @@ +"""Generic, strategy-agnostic containers for an extra HTML report section. + +A strategy profile that declares its own :class:`~quantlab.dashboard. +explorer.profile.ResultsDiagnostics` builds one of these to describe its +report section; ``html_report.py`` renders it by type, never by strategy +name, so a future strategy can add its own report section without any +change to the report renderer. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import pandas as pd + + +@dataclass(frozen=True) +class DiagnosticsSection: + """One report section: a table, an optional chart, an optional note. + + ``chart_data_uri`` is a ready-to-embed ``data:image/...;base64,...`` + string (see ``reporting.charts.fig_to_base64``). ``note`` is plain + text (escaped at render time, never treated as markup). + """ + + table: pd.DataFrame + chart_data_uri: str | None = None + note: str | None = None diff --git a/src/quantlab/reporting/tables.py b/src/quantlab/reporting/tables.py index 0ea5935..639e3f3 100644 --- a/src/quantlab/reporting/tables.py +++ b/src/quantlab/reporting/tables.py @@ -20,6 +20,7 @@ if TYPE_CHECKING: from quantlab.backtesting.result import BacktestResult + from quantlab.features.pairs_diagnostics import PairDiagnostics _METRIC_FORMAT = { @@ -38,7 +39,7 @@ "kurtosis": ("Kurtosis", "num"), "annual_turnover": ("Annual turnover (x/year)", "num"), "average_gross_exposure": ("Avg gross exposure (x)", "num"), - "number_of_trades": ("Number of trades", "int"), + "number_of_trades": ("Number of fills", "int"), "beta": ("Beta", "num"), "alpha": ("Alpha (ann.)", "pct"), "information_ratio": ("Information ratio", "num"), @@ -221,3 +222,67 @@ def regime_table( row["Observations"] = len(regime_returns) rows.append(row) return pd.DataFrame(rows) + + +#: Formatting kind for each statistic name BootstrapResult.summary() reports. +_BOOTSTRAP_STATISTIC_FORMAT: dict[str, tuple[str, str]] = { + "cagr": ("CAGR", "pct"), + "sharpe": ("Sharpe", "num"), + "max_drawdown": ("Max Drawdown", "pct"), + "final_value": ("Final Value", "currency"), +} + + +def pair_diagnostics_summary_table(diagnostics: PairDiagnostics) -> pd.DataFrame: + """Metric/Value summary table for a pairs-trading result's diagnostics. + + A snapshot only -- the full spread/indicator/rolling-stability history + lives in the accompanying chart (``reporting.charts.pair_spread_ + chart``), not in this table. + """ + adf = diagnostics.adf_result + coint = diagnostics.cointegration_result + rows = [ + ("Symbols", f"{diagnostics.symbol_a} / {diagnostics.symbol_b}"), + ("Return correlation", _fmt(diagnostics.correlation, "num")), + ( + "Hedge-ratio stability (std of beta)", + _fmt(diagnostics.hedge_ratio_stability, "num"), + ), + ("Half-life (periods)", _fmt(diagnostics.half_life, "num")), + ("ADF statistic (spread)", _fmt(adf.statistic, "num") if adf else "n/a"), + ("ADF p-value (spread)", _fmt(adf.pvalue, "num") if adf else "n/a"), + ( + "Engle-Granger statistic", + _fmt(coint.statistic, "num") if coint else "n/a", + ), + ("Engle-Granger p-value", _fmt(coint.pvalue, "num") if coint else "n/a"), + ] + return pd.DataFrame(rows, columns=["Metric", "Value"]) + + +def format_bootstrap_summary(summary: pd.DataFrame) -> pd.DataFrame: + """Format ``BootstrapResult.summary()`` for display. + + Its ``mean``/``median``/``std``/``p_lower``/``p_upper`` columns stack + values of very different scale across rows -- a CAGR near 0.05 next to + a final value near 100000 -- because each row is a different + statistic sharing the same generic columns. Pandas' default float + repr renders that mix inconsistently (scientific notation for some + cells, fixed-point for others, depending on each cell's own + magnitude). Formatting every cell in a row by its own statistic's + kind -- the same percent/number/currency convention ``metrics_table`` + uses -- keeps the whole table in fixed-point notation regardless of + what the other rows contain. + """ + numeric_columns = [column for column in summary.columns if column != "statistic"] + rows = [] + for _, row in summary.iterrows(): + label, kind = _BOOTSTRAP_STATISTIC_FORMAT.get( + row["statistic"], (str(row["statistic"]), "num") + ) + formatted_row: dict[str, object] = {"statistic": label} + for column in numeric_columns: + formatted_row[column] = _fmt(row[column], kind) + rows.append(formatted_row) + return pd.DataFrame(rows, columns=["statistic", *numeric_columns]) diff --git a/src/quantlab/strategies/base.py b/src/quantlab/strategies/base.py index 69a97b9..2392c04 100644 --- a/src/quantlab/strategies/base.py +++ b/src/quantlab/strategies/base.py @@ -8,20 +8,75 @@ import typing from abc import ABC, abstractmethod from collections.abc import Callable, Mapping +from dataclasses import dataclass from numbers import Integral, Real -from typing import Any, TypeVar +from typing import Any, ClassVar, TypeVar import numpy as np import pandas as pd from quantlab.data.base import price_matrix from quantlab.exceptions import StrategyError -from quantlab.features._validation import numeric_pandas +from quantlab.features._validation import finite_real, numeric_pandas + +#: Accepted values for every strategy's ``price_type`` constructor +#: parameter (see ``BaseStrategy._prices()``). +PRICE_TYPES = frozenset({"adjusted_close", "close"}) _REGISTRY: dict[str, type[BaseStrategy]] = {} _StrategyT = TypeVar("_StrategyT", bound="BaseStrategy") +class UnsetType: + """Sentinel distinguishing "not passed" from an explicit ``None``. + + Used for an optional, indicator/threshold-dependent constructor + parameter (e.g. `MeanReversionStrategy`/`PairsTradingStrategy`'s + ``stop_threshold``) whose sensible default depends on another + parameter chosen in the SAME call (e.g. ``indicator``) and therefore + cannot be a plain literal default. If ``None`` were the parameter's + own default, "not passed" and "explicitly disabled" would collapse to + the same value, making "disable this" inexpressible. `UNSET` is the + constructor's actual default instead: `UNSET` resolves to whatever + indicator-specific default applies, while an explicit ``None`` is + respected as "disabled". + """ + + def __repr__(self) -> str: + """Return a short, unambiguous debug representation.""" + return "" + + +UNSET = UnsetType() + + +def validate_risk_control_parameters( + stop_loss_pct: object, take_profit_pct: object +) -> tuple[float | None, float | None]: + """Validate a strategy's ``stop_loss_pct``/``take_profit_pct`` constructor pair. + + Shared by every strategy that accepts these two (rather than each + duplicating the same two ``finite_real`` calls) -- both are optional, + strictly positive fractions (e.g. ``0.10`` = 10%) with no relational + constraint between them (unlike ``entry``/``exit``/``stop``, a + stop-loss and a take-profit are independent conditions on opposite + sides of zero return, not points on the same ordered scale). + """ + validated_stop_loss = ( + None + if stop_loss_pct is None + else finite_real(stop_loss_pct, name="stop_loss_pct", minimum=0.0, strict=True) + ) + validated_take_profit = ( + None + if take_profit_pct is None + else finite_real( + take_profit_pct, name="take_profit_pct", minimum=0.0, strict=True + ) + ) + return validated_stop_loss, validated_take_profit + + def _registry_name(name: object) -> str: if not isinstance(name, str) or not name.strip(): raise StrategyError("Strategy name must be a non-empty string.") @@ -107,19 +162,28 @@ def strategy_sweepable_parameter_names(name: str) -> set[str]: (comma-separated) are easy to misparse as an int/str instead of a bool. ``default_parameter_grid`` already treats these as fixed, not swept, for walk-forward's own default grid — sensitivity applies the same rule. + + Also excludes ``cls.deprecated_parameter_names`` (a strategy's own + deprecated backward-compatible aliases for a renamed parameter, if + any are currently registered): offering both a deprecated alias and + its canonical replacement as independent sweep axes would let a sweep + set the alias to a value that conflicts with the canonical name + already fixed elsewhere in the same config. """ registry_name = _registry_name(name) if registry_name not in _REGISTRY: raise StrategyError( f"Unknown strategy '{registry_name}'. Registered: {sorted(_REGISTRY)}." ) - signature = inspect.signature(_REGISTRY[registry_name].__init__) + strategy_class = _REGISTRY[registry_name] + signature = inspect.signature(strategy_class.__init__) return { parameter.name for parameter in signature.parameters.values() if parameter.name != "self" and parameter.kind not in (parameter.VAR_POSITIONAL, parameter.VAR_KEYWORD) and not isinstance(parameter.default, bool) + and parameter.name not in strategy_class.deprecated_parameter_names } @@ -216,10 +280,79 @@ def validate_strategy_parameters(name: str, parameters: Mapping[str, Any]) -> No cls.validate_parameters(complete) +@dataclass(frozen=True) +class SignalReasons: + """Optional, strategy-specific explanation of ``generate_signals()``. + + Both frames must share ``generate_signals()``'s own ``dates x + symbols`` shape and index/columns exactly. ``detail_code`` is a + closed set of stable, machine-readable strings (or ``None`` where no + transition happened that date); ``details`` is optional free text + with the concrete values/thresholds involved, for human reading + only. Mirrors ``trade_log.py``'s own ``trigger_reason_code``/ + ``trigger_reason_detail_code``/``trigger_reason_details`` split one + level up: this is the strategy's own contribution to a row whose + ``trigger_reason_code == "strategy_signal"``. + """ + + detail_code: pd.DataFrame + details: pd.DataFrame + + class BaseStrategy(ABC): """Abstract base class for signal-generating strategies.""" name: str = "base" + #: Price series ``_prices()`` reads for signal generation -- + #: "adjusted_close" (default) or "close". A class attribute fallback + #: for any strategy that doesn't accept its own ``price_type`` + #: constructor parameter; every built-in strategy sets its own + #: instance attribute of the same name, validated at construction. + price_type: str = "adjusted_close" + #: Fractional (e.g. 0.10 = 10%) gross-return thresholds that force- + #: flatten this strategy's REAL executed position (see + #: `quantlab.backtesting.accounting._detect_stop_loss_take_profit`) -- + #: class attribute fallbacks (mirroring `price_type` above) so calling + #: code can read `strategy.stop_loss_pct`/`strategy.take_profit_pct` + #: uniformly across every strategy, including ones that don't accept + #: either as a constructor parameter. `None` (default) disables the + #: check entirely, with strictly no change to accounting's numbers. + stop_loss_pct: float | None = None + take_profit_pct: float | None = None + #: Per-symbol calendar names, engine-injected onto the strategy + #: instance right before `generate_signals()`/`explain_signals()`/ + #: `decision_signal()` are called (see + #: `quantlab.backtesting.engine.BacktestEngine.run`) so every rolling- + #: window feature call site can compute on each symbol's own native + #: calendar rather than a closure-padded combined timeline -- see + #: `quantlab.features.native_calendar.compute_native_then_align`. + #: Never a user-configured constructor hyperparameter: exempted from + #: both the post-construction freeze (`__setattr__` below) and + #: `parameters()` (must never appear in a config-YAML round-trip, + #: execution-model hash, or sweep-parameter enumeration -- it is + #: engine context, not a strategy parameter). `None` when the engine + #: has not injected it (e.g. a strategy constructed directly in a + #: unit test); every native-calendar call site must treat that the + #: same as "no mixed calendars", falling back to a single vectorized + #: computation. + symbol_calendars: dict[str, str] | None = None + #: Instance attributes exempted from both the freeze and `parameters()` + #: -- engine-injected context, never a real strategy parameter. + _NON_PARAMETER_ATTRIBUTES: ClassVar[frozenset[str]] = frozenset( + {"symbol_calendars"} + ) + #: Constructor keyword(s) kept only as deprecated backward-compatible + #: aliases for a renamed parameter. No built-in strategy currently + #: registers any (defaults to empty) -- kept as generic infrastructure + #: for the next time a strategy parameter is renamed. A registered + #: alias would still be fully valid to pass, and still documented via + #: `ParameterDoc` -- excluded only from + #: `strategy_sweepable_parameter_names()`, so a sensitivity/robustness + #: sweep never offers BOTH an alias and its canonical name as + #: independent axes (which would let a sweep set the alias to a value + #: that conflicts with the canonical name already fixed elsewhere in + #: the same config). + deprecated_parameter_names: ClassVar[frozenset[str]] = frozenset() @classmethod def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: @@ -233,13 +366,91 @@ def generate_signals( """Return a ``dates x symbols`` signal matrix in ``[-1, 1]``.""" raise NotImplementedError - @staticmethod - def _prices(data: pd.DataFrame) -> pd.DataFrame: - """Return a finite, positive adjusted-close matrix.""" - prices = price_matrix(data, adjusted=True) + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons | None: + """Optionally explain WHY ``generate_signals()`` changed its output. + + Must be a pure function of exactly the same ``data``/``features`` + given to ``generate_signals()`` -- no information unavailable at + each row's own date (no look-ahead), and no dependency on state + left over from a prior call. The default implementation returns + ``None``, meaning no strategy-specific attribution is available; + callers must treat that as "not analyzed", not as "unknown"/"no + reason" -- the generic ``strategy_signal`` reason still applies. + """ + return None + + def decision_signal( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> pd.DataFrame | None: + """Optional diagnostic representation of the strategy's decision state. + + Used ONLY for trade-reason attribution (trigger detection: "did + the strategy make a new decision since the last rebalance") and + strategic-origin tracking -- never a substitute for + ``generate_signals()``'s own output in sizing, allocation, + constraints, execution, PnL or cost calculations. Must be a pure + function of exactly the same ``data``/``features`` given to + ``generate_signals()`` (same no-look-ahead, no-leftover-state + contract as :meth:`explain_signals`). + + The default (``None``) means ``generate_signals()``'s own output + is already a faithful decision proxy -- true for every built-in + strategy except :class:`~quantlab.strategies.pairs_trading. + PairsTradingStrategy`, whose raw signal mixes a discrete decision + state with purely mechanical rescaling (price/beta/normalization) + that a plain "did the signal change" comparison cannot tell + apart from a real new decision. Overriding this is an exception, + not the norm: a strategy whose signal is itself the decision + (the common case, including every strategy with a continuous or + volatility-adjusted signal, where a magnitude change legitimately + IS a new sizing decision) must leave this at its default. + + When overridden, the returned frame must share ``generate_ + signals()``'s own ``dates x symbols`` shape, index and columns + exactly, contain only finite numeric values (no NaN/Infinity -- + raises :class:`~quantlab.exceptions.StrategyError` otherwise, + never silently coerced or dropped), and is never reindexed by a + caller to "align" a mismatched shape -- a caller that receives a + mismatched frame must raise, not guess an alignment that could + introduce a temporal offset. + """ + return None + + def position_groups(self) -> tuple[tuple[str, ...], ...] | None: + """Optional: symbol columns whose COMBINED P&L is one logical position. + + Used ONLY by a stop-loss/take-profit check (see + :func:`quantlab.backtesting.accounting._detect_stop_loss_take_profit`) + to decide whether a group of columns should be force-flattened + together based on their combined realized return, rather than + each column's own return independently. The default (``None``) + means every symbol is its own independent group -- correct for + every built-in strategy except :class:`~quantlab.strategies. + pairs_trading.PairsTradingStrategy`, whose two legs (``symbol_a``/ + ``symbol_b``) form one economic position that a per-leg check + would evaluate incorrectly (e.g. treating a leg that moves + against the pair's own net P&L as a standalone loss). A symbol + never mentioned in any returned group is still its own + independent group -- this need not enumerate every column. + """ + return None + + def _prices(self, data: pd.DataFrame) -> pd.DataFrame: + """Return a finite, positive price matrix at ``self.price_type``.""" + if self.price_type not in PRICE_TYPES: + raise StrategyError( + f"Unknown price_type {self.price_type!r}; expected one of " + f"{sorted(PRICE_TYPES)}." + ) + adjusted = self.price_type == "adjusted_close" + prices = price_matrix(data, adjusted=adjusted) try: validated = numeric_pandas( - prices, name="adjusted-close prices", strictly_positive=True + prices, + name=f"{self.price_type.replace('_', '-')} prices", + strictly_positive=True, ) except (TypeError, ValueError) as exc: raise StrategyError(str(exc)) from exc @@ -249,6 +460,26 @@ def _prices(data: pd.DataFrame) -> pd.DataFrame: ) return validated.astype(float) + def _native_feature( + self, + prices: pd.DataFrame, + compute_fn: Callable[[pd.DataFrame], pd.DataFrame], + ) -> pd.DataFrame: + """Compute a rolling-window feature on each symbol's own calendar. + + Uses ``self.symbol_calendars``, aligned back onto + ``prices.index`` -- see + :func:`quantlab.features.native_calendar.compute_native_then_align`. + ``self.symbol_calendars is None`` (not engine-injected, e.g. a + strategy constructed directly in a unit test) short-circuits to + calling ``compute_fn(prices)`` directly. + """ + from quantlab.features.native_calendar import compute_native_then_align + + return compute_native_then_align( + compute_fn, prices, self.symbol_calendars, pd.DatetimeIndex(prices.index) + ) + @staticmethod def _validate_signals( signals: pd.DataFrame, reference: pd.DataFrame | None = None @@ -273,13 +504,91 @@ def _validate_signals( raise StrategyError("Finite signals must remain within [-1, 1].") return validated.fillna(0.0).astype(float) + @staticmethod + def _validate_decision_signal( + decision: pd.DataFrame, reference: pd.DataFrame + ) -> pd.DataFrame: + """Validate a :meth:`decision_signal` result against its reference. + + Stricter than :meth:`_validate_signals`: NaN/Infinity are always + an error (never silently filled), and axes must match ``reference`` + exactly -- no reindexing, which could otherwise mask a temporal + misalignment (look-ahead) between the decision frame and the + signal it is meant to diagnose. + """ + if not isinstance(decision, pd.DataFrame): + raise StrategyError("decision_signal() must return a pandas DataFrame.") + if not decision.index.equals(reference.index) or not decision.columns.equals( + reference.columns + ): + raise StrategyError( + "decision_signal() index and columns must exactly match " + "generate_signals()'s own output." + ) + try: + values = decision.to_numpy(dtype=float) + except (TypeError, ValueError) as exc: + raise StrategyError( + "decision_signal() must contain only numeric values." + ) from exc + if not np.isfinite(values).all(): + raise StrategyError("decision_signal() must not contain NaN or Infinity.") + return decision.astype(float) + + @staticmethod + def _validate_signal_reasons( + detail_code: pd.DataFrame, details: pd.DataFrame, reference: pd.DataFrame + ) -> SignalReasons: + """Validate an ``explain_signals()`` result against its reference. + + ``reference`` is whatever axes ``generate_signals()`` itself used + (its own price/signal matrix) -- both frames must match exactly, + the same requirement ``_validate_signals`` already enforces for + the numeric signal matrix. + """ + normalized: dict[str, pd.DataFrame] = {} + for frame, name in ((detail_code, "detail_code"), (details, "details")): + if not isinstance(frame, pd.DataFrame): + raise StrategyError(f"SignalReasons.{name} must be a pandas DataFrame.") + if not frame.index.equals(reference.index) or not frame.columns.equals( + reference.columns + ): + raise StrategyError( + f"SignalReasons.{name} index and columns must exactly match " + "the price matrix." + ) + # Normalize to plain object dtype with real None for missing + # cells -- assigning a None/str column into a DataFrame can + # get silently promoted to pandas' StringDtype, whose missing + # marker is NaN rather than None (bites even careful callers, + # not just naive ones), and downstream code (engine.py, + # trade_log.py) relies on a strict `is None` check. + frame = frame.astype(object).where(frame.notna(), None) + bad = frame.map( + lambda value: value is not None and not isinstance(value, str) + ) + if bad.to_numpy().any(): + raise StrategyError(f"SignalReasons.{name} values must be str or None.") + normalized[name] = frame + return SignalReasons( + detail_code=normalized["detail_code"], details=normalized["details"] + ) + def _freeze_parameters(self) -> None: object.__setattr__(self, "_strategy_parameters_frozen", True) def __setattr__(self, name: str, value: object) -> None: - """Prevent public parameter mutation after construction.""" - if getattr(self, "_strategy_parameters_frozen", False) and not name.startswith( - "_" + """Prevent public parameter mutation after construction. + + ``_NON_PARAMETER_ATTRIBUTES`` (e.g. ``symbol_calendars``) is + exempted: engine-injected context set on the instance after + construction, not a user-supplied hyperparameter the freeze is + meant to protect. + """ + if ( + getattr(self, "_strategy_parameters_frozen", False) + and not name.startswith("_") + and name not in self._NON_PARAMETER_ATTRIBUTES ): raise AttributeError( "Strategy parameters are immutable after construction." @@ -289,7 +598,11 @@ def __setattr__(self, name: str, value: object) -> None: def parameters(self) -> dict[str, Any]: """Return a defensive copy of public strategy parameters.""" return copy.deepcopy( - {key: value for key, value in vars(self).items() if not key.startswith("_")} + { + key: value + for key, value in vars(self).items() + if not key.startswith("_") and key not in self._NON_PARAMETER_ATTRIBUTES + } ) def __repr__(self) -> str: diff --git a/src/quantlab/strategies/buy_and_hold.py b/src/quantlab/strategies/buy_and_hold.py index b005ede..9c0bf5c 100644 --- a/src/quantlab/strategies/buy_and_hold.py +++ b/src/quantlab/strategies/buy_and_hold.py @@ -2,9 +2,19 @@ from __future__ import annotations +from collections.abc import Mapping +from typing import Any + +import numpy as np import pandas as pd -from quantlab.strategies.base import BaseStrategy, register_strategy +from quantlab.features._validation import choice +from quantlab.strategies.base import ( + PRICE_TYPES, + BaseStrategy, + SignalReasons, + register_strategy, +) @register_strategy("buy_and_hold") @@ -15,9 +25,20 @@ class BuyAndHoldStrategy(BaseStrategy): for multiple assets this is not necessarily a literal buy-once portfolio. """ - def __init__(self) -> None: + def __init__(self, price_type: str = "adjusted_close") -> None: + values = self.validate_parameters({"price_type": price_type}) + self.price_type = values["price_type"] self._freeze_parameters() + @classmethod + def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: + """Validate the price-type choice.""" + values = dict(parameters) + values["price_type"] = choice( + values["price_type"], name="price_type", options=PRICE_TYPES + ) + return values + def generate_signals( self, data: pd.DataFrame, features: pd.DataFrame | None = None ) -> pd.DataFrame: @@ -25,3 +46,37 @@ def generate_signals( prices = self._prices(data) signals = prices.notna().astype(float) return self._validate_signals(signals, prices) + + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons: + """Explain each 0<->1 transition as a change in price availability. + + Price availability is the ONLY thing this strategy's signal + depends on (see ``generate_signals``), so every transition is + either the first date a symbol's price becomes valid, or a later + date it stops being valid (a gap/delisting in the underlying + data). + """ + prices = self._prices(data) + available = prices.notna() + previous_available = available.shift(1, fill_value=False) + became_available = (available & ~previous_available).to_numpy() + became_unavailable = (~available & previous_available).to_numpy() + + detail_code = np.full(became_available.shape, None, dtype=object) + details = np.full(became_available.shape, None, dtype=object) + detail_code[became_available] = "price_became_available" + details[became_available] = "price became available" + detail_code[became_unavailable] = "price_became_unavailable" + details[became_unavailable] = "price became unavailable" + + return self._validate_signal_reasons( + pd.DataFrame( + detail_code, index=prices.index, columns=prices.columns, dtype=object + ), + pd.DataFrame( + details, index=prices.index, columns=prices.columns, dtype=object + ), + prices, + ) diff --git a/src/quantlab/strategies/mean_reversion.py b/src/quantlab/strategies/mean_reversion.py index 8ce2c4a..7d311a7 100644 --- a/src/quantlab/strategies/mean_reversion.py +++ b/src/quantlab/strategies/mean_reversion.py @@ -1,4 +1,25 @@ -"""Stateful mean reversion around a trailing price z-score.""" +"""Stateful mean reversion around a trailing, zero-centered indicator. + +Five interchangeable indicators (``indicator=``) can drive the same +entry/exit/stop state machine: a rolling z-score (the default), a +Bollinger-band-relative deviation (NOT the traditional [0, 1]-ranged %B -- +see ``bollinger``'s own branch in :func:`_centered_indicator`), RSI, +distance to a moving average, or a rolling percentile rank -- see +:data:`INDICATORS`. Only three (``zscore``, ``rsi``, ``percentile``) are +offered as a primary choice in the dashboard/lab UI (see +:data:`UI_INDICATORS`); ``bollinger``/``distance_ma`` remain fully valid, +tested constructor arguments for internal/research use. Every indicator is +first converted to a zero-centered series (negative = oversold/long +candidate, positive = overbought/short candidate) by +:func:`_centered_indicator`; the state machine itself +(:func:`_walk_positions_with_reasons`) only ever compares a threshold +against ``abs(value)`` on that centered series, so it is completely +indicator-agnostic. + +``stop_threshold`` is genuinely optional: pass an explicit ``None`` to +disable it entirely, or leave it unset to use the chosen indicator's +default (see :data:`INDICATOR_DEFAULT_THRESHOLDS`). +""" from __future__ import annotations @@ -8,105 +29,376 @@ import numpy as np import pandas as pd -from quantlab.features._validation import boolean, finite_real, positive_int -from quantlab.features.mean_reversion import rolling_zscore -from quantlab.strategies.base import BaseStrategy, register_strategy +from quantlab.constants import EPSILON +from quantlab.features._validation import boolean, choice, finite_real, positive_int +from quantlab.features.mean_reversion import ( + normalized_distance_to_mean, + rolling_percentile_rank, + rolling_zscore, + rsi, +) +from quantlab.features.native_calendar import compute_native_then_align +from quantlab.logging_config import get_logger +from quantlab.strategies.base import ( + PRICE_TYPES, + UNSET, + BaseStrategy, + SignalReasons, + UnsetType, + register_strategy, + validate_risk_control_parameters, +) + +logger = get_logger(__name__) + +#: Every indicator `MeanReversionStrategy` can drive its state machine from. +#: `bollinger`/`distance_ma` remain fully supported here (validated, +#: tested, computable) for internal/research use -- see `UI_INDICATORS` for +#: the narrower set actually offered as a primary choice in the dashboard +#: and Strategy Explorer lab. +INDICATORS = frozenset({"zscore", "bollinger", "rsi", "distance_ma", "percentile"}) + +#: Indicators offered as the primary choice in the dashboard sidebar and +#: the Strategy Explorer lab's indicator selector. `bollinger` is excluded +#: here -- it is `(price - rolling_mean) / (num_std * rolling_std)`, the +#: SAME rolling mean/std construction as `zscore` merely rescaled by +#: `bollinger_num_std`, so it rarely produces a meaningfully different +#: backtest. `distance_ma` is excluded for a DIFFERENT reason: it is +#: `(price - rolling_mean) / rolling_mean` -- normalized by the mean's own +#: level, with no volatility term at all -- so unlike `bollinger` it is +#: not simply a rescaled `zscore` and can diverge from it materially +#: whenever the asset's volatility regime shifts (a fixed % move away +#: from the mean reads as a smaller z-score in a high-volatility period +#: than in a low-volatility one, but reads as the same `distance_ma` +#: either way). Both stay valid, documented `INDICATORS` members and can +#: still be selected programmatically (YAML config, Python, robustness +#: sweeps) -- only the two main UI selectors are narrowed. +UI_INDICATORS: tuple[str, ...] = ("zscore", "rsi", "percentile") + +#: Sensible (entry, exit, stop) defaults per indicator, used only when the +#: caller leaves `entry_threshold`/`exit_threshold` unset, or leaves +#: `stop_threshold` at its own sentinel default (see `MeanReversionStrategy +#: .__init__`) -- each indicator's centered series has a different natural +#: scale (a z-score's few units vs. a fractional distance vs. RSI's +/-50 +#: range), so one shared default would be meaningless for at least four of +#: the five indicators. Public: the dashboard sidebar and the Strategy +#: Explorer lab both read these same numbers for their own widgets' +#: default values, rather than each hardcoding a second copy that could +#: silently drift from this one. +INDICATOR_DEFAULT_THRESHOLDS: dict[str, tuple[float, float, float]] = { + "zscore": (2.0, 0.5, 4.0), + "bollinger": (1.0, 0.2, 1.5), + "rsi": (20.0, 10.0, 45.0), + "distance_ma": (0.05, 0.01, 0.15), + "percentile": (0.45, 0.10, 0.49), +} + + +def _centered_indicator( + prices: pd.DataFrame, + indicator: str, + lookback_period: int, + bollinger_num_std: float, + symbol_calendars: Mapping[str, str] | None = None, +) -> pd.DataFrame: + """Compute ``indicator``'s zero-centered series for the state machine. + + Zero-centered: negative means below "normal" (a long/oversold + candidate), positive means above "normal" (a short/overbought + candidate) -- the same sign convention `rolling_zscore` already has, + so the entry/exit/stop machine below never needs to know which + indicator produced the series it is walking. + + ``symbol_calendars`` (the strategy's own, see `BaseStrategy.symbol_ + calendars`) routes every rolling computation through `compute_native_ + then_align` so each symbol's window is computed on its own native + session dates rather than a closure-padded combined timeline -- + `None` short-circuits to a single vectorized computation. + """ + combined_index = pd.DatetimeIndex(prices.index) + if indicator == "zscore": + return compute_native_then_align( + lambda p: rolling_zscore(p, lookback_period), + prices, + symbol_calendars, + combined_index, + ) + if indicator == "bollinger": + def _bollinger(p: pd.DataFrame) -> pd.DataFrame: + mean = p.rolling(lookback_period, min_periods=lookback_period).mean() + std = p.rolling(lookback_period, min_periods=lookback_period).std(ddof=1) + return (p - mean) / (bollinger_num_std * std + EPSILON) -def _walk_positions( + return compute_native_then_align( + _bollinger, prices, symbol_calendars, combined_index + ) + if indicator == "rsi": + return ( + compute_native_then_align( + lambda p: rsi(p, lookback_period), + prices, + symbol_calendars, + combined_index, + ) + - 50.0 + ) + if indicator == "distance_ma": + return compute_native_then_align( + lambda p: normalized_distance_to_mean(p, lookback_period), + prices, + symbol_calendars, + combined_index, + ) + if indicator == "percentile": + return ( + compute_native_then_align( + lambda p: rolling_percentile_rank(p, lookback_period), + prices, + symbol_calendars, + combined_index, + ) + - 0.5 + ) + raise ValueError(f"Unknown indicator {indicator!r}.") # unreachable after choice() + + +def _walk_positions_with_reasons( z: np.ndarray, entry: float, exit_: float, stop: float | None, long_only: bool, -) -> np.ndarray: - """Convert z-scores into persistent positions in ``{-1, 0, 1}``.""" +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Convert a centered indicator into persistent positions in ``{-1, 0, 1}``. + + Also records, at every index where the state actually transitions, a + closed-set ``reason_detail_code`` and a human-readable ``reason_ + details`` string naming exactly which branch below fired -- computed + in the SAME pass as the position itself, so ``generate_signals()`` + and ``explain_signals()`` can never disagree about why a position + changed (both call this one function; neither reconstructs the other + separately). Indicator-agnostic: ``z`` is whatever + :func:`_centered_indicator` produced for the configured ``indicator``, + never assumed to specifically be a z-score. + """ positions = np.zeros_like(z, dtype=float) + detail_code = np.full(z.shape, None, dtype=object) + details = np.full(z.shape, None, dtype=object) state = 0.0 for index, value in enumerate(z): - if np.isnan(value) or (stop is not None and abs(value) > stop): + previous_state = state + if np.isnan(value): + state = 0.0 + if previous_state != state: + detail_code[index] = "data_unavailable_exit" + details[index] = "indicator unavailable (insufficient trailing history)" + elif stop is not None and abs(value) > stop: state = 0.0 + if previous_state != state: + signed_stop = stop if value > 0 else -stop + detail_code[index] = "stop_loss_exit" + details[index] = ( + f"indicator {value:.4f} breached stop threshold {signed_stop:.4f}" + ) elif state == 0.0: if value < -entry: state = 1.0 + detail_code[index] = "oversold_entry" + details[index] = ( + f"indicator {value:.4f} crossed entry threshold {-entry:.4f}" + ) elif value > entry and not long_only: state = -1.0 + detail_code[index] = "overbought_entry" + details[index] = ( + f"indicator {value:.4f} crossed entry threshold {entry:.4f}" + ) elif (state == 1.0 and value > -exit_) or (state == -1.0 and value < exit_): state = 0.0 + threshold = -exit_ if previous_state == 1.0 else exit_ + detail_code[index] = "mean_reversion_exit" + details[index] = ( + f"indicator {value:.4f} crossed exit threshold {threshold:.4f}" + ) positions[index] = state - return positions + return positions, detail_code, details @register_strategy("mean_reversion") class MeanReversionStrategy(BaseStrategy): - """Trade deviations from a trailing mean until exit or stop thresholds.""" + """Trade deviations from a chosen indicator until exit or stop thresholds.""" def __init__( self, lookback_period: int = 20, - entry_zscore: float = 2.0, - exit_zscore: float = 0.5, - stop_zscore: float | None = 4.0, + indicator: str = "zscore", + entry_threshold: float | None = None, + exit_threshold: float | None = None, + stop_threshold: float | UnsetType | None = UNSET, + bollinger_num_std: float = 2.0, long_only: bool = True, + price_type: str = "adjusted_close", + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, ) -> None: values = self.validate_parameters( { "lookback_period": lookback_period, - "entry_zscore": entry_zscore, - "exit_zscore": exit_zscore, - "stop_zscore": stop_zscore, + "indicator": indicator, + "entry_threshold": entry_threshold, + "exit_threshold": exit_threshold, + "stop_threshold": stop_threshold, + "bollinger_num_std": bollinger_num_std, "long_only": long_only, + "price_type": price_type, + "stop_loss_pct": stop_loss_pct, + "take_profit_pct": take_profit_pct, } ) self.lookback_period = values["lookback_period"] - self.entry_zscore = values["entry_zscore"] - self.exit_zscore = values["exit_zscore"] - self.stop_zscore = values["stop_zscore"] + self.indicator = values["indicator"] + self.entry_threshold = values["entry_threshold"] + self.exit_threshold = values["exit_threshold"] + self.stop_threshold = values["stop_threshold"] + self.bollinger_num_std = values["bollinger_num_std"] self.long_only = values["long_only"] + self.price_type = values["price_type"] + self.stop_loss_pct = values["stop_loss_pct"] + self.take_profit_pct = values["take_profit_pct"] self._freeze_parameters() @classmethod def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: - """Validate z-score windows, thresholds and direction mode.""" + """Validate the indicator choice, thresholds and direction mode.""" values = dict(parameters) values["lookback_period"] = positive_int( values["lookback_period"], name="lookback_period", minimum=2 ) - values["entry_zscore"] = finite_real( - values["entry_zscore"], name="entry_zscore", minimum=0.0, strict=True + values["indicator"] = choice( + values["indicator"], name="indicator", options=INDICATORS ) - values["exit_zscore"] = finite_real( - values["exit_zscore"], name="exit_zscore", minimum=0.0 + values["bollinger_num_std"] = finite_real( + values["bollinger_num_std"], + name="bollinger_num_std", + minimum=0.0, + strict=True, ) - if values["stop_zscore"] is not None: - values["stop_zscore"] = finite_real( - values["stop_zscore"], name="stop_zscore", minimum=0.0 - ) values["long_only"] = boolean(values["long_only"], name="long_only") - if values["entry_zscore"] <= values["exit_zscore"]: - raise ValueError("entry_zscore must exceed exit_zscore.") + values["price_type"] = choice( + values["price_type"], name="price_type", options=PRICE_TYPES + ) + values["stop_loss_pct"], values["take_profit_pct"] = ( + validate_risk_control_parameters( + values["stop_loss_pct"], values["take_profit_pct"] + ) + ) + + entry_threshold = values["entry_threshold"] + exit_threshold = values["exit_threshold"] + stop_threshold = values["stop_threshold"] + default_entry, default_exit, default_stop = INDICATOR_DEFAULT_THRESHOLDS[ + values["indicator"] + ] + if entry_threshold is None: + entry_threshold = default_entry + if exit_threshold is None: + exit_threshold = default_exit + if isinstance(stop_threshold, UnsetType): + # Not passed at all -> use this indicator's default stop. + stop_threshold = default_stop + # else: an explicit stop_threshold=None means "disabled" and is + # left as None; an explicit float is used as-is. + + values["entry_threshold"] = finite_real( + entry_threshold, name="entry_threshold", minimum=0.0, strict=True + ) + values["exit_threshold"] = finite_real( + exit_threshold, name="exit_threshold", minimum=0.0 + ) + if stop_threshold is not None: + values["stop_threshold"] = finite_real( + stop_threshold, name="stop_threshold", minimum=0.0 + ) + else: + values["stop_threshold"] = None + if values["entry_threshold"] <= values["exit_threshold"]: + raise ValueError("entry_threshold must exceed exit_threshold.") if ( - values["stop_zscore"] is not None - and values["stop_zscore"] <= values["entry_zscore"] + values["stop_threshold"] is not None + and values["stop_threshold"] <= values["entry_threshold"] ): - raise ValueError("stop_zscore must exceed entry_zscore.") + raise ValueError("stop_threshold must exceed entry_threshold.") return values def generate_signals( self, data: pd.DataFrame, features: pd.DataFrame | None = None ) -> pd.DataFrame: - """Return the trailing z-score state for every asset.""" + """Return the trailing state machine's position for every asset.""" prices = self._prices(data) - zscore = rolling_zscore(prices, self.lookback_period) + indicator = _centered_indicator( + prices, + self.indicator, + self.lookback_period, + self.bollinger_num_std, + self.symbol_calendars, + ) signals = pd.DataFrame( 0.0, index=prices.index, columns=prices.columns, dtype=float ) for symbol in prices.columns: - signals[symbol] = _walk_positions( - zscore[symbol].to_numpy(dtype=float), - entry=self.entry_zscore, - exit_=self.exit_zscore, - stop=self.stop_zscore, + positions, _, _ = _walk_positions_with_reasons( + indicator[symbol].to_numpy(dtype=float), + entry=self.entry_threshold, + exit_=self.exit_threshold, + stop=self.stop_threshold, long_only=self.long_only, ) + signals[symbol] = positions return self._validate_signals(signals, prices) + + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons: + """Explain each position transition from the same indicator walk. + + Recomputes the centered indicator and re-walks the identical + state machine used by ``generate_signals()`` (same function, same + parameters, same ``data``) -- a pure, deterministic recomputation, + never a cache of a prior call, so it can never look ahead or + drift from the positions actually produced. + """ + prices = self._prices(data) + indicator = _centered_indicator( + prices, + self.indicator, + self.lookback_period, + self.bollinger_num_std, + self.symbol_calendars, + ) + # Built as plain numpy object arrays and handed to the DataFrame + # constructor with an explicit dtype=object: assigning column by + # column (`frame[symbol] = array`) lets pandas' string-dtype + # inference silently promote a None/str object column to its new + # StringDtype, which represents "missing" as NaN instead of the + # None _validate_signal_reasons requires -- constructing the + # whole frame at once with dtype=object forced avoids that. + detail_code_values = np.empty(prices.shape, dtype=object) + details_values = np.empty(prices.shape, dtype=object) + for column_index, symbol in enumerate(prices.columns): + _, symbol_detail_code, symbol_details = _walk_positions_with_reasons( + indicator[symbol].to_numpy(dtype=float), + entry=self.entry_threshold, + exit_=self.exit_threshold, + stop=self.stop_threshold, + long_only=self.long_only, + ) + detail_code_values[:, column_index] = symbol_detail_code + details_values[:, column_index] = symbol_details + detail_code = pd.DataFrame( + detail_code_values, index=prices.index, columns=prices.columns, dtype=object + ) + details = pd.DataFrame( + details_values, index=prices.index, columns=prices.columns, dtype=object + ) + return self._validate_signal_reasons(detail_code, details, prices) diff --git a/src/quantlab/strategies/momentum.py b/src/quantlab/strategies/momentum.py index 65ac225..5fc122b 100644 --- a/src/quantlab/strategies/momentum.py +++ b/src/quantlab/strategies/momentum.py @@ -8,17 +8,23 @@ import numpy as np import pandas as pd +from quantlab.constants import EPSILON from quantlab.features._validation import ( boolean, + choice, finite_real, non_negative_int, positive_int, ) from quantlab.features.cross_sectional import select_top_bottom -from quantlab.features.momentum import momentum -from quantlab.features.returns import simple_returns -from quantlab.features.volatility import realized_volatility -from quantlab.strategies.base import BaseStrategy, register_strategy +from quantlab.features.momentum import momentum, volatility_adjusted_momentum +from quantlab.strategies.base import ( + PRICE_TYPES, + BaseStrategy, + SignalReasons, + register_strategy, + validate_risk_control_parameters, +) _TIME_SERIES_SCALINGS = frozenset({"binary", "continuous", "volatility_adjusted"}) @@ -48,6 +54,9 @@ def __init__( signal_scaling: str = "binary", volatility_window: int = 63, periods_per_year: int = 252, + price_type: str = "adjusted_close", + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, ) -> None: values = self.validate_parameters( { @@ -57,6 +66,9 @@ def __init__( "signal_scaling": signal_scaling, "volatility_window": volatility_window, "periods_per_year": periods_per_year, + "price_type": price_type, + "stop_loss_pct": stop_loss_pct, + "take_profit_pct": take_profit_pct, } ) self.lookback_period = values["lookback_period"] @@ -65,6 +77,9 @@ def __init__( self.signal_scaling = values["signal_scaling"] self.volatility_window = values["volatility_window"] self.periods_per_year = values["periods_per_year"] + self.price_type = values["price_type"] + self.stop_loss_pct = values["stop_loss_pct"] + self.take_profit_pct = values["take_profit_pct"] self._freeze_parameters() @classmethod @@ -93,6 +108,14 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: values["periods_per_year"] = positive_int( values["periods_per_year"], name="periods_per_year" ) + values["price_type"] = choice( + values["price_type"], name="price_type", options=PRICE_TYPES + ) + values["stop_loss_pct"], values["take_profit_pct"] = ( + validate_risk_control_parameters( + values["stop_loss_pct"], values["take_profit_pct"] + ) + ) return values def generate_signals( @@ -100,24 +123,38 @@ def generate_signals( ) -> pd.DataFrame: """Return per-asset trailing-momentum signals.""" prices = self._prices(data) - score = momentum(prices, self.lookback_period, self.skip_period) + score = self._native_feature( + prices, lambda p: momentum(p, self.lookback_period, self.skip_period) + ) if self.signal_scaling == "binary": signal = pd.DataFrame( np.sign(score), index=score.index, columns=score.columns ) elif self.signal_scaling == "continuous": - dispersion = score.rolling( - self.lookback_period, min_periods=min(20, self.lookback_period) - ).std(ddof=1) + dispersion = self._native_feature( + score, + lambda s: s.rolling( + self.lookback_period, min_periods=min(20, self.lookback_period) + ).std(ddof=1), + ) signal = (score / dispersion).clip(-1.0, 1.0) elif self.signal_scaling == "volatility_adjusted": - volatility = realized_volatility( - simple_returns(prices), - window=self.volatility_window, - periods_per_year=self.periods_per_year, - ) - signal = (score / volatility).clip(-1.0, 1.0) + # Delegates to the public helper (rather than recomputing + # momentum/volatility inline) so a zero-volatility window is + # masked to NaN -- never silently divided into +-inf, which + # `.clip(-1, 1)` would otherwise turn into a false +-1.0 full + # -conviction signal instead of the "no reliable read" it is. + signal = self._native_feature( + prices, + lambda p: volatility_adjusted_momentum( + p, + self.lookback_period, + self.skip_period, + self.volatility_window, + self.periods_per_year, + ), + ).clip(-1.0, 1.0) else: # pragma: no cover - constructor invariant raise RuntimeError(f"Unsupported signal scaling: {self.signal_scaling!r}.") @@ -125,10 +162,127 @@ def generate_signals( signal = signal.clip(lower=0.0) return self._validate_signals(signal, prices) + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons | None: + """Explain each transition of a ``binary``-scaled momentum signal. + + Only ``signal_scaling == "binary"`` gets a specific attribution: + that mode is a clean, discrete ``sign(momentum_score)`` (like + ``trend_following``'s crossover), so "the score crossed zero" is + a real, nameable event. ``"continuous"``/``"volatility_adjusted"`` + produce a continuously varying value that changes on almost every + rebalance date -- the generic ``"signal X -> Y since last + rebalance"`` text already IS the complete explanation there (the + magnitude itself is the story); inventing a label repeated on + nearly every row would be exactly the kind of generic-reason- + dressed-as-specific this feature exists to avoid, so this + deliberately returns ``None`` for those two modes. + """ + if self.signal_scaling != "binary": + return None + prices = self._prices(data) + score = self._native_feature( + prices, lambda p: momentum(p, self.lookback_period, self.skip_period) + ) + signal = pd.DataFrame(np.sign(score), index=score.index, columns=score.columns) + if self.long_only: + signal = signal.clip(lower=0.0) + final = signal.fillna(0.0).to_numpy() + previous = np.vstack([np.zeros((1, final.shape[1])), final[:-1]]) + score_values = score.to_numpy() + + detail_code = np.empty(final.shape, dtype=object) + details = np.empty(final.shape, dtype=object) + for row in range(final.shape[0]): + for col in range(final.shape[1]): + delta = final[row, col] - previous[row, col] + if abs(delta) <= EPSILON: + continue + score_value = score_values[row, col] + if final[row, col] > EPSILON: + detail_code[row, col] = "positive_momentum_entry" + details[row, col] = ( + f"momentum score {score_value:.4f} turned positive" + ) + elif final[row, col] < -EPSILON: + detail_code[row, col] = "negative_momentum_entry" + details[row, col] = ( + f"momentum score {score_value:.4f} turned negative" + ) + else: + detail_code[row, col] = "momentum_exit" + details[row, col] = f"momentum score {score_value:.4f} crossed zero" + + return self._validate_signal_reasons( + pd.DataFrame( + detail_code, index=prices.index, columns=prices.columns, dtype=object + ), + pd.DataFrame( + details, index=prices.index, columns=prices.columns, dtype=object + ), + prices, + ) + + +_CROSS_SECTIONAL_SCALINGS = frozenset({"binary", "continuous"}) + + +def _cross_sectional_magnitude( + score: pd.DataFrame, selection: pd.DataFrame +) -> pd.DataFrame: + """Per-date, per-leg rank-based magnitude, never zero for a selected asset. + + Computed separately within each of the two SELECTED legs (long: + ``selection > 0``, short: ``selection < 0``), never across the whole + cross-section. For the long leg: rank ascending by score (1 = weakest + selected long, N = strongest), divided by the leg's own selected count + N, so magnitude is non-decreasing in score and always in ``(0, 1]`` + (never exactly 0 for a selected asset, since the minimum attainable + rank is 1, not 0). The short leg mirrors this: rank DESCENDING by + score (the most negative -- the best short candidate -- gets the top + rank N), so magnitude is non-increasing in score. Tied scores get the + IDENTICAL rank -- the top of their shared tie group (pandas' + ``rank(method="max")``) -- so magnitude depends only on each asset's + own score, never on column/symbol order, and a leg with a single + selected asset (or every selected score tied) resolves to a magnitude + of exactly 1.0 for every tied member. + """ + long_mask = selection.gt(0.0) + short_mask = selection.lt(0.0) + long_ranks = score.where(long_mask).rank(axis=1, method="max") + short_ranks = score.where(short_mask).rank(axis=1, method="max", ascending=False) + long_count = long_mask.sum(axis=1) + short_count = short_mask.sum(axis=1) + with np.errstate(invalid="ignore", divide="ignore"): + long_magnitude = long_ranks.div(long_count, axis=0) + short_magnitude = short_ranks.div(short_count, axis=0) + magnitude = pd.DataFrame(0.0, index=score.index, columns=score.columns) + magnitude = magnitude.where(~long_mask, long_magnitude) + magnitude = magnitude.where(~short_mask, short_magnitude) + return magnitude + @register_strategy("cross_sectional_momentum") class CrossSectionalMomentumStrategy(BaseStrategy): - """Select the strongest assets and optionally short the weakest.""" + """Select the strongest assets and optionally short the weakest. + + ``binary`` (default) gives every selected asset an identical signal + magnitude (+-1). ``continuous`` scales each selected asset's SIGNAL + magnitude by its RANK within its own selected leg (see + :func:`_cross_sectional_magnitude`): within the long leg, the weakest + selected name gets the smallest magnitude and the strongest gets the + full +1; within the short leg (mirrored), the least-negative selected + name gets the smallest magnitude and the most negative gets the full + -1 -- guaranteed monotone in score by construction and never zero for + a selected asset, while WHICH assets are selected (the top/bottom + fraction cutoff itself) is unchanged. This is a SIGNAL magnitude, not + a portfolio weight: the allocator downstream still determines the + actual target weights. Only the ``signal_proportional`` allocator + actually uses this magnitude -- the default ``equal_weight`` allocator + discards it (``np.sign`` of the signal), making ``continuous`` behave + identically to ``binary``. + """ def __init__( self, @@ -138,6 +292,9 @@ def __init__( bottom_fraction: float = 0.25, long_short: bool = False, signal_scaling: str = "binary", + price_type: str = "adjusted_close", + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, ) -> None: values = self.validate_parameters( { @@ -147,6 +304,9 @@ def __init__( "bottom_fraction": bottom_fraction, "long_short": long_short, "signal_scaling": signal_scaling, + "price_type": price_type, + "stop_loss_pct": stop_loss_pct, + "take_profit_pct": take_profit_pct, } ) self.lookback_period = values["lookback_period"] @@ -155,6 +315,9 @@ def __init__( self.bottom_fraction = values["bottom_fraction"] self.long_short = values["long_short"] self.signal_scaling = values["signal_scaling"] + self.price_type = values["price_type"] + self.stop_loss_pct = values["stop_loss_pct"] + self.take_profit_pct = values["take_profit_pct"] self._freeze_parameters() @classmethod @@ -187,7 +350,15 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: "long_short is enabled." ) values["signal_scaling"] = _scaling( - values["signal_scaling"], allowed=frozenset({"binary"}) + values["signal_scaling"], allowed=_CROSS_SECTIONAL_SCALINGS + ) + values["price_type"] = choice( + values["price_type"], name="price_type", options=PRICE_TYPES + ) + values["stop_loss_pct"], values["take_profit_pct"] = ( + validate_risk_control_parameters( + values["stop_loss_pct"], values["take_profit_pct"] + ) ) return values @@ -196,9 +367,92 @@ def generate_signals( ) -> pd.DataFrame: """Return disjoint cross-sectional selections.""" prices = self._prices(data) - score = momentum(prices, self.lookback_period, self.skip_period) + score = self._native_feature( + prices, lambda p: momentum(p, self.lookback_period, self.skip_period) + ) bottom = self.bottom_fraction if self.long_short else 0.0 selection = select_top_bottom( score, top_fraction=self.top_fraction, bottom_fraction=bottom ) - return self._validate_signals(selection, prices) + if self.signal_scaling == "continuous": + magnitude = _cross_sectional_magnitude(score, selection) + selection_values = selection.to_numpy(dtype=float) + scaled = np.where( + selection_values != 0.0, + selection_values * magnitude.to_numpy(dtype=float), + 0.0, + ) + signal = pd.DataFrame( + scaled, index=selection.index, columns=selection.columns + ) + else: + signal = selection + return self._validate_signals(signal, prices) + + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons | None: + """Explain each entry into/exit from the top or bottom selection. + + There is no persistent membership state at all (see + ``select_top_bottom``'s own docstring: it recomputes the disjoint + top/bottom groups from scratch at every date) -- a transition is + simply "this symbol's selection changed since yesterday", read + directly off the SAME ``select_top_bottom(...)`` call + ``generate_signals()`` itself makes (the real function, not a + reimplementation, so it can never diverge). ``select_top_bottom`` + does not expose each symbol's rank or the cutoff score, so those + are not included here -- only the momentum score, which is real + and directly available. + + Only ``signal_scaling == "binary"`` gets this attribution, same + rationale as ``TimeSeriesMomentumStrategy``: under ``"continuous"`` + the executed WEIGHT still varies within an unchanged selection on + almost every rebalance (the cross-sectional magnitude), so the + generic "signal X -> Y since last rebalance" text already is the + complete explanation there. + """ + if self.signal_scaling != "binary": + return None + prices = self._prices(data) + score = self._native_feature( + prices, lambda p: momentum(p, self.lookback_period, self.skip_period) + ) + bottom = self.bottom_fraction if self.long_short else 0.0 + selection = select_top_bottom( + score, top_fraction=self.top_fraction, bottom_fraction=bottom + ) + final = selection.fillna(0.0).to_numpy() + previous = np.vstack([np.zeros((1, final.shape[1])), final[:-1]]) + score_values = score.to_numpy() + + detail_code = np.empty(final.shape, dtype=object) + details = np.empty(final.shape, dtype=object) + for row in range(final.shape[0]): + for col in range(final.shape[1]): + delta = final[row, col] - previous[row, col] + if abs(delta) <= EPSILON: + continue + score_value = score_values[row, col] + if final[row, col] > EPSILON: + detail_code[row, col] = "entered_top_selection" + details[row, col] = f"momentum score {score_value:.4f}" + elif final[row, col] < -EPSILON: + detail_code[row, col] = "entered_bottom_selection" + details[row, col] = f"momentum score {score_value:.4f}" + elif previous[row, col] > EPSILON: + detail_code[row, col] = "left_top_selection" + details[row, col] = f"momentum score {score_value:.4f}" + else: + detail_code[row, col] = "left_bottom_selection" + details[row, col] = f"momentum score {score_value:.4f}" + + return self._validate_signal_reasons( + pd.DataFrame( + detail_code, index=prices.index, columns=prices.columns, dtype=object + ), + pd.DataFrame( + details, index=prices.index, columns=prices.columns, dtype=object + ), + prices, + ) diff --git a/src/quantlab/strategies/pairs_trading.py b/src/quantlab/strategies/pairs_trading.py index 969d6b1..abb6f7c 100644 --- a/src/quantlab/strategies/pairs_trading.py +++ b/src/quantlab/strategies/pairs_trading.py @@ -3,43 +3,78 @@ from __future__ import annotations from collections.abc import Mapping -from typing import Any +from typing import Any, cast import numpy as np import pandas as pd from quantlab.constants import EPSILON +from quantlab.data.calendar import is_session_day from quantlab.exceptions import StrategyError from quantlab.features._validation import ( boolean, + choice, finite_real, numeric_pandas, positive_int, same_axes, ) -from quantlab.features.mean_reversion import rolling_zscore +from quantlab.features.mean_reversion import rolling_percentile_rank, rolling_zscore +from quantlab.features.mean_reversion import rsi as _price_rsi +from quantlab.features.pairs_diagnostics import spread as compute_spread +from quantlab.features.stationarity import adf_test from quantlab.logging_config import get_logger -from quantlab.strategies.base import BaseStrategy, register_strategy +from quantlab.strategies.base import ( + PRICE_TYPES, + UNSET, + BaseStrategy, + SignalReasons, + UnsetType, + register_strategy, + validate_risk_control_parameters, +) +from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS logger = get_logger(__name__) +#: Indicators `PairsTradingStrategy` can drive its state machine from -- +#: mirrors mean_reversion's `UI_INDICATORS` (the 3 indicators that can +#: meaningfully diverge), applied to the spread residual instead of a raw +#: price. No `bollinger`/`distance_ma` here: pairs trading never offered +#: them, and mean_reversion's own analysis (they rarely diverge from +#: `zscore`) applies just as much to a spread. +INDICATORS = ("zscore", "rsi", "percentile") + + +def _centered_spread_indicator( + spread: pd.Series, indicator: str, window: int +) -> pd.Series: + """Compute ``indicator``'s zero-centered series for the spread residual. + + Mirrors `quantlab.strategies.mean_reversion._centered_indicator`'s + dispatch and sign convention (negative = below normal, positive = + above normal), but for a single spread Series that can legitimately be + zero or negative (unlike a price) -- `rsi`/`rolling_percentile_rank` + are called with `strictly_positive=False` for exactly that reason. + """ + if indicator == "zscore": + return rolling_zscore(spread, window) + if indicator == "rsi": + return _price_rsi(spread, window, strictly_positive=False) - 50.0 + if indicator == "percentile": + return rolling_percentile_rank(spread, window, strictly_positive=False) - 0.5 + raise ValueError(f"Unknown indicator {indicator!r}.") # unreachable after choice() + def adf_pvalue(series: pd.Series) -> float | None: - """Return an ADF p-value, or ``None`` when the test is inconclusive.""" - if not isinstance(series, pd.Series): - raise TypeError("series must be a pandas Series.") - validated = numeric_pandas(series, name="series") - values = validated.dropna().to_numpy(dtype=float) - if len(values) < 20 or np.allclose(values, values[0]): - return None - try: - from statsmodels.tsa.stattools import adfuller - - pvalue = float(adfuller(values, autolag="AIC")[1]) - except Exception as exc: # pragma: no cover - third-party numerical failures - logger.warning("ADF test failed: %s", exc) - return None - return pvalue if np.isfinite(pvalue) else None + """Return an ADF p-value, or ``None`` when the test is inconclusive. + + Thin convenience wrapper over :func:`quantlab.features.stationarity. + adf_test`, kept here since the stationarity gate below only ever needs + the raw p-value, not the full structured result. + """ + result = adf_test(series) + return result.pvalue if result is not None else None def _ols_coefficients(x: np.ndarray, y: np.ndarray) -> tuple[float, float]: @@ -111,37 +146,104 @@ def _rolling_hedge_ratio( def _walk_pairs_positions( - zscore: np.ndarray, + indicator: np.ndarray, tradable: np.ndarray, entry: float, exit_: float, stop: float | None, ) -> np.ndarray: - """Convert spread z-scores into persistent positions in ``{-1, 0, 1}``.""" - if len(zscore) != len(tradable): - raise ValueError("zscore and tradable must have the same length.") - positions = np.zeros_like(zscore, dtype=float) + """Convert the spread's centered indicator into positions in ``{-1, 0, 1}``.""" + positions, _, _ = _walk_pairs_positions_with_reasons( + indicator, tradable, entry, exit_, stop + ) + return positions + + +def _walk_pairs_positions_with_reasons( + indicator: np.ndarray, + tradable: np.ndarray, + entry: float, + exit_: float, + stop: float | None, + *, + adf_gate_enabled: bool = True, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Convert the spread's centered indicator into positions in ``{-1, 0, 1}``. + + Also records, at every index where the state actually transitions, a + closed-set ``reason_detail_code`` and a human-readable ``reason_ + details`` string naming exactly which branch fired -- computed in the + SAME pass as the position itself (mirrors mean_reversion's + ``_walk_positions_with_reasons``), so ``generate_signals()`` and + ``explain_signals()`` can never disagree. The stationarity (ADF) gate + is consulted ONLY inside the ``state == 0.0`` branch, exactly as in + the original state machine -- it can silently block an entry but can + never, by itself, force an exit, so it never gets its own + ``reason_detail_code``; a successful entry mentions the gate as + context in ``reason_details`` only. + """ + if len(indicator) != len(tradable): + raise ValueError("indicator and tradable must have the same length.") + positions = np.zeros_like(indicator, dtype=float) + detail_code = np.full(indicator.shape, None, dtype=object) + details = np.full(indicator.shape, None, dtype=object) state = 0.0 - for index, value in enumerate(zscore): - if not np.isfinite(value) or (stop is not None and abs(value) > stop): + for index, value in enumerate(indicator): + previous_state = state + if not np.isfinite(value): + state = 0.0 + if previous_state != state: + detail_code[index] = "data_unavailable_exit" + details[index] = ( + "spread indicator unavailable (insufficient trailing history)" + ) + elif stop is not None and abs(value) > stop: state = 0.0 - elif state == 0.0 and bool(tradable[index]): - if value < -entry: - state = 1.0 - elif value > entry: - state = -1.0 + if previous_state != state: + signed_stop = stop if value > 0 else -stop + detail_code[index] = "stop_loss_exit" + details[index] = ( + f"spread indicator {value:.4f} breached stop threshold " + f"{signed_stop:.4f}" + ) + elif state == 0.0: + if bool(tradable[index]): + gate_clause = ( + "stationarity gate open" if adf_gate_enabled else "gate disabled" + ) + if value < -entry: + state = 1.0 + detail_code[index] = "spread_oversold_entry" + details[index] = ( + f"spread indicator {value:.4f} crossed entry threshold " + f"{-entry:.4f} ({gate_clause})" + ) + elif value > entry: + state = -1.0 + detail_code[index] = "spread_overbought_entry" + details[index] = ( + f"spread indicator {value:.4f} crossed entry threshold " + f"{entry:.4f} ({gate_clause})" + ) elif (state == 1.0 and value > -exit_) or (state == -1.0 and value < exit_): state = 0.0 + threshold = -exit_ if previous_state == 1.0 else exit_ + detail_code[index] = "mean_reversion_exit" + details[index] = ( + f"spread indicator {value:.4f} crossed exit threshold {threshold:.4f}" + ) positions[index] = state - return positions + return positions, detail_code, details @register_strategy("pairs_trading") class PairsTradingStrategy(BaseStrategy): """Trade the residual of a trailing price-level regression between two assets. - The ADF test gates new entries. Open positions still follow their z-score - exit and stop rules, and any undefined z-score forces the pair flat. + The ADF test gates new entries (unless ``adf_pvalue_threshold=None`` + disables it). Open positions still follow their own exit/stop rules + (on the chosen ``indicator``'s centered series of the spread), and any + undefined indicator value forces the pair flat. """ def __init__( @@ -149,37 +251,59 @@ def __init__( symbol_a: str, symbol_b: str, formation_window: int = 252, - zscore_window: int = 63, - entry_zscore: float = 2.0, - exit_zscore: float = 0.5, - stop_zscore: float | None = 4.0, + indicator_window: int = 63, + indicator: str = "zscore", + entry_threshold: float | None = None, + exit_threshold: float | None = None, + stop_threshold: float | UnsetType | None = UNSET, dynamic_hedge_ratio: bool = True, - adf_pvalue_threshold: float = 0.10, + adf_pvalue_threshold: float | None = 0.10, + price_type: str = "adjusted_close", + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, ) -> None: values = self.validate_parameters( { "symbol_a": symbol_a, "symbol_b": symbol_b, "formation_window": formation_window, - "zscore_window": zscore_window, - "entry_zscore": entry_zscore, - "exit_zscore": exit_zscore, - "stop_zscore": stop_zscore, + "indicator_window": indicator_window, + "indicator": indicator, + "entry_threshold": entry_threshold, + "exit_threshold": exit_threshold, + "stop_threshold": stop_threshold, "dynamic_hedge_ratio": dynamic_hedge_ratio, "adf_pvalue_threshold": adf_pvalue_threshold, + "price_type": price_type, + "stop_loss_pct": stop_loss_pct, + "take_profit_pct": take_profit_pct, } ) self.symbol_a = values["symbol_a"] self.symbol_b = values["symbol_b"] self.formation_window = values["formation_window"] - self.zscore_window = values["zscore_window"] - self.entry_zscore = values["entry_zscore"] - self.exit_zscore = values["exit_zscore"] - self.stop_zscore = values["stop_zscore"] + self.indicator_window = values["indicator_window"] + self.indicator = values["indicator"] + self.entry_threshold = values["entry_threshold"] + self.exit_threshold = values["exit_threshold"] + self.stop_threshold = values["stop_threshold"] self.dynamic_hedge_ratio = values["dynamic_hedge_ratio"] self.adf_pvalue_threshold = values["adf_pvalue_threshold"] + self.price_type = values["price_type"] + self.stop_loss_pct = values["stop_loss_pct"] + self.take_profit_pct = values["take_profit_pct"] self._freeze_parameters() + def position_groups(self) -> tuple[tuple[str, ...], ...] | None: + """The two legs form one economic position for stop-loss/take-profit. + + See `BaseStrategy.position_groups()` -- a per-leg check would + evaluate the wrong thing (e.g. treat a hedge leg's own gain, + which OFFSETS the pair's real loss, as if it were an independent + position). + """ + return ((self.symbol_a, self.symbol_b),) + @classmethod def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: """Validate symbols, windows, thresholds and ADF confidence.""" @@ -194,43 +318,75 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: values["formation_window"] = positive_int( values["formation_window"], name="formation_window", minimum=20 ) - values["zscore_window"] = positive_int( - values["zscore_window"], name="zscore_window", minimum=2 + values["indicator_window"] = positive_int( + values["indicator_window"], name="indicator_window", minimum=2 + ) + values["indicator"] = choice( + values["indicator"], name="indicator", options=frozenset(INDICATORS) ) - values["entry_zscore"] = finite_real( - values["entry_zscore"], name="entry_zscore", minimum=0.0, strict=True + entry_threshold = values["entry_threshold"] + exit_threshold = values["exit_threshold"] + stop_threshold = values["stop_threshold"] + default_entry, default_exit, default_stop = INDICATOR_DEFAULT_THRESHOLDS[ + values["indicator"] + ] + if entry_threshold is None: + entry_threshold = default_entry + if exit_threshold is None: + exit_threshold = default_exit + if isinstance(stop_threshold, UnsetType): + stop_threshold = default_stop + # else: an explicit stop_threshold=None means "disabled" and is + # left as None; an explicit float is used as-is. + values["entry_threshold"] = finite_real( + entry_threshold, name="entry_threshold", minimum=0.0, strict=True ) - values["exit_zscore"] = finite_real( - values["exit_zscore"], name="exit_zscore", minimum=0.0 + values["exit_threshold"] = finite_real( + exit_threshold, name="exit_threshold", minimum=0.0 ) - if values["stop_zscore"] is not None: - values["stop_zscore"] = finite_real( - values["stop_zscore"], name="stop_zscore", minimum=0.0 + if stop_threshold is not None: + values["stop_threshold"] = finite_real( + stop_threshold, name="stop_threshold", minimum=0.0 ) - if values["entry_zscore"] <= values["exit_zscore"]: - raise ValueError("entry_zscore must exceed exit_zscore.") + else: + values["stop_threshold"] = None + if values["entry_threshold"] <= values["exit_threshold"]: + raise ValueError("entry_threshold must exceed exit_threshold.") if ( - values["stop_zscore"] is not None - and values["stop_zscore"] <= values["entry_zscore"] + values["stop_threshold"] is not None + and values["stop_threshold"] <= values["entry_threshold"] ): - raise ValueError("stop_zscore must exceed entry_zscore.") + raise ValueError("stop_threshold must exceed entry_threshold.") values["dynamic_hedge_ratio"] = boolean( values["dynamic_hedge_ratio"], name="dynamic_hedge_ratio" ) - values["adf_pvalue_threshold"] = finite_real( - values["adf_pvalue_threshold"], - name="adf_pvalue_threshold", - minimum=0.0, - strict=True, + if values["adf_pvalue_threshold"] is not None: + values["adf_pvalue_threshold"] = finite_real( + values["adf_pvalue_threshold"], + name="adf_pvalue_threshold", + minimum=0.0, + strict=True, + ) + if values["adf_pvalue_threshold"] >= 1.0: + raise ValueError("adf_pvalue_threshold must be strictly below 1.") + values["price_type"] = choice( + values["price_type"], name="price_type", options=PRICE_TYPES + ) + values["stop_loss_pct"], values["take_profit_pct"] = ( + validate_risk_control_parameters( + values["stop_loss_pct"], values["take_profit_pct"] + ) ) - if values["adf_pvalue_threshold"] >= 1.0: - raise ValueError("adf_pvalue_threshold must be strictly below 1.") return values def generate_signals( self, data: pd.DataFrame, features: pd.DataFrame | None = None ) -> pd.DataFrame: - """Return dollar-hedged signals for the two configured legs.""" + """Return signals for the two configured legs. + + The two legs' relative weights are scaled by the fitted hedge ratio + (``beta``), not necessarily dollar-neutral. + """ prices = self._prices(data) for symbol in (self.symbol_a, self.symbol_b): if symbol not in prices.columns: @@ -238,20 +394,14 @@ def generate_signals( f"Pairs trading needs symbol '{symbol}' in the data; " f"available: {list(prices.columns)}." ) - a = prices[self.symbol_a] - b = prices[self.symbol_b] - intercept, beta = rolling_hedge_parameters( - a, b, self.formation_window, self.dynamic_hedge_ratio - ) - spread = a - intercept - beta * b - zscore = rolling_zscore(spread, self.zscore_window) + a, b, indicator, beta, tradable = self._native_pair_context(prices) state = pd.Series( _walk_pairs_positions( - zscore.to_numpy(dtype=float), - self._stationarity_gate(a, b), - entry=self.entry_zscore, - exit_=self.exit_zscore, - stop=self.stop_zscore, + indicator.to_numpy(dtype=float), + tradable, + entry=self.entry_threshold, + exit_=self.exit_threshold, + stop=self.stop_threshold, ), index=prices.index, dtype=float, @@ -272,40 +422,261 @@ def generate_signals( signals.loc[:, [self.symbol_a, self.symbol_b]] = pair_signals return self._validate_signals(signals, prices) - def _stationarity_gate(self, a: pd.Series, b: pd.Series) -> np.ndarray: - """Test full trailing formation residuals at bounded intervals.""" - gate = np.zeros(len(a), dtype=bool) - last_pvalue: float | None = None - static_coefficients: tuple[float, float] | None = None - if not self.dynamic_hedge_ratio and len(a) >= self.formation_window: - static_coefficients = _ols_coefficients( - b.iloc[: self.formation_window].to_numpy(dtype=float), - a.iloc[: self.formation_window].to_numpy(dtype=float), + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons: + """Explain each transition of the pair's shared position state. + + Recomputes the spread/indicator/ADF-gate exactly like + ``generate_signals()`` and re-walks the SAME state machine + (shared helper function) so the two can never disagree. Both + legs move together (one shared pair position), so they always + carry the same reason on the same date; every other symbol in + the universe stays ``None`` (this strategy never touches them). + """ + prices = self._prices(data) + for symbol in (self.symbol_a, self.symbol_b): + if symbol not in prices.columns: + raise StrategyError( + f"Pairs trading needs symbol '{symbol}' in the data; " + f"available: {list(prices.columns)}." + ) + _, _, indicator, _, tradable = self._native_pair_context(prices) + _, symbol_detail_code, symbol_details = _walk_pairs_positions_with_reasons( + indicator.to_numpy(dtype=float), + tradable, + entry=self.entry_threshold, + exit_=self.exit_threshold, + stop=self.stop_threshold, + adf_gate_enabled=self.adf_pvalue_threshold is not None, + ) + + detail_code = np.full( + (len(prices.index), len(prices.columns)), None, dtype=object + ) + details = np.full((len(prices.index), len(prices.columns)), None, dtype=object) + a_index = prices.columns.get_loc(self.symbol_a) + b_index = prices.columns.get_loc(self.symbol_b) + detail_code[:, a_index] = symbol_detail_code + detail_code[:, b_index] = symbol_detail_code + details[:, a_index] = symbol_details + details[:, b_index] = symbol_details + + return self._validate_signal_reasons( + pd.DataFrame( + detail_code, index=prices.index, columns=prices.columns, dtype=object + ), + pd.DataFrame( + details, index=prices.index, columns=prices.columns, dtype=object + ), + prices, + ) + + def decision_signal( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> pd.DataFrame: + """Expose the discrete pair state, stripped of price/beta rescaling. + + ``generate_signals()``'s own output mixes the discrete decision + (``state`` in ``{-1, 0, 1}``, which only changes on a real + entry/exit/reversal) with purely mechanical rescaling from + ``a``/``b`` (price) and ``beta`` (hedge ratio, recomputed daily + when ``dynamic_hedge_ratio=True``) -- a plain "did the final + signal change" comparison cannot tell a real new decision apart + from pure drift. This recomputes ``state`` the same way + ``explain_signals()`` does (shared helper, same pass, never + diverges from ``generate_signals()``'s own internal ``state``) and + rebroadcasts it onto ``symbol_a``/``symbol_b`` (0 elsewhere) -- + diagnostic only, see :meth:`BaseStrategy.decision_signal`. + """ + prices = self._prices(data) + for symbol in (self.symbol_a, self.symbol_b): + if symbol not in prices.columns: + raise StrategyError( + f"Pairs trading needs symbol '{symbol}' in the data; " + f"available: {list(prices.columns)}." + ) + _, _, indicator, _, tradable = self._native_pair_context(prices) + state, _, _ = _walk_pairs_positions_with_reasons( + indicator.to_numpy(dtype=float), + tradable, + entry=self.entry_threshold, + exit_=self.exit_threshold, + stop=self.stop_threshold, + ) + decision = pd.DataFrame( + 0.0, index=prices.index, columns=prices.columns, dtype=float + ) + decision[self.symbol_a] = state + decision[self.symbol_b] = state + return self._validate_decision_signal(decision, prices) + + def _native_pair_context( + self, prices: pd.DataFrame + ) -> tuple[pd.Series, pd.Series, pd.Series, pd.Series, np.ndarray]: + """Return ``(a, b, indicator, beta, tradable)`` for the shared state machine. + + The hedge-ratio fit, spread/indicator and periodic ADF re-check are + all rolling-window computations over the two legs jointly -- per + ``BaseStrategy.symbol_calendars``, they are computed on the + INTERSECTION of both legs' own native session dates (never a date + manufactured by one leg's own closure padding), then reindexed and + forward-filled onto the combined timeline, exactly like a single- + symbol native feature (see ``quantlab.features.native_calendar``). + ``tradable`` (the state machine's own entry gate) is the AND of the + ADF stationarity gate with "both legs open today" -- a closed leg + must never allow a fresh entry on a stale price for that leg, even + when the ADF gate alone would allow it; an already-open position's + exit/stop rules are unaffected (the state machine only consults + ``tradable`` for a NEW entry, never for an exit/stop). + """ + a = prices[self.symbol_a] + b = prices[self.symbol_b] + calendars = self.symbol_calendars or {} + calendar_a = calendars.get(self.symbol_a) + calendar_b = calendars.get(self.symbol_b) + combined_index = pd.DatetimeIndex(prices.index) + open_a = ( + is_session_day(calendar_a, combined_index) + if calendar_a is not None + else np.ones(len(prices.index), dtype=bool) + ) + open_b = ( + is_session_day(calendar_b, combined_index) + if calendar_b is not None + else np.ones(len(prices.index), dtype=bool) + ) + both_open = open_a & open_b + + if bool(both_open.all()): + intercept, beta = rolling_hedge_parameters( + a, b, self.formation_window, self.dynamic_hedge_ratio + ) + spread = compute_spread(a, b, intercept, beta) + indicator = _centered_spread_indicator( + spread, self.indicator, self.indicator_window ) - for position in range(self.formation_window, len(a)): - if (position - self.formation_window) % self.zscore_window == 0: - start = position - self.formation_window - window = pd.concat( - {"a": a.iloc[start:position], "b": b.iloc[start:position]}, - axis=1, - ).dropna() - if len(window) != self.formation_window: + adf_gate = self._stationarity_gate(a, b) + return a, b, indicator, beta, both_open & adf_gate + + native_index = prices.index[both_open] + native_a, native_b = a.loc[native_index], b.loc[native_index] + intercept, beta_native = rolling_hedge_parameters( + native_a, native_b, self.formation_window, self.dynamic_hedge_ratio + ) + spread_native = compute_spread(native_a, native_b, intercept, beta_native) + indicator_native = _centered_spread_indicator( + spread_native, self.indicator, self.indicator_window + ) + adf_gate_native = self._stationarity_gate(native_a, native_b) + + fillable = pd.Series(~both_open, index=prices.index) + beta = beta_native.reindex(prices.index) + beta = beta.mask(fillable & beta.isna(), beta.ffill()) + indicator = indicator_native.reindex(prices.index) + indicator = indicator.mask(fillable & indicator.isna(), indicator.ffill()) + adf_gate_series = pd.Series(adf_gate_native, index=native_index).reindex( + prices.index + ) + adf_gate_series = adf_gate_series.mask( + fillable & adf_gate_series.isna(), adf_gate_series.ffill() + ) + adf_gate = adf_gate_series.fillna(False).to_numpy(dtype=bool) + return a, b, indicator, beta, both_open & adf_gate + + def _stationarity_gate(self, a: pd.Series, b: pd.Series) -> np.ndarray: + """Test full trailing formation residuals at bounded intervals. + + Returns an all-``True`` gate (every date tradable) without running + any ADF test when ``adf_pvalue_threshold is None`` -- the gate is + disabled entirely, not merely widened. + """ + if self.adf_pvalue_threshold is None: + return np.ones(len(a), dtype=bool) + pvalues = periodic_stationarity_pvalues( + a, + b, + formation_window=self.formation_window, + stride=self.indicator_window, + dynamic_hedge_ratio=self.dynamic_hedge_ratio, + ) + values = pvalues.to_numpy(dtype=float) + gate = np.isfinite(values) & (values <= self.adf_pvalue_threshold) + return cast(np.ndarray, gate) + + +def periodic_stationarity_pvalues( + a: pd.Series, + b: pd.Series, + *, + formation_window: int, + stride: int, + dynamic_hedge_ratio: bool, +) -> pd.Series: + """ADF p-value of a single-window regression residual, rechecked periodically. + + Recomputed every ``stride`` positions starting at ``formation_window`` + and held constant between checkpoints (matching ``PairsTradingStrategy. + _stationarity_gate``'s own periodic recheck, which this function IS -- + ``_stationarity_gate`` just thresholds it). With ``dynamic_hedge_ratio= + True``, each checkpoint refits (intercept, beta) on its own trailing + ``formation_window``-length window. With ``dynamic_hedge_ratio=False``, + every checkpoint instead reuses the ONE (intercept, beta) fit once on + the very first ``formation_window`` -- only the ADF test itself, not + the regression, is redone at each checkpoint. Either way this is + deliberately distinct from running ADF on a slice of ``spread(a, b, + *rolling_hedge_parameters(...))``: with ``dynamic_hedge_ratio=True`` + that spread's hedge ratio is refit EVERY day (trailing + ``formation_window``-length window ending at that day), so slicing it + would test a residual built from a DIFFERENT regression than the + checkpoint-window fit the strategy's own gate actually uses. Reused + identically by :func:`quantlab.features.pairs_diagnostics. + compute_pair_diagnostics` (its ``rolling_adf_pvalue`` field) so the + Strategy Explorer's diagnostics agree with what the live strategy + gates entries on whenever the data range, symbols, price type and + parameters are identical. + + Returns ``NaN`` before the first checkpoint (``position < + formation_window``) and at any checkpoint where the window has missing + data or the regression is numerically degenerate. + """ + if not isinstance(a, pd.Series) or not isinstance(b, pd.Series): + raise TypeError("a and b must be pandas Series.") + same_axes(a, b, names=("b",)) + window_length = positive_int(formation_window, name="formation_window", minimum=2) + check_stride = positive_int(stride, name="stride", minimum=1) + use_dynamic = boolean(dynamic_hedge_ratio, name="dynamic_hedge_ratio") + + pvalue = pd.Series(np.nan, index=a.index, dtype=float) + last_pvalue: float | None = None + static_coefficients: tuple[float, float] | None = None + if not use_dynamic and len(a) >= window_length: + static_coefficients = _ols_coefficients( + b.iloc[:window_length].to_numpy(dtype=float), + a.iloc[:window_length].to_numpy(dtype=float), + ) + for position in range(window_length, len(a)): + if (position - window_length) % check_stride == 0: + start = position - window_length + window = pd.concat( + {"a": a.iloc[start:position], "b": b.iloc[start:position]}, + axis=1, + ).dropna() + if len(window) != window_length: + last_pvalue = None + else: + intercept, beta = ( + _ols_coefficients( + window["b"].to_numpy(dtype=float), + window["a"].to_numpy(dtype=float), + ) + if use_dynamic + else static_coefficients or (np.nan, np.nan) + ) + if not np.isfinite(intercept) or not np.isfinite(beta): last_pvalue = None else: - intercept, beta = ( - _ols_coefficients( - window["b"].to_numpy(dtype=float), - window["a"].to_numpy(dtype=float), - ) - if self.dynamic_hedge_ratio - else static_coefficients or (np.nan, np.nan) - ) - if not np.isfinite(intercept) or not np.isfinite(beta): - last_pvalue = None - else: - residual = window["a"] - intercept - beta * window["b"] - last_pvalue = adf_pvalue(residual) - gate[position] = ( - last_pvalue is not None and last_pvalue <= self.adf_pvalue_threshold - ) - return gate + residual = window["a"] - intercept - beta * window["b"] + last_pvalue = adf_pvalue(residual) + pvalue.iloc[position] = last_pvalue if last_pvalue is not None else np.nan + return pvalue diff --git a/src/quantlab/strategies/trend_following.py b/src/quantlab/strategies/trend_following.py index 42fb992..ecfba77 100644 --- a/src/quantlab/strategies/trend_following.py +++ b/src/quantlab/strategies/trend_following.py @@ -5,11 +5,19 @@ from collections.abc import Mapping from typing import Any +import numpy as np import pandas as pd -from quantlab.features._validation import boolean, positive_int -from quantlab.features.momentum import ma_crossover_signal -from quantlab.strategies.base import BaseStrategy, register_strategy +from quantlab.constants import EPSILON +from quantlab.features._validation import boolean, choice, positive_int +from quantlab.features.momentum import ma_crossover_signal, moving_average +from quantlab.strategies.base import ( + PRICE_TYPES, + BaseStrategy, + SignalReasons, + register_strategy, + validate_risk_control_parameters, +) @register_strategy("trend_following") @@ -25,17 +33,26 @@ def __init__( fast_window: int = 20, slow_window: int = 100, long_only: bool = True, + price_type: str = "adjusted_close", + stop_loss_pct: float | None = None, + take_profit_pct: float | None = None, ) -> None: values = self.validate_parameters( { "fast_window": fast_window, "slow_window": slow_window, "long_only": long_only, + "price_type": price_type, + "stop_loss_pct": stop_loss_pct, + "take_profit_pct": take_profit_pct, } ) self.fast_window = values["fast_window"] self.slow_window = values["slow_window"] self.long_only = values["long_only"] + self.price_type = values["price_type"] + self.stop_loss_pct = values["stop_loss_pct"] + self.take_profit_pct = values["take_profit_pct"] self._freeze_parameters() @classmethod @@ -45,8 +62,16 @@ def validate_parameters(cls, parameters: Mapping[str, Any]) -> dict[str, Any]: values["fast_window"] = positive_int(values["fast_window"], name="fast_window") values["slow_window"] = positive_int(values["slow_window"], name="slow_window") values["long_only"] = boolean(values["long_only"], name="long_only") + values["price_type"] = choice( + values["price_type"], name="price_type", options=PRICE_TYPES + ) if values["fast_window"] >= values["slow_window"]: raise ValueError("fast_window must be smaller than slow_window.") + values["stop_loss_pct"], values["take_profit_pct"] = ( + validate_risk_control_parameters( + values["stop_loss_pct"], values["take_profit_pct"] + ) + ) return values def generate_signals( @@ -54,9 +79,74 @@ def generate_signals( ) -> pd.DataFrame: """Return trailing moving-average crossover directions.""" prices = self._prices(data) - signal = ma_crossover_signal( - prices, fast_window=self.fast_window, slow_window=self.slow_window + signal = self._native_feature( + prices, + lambda p: ma_crossover_signal( + p, fast_window=self.fast_window, slow_window=self.slow_window + ), ) if self.long_only: signal = signal.clip(lower=0.0) return self._validate_signals(signal, prices) + + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons: + """Explain each transition as a bullish/bearish MA crossover. + + The signal is memoryless -- recomputed fresh every row from the + CURRENT fast/slow moving averages (see ``generate_signals``), no + persistent state. A transition is "today's final (post- + ``long_only``) signal differs from yesterday's", computed on the + exact same clipped signal ``generate_signals()`` returns, so the + two can never disagree. An increase in the final signal always + means the fast MA crossed above the slow one (bullish); a + decrease always means it crossed below (bearish) -- true whether + the move is a fresh entry, an exit forced by ``long_only`` + clipping, or a direct long<->short reversal, since the final + signal is a monotonic function of ``sign(fast_ma - slow_ma)``. + """ + prices = self._prices(data) + fast_ma = self._native_feature( + prices, lambda p: moving_average(p, self.fast_window) + ).to_numpy() + slow_ma = self._native_feature( + prices, lambda p: moving_average(p, self.slow_window) + ).to_numpy() + signal = self._native_feature( + prices, + lambda p: ma_crossover_signal( + p, fast_window=self.fast_window, slow_window=self.slow_window + ), + ) + if self.long_only: + signal = signal.clip(lower=0.0) + final = signal.fillna(0.0).to_numpy() + previous = np.vstack([np.zeros((1, final.shape[1])), final[:-1]]) + + detail_code = np.empty(final.shape, dtype=object) + details = np.empty(final.shape, dtype=object) + for row in range(final.shape[0]): + for col in range(final.shape[1]): + if final[row, col] - previous[row, col] > EPSILON: + detail_code[row, col] = "bullish_crossover" + details[row, col] = ( + f"fast MA {fast_ma[row, col]:.4f} crossed above " + f"slow MA {slow_ma[row, col]:.4f}" + ) + elif previous[row, col] - final[row, col] > EPSILON: + detail_code[row, col] = "bearish_crossover" + details[row, col] = ( + f"fast MA {fast_ma[row, col]:.4f} crossed below " + f"slow MA {slow_ma[row, col]:.4f}" + ) + + return self._validate_signal_reasons( + pd.DataFrame( + detail_code, index=prices.index, columns=prices.columns, dtype=object + ), + pd.DataFrame( + details, index=prices.index, columns=prices.columns, dtype=object + ), + prices, + ) diff --git a/src/quantlab/validation/bootstrap.py b/src/quantlab/validation/bootstrap.py index aed434e..1c36256 100644 --- a/src/quantlab/validation/bootstrap.py +++ b/src/quantlab/validation/bootstrap.py @@ -35,8 +35,18 @@ def __post_init__(self) -> None: raise ValueError(f"samples is missing columns: {sorted(missing)}.") object.__setattr__(self, "samples", self.samples.loc[:, _SAMPLE_COLUMNS].copy()) - def summary(self) -> pd.DataFrame: - """Return the mean, dispersion and central percentile interval.""" + def summary(self, confidence_level: float = 0.90) -> pd.DataFrame: + """Return the mean, dispersion and central percentile interval. + + ``confidence_level`` (default 0.90) sets the width of the reported + ``p_lower``/``p_upper`` percentile band, e.g. 0.90 -> the 5th/95th + percentiles, 0.95 -> the 2.5th/97.5th. + """ + level = finite_real(confidence_level, name="confidence_level") + if not (0.0 < level < 1.0): + raise ValueError("confidence_level must be strictly between 0 and 1.") + lower_quantile = (1.0 - level) / 2.0 + upper_quantile = 1.0 - lower_quantile rows = [] for column in self.samples.columns: series = self.samples[column].dropna() @@ -44,8 +54,8 @@ def summary(self) -> pd.DataFrame: { "statistic": column, "median": float(series.median()), - "p05": float(series.quantile(0.05)), - "p95": float(series.quantile(0.95)), + "p_lower": float(series.quantile(lower_quantile)), + "p_upper": float(series.quantile(upper_quantile)), "mean": float(series.mean()), "std": float(series.std(ddof=1)) if len(series) > 1 else 0.0, } diff --git a/src/quantlab/validation/parameter_grid.py b/src/quantlab/validation/parameter_grid.py index c8a3edc..b16b5df 100644 --- a/src/quantlab/validation/parameter_grid.py +++ b/src/quantlab/validation/parameter_grid.py @@ -76,18 +76,45 @@ def default_parameter_grid(config: ExperimentConfig) -> dict[str, list[Any]]: grid.update(_cross_sectional_fraction_grid(config, parameters)) return grid case "mean_reversion": - configured_entry = float(parameters.get("entry_zscore", 2.0)) - exit_zscore = float(parameters.get("exit_zscore", 0.5)) - stop_zscore = parameters.get("stop_zscore", 4.0) - maximum_entry = float(stop_zscore) if stop_zscore is not None else None + from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS + + indicator = parameters.get("indicator", "zscore") + default_entry, default_exit, default_stop = ( + INDICATOR_DEFAULT_THRESHOLDS.get( + indicator, INDICATOR_DEFAULT_THRESHOLDS["zscore"] + ) + ) + # entry_threshold/exit_threshold=None (absent, or an explicit + # YAML `null`) both mean "the indicator's own default" per + # MeanReversionStrategy.validate_parameters -- unlike + # stop_threshold, there is no separate UNSET/None distinction + # to preserve here. + raw_entry = parameters.get("entry_threshold") + configured_entry = default_entry if raw_entry is None else float(raw_entry) + raw_exit = parameters.get("exit_threshold") + exit_threshold = default_exit if raw_exit is None else float(raw_exit) + stop_threshold = parameters.get("stop_threshold", default_stop) + maximum_entry = ( + float(stop_threshold) if stop_threshold is not None else None + ) + # Candidates scaled around THIS indicator's own default (0.75x/ + # 1x/1.25x) rather than z-score-specific literals that would + # be meaningless on e.g. rsi's or percentile's own scale. return { "lookback_period": _ordered_unique( [10, 20, 40, int(parameters.get("lookback_period", 20))] ), - "entry_zscore": [ + "entry_threshold": [ value - for value in _ordered_unique([1.5, 2.0, 2.5, configured_entry]) - if value > exit_zscore + for value in _ordered_unique( + [ + default_entry * 0.75, + default_entry, + default_entry * 1.25, + configured_entry, + ] + ) + if value > exit_threshold and (maximum_entry is None or value < maximum_entry) ], } @@ -106,21 +133,45 @@ def default_parameter_grid(config: ExperimentConfig) -> dict[str, list[Any]]: ] return {"fast_window": fast_candidates, "slow_window": slow_candidates} case "pairs_trading": - configured_entry = float(parameters.get("entry_zscore", 2.0)) - exit_zscore = float(parameters.get("exit_zscore", 0.5)) - stop_zscore = parameters.get("stop_zscore", 4.0) - maximum_entry = float(stop_zscore) if stop_zscore is not None else None + from quantlab.strategies.mean_reversion import INDICATOR_DEFAULT_THRESHOLDS + + indicator = parameters.get("indicator", "zscore") + default_entry, default_exit, default_stop = ( + INDICATOR_DEFAULT_THRESHOLDS.get( + indicator, INDICATOR_DEFAULT_THRESHOLDS["zscore"] + ) + ) + # entry_threshold/exit_threshold=None (absent, or an explicit + # YAML `null`) both mean "the indicator's own default" per + # PairsTradingStrategy.validate_parameters -- unlike + # stop_threshold, there is no separate UNSET/None distinction + # to preserve here. + raw_entry = parameters.get("entry_threshold") + configured_entry = default_entry if raw_entry is None else float(raw_entry) + raw_exit = parameters.get("exit_threshold") + exit_threshold = default_exit if raw_exit is None else float(raw_exit) + stop_threshold = parameters.get("stop_threshold", default_stop) + maximum_entry = ( + float(stop_threshold) if stop_threshold is not None else None + ) return { "formation_window": _ordered_unique( [126, 252, 504, int(parameters.get("formation_window", 252))] ), - "zscore_window": _ordered_unique( - [21, 63, 126, int(parameters.get("zscore_window", 63))] + "indicator_window": _ordered_unique( + [21, 63, 126, int(parameters.get("indicator_window", 63))] ), - "entry_zscore": [ + "entry_threshold": [ value - for value in _ordered_unique([1.5, 2.0, 2.5, configured_entry]) - if value > exit_zscore + for value in _ordered_unique( + [ + default_entry * 0.75, + default_entry, + default_entry * 1.25, + configured_entry, + ] + ) + if value > exit_threshold and (maximum_entry is None or value < maximum_entry) ], } diff --git a/src/quantlab/validation/parameter_sensitivity.py b/src/quantlab/validation/parameter_sensitivity.py index e88cbe9..1dabedb 100644 --- a/src/quantlab/validation/parameter_sensitivity.py +++ b/src/quantlab/validation/parameter_sensitivity.py @@ -181,8 +181,8 @@ def run_walk_forward_parameter_sensitivity( WalkForwardValidator` run (all folds, OOS reconstruction), scored on that run's out-of-sample metrics. This keeps Walk-forward mode's sensitivity heatmap genuinely walk-forward-derived rather than silently reusing plain - single-backtest numbers. Train/validation/test windows and expanding - mode come from ``base_config.validation`` + single-backtest numbers. Train/validation/test/step windows and + expanding mode come from ``base_config.validation`` (:func:`~quantlab.validation.walk_forward.resolve_walk_forward_windows`). Args: @@ -212,7 +212,7 @@ def run_walk_forward_parameter_sensitivity( if not isinstance(base_config, ExperimentConfig): raise TypeError("base_config must be an ExperimentConfig.") _validate_parameter_axes(base_config, parameter_x, values_x, parameter_y, values_y) - train_window, validation_window, test_window = resolve_walk_forward_windows( + train_window, validation_window, test_window, step = resolve_walk_forward_windows( base_config ) expanding = base_config.validation.expanding @@ -299,6 +299,7 @@ def _validate_cell_state(state: Any, progress: int) -> bool: validation_window=validation_window, test_window=test_window, expanding=expanding, + step=step, ) if wf.oos_result is None: raise InvalidConfigurationError( diff --git a/src/quantlab/validation/robustness.py b/src/quantlab/validation/robustness.py index 1595794..856b7d5 100644 --- a/src/quantlab/validation/robustness.py +++ b/src/quantlab/validation/robustness.py @@ -4,6 +4,7 @@ import math from collections.abc import Callable +from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING, Any @@ -13,7 +14,7 @@ from quantlab.backtesting.runner import run_backtest_from_config from quantlab.config import ExperimentConfig from quantlab.constants import SYMBOL, TIMESTAMP -from quantlab.exceptions import QuantLabError +from quantlab.exceptions import InvalidConfigurationError, QuantLabError from quantlab.logging_config import get_logger from quantlab.risk import metrics as M from quantlab.risk._validation import ( @@ -76,6 +77,24 @@ def _failed_row(name: str, error: QuantLabError) -> dict[str, object]: } +def _ensure_unique_scenario_names(names: list[str]) -> None: + """Reject scenario names that collide once formatted for display. + + Multipliers close enough together (e.g. 1.0000001 and 1.0000002) both + format to "x1" under ``:g`` -- distinct configured values must not + silently collapse onto the same row identity. + """ + seen: set[str] = set() + for name in names: + if name in seen: + raise InvalidConfigurationError( + f"Two configured stress-test scenarios both format to the " + f"name {name!r} -- pick magnitudes that remain visibly " + "distinct once rounded for display." + ) + seen.add(name) + + _STRESS_METRIC_COLUMNS = ("total_return", "cagr", "sharpe", "max_drawdown") @@ -165,6 +184,74 @@ def _baseline_returns_is_valid( return False +@dataclass(frozen=True) +class _CostScenario: + """One commission- or slippage-multiplier stress scenario.""" + + name: str + kind: str # "commission" | "slippage" + multiplier: float + + +def _cost_scenarios(config: ExperimentConfig) -> list[_CostScenario]: + """Return every configured commission/slippage scenario, in fixed order. + + Commission scenarios first (in the configured order), then slippage. + """ + settings = config.robustness.stress_test + return [ + _CostScenario(f"commission x{multiplier:g}", "commission", multiplier) + for multiplier in settings.commission_multipliers + ] + [ + _CostScenario(f"slippage x{multiplier:g}", "slippage", multiplier) + for multiplier in settings.slippage_multipliers + ] + + +def _cost_scenario_config( + config: ExperimentConfig, scenario: _CostScenario +) -> ExperimentConfig: + """Return ``config`` with exactly one cost component scaled.""" + if scenario.kind == "commission": + return scale_costs(config, commission_mult=scenario.multiplier) + return scale_costs(config, slippage_mult=scenario.multiplier) + + +def _execution_delay_scenarios(config: ExperimentConfig) -> list[tuple[str, int]]: + """Return ``(name, delay)`` for every configured execution-delay scenario.""" + return [ + (f"execution delay +{delay}", delay) + for delay in config.robustness.stress_test.execution_delays + ] + + +def _best_days_removed_scenarios(config: ExperimentConfig) -> list[tuple[str, int]]: + """Return ``(name, n)`` for every configured best-days-removed scenario.""" + return [ + (f"best {n} days removed", n) + for n in config.robustness.stress_test.best_days_removed + ] + + +def _reduced_universe_scenarios(config: ExperimentConfig) -> list[tuple[str, int]]: + """Return ``(name, count)`` for every configured reduced-universe scenario. + + Every configured ``count`` gets a row: one whose universe is too small + to leave at least 2 tradable symbols is recorded with status="failed" + at run time (see ``_universe_reduction_is_feasible``), never silently + omitted from the table. + """ + return [ + (f"reduced universe (-{count})", count) + for count in config.robustness.stress_test.reduce_universe_by + ] + + +def _universe_reduction_is_feasible(config: ExperimentConfig, count: int) -> bool: + """Return whether dropping ``count`` symbols leaves >=2 tradable ones.""" + return len(config.symbols) > count + 1 + + def run_stress_tests( data: pd.DataFrame, config: ExperimentConfig, @@ -174,14 +261,22 @@ def run_stress_tests( ) -> pd.DataFrame: """Re-run the experiment under cost, delay and universe perturbations. + Every scenario's magnitude comes from ``config.robustness.stress_test`` + (commission/slippage multipliers, execution delays, days-removed + counts, universe-reduction counts) -- each a list, so more than one + magnitude can be evaluated per scenario type; an empty list disables + that scenario type entirely. See :class:`~quantlab.config. + StressTestSettings`. + Args: data: Canonical long OHLCV frame. config: Experiment configuration. on_progress: Optional callback invoked as ``on_progress(done, total)`` - once before the first scenario and once after each of the - (baseline plus) up to 6 scenarios below completes — each is a - single backtest, so this is coarser than a fold-level signal but - still enough to show a stalled run is actually progressing. + once before the first scenario and once after each scenario + (baseline plus every configured cost/delay/best-days/universe + scenario) completes — each is a single backtest, so this is + coarser than a fold-level signal but still enough to show a + stalled run is actually progressing. checkpoint_path: Optional path to persist per-scenario progress to, so an interrupted run resumes from its last completed scenario instead of starting over. See ``quantlab.validation.checkpoint``. @@ -195,11 +290,20 @@ def run_stress_tests( periods_per_year = positive_int(config.periods_per_year, name="periods_per_year") risk_free_rate = finite_real(config.risk_free_rate, name="risk_free_rate") - reduced_universe = len(config.symbols) > 2 - total_scenarios = 1 + 3 + 2 + (1 if reduced_universe else 0) + cost_scenarios = _cost_scenarios(config) + delay_scenarios = _execution_delay_scenarios(config) + best_days_scenarios = _best_days_removed_scenarios(config) + universe_scenarios = _reduced_universe_scenarios(config) + total_scenarios = ( + 1 + + len(cost_scenarios) + + len(delay_scenarios) + + len(best_days_scenarios) + + len(universe_scenarios) + ) rows: list[dict[str, object]] = [] - # "best 10 days removed" needs the actual baseline returns Series later, + # "best N days removed" needs the actual baseline returns Series later, # not just its already-computed metrics row, so it has to be part of the # checkpointed state too, or resuming past "baseline" would lose it. baseline_returns: pd.Series | None = None @@ -207,19 +311,17 @@ def run_stress_tests( completed_scenarios = 0 # The exact scenario name each row must have, in order -- fixed by this - # function's own scenario sequence below, independent of `progress` - # (which only ever names a prefix of it: a config with two symbols or - # fewer never reaches "reduced universe", and neither does an in-progress - # resume). - _scenario_names_in_order = [ - "baseline", - "commission x2", - "commission x5", - "slippage x2", - "execution delay +1", - "best 10 days removed", - "reduced universe", - ] + # function's own scenario sequence below (built from the same + # configured lists, so it can never drift from what actually runs), + # independent of `progress` (which only ever names a prefix of it). + _scenario_names_in_order = ( + ["baseline"] + + [scenario.name for scenario in cost_scenarios] + + [name for name, _ in delay_scenarios] + + [name for name, _ in best_days_scenarios] + + [name for name, _ in universe_scenarios] + ) + _ensure_unique_scenario_names(_scenario_names_in_order) def _validate_stress_state(state: Any, progress: int) -> bool: # One row per scenario, in lockstep with `progress` -- so an exact @@ -290,85 +392,114 @@ def _checkpoint() -> None: if on_progress is not None: on_progress(completed_scenarios, total_scenarios) - scenarios = { - "commission x2": scale_costs(config, commission_mult=2.0), - "commission x5": scale_costs(config, commission_mult=5.0), - "slippage x2": scale_costs(config, slippage_mult=2.0), - } - for position, (name, scenario_config) in enumerate(scenarios.items(), start=2): + position = 2 + for scenario in cost_scenarios: if completed_scenarios < position: - result = run_backtest_from_config(data, scenario_config) - rows.append( - _metrics_row(name, result.returns, periods_per_year, risk_free_rate) - ) + try: + scenario_config = _cost_scenario_config(config, scenario) + result = run_backtest_from_config(data, scenario_config) + except QuantLabError as exc: + logger.warning("%s scenario failed: %s", scenario.name, exc) + rows.append(_failed_row(scenario.name, exc)) + else: + rows.append( + _metrics_row( + scenario.name, result.returns, periods_per_year, risk_free_rate + ) + ) completed_scenarios += 1 _checkpoint() if on_progress is not None: on_progress(completed_scenarios, total_scenarios) + position += 1 - if completed_scenarios < 5: - delayed = run_backtest_from_config(data, config, execution_delay=1) - rows.append( - _metrics_row( - "execution delay +1", - delayed.returns, - periods_per_year, - risk_free_rate, - ) - ) - completed_scenarios += 1 - _checkpoint() - if on_progress is not None: - on_progress(completed_scenarios, total_scenarios) + for name, delay in delay_scenarios: + if completed_scenarios < position: + try: + delayed = run_backtest_from_config(data, config, execution_delay=delay) + except QuantLabError as exc: + logger.warning("%s scenario failed: %s", name, exc) + rows.append(_failed_row(name, exc)) + else: + rows.append( + _metrics_row( + name, delayed.returns, periods_per_year, risk_free_rate + ) + ) + completed_scenarios += 1 + _checkpoint() + if on_progress is not None: + on_progress(completed_scenarios, total_scenarios) + position += 1 - if completed_scenarios < 6: - assert baseline_returns is not None - rows.append( - _metrics_row( - "best 10 days removed", - remove_best_days(baseline_returns, 10), - periods_per_year, - risk_free_rate, - ) - ) - completed_scenarios += 1 - _checkpoint() - if on_progress is not None: - on_progress(completed_scenarios, total_scenarios) + for name, n in best_days_scenarios: + if completed_scenarios < position: + # completed_scenarios >= 1 guarantees baseline_returns is set. + assert baseline_returns is not None + try: + scenario_returns = remove_best_days(baseline_returns, n) + except QuantLabError as exc: + logger.warning("%s scenario failed: %s", name, exc) + rows.append(_failed_row(name, exc)) + else: + rows.append( + _metrics_row( + name, scenario_returns, periods_per_year, risk_free_rate + ) + ) + completed_scenarios += 1 + _checkpoint() + if on_progress is not None: + on_progress(completed_scenarios, total_scenarios) + position += 1 - if reduced_universe and completed_scenarios < 7: - reduced_symbols = config.symbols[:-1] - reduced_instruments = [ - instrument - for instrument in config.data.instruments - if instrument.symbol in reduced_symbols - ] - data_config = config.data.revalidated_copy( - update={"instruments": reduced_instruments} - ) - reduced_config = config.revalidated_copy(update={"data": data_config}) - required_symbols = set(reduced_symbols) - if config.benchmark_symbol is not None: - required_symbols.add(config.benchmark_symbol) - subset = data[data[SYMBOL].isin(required_symbols)].reset_index(drop=True) - try: - result = run_backtest_from_config(subset, reduced_config) - except QuantLabError as exc: - logger.warning("Reduced-universe scenario failed: %s", exc) - rows.append(_failed_row("reduced universe", exc)) - else: - rows.append( - _metrics_row( - "reduced universe", - result.returns, - periods_per_year, - risk_free_rate, + for name, count in universe_scenarios: + if completed_scenarios < position: + if not _universe_reduction_is_feasible(config, count): + rows.append( + _failed_row( + name, + QuantLabError( + f"Universe has only {len(config.symbols)} symbols; " + f"removing {count} would leave fewer than 2 tradable." + ), + ) ) - ) - completed_scenarios += 1 - _checkpoint() - if on_progress is not None: - on_progress(completed_scenarios, total_scenarios) + else: + try: + reduced_symbols = config.symbols[:-count] + reduced_instruments = [ + instrument + for instrument in config.data.instruments + if instrument.symbol in reduced_symbols + ] + data_config = config.data.revalidated_copy( + update={"instruments": reduced_instruments} + ) + reduced_config = config.revalidated_copy( + update={"data": data_config} + ) + required_symbols = set(reduced_symbols) + if config.benchmark_symbol is not None: + required_symbols.add(config.benchmark_symbol) + subset = data[data[SYMBOL].isin(required_symbols)].reset_index( + drop=True + ) + result = run_backtest_from_config(subset, reduced_config) + except QuantLabError as exc: + logger.warning("%s scenario failed: %s", name, exc) + rows.append(_failed_row(name, exc)) + else: + rows.append( + _metrics_row( + name, result.returns, periods_per_year, risk_free_rate + ) + ) + completed_scenarios += 1 + _checkpoint() + if on_progress is not None: + on_progress(completed_scenarios, total_scenarios) + position += 1 if checkpoint_path is not None: clear_checkpoint(checkpoint_path) @@ -403,27 +534,32 @@ def run_walk_forward_stress_tests( ) -> pd.DataFrame: """Re-run the whole walk-forward process under cost/delay/universe stress. - Unlike :func:`run_stress_tests`, every scenario except "best 10 days + Every scenario's magnitude comes from ``config.robustness.stress_test`` + (see :func:`run_stress_tests` and :class:`~quantlab.config. + StressTestSettings`) -- each a list, so more than one magnitude can be + evaluated per scenario type; an empty list disables that scenario type + entirely. + + Unlike :func:`run_stress_tests`, every scenario except "best N days removed" re-evaluates parameter selection under the scenario's perturbation instead of a single plain backtest — so a Walk-forward mode's Robustness numbers never silently come from a different validation method than the one currently in effect. - The three cost-only scenarios ("commission x2", "commission x5", - "slippage x2") never change signals or portfolio allocation — only the - accounting step depends on execution costs — so they share a single - :class:`~quantlab.validation.walk_forward.WalkForwardWeightCache` built - once from the baseline config, cheaply re-scoring each fold's cached - candidates under the new costs via + Every commission/slippage scenario never changes signals or portfolio + allocation — only the accounting step depends on execution costs — so + they all share a single :class:`~quantlab.validation.walk_forward. + WalkForwardWeightCache` built once from the baseline config, cheaply + re-scoring each fold's cached candidates under the new costs via :meth:`~quantlab.validation.walk_forward.WalkForwardValidator. rescore_with_costs` instead of re-running signal generation and - allocation three more times. "Execution delay +1" and "reduced - universe" genuinely change the weights themselves (the delay shift and - the tradable universe respectively), so they still re-execute + allocation once per scenario. Every execution-delay and reduced-universe + scenario genuinely changes the weights themselves (the delay shift and + the tradable universe respectively), so each still re-executes :class:`~quantlab.validation.walk_forward.WalkForwardValidator` end to - end. "Best 10 days removed" stays a post-hoc transform of the - already-realised baseline OOS returns: it changes no configuration, so - re-running walk-forward would only waste time reproducing the exact + end. Every "best N days removed" scenario stays a post-hoc transform of + the already-realised baseline OOS returns: it changes no configuration, + so re-running walk-forward would only waste time reproducing the exact same selection. Args: @@ -431,20 +567,20 @@ def run_walk_forward_stress_tests( config: The baseline experiment config (``validation.method`` must be ``"walk_forward"``). wf_baseline: The already-computed baseline ``WalkForwardResult``, - reused for "baseline" and "best 10 days removed". + reused for "baseline" and every "best N days removed" scenario. on_progress: Optional callback invoked as ``on_progress(done, total)`` once before any work starts and once after each unit of work completes: one tick per candidate (folds x grid size) while the - weight cache used by the three cost-only scenarios is + weight cache used by the commission/slippage scenarios is (re)built — the bulk of the total cost — then one tick per - remaining scenario ("execution delay +1" and "reduced universe" - are each a full walk-forward run; the three cost-only rescores - and "best 10 days removed" are - cheap). + remaining scenario (each execution-delay/reduced-universe + scenario is a full walk-forward run; the commission/slippage + rescores and best-N-days-removed scenarios are cheap). checkpoint_path: Optional path to persist progress to, so an interrupted run resumes instead of starting over — at the - scenario-block level (baseline / [cache-build + the 3 cost-only - rescores] / execution-delay+1 / best-10-days / reduced-universe) + scenario-block level (baseline / [cache-build + every + commission/slippage rescore] / every execution-delay scenario / + every best-N-days scenario / every reduced-universe scenario) for this function's own progress, plus a separate, nested checkpoint for the cache-build block specifically (passed through to :meth:`~quantlab.validation.walk_forward. @@ -500,7 +636,9 @@ def run_walk_forward_stress_tests( ) periods_per_year = positive_int(config.periods_per_year, name="periods_per_year") risk_free_rate = finite_real(config.risk_free_rate, name="risk_free_rate") - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, step = resolve_walk_forward_windows( + config + ) expanding = config.validation.expanding grid = parameter_grid_for_config(config) @@ -520,6 +658,7 @@ def run_walk_forward_stress_tests( "train_window": train_window, "validation_window": validation_window, "test_window": test_window, + "step": step, "expanding": expanding, } if baseline_windows != expected_windows: @@ -536,18 +675,24 @@ def run_walk_forward_stress_tests( f"its own recorded walk_forward_parameter_grid {baseline_grid!r} " f"does not match {grid!r} derived from `config`." ) - # The "execution delay +1" scenario below is only meaningful relative to - # a zero-delay baseline; a baseline already run with a non-zero delay - # would need "+1" to mean "one more than the baseline's own", not a - # hardcoded absolute 1. - baseline_delay = wf_baseline.oos_result.metadata.get("walk_forward_execution_delay") - if baseline_delay != 0: - raise ValueError( - "wf_baseline was not built with execution_delay=0: its own " - f"recorded walk_forward_execution_delay is {baseline_delay!r}, " - "so this call's 'execution delay +1' scenario cannot be " - "compared against it." + # Every configured execution-delay scenario below is a delay relative to + # the baseline's own delay; a baseline already run with a non-zero delay + # would make "delay +N" mean "N more than the baseline's own", not a + # delay of exactly N. Only checked when a delay scenario is actually + # configured -- an unrelated non-zero baseline delay is not this + # function's problem when no delay scenario will ever use it. + delay_scenarios = _execution_delay_scenarios(config) + if delay_scenarios: + baseline_delay = wf_baseline.oos_result.metadata.get( + "walk_forward_execution_delay" ) + if baseline_delay != 0: + raise ValueError( + "wf_baseline was not built with execution_delay=0: its own " + f"recorded walk_forward_execution_delay is {baseline_delay!r}, " + "so this call's configured execution-delay scenarios cannot " + "be compared against it." + ) def _run_walk_forward( scenario_config: ExperimentConfig, @@ -563,20 +708,25 @@ def _run_walk_forward( validation_window=validation_window, test_window=test_window, expanding=expanding, + step=step, execution_delay=execution_delay, ) - cost_scenarios = { - "commission x2": scale_costs(config, commission_mult=2.0), - "commission x5": scale_costs(config, commission_mult=5.0), - "slippage x2": scale_costs(config, slippage_mult=2.0), - } - reduced_universe = len(config.symbols) > 2 + cost_scenarios = _cost_scenarios(config) + best_days_scenarios = _best_days_removed_scenarios(config) + universe_scenarios = _reduced_universe_scenarios(config) n_baseline_folds = len(wf_baseline.folds) n_combinations = math.prod(len(values) for values in grid.values()) if grid else 1 - n_cache_units = n_baseline_folds * n_combinations + # No cost scenario means the weight cache below is never consulted -- + # skip counting (and later, building) it entirely rather than paying for + # the second-most-expensive step of this whole function for nothing. + n_cache_units = (n_baseline_folds * n_combinations) if cost_scenarios else 0 total_units = ( - n_cache_units + len(cost_scenarios) + 2 + (1 if reduced_universe else 0) + n_cache_units + + len(cost_scenarios) + + len(delay_scenarios) + + len(best_days_scenarios) + + len(universe_scenarios) ) def _cache_progress(done: int, _total: int) -> None: @@ -584,48 +734,49 @@ def _cache_progress(done: int, _total: int) -> None: on_progress(done, total_units) # Scenario-block-level checkpoint (own file): baseline / [cache-build + - # the 3 cost-only rescores, as one block] / execution-delay+1 / - # best-10-days / reduced-universe. `rows` is the only state that needs - # to survive between blocks — every block computes from `config`/`data`/ - # `wf_baseline`, already covered by `provenance`, not from a prior - # block's output. + # every commission/slippage rescore, as one block] / every + # execution-delay scenario / every best-N-days scenario / every + # reduced-universe scenario -- always exactly 5 blocks, even when a + # scenario type's own list is empty (that block then simply appends no + # rows). `rows` is the only state that needs to survive between blocks + # — every block computes from `config`/`data`/`wf_baseline`, already + # covered by `provenance`, not from a prior block's output. rows: list[dict[str, object]] = [] provenance: dict[str, Any] | None = None cache_checkpoint_path: Path | None = None completed_blocks = 0 - total_blocks = 4 + (1 if reduced_universe else 0) + total_blocks = 5 + _block_row_counts = [ + 1, + len(cost_scenarios), + len(delay_scenarios), + len(best_days_scenarios), + len(universe_scenarios), + ] def _expected_block_row_count(progress: int) -> int: - """Return exactly how many rows each block count has appended. + """Return exactly how many rows the first ``progress`` blocks append. - Block 1 ("baseline") appends 1 row; block 2 appends one row per - cost-only scenario (``len(cost_scenarios)`` = 3: commission x2, - commission x5, slippage x2); blocks 3-5 ("execution delay +1", - "best 10 days removed", "reduced universe") each append exactly 1. - Not a simple linear formula in ``progress`` alone, but still fully + Not a simple linear formula in ``progress`` alone (each block can + append a different, configured number of rows), but still fully determined by it -- there is no ambiguity to fall back to a mere upper bound for. """ - if progress <= 0: - return 0 - if progress == 1: - return 1 - if progress == 2: - return 1 + len(cost_scenarios) - return 1 + len(cost_scenarios) + (progress - 2) + return sum(_block_row_counts[:progress]) # The exact scenario name each row must have, in order -- not just how # many rows there should be. Fixed by the block structure above: - # baseline, then the 3 cost-only rescores (in `cost_scenarios`' own - # order), then execution delay, best-10-days, and (if applicable) - # reduced universe. - _scenario_names_in_order = [ - "baseline", - *cost_scenarios.keys(), - "execution delay +1", - "best 10 days removed", - "reduced universe", - ] + # baseline, then every commission/slippage rescore (in `cost_scenarios`' + # own order), then every execution-delay, best-N-days and + # reduced-universe scenario. + _scenario_names_in_order = ( + ["baseline"] + + [scenario.name for scenario in cost_scenarios] + + [name for name, _ in delay_scenarios] + + [name for name, _ in best_days_scenarios] + + [name for name, _ in universe_scenarios] + ) + _ensure_unique_scenario_names(_scenario_names_in_order) def _validate_block_state(state: Any, progress: int) -> bool: # A structurally-plausible-but-incoherent checkpoint (e.g. @@ -680,7 +831,12 @@ def _validate_block_state(state: Any, progress: int) -> bool: completed_units = 0 if completed_blocks >= 2: completed_units += n_cache_units + len(cost_scenarios) - completed_units += max(0, completed_blocks - 2) + if completed_blocks >= 3: + completed_units += len(delay_scenarios) + if completed_blocks >= 4: + completed_units += len(best_days_scenarios) + if completed_blocks >= 5: + completed_units += len(universe_scenarios) if on_progress is not None and completed_blocks >= 2: # Otherwise block 2 is about to run (fresh or resumed) and # run_with_weight_cache() below already emits its own accurate @@ -707,43 +863,60 @@ def _checkpoint_block() -> None: _checkpoint_block() if completed_blocks < 2: - # Signals and portfolio allocation never depend on execution costs, - # so build the per-candidate weight cache once (this is where the - # candidate-level progress reported above comes from — finer-grained - # than one tick per fold, since this build does roughly twice the - # work of a plain walk-forward fold) and reuse it for every - # cost-only scenario below instead of paying for a full re-run each. - # This block gets its own nested checkpoint (see the docstring): - # it's the expensive one, and the whole point of the weight-cache - # optimisation is not having to redo it. - validator = WalkForwardValidator(config) - _, weight_cache = validator.run_with_weight_cache( - data, - parameter_grid=grid, - train_window=train_window, - validation_window=validation_window, - test_window=test_window, - expanding=expanding, - execution_delay=0, - on_progress=_cache_progress, - checkpoint_path=cache_checkpoint_path, - ) - # No separate on_progress call needed here: run_with_weight_cache's - # own ticks (via _cache_progress) already reached completed_units == - # n_cache_units by the time it returns. - completed_units = n_cache_units - - for name, scenario_config in cost_scenarios.items(): - wf = validator.rescore_with_costs(weight_cache, scenario_config) - assert wf.oos_result is not None # same data/windows as the baseline - rows.append( - _metrics_row( - name, wf.oos_result.returns, periods_per_year, risk_free_rate - ) + if cost_scenarios: + # Signals and portfolio allocation never depend on execution + # costs, so build the per-candidate weight cache once (this is + # where the candidate-level progress reported above comes from — + # finer-grained than one tick per fold, since this build does + # roughly twice the work of a plain walk-forward fold) and reuse + # it for every cost-only scenario below instead of paying for a + # full re-run each. This block gets its own nested checkpoint + # (see the docstring): it's the expensive one, and the whole + # point of the weight-cache optimisation is not having to redo + # it. Skipped entirely when there is no cost scenario to use it + # for -- building it would waste this function's second-most + # expensive step on nothing. + validator = WalkForwardValidator(config) + _, weight_cache = validator.run_with_weight_cache( + data, + parameter_grid=grid, + train_window=train_window, + validation_window=validation_window, + test_window=test_window, + expanding=expanding, + step=step, + execution_delay=0, + on_progress=_cache_progress, + checkpoint_path=cache_checkpoint_path, ) - completed_units += 1 - if on_progress is not None: - on_progress(completed_units, total_units) + # No separate on_progress call needed here: run_with_weight_ + # cache's own ticks (via _cache_progress) already reached + # completed_units == n_cache_units by the time it returns. + completed_units = n_cache_units + + for scenario in cost_scenarios: + scenario_config = _cost_scenario_config(config, scenario) + wf = validator.rescore_with_costs(weight_cache, scenario_config) + if wf.oos_result is None: + # Rescoring reuses the baseline's own already-fitted + # folds/windows, so this should be unreachable -- a hard + # invariant violation, not an expected external failure + # to report as a "failed" row. + raise QuantLabError( + f"{scenario.name}: rescore_with_costs produced no OOS " + "result despite reusing the baseline's own windows." + ) + rows.append( + _metrics_row( + scenario.name, + wf.oos_result.returns, + periods_per_year, + risk_free_rate, + ) + ) + completed_units += 1 + if on_progress is not None: + on_progress(completed_units, total_units) completed_blocks += 1 if cache_checkpoint_path is not None: clear_checkpoint(cache_checkpoint_path) @@ -752,76 +925,106 @@ def _checkpoint_block() -> None: # seeded correctly above, nothing left to recompute for this block. if completed_blocks < 3: - delayed = _run_walk_forward(config, data, grid, execution_delay=1) - assert delayed.oos_result is not None - rows.append( - _metrics_row( - "execution delay +1", - delayed.oos_result.returns, - periods_per_year, - risk_free_rate, - ) - ) - completed_units += 1 + for name, delay in delay_scenarios: + try: + delayed = _run_walk_forward(config, data, grid, execution_delay=delay) + if delayed.oos_result is None: + raise QuantLabError( + f"{name}: no walk-forward fold fit under this delay." + ) + except QuantLabError as exc: + logger.warning("%s scenario failed: %s", name, exc) + rows.append(_failed_row(name, exc)) + else: + rows.append( + _metrics_row( + name, + delayed.oos_result.returns, + periods_per_year, + risk_free_rate, + ) + ) + completed_units += 1 + if on_progress is not None: + on_progress(completed_units, total_units) completed_blocks += 1 _checkpoint_block() - if on_progress is not None: - on_progress(completed_units, total_units) if completed_blocks < 4: - rows.append( - _metrics_row( - "best 10 days removed", - remove_best_days(wf_baseline.oos_result.returns, 10), - periods_per_year, - risk_free_rate, - ) - ) - completed_units += 1 + for name, n in best_days_scenarios: + try: + scenario_returns = remove_best_days(wf_baseline.oos_result.returns, n) + except QuantLabError as exc: + logger.warning("%s scenario failed: %s", name, exc) + rows.append(_failed_row(name, exc)) + else: + rows.append( + _metrics_row( + name, scenario_returns, periods_per_year, risk_free_rate + ) + ) + completed_units += 1 + if on_progress is not None: + on_progress(completed_units, total_units) completed_blocks += 1 _checkpoint_block() - if on_progress is not None: - on_progress(completed_units, total_units) - - if reduced_universe and completed_blocks < 5: - reduced_symbols = config.symbols[:-1] - reduced_instruments = [ - instrument - for instrument in config.data.instruments - if instrument.symbol in reduced_symbols - ] - data_config = config.data.revalidated_copy( - update={"instruments": reduced_instruments} - ) - reduced_config = config.revalidated_copy(update={"data": data_config}) - required_symbols = set(reduced_symbols) - if config.benchmark_symbol is not None: - required_symbols.add(config.benchmark_symbol) - subset = data[data[SYMBOL].isin(required_symbols)].reset_index(drop=True) - try: - reduced_grid = parameter_grid_for_config(reduced_config) - wf_reduced = _run_walk_forward(reduced_config, subset, reduced_grid) - if wf_reduced.oos_result is None: - raise QuantLabError( - "No walk-forward fold fit the reduced universe's history." + + if completed_blocks < 5: + for name, count in universe_scenarios: + if not _universe_reduction_is_feasible(config, count): + rows.append( + _failed_row( + name, + QuantLabError( + f"Universe has only {len(config.symbols)} symbols; " + f"removing {count} would leave fewer than 2 tradable." + ), + ) ) - except QuantLabError as exc: - logger.warning("Reduced-universe walk-forward scenario failed: %s", exc) - rows.append(_failed_row("reduced universe", exc)) - else: - rows.append( - _metrics_row( - "reduced universe", - wf_reduced.oos_result.returns, - periods_per_year, - risk_free_rate, + completed_units += 1 + if on_progress is not None: + on_progress(completed_units, total_units) + continue + reduced_symbols = config.symbols[:-count] + reduced_instruments = [ + instrument + for instrument in config.data.instruments + if instrument.symbol in reduced_symbols + ] + try: + data_config = config.data.revalidated_copy( + update={"instruments": reduced_instruments} ) - ) - completed_units += 1 + reduced_config = config.revalidated_copy(update={"data": data_config}) + required_symbols = set(reduced_symbols) + if config.benchmark_symbol is not None: + required_symbols.add(config.benchmark_symbol) + subset = data[data[SYMBOL].isin(required_symbols)].reset_index( + drop=True + ) + reduced_grid = parameter_grid_for_config(reduced_config) + wf_reduced = _run_walk_forward(reduced_config, subset, reduced_grid) + if wf_reduced.oos_result is None: + raise QuantLabError( + "No walk-forward fold fit the reduced universe's history." + ) + except QuantLabError as exc: + logger.warning("Reduced-universe walk-forward scenario failed: %s", exc) + rows.append(_failed_row(name, exc)) + else: + rows.append( + _metrics_row( + name, + wf_reduced.oos_result.returns, + periods_per_year, + risk_free_rate, + ) + ) + completed_units += 1 + if on_progress is not None: + on_progress(completed_units, total_units) completed_blocks += 1 _checkpoint_block() - if on_progress is not None: - on_progress(completed_units, total_units) if checkpoint_path is not None: clear_checkpoint(checkpoint_path) diff --git a/src/quantlab/validation/splits.py b/src/quantlab/validation/splits.py index 5a1e9e5..1c04540 100644 --- a/src/quantlab/validation/splits.py +++ b/src/quantlab/validation/splits.py @@ -87,15 +87,30 @@ def walk_forward_windows( test_window: int, *, expanding: bool = True, + step: int | None = None, ) -> list[WalkForwardWindow]: """Generate contiguous rolling or expanding walk-forward folds. - Test blocks advance by ``test_window`` and therefore never overlap. + Each fold's train window starts ``step`` periods after the previous + fold's (default: ``step = test_window``, the original "test blocks + advance by test_window and never overlap" behaviour). A ``step`` + smaller than ``test_window`` makes consecutive test blocks overlap + (denser evaluation). ``step`` must not exceed ``test_window``: a larger + step would skip observations between folds, leaving the stitched OOS + curve with gaps that CAGR/annualisation (which assume regularly spaced + observations) would silently misread as a shorter elapsed time. """ _validate_datetime_index(index) train_window = _validate_window(train_window, name="train_window") validation_window = _validate_window(validation_window, name="validation_window") test_window = _validate_window(test_window, name="test_window") + step = test_window if step is None else _validate_window(step, name="step") + if step > test_window: + raise InvalidConfigurationError( + f"step ({step}) must not exceed test_window ({test_window}) -- a " + "larger step would skip observations between folds, which " + "CAGR/annualisation cannot account for." + ) if not isinstance(expanding, (bool, np.bool_)): raise InvalidConfigurationError("expanding must be a boolean.") expanding = bool(expanding) @@ -120,7 +135,7 @@ def walk_forward_windows( ) ) fold += 1 - start += test_window + start += step return folds diff --git a/src/quantlab/validation/walk_forward.py b/src/quantlab/validation/walk_forward.py index 153f2a1..aeaba4a 100644 --- a/src/quantlab/validation/walk_forward.py +++ b/src/quantlab/validation/walk_forward.py @@ -36,7 +36,7 @@ run_backtest_from_config, ) from quantlab.backtesting.trade_log import build_trade_log -from quantlab.config import BenchmarkKind, ExperimentConfig +from quantlab.config import BenchmarkKind, ExperimentConfig, RebalanceFrequency from quantlab.constants import SYMBOL from quantlab.data.base import price_matrix from quantlab.data.calendar import uniform_calendar @@ -44,6 +44,7 @@ from quantlab.data.storage import ParquetStorage from quantlab.exceptions import InvalidConfigurationError from quantlab.execution.execution_model import ExecutionModel +from quantlab.execution.orders import executed_weights as compute_executed_weights from quantlab.execution.orders import shift_respecting_tradability from quantlab.logging_config import get_logger from quantlab.portfolio.rebalancing import ( @@ -257,6 +258,7 @@ class WalkForwardWeightCache: train_window: int validation_window: int test_window: int + step: int expanding: bool execution_delay: int #: The config this cache's weights/candidates were actually computed @@ -268,16 +270,22 @@ class WalkForwardWeightCache: base_config: ExperimentConfig -def resolve_walk_forward_windows(config: ExperimentConfig) -> tuple[int, int, int]: - """Resolve train/validation/test windows, applying the documented default. +def resolve_walk_forward_windows( + config: ExperimentConfig, +) -> tuple[int, int, int, int]: + """Resolve train/validation/test/step windows, applying documented defaults. Shared by the CLI, dashboard and walk-forward-aware robustness/sensitivity - functions so the 500/126/126 fallback lives in exactly one place. + functions so the 500/126/126 fallback (and ``step``'s own default of + "equal to the resolved test_window", reproducing the original + non-overlapping-folds behaviour) lives in exactly one place. """ + test_window = config.validation.test_window or 126 return ( config.validation.train_window or 500, config.validation.validation_window or 126, - config.validation.test_window or 126, + test_window, + config.validation.step or test_window, ) @@ -305,6 +313,7 @@ class _PreparedWalkForward: scorer: Callable[[pd.Series, pd.Series, int, float], float] grid: dict[str, Sequence[Any]] delay: int + step: int class WalkForwardValidator: @@ -324,6 +333,7 @@ def run( test_window: int, *, expanding: bool = True, + step: int | None = None, execution_delay: int = 0, on_progress: Callable[[int, int], None] | None = None, checkpoint_path: Path | None = None, @@ -340,6 +350,12 @@ def run( test_window: Out-of-sample test periods per fold. expanding: Grow the training window across folds instead of sliding it. + step: Periods to advance between consecutive folds' train + windows. ``None`` (default) equals ``test_window``, so test + blocks are contiguous and never overlap. A smaller step + overlaps test blocks; ``step`` must not exceed + ``test_window`` (a larger step would leave gaps that + CAGR/annualisation cannot account for). on_progress: Optional callback invoked as ``on_progress(done, total)`` once before the first candidate (``done=0``, or the resumed count if ``checkpoint_path`` supplied a partial run) @@ -373,8 +389,10 @@ def run( validation_window, test_window, expanding=expanding, + step=step, execution_delay=execution_delay, ) + step = prepared.step fold_windows: list[WalkForwardWindow] = [] fold_parameters: list[dict[str, Any]] = [] @@ -449,6 +467,7 @@ def _validate_fold_state(state: Any, progress: int) -> bool: train_window=train_window, validation_window=validation_window, test_window=test_window, + step=step, expanding=expanding, execution_delay=execution_delay, parameter_grid={k: list(v) for k, v in parameter_grid.items()}, @@ -522,6 +541,7 @@ def _tick() -> None: train_window, validation_window, test_window, + step, expanding, started, ) @@ -538,6 +558,7 @@ def run_with_weight_cache( test_window: int, *, expanding: bool = True, + step: int | None = None, execution_delay: int = 0, on_progress: Callable[[int, int], None] | None = None, checkpoint_path: Path | None = None, @@ -575,8 +596,10 @@ def run_with_weight_cache( validation_window, test_window, expanding=expanding, + step=step, execution_delay=execution_delay, ) + step = prepared.step fold_windows: list[WalkForwardWindow] = [] fold_parameters: list[dict[str, Any]] = [] @@ -650,6 +673,7 @@ def _validate_cache_fold_state(state: Any, progress: int) -> bool: train_window=train_window, validation_window=validation_window, test_window=test_window, + step=step, expanding=expanding, execution_delay=execution_delay, parameter_grid={k: list(v) for k, v in parameter_grid.items()}, @@ -740,6 +764,7 @@ def _tick() -> None: train_window, validation_window, test_window, + step, expanding, started, ) @@ -752,6 +777,7 @@ def _tick() -> None: train_window=train_window, validation_window=validation_window, test_window=test_window, + step=step, expanding=expanding, execution_delay=prepared.delay, base_config=self.base_config, @@ -818,6 +844,10 @@ def rescore_with_costs( ) from exc tradable = cache.data[cache.data[SYMBOL].isin(set(scenario_config.symbols))] + accounting_kwargs = _accounting_kwargs_for_config(scenario_config) + shared_calendar = uniform_calendar( + instrument.calendar for instrument in scenario_config.data.instruments + ) fold_parameters: list[dict[str, Any]] = [] fold_scores: list[float] = [] target_pieces: list[pd.DataFrame] = [] @@ -852,6 +882,14 @@ def rescore_with_costs( execution_model, scenario_config.initial_capital, tradable=aligned_tradable, + rebalance_date=_rebalance_date_for_run_accounting( + candidate.validation_weights, + scenario_config.portfolio.rebalance_frequency, + shared_calendar, + aligned_tradable, + 0, + ), + **accounting_kwargs, ) equity = M.equity_from_returns(accounting.net_returns) score = scorer( @@ -887,6 +925,7 @@ def rescore_with_costs( cache.train_window, cache.validation_window, cache.test_window, + cache.step, cache.expanding, started, ) @@ -901,6 +940,7 @@ def _prepare( *, expanding: bool, execution_delay: int, + step: int | None = None, ) -> _PreparedWalkForward: """Validate inputs and compute what run() and run_with_weight_cache() share.""" if not isinstance(data, pd.DataFrame): @@ -938,18 +978,31 @@ def _prepare( "Walk-forward data is missing configured tradable symbol(s): " f"{missing_symbols}. Refusing to validate a reduced universe." ) + # train_window/validation_window/test_window are likewise never + # pre-validated here -- walk_forward_windows() below is the single + # place that validates every window parameter, `step` included. index = pd.DatetimeIndex(price_matrix(tradable, adjusted=True).index) windows = walk_forward_windows( - index, train_window, validation_window, test_window, expanding=expanding + index, + train_window, + validation_window, + test_window, + expanding=expanding, + step=step, ) + # None resolves to test_window, matching walk_forward_windows()'s own + # default -- read back here (rather than reconstructed independently) + # only after the call above has already validated it. + resolved_step = test_window if step is None else int(step) if not windows: logger.warning( "No walk-forward windows fit in %d observations with " - "train=%d validation=%d test=%d.", + "train=%d validation=%d test=%d step=%d.", len(index), train_window, validation_window, test_window, + resolved_step, ) combinations = _grid_combinations(grid) @@ -975,6 +1028,7 @@ def _prepare( scorer=scorer, grid=grid, delay=delay, + step=resolved_step, ) def _finalize( @@ -993,6 +1047,7 @@ def _finalize( train_window: int, validation_window: int, test_window: int, + step: int, expanding: bool, started: float, ) -> WalkForwardResult: @@ -1008,22 +1063,49 @@ def _finalize( # Applying rebalancing, turnover and accounting once preserves state # and transaction costs across fold boundaries. if target_pieces: - all_targets = pd.concat(target_pieces).sort_index() - if not all_targets.index.is_unique: + all_targets = pd.concat(target_pieces) + if step < test_window: + # An intentionally smaller step overlaps consecutive test + # blocks by design (see walk_forward_windows()'s docstring) + # -- target_pieces is in fold order, so keeping the LAST + # occurrence of a repeated date keeps the most recent + # fold's (more up-to-date training data) decision for it, + # done here, before sort_index() below, while "last in the + # concatenation" still unambiguously means "from the + # highest-numbered fold" (a stable post-sort tiebreak is + # not guaranteed). + all_targets = all_targets[~all_targets.index.duplicated(keep="last")] + elif not all_targets.index.is_unique: duplicates = all_targets.index[all_targets.index.duplicated()].unique() raise InvalidConfigurationError( "Walk-forward test blocks overlap; duplicate target dates: " f"{list(duplicates[:5])}." ) + all_targets = all_targets.sort_index() tradable_mask = _tradable_mask_if_mixed_calendar( active_config, pd.DatetimeIndex(all_targets.index) ) shared_calendar = uniform_calendar( instrument.calendar for instrument in active_config.data.instruments ) + # Mirrors engine.py's own `decision_portfolio_config`: when + # weight drift is enabled, `apply_weight_drift` (via + # run_accounting, below) is the sole place `maximum_turnover` + # is applied -- capping it here too would hand the drift layer + # an already-capped intermediate target instead of the true + # schedule target, letting it trade the portfolio backward + # toward that stale value once organic drift has carried it + # past it. + decision_portfolio_config = ( + active_config.portfolio.revalidated_copy( + update={"maximum_turnover": None} + ) + if active_config.portfolio.model_weight_drift + else active_config.portfolio + ) all_weights = rebalance_and_cap_turnover( all_targets, - active_config.portfolio, + decision_portfolio_config, tradable=tradable_mask, calendar=shared_calendar, ) @@ -1061,6 +1143,24 @@ def _finalize( ) aligned_starts.append(all_weights.index[execution_location]) + for previous_window, window, previous_start, start in zip( + fold_windows, + fold_windows[1:], + aligned_starts, + aligned_starts[1:], + strict=False, + ): + if start <= previous_start: + raise InvalidConfigurationError( + f"Fold {window.fold}'s first execution date ({start.date()}) " + f"does not come after fold {previous_window.fold}'s " + f"({previous_start.date()}) -- a small step combined with " + "infrequent rebalancing collapsed these folds' reporting " + "windows onto each other, which would attribute zero (or " + "the wrong) observations to one of them; increase step or " + "rebalance more often." + ) + prices = price_matrix(tradable, adjusted=True) asset_returns = compute_asset_returns(prices).reindex(all_weights.index) execution_model = build_execution_from_config(active_config, data) @@ -1075,10 +1175,10 @@ def _finalize( if delay > 0: if aligned_tradable_mask is not None: # A raw row-count shift would delay execution onto a - # date a symbol can't actually trade on -- same bug the - # mandatory look-ahead-barrier shift inside - # run_accounting avoids, so the extra configured delay - # must avoid it too. + # date a symbol can't actually trade on -- the mandatory + # look-ahead-barrier shift inside run_accounting avoids + # exactly this, and the extra configured delay must + # avoid it too. executed_weights = shift_respecting_tradability( executed_weights, delay, aligned_tradable_mask ).fillna(0.0) @@ -1090,6 +1190,14 @@ def _finalize( execution_model, active_config.initial_capital, tradable=aligned_tradable_mask, + rebalance_date=_rebalance_date_for_run_accounting( + all_weights, + active_config.portfolio.rebalance_frequency, + shared_calendar, + aligned_tradable_mask, + delay, + ), + **_accounting_kwargs_for_config(active_config), ) oos_returns = accounting.net_returns oos_equity = accounting.equity @@ -1109,6 +1217,7 @@ def _finalize( train_window, validation_window, test_window, + step, expanding, ) else: @@ -1171,6 +1280,7 @@ def _build_oos_result( train_window: int, validation_window: int, test_window: int, + step: int, expanding: bool, ) -> BacktestResult: """Build a genuine BacktestResult from the stitched OOS series. @@ -1184,6 +1294,16 @@ def _build_oos_result( or a cost-scenario config from :meth:`rescore_with_costs`), so its cost fields and metadata describe what was actually run. """ + # No trigger_*/adjustment_*/position_strategy_origin_* provenance is + # passed here: attribution is unavailable in this aggregated + # walk-forward reconstruction. The stitched out-of-sample series is + # rebuilt from per-fold weights, not from a single engine run's + # diagnostic frames -- each fold reruns the pipeline independently + # with its own warmup/fit, and those frames do not survive the + # cut/restitch across folds. `build_trade_log` leaves the reason + # columns `None`/`NaT` whenever these kwargs are omitted (its + # documented legacy/no-attribution path), which is correct here, not + # a bug to fix. trades = build_trade_log( accounting.executed_weights, accounting.weight_changes, @@ -1271,8 +1391,27 @@ def _build_oos_result( "train_window": train_window, "validation_window": validation_window, "test_window": test_window, + "step": step, "expanding": expanding, }, + # Consolidated view for full reproducibility -- mirrors the two + # keys just above (never a replacement for them: other code + # reads those by their own exact name, see e.g. + # `load_previous_walk_forward_robustness`). `result.save()` + # later adds `signal_price_type` to this same dict. + "resolved_config": { + "walk_forward": { + "train_window": train_window, + "validation_window": validation_window, + "test_window": test_window, + "step": step, + "expanding": expanding, + "parameter_grid": dict(grid), + "optimization_metric": str( + active_config.validation.optimization_metric + ), + } + }, } # Mirrored under these exact keys because # `load_previous_walk_forward_robustness` (quantlab.backtesting. @@ -1548,6 +1687,90 @@ def _tradable_mask_if_mixed_calendar( return tradable_mask_for(index, config.symbols, symbol_calendars) +def _accounting_kwargs_for_config(config: ExperimentConfig) -> dict[str, Any]: + """The non-``tradable`` ``run_accounting`` keyword arguments a full run would use. + + Mirrors :class:`~quantlab.backtesting.engine.BacktestEngine`'s own call + exactly (including its ``maximum_gross_exposure``/``maximum_leverage`` + combined-cap convention). Every accounting call site in this module + must use this (candidate scoring, cost-only rescoring, final OOS + stitching) so walk-forward's own numbers stay in parity with the main + backtest under the same YAML, instead of silently ignoring stop-loss/ + take-profit/position-groups/weight-drift/exposure-limit configuration. + """ + strategy = build_strategy_from_config(config) + portfolio = config.portfolio + maximum_gross_exposure = ( + min(portfolio.maximum_gross_exposure, portfolio.maximum_leverage) + if portfolio.maximum_gross_exposure is not None + else portfolio.maximum_leverage + ) + return { + "stop_loss_pct": strategy.stop_loss_pct, + "take_profit_pct": strategy.take_profit_pct, + "position_groups": strategy.position_groups(), + "model_weight_drift": portfolio.model_weight_drift, + "maximum_weight": portfolio.maximum_weight, + "maximum_gross_exposure": maximum_gross_exposure, + "maximum_net_exposure": portfolio.maximum_net_exposure, + "long_only": portfolio.long_only, + "maximum_turnover": portfolio.maximum_turnover, + } + + +def _rebalance_date_for_run_accounting( + decision_weights: pd.DataFrame, + frequency: RebalanceFrequency | str, + calendar: str | None, + tradable_mask: pd.DataFrame | None, + delay: int, +) -> pd.DataFrame: + """Boolean, aligned exactly like `run_accounting`'s own `rebalance_date`. + + Mirrors `BacktestEngine.run()`'s identical construction: a genuine + scheduled-rebalance-date flag, broadcast across every column at the + DECISION cadence, shifted onto the EXECUTED timeline the same way + ``decision_weights`` itself becomes executed (any extra configured + delay, then the mandatory look-ahead-barrier shift) -- required so + `apply_weight_drift`'s own anchor detection (see its docstring) can + catch a scheduled rebalance whose freshly-decided target happens to + numerically match the immediately preceding one, which plain + value-diffing `executed` against its own previous row cannot. + Per-column, NOT collapsed with `.any(axis=1)`: a closed instrument + must never be forced to anchor just because another instrument's own + schedule/value-change fires the same row. + """ + schedule = compute_rebalance_dates( + pd.DatetimeIndex(decision_weights.index), frequency, calendar=calendar + ) + is_rebalance_date = pd.DataFrame( + np.broadcast_to( + decision_weights.index.isin(schedule)[:, None], decision_weights.shape + ), + index=decision_weights.index, + columns=decision_weights.columns, + dtype=float, + ) + if delay > 0: + is_rebalance_date = ( + shift_respecting_tradability(is_rebalance_date, delay, tradable_mask) + if tradable_mask is not None + else is_rebalance_date.shift(delay) + ).fillna(0.0) + aligned = compute_executed_weights(is_rebalance_date, tradable=tradable_mask) + flag = aligned > 0.5 + # `compute_executed_weights` is built for *weights*, where a closed row + # correctly repeats the last tradable row's value. Applied to a boolean + # flag, that same repetition would keep it True for every row a column + # stays closed after it lands True once -- wrong for a flag, which must + # describe THIS row's own event. AND with `tradable_mask` so a closed + # row's flag is always False, matching apply_weight_drift's own + # documented precondition. + if tradable_mask is not None: + flag = flag & tradable_mask + return flag + + def _slice_between( data: pd.DataFrame, start: pd.Timestamp, end: pd.Timestamp ) -> pd.DataFrame: @@ -1625,12 +1848,31 @@ def _weights_and_returns_for_validation( if tradable_mask is not None else None ) + shared_calendar = uniform_calendar( + instrument.calendar for instrument in config.data.instruments + ) accounting = run_accounting( window_weights, asset_returns, execution_model, config.initial_capital, tradable=aligned_tradable, + # `window_weights` (from `_weights_for_window`, via + # `run_backtest_from_config(..., execution_delay=execution_delay)`) + # already has `execution_delay` baked in -- it IS + # `BacktestResult.weights`, itself already `_apply_extra_delay`-ed. + # Passing `execution_delay` again here would shift it a SECOND + # time; `delay=0` matches the sibling call site at this module's + # own candidate-scoring loop, which passes the same kind of + # already-delayed frame the same way. + rebalance_date=_rebalance_date_for_run_accounting( + window_weights, + config.portfolio.rebalance_frequency, + shared_calendar, + aligned_tradable, + 0, + ), + **_accounting_kwargs_for_config(config), ) return window_weights, accounting.net_returns @@ -1689,7 +1931,7 @@ def _minimum_observations_for_executable_weight(config: ExperimentConfig) -> int signal_observations = int(parameters["slow_window"]) elif strategy_name == "pairs_trading": signal_observations = int(parameters["formation_window"]) + int( - parameters["zscore_window"] + parameters["indicator_window"] ) allocator_observations = 1 diff --git a/tests/integration/test_backtest_pipeline.py b/tests/integration/test_backtest_pipeline.py index 2c61425..c9007ab 100644 --- a/tests/integration/test_backtest_pipeline.py +++ b/tests/integration/test_backtest_pipeline.py @@ -6,6 +6,8 @@ from __future__ import annotations +from typing import Any + import numpy as np import pandas as pd import pytest @@ -450,6 +452,500 @@ def test_all_configured_benchmark_kinds_run_end_to_end(benchmark_kind: str) -> N assert not result.benchmark_returns.isna().any() +def test_spy_portfolio_with_btcusdt_benchmark_runs_end_to_end() -> None: + """Regression guard for the originally-reported SPY portfolio / + BTCUSDT benchmark scenario (`MergeError: incompatible merge keys ... + dtype(' None: + """A BTCUSDT portfolio with a SPY benchmark must run cleanly even + though BTCUSDT trades on 2019-01-01, a date XNYS's own calendar marks + as a holiday closure -- SPY has no observation for it at all.""" + btc_prices = geometric_series(45, mu=0.001, sigma=0.02, s0=3000.0, seed=21) + btc = make_ohlcv("BTCUSDT", btc_prices, start="2019-01-01", freq="D") + spy_prices = geometric_series(45, mu=0.0005, sigma=0.008, s0=300.0, seed=22) + spy = make_ohlcv("SPY", spy_prices, start="2019-01-02", freq="B") + data = pd.concat([btc, spy], ignore_index=True) + + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "btc_spy_benchmark", + "data": { + "instruments": [ + {"symbol": "BTCUSDT", "source": "binance", "calendar": "24/7"} + ], + "start_date": "2019-01-01", + "end_date": "2019-02-10", + }, + "strategy": {"name": "buy_and_hold"}, + "portfolio": {"allocator": "equal_weight"}, + "execution": {}, + "backtest": { + "benchmark_kind": "symbol", + "benchmark": {"symbol": "SPY", "source": "yahoo", "calendar": "XNYS"}, + }, + } + ) + + result = run_backtest_from_config(data, cfg) + + assert result.benchmark_returns is not None + assert not result.benchmark_returns.isna().any() + assert result.benchmark_returns.iloc[0] == 0.0 + + +def test_model_weight_drift_never_trades_a_closed_instrument() -> None: + """A mixed-calendar portfolio (SPY on XNYS, BTCUSDT on 24/7) with + `model_weight_drift=True` must never record a trade for SPY on a date + XNYS's own calendar closes it -- even though BTCUSDT keeps trading, + and even on a monthly rebalance date that happens to land on a day + SPY itself is shut (e.g. 2019-01-01, New Year's Day). Regression + guard for the anchor-collapse bug: a scalar, whole-row anchor flag + used to force EVERY column to re-trade whenever ANY column anchored, + including a currently-closed one.""" + btc_prices = geometric_series(150, mu=0.001, sigma=0.02, s0=3000.0, seed=31) + btc = make_ohlcv("BTCUSDT", btc_prices, start="2019-01-01", freq="D") + spy_prices = geometric_series(150, mu=0.0005, sigma=0.008, s0=300.0, seed=32) + spy = make_ohlcv("SPY", spy_prices, start="2019-01-01", freq="D") + data = pd.concat([btc, spy], ignore_index=True) + + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "mixed_calendar_drift", + "data": { + "instruments": [ + {"symbol": "SPY", "source": "yahoo", "calendar": "XNYS"}, + {"symbol": "BTCUSDT", "source": "binance", "calendar": "24/7"}, + ], + "start_date": "2019-01-01", + "end_date": "2019-05-30", + }, + "strategy": {"name": "buy_and_hold"}, + "portfolio": { + "allocator": "equal_weight", + "rebalance_frequency": "monthly", + "model_weight_drift": True, + }, + "execution": {"commission_bps": 5.0}, + "backtest": {"initial_capital": 100_000, "periods_per_year": 252}, + } + ) + + result = run_backtest_from_config(data, cfg) + + assert np.isfinite(result.equity_curve.to_numpy()).all() + spy_trades = result.trades[result.trades["symbol"] == "SPY"] + assert len(spy_trades) > 0 # sanity: SPY does trade on its own open days + + from quantlab.data.calendar import is_session_day + + spy_trade_dates = pd.DatetimeIndex(spy_trades["timestamp"]) + open_mask = is_session_day("XNYS", spy_trade_dates) + assert open_mask.all(), ( + f"SPY has trade log rows on closed dates: " + f"{spy_trade_dates[~open_mask].tolist()}" + ) + + +def test_rebalance_date_flag_is_never_true_on_a_closed_row( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression test: `engine.py`'s `_align_bool` reused `executed_weights` + (built for *weights*, where a closed row correctly repeats the last + tradable row's frozen value) to align the `rebalance_date` boolean flag + onto the executed timeline. Applied to a flag instead of a weight, that + same repetition kept the flag True for every row a column stayed closed + right after a landing -- `apply_weight_drift`'s own documented + precondition explicitly forbids this, since it re-anchors ordinary + debt to a stale target and executes an unscheduled trade the moment the + column reopens. `rebalance_frequency=daily` deterministically triggers + the closure-adjacent pattern via SPY's own weekend closures (no + reliance on a specific holiday landing).""" + btc_prices = geometric_series(150, mu=0.001, sigma=0.02, s0=3000.0, seed=31) + btc = make_ohlcv("BTCUSDT", btc_prices, start="2019-01-01", freq="D") + spy_prices = geometric_series(150, mu=0.0005, sigma=0.008, s0=300.0, seed=32) + spy = make_ohlcv("SPY", spy_prices, start="2019-01-01", freq="D") + data = pd.concat([btc, spy], ignore_index=True) + + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "mixed_calendar_daily_rebalance", + "data": { + "instruments": [ + {"symbol": "SPY", "source": "yahoo", "calendar": "XNYS"}, + {"symbol": "BTCUSDT", "source": "binance", "calendar": "24/7"}, + ], + "start_date": "2019-01-01", + "end_date": "2019-05-30", + }, + "strategy": {"name": "buy_and_hold"}, + "portfolio": { + "allocator": "equal_weight", + "rebalance_frequency": "daily", + "model_weight_drift": True, + }, + "execution": {"commission_bps": 5.0}, + "backtest": {"initial_capital": 100_000, "periods_per_year": 252}, + } + ) + + import quantlab.backtesting.engine as engine_mod + + captured: dict[str, pd.DataFrame] = {} + orig_run_accounting = engine_mod.run_accounting + + def spy_run_accounting(*args: Any, **kwargs: Any) -> Any: + captured["tradable"] = kwargs["tradable"].copy() + captured["rebalance_date"] = kwargs["rebalance_date"].copy() + return orig_run_accounting(*args, **kwargs) + + monkeypatch.setattr(engine_mod, "run_accounting", spy_run_accounting) + + run_backtest_from_config(data, cfg) + + violation = captured["rebalance_date"] & ~captured["tradable"] + assert not violation.to_numpy().any(), ( + f"rebalance_date is True on a closed row: {violation[violation.any(axis=1)]}" + ) + + +def test_model_weight_drift_changes_returns_and_rebalances_on_schedule() -> None: + """`PortfolioConfig.model_weight_drift=True` (the default) must run + cleanly end-to-end through the full config-driven pipeline and produce + genuinely different (finite, sensible) equity from the legacy + constant-weight step function (`model_weight_drift=False`) -- the whole + point of the weight-drift feedback loop. + + Turnover must be exactly zero between scheduled rebalance dates -- + drift itself is never read back as a series of phantom trades, since + `apply_weight_drift`'s own `trade_changes` output separates real + trades from organic drift -- but NONZERO roughly once per scheduled + rebalance date, even for a buy-and-hold/equal_weight strategy whose + freshly-decided target NEVER numerically changes: real drift-driven + turnover snaps the portfolio back to target on schedule via + `rebalance_date`, rather than being silently absorbed into ongoing + drift just because the target number happens to match the previous + one. The legacy constant-weight baseline never rebalances at all past + its first anchor (a genuinely constant target has nothing to correct + back to), so drifted turnover is strictly higher in total. + """ + data = _panel() + base_cfg = _config({"name": "buy_and_hold"}) + legacy_cfg = base_cfg.revalidated_copy( + update={ + "portfolio": base_cfg.portfolio.revalidated_copy( + update={"model_weight_drift": False} + ) + } + ) + drift_cfg = base_cfg.revalidated_copy( + update={ + "portfolio": base_cfg.portfolio.revalidated_copy( + update={"model_weight_drift": True} + ) + } + ) + + baseline = run_backtest_from_config(data, legacy_cfg) + drifted = run_backtest_from_config(data, drift_cfg) + + assert np.isfinite(drifted.equity_curve.to_numpy()).all() + assert not drifted.equity_curve.equals(baseline.equity_curve) + assert drifted.metrics["annual_turnover"] > baseline.metrics["annual_turnover"] + + from quantlab.portfolio.rebalancing import rebalance_dates + + # One nonzero-turnover date per scheduled rebalance (the executed + # timeline is the decision timeline shifted forward by exactly one + # look-ahead-barrier row -- see quantlab.execution.orders. + # executed_weights) -- never one per drift row (that would be the + # phantom-turnover bug the drift/turnover separation in + # apply_weight_drift's own trade_changes output exists to prevent). + assert drifted.turnover is not None + schedule = rebalance_dates( + pd.DatetimeIndex(drifted.turnover.index), + base_cfg.portfolio.rebalance_frequency, + ) + nonzero_turnover_dates = drifted.turnover[drifted.turnover.abs() > 1e-9].index + assert len(nonzero_turnover_dates) == len(schedule) + + +@pytest.mark.parametrize( + ("frequency", "execution_delay"), + [ + ("daily", 0), + ("weekly", 0), + ("monthly", 1), + ("quarterly", 0), + ], +) +def test_model_weight_drift_never_exceeds_maximum_turnover( + frequency: str, execution_delay: int +) -> None: + """A drift-caused anchor catch-up (including a constant-target + schedule that never numerically changes -- see + `apply_weight_drift`'s own `rebalance_date`-forced-rebalance + mechanism) must never push realized turnover past + `PortfolioConfig.maximum_turnover`, across rebalance frequencies and + with a nonzero execution delay.""" + data = _panel() + cfg = _config({"name": "buy_and_hold"}) + cfg = cfg.revalidated_copy( + update={ + "portfolio": cfg.portfolio.revalidated_copy( + update={ + "model_weight_drift": True, + "rebalance_frequency": frequency, + "maximum_turnover": 0.05, + } + ) + } + ) + result = run_backtest_from_config(data, cfg, execution_delay=execution_delay) + assert result.turnover is not None + assert result.turnover.max() <= 0.05 + 1e-6 + + +def test_model_weight_drift_progresses_toward_the_true_target_never_backward() -> None: + """The executed position must climb monotonically toward the true + target at exactly the configured `maximum_turnover` cap rate, never + reversing, even once organic appreciation under + `model_weight_drift=True` has carried the real position past an + intermediate decision-level value. `maximum_turnover` must be applied + ONLY ONCE -- at the decision level (`rebalance_and_cap_turnover`) OR + in `apply_weight_drift`'s own ordinary-debt mechanism (the decision + layer's own cap is disabled whenever the drift layer owns it + instead), never both: double-applying it would hand the drift layer + an already-capped INTERMEDIATE target instead of the constant TRUE + target (1.0, for a single-instrument buy_and_hold), which the drift + layer would then treat as "the" target -- capable of trading the + portfolio BACKWARD, opposite the strategy's own direction, once + organic appreciation had already carried the real position past that + stale intermediate value.""" + prices = geometric_series(150, mu=0.01, sigma=0.0, s0=100.0, seed=1) + data = make_ohlcv("AAA", prices, start="2019-01-01") + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "drift_no_reversal", + "data": { + "instruments": [{"symbol": "AAA", "source": "csv", "calendar": "XNYS"}], + "start_date": "2019-01-01", + "end_date": "2019-12-01", + }, + "strategy": {"name": "buy_and_hold"}, + "portfolio": { + "allocator": "equal_weight", + "maximum_turnover": 0.1, + "model_weight_drift": True, + "rebalance_frequency": "monthly", + }, + "execution": { + "commission_bps": 0.0, + "spread_bps": 0.0, + "slippage_bps": 0.0, + }, + "backtest": {"initial_capital": 100_000, "benchmark_kind": "cash"}, + } + ) + + result = run_backtest_from_config(data, cfg) + + # The decision-level target is the constant true target throughout -- + # never a stale, already-capped intermediate value. + assert np.allclose(result.weights["AAA"].to_numpy(), 1.0) + positions = result.positions["AAA"] + assert positions.iloc[0] == pytest.approx(0.0) + # Every executed transaction progresses toward the true target: the + # position never decreases, and reaches it exactly once fully caught up. + assert (positions.diff().dropna() >= -1e-9).all() + assert positions.iloc[-1] == pytest.approx(1.0) + assert result.turnover is not None + assert result.turnover.max() <= 0.1 + 1e-6 + + +@pytest.mark.parametrize( + ("maximum_gross_exposure", "maximum_leverage", "expected_gross_cap"), + [ + (None, 1.5, 1.5), # unset -> maximum_leverage alone + (0.8, 1.5, 0.8), # maximum_gross_exposure is the tighter cap + (2.0, 1.5, 1.5), # maximum_leverage is the tighter cap + ], +) +def test_model_weight_drift_forwards_the_combined_gross_exposure_cap( + maximum_gross_exposure: float | None, + maximum_leverage: float, + expected_gross_cap: float, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """engine.py combines `maximum_gross_exposure`/`maximum_leverage` via + `min(...)` (or falls back to `maximum_leverage` alone when `maximum_ + gross_exposure` is unset) before forwarding into `run_accounting`'s own + `maximum_gross_exposure` -- the sole place the drift-compliance LP + reads it once `model_weight_drift` is active. Before this test, zero + coverage exercised this specific forwarding: a reversed ternary or a + wrong field would have passed every other test. Captures the actual + kwargs `run_accounting` is called with (mirroring + `test_rebalance_date_flag_is_never_true_on_a_closed_row`'s own + technique) rather than inferring the cap indirectly from whether a + breach happens to occur.""" + data = _panel() + cfg = _config({"name": "buy_and_hold"}) + portfolio_update: dict[str, Any] = { + "model_weight_drift": True, + "maximum_leverage": maximum_leverage, + } + if maximum_gross_exposure is not None: + portfolio_update["maximum_gross_exposure"] = maximum_gross_exposure + cfg = cfg.revalidated_copy( + update={"portfolio": cfg.portfolio.revalidated_copy(update=portfolio_update)} + ) + + import quantlab.backtesting.engine as engine_mod + + captured: dict[str, Any] = {} + orig_run_accounting = engine_mod.run_accounting + + def spy_run_accounting(*args: Any, **kwargs: Any) -> Any: + captured["maximum_gross_exposure"] = kwargs["maximum_gross_exposure"] + captured["maximum_weight"] = kwargs["maximum_weight"] + captured["maximum_net_exposure"] = kwargs["maximum_net_exposure"] + captured["long_only"] = kwargs["long_only"] + return orig_run_accounting(*args, **kwargs) + + monkeypatch.setattr(engine_mod, "run_accounting", spy_run_accounting) + + run_backtest_from_config(data, cfg) + + assert captured["maximum_gross_exposure"] == pytest.approx(expected_gross_cap) + assert captured["maximum_weight"] == cfg.portfolio.maximum_weight + assert captured["maximum_net_exposure"] == cfg.portfolio.maximum_net_exposure + assert captured["long_only"] == cfg.portfolio.long_only + + +def test_model_weight_drift_restores_a_breach_of_combined_caps_end_to_end() -> None: + """End-to-end (not a unit test of the LP directly): a two-asset + portfolio with `model_weight_drift=True`, a tight `maximum_weight`, a + long rebalance interval (quarterly, so drift has room to build up) and + one asset engineered to strongly outgrow the other must genuinely + breach `maximum_weight` from pure organic drift, and that breach must + never persist for more than the one documented row of look-ahead-free + lag (`apply_weight_drift`'s own temporal convention: a breach detected + at `t` queues a correction that lands at `t+1`, never retroactively + touching `t` itself) -- i.e. two CONSECUTIVE breaching rows for the + same symbol would mean a queued correction failed to land on schedule. + The trade log's `drift_compliance` adjustment code must also actually + appear, proving the LP genuinely fired rather than the test vacuously + passing because no breach ever occurred. This is exactly the + combination (`model_weight_drift` + these caps, through the full + `engine.py` pipeline) the prior integration tests never exercised + together.""" + winner = geometric_series(260, mu=0.02, sigma=0.01, s0=100.0, seed=11) + loser = geometric_series(260, mu=-0.005, sigma=0.01, s0=100.0, seed=12) + data = pd.concat( + [ + make_ohlcv("WIN", winner, start="2019-01-01"), + make_ohlcv("LOSE", loser, start="2019-01-01"), + ], + ignore_index=True, + ) + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "drift_compliance_end_to_end", + "data": { + "instruments": [ + {"symbol": "WIN", "source": "csv", "calendar": "XNYS"}, + {"symbol": "LOSE", "source": "csv", "calendar": "XNYS"}, + ], + "start_date": "2019-01-01", + "end_date": "2019-12-31", + }, + "strategy": {"name": "buy_and_hold"}, + "portfolio": { + "allocator": "equal_weight", + "rebalance_frequency": "quarterly", + "model_weight_drift": True, + "maximum_weight": 0.55, + "maximum_gross_exposure": 1.0, + "maximum_leverage": 1.0, + "long_only": True, + }, + "execution": { + "commission_bps": 2.0, + "spread_bps": 3.0, + "slippage_bps": 2.0, + }, + "backtest": {"initial_capital": 100_000, "periods_per_year": 252}, + } + ) + + result = run_backtest_from_config(data, cfg) + + # `positions` is `accounting.executed_weights` -- the REAL, post-drift, + # post-compliance-correction book (`weights` is the pre-drift decision + # timeline, which never breaches anything by construction and so would + # not exercise this at all). + executed = result.positions + assert executed is not None + breach = executed.abs() > 0.55 + 1e-6 + assert breach.to_numpy().any(), ( + "expected WIN's organic drift to actually breach maximum_weight at " + "least once -- otherwise this test does not engineer the scenario " + "it claims to" + ) + consecutive_breach = breach & breach.shift(1, fill_value=False) + assert not consecutive_breach.to_numpy().any(), ( + f"a breach persisted for 2+ consecutive rows (never restored on " + f"schedule): {consecutive_breach[consecutive_breach.any(axis=1)]}" + ) + assert (executed.sum(axis=1).abs().to_numpy() <= 1.0 + 1e-6).all() + assert (executed.to_numpy() >= -1e-9).all() # long_only + codes = result.trades["adjustment_reason_codes"].fillna("") + assert codes.str.contains("drift_compliance").any(), ( + "expected at least one drift_compliance-attributed correction -- " + "otherwise this test does not actually exercise the LP path" + ) + + def test_equal_weight_benchmark_ignores_data_outside_configured_universe() -> None: data = _panel() extra = make_ohlcv( diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 9096ac1..aa89fad 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -774,6 +774,32 @@ def test_cli_robustness_orchestrator_runs_only_enabled_techniques( assert not (exp_dir / "sensitivity.csv").is_file() +def test_cli_robustness_orchestrator_report_includes_strategy_diagnostics( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, isolated_reports_dir: Path +) -> None: + """Regression test: `robustness` used to save a report with no Strategy + diagnostics section at all -- unlike `backtest`/`report`, it never + called `_strategy_diagnostics_robustness`. Reproduces the exact + "backtest, then robustness" sequence: the first save (via `backtest`) + would include diagnostics; `robustness` then overwrote that same + report.html without them, since neither `_ROBUSTNESS_ARTIFACT_FILES` + nor `load_previous_robustness_artifacts` carries that section forward + from a prior save. cross_sectional_momentum (this fixture's default + strategy) declares `results_diagnostics`, so it must appear here too.""" + config_path, raw = _write_offline_experiment( + tmp_path, + extra={"robustness": {"bootstrap": {"enabled": True, "n_iterations": 30}}}, + ) + _patch_raw_dir(monkeypatch, raw) + + result = runner.invoke(app, ["robustness", "--config", str(config_path)]) + + assert result.exit_code == 0, result.stdout + exp_dir = isolated_reports_dir / "cli_test" + report_html = (exp_dir / "report.html").read_text(encoding="utf-8") + assert "Strategy diagnostics" in report_html + + def test_cli_robustness_orchestrator_warns_when_nothing_enabled( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/unit/test_accounting.py b/tests/unit/test_accounting.py index 70d5967..595349a 100644 --- a/tests/unit/test_accounting.py +++ b/tests/unit/test_accounting.py @@ -10,7 +10,11 @@ import pandas as pd import pytest -from quantlab.backtesting.accounting import compute_asset_returns, run_accounting +from quantlab.backtesting.accounting import ( + _detect_stop_loss_take_profit, + compute_asset_returns, + run_accounting, +) from quantlab.config import ExecutionConfig from quantlab.exceptions import BacktestError from quantlab.execution.execution_model import ExecutionModel @@ -110,6 +114,111 @@ def test_invalid_initial_capital_raises(bad_capital: object) -> None: run_accounting(held, asset_returns, _zero_cost_model(), bad_capital) # type: ignore[arg-type] +@pytest.mark.parametrize( + ("kwargs", "match"), + [ + ({"model_weight_drift": "yes"}, "model_weight_drift"), + ({"long_only": 1}, "long_only"), + ({"stop_loss_pct": -1.0}, "stop_loss_pct"), + ({"stop_loss_pct": 0.0}, "stop_loss_pct"), + ({"take_profit_pct": -0.5}, "take_profit_pct"), + ({"model_weight_drift": True, "maximum_weight": -0.1}, "maximum_weight"), + ({"model_weight_drift": True, "maximum_weight": 1.5}, "maximum_weight"), + ( + {"model_weight_drift": True, "maximum_gross_exposure": -0.1}, + "maximum_gross_exposure", + ), + ( + {"model_weight_drift": True, "maximum_net_exposure": -0.1}, + "maximum_net_exposure", + ), + ( + {"model_weight_drift": True, "maximum_turnover": -0.1}, + "maximum_turnover", + ), + ( + {"model_weight_drift": True, "maximum_turnover": 0.0}, + "maximum_turnover", + ), + ], +) +def test_run_accounting_rejects_invalid_direct_api_arguments( + kwargs: dict[str, object], match: str +) -> None: + """A direct caller of `run_accounting` (bypassing PortfolioConfig's own + field validation and strategies.base.validate_risk_control_parameters + entirely) must not be able to silently pass a truthy non-bool flag, a + non-positive stop_loss_pct/take_profit_pct, or an out-of-range + exposure cap that would otherwise reach the drift-compliance LP as a + confusing "bug in the algorithm" error instead of a clear, + immediate one.""" + idx = pd.date_range("2020-01-01", periods=3, freq="D") + held = pd.DataFrame({"AAA": [0.5, 0.5, 0.5]}, index=idx) + asset_returns = pd.DataFrame({"AAA": [np.nan, 0.01, 0.01]}, index=idx) + + with pytest.raises(BacktestError, match=match): + run_accounting( + held, + asset_returns, + _zero_cost_model(), + 100_000.0, + **kwargs, # type: ignore[arg-type] + ) + + +def test_run_accounting_rejects_a_non_execution_model_instance() -> None: + """A wrong-type `execution_model` must raise `BacktestError` (per this + function's own documented contract), never let a missing `.compute` + attribute surface as a confusing `AttributeError` from deep inside + `_solve_accounting`.""" + idx = pd.date_range("2020-01-01", periods=2, freq="D") + held = pd.DataFrame({"AAA": [0.5, 0.5]}, index=idx) + asset_returns = pd.DataFrame({"AAA": [np.nan, 0.01]}, index=idx) + + with pytest.raises(BacktestError, match="execution_model"): + run_accounting(held, asset_returns, object(), 100_000.0) # type: ignore[arg-type] + + +@pytest.mark.parametrize( + ("rebalance_date", "match"), + [ + ( + pd.DataFrame({"AAA": ["False", "True", "False"]}), + "boolean", + ), + ( + pd.DataFrame({"AAA": [1, 0, 1]}), + "boolean", + ), + ( + pd.DataFrame({"AAA": [True, None, False]}), + "missing values", + ), + ], +) +def test_run_accounting_rejects_a_non_boolean_rebalance_date( + rebalance_date: pd.DataFrame, match: str +) -> None: + """A non-boolean `rebalance_date` column (e.g. the string `'False'`, + which Python/pandas would otherwise silently coerce to a truthy + non-empty string, or a `0`/`1` integer column) must raise, never + silently be treated as `True` and force a phantom rebalance.""" + idx = pd.date_range("2020-01-01", periods=3, freq="D") + rebalance_date = rebalance_date.set_index(idx) + held = pd.DataFrame({"AAA": [0.5, 0.5, 0.5]}, index=idx) + asset_returns = pd.DataFrame({"AAA": [np.nan, 0.01, 0.01]}, index=idx) + + with pytest.raises(BacktestError, match=match): + run_accounting( + held, + asset_returns, + _zero_cost_model(), + 100_000.0, + model_weight_drift=True, + rebalance_date=rebalance_date, + ) + + @pytest.mark.parametrize("invalid_input", ["weights", "returns"]) def test_non_finite_accounting_input_raises(invalid_input: str) -> None: idx = pd.date_range("2020-01-01", periods=2, freq="D") @@ -187,3 +296,312 @@ def test_tradable_must_cover_the_same_set_of_symbols_as_held_weights() -> None: run_accounting( held, asset_returns, _zero_cost_model(), 100_000.0, tradable=tradable ) + + +# --------------------------------------------------------------------------- # +# Stop-loss / take-profit -- operates on the REAL executed position, never a +# raw strategy signal (see `_detect_stop_loss_take_profit`'s docstring). +# --------------------------------------------------------------------------- # +def test_stop_loss_take_profit_disabled_by_default_changes_nothing() -> None: + """The single most important non-regression guarantee: leaving both + thresholds at their `None` default must produce byte-identical + accounting to today's behavior, for any weights/returns.""" + idx = pd.date_range("2020-01-01", periods=6, freq="D") + held = pd.DataFrame({"AAA": [1.0, 1.0, 1.0, 1.0, 1.0, 1.0]}, index=idx) + asset_returns = pd.DataFrame( + {"AAA": [0.0, 0.0, -0.30, -0.30, 0.05, 0.05]}, index=idx + ) + baseline = run_accounting(held, asset_returns, _zero_cost_model(), 100_000.0) + with_none = run_accounting( + held, + asset_returns, + _zero_cost_model(), + 100_000.0, + stop_loss_pct=None, + take_profit_pct=None, + position_groups=None, + ) + pd.testing.assert_frame_equal(baseline.executed_weights, with_none.executed_weights) + pd.testing.assert_series_equal(baseline.equity, with_none.equity) + assert not with_none.stop_loss_triggered.to_numpy().any() + assert not with_none.take_profit_triggered.to_numpy().any() + + +def test_stop_loss_forces_flat_the_bar_after_the_cumulative_breach() -> None: + """Hand-computed: long AAA throughout, -6% then another -6% (cumulative + 0.94*0.94-1 = -11.64%, past a 10% stop) -- the LOSS-REALIZING bar itself + keeps its return (no look-ahead: that loss already happened), and the + position is force-flattened starting the NEXT bar.""" + idx = pd.date_range("2020-01-01", periods=6, freq="D") + held = pd.DataFrame({"AAA": [1.0] * 6}, index=idx) + asset_returns = pd.DataFrame({"AAA": [0.0, 0.0, -0.06, -0.06, 0.0, 0.0]}, index=idx) + + acc = run_accounting( + held, asset_returns, _zero_cost_model(), 100_000.0, stop_loss_pct=0.10 + ) + + assert acc.executed_weights["AAA"].tolist() == [0.0, 1.0, 1.0, 1.0, 0.0, 0.0] + # The trigger array marks only the FIRST force-flattened date (the + # "exit" event itself), not every date the position stays flat + # thereafter -- that ongoing status already lives in executed_weights + # (0.0) and would double as a "trigger" on every subsequent bar if not + # deliberately restricted to the transition. + assert acc.stop_loss_triggered["AAA"].tolist() == [ + False, + False, + False, + False, + True, + False, + ] + assert not acc.take_profit_triggered.to_numpy().any() + # The bar that realized the breaching loss (index 3) is NOT itself + # force-flattened -- that loss had already happened. + assert acc.gross_returns.iloc[3] == pytest.approx(-0.06) + + +def test_stop_loss_gates_the_real_drifted_position_not_a_step_function() -> None: + """Regression test: `model_weight_drift=True` is now the DEFAULT, so + stop-loss/take-profit combined with organic weight drift is close to + the ordinary path, not an exotic combination -- yet `_detect_stop_ + loss_take_profit`'s `before_state = executed - weight_changes` gated- + turnover patch was previously never exercised with drift active. + + A and B start 50/50; A loses value every day (cumulative loss exceeds + the 10% stop by the bar entering 2024-01-05); with weight drift on, + A's weight organically SHRINKS below its own anchor value each day + (never a step function) while B's grows to compensate (E shrinks from + A's losses alone). The stop-loss must gate the REAL drifted weight A + was actually sitting at when it flattens -- not the stale 0.5 anchor + -- and the resulting turnover must reflect exactly that real value.""" + dates = pd.date_range("2024-01-01", periods=6, freq="D") + held = pd.DataFrame({"A": [0.5] * 6, "B": [0.5] * 6}, index=dates) + asset_returns = pd.DataFrame( + { + "A": [np.nan, -0.05, -0.05, -0.03, 0.0, 0.0], + "B": [np.nan, 0.0, 0.0, 0.0, 0.0, 0.0], + }, + index=dates, + ) + + result = run_accounting( + held, + asset_returns, + _zero_cost_model(), + 100_000.0, + model_weight_drift=True, + stop_loss_pct=0.10, + ) + + # Organic drift entering rows 2-3 (never the flat 0.5 anchor) -- + # confirms this scenario genuinely exercises drift, not a step + # function: dollar_A = 0.5*(1-0.05) = 0.475, E = 1+(0.5*-0.05) = 0.975, + # weight_A = 0.475/0.975. + assert result.executed_weights.loc[dates[2], "A"] == pytest.approx( + 0.475 / 0.975, rel=1e-6 + ) + assert result.executed_weights.loc[dates[2], "A"] != pytest.approx(0.5) + + # Cumulative A return since anchor: 0.95*0.95*0.97 - 1 ≈ -12.46%, past + # the 10% stop -- triggers starting the bar after the breaching return + # is realized (index 4), B is entirely unaffected (independent group). + assert bool(result.stop_loss_triggered.loc[dates[4], "A"]) + assert not bool(result.stop_loss_triggered.loc[dates[4], "B"]) + assert not result.take_profit_triggered.to_numpy().any() + assert result.executed_weights.loc[dates[4], "A"] == pytest.approx(0.0) + assert result.executed_weights.loc[dates[4], "B"] == pytest.approx( + 0.533212, abs=1e-6 + ) + + # The gated turnover must equal the REAL drifted weight A was sitting + # at just before this row's own forced flatten (0.466788 -- verified + # independently against the undisturbed drift path with no stop-loss + # configured at all), never the stale 0.5 anchor or a value from an + # earlier row. + assert result.turnover.loc[dates[4]] == pytest.approx(0.466788, abs=1e-6) + assert result.turnover.loc[dates[4]] != pytest.approx(0.5) + + # No trade at all for B on the stop-loss row -- gating A must never + # spill into an unrelated, independent position group. + assert result.weight_changes.loc[dates[4], "B"] == pytest.approx(0.0) + + assert np.isfinite(result.equity.to_numpy()).all() + assert np.isfinite(result.net_returns.to_numpy()).all() + assert np.isfinite(result.costs.total.to_numpy()).all() + + +def test_take_profit_forces_flat_the_bar_after_the_cumulative_gain() -> None: + """Mirror of the stop-loss test, on the favorable side.""" + idx = pd.date_range("2020-01-01", periods=6, freq="D") + held = pd.DataFrame({"AAA": [1.0] * 6}, index=idx) + asset_returns = pd.DataFrame({"AAA": [0.0, 0.0, 0.06, 0.06, 0.0, 0.0]}, index=idx) + + acc = run_accounting( + held, asset_returns, _zero_cost_model(), 100_000.0, take_profit_pct=0.10 + ) + + assert acc.executed_weights["AAA"].tolist() == [0.0, 1.0, 1.0, 1.0, 0.0, 0.0] + assert acc.take_profit_triggered["AAA"].tolist() == [ + False, + False, + False, + False, + True, + False, + ] + assert not acc.stop_loss_triggered.to_numpy().any() + + +def test_stop_loss_never_triggers_when_no_position_is_actually_executed() -> None: + """The test that would have failed the FIRST (rejected) design: a + strategy can emit a signal, or the allocator can decide a target, but + if it never becomes an actually-held (executed) position -- here + ``held`` is always 0 -- a stop-loss must never fire, no matter how + extreme the asset's own return is.""" + idx = pd.date_range("2020-01-01", periods=6, freq="D") + held = pd.DataFrame({"AAA": [0.0] * 6}, index=idx) + asset_returns = pd.DataFrame({"AAA": [0.0, 0.0, -0.5, -0.5, 0.0, 0.0]}, index=idx) + + acc = run_accounting( + held, asset_returns, _zero_cost_model(), 100_000.0, stop_loss_pct=0.10 + ) + + assert not acc.stop_loss_triggered.to_numpy().any() + assert (acc.executed_weights["AAA"] == 0.0).all() + + +def test_stop_loss_does_not_immediately_reenter_at_a_rebased_price() -> None: + """Once stopped, the position stays flat until the NEXT flat-to-non- + flat transition of the (still-nonzero) held weight -- not an + immediate re-entry the following bar, matching mean_reversion's own + stop_threshold re-entry convention.""" + idx = pd.date_range("2020-01-01", periods=8, freq="D") + # Held stays long throughout (the raw held target never goes back to + # flat) -- a naive design might re-enter as soon as the position + # "recovers"; the real one must not, since the group never actually + # returned to flat. + held = pd.DataFrame({"AAA": [1.0] * 8}, index=idx) + asset_returns = pd.DataFrame( + {"AAA": [0.0, 0.0, -0.20, 0.0, 0.20, 0.20, 0.20, 0.20]}, index=idx + ) + + acc = run_accounting( + held, asset_returns, _zero_cost_model(), 100_000.0, stop_loss_pct=0.10 + ) + + # Stopped once at index 2 (-20% > 10% stop), forced flat from index 3 + # onward for the REST of the series -- even though later returns are + # strongly positive, there is no re-entry since held never returns to 0. + assert acc.executed_weights["AAA"].tolist() == [ + 0.0, + 1.0, + 1.0, + 0.0, + 0.0, + 0.0, + 0.0, + 0.0, + ] + + +def test_position_groups_use_combined_pair_pnl_not_each_leg_separately() -> None: + """Hand-computed pair scenario with a rebalance that HALVES both legs' + magnitude mid-hold (simulating a hedge-ratio/position-size change): + executed A=[1.0, 0.5, 0.25], B=[-1.0, -0.5, -0.25] (fixed 1:1 ratio, + scaled down), returns A=-20%/-20%/-20%, B=0%/0%/0%. The GROUP return + per unit of gross exposure is a CONSTANT -10% every period regardless + of the scaling (gross_exposure exactly cancels the position-size + change) -- this is the whole point of normalizing by realized + exposure rather than dollar contribution. Cumulative: 0.90 -> 0.81 + (-19%, past a 15% stop, set after that bar) -> forces BOTH legs flat + starting the next bar.""" + idx = pd.date_range("2020-01-01", periods=4, freq="D") + executed = pd.DataFrame( + {"A": [0.0, 1.0, 0.5, 0.25], "B": [0.0, -1.0, -0.5, -0.25]}, index=idx + ) + asset_returns = pd.DataFrame( + {"A": [0.0, -0.20, -0.20, -0.20], "B": [0.0, 0.0, 0.0, 0.0]}, index=idx + ) + + gated, stop_loss, take_profit, _ = _detect_stop_loss_take_profit( + executed, asset_returns, [("A", "B")], 0.15, None + ) + + assert gated["A"].tolist() == [0.0, 1.0, 0.5, 0.0] + assert gated["B"].tolist() == [0.0, -1.0, -0.5, 0.0] + assert stop_loss["A"].tolist() == [False, False, False, True] + assert stop_loss["B"].tolist() == [False, False, False, True] + assert not take_profit.to_numpy().any() + + +def test_position_groups_default_to_one_independent_group_per_symbol() -> None: + """`position_groups=None` (the default) must behave identically to + declaring every symbol its own singleton group -- two unrelated + symbols' stop-losses must never interact.""" + idx = pd.date_range("2020-01-01", periods=4, freq="D") + executed = pd.DataFrame( + {"A": [0.0, 1.0, 1.0, 1.0], "B": [0.0, 1.0, 1.0, 1.0]}, index=idx + ) + # A breaches a 10% stop; B never does. + asset_returns = pd.DataFrame( + {"A": [0.0, -0.06, -0.06, 0.0], "B": [0.0, 0.01, 0.01, 0.01]}, index=idx + ) + + gated, stop_loss, _, _ = _detect_stop_loss_take_profit( + executed, asset_returns, None, 0.10, None + ) + + assert gated["A"].tolist() == [0.0, 1.0, 1.0, 0.0] + assert gated["B"].tolist() == [0.0, 1.0, 1.0, 1.0] + assert stop_loss["A"].tolist() == [False, False, False, True] + assert not stop_loss["B"].any() + + +def test_direct_long_to_short_reversal_starts_a_fresh_episode() -> None: + """A same-bar sign flip (long directly to short, no intermediate flat + row) must start a brand-new stop-loss episode for the new direction -- + it must NOT inherit the opposite-direction position's already- + triggered stop and stay force-flattened forever, since the reversed + position was never actually the one that breached. + + Long AAA, -20% breaches a 10% stop (forced flat from index 2). At + index 3 the raw signal reverses directly to short (skipping flat) -- + the new short position must be allowed to hold, since -5%/-5% moves + against it never breach 10% on their OWN fresh cumulative return.""" + idx = pd.date_range("2020-01-01", periods=5, freq="D") + executed = pd.DataFrame({"AAA": [1.0, 1.0, 1.0, -1.0, -1.0]}, index=idx) + asset_returns = pd.DataFrame({"AAA": [0.0, -0.20, 0.0, 0.05, 0.05]}, index=idx) + + gated, stop_loss, _, _ = _detect_stop_loss_take_profit( + executed, asset_returns, None, 0.10, None + ) + + assert gated["AAA"].tolist() == [1.0, 1.0, 0.0, -1.0, -1.0] + assert stop_loss["AAA"].tolist() == [False, False, True, False, False] + + +def test_position_groups_reject_malformed_declarations() -> None: + """An empty group, a self-duplicate, an unknown symbol, or two groups + claiming the same symbol must raise loudly -- silently accepting any + of these would double-process (or mis-key) a symbol across two + different entry-timing episodes instead of failing fast.""" + idx = pd.date_range("2020-01-01", periods=2, freq="D") + executed = pd.DataFrame( + {"A": [0.0, 1.0], "B": [0.0, 1.0], "C": [0.0, 1.0]}, index=idx + ) + asset_returns = pd.DataFrame( + {"A": [0.0, 0.01], "B": [0.0, 0.01], "C": [0.0, 0.01]}, index=idx + ) + + with pytest.raises(BacktestError, match="empty group"): + _detect_stop_loss_take_profit(executed, asset_returns, [()], 0.10, None) + with pytest.raises(BacktestError, match="repeats a symbol"): + _detect_stop_loss_take_profit(executed, asset_returns, [("A", "A")], 0.10, None) + with pytest.raises(BacktestError, match="not present"): + _detect_stop_loss_take_profit( + executed, asset_returns, [("A", "ZZZ")], 0.10, None + ) + with pytest.raises(BacktestError, match="overlaps symbol"): + _detect_stop_loss_take_profit( + executed, asset_returns, [("A", "B"), ("B", "C")], 0.10, None + ) diff --git a/tests/unit/test_benchmark.py b/tests/unit/test_benchmark.py index 479966a..490ab1d 100644 --- a/tests/unit/test_benchmark.py +++ b/tests/unit/test_benchmark.py @@ -6,7 +6,7 @@ import pandas as pd import pytest -from quantlab.backtesting.benchmark import build_benchmark, cash_returns +from quantlab.backtesting.benchmark import _align_returns, build_benchmark, cash_returns from quantlab.config import ExperimentConfig from quantlab.constants import ( ADJUSTED_CLOSE, @@ -69,6 +69,58 @@ def test_missing_benchmark_return_after_initial_period_raises() -> None: build_benchmark(data, portfolio_index, benchmark_symbol="AAA") +def test_align_returns_tolerates_a_coarser_datetime_resolution_than_the_schedule() -> ( + None +): + """Coverage gap the calendar-management research surfaced: `_align_ + returns`'s `calendar=` branch previously had zero test coverage at all + (no call in this file passed `calendar=`). Guard it against a + lower-resolution `datetime64` benchmark index, mirroring the existing + `session_labels` regression test for the same class of issue.""" + benchmark_index = pd.date_range("2024-01-02", periods=3, freq="B").astype( + "datetime64[ms]" + ) + portfolio_index = pd.date_range("2024-01-02", periods=3, freq="B") + series = pd.Series([np.nan, 0.01, 0.02], index=benchmark_index) + + result = _align_returns(series, portfolio_index, calendar="XNYS") + + assert not result.isna().any() + assert result.tolist() == pytest.approx([0.0, 0.01, 0.02]) + + +def test_align_returns_seeds_a_leading_verified_closure_of_the_benchmark_calendar() -> ( + None +): + """BTCUSDT (24/7) trading on 2019-01-01 -- a date XNYS's own calendar + marks as a holiday closure, not a session -- must not raise just + because the benchmark has no observation for a date its own calendar + says isn't a session at all.""" + portfolio_index = pd.DatetimeIndex(["2019-01-01", "2019-01-02", "2019-01-03"]) + benchmark_index = pd.DatetimeIndex(["2019-01-02", "2019-01-03"]) + series = pd.Series([np.nan, 0.02], index=benchmark_index) + + result = _align_returns(series, portfolio_index, calendar="XNYS") + + assert result.tolist() == pytest.approx([0.0, 0.0, 0.02]) + + +def test_align_returns_still_raises_when_a_leading_missing_date_is_not_a_closure() -> ( + None +): + """The seed above must not overreach: a leading date missing for a + genuine reason (not a verified closure of the benchmark's own + calendar) must still raise, exactly as before.""" + # 2019-01-02 is a real XNYS trading session; the benchmark's own data + # simply skips it (a genuine gap, not a holiday). + portfolio_index = pd.DatetimeIndex(["2019-01-02", "2019-01-03", "2019-01-04"]) + benchmark_index = pd.DatetimeIndex(["2019-01-03", "2019-01-04"]) + series = pd.Series([np.nan, 0.02], index=benchmark_index) + + with pytest.raises(BacktestError, match="missing on portfolio dates"): + _align_returns(series, portfolio_index, calendar="XNYS") + + def test_first_benchmark_period_is_zero() -> None: index = pd.date_range("2024-01-01", periods=3, freq="D") data = _market_data({"AAA": [100.0, 110.0, 121.0]}, index) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 11da2bc..ac0be59 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -71,6 +71,33 @@ def test_walk_forward_parameter_grid_is_validated_at_config_load() -> None: ExperimentConfig.from_dict(invalid) +def test_walk_forward_parameter_grid_rejects_duplicate_candidates_at_load() -> None: + """Regression test: `validation.parameter_grid.lookback_period: [126, + 126]` used to load fine and only fail later, deep inside + `walk_forward.py`'s own execution-time duplicate check (`Grid + parameter 'lookback_period' must not contain duplicate values`) -- + caught here at YAML load time instead, mirroring + `StressTestSettings`'s own established convention.""" + base: dict[str, Any] = { + "experiment_name": "yaml_grid_dupe", + "data": { + "instruments": [{"symbol": "AAA", "source": "csv", "calendar": "XNYS"}], + "start_date": "2020-01-01", + "end_date": "2022-01-01", + }, + "strategy": { + "name": "mean_reversion", + "parameters": {"lookback_period": 20}, + }, + "validation": { + "method": "walk_forward", + "parameter_grid": {"lookback_period": [126, 126]}, + }, + } + with pytest.raises(InvalidConfigurationError, match="duplicate"): + ExperimentConfig.from_dict(base) + + def test_parameter_grid_is_rejected_for_non_walk_forward_validation() -> None: with pytest.raises(ValueError, match="applies only"): ValidationConfig.model_validate( @@ -140,11 +167,228 @@ def test_robustness_bootstrap_n_iterations_must_be_positive( ) +def test_validation_step_defaults_to_none_and_accepts_override() -> None: + config = ExperimentConfig.from_dict(_robustness_base_dict()) + assert config.validation.step is None + with_step = ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "validation": {"method": "walk_forward", "step": 5}, + } + ) + assert with_step.validation.step == 5 + + +def test_validation_walk_forward_fields_rejected_outside_walk_forward_method() -> None: + with pytest.raises(InvalidConfigurationError, match="walk_forward"): + ExperimentConfig.from_dict( + {**_robustness_base_dict(), "validation": {"step": 5}} + ) + + +@pytest.mark.parametrize("bad_step", [0, -1]) +def test_validation_step_must_be_positive(bad_step: int) -> None: + with pytest.raises(InvalidConfigurationError): + ExperimentConfig.from_dict( + {**_robustness_base_dict(), "validation": {"step": bad_step}} + ) + + +def test_validation_step_exceeding_test_window_is_rejected() -> None: + """A larger step than test_window would skip dates between consecutive + folds' test blocks entirely -- documented as forbidden (see + ValidationConfig.step's own docstring) but previously unenforced at + config-load time, only failing much later inside the walk-forward + validator itself.""" + with pytest.raises(InvalidConfigurationError, match="step"): + ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "validation": { + "method": "walk_forward", + "test_window": 10, + "step": 11, + }, + } + ) + + +def test_validation_step_equal_to_test_window_is_accepted() -> None: + config = ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "validation": {"method": "walk_forward", "test_window": 10, "step": 10}, + } + ) + assert config.validation.step == 10 + + +def test_validation_step_exceeding_default_test_window_is_rejected() -> None: + """test_window omitted -> resolve_walk_forward_windows()'s own 126 + default applies; the same check must account for it, not only an + explicitly-set test_window.""" + with pytest.raises(InvalidConfigurationError, match="step"): + ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "validation": {"method": "walk_forward", "step": 200}, + } + ) + + +def test_bootstrap_confidence_level_default_and_override() -> None: + config = ExperimentConfig.from_dict(_robustness_base_dict()) + assert config.robustness.bootstrap.confidence_level == pytest.approx(0.90) + custom = ExperimentConfig.from_dict( + _robustness_base_dict(bootstrap={"confidence_level": 0.95}) + ) + assert custom.robustness.bootstrap.confidence_level == pytest.approx(0.95) + + +@pytest.mark.parametrize("bad_level", [0.0, 1.0, -0.1, 1.5]) +def test_bootstrap_confidence_level_must_be_strictly_between_0_and_1( + bad_level: float, +) -> None: + with pytest.raises(InvalidConfigurationError): + ExperimentConfig.from_dict( + _robustness_base_dict(bootstrap={"confidence_level": bad_level}) + ) + + +def test_stress_test_settings_default_lists_reproduce_original_scenarios() -> None: + config = ExperimentConfig.from_dict(_robustness_base_dict()) + settings = config.robustness.stress_test + assert settings.commission_multipliers == [2.0, 5.0] + assert settings.slippage_multipliers == [2.0] + assert settings.execution_delays == [1] + assert settings.best_days_removed == [10] + assert settings.reduce_universe_by == [1] + + +def test_stress_test_settings_accept_custom_lists() -> None: + config = ExperimentConfig.from_dict( + _robustness_base_dict( + stress_test={ + "commission_multipliers": [3.0], + "slippage_multipliers": [], + "execution_delays": [1, 2, 3], + "best_days_removed": [5, 20], + "reduce_universe_by": [], + } + ) + ) + settings = config.robustness.stress_test + assert settings.commission_multipliers == [3.0] + assert settings.slippage_multipliers == [] + assert settings.execution_delays == [1, 2, 3] + assert settings.best_days_removed == [5, 20] + assert settings.reduce_universe_by == [] + + +@pytest.mark.parametrize( + "field", + [ + "commission_multipliers", + "slippage_multipliers", + "execution_delays", + "best_days_removed", + "reduce_universe_by", + ], +) +def test_stress_test_settings_reject_duplicate_values(field: str) -> None: + with pytest.raises(InvalidConfigurationError): + ExperimentConfig.from_dict(_robustness_base_dict(stress_test={field: [2, 2]})) + + +@pytest.mark.parametrize( + "field", + [ + "commission_multipliers", + "slippage_multipliers", + "execution_delays", + "best_days_removed", + "reduce_universe_by", + ], +) +def test_stress_test_settings_reject_non_positive_values(field: str) -> None: + with pytest.raises(InvalidConfigurationError): + ExperimentConfig.from_dict(_robustness_base_dict(stress_test={field: [0]})) + + +def test_strategy_signal_price_type_default_and_override() -> None: + config = ExperimentConfig.from_dict(_robustness_base_dict()) + assert config.strategy.signal_price_type == "adjusted_close" + custom = ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "strategy": { + "name": "mean_reversion", + "parameters": {"lookback_period": 20}, + "signal_price_type": "close", + }, + } + ) + assert custom.strategy.signal_price_type == "close" + + +def test_strategy_signal_price_type_rejects_invalid_value() -> None: + with pytest.raises(InvalidConfigurationError): + ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "strategy": { + "name": "mean_reversion", + "parameters": {"lookback_period": 20}, + "signal_price_type": "vwap", + }, + } + ) + + +def test_research_question_and_hypothesis_default_none_and_accept_override() -> None: + config = ExperimentConfig.from_dict(_robustness_base_dict()) + assert config.research_question is None + assert config.hypothesis is None + custom = ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "research_question": "Is this strategy any good?", + "hypothesis": "H1: yes. H0: no.", + } + ) + assert custom.research_question == "Is this strategy any good?" + assert custom.hypothesis == "H1: yes. H0: no." + + +def test_output_config_defaults_and_accepts_override() -> None: + config = ExperimentConfig.from_dict(_robustness_base_dict()) + assert config.output.directory is None + assert config.output.save_html_report is True + assert config.output.save_figures is True + custom = ExperimentConfig.from_dict( + { + **_robustness_base_dict(), + "output": { + "directory": "/tmp/custom", + "save_html_report": False, + "save_figures": False, + }, + } + ) + assert custom.output.directory == "/tmp/custom" + assert custom.output.save_html_report is False + assert custom.output.save_figures is False + + @pytest.mark.parametrize( "parameters", [ {"lookback_period": [10, 20]}, - {"lookback_period": [10, 20], "entry_zscore": [1.0, 2.0], "exit_zscore": [0.5]}, + { + "lookback_period": [10, 20], + "entry_threshold": [1.0, 2.0], + "exit_threshold": [0.5], + }, ], ) def test_robustness_sensitivity_parameters_must_have_exactly_two_keys( @@ -161,7 +405,29 @@ def test_robustness_sensitivity_parameters_rejects_empty_candidate_list() -> Non ExperimentConfig.from_dict( _robustness_base_dict( sensitivity={ - "parameters": {"lookback_period": [10, 20], "entry_zscore": []} + "parameters": {"lookback_period": [10, 20], "entry_threshold": []} + } + ) + ) + + +def test_robustness_sensitivity_parameters_rejects_duplicate_candidates_at_load() -> ( + None +): + """Regression test: `robustness.sensitivity.parameters.lookback_ + period: [126, 126]` used to load fine and only fail later, deep + inside `parameter_sensitivity.py`'s own execution-time duplicate + check (`Values for 'lookback_period' must not contain duplicates`) -- + caught here at YAML load time instead, mirroring `StressTestSettings`/ + `validation.parameter_grid`'s own established convention.""" + with pytest.raises(InvalidConfigurationError, match="duplicate"): + ExperimentConfig.from_dict( + _robustness_base_dict( + sensitivity={ + "parameters": { + "lookback_period": [126, 126], + "entry_threshold": [1.0, 2.0], + } } ) ) @@ -205,7 +471,7 @@ def test_robustness_sensitivity_parameters_accepts_two_valid_keys() -> None: "enabled": True, "parameters": { "lookback_period": [10, 20], - "entry_zscore": [1.0, 2.0], + "entry_threshold": [1.0, 2.0], }, } ) @@ -213,7 +479,7 @@ def test_robustness_sensitivity_parameters_accepts_two_valid_keys() -> None: assert config.robustness.sensitivity.enabled is True assert config.robustness.sensitivity.parameters == { "lookback_period": [10, 20], - "entry_zscore": [1.0, 2.0], + "entry_threshold": [1.0, 2.0], } @@ -248,7 +514,7 @@ def test_robustness_sensitivity_parameters_rejects_an_invalid_candidate_value() "enabled": True, "parameters": { "lookback_period": [0], - "entry_zscore": [1.0, 2.0], + "entry_threshold": [1.0, 2.0], }, } ) @@ -262,15 +528,15 @@ def test_robustness_sensitivity_parameters_rejects_an_invalid_value_combination( *combination* across the two axes still isn't -- this must be checked against the strategy's own combined-parameter validator, not just each axis's values in isolation.""" - with pytest.raises(InvalidConfigurationError, match="exit_zscore"): + with pytest.raises(InvalidConfigurationError, match="entry_threshold"): ExperimentConfig.from_dict( _robustness_base_dict( sensitivity={ "enabled": True, "parameters": { - # mean_reversion requires exit_zscore < entry_zscore. - "entry_zscore": [1.0], - "exit_zscore": [2.0], + # mean_reversion requires exit_threshold < entry_threshold. + "entry_threshold": [1.0], + "exit_threshold": [2.0], }, } ) diff --git a/tests/unit/test_dashboard.py b/tests/unit/test_dashboard.py index 5442b6c..59cf2a0 100644 --- a/tests/unit/test_dashboard.py +++ b/tests/unit/test_dashboard.py @@ -15,13 +15,17 @@ from types import SimpleNamespace from typing import Any, cast +import numpy as np import pandas as pd import pytest from quantlab.dashboard.components import ( + _is_missing, _monthly_return_pivot, render_gross_net_comparison, render_metric_cards, + render_pair_diagnostics, + render_trade_table, ) pytest.importorskip("streamlit") @@ -72,6 +76,26 @@ def _sidebar_multiselect_by_key(at: AppTest, key: str) -> Any: return next(ms for ms in at.sidebar.multiselect if ms.key == key) +def _run_button(at: AppTest) -> Any: + """The "Run backtest"/"Run walk-forward" button, found by its stable + key rather than by position -- the sidebar can render OTHER buttons + before it (e.g. "Load Binance symbols", gated behind its own explicit + click -- see `_load_binance_universe`), so it is not reliably + ``at.sidebar.button[0]``.""" + return next(b for b in at.sidebar.button if b.key == "run_button") + + +def _load_binance_universe(at: AppTest) -> AppTest: + """Click "Load Binance symbols" so the ``binance_symbols`` multiselect + actually exists -- fetching Binance's universe is gated behind this + explicit button precisely so it is NOT called on every dashboard load + (see `_binance_symbols_picker` in app.py).""" + next( + b for b in at.sidebar.button if b.key == "binance_universe_load_button" + ).click().run() + return at + + def _configure_offline_pairs_trade(at: AppTest) -> AppTest: """Point the dashboard at locally cached CSV data for SPY/QQQ.""" _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run() @@ -167,6 +191,12 @@ def fake_universe(self: BinanceDataSource) -> list[SymbolSuggestion]: at.run() assert not at.exception + # Not fetched on initial load -- see _load_binance_universe's docstring + # and test_dashboard_initial_load_never_calls_binance below. + assert fetches == [] + _load_binance_universe(at) + assert fetches == [None] + assert not any(field.label == "Search" for field in at.sidebar.text_input) picker = _sidebar_multiselect_by_key(at, "binance_symbols") assert picker.options == ["BTCUSDT — BTC/USDT", "ETHUSDT — ETH/USDT"] @@ -180,6 +210,37 @@ def fake_universe(self: BinanceDataSource) -> list[SymbolSuggestion]: assert fetches == [None] +def test_dashboard_initial_load_never_calls_binance( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression test: a plain `st.expander` still runs its body every + rerun even while collapsed, so the "Binance" section previously called + Binance's real API on every dashboard load -- including a load that + immediately switches to Strategies mode, which never touches the + (hidden) sidebar at all. Fetching Binance's universe must only ever + happen after the explicit "Load Binance symbols" button is clicked.""" + from quantlab.data.base import SymbolSuggestion + from quantlab.data.binance import BinanceDataSource + + fetches: list[None] = [] + + def fake_universe(self: BinanceDataSource) -> list[SymbolSuggestion]: + fetches.append(None) + return [SymbolSuggestion(symbol="BTCUSDT", description="BTC/USDT")] + + monkeypatch.setattr(BinanceDataSource, "list_trading_symbols", fake_universe) + st.cache_data.clear() # avoid a real universe cached by an earlier test/run + + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + assert not at.exception + assert fetches == [] + + _switch_to_strategies_mode(at) + assert not at.exception + assert fetches == [] + + def test_yahoo_symbols_picker_is_an_instant_dropdown_over_the_bundled_universe() -> ( None ): @@ -273,6 +334,7 @@ def test_binance_shows_no_incomplete_suggestions_note( assert not any( "Not every symbol is suggested" in c.value for c in at.sidebar.caption ) + _load_binance_universe(at) picker = _sidebar_multiselect_by_key(at, "binance_symbols") assert "Not every symbol is suggested" not in picker.proto.help @@ -283,6 +345,7 @@ def test_binance_symbols_picker_rejects_symbols_outside_the_universe() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() + _load_binance_universe(at) picker = _sidebar_multiselect_by_key(at, "binance_symbols") assert picker.proto.accept_new_options is False @@ -300,6 +363,35 @@ def test_monthly_return_pivot_preserves_a_month_without_observations() -> None: assert pivot.loc[2024, 3] == pytest.approx(-0.05) +@pytest.mark.parametrize( + ("value", "expected"), + [ + (None, True), + (pd.NaT, True), + (pd.NA, True), + (float("nan"), True), + (np.float64("nan"), True), + (np.float32("nan"), True), + (np.float16("nan"), True), + ("some string", False), + ("trigger+exit", False), + (0.0, False), + (0, False), + (["a", "b"], False), + ], +) +def test_is_missing_handles_every_missing_scalar_type( + value: object, expected: bool +) -> None: + """A prior `isinstance(value, float) and pd.isna(value)` implementation + missed `pd.NA` (not a `float` subclass) and non-float64 NaN scalars + like `numpy.float32('nan')` (only `numpy.float64` is itself a `float` + subclass, via CPython's numpy integration) -- either would then reach + `parse_adjustment_codes(str(value))` as a bogus `""`/`"nan"` code + and raise `BacktestError` instead of being treated as missing.""" + assert _is_missing(value) is expected + + def test_metric_cards_use_a_four_column_grid_and_explain_cost_units() -> None: class FakeColumn: def __init__(self, sink: list[tuple[str, str, dict[str, object]]]) -> None: @@ -328,7 +420,7 @@ def columns(self, n: int) -> list[FakeColumn]: # 4 columns: eight cards divide evenly into two full, aligned rows -- # any count that doesn't evenly divide 8 leaves a ragged last row (e.g. - # 3 columns strands "Total costs"/"Number of trades" alone on a + # 3 columns strands "Total costs"/"Number of fills" alone on a # half-empty row, visually detached from the grid above). assert fake.columns_requested == 4 assert len(fake.metrics) == 8 @@ -337,6 +429,357 @@ def columns(self, n: int) -> list[FakeColumn]: assert "currency units" in str(costs[2]["help"]) +class _FakeColumnConfig: + # Names mirror st.column_config's actual (PascalCase) API. + def TextColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802 + return {"args": args, "kwargs": kwargs} + + def NumberColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802 + return {"args": args, "kwargs": kwargs} + + def DateColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802 + return {"args": args, "kwargs": kwargs} + + def DatetimeColumn(self, *args: object, **kwargs: object) -> dict[str, object]: # noqa: N802 + return {"args": args, "kwargs": kwargs} + + +class _FakeStreamlit: + def __init__(self) -> None: + self.dataframe_calls: list[tuple[pd.DataFrame, dict[str, object]]] = [] + self.caption_calls: list[tuple[object, dict[str, object]]] = [] + self.column_config = _FakeColumnConfig() + + def info(self, *args: object, **kwargs: object) -> None: + pass + + def dataframe(self, frame: pd.DataFrame, **kwargs: object) -> None: + self.dataframe_calls.append((frame, kwargs)) + + def caption(self, text: object, **kwargs: object) -> None: + self.caption_calls.append((text, kwargs)) + + def download_button(self, *args: object, **kwargs: object) -> None: + pass + + +def _trade_row(**overrides: object) -> dict[str, object]: + """One full 21-field trade-log record, defaulted to a plain, + unattributed fill -- callers override only the fields their scenario + cares about.""" + row: dict[str, object] = { + "timestamp": pd.Timestamp("2024-01-02"), + "symbol": "AAA", + "previous_weight": 0.0, + "new_weight": 0.5, + "weight_change": 0.5, + "side": "buy", + "action": "entry_long", + "trigger_reason_code": None, + "trigger_reason_detail_code": None, + "trigger_reason_details": None, + "adjustment_reason_codes": None, + "adjustment_reason_details": None, + "position_strategy_origin_timestamp": pd.NaT, + "position_strategy_origin_code": None, + "position_strategy_origin_details": None, + "reference_price": 100.0, + "traded_notional": 1000.0, + "commission": 0.5, + "spread_cost": 0.3, + "slippage_cost": 0.2, + "total_cost": 1.0, + } + row.update(overrides) + return row + + +def test_trade_table_shows_separate_columns_when_no_row_has_a_detail_code() -> None: + """Regression test: when every trigger_reason_detail_code is None (no + strategy sub-code fired on this run), pandas can infer that all-None + column as plain "object" while trigger_reason_code (real string + values) infers its new "str" extension dtype -- assigning into the + detail-code column without first casting it to plain "object" used + to raise/silently corrupt data. Every trigger/adjustment/position- + origin value is shown separate and unmodified (no Python-side string + concatenation into a single column, no cell blanked for + "compactness") -- each must stay exactly what build_trade_log + produced, just under its human-readable display column.""" + trades = pd.DataFrame.from_records( + [ + _trade_row( + trigger_reason_code="strategy_signal", + trigger_reason_details="signal 0.0000 -> 1.0000 since last rebalance", + position_strategy_origin_timestamp=pd.Timestamp("2024-01-02"), + ) + ] + ) + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace(experiment_name="test", strategy_parameters={}), + ) + fake = _FakeStreamlit() + + render_trade_table(fake, result) + + displayed = fake.dataframe_calls[0][0] + assert displayed["Trigger"].tolist() == ["strategy_signal"] + assert displayed["Trigger detail"].tolist() == [None] + assert displayed["Adjustments"].tolist() == [None] + assert displayed["Details"].tolist() == [ + "Trigger: signal 0.0000 -> 1.0000 since last rebalance" + ] + # Position origin is always present with its raw value, never blanked. + assert displayed["Position origin date"].tolist() == [pd.Timestamp("2024-01-02")] + + +def test_trade_table_adjustment_codes_display_spaces_out_the_plus_join() -> None: + """adjustment_reason_codes keeps its compact "+" form in the + underlying data (see trade_log.serialize_adjustment_codes) but is + reformatted with spaces for on-screen readability in the Adjustments + column -- every other value, including Trigger, must be untouched + (no "code (detail)" fusion).""" + trades = pd.DataFrame.from_records( + [ + _trade_row( + action="increase_long", + adjustment_reason_codes="maximum_weight+maximum_gross_exposure", + adjustment_reason_details=( + "maximum_weight: 0.7 -> 0.5; maximum_gross_exposure: 1.0 -> 0.8" + ), + ) + ] + ) + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace(experiment_name="test", strategy_parameters={}), + ) + fake = _FakeStreamlit() + + render_trade_table(fake, result) + + displayed = fake.dataframe_calls[0][0] + assert displayed["Trigger"].tolist() == [None] + assert displayed["Adjustments"].tolist() == [ + "maximum_weight + maximum_gross_exposure" + ] + assert displayed["Details"].tolist() == [ + "Adjustment: maximum_weight: 0.7 -> 0.5; maximum_gross_exposure: 1.0 -> 0.8" + ] + # The underlying result.trades DataFrame itself must stay untouched. + assert trades["adjustment_reason_codes"].tolist() == [ + "maximum_weight+maximum_gross_exposure" + ] + + +def test_trade_table_display_columns_are_identical_across_strategies() -> None: + """The visible table's column set and order must never depend on + which strategy produced the result -- only the VALUES may differ. + Simulates two very different strategies' trade logs (one with a + constraint adjustment and a real position origin, one bare) and + asserts both render the exact same 15 columns, in the exact same + order (`_TRADE_TABLE_DISPLAY_COLUMNS`).""" + from quantlab.dashboard.components import _TRADE_TABLE_DISPLAY_COLUMNS + + trend_following_trades = pd.DataFrame.from_records( + [ + _trade_row( + symbol="SPY", + action="entry_long", + trigger_reason_code="strategy_signal", + trigger_reason_detail_code="bullish_crossover", + position_strategy_origin_timestamp=pd.Timestamp("2024-01-02"), + position_strategy_origin_code="bullish_crossover", + ) + ] + ) + buy_and_hold_trades = pd.DataFrame.from_records( + [ + _trade_row( + symbol="QQQ", + action="entry_long", + trigger_reason_code="strategy_signal", + trigger_reason_detail_code="price_became_available", + adjustment_reason_codes="maximum_weight", + adjustment_reason_details="maximum_weight: 0.9 -> 0.6", + position_strategy_origin_timestamp=pd.Timestamp("2024-01-02"), + position_strategy_origin_code="price_became_available", + ) + ] + ) + + displayed_columns = [] + for trades in (trend_following_trades, buy_and_hold_trades): + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace(experiment_name="test", strategy_parameters={}), + ) + fake = _FakeStreamlit() + render_trade_table(fake, result) + displayed_columns.append(list(fake.dataframe_calls[0][0].columns)) + + assert displayed_columns[0] == displayed_columns[1] == _TRADE_TABLE_DISPLAY_COLUMNS + + +def test_trade_table_empty_position_origin_means_no_origin_not_masking() -> None: + """A blank `Position origin` cell must mean only one thing -- no + strategic position is currently active (decision_proxy is flat) -- + never "hidden because it duplicates Trigger". A row whose Trigger + equals a real value but whose position_strategy_origin_code is + genuinely None (e.g. a walk-forward result with no attribution, or a + real exit-to-flat trade) must still render the Position origin + column, blank, never dropped.""" + trades = pd.DataFrame.from_records( + [ + _trade_row( + action="exit_long", + trigger_reason_code="strategy_signal", + trigger_reason_detail_code="mean_reversion_exit", + position_strategy_origin_timestamp=pd.NaT, + position_strategy_origin_code=None, + ) + ] + ) + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace(experiment_name="test", strategy_parameters={}), + ) + fake = _FakeStreamlit() + + render_trade_table(fake, result) + + displayed = fake.dataframe_calls[0][0] + assert "Position origin" in displayed.columns + assert "Position origin date" in displayed.columns + assert displayed["Position origin"].tolist() == [None] + assert pd.isna(displayed["Position origin date"].iloc[0]) + # Trigger, on the same row, is a real value -- confirms the blank + # Position origin is not a side effect of an otherwise-empty row. + assert displayed["Trigger"].tolist() == ["strategy_signal"] + + +def test_trade_table_csv_export_always_has_the_full_21_column_schema() -> None: + """The CSV download must always use the raw `result.trades` frame -- + its schema must never depend on, or be narrowed by, the display + view above it.""" + from quantlab.backtesting.trade_log import TRADE_LOG_COLUMNS + + trades = pd.DataFrame.from_records([_trade_row()]) + assert list(trades.columns) == TRADE_LOG_COLUMNS + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace(experiment_name="test", strategy_parameters={}), + ) + fake = _FakeStreamlit() + + exported: dict[str, object] = {} + + def _capture_download_button(*args: object, **kwargs: object) -> None: + exported["csv_bytes"] = args[1] if len(args) > 1 else kwargs.get("data") + + fake.download_button = _capture_download_button # type: ignore[method-assign] + + render_trade_table(fake, result) + + assert "csv_bytes" in exported + exported_csv = cast(bytes, exported["csv_bytes"]).decode("utf-8") + header = exported_csv.splitlines()[0] + assert header.split(",") == TRADE_LOG_COLUMNS + + +def test_trade_table_stop_loss_take_profit_caption_shown_even_with_zero_triggers() -> ( + None +): + """Both thresholds configured but neither ever fired (0 triggers) must + still show the caption -- a configured-but-never-triggered stop/target + is meaningful information, not the same as "not configured at all".""" + trades = pd.DataFrame.from_records([_trade_row()]) + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace( + experiment_name="test", + strategy_parameters={"stop_loss_pct": 0.1, "take_profit_pct": 0.2}, + ), + ) + fake = _FakeStreamlit() + + render_trade_table(fake, result) + + assert len(fake.caption_calls) == 1 + text = str(fake.caption_calls[0][0]) + assert "Stop-loss affected" in text + assert "take-profit affected" in text + + +def test_trade_table_caption_only_mentions_stop_loss_when_take_profit_disabled() -> ( + None +): + """Enabling only stop_loss_pct must produce a caption about stop-loss + alone -- it must never imply take-profit was also active at 0.""" + trades = pd.DataFrame.from_records([_trade_row()]) + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace( + experiment_name="test", + strategy_parameters={"stop_loss_pct": 0.1, "take_profit_pct": None}, + ), + ) + fake = _FakeStreamlit() + + render_trade_table(fake, result) + + assert len(fake.caption_calls) == 1 + text = str(fake.caption_calls[0][0]) + # The trailing counting-convention caveat generically names both terms + # regardless of which is configured -- only the leading clause (before + # it) must be scoped to what's actually enabled. + leading_clause = text.split(" -- counted", 1)[0] + assert "Stop-loss affected" in leading_clause + assert "take-profit" not in leading_clause.lower() + + +def test_trade_table_caption_only_mentions_take_profit_when_stop_loss_disabled() -> ( + None +): + """Symmetric case: only take_profit_pct configured -- the caption must + lead with "Take-profit" (capitalized, since it is now the first + clause) and never mention stop-loss.""" + trades = pd.DataFrame.from_records([_trade_row()]) + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace( + experiment_name="test", + strategy_parameters={"stop_loss_pct": None, "take_profit_pct": 0.2}, + ), + ) + fake = _FakeStreamlit() + + render_trade_table(fake, result) + + assert len(fake.caption_calls) == 1 + text = str(fake.caption_calls[0][0]) + leading_clause = text.split(" -- counted", 1)[0] + assert text.startswith("Take-profit affected") + assert "stop-loss" not in leading_clause.lower() + + +def test_trade_table_no_caption_when_neither_stop_loss_nor_take_profit_configured() -> ( + None +): + """Neither threshold configured -- no caption at all, matching a + strategy that never declared either parameter.""" + trades = pd.DataFrame.from_records([_trade_row()]) + result: Any = SimpleNamespace( + trades=trades, + config=SimpleNamespace(experiment_name="test", strategy_parameters={}), + ) + fake = _FakeStreamlit() + + render_trade_table(fake, result) + + assert fake.caption_calls == [] + + @pytest.mark.parametrize( ("signal_scaling", "expected_options"), [ @@ -378,6 +821,46 @@ def test_allocator_options_match_time_series_momentum_scaling( assert allocator.value in expected_options +@pytest.mark.parametrize( + ("signal_scaling", "expected_options"), + [ + ( + "binary", + [ + "equal_weight", + "signal_proportional", + "inverse_volatility", + "volatility_targeting", + ], + ), + ( + "continuous", + ["signal_proportional", "inverse_volatility", "volatility_targeting"], + ), + ], +) +def test_allocator_options_match_cross_sectional_momentum_scaling( + signal_scaling: str, expected_options: list[str] +) -> None: + """Regression test: the dashboard must mirror ExperimentConfig's own + validator (config.py's "Non-binary cross_sectional_momentum signals + require an allocator that preserves signal magnitude; equal_weight + keeps only signs") -- equal_weight was newly reachable in the UI for + 'continuous' scaling even though the backend has always rejected it, + failing only after "Run backtest" instead of narrowing the choice + up front, exactly like the sibling time_series_momentum case already + does.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _sidebar_selectbox(at, "Strategy").set_value("cross_sectional_momentum").run() + _sidebar_selectbox(at, "Signal scaling").set_value(signal_scaling).run() + assert not at.exception + + allocator = _sidebar_selectbox(at, "Allocator") + assert allocator.options == expected_options + assert allocator.value in expected_options + + def test_pairs_trading_symbol_inputs_render_without_crash() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() @@ -415,7 +898,7 @@ def test_bundled_demo_csvs_require_an_explicit_dashboard_opt_in() -> None: _configure_offline_pairs_trade(at) _sidebar_toggle(at, "Allow bundled synthetic demo data").set_value(True).run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error @@ -464,13 +947,17 @@ def test_strategy_widget_dispatch_uses_exact_registered_names( _sidebar_selectbox(at, "Strategy").set_value(custom_name).run() assert not at.exception - assert not any(slider.label == "Entry z-score" for slider in at.sidebar.slider) + assert not any(slider.label == "Entry threshold" for slider in at.sidebar.slider) @pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"]) def test_reversion_exit_slider_stays_strictly_below_entry( strategy_name: str, ) -> None: + """Both mean_reversion and pairs_trading use the generic 'Entry/Exit + threshold' labels -- both now support the same zscore/rsi/percentile + indicator choice, not only a z-score.""" + entry_label, exit_label = "Entry threshold", "Exit threshold" at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() if strategy_name == "pairs_trading": @@ -479,13 +966,9 @@ def test_reversion_exit_slider_stays_strictly_below_entry( ).run() _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run() - entry = next( - slider for slider in at.sidebar.slider if slider.label == "Entry z-score" - ) + entry = next(slider for slider in at.sidebar.slider if slider.label == entry_label) entry.set_value(1.0).run() - exit_ = next( - slider for slider in at.sidebar.slider if slider.label == "Exit z-score" - ) + exit_ = next(slider for slider in at.sidebar.slider if slider.label == exit_label) exit_max = exit_.proto.max entry_value = cast(float, entry.value) @@ -493,6 +976,155 @@ def test_reversion_exit_slider_stays_strictly_below_entry( assert exit_max < entry_value +@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"]) +@pytest.mark.parametrize("indicator", ["rsi", "percentile"]) +def test_entry_at_max_never_crashes_the_stop_slider( + strategy_name: str, indicator: str +) -> None: + """For rsi/percentile, entry_threshold's own slider max equals + stop_threshold's max -- dragging entry all the way up must not push the + stop slider's min (entry + step) past its own max and crash it.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + if strategy_name == "pairs_trading": + _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value( + "AAA, BBB" + ).run() + _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run() + _sidebar_selectbox(at, "Indicator").set_value(indicator).run() + + entry = next( + slider for slider in at.sidebar.slider if slider.label == "Entry threshold" + ) + entry.set_value(entry.proto.max).run() + + assert not at.exception + stop = next( + slider for slider in at.sidebar.slider if slider.label == "Stop threshold" + ) + # Streamlit's own slider requires min STRICTLY less than max (min == + # max raises too) -- the off-by-one this test originally missed. + assert stop.proto.min < stop.proto.max + + +@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"]) +@pytest.mark.parametrize("indicator", ["rsi", "percentile"]) +def test_entry_at_min_never_crashes_the_exit_slider( + strategy_name: str, indicator: str +) -> None: + """Dragging entry all the way down to its own min must never crash -- + for `percentile`, entry's own min equals the exit slider's step, which + pushes the exit slider's own max (entry - step) down to exactly 0.0: + a degenerate range Streamlit's slider would reject (min == max), so + the dashboard shows a fixed 0.0 caption instead of rendering a slider + at all. For `rsi`, entry's min stays comfortably above `step`, so an + ordinary, non-degenerate exit slider still renders.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + if strategy_name == "pairs_trading": + _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value( + "AAA, BBB" + ).run() + _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run() + _sidebar_selectbox(at, "Indicator").set_value(indicator).run() + + entry = next( + slider for slider in at.sidebar.slider if slider.label == "Entry threshold" + ) + entry.set_value(entry.proto.min).run() + + assert not at.exception + exit_sliders = [s for s in at.sidebar.slider if s.label == "Exit threshold"] + if indicator == "percentile": + # Degenerate case: entry.min == step, so exit_threshold's only + # valid value (0.0) is shown as a caption, never a slider. + assert exit_sliders == [] + assert any( + "Exit threshold: 0.0" in caption.value for caption in at.sidebar.caption + ) + else: + assert len(exit_sliders) == 1 + assert exit_sliders[0].proto.min < exit_sliders[0].proto.max + + +@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"]) +def test_entry_threshold_reaches_its_own_step_sized_minimum( + strategy_name: str, +) -> None: + """`percentile`'s own YAML/Python-valid minimum, 0.01 (== its step), + must be reachable through the dashboard slider -- an earlier version + unconditionally floored entry's own min at `2 * step` (0.02) to dodge + the exit slider's degenerate near-zero case, silently making a valid + configuration value unreachable through the UI.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + if strategy_name == "pairs_trading": + _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value( + "AAA, BBB" + ).run() + _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run() + _sidebar_selectbox(at, "Indicator").set_value("percentile").run() + + entry = next( + slider for slider in at.sidebar.slider if slider.label == "Entry threshold" + ) + assert entry.proto.min == pytest.approx(0.01) + + +@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"]) +@pytest.mark.parametrize( + ("indicator", "full_max"), [("rsi", 50.0), ("percentile", 0.49)] +) +def test_entry_domain_keeps_its_full_range_when_stop_is_disabled( + strategy_name: str, indicator: str, full_max: float +) -> None: + """Entry threshold's own max must only be narrowed to protect the stop + slider (min == entry + step must stay < stop_max) WHILE that slider + actually exists -- with "Enable stop threshold" off, entry must keep + its full original range (rsi up to 50, percentile up to 0.49), not + silently lose reachable values guarding a slider that isn't rendered.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + if strategy_name == "pairs_trading": + _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value( + "AAA, BBB" + ).run() + _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run() + _sidebar_selectbox(at, "Indicator").set_value(indicator).run() + stop_checkbox = next( + box + for box in at.sidebar.checkbox + if box.label in ("Enable stop threshold", "stop_threshold enabled") + ) + stop_checkbox.set_value(False).run() + + entry = next( + slider for slider in at.sidebar.slider if slider.label == "Entry threshold" + ) + assert entry.proto.max == pytest.approx(full_max) + + +@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"]) +def test_entry_domain_is_narrowed_when_stop_is_enabled(strategy_name: str) -> None: + """The mirror of the above: with "Enable stop threshold" on (the + default), entry's max IS narrowed below rsi's full 50 -- proving the + restriction is actually conditional on the checkbox, not just always + off.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + if strategy_name == "pairs_trading": + _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value( + "AAA, BBB" + ).run() + _sidebar_selectbox(at, "Strategy").set_value(strategy_name).run() + _sidebar_selectbox(at, "Indicator").set_value("rsi").run() + + entry = next( + slider for slider in at.sidebar.slider if slider.label == "Entry threshold" + ) + assert entry.proto.max == pytest.approx(48.0) + + def test_holdout_controls_do_not_claim_automatic_tuning() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() @@ -531,7 +1163,7 @@ def test_dashboard_can_disable_volatility_targeting_and_set_risk_free_rate() -> _configure_offline_pairs_trade(at) _sidebar_toggle(at, "Enable volatility targeting").set_value(False).run() _sidebar_number_input(at, "Risk-free rate (annual %)").set_value(3.5).run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error @@ -549,12 +1181,12 @@ def test_failed_backtest_invalidates_previous_result() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() _configure_offline_pairs_trade(at) - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert "result" in at.session_state _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY").run() - assert at.sidebar.button[0].proto.disabled is False - at.sidebar.button[0].click().run() + assert _run_button(at).proto.disabled is False + _run_button(at).click().run() assert not at.exception assert any("Backtest failed" in error.value for error in at.error) @@ -568,7 +1200,7 @@ def test_successful_pairs_backtest_creates_expected_tab_navigation() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() _configure_offline_pairs_trade(at) - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error @@ -578,6 +1210,79 @@ def test_successful_pairs_backtest_creates_expected_tab_navigation() -> None: assert len(at.tabs[0].metric) > 0 +def test_trades_tab_shows_action_and_separate_reason_columns() -> None: + """The Trades tab must show action/trigger/adjustment/position-origin + as separate, human-labelled columns in the fixed display order -- + never a bare buy/sell-only table, and never a fused "code (detail)" + string (that silently dropped a detail code from this table's own + native CSV export icon -- a real regression a user hit).""" + from quantlab.dashboard.components import _TRADE_TABLE_DISPLAY_COLUMNS + + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + # pairs_trading on this bundled synthetic (independent-random-walk) CSV + # data never finds a tradeable spread -- use mean_reversion instead, + # over a wide enough window to actually generate fills, so this test + # exercises a populated table, not the empty-state "no trades" message. + _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run() + _sidebar_date_input(at, "End date").set_value(datetime.date(2020, 6, 1)).run() + _sidebar_selectbox(at, "Strategy").set_value("mean_reversion").run() + _run_button(at).click().run() + assert not at.exception + + at.session_state["dashboard_active_tab"] = "Trades" + _run_button(at).click().run() + assert not at.exception + + trades_tab = at.tabs[1] + assert trades_tab.label == "Trades" + assert len(trades_tab.dataframe) == 1 + table = trades_tab.dataframe[0].value + # Fixed schema: same 15 columns, same order, regardless of strategy. + assert list(table.columns) == _TRADE_TABLE_DISPLAY_COLUMNS + assert len(table) > 0 + # Display labels are the DataFrame's own column names for most + # columns; the few with extra column_config (formatting/help) still + # carry that same name as their label, never a different alias. + column_config = json.loads(trades_tab.dataframe[0].proto.columns) + assert column_config["Trigger"]["label"] == "Trigger" + assert column_config["Trigger detail"]["label"] == "Trigger detail" + assert column_config["Adjustments"]["label"] == "Adjustments" + assert column_config["Position origin"]["label"] == "Position origin" + assert column_config["Position origin date"]["label"] == "Position origin date" + # Every real action must resolve to one of _classify_action's own + # labels, never a leftover placeholder -- confirms the engine's new + # frames actually reached build_trade_log, not just that a "side" + # column silently carried the table alone. + from quantlab.backtesting.trade_log import _classify_action + + valid_actions = { + _classify_action(p, n) + for p in (-1.0, -0.5, 0.0, 0.5, 1.0) + for n in (-1.0, -0.5, 0.0, 0.5, 1.0) + } + assert set(table["Action"]) <= valid_actions + # Trigger is never fused with Trigger detail -- no row's Trigger + # contains a literal "(" -- and at least one row has a precise + # Trigger detail (mean_reversion's own explain_signals()) plus at + # least one strategy_signal row's Details text keeps the generic + # "signal X -> Y since last rebalance" text. Also confirms the core + # fix: at least one entry keeps its strategy_signal trigger even + # though a real constraint also fired as an adjustment on it. + assert table["Trigger"].notna().any() + assert not any("(" in str(value) for value in table["Trigger"]) + assert table["Trigger detail"].notna().any() + strategy_signal_details = table.loc[ + table["Trigger"] == "strategy_signal", "Details" + ] + assert strategy_signal_details.notna().all() + assert all( + "since last rebalance" in str(value) for value in strategy_signal_details + ) + entries = table[table["Action"] == "entry_long"] + assert (entries["Trigger"] == "strategy_signal").any() + + def test_robustness_tab_shows_holdout_table_when_enabled() -> None: """With holdout ticked, the Robustness tab must show a real data table, not just the "no holdout attached" placeholder.""" @@ -594,7 +1299,7 @@ def test_robustness_tab_shows_holdout_table_when_enabled() -> None: # click, so the tab is selected directly via the widget's own session # state key instead (see `app.py`'s `st.tabs(..., key=...)`). at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception robustness_tab = at.tabs[2] @@ -615,7 +1320,7 @@ def test_robustness_tab_shows_placeholder_without_holdout() -> None: at.run() _configure_offline_pairs_trade(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception robustness_tab = at.tabs[2] @@ -629,7 +1334,7 @@ def test_failed_stress_run_clears_previous_stress_evidence() -> None: at.run() _configure_offline_pairs_trade(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception result = at.session_state["result"] @@ -656,7 +1361,7 @@ def test_stale_result_warning_shown_after_sidebar_change() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() _configure_offline_pairs_trade(at) - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not any("configuration has changed" in w.value for w in at.warning) _sidebar_selectbox(at, "Strategy").set_value("mean_reversion").run() @@ -670,7 +1375,7 @@ def test_frequency_mismatch_shown_as_prominent_error_not_small_caption() -> None _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run() _sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run() _sidebar_selectbox(at, "Frequency").set_value("1h").run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert any("Frequency mismatch detected" in e.value for e in at.error) @@ -690,7 +1395,7 @@ def test_only_the_open_tab_renders_its_content() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() _configure_offline_pairs_trade(at) - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception results_tab = at.tabs[0] @@ -711,7 +1416,7 @@ def test_switching_the_open_tab_renders_its_content_instead() -> None: at.run() _configure_offline_pairs_trade(at) at.session_state["dashboard_active_tab"] = "Trades" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception trades_tab = at.tabs[1] @@ -733,7 +1438,7 @@ def boom(_result: Any) -> Any: at.run() _configure_offline_pairs_trade(at) at.session_state["dashboard_active_tab"] = "Report" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception report_tab = at.tabs[3] @@ -749,7 +1454,7 @@ def test_report_tab_includes_stress_tests_run_in_robustness_tab() -> None: at.run() _configure_offline_pairs_trade(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception robustness_tab = at.tabs[2] @@ -798,7 +1503,7 @@ def test_advanced_data_settings_are_accepted_by_the_config() -> None: _sidebar_number_input(at, "Forward-fill limit (consecutive bars)").set_value( 3 ).run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error @@ -832,7 +1537,7 @@ def test_advanced_execution_settings_are_accepted_by_the_config() -> None: _configure_offline_pairs_trade(at) _sidebar_selectbox(at, "Slippage model").set_value("volume").run() _sidebar_number_input(at, "Volume impact coefficient").set_value(0.25).run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error @@ -869,10 +1574,10 @@ def test_advanced_portfolio_constraints_are_accepted_by_the_config() -> None: _sidebar_checkbox(at, "Cap gross exposure").set_value(True).run() _sidebar_checkbox(at, "Cap net exposure").set_value(True).run() _sidebar_checkbox(at, "Cap number of positions").set_value(True).run() - _sidebar_checkbox(at, "Cap turnover per rebalance").set_value(True).run() + _sidebar_checkbox(at, "Cap turnover per period").set_value(True).run() assert not at.exception - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error portfolio = at.session_state["result"].config.portfolio @@ -887,7 +1592,7 @@ def test_gross_vs_net_section_renders_with_real_values() -> None: at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() _sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error @@ -1088,7 +1793,7 @@ def test_walk_forward_run_populates_oos_result_and_tabs() -> None: at = AppTest.from_file(APP_PATH, default_timeout=120) at.run() _configure_offline_walk_forward(at) - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert not at.error @@ -1114,7 +1819,7 @@ def test_walk_forward_robustness_tab_shows_fold_table_and_stability() -> None: at, "Candidate values for lookback_period (comma-separated)" ).set_value("10, 20").run() at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception robustness_tab = at.tabs[2] @@ -1134,7 +1839,7 @@ def test_walk_forward_report_tab_includes_fold_evidence() -> None: at.run() _configure_offline_walk_forward(at) at.session_state["dashboard_active_tab"] = "Report" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception report_tab = at.tabs[3] @@ -1150,7 +1855,7 @@ def test_walk_forward_no_fitting_fold_shows_error_not_crash() -> None: _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run() _sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run() _sidebar_number_input(at, "Train window (periods)").set_value(10_000).run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert any("No walk-forward fold fit" in e.value for e in at.error) @@ -1163,7 +1868,7 @@ def test_switching_to_backtest_mode_does_not_affect_a_stored_walk_forward_result at = AppTest.from_file(APP_PATH, default_timeout=120) at.run() _configure_offline_walk_forward(at) - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception assert "wf_result" in at.session_state @@ -1188,7 +1893,7 @@ def test_backtest_robustness_tab_bootstrap_runs_and_displays() -> None: at.run() _configure_offline_pairs_trade(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception n_iterations = next( @@ -1215,7 +1920,7 @@ def test_backtest_robustness_tab_permutation_test_runs_and_displays() -> None: at.run() _configure_offline_pairs_trade(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception n_iterations = next( @@ -1240,7 +1945,7 @@ def test_backtest_robustness_tab_sensitivity_runs_and_displays_heatmap() -> None at.run() _configure_offline_mean_reversion(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception robustness_tab = at.tabs[2] @@ -1259,7 +1964,7 @@ def test_backtest_robustness_tab_sensitivity_runs_and_displays_heatmap() -> None y_select = next( sb for sb in robustness_tab.selectbox if sb.label == "Parameter (y-axis)" ) - y_select.set_value("entry_zscore").run() + y_select.set_value("entry_threshold").run() robustness_tab = at.tabs[2] y_values = next( f @@ -1290,7 +1995,7 @@ def test_walk_forward_robustness_tab_bootstrap_runs_and_displays() -> None: at.run() _configure_offline_walk_forward(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception n_iterations = next( @@ -1316,7 +2021,7 @@ def test_walk_forward_robustness_tab_permutation_test_runs_and_displays() -> Non at.run() _configure_offline_walk_forward(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception n_iterations = next( @@ -1340,7 +2045,7 @@ def test_walk_forward_robustness_tab_stress_tests_reruns_selection() -> None: at.run() _configure_offline_walk_forward(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception stress_button = next(b for b in at.tabs[2].button if b.label == "Run stress tests") @@ -1358,7 +2063,7 @@ def test_backtest_run_all_robustness_tests_populates_every_technique() -> None: at.run() _configure_offline_mean_reversion(at) at.session_state["dashboard_active_tab"] = "Robustness" - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert not at.exception robustness_tab = at.tabs[2] @@ -1382,7 +2087,7 @@ def test_backtest_run_all_robustness_tests_populates_every_technique() -> None: robustness_tab = at.tabs[2] next( sb for sb in robustness_tab.selectbox if sb.label == "Parameter (y-axis)" - ).set_value("entry_zscore").run() + ).set_value("entry_threshold").run() robustness_tab = at.tabs[2] next( f @@ -1414,7 +2119,7 @@ def test_conflicting_symbol_across_pickers_blocks_submission_with_error() -> Non in app.py).""" at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() - assert at.sidebar.button[0].proto.disabled is False + assert _run_button(at).proto.disabled is False yahoo_ms = _sidebar_multiselect_by_key(at, "yahoo_symbols") # SPY is already in CSV's default population (SPY, QQQ, TLT, GLD). @@ -1422,7 +2127,7 @@ def test_conflicting_symbol_across_pickers_blocks_submission_with_error() -> Non assert not at.exception assert any("ambiguous" in e.value and "SPY" in e.value for e in at.sidebar.error) - assert at.sidebar.button[0].proto.disabled is True + assert _run_button(at).proto.disabled is True def test_instrument_source_comes_from_picker_provenance_not_a_heuristic( @@ -1435,10 +2140,10 @@ def test_instrument_source_comes_from_picker_provenance_not_a_heuristic( own table, so this exercises the real sidebar -> `_collect_inputs` -> `build_config_from_inputs` pipeline. - `run_dashboard_backtest` is monkeypatched to capture the config and - raise immediately (before any data loading happens), so this stays - fully offline — real Binance OHLCV data is never fetched, only its - (also monkeypatched) symbol-suggestion list.""" + `run_dashboard_backtest_with_data` is monkeypatched to capture the + config and raise immediately (before any data loading happens), so + this stays fully offline — real Binance OHLCV data is never fetched, + only its (also monkeypatched) symbol-suggestion list.""" import quantlab.dashboard.state as state_module from quantlab.config import DataSourceName from quantlab.data.base import SymbolSuggestion @@ -1453,21 +2158,24 @@ def test_instrument_source_comes_from_picker_provenance_not_a_heuristic( captured: dict[str, Any] = {} - def fake_run_dashboard_backtest(config: Any) -> Any: + def fake_run_dashboard_backtest_with_data(config: Any) -> Any: captured["config"] = config raise RuntimeError("stop before real data loading") monkeypatch.setattr( - state_module, "run_dashboard_backtest", fake_run_dashboard_backtest + state_module, + "run_dashboard_backtest_with_data", + fake_run_dashboard_backtest_with_data, ) at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("").run() + _load_binance_universe(at) binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols") binance_ms.set_value([binance_ms.options[0]]).run() - assert at.sidebar.button[0].proto.disabled is False - at.sidebar.button[0].click().run() + assert _run_button(at).proto.disabled is False + _run_button(at).click().run() assert "config" in captured instruments = captured["config"].data.instruments @@ -1504,6 +2212,7 @@ def test_frequency_options_reflect_selected_sources( ) assert freq.options == expected_csv_only + _load_binance_universe(at) binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols") binance_ms.set_value([binance_ms.options[0]]).run() @@ -1553,6 +2262,7 @@ def test_mixed_calendar_warning_and_periods_per_year_field_appear_together( for f in at.sidebar.number_input ) + _load_binance_universe(at) binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols") binance_ms.set_value([binance_ms.options[0]]).run() @@ -1569,7 +2279,7 @@ def test_mixed_calendar_warning_and_periods_per_year_field_appear_together( # so the business-day equity convention would understate its real # trading frequency. assert periods_field.value == 365 - assert at.sidebar.button[0].proto.disabled is False + assert _run_button(at).proto.disabled is False def test_mixed_calendar_universe_excludes_intraday_frequency( @@ -1594,6 +2304,7 @@ def test_mixed_calendar_universe_excludes_intraday_frequency( freq = next(sb for sb in at.sidebar.selectbox if sb.label == "Frequency") assert "1h" in freq.options # CSV's default population is all XNYS + _load_binance_universe(at) binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols") binance_ms.set_value([binance_ms.options[0]]).run() @@ -1611,9 +2322,9 @@ def test_periods_per_year_value_flows_into_the_built_config( """Complements the sidebar-only check above: an explicit "Periods per year" value, entered while the universe spans more than one calendar, actually reaches `BacktestConfig.periods_per_year` via - `_collect_inputs` / `build_config_from_inputs`. `run_dashboard_backtest` - is monkeypatched to capture the config before any data loading, keeping - this offline.""" + `_collect_inputs` / `build_config_from_inputs`. + `run_dashboard_backtest_with_data` is monkeypatched to capture the + config before any data loading, keeping this offline.""" import quantlab.dashboard.state as state_module from quantlab.data.base import SymbolSuggestion from quantlab.data.binance import BinanceDataSource @@ -1627,16 +2338,19 @@ def test_periods_per_year_value_flows_into_the_built_config( captured: dict[str, Any] = {} - def fake_run_dashboard_backtest(config: Any) -> Any: + def fake_run_dashboard_backtest_with_data(config: Any) -> Any: captured["config"] = config raise RuntimeError("stop before real data loading") monkeypatch.setattr( - state_module, "run_dashboard_backtest", fake_run_dashboard_backtest + state_module, + "run_dashboard_backtest_with_data", + fake_run_dashboard_backtest_with_data, ) at = AppTest.from_file(APP_PATH, default_timeout=60) at.run() + _load_binance_universe(at) binance_ms = _sidebar_multiselect_by_key(at, "binance_symbols") binance_ms.set_value([binance_ms.options[0]]).run() periods_field = next( @@ -1645,7 +2359,7 @@ def fake_run_dashboard_backtest(config: Any) -> Any: if f.label == "Periods per year (annualisation factor)" ) periods_field.set_value(365).run() - at.sidebar.button[0].click().run() + _run_button(at).click().run() assert "config" in captured assert captured["config"].backtest.periods_per_year == 365 @@ -1687,3 +2401,615 @@ def test_benchmark_symbol_not_matching_any_instrument_shows_source_and_calendar( calendar = next(f for f in at.sidebar.text_input if f.label == "Benchmark calendar") assert calendar.key == "benchmark_calendar_input" assert calendar.value == "XNYS" + + +def _switch_to_strategies_mode(at: AppTest) -> AppTest: + at.segmented_control[0].set_value("Strategies").run() + return at + + +def test_strategies_mode_shows_gallery_with_one_card_per_strategy() -> None: + from quantlab.strategies.base import available_strategies + + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + assert not at.exception + + strategies = available_strategies() + open_buttons = { + b.key for b in at.button if b.key and b.key.startswith("explorer_open_") + } + assert open_buttons == {f"explorer_open_{name}" for name in strategies} + + +def test_strategies_mode_hides_the_sidebar() -> None: + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + assert len(at.sidebar.children) > 0 # Backtest mode: sidebar populated + _switch_to_strategies_mode(at) + assert len(at.sidebar.children) == 0 + + +@pytest.mark.parametrize( + "strategy_name", + [ + "buy_and_hold", + "pairs_trading", + "mean_reversion", + "time_series_momentum", + "cross_sectional_momentum", + "trend_following", + ], +) +def test_strategy_detail_page_opens_with_every_section(strategy_name: str) -> None: + """Opening any registered strategy's detail page must not raise, and + must show every common documented section. References / Further + reading is optional -- required only when that strategy's profile + actually sets ``references_md`` (e.g. Buy & Hold deliberately has none; + see `test_buy_and_hold_detail_page_has_no_references_section` below). + The interactive lab lives in a lazy expander (see `detail.py`) and is + collapsed by default, so its own body does NOT run here -- see + `test_strategy_lab_opens_and_runs_without_exception` below for that.""" + from quantlab.dashboard.explorer.profile import get_profile + + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key=f"explorer_open_{strategy_name}").click().run() + + assert not at.exception + assert at.session_state["explorer_strategy"] == strategy_name + expander_labels = {e.label for e in at.expander} + assert expander_labels >= { + "Overview", + "Economic intuition", + "Mathematical definition & signals", + "Assumptions", + "Diagnostics", + "Parameters", + "Interactive laboratory", + "Interpretation", + "Limitations & failure modes", + } + profile = get_profile(strategy_name) + assert profile is not None + has_references_section = "References / Further reading" in expander_labels + assert has_references_section == (profile.references_md is not None) + + +def test_buy_and_hold_detail_page_has_no_references_section() -> None: + """Buy & Hold's profile deliberately sets ``references_md=None`` -- no + strategy-specific literature was genuinely indispensable for the + zero-skill baseline it describes -- so its detail page must not show a + References / Further reading section at all.""" + from quantlab.dashboard.explorer.profile import get_profile + + profile = get_profile("buy_and_hold") + assert profile is not None + assert profile.references_md is None + + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key="explorer_open_buy_and_hold").click().run() + assert not at.exception + expander_labels = {e.label for e in at.expander} + assert "References / Further reading" not in expander_labels + + +@pytest.mark.parametrize( + "strategy_name", + [ + "buy_and_hold", + "pairs_trading", + "mean_reversion", + "time_series_momentum", + "cross_sectional_momentum", + "trend_following", + ], +) +def test_strategy_lab_opens_and_runs_without_exception(strategy_name: str) -> None: + """Actually opening each strategy's Interactive laboratory expander (not + just visiting the detail page -- see the lazy-expander note on the test + above) must run its full default body without exception, on real + bundled offline data.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key=f"explorer_open_{strategy_name}").click().run() + assert not at.exception + + at.session_state[f"explorer_lab_expander_{strategy_name}"] = True + at.run() + assert not at.exception + + +def _open_lab(at: AppTest, strategy_name: str) -> AppTest: + _switch_to_strategies_mode(at) + at.button(key=f"explorer_open_{strategy_name}").click().run() + at.session_state[f"explorer_lab_expander_{strategy_name}"] = True + at.run() + return at + + +def test_lab_symbol_picker_defaults_to_csv_with_bundled_demo_data_on() -> None: + """Unlike the main sidebar (`use_bundled_demo_data` defaults to False), + a lab defaults it to True so it keeps working fully offline with no + setup -- see `render_symbol_and_source_picker`.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _open_lab(at, "mean_reversion") + assert not at.exception + + source = next(r for r in at.radio if r.key == "explorer_mr_source") + assert source.value == "csv" + csv_input = next( + f for f in at.text_input if f.key == "explorer_mr_csv_symbols_input" + ) + assert csv_input.value == "SPY, QQQ, TLT, GLD" + bundled_toggle = next( + t for t in at.toggle if t.key == "explorer_mr_use_bundled_demo_data" + ) + assert bundled_toggle.value is True + + +@pytest.mark.parametrize( + ("calendar", "expected"), + [("24/7", 365), ("XNYS", 252), ("XHKG", 252)], +) +def test_tsmom_lab_periods_per_year_derives_from_the_calendar( + calendar: str, expected: int +) -> None: + """Regression test: the Time-Series Momentum lab's volatility_adjusted + panel used to annualise at a hardcoded 252 regardless of the selected + calendar -- for a 24/7 market this silently mis-annualised the + illustrative volatility, unlike the real strategy (`periods_per_year` + is injected from the experiment's own data frequency: 365 for daily + crypto). Tested directly (no Streamlit runtime needed) since AppTest + cannot introspect a rendered Plotly chart's own title/values in this + Streamlit version.""" + from quantlab.dashboard.explorer.labs.time_series_momentum import ( + _periods_per_year_for_calendar, + ) + + assert _periods_per_year_for_calendar(calendar) == expected + + +def test_tsmom_lab_accepts_an_overridden_24_7_calendar_without_exception() -> None: + """End-to-end smoke check that the new editable "Calendar" field (see + render_symbol_and_source_picker) actually reaches the lab and the + 24/7 branch renders without exception -- avoids depending on live + Binance network access just to exercise this calendar.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _open_lab(at, "time_series_momentum") + assert not at.exception + + calendar_input = next( + f for f in at.text_input if f.key == "explorer_tsmom_csv_calendar" + ) + calendar_input.set_value("24/7").run() + assert not at.exception + + +def test_lab_symbol_picker_switching_to_yahoo_shows_the_yahoo_picker() -> None: + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _open_lab(at, "mean_reversion") + + source = next(r for r in at.radio if r.key == "explorer_mr_source") + at.session_state["explorer_lab_expander_mean_reversion"] = True + source.set_value("yahoo").run() + assert not at.exception + assert not any(f.key == "explorer_mr_csv_symbols_input" for f in at.text_input) + assert any(ms.key == "explorer_mr_yahoo_symbols" for ms in at.multiselect) + + +def test_lab_csv_calendar_defaults_to_xnys_and_is_editable() -> None: + """Regression test: a lab's CSV symbol picker used to always assume + XNYS with no way to change it -- CSV data carries no calendar + information at all, so a non-XNYS local instrument (futures, a + non-US index) needs an explicit override, same as the main + dashboard's own per-instrument table.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _open_lab(at, "mean_reversion") + assert not at.exception + + calendar_input = next( + f for f in at.text_input if f.key == "explorer_mr_csv_calendar" + ) + assert calendar_input.value == "XNYS" + + +def test_lab_yahoo_symbol_auto_detects_a_non_xnys_calendar() -> None: + """Regression test: a Yahoo symbol used to silently get XNYS regardless + of its own suffix -- "1211.HK" must auto-detect XHKG (see + `detect_calendar`), shown in an editable field the user can still + correct if the guess is wrong.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _open_lab(at, "mean_reversion") + + source = next(r for r in at.radio if r.key == "explorer_mr_source") + at.session_state["explorer_lab_expander_mean_reversion"] = True + source.set_value("yahoo").run() + + picker = next(ms for ms in at.multiselect if ms.key == "explorer_mr_yahoo_symbols") + at.session_state["explorer_lab_expander_mean_reversion"] = True + picker.set_value(["1211.HK"]).run() + assert not at.exception + + calendar_input = next( + f for f in at.text_input if f.key == "explorer_mr_yahoo_calendar" + ) + assert calendar_input.value == "XHKG" + + +def test_lab_yahoo_multi_calendar_selection_is_rejected_with_a_clear_error() -> None: + """A lab computes on one flat price matrix and cannot represent more + than one calendar at once -- selecting symbols that need different + calendars (a US ticker and a Hong Kong one) must be rejected with a + clear error rather than silently picking one for all of them.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _open_lab(at, "mean_reversion") + + source = next(r for r in at.radio if r.key == "explorer_mr_source") + at.session_state["explorer_lab_expander_mean_reversion"] = True + source.set_value("yahoo").run() + + picker = next(ms for ms in at.multiselect if ms.key == "explorer_mr_yahoo_symbols") + at.session_state["explorer_lab_expander_mean_reversion"] = True + picker.set_value(["AAPL", "1211.HK"]).run() + assert not at.exception + assert not any(f.key == "explorer_mr_yahoo_calendar" for f in at.text_input) + assert any("different calendars" in e.value for e in at.error) + + +def test_lab_symbol_picker_binance_requires_an_explicit_load_click() -> None: + """Mirrors the main sidebar's own Binance gate: fetching the universe + is never triggered just by selecting "binance" as the source.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _open_lab(at, "mean_reversion") + + source = next(r for r in at.radio if r.key == "explorer_mr_source") + at.session_state["explorer_lab_expander_mean_reversion"] = True + source.set_value("binance").run() + assert not at.exception + assert not any(ms.key == "explorer_mr_binance_symbols" for ms in at.multiselect) + load_button = next( + b for b in at.button if b.key == "explorer_mr_binance_universe_load_button" + ) + + at.session_state["explorer_lab_expander_mean_reversion"] = True + load_button.click().run() + assert not at.exception + assert any(ms.key == "explorer_mr_binance_symbols" for ms in at.multiselect) + + +def test_every_lab_uses_a_symbol_picker_key_prefix_unique_to_itself() -> None: + """Every lab's `render_symbol_and_source_picker(key_prefix=...)` must be + unique -- a shared/copy-pasted prefix would make two labs silently + read and write the same session_state entries (a picked Yahoo symbol + in one lab leaking into another).""" + import re + from pathlib import Path + + import quantlab.dashboard.explorer.labs as labs_package + + source_dir = Path(labs_package.__file__).parent + lab_files = ( + "buy_and_hold.py", + "pairs_trading.py", + "mean_reversion.py", + "time_series_momentum.py", + "cross_sectional_momentum.py", + "trend_following.py", + ) + found_prefixes = {} + for filename in lab_files: + text = (source_dir / filename).read_text(encoding="utf-8") + match = re.search(r'key_prefix="(explorer_\w+)"', text) + assert match is not None, f"{filename}: no key_prefix found" + found_prefixes[filename] = match.group(1) + assert len(set(found_prefixes.values())) == 6, found_prefixes + + +def test_cross_sectional_momentum_lab_handles_long_only_top_fraction_above_half() -> ( + None +): + """Regression test: with long_short disabled and top_fraction=0.75, the + lab previously reused top_fraction verbatim as the comparison bottom + fraction, so 0.75 + 0.75 > 1 made select_top_bottom() raise inside + cross_sectional_momentum_persistence().""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key="explorer_open_cross_sectional_momentum").click().run() + at.session_state["explorer_lab_expander_cross_sectional_momentum"] = True + at.run() + assert not at.exception + + at.session_state["explorer_lab_expander_cross_sectional_momentum"] = True + at.slider(key="explorer_csmom_top").set_value(0.75).run() + assert not at.exception + + +def test_back_to_gallery_button_returns_to_the_gallery() -> None: + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key="explorer_open_buy_and_hold").click().run() + assert at.session_state["explorer_strategy"] == "buy_and_hold" + + at.button(key="explorer_back").click().run() + assert not at.exception + assert "explorer_strategy" not in at.session_state + open_buttons = [ + b.key for b in at.button if b.key and b.key.startswith("explorer_open_") + ] + assert "explorer_open_buy_and_hold" in open_buttons + + +def test_pairs_trading_backtest_results_tab_shows_pair_diagnostics() -> None: + """The generic Strategy Explorer results-diagnostics dispatch (declared + only by the pairs_trading profile) surfaces its section in the Results + tab -- and only there, never for a strategy without one.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _configure_offline_pairs_trade(at) + _run_button(at).click().run() + assert not at.exception + + subheaders = {s.value for s in at.subheader} + assert "Pair relationship diagnostics" in subheaders + + +def test_non_pairs_strategy_backtest_results_tab_has_no_pair_diagnostics() -> None: + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _run_button(at).click().run() + assert not at.exception + + subheaders = {s.value for s in at.subheader} + assert "Pair relationship diagnostics" not in subheaders + + +def test_backtest_downloaded_report_includes_pair_diagnostics() -> None: + """The HTML report downloaded from Backtest mode's Report tab must + include the same pair-diagnostics section visible live in Results -- + `_collect_backtest_robustness_evidence` folds it in, keyed by the + profile's own `results_diagnostics.key`, converted via + `report_section()` exactly like the CLI does.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _configure_offline_pairs_trade(at) + at.session_state["dashboard_active_tab"] = "Report" + _run_button(at).click().run() + assert not at.exception + + _, (report_tab_html, _warnings) = at.session_state["report_html"] + assert "Pair Diagnostics" in report_tab_html + assert "Strategy diagnostics" in report_tab_html + assert "

    Robustness

    " in report_tab_html + robustness_index = report_tab_html.index("

    Robustness

    ") + diagnostics_index = report_tab_html.index("Strategy diagnostics") + assert diagnostics_index < robustness_index + + +def test_downloaded_report_updates_when_a_live_diagnostic_slider_moves() -> None: + """Regression test: moving the Results tab's "Forward-return horizon" + slider used to leave the downloaded HTML report stuck showing the + fixed default (skip_period) value, because `_render_report_tab`'s + cache key was keyed off `id(diagnostics)`, which never changes when + only a live Results-tab widget choice changes -- `report_section()` + reads that widget straight from session_state instead (see + `_collect_backtest_robustness_evidence`). Reproduces with + long_short=True, matching the user-reported scenario exactly.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _sidebar_text_input(at, "CSV symbols (comma-separated)").set_value("SPY, QQQ").run() + _sidebar_date_input(at, "End date").set_value(datetime.date(2019, 6, 1)).run() + _sidebar_selectbox(at, "Strategy").set_value("cross_sectional_momentum").run() + _sidebar_checkbox(at, "Long/short").set_value(True).run() + at.session_state["dashboard_active_tab"] = "Report" + _run_button(at).click().run() + assert not at.exception + + _, (report_before, _warnings_before) = at.session_state["report_html"] + + at.session_state["csmom_results_diag_holding_period"] = 5 + at.run() + assert not at.exception + + _, (report_after, _warnings_after) = at.session_state["report_html"] + assert report_after != report_before + + +def test_walk_forward_diagnostics_note_shown_for_a_strategy_with_diagnostics() -> None: + """A strategy WITH declared `results_diagnostics` (every built-in + strategy except buy_and_hold, which is itself excluded from Walk- + forward mode entirely) gets an explanatory note instead of a silently + missing section (see `_render_walk_forward_diagnostics_note`: each + fold can select different parameters than the base config, so the + diagnostics visible in Backtest mode are intentionally absent here). + The `profile.results_diagnostics is None` branch itself (no note) is + covered structurally by test_dashboard_explorer_profiles.py, not by a + live strategy here -- every walk-forward-eligible strategy now + declares diagnostics.""" + at_pairs = AppTest.from_file(APP_PATH, default_timeout=120) + at_pairs.run() + _switch_to_walk_forward_mode(at_pairs) + _sidebar_text_input(at_pairs, "CSV symbols (comma-separated)").set_value( + "SPY, QQQ" + ).run() + _sidebar_selectbox(at_pairs, "Strategy").set_value("pairs_trading").run() + # Small windows and a short date range -- this only needs ONE completed + # fold to reach the Results tab, not a realistic pairs-trading backtest + # (an unbounded end date here previously produced dozens of folds times + # a parameter grid search, timing out well past two minutes). + # formation_window must fit inside train_window for any fold to produce + # a weight at all. + _sidebar_date_input(at_pairs, "End date").set_value( + datetime.date(2019, 7, 15) + ).run() + formation_window = next( + s for s in at_pairs.sidebar.slider if s.label == "Formation window (periods)" + ) + formation_window.set_value(60).run() + _sidebar_number_input(at_pairs, "Train window (periods)").set_value(90).run() + _sidebar_number_input(at_pairs, "Validation window (periods)").set_value(20).run() + _sidebar_number_input(at_pairs, "Test window (periods)").set_value(20).run() + _run_button(at_pairs).click().run() + assert not at_pairs.exception + pairs_captions = [c.value for c in at_pairs.caption] + assert any("results diagnostics" in c for c in pairs_captions) + assert "wf_strategy_diagnostics" not in at_pairs.session_state + + +def test_pairs_trading_lab_widget_interaction_recomputes_without_exception() -> None: + """Moving a lab parameter must trigger a real Streamlit rerun that + recomputes and re-renders -- not just render once with default values.""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key="explorer_open_pairs_trading").click().run() + assert not at.exception + # The lab lives in a lazy expander (see `detail.py`) -- collapsed by + # default, so it must be opened before its widgets exist. AppTest does + # not persist a directly-assigned session_state value for a tracked + # container widget across an unrelated interaction's own rerun, so it + # must be re-asserted before every subsequent `.run()` below. + at.session_state["explorer_lab_expander_pairs_trading"] = True + at.run() + assert not at.exception + + at.session_state["explorer_lab_expander_pairs_trading"] = True + at.slider(key="explorer_pairs_formation").set_value(250).run() + assert not at.exception + assert at.slider(key="explorer_pairs_formation").value == 250 + + at.session_state["explorer_lab_expander_pairs_trading"] = True + at.checkbox(key="explorer_pairs_dynamic").set_value(False).run() + assert not at.exception + + +def test_mean_reversion_lab_widget_interaction_recomputes_without_exception() -> None: + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key="explorer_open_mean_reversion").click().run() + assert not at.exception + at.session_state["explorer_lab_expander_mean_reversion"] = True + at.run() + assert not at.exception + + at.session_state["explorer_lab_expander_mean_reversion"] = True + at.checkbox(key="explorer_mr_use_zscore").set_value(False).run() + assert not at.exception + at.session_state["explorer_lab_expander_mean_reversion"] = True + at.slider(key="explorer_mr_rsi_window").set_value(30).run() + assert not at.exception + + +def test_strategy_lab_does_not_run_while_its_expander_is_collapsed() -> None: + """Regression test: a plain `st.expander` still runs its body every + rerun while collapsed. The Interactive laboratory expander must use + the stateful/lazy variant so opening the detail page (or interacting + with any OTHER widget on it) does not silently re-trigger the lab's + full computation (data load, OLS fits, ADF/cointegration tests).""" + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + _switch_to_strategies_mode(at) + at.button(key="explorer_open_pairs_trading").click().run() + assert not at.exception + # The lab's own widgets must not exist yet -- its body never ran. + assert not any(s.key and s.key.startswith("explorer_pairs_") for s in at.slider) + + at.session_state["explorer_lab_expander_pairs_trading"] = True + at.run() + assert not at.exception + assert any(s.key == "explorer_pairs_formation" for s in at.slider) + + +def test_render_pair_diagnostics_isolated_component( + two_symbol_panel: pd.DataFrame, +) -> None: + """Isolated (no real Streamlit, no dashboard) component test for the + Results-tab pair-diagnostics renderer, driven by a real + ``PairDiagnostics`` computed on the cointegrated-by-construction + ``two_symbol_panel`` fixture -- complements the AppTest-based + integration coverage above.""" + from quantlab.data.base import price_matrix + from quantlab.features.pairs_diagnostics import compute_pair_diagnostics + + class FakeColumn: + def __init__(self, sink: list[tuple[str, str]]) -> None: + self._sink = sink + + def metric(self, label: str, value: str) -> None: + self._sink.append((label, value)) + + class FakeStreamlit: + def __init__(self) -> None: + self.subheaders: list[str] = [] + self.captions: list[str] = [] + self.infos: list[str] = [] + self.plotly_chart_calls = 0 + self.metrics: list[tuple[str, str]] = [] + + def subheader(self, text: str) -> None: + self.subheaders.append(text) + + def caption(self, text: str) -> None: + self.captions.append(text) + + def info(self, text: str) -> None: + self.infos.append(text) + + def plotly_chart(self, fig: object, **kwargs: object) -> None: + self.plotly_chart_calls += 1 + + def columns(self, n: int) -> list[FakeColumn]: + return [FakeColumn(self.metrics) for _ in range(n)] + + diagnostics = compute_pair_diagnostics( + price_matrix(two_symbol_panel), + "EWA", + "EWB", + formation_window=100, + indicator_window=20, + dynamic_hedge_ratio=True, + ) + fake = FakeStreamlit() + + render_pair_diagnostics( + fake, + diagnostics, + entry_threshold=2.0, + exit_threshold=0.5, + stop_threshold=4.0, + adf_pvalue_threshold=0.10, + ) + + assert fake.subheaders == ["Pair relationship diagnostics"] + assert any("EWA / EWB" in caption for caption in fake.captions) + # Hedge ratio, spread, the centered-indicator threshold-overlay chart, + # plus a rolling-ADF-p-value chart (the pair is cointegrated by + # construction, so this last chart is present). + assert fake.plotly_chart_calls == 4 + # Two stationarity cards (ADF, Engle-Granger cointegration), 3 metrics + # each, plus the Half-life/Hedge-ratio-stability metric pair. + assert len(fake.metrics) == 8 + labels = {label for label, _ in fake.metrics} + assert labels == { + "Statistic", + "p-value", + "Verdict", + "Half-life", + "Hedge-ratio stability (std of beta)", + } + assert not fake.infos # both ADF and cointegration results are conclusive diff --git a/tests/unit/test_dashboard_explorer_profiles.py b/tests/unit/test_dashboard_explorer_profiles.py new file mode 100644 index 0000000..d904fc1 --- /dev/null +++ b/tests/unit/test_dashboard_explorer_profiles.py @@ -0,0 +1,632 @@ +"""Structural guarantees for the Strategy Explorer content registry. + +These are not content-quality tests (no automated check can judge whether +a markdown explanation is actually good) -- they verify the invariants the +approved plan named explicitly: every registered strategy has a profile, +every profile documents exactly the parameters its strategy accepts +(including structurally-injected ones like ``price_type``/ +``periods_per_year``, never silently excluded), every markdown field is +non-empty, and the dispatch mechanism stays name-free outside the profile +files themselves. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest + +if TYPE_CHECKING: + import pandas as pd + +import quantlab.dashboard.explorer.profiles # noqa: F401 (registration side effect) +from quantlab.dashboard.explorer.profile import ( + StrategyProfile, + available_profiles, + get_profile, +) +from quantlab.strategies.base import available_strategies, strategy_parameter_names + +_SRC = Path(__file__).resolve().parents[2] / "src" / "quantlab" + +_MARKDOWN_FIELDS = ( + "overview_md", + "economic_intuition_md", + "mathematical_definition_md", + "assumptions_md", + "diagnostics_md", + "interpretation_md", + "limitations_md", +) + + +def test_every_registered_strategy_has_a_profile() -> None: + missing = set(available_strategies()) - set(available_profiles()) + assert not missing, f"No profile registered for: {sorted(missing)}" + + +def test_available_profiles_are_all_real_strategies() -> None: + """A profile registered under a name the strategy registry doesn't + recognise would silently never be reachable from the gallery.""" + unknown = set(available_profiles()) - set(available_strategies()) + assert not unknown, f"Profile registered for unknown strategy: {sorted(unknown)}" + + +@pytest.mark.parametrize("strategy_name", available_strategies()) +def test_profile_documents_exactly_the_strategy_constructor_parameters( + strategy_name: str, +) -> None: + """No parameter is missing, and none is documented that doesn't exist -- + including ``price_type``/``periods_per_year`` structurally injected by + the runner, which must never be excluded just because they aren't + passed explicitly in YAML.""" + profile = get_profile(strategy_name) + assert profile is not None + documented = {parameter.name for parameter in profile.parameters} + expected = strategy_parameter_names(strategy_name) + assert documented == expected, ( + f"{strategy_name}: documented={sorted(documented)} vs " + f"expected={sorted(expected)}" + ) + + +@pytest.mark.parametrize("strategy_name", available_strategies()) +def test_profile_markdown_fields_are_non_empty(strategy_name: str) -> None: + profile = get_profile(strategy_name) + assert profile is not None + for field in _MARKDOWN_FIELDS: + value = getattr(profile, field) + assert isinstance(value, str) + assert value.strip(), f"{strategy_name}.{field} is empty" + + +@pytest.mark.parametrize("strategy_name", available_strategies()) +def test_profile_lab_is_callable(strategy_name: str) -> None: + profile = get_profile(strategy_name) + assert profile is not None + assert callable(profile.lab) + + +@pytest.mark.parametrize("strategy_name", available_strategies()) +def test_profile_display_name_and_category_are_set(strategy_name: str) -> None: + profile = get_profile(strategy_name) + assert profile is not None + assert profile.display_name.strip() + assert profile.category.strip() + + +def test_registered_profile_is_a_strategy_profile_instance() -> None: + for name in available_profiles(): + assert isinstance(get_profile(name), StrategyProfile) + + +def test_html_report_never_names_a_specific_strategy() -> None: + """`html_report.py`'s Robustness rendering dispatches a strategy's own + results diagnostics generically, by `isinstance(value, DiagnosticsSection)` + (see `_render_robustness`) -- never by strategy name. If a strategy name + ever appears in this file, that architectural guarantee has been broken. + """ + source = (_SRC / "reporting" / "html_report.py").read_text(encoding="utf-8") + for strategy_name in available_strategies(): + assert strategy_name not in source, ( + f"html_report.py must not name '{strategy_name}' directly -- " + "dispatch strategy-specific report content via " + "quantlab.reporting.sections.DiagnosticsSection instead." + ) + + +def test_pairs_trading_diagnostics_respects_signal_price_type() -> None: + """The pairs_trading profile's ``results_diagnostics.compute`` must price + the diagnostics on whichever series the strategy itself actually trades + (``strategy.signal_price_type`` -- ``strategy.parameters.price_type`` is + rejected at config validation and can never override it) -- never + silently default to adjusted_close regardless of what the config says, + which would show a hedge ratio/spread that does not match what the + backtest itself traded. + """ + import numpy as np + import pandas as pd + + from quantlab.config import ExperimentConfig + from quantlab.constants import ( + ADJUSTED_CLOSE, + CLOSE, + HIGH, + LOW, + OPEN, + SYMBOL, + TIMESTAMP, + VOLUME, + ) + + idx = pd.date_range("2020-01-01", periods=60, freq="B") + rng = np.random.default_rng(0) + close_a = 100.0 + np.cumsum(rng.normal(0.0, 1.0, size=60)) + close_b = 50.0 + 0.5 * (close_a - 100.0) + rng.normal(0.0, 0.1, size=60) + # Deliberately offset from close, as if a corporate action had occurred, + # so the two price choices produce numerically different diagnostics. + adjusted_a = close_a * 0.5 + adjusted_b = close_b * 0.5 + + def _frame(symbol: str, close: np.ndarray, adjusted: np.ndarray) -> pd.DataFrame: + return pd.DataFrame( + { + TIMESTAMP: idx, + SYMBOL: symbol, + OPEN: close, + HIGH: close * 1.01, + LOW: close * 0.99, + CLOSE: close, + ADJUSTED_CLOSE: adjusted, + VOLUME: 1_000_000.0, + } + ) + + data = pd.concat( + [_frame("AAA", close_a, adjusted_a), _frame("BBB", close_b, adjusted_b)], + ignore_index=True, + ) + cfg_close = ExperimentConfig.from_dict( + { + "experiment_name": "price_type_test", + "data": { + "instruments": [ + {"symbol": "AAA", "source": "csv", "calendar": "XNYS"}, + {"symbol": "BBB", "source": "csv", "calendar": "XNYS"}, + ], + "start_date": "2020-01-01", + "end_date": "2020-04-01", + }, + "portfolio": {"allocator": "signal_proportional"}, + "strategy": { + "name": "pairs_trading", + "signal_price_type": "close", + "parameters": { + "symbol_a": "AAA", + "symbol_b": "BBB", + "formation_window": 20, + "indicator_window": 5, + }, + }, + } + ) + cfg_adjusted = cfg_close.revalidated_copy( + update={ + "strategy": cfg_close.strategy.revalidated_copy( + update={"signal_price_type": "adjusted_close"} + ) + } + ) + + profile = get_profile("pairs_trading") + assert profile is not None + assert profile.results_diagnostics is not None + diagnostics_close = profile.results_diagnostics.compute(data, cfg_close) + diagnostics_adjusted = profile.results_diagnostics.compute(data, cfg_adjusted) + + close_spread = diagnostics_close.diagnostics.spread.dropna() + adjusted_spread = diagnostics_adjusted.diagnostics.spread.dropna() + assert not close_spread.equals(adjusted_spread) + + # Directly verify against a manual computation on the raw close prices, + # rather than only checking "the two differ". + from quantlab.data.base import price_matrix + from quantlab.features.pairs_diagnostics import compute_pair_diagnostics + + expected_close = compute_pair_diagnostics( + price_matrix(data, adjusted=False), + "AAA", + "BBB", + formation_window=20, + indicator_window=5, + dynamic_hedge_ratio=True, + ) + pd.testing.assert_series_equal(close_spread, expected_close.spread.dropna()) + + +def _momentum_universe_data(n: int = 650) -> pd.DataFrame: + import numpy as np + import pandas as pd + + from quantlab.constants import ( + ADJUSTED_CLOSE, + CLOSE, + HIGH, + LOW, + OPEN, + SYMBOL, + TIMESTAMP, + VOLUME, + ) + + idx = pd.date_range("2019-01-01", periods=n, freq="B") + frames = [] + for i, symbol in enumerate(["AAA", "BBB", "CCC"]): + prices = 100.0 + np.cumsum(np.random.default_rng(i).normal(0.0, 1.0, size=n)) + prices = np.maximum(prices, 1.0) + frames.append( + pd.DataFrame( + { + TIMESTAMP: idx, + SYMBOL: symbol, + OPEN: prices, + HIGH: prices * 1.01, + LOW: prices * 0.99, + CLOSE: prices, + ADJUSTED_CLOSE: prices, + VOLUME: 1_000_000.0, + } + ) + ) + return pd.concat(frames, ignore_index=True) + + +@pytest.mark.parametrize("skip_period", [0, 21, 500]) +def test_cross_sectional_momentum_diagnostics_handles_any_valid_skip_period( + skip_period: int, +) -> None: + """Regression test: the diagnostic's own forward-return horizon used + to be fixed directly to `skip_period` -- `skip_period=0` is a + perfectly valid strategy config (0-21 is even the documented typical + range), but `holding_period=0` is rejected by + `cross_sectional_momentum_persistence` (must be >= 1); a `skip_period` + above 252 is also valid (only constrained to be < lookback_period) but + would put the Results-tab slider's default value outside its own + 1-252 range. `compute()` must succeed for every valid skip_period, + always defaulting the diagnostic's own horizon to a fixed, + skip_period-independent value within [1, 252].""" + from quantlab.config import ExperimentConfig + + lookback_period = 600 if skip_period >= 252 else 100 + data = _momentum_universe_data() + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "csmom_skip_period_test", + "data": { + "instruments": [ + {"symbol": s, "source": "csv", "calendar": "XNYS"} + for s in ["AAA", "BBB", "CCC"] + ], + "start_date": "2019-01-01", + "end_date": "2021-06-01", + }, + "portfolio": {"allocator": "equal_weight"}, + "strategy": { + "name": "cross_sectional_momentum", + "parameters": { + "lookback_period": lookback_period, + "skip_period": skip_period, + "top_fraction": 0.5, + }, + }, + } + ) + profile = get_profile("cross_sectional_momentum") + assert profile is not None + assert profile.results_diagnostics is not None + + result = profile.results_diagnostics.compute(data, cfg) + + assert 1 <= result.holding_period <= 252 + assert result.skip_period == skip_period + + +@pytest.mark.parametrize("skip_period", [0, 21, 500]) +def test_time_series_momentum_diagnostics_handles_any_valid_skip_period( + skip_period: int, +) -> None: + """Same regression as the cross-sectional case above, for + time_series_momentum's own diagnostic.""" + from quantlab.config import ExperimentConfig + + lookback_period = 600 if skip_period >= 252 else 100 + data = _momentum_universe_data() + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "tsmom_skip_period_test", + "data": { + "instruments": [ + {"symbol": s, "source": "csv", "calendar": "XNYS"} + for s in ["AAA", "BBB", "CCC"] + ], + "start_date": "2019-01-01", + "end_date": "2021-06-01", + }, + "portfolio": {"allocator": "equal_weight"}, + "strategy": { + "name": "time_series_momentum", + "parameters": { + "lookback_period": lookback_period, + "skip_period": skip_period, + }, + }, + } + ) + profile = get_profile("time_series_momentum") + assert profile is not None + assert profile.results_diagnostics is not None + + result = profile.results_diagnostics.compute(data, cfg) + + assert 1 <= result.holding_period <= 252 + assert result.skip_period == skip_period + + +@pytest.mark.parametrize("slow_window", [2, 3, 4]) +def test_trend_following_results_er_slider_handles_a_slow_window_below_five( + slow_window: int, +) -> None: + """Regression test: `fast_window=1, slow_window=2` (and similarly + small windows) are valid strategy configs -- only `fast_window < + slow_window`, both `>= 1`, are enforced -- but the Results tab's + Efficiency Ratio slider declares a fixed [5, 200] range. Its default + value used to be `min(slow_window, 200)` directly, which could fall + below 5 and put the slider's own default outside its declared bounds. + Uses a real `AppTest` (not a fake streamlit stand-in) to exercise the + actual widget construction, since the dashboard's own sidebar cannot + reach a `slow_window` this small (its slider is bounded at 30) -- + the only way to observe this is a direct, config-driven scenario like + a hand-written YAML config passed straight to the CLI.""" + pytest.importorskip("streamlit") + from streamlit.testing.v1 import AppTest + + script = f""" +import pandas as pd +import streamlit as st +from quantlab.dashboard.explorer.profiles.trend_following import ( + TrendFollowingDiagnostics, + _render_diagnostics, +) + +prices = {{"AAA": pd.Series([1.0, 2.0, 3.0, 4.0, 5.0])}} +summary = pd.DataFrame( + {{"Whipsaw (flips / 126p, latest)": [0.0], "Median Efficiency Ratio": [0.5]}}, + index=pd.Index(["AAA"], name="Symbol"), +) +result = TrendFollowingDiagnostics( + summary=summary, + prices=prices, + fast_ma=prices, + slow_ma=prices, + signal=prices, + slow_window={slow_window}, +) +_render_diagnostics(st, result) +""" + at = AppTest.from_string(script, default_timeout=30) + at.run() + + assert not at.exception + er_slider = next(s for s in at.slider if s.label == "Efficiency Ratio window") + assert 5 <= cast(int, er_slider.value) <= 200 + + +def _minimal_profile(**overrides: object) -> StrategyProfile: + """Build an otherwise-valid StrategyProfile, letting a test override + just the field(s) it wants to test the registration guard for.""" + fields: dict[str, object] = { + "strategy_name": "buy_and_hold", + "display_name": "Test", + "category": "Test", + "overview_md": "x", + "economic_intuition_md": "x", + "mathematical_definition_md": "x", + "assumptions_md": "x", + "diagnostics_md": "x", + "interpretation_md": "x", + "limitations_md": "x", + "parameters": [], + "lab": lambda st: None, + } + fields.update(overrides) + return StrategyProfile(**fields) # type: ignore[arg-type] + + +def _isolated_registry(monkeypatch: pytest.MonkeyPatch) -> None: + """Swap in a throwaway copy of the module-level registry so a test's + own `register_profile()` calls can't leak into other tests -- restored + automatically by monkeypatch's own teardown.""" + import quantlab.dashboard.explorer.profile as profile_module + + monkeypatch.setattr(profile_module, "_REGISTRY", dict(profile_module._REGISTRY)) + + +@pytest.mark.parametrize( + "field", + [ + "overview_md", + "economic_intuition_md", + "mathematical_definition_md", + "assumptions_md", + "diagnostics_md", + "interpretation_md", + "limitations_md", + ], +) +def test_register_profile_rejects_an_empty_markdown_field( + monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + from quantlab.dashboard.explorer.profile import register_profile + + _isolated_registry(monkeypatch) + profile = _minimal_profile(**{field: " "}) + with pytest.raises(ValueError, match=field): + register_profile(profile, replace=True) + + +def test_register_profile_rejects_empty_display_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from quantlab.dashboard.explorer.profile import register_profile + + _isolated_registry(monkeypatch) + with pytest.raises(ValueError, match="display_name"): + register_profile(_minimal_profile(display_name=""), replace=True) + + +def test_register_profile_rejects_duplicate_parameter_names( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from quantlab.dashboard.explorer.profile import ParameterDoc, register_profile + + _isolated_registry(monkeypatch) + duplicate = ParameterDoc( + name="lookback_period", + what="x", + where="x", + why="x", + default="x", + typical_range="x", + effect_increase="x", + effect_decrease="x", + tradeoffs="x", + ) + profile = _minimal_profile(parameters=[duplicate, duplicate]) + with pytest.raises(ValueError, match="duplicate"): + register_profile(profile, replace=True) + + +@pytest.mark.parametrize( + "field", + [ + "name", + "what", + "where", + "why", + "default", + "typical_range", + "effect_increase", + "effect_decrease", + "tradeoffs", + ], +) +def test_register_profile_rejects_an_empty_parameter_doc_field( + monkeypatch: pytest.MonkeyPatch, field: str +) -> None: + from quantlab.dashboard.explorer.profile import ParameterDoc, register_profile + + _isolated_registry(monkeypatch) + fields = { + "name": "lookback_period", + "what": "x", + "where": "x", + "why": "x", + "default": "x", + "typical_range": "x", + "effect_increase": "x", + "effect_decrease": "x", + "tradeoffs": "x", + } + fields[field] = " " + profile = _minimal_profile(parameters=[ParameterDoc(**fields)]) + with pytest.raises(ValueError, match=field): + register_profile(profile, replace=True) + + +def test_register_profile_allows_an_empty_parameter_doc_interactions( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`interactions` defaults to '' for a parameter with none to report -- + unlike every other ParameterDoc field, an empty one must not be rejected.""" + from quantlab.dashboard.explorer.profile import ParameterDoc, register_profile + + _isolated_registry(monkeypatch) + parameter = ParameterDoc( + name="lookback_period", + what="x", + where="x", + why="x", + default="x", + typical_range="x", + effect_increase="x", + effect_decrease="x", + tradeoffs="x", + interactions="", + ) + register_profile(_minimal_profile(parameters=[parameter]), replace=True) + + +def test_register_profile_rejects_an_unregistered_strategy_name( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from quantlab.dashboard.explorer.profile import register_profile + + _isolated_registry(monkeypatch) + profile = _minimal_profile(strategy_name="not_a_real_strategy") + with pytest.raises(ValueError, match="not_a_real_strategy"): + register_profile(profile, replace=True) + + +def test_register_profile_rejects_a_non_callable_results_diagnostics_field( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from quantlab.dashboard.explorer.profile import ResultsDiagnostics, register_profile + + _isolated_registry(monkeypatch) + diagnostics = ResultsDiagnostics( + key="k", + compute="not_callable", # type: ignore[arg-type] + render=lambda st, result: None, + report_section=lambda result: None, # type: ignore[arg-type,return-value] + ) + profile = _minimal_profile(results_diagnostics=diagnostics) + with pytest.raises(ValueError, match="compute"): + register_profile(profile, replace=True) + + +def test_register_profile_rejects_an_empty_results_diagnostics_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from quantlab.dashboard.explorer.profile import ResultsDiagnostics, register_profile + + _isolated_registry(monkeypatch) + diagnostics = ResultsDiagnostics( + key=" ", + compute=lambda data, cfg: None, + render=lambda st, result: None, + report_section=lambda result: None, # type: ignore[arg-type,return-value] + ) + profile = _minimal_profile(results_diagnostics=diagnostics) + with pytest.raises(ValueError, match="key"): + register_profile(profile, replace=True) + + +def test_register_profile_rejects_a_colliding_results_diagnostics_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`results_diagnostics.key` doubles as the robustness-dict/session_state + key -- a collision between two profiles would let them silently clobber + each other's diagnostics.""" + from quantlab.dashboard.explorer.profile import ResultsDiagnostics, register_profile + + _isolated_registry(monkeypatch) + diagnostics = ResultsDiagnostics( + key="shared_key", + compute=lambda data, cfg: None, + render=lambda st, result: None, + report_section=lambda result: None, # type: ignore[arg-type,return-value] + ) + first = _minimal_profile( + strategy_name="buy_and_hold", results_diagnostics=diagnostics + ) + second = _minimal_profile( + strategy_name="mean_reversion", results_diagnostics=diagnostics + ) + register_profile(first, replace=True) + with pytest.raises(ValueError, match="shared_key"): + register_profile(second, replace=True) + + +def test_gallery_and_detail_pages_never_name_a_specific_strategy() -> None: + """The gallery/detail pages are driven entirely by `available_strategies()` + / `get_profile()` -- a strategy name appearing in either file would mean + a new strategy needs a dashboard code change beyond its own profile file, + which is exactly what the registry pattern exists to avoid.""" + explorer_dir = _SRC / "dashboard" / "explorer" + for filename in ("gallery.py", "detail.py"): + source = (explorer_dir / filename).read_text(encoding="utf-8") + for strategy_name in available_strategies(): + assert strategy_name not in source, ( + f"{filename} must not name '{strategy_name}' directly." + ) diff --git a/tests/unit/test_dashboard_state.py b/tests/unit/test_dashboard_state.py index 8afa8e5..0691fb1 100644 --- a/tests/unit/test_dashboard_state.py +++ b/tests/unit/test_dashboard_state.py @@ -10,6 +10,7 @@ from quantlab.dashboard.state import ( build_config_from_inputs, estimate_walk_forward_backtest_count, + run_dashboard_backtest_with_data, ) from quantlab.validation.parameter_grid import parse_parameter_grid_values @@ -185,3 +186,41 @@ def _fake_run(self, data, **kwargs): # type: ignore[no-untyped-def] run_dashboard_walk_forward(config) assert captured["checkpoint_path"] == _checkpoint_path(config, "walk_forward") + + +def test_run_dashboard_backtest_with_data_returns_the_exact_frame_it_ran_on( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The returned frame must be the SAME object the backtest itself ran + on (identity, not just equality) -- a Strategy Explorer results + diagnostic reusing anything else (e.g. a fresh, independent reload) + could silently observe different data than the displayed result for a + remote source that changed, or a cache that refreshed, between the two + loads.""" + from types import SimpleNamespace + + import quantlab.dashboard.state as state_module + + config = build_config_from_inputs(_base_inputs(experiment_name="identity_check")) + the_frame = pd.DataFrame({"marker": [1, 2, 3]}) + monkeypatch.setattr( + state_module.DataLoader, + "load", + lambda self, cfg: (the_frame, SimpleNamespace(warnings=["w"])), + ) + captured: dict[str, object] = {} + + def fake_run_backtest_from_config(data, cfg, *, data_quality_report=None): # type: ignore[no-untyped-def] + captured["data_seen_by_backtest"] = data + return "fake-result" + + monkeypatch.setattr( + state_module, "run_backtest_from_config", fake_run_backtest_from_config + ) + + result, warnings, returned_data = run_dashboard_backtest_with_data(config) + + assert result == "fake-result" + assert warnings == ["w"] + assert returned_data is the_frame + assert captured["data_seen_by_backtest"] is the_frame diff --git a/tests/unit/test_drift_compliance.py b/tests/unit/test_drift_compliance.py new file mode 100644 index 0000000..181419c --- /dev/null +++ b/tests/unit/test_drift_compliance.py @@ -0,0 +1,549 @@ +"""Direct tests for the drift-compliance LP (`restore_drift_compliance`). + +Each test asserts against a hand-derived closed-form minimal-L1-distance +solution, not just "no crash" -- this is the riskiest piece of math behind +weight drift, so its correctness must be nailed down before it is wired +into `accounting.py`. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pytest + +from quantlab.exceptions import BacktestError +from quantlab.portfolio.drift_compliance import restore_drift_compliance + + +def test_lone_maximum_weight_breach_clips_exactly_to_the_cap() -> None: + result = restore_drift_compliance( + np.array([0.5]), + ["A"], + np.array([True]), + [("A",)], + maximum_weight=0.3, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + assert not result.pending + assert result.corrected[0] == pytest.approx(0.3) + + +def test_lone_gross_exposure_breach_clips_the_only_column() -> None: + result = restore_drift_compliance( + np.array([0.5]), + ["A"], + np.array([True]), + [("A",)], + maximum_weight=None, + maximum_gross_exposure=0.3, + maximum_net_exposure=None, + long_only=False, + ) + assert not result.pending + assert result.corrected[0] == pytest.approx(0.3) + + +def test_lone_long_only_breach_snaps_to_zero() -> None: + """Nearest point on `w >= 0` to a negative drifted value is exactly 0.""" + result = restore_drift_compliance( + np.array([-0.2]), + ["A"], + np.array([True]), + [("A",)], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=True, + ) + assert not result.pending + assert result.corrected[0] == pytest.approx(0.0) + + +def test_closed_long_tradable_short_counterexample_pushes_short_more_negative() -> None: + """The scenario that disproves "clip then scale tradable columns toward + 0" as a general solution: a large UNTRADABLE long position plus a + TRADABLE short breaching maximum_net_exposure needs the short pushed + MORE negative (away from 0), not scaled toward 0.""" + drifted = np.array([0.9, -0.1]) # A untradable, B tradable + result = restore_drift_compliance( + drifted, + ["A", "B"], + np.array([False, True]), + [("A",), ("B",)], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=0.5, + long_only=False, + ) + assert not result.pending + # A is untradable -- unchanged. + assert result.corrected[0] == pytest.approx(0.9) + # Feasible region for B: 0.9 + w_B in [-0.5, 0.5] => w_B in [-1.4, -0.4]. + # Nearest point to drifted B=-0.1 is -0.4 -- MORE negative, not toward 0. + assert result.corrected[1] == pytest.approx(-0.4) + assert result.corrected[1] < drifted[1] + + +def test_uninvolved_compliant_column_is_never_liquidated_by_an_unrelated_breach() -> ( + None +): + """An uninvolved, already-compliant column must never be moved just + because an UNRELATED constraint violation is being fixed elsewhere in + the row: a single-stage slack-only relaxation that minimizes ONLY the + constraint-violation slacks, with no term penalizing movement of such + a column, would let the solver pick any optimal vertex, including one + that arbitrarily liquidates B even though `maximum_weight` (a purely + per-column cap) doesn't even reference B. A closed at 0.6 alone + already breaches `maximum_weight=0.5` (tradability-caused, pending); + B, open and compliant at 0.4, must stay exactly where it is -- the + two-stage lexicographic fix (fix the minimal violation, then minimize + L1 deviation among solutions achieving it) has no reason to move a + column the violation doesn't involve.""" + result = restore_drift_compliance( + np.array([0.6, 0.4]), + ["A", "B"], + np.array([False, True]), + [("A",), ("B",)], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + assert result.pending + assert result.corrected[0] == pytest.approx(0.6) + assert result.corrected[1] == pytest.approx(0.4) + + +def test_never_opens_a_new_position_on_a_column_drifted_to_exactly_zero() -> None: + """Regression test: the LP must never invent a brand-new position + (long OR short) on a column the drifted book does not already hold, + even when doing so would have been the cheapest (or only) way to + restore compliance. Before sign/support-preservation, this exact + scenario would have opened a short on B (the strict LP was feasible + by setting w_B=-0.1) -- a hedge the strategy never asked for and + portfolio.long_only=False alone would have silently allowed. B must + now stay fixed at exactly 0, and since the untradable A alone (0.6) + already exceeds the 0.5 cap, this becomes a tradability-caused + pending correction instead -- an honest "still breaching, waiting + for A to reopen" rather than a silently manufactured hedge.""" + drifted = np.array([0.6, 0.0]) # A untradable long 0.6, B tradable AT ZERO + result = restore_drift_compliance( + drifted, + ["A", "B"], + np.array([False, True]), + [("A",), ("B",)], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=0.5, + long_only=False, + ) + assert result.pending + assert result.corrected[0] == pytest.approx(0.6) + assert result.corrected[1] == pytest.approx(0.0) + + +def test_existing_long_may_not_flip_to_a_new_short_or_vice_versa() -> None: + """A currently-LONG column may shrink toward 0 (or grow further long) + but must never cross into short territory, and vice versa -- crossing + zero is just as much "inventing a position the drifted book didn't + hold" as starting from exactly zero would be.""" + # A long-only column (0.4) would need to go negative to satisfy this + # net cap alongside an untradable 0.3 -- but it may only shrink to 0. + drifted = np.array([0.3, 0.4]) # A untradable, B tradable LONG + result = restore_drift_compliance( + drifted, + ["A", "B"], + np.array([False, True]), + [("A",), ("B",)], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=0.2, + long_only=False, + ) + # 0.3 (fixed) alone already exceeds the 0.2 cap -- tradability-caused, + # B shrinks to its floor of 0 (not below), still pending. + assert result.pending + assert result.corrected[0] == pytest.approx(0.3) + assert result.corrected[1] == pytest.approx(0.0) + + +def test_position_group_moves_both_legs_via_one_shared_scalar() -> None: + """A declared group's legs must move together (`k_g`), never as + independent free variables -- a per-column LP could satisfy the + objective by moving only one leg, breaking the pair's hedge ratio.""" + drifted = np.array([0.6, -0.3]) # X, Y -- one group, both tradable + result = restore_drift_compliance( + drifted, + ["X", "Y"], + np.array([True, True]), + [("X", "Y")], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + assert not result.pending + # k_g <= 0.5/0.6 = 0.8333... (from X's own maximum_weight bound); + # minimal-L1 picks k_g as close to 1 as feasible => k_g = 0.8333... + expected_k = 0.5 / 0.6 + assert result.corrected[0] == pytest.approx(expected_k * 0.6) + assert result.corrected[1] == pytest.approx(expected_k * -0.3) + # The ratio between the two legs is exactly preserved (coherent move). + assert result.corrected[0] / result.corrected[1] == pytest.approx( + drifted[0] / drifted[1] + ) + + +def test_position_group_with_one_untradable_leg_is_fixed_entirely() -> None: + """A group is only eligible to move when EVERY leg is tradable -- one + untradable leg must fix `k_g` at exactly 1 (the whole group frozen at + its drifted proportions), never let the tradable leg move alone. Only + the trivial both-tradable/both-untradable cases were tested before + this: X alone (untradable, 0.6) already exceeds `maximum_weight=0.5`, + so this is tradability-caused and pending -- the group stays exactly + at its drifted values rather than Y moving independently.""" + drifted = np.array([0.6, -0.3]) # X untradable, Y tradable, one group + result = restore_drift_compliance( + drifted, + ["X", "Y"], + np.array([False, True]), + [("X", "Y")], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + assert result.pending + assert result.corrected[0] == pytest.approx(0.6) + assert result.corrected[1] == pytest.approx(-0.3) + + +def test_position_group_gross_exposure_cap_scales_k_g() -> None: + """Group + `maximum_gross_exposure` interaction -- only per-leg + `maximum_weight`/net-exposure combos with a group were tested before + this. Both legs tradable, gross = |0.6| + |-0.3| = 0.9 at k_g=1; + capped to 0.6 forces k_g <= 0.6/0.9 = 0.6667, and minimal-L1 picks the + largest feasible k_g (closest to 1).""" + drifted = np.array([0.6, -0.3]) + result = restore_drift_compliance( + drifted, + ["X", "Y"], + np.array([True, True]), + [("X", "Y")], + maximum_weight=None, + maximum_gross_exposure=0.6, + maximum_net_exposure=None, + long_only=False, + ) + assert not result.pending + expected_k = 0.6 / 0.9 + assert result.corrected[0] == pytest.approx(expected_k * 0.6) + assert result.corrected[1] == pytest.approx(expected_k * -0.3) + assert result.corrected[0] / result.corrected[1] == pytest.approx( + drifted[0] / drifted[1] + ) + + +def test_untradable_independent_column_negative_under_long_only_is_pending() -> None: + """Regression test: `long_only` was only ever baked into a TRADABLE + independent column's own lower bound -- silently a no-op for an + UNTRADABLE (fixed) column, whose bound is pinned at its drifted value + regardless of sign. Before the fix, the strict LP had no constraint + that could ever reject a fixed negative value under long_only, so it + trivially "succeeded" (`pending=False`) over a row that still + genuinely violated long_only -- exactly the silent formulation bug + this module exists to avoid elsewhere. A fixed column alone violating + long_only must be diagnosed as tradability-caused (mirroring + `_fixed_positions_alone_violate`'s own long_only branch, previously + unreachable) and returned as a `pending`, best-effort (here: fully + unchanged, since nothing else is free) correction.""" + from quantlab.portfolio.rebalancing import _compliance_violations + + result = restore_drift_compliance( + np.array([-0.2]), + ["A"], + np.array([False]), + [("A",)], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=True, + ) + assert result.pending + assert result.corrected[0] == pytest.approx(-0.2) + assert _compliance_violations( + result.corrected, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=True, + ) == ["long_only"] + + +def test_untradable_group_with_a_negative_leg_under_long_only_is_pending() -> None: + """Same bug as the independent-column case above, for a group: an + untradable group fixes `k_g=1`, and a negative leg at k_g=1 was + silently accepted as "compliant" before this constraint existed.""" + result = restore_drift_compliance( + np.array([-0.2, 0.1]), + ["X", "Y"], + np.array([False, False]), + [("X", "Y")], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=True, + ) + assert result.pending + assert result.corrected[0] == pytest.approx(-0.2) + assert result.corrected[1] == pytest.approx(0.1) + + +def test_position_group_can_grow_past_one_when_genuinely_minimal() -> None: + """`k_g` is bounded only by `k_g >= 0`, never capped at 1 -- capping it + would incorrectly exclude a real minimal-L1 solution that requires + growing a group. Here an untradable, fixed column `C` alone already + pushes net exposure to 0.8; the only way to bring it back within + `maximum_net_exposure=0.5` is to grow the (net-negative) hedge group + past its own drifted proportions, offsetting C -- shrinking or leaving + it at k_g=1 cannot satisfy the constraint at all, since C cannot move. + """ + drifted = np.array([0.8, 0.1, -0.3]) # group net (at k_g=1) = -0.2 + result = restore_drift_compliance( + drifted, + ["C", "X", "Y"], + np.array([False, True, True]), + [("C",), ("X", "Y")], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=0.5, + long_only=False, + ) + assert not result.pending + # C is untradable and stays fixed; solving 0.8 - 0.2*k_g == 0.5 (the + # nearest feasible net exposure to k_g=1) gives k_g == 1.5. + expected_k = 1.5 + assert result.corrected[0] == pytest.approx(0.8) + assert result.corrected[1] == pytest.approx(expected_k * 0.1) + assert result.corrected[2] == pytest.approx(expected_k * -0.3) + assert result.corrected[1] / result.corrected[2] == pytest.approx( + drifted[1] / drifted[2] + ) + + +def test_tradability_caused_infeasibility_uses_slack_relaxation_and_flags_pending() -> ( + None +): + """An untradable column's own drifted value already violates + maximum_weight -- no amount of free-column movement can fix it. The + slack-relaxation fallback must fire (not raise) and flag `pending`.""" + result = restore_drift_compliance( + np.array([0.9]), + ["A"], + np.array([False]), + [("A",)], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + assert result.pending + # Nothing free exists to help -- the untradable column is unchanged. + assert result.corrected[0] == pytest.approx(0.9) + + +def test_tradability_caused_infeasibility_uses_free_columns_to_help() -> None: + """When some OTHER column is free, the slack-relaxation solve should + still let it move to reduce the aggregate breach as much as + achievable, even though the untradable column itself can't be fixed.""" + drifted = np.array([0.9, 0.3]) # A untradable & breaches alone; B tradable + result = restore_drift_compliance( + drifted, + ["A", "B"], + np.array([False, True]), + [("A",), ("B",)], + maximum_weight=None, + maximum_gross_exposure=0.5, + maximum_net_exposure=None, + long_only=False, + ) + assert result.pending + assert result.corrected[0] == pytest.approx(0.9) + # B is free and should be pulled toward 0 to minimize the slack needed. + assert result.corrected[1] < drifted[1] + + +def test_always_feasible_and_never_pending_when_everything_is_tradable() -> None: + """With every column tradable, 0 is always a feasible point for every + free variable, so the strict LP can never be genuinely infeasible for + a reason other than tradability -- this is what makes the "loud raise" + branch an unreachable defensive invariant (mirroring `_assert_ + holdings_compliant`'s identical philosophy) under any valid, + non-negative constraint configuration.""" + rng = np.random.default_rng(7) + for _ in range(20): + drifted = rng.normal(scale=1.5, size=4) + result = restore_drift_compliance( + drifted, + ["A", "B", "C", "D"], + np.array([True, True, True, True]), + [("A",), ("B",), ("C",), ("D",)], + maximum_weight=0.4, + maximum_gross_exposure=1.0, + maximum_net_exposure=0.6, + long_only=False, + ) + assert not result.pending + assert np.all(np.abs(result.corrected) <= 0.4 + 1e-6) + assert np.sum(np.abs(result.corrected)) <= 1.0 + 1e-6 + assert abs(np.sum(result.corrected)) <= 0.6 + 1e-6 + + +def test_untradable_columns_are_bit_for_bit_unchanged_by_the_lp() -> None: + drifted = np.array([0.2, 0.9, -0.3]) + result = restore_drift_compliance( + drifted, + ["A", "B", "C"], + np.array([True, False, True]), + [("A",), ("B",), ("C",)], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + assert result.corrected[1] == drifted[1] + + +def test_shape_mismatch_raises() -> None: + with pytest.raises(BacktestError): + restore_drift_compliance( + np.array([0.1, 0.2]), + ["A"], + np.array([True]), + [("A",)], + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + + +def _fake_linprog_result(status: int) -> Any: + from scipy.optimize import OptimizeResult + + return OptimizeResult(x=np.zeros(1), status=status, message="synthetic status") + + +def test_strict_lp_non_infeasible_solver_failure_raises_not_silently_diagnosed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A HiGHS status that is neither optimal (0) nor infeasible (2) -- an + iteration limit, an unbounded report, or numerical difficulties -- is a + genuine solver failure, never the ordinary "tradability caused this" + infeasibility the caller's diagnosis branch expects. Folding it into + the same `None` return the real infeasible case uses would let a + solver hiccup silently masquerade as an expected, best-effort + correction (or an unrelated 'bug in the algorithm' report) instead of + surfacing loudly with the solver's own status.""" + import quantlab.portfolio.drift_compliance as drift_compliance_mod + + monkeypatch.setattr( + drift_compliance_mod, "linprog", lambda *a, **k: _fake_linprog_result(4) + ) + + with pytest.raises(BacktestError, match="non-infeasible, non-optimal"): + restore_drift_compliance( + np.array([0.5]), + ["A"], + np.array([True]), + [("A",)], + maximum_weight=0.3, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + + +def test_slack_stage1_non_infeasible_solver_failure_raises() -> None: + """Same principle as the strict-LP case, for stage 1 of the slack-mode + solve: only genuine infeasibility (status 2) is treated as the + expected 'nothing to relax' case the always-feasible slack LP should + never actually hit; every other non-optimal status is a real failure + and must raise, not vanish into the caller's generic 'unexpectedly + infeasible' message.""" + from scipy.optimize import linprog as real_linprog + + import quantlab.portfolio.drift_compliance as drift_compliance_mod + + calls = {"n": 0} + + def fake_linprog(*args: Any, **kwargs: Any) -> Any: + calls["n"] += 1 + if calls["n"] == 1: + # The strict LP: force genuine infeasibility so the caller + # proceeds to the tradability diagnosis and slack mode. + return _fake_linprog_result(2) + # Stage 1 of the slack-mode solve. + return _fake_linprog_result(1) + + drift_compliance_mod.linprog = fake_linprog + try: + with pytest.raises(BacktestError, match="stage 1 failed"): + # A alone (untradable) already violates maximum_weight -- a + # genuine tradability-caused breach, so the strict LP's forced + # infeasibility above is consistent with the real diagnosis. + restore_drift_compliance( + np.array([0.6]), + ["A"], + np.array([False]), + [("A",)], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + finally: + drift_compliance_mod.linprog = real_linprog + + +def test_slack_stage2_failure_raises_not_stage1s_unpenalized_solution() -> None: + """Stage 1 only minimizes total slack, with zero cost on every other + free variable -- if stage 2 (which adds the real L1-deviation + objective) unexpectedly fails to solve, silently substituting stage + 1's own solution could return an arbitrary, needlessly destructive + correction (e.g. liquidating an uninvolved column) with no indication + the fallback path was taken. This must raise instead.""" + from scipy.optimize import linprog as real_linprog + + import quantlab.portfolio.drift_compliance as drift_compliance_mod + + calls = {"n": 0} + + def fake_linprog(*args: Any, **kwargs: Any) -> Any: + calls["n"] += 1 + if calls["n"] <= 2: + # Strict LP (infeasible) then stage 1 (slack) -- both real, so + # stage 1's own solution is genuinely feasible for stage 2. + return real_linprog(*args, **kwargs) + # Stage 2. + return _fake_linprog_result(4) + + drift_compliance_mod.linprog = fake_linprog + try: + with pytest.raises(BacktestError, match="stage 2 unexpectedly failed"): + restore_drift_compliance( + np.array([0.6]), + ["A"], + np.array([False]), + [("A",)], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + finally: + drift_compliance_mod.linprog = real_linprog diff --git a/tests/unit/test_features.py b/tests/unit/test_features.py index 42f719c..dcc229f 100644 --- a/tests/unit/test_features.py +++ b/tests/unit/test_features.py @@ -14,8 +14,18 @@ cross_sectional_zscore, select_top_bottom, ) -from quantlab.features.mean_reversion import half_life, rolling_zscore, rsi -from quantlab.features.momentum import ma_crossover_signal, momentum +from quantlab.features.mean_reversion import ( + half_life, + rolling_percentile_rank, + rolling_zscore, + rsi, +) +from quantlab.features.momentum import ( + cross_sectional_momentum_persistence, + ma_crossover_signal, + momentum, + momentum_persistence, +) from quantlab.features.pipeline import FeaturePipeline from quantlab.features.returns import ( cumulative_returns, @@ -74,6 +84,91 @@ def test_momentum_rejects_bad_skip() -> None: momentum(pd.Series([1.0, 2.0, 3.0]), lookback_period=5, skip_period=5) +def test_momentum_persistence_pairs_past_score_with_future_return() -> None: + prices = pd.Series(np.linspace(100, 200, 100)) + paired = momentum_persistence( + prices, lookback_period=20, skip_period=0, holding_period=5 + ) + assert list(paired.columns) == ["past_momentum", "future_return"] + assert not paired.empty + # A strictly rising series must show positive past momentum for every + # row that survives dropna() (the trailing/leading warm-up is excluded). + assert (paired["past_momentum"] > 0).all() + assert (paired["future_return"] > 0).all() + + +def test_cross_sectional_momentum_persistence_with_known_future_ranking() -> None: + """5 assets with distinct constant growth rates: the momentum ranking + (by construction) exactly matches the future-return ranking at every + date, so the Spearman rank correlation must be (near) perfect and the + top-minus-bottom spread must be strictly positive throughout -- the + concrete counter-example to a single-asset past-vs-future-return + scatter, which cannot even express a cross-sectional ranking claim.""" + n = 120 + growth_rates = [0.0001, 0.0005, 0.0010, 0.0015, 0.0020] + prices = pd.DataFrame( + { + f"A{i}": 100.0 * (1.0 + rate) ** np.arange(n) + for i, rate in enumerate(growth_rates) + }, + index=pd.date_range("2020-01-01", periods=n, freq="B"), + ) + result = cross_sectional_momentum_persistence( + prices, + lookback_period=20, + skip_period=0, + holding_period=5, + top_fraction=0.2, + bottom_fraction=0.2, + ) + assert list(result.columns) == [ + "rank_correlation", + "top_return", + "bottom_return", + "top_minus_bottom", + ] + assert not result.empty + assert (result["rank_correlation"] > 0.99).all() + assert (result["top_minus_bottom"] > 0).all() + + +def test_cross_sectional_momentum_persistence_rejects_non_dataframe() -> None: + with pytest.raises(TypeError, match="pandas DataFrame"): + cross_sectional_momentum_persistence( + pd.Series([1.0, 2.0]), # type: ignore[arg-type] + lookback_period=5, + skip_period=0, + holding_period=1, + ) + + +def test_cross_sectional_momentum_persistence_skips_dates_with_too_few_assets() -> None: + """A date with fewer than 3 scored assets cannot support a meaningful + rank correlation -- it must be excluded entirely, not produce a NaN row.""" + n = 60 + prices = pd.DataFrame( + { + "A": 100.0 * (1.01 ** np.arange(n)), + "B": 100.0 * (1.02 ** np.arange(n)), + }, + index=pd.date_range("2020-01-01", periods=n, freq="B"), + ) + result = cross_sectional_momentum_persistence( + prices, lookback_period=10, skip_period=0, holding_period=5 + ) + assert result.empty + + +def test_momentum_persistence_rejects_non_series_input() -> None: + with pytest.raises(TypeError, match="pandas Series"): + momentum_persistence( + pd.DataFrame({"a": [1.0, 2.0]}), # type: ignore[arg-type] + lookback_period=5, + skip_period=0, + holding_period=1, + ) + + def test_ma_crossover_sign() -> None: prices = pd.Series(np.linspace(100, 200, 60)) sig = ma_crossover_signal(prices, fast_window=5, slow_window=20) @@ -110,6 +205,29 @@ def test_rsi_range() -> None: assert r.between(0, 100).all() +def test_rolling_percentile_rank_is_one_for_a_new_high() -> None: + prices = pd.Series(np.arange(1.0, 31.0)) # strictly increasing. + rank = rolling_percentile_rank(prices, window=20) + # The last observation of a strictly increasing window is its max. + assert rank.iloc[-1] == pytest.approx(1.0) + + +def test_rolling_percentile_rank_is_lowest_for_a_new_low() -> None: + prices = pd.Series(np.arange(30.0, 0.0, -1.0)) # strictly decreasing. + rank = rolling_percentile_rank(prices, window=20) + # pandas' rank(pct=True) is 1-indexed, so the minimum of a 20-window + # scores 1/20, not exactly 0 -- still the lowest rank in that window. + assert rank.iloc[-1] == pytest.approx(1.0 / 20.0) + + +def test_rolling_percentile_rank_is_bounded_and_nan_during_warmup() -> None: + rng = np.random.default_rng(3) + prices = pd.Series(100 + np.cumsum(rng.normal(0, 1, 100)).clip(min=-90)) + rank = rolling_percentile_rank(prices, window=20) + assert rank.iloc[:19].isna().all() + assert rank.dropna().between(0.0, 1.0).all() + + def test_half_life_detects_mean_reversion() -> None: # AR(1) with phi < 1 mean-reverts; half-life should be finite and positive. rng = np.random.default_rng(2) @@ -167,3 +285,35 @@ def test_feature_pipeline_records_metadata() -> None: assert isinstance(out.columns, pd.MultiIndex) meta = pipe.metadata() assert meta[0]["generated_nans"] >= 20 + + +# --------------------------------------------------------------------------- # +# Public API (quantlab.features.__all__) +# --------------------------------------------------------------------------- # +def test_features_public_api_names_are_all_importable() -> None: + """Every name in `quantlab.features.__all__` must actually resolve on + the package -- in particular the stationarity/correlation/pairs- + diagnostics/efficiency-ratio/momentum-persistence surface added + alongside the Strategy Explorer feature, which `__init__.py` had + stopped re-exporting even though its own docstring calls it the + public API.""" + import quantlab.features as features + + assert features.__all__, "features.__all__ must not be empty" + for name in features.__all__: + assert hasattr(features, name), f"quantlab.features.{name} is missing" + + for name in ( + "efficiency_ratio", + "momentum_persistence", + "cross_sectional_momentum_persistence", + "correlation_matrix", + "ADFResult", + "CointegrationResult", + "adf_test", + "cointegration_test", + "hurst_exponent", + "PairDiagnostics", + "compute_pair_diagnostics", + ): + assert name in features.__all__, f"{name} missing from features.__all__" diff --git a/tests/unit/test_features_correlation.py b/tests/unit/test_features_correlation.py new file mode 100644 index 0000000..382c214 --- /dev/null +++ b/tests/unit/test_features_correlation.py @@ -0,0 +1,55 @@ +"""Tests for the multi-asset correlation matrix diagnostic.""" + +from __future__ import annotations + +from typing import cast + +import numpy as np +import pandas as pd +import pytest + +from quantlab.features.correlation import correlation_matrix + + +def test_correlation_matrix_diagonal_is_one() -> None: + index = pd.date_range("2020-01-01", periods=100, freq="D") + rng = np.random.default_rng(0) + prices = pd.DataFrame( + { + "A": 100.0 * np.cumprod(1.0 + rng.normal(0.0005, 0.01, 100)), + "B": 100.0 * np.cumprod(1.0 + rng.normal(0.0003, 0.01, 100)), + }, + index=index, + ) + matrix = correlation_matrix(prices) + assert cast(float, matrix.loc["A", "A"]) == pytest.approx(1.0) + assert cast(float, matrix.loc["B", "B"]) == pytest.approx(1.0) + assert cast(float, matrix.loc["A", "B"]) == pytest.approx( + cast(float, matrix.loc["B", "A"]) + ) + + +def test_correlation_matrix_detects_strongly_correlated_assets() -> None: + index = pd.date_range("2020-01-01", periods=200, freq="D") + rng = np.random.default_rng(1) + base_returns = rng.normal(0.0004, 0.01, 200) + a = 100.0 * np.cumprod(1.0 + base_returns) + b = 50.0 * np.cumprod(1.0 + base_returns + rng.normal(0.0, 0.0005, 200)) + c = 100.0 * np.cumprod(1.0 + rng.normal(0.0002, 0.015, 200)) + prices = pd.DataFrame({"A": a, "B": b, "C": c}, index=index) + matrix = correlation_matrix(prices) + ab = cast(float, matrix.loc["A", "B"]) + ac = cast(float, matrix.loc["A", "C"]) + assert ab > 0.9 + assert abs(ac) < ab + + +def test_correlation_matrix_rejects_unknown_method() -> None: + prices = pd.DataFrame({"A": [1.0, 2.0, 3.0]}) + with pytest.raises(ValueError, match="method"): + correlation_matrix(prices, method="bogus") # type: ignore[arg-type] + + +def test_correlation_matrix_rejects_non_dataframe() -> None: + with pytest.raises(TypeError, match="DataFrame"): + correlation_matrix(pd.Series([1.0, 2.0])) # type: ignore[arg-type] diff --git a/tests/unit/test_features_extra.py b/tests/unit/test_features_extra.py index bbb1757..5aa5f8f 100644 --- a/tests/unit/test_features_extra.py +++ b/tests/unit/test_features_extra.py @@ -38,6 +38,25 @@ def test_rolling_channels_and_donchian() -> None: assert pos.between(-0.01, 1.01).all() +def test_efficiency_ratio_is_high_for_a_clean_trend_and_low_for_noise() -> None: + trending = pd.Series(np.linspace(100, 200, 60)) + rng = np.random.default_rng(0) + choppy = pd.Series(100.0 + np.cumsum(rng.normal(0.0, 1.0, 60))) + trending_ratio = T.efficiency_ratio(trending, 20).dropna() + choppy_ratio = T.efficiency_ratio(choppy, 20).dropna() + assert trending_ratio.between(0.0, 1.0).all() + assert choppy_ratio.between(0.0, 1.0).all() + # A perfectly monotonic trend's net move equals its total path length. + assert trending_ratio.iloc[-1] == pytest.approx(1.0) + assert trending_ratio.mean() > choppy_ratio.mean() + + +def test_efficiency_ratio_is_neutral_for_a_flat_window() -> None: + flat = pd.Series(np.full(30, 100.0)) + ratio = T.efficiency_ratio(flat, 10).dropna() + assert (ratio == 0.5).all() + + def test_log_returns_and_equity_curve() -> None: prices = pd.Series([100.0, 110.0, 99.0]) lr = log_returns(prices) diff --git a/tests/unit/test_features_stationarity.py b/tests/unit/test_features_stationarity.py new file mode 100644 index 0000000..e4f9d4c --- /dev/null +++ b/tests/unit/test_features_stationarity.py @@ -0,0 +1,225 @@ +"""Tests for stationarity/cointegration/persistence diagnostics.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from quantlab.features.stationarity import ( + adf_test, + cointegration_test, + hurst_exponent, +) + + +def _mean_reverting_series( + n: int = 300, *, seed: int = 0, lam: float = 0.3 +) -> pd.Series: + """A strongly mean-reverting AR(1) series: x_t = (1 - lam) * x_{t-1} + noise.""" + rng = np.random.default_rng(seed) + values = np.zeros(n) + for t in range(1, n): + values[t] = (1.0 - lam) * values[t - 1] + rng.normal(0.0, 1.0) + return pd.Series(values) + + +def _random_walk(n: int = 300, *, seed: int = 0, drift: float = 0.0) -> pd.Series: + rng = np.random.default_rng(seed) + steps = rng.normal(drift, 1.0, n) + return pd.Series(np.cumsum(steps)) + + +# --------------------------------------------------------------------------- # +# adf_test +# --------------------------------------------------------------------------- # +def test_adf_test_rejects_null_for_a_strongly_mean_reverting_series() -> None: + result = adf_test(_mean_reverting_series()) + assert result is not None + assert result.reject_null is True + assert result.pvalue <= 0.05 + assert "stationarity" in result.interpretation + + +def test_adf_test_does_not_reject_null_for_a_random_walk() -> None: + result = adf_test(_random_walk()) + assert result is not None + assert result.reject_null is False + assert result.pvalue > 0.05 + + +def test_adf_test_returns_none_for_too_few_observations() -> None: + assert adf_test(pd.Series(np.arange(10, dtype=float))) is None + + +def test_adf_test_returns_none_for_a_constant_series() -> None: + assert adf_test(pd.Series(np.full(50, 3.0))) is None + + +def test_adf_test_rejects_non_series_input() -> None: + with pytest.raises(TypeError, match="pandas Series"): + adf_test(pd.DataFrame({"a": [1.0, 2.0]})) # type: ignore[arg-type] + + +@pytest.mark.parametrize("bad_level", [0.0, 1.0, -0.1, 1.5]) +def test_adf_test_rejects_bad_significance(bad_level: float) -> None: + with pytest.raises(ValueError, match="significance"): + adf_test(_random_walk(), significance=bad_level) + + +def test_adf_test_critical_values_and_metadata_are_populated() -> None: + result = adf_test(_mean_reverting_series()) + assert result is not None + assert set(result.critical_values) == {"1%", "5%", "10%"} + assert result.n_obs > 0 + assert result.n_lags >= 0 + + +def test_adf_test_returns_none_for_a_non_finite_statistic( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-finite ADF statistic (even with a finite p-value) is a + numerically degenerate result -- must be treated as inconclusive.""" + + def _fake_adfuller(values: object, autolag: str) -> tuple[object, ...]: + return (float("inf"), 0.01, 1, 100, {"1%": -3.5, "5%": -2.9, "10%": -2.6}, 0.0) + + monkeypatch.setattr( + "statsmodels.tsa.stattools.adfuller", + _fake_adfuller, + ) + assert adf_test(_mean_reverting_series()) is None + + +def test_adf_test_returns_none_for_a_non_finite_critical_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _fake_adfuller(values: object, autolag: str) -> tuple[object, ...]: + return ( + -3.0, + 0.01, + 1, + 100, + {"1%": float("nan"), "5%": -2.9, "10%": -2.6}, + 0.0, + ) + + monkeypatch.setattr( + "statsmodels.tsa.stattools.adfuller", + _fake_adfuller, + ) + assert adf_test(_mean_reverting_series()) is None + + +# --------------------------------------------------------------------------- # +# cointegration_test +# --------------------------------------------------------------------------- # +def test_cointegration_test_detects_a_cointegrated_pair() -> None: + common_trend = _random_walk(seed=1) + noise = pd.Series(np.random.default_rng(2).normal(0.0, 0.5, len(common_trend))) + a = common_trend + b = common_trend * 1.5 + noise + result = cointegration_test(a, b) + assert result is not None + assert result.reject_null is True + assert result.pvalue <= 0.05 + + +def test_cointegration_test_does_not_reject_null_for_independent_walks() -> None: + a = _random_walk(seed=10) + b = _random_walk(seed=20) + result = cointegration_test(a, b) + assert result is not None + assert result.reject_null is False + + +def test_cointegration_test_returns_none_for_near_perfect_collinearity() -> None: + """``b = 2 * a`` is (near-)perfectly collinear -- statsmodels itself + warns the test is numerically unreliable in this case (a spurious + ``statistic=-inf``/``pvalue=0.0`` "confident" result otherwise). Must + be treated as inconclusive (``None``), not returned as evidence of a + stable long-run relationship.""" + a = _random_walk(seed=3) + b = a * 2.0 + assert cointegration_test(a, b) is None + + +def test_cointegration_test_returns_none_for_a_non_finite_critical_value( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A non-finite critical value is as numerically degenerate as a + non-finite statistic/p-value -- must be treated as inconclusive.""" + + def _fake_coint(a: object, b: object) -> tuple[float, float, list[float]]: + return (-3.0, 0.01, [float("nan"), -3.3, -3.0]) + + monkeypatch.setattr("statsmodels.tsa.stattools.coint", _fake_coint) + a = _random_walk(seed=1) + b = _random_walk(seed=2) + assert cointegration_test(a, b) is None + + +def test_cointegration_test_returns_none_for_too_few_observations() -> None: + short_a = pd.Series(np.arange(10, dtype=float)) + short_b = pd.Series(np.arange(10, dtype=float) * 2) + assert cointegration_test(short_a, short_b) is None + + +def test_cointegration_test_rejects_non_series_input() -> None: + with pytest.raises(TypeError, match="pandas Series"): + cointegration_test(pd.DataFrame({"a": [1.0]}), pd.Series([1.0])) # type: ignore[arg-type] + + +def test_cointegration_test_rejects_mismatched_axes() -> None: + a = pd.Series(np.arange(30, dtype=float)) + b = pd.Series(np.arange(30, dtype=float), index=np.arange(30, 60)) + with pytest.raises(ValueError, match="index"): + cointegration_test(a, b) + + +# --------------------------------------------------------------------------- # +# hurst_exponent +# --------------------------------------------------------------------------- # +def test_hurst_exponent_is_low_for_a_mean_reverting_series() -> None: + h = hurst_exponent(_mean_reverting_series(n=500, lam=0.5)) + assert h < 0.4 + + +def test_hurst_exponent_is_near_half_for_a_random_walk() -> None: + h = hurst_exponent(_random_walk(n=2000)) + assert 0.35 < h < 0.65 + + +def test_hurst_exponent_is_high_for_a_trending_series() -> None: + """A deterministic straight line isn't the right synthetic case here: its + lag-k differences are a constant plus noise, so their *variance* doesn't + grow with lag at all (the estimator reads that as H ~= 0, not high). + Genuine persistence needs positively autocorrelated *increments* (each + step likely continues the last one's direction) accumulated into a walk + -- the standard way to simulate trending/persistent fBm-like data.""" + rng = np.random.default_rng(3) + increments = np.zeros(500) + for t in range(1, len(increments)): + increments[t] = 0.6 * increments[t - 1] + rng.normal(0.0, 1.0) + trend = pd.Series(np.cumsum(increments)) + h = hurst_exponent(trend) + assert h > 0.6 + + +def test_hurst_exponent_is_nan_for_too_short_a_series() -> None: + assert np.isnan(hurst_exponent(pd.Series(np.arange(5, dtype=float)), max_lag=20)) + + +def test_hurst_exponent_is_nan_for_a_constant_series() -> None: + assert np.isnan(hurst_exponent(pd.Series(np.full(100, 5.0)))) + + +def test_hurst_exponent_rejects_non_series_input() -> None: + with pytest.raises(TypeError, match="pandas Series"): + hurst_exponent(pd.DataFrame({"a": [1.0, 2.0]})) # type: ignore[arg-type] + + +@pytest.mark.parametrize("bad_lag", [0, 1, -5]) +def test_hurst_exponent_rejects_bad_max_lag(bad_lag: int) -> None: + with pytest.raises(ValueError, match="max_lag"): + hurst_exponent(_random_walk(), max_lag=bad_lag) diff --git a/tests/unit/test_native_calendar.py b/tests/unit/test_native_calendar.py new file mode 100644 index 0000000..5ba7833 --- /dev/null +++ b/tests/unit/test_native_calendar.py @@ -0,0 +1,188 @@ +"""Direct tests for `compute_native_then_align`, the shared helper that +computes a rolling-window feature on each symbol's own native calendar +before aligning it back onto a closure-padded combined timeline.""" + +from __future__ import annotations + +from collections.abc import Callable + +import pandas as pd +import pytest + +from quantlab.features.native_calendar import compute_native_then_align + + +def _mean_of_last(window: int) -> Callable[[pd.DataFrame], pd.DataFrame]: + return lambda p: p.rolling(window, min_periods=window).mean() + + +def test_symbol_calendars_none_short_circuits_to_compute_fn() -> None: + prices = pd.DataFrame( + {"AAA": [1.0, 2.0, 3.0]}, index=pd.date_range("2024-01-01", periods=3) + ) + calls = [] + + def compute_fn(p: pd.DataFrame) -> pd.DataFrame: + calls.append(p) + return p * 2.0 + + result = compute_native_then_align( + compute_fn, prices, None, pd.DatetimeIndex(prices.index) + ) + + pd.testing.assert_frame_equal(result, prices * 2.0) + assert len(calls) == 1 + + +def test_no_calendar_for_any_column_short_circuits() -> None: + prices = pd.DataFrame( + {"AAA": [1.0, 2.0, 3.0]}, index=pd.date_range("2024-01-01", periods=3) + ) + result = compute_native_then_align( + lambda p: p * 2.0, prices, {"ZZZ": "XNYS"}, pd.DatetimeIndex(prices.index) + ) + pd.testing.assert_frame_equal(result, prices * 2.0) + + +def test_no_actual_closure_in_range_short_circuits_byte_identical() -> None: + """A calendar is configured, but every row in `prices.index` happens to + be a real session on it (e.g. only business days present) -- must take + the fast path (compute once on the whole frame), byte-identical to the + single-calendar vectorized call.""" + prices = pd.DataFrame( + {"AAA": [1.0, 2.0, 3.0, 4.0]}, + index=pd.date_range("2024-01-02", periods=4, freq="B"), + ) + result = compute_native_then_align( + _mean_of_last(2), prices, {"AAA": "XNYS"}, pd.DatetimeIndex(prices.index) + ) + expected = prices.rolling(2, min_periods=2).mean() + pd.testing.assert_frame_equal(result, expected) + + +def test_uniform_calendar_fast_path_covers_multiple_columns_sharing_it() -> None: + """The uniform-calendar short-circuit (`len(calendars) == + len(prices.columns) and uniform_calendar(...) is not None`) exists + precisely for a genuine multi-column, single-calendar universe (e.g. + several XNYS equities together, per this module's own docstring) -- + a single column trivially satisfies "every column shares one + calendar" without exercising more than one, so this test uses three. + Confirms both the byte-for-byte result AND that `compute_fn` is + called exactly ONCE on the full multi-column frame, never once per + column.""" + prices = pd.DataFrame( + { + "AAA": [1.0, 2.0, 3.0, 4.0], + "BBB": [10.0, 20.0, 30.0, 40.0], + "CCC": [100.0, 200.0, 300.0, 400.0], + }, + index=pd.date_range("2024-01-02", periods=4, freq="B"), + ) + calls: list[pd.DataFrame] = [] + + def compute_fn(p: pd.DataFrame) -> pd.DataFrame: + calls.append(p.copy()) + return p.rolling(2, min_periods=2).mean() + + result = compute_native_then_align( + compute_fn, + prices, + {"AAA": "XNYS", "BBB": "XNYS", "CCC": "XNYS"}, + pd.DatetimeIndex(prices.index), + ) + + expected = prices.rolling(2, min_periods=2).mean() + pd.testing.assert_frame_equal(result, expected) + assert len(calls) == 1 + assert list(calls[0].columns) == ["AAA", "BBB", "CCC"] + + +def test_native_computation_removes_closure_dilution() -> None: + """The core fix: AAA (XNYS) is closed over a weekend shared with BTC + (24/7) on the same combined timeline. A 3-period rolling mean computed + on AAA's own native (session-only) dates must differ from -- and be + more accurate than -- the same rolling mean computed directly on the + closure-padded combined timeline.""" + dates = pd.DatetimeIndex( + [ + "2024-01-04", # Thu (AAA open) + "2024-01-05", # Fri (AAA open) + "2024-01-06", # Sat (AAA closed, verified XNYS weekend) + "2024-01-07", # Sun (AAA closed, verified XNYS weekend) + "2024-01-08", # Mon (AAA open) + "2024-01-09", # Tue (AAA open) + ] + ) + # AAA's padded series: flat-filled (last real close) on the weekend, + # exactly as `insert_verified_closure_bars` produces in production. + aaa = pd.Series([10.0, 12.0, 12.0, 12.0, 16.0, 20.0], index=dates) + btc = pd.Series([100.0, 101.0, 102.0, 103.0, 104.0, 105.0], index=dates) + prices = pd.DataFrame({"AAA": aaa, "BTC": btc}) + + result = compute_native_then_align( + _mean_of_last(3), + prices, + {"AAA": "XNYS", "BTC": "24/7"}, + pd.DatetimeIndex(prices.index), + ) + + # Native AAA rolling(3): only real sessions Thu/Fri/Mon/Tue contribute. + # Warm-up needs 3 native observations, so only Mon (Thu,Fri,Mon) and Tue + # (Fri,Mon,Tue) are defined; the weekend rows forward-fill from Friday's + # own (still-NaN, insufficient-warmup) native result, so they stay NaN. + assert pd.isna(result.loc["2024-01-04", "AAA"]) + assert pd.isna(result.loc["2024-01-05", "AAA"]) + assert pd.isna(result.loc["2024-01-06", "AAA"]) + assert pd.isna(result.loc["2024-01-07", "AAA"]) + assert result.loc["2024-01-08", "AAA"] == pytest.approx((10.0 + 12.0 + 16.0) / 3.0) + assert result.loc["2024-01-09", "AAA"] == pytest.approx((12.0 + 16.0 + 20.0) / 3.0) + + # The naive diluted computation (directly on the padded frame) would + # have given a materially different, wrong answer for Monday. + diluted = prices.rolling(3, min_periods=3).mean() + assert diluted.loc["2024-01-08", "AAA"] != pytest.approx( + result.loc["2024-01-08", "AAA"] + ) + + # BTC has no closures at all -- untouched, byte-identical to a plain + # vectorized computation on its own full series. + expected_btc = btc.rolling(3, min_periods=3).mean() + pd.testing.assert_series_equal(result["BTC"], expected_btc, check_names=False) + + +def test_symbol_with_no_calendar_entry_computed_directly() -> None: + """A column absent from `symbol_calendars` is treated as always open -- + computed directly, never sliced. + + Regression test: the original version of this test used a calendar + with zero actual closures in range (or no calendar at all), which hits + `compute_native_then_align`'s own EARLIER short-circuits (a calendar- + less universe, or one where nothing genuinely closed, both return + before ever reaching the per-column loop this test means to exercise) + -- it happened to pass, but for the wrong reason, never actually + running the `calendar is None` branch it claimed to cover. AAA (XNYS) + below has a genuine weekend closure, forcing the function past both + short-circuits into the per-column loop; ZZZ, absent from + `symbol_calendars` entirely, must then be reached by THAT loop and + take its own `calendar is None` branch.""" + dates = pd.DatetimeIndex( + [ + "2024-01-04", # Thu (AAA open) + "2024-01-05", # Fri (AAA open) + "2024-01-06", # Sat (AAA closed, verified XNYS weekend) + "2024-01-07", # Sun (AAA closed, verified XNYS weekend) + "2024-01-08", # Mon (AAA open) + ] + ) + aaa = pd.Series([10.0, 12.0, 12.0, 12.0, 16.0], index=dates) + zzz = pd.Series([1.0, 2.0, 3.0, 4.0, 5.0], index=dates) + prices = pd.DataFrame({"AAA": aaa, "ZZZ": zzz}) + + result = compute_native_then_align( + _mean_of_last(2), prices, {"AAA": "XNYS"}, pd.DatetimeIndex(prices.index) + ) + + # ZZZ has no calendar entry at all -- computed directly on its own + # full (unsliced) column, byte-identical to a plain vectorized call. + expected_zzz = prices[["ZZZ"]].rolling(2, min_periods=2).mean()["ZZZ"] + pd.testing.assert_series_equal(result["ZZZ"], expected_zzz, check_names=False) diff --git a/tests/unit/test_native_calendar_strategies.py b/tests/unit/test_native_calendar_strategies.py new file mode 100644 index 0000000..3585b8e --- /dev/null +++ b/tests/unit/test_native_calendar_strategies.py @@ -0,0 +1,406 @@ +"""Wiring tests: every rolling-window feature call site in a strategy (and +`runner.py`'s ADV) must route through `compute_native_then_align` via +`BaseStrategy.symbol_calendars` / `self._native_feature`, and must be a +provable no-op (byte-identical) when `symbol_calendars` is unset. +""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + +from quantlab.constants import ( + ADJUSTED_CLOSE, + CLOSE, + HIGH, + LOW, + OPEN, + SYMBOL, + TIMESTAMP, + VOLUME, +) + + +def _long_from_wide(prices: pd.DataFrame) -> pd.DataFrame: + frames = [] + for symbol in prices.columns: + series = prices[symbol] + frames.append( + pd.DataFrame( + { + TIMESTAMP: series.index, + SYMBOL: symbol, + OPEN: series.to_numpy(), + HIGH: series.to_numpy(), + LOW: series.to_numpy(), + CLOSE: series.to_numpy(), + ADJUSTED_CLOSE: series.to_numpy(), + VOLUME: 1_000.0, + } + ) + ) + return pd.concat(frames, ignore_index=True) + + +def _mixed_calendar_panel(periods: int = 42) -> pd.DataFrame: + """AAA (session-bound, closed weekends) + BTC (24/7), 42 calendar days + (30 native AAA trading days -- enough margin above pairs_trading's + ``formation_window`` minimum of 20). + + AAA's weekend rows are flat-filled from the prior real close, exactly + matching `insert_verified_closure_bars`'s own production convention. + """ + dates = pd.date_range("2024-01-01", periods=periods, freq="D") # Jan 1 = Monday + is_weekend = dates.weekday >= 5 + aaa = np.empty(len(dates)) + trading_values = np.linspace(100.0, 150.0, num=int((~is_weekend).sum())) + aaa[~is_weekend] = trading_values + last = np.nan + for i in range(len(dates)): + if is_weekend[i]: + aaa[i] = last + else: + last = aaa[i] + btc = np.linspace(40_000.0, 41_260.0, num=len(dates)) + return pd.DataFrame({"AAA": aaa, "BTC": btc}, index=dates) + + +_CALENDARS = {"AAA": "XNYS", "BTC": "24/7"} + + +def test_time_series_momentum_native_calendar_changes_diluted_output() -> None: + from quantlab.strategies.momentum import TimeSeriesMomentumStrategy + + prices = _mixed_calendar_panel() + data = _long_from_wide(prices) + + diluted = TimeSeriesMomentumStrategy( + lookback_period=10, skip_period=0, signal_scaling="binary" + ) + diluted_signals = diluted.generate_signals(data) + + native = TimeSeriesMomentumStrategy( + lookback_period=10, skip_period=0, signal_scaling="binary" + ) + native.symbol_calendars = _CALENDARS + native_signals = native.generate_signals(data) + + # BTC has no closures at all -- untouched either way. + pd.testing.assert_series_equal( + diluted_signals["BTC"], native_signals["BTC"], check_names=False + ) + # AAA's dilution genuinely changes at least one date's signal. + assert not diluted_signals["AAA"].equals(native_signals["AAA"]) + + +def test_cross_sectional_momentum_native_calendar_changes_diluted_output() -> None: + from quantlab.strategies.momentum import CrossSectionalMomentumStrategy + + prices = _mixed_calendar_panel() + data = _long_from_wide(prices) + + diluted = CrossSectionalMomentumStrategy( + lookback_period=10, skip_period=0, top_fraction=0.5, signal_scaling="binary" + ) + diluted_signals = diluted.generate_signals(data) + + native = CrossSectionalMomentumStrategy( + lookback_period=10, skip_period=0, top_fraction=0.5, signal_scaling="binary" + ) + native.symbol_calendars = _CALENDARS + native_signals = native.generate_signals(data) + + assert not diluted_signals.equals(native_signals) + + +def test_trend_following_native_calendar_changes_diluted_output() -> None: + from quantlab.strategies.trend_following import TrendFollowingStrategy + + prices = _mixed_calendar_panel() + data = _long_from_wide(prices) + + diluted = TrendFollowingStrategy(fast_window=3, slow_window=10) + diluted_signals = diluted.generate_signals(data) + + native = TrendFollowingStrategy(fast_window=3, slow_window=10) + native.symbol_calendars = _CALENDARS + native_signals = native.generate_signals(data) + + pd.testing.assert_series_equal( + diluted_signals["BTC"], native_signals["BTC"], check_names=False + ) + assert not diluted_signals["AAA"].equals(native_signals["AAA"]) + + +def test_mean_reversion_native_calendar_changes_diluted_output() -> None: + """Exercises `_centered_indicator` directly (rather than the full + entry/exit state machine) -- this simple synthetic price panel never + crosses the default z-score entry threshold, so the post-threshold + SIGNAL would be an uninformative constant zero either way; the + underlying INDICATOR is where the native-calendar wrapping actually + shows up.""" + from quantlab.strategies.mean_reversion import _centered_indicator + + prices = _mixed_calendar_panel() + + diluted = _centered_indicator(prices, "zscore", 10, 2.0) + native = _centered_indicator(prices, "zscore", 10, 2.0, _CALENDARS) + + pd.testing.assert_series_equal(diluted["BTC"], native["BTC"], check_names=False) + assert not diluted["AAA"].equals(native["AAA"]) + + +def test_engine_collapses_position_group_tradability_end_to_end() -> None: + """`BacktestEngine.run()` must gate a declared position group's + tradability as ONE unit (both legs eligible only on a date BOTH are + open), not per-leg independently -- otherwise a rebalance could move + one leg of a pair while the other stays frozen, introducing unmodeled + legging risk. Checked at the `result.weights` level: across the whole + backtest, AAA's executed weight must change on a date if and only if + BTC's does too.""" + from tests.conftest import geometric_series + + from quantlab.backtesting.runner import run_backtest_from_config + from quantlab.config import ExperimentConfig + + dates = pd.date_range("2024-01-01", periods=90, freq="D") + is_weekend = dates.weekday >= 5 + aaa_native = geometric_series( + int((~is_weekend).sum()), mu=0.0, sigma=0.02, s0=100.0, seed=11 + ) + aaa = np.empty(len(dates)) + aaa[~is_weekend] = aaa_native + last = np.nan + for i in range(len(dates)): + if is_weekend[i]: + aaa[i] = last + else: + last = aaa[i] + btc = geometric_series(len(dates), mu=0.0, sigma=0.02, s0=100.0, seed=22) + prices = pd.DataFrame({"AAA": aaa, "BTC": btc}, index=dates) + data = _long_from_wide(prices) + + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "pairs_group_tradability", + "data": { + "instruments": [ + {"symbol": "AAA", "source": "csv", "calendar": "XNYS"}, + {"symbol": "BTC", "source": "csv", "calendar": "24/7"}, + ], + "start_date": "2024-01-01", + "end_date": "2024-03-30", + }, + "strategy": { + "name": "pairs_trading", + "parameters": { + "symbol_a": "AAA", + "symbol_b": "BTC", + "formation_window": 20, + "indicator_window": 5, + "indicator": "percentile", + "entry_threshold": 0.30, + "exit_threshold": 0.05, + "adf_pvalue_threshold": None, + "dynamic_hedge_ratio": True, + }, + }, + "portfolio": { + "allocator": "signal_proportional", + "rebalance_frequency": "daily", + }, + "execution": {}, + "backtest": {"periods_per_year": 252}, + } + ) + + result = run_backtest_from_config(data, cfg) + + aaa_changed = result.weights["AAA"].diff().abs() > 1e-9 + btc_changed = result.weights["BTC"].diff().abs() > 1e-9 + pd.testing.assert_series_equal(aaa_changed, btc_changed, check_names=False) + assert bool(aaa_changed.any()) # the invariant must be exercised, not vacuous + + +def test_pairs_trading_native_pair_context_uses_intersection_of_native_calendars() -> ( + None +): + from quantlab.strategies.pairs_trading import PairsTradingStrategy + + prices = _mixed_calendar_panel() + + diluted = PairsTradingStrategy( + symbol_a="AAA", + symbol_b="BTC", + formation_window=20, + indicator_window=5, + adf_pvalue_threshold=None, + ) + _, _, diluted_indicator, _, diluted_tradable = diluted._native_pair_context(prices) + + native = PairsTradingStrategy( + symbol_a="AAA", + symbol_b="BTC", + formation_window=20, + indicator_window=5, + adf_pvalue_threshold=None, + ) + native.symbol_calendars = _CALENDARS + _, _, native_indicator, _, native_tradable = native._native_pair_context(prices) + + assert not diluted_indicator.equals(native_indicator) + # No injected calendar -> both legs always considered open. + assert bool(diluted_tradable.all()) + # Injected mixed calendars -> weekends/holidays correctly block entry. + assert not bool(native_tradable.all()) + + +def test_pairs_trading_entry_gate_matches_symbol_a_native_calendar() -> None: + """`symbol_b` (BTC) is 24/7, so the combined entry gate must reduce + exactly to `symbol_a`'s (AAA/XNYS) own native session mask when the ADF + stationarity gate is disabled.""" + from quantlab.data.calendar import is_session_day + from quantlab.strategies.pairs_trading import PairsTradingStrategy + + prices = _mixed_calendar_panel() + strategy = PairsTradingStrategy( + symbol_a="AAA", + symbol_b="BTC", + formation_window=20, + indicator_window=5, + adf_pvalue_threshold=None, + ) + strategy.symbol_calendars = _CALENDARS + _, _, _, _, tradable = strategy._native_pair_context(prices) + + expected = is_session_day("XNYS", pd.DatetimeIndex(prices.index)) + np.testing.assert_array_equal(tradable, expected) + + +def test_build_execution_from_config_adv_uses_native_calendar() -> None: + """A weekend closure's synthetic zero-volume bar must never drag down a + session-bound symbol's own trailing dollar-ADV -- the window's content + is computed on its own native calendar, not the closure-padded + combined timeline.""" + from quantlab.backtesting.runner import build_execution_from_config + from quantlab.config import ExperimentConfig + from quantlab.execution.slippage import VolumeBasedSlippageModel + + dates = pd.date_range("2024-01-01", periods=28, freq="D") + is_weekend = dates.weekday >= 5 + aaa_close = np.full(len(dates), 100.0) + aaa_volume = np.where(is_weekend, 0.0, 1_000_000.0) + btc_close = np.full(len(dates), 40_000.0) + btc_volume = np.full(len(dates), 500_000.0) + + data = pd.concat( + [ + pd.DataFrame( + { + TIMESTAMP: dates, + SYMBOL: "AAA", + OPEN: aaa_close, + HIGH: aaa_close, + LOW: aaa_close, + CLOSE: aaa_close, + ADJUSTED_CLOSE: aaa_close, + VOLUME: aaa_volume, + } + ), + pd.DataFrame( + { + TIMESTAMP: dates, + SYMBOL: "BTC", + OPEN: btc_close, + HIGH: btc_close, + LOW: btc_close, + CLOSE: btc_close, + ADJUSTED_CLOSE: btc_close, + VOLUME: btc_volume, + } + ), + ], + ignore_index=True, + ) + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "adv_native", + "data": { + "instruments": [ + {"symbol": "AAA", "source": "csv", "calendar": "XNYS"}, + {"symbol": "BTC", "source": "csv", "calendar": "24/7"}, + ], + "start_date": "2024-01-01", + "end_date": "2024-01-28", + }, + "strategy": {"name": "buy_and_hold"}, + "execution": {"slippage_model": "volume", "slippage_bps": 5.0}, + "backtest": {"periods_per_year": 252}, + } + ) + + execution = build_execution_from_config(cfg, data) + slippage = execution.slippage + assert isinstance(slippage, VolumeBasedSlippageModel) + adv = slippage.average_daily_volume + assert isinstance(adv, pd.DataFrame) + + naive = ( + pd.DataFrame( + {"AAA": aaa_volume * aaa_close, "BTC": btc_volume * btc_close}, index=dates + ) + .rolling(21, min_periods=1) + .mean() + .shift(1) + ) + assert not adv["AAA"].equals(naive["AAA"]) + # Every real AAA trading day has an identical dollar volume; its native + # trailing ADV must therefore be exactly constant, never diluted below + # this by a weekend's zero-volume synthetic bar. + assert np.allclose(adv["AAA"].dropna().to_numpy(), 100_000_000.0) + + +def test_symbol_calendars_none_is_byte_identical_to_no_wrapper() -> None: + """Regression safety gate: an unset `symbol_calendars` (the default, + e.g. a strategy built directly in a unit test outside the engine) must + reproduce today's plain vectorized computation exactly.""" + from quantlab.features.momentum import momentum + from quantlab.strategies.momentum import TimeSeriesMomentumStrategy + + prices = _mixed_calendar_panel() + data = _long_from_wide(prices) + strategy = TimeSeriesMomentumStrategy( + lookback_period=10, skip_period=0, signal_scaling="binary" + ) + signals = strategy.generate_signals(data) + + expected_score = momentum(prices, 10, 0) + expected = pd.DataFrame( + np.sign(expected_score.to_numpy()), + index=expected_score.index, + columns=expected_score.columns, + ).fillna(0.0) + pd.testing.assert_frame_equal( + signals, expected, check_dtype=False, check_names=False, check_freq=False + ) + + +def test_parameters_excludes_symbol_calendars() -> None: + """`symbol_calendars` is engine-injected context, never a user-supplied + hyperparameter -- `BaseStrategy.parameters()` must never surface it in + a config-YAML round-trip, execution-model hash, or sweep-parameter + enumeration (see `_NON_PARAMETER_ATTRIBUTES`). Relied on throughout + this module (every other test here sets `.symbol_calendars` directly, + bypassing the constructor-parameter freeze) but never directly + asserted until now.""" + from quantlab.strategies.momentum import TimeSeriesMomentumStrategy + + strategy = TimeSeriesMomentumStrategy( + lookback_period=10, skip_period=0, signal_scaling="binary" + ) + strategy.symbol_calendars = _CALENDARS + + params = strategy.parameters() + + assert "symbol_calendars" not in params + assert strategy.symbol_calendars == _CALENDARS diff --git a/tests/unit/test_pair_diagnostics.py b/tests/unit/test_pair_diagnostics.py new file mode 100644 index 0000000..f1defdf --- /dev/null +++ b/tests/unit/test_pair_diagnostics.py @@ -0,0 +1,249 @@ +"""Tests for the shared pairs-trading diagnostics module.""" + +from __future__ import annotations + +import numpy as np +import pandas as pd +import pytest + +from quantlab.data.base import price_matrix +from quantlab.features.pairs_diagnostics import compute_pair_diagnostics, spread + + +def test_spread_matches_manual_computation() -> None: + index = pd.date_range("2020-01-01", periods=5, freq="D") + a = pd.Series([10.0, 11.0, 12.0, 13.0, 14.0], index=index) + b = pd.Series([5.0, 5.5, 6.0, 6.5, 7.0], index=index) + intercept = pd.Series(1.0, index=index) + beta = pd.Series(2.0, index=index) + result = spread(a, b, intercept, beta) + expected = a - 1.0 - 2.0 * b + pd.testing.assert_series_equal(result, expected, check_names=False) + + +def test_spread_rejects_mismatched_axes() -> None: + a = pd.Series([1.0, 2.0], index=[0, 1]) + b = pd.Series([1.0, 2.0], index=[0, 2]) + intercept = pd.Series([0.0, 0.0], index=[0, 1]) + beta = pd.Series([1.0, 1.0], index=[0, 1]) + with pytest.raises(ValueError, match="index"): + spread(a, b, intercept, beta) + + +def test_compute_pair_diagnostics_on_a_cointegrated_pair( + two_symbol_panel: pd.DataFrame, +) -> None: + """``two_symbol_panel`` builds EWB = 0.8 * EWA + 20 + small noise -- a + strongly cointegrated, mean-reverting-spread pair by construction.""" + prices = price_matrix(two_symbol_panel) + diagnostics = compute_pair_diagnostics( + prices, + "EWA", + "EWB", + formation_window=100, + indicator_window=20, + dynamic_hedge_ratio=True, + ) + assert diagnostics.symbol_a == "EWA" + assert diagnostics.symbol_b == "EWB" + assert -1.0 <= diagnostics.correlation <= 1.0 + assert diagnostics.correlation > 0.5 + assert diagnostics.hedge_ratio.notna().sum() > 0 + assert diagnostics.spread.notna().sum() > 0 + assert diagnostics.indicator == "zscore" + assert diagnostics.spread_indicator.notna().sum() > 0 + assert diagnostics.adf_result is not None + assert diagnostics.adf_result.reject_null is True + assert diagnostics.cointegration_result is not None + assert diagnostics.cointegration_result.reject_null is True + assert np.isfinite(diagnostics.half_life) + assert diagnostics.half_life > 0 + assert np.isfinite(diagnostics.hedge_ratio_stability) + assert diagnostics.rolling_adf_pvalue.notna().sum() > 0 + + +def test_compute_pair_diagnostics_rejects_missing_symbol( + two_symbol_panel: pd.DataFrame, +) -> None: + prices = price_matrix(two_symbol_panel) + with pytest.raises(ValueError, match="EWZ"): + compute_pair_diagnostics( + prices, + "EWA", + "EWZ", + formation_window=100, + indicator_window=20, + dynamic_hedge_ratio=True, + ) + + +def test_compute_pair_diagnostics_rejects_non_dataframe() -> None: + with pytest.raises(TypeError, match="DataFrame"): + compute_pair_diagnostics( + pd.Series([1.0, 2.0]), # type: ignore[arg-type] + "EWA", + "EWB", + formation_window=100, + indicator_window=20, + dynamic_hedge_ratio=True, + ) + + +def test_compute_pair_diagnostics_static_hedge_ratio_is_stable_by_construction( + two_symbol_panel: pd.DataFrame, +) -> None: + """A static hedge ratio never varies after formation, so its own + stability diagnostic must read as exactly zero dispersion.""" + prices = price_matrix(two_symbol_panel) + diagnostics = compute_pair_diagnostics( + prices, + "EWA", + "EWB", + formation_window=100, + indicator_window=20, + dynamic_hedge_ratio=False, + ) + assert diagnostics.hedge_ratio_stability == pytest.approx(0.0) + + +@pytest.mark.parametrize("dynamic_hedge_ratio", [True, False]) +def test_rolling_adf_pvalue_reproduces_the_live_strategys_own_gate( + two_symbol_panel: pd.DataFrame, dynamic_hedge_ratio: bool +) -> None: + """``PairDiagnostics.rolling_adf_pvalue``, thresholded, must equal + ``PairsTradingStrategy._stationarity_gate`` bar for bar -- the whole + point of both calling the same shared + ``periodic_stationarity_pvalues`` function (see its docstring). A + diagnostic that silently used a DIFFERENT computation (e.g. slicing an + already-dynamically-refit spread series instead of fitting one fresh + regression per checkpoint window) would show a pair as stationarity- + gated on dates where a real backtest of it was not, or vice versa. + + ``two_symbol_panel`` is single-calendar, and ``_stationarity_gate`` is + called directly here with the SAME ``prices["EWA"]``/``prices["EWB"]`` + series ``compute_pair_diagnostics`` itself uses -- this only proves + "same function, same input -> same output". The live strategy's own + entry gate instead reaches ``_stationarity_gate`` via + ``_native_pair_context``, which feeds it each leg sliced to the + intersection of both legs' native session dates: for a MIXED-calendar + pair that input differs from what ``compute_pair_diagnostics`` uses, + and the two genuinely diverge -- see + ``test_rolling_adf_pvalue_diverges_from_the_live_gate_under_mixed_ + calendars`` below. + """ + from quantlab.strategies.pairs_trading import PairsTradingStrategy + + prices = price_matrix(two_symbol_panel) + formation_window = 100 + indicator_window = 20 + adf_pvalue_threshold = 0.10 + + diagnostics = compute_pair_diagnostics( + prices, + "EWA", + "EWB", + formation_window=formation_window, + indicator_window=indicator_window, + dynamic_hedge_ratio=dynamic_hedge_ratio, + ) + diagnostics_gate = ( + diagnostics.rolling_adf_pvalue.notna() + & (diagnostics.rolling_adf_pvalue <= adf_pvalue_threshold) + ).to_numpy() + + strategy = PairsTradingStrategy( + symbol_a="EWA", + symbol_b="EWB", + formation_window=formation_window, + indicator_window=indicator_window, + dynamic_hedge_ratio=dynamic_hedge_ratio, + adf_pvalue_threshold=adf_pvalue_threshold, + ) + strategy_gate = strategy._stationarity_gate(prices["EWA"], prices["EWB"]) + + np.testing.assert_array_equal(diagnostics_gate, strategy_gate) + # Not a vacuous comparison of two all-False arrays. + assert strategy_gate.any() + + +def test_rolling_adf_pvalue_diverges_from_the_live_gate_under_mixed_calendars() -> None: + """Regression test for a documentation bug: earlier docstrings claimed + ``rolling_adf_pvalue`` was "the one exception" among this module's + diagnostics that always reproduces the live entry gate exactly, + because it calls the exact same ``periodic_stationarity_pvalues`` + FUNCTION. Calling the same function is not the same as reproducing + the same RESULT: under a mixed-calendar universe, the live gate (via + ``PairsTradingStrategy._native_pair_context``) feeds that function + each leg sliced to the intersection of both legs' own native session + dates, while ``compute_pair_diagnostics`` feeds it the full combined, + closure-padded timeline. Proven directly here by calling + ``periodic_stationarity_pvalues`` both ways on the same mixed-calendar + pair and showing the results genuinely differ on native session dates + (not just on the padding itself, where they could trivially differ).""" + from quantlab.data.calendar import is_session_day + from quantlab.strategies.pairs_trading import periodic_stationarity_pvalues + + dates = pd.date_range("2019-01-01", periods=200, freq="D") # includes weekends + is_weekend = dates.weekday >= 5 + rng = np.random.default_rng(5) + common = np.empty(len(dates)) + common[~is_weekend] = 100.0 + np.cumsum( + rng.normal(0.05, 1.0, size=int((~is_weekend).sum())) + ) + last = np.nan + for i in range(len(dates)): + if is_weekend[i]: + common[i] = last + else: + last = common[i] + noise = rng.normal(0.0, 0.5, size=len(dates)) + a = pd.Series(common, index=dates) # AAA: XNYS, flat-filled on weekends + b = pd.Series(0.8 * common + 20.0 + noise, index=dates) # BTC: 24/7, tracks A + + both_open = is_session_day("XNYS", dates) + native_index = dates[both_open] + + combined_pvalues = periodic_stationarity_pvalues( + a, b, formation_window=60, stride=10, dynamic_hedge_ratio=True + ) + native_pvalues = periodic_stationarity_pvalues( + a.loc[native_index], + b.loc[native_index], + formation_window=60, + stride=10, + dynamic_hedge_ratio=True, + ) + + # Compare on native session dates only -- a mismatch there proves the + # divergence isn't just an artefact of the padding/reindex itself. + combined_on_native = combined_pvalues.loc[native_index] + assert not combined_on_native.reset_index(drop=True).equals( + native_pvalues.reset_index(drop=True) + ) + + +@pytest.mark.parametrize("indicator", ["zscore", "rsi", "percentile"]) +def test_compute_pair_diagnostics_indicator_matches_the_strategys_own_series( + two_symbol_panel: pd.DataFrame, indicator: str +) -> None: + """``spread_indicator`` must match the SAME series + ``PairsTradingStrategy._centered_spread_indicator`` computes for a + given ``indicator`` -- not always the zscore, regardless of which + indicator was requested.""" + from quantlab.strategies.pairs_trading import _centered_spread_indicator + + prices = price_matrix(two_symbol_panel) + diagnostics = compute_pair_diagnostics( + prices, + "EWA", + "EWB", + formation_window=100, + indicator_window=20, + dynamic_hedge_ratio=True, + indicator=indicator, + ) + assert diagnostics.indicator == indicator + expected = _centered_spread_indicator(diagnostics.spread, indicator, 20) + pd.testing.assert_series_equal( + diagnostics.spread_indicator, expected, check_names=False + ) diff --git a/tests/unit/test_portfolio.py b/tests/unit/test_portfolio.py index 5f50806..35f5f30 100644 --- a/tests/unit/test_portfolio.py +++ b/tests/unit/test_portfolio.py @@ -2,22 +2,27 @@ from __future__ import annotations +from typing import cast + import numpy as np import pandas as pd import pytest -from quantlab.config import RebalanceFrequency +from quantlab.config import PortfolioConfig, RebalanceFrequency from quantlab.portfolio.allocator import ( EqualWeightAllocator, InverseVolatilityAllocator, SignalProportionalAllocator, build_allocator, ) -from quantlab.portfolio.constraints import ConstraintSet +from quantlab.portfolio.constraints import ConstraintSet, ConstraintTouch, _mark_touched from quantlab.portfolio.position_sizing import gross_exposure from quantlab.portfolio.rebalancing import ( + _rebalance_tradability_aware, apply_rebalancing, + cap_turnover, compute_turnover, + rebalance_and_cap_turnover, rebalance_dates, ) @@ -101,6 +106,232 @@ def test_constraint_gross_cap() -> None: assert gross_exposure(out).iloc[0] == pytest.approx(1.0) +# --------------------------------------------------------------------------- # +# Constraint provenance (apply_with_provenance) +# --------------------------------------------------------------------------- # +def test_apply_with_provenance_matches_apply_exactly() -> None: + """apply() and apply_with_provenance()[0] must be byte-identical -- + provenance tracking is pure instrumentation, never a second, possibly + diverging computation.""" + idx = pd.date_range("2020-01-01", periods=5) + rng = np.random.default_rng(0) + weights = pd.DataFrame( + rng.normal(scale=0.5, size=(5, 4)), index=idx, columns=["A", "B", "C", "D"] + ) + constraints = ConstraintSet( + maximum_weight=0.3, + minimum_weight=0.02, + maximum_gross_exposure=1.0, + maximum_leverage=1.0, + maximum_net_exposure=0.5, + maximum_positions=3, + long_only=False, + ) + + direct = constraints.apply(weights) + via_provenance, _ = constraints.apply_with_provenance(weights) + + pd.testing.assert_frame_equal(direct, via_provenance) + + +def test_apply_with_provenance_marks_only_the_constraint_that_fired() -> None: + idx = pd.date_range("2020-01-01", periods=1) + weights = pd.DataFrame({"A": [0.6], "B": [0.4]}, index=idx) + + _, touches = ConstraintSet(maximum_weight=0.3).apply_with_provenance(weights) + + assert set(touches) == {"maximum_weight"} + assert bool(touches["maximum_weight"].touched.loc[idx[0], "A"]) + assert touches["maximum_weight"].before.loc[idx[0], "A"] == pytest.approx(0.6) + assert touches["maximum_weight"].after.loc[idx[0], "A"] == pytest.approx(0.3) + + +def test_apply_with_provenance_untriggered_constraint_has_an_all_false_mask() -> None: + """A configured constraint that never actually binds must still appear + in the provenance dict (so callers can tell "configured but inert" + from "not configured"), with an all-False touched mask.""" + idx = pd.date_range("2020-01-01", periods=1) + weights = pd.DataFrame({"A": [0.2], "B": [0.2]}, index=idx) # already <= 0.3 + + _, touches = ConstraintSet(maximum_weight=0.3).apply_with_provenance(weights) + + assert "maximum_weight" in touches + assert not touches["maximum_weight"].touched.to_numpy().any() + + +def test_apply_with_provenance_marks_every_constraint_that_binds_on_the_same_cell() -> ( + None +): + """maximum_weight trims first, then maximum_gross_exposure rescales the + whole row further -- both must be recorded as touching the cell, not + just the last one to run (the user's explicit "multiple causes" + requirement).""" + idx = pd.date_range("2020-01-01", periods=1) + weights = pd.DataFrame({"A": [0.9], "B": [0.9]}, index=idx) # gross 1.8 + + _, touches = ConstraintSet( + maximum_weight=0.5, maximum_gross_exposure=0.6 + ).apply_with_provenance(weights) + + assert set(touches) == {"maximum_weight", "maximum_gross_exposure"} + assert bool(touches["maximum_weight"].touched.loc[idx[0], "A"]) + assert bool(touches["maximum_gross_exposure"].touched.loc[idx[0], "A"]) + + +def test_apply_with_provenance_maximum_weight_direct_vs_redistribution() -> None: + """A cell directly clipped by maximum_weight (A) vs a cell only + redimensioned by the water-filling redistribution that follows (B, + which never itself exceeded the cap) must be distinguishable via + `direct` -- confirmed against `renormalize_within_cap`'s own two-step + clip-then-water-fill behaviour (position_sizing.py).""" + idx = pd.date_range("2020-01-01", periods=1) + weights = pd.DataFrame({"A": [0.6], "B": [0.1]}, index=idx) + + _, touches = ConstraintSet(maximum_weight=0.3).apply_with_provenance(weights) + touch = touches["maximum_weight"] + + assert bool(touch.direct.loc[idx[0], "A"]) + assert bool(touch.touched.loc[idx[0], "B"]) + assert not bool(touch.direct.loc[idx[0], "B"]) + # B was genuinely redistributed upward (never itself over the cap). + assert cast(float, touch.after.at[idx[0], "B"]) > cast( + float, touch.before.at[idx[0], "B"] + ) + + +def test_apply_with_provenance_minimum_weight_direct_vs_redistribution() -> None: + """A is dropped as dust (direct); B, a genuine survivor well above the + minimum, is only redimensioned by the redistribution back to the + pre-drop gross target.""" + idx = pd.date_range("2020-01-01", periods=1) + weights = pd.DataFrame({"A": [0.05], "B": [0.3]}, index=idx) + + _, touches = ConstraintSet(minimum_weight=0.1).apply_with_provenance(weights) + touch = touches["minimum_weight"] + + assert bool(touch.direct.loc[idx[0], "A"]) + assert touch.after.loc[idx[0], "A"] == pytest.approx(0.0) + assert bool(touch.touched.loc[idx[0], "B"]) + assert not bool(touch.direct.loc[idx[0], "B"]) + assert cast(float, touch.after.at[idx[0], "B"]) > cast( + float, touch.before.at[idx[0], "B"] + ) + + +def test_apply_with_provenance_maximum_positions_direct_vs_redistribution() -> None: + """C (the smallest) is directly dropped by the cardinality cut; A/B + (the survivors) are only redimensioned by the redistribution back to + the pre-drop gross target.""" + idx = pd.date_range("2020-01-01", periods=1) + weights = pd.DataFrame({"A": [0.5], "B": [0.3], "C": [0.1]}, index=idx) + + _, touches = ConstraintSet(maximum_positions=2).apply_with_provenance(weights) + touch = touches["maximum_positions"] + + assert bool(touch.direct.loc[idx[0], "C"]) + assert touch.after.loc[idx[0], "C"] == pytest.approx(0.0) + for survivor in ("A", "B"): + assert bool(touch.touched.loc[idx[0], survivor]) + assert not bool(touch.direct.loc[idx[0], survivor]) + after_value = cast(float, touch.after.at[idx[0], survivor]) + before_value = cast(float, touch.before.at[idx[0], survivor]) + assert after_value > before_value + + +def test_apply_with_provenance_direct_equals_touched_for_uniform_rescales() -> None: + """maximum_gross_exposure/maximum_leverage/maximum_net_exposure/ + long_only are uniform whole-row rescales with no cell-level direct-vs- + indirect distinction -- `direct` must always equal `touched`.""" + idx = pd.date_range("2020-01-01", periods=1) + weights = pd.DataFrame({"A": [0.9], "B": [0.9]}, index=idx) + + _, touches = ConstraintSet(maximum_gross_exposure=1.0).apply_with_provenance( + weights + ) + touch = touches["maximum_gross_exposure"] + + pd.testing.assert_frame_equal(touch.direct, touch.touched) + + +def test_apply_with_provenance_direct_is_always_a_subset_of_touched() -> None: + """Invariant that must hold for every constraint, every run: a cell + can never be `direct` without also being `touched`.""" + idx = pd.date_range("2020-01-01", periods=5) + rng = np.random.default_rng(1) + weights = pd.DataFrame( + rng.normal(scale=0.5, size=(5, 4)), index=idx, columns=["A", "B", "C", "D"] + ) + constraints = ConstraintSet( + maximum_weight=0.3, + minimum_weight=0.02, + maximum_gross_exposure=1.0, + maximum_leverage=1.0, + maximum_net_exposure=0.5, + maximum_positions=3, + long_only=False, + ) + + _, touches = constraints.apply_with_provenance(weights) + + for touch in touches.values(): + violation = touch.direct & ~touch.touched + assert not bool(violation.to_numpy().any()) + + +def test_mark_touched_after_only_updates_on_a_pass_that_actually_retouches() -> None: + """Point 2/9: a cell whose value changes between two passes of THIS + constraint, but NOT because of this constraint's own operation on the + intervening pass, must not have that unrelated change attributed to + it -- `after` only moves on a pass where `before != after` for THIS + call. `before` stays the very first value across every pass.""" + idx = pd.date_range("2020-01-01", periods=1) + touched: dict[str, ConstraintTouch] = {} + + # Pass 1: X changes A from 0.9 to 0.7. + _mark_touched( + touched, + "X", + pd.DataFrame({"A": [0.9]}, index=idx), + pd.DataFrame({"A": [0.7]}, index=idx), + ) + assert touched["X"].before.loc[idx[0], "A"] == pytest.approx(0.9) + assert touched["X"].after.loc[idx[0], "A"] == pytest.approx(0.7) + + # Between passes, an UNRELATED operation moved A to 0.75. Pass 2: X's + # own before/after this call are equal (0.75 -> 0.75) -- it did not + # retouch A. + _mark_touched( + touched, + "X", + pd.DataFrame({"A": [0.75]}, index=idx), + pd.DataFrame({"A": [0.75]}, index=idx), + ) + assert touched["X"].after.loc[idx[0], "A"] == pytest.approx(0.7) + assert touched["X"].before.loc[idx[0], "A"] == pytest.approx(0.9) + + # Pass 3: X retouches A for real (0.75 -> 0.6). + _mark_touched( + touched, + "X", + pd.DataFrame({"A": [0.75]}, index=idx), + pd.DataFrame({"A": [0.6]}, index=idx), + ) + assert touched["X"].before.loc[idx[0], "A"] == pytest.approx(0.9) + assert touched["X"].after.loc[idx[0], "A"] == pytest.approx(0.6) + + +def test_maximum_weight_direct_predicate_matches_the_real_clip_exactly() -> None: + """No epsilon reconstructed for `direct`: at exactly the cap, clip() + is a no-op (not direct); a hair above it, clip() DOES change the + value (direct) -- boundary values chosen to straddle EPSILON.""" + cap = 0.3 + idx = pd.date_range("2020-01-01", periods=1) + for offset, expect_direct in ((0.0, False), (1e-13, False), (1e-8, True)): + weights = pd.DataFrame({"A": [cap + offset], "B": [0.05]}, index=idx) + _, touches = ConstraintSet(maximum_weight=cap).apply_with_provenance(weights) + assert bool(touches["maximum_weight"].direct.loc[idx[0], "A"]) is expect_direct + + def test_weights_have_no_nan_or_inf(synthetic_panel: pd.DataFrame) -> None: idx = synthetic_panel["timestamp"].drop_duplicates().sort_values() signals = pd.DataFrame(1.0, index=idx, columns=["AAA", "BBB", "CCC"]) @@ -108,6 +339,172 @@ def test_weights_have_no_nan_or_inf(synthetic_panel: pd.DataFrame) -> None: assert np.isfinite(weights.to_numpy()).all() +# --------------------------------------------------------------------------- # +# Turnover-cap provenance (cell-level, episode-scoped) +# --------------------------------------------------------------------------- # +def test_cap_turnover_provenance_does_not_change_the_computed_weights() -> None: + """Provenance tracking is pure instrumentation -- requesting it must + never change the numeric result.""" + idx = pd.date_range("2020-01-01", periods=4) + held = pd.DataFrame({"A": [1.0, 1.0, 0.3, 0.3]}, index=idx) + episode_id = pd.DataFrame({"A": [1, 1, 2, 2]}, index=idx) + + without = cap_turnover(held, maximum_turnover=0.4) + with_provenance, _ = cap_turnover( + held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True + ) + + pd.testing.assert_frame_equal(without, with_provenance) + + +def test_cap_turnover_touched_is_cell_level_not_a_row_broadcast() -> None: + """Two columns, only one of which actually has a requested delta this + row -- the untouched one must never be marked, even though the row as + a whole was turnover-limited.""" + idx = pd.date_range("2020-01-01", periods=1) + held = pd.DataFrame({"A": [1.0], "B": [0.0]}, index=idx) + episode_id = pd.DataFrame({"A": [1], "B": [0]}, index=idx) + + _, provenance = cap_turnover( + held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True + ) + + assert bool(provenance.turnover_touched.loc[idx[0], "A"]) + assert not bool(provenance.turnover_touched.loc[idx[0], "B"]) + + +def test_cap_turnover_touched_persists_across_the_same_episode() -> None: + """The exact scenario from the redesign's central example: a target + held constant across 3 turnover-limited rebalances -- the LAST fill + (no longer actively binding) must still carry the real turnover_cap + provenance, as a catch-up of the same still-unresolved episode.""" + idx = pd.date_range("2020-01-01", periods=3) + held = pd.DataFrame({"A": [1.0, 1.0, 1.0]}, index=idx) + episode_id = pd.DataFrame({"A": [1, 1, 1]}, index=idx) + + output, provenance = cap_turnover( + held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True + ) + + assert output["A"].tolist() == pytest.approx([0.4, 0.8, 1.0]) + assert provenance.turnover_actively_limited["A"].tolist() == [True, True, False] + assert provenance.turnover_touched["A"].tolist() == [True, True, True] + + +def test_cap_turnover_new_episode_does_not_inherit_debt_even_same_sign() -> None: + """Core fix: a NEW upstream decision (different episode_id) must NOT + inherit an old episode's turnover debt, even when the new target + happens to continue moving in the same direction.""" + idx = pd.date_range("2020-01-01", periods=2) + held = pd.DataFrame({"A": [1.0, 0.6]}, index=idx) + episode_id = pd.DataFrame({"A": [1, 2]}, index=idx) + + output, provenance = cap_turnover( + held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True + ) + + assert output["A"].tolist() == pytest.approx([0.4, 0.6]) + assert provenance.turnover_actively_limited["A"].tolist() == [True, False] + assert provenance.turnover_touched["A"].tolist() == [True, False] + + +def test_cap_turnover_new_episode_abandoning_the_debt() -> None: + idx = pd.date_range("2020-01-01", periods=2) + held = pd.DataFrame({"A": [1.0, 0.2]}, index=idx) + episode_id = pd.DataFrame({"A": [1, 2]}, index=idx) + + output, provenance = cap_turnover( + held, maximum_turnover=0.4, episode_id=episode_id, return_provenance=True + ) + + assert output["A"].tolist() == pytest.approx([0.4, 0.2]) + assert provenance.turnover_touched["A"].tolist() == [True, False] + + +def test_cap_turnover_debt_identity_comes_from_episode_id_not_target_value() -> None: + """The adversarial case demanded explicitly: an IDENTICAL weight path + (so the numeric result is unaffected), but the second row is tagged + as a DIFFERENT episode than the first even though the target value + coincidentally repeats -- the catch-up must not be attributed to the + first episode's debt. A same-episode control confirms the debt IS + correctly inherited when it's genuinely the same decision.""" + idx = pd.date_range("2020-01-01", periods=2) + held = pd.DataFrame({"A": [0.5, 0.5]}, index=idx) + + same_episode = pd.DataFrame({"A": [1, 1]}, index=idx) + same_output, same_provenance = cap_turnover( + held, maximum_turnover=0.3, episode_id=same_episode, return_provenance=True + ) + assert same_provenance.turnover_touched["A"].tolist() == [True, True] + + different_episode = pd.DataFrame({"A": [1, 2]}, index=idx) + different_output, different_provenance = cap_turnover( + held, + maximum_turnover=0.3, + episode_id=different_episode, + return_provenance=True, + ) + assert different_provenance.turnover_touched["A"].tolist() == [True, False] + + # Same weight path in both cases -- provenance never affects the + # computed numbers. + pd.testing.assert_series_equal(same_output["A"], different_output["A"]) + + +def test_rebalance_tradability_aware_tradability_touched_on_reopen_catchup() -> None: + """A target changes while the symbol is closed; no trade happens while + it stays closed; on reopening, the catch-up trade must carry + tradability_touched=True even though `tradable` is True again that + exact day (the current-row boolean alone cannot explain a real + executed trade -- see _rebalance_tradability_aware's own change==0 + guarantee while ineligible).""" + idx = pd.date_range("2020-01-01", periods=3) + target = pd.DataFrame({"A": [0.0, 1.0, 1.0]}, index=idx) + tradable = pd.DataFrame({"A": [True, False, True]}, index=idx) + episode_id = pd.DataFrame({"A": [0, 1, 1]}, index=idx) + portfolio_config = PortfolioConfig(rebalance_frequency=RebalanceFrequency.DAILY) + + output, provenance = _rebalance_tradability_aware( + target, + portfolio_config, + tradable, + episode_id=episode_id, + return_provenance=True, + ) + + # No trade at all while closed (row 1): held at the prior value. + assert output["A"].tolist() == pytest.approx([0.0, 0.0, 1.0]) + assert not bool(provenance.tradability_touched.loc[idx[1], "A"]) + assert bool(provenance.tradability_touched.loc[idx[2], "A"]) + + +def test_rebalance_and_cap_turnover_returns_provenance_when_requested() -> None: + """The public dispatcher (used by engine.py) must forward episode_id/ + return_provenance correctly on both the tradable and non-tradable + paths, and on the no-turnover-cap-configured path.""" + idx = pd.date_range("2020-01-01", periods=2) + target = pd.DataFrame({"A": [1.0, 1.0]}, index=idx) + episode_id = pd.DataFrame({"A": [1, 1]}, index=idx) + portfolio_config = PortfolioConfig( + rebalance_frequency=RebalanceFrequency.DAILY, maximum_turnover=0.4 + ) + + output, provenance = rebalance_and_cap_turnover( + target, portfolio_config, episode_id=episode_id, return_provenance=True + ) + assert output["A"].tolist() == pytest.approx([0.4, 0.8]) + assert provenance.turnover_touched["A"].tolist() == [True, True] + + # No turnover cap configured at all -- provenance must still come + # back, all-False (nothing can ever be turnover-limited). + no_cap_config = PortfolioConfig(rebalance_frequency=RebalanceFrequency.DAILY) + no_cap_output, no_cap_provenance = rebalance_and_cap_turnover( + target, no_cap_config, episode_id=episode_id, return_provenance=True + ) + assert no_cap_output["A"].tolist() == pytest.approx([1.0, 1.0]) + assert not bool(no_cap_provenance.turnover_touched.to_numpy().any()) + + # --------------------------------------------------------------------------- # # Rebalancing # --------------------------------------------------------------------------- # diff --git a/tests/unit/test_portfolio_hardening.py b/tests/unit/test_portfolio_hardening.py index e494cc7..d1dfa8b 100644 --- a/tests/unit/test_portfolio_hardening.py +++ b/tests/unit/test_portfolio_hardening.py @@ -178,7 +178,7 @@ def test_turnover_functions_reject_non_finite_weights() -> None: def test_cap_turnover_rejects_invalid_direct_budget(bad: object) -> None: targets = pd.DataFrame({"A": [0.5]}) with pytest.raises(InvalidConfigurationError): - cap_turnover(targets, maximum_turnover=bad) # type: ignore[arg-type] + cap_turnover(targets, maximum_turnover=bad) # type: ignore[call-overload] def test_volatility_estimator_excludes_missing_observations() -> None: diff --git a/tests/unit/test_regression_data.py b/tests/unit/test_regression_data.py index c018cd7..7ea9b2d 100644 --- a/tests/unit/test_regression_data.py +++ b/tests/unit/test_regression_data.py @@ -1409,14 +1409,19 @@ def test_generator_hash_is_sensitive_to_cli_edits_but_not_dashboard_edits( assert engine._generator_hash() != original -def test_robustness_placeholder_does_not_overclaim_cli_coverage() -> None: +def test_robustness_placeholder_points_to_the_actual_cli_commands() -> None: + """Every technique named here now has a real `quantlab ` CLI + command (added alongside YAML-configurable robustness settings) -- + the placeholder must point users at those, not only the Python API.""" from quantlab.reporting.html_report import _render_robustness html = _render_robustness(None) assert "quantlab walk-forward" in html - assert "run_parameter_sensitivity" in html - assert "bootstrap_returns" in html - assert "monte_carlo_permutation" in html + assert "quantlab stress-test" in html + assert "quantlab sensitivity" in html + assert "quantlab bootstrap" in html + assert "quantlab permutation-test" in html + assert "quantlab robustness" in html def test_cache_covers_tolerates_a_weekend_end_date(tmp_path: Path) -> None: @@ -4126,26 +4131,51 @@ def test_bar_bucket_end_distinguishes_xnys_friday_close_from_24_7_monday() -> No def test_drop_still_open_bars_uses_the_right_calendar( - tmp_path: Path, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: + """Deterministic in wall-clock time: real ``_utc_now()`` briefly disagreed + with itself near a UTC/exchange-local day boundary (the equity bar was + already safe 12h past its close, but the 24/7 bar -- whose bucket runs to + the following UTC midnight -- was not yet 12h past its own close), making + this flaky once per day. Pinned via monkeypatch instead.""" + import quantlab.data.storage as storage_module from quantlab.data.calendar import ( daily_equity_bucket_settlement, last_trading_day_on_or_before, ) - from quantlab.data.storage import ParquetStorage, _drop_still_open_bars + from quantlab.data.storage import ( + ParquetStorage, + _drop_still_open_bars, + _posting_lag_for, + ) - now = pd.Timestamp.now(tz="UTC").tz_localize(None) - today = pd.Timestamp(year=now.year, month=now.month, day=now.day) + # A fixed, known-closed XNYS session -- no dependence on wall-clock time. closed_session = last_trading_day_on_or_before( - today - pd.Timedelta(days=1), calendar="XNYS" + pd.Timestamp("2024-01-10"), calendar="XNYS" ) data = pd.DataFrame({"timestamp": [closed_session], "symbol": ["AAPL"]}) - assert len(_drop_still_open_bars(data, "1d", calendar="XNYS")) == 1 - assert len(_drop_still_open_bars(data, "1d", calendar="24/7")) == 1 equity_close = daily_equity_bucket_settlement(closed_session) flat_close = closed_session + pd.Timedelta(days=1) assert equity_close < flat_close + posting_lag = _posting_lag_for("1d") + equity_safe_at = equity_close + posting_lag + flat_safe_at = flat_close + posting_lag + assert equity_safe_at < flat_safe_at + + # Between the two safe-at instants: the equity bar is already safe, the + # 24/7 bar (whose bucket, and thus posting-lag clock, starts later) is not. + between = equity_safe_at + (flat_safe_at - equity_safe_at) / 2 + monkeypatch.setattr(storage_module, "_utc_now", lambda: between) + assert len(_drop_still_open_bars(data, "1d", calendar="XNYS")) == 1 + assert len(_drop_still_open_bars(data, "1d", calendar="24/7")) == 0 + + # After both instants: both calendars now consider the bar safe. + monkeypatch.setattr( + storage_module, "_utc_now", lambda: flat_safe_at + pd.Timedelta(minutes=1) + ) + assert len(_drop_still_open_bars(data, "1d", calendar="XNYS")) == 1 + assert len(_drop_still_open_bars(data, "1d", calendar="24/7")) == 1 storage = ParquetStorage(cache_dir=tmp_path / "cache", metadata_dir=tmp_path / "md") equity_data = pd.DataFrame( diff --git a/tests/unit/test_regression_execution.py b/tests/unit/test_regression_execution.py index 806d3b4..8d9cdc3 100644 --- a/tests/unit/test_regression_execution.py +++ b/tests/unit/test_regression_execution.py @@ -451,7 +451,7 @@ def test_cap_turnover_rejects_invalid_public_api_parameters() -> None: for name in ("maximum_weight", "maximum_gross_exposure", "maximum_net_exposure"): for bad in (-0.1, float("nan")): with pytest.raises(InvalidConfigurationError): - cap_turnover(targets, maximum_turnover=0.5, **{name: bad}) # type: ignore[arg-type] + cap_turnover(targets, maximum_turnover=0.5, **{name: bad}) # type: ignore[call-overload] # Sanity: ordinary usage remains unaffected. out = cap_turnover(targets, maximum_turnover=0.3) assert out.to_numpy().tolist() == [[0.3], [0.5]] @@ -943,6 +943,16 @@ def test_turnover_cap_completes_a_full_rotation_between_disjoint_sets() -> None: def test_engine_turnover_cap_never_exceeds_the_configured_budget() -> None: + """`maximum_turnover` bounds the REAL trade size (`result.turnover`, + built from `apply_weight_drift`'s own `trade_changes` -- exactly zero + on a pure-drift row, the real size on an anchor or a landed + correction), not a raw row-to-row diff of `result.positions`. With + `model_weight_drift` at its default (`True`), `result.positions` + itself keeps moving every day from organic price drift independently + of the turnover cap -- that is the whole point of the feature, not a + violation of this cap, which only throttles genuine trades (see + docs/backtesting.md's Rebalancing & turnover / Weight drift + sections).""" from quantlab.backtesting.runner import run_backtest_from_config frames = [ @@ -991,10 +1001,8 @@ def test_engine_turnover_cap_never_exceeds_the_configured_budget() -> None: assert result.target_weights is not None target_nonzero = (result.target_weights.abs() > 1e-9).sum(axis=1) assert target_nonzero.max() <= 2 - realised_turnover = ( - (result.positions - result.positions.shift(1).fillna(0.0)).abs().sum(axis=1) - ) - assert realised_turnover.max() <= 0.1 + 1e-9 + assert result.turnover is not None + assert result.turnover.max() <= 0.1 + 1e-9 def test_infeasible_position_weight_combo_warns(caplog: Any) -> None: @@ -1287,7 +1295,15 @@ def test_cap_turnover_preserves_float_precision_for_integer_input() -> None: def test_engine_only_trades_cap_turnover_on_rebalance_dates() -> None: """End-to-end: a monthly-rebalanced, turnover-capped backtest must not - trade on non-rebalance dates.""" + trade on non-rebalance dates. + + `model_weight_drift` is explicitly disabled: this test is specifically + about the turnover-cap/rebalance-schedule mechanism producing a + constant-weight step function between rebalances -- with drift enabled + (the default), `result.positions` genuinely changes every day from + organic price movement, which is the whole point of that feature, not + a violation of this one's own scope. + """ from quantlab.backtesting.runner import run_backtest_from_config data, cfg = _rf_test_setup() @@ -1297,6 +1313,7 @@ def test_engine_only_trades_cap_turnover_on_rebalance_dates() -> None: update={ "maximum_turnover": 0.1, "rebalance_frequency": "monthly", + "model_weight_drift": False, } ) } diff --git a/tests/unit/test_regression_reporting.py b/tests/unit/test_regression_reporting.py index 7cbabc0..ffeb3df 100644 --- a/tests/unit/test_regression_reporting.py +++ b/tests/unit/test_regression_reporting.py @@ -592,19 +592,100 @@ def test_report_fallback_discovers_a_bundled_yml_config( lambda loaded_data, loaded_cfg, *, data_quality_report: fake_result, ) - def fake_save(result: object, exp_dir: Path) -> Path: + def fake_save( + result: object, exp_dir: Path, *, robustness_extra: object = None + ) -> Path: saved["result"] = result saved["exp_dir"] = exp_dir return exp_dir monkeypatch.setattr(result_module, "save_with_walk_forward_reuse", fake_save) - cli_module.report(experiment=experiment) + cli_module.report(experiment=experiment, config=None, shipped_config=None) assert saved["result"] is fake_result assert saved["exp_dir"] == (reports / experiment).resolve() +def test_report_dash_config_finds_a_custom_output_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """`--experiment` can only ever discover a config saved under + reports/generated/ (or a bundled config of the same name) -- a config + saved with its own `output.directory` elsewhere (never bundled) is + unreachable that way. `--config PATH`, pointing directly at the saved + config file, must still find and regenerate it.""" + from types import SimpleNamespace + + import quantlab.backtesting.result as result_module + import quantlab.backtesting.runner as runner_module + import quantlab.cli as cli_module + import quantlab.data.loader as loader_module + + data, cfg = _holdout_config() + custom_output_dir = tmp_path / "somewhere" / "custom" + cfg = cfg.revalidated_copy( + update={ + "output": cfg.output.revalidated_copy( + update={"directory": str(custom_output_dir)} + ) + } + ) + config_path = tmp_path / "not_bundled_config.yaml" + cfg.to_yaml(config_path) + saved: dict[str, object] = {} + fake_result = SimpleNamespace(save_warnings=[]) + + monkeypatch.setattr( + loader_module.DataLoader, + "load", + lambda _self, loaded_cfg: (data, SimpleNamespace(warnings=[])), + ) + monkeypatch.setattr( + runner_module, + "run_backtest_from_config", + lambda loaded_data, loaded_cfg, *, data_quality_report: fake_result, + ) + + def fake_save( + result: object, exp_dir: Path, *, robustness_extra: object = None + ) -> Path: + saved["result"] = result + saved["exp_dir"] = exp_dir + return exp_dir + + monkeypatch.setattr(result_module, "save_with_walk_forward_reuse", fake_save) + + cli_module.report(experiment=None, config=config_path, shipped_config=None) + + assert saved["result"] is fake_result + assert saved["exp_dir"] == custom_output_dir + + +def test_report_experiment_and_config_are_mutually_exclusive() -> None: + import typer + + import quantlab.cli as cli_module + + with pytest.raises(typer.Exit) as raised: + cli_module.report( + experiment="some_experiment", + config=Path("some_config.yaml"), + shipped_config=None, + ) + assert raised.value.exit_code == 1 + + +def test_report_requires_one_of_experiment_config_or_shipped_config() -> None: + import typer + + import quantlab.cli as cli_module + + with pytest.raises(typer.Exit) as raised: + cli_module.report(experiment=None, config=None, shipped_config=None) + assert raised.value.exit_code == 1 + + def test_code_hash_changes_when_quantlab_source_changes( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -751,6 +832,119 @@ def test_save_records_figure_failures_instead_of_masking_them( assert (out / "equity_curve.csv").is_file() +def test_save_records_trade_log_schema_version_in_metadata(tmp_path: Path) -> None: + """The trade log's schema version is not a CSV column (a raw export + carries no such metadata) -- it must be recoverable from the bundle's + `metadata.json` instead, alongside the other run-level facts.""" + from quantlab.backtesting.runner import run_backtest_from_config + from quantlab.backtesting.trade_log import TRADE_LOG_SCHEMA_VERSION + + data, cfg = _holdout_config() + result = run_backtest_from_config(data, cfg) + out = result.save(tmp_path / "out") + + on_disk = json.loads((out / "metadata.json").read_text(encoding="utf-8")) + assert on_disk["trade_log_schema_version"] == TRADE_LOG_SCHEMA_VERSION == 2 + + +def test_save_records_signal_price_type_in_resolved_config(tmp_path: Path) -> None: + """resolved_config.signal_price_type must always be present and match + whatever the strategy was actually built with -- true whether or not + the YAML explicitly set strategy.signal_price_type.""" + from quantlab.backtesting.runner import run_backtest_from_config + + data, cfg = _holdout_config() + result = run_backtest_from_config(data, cfg) + out = result.save(tmp_path / "default") + on_disk = json.loads((out / "metadata.json").read_text(encoding="utf-8")) + assert on_disk["resolved_config"]["signal_price_type"] == "adjusted_close" + + custom_cfg = cfg.revalidated_copy( + update={ + "strategy": cfg.strategy.revalidated_copy( + update={"signal_price_type": "close"} + ) + } + ) + custom_result = run_backtest_from_config(data, custom_cfg) + custom_out = custom_result.save(tmp_path / "custom") + custom_on_disk = json.loads( + (custom_out / "metadata.json").read_text(encoding="utf-8") + ) + assert custom_on_disk["resolved_config"]["signal_price_type"] == "close" + + +def test_custom_research_question_and_hypothesis_appear_in_the_html_report() -> None: + """config.research_question/.hypothesis, when set, must reach the + rendered HTML report verbatim -- not just the standalone research_ + summary functions in isolation.""" + from quantlab.backtesting.runner import run_backtest_from_config + + data, cfg = _holdout_config() + custom_cfg = cfg.revalidated_copy( + update={ + "research_question": "Does this exact sentinel phrase survive?", + "hypothesis": "H1: sentinel survives. H0: it does not.", + } + ) + result = run_backtest_from_config(data, custom_cfg) + html = result.to_html() + assert "Does this exact sentinel phrase survive?" in html + assert "H1: sentinel survives. H0: it does not." in html + + +def test_output_directory_config_is_respected_by_save(tmp_path: Path) -> None: + """config.output.directory must be used when result.save() is called + with no explicit override -- an explicit call-time argument still wins.""" + from quantlab.backtesting.runner import run_backtest_from_config + + custom_dir = tmp_path / "configured_output" + data, cfg = _holdout_config() + cfg = cfg.revalidated_copy(update={"output": {"directory": str(custom_dir)}}) + result = run_backtest_from_config(data, cfg) + + out = result.save() + assert out == custom_dir + assert (out / "metrics.json").is_file() + + explicit_dir = tmp_path / "explicit_override" + out2 = result.save(explicit_dir) + assert out2 == explicit_dir + + +def test_output_save_toggles_skip_only_the_presentation_layer(tmp_path: Path) -> None: + """save_html_report=False/save_figures=False must skip only the HTML/ + PNG rendering -- every numeric artefact stays unconditional, so + `quantlab report`-style regeneration can still work afterwards.""" + from quantlab.backtesting.runner import run_backtest_from_config + + data, cfg = _holdout_config() + cfg = cfg.revalidated_copy( + update={ + "output": { + "directory": str(tmp_path / "out"), + "save_html_report": False, + "save_figures": False, + } + } + ) + result = run_backtest_from_config(data, cfg) + out = result.save() + + assert not (out / "report.html").exists() + assert (out / "figures").is_dir() + assert not any((out / "figures").iterdir()) + assert (out / "trades.csv").is_file() + assert (out / "equity_curve.csv").is_file() + assert (out / "metrics.json").is_file() + assert (out / "metadata.json").is_file() + + # A later, HTML-enabled save (e.g. `quantlab report`'s own regeneration + # path) must still be able to render the report from the same bundle. + html = result.to_html() + assert " None: @@ -1044,7 +1238,7 @@ def test_report_command_rejects_experiment_path_traversal( ) with pytest.raises(typer.Exit) as raised: - cli_module.report(experiment="../../../etc") + cli_module.report(experiment="../../../etc", config=None, shipped_config=None) assert raised.value.exit_code == 1 assert any( diff --git a/tests/unit/test_regression_risk.py b/tests/unit/test_regression_risk.py index ba6ee44..ed651be 100644 --- a/tests/unit/test_regression_risk.py +++ b/tests/unit/test_regression_risk.py @@ -147,7 +147,7 @@ def test_benchmark_outside_universe_is_loaded_but_not_tradable( "symbol_a": "EWA", "symbol_b": "EWC", "formation_window": 40, - "zscore_window": 15, + "indicator_window": 15, }, }, "portfolio": {"allocator": "signal_proportional"}, diff --git a/tests/unit/test_regression_strategies.py b/tests/unit/test_regression_strategies.py index b1617b8..cfc46ec 100644 --- a/tests/unit/test_regression_strategies.py +++ b/tests/unit/test_regression_strategies.py @@ -67,11 +67,11 @@ def test_known_strategy_parameters_still_accepted() -> None: }, "strategy": { "name": "mean_reversion", - "parameters": {"lookback_period": 20, "entry_zscore": 2.0}, + "parameters": {"lookback_period": 20, "entry_threshold": 2.0}, }, } ) - assert cfg.strategy.parameters == {"lookback_period": 20, "entry_zscore": 2.0} + assert cfg.strategy.parameters == {"lookback_period": 20, "entry_threshold": 2.0} def test_var_keyword_catch_all_does_not_admit_bogus_parameters() -> None: @@ -194,8 +194,8 @@ def test_time_series_momentum_unknown_signal_scaling_rejected() -> None: def test_int_accepted_for_float_strategy_parameter() -> None: - """`entry_zscore: 2` (an int) is semantically identical to `2.0` and - must not be rejected just because the annotation says `float`.""" + """`entry_threshold: 2` (an int) is semantically identical to `2.0` + and must not be rejected just because the annotation says `float`.""" cfg = ExperimentConfig.from_dict( { "experiment_name": "x", @@ -208,21 +208,29 @@ def test_int_accepted_for_float_strategy_parameter() -> None: }, "strategy": { "name": "mean_reversion", - "parameters": {"entry_zscore": 2, "exit_zscore": 0.5}, + "parameters": {"entry_threshold": 2, "exit_threshold": 0.5}, }, } ) - assert cfg.strategy.parameters["entry_zscore"] == 2 + assert cfg.strategy.parameters["entry_threshold"] == 2 -def test_any_annotated_custom_strategy_parameter_does_not_crash() -> None: - +def test_any_annotated_custom_strategy_parameter_does_not_crash( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import quantlab.strategies.base as strategy_base from quantlab.strategies.base import ( BaseStrategy, register_strategy, validate_strategy_parameters, ) + # Registering a strategy mutates the module-level global registry -- + # isolate it to this test (matching test_strategies_hardening.py's own + # pattern) so `any_param_test` never leaks into another test's own + # `available_strategies()`/`available_profiles()` check. + monkeypatch.setattr(strategy_base, "_REGISTRY", dict(strategy_base._REGISTRY)) + @register_strategy("any_param_test") class _AnyParamStrategy(BaseStrategy): def __init__(self, threshold: Any = None) -> None: @@ -321,7 +329,7 @@ def test_mean_reversion_stop_below_entry_rejected() -> None: with pytest.raises(InvalidConfigurationError): _try_strategy( "mean_reversion", - {"entry_zscore": 2.0, "exit_zscore": 0.5, "stop_zscore": 1.0}, + {"entry_threshold": 2.0, "exit_threshold": 0.5, "stop_threshold": 1.0}, ) @@ -334,8 +342,8 @@ def test_pairs_trading_entry_below_exit_rejected() -> None: { "symbol_a": "AAA", "symbol_b": "BBB", - "entry_zscore": 0.5, - "exit_zscore": 2.0, + "entry_threshold": 0.5, + "exit_threshold": 2.0, }, ) @@ -347,9 +355,9 @@ def test_pairs_trading_stop_below_entry_rejected() -> None: { "symbol_a": "AAA", "symbol_b": "BBB", - "entry_zscore": 2.0, - "exit_zscore": 0.5, - "stop_zscore": 1.0, + "entry_threshold": 2.0, + "exit_threshold": 0.5, + "stop_threshold": 1.0, }, ) @@ -362,28 +370,30 @@ def test_pairs_trading_zero_formation_window_rejected() -> None: ) -def test_pairs_trading_zero_zscore_window_rejected() -> None: +def test_pairs_trading_zero_indicator_window_rejected() -> None: with pytest.raises(InvalidConfigurationError): _try_strategy( "pairs_trading", - {"symbol_a": "AAA", "symbol_b": "BBB", "zscore_window": 0}, + {"symbol_a": "AAA", "symbol_b": "BBB", "indicator_window": 0}, ) -def test_mean_reversion_negative_zscore_rejected() -> None: +def test_mean_reversion_negative_threshold_rejected() -> None: with pytest.raises(InvalidConfigurationError): - _try_strategy("mean_reversion", {"entry_zscore": -1.0, "exit_zscore": -2.0}) + _try_strategy( + "mean_reversion", {"entry_threshold": -1.0, "exit_threshold": -2.0} + ) -def test_pairs_trading_negative_zscore_rejected() -> None: +def test_pairs_trading_negative_threshold_rejected() -> None: with pytest.raises(InvalidConfigurationError): _try_strategy( "pairs_trading", { "symbol_a": "AAA", "symbol_b": "BBB", - "entry_zscore": -1.0, - "exit_zscore": -2.0, + "entry_threshold": -1.0, + "exit_threshold": -2.0, }, ) @@ -420,11 +430,34 @@ def test_time_series_momentum_continuous_scaling_small_lookback_works() -> None: assert signals.shape == (100, 1) -def test_cross_sectional_momentum_non_binary_signal_scaling_rejected() -> None: +def test_cross_sectional_momentum_continuous_signal_scaling_accepted() -> None: + config = _try_strategy( + "cross_sectional_momentum", + {"signal_scaling": "continuous"}, + portfolio={"allocator": "signal_proportional"}, + ) + assert config.strategy.parameters["signal_scaling"] == "continuous" + + +def test_cross_sectional_momentum_continuous_scaling_rejects_equal_weight() -> None: + """Mirrors time_series_momentum's identical guard: equal_weight discards + signal magnitude (np.sign only), silently degenerating continuous + scaling to binary -- must be rejected at config load, not silently + accepted.""" with pytest.raises(InvalidConfigurationError): _try_strategy("cross_sectional_momentum", {"signal_scaling": "continuous"}) +def test_cross_sectional_momentum_volatility_adjusted_signal_scaling_rejected() -> None: + """`volatility_adjusted` is a real signal_scaling value, but only for + `time_series_momentum` -- cross_sectional_momentum only accepts + 'binary'/'continuous'.""" + with pytest.raises(InvalidConfigurationError): + _try_strategy( + "cross_sectional_momentum", {"signal_scaling": "volatility_adjusted"} + ) + + def test_time_series_momentum_continuous_scaling_needs_lookback_ge_2() -> None: with pytest.raises(InvalidConfigurationError): _try_strategy( @@ -450,19 +483,24 @@ def test_cross_sectional_momentum_zero_top_fraction_accepted_for_short_only() -> assert config.strategy.parameters["top_fraction"] == 0.0 -def test_mean_reversion_zero_entry_zscore_rejected_with_clear_message() -> None: - with pytest.raises(InvalidConfigurationError, match=r"entry_zscore must be > 0\.0"): - _try_strategy("mean_reversion", {"entry_zscore": 0.0, "exit_zscore": -0.5}) +def test_mean_reversion_zero_entry_threshold_rejected_with_clear_message() -> None: + with pytest.raises( + InvalidConfigurationError, match=r"entry_threshold must be > 0\.0" + ): + _try_strategy( + "mean_reversion", {"entry_threshold": 0.0, "exit_threshold": -0.5} + ) -def test_pairs_trading_zero_entry_zscore_rejected_with_clear_message() -> None: - """See `test_mean_reversion_zero_entry_zscore_rejected_with_clear_message` - — `pairs_trading` walks the same z-score state machine and must give - the same direct error naming `entry_zscore`.""" - with pytest.raises(InvalidConfigurationError, match=r"entry_zscore must be > 0\.0"): +def test_pairs_trading_zero_entry_threshold_rejected_with_clear_message() -> None: + """`pairs_trading` walks the same state machine as mean_reversion and + must give the same direct error naming `entry_threshold`.""" + with pytest.raises( + InvalidConfigurationError, match=r"entry_threshold must be > 0\.0" + ): _try_strategy( "pairs_trading", - {"symbol_a": "AAA", "symbol_b": "BBB", "entry_zscore": 0.0}, + {"symbol_a": "AAA", "symbol_b": "BBB", "entry_threshold": 0.0}, ) diff --git a/tests/unit/test_regression_validation.py b/tests/unit/test_regression_validation.py index 0027a3b..42d9a88 100644 --- a/tests/unit/test_regression_validation.py +++ b/tests/unit/test_regression_validation.py @@ -321,6 +321,44 @@ def test_walk_forward_oos_result_reports_config_yaml_reflects_everything() -> No assert "reproducible from config.yaml given the same code" in html +def test_walk_forward_trade_log_has_no_reason_attribution() -> None: + """The stitched out-of-sample trade log has no trigger/adjustment/ + position_strategy_origin provenance -- not a crash, not a fabricated + value, and not the `unknown` safety-net code either (that is reserved + for the *active* attribution path failing to identify a real cause, + never for an attribution path that was never run). Each fold reruns + the pipeline independently with its own warmup/fit; the diagnostic + frames a single engine run keeps for attribution do not survive the + cut/restitch across folds, so `build_trade_log` is called here without + any of the provenance kwargs (see the comment at that call site in + `walk_forward.py`), leaving the reason columns `None`/`NaT` + everywhere -- a real architectural fact about walk-forward, not a + negligence bug.""" + from quantlab.validation.walk_forward import WalkForwardValidator + + data, cfg = _rf_test_setup() + wf = WalkForwardValidator(cfg).run( + data, parameter_grid={}, train_window=200, validation_window=50, test_window=50 + ) + assert wf.oos_result is not None + trades = wf.oos_result.trades + assert len(trades) > 0 # sanity: the fixture must actually produce trades to check + + reason_columns = [ + "trigger_reason_code", + "trigger_reason_detail_code", + "trigger_reason_details", + "adjustment_reason_codes", + "adjustment_reason_details", + "position_strategy_origin_code", + "position_strategy_origin_details", + ] + for column in reason_columns: + assert trades[column].isna().all(), column + assert trades["position_strategy_origin_timestamp"].isna().all() + assert (trades["trigger_reason_code"] == "unknown").sum() == 0 + + def test_holdout_test_ratio_without_validation_ratio_does_not_crash() -> None: from quantlab.backtesting.runner import run_backtest_from_config @@ -761,7 +799,177 @@ def test_walk_forward_oos_metrics_use_configured_risk_free_rate() -> None: assert with_rf["sharpe_ratio"] != pytest.approx(without_rf["sharpe_ratio"]) +def test_walk_forward_step_defaults_to_test_window() -> None: + """Omitting ``step`` must reproduce the original non-overlapping-folds + behaviour exactly: the same fold count/dates as an explicit + ``step=test_window``, and the metadata must record that resolved value.""" + from quantlab.validation.walk_forward import WalkForwardValidator + + data, cfg = _rf_test_setup() + default_run = WalkForwardValidator(cfg).run( + data, parameter_grid={}, train_window=150, validation_window=30, test_window=40 + ) + explicit_run = WalkForwardValidator(cfg).run( + data, + parameter_grid={}, + train_window=150, + validation_window=30, + test_window=40, + step=40, + ) + assert len(default_run.folds) == len(explicit_run.folds) + assert default_run.oos_returns.equals(explicit_run.oos_returns) + assert default_run.oos_result is not None + assert default_run.oos_result.metadata["walk_forward_windows"]["step"] == 40 + + +def test_walk_forward_step_smaller_than_test_window_overlaps_folds() -> None: + """A step smaller than test_window must produce MORE folds than the + default (overlapping test blocks) and must not raise the "test blocks + overlap" error a genuine bug would trigger -- the stitched OOS series + must still come out with a unique, sorted date index.""" + from quantlab.config import RebalanceFrequency + from quantlab.validation.walk_forward import WalkForwardValidator + + data, cfg = _rf_test_setup() + # Daily rebalancing so each fold's first execution date is distinct even + # with heavily overlapping test blocks -- with the default monthly + # cadence, a step this much smaller than test_window can make two folds' + # test blocks share the same first rebalance date, which is a distinct, + # separately covered failure mode (see + # test_walk_forward_folds_are_rejected_when_execution_dates_collide). + cfg = cfg.revalidated_copy( + update={ + "portfolio": cfg.portfolio.revalidated_copy( + update={"rebalance_frequency": RebalanceFrequency.DAILY} + ) + } + ) + default_run = WalkForwardValidator(cfg).run( + data, parameter_grid={}, train_window=150, validation_window=30, test_window=40 + ) + overlapping_run = WalkForwardValidator(cfg).run( + data, + parameter_grid={}, + train_window=150, + validation_window=30, + test_window=40, + step=20, + ) + assert len(overlapping_run.folds) > len(default_run.folds) + assert overlapping_run.oos_returns.index.is_unique + assert overlapping_run.oos_returns.index.is_monotonic_increasing + + +def test_walk_forward_folds_are_rejected_when_execution_dates_collide() -> None: + """A step small enough to overlap test blocks, combined with rebalancing + too infrequent to distinguish them, can make two folds resolve to the + SAME first execution date -- silently attributing zero (or the wrong) + observations to one of them via FoldResult.test_returns slicing. This + must be rejected outright rather than reported silently.""" + from quantlab.exceptions import InvalidConfigurationError + from quantlab.validation.walk_forward import WalkForwardValidator + + data, cfg = _rf_test_setup() + with pytest.raises(InvalidConfigurationError, match="execution date"): + WalkForwardValidator(cfg).run( + data, + parameter_grid={}, + train_window=150, + validation_window=30, + test_window=40, + step=20, + ) + + +def test_walk_forward_step_larger_than_test_window_is_rejected() -> None: + """A step larger than test_window would skip observations between folds, + leaving the stitched OOS curve with gaps that CAGR/annualisation (which + assume regularly spaced observations) cannot account for -- rejected + outright rather than silently misreporting elapsed time.""" + from quantlab.exceptions import InvalidConfigurationError + from quantlab.validation.walk_forward import WalkForwardValidator + + data, cfg = _rf_test_setup() + with pytest.raises(InvalidConfigurationError, match="step"): + WalkForwardValidator(cfg).run( + data, + parameter_grid={}, + train_window=150, + validation_window=30, + test_window=40, + step=80, + ) + + +def test_walk_forward_overlapping_step_keeps_the_latest_folds_target() -> None: + """On a date shared by two overlapping folds' test blocks, the stitched + series must keep the LATER fold's own target -- verified directly on + ``_finalize()``'s own dedup step with two fabricated, deliberately + conflicting target pieces, sidestepping any dependency on a real + strategy/allocator pipeline actually producing distinguishable values + for two overlapping folds (not guaranteed for every strategy).""" + from quantlab.config import RebalanceFrequency + from quantlab.validation.splits import WalkForwardWindow + from quantlab.validation.walk_forward import WalkForwardValidator + + data, cfg = _rf_test_setup() + # Daily rebalancing so every short, synthetic fold test-window below + # has at least one rebalance date -- irrelevant to what this test + # actually verifies (the dedup step), just a precondition _finalize() + # enforces. + cfg = cfg.revalidated_copy( + update={ + "portfolio": cfg.portfolio.revalidated_copy( + update={"rebalance_frequency": RebalanceFrequency.DAILY} + ) + } + ) + validator = WalkForwardValidator(cfg) + idx = pd.bdate_range("2020-06-01", periods=10) + shared_dates = idx[3:7] + piece_a = pd.DataFrame(0.1, index=idx[0:7], columns=["AAA", "BBB"]) + piece_b = pd.DataFrame(0.9, index=idx[3:10], columns=["AAA", "BBB"]) + fold_a = WalkForwardWindow(fold=0, train=idx[:1], validation=idx[:1], test=idx[0:7]) + fold_b = WalkForwardWindow( + fold=1, train=idx[:1], validation=idx[:1], test=idx[3:10] + ) + + stitched = validator._finalize( + [fold_a, fold_b], + [{}, {}], + [1.0, 1.0], + [piece_a, piece_b], + data[data["symbol"].isin({"AAA", "BBB"})], + data, + cfg, + 252, + 0.0, + 0, + {}, + 7, + 1, + 7, + 4, # step=4 < test_window=7 -> overlap, dedup path + True, + 0.0, + ) + assert stitched.oos_result is not None + assert stitched.oos_result.target_weights is not None + for date in shared_dates: + target = stitched.oos_result.target_weights.at[date, "AAA"] + assert target == pytest.approx(0.9) + + def test_walk_forward_charges_entry_cost_at_the_first_fold_start() -> None: + """This test is about the OOS-stitching mechanism, not weight drift: it + checks that per-fold reporting doesn't spuriously double-charge an + entry cost at a fold boundary the position was actually carried + through. `model_weight_drift` is pinned `False` here so a genuine, + correct periodic rebalance-driven cost (buy_and_hold's constant + target snapping back from organic price drift on a scheduled + rebalance -- see test_weight_drift.py) can't be mistaken for a + stitching bug.""" from quantlab.validation.walk_forward import WalkForwardValidator def run_with_commission(commission_bps: float) -> list[pd.Series]: @@ -774,7 +982,10 @@ def run_with_commission(commission_bps: float) -> list[pd.Series]: "spread_bps": 0.0, "slippage_bps": 0.0, } - ) + ), + "portfolio": cfg.portfolio.revalidated_copy( + update={"model_weight_drift": False} + ), } ) wf = WalkForwardValidator(cfg).run( @@ -953,23 +1164,19 @@ def fake_weights_on_test( monkeypatch.setattr(WalkForwardValidator, "_weights_on_test", fake_weights_on_test) - import quantlab.validation.walk_forward as wf_mod - - captured: dict[str, pd.DataFrame] = {} - orig_run_accounting = wf_mod.run_accounting - - def spy_run_accounting(all_weights: pd.DataFrame, *args: Any, **kwargs: Any) -> Any: - captured["weights"] = all_weights.copy() - return orig_run_accounting(all_weights, *args, **kwargs) - - monkeypatch.setattr(wf_mod, "run_accounting", spy_run_accounting) - - validator.run( + result = validator.run( data, parameter_grid={}, train_window=100, validation_window=20, test_window=5 ) - weights = captured["weights"] - turnover = (weights - weights.shift(1).fillna(0.0)).abs().sum(axis=1) + # With `model_weight_drift` at its default (True), `apply_weight_drift` + # (inside `run_accounting`) is the SOLE place `maximum_turnover` is + # enforced -- the decision-level `all_weights` handed to accounting is + # deliberately left uncapped (see `decision_portfolio_config` in + # walk_forward.py's own OOS-stitching call site), so the cap must be + # checked on the final realised turnover, not intercepted upstream. + assert result.oos_result is not None + turnover = result.oos_result.turnover + assert turnover is not None assert turnover.max() == pytest.approx(0.1, abs=1e-6), ( f"maximum_turnover=0.1 must bound every rebalance, including across " f"a fold boundary; observed max realised turnover {turnover.max()}" @@ -1456,3 +1663,105 @@ def test_notebook_walk_forward_cell_passes_risk_free_rate() -> None: assert oos_metrics_cells, "expected a wf.oos_metrics(...) cell in the notebook" for code in oos_metrics_cells: assert "wf.oos_metrics(config.periods_per_year, config.risk_free_rate)" in code + + +def test_evaluate_window_never_double_applies_execution_delay_to_rebalance_date() -> ( + None +): + """`window_weights` (from `_weights_for_window`, via + `run_backtest_from_config(..., execution_delay=execution_delay)`) is + itself already `execution_delay`-shifted -- it IS + `BacktestResult.weights`. Passing `execution_delay` a second time into + `_rebalance_date_for_run_accounting` would shift the rebalance-date + flag an EXTRA `execution_delay` rows past where `window_weights` + itself already sits, misaligning candidate-scoring's schedule-anchor + detection from the actual execution model it's supposed to score. + `rebalance_date` must be built with `delay=0`, matching the sibling + call site in this module's own candidate-scoring loop.""" + from tests.regression_helpers import _rf_test_setup + + import quantlab.validation.walk_forward as wf_mod + + data, cfg = _rf_test_setup() + cfg = cfg.revalidated_copy( + update={ + "portfolio": cfg.portfolio.revalidated_copy( + update={"rebalance_frequency": "daily"} + ) + } + ) + lookback_start = pd.Timestamp("2020-01-01") + window_start = pd.Timestamp("2020-03-01") + window_end = pd.Timestamp("2020-06-01") + + captured: dict[str, pd.DataFrame] = {} + orig_run_accounting = wf_mod.run_accounting + + def spy_run_accounting(*args: Any, **kwargs: Any) -> Any: + captured["rebalance_date"] = kwargs["rebalance_date"].copy() + captured["tradable"] = kwargs["tradable"] + return orig_run_accounting(*args, **kwargs) + + wf_mod.run_accounting = spy_run_accounting + try: + window_weights, _ = wf_mod._weights_and_returns_for_validation( + data, cfg, lookback_start, window_start, window_end, execution_delay=2 + ) + finally: + wf_mod.run_accounting = orig_run_accounting + + expected = wf_mod._rebalance_date_for_run_accounting( + window_weights, + cfg.portfolio.rebalance_frequency, + None, + captured["tradable"], + 0, + ) + double_delayed = wf_mod._rebalance_date_for_run_accounting( + window_weights, + cfg.portfolio.rebalance_frequency, + None, + captured["tradable"], + 2, + ) + # Sanity: the two would genuinely differ, so this test is not vacuous. + assert not expected.equals(double_delayed) + pd.testing.assert_frame_equal(captured["rebalance_date"], expected) + + +def test_rebalance_date_for_run_accounting_never_true_on_a_closed_row() -> None: + """Regression test: `compute_executed_weights` is built for *weights*, + where a closed row correctly repeats the last tradable row's frozen + value. `_rebalance_date_for_run_accounting` reused it to align a + boolean flag -- applied to a flag, that same repetition kept it True + for every row a column stayed closed right after a landing, which + `apply_weight_drift`'s own documented precondition explicitly forbids + (it re-anchors ordinary debt to a stale target and fires an + unscheduled trade the moment the column reopens). A `daily` schedule + flags every row True, so a landing on the last tradable row before a + closure is guaranteed, not scenario-dependent.""" + from quantlab.validation.walk_forward import _rebalance_date_for_run_accounting + + dates = pd.date_range("2024-01-01", periods=8, freq="D") + decision_weights = pd.DataFrame({"A": [0.5] * 8, "B": [0.5] * 8}, index=dates) + tradable = pd.DataFrame( + { + "A": [True, True, True, False, False, True, True, True], + "B": [True] * 8, + }, + index=dates, + ) + + result = _rebalance_date_for_run_accounting( + decision_weights, "daily", None, tradable, 0 + ) + + violation = result & ~tradable + assert not violation.to_numpy().any(), ( + f"rebalance_date is True on a closed row: {violation[violation.any(axis=1)]}" + ) + # Sanity: the closure itself is genuinely exercised, not vacuously + # passing because A never lands True around it. + assert result.loc[dates[2], "A"] # lands True right before the closure + assert not result.loc[dates[3], "A"] + assert not result.loc[dates[4], "A"] diff --git a/tests/unit/test_reporting_hardening.py b/tests/unit/test_reporting_hardening.py index 03f6be0..de60c8e 100644 --- a/tests/unit/test_reporting_hardening.py +++ b/tests/unit/test_reporting_hardening.py @@ -16,6 +16,8 @@ from quantlab.config import ExperimentConfig from quantlab.reporting import charts from quantlab.reporting.html_report import ( + _format_cell, + _format_report_table, _render_data_quality, _render_robustness, ) @@ -24,7 +26,7 @@ data_description, methodology, ) -from quantlab.reporting.tables import _fmt, regime_table +from quantlab.reporting.tables import _fmt, format_bootstrap_summary, regime_table def _config( @@ -74,6 +76,37 @@ def test_metric_formatter_handles_numpy_non_finite_values() -> None: assert _fmt(np.float32(np.nan), "int") == "n/a" +def test_format_bootstrap_summary_avoids_scientific_notation() -> None: + """Each row is formatted by its own statistic's kind (not its column's + shared repr) so wildly different magnitudes in the same column -- + final_value near 1e5 next to cagr near 5e-2 -- never push pandas' + default float repr into scientific notation for either one.""" + summary = pd.DataFrame( + { + "statistic": ["cagr", "sharpe", "max_drawdown", "final_value"], + "median": [0.026741, 0.244565, -0.191660, 105375.654092], + "p_lower": [-0.113499, -0.700071, -0.343333, 78738.751320], + "p_upper": [0.239792, 1.404149, -0.110733, 153184.828396], + "mean": [0.047455, 0.335996, -0.204574, 110866.493909], + "std": [0.112626, 0.657864, 0.070470, 24125.201431], + } + ) + formatted = format_bootstrap_summary(summary) + assert list(formatted["statistic"]) == [ + "CAGR", + "Sharpe", + "Max Drawdown", + "Final Value", + ] + rendered = formatted.to_string(index=False).lower() + assert "e+" not in rendered + assert "e-" not in rendered + assert formatted.loc[0, "median"] == "2.67%" + assert formatted.loc[1, "median"] == "0.24" + assert formatted.loc[2, "median"] == "-19.17%" + assert formatted.loc[3, "median"] == "105,375.65" + + def test_regime_table_excludes_undefined_warmup_and_empty_regimes() -> None: short_index = pd.date_range("2024-01-01", periods=4, freq="D") config = SimpleNamespace( @@ -146,6 +179,40 @@ def test_robustness_tables_format_percentage_columns() -> None: assert "0.42" in rendered +def test_format_cell_shows_a_plain_bool_instead_of_n_a() -> None: + """Regression test: `bool` is a subclass of `int` (a `numbers.Real`), + so a plain `True`/`False` value in a GENERIC (non percent/number/ + integer) column used to be misclassified as "a Real number that isn't + finite" and rendered as "n/a" -- e.g. a strategy diagnostics table's + `("Long/short", False)` row would show "n/a" instead of `False`, + misrepresenting a real, meaningful configuration value as missing + data. A bool inside an ACTUAL designated numeric column (where it + would be nonsensical, e.g. formatted as "0.00") must still fall back + to "n/a" -- that specific exclusion is deliberate, not a bug.""" + assert _format_cell(False, "Value") is False + assert _format_cell(True, "Value") is True + # Genuine non-finite reals must still show "n/a", in any column. + assert _format_cell(float("nan"), "Value") == "n/a" + assert _format_cell(float("inf"), "Value") == "n/a" + assert _format_cell(None, "Value") == "n/a" + # A bool landing in an ACTUAL numeric-formatted column is still "n/a". + assert _format_cell(True, "return") == "n/a" + assert _format_cell(False, "trades") == "n/a" + + +def test_diagnostics_table_shows_a_bool_metric_correctly() -> None: + """End-to-end: a strategy diagnostics table (e.g. + cross_sectional_momentum's `("Long/short", False)` row) must render + the real boolean value, not "n/a".""" + table = pd.DataFrame( + [("Long/short", False), ("Mean rank correlation", 0.123)], + columns=["Metric", "Value"], + ) + formatted = _format_report_table(table) + assert formatted.loc[0, "Value"] is False + assert "n/a" not in formatted["Value"].astype(str).tolist() + + def _sensitivity_frame() -> pd.DataFrame: return pd.DataFrame( { @@ -201,6 +268,85 @@ def test_render_robustness_sensitivity_heatmap_failure_does_not_crash_report() - assert "60" in rendered # the raw table must still render +def test_render_strategy_diagnostics_dispatches_by_type() -> None: + """`render_html_report` filters any `DiagnosticsSection` value out of the + merged robustness dict, dispatched by isinstance -- never by a + hard-coded key name -- and renders it via `_render_strategy_diagnostics` + into a section separate from "Robustness" (see + `test_diagnostics_section_appears_outside_robustness_section` below): a + correlation/spread/ADF diagnostic describes whether the STRATEGY's + assumptions hold, not whether the RESULT is robust to perturbation.""" + from quantlab.reporting.html_report import _render_strategy_diagnostics + from quantlab.reporting.sections import DiagnosticsSection + + section = DiagnosticsSection( + table=pd.DataFrame({"Metric": ["Correlation"], "Value": ["0.83"]}), + chart_data_uri="data:image/png;base64,AAAA", + note="Pair relationship diagnostics.", + ) + rendered = _render_strategy_diagnostics({"pair_diagnostics": section}) + assert "Pair Diagnostics" in rendered + assert "Pair relationship diagnostics." in rendered + assert '' in rendered + assert "Correlation" in rendered + + +def test_render_strategy_diagnostics_omits_missing_chart_and_note() -> None: + """`note`/`chart_data_uri` are optional -- absent, they render nothing, + never an empty `

    `/`` tag.""" + from quantlab.reporting.html_report import _render_strategy_diagnostics + from quantlab.reporting.sections import DiagnosticsSection + + section = DiagnosticsSection(table=pd.DataFrame({"Metric": ["x"], "Value": ["1"]})) + rendered = _render_strategy_diagnostics({"pair_diagnostics": section}) + assert "" not in rendered + + +def test_render_robustness_never_receives_a_diagnostics_section() -> None: + """A `DiagnosticsSection` value passed to `_render_robustness` directly + (bypassing `render_html_report`'s own filtering) renders as a generic + unrecognised object rather than specially -- `_render_robustness` no + longer knows about this type at all; filtering it out is + `render_html_report`'s job, exercised by + `test_diagnostics_section_appears_outside_robustness_section` below.""" + from quantlab.reporting.sections import DiagnosticsSection + + section = DiagnosticsSection(table=pd.DataFrame({"Metric": ["x"], "Value": ["1"]})) + rendered = _render_robustness({"pair_diagnostics": section}) + assert "Correlation" not in rendered + assert " None: + """`render_html_report` places a `DiagnosticsSection`-typed robustness + entry under its own "Strategy diagnostics" heading, before + "Robustness", and never inside the Robustness section's own rendered + HTML -- a strategy diagnostic must never be misclassified as + robustness evidence.""" + from quantlab.reporting.html_report import render_html_report + from quantlab.reporting.sections import DiagnosticsSection + + result = _result() + section = DiagnosticsSection( + table=pd.DataFrame({"Metric": ["Correlation"], "Value": ["0.83"]}), + note="Pair relationship diagnostics: correlation, hedge ratio.", + ) + document = render_html_report( + result, + robustness={"pair_diagnostics": section}, + figures={}, + ) + diagnostics_index = document.index("Strategy diagnostics") + robustness_index = document.index("

    Robustness

    ") + assert diagnostics_index < robustness_index + robustness_section = document[ + robustness_index : document.index("Limitations", robustness_index) + ] + assert "Correlation" not in robustness_section + assert "pair relationship diagnostics" not in robustness_section.lower() + + def test_methodology_describes_volume_slippage_and_constraints() -> None: result: Any = SimpleNamespace( config=_config(volume_slippage=True), @@ -211,7 +357,7 @@ def test_methodology_describes_volume_slippage_and_constraints() -> None: assert "impact coefficient 0.2000" in text assert "maximum absolute weight 80.00%" in text assert "annual volatility target 10.00%" in text - assert "maximum L1 turnover per rebalance 0.30" in text + assert "maximum L1 turnover per period 0.30" in text def test_data_description_separates_requested_and_observed_periods() -> None: diff --git a/tests/unit/test_shared_components.py b/tests/unit/test_shared_components.py new file mode 100644 index 0000000..4b69e95 --- /dev/null +++ b/tests/unit/test_shared_components.py @@ -0,0 +1,129 @@ +"""Direct tests for `quantlab.dashboard.explorer.shared_components`.""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +import pandas as pd +import pytest + +from quantlab.dashboard.explorer.shared_components import ( + render_stop_loss_take_profit_illustration, +) + + +class _FakeColumn: + def __init__(self, value: float) -> None: + self._value = value + + def slider(self, *args: object, **kwargs: object) -> float: + return self._value + + +class _FakeStreamlit: + def __init__( + self, stop_loss_pct: float = 0.1, take_profit_pct: float = 0.0 + ) -> None: + self._stop_loss_pct = stop_loss_pct + self._take_profit_pct = take_profit_pct + self.warnings: list[str] = [] + + def markdown(self, *args: object, **kwargs: object) -> None: + pass + + def caption(self, *args: object, **kwargs: object) -> None: + pass + + def columns(self, n: int) -> list[_FakeColumn]: + return [_FakeColumn(self._stop_loss_pct), _FakeColumn(self._take_profit_pct)] + + def warning(self, message: str, **kwargs: object) -> None: + self.warnings.append(message) + + def dataframe(self, *args: object, **kwargs: object) -> None: + pass + + +def _patch_render_price_chart(monkeypatch: pytest.MonkeyPatch) -> None: + """Stub out chart rendering -- these tests only care about the + gap-detection/warning logic, not Plotly figure construction.""" + import quantlab.dashboard.explorer.shared_components as shared_components + + monkeypatch.setattr( + shared_components, + "render_price_chart", + lambda *args, **kwargs: None, + ) + + +def test_internal_price_gap_while_held_triggers_a_warning( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression test: `pct_change().fillna(0.0)` used to silently turn a + genuine internal missing price into a flat 0% return, which could hide + a real stop-loss/take-profit trigger. A NaN price mid-series while the + position is nonzero must be reported via `st.warning`, naming the + affected date(s).""" + _patch_render_price_chart(monkeypatch) + idx = pd.date_range("2024-01-01", periods=6, freq="D") + prices = pd.Series([100.0, 101.0, np.nan, 103.0, 104.0, 105.0], index=idx) + positions = pd.Series([0.0, 0.5, 0.5, 0.5, 0.5, 0.5], index=idx) + + st: Any = _FakeStreamlit(stop_loss_pct=0.1) + render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x") + + assert len(st.warnings) == 1 + assert "asset" in st.warnings[0] + assert "2024-01-03" in st.warnings[0] # the NaN price itself + assert "2024-01-04" in st.warnings[0] # pct_change's own next-day NaN + + +def test_no_warning_when_the_gap_coincides_with_a_flat_position( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A missing price while the position is flat (0) carries no risk of + hiding a stop-loss/take-profit trigger -- must not warn.""" + _patch_render_price_chart(monkeypatch) + idx = pd.date_range("2024-01-01", periods=6, freq="D") + prices = pd.Series([100.0, 101.0, np.nan, 103.0, 104.0, 105.0], index=idx) + positions = pd.Series([0.0, 0.0, 0.0, 0.0, 0.5, 0.5], index=idx) + + st: Any = _FakeStreamlit(stop_loss_pct=0.1) + render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x") + + assert st.warnings == [] + + +def test_first_observation_is_zeroed_not_reported_as_a_gap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The very first observation has no prior price to compare against -- + a structural absence, not a genuine missing return -- and must be + silently zeroed even when the position is already nonzero there, + never reported as a warned gap.""" + _patch_render_price_chart(monkeypatch) + idx = pd.date_range("2024-01-01", periods=4, freq="D") + prices = pd.Series([100.0, 101.0, 102.0, 103.0], index=idx) + positions = pd.Series([0.5, 0.5, 0.5, 0.5], index=idx) + + st: Any = _FakeStreamlit(stop_loss_pct=0.1) + render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x") + + assert st.warnings == [] + + +def test_both_thresholds_disabled_returns_before_computing_returns_at_all( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Sanity check: with both thresholds at 0 (disabled), the function + returns early and never touches the gap-detection path.""" + _patch_render_price_chart(monkeypatch) + idx = pd.date_range("2024-01-01", periods=3, freq="D") + prices = pd.Series([100.0, np.nan, 102.0], index=idx) + positions = pd.Series([0.5, 0.5, 0.5], index=idx) + + st: Any = _FakeStreamlit(stop_loss_pct=0.0, take_profit_pct=0.0) + render_stop_loss_take_profit_illustration(st, positions, prices, key_prefix="x") + + assert st.warnings == [] diff --git a/tests/unit/test_strategies.py b/tests/unit/test_strategies.py index 5841778..15fabe7 100644 --- a/tests/unit/test_strategies.py +++ b/tests/unit/test_strategies.py @@ -12,16 +12,30 @@ import pytest from tests.conftest import make_ohlcv +from quantlab.exceptions import StrategyError +from quantlab.features.mean_reversion import rolling_zscore from quantlab.strategies import ( available_strategies, build_strategy, ) -from quantlab.strategies.mean_reversion import MeanReversionStrategy +from quantlab.strategies.base import BaseStrategy +from quantlab.strategies.buy_and_hold import BuyAndHoldStrategy +from quantlab.strategies.mean_reversion import ( + INDICATORS, + MeanReversionStrategy, + _walk_positions_with_reasons, +) from quantlab.strategies.momentum import ( CrossSectionalMomentumStrategy, TimeSeriesMomentumStrategy, ) -from quantlab.strategies.pairs_trading import PairsTradingStrategy, adf_pvalue +from quantlab.strategies.pairs_trading import ( + PairsTradingStrategy, + _walk_pairs_positions_with_reasons, + adf_pvalue, + rolling_hedge_parameters, +) +from quantlab.strategies.trend_following import TrendFollowingStrategy def _assert_contract(signals: pd.DataFrame) -> None: @@ -88,12 +102,99 @@ def test_cross_sectional_momentum_picks_winner(synthetic_panel: pd.DataFrame) -> assert last["BBB"] == 0.0 # weakest → not selected (long-only) +def test_cross_sectional_magnitude_is_monotone_within_each_selected_leg() -> None: + """Regression test: an earlier, cross-sectional-mean-centered version + of this function was NOT monotone in score within a selected leg -- + scores [0, 1, 2] all selected as one long leg standardized to + magnitudes [1, 0, 1] (mean 1, std 1), zeroing out the MIDDLE score + while the best and worst tied at full weight. Rank-within-leg must + fix this: strictly increasing with score in the long leg, strictly + decreasing (more negative = higher magnitude) in the short leg, and + never exactly zero for a selected asset.""" + from quantlab.strategies.momentum import _cross_sectional_magnitude + + idx = pd.date_range("2024-01-01", periods=1) + score = pd.DataFrame({"A": [0.0], "B": [1.0], "C": [2.0]}, index=idx) + selection = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx) + magnitude = _cross_sectional_magnitude(score, selection) + row = magnitude.iloc[0] + assert row["A"] < row["B"] < row["C"] + assert (row > 0.0).all() + assert row["C"] == pytest.approx(1.0) + + # Mirrored on the short side: more negative score -> higher magnitude. + short_score = pd.DataFrame({"A": [-2.0], "B": [-1.0], "C": [-0.5]}, index=idx) + short_selection = pd.DataFrame({"A": [-1.0], "B": [-1.0], "C": [-1.0]}, index=idx) + short_magnitude = _cross_sectional_magnitude(short_score, short_selection) + short_row = short_magnitude.iloc[0] + assert short_row["A"] > short_row["B"] > short_row["C"] + assert (short_row > 0.0).all() + assert short_row["A"] == pytest.approx(1.0) + + # Unselected assets stay at exactly zero regardless of their score. + mixed_score = pd.DataFrame( + {"A": [5.0], "B": [1.0], "C": [-1.0], "D": [-5.0]}, index=idx + ) + mixed_selection = pd.DataFrame( + {"A": [1.0], "B": [0.0], "C": [0.0], "D": [-1.0]}, index=idx + ) + mixed_magnitude = _cross_sectional_magnitude(mixed_score, mixed_selection) + mixed_row = mixed_magnitude.iloc[0] + assert mixed_row["B"] == 0.0 + assert mixed_row["C"] == 0.0 + assert mixed_row["A"] == pytest.approx(1.0) + assert mixed_row["D"] == pytest.approx(1.0) + + +def test_cross_sectional_magnitude_is_invariant_to_column_permutation() -> None: + """Regression test: identical scores must get identical magnitudes + regardless of which column order they happen to be pivoted into -- + ``rank(method="first")`` broke ties by column position, an arbitrary, + non-economic artifact (e.g. two backtests over the same data loaded + with a differently-ordered universe declaration would silently size + tied positions differently). ``method="max"`` fixes this: every tied + score shares the same rank.""" + from quantlab.strategies.momentum import _cross_sectional_magnitude + + idx = pd.date_range("2024-01-01", periods=1) + score_abc = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx) + selection_abc = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx) + magnitude_abc = _cross_sectional_magnitude(score_abc, selection_abc) + # A fully tied leg must resolve to magnitude 1.0 for EVERY member, not + # a range spread across the tie group by column order. + assert magnitude_abc.iloc[0].to_dict() == {"A": 1.0, "B": 1.0, "C": 1.0} + + score_cab = score_abc[["C", "A", "B"]] + selection_cab = selection_abc[["C", "A", "B"]] + magnitude_cab = _cross_sectional_magnitude(score_cab, selection_cab) + assert magnitude_cab.iloc[0].to_dict() == {"C": 1.0, "A": 1.0, "B": 1.0} + + # A partial tie (two names share the best score) must also resolve + # identically for both, regardless of order, while staying monotone + # against the untied, lower-scored name. + partial_score = pd.DataFrame({"A": [0.0], "B": [2.0], "C": [2.0]}, index=idx) + partial_selection = pd.DataFrame({"A": [1.0], "B": [1.0], "C": [1.0]}, index=idx) + partial_magnitude = _cross_sectional_magnitude(partial_score, partial_selection) + partial_row = partial_magnitude.iloc[0] + assert partial_row["B"] == partial_row["C"] == pytest.approx(1.0) + assert partial_row["A"] < partial_row["B"] + + reordered_score = partial_score[["C", "A", "B"]] + reordered_selection = partial_selection[["C", "A", "B"]] + reordered_magnitude = _cross_sectional_magnitude( + reordered_score, reordered_selection + ) + assert reordered_magnitude.iloc[0]["B"] == pytest.approx(partial_row["B"]) + assert reordered_magnitude.iloc[0]["C"] == pytest.approx(partial_row["C"]) + assert reordered_magnitude.iloc[0]["A"] == pytest.approx(partial_row["A"]) + + def test_mean_reversion_goes_long_after_crash() -> None: # Flat then a sharp drop → z-score deeply negative → long entry. prices = np.concatenate([np.full(40, 100.0), np.linspace(100, 70, 10)]) data = make_ohlcv("AAA", prices) strat = MeanReversionStrategy( - lookback_period=20, entry_zscore=1.5, exit_zscore=0.5, long_only=True + lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True ) signals = strat.generate_signals(data) _assert_contract(signals) @@ -101,8 +202,319 @@ def test_mean_reversion_goes_long_after_crash() -> None: def test_mean_reversion_rejects_bad_thresholds() -> None: - with pytest.raises(ValueError, match="entry_zscore"): - MeanReversionStrategy(entry_zscore=0.5, exit_zscore=2.0) + """entry_threshold must exceed exit_threshold.""" + with pytest.raises(ValueError, match="entry_threshold"): + MeanReversionStrategy(entry_threshold=0.5, exit_threshold=2.0) + + +def test_mean_reversion_explicit_none_disables_stop_threshold() -> None: + """Passing stop_threshold=None explicitly must disable the stop + entirely -- NOT silently resolve to the indicator's own default, the + bug this sentinel-based design fixes.""" + strat = MeanReversionStrategy(lookback_period=20, stop_threshold=None) + assert strat.stop_threshold is None + + +def test_mean_reversion_omitted_stop_threshold_uses_indicator_default() -> None: + """Leaving stop_threshold out entirely must resolve to the chosen + indicator's own default -- distinct from an explicit None (see the + test above).""" + strat = MeanReversionStrategy(lookback_period=20, indicator="zscore") + assert strat.stop_threshold == 4.0 + + +@pytest.mark.parametrize("indicator", sorted(INDICATORS)) +def test_mean_reversion_every_indicator_produces_a_valid_signal(indicator: str) -> None: + """Every one of the five indicators must drive the SAME state machine + to a valid, actually-nonzero signal on a series constructed to deviate + sharply from its own recent history -- not just avoid raising. + + A 30-period monotonic decline (longer than the 25-period lookback) + ensures even `percentile`'s rank(pct=True) -- whose minimum is + exactly 1/N, never 0 -- comfortably clears its own default entry + threshold (percentile < 0.05, i.e. 1/25 = 0.04).""" + rng = np.random.default_rng(7) + prices = np.concatenate( + [100.0 + np.cumsum(rng.normal(0.0, 0.2, 60)), np.linspace(100.0, 40.0, 30)] + ) + data = make_ohlcv("AAA", prices) + strat = MeanReversionStrategy( + lookback_period=25, indicator=indicator, long_only=False + ) + signals = strat.generate_signals(data) + _assert_contract(signals) + assert (signals["AAA"] != 0.0).any() + + +def test_walk_positions_with_reasons_covers_every_branch() -> None: + """Direct test of the state machine's reason attribution -- one + z-score path deliberately visits every branch: oversold entry, + mean-reversion exit, overbought entry, stop-loss exit, a no-op NaN + (already flat, no reason recorded) and a NaN-driven forced exit.""" + z = np.array( + [ + 0.0, # flat, below threshold -> no transition + -2.5, # crosses -entry (-2.0) -> oversold_entry + -0.3, # crosses -exit_ (-0.5) -> mean_reversion_exit + 2.5, # crosses entry (2.0) -> overbought_entry + 5.0, # |z| > stop (4.0) -> stop_loss_exit + np.nan, # already flat -> no-op, no reason recorded + -2.5, # oversold_entry again + np.nan, # was long -> data_unavailable_exit + ] + ) + + positions, detail_code, details = _walk_positions_with_reasons( + z, entry=2.0, exit_=0.5, stop=4.0, long_only=False + ) + + assert positions.tolist() == [0.0, 1.0, 0.0, -1.0, 0.0, 0.0, 1.0, 0.0] + assert detail_code.tolist() == [ + None, + "oversold_entry", + "mean_reversion_exit", + "overbought_entry", + "stop_loss_exit", + None, + "oversold_entry", + "data_unavailable_exit", + ] + assert details[1] is not None + assert "entry threshold -2.0000" in details[1] + assert details[2] is not None + assert "exit threshold -0.5000" in details[2] + assert details[3] is not None + assert "entry threshold 2.0000" in details[3] + assert details[4] is not None + assert "stop threshold 4.0000" in details[4] + assert details[7] is not None + assert "unavailable" in details[7] + + +def test_walk_positions_with_reasons_long_only_suppresses_short_entry() -> None: + """long_only=True must never record overbought_entry -- the branch is + unreachable, matching generate_signals' own long_only gate.""" + z = np.array([0.0, 2.5]) + + positions, detail_code, _ = _walk_positions_with_reasons( + z, entry=2.0, exit_=0.5, stop=None, long_only=True + ) + + assert positions.tolist() == [0.0, 0.0] + assert detail_code.tolist() == [None, None] + + +def test_mean_reversion_explain_signals_matches_generate_signals_transitions() -> None: + """Every date generate_signals() actually changes AAA's position must + have a non-None reason, and vice versa -- explain_signals() must + never invent a reason for a date nothing happened, nor omit one where + something did.""" + prices = np.concatenate([np.full(40, 100.0), np.linspace(100, 70, 10)]) + data = make_ohlcv("AAA", prices) + strat = MeanReversionStrategy( + lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True + ) + + signals = strat.generate_signals(data) + reasons = strat.explain_signals(data) + + assert reasons.detail_code.index.equals(signals.index) + assert reasons.detail_code.columns.equals(signals.columns) + assert reasons.details.index.equals(signals.index) + assert reasons.details.columns.equals(signals.columns) + + values = signals["AAA"].to_numpy() + previous = np.concatenate([[0.0], values[:-1]]) + changed = np.abs(values - previous) > 1e-12 + has_reason = reasons.detail_code["AAA"].notna().to_numpy() + assert (changed == has_reason).all() + # Exactly one transition (flat -> long) drives this whole crash + # scenario -- the FIRST row generate_signals() goes to 1.0 must read + # as oversold_entry, not some other branch. + entry_row = int(np.flatnonzero(changed)[0]) + assert reasons.detail_code["AAA"].iloc[entry_row] == "oversold_entry" + + +def test_mean_reversion_explain_signals_does_not_affect_generate_signals() -> None: + """explain_signals() is a pure, independent recomputation -- calling + it must not change what generate_signals() itself returns.""" + prices = np.concatenate([np.full(40, 100.0), np.linspace(100, 70, 10)]) + data = make_ohlcv("AAA", prices) + strat = MeanReversionStrategy( + lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True + ) + + before = strat.generate_signals(data) + strat.explain_signals(data) + after = strat.generate_signals(data) + + pd.testing.assert_frame_equal(before, after) + + +def test_buy_and_hold_explain_signals_matches_generate_signals_transitions() -> None: + """A symbol whose price starts partway through the window (a + staggered listing date) must read as price_became_available exactly + on its first valid row -- the only thing this strategy's signal can + ever depend on -- and every transition generate_signals() actually + makes must have a matching non-None reason, and vice versa.""" + data_a = make_ohlcv("AAA", np.full(20, 100.0), start="2020-01-01") + data_b = make_ohlcv("BBB", np.full(20, 50.0), start="2020-01-01").iloc[5:] + data = pd.concat([data_a, data_b], ignore_index=True) + + strat = BuyAndHoldStrategy() + signals = strat.generate_signals(data) + reasons = strat.explain_signals(data) + + for symbol in ("AAA", "BBB"): + values = signals[symbol].to_numpy() + previous = np.concatenate([[0.0], values[:-1]]) + changed = np.abs(values - previous) > 1e-12 + has_reason = reasons.detail_code[symbol].notna().to_numpy() + assert (changed == has_reason).all() + + first_valid_bbb_date = signals.index[5] + assert ( + reasons.detail_code.at[first_valid_bbb_date, "BBB"] == "price_became_available" + ) + assert reasons.detail_code.at[signals.index[0], "AAA"] == "price_became_available" + + +def test_trend_following_explain_signals_reports_crossover_codes() -> None: + """A clean down-then-up price path forces exactly one bearish and one + bullish crossover; codes and the fast/slow MA values in the details + must match generate_signals()' own transitions.""" + prices = np.concatenate([np.linspace(100, 80, 60), np.linspace(80, 120, 60)]) + data = make_ohlcv("AAA", prices, start="2020-01-01") + strat = TrendFollowingStrategy(fast_window=5, slow_window=20, long_only=False) + + signals = strat.generate_signals(data) + reasons = strat.explain_signals(data) + + values = signals["AAA"].to_numpy() + previous = np.concatenate([[0.0], values[:-1]]) + changed = np.abs(values - previous) > 1e-12 + has_reason = reasons.detail_code["AAA"].notna().to_numpy() + assert (changed == has_reason).all() + assert set(reasons.detail_code["AAA"].dropna().unique()) <= { + "bullish_crossover", + "bearish_crossover", + } + # The uptrend leg must eventually produce a bullish crossover, and + # its details must cite real MA values. + bullish = reasons.detail_code["AAA"] == "bullish_crossover" + assert bullish.any() + bullish_details = reasons.details["AAA"][bullish].iloc[0] + assert "fast MA" in bullish_details + assert "crossed above slow MA" in bullish_details + + +def test_time_series_momentum_binary_explain_signals_reports_entry_codes() -> None: + prices = np.concatenate([np.full(30, 100.0), np.linspace(100, 160, 40)]) + data = make_ohlcv("AAA", prices, start="2020-01-01") + strat = TimeSeriesMomentumStrategy( + lookback_period=20, skip_period=1, signal_scaling="binary", long_only=True + ) + + signals = strat.generate_signals(data) + reasons = strat.explain_signals(data) + assert reasons is not None + + values = signals["AAA"].to_numpy() + previous = np.concatenate([[0.0], values[:-1]]) + changed = np.abs(values - previous) > 1e-12 + has_reason = reasons.detail_code["AAA"].notna().to_numpy() + assert (changed == has_reason).all() + codes = set(reasons.detail_code["AAA"].dropna().unique()) + assert codes <= { + "positive_momentum_entry", + "negative_momentum_entry", + "momentum_exit", + } + assert "positive_momentum_entry" in codes + + +@pytest.mark.parametrize("signal_scaling", ["continuous", "volatility_adjusted"]) +def test_time_series_momentum_non_binary_explain_signals_returns_none( + signal_scaling: str, +) -> None: + """A continuously-scaled signal changes almost every rebalance date -- + the generic pipeline text already explains it fully, so this + deliberately opts out of a strategy-specific attribution rather than + inventing a label repeated on nearly every row.""" + prices = np.concatenate([np.full(30, 100.0), np.linspace(100, 160, 40)]) + data = make_ohlcv("AAA", prices, start="2020-01-01") + strat = TimeSeriesMomentumStrategy( + lookback_period=20, skip_period=1, signal_scaling=signal_scaling + ) + assert strat.explain_signals(data) is None + + +def test_time_series_momentum_volatility_adjusted_masks_zero_volatility() -> None: + """Regression test: a price jump followed by a long dead-flat stretch + gives a zero trailing realized volatility while the (longer-lookback) + momentum score is still positive. `generate_signals()` previously + computed ``score / volatility`` inline and clipped the result, so + ``positive / 0 == inf`` became a false full-conviction ``+1.0`` there -- + diverging from the public `volatility_adjusted_momentum()` helper (and + the Strategy Explorer lab), which both mask a zero-volatility window to + ``NaN`` (an inconclusive read, not a confident signal). The strategy + must now agree with the helper: NaN there, filled to `0.0` by + `_validate_signals()`, never `1.0`. + """ + from quantlab.features.momentum import volatility_adjusted_momentum + + prices = np.concatenate([np.full(10, 100.0), np.full(90, 110.0)]) + data = make_ohlcv("AAA", prices, start="2020-01-01") + lookback, skip, vol_window = 60, 0, 20 + strat = TimeSeriesMomentumStrategy( + lookback_period=lookback, + skip_period=skip, + signal_scaling="volatility_adjusted", + volatility_window=vol_window, + long_only=False, + ) + signals = strat.generate_signals(data)["AAA"] + + helper = volatility_adjusted_momentum( + pd.Series(prices, index=data["timestamp"].unique()), + lookback, + skip, + vol_window, + 252, + ).clip(-1.0, 1.0) + expected = helper.fillna(0.0) + expected.index = signals.index + pd.testing.assert_series_equal(signals, expected, check_names=False) + # Not a vacuous comparison -- confirm the zero-volatility window this + # test targets actually occurs and would previously have been 1.0. + assert (expected == 0.0).any() + + +def test_cross_sectional_momentum_explain_signals_reports_selection_codes( + synthetic_panel: pd.DataFrame, +) -> None: + strat = CrossSectionalMomentumStrategy( + lookback_period=60, skip_period=5, top_fraction=0.34, long_short=True + ) + signals = strat.generate_signals(synthetic_panel) + reasons = strat.explain_signals(synthetic_panel) + assert reasons is not None + + for symbol in signals.columns: + values = signals[symbol].to_numpy() + previous = np.concatenate([[0.0], values[:-1]]) + changed = np.abs(values - previous) > 1e-12 + has_reason = reasons.detail_code[symbol].notna().to_numpy() + assert (changed == has_reason).all() + + codes = set(np.unique(reasons.detail_code.to_numpy()[reasons.detail_code.notna()])) + assert codes <= { + "entered_top_selection", + "left_top_selection", + "entered_bottom_selection", + "left_bottom_selection", + } + assert "entered_top_selection" in codes def test_pairs_trading_contract(two_symbol_panel: pd.DataFrame) -> None: @@ -110,9 +522,9 @@ def test_pairs_trading_contract(two_symbol_panel: pd.DataFrame) -> None: symbol_a="EWA", symbol_b="EWB", formation_window=120, - zscore_window=30, - entry_zscore=1.5, - exit_zscore=0.5, + indicator_window=30, + entry_threshold=1.5, + exit_threshold=0.5, ) signals = strat.generate_signals(two_symbol_panel) _assert_contract(signals) @@ -124,6 +536,278 @@ def test_pairs_trading_contract(two_symbol_panel: pd.DataFrame) -> None: assert np.sign(row["EWA"]) == -np.sign(row["EWB"]) +def test_pairs_trading_explain_signals_matches_generate_signals_transitions( + two_symbol_panel: pd.DataFrame, +) -> None: + """Both legs must carry the SAME reason at the SAME date (one shared + pair position), matching a direct re-walk of the state machine; every + other symbol stays None.""" + strat = PairsTradingStrategy( + symbol_a="EWA", + symbol_b="EWB", + formation_window=120, + indicator_window=30, + entry_threshold=1.5, + exit_threshold=0.5, + ) + prices = strat._prices(two_symbol_panel) + a, b = prices["EWA"], prices["EWB"] + intercept, beta = rolling_hedge_parameters( + a, b, strat.formation_window, strat.dynamic_hedge_ratio + ) + zscore = rolling_zscore(a - intercept - beta * b, strat.indicator_window) + state, expected_detail_code, expected_details = _walk_pairs_positions_with_reasons( + zscore.to_numpy(dtype=float), + strat._stationarity_gate(a, b), + entry=strat.entry_threshold, + exit_=strat.exit_threshold, + stop=strat.stop_threshold, + ) + + reasons = strat.explain_signals(two_symbol_panel) + + pd.testing.assert_series_equal( + reasons.detail_code["EWA"], reasons.detail_code["EWB"], check_names=False + ) + assert reasons.detail_code["EWA"].tolist() == list(expected_detail_code) + assert reasons.details["EWA"].tolist() == list(expected_details) + codes = set(reasons.detail_code["EWA"].dropna().unique()) + assert codes <= { + "spread_oversold_entry", + "spread_overbought_entry", + "mean_reversion_exit", + "stop_loss_exit", + "data_unavailable_exit", + } + assert codes # this panel is designed to actually trade + assert (state != 0).any() + + +def test_pairs_trading_explain_signals_says_gate_disabled_when_adf_is_none( + two_symbol_panel: pd.DataFrame, +) -> None: + """`adf_pvalue_threshold=None` disables the stationarity gate entirely + -- an entry's reason text must say so, never claim "stationarity gate + open" for a gate that was never even evaluated.""" + strat = PairsTradingStrategy( + symbol_a="EWA", + symbol_b="EWB", + formation_window=120, + indicator_window=30, + entry_threshold=1.5, + exit_threshold=0.5, + adf_pvalue_threshold=None, + ) + reasons = strat.explain_signals(two_symbol_panel) + entry_details = reasons.details["EWA"][ + reasons.detail_code["EWA"].isin( + ["spread_oversold_entry", "spread_overbought_entry"] + ) + ] + assert not entry_details.empty # this panel is designed to actually trade + assert entry_details.str.contains("gate disabled").all() + assert not entry_details.str.contains("stationarity gate open").any() + + +def test_pairs_trading_decision_signal_matches_the_real_state_array( + two_symbol_panel: pd.DataFrame, +) -> None: + """decision_signal() must return EXACTLY the same discrete `state` + array (+-1/0) that generate_signals() computes internally -- a pure + recalculation via the same shared helper, never a reconstruction that + could diverge.""" + strat = PairsTradingStrategy( + symbol_a="EWA", + symbol_b="EWB", + formation_window=120, + indicator_window=30, + entry_threshold=1.5, + exit_threshold=0.5, + ) + prices = strat._prices(two_symbol_panel) + a, b = prices["EWA"], prices["EWB"] + intercept, beta = rolling_hedge_parameters( + a, b, strat.formation_window, strat.dynamic_hedge_ratio + ) + zscore = rolling_zscore(a - intercept - beta * b, strat.indicator_window) + expected_state, _, _ = _walk_pairs_positions_with_reasons( + zscore.to_numpy(dtype=float), + strat._stationarity_gate(a, b), + entry=strat.entry_threshold, + exit_=strat.exit_threshold, + stop=strat.stop_threshold, + ) + + decision = strat.decision_signal(two_symbol_panel) + + assert decision is not None + assert decision["EWA"].tolist() == list(expected_state) + assert decision["EWB"].tolist() == list(expected_state) + # Every other symbol in the universe stays 0 -- this strategy never + # touches them. + other_columns = [c for c in decision.columns if c not in ("EWA", "EWB")] + for column in other_columns: + assert (decision[column] == 0.0).all() + + +def test_pairs_trading_decision_signal_shares_index_and_columns_with_prices( + two_symbol_panel: pd.DataFrame, +) -> None: + strat = PairsTradingStrategy( + symbol_a="EWA", + symbol_b="EWB", + formation_window=120, + indicator_window=30, + entry_threshold=1.5, + exit_threshold=0.5, + ) + prices = strat._prices(two_symbol_panel) + + decision = strat.decision_signal(two_symbol_panel) + + assert decision is not None + assert decision.index.equals(prices.index) + assert decision.columns.equals(prices.columns) + assert np.isfinite(decision.to_numpy(dtype=float)).all() + + +def test_base_strategy_decision_signal_defaults_to_none() -> None: + """Every built-in strategy except pairs_trading leaves decision_signal + at its default -- generate_signals()'s own output is already a + faithful decision proxy for them.""" + strat = MeanReversionStrategy( + lookback_period=20, entry_threshold=1.5, exit_threshold=0.5 + ) + assert strat.decision_signal(pd.DataFrame()) is None + + +def test_validate_decision_signal_rejects_mismatched_shape() -> None: + reference = pd.DataFrame( + {"A": [1.0, 2.0]}, index=pd.date_range("2020-01-01", periods=2) + ) + mismatched = pd.DataFrame( + {"A": [1.0]}, index=pd.date_range("2020-01-01", periods=1) + ) + with pytest.raises(StrategyError, match="index and columns"): + BaseStrategy._validate_decision_signal(mismatched, reference) + + +def test_validate_decision_signal_rejects_mismatched_columns() -> None: + idx = pd.date_range("2020-01-01", periods=2) + reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx) + mismatched = pd.DataFrame({"B": [1.0, 2.0]}, index=idx) + with pytest.raises(StrategyError, match="index and columns"): + BaseStrategy._validate_decision_signal(mismatched, reference) + + +def test_validate_decision_signal_rejects_non_numeric_values() -> None: + idx = pd.date_range("2020-01-01", periods=2) + reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx) + non_numeric = pd.DataFrame({"A": ["x", "y"]}, index=idx) + with pytest.raises(StrategyError, match="numeric"): + BaseStrategy._validate_decision_signal(non_numeric, reference) + + +def test_validate_decision_signal_rejects_nan() -> None: + idx = pd.date_range("2020-01-01", periods=2) + reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx) + with_nan = pd.DataFrame({"A": [1.0, np.nan]}, index=idx) + with pytest.raises(StrategyError, match="NaN or Infinity"): + BaseStrategy._validate_decision_signal(with_nan, reference) + + +def test_validate_decision_signal_rejects_infinity() -> None: + idx = pd.date_range("2020-01-01", periods=2) + reference = pd.DataFrame({"A": [1.0, 2.0]}, index=idx) + with_inf = pd.DataFrame({"A": [1.0, np.inf]}, index=idx) + with pytest.raises(StrategyError, match="NaN or Infinity"): + BaseStrategy._validate_decision_signal(with_inf, reference) + + +def test_decision_signal_never_affects_backtest_numerics( + two_symbol_panel: pd.DataFrame, +) -> None: + """decision_signal() is a strictly diagnostic proxy (point 6): a + monkeypatched version that always returns None (forcing the engine's + fallback to the raw `signals`) must produce a BIT-IDENTICAL backtest + -- weights, PnL, costs -- to the real override; only the trade log's + reason columns may differ.""" + from quantlab.backtesting.engine import BacktestEngine + from quantlab.config import ExperimentConfig + from quantlab.execution.execution_model import ExecutionModel + from quantlab.portfolio.allocator import build_allocator + + config = ExperimentConfig.from_dict( + { + "experiment_name": "decision_signal_invariance", + "data": { + "instruments": [ + {"symbol": "EWA", "source": "csv", "calendar": "XNYS"}, + {"symbol": "EWB", "source": "csv", "calendar": "XNYS"}, + ], + "start_date": "2020-01-01", + "end_date": "2020-12-31", + }, + "strategy": { + "name": "pairs_trading", + "parameters": {"symbol_a": "EWA", "symbol_b": "EWB"}, + }, # unused: an instance is passed directly to .run() below + "portfolio": { + "allocator": "signal_proportional", + "rebalance_frequency": "daily", + }, + "execution": { + "commission_bps": 0.0, + "spread_bps": 0.0, + "slippage_bps": 0.0, + }, + "backtest": {"initial_capital": 100_000}, + } + ) + strategy = PairsTradingStrategy( + symbol_a="EWA", + symbol_b="EWB", + formation_window=60, + indicator_window=15, + entry_threshold=1.0, + exit_threshold=0.3, + ) + execution_model = ExecutionModel.from_config(config.execution) + allocator = build_allocator("signal_proportional") + with_decision = BacktestEngine().run( + two_symbol_panel, strategy, allocator, execution_model, config + ) + + class _NoDecisionProxy(PairsTradingStrategy): + def decision_signal( # type: ignore[override] + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> None: + return None + + fallback_strategy = _NoDecisionProxy( + symbol_a="EWA", + symbol_b="EWB", + formation_window=60, + indicator_window=15, + entry_threshold=1.0, + exit_threshold=0.3, + ) + without_decision = BacktestEngine().run( + two_symbol_panel, fallback_strategy, allocator, execution_model, config + ) + + pd.testing.assert_series_equal( + with_decision.equity_curve, without_decision.equity_curve + ) + pd.testing.assert_frame_equal(with_decision.weights, without_decision.weights) + pd.testing.assert_frame_equal(with_decision.positions, without_decision.positions) + pd.testing.assert_series_equal(with_decision.returns, without_decision.returns) + for column in ("commission", "spread_cost", "slippage_cost", "total_cost"): + pd.testing.assert_series_equal( + with_decision.trades[column], without_decision.trades[column] + ) + + def test_adf_pvalue_on_stationary_series() -> None: rng = np.random.default_rng(0) n = 400 @@ -140,3 +824,119 @@ def test_build_strategy_unknown_raises() -> None: with pytest.raises(StrategyError): build_strategy("does_not_exist") + + +def _split_like_data(symbol: str = "AAA") -> pd.DataFrame: + """40 rows whose adjusted_close diverges from close via a simulated split. + + The first half of adjusted_close is halved relative to close, creating + a real, structural divergence between the two price series -- not just + numeric noise -- so ``_prices()`` must produce genuinely different + matrices for ``price_type="close"`` vs ``"adjusted_close"``. + """ + n = 40 + dates = pd.bdate_range("2020-01-01", periods=n) + close = np.linspace(100, 140, n) + adjusted_close = close.copy() + adjusted_close[:20] /= 2.0 + return pd.DataFrame( + { + "timestamp": dates, + "symbol": symbol, + "open": close, + "high": close, + "low": close, + "close": close, + "adjusted_close": adjusted_close, + "volume": 1_000_000.0, + } + ) + + +@pytest.mark.parametrize( + ("strategy_cls", "kwargs"), + [ + (BuyAndHoldStrategy, {}), + (TimeSeriesMomentumStrategy, {"lookback_period": 10, "skip_period": 0}), + (CrossSectionalMomentumStrategy, {"lookback_period": 10, "skip_period": 0}), + (MeanReversionStrategy, {"lookback_period": 10}), + (TrendFollowingStrategy, {"fast_window": 3, "slow_window": 8}), + ], +) +def test_prices_respects_signal_price_type_per_strategy( + strategy_cls: type[BaseStrategy], kwargs: dict[str, object] +) -> None: + """Each strategy's ``_prices()`` must read whichever ``price_type`` it + was constructed with -- ``"close"`` and ``"adjusted_close"`` must + produce genuinely different price matrices on data with a real + divergence between the two fields.""" + data = _split_like_data() + strategy_close = strategy_cls(price_type="close", **kwargs) # type: ignore[call-arg] + strategy_adjusted = strategy_cls(price_type="adjusted_close", **kwargs) # type: ignore[call-arg] + assert strategy_close.price_type == "close" + assert strategy_adjusted.price_type == "adjusted_close" + + prices_close = strategy_close._prices(data) + prices_adjusted = strategy_adjusted._prices(data) + assert not prices_close.equals(prices_adjusted) + pd.testing.assert_series_equal( + prices_close["AAA"], data.set_index("timestamp")["close"], check_names=False + ) + pd.testing.assert_series_equal( + prices_adjusted["AAA"], + data.set_index("timestamp")["adjusted_close"], + check_names=False, + ) + + +def test_prices_respects_signal_price_type_for_pairs_trading() -> None: + data = pd.concat( + [_split_like_data("AAA"), _split_like_data("BBB")], ignore_index=True + ) + strategy_close = PairsTradingStrategy( + symbol_a="AAA", symbol_b="BBB", formation_window=20, price_type="close" + ) + strategy_adjusted = PairsTradingStrategy( + symbol_a="AAA", symbol_b="BBB", formation_window=20, price_type="adjusted_close" + ) + prices_close = strategy_close._prices(data) + prices_adjusted = strategy_adjusted._prices(data) + assert not prices_close.equals(prices_adjusted) + + +def test_price_type_is_rejected_when_invalid() -> None: + with pytest.raises(ValueError, match="price_type"): + BuyAndHoldStrategy(price_type="vwap") + with pytest.raises(StrategyError, match="price_type"): + build_strategy("buy_and_hold", {"price_type": "vwap"}) + + +def test_build_strategy_from_config_injects_signal_price_type() -> None: + """build_strategy_from_config() must inject strategy.signal_price_type + the same way it already injects periods_per_year -- only when the + strategy accepts it and the YAML didn't already set it explicitly.""" + from quantlab.backtesting.runner import build_strategy_from_config + from quantlab.config import ExperimentConfig + + cfg = ExperimentConfig.from_dict( + { + "experiment_name": "price_type_injection", + "data": { + "instruments": [{"symbol": "AAA", "source": "csv", "calendar": "XNYS"}], + "start_date": "2020-01-01", + "end_date": "2020-06-01", + }, + "strategy": {"name": "buy_and_hold", "signal_price_type": "close"}, + } + ) + strategy = build_strategy_from_config(cfg) + assert strategy.price_type == "close" + + default_cfg = cfg.revalidated_copy( + update={ + "strategy": cfg.strategy.revalidated_copy( + update={"signal_price_type": "adjusted_close"} + ) + } + ) + assert build_strategy_from_config(default_cfg).price_type == "adjusted_close" diff --git a/tests/unit/test_strategies_hardening.py b/tests/unit/test_strategies_hardening.py index d303fe2..98961a3 100644 --- a/tests/unit/test_strategies_hardening.py +++ b/tests/unit/test_strategies_hardening.py @@ -140,7 +140,7 @@ def test_direct_strategy_data_requires_finite_positive_prices( @pytest.mark.parametrize( ("factory", "message"), [ - (lambda: MeanReversionStrategy(entry_zscore=np.nan), "finite"), + (lambda: MeanReversionStrategy(entry_threshold=np.nan), "finite"), (lambda: MeanReversionStrategy(long_only=cast(Any, "false")), "boolean"), ( lambda: TimeSeriesMomentumStrategy(lookback_period=cast(Any, True)), @@ -185,7 +185,9 @@ def test_adf_inconclusive_is_explicit_and_never_passes_gate( PairsTradingStrategy("AAA", "BBB", adf_pvalue_threshold=1.0) monkeypatch.setattr(pairs_module, "adf_pvalue", lambda series: None) - strategy = PairsTradingStrategy("AAA", "BBB", formation_window=20, zscore_window=2) + strategy = PairsTradingStrategy( + "AAA", "BBB", formation_window=20, indicator_window=2 + ) index = pd.date_range("2020-01-01", periods=25) a = pd.Series(np.linspace(100.0, 120.0, 25), index=index) b = pd.Series(np.linspace(50.0, 60.0, 25), index=index) @@ -204,7 +206,9 @@ def _capture(series: pd.Series) -> float: return 0.01 monkeypatch.setattr(pairs_module, "adf_pvalue", _capture) - strategy = PairsTradingStrategy("AAA", "BBB", formation_window=20, zscore_window=2) + strategy = PairsTradingStrategy( + "AAA", "BBB", formation_window=20, indicator_window=2 + ) index = pd.date_range("2020-01-01", periods=25) b = pd.Series(np.linspace(50.0, 60.0, 25), index=index) a = 5.0 + 1.5 * b + pd.Series(np.sin(np.arange(25)), index=index) @@ -232,7 +236,7 @@ def _counted_ols(x: np.ndarray, y: np.ndarray) -> tuple[float, float]: "AAA", "BBB", formation_window=20, - zscore_window=2, + indicator_window=2, dynamic_hedge_ratio=False, ) index = pd.date_range("2020-01-01", periods=25) @@ -270,7 +274,9 @@ def _parameters( [make_ohlcv("AAA", np.full(25, 100.0)), make_ohlcv("BBB", np.full(25, 50.0))], ignore_index=True, ) - strategy = PairsTradingStrategy("AAA", "BBB", formation_window=20, zscore_window=2) + strategy = PairsTradingStrategy( + "AAA", "BBB", formation_window=20, indicator_window=2 + ) last = strategy.generate_signals(data).iloc[-1] assert last["AAA"] == pytest.approx(1.0) assert last["BBB"] == pytest.approx(-1.0) @@ -363,6 +369,9 @@ def test_trend_strategy_contains_direction_parameters_only() -> None: "fast_window": 10, "slow_window": 30, "long_only": True, + "price_type": "adjusted_close", + "stop_loss_pct": None, + "take_profit_pct": None, } @@ -420,7 +429,7 @@ def test_unwrap_simple_type_returns_none_for_ambiguous_annotations() -> None: def test_prices_rejects_data_with_no_rows() -> None: empty = make_ohlcv("AAA", [100.0, 101.0]).iloc[0:0] with pytest.raises(StrategyError, match="at least one date and symbol"): - BaseStrategy._prices(empty) + BuyAndHoldStrategy()._prices(empty) def test_validate_signals_rejects_non_dataframe_input() -> None: @@ -497,8 +506,8 @@ def test_walk_pairs_positions_requires_matching_lengths() -> None: def test_pairs_strategy_allows_no_stop_and_rejects_missing_symbol() -> None: - strategy = PairsTradingStrategy("AAA", "BBB", stop_zscore=None) - assert strategy.stop_zscore is None + strategy = PairsTradingStrategy("AAA", "BBB", stop_threshold=None) + assert strategy.stop_threshold is None data = make_ohlcv("AAA", [100.0] * 30) with pytest.raises(StrategyError, match="needs symbol"): strategy.generate_signals(data) diff --git a/tests/unit/test_trade_log.py b/tests/unit/test_trade_log.py index 22f8852..60ae133 100644 --- a/tests/unit/test_trade_log.py +++ b/tests/unit/test_trade_log.py @@ -2,15 +2,43 @@ from __future__ import annotations -from typing import Any +from typing import Any, TypedDict, cast import numpy as np import pandas as pd import pytest -from quantlab.backtesting.trade_log import build_trade_log +from quantlab.backtesting.trade_log import ( + ADJUSTMENT_ORDER, + TRADE_LOG_COLUMNS, + TRADE_LOG_SCHEMA_VERSION, + TradeReason, + _classify_action, + _classify_reason, + build_trade_log, + parse_adjustment_codes, + serialize_adjustment_codes, +) from quantlab.exceptions import BacktestError from quantlab.execution.slippage import ConstantSlippageModel, SlippageModel +from quantlab.portfolio.constraints import ConstraintTouch + + +def _touch( + touched: pd.DataFrame, + before: pd.DataFrame, + after: pd.DataFrame, + *, + direct: pd.DataFrame | None = None, +) -> ConstraintTouch: + """Build a ConstraintTouch, defaulting `direct` to `touched` (no + redistribution concept -- matches _mark_touched's own default).""" + return ConstraintTouch( + touched=touched, + before=before, + after=after, + direct=direct if direct is not None else touched, + ) def _inputs() -> tuple[pd.DataFrame, pd.DataFrame, pd.Series, pd.DataFrame]: @@ -49,6 +77,524 @@ def test_trade_log_uses_new_weight_schema() -> None: assert trades["reference_price"].tolist() == [10.0] +def test_trade_log_schema_has_21_columns_in_order() -> None: + """`action` is always computed; the trigger/adjustment/position_ + strategy_origin columns stay `None`/`NaT` when the optional reason + frames are omitted -- the walk-forward call site (which rebuilds + trades from a stitched out-of-sample series with no per-fold + diagnostic frames surviving the stitch) must keep working unchanged.""" + trades = _build(*_inputs()) + + assert list(trades.columns) == TRADE_LOG_COLUMNS + assert len(TRADE_LOG_COLUMNS) == 21 + assert TRADE_LOG_SCHEMA_VERSION == 2 + assert trades["action"].tolist() == ["entry_long"] + for column in ( + "trigger_reason_code", + "trigger_reason_detail_code", + "trigger_reason_details", + "adjustment_reason_codes", + "adjustment_reason_details", + "position_strategy_origin_code", + "position_strategy_origin_details", + ): + assert trades[column].tolist() == [None] + assert pd.isna(trades["position_strategy_origin_timestamp"].iloc[0]) + + +def test_previous_weight_reflects_organic_drift_not_the_prior_rows_own_value() -> None: + """Regression test: `previous_weight` must be the value organic drift + actually left the position at going into this trade -- NOT the + previous ROW's own reported `executed_weight`, which under + `model_weight_drift=True` can differ from it whenever drift moved the + position between rows with no trade of its own. Reproduces the exact + scenario reported: a position drifts from 0.50 up to 0.60 with no + trade recorded (row 1, `weight_changes=0`), then a sell brings it to + 0.55 (`weight_change=-0.05`) -- `previous_weight` must read 0.60, and + the action must be `reduce_long`, not `increase_long` (what the old + `executed_weights.shift(1)` formula -- which would have read 0.50 -- + would have produced).""" + index = pd.date_range("2024-01-01", periods=4, freq="D") + # A leading flat (no-trade) row, matching every real backtest's own + # warm-up convention -- gives the entry on row 1 a valid prior-period + # reference price (row 0's), avoiding an unrelated "no prior price" + # error for what would otherwise be the very first row. + executed = pd.DataFrame({"AAA": [0.0, 0.5, 0.6, 0.55]}, index=index) + changes = pd.DataFrame({"AAA": [0.0, 0.5, 0.0, -0.05]}, index=index) + equity = pd.Series([100.0, 100.0, 100.0, 100.0], index=index) + prices = pd.DataFrame({"AAA": [9.0, 10.0, 11.0, 12.0]}, index=index) + + trades = _build(executed, changes, equity, prices) + + assert trades["previous_weight"].tolist() == [0.0, pytest.approx(0.6)] + assert trades["new_weight"].tolist() == [0.5, pytest.approx(0.55)] + assert trades["weight_change"].tolist() == [0.5, pytest.approx(-0.05)] + assert trades["side"].tolist() == ["buy", "sell"] + assert trades["action"].tolist() == ["entry_long", "reduce_long"] + assert np.allclose( + (trades["new_weight"] - trades["previous_weight"]).to_numpy(), + trades["weight_change"].to_numpy(), + ) + + +def test_previous_weight_reflects_downward_drift_before_a_buy() -> None: + """Symmetric case: a long position drifts DOWN from 0.5 to 0.4 with no + trade of its own, then a buy tops it back up to 0.45 + (`weight_change=+0.05`) -- `previous_weight` must read 0.40, and the + action must be `increase_long`.""" + index = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame({"AAA": [0.0, 0.5, 0.4, 0.45]}, index=index) + changes = pd.DataFrame({"AAA": [0.0, 0.5, 0.0, 0.05]}, index=index) + equity = pd.Series([100.0, 100.0, 100.0, 100.0], index=index) + prices = pd.DataFrame({"AAA": [11.0, 10.0, 9.0, 9.5]}, index=index) + + trades = _build(executed, changes, equity, prices) + + assert trades["previous_weight"].tolist() == [0.0, pytest.approx(0.4)] + assert trades["new_weight"].tolist() == [0.5, pytest.approx(0.45)] + assert trades["side"].tolist() == ["buy", "buy"] + assert trades["action"].tolist() == ["entry_long", "increase_long"] + assert np.allclose( + (trades["new_weight"] - trades["previous_weight"]).to_numpy(), + trades["weight_change"].to_numpy(), + ) + + +def test_previous_weight_correct_across_a_long_short_reversal() -> None: + """A position drifts from a long anchor down to a small residual long + (0.5 -> 0.1, no trade), then a trade flips it to short (-0.2, + `weight_change=-0.3`) -- `previous_weight` must read the drifted 0.10, + not the anchor 0.5, and the action must be `reverse_long_to_short`.""" + index = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame({"AAA": [0.0, 0.5, 0.1, -0.2]}, index=index) + changes = pd.DataFrame({"AAA": [0.0, 0.5, 0.0, -0.3]}, index=index) + equity = pd.Series([100.0, 100.0, 100.0, 100.0], index=index) + prices = pd.DataFrame({"AAA": [11.0, 10.0, 6.0, 6.0]}, index=index) + + trades = _build(executed, changes, equity, prices) + + assert trades["previous_weight"].tolist() == [0.0, pytest.approx(0.1)] + assert trades["new_weight"].tolist() == [0.5, pytest.approx(-0.2)] + assert trades["side"].tolist() == ["buy", "sell"] + assert trades["action"].tolist() == ["entry_long", "reverse_long_to_short"] + assert np.allclose( + (trades["new_weight"] - trades["previous_weight"]).to_numpy(), + trades["weight_change"].to_numpy(), + ) + + +def test_previous_weight_correct_for_a_drift_compliance_forced_trade() -> None: + """`previous_weight` for a row where the drift-compliance LP forces a + correction must be the value organic drift actually pushed the + position to (the breach itself), not the previous row's own value -- + reuses the exact numeric scenario `test_maximum_weight_breach_ + correction_lands_next_row_never_same_row` (test_weight_drift.py) + already proves the underlying drift/correction mechanics for, this + time feeding `apply_weight_drift`'s own output straight into + `build_trade_log`, exactly like the real pipeline (`engine.py` passes + `accounting.executed_weights`/`accounting.weight_changes`, which ARE + `apply_weight_drift`'s two frames when drift is active).""" + from quantlab.backtesting.accounting import apply_weight_drift + + dates = pd.date_range("2024-01-01", periods=5, freq="D") + executed = pd.DataFrame({"A": [0.0, 0.5, 0.5, 0.5, 0.5]}, index=dates) + asset_returns = pd.DataFrame({"A": [np.nan, 0.0, 1.0, 0.0, 0.0]}, index=dates) + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=0.6, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + breach_row = dates[3] + landed_row = dates[4] + assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"]) + assert bool(provenance.drift_compliance_forced.loc[landed_row, "A"]) + + equity = pd.Series(100.0, index=dates) + prices = pd.DataFrame({"A": [10.0] * 5}, index=dates) + trades = _build( + drifted, + trade_changes, + equity, + prices, + # `drifted` stands in for every decision-level diagnostic frame: + # nothing else is being attributed here, so this isolates + # drift_compliance as the sole adjustment reason. + executed_desired=drifted, + executed_constrained=drifted, + executed_signal_diag=drifted, + executed_allocated_diag=drifted, + executed_desired_diag=drifted, + executed_drift_compliance_forced=provenance.drift_compliance_forced, + executed_drift_compliance_pending=provenance.drift_compliance_pending, + ) + + landed_trade = trades[trades["timestamp"] == landed_row].iloc[0] + # The breach itself (organic drift, no trade landed yet) is never a + # trade-log row -- the first row for this symbol after the initial + # entry is the correction landing. + assert landed_trade["previous_weight"] == pytest.approx( + drifted.loc[breach_row, "A"] + ) + assert landed_trade["new_weight"] == pytest.approx(0.6) + assert landed_trade["adjustment_reason_codes"] == "drift_compliance" + assert np.allclose( + (trades["new_weight"] - trades["previous_weight"]).to_numpy(), + trades["weight_change"].to_numpy(), + ) + + +def test_previous_weight_correct_for_a_maximum_turnover_deferred_catchup() -> None: + """`previous_weight` across a `maximum_turnover`-throttled, multi-row + catch-up must reflect each row's own true entering value -- reuses + `test_maximum_turnover_caps_an_anchor_catch_up_and_carries_the_ + remainder`'s exact scenario (test_weight_drift.py), this time + verifying the trade log's own invariant across both the partial- + landing row and the remainder-landing row.""" + from quantlab.backtesting.accounting import apply_weight_drift + + # One extra leading flat (no-trade) row versus test_weight_drift.py's + # own version of this scenario -- gives the initial entry (itself + # subject to the turnover cap, per that test's own docstring) a valid + # prior-period reference price; every index below is shifted by +1 + # accordingly (drift shock at 21, schedule at 22, etc.). + n = 41 + dates = pd.date_range("2024-01-01", periods=n, freq="D") + executed = pd.DataFrame( + {"A": [0.0] + [0.5] * (n - 1), "B": [0.0] + [0.5] * (n - 1)}, index=dates + ) + asset_returns = pd.DataFrame( + {"A": [np.nan] + [0.0] * (n - 1), "B": [np.nan] + [0.0] * (n - 1)}, + index=dates, + ) + asset_returns.loc[dates[21], "A"] = 0.20 + rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"]) + rebalance_date.loc[dates[22]] = True + cap = 0.05 + + drifted, trade_changes, _provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + maximum_turnover=cap, + ) + equity = pd.Series(100.0, index=dates) + prices = pd.DataFrame({"A": [10.0] * n, "B": [10.0] * n}, index=dates) + trades = _build(drifted, trade_changes, equity, prices) + + # B also has a real row on both these dates (the schedule flag marks + # both columns "fresh" even though B's own target doesn't numerically + # change; B's actual weight still drifted slightly, since A's outsized + # gain grows total equity and so shrinks B's share of it) -- select A + # specifically rather than assuming a single row per date. + partial_row = trades[(trades["timestamp"] == dates[22]) & (trades["symbol"] == "A")] + remainder_row = trades[ + (trades["timestamp"] == dates[23]) & (trades["symbol"] == "A") + ] + assert len(partial_row) == 1 + assert len(remainder_row) == 1 + # The value organic drift ACTUALLY pushed A to entering this row (0.5 * + # 1.2 / 1.1), not 0.5 -- this is precisely the discrepancy this whole + # fix is about: the previous ROW's own reported value (0.5, before the + # shock landed) differs from what genuinely entered this row. + assert partial_row.iloc[0]["previous_weight"] == pytest.approx(0.5 * 1.2 / 1.1) + assert partial_row.iloc[0]["new_weight"] == pytest.approx(0.5204545454545454) + assert remainder_row.iloc[0]["previous_weight"] == pytest.approx(0.5204545454545454) + assert remainder_row.iloc[0]["new_weight"] == pytest.approx(0.5, abs=1e-9) + assert np.allclose( + (trades["new_weight"] - trades["previous_weight"]).to_numpy(), + trades["weight_change"].to_numpy(), + ) + + +def test_new_minus_previous_always_equals_change_across_a_full_drift_run() -> None: + """Property-style regression guard: across every row a broader, + multi-asset drift scenario produces (mixed drift, scheduled + rebalances, and a maximum_weight breach/correction all in one run), + `new_weight - previous_weight == weight_change` must hold for EVERY + trade-log row, not just the hand-picked ones the scenario-specific + tests above check.""" + from quantlab.backtesting.accounting import apply_weight_drift + + # A leading flat (no-trade) row (see the other tests above) plus one + # guaranteed large shock mixed into the random walk -- the fuzz alone + # can't be relied on to reliably breach maximum_weight for every seed, + # but the invariant below must be checked across a real correction, + # not just ordinary small-drift rows. + n = 61 + dates = pd.date_range("2024-01-01", periods=n, freq="D") + rng = np.random.default_rng(3) + a_returns = np.concatenate([[np.nan], rng.normal(0.0, 0.02, n - 1)]) + b_returns = np.concatenate([[np.nan], rng.normal(0.0, 0.015, n - 1)]) + a_returns[15] = 0.3 + executed = pd.DataFrame( + {"A": [0.0] + [0.5] * (n - 1), "B": [0.0] + [0.5] * (n - 1)}, + index=dates, + ) + asset_returns = pd.DataFrame({"A": a_returns, "B": b_returns}, index=dates) + rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"]) + rebalance_date.iloc[::10] = True + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=0.55, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + ) + assert provenance.drift_compliance_forced.to_numpy().any(), ( + "expected at least one drift-compliance correction in this " + "scenario -- otherwise the invariant check below doesn't " + "actually exercise that path" + ) + + equity = pd.Series(100.0, index=dates) + prices = pd.DataFrame({"A": [10.0] * n, "B": [10.0] * n}, index=dates) + trades = _build( + drifted, + trade_changes, + equity, + prices, + executed_desired=drifted, + executed_constrained=drifted, + executed_signal_diag=drifted, + executed_allocated_diag=drifted, + executed_desired_diag=drifted, + executed_drift_compliance_forced=provenance.drift_compliance_forced, + executed_drift_compliance_pending=provenance.drift_compliance_pending, + ) + + assert len(trades) > 0 + assert np.allclose( + (trades["new_weight"] - trades["previous_weight"]).to_numpy(), + trades["weight_change"].to_numpy(), + ) + # side must always agree with the sign of weight_change. + assert ((trades["side"] == "buy") == (trades["weight_change"] > 0)).all() + + +def test_trade_log_reason_frames_must_be_all_or_nothing() -> None: + executed, changes, equity, prices = _inputs() + + with pytest.raises(BacktestError, match="all together or not at all"): + _build(executed, changes, equity, prices, executed_desired=executed) + + +def test_trade_log_reason_frame_axes_must_match_executed_weights() -> None: + executed, changes, equity, prices = _inputs() + mismatched = executed.rename(columns={"AAA": "BBB"}) + reason_kwargs = { + "executed_desired": executed, + "executed_constrained": executed, + "executed_signal_diag": executed, + "executed_allocated_diag": executed, + "executed_desired_diag": mismatched, + } + + with pytest.raises(BacktestError, match="executed_desired_diag"): + _build(executed, changes, equity, prices, **reason_kwargs) + + +def test_trade_log_populates_reason_when_all_frames_are_supplied() -> None: + """A minimal end-to-end sanity check that supplying the reason frames + actually reaches _classify_reason -- full pipeline scenarios (turnover + cap, vol-targeting, tradability, ruin) live in test_trade_reasons.py.""" + executed, changes, equity, prices = _inputs() + # The fill at index 1 goes 0.0 -> 1.0; make the desired target agree + # (no constraint) and the signal change since the (implicit, flat) + # prior rebalance, so this resolves to a clean strategy_signal fill. + desired = executed.copy() + signal = pd.DataFrame({"AAA": [0.0, 1.0, 1.0]}, index=executed.index) + reason_kwargs = { + "executed_desired": desired, + "executed_constrained": desired, + "executed_signal_diag": signal, + "executed_allocated_diag": desired, + "executed_desired_diag": desired, + } + + trades = _build(executed, changes, equity, prices, **reason_kwargs) + + assert trades["trigger_reason_code"].tolist() == ["strategy_signal"] + assert trades["trigger_reason_detail_code"].tolist() == [None] + assert trades["trigger_reason_details"].iloc[0] is not None + assert trades["adjustment_reason_codes"].tolist() == [None] + + +def _base_reason_kwargs( + executed: pd.DataFrame, +) -> dict[str, pd.DataFrame]: + desired = executed.copy() + signal = pd.DataFrame({"AAA": [0.0, 1.0, 1.0]}, index=executed.index) + return { + "executed_desired": desired, + "executed_constrained": desired, + "executed_signal_diag": signal, + "executed_allocated_diag": desired, + "executed_desired_diag": desired, + } + + +def test_trade_log_strategy_reason_frames_must_be_supplied_together() -> None: + executed, changes, equity, prices = _inputs() + + with pytest.raises(BacktestError, match="must be supplied all together"): + _build( + executed, + changes, + equity, + prices, + **_base_reason_kwargs(executed), + executed_strategy_reason_code=executed, + ) + + +def test_trade_log_strategy_reason_frames_require_the_base_reason_frames() -> None: + executed, changes, equity, prices = _inputs() + strategy_code = pd.DataFrame( + {"AAA": [None, "oversold_entry", None]}, index=executed.index + ) + + with pytest.raises(BacktestError, match="requires the reason-attribution frames"): + _build( + executed, + changes, + equity, + prices, + executed_strategy_reason_code=strategy_code, + executed_strategy_reason_details=strategy_code, + ) + + +def test_trade_log_populates_strategy_specific_reason_when_supplied() -> None: + executed, changes, equity, prices = _inputs() + strategy_code = pd.DataFrame( + {"AAA": [None, "oversold_entry", None]}, index=executed.index, dtype=object + ) + strategy_details = pd.DataFrame( + {"AAA": [None, "z-score -2.5000 crossed entry threshold -2.0000", None]}, + index=executed.index, + dtype=object, + ) + + trades = _build( + executed, + changes, + equity, + prices, + **_base_reason_kwargs(executed), + executed_strategy_reason_code=strategy_code, + executed_strategy_reason_details=strategy_details, + ) + + assert trades["trigger_reason_code"].tolist() == ["strategy_signal"] + assert trades["trigger_reason_detail_code"].tolist() == ["oversold_entry"] + assert trades["trigger_reason_details"].iloc[0] == ( + "signal 0.0000 -> 1.0000 since last rebalance; " + "z-score -2.5000 crossed entry threshold -2.0000" + ) + + +def test_trade_log_constraint_provenance_requires_the_base_reason_frames() -> None: + executed, changes, equity, prices = _inputs() + touch = _touch( + touched=pd.DataFrame({"AAA": [False, True, False]}, index=executed.index), + before=executed, + after=executed, + ) + + with pytest.raises(BacktestError, match="requires the reason-attribution frames"): + _build( + executed, + changes, + equity, + prices, + constraint_provenance={"maximum_weight": touch}, + ) + + +def test_trade_log_constraint_provenance_rejects_mismatched_axes() -> None: + executed, changes, equity, prices = _inputs() + mismatched = executed.rename(columns={"AAA": "BBB"}) + touch = _touch(touched=mismatched, before=executed, after=executed) + + pattern = r"constraint_provenance\['maximum_weight'\]" + with pytest.raises(BacktestError, match=pattern): + _build( + executed, + changes, + equity, + prices, + **_base_reason_kwargs(executed), + constraint_provenance={"maximum_weight": touch}, + ) + + +def test_trade_log_populates_precise_constraint_code_when_provenance_is_supplied() -> ( + None +): + executed, changes, equity, prices = _inputs() + # Row 1 (the only fill) landed exactly on a post-constraint target + # (1.0) that ConstraintSet itself already trimmed from the desired + # 1.2 -- supplying provenance must yield the precise constraint name. + desired = pd.DataFrame({"AAA": [0.0, 1.2, 1.0]}, index=executed.index) + constrained = pd.DataFrame({"AAA": [0.0, 1.0, 1.0]}, index=executed.index) + reason_kwargs = _base_reason_kwargs(executed) + reason_kwargs["executed_desired"] = desired + reason_kwargs["executed_constrained"] = constrained + touched = pd.DataFrame({"AAA": [False, True, False]}, index=executed.index) + touch = _touch(touched=touched, before=desired, after=constrained) + + trades = _build( + executed, + changes, + equity, + prices, + **reason_kwargs, + constraint_provenance={"maximum_weight": touch}, + ) + + assert trades["adjustment_reason_codes"].tolist() == ["maximum_weight"] + assert trades["adjustment_reason_details"].iloc[0] == ( + "maximum_weight: 1.2000 -> 1.0000" + ) + + +@pytest.mark.parametrize( + ("previous", "new", "expected"), + [ + (0.0, 0.5, "entry_long"), + (0.0, -0.5, "entry_short"), + (0.5, 0.0, "exit_long"), + (-0.5, 0.0, "exit_short"), + (0.5, -0.5, "reverse_long_to_short"), + (-0.5, 0.5, "reverse_short_to_long"), + (0.3, 0.6, "increase_long"), + (0.6, 0.3, "reduce_long"), + (-0.3, -0.6, "increase_short"), + (-0.6, -0.3, "reduce_short"), + (0.6e-12, -0.6e-12, "flat_to_flat"), + ], +) +def test_classify_action(previous: float, new: float, expected: str) -> None: + assert _classify_action(previous, new) == expected + + @pytest.mark.parametrize( ("name", "value"), [ @@ -112,3 +658,489 @@ def per_symbol_cost( def test_trade_log_rejects_invalid_per_symbol_slippage() -> None: with pytest.raises(BacktestError, match=r"slippage costs.*non-negative"): _build(*_inputs(), slippage_model=_NegativeSlippage()) + + +class _ReasonKwargs(TypedDict, total=False): + new: float + previous: float + executed_desired: float + executed_desired_prev: float + executed_constrained: float + signal_now: float + signal_prev: float + allocated_now: float + allocated_prev: float + desired_diag_now: float + desired_diag_prev: float + + +def _reason_kwargs(**overrides: Any) -> _ReasonKwargs: + """A baseline where nothing looks changed anywhere in the pipeline -- + each test overrides only the specific comparison it wants to exercise, + so a passing test proves *that* branch fired, not an accidental + combination of several at once.""" + base: dict[str, Any] = { + "new": 0.5, + "previous": 0.5, + "executed_desired": 0.5, + "executed_desired_prev": 0.5, + "executed_constrained": 0.5, + "signal_now": 1.0, + "signal_prev": 1.0, + "allocated_now": 0.5, + "allocated_prev": 0.5, + "desired_diag_now": 0.5, + "desired_diag_prev": 0.5, + } + base.update(overrides) + return cast(_ReasonKwargs, base) + + +def test_classify_reason_contributing_constraint() -> None: + reason = _classify_reason( + **_reason_kwargs(), + contributing_constraints=["maximum_weight"], + constraint_before={"maximum_weight": 0.7892}, + constraint_after={"maximum_weight": 0.7000}, + ) + assert reason.adjustment_codes == "maximum_weight" + assert reason.adjustment_details == "maximum_weight: 0.7892 -> 0.7000" + assert reason.trigger_code is None + + +def test_classify_reason_redistribution_detail_text_is_stage_specific() -> None: + """Each redistribution-capable constraint gets ITS OWN honest text -- + never a generic "another position was capped" sentence borrowed from + maximum_weight.""" + for base_name, expected_fragment in ( + ("maximum_weight", "another position was capped"), + ("minimum_weight", "dust/small positions were removed"), + ("maximum_positions", "dropped to satisfy maximum_positions"), + ): + name = f"{base_name}_redistribution" + reason = _classify_reason( + **_reason_kwargs(), + contributing_constraints=[name], + constraint_before={name: 0.3761}, + constraint_after={name: 0.3770}, + ) + assert reason.adjustment_codes == name + assert expected_fragment in (reason.adjustment_details or "") + # Never implies the asset itself exceeded a threshold. + assert "0.3761 -> 0.3770" in (reason.adjustment_details or "") + + +def test_classify_reason_tradability_touched() -> None: + reason = _classify_reason( + **_reason_kwargs(new=0.3, executed_constrained=0.5), + tradability_touched=True, + ) + assert reason.adjustment_codes == "tradability" + assert "closed" in (reason.adjustment_details or "") + + +def test_classify_reason_tradability_compliance_limited_has_distinct_text() -> None: + reason = _classify_reason( + **_reason_kwargs(new=0.3, executed_constrained=0.5), + tradability_touched=True, + tradability_compliance_limited=True, + ) + assert reason.adjustment_codes == "tradability" + assert "feasibility limit" in (reason.adjustment_details or "") + + +def test_classify_reason_turnover_touched() -> None: + reason = _classify_reason( + **_reason_kwargs(new=0.3, executed_constrained=0.5), + turnover_touched=True, + turnover_actively_limited=True, + ) + assert reason.adjustment_codes == "turnover_cap" + assert "turnover-capped" in (reason.adjustment_details or "") + + +def test_classify_reason_turnover_touched_catchup_has_distinct_text() -> None: + """A row still catching up an earlier episode's debt, but not itself + actively capped, must say so -- not claim it's being capped today.""" + reason = _classify_reason( + **_reason_kwargs(new=0.3, executed_constrained=0.5), + turnover_touched=True, + turnover_actively_limited=False, + ) + assert reason.adjustment_codes == "turnover_cap" + assert "previously deferred" in (reason.adjustment_details or "") + + +def test_classify_reason_multi_cause_adjustment_constraint_and_turnover() -> None: + """The core bug this whole redesign fixes: a constraint AND turnover_ + cap acting on the SAME trade must both be visible, in ADJUSTMENT_ORDER + (constraints before turnover_cap), never one masking the other.""" + reason = _classify_reason( + **_reason_kwargs(new=0.3, executed_constrained=0.5), + contributing_constraints=["maximum_weight"], + constraint_before={"maximum_weight": 0.9}, + constraint_after={"maximum_weight": 0.5}, + turnover_touched=True, + turnover_actively_limited=True, + ) + assert reason.adjustment_codes == "maximum_weight+turnover_cap" + assert "maximum_weight: 0.9000 -> 0.5000" in (reason.adjustment_details or "") + assert "turnover_cap" in (reason.adjustment_details or "") + + +def test_classify_reason_multi_cause_adjustment_constraint_and_tradability() -> None: + reason = _classify_reason( + **_reason_kwargs(new=0.3, executed_constrained=0.5), + contributing_constraints=["maximum_weight"], + constraint_before={"maximum_weight": 0.9}, + constraint_after={"maximum_weight": 0.5}, + tradability_touched=True, + ) + assert reason.adjustment_codes == "maximum_weight+tradability" + + +def test_classify_reason_trigger_and_adjustment_coexist() -> None: + """A strategy-driven entry that is ALSO capped by a constraint must + show BOTH -- the original masking bug this redesign fixes.""" + reason = _classify_reason( + **_reason_kwargs( + new=0.3, + executed_constrained=0.5, + signal_now=1.0, + signal_prev=0.0, + ), + strategy_detail_code="oversold_entry", + contributing_constraints=["maximum_weight"], + constraint_before={"maximum_weight": 0.9}, + constraint_after={"maximum_weight": 0.5}, + ) + assert reason.trigger_code == "strategy_signal" + assert reason.trigger_detail_code == "oversold_entry" + assert reason.adjustment_codes == "maximum_weight" + + +def test_classify_reason_strategy_signal_wins_over_downstream_changes() -> None: + """Even when the allocator/desired-target ALSO changed (a signal change + always cascades downstream), strategy_signal must be reported -- the + most upstream, most specific cause -- not portfolio_rebalance or + volatility_target_adjustment.""" + reason = _classify_reason( + **_reason_kwargs( + signal_now=1.0, + signal_prev=0.0, + allocated_now=0.6, + allocated_prev=0.4, + desired_diag_now=0.6, + desired_diag_prev=0.4, + ) + ) + assert (reason.trigger_code, reason.trigger_detail_code) == ( + "strategy_signal", + None, + ) + + +def test_classify_reason_portfolio_rebalance() -> None: + reason = _classify_reason(**_reason_kwargs(allocated_now=0.6, allocated_prev=0.4)) + assert (reason.trigger_code, reason.trigger_detail_code) == ( + "portfolio_rebalance", + None, + ) + + +def test_classify_reason_volatility_target_adjustment() -> None: + reason = _classify_reason( + **_reason_kwargs(desired_diag_now=0.6, desired_diag_prev=0.4) + ) + assert (reason.trigger_code, reason.trigger_detail_code) == ( + "volatility_target_adjustment", + None, + ) + + +def test_classify_reason_position_rescaling_when_target_still_drifting() -> None: + """No trigger, no known adjustment layer, but the pre-turnover target + itself is still drifting row-over-row -- the pairs_trading price/beta + residual case.""" + reason = _classify_reason( + **_reason_kwargs( + new=0.5, previous=0.3, executed_desired=0.55, executed_desired_prev=0.5 + ) + ) + assert reason.adjustment_codes == "position_rescaling" + assert reason.trigger_code is None + + +def test_classify_reason_deferred_catchup_when_target_is_static() -> None: + """Nothing upstream changed since the last rebalance, the pre-turnover + target has been STATIC, yet the position still moved -- a turnover- + cap/tradability shortfall completing with no real cause identifiable + (genuinely unknown, not one of the real provenance signals).""" + reason = _classify_reason(**_reason_kwargs(new=0.5, previous=0.3)) + assert reason.adjustment_codes == "deferred_catchup" + assert reason.trigger_code is None + + +def test_classify_reason_position_rescaling_never_fires_alongside_a_trigger() -> None: + """Strict fallback guard (point 1): a value combination that would + satisfy position_rescaling's own condition must still be preempted by + a real trigger -- position_rescaling is reached ONLY via the `elif` + after trigger is confirmed None.""" + reason = _classify_reason( + **_reason_kwargs( + new=0.5, + previous=0.3, + executed_desired=0.55, + executed_desired_prev=0.5, + signal_now=1.0, + signal_prev=0.0, + ) + ) + assert reason.trigger_code == "strategy_signal" + assert reason.adjustment_codes is None + + +def test_classify_reason_position_rescaling_never_fires_alongside_real_adjustment() -> ( + None +): + """Strict fallback guard: a real adjustment layer (here, a contributing + constraint) must preempt position_rescaling even though the drifting- + target condition also holds.""" + reason = _classify_reason( + **_reason_kwargs( + new=0.3, + previous=0.3, + executed_constrained=0.5, + executed_desired=0.55, + executed_desired_prev=0.5, + ), + contributing_constraints=["maximum_weight"], + constraint_before={"maximum_weight": 0.9}, + constraint_after={"maximum_weight": 0.5}, + ) + assert reason.adjustment_codes == "maximum_weight" + assert "position_rescaling" not in reason.adjustment_codes + + +def test_classify_reason_unknown_when_nothing_explains_the_row() -> None: + reason = _classify_reason(**_reason_kwargs()) + assert reason == TradeReason( + trigger_code="unknown", + trigger_detail_code=None, + trigger_details="no upstream driver identified", + adjustment_codes=None, + adjustment_details=None, + ) + + +def test_classify_reason_strategy_detail_code_overrides_the_generic_text() -> None: + """reason_detail_code becomes the precise code, but reason_details + keeps the generic "signal X -> Y" text WITH the strategy-specific + text appended, never the specific text alone.""" + reason = _classify_reason( + **_reason_kwargs( + signal_now=1.0, + signal_prev=0.0, + strategy_detail_code="oversold_entry", + strategy_details="z-score -2.5000 crossed entry threshold -2.0000", + ) + ) + assert (reason.trigger_code, reason.trigger_detail_code) == ( + "strategy_signal", + "oversold_entry", + ) + assert reason.trigger_details == ( + "signal 0.0000 -> 1.0000 since last rebalance; " + "z-score -2.5000 crossed entry threshold -2.0000" + ) + + +def test_classify_reason_strategy_signal_without_detail_code_is_unchanged() -> None: + reason = _classify_reason(**_reason_kwargs(signal_now=1.0, signal_prev=0.0)) + assert (reason.trigger_code, reason.trigger_detail_code) == ( + "strategy_signal", + None, + ) + assert reason.trigger_details is not None + assert "since last rebalance" in reason.trigger_details + + +def test_classify_reason_forced_liquidation_overrides_every_other_adjustment() -> None: + """Once ruined, no other layer's specific clip value still explains + the executed weight -- forced_liquidation replaces the whole + adjustment list rather than composing with it.""" + reason = _classify_reason( + **_reason_kwargs(new=0.0, previous=0.5, executed_constrained=0.5), + contributing_constraints=["maximum_weight"], + constraint_before={"maximum_weight": 0.9}, + constraint_after={"maximum_weight": 0.5}, + turnover_touched=True, + turnover_actively_limited=True, + forced_liquidation=True, + ) + assert reason.adjustment_codes == "forced_liquidation" + + +def test_classify_reason_forced_liquidation_never_overrides_trigger() -> None: + """The strategy's own wish (trigger) survives even when the executed + weight was forced to zero -- the two are independent concepts.""" + reason = _classify_reason( + **_reason_kwargs( + new=0.0, + previous=0.5, + executed_constrained=0.5, + signal_now=1.0, + signal_prev=0.5, + ), + forced_liquidation=True, + ) + assert reason.trigger_code == "strategy_signal" + assert reason.adjustment_codes == "forced_liquidation" + + +def test_classify_reason_drift_compliance_overrides_ordinary_constraints() -> None: + """A row whose magnitude comes from the drift-compliance LP is NOT + decision-pipeline-driven at all, so it overrides an ordinary + constraint adjustment that would otherwise also apply to the same + row -- the constraint's own before/after clip value becomes moot.""" + reason = _classify_reason( + **_reason_kwargs(new=0.3, previous=0.5, executed_constrained=0.5), + contributing_constraints=["maximum_weight"], + constraint_before={"maximum_weight": 0.9}, + constraint_after={"maximum_weight": 0.5}, + drift_compliance_forced=True, + ) + assert reason.adjustment_codes == "drift_compliance" + + +def test_classify_reason_drift_compliance_pending_is_its_own_code() -> None: + """A still-unresolved drift breach (responsible symbol/group still + untradable) gets its own distinct code, not conflated with a landed + correction.""" + reason = _classify_reason( + **_reason_kwargs(new=0.3, previous=0.3), drift_compliance_pending=True + ) + assert reason.adjustment_codes == "drift_compliance_pending" + + +def test_classify_reason_stop_loss_overrides_drift_compliance() -> None: + """A stop-loss/take-profit breach detected on the drift-corrected + weight is a still more specific, more severe cause and wins over a + drift-compliance adjustment on the same row.""" + reason = _classify_reason( + **_reason_kwargs(new=0.0, previous=0.4), + drift_compliance_forced=True, + stop_loss_triggered=True, + ) + assert reason.adjustment_codes == "stop_loss" + + +def test_classify_reason_forced_liquidation_overrides_drift_compliance() -> None: + """Portfolio ruin is more severe than a drift-compliance correction.""" + reason = _classify_reason( + **_reason_kwargs(new=0.0, previous=0.4), + drift_compliance_forced=True, + forced_liquidation=True, + ) + assert reason.adjustment_codes == "forced_liquidation" + + +def test_classify_reason_every_branch_emits_only_adjustment_order_codes() -> None: + """Exhaustive sweep of every branch of _classify_reason (point 3): + every code it can ever emit must be a member of ADJUSTMENT_ORDER.""" + scenarios: list[dict[str, Any]] = [ + { + "contributing_constraints": ["maximum_weight"], + "constraint_before": {"maximum_weight": 0.9}, + "constraint_after": {"maximum_weight": 0.5}, + }, + { + "contributing_constraints": ["maximum_weight_redistribution"], + "constraint_before": {"maximum_weight_redistribution": 0.3}, + "constraint_after": {"maximum_weight_redistribution": 0.31}, + }, + {"tradability_touched": True}, + {"tradability_touched": True, "tradability_compliance_limited": True}, + {"turnover_touched": True, "turnover_actively_limited": True}, + {"turnover_touched": True, "turnover_actively_limited": False}, + {"stop_loss_triggered": True}, + {"take_profit_triggered": True}, + {"drift_compliance_forced": True}, + {"drift_compliance_pending": True}, + {"forced_liquidation": True}, + ] + for extra in scenarios: + reason = _classify_reason( + **_reason_kwargs(new=0.3, previous=0.3, executed_constrained=0.5), **extra + ) + if reason.adjustment_codes is not None: + for code in reason.adjustment_codes.split("+"): + assert code in ADJUSTMENT_ORDER + + fallback_reason = _classify_reason( + **_reason_kwargs( + new=0.5, previous=0.3, executed_desired=0.55, executed_desired_prev=0.5 + ) + ) + assert fallback_reason.adjustment_codes in ADJUSTMENT_ORDER + catchup_reason = _classify_reason(**_reason_kwargs(new=0.5, previous=0.3)) + assert catchup_reason.adjustment_codes in ADJUSTMENT_ORDER + + +# --------------------------------------------------------------------------- # +# serialize_adjustment_codes / parse_adjustment_codes +# --------------------------------------------------------------------------- # +def test_serialize_adjustment_codes_orders_by_pipeline_order_not_input_order() -> None: + assert ( + serialize_adjustment_codes(["turnover_cap", "maximum_weight", "tradability"]) + == "maximum_weight+tradability+turnover_cap" + ) + + +def test_serialize_adjustment_codes_single_name_has_no_separator() -> None: + assert serialize_adjustment_codes(["maximum_weight"]) == "maximum_weight" + + +def test_serialize_adjustment_codes_deduplicates() -> None: + assert ( + serialize_adjustment_codes(["maximum_weight", "maximum_weight", "tradability"]) + == "maximum_weight+tradability" + ) + + +def test_serialize_adjustment_codes_covers_every_adjustment_order_entry() -> None: + """Round-trips the full canonical order in one call as a sanity check + that ADJUSTMENT_ORDER and serialize_adjustment_codes stay in sync.""" + assert serialize_adjustment_codes(ADJUSTMENT_ORDER) == "+".join(ADJUSTMENT_ORDER) + + +def test_serialize_adjustment_codes_rejects_unknown_code() -> None: + with pytest.raises(BacktestError, match="Unknown adjustment code"): + serialize_adjustment_codes(["not_a_real_code"]) + + +def test_parse_adjustment_codes_round_trips_serialize() -> None: + codes = ["maximum_gross_exposure", "long_only"] + assert parse_adjustment_codes(serialize_adjustment_codes(codes)) == [ + "long_only", + "maximum_gross_exposure", + ] + + +def test_parse_adjustment_codes_single_token_has_nothing_to_split() -> None: + assert parse_adjustment_codes("tradability") == ["tradability"] + + +def test_parse_adjustment_codes_strict_rejects_unknown_code() -> None: + with pytest.raises(BacktestError, match="Unknown adjustment code"): + parse_adjustment_codes("not_a_real_code") + + +def test_parse_adjustment_codes_permissive_preserves_unknown_code() -> None: + assert parse_adjustment_codes("not_a_real_code", strict=False) == [ + "not_a_real_code" + ] + assert parse_adjustment_codes("maximum_weight+not_a_real_code", strict=False) == [ + "maximum_weight", + "not_a_real_code", + ] diff --git a/tests/unit/test_trade_reasons.py b/tests/unit/test_trade_reasons.py new file mode 100644 index 0000000..f50b0d4 --- /dev/null +++ b/tests/unit/test_trade_reasons.py @@ -0,0 +1,847 @@ +"""End-to-end reason-attribution scenarios via the real BacktestEngine. + +Unlike test_trade_log.py's direct _classify_reason unit tests (which pin +down the classifier's priority logic in isolation), these tests exercise +the actual engine-side plumbing added to feed it: capturing signals/ +allocated/desired-target, resampling them to rebalance dates, and aligning +them to accounting.executed_weights via the same executed_weights() shift +run_accounting uses internally. A scripted, deterministic BaseStrategy +subclass is used throughout instead of a registered strategy name, for +exact control over when the signal changes -- passed directly to +BacktestEngine.run() (which takes strategy/allocator as instances, not +resolved from config), matching the pattern already established by +test_reporting_hardening.py. +""" + +from __future__ import annotations + +from typing import cast + +import numpy as np +import pandas as pd +import pytest +from tests.conftest import make_ohlcv + +from quantlab.backtesting.engine import BacktestEngine +from quantlab.config import ExperimentConfig +from quantlab.execution.execution_model import ExecutionModel +from quantlab.portfolio.allocator import ( + EqualWeightAllocator, + InverseVolatilityAllocator, + build_allocator, +) +from quantlab.portfolio.rebalancing import rebalance_dates +from quantlab.strategies.base import BaseStrategy, SignalReasons +from quantlab.strategies.mean_reversion import MeanReversionStrategy + + +class _ScriptedStrategy(BaseStrategy): + """Returns a hand-specified signal path, ignoring the market data. + + ``schedule`` maps a symbol to its full signal path (one value per row + of whatever ``data`` the engine hands it, aligned positionally) -- + lets a test dictate exactly which date a signal changes, rather than + reverse-engineering a real strategy's parameters to do it indirectly. + """ + + name = "scripted" + + def __init__(self, schedule: dict[str, list[float]]) -> None: + self.schedule = schedule + + def generate_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> pd.DataFrame: + prices = self._prices(data) + signals = pd.DataFrame(0.0, index=prices.index, columns=prices.columns) + for symbol, path in self.schedule.items(): + signals[symbol] = path + return self._validate_signals(signals, prices) + + +class _ScriptedStrategyWithReasons(BaseStrategy): + """Like ``_ScriptedStrategy``, but also implements ``explain_signals()`` + with a hand-specified per-row reason schedule. + + Lets a test dictate exactly which RAW row carries the "true" + transition reason, independent of which row a later rebalance/ + execution step ends up consuming it on -- the crux of the alignment + fix under test. + """ + + name = "scripted_with_reasons" + + def __init__( + self, + schedule: dict[str, list[float]], + reason_schedule: dict[str, list[str | None]], + ) -> None: + self.schedule = schedule + self.reason_schedule = reason_schedule + + def generate_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> pd.DataFrame: + prices = self._prices(data) + signals = pd.DataFrame(0.0, index=prices.index, columns=prices.columns) + for symbol, path in self.schedule.items(): + signals[symbol] = path + return self._validate_signals(signals, prices) + + def explain_signals( + self, data: pd.DataFrame, features: pd.DataFrame | None = None + ) -> SignalReasons: + prices = self._prices(data) + detail_code = np.full(prices.shape, None, dtype=object) + details = np.full(prices.shape, None, dtype=object) + for symbol, path in self.reason_schedule.items(): + column_index = prices.columns.get_loc(symbol) + for row_index, code in enumerate(path): + if code is not None: + detail_code[row_index, column_index] = code + details[row_index, column_index] = f"scripted: {code}" + return self._validate_signal_reasons( + pd.DataFrame( + detail_code, index=prices.index, columns=prices.columns, dtype=object + ), + pd.DataFrame( + details, index=prices.index, columns=prices.columns, dtype=object + ), + prices, + ) + + +def _config(**portfolio_overrides: object) -> ExperimentConfig: + return ExperimentConfig.from_dict( + { + "experiment_name": "trade_reasons", + "data": { + "instruments": [{"symbol": "A", "source": "csv", "calendar": "XNYS"}], + "start_date": "2020-01-01", + "end_date": "2020-03-01", + }, + "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed + "portfolio": {"allocator": "equal_weight", **portfolio_overrides}, + "execution": { + "commission_bps": 0.0, + "spread_bps": 0.0, + "slippage_bps": 0.0, + }, + "backtest": {"initial_capital": 100_000}, + } + ) + + +def _run(schedule: dict[str, list[float]], config: ExperimentConfig) -> pd.DataFrame: + n = len(next(iter(schedule.values()))) + data = make_ohlcv("A", [100.0] * n, start="2020-01-01") + result = BacktestEngine().run( + data, + _ScriptedStrategy(schedule), + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + return result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + +def _run_strategy( + strategy: BaseStrategy, prices: list[float], config: ExperimentConfig +) -> pd.DataFrame: + data = make_ohlcv("A", prices, start="2020-01-01") + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + return result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + +def test_turnover_cap_then_deferred_catchup_on_the_same_entry() -> None: + """Signal jumps 0 -> 1 and stays there; a tight maximum_turnover (0.5) + forces the entry to land in two exact half-steps (0.5 then 1.0) instead + of one -- round numbers (0.5 x 2 = 1.0 exactly) sidestep float-precision + flakiness. Under the trigger/adjustment model, BOTH fills carry a real + `turnover_cap` adjustment (the second is an episode-scoped catch-up of + the same still-unresolved decision, not `deferred_catchup` -- that + fallback is reserved for a genuinely unknown cause) -- and the FIRST + fill ALSO correctly shows the real `strategy_signal` trigger that + caused it, no longer masked by the constraint (the core bug this + redesign fixes).""" + n = 20 + schedule = {"A": [0.0] * 5 + [1.0] * (n - 5)} + config = _config(rebalance_frequency="daily", maximum_turnover=0.5) + + trades = _run(schedule, config) + + assert len(trades) == 2 + assert trades.loc[0, "action"] == "entry_long" + assert trades.loc[0, "trigger_reason_code"] == "strategy_signal" + assert trades.loc[0, "adjustment_reason_codes"] == "turnover_cap" + assert "turnover-capped" in str(trades.loc[0, "adjustment_reason_details"]) + assert trades.loc[1, "action"] == "increase_long" + assert trades.loc[1, "trigger_reason_code"] is None + assert trades.loc[1, "adjustment_reason_codes"] == "turnover_cap" + assert "previously deferred" in str(trades.loc[1, "adjustment_reason_details"]) + + +def test_clean_signal_driven_entry_with_no_binding_constraint() -> None: + """No turnover cap, no portfolio constraint active -- the entry reaches + its full desired size in one fill, so this is a clean strategy_signal, + and (being the symbol's very first trade) needs no special-casing.""" + n = 10 + schedule = {"A": [0.0] * 3 + [1.0] * (n - 3)} + config = _config(rebalance_frequency="daily") + + trades = _run(schedule, config) + + assert len(trades) == 1 + assert trades.loc[0, "previous_weight"] == pytest.approx(0.0) + assert trades.loc[0, "new_weight"] == pytest.approx(1.0) + assert trades.loc[0, "action"] == "entry_long" + assert trades.loc[0, "trigger_reason_code"] == "strategy_signal" + assert trades.loc[0, "trigger_reason_detail_code"] is None + assert trades.loc[0, "trigger_reason_details"] is not None + assert trades.loc[0, "adjustment_reason_codes"] is None + + +def test_reverse_long_to_short_reports_the_correct_action_and_reason() -> None: + """A single-step long-to-short flip, unconstrained -- the action must + say "reverse", not the generic side="sell" a naive buy/sell label would + give a covering-and-shorting fill indistinguishable from a partial + reduction.""" + n = 12 + schedule = {"A": [0.0] * 3 + [1.0] * 3 + [-1.0] * (n - 6)} + config = _config(rebalance_frequency="daily") + + trades = _run(schedule, config) + + assert len(trades) == 2 + assert trades.loc[0, "action"] == "entry_long" + flip = trades.loc[1] + assert cast(float, flip["previous_weight"]) == pytest.approx(1.0) + assert cast(float, flip["new_weight"]) == pytest.approx(-1.0) + assert cast(str, flip["action"]) == "reverse_long_to_short" + assert cast(str, flip["trigger_reason_code"]) == "strategy_signal" + + +def test_portfolio_constraint_sub_code_when_max_weight_trims_the_target() -> None: + """maximum_weight=0.4 trims the allocator's desired 1.0 down to 0.4 + inside ConstraintSet itself, before turnover-cap ever runs (no turnover + cap configured here) -- must read as the precise constraint name + (maximum_weight) in `adjustment_reason_codes`. Under the trigger/ + adjustment model, this trade ALSO correctly keeps its `strategy_signal` + trigger -- the entry is no longer masked by the constraint that capped + its size (the core bug this redesign fixes).""" + n = 10 + schedule = {"A": [0.0] * 3 + [1.0] * (n - 3)} + config = _config(rebalance_frequency="daily", maximum_weight=0.4) + + trades = _run(schedule, config) + + assert len(trades) == 1 + assert trades.loc[0, "new_weight"] == pytest.approx(0.4) + assert trades.loc[0, "trigger_reason_code"] == "strategy_signal" + assert trades.loc[0, "adjustment_reason_codes"] == "maximum_weight" + + +def test_multiple_constraints_combine_into_one_adjustment_reason_codes() -> None: + """maximum_weight caps the single desired weight (1.0 -> 0.5), then + maximum_gross_exposure (tighter than the post-cap gross) rescales it + further (0.5 -> 0.3) -- both constraints genuinely fired on the same + fill and must both show up, joined via the canonical "+" convention + in pipeline-execution order, not just the last one to run -- alongside + the real `strategy_signal` trigger that caused the entry.""" + n = 10 + schedule = {"A": [0.0] * 3 + [1.0] * (n - 3)} + config = _config( + rebalance_frequency="daily", maximum_weight=0.5, maximum_gross_exposure=0.3 + ) + + trades = _run(schedule, config) + + assert len(trades) == 1 + assert trades.loc[0, "new_weight"] == pytest.approx(0.3) + assert trades.loc[0, "trigger_reason_code"] == "strategy_signal" + expected_codes = "maximum_weight+maximum_gross_exposure" + assert trades.loc[0, "adjustment_reason_codes"] == expected_codes + details = str(trades.loc[0, "adjustment_reason_details"]) + assert "maximum_weight" in details + assert "maximum_gross_exposure" in details + + +def test_mean_reversion_strategy_signal_gets_precise_reason_via_full_engine() -> None: + """The strategy-specific reason from MeanReversionStrategy. + explain_signals() must reach the trade log through the FULL engine + pipeline -- rebalance-date sampling, the extra-delay shift and the + executed_weights alignment, via the positional gather in engine.py -- + not just work when the classifier or the strategy is tested alone.""" + n = 60 + prices = list(np.full(40, 100.0)) + list(np.linspace(100, 70, n - 40)) + config = ExperimentConfig.from_dict( + { + "experiment_name": "mean_reversion_full_engine", + "data": { + "instruments": [{"symbol": "A", "source": "csv", "calendar": "XNYS"}], + "start_date": "2020-01-01", + "end_date": "2020-06-01", + }, + "strategy": {"name": "mean_reversion"}, # unused: an instance is passed + "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"}, + "execution": { + "commission_bps": 0.0, + "spread_bps": 0.0, + "slippage_bps": 0.0, + }, + "backtest": {"initial_capital": 100_000}, + } + ) + strategy = MeanReversionStrategy( + lookback_period=20, entry_threshold=1.5, exit_threshold=0.5, long_only=True + ) + + trades = _run_strategy(strategy, prices, config) + + entries = trades[trades["action"] == "entry_long"] + assert len(entries) == 1 + assert entries.iloc[0]["trigger_reason_code"] == "strategy_signal" + assert entries.iloc[0]["trigger_reason_detail_code"] == "oversold_entry" + assert "entry threshold" in entries.iloc[0]["trigger_reason_details"] + + +# --------------------------------------------------------------------------- # +# position_strategy_origin: driven purely by decision_proxy's own regime +# (flat/long/short via sign) -- cleared only when decision_proxy itself +# returns to flat, never by a downstream layer, and insensitive to a +# continuous signal's own magnitude drift. +# --------------------------------------------------------------------------- # +def test_position_strategy_origin_entry_exit_flat_then_new_entry() -> None: + n = 20 + schedule = {"A": [0.0] * 3 + [1.0] * 4 + [0.0] * 4 + [1.0] * (n - 11)} + reason_schedule: dict[str, list[str | None]] = {"A": [None] * n} + reason_schedule["A"][3] = "first_entry" + reason_schedule["A"][7] = "first_exit" + reason_schedule["A"][11] = "second_entry" + config = _config(rebalance_frequency="daily") + strategy = _ScriptedStrategyWithReasons({"A": schedule["A"]}, reason_schedule) + data = make_ohlcv("A", [100.0] * n, start="2020-01-01") + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + entries = trades[trades["action"] == "entry_long"].reset_index(drop=True) + exits = trades[trades["action"] == "exit_long"].reset_index(drop=True) + + assert len(entries) == 2 + assert len(exits) == 1 + assert entries.loc[0, "position_strategy_origin_code"] == "first_entry" + # Origin is cleared the moment decision_proxy itself returns to flat. + assert pd.isna(exits.loc[0, "position_strategy_origin_timestamp"]) + assert exits.loc[0, "position_strategy_origin_code"] is None + assert entries.loc[1, "position_strategy_origin_code"] == "second_entry" + assert cast( + pd.Timestamp, entries.loc[1, "position_strategy_origin_timestamp"] + ) > cast(pd.Timestamp, entries.loc[0, "position_strategy_origin_timestamp"]) + + +def test_position_strategy_origin_reversal_replaces_not_merges() -> None: + n = 15 + schedule = {"A": [0.0] * 3 + [1.0] * 4 + [-1.0] * (n - 7)} + reason_schedule: dict[str, list[str | None]] = {"A": [None] * n} + reason_schedule["A"][3] = "long_entry" + reason_schedule["A"][7] = "short_entry" + config = _config(rebalance_frequency="daily") + strategy = _ScriptedStrategyWithReasons({"A": schedule["A"]}, reason_schedule) + data = make_ohlcv("A", [100.0] * n, start="2020-01-01") + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + reversal = trades[trades["action"] == "reverse_long_to_short"].iloc[0] + assert reversal["position_strategy_origin_code"] == "short_entry" + + +def test_position_strategy_origin_insensitive_to_continuous_magnitude_drift() -> None: + """A continuous signal's own magnitude drift (0.4 -> 0.5 -> 0.3 -> 0.6, + same regime throughout) must never recreate the origin -- only a + flat<->non-flat regime change does. A second, constant-signal symbol B + is included so signal_proportional's relative split actually moves A's + executed weight as A's own magnitude drifts (a single-asset universe + would always normalize to a constant sign-only weight, masking drift).""" + n = 20 + schedule_a = [0.0] * 3 + [0.4, 0.5, 0.3, 0.6] + [0.0] * (n - 7) + schedule_b = [0.5] * n + reason_schedule: dict[str, list[str | None]] = { + "A": [None] * n, + "B": [None] * n, + } + reason_schedule["A"][3] = "continuous_entry" + config = ExperimentConfig.from_dict( + { + "experiment_name": "trade_reasons_continuous_drift", + "data": { + "instruments": [ + {"symbol": "A", "source": "csv", "calendar": "XNYS"}, + {"symbol": "B", "source": "csv", "calendar": "XNYS"}, + ], + "start_date": "2020-01-01", + "end_date": "2020-03-01", + }, + "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed + "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"}, + "execution": { + "commission_bps": 0.0, + "spread_bps": 0.0, + "slippage_bps": 0.0, + }, + "backtest": {"initial_capital": 100_000}, + } + ) + strategy = _ScriptedStrategyWithReasons( + {"A": schedule_a, "B": schedule_b}, reason_schedule + ) + data_a = make_ohlcv("A", [100.0] * n, start="2020-01-01") + data_b = make_ohlcv("B", [100.0] * n, start="2020-01-01") + data = pd.concat([data_a, data_b], ignore_index=True) + result = BacktestEngine().run( + data, + strategy, + build_allocator("signal_proportional"), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + held_trades = trades[ + trades["action"].isin(["entry_long", "increase_long", "reduce_long"]) + ] + + assert len(held_trades) >= 2 # entry + at least one magnitude-drift fill + origins = held_trades["position_strategy_origin_code"].unique().tolist() + assert origins == ["continuous_entry"] + timestamps = held_trades["position_strategy_origin_timestamp"].unique() + assert len(timestamps) == 1 + + +def test_position_strategy_origin_survives_allocator_warmup_before_first_trade() -> ( + None +): + """buy_and_hold-style scenario (points 2/7): the strategy's decision + becomes active immediately, but InverseVolatilityAllocator only + produces its first non-zero weight once its volatility window fills + -- the resulting first trade's TRIGGER is portfolio_rebalance (no NEW + signal transition that day), but position_strategy_origin must still + correctly point back to the original strategic decision -- confirming + it tracks the strategic regime, not the executed-weight episode.""" + n = 40 + schedule = {"A": [1.0] * n} # active from day 0 + reason_schedule: dict[str, list[str | None]] = {"A": [None] * n} + reason_schedule["A"][0] = "price_became_available" + config = _config(rebalance_frequency="daily") + strategy = _ScriptedStrategyWithReasons(schedule, reason_schedule) + rng = np.random.default_rng(0) + prices = list(100.0 * np.cumprod(1.0 + rng.normal(0.0005, 0.01, n))) + data = make_ohlcv("A", prices, start="2020-01-01") + result = BacktestEngine().run( + data, + strategy, + # A short volatility_window guarantees the warmup mismatch + # (signal active from day 0, allocator only produces its first + # non-zero weight once its own window fills) resolves well within + # this test's 40-row window. + InverseVolatilityAllocator(volatility_window=10), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + assert len(trades) >= 1 + first_trade = trades.iloc[0] + assert first_trade["action"] == "entry_long" + assert first_trade["trigger_reason_code"] == "portfolio_rebalance" + assert first_trade["position_strategy_origin_code"] == "price_became_available" + assert pd.notna(first_trade["position_strategy_origin_timestamp"]) + assert first_trade["position_strategy_origin_timestamp"] <= first_trade["timestamp"] + + +def test_forced_liquidation_after_ruin_is_correctly_unrepresented_not_fabricated() -> ( + None +): + """A full short position hit by a price spike large enough to floor + the period return at exactly -100% (see accounting.py's own + `_floor_at_total_loss`) ruins the portfolio. `AccountingResult. + force_flat` (see `_run_accounting_steps`'s own docstring: "preventing + a closing trade with no remaining capital") deliberately zeroes + `weight_changes` on every ruined date -- there is genuinely no + capital left to execute a real closing trade, so NO trade-log row is + -- correctly -- ever produced for the liquidation moment itself, even + though `positions`/`equity_curve` show the position and equity + dropping to zero there. The `forced_liquidation` adjustment wiring + (`executed_forced_liquidation`, sourced from the real + `AccountingResult.ruined`) exists for whichever cell/row combination + WOULD have a recorded change on a ruined date; today's accounting + semantics make that combination unreachable, and this test pins that + down explicitly so a future accounting change that DOES produce such + a row is caught by the adjacent `_classify_reason` unit test + (`test_classify_reason_forced_liquidation_*` in test_trade_log.py) + rather than silently reverting to `unknown`/`deferred_catchup`. + + `model_weight_drift` is explicitly disabled: a position held constant + for several days before one catastrophic spike is exactly the shape + `apply_weight_drift`'s OWN, separate per-episode bankruptcy guard + (`E <= EPSILON`) is designed to catch -- with drift enabled (the + default), that guard fires first and flattens the position before + `AccountingResult`'s own absolute-equity `ruined` mechanism, the one + this test specifically targets, ever sees the floored return. The two + guards are independent and BOTH legitimate (see `apply_weight_drift`'s + own docstring); this test isolates the one it is actually about.""" + n = 15 + schedule = {"A": [0.0] * 3 + [-1.0] * (n - 3)} # full short from day 3 + # Price flat through day 4, then a +300% spike on day 5 -- applied to + # the already-short position decided the prior day, this floors that + # period's return at exactly -100% (total loss). + prices = [100.0] * 5 + [400.0] * (n - 5) + config = _config(rebalance_frequency="daily", model_weight_drift=False) + data = make_ohlcv("A", prices, start="2020-01-01") + result = BacktestEngine().run( + data, + _ScriptedStrategy(schedule), + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + # Ruin genuinely happened (equity hits exactly zero, and the position + # is force-flattened starting the NEXT date -- `ruined` itself is a + # shift(1) of the equity condition, see run_accounting) ... + assert (result.equity_curve <= 0.0).any() + first_ruin_date = result.equity_curve.index[result.equity_curve <= 0.0][0] + after_ruin = result.positions["A"].loc[result.positions.index > first_ruin_date] + assert len(after_ruin) > 0 + assert (after_ruin == 0.0).all() + # ... but no trade row exists for it (no capital to execute a real + # closing trade) -- exactly one trade total, the original entry. + assert len(trades) == 1 + assert trades.iloc[0]["action"] == "entry_short" + # No row anywhere is left unattributed or mis-attributed as a result. + assert not (trades["trigger_reason_code"] == "unknown").any() + assert not (trades["adjustment_reason_codes"] == "deferred_catchup").any() + + +# --------------------------------------------------------------------------- # +# Alignment: the strategy-specific reason must follow the transition that +# actually produced the executed signal value, not the row a later +# rebalance/execution step happens to sample. +# --------------------------------------------------------------------------- # +def _weekly_rebalance_positions(n: int) -> list[int]: + """Row positions of each weekly rebalance date for an n-row XNYS index + starting 2020-01-01 -- computed via the real rebalance_dates(), not + guessed, so the test never silently drifts from actual calendar + behaviour.""" + data = make_ohlcv("A", [100.0] * n, start="2020-01-01") + index = pd.DatetimeIndex(data["timestamp"].drop_duplicates().sort_values()) + dates = rebalance_dates(index, "weekly", calendar="XNYS") + return [int(position) for position in index.get_indexer(dates)] + + +def test_off_cycle_strategy_reason_survives_until_the_next_rebalance() -> None: + """A transition on a day that is NOT a rebalance date must still be + the reason attached to the trade once the persisting signal is + finally rebalanced/executed -- not None (the sampled rebalance-date + row's own, empty, reason cell) and not some unrelated later reason.""" + n = 30 + positions = _weekly_rebalance_positions(n) + # positions e.g. [0, 3, 8, 13, ...] -- pick a row strictly between the + # 2nd and 3rd rebalance date (never rely on a fixed magic number). + previous_rebalance, next_rebalance = positions[1], positions[2] + off_cycle = previous_rebalance + 1 + assert off_cycle < next_rebalance, "need at least one off-cycle row" + + schedule_a = [0.0] * off_cycle + [1.0] * (n - off_cycle) + reason_schedule_a: list[str | None] = [None] * n + reason_schedule_a[off_cycle] = "off_cycle_entry" + + config = _config(rebalance_frequency="weekly") + strategy = _ScriptedStrategyWithReasons({"A": schedule_a}, {"A": reason_schedule_a}) + data = make_ohlcv("A", [100.0] * n, start="2020-01-01") + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + assert len(trades) == 1 + assert trades.loc[0, "trigger_reason_code"] == "strategy_signal" + assert trades.loc[0, "trigger_reason_detail_code"] == "off_cycle_entry" + assert "scripted: off_cycle_entry" in str(trades.loc[0, "trigger_reason_details"]) + + +def test_the_last_of_two_off_cycle_transitions_wins() -> None: + """Two transitions happen before the next rebalance consumes them -- + the trade must carry the LATER transition's reason, not the earlier + one (a newer transition always overwrites an older one, see + engine.py's last_transition_seed).""" + n = 30 + positions = _weekly_rebalance_positions(n) + previous_rebalance, next_rebalance = positions[1], positions[2] + first_step = previous_rebalance + 1 + second_step = first_step + 1 + assert second_step < next_rebalance, ( + "need two off-cycle rows before the next rebalance" + ) + + schedule_a = ( + [0.0] * first_step + + [0.5] * (second_step - first_step) + + [1.0] * (n - second_step) + ) + reason_schedule_a: list[str | None] = [None] * n + reason_schedule_a[first_step] = "first_step" + reason_schedule_a[second_step] = "second_step" + + config = _config(rebalance_frequency="weekly") + strategy = _ScriptedStrategyWithReasons({"A": schedule_a}, {"A": reason_schedule_a}) + data = make_ohlcv("A", [100.0] * n, start="2020-01-01") + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + assert len(trades) == 1 + assert trades.loc[0, "trigger_reason_detail_code"] == "second_step" + reason_details = str(trades.loc[0, "trigger_reason_details"]) + assert "scripted: second_step" in reason_details + assert "first_step" not in reason_details + + +def test_off_cycle_strategy_reason_survives_an_execution_delay() -> None: + """The alignment fix must still work once an extra execution_delay + shift is layered on top of the rebalance-date sampling.""" + n = 30 + positions = _weekly_rebalance_positions(n) + previous_rebalance, next_rebalance = positions[1], positions[2] + off_cycle = previous_rebalance + 1 + assert off_cycle < next_rebalance, "need at least one off-cycle row" + + schedule_a = [0.0] * off_cycle + [1.0] * (n - off_cycle) + reason_schedule_a: list[str | None] = [None] * n + reason_schedule_a[off_cycle] = "off_cycle_entry" + + config = _config(rebalance_frequency="weekly") + strategy = _ScriptedStrategyWithReasons({"A": schedule_a}, {"A": reason_schedule_a}) + data = make_ohlcv("A", [100.0] * n, start="2020-01-01") + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + execution_delay=2, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + assert len(trades) == 1 + assert trades.loc[0, "trigger_reason_detail_code"] == "off_cycle_entry" + assert "scripted: off_cycle_entry" in str(trades.loc[0, "trigger_reason_details"]) + + +def test_off_cycle_strategy_reason_survives_a_non_tradable_gap() -> None: + """The alignment fix must still work when the symbol has its own + calendar closures (a mixed-calendar universe, so engine.py actually + computes a per-symbol ``tradable`` mask instead of taking the + ``tradable=None`` fast path).""" + n = 30 + positions = _weekly_rebalance_positions(n) + previous_rebalance, next_rebalance = positions[1], positions[2] + off_cycle = previous_rebalance + 1 + assert off_cycle < next_rebalance, "need at least one off-cycle row" + + schedule_a = [0.0] * off_cycle + [1.0] * (n - off_cycle) + schedule_b = [0.0] * n + reason_schedule_a: list[str | None] = [None] * n + reason_schedule_a[off_cycle] = "off_cycle_entry" + reason_schedule_b: list[str | None] = [None] * n + + # Two instruments on DIFFERENT calendars -- forces engine.py to build + # a real per-symbol tradable mask (uniform_calendar returns None + # otherwise, and the whole tradable-mask machinery is skipped). + config = ExperimentConfig.from_dict( + { + "experiment_name": "trade_reasons_tradability", + "data": { + "instruments": [ + {"symbol": "A", "source": "csv", "calendar": "XNYS"}, + {"symbol": "B", "source": "csv", "calendar": "24/7"}, + ], + "start_date": "2020-01-01", + "end_date": "2020-03-01", + }, + "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed + "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "weekly"}, + "execution": { + "commission_bps": 0.0, + "spread_bps": 0.0, + "slippage_bps": 0.0, + }, + "backtest": {"initial_capital": 100_000, "periods_per_year": 252}, + } + ) + strategy = _ScriptedStrategyWithReasons( + {"A": schedule_a, "B": schedule_b}, + {"A": reason_schedule_a, "B": reason_schedule_b}, + ) + data_a = make_ohlcv("A", [100.0] * n, start="2020-01-01") + data_b = make_ohlcv("B", [100.0] * n, start="2020-01-01") + data = pd.concat([data_a, data_b], ignore_index=True) + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + trades = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + + assert len(trades) == 1 + assert trades.loc[0, "trigger_reason_detail_code"] == "off_cycle_entry" + assert "scripted: off_cycle_entry" in str(trades.loc[0, "trigger_reason_details"]) + + +# --------------------------------------------------------------------------- # +# stop_loss_pct/take_profit_pct: end-to-end through the real BacktestEngine, +# operating on the REAL executed position (never the raw signal) -- see +# quantlab.backtesting.accounting._detect_stop_loss_take_profit. +# --------------------------------------------------------------------------- # +class _ScriptedPairStrategy(_ScriptedStrategy): + """Like `_ScriptedStrategy`, but declares its two symbols as one + `position_groups()` -- the pairs_trading pattern, for an end-to-end + test that a stop-loss triggers on the GROUP's combined P&L.""" + + name = "scripted_pair" + + def __init__( + self, schedule: dict[str, list[float]], symbol_a: str, symbol_b: str + ) -> None: + super().__init__(schedule) + self._symbol_a = symbol_a + self._symbol_b = symbol_b + + def position_groups(self) -> tuple[tuple[str, ...], ...] | None: + return ((self._symbol_a, self._symbol_b),) + + +def test_stop_loss_pct_closes_a_real_position_and_the_trade_log_shows_it() -> None: + """Long throughout, -6% then another -6% (cumulative -11.64%, past a + 10% stop): the loss-realizing bar keeps its return, and the position + is closed the FOLLOWING bar with a real, non-zero exit trade whose + adjustment_reason_codes is exactly 'stop_loss' -- end-to-end proof + that the mechanism operates on the real executed position and is + correctly surfaced in the trade log.""" + n = 8 + schedule = {"A": [1.0] * n} + prices = [100.0, 100.0, 94.0, 88.36, 88.36, 88.36, 88.36, 88.36] + config = _config(rebalance_frequency="daily") + strategy = _ScriptedStrategy(schedule) + strategy.stop_loss_pct = 0.10 + + trades = _run_strategy(strategy, prices, config) + + exits = trades[trades["action"] == "exit_long"] + assert len(exits) == 1 + assert exits.iloc[0]["adjustment_reason_codes"] == "stop_loss" + assert "stop_loss_pct" in str(exits.iloc[0]["adjustment_reason_details"]) + + +def test_stop_loss_pct_none_by_default_changes_nothing_end_to_end() -> None: + """The critical non-regression guarantee at the engine level: a + strategy that never sets stop_loss_pct/take_profit_pct (the default + on every built-in strategy) must produce a trade log identical to + today's, with no 'stop_loss'/'take_profit' adjustment ever appearing.""" + n = 8 + schedule = {"A": [1.0] * n} + prices = [100.0, 100.0, 94.0, 88.36, 88.36, 88.36, 88.36, 88.36] + config = _config(rebalance_frequency="daily") + strategy = _ScriptedStrategy(schedule) + + trades = _run_strategy(strategy, prices, config) + + assert not (trades["adjustment_reason_codes"] == "stop_loss").any() + assert not (trades["adjustment_reason_codes"] == "take_profit").any() + assert len(trades[trades["action"] == "exit_long"]) == 0 + + +def test_position_groups_stop_loss_closes_both_legs_together() -> None: + """A scripted pair strategy (mirroring pairs_trading's own + `position_groups()`) with legs A=+1.0/B=-1.0 scaled down to +0.5/-0.5 + mid-hold (a rebalance) -- A drops 20%/20%, B is flat, so the GROUP's + combined return per unit of ITS OWN exposure breaches a 15% stop + (cumulative 0.90*0.90-1=-19%) regardless of the leg-size change. + BOTH legs must show the exit AND the 'stop_loss' code on the SAME + date -- proof position_groups() is honored end-to-end, not just at + the accounting-layer unit test level.""" + schedule_a = [1.0, 1.0, 0.5, 0.5, 0.5, 0.5] + schedule_b = [-1.0, -1.0, -0.5, -0.5, -0.5, -0.5] + prices_a = [100.0, 100.0, 80.0, 64.0, 64.0, 64.0] + prices_b = [100.0, 100.0, 100.0, 100.0, 100.0, 100.0] + config = ExperimentConfig.from_dict( + { + "experiment_name": "trade_reasons_stop_loss_pair", + "data": { + "instruments": [ + {"symbol": "A", "source": "csv", "calendar": "XNYS"}, + {"symbol": "B", "source": "csv", "calendar": "XNYS"}, + ], + "start_date": "2020-01-01", + "end_date": "2020-03-01", + }, + "strategy": {"name": "buy_and_hold"}, # unused: an instance is passed + "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"}, + "execution": { + "commission_bps": 0.0, + "spread_bps": 0.0, + "slippage_bps": 0.0, + }, + "backtest": {"initial_capital": 100_000}, + } + ) + strategy = _ScriptedPairStrategy({"A": schedule_a, "B": schedule_b}, "A", "B") + strategy.stop_loss_pct = 0.15 + data_a = make_ohlcv("A", prices_a, start="2020-01-01") + data_b = make_ohlcv("B", prices_b, start="2020-01-01") + data = pd.concat([data_a, data_b], ignore_index=True) + + result = BacktestEngine().run( + data, + strategy, + EqualWeightAllocator(), + ExecutionModel.from_config(config.execution), + config, + ) + + trades_a = result.trades[result.trades["symbol"] == "A"].reset_index(drop=True) + trades_b = result.trades[result.trades["symbol"] == "B"].reset_index(drop=True) + stop_a = trades_a[trades_a["adjustment_reason_codes"] == "stop_loss"] + stop_b = trades_b[trades_b["adjustment_reason_codes"] == "stop_loss"] + assert len(stop_a) == 1 + assert len(stop_b) == 1 + assert stop_a.iloc[0]["timestamp"] == stop_b.iloc[0]["timestamp"] + assert stop_a.iloc[0]["action"] == "exit_long" + assert stop_b.iloc[0]["action"] == "exit_short" diff --git a/tests/unit/test_validation.py b/tests/unit/test_validation.py index 5c6fac1..729f879 100644 --- a/tests/unit/test_validation.py +++ b/tests/unit/test_validation.py @@ -4,7 +4,7 @@ import math from pathlib import Path -from typing import Any, TypedDict +from typing import Any, TypedDict, cast import numpy as np import pandas as pd @@ -115,8 +115,8 @@ def _cost_sensitive_config(commission_bps: float = 50.0) -> ExperimentConfig: "name": "mean_reversion", "parameters": { "lookback_period": 10, - "entry_zscore": 1.0, - "exit_zscore": 0.1, + "entry_threshold": 1.0, + "exit_threshold": 0.1, }, }, "portfolio": {"allocator": "equal_weight", "rebalance_frequency": "daily"}, @@ -135,7 +135,7 @@ def _cost_sensitive_config(commission_bps: float = 50.0) -> ExperimentConfig: # Pinned explicitly: run_walk_forward_stress_tests() resolves # its grid from this config (parameter_grid_for_config), not # from an argument, so it must match the grid used below. - "parameter_grid": {"entry_zscore": [0.5, 3.0]}, + "parameter_grid": {"entry_threshold": [0.5, 3.0]}, }, } ) @@ -368,6 +368,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object): train_window=300, validation_window=120, test_window=120, + step=120, expanding=True, execution_delay=0, parameter_grid={"lookback_period": [60, 120]}, @@ -455,6 +456,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object): train_window=300, validation_window=120, test_window=120, + step=120, expanding=True, execution_delay=0, parameter_grid={"lookback_period": [60, 120]}, @@ -539,6 +541,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object): train_window=300, validation_window=120, test_window=120, + step=120, expanding=True, execution_delay=0, parameter_grid={"lookback_period": [60, 120]}, @@ -621,6 +624,7 @@ def _flaky_select(self: WalkForwardValidator, *args: object, **kwargs: object): train_window=300, validation_window=120, test_window=120, + step=120, expanding=True, execution_delay=0, parameter_grid={"lookback_period": [60, 120]}, @@ -863,13 +867,13 @@ def test_run_walk_forward_stress_tests_reselects_parameters_under_higher_costs() ): """The methodological point of the whole process-level/returns-level split: a Walk-forward mode stress scenario must genuinely re-run - selection, not rescale a fixed baseline's weights. entry_zscore=0.5 + selection, not rescale a fixed baseline's weights. entry_threshold=0.5 trades far more often than 3.0 on this mean-reverting panel, so it must lose ground once commission is stressed 5x — and the stress-test function's own numbers must come from that re-selected run.""" data = _cost_sensitive_panel() config = _cost_sensitive_config(commission_bps=50.0) - grid = {"entry_zscore": [0.5, 3.0]} + grid = {"entry_threshold": [0.5, 3.0]} windows: _WalkForwardWindows = { "train_window": 150, "validation_window": 60, @@ -879,7 +883,9 @@ def test_run_walk_forward_stress_tests_reselects_parameters_under_higher_costs() wf_baseline = WalkForwardValidator(config).run(data, parameter_grid=grid, **windows) assert wf_baseline.oos_result is not None - baseline_choices = [fold.best_params["entry_zscore"] for fold in wf_baseline.folds] + baseline_choices = [ + fold.best_params["entry_threshold"] for fold in wf_baseline.folds + ] # The high-turnover parameter must win at least one fold at baseline cost # — otherwise there is nothing for higher costs to knock it away from. assert 0.5 in baseline_choices @@ -887,7 +893,7 @@ def test_run_walk_forward_stress_tests_reselects_parameters_under_higher_costs() x5_config = scale_costs(config, commission_mult=5.0) wf_x5 = WalkForwardValidator(x5_config).run(data, parameter_grid=grid, **windows) assert wf_x5.oos_result is not None - x5_choices = [fold.best_params["entry_zscore"] for fold in wf_x5.folds] + x5_choices = [fold.best_params["entry_threshold"] for fold in wf_x5.folds] # The core assertion: re-running walk-forward selection under 5x # commission actually changes which parameter wins on at least one fold. @@ -912,7 +918,7 @@ def test_run_with_weight_cache_matches_plain_run() -> None: by-product of the same computation, not a different one.""" data = _cost_sensitive_panel() config = _cost_sensitive_config(commission_bps=50.0) - grid = {"entry_zscore": [0.5, 3.0]} + grid = {"entry_threshold": [0.5, 3.0]} windows: _WalkForwardWindows = { "train_window": 150, "validation_window": 60, @@ -942,7 +948,7 @@ def test_run_with_weight_cache_resumes_from_a_checkpoint_and_matches_a_fresh_run resumable and bit-for-bit reproducible on its own.""" data = _cost_sensitive_panel() config = _cost_sensitive_config(commission_bps=50.0) - grid = {"entry_zscore": [0.5, 3.0]} + grid = {"entry_threshold": [0.5, 3.0]} windows: _WalkForwardWindows = { "train_window": 150, "validation_window": 60, @@ -1003,7 +1009,7 @@ def test_run_with_weight_cache_refuses_a_checkpoint_with_a_mismatched_candidate_ data = _cost_sensitive_panel() config = _cost_sensitive_config(commission_bps=50.0) - grid = {"entry_zscore": [0.5, 3.0]} + grid = {"entry_threshold": [0.5, 3.0]} windows: _WalkForwardWindows = { "train_window": 150, "validation_window": 60, @@ -1040,9 +1046,10 @@ def _flaky_capture(self: WalkForwardValidator, *args: object, **kwargs: object): train_window=150, validation_window=60, test_window=60, + step=60, expanding=True, execution_delay=0, - parameter_grid={"entry_zscore": [0.5, 3.0]}, + parameter_grid={"entry_threshold": [0.5, 3.0]}, ) loaded = load_checkpoint(checkpoint_path, provenance) assert loaded is not None @@ -1084,7 +1091,7 @@ def test_run_with_weight_cache_refuses_a_checkpoint_with_a_corrupted_candidate_f data = _cost_sensitive_panel() config = _cost_sensitive_config(commission_bps=50.0) - grid = {"entry_zscore": [0.5, 3.0]} + grid = {"entry_threshold": [0.5, 3.0]} windows: _WalkForwardWindows = { "train_window": 150, "validation_window": 60, @@ -1121,9 +1128,10 @@ def _flaky_capture(self: WalkForwardValidator, *args: object, **kwargs: object): train_window=150, validation_window=60, test_window=60, + step=60, expanding=True, execution_delay=0, - parameter_grid={"entry_zscore": [0.5, 3.0]}, + parameter_grid={"entry_threshold": [0.5, 3.0]}, ) loaded = load_checkpoint(checkpoint_path, provenance) assert loaded is not None @@ -1164,7 +1172,7 @@ def test_rescore_with_costs_matches_a_fresh_scenario_run() -> None: faster way to compute the same answer, not an approximation.""" data = _cost_sensitive_panel() config = _cost_sensitive_config(commission_bps=50.0) - grid = {"entry_zscore": [0.5, 3.0]} + grid = {"entry_threshold": [0.5, 3.0]} windows: _WalkForwardWindows = { "train_window": 150, "validation_window": 60, @@ -1206,7 +1214,9 @@ def test_run_walk_forward_stress_tests_best_days_removed_reuses_baseline() -> No # and windows (resolve_walk_forward_windows(config)), so both must match # what the baseline below is built with. config = _config_with_grid(grid) - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, _step = resolve_walk_forward_windows( + config + ) wf_baseline = WalkForwardValidator(config).run( data, parameter_grid=grid, @@ -1312,7 +1322,9 @@ def test_run_walk_forward_stress_tests_reports_scenario_progress() -> None: # way real callers (dashboard/CLI) do — otherwise its fold count # wouldn't match the weight cache's, which this progress accounting # relies on. - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, _step = resolve_walk_forward_windows( + config + ) wf_baseline = WalkForwardValidator(config).run( data, parameter_grid=grid, @@ -1359,7 +1371,9 @@ def test_run_walk_forward_stress_tests_resumes_the_weight_cache_build( data = _panel() grid = {"lookback_period": [60, 120]} config = _config_with_grid(grid) - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, _step = resolve_walk_forward_windows( + config + ) wf_baseline = WalkForwardValidator(config).run( data, parameter_grid=grid, @@ -1435,7 +1449,9 @@ def test_run_walk_forward_stress_tests_resume_after_cost_block_runs_every_later_ data = _panel() config = _config_with_grid({}) - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, _step = resolve_walk_forward_windows( + config + ) wf_baseline = WalkForwardValidator(config).run( data, parameter_grid={}, @@ -1490,7 +1506,9 @@ def test_run_walk_forward_stress_tests_refuses_a_checkpoint_with_wrong_scenario_ data = _panel() config = _config_with_grid({}) - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, _step = resolve_walk_forward_windows( + config + ) wf_baseline = WalkForwardValidator(config).run( data, parameter_grid={}, @@ -1543,7 +1561,9 @@ def test_run_walk_forward_stress_tests_refuses_a_checkpoint_with_an_inconsistent data = _panel() config = _config_with_grid({}) - train_window, validation_window, test_window = resolve_walk_forward_windows(config) + train_window, validation_window, test_window, _step = resolve_walk_forward_windows( + config + ) wf_baseline = WalkForwardValidator(config).run( data, parameter_grid={}, @@ -1709,14 +1729,13 @@ def test_walk_forward_parameter_sensitivity_refuses_a_checkpoint_with_a_garbage_ def test_walk_forward_parameter_sensitivity_recovers_from_a_pd_na_checkpoint( tmp_path: Path, ) -> None: - """A checkpointed cell whose swept-parameter value is ``pd.NA`` used to - crash the resume path with ``TypeError: boolean value of NA is - ambiguous`` deep inside the comparison that checks a cell's value - against its expected combination -- an exception that then propagated - out of ``load_checkpoint`` despite its own contract that a corrupted - checkpoint is only ever skipped, never raised. Must now be treated as - "not a match" and trigger a fresh recompute instead of crashing the - whole sweep.""" + """A checkpointed cell whose swept-parameter value is ``pd.NA`` must be + treated as "not a match" and trigger a fresh recompute instead of + crashing the whole sweep: the comparison that checks a cell's value + against its expected combination must never let ``TypeError: boolean + value of NA is ambiguous`` propagate out of ``load_checkpoint``, + whose own contract is that a corrupted checkpoint is only ever + skipped, never raised.""" from quantlab.validation.checkpoint import compute_provenance, save_checkpoint data = _panel() @@ -1795,7 +1814,39 @@ def test_bootstrap_summary_percentiles() -> None: ) # Percentile ordering holds. for _, row in summary.iterrows(): - assert row["p05"] <= row["median"] <= row["p95"] + assert row["p_lower"] <= row["median"] <= row["p_upper"] + + +def test_bootstrap_summary_confidence_level_is_configurable() -> None: + """A narrower confidence level must yield a tighter p_lower/p_upper band. + + 0.90 (the default) reproduces the original fixed 5th/95th percentile + interval exactly; a lower confidence level (e.g. 0.50 -> 25th/75th) + must be strictly narrower for a large enough sample. + """ + rng = np.random.default_rng(0) + returns = pd.Series(rng.normal(0.0005, 0.01, 500)) + boot = bootstrap_returns(returns, n_iterations=300, block_size=5, seed=42) + + default_summary = boot.summary() + explicit_90 = boot.summary(confidence_level=0.90) + pd.testing.assert_frame_equal(default_summary, explicit_90) + + narrow_summary = boot.summary(confidence_level=0.50) + wide = default_summary.set_index("statistic") + narrow = narrow_summary.set_index("statistic") + for statistic in wide.index: + wide_width = cast(float, wide.at[statistic, "p_upper"]) - cast( + float, wide.at[statistic, "p_lower"] + ) + narrow_width = cast(float, narrow.at[statistic, "p_upper"]) - cast( + float, narrow.at[statistic, "p_lower"] + ) + assert narrow_width < wide_width + + for level in (0.0, 1.0, -0.1, 1.5): + with pytest.raises(ValueError, match="confidence_level"): + boot.summary(confidence_level=level) def test_bootstrap_is_reproducible() -> None: @@ -1875,6 +1926,101 @@ def _flaky_backtest(*args: object, **kwargs: object): # type: ignore[no-untyped ) +def test_stress_tests_with_custom_scenario_lists_produces_expected_names() -> None: + """Multiple configured values per scenario type must each produce their + own row, in the fixed order (commission, slippage, delay, best-days, + universe), and an empty list must produce none for that type.""" + data = _panel() + config = _config().revalidated_copy( + update={ + "robustness": _config().robustness.revalidated_copy( + update={ + "stress_test": { + "enabled": True, + "commission_multipliers": [3.0], + "slippage_multipliers": [], + "execution_delays": [1, 2], + "best_days_removed": [5], + "reduce_universe_by": [1], + } + } + ) + } + ) + table = run_stress_tests(data, config) + assert list(table["scenario"]) == [ + "baseline", + "commission x3", + "execution delay +1", + "execution delay +2", + "best 5 days removed", + "reduced universe (-1)", + ] + assert (table["status"] == "ok").all() + + +def test_stress_tests_with_custom_scenario_lists_resumes_from_a_checkpoint( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Checkpoint/resume must stay correct when scenario counts per type + differ from the historical fixed 1-1-1-1-1 shape (the dynamic block/ + row-count bookkeeping this rewrite introduced is the actual risk).""" + import quantlab.validation.robustness as robustness_module + + data = _panel() + config = _config().revalidated_copy( + update={ + "robustness": _config().robustness.revalidated_copy( + update={ + "stress_test": { + "enabled": True, + "commission_multipliers": [2.0, 3.0], + "slippage_multipliers": [1.5], + "execution_delays": [1], + "best_days_removed": [10, 20], + "reduce_universe_by": [1], + } + } + ) + } + ) + checkpoint_path = tmp_path / "checkpoint.pkl" + + real_backtest = robustness_module.run_backtest_from_config + calls = {"n": 0} + + def _flaky_backtest(*args: object, **kwargs: object): # type: ignore[no-untyped-def] + calls["n"] += 1 + # 1=baseline, 2=commission x2, 3=commission x3, 4=slippage x1.5 -- + # interrupt mid-way through the cost scenarios. + if calls["n"] == 4: + raise RuntimeError("simulated interruption") + return real_backtest(*args, **kwargs) # type: ignore[arg-type] + + monkeypatch.setattr(robustness_module, "run_backtest_from_config", _flaky_backtest) + with pytest.raises(RuntimeError, match="simulated interruption"): + run_stress_tests(data, config, checkpoint_path=checkpoint_path) + assert checkpoint_path.is_file() + monkeypatch.undo() + + resumed = run_stress_tests(data, config, checkpoint_path=checkpoint_path) + assert not checkpoint_path.is_file() + fresh = run_stress_tests(data, config) + pd.testing.assert_frame_equal( + resumed.reset_index(drop=True), fresh.reset_index(drop=True) + ) + assert list(resumed["scenario"]) == [ + "baseline", + "commission x2", + "commission x3", + "slippage x1.5", + "execution delay +1", + "best 10 days removed", + "best 20 days removed", + "reduced universe (-1)", + ] + + @pytest.mark.slow def test_stress_tests_refuses_a_structurally_plausible_but_incoherent_checkpoint( tmp_path: Path, diff --git a/tests/unit/test_validation_hardening.py b/tests/unit/test_validation_hardening.py index ebc0b2e..b845f70 100644 --- a/tests/unit/test_validation_hardening.py +++ b/tests/unit/test_validation_hardening.py @@ -105,11 +105,11 @@ def _config(*, benchmark: str | None = None) -> ExperimentConfig: "mean_reversion", { "lookback_period": 20, - "entry_zscore": 2.0, - "exit_zscore": 0.5, - "stop_zscore": 4.0, + "entry_threshold": 2.0, + "exit_threshold": 0.5, + "stop_threshold": 4.0, }, - {"lookback_period", "entry_zscore"}, + {"lookback_period", "entry_threshold"}, ), ( "trend_following", @@ -122,12 +122,12 @@ def _config(*, benchmark: str | None = None) -> ExperimentConfig: "symbol_a": "AAA", "symbol_b": "BBB", "formation_window": 252, - "zscore_window": 63, - "entry_zscore": 2.0, - "exit_zscore": 0.5, - "stop_zscore": 4.0, + "indicator_window": 63, + "entry_threshold": 2.0, + "exit_threshold": 0.5, + "stop_threshold": 4.0, }, - {"formation_window", "zscore_window", "entry_zscore"}, + {"formation_window", "indicator_window", "entry_threshold"}, ), ], ) @@ -170,6 +170,44 @@ def test_default_walk_forward_grid_covers_each_builtin_strategy_with_valid_combi _with_params(config, combination) +@pytest.mark.parametrize("strategy_name", ["mean_reversion", "pairs_trading"]) +def test_default_grid_treats_null_thresholds_as_the_indicators_own_default( + strategy_name: str, +) -> None: + parameters: dict[str, Any] = { + "entry_threshold": None, + "exit_threshold": None, + } + portfolio = ( + {"allocator": "signal_proportional"} if strategy_name == "pairs_trading" else {} + ) + if strategy_name == "pairs_trading": + parameters.update({"symbol_a": "AAA", "symbol_b": "BBB"}) + config = ExperimentConfig.from_dict( + { + "experiment_name": f"grid_null_thresholds_{strategy_name}", + "data": { + "instruments": [ + {"symbol": "AAA", "source": "csv", "calendar": "XNYS"}, + {"symbol": "BBB", "source": "csv", "calendar": "XNYS"}, + ], + "start_date": "2010-01-01", + "end_date": "2020-12-31", + }, + "strategy": {"name": strategy_name, "parameters": parameters}, + "portfolio": portfolio, + "backtest": {"benchmark_kind": "cash"}, + } + ) + + grid = default_parameter_grid(config) + + assert "entry_threshold" in grid + assert grid["entry_threshold"] + for combination in _grid_combinations(grid): + _with_params(config, combination) + + def test_default_cross_sectional_long_short_grid_remains_disjoint() -> None: config = ExperimentConfig.from_dict( { @@ -252,7 +290,7 @@ def _pairs_walk_forward_config() -> ExperimentConfig: "symbol_a": "AAA", "symbol_b": "BBB", "formation_window": 252, - "zscore_window": 63, + "indicator_window": 63, }, }, "portfolio": { @@ -277,8 +315,8 @@ def test_walk_forward_skips_only_structurally_unwarmed_pair_combinations( validation=index[500:626], test=index[626:752], ) - insufficient = {"formation_window": 504, "zscore_window": 126} - usable = {"formation_window": 252, "zscore_window": 63} + insufficient = {"formation_window": 504, "indicator_window": 126} + usable = {"formation_window": 252, "indicator_window": 63} evaluated: list[dict[str, Any]] = [] def fake_evaluate( @@ -290,7 +328,7 @@ def fake_evaluate( evaluated.append( { "formation_window": candidate.strategy_parameters["formation_window"], - "zscore_window": candidate.strategy_parameters["zscore_window"], + "indicator_window": candidate.strategy_parameters["indicator_window"], } ) # A sufficiently warmed-up strategy may legitimately choose to stay flat. @@ -328,7 +366,7 @@ def test_walk_forward_explains_when_every_combination_is_still_in_warmup() -> No WalkForwardValidator(config)._select_on_validation( pd.DataFrame(), window, - [{"formation_window": 504, "zscore_window": 126}], + [{"formation_window": 504, "indicator_window": 126}], _SCORERS["sharpe"], periods_per_year=252, risk_free_rate=0.0, @@ -480,7 +518,7 @@ def fake_run(data: pd.DataFrame, config: ExperimentConfig, **_: object) -> objec ) table = run_stress_tests(data, _config(benchmark="BENCH")) assert "BENCH" in seen_reduced_symbols - reduced = table.loc[table["scenario"] == "reduced universe"].iloc[0] + reduced = table.loc[table["scenario"] == "reduced universe (-1)"].iloc[0] assert reduced["status"] == "ok" @@ -502,7 +540,7 @@ def fake_run(data: pd.DataFrame, config: ExperimentConfig, **_: object) -> objec } ) table = run_stress_tests(data, _config()) - reduced = table.loc[table["scenario"] == "reduced universe"].iloc[0] + reduced = table.loc[table["scenario"] == "reduced universe (-1)"].iloc[0] assert reduced["status"] == "failed" assert "synthetic reduced-universe failure" in reduced["error"] diff --git a/tests/unit/test_weight_drift.py b/tests/unit/test_weight_drift.py new file mode 100644 index 0000000..6491861 --- /dev/null +++ b/tests/unit/test_weight_drift.py @@ -0,0 +1,1368 @@ +"""Direct tests for `apply_weight_drift` (the weight-drift feedback loop). + +Each test asserts against either a GENUINELY independent reference (a +different computational path -- price levels and share counts, never a +copy of `apply_weight_drift`'s own return-compounding recursion, which +would silently reproduce the same bug it's meant to catch) or a precise +hand-derived expectation. +""" + +from __future__ import annotations + +from typing import Any, cast + +import numpy as np +import pandas as pd +import pytest + +from quantlab.backtesting.accounting import apply_weight_drift, run_accounting +from quantlab.constants import EPSILON +from quantlab.exceptions import BacktestError + + +def _reference_drift_from_prices( + executed: pd.DataFrame, asset_returns: pd.DataFrame +) -> pd.DataFrame: + """Independent reference: reconstructs PRICE LEVELS from returns and + tracks SHARE-COUNT-implied dollar exposure plus an EXPLICIT residual + cash balance -- a genuinely different computational path from + `apply_weight_drift`'s own dollar/E return-compounding recursion, so + it cannot silently share the same bug. + + ``price_entering[t]`` is each asset's price level just BEFORE row t's + own return is applied (i.e. entering row t) -- consistent with the + expectation that ``executed[t]`` (this function's own output, and + `apply_weight_drift`'s) is likewise a PRE-period value. Any weight not + allocated at the anchor (``1 - sum(target)``) is tracked as explicit, + zero-return cash -- normalizing by ``sum(dollar)`` alone (ignoring + that residual) would be wrong for a partially-invested portfolio. + """ + columns = executed.columns + growth = asset_returns.fillna(0.0) + 1.0 + cum_growth = growth.cumprod() + price_entering = cum_growth.shift(1).fillna(1.0) + + out = pd.DataFrame(0.0, index=executed.index, columns=columns) + shares = pd.Series(0.0, index=columns) + cash = 0.0 + previous: np.ndarray | None = None + for raw_date in executed.index: + date = pd.Timestamp(raw_date) + row = cast("pd.Series", executed.loc[date]) + row_np = row.to_numpy(dtype=float) + is_anchor = previous is None or bool( + np.any(np.abs(row_np - previous) > EPSILON) + ) + previous = row_np + price_now = cast("pd.Series", price_entering.loc[date]) + if is_anchor: + shares = (row / price_now.replace(0.0, np.nan)).fillna(0.0) + cash = 1.0 - float(row.sum()) + out.loc[date] = row + else: + dollar = shares * price_now + total = cash + float(dollar.sum()) + out.loc[date] = (dollar / total) if total != 0.0 else 0.0 + return out + + +def test_price_round_trip_produces_the_mathematically_correct_nav() -> None: + """50/50 A/B, A gains 10% then loses exactly 1/11 (9.0909...%), B + never moves, no rebalancing, no fees. The true final NAV is 1.0 + exactly (A round-trips back to its starting price: 1.10 * 10/11 = 1.0) + and NOT ONE unit of turnover beyond the single initial entry should + ever be attributed to the pure-price drift in between.""" + dates = pd.date_range("2024-01-01", periods=3, freq="D") + held = pd.DataFrame({"A": [0.5, 0.5, 0.5], "B": [0.5, 0.5, 0.5]}, index=dates) + asset_returns = pd.DataFrame( + {"A": [np.nan, 0.10, -1.0 / 11.0], "B": [np.nan, 0.0, 0.0]}, index=dates + ) + from quantlab.config import ExecutionConfig + from quantlab.execution.execution_model import ExecutionModel + + # First: zero costs, to check the exact NAV claim cleanly. + free_execution_model = ExecutionModel.from_config(ExecutionConfig()) + free_result = run_accounting( + held, asset_returns, free_execution_model, 100_000.0, model_weight_drift=True + ) + assert free_result.equity.iloc[-1] == pytest.approx(100_000.0, rel=1e-9) + # Turnover: 1.0 on the entry (0 -> 50/50), exactly 0.0 on the pure- + # drift row -- never "interpreted as a daily transaction". + assert free_result.turnover.tolist() == pytest.approx([0.0, 1.0, 0.0]) + + # Second: nonzero costs -- if drift were ever mistaken for a trade, + # this would show up as a nonzero cost drag on the pure-drift row. + execution_model = ExecutionModel.from_config( + ExecutionConfig(commission_bps=10.0, spread_bps=10.0, slippage_bps=10.0) + ) + result = run_accounting( + held, asset_returns, execution_model, 100_000.0, model_weight_drift=True + ) + assert result.costs.total.iloc[2] == pytest.approx(0.0, abs=1e-9) + assert result.costs.total.iloc[1] > 0.0 + + +def test_hand_computed_drift_matches_a_genuinely_independent_price_reference() -> None: + dates = pd.date_range("2024-01-01", periods=5, freq="D") + executed = pd.DataFrame( + { + "A": [0.0, 0.5, 0.5, 0.5, 0.4], + "B": [0.0, 0.3, 0.3, 0.3, 0.2], + }, + index=dates, + ) + asset_returns = pd.DataFrame( + { + "A": [np.nan, 0.0, 0.10, 0.02, 0.0], + "B": [np.nan, 0.0, -0.05, 0.01, 0.0], + }, + index=dates, + ) + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + expected = _reference_drift_from_prices(executed, asset_returns) + pd.testing.assert_frame_equal(drifted, expected, check_exact=False) + assert not provenance.drift_compliance_forced.to_numpy().any() + assert not provenance.drift_compliance_pending.to_numpy().any() + + # Row 1 is the anchor (0 -> 0.5/0.3): a real trade, output == target. + assert drifted.loc[dates[1], "A"] == pytest.approx(0.5) + assert trade_changes.loc[dates[1], "A"] == pytest.approx(0.5) + # Row 2 is a pure-drift row, but row 1's OWN return was flat (0%), so + # the weight ENTERING row 2 is still unchanged at 0.5 -- zero trade. + assert cast(float, trade_changes.loc[dates[2]].abs().sum()) == pytest.approx(0.0) + assert drifted.loc[dates[2], "A"] == pytest.approx(0.5) + # Row 3 is where drift actually becomes visible: row 2's OWN +10%/-5% + # returns are what move the weight ENTERING row 3 away from 0.5/0.3. + assert cast(float, trade_changes.loc[dates[3]].abs().sum()) == pytest.approx(0.0) + assert drifted.loc[dates[3], "A"] != pytest.approx(0.5) + # Row 4 is a fresh anchor (a real rebalance) -- output exactly as + # given, and the trade delta is the REAL size (target minus whatever + # drift alone would have produced), not simply the row-to-row diff of + # `drifted` (which would conflate the rebalance with prior drift). + assert drifted.loc[dates[4], "A"] == pytest.approx(0.4) + assert drifted.loc[dates[4], "B"] == pytest.approx(0.2) + # The trade delta is the REAL size (target minus whatever drift alone + # would have produced entering row 4, i.e. row 3's own return applied + # on top of `drifted.loc[dates[3]]`) -- genuinely nonzero, and NOT + # simply `target - drifted.loc[dates[3]]` (that would ignore row 3's + # own return, which further moved the pre-anchor drifted value). + assert trade_changes.loc[dates[4], "A"] != pytest.approx(0.0) + row3 = pd.Timestamp(dates[3]) + naive_diff = 0.4 - cast(float, drifted.loc[row3, "A"]) + assert trade_changes.loc[dates[4], "A"] != pytest.approx(naive_diff) + + +def test_pre_period_weight_is_not_double_counted_against_its_own_return() -> None: + """The core bug this rewrite fixes: `apply_weight_drift`'s output for + row t must be the weight HELD ENTERING row t (pre-return), never the + weight AFTER row t's own return has already been baked in -- reusing + the post-return value would double-count that return when accounting.py + multiplies it by `asset_returns[t]` a second time.""" + dates = pd.date_range("2024-01-01", periods=3, freq="D") + executed = pd.DataFrame({"A": [0.5, 0.5, 0.5]}, index=dates) + asset_returns = pd.DataFrame({"A": [np.nan, 0.20, 0.0]}, index=dates) + drifted, trade_changes, _ = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + # Row 0 is the anchor: weight entering row 0 is 0.5 (the target itself). + assert drifted.loc[dates[0], "A"] == pytest.approx(0.5) + # Row 1: weight ENTERING row 1 -- i.e. BEFORE row 1's own (flat, 0%) + # return -- is still just 0.5 (only row 0's anchor has happened so + # far; row 1 hasn't earned its own return yet from this frame's point + # of view). A buggy "post-return" convention would instead already + # show row 0's +20% baked in here, which never happened for row 1. + assert drifted.loc[dates[1], "A"] == pytest.approx(0.5) + assert cast(float, trade_changes.loc[dates[1]].abs().sum()) == pytest.approx(0.0) + + +def test_constant_target_on_schedule_still_rebalances_back_to_target() -> None: + """A scheduled rebalance whose freshly-decided target happens to + numerically equal the immediately preceding one (e.g. a constant + 50/50 target under a daily schedule) must still be treated as a real + trade back to target -- value-diffing `executed` against its own + previous row alone cannot detect this, since the two numbers are + identical. Without `rebalance_date`, the position would silently keep + drifting away from target forever with zero recorded turnover.""" + dates = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame( + {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.5, 0.5, 0.5, 0.5]}, index=dates + ) + asset_returns = pd.DataFrame( + {"A": [0.0, 0.20, 0.0, 0.0], "B": [0.0, 0.0, 0.0, 0.0]}, index=dates + ) + tradable = pd.DataFrame(True, index=dates, columns=["A", "B"]) + + # Without rebalance_date: the position drifts to ~54.55/45.45 after + # A's +20% and never snaps back, even though every day is nominally a + # scheduled (daily) rebalance. + without_schedule, _, _ = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + assert without_schedule.loc[dates[2], "A"] == pytest.approx(0.6 / 1.1) + assert without_schedule.loc[dates[3], "A"] == pytest.approx(0.6 / 1.1) + + # With rebalance_date=True every day (a daily schedule): the position + # correctly snaps back to 50/50 the next row after it drifted -- the fix. + rebalance_date = pd.DataFrame(True, index=dates, columns=["A", "B"]) + with_schedule, trade_changes, _ = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + ) + assert with_schedule.loc[dates[2], "A"] == pytest.approx(0.5) + assert with_schedule.loc[dates[3], "A"] == pytest.approx(0.5) + # A real, nonzero trade is recorded snapping back to target. + assert cast(float, trade_changes.loc[dates[2]].abs().sum()) > 0.0 + + +def test_rebalance_date_between_schedule_dates_still_drifts_normally() -> None: + """A non-daily schedule (`rebalance_date` True only on a few rows) + must still let drift accumulate normally BETWEEN those rows -- the + fix must not force a snap-back on every row, only on genuine + scheduled dates.""" + dates = pd.date_range("2024-01-01", periods=5, freq="D") + executed = pd.DataFrame( + {"A": [0.5] * 5, "B": [0.5] * 5}, + index=dates, + ) + asset_returns = pd.DataFrame( + {"A": [0.0, 0.20, 0.0, 0.0, 0.0], "B": [0.0, 0.0, 0.0, 0.0, 0.0]}, + index=dates, + ) + tradable = pd.DataFrame(True, index=dates, columns=["A", "B"]) + # Rebalance only on the first and last row (like a weekly/monthly + # schedule where the middle rows aren't rebalance dates). + rebalance_date = pd.DataFrame( + { + "A": [True, False, False, False, True], + "B": [True, False, False, False, True], + }, + index=dates, + ) + + drifted, trade_changes, _ = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + ) + # Drift continues normally through the non-rebalance rows. + assert drifted.loc[dates[2], "A"] == pytest.approx(0.6 / 1.1) + assert drifted.loc[dates[3], "A"] == pytest.approx(0.6 / 1.1) + # The scheduled rebalance date snaps back to target with a real trade. + assert drifted.loc[dates[4], "A"] == pytest.approx(0.5) + assert cast(float, trade_changes.loc[dates[4]].abs().sum()) > 0.0 + + +def test_closed_asset_dollar_frozen_but_weight_still_drifts() -> None: + """A closed asset's own dollar exposure does not move (its + `asset_return` is 0 on a synthetic closure bar), but its WEIGHT still + drifts purely through E's own movement from the other, tradable + asset's real return.""" + dates = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame( + {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.3, 0.3, 0.3, 0.3]}, index=dates + ) + asset_returns = pd.DataFrame( + {"A": [np.nan, 0.0, 0.20, 0.0], "B": [np.nan, 0.0, 0.0, 0.0]}, index=dates + ) + drifted, _, _ = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + # Weight ENTERING row 3 reflects row 2's +20% A move (E grew to 1.10); + # B's dollar exposure never moved, but its weight (0.3/1.10) shrank. + assert drifted.loc[dates[3], "B"] == pytest.approx(0.3 / 1.10) + assert drifted.loc[dates[3], "B"] != pytest.approx(0.3) + + +def test_bankruptcy_guard_flattens_and_never_produces_inf_or_nan() -> None: + """A leveraged/short scenario engineered so relative E crosses <= 0. + The row whose OWN return causes the ruin still reports its real + (catastrophic) pre-ruin weight -- only the FOLLOWING row is flat.""" + dates = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame( + {"A": [2.0, 2.0, 2.0, 2.0]}, index=dates + ) # 2x leveraged long + asset_returns = pd.DataFrame( + {"A": [np.nan, -0.60, 0.0, 0.05]}, index=dates + ) # -60% move: gross_return = 2.0 * -0.60 = -1.20 -> E <= 0 by row 2 + drifted, trade_changes, _ = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + values = drifted.to_numpy() + assert np.isfinite(values).all() + assert not np.isnan(values).any() + # Row 0 is the anchor (2.0 decided/executed there); row 1 is the + # first drift row, and the weight ENTERING it is still 2.0 (nothing + # has drifted yet -- row 0 itself had a flat/undefined return). + assert drifted.loc[dates[1], "A"] == pytest.approx(2.0) + # Row 1's OWN return (-60%) is what wipes the position out (gross_ + # return = 2.0 * -0.60 = -1.20 -> E <= 0 while advancing past row 1) -- + # so row 2 is the first row whose ENTERING weight is force-flattened. + assert drifted.loc[dates[2], "A"] == pytest.approx(0.0) + # No phantom closing trade is charged for the forced flatten itself. + assert trade_changes.loc[dates[2], "A"] == pytest.approx(0.0) + + +def test_maximum_weight_breach_correction_lands_next_row_never_same_row() -> None: + """Look-ahead / temporal-convention test: a breach detected at row t + must leave row t's own (entering-t) output untouched (bit-for-bit + identical to a no-cap control run), and the correction must land + starting row t+1, never at t itself.""" + dates = pd.date_range("2024-01-01", periods=5, freq="D") + executed = pd.DataFrame({"A": [0.0, 0.5, 0.5, 0.5, 0.5]}, index=dates) + asset_returns = pd.DataFrame({"A": [np.nan, 0.0, 1.0, 0.0, 0.0]}, index=dates) + + uncapped, _, _ = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + capped, capped_changes, provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=0.6, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + + # Weight ENTERING row 3 already reflects row 2's +100% A move (E grew + # to 1.5, weight = 1.0/1.5 = 0.6667) -- genuinely breaching 0.6. + breach_row = pd.Timestamp(dates[3]) + assert uncapped.loc[breach_row, "A"] == pytest.approx(1.0 / 1.5) + assert capped.loc[breach_row, "A"] == pytest.approx(uncapped.loc[breach_row, "A"]) + assert cast(float, capped.loc[breach_row, "A"]) > 0.6 + assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"]) + + landed_row = pd.Timestamp(dates[4]) + assert capped.loc[landed_row, "A"] == pytest.approx(0.6) + assert bool(provenance.drift_compliance_forced.loc[landed_row, "A"]) + assert capped_changes.loc[landed_row, "A"] == pytest.approx( + 0.6 - cast(float, capped.loc[breach_row, "A"]) + ) + + +def test_position_group_correction_moves_both_legs_coherently() -> None: + """A declared position group's breach correction must scale both legs + via one shared `k_g`, preserving the drifted ratio exactly -- never a + single leg moving alone.""" + dates = pd.date_range("2024-01-01", periods=6, freq="D") + executed = pd.DataFrame( + { + "X": [0.0, 0.45, 0.45, 0.45, 0.45, 0.45], + "Y": [0.0, -0.20, -0.20, -0.20, -0.20, -0.20], + }, + index=dates, + ) + asset_returns = pd.DataFrame( + { + "X": [np.nan, 0.0, 0.0, 0.30, 0.0, 0.0], + "Y": [np.nan, 0.0, 0.0, 0.0, 0.0, 0.0], + }, + index=dates, + ) + drifted, _, provenance = apply_weight_drift( + executed, + asset_returns, + None, + [("X", "Y")], + maximum_weight=0.5, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + # Row 1 is the anchor; row 3's OWN +30% X return (applied while + # advancing past row 3) is what makes the weight ENTERING row 4 breach. + breach_row = pd.Timestamp(dates[4]) + assert cast(float, drifted.loc[breach_row, "X"]) > 0.5 + breach_ratio = cast(float, drifted.loc[breach_row, "X"]) / cast( + float, drifted.loc[breach_row, "Y"] + ) + + landed_row = pd.Timestamp(dates[5]) + assert drifted.loc[landed_row, "X"] == pytest.approx(0.5) + assert bool(provenance.drift_compliance_forced.loc[landed_row, "X"]) + assert bool(provenance.drift_compliance_forced.loc[landed_row, "Y"]) + landed_ratio = cast(float, drifted.loc[landed_row, "X"]) / cast( + float, drifted.loc[landed_row, "Y"] + ) + assert landed_ratio == pytest.approx(breach_ratio) + + +def test_closed_asset_responsible_for_breach_resolves_once_it_reopens() -> None: + """A breach caused by a currently-untradable asset cannot be fixed + immediately -- the residual is carried as `pending` and resolves once + the asset reopens, landing one bar after the LP can finally solve it + without slack.""" + dates = pd.date_range("2024-01-01", periods=7, freq="D") + executed = pd.DataFrame({"A": [0.0, 0.4, 0.4, 0.4, 0.4, 0.4, 0.4]}, index=dates) + asset_returns = pd.DataFrame( + {"A": [np.nan, 0.0, 0.0, 0.30, 0.0, 0.0, 0.0]}, index=dates + ) + tradable = pd.DataFrame( + {"A": [True, True, True, False, False, True, True]}, index=dates + ) + drifted, _, provenance = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=0.45, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + # Row 1 is the anchor; row 3's OWN +30% return (applied while + # advancing past row 3) is what makes the weight ENTERING row 4 + # breach -- and row 4 is closed, so it is unresolvable that row. + breach_row = pd.Timestamp(dates[4]) + assert drifted.loc[breach_row, "A"] == pytest.approx(0.52 / 1.12) + assert cast(float, drifted.loc[breach_row, "A"]) > 0.45 + assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"]) + # Row 5 (`dates[5]`) is where A actually reopens -- the correction + # lands there, at the EARLIEST row it is achievable using that row's + # own information (not one row later): landing is not deferred an + # extra row beyond what tradability itself requires. + reopens_row = pd.Timestamp(dates[5]) + assert drifted.loc[reopens_row, "A"] == pytest.approx(0.45) + assert bool(provenance.drift_compliance_forced.loc[reopens_row, "A"]) + # Stays resolved on the following row -- no further correction needed. + assert drifted.loc[dates[6], "A"] == pytest.approx(0.45) + assert not bool(provenance.drift_compliance_forced.loc[dates[6], "A"]) + + +def test_model_weight_drift_defaults_to_no_drift_via_run_accounting() -> None: + """Regression gate: `run_accounting`'s `model_weight_drift=False` + reproduces the plain constant-weight step function exactly.""" + from quantlab.config import ExecutionConfig + from quantlab.execution.execution_model import ExecutionModel + + dates = pd.date_range("2024-01-01", periods=4, freq="D") + held = pd.DataFrame({"A": [0.5, 0.5, 0.5, 0.5]}, index=dates) + returns = pd.DataFrame({"A": [np.nan, 0.10, 0.02, -0.01]}, index=dates) + execution_model = ExecutionModel.from_config(ExecutionConfig()) + result = run_accounting( + held, returns, execution_model, 100_000.0, model_weight_drift=False + ) + # Constant-weight step function: every row's executed weight equals + # the held weight shifted by exactly one period (no drift). + assert result.executed_weights.loc[dates[2], "A"] == pytest.approx(0.5) + assert not result.drift_compliance_forced.to_numpy().any() + assert not result.drift_compliance_pending.to_numpy().any() + + +def test_run_accounting_rebalance_date_forces_on_schedule_rebalance() -> None: + """`run_accounting`'s own `rebalance_date` parameter must reach + `apply_weight_drift` and produce real turnover on a scheduled date + whose freshly-decided target coincidentally matches the previous one + -- not silently absorbed into drift. Without `rebalance_date`, + turnover on that date is exactly zero; with it, nonzero.""" + from quantlab.config import ExecutionConfig + from quantlab.execution.execution_model import ExecutionModel + + dates = pd.date_range("2024-01-01", periods=4, freq="D") + held = pd.DataFrame( + {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.5, 0.5, 0.5, 0.5]}, index=dates + ) + returns = pd.DataFrame( + {"A": [np.nan, 0.20, 0.0, 0.0], "B": [np.nan, 0.0, 0.0, 0.0]}, index=dates + ) + execution_model = ExecutionModel.from_config(ExecutionConfig()) + + without_schedule = run_accounting( + held, returns, execution_model, 100_000.0, model_weight_drift=True + ) + assert without_schedule.turnover.loc[dates[2]] == pytest.approx(0.0) + assert without_schedule.executed_weights.loc[dates[2], "A"] == pytest.approx( + 0.6 / 1.1 + ) + + with_schedule = run_accounting( + held, + returns, + execution_model, + 100_000.0, + model_weight_drift=True, + rebalance_date=pd.DataFrame(True, index=dates, columns=["A", "B"]), + ) + assert with_schedule.turnover.loc[dates[2]] > 0.0 + + +def test_closed_column_never_anchors_just_because_another_column_does() -> None: + """A closed instrument must never be force-reset to its stale decided + value just because a DIFFERENT, open instrument's own schedule/value + change anchors the same row -- it must keep drifting undisturbed, + with zero trade/turnover attributed to it, until it is itself + genuinely decided on a date it is actually tradable.""" + dates = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame({"A": [0.5] * 4, "B": [0.5] * 4}, index=dates) + asset_returns = pd.DataFrame( + {"A": [0.0, 0.20, 0.0, 0.0], "B": [0.0, 0.0, 0.20, 0.0]}, index=dates + ) + # A is closed on date[2], the day B's own schedule fires. + tradable = pd.DataFrame(True, index=dates, columns=["A", "B"]) + tradable.loc[dates[2], "A"] = False + rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"]) + rebalance_date.loc[dates[2], "B"] = True + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + ) + # A keeps drifting from its own +20% move entering date[2] -- never + # reset to the stale 0.5 target just because B anchors this row. + assert drifted.loc[dates[2], "A"] == pytest.approx(0.6 / 1.1) + assert trade_changes.loc[dates[2], "A"] == pytest.approx(0.0) + # B genuinely anchors (its own schedule date): real trade recorded. + assert drifted.loc[dates[2], "B"] == pytest.approx(0.5) + assert trade_changes.loc[dates[2], "B"] != pytest.approx(0.0) + assert not provenance.drift_compliance_forced.to_numpy().any() + + # A reopens on date[3] with no fresh decision of its own: it should + # continue drifting from where it actually was (still not snapped to + # any stale target), not suddenly reset either. + assert trade_changes.loc[dates[3], "A"] == pytest.approx(0.0) + + +def test_full_portfolio_anchor_unaffected_by_partial_anchor_logic() -> None: + """When EVERY column anchors together (the ordinary single-calendar + case), behavior must stay byte-identical to a plain whole-row reset: + both columns land exactly on target with a real trade, and `E`/` + dollar` fully renormalize.""" + dates = pd.date_range("2024-01-01", periods=3, freq="D") + executed = pd.DataFrame({"A": [0.5] * 3, "B": [0.5] * 3}, index=dates) + asset_returns = pd.DataFrame( + {"A": [0.0, 0.20, 0.0], "B": [0.0, 0.0, 0.0]}, index=dates + ) + rebalance_date = pd.DataFrame(True, index=dates, columns=["A", "B"]) + drifted, trade_changes, _ = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + ) + assert drifted.loc[dates[2], "A"] == pytest.approx(0.5) + assert drifted.loc[dates[2], "B"] == pytest.approx(0.5) + assert cast(float, trade_changes.loc[dates[2]].abs().sum()) > 0.0 + + +def test_maximum_turnover_caps_an_anchor_catch_up_and_carries_the_remainder() -> None: + """A scheduled anchor's catch-up trade must respect `maximum_turnover` + exactly like an ordinary decision-level rebalance does -- landing + partially, carrying the unresolved remainder forward, and never + exceeding the cap on any single row. The initial entry (magnitude 1.0 + from the conventional `w_{-1} = 0` treatment `cap_turnover` already + uses) is itself subject to the same cap, so it needs several rows to + fully resolve before the interesting scheduled-catch-up scenario + (drift away from a numerically unchanged target, then a schedule date) + even begins.""" + n = 40 + dates = pd.date_range("2024-01-01", periods=n, freq="D") + executed = pd.DataFrame({"A": [0.5] * n, "B": [0.5] * n}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * n, "B": [0.0] * n}, index=dates) + asset_returns.loc[dates[20], "A"] = 0.20 + rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"]) + rebalance_date.loc[dates[21]] = True + cap = 0.05 + + drifted, trade_changes, _ = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + maximum_turnover=cap, + ) + # Never exceeds the cap on any row, including the initial entry. + assert trade_changes.abs().sum(axis=1).max() <= cap + 1e-9 + # The initial entry (from an implicit all-cash start) fully resolves + # well before the drift/schedule scenario begins at row 20. + assert drifted.loc[dates[19], "A"] == pytest.approx(0.5) + assert drifted.loc[dates[19], "B"] == pytest.approx(0.5) + # The schedule fires at row 21, but the catch-up doesn't land in one + # shot -- it is genuinely throttled by the cap. + assert drifted.loc[dates[21], "A"] == pytest.approx(0.5204545454545454) + assert cast(float, trade_changes.loc[dates[21]].abs().sum()) == pytest.approx(cap) + # The remainder is fully caught up by the very next row, with no + # further schedule/value trigger needed. + assert drifted.loc[dates[22], "A"] == pytest.approx(0.5, abs=1e-9) + assert drifted.loc[dates[22], "B"] == pytest.approx(0.5, abs=1e-9) + assert drifted.loc[dates[-1], "A"] == pytest.approx(0.5, abs=1e-9) + + +def test_partial_anchor_respects_maximum_turnover_and_debt_survives_a_closure() -> None: + """Two invariants checked together: (1) `maximum_turnover` must bound + a MIXED-tradability partial anchor's catch-up exactly like a + whole-portfolio one -- never applying a partial anchor's fresh + decision directly and uncapped; (2) a turnover-capped catch-up debt + must never trade a column while it is closed -- never chasing + `catchup_target` on a column regardless of that column's own + tradability. A is tradable only for the very first row (its own + capped entry), then closed for the rest of the window, while B + anchors on every row and is always tradable.""" + dates = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame({"A": [0.3] * 4, "B": [0.3, 0.3, 0.9, 0.9]}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * 4, "B": [0.0] * 4}, index=dates) + tradable = pd.DataFrame( + {"A": [True, False, False, False], "B": [True] * 4}, index=dates + ) + cap = 0.2 + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + maximum_turnover=cap, + ) + # Never exceeds the cap on any row, including the mixed-tradability + # partial-anchor rows. + assert trade_changes.abs().sum(axis=1).max() <= cap + 1e-9 + # A's own entry (target 0.3) is capped below its full size on row 0 + # (shared budget with B's own simultaneous entry) and then genuinely + # frozen -- zero further trade -- for every row it stays closed, + # never silently caught up while untradable. + assert drifted.loc[dates[0], "A"] == pytest.approx(0.1) + assert drifted.loc[dates[0], "A"] != pytest.approx(0.3) + for date in dates[1:]: + assert drifted.loc[date, "A"] == pytest.approx(0.1) + assert trade_changes.loc[date, "A"] == pytest.approx(0.0) + # B's own debt (entry, then the row-2 anchor to 0.9) keeps resolving, + # entirely unaffected by A sitting closed with its own debt untouched. + b_last = cast(float, drifted.loc[dates[-1], "B"]) + b_first = cast(float, drifted.loc[dates[0], "B"]) + assert b_last < 0.9 + assert b_last > b_first + assert not provenance.drift_compliance_forced.to_numpy().any() + assert not provenance.drift_compliance_pending.to_numpy().any() + + +def test_a_new_partial_decision_never_wipes_an_unrelated_columns_debt() -> None: + """A fresh decision on ONE column must only supersede that column's + own outstanding ordinary debt, never wipe an unrelated column's own + still-resolving debt or the WHOLE portfolio's turnover-catch-up + state. Scenario: target A=B=1, a + turnover-capped first fill lands both at 0.25, then a fresh decision + arrives for B ALONE (B=0.80) -- A must + keep converging toward 1.0, and B must converge toward its OWN new + target without exceeding the shared cap.""" + dates = pd.date_range("2024-01-01", periods=3, freq="D") + executed = pd.DataFrame({"A": [1.0, 1.0, 1.0], "B": [1.0, 1.0, 0.80]}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * 3, "B": [0.0] * 3}, index=dates) + cap = 0.5 + + drifted, trade_changes, _ = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + maximum_turnover=cap, + ) + assert trade_changes.abs().sum(axis=1).max() <= cap + 1e-9 + # First (shared) fill: both capped at 0.25. + assert drifted.loc[dates[0], "A"] == pytest.approx(0.25) + assert drifted.loc[dates[0], "B"] == pytest.approx(0.25) + # A's OWN debt (toward 1.0) keeps converging even after B gets its own + # fresh, unrelated decision -- never stuck at 0.25 forever. + a1 = cast(float, drifted.loc[dates[1], "A"]) + a2 = cast(float, drifted.loc[dates[2], "A"]) + assert a2 > a1 > 0.25 + # B converges toward its NEW target (0.80), never overshooting past it. + b2 = cast(float, drifted.loc[dates[2], "B"]) + assert b2 <= 0.80 + 1e-9 + assert b2 > 0.25 + + +def test_mixing_closed_drift_with_a_fresh_partial_target_stays_compliant() -> None: + """Regression test: combining a currently-closed, already-drifted + column with another column's freshly-decided partial target can + create a NEW hard-limit violation neither had alone -- this must + never be visible in the output, not even for one row, since every + input needed to detect and fix it is already known before this row's + own output is finalized (unlike organic drift, which genuinely needs + a one-row lag). `maximum_gross_exposure=1.1` is chosen so drift is + load-bearing: A's own UNDRAFTED target (0.5) plus B's fresh target + (0.6) sum to exactly 1.1 -- compliant on its own, proven by the + no-drift control below -- and it is only A's organic drift (closed, + to 0.5833 after its own 0.4 return) combined with B's fresh decision + that tips gross exposure over the cap.""" + dates = pd.date_range("2024-01-01", periods=4, freq="D") + executed = pd.DataFrame( + {"A": [0.5, 0.5, 0.5, 0.5], "B": [0.3, 0.3, 0.3, 0.6]}, index=dates + ) + asset_returns = pd.DataFrame( + {"A": [0.0, 0.4, 0.0, 0.0], "B": [0.0] * 4}, index=dates + ) + tradable = pd.DataFrame( + {"A": [True, True, False, False], "B": [True] * 4}, index=dates + ) + cap = 1.1 + + # Control: with no drift at all (A's own return held at zero), the + # same executed/tradable/cap combination never breaches -- proving + # the violation below genuinely requires drift, not just mixing. + no_drift_returns = pd.DataFrame({"A": [0.0] * 4, "B": [0.0] * 4}, index=dates) + control, _, control_provenance = apply_weight_drift( + executed, + no_drift_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=cap, + maximum_net_exposure=None, + long_only=False, + ) + assert control.abs().sum(axis=1).max() <= cap + 1e-9 + assert not control_provenance.drift_compliance_forced.to_numpy().any() + + drifted, _, provenance = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=cap, + maximum_net_exposure=None, + long_only=False, + ) + gross = drifted.abs().sum(axis=1) + assert gross.max() <= cap + 1e-9 + # The mixing row (B's fresh target combined with A's closed, drifted + # value) is corrected in the SAME row -- no look-ahead lag needed. + assert bool(provenance.drift_compliance_forced.loc[dates[3], "B"]) + assert not bool(provenance.drift_compliance_pending.loc[dates[3]].any()) + + +def test_drift_compliance_forced_correction_is_exempt_from_the_turnover_cap() -> None: + """A hard-risk-limit-forced drift-compliance correction must NEVER be + subject to `maximum_turnover` -- capping it could leave a genuine + `maximum_weight`/exposure breach uncorrected indefinitely, which is + strictly worse than a large one-off corrective trade. Enough rows are + given for the (also turnover-capped) initial entry to fully resolve + well before the drift/violation scenario begins.""" + n = 130 + dates = pd.date_range("2024-01-01", periods=n, freq="D") + executed = pd.DataFrame({"A": [0.5] * n, "B": [0.5] * n}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * n, "B": [0.0] * n}, index=dates) + asset_returns.loc[dates[110], "A"] = 1.0 + cap = 0.01 + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=0.55, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + maximum_turnover=cap, + ) + assert drifted.loc[dates[109], "A"] == pytest.approx(0.5) + # Row 111 (drift-only, no correction landed yet) genuinely breaches + # maximum_weight -- the breach is real and reported, not pre-empted. + assert drifted.loc[dates[111], "A"] == pytest.approx(2.0 / 3.0) + forced_row = provenance.drift_compliance_forced.any(axis=1) + assert forced_row.sum() == 1 + landed_date = drifted.index[forced_row][0] + assert landed_date == dates[112] + # The correction's own trade size on its landing row exceeds the tiny + # turnover cap -- proving it was never throttled by it. + assert trade_changes.loc[landed_date].abs().sum() > cap + assert cast(float, drifted.loc[landed_date, "A"]) <= 0.55 + 1e-9 + + +def test_compliance_correction_is_never_delayed_behind_ordinary_debt() -> None: + """Regression test: a queued drift-compliance correction must be + checked and landed BEFORE ordinary rebalance debt is processed, every + row -- an unrelated column's own outstanding, still-resolving + turnover-capped debt must never delay or throttle a compliance + correction on a DIFFERENT column. C carries a large, slowly-resolving + ordinary debt the entire time A's own maximum_weight breach is + detected and lands -- A's correction still lands in full, on the same + exempt-from-`maximum_turnover` schedule as if C's debt did not exist.""" + n = 130 + dates = pd.date_range("2024-01-01", periods=n, freq="D") + executed = pd.DataFrame({"A": [0.5] * n, "B": [0.5] * n}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * n, "B": [0.0] * n}, index=dates) + asset_returns.loc[dates[110], "A"] = 1.0 + cap = 0.01 + executed["C"] = 0.0 + executed.loc[dates[50] :, "C"] = 0.2 + asset_returns["C"] = 0.0 + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=0.55, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + maximum_turnover=cap, + ) + # C's own ordinary debt is genuinely still outstanding (mid-catch-up, + # not yet at its 0.2 target) when A's compliance breach lands. + forced_row = provenance.drift_compliance_forced["A"] + assert forced_row.any() + landed_date = drifted.index[forced_row][0] + assert 0.0 < cast(float, drifted.loc[landed_date, "C"]) < 0.2 + # A's correction still lands in full, exempt from the tiny cap. + assert cast(float, drifted.loc[landed_date, "A"]) == pytest.approx(0.55) + assert cast(float, trade_changes.loc[landed_date, "A"]) != pytest.approx(0.0) + assert abs(cast(float, trade_changes.loc[landed_date, "A"])) > cap + + +def test_landed_compliance_correction_never_uses_a_stale_closed_asset_value() -> None: + """Regression test: when a queued drift-compliance correction finally + lands, a column the LP never moved (here, A -- closed after its own + initial entry, fixed by the LP's own equality constraint) must + reflect its CURRENT, naturally-continued weight at landing time, + never the STALE value it had back on the row the breach was first + detected. A closed asset's weight keeps drifting via `E` even while a + compliance correction is pending elsewhere; landing the stale + detection-time snapshot would silently force-reset it -- the exact + closed-asset-gets-traded bug this function exists to prevent, just + reached through the compliance path instead of the schedule-anchor + path.""" + dates = pd.date_range("2024-01-01", periods=6, freq="D") + executed = pd.DataFrame({"A": [0.5] * 6, "B": [0.3] * 6}, index=dates) + asset_returns = pd.DataFrame( + {"A": [0.0] * 6, "B": [0.0, 1.0, 0.3, 0.2, 0.0, 0.0]}, index=dates + ) + # A is tradable only long enough to enter at the anchor row, then + # closed for the rest of the window. + tradable = pd.DataFrame( + {"A": [True, False, False, False, False, False], "B": [True] * 6}, index=dates + ) + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=0.85, + maximum_net_exposure=None, + long_only=False, + ) + # Breach detected at row 3 (2024-01-04, gross ~0.865 > 0.85), landed + # at row 4 (2024-01-05) -- the standard one-row-lag temporal + # convention, unaffected by this fix. + assert bool(provenance.drift_compliance_pending.loc[dates[3], "B"]) + assert bool(provenance.drift_compliance_forced.loc[dates[4], "B"]) + # A is never marked as moved/forced -- the LP fixed it, never traded it. + assert not provenance.drift_compliance_forced.loc[dates[4], "A"] + assert trade_changes.loc[dates[4], "A"] == pytest.approx(0.0) + # A's own weight at landing time reflects its CURRENT continued drift + # (it moved between detection and landing purely because B's own + # further return shifted E), NOT the stale value frozen at detection. + assert drifted.loc[dates[3], "A"] == pytest.approx(0.337838, abs=1e-5) + assert drifted.loc[dates[4], "A"] == pytest.approx(0.305623, abs=1e-5) + assert drifted.loc[dates[4], "A"] != pytest.approx(drifted.loc[dates[3], "A"]) + # The landed row is fully compliant. + assert cast(float, drifted.loc[dates[4]].abs().sum()) <= 0.85 + 1e-6 + + +def test_best_effort_correction_actually_lands_on_open_columns_while_blocked() -> None: + """Regression test: when the breach is caused by a currently-closed + column the LP cannot move (A, fixed by its own equality constraint, + genuinely too large to fix even by fully zeroing every other column), + `restore_drift_compliance` still proposes the best ACHIEVABLE + improvement using whatever IS tradable (B) -- this must actually be + APPLIED to the row's own output, not merely recomputed and discarded + every day while the correction sits pending forever. A surges (its + own price return, still open) right before closing, leaving a + genuinely irresolvable breach; B must still be walked toward zero on + the very next row, even though the overall breach remains `pending` + (A alone already exceeds the cap).""" + dates = pd.date_range("2024-01-01", periods=6, freq="D") + executed = pd.DataFrame({"A": [0.3] * 6, "B": [0.05] * 6}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * 6, "B": [0.0] * 6}, index=dates) + asset_returns.loc[dates[2], "A"] = 3.0 # A quadruples right before closing. + tradable = pd.DataFrame( + {"A": [True, True, True, False, False, False], "B": [True] * 6}, index=dates + ) + + drifted, trade_changes, provenance = apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=0.5, + maximum_net_exposure=None, + long_only=False, + ) + # A's own surge (applied while advancing past row 2) breaches the cap + # entering row 3, the row A closes -- detected there, not yet acted on. + breach_row = pd.Timestamp(dates[3]) + assert cast(float, drifted.loc[breach_row].abs().sum()) > 0.5 + assert bool(provenance.drift_compliance_pending.loc[breach_row, "A"]) + assert trade_changes.loc[breach_row, "B"] == pytest.approx(0.0) + # The following row: B is walked to its best achievable value (zero, + # the only feasible reduction) -- a REAL trade, not merely recomputed + # and left unapplied. The overall breach stays pending (A alone still + # exceeds the cap; nothing further is achievable), but B's own + # component of the fix is genuinely done. + applied_row = pd.Timestamp(dates[4]) + assert drifted.loc[applied_row, "B"] == pytest.approx(0.0, abs=1e-9) + assert trade_changes.loc[applied_row, "B"] != pytest.approx(0.0) + assert bool(provenance.drift_compliance_pending.loc[applied_row, "A"]) + # B never moves again once it has nothing left to give. + assert drifted.loc[dates[5], "B"] == pytest.approx(0.0, abs=1e-9) + assert trade_changes.loc[dates[5], "B"] == pytest.approx(0.0) + + +def test_apply_weight_drift_rejects_a_non_boolean_rebalance_date_directly() -> None: + """`apply_weight_drift` itself -- not just `run_accounting` -- must + reject a non-boolean `rebalance_date` (e.g. the string `'False'`, + which would otherwise silently coerce to truthy) rather than letting + it corrupt anchor detection.""" + dates = pd.date_range("2024-01-01", periods=3, freq="D") + executed = pd.DataFrame({"A": [0.5] * 3, "B": [0.5] * 3}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * 3, "B": [0.0] * 3}, index=dates) + bad_rebalance_date = pd.DataFrame( + {"A": ["False", "True", "False"], "B": ["False", "True", "False"]}, + index=dates, + ) + with pytest.raises(BacktestError, match="boolean"): + apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=bad_rebalance_date, + ) + + +def _valid_apply_weight_drift_args() -> tuple[pd.DataFrame, pd.DataFrame]: + dates = pd.date_range("2024-01-01", periods=3, freq="D") + executed = pd.DataFrame({"A": [0.5] * 3, "B": [0.5] * 3}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0] * 3, "B": [0.0] * 3}, index=dates) + return executed, asset_returns + + +@pytest.mark.parametrize( + ("override", "match"), + [ + ("asset_returns_not_a_frame", "asset_returns must be a pandas DataFrame"), + ("asset_returns_dup_index", "asset_returns index must not contain duplicate"), + ( + "asset_returns_dup_columns", + "asset_returns columns must not contain duplicate", + ), + ("asset_returns_missing_symbol", "asset_returns must cover every executed"), + ("asset_returns_non_numeric", "asset_returns must contain only numeric"), + ("asset_returns_infinity", "asset_returns must not contain Infinity"), + ("asset_returns_below_total_loss", r"asset_returns must not contain simple"), + ( + "asset_returns_missing_held_return", + "asset_returns is missing a return for a held position", + ), + ("tradable_not_a_frame", "tradable must be a pandas DataFrame"), + ("tradable_dup_index", "tradable index must not contain duplicate"), + ("tradable_missing_values", "tradable must not contain missing values"), + ("tradable_non_boolean", "tradable must contain only boolean"), + ("rebalance_date_not_a_frame", "rebalance_date must be a pandas DataFrame"), + ( + "rebalance_date_dup_index", + "rebalance_date index must not contain duplicate", + ), + ( + "rebalance_date_axis_mismatch", + "rebalance_date must have the same dates and symbols", + ), + ( + "rebalance_date_missing_values", + "rebalance_date must not contain missing values", + ), + ], +) +def test_apply_weight_drift_rejects_malformed_direct_call_arguments( + override: str, match: str +) -> None: + """`apply_weight_drift` is a directly-callable public function (see its + own docstring) that must not silently accept malformed + `asset_returns`/`tradable`/`rebalance_date` -- unlike `run_accounting`, + a caller can invoke it directly with entirely unvalidated data. Each + case here reaches this function's own validation block, not + `run_accounting`'s (which never delegates to `apply_weight_drift` for + a malformed-input test, since it validates and raises first).""" + executed, valid_asset_returns = _valid_apply_weight_drift_args() + dates = executed.index + # Typed `Any`, not `pd.DataFrame | None`: several branches below + # deliberately assign a wrong-typed value (a bare string) to prove + # apply_weight_drift's own runtime validation rejects it -- that is + # the point of this test, not a type error to suppress per line. + asset_returns: Any = valid_asset_returns + tradable: Any = None + rebalance_date: Any = None + + if override == "asset_returns_not_a_frame": + asset_returns = "not a frame" + elif override == "asset_returns_dup_index": + asset_returns = asset_returns.copy() + asset_returns.index = pd.DatetimeIndex([dates[0], dates[0], dates[2]]) + elif override == "asset_returns_dup_columns": + asset_returns = asset_returns.copy() + asset_returns.columns = ["A", "A"] + elif override == "asset_returns_missing_symbol": + asset_returns = asset_returns.drop(columns=["B"]) + elif override == "asset_returns_non_numeric": + asset_returns = asset_returns.astype(object) + asset_returns.iloc[1, 0] = "not a number" + elif override == "asset_returns_infinity": + asset_returns = asset_returns.copy() + asset_returns.iloc[1, 0] = np.inf + elif override == "asset_returns_below_total_loss": + asset_returns = asset_returns.copy() + asset_returns.iloc[1, 0] = -1.5 + elif override == "asset_returns_missing_held_return": + asset_returns = asset_returns.copy() + asset_returns.iloc[1, 0] = np.nan + elif override == "tradable_not_a_frame": + tradable = "not a frame" + elif override == "tradable_dup_index": + tradable = pd.DataFrame(True, index=dates, columns=["A", "B"]) + tradable.index = pd.DatetimeIndex([dates[0], dates[0], dates[2]]) + elif override == "tradable_missing_values": + tradable = pd.DataFrame({"A": [True, None, True], "B": [True] * 3}, index=dates) + elif override == "tradable_non_boolean": + tradable = pd.DataFrame( + {"A": ["False", "True", "False"], "B": ["True"] * 3}, index=dates + ) + elif override == "rebalance_date_not_a_frame": + rebalance_date = "not a frame" + elif override == "rebalance_date_dup_index": + rebalance_date = pd.DataFrame(False, index=dates, columns=["A", "B"]) + rebalance_date.index = pd.DatetimeIndex([dates[0], dates[0], dates[2]]) + elif override == "rebalance_date_axis_mismatch": + rebalance_date = pd.DataFrame(False, index=dates, columns=["A"]) + elif override == "rebalance_date_missing_values": + rebalance_date = pd.DataFrame( + {"A": [False, None, False], "B": [False] * 3}, index=dates + ) + + with pytest.raises(BacktestError, match=match): + apply_weight_drift( + executed, + asset_returns, + tradable, + None, + maximum_weight=None, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + rebalance_date=rebalance_date, + ) + + +def test_forced_and_pending_are_never_both_true_for_the_same_cell( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression test: the same-row re-check (below the ordinary-debt + step) is a SECOND, independent `_try_restore` call that can re- + implicate a column Step 1 (the compliance-debt branch above) already + landed and marked `forced=True` THIS row -- e.g. Step 1 fully + resolves column A's own `maximum_weight` breach, but the resulting + gross exposure (combined with column B) is a NEW violation the + same-row re-check discovers, only partially fixable, re-marking A + `pending=True` too. `forced`/`pending` must never both be True for + the same cell in the same row -- whichever call's write to `landed` + is temporally LAST (the same-row re-check, since it runs after Step + 1) must be the one whose verdict survives, since it is what the + final output value actually reflects. `restore_drift_compliance` is + mocked to force this exact sequence deterministically -- the natural + LP's own behavior makes this specific overlap rare enough that a + hand-constructed numeric scenario proved too fragile to rely on.""" + import quantlab.backtesting.accounting as acct_mod + from quantlab.portfolio.drift_compliance import DriftComplianceResult + + dates = pd.date_range("2024-01-01", periods=2, freq="D") + executed = pd.DataFrame({"A": [0.5, 0.5], "B": [0.3, 0.3]}, index=dates) + asset_returns = pd.DataFrame({"A": [0.0, 0.0], "B": [0.0, 0.0]}, index=dates) + + calls = {"n": 0} + + def fake_restore(row: np.ndarray, *args: object, **kwargs: object) -> object: + calls["n"] += 1 + if calls["n"] == 1: + # Row 0: initial maximum_weight breach on A -- carried pending. + return DriftComplianceResult(corrected=row.copy(), pending=True) + corrected = row.copy() + corrected[0] = 0.3 # A resolved to exactly the maximum_weight cap. + if calls["n"] == 2: + # Row 1, Step 1: A's own maximum_weight breach fully resolved. + # Gross exposure (A+B = 0.3+0.3 = 0.6) still exceeds the 0.5 + # cap this leaves behind, so the same-row re-check below will + # find a genuine violation and actually invoke this mock again + # (its own entry guard checks real, unmocked constraints). + return DriftComplianceResult(corrected=corrected, pending=False) + # Row 1, same-row re-check: resolves the NEW gross-exposure + # violation, re-implicating A -- only partially achievable. + corrected2 = corrected.copy() + corrected2[0] = 0.25 + return DriftComplianceResult(corrected=corrected2, pending=True) + + monkeypatch.setattr(acct_mod, "restore_drift_compliance", fake_restore) + + _, _, provenance = apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=0.3, + maximum_gross_exposure=0.5, + maximum_net_exposure=None, + long_only=False, + ) + + overlap = provenance.drift_compliance_forced & provenance.drift_compliance_pending + assert not overlap.to_numpy().any() + # The same-row re-check's own verdict (still pending) is what survives + # for A, matching the actual final landed value (0.25, not 0.3). + assert bool(provenance.drift_compliance_pending.loc[dates[1], "A"]) + assert not bool(provenance.drift_compliance_forced.loc[dates[1], "A"]) + + +def test_a_row_believed_fully_restored_but_still_violating_raises( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression test: `restore_drift_compliance` reporting `pending=False` + (fully compliant) is trusted at face value -- a formulation bug in the + LP that still reports success despite leaving a real violation behind + must not silently slip through and get reported as a clean "compliance + restored" trade-log event. `restore_drift_compliance` is mocked to + return exactly this broken response (unchanged, still-breaching + `corrected`, `pending=False`) regardless of how many times the loop + calls it this row -- the row-walk must raise once it reaches the end + of the row still believing no compliance debt remains outstanding.""" + import quantlab.backtesting.accounting as acct_mod + from quantlab.portfolio.drift_compliance import DriftComplianceResult + + dates = pd.date_range("2024-01-01", periods=1, freq="D") + executed = pd.DataFrame({"A": [0.5]}, index=dates) + asset_returns = pd.DataFrame({"A": [np.nan]}, index=dates) + + def broken_restore(row: np.ndarray, *args: object, **kwargs: object) -> object: + # Always claims success without actually fixing anything -- + # simulates a bug in the LP's own formulation/status handling. + return DriftComplianceResult(corrected=row.copy(), pending=False) + + monkeypatch.setattr(acct_mod, "restore_drift_compliance", broken_restore) + + with pytest.raises(BacktestError, match="believed fully compliant"): + apply_weight_drift( + executed, + asset_returns, + None, + None, + maximum_weight=0.3, + maximum_gross_exposure=None, + maximum_net_exposure=None, + long_only=False, + ) + + +def _random_drift_scenario(rng: np.random.Generator) -> dict[str, object]: + """Build one randomized, but internally consistent, drift scenario.""" + n_cols = int(rng.integers(2, 5)) + n_rows = int(rng.integers(25, 60)) + columns = [f"S{i}" for i in range(n_cols)] + dates = pd.date_range("2024-01-01", periods=n_rows, freq="D") + + long_only = bool(rng.random() < 0.3) + # Anchor weights compliant by construction: split a random total gross + # budget across columns, with a random sign per column (all-positive + # under long_only). + gross_budget = float(rng.uniform(0.3, 0.9)) + raw = rng.random(n_cols) + raw = raw / raw.sum() * gross_budget + signs = np.ones(n_cols) if long_only else rng.choice([-1.0, 1.0], size=n_cols) + anchor = raw * signs + executed = pd.DataFrame(np.tile(anchor, (n_rows, 1)), index=dates, columns=columns) + + asset_returns = pd.DataFrame( + rng.normal(0.0, 0.03, size=(n_rows, n_cols)), index=dates, columns=columns + ) + asset_returns.iloc[0] = np.nan + # Occasional larger shocks, to actually exercise compliance breaches + # and the LP, not just small in-bounds drift. + shock_mask = rng.random(size=(n_rows, n_cols)) < 0.08 + asset_returns = asset_returns.mask( + shock_mask, asset_returns + rng.normal(0.0, 0.25, size=(n_rows, n_cols)) + ) + asset_returns.iloc[0] = np.nan + + tradable = pd.DataFrame( + rng.random(size=(n_rows, n_cols)) > 0.15, index=dates, columns=columns + ) + tradable.iloc[0] = True # the anchor row must be tradable + + rebalance_date = pd.DataFrame( + rng.random(size=(n_rows, n_cols)) < 0.05, index=dates, columns=columns + ) + + maximum_weight = float(rng.uniform(0.15, 0.6)) if rng.random() < 0.7 else None + maximum_gross_exposure = ( + float(rng.uniform(0.4, 1.5)) if rng.random() < 0.7 else None + ) + maximum_net_exposure = float(rng.uniform(0.2, 1.2)) if rng.random() < 0.5 else None + maximum_turnover = float(rng.uniform(0.02, 0.3)) if rng.random() < 0.5 else None + + return { + "executed": executed, + "asset_returns": asset_returns, + "tradable": tradable, + "rebalance_date": rebalance_date, + "maximum_weight": maximum_weight, + "maximum_gross_exposure": maximum_gross_exposure, + "maximum_net_exposure": maximum_net_exposure, + "long_only": long_only, + "maximum_turnover": maximum_turnover, + } + + +def test_drift_invariants_hold_across_randomized_deterministic_scenarios() -> None: + """Deterministic fuzz test: across many randomized drift scenarios + (variable columns, returns including large shocks, tradability gaps, + schedules, and hard-risk-limit combinations), `apply_weight_drift` + must never produce a NaN/Inf weight, never raise (the internal + "believed fully compliant but still violates" guard alone already + re-verifies every non-pending row's actual compliance across every + draw below), and never let ordinary (non-compliance-forced) turnover + exceed `maximum_turnover`. A fixed seed keeps this reproducible.""" + rng = np.random.default_rng(20260209) + n_scenarios = 200 + exercised_compliance_forced = False + exercised_maximum_turnover_binding = False + + for _ in range(n_scenarios): + scenario = _random_drift_scenario(rng) + drifted, trade_changes, provenance = apply_weight_drift( + cast(pd.DataFrame, scenario["executed"]), + cast(pd.DataFrame, scenario["asset_returns"]), + cast(pd.DataFrame, scenario["tradable"]), + None, + maximum_weight=cast("float | None", scenario["maximum_weight"]), + maximum_gross_exposure=cast( + "float | None", scenario["maximum_gross_exposure"] + ), + maximum_net_exposure=cast("float | None", scenario["maximum_net_exposure"]), + long_only=cast(bool, scenario["long_only"]), + rebalance_date=cast(pd.DataFrame, scenario["rebalance_date"]), + maximum_turnover=cast("float | None", scenario["maximum_turnover"]), + ) + + assert np.isfinite(drifted.to_numpy()).all() + assert np.isfinite(trade_changes.to_numpy()).all() + + if provenance.drift_compliance_forced.to_numpy().any(): + exercised_compliance_forced = True + + maximum_turnover = scenario["maximum_turnover"] + if maximum_turnover is not None: + # Compliance-forced/pending rows are explicitly EXEMPT from + # maximum_turnover (see apply_weight_drift's own docstring) -- + # only rows with no compliance activity at all are checked. + compliance_active = ( + provenance.drift_compliance_forced.to_numpy() + | provenance.drift_compliance_pending.to_numpy() + ).any(axis=1) + ordinary_turnover = trade_changes.abs().sum(axis=1).to_numpy() + ordinary_only = ordinary_turnover[~compliance_active] + assert (ordinary_only <= cast(float, maximum_turnover) + 1e-6).all() + if len(ordinary_only) and (ordinary_only > 1e-9).any(): + exercised_maximum_turnover_binding = True + + # Not vacuous: the randomized shocks/caps must have actually exercised + # both the compliance-restoration path and a real turnover cap at + # least once across 200 draws. + assert exercised_compliance_forced + assert exercised_maximum_turnover_binding