From 53c7aa9182b8157bd19fbd142cf3412dd420cf75 Mon Sep 17 00:00:00 2001 From: xiaochuan Date: Fri, 14 Nov 2025 23:16:51 +0000 Subject: [PATCH] Add files via upload --- Q Research/README.md | 41 + Q Research/requirements.txt | 12 + .../backtest_example.cpython-312.pyc | Bin 0 -> 8313 bytes .../backtest_strategy.cpython-312.pyc | Bin 0 -> 44288 bytes .../backtest_strategy.cpython-313.pyc | Bin 0 -> 44580 bytes .../compute_indicators.cpython-312.pyc | Bin 0 -> 3148 bytes .../__pycache__/get_candles.cpython-312.pyc | Bin 0 -> 10470 bytes .../__pycache__/paper_trade.cpython-312.pyc | Bin 0 -> 13890 bytes .../__pycache__/paper_trade.cpython-313.pyc | Bin 0 -> 14047 bytes .../__pycache__/plot_candles.cpython-312.pyc | Bin 0 -> 1770 bytes .../plot_indicators.cpython-312.pyc | Bin 0 -> 5744 bytes .../scenario_utils.cpython-312.pyc | Bin 0 -> 4842 bytes .../scenario_utils.cpython-313.pyc | Bin 0 -> 4934 bytes .../simulate_execution.cpython-312.pyc | Bin 0 -> 13761 bytes .../train_xgb_usdjpy.cpython-312.pyc | Bin 0 -> 15528 bytes .../validate_dataset.cpython-312.pyc | Bin 0 -> 10748 bytes .../validate_dataset.cpython-313.pyc | Bin 0 -> 10905 bytes Q Research/scripts/aggregate_data_quality.py | 101 +++ Q Research/scripts/analyze_cost_profiles.py | 123 +++ Q Research/scripts/backfill_risk_metrics.py | 146 +++ Q Research/scripts/backtest_strategy.py | 828 ++++++++++++++++++ Q Research/scripts/build_dataset_manifest.py | 183 ++++ Q Research/scripts/check_data_integrity.py | 110 +++ Q Research/scripts/check_risk_report.py | 42 + Q Research/scripts/cleanup_outputs.py | 17 + Q Research/scripts/compare_cost_scenarios.py | 70 ++ Q Research/scripts/compare_fills.py | 82 ++ Q Research/scripts/compute_indicators.py | 50 ++ Q Research/scripts/export_metrics_prom.py | 65 ++ Q Research/scripts/get_candles.py | 200 +++++ .../scripts/grid_search_rsi_trailing.py | 490 +++++++++++ Q Research/scripts/ingest_oanda.py | 218 +++++ Q Research/scripts/notify_risk_metrics.sh | 25 + Q Research/scripts/patch_missing_summaries.py | 119 +++ .../scripts/plot_backtest_diagnostics.py | 282 ++++++ Q Research/scripts/plot_candles.py | 36 + Q Research/scripts/plot_indicators.py | 138 +++ Q Research/scripts/plot_risk_metrics.py | 63 ++ Q Research/scripts/risk_report.py | 107 +++ Q Research/scripts/run_batch_backtests.py | 268 ++++++ Q Research/scripts/run_ci_diagnostics.sh | 35 + Q Research/scripts/run_monte_carlo.py | 181 ++++ Q Research/scripts/run_risk_sim.sh | 55 ++ Q Research/scripts/run_walkforward.py | 359 ++++++++ Q Research/scripts/scenario_utils.py | 99 +++ Q Research/scripts/simulate_execution.py | 277 ++++++ Q Research/scripts/train_xgb_usdjpy.py | 234 +++++ Q Research/scripts/update_metrics_from_tca.py | 90 ++ Q Research/scripts/validate_dataset.py | 206 +++++ Q Research/scripts/validate_results.py | 101 +++ .../scripts/validate_stress_scenarios.py | 49 ++ Q Research/scripts/watch_ops_metrics.py | 79 ++ Q Research/scripts/watch_quality.py | 80 ++ Q Research/scripts/watch_risk_metrics.py | 56 ++ Q Research/strategies/__init__.py | 37 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1750 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 1766 bytes .../band_mean_revert.cpython-312.pyc | Bin 0 -> 2887 bytes .../band_mean_revert.cpython-313.pyc | Bin 0 -> 2962 bytes .../__pycache__/base.cpython-312.pyc | Bin 0 -> 1302 bytes .../__pycache__/base.cpython-313.pyc | Bin 0 -> 1348 bytes .../bollinger_mean_revert.cpython-312.pyc | Bin 0 -> 3480 bytes .../bollinger_mean_revert.cpython-313.pyc | Bin 0 -> 3561 bytes .../__pycache__/ma_cross.cpython-313.pyc | Bin 0 -> 2581 bytes .../__pycache__/ma_crossover.cpython-312.pyc | Bin 0 -> 3782 bytes .../__pycache__/ma_crossover.cpython-313.pyc | Bin 0 -> 3913 bytes .../mean_reversion.cpython-313.pyc | Bin 0 -> 1064 bytes .../__pycache__/momentum.cpython-312.pyc | Bin 0 -> 4800 bytes .../__pycache__/momentum.cpython-313.pyc | Bin 0 -> 4884 bytes .../__pycache__/regime_sma.cpython-312.pyc | Bin 0 -> 11184 bytes .../__pycache__/regime_sma.cpython-313.pyc | Bin 0 -> 11829 bytes .../__pycache__/sma_atr.cpython-312.pyc | Bin 0 -> 3385 bytes .../__pycache__/sma_atr.cpython-313.pyc | Bin 0 -> 3633 bytes .../__pycache__/xgb_signal.cpython-312.pyc | Bin 0 -> 9533 bytes .../__pycache__/xgb_signal.cpython-313.pyc | Bin 0 -> 14035 bytes Q Research/strategies/band_mean_revert.py | 63 ++ Q Research/strategies/base.py | 16 + .../strategies/bollinger_mean_revert.py | 78 ++ Q Research/strategies/ma_cross.py | 33 + Q Research/strategies/ma_crossover.py | 94 ++ Q Research/strategies/mean_reversion.py | 17 + Q Research/strategies/momentum.py | 104 +++ Q Research/strategies/regime_sma.py | 246 ++++++ Q Research/strategies/sma_atr.py | 110 +++ Q Research/strategies/xgb_signal.py | 274 ++++++ 85 files changed, 6789 insertions(+) create mode 100644 Q Research/README.md create mode 100644 Q Research/requirements.txt create mode 100644 Q Research/scripts/__pycache__/backtest_example.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/backtest_strategy.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/backtest_strategy.cpython-313.pyc create mode 100644 Q Research/scripts/__pycache__/compute_indicators.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/get_candles.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/paper_trade.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/paper_trade.cpython-313.pyc create mode 100644 Q Research/scripts/__pycache__/plot_candles.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/plot_indicators.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/scenario_utils.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/scenario_utils.cpython-313.pyc create mode 100644 Q Research/scripts/__pycache__/simulate_execution.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/train_xgb_usdjpy.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/validate_dataset.cpython-312.pyc create mode 100644 Q Research/scripts/__pycache__/validate_dataset.cpython-313.pyc create mode 100644 Q Research/scripts/aggregate_data_quality.py create mode 100644 Q Research/scripts/analyze_cost_profiles.py create mode 100644 Q Research/scripts/backfill_risk_metrics.py create mode 100644 Q Research/scripts/backtest_strategy.py create mode 100644 Q Research/scripts/build_dataset_manifest.py create mode 100644 Q Research/scripts/check_data_integrity.py create mode 100644 Q Research/scripts/check_risk_report.py create mode 100644 Q Research/scripts/cleanup_outputs.py create mode 100644 Q Research/scripts/compare_cost_scenarios.py create mode 100644 Q Research/scripts/compare_fills.py create mode 100644 Q Research/scripts/compute_indicators.py create mode 100644 Q Research/scripts/export_metrics_prom.py create mode 100644 Q Research/scripts/get_candles.py create mode 100644 Q Research/scripts/grid_search_rsi_trailing.py create mode 100644 Q Research/scripts/ingest_oanda.py create mode 100644 Q Research/scripts/notify_risk_metrics.sh create mode 100644 Q Research/scripts/patch_missing_summaries.py create mode 100644 Q Research/scripts/plot_backtest_diagnostics.py create mode 100644 Q Research/scripts/plot_candles.py create mode 100644 Q Research/scripts/plot_indicators.py create mode 100644 Q Research/scripts/plot_risk_metrics.py create mode 100644 Q Research/scripts/risk_report.py create mode 100644 Q Research/scripts/run_batch_backtests.py create mode 100644 Q Research/scripts/run_ci_diagnostics.sh create mode 100644 Q Research/scripts/run_monte_carlo.py create mode 100644 Q Research/scripts/run_risk_sim.sh create mode 100644 Q Research/scripts/run_walkforward.py create mode 100644 Q Research/scripts/scenario_utils.py create mode 100644 Q Research/scripts/simulate_execution.py create mode 100644 Q Research/scripts/train_xgb_usdjpy.py create mode 100644 Q Research/scripts/update_metrics_from_tca.py create mode 100644 Q Research/scripts/validate_dataset.py create mode 100644 Q Research/scripts/validate_results.py create mode 100644 Q Research/scripts/validate_stress_scenarios.py create mode 100644 Q Research/scripts/watch_ops_metrics.py create mode 100644 Q Research/scripts/watch_quality.py create mode 100644 Q Research/scripts/watch_risk_metrics.py create mode 100644 Q Research/strategies/__init__.py create mode 100644 Q Research/strategies/__pycache__/__init__.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/__init__.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/band_mean_revert.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/band_mean_revert.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/base.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/base.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/bollinger_mean_revert.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/bollinger_mean_revert.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/ma_cross.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/ma_crossover.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/ma_crossover.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/mean_reversion.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/momentum.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/momentum.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/regime_sma.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/regime_sma.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/sma_atr.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/sma_atr.cpython-313.pyc create mode 100644 Q Research/strategies/__pycache__/xgb_signal.cpython-312.pyc create mode 100644 Q Research/strategies/__pycache__/xgb_signal.cpython-313.pyc create mode 100644 Q Research/strategies/band_mean_revert.py create mode 100644 Q Research/strategies/base.py create mode 100644 Q Research/strategies/bollinger_mean_revert.py create mode 100644 Q Research/strategies/ma_cross.py create mode 100644 Q Research/strategies/ma_crossover.py create mode 100644 Q Research/strategies/mean_reversion.py create mode 100644 Q Research/strategies/momentum.py create mode 100644 Q Research/strategies/regime_sma.py create mode 100644 Q Research/strategies/sma_atr.py create mode 100644 Q Research/strategies/xgb_signal.py diff --git a/Q Research/README.md b/Q Research/README.md new file mode 100644 index 0000000..6d1a621 --- /dev/null +++ b/Q Research/README.md @@ -0,0 +1,41 @@ +# QuantResearch + +## 数据与回测提交须知 + +- 任何修改 `data/raw`、`data/derived`、`results/data_quality` 时,务必重新运行: + ```bash + python scripts/build_dataset_manifest.py --dirs data/raw data/derived --output data/_manifest.json + python scripts/check_data_integrity.py + ``` +- 如果 `data/signature_baseline.json` 中的哈希需要更新,请在 PR 描述中写明原因、影响范围,并附上新的 `results/data_quality/*.json` 报告路径。 +- 回测脚本会在 `results//summary.json` 记录本次运行的 KPI + 数据签名,提交代码时请一并引用该 run_id 便于审核。 +- 提交前可执行 `python scripts/validate_results.py results/`,快速检查 KPI 字段、数据报告引用是否完整。 + +## 风控与 Diagnostics 流程 + +1. **风险仿真守门** + ```bash + cd QuantResearch + RUN= ./scripts/run_risk_sim.sh + ./bin/backfill_risk.sh # 确保 results/risk/metrics.csv 同步最新 run + ``` + `run_risk_sim.sh` 默认使用 `QuantTrader/config/risk_limits_sim.yaml`(宽松额度,仅用于数据 gating)。在 PR 描述中标注对应 run_id,并说明若需要引用实盘限额,应改用 `QuantTrader/config/risk_limits.yaml`。 + > CI 会在 diagnostics workflow 中调用 `python scripts/watch_risk_metrics.py`;若 metrics 中仍存在 fail/run 缺失,PR 会直接失败,因此务必在本地先修复再推送。 + +2. **诊断图表** + ```bash + ./scripts/run_ci_diagnostics.sh # 自动挑选最新 batch/walkforward/MC 输出 + ``` + 图表保存在 `charts/ci/diagnostics_/`,CI 可上传该目录作为 artifact 供审阅。 + +3. **Slack 告警(可选)** + - 在本地或 CI 中设置 `SLACK_RISK_WEBHOOK=https://hooks.slack.com/services/...`,即可运行 `./scripts/notify_risk_metrics.sh`;脚本会调用 `watch_risk_metrics.py` 并在发现 fail 时发送告警。 + - 生产服务器可将同一命令写入 cron(示例:`*/30 * * * * cd /path/to/QuantResearch && source .env && ./scripts/notify_risk_metrics.sh`);请参考 `.env.example` 填写 webhook。 + +4. **Prometheus 推送(Phase 4)** + ```bash + python scripts/export_metrics_prom.py | curl --data-binary @- http://pushgateway:9091/metrics/job/risk_sim + ``` + CI 已支持该脚本;如需手动推送,请先在 `.env` 中配置 `PUSHGATEWAY_URL`。 + +更多运维细节见 `docs/runbook_paper_risk.md` 与 `docs/runbook_ops.md`。 diff --git a/Q Research/requirements.txt b/Q Research/requirements.txt new file mode 100644 index 0000000..1646ab7 --- /dev/null +++ b/Q Research/requirements.txt @@ -0,0 +1,12 @@ +# requirements.txt + +oandapyV20>=0.7.2 +pandas>=2.0.0 +numpy>=1.23.0 +loguru>=0.7.0 +python-dotenv>=1.0.0 +plotly>=5.0.0 +ta>=0.10.0 +pyyaml>=6.0.0 +streamlit>=1.28.0 +xgboost==1.7.6 diff --git a/Q Research/scripts/__pycache__/backtest_example.cpython-312.pyc b/Q Research/scripts/__pycache__/backtest_example.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..937dbf9ce7d6e7c6ae4cd74801f93cdbed41d94f GIT binary patch literal 8313 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 IQZn!V0#@b3K>z>% literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/backtest_strategy.cpython-312.pyc b/Q Research/scripts/__pycache__/backtest_strategy.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f23bdd862a090db7edaaefc5cace1326c74a0272 GIT binary patch literal 44288 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 RMF+?Uhg!6oYDIfp{t3-NCZhlV literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/backtest_strategy.cpython-313.pyc b/Q Research/scripts/__pycache__/backtest_strategy.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d3edde4da6596045fa8e39515c60a180ce626c6 GIT binary patch literal 44580 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 literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/compute_indicators.cpython-312.pyc b/Q Research/scripts/__pycache__/compute_indicators.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7eaec1b3b89d979d5e97a8ac177cc7b4a571c7a0 GIT binary patch literal 3148 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 CJ}<5S literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/paper_trade.cpython-312.pyc b/Q Research/scripts/__pycache__/paper_trade.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0b93540aa4177349422d68d3e8d98916ee254a36 GIT binary patch literal 13890 zcmcILS#Vp|bq~M;u`k39k|M!HBtenXzF3PTTe~HZiYY}F?F0t#9w|`R{N95kvJW=Y zI1!b~78$1|726e+abqS{5~l4mYMLhQSJGt~P+*4e3o}i}TCA_qkvr+QKRxFH58!HM zrqjpax#ymH?so1ycRBaqUrZ)F1z*KK8^fE{Qq)Hnkw0A?Q9S-v1x1Zg4Ao6B3Pu@G zM3vo21>&lRs#`@ux|=4Ux?2q)9Vv@yx;0U4x3<8i>(&EZ9Wg|W-NpjV)NLYp%-v=Z zTDmQ)rQau4Ukdx{OSp`&=4Cmd4~xJ5GiTG{5-Eyxybm(LZ>5m*u`J?@y1HEi*F@Y= zPq&BQ+K4w=(OnU(?5-qnU8E|yrh84ay1P1B(_Itwb^D^V-L)i5AE}Ghch^T7x*HUf zk~&N=hF2-Z_`WhXC{WFWdhik(N@5vMg6>o}bpGH4HYP~=PL5$Yg8S8y88VSZ z93*X!jB>my!Gvj!;38Bq)r5;_*I+j#4butpF`iG78WN9ixQNpcy@*f>6yE z7#hMd#>l84)G#Kd3_>kqW;77$2DE;QWO_QtJ=NF8v3)@y&JFI-`{0uatWN+@_?Vy& z%#TEG_!7$iG0OIG!6*#Yk>mS)J<#uVk}ss1*HjvCh$|is5?vM*{e}5EkB4ZtJg@ra z=PoeGvq+=@g`QKCppi=um4a5r*v!t=&(r_Wz5X*f0!35HlnolJm^ zN@e^&v?m^s^kF_6_jMluTV0L}!4jR-+WSDX~6KQdMS#b6}K z9^|+ z&S-Z-MjZsh2fld}f^qs??ZlSbJ9DiYhUnLfkD+B$sHhnbQNuscI+19dP}I5p`COO{ zKF~=N=b}%Zktro;Qc>4ROrQrvohVU@xl~Xp+2KoRhyk@YmrE&wfHI0fnDr?d$}P_5 z5=Ytr^zu?%#zd6}8uWf)&468o0?M)_oZk-fXDy`J<-Da(UKLX6Kdu&0-340OU)17K zNu)Ie8Dnew%8rMH)O~&fw+8UMW`H_H6EKx|N!=TX$GQB}R`rA#NfqSJb9Kn6hkYc- zfvyBOz{jDy{ll4x9{xebVx6?97#h0?UWFt_=0EW?*U8tRPC z$F&f)d?AC5g_Ax{#e(?10Fb6;O_u8~Tzz4z;bzl~rmU%MNSQ0Qk9tNtKxx0xo-Owe zmE|n<>zA%x0!s6Z<|#|vcxcM(A5y_wpD|QV8LBge#+=c5z4L14*rDJM1{6P0AeFLsy2 ziW*H~1x)}=qXNZpQ6~bpCXjD{;`RUn69*?5SC60p0iIzwTj2p|_~!=i6Nu43(E2uh+3JCPM`Jya}07}7Cn}|{ZWF-YzUaYGLP+t8P@O{oM zv4C%x-D27Dmf1gKKDf;(-`1oQqN0oPt2)SG$FIpB9dJQgZmvsZ7vd2(G{}57^~stX z6qaL$C4nrrsvbD+;>;_oreuv+q6ft#y8}4+lK|4x?_3q5=SR-xY_8GPk=9v<7Zy$b z4S&|rumHDv#vC_2H#~3bopn}>ZMnJg#?Gv>G3RoBV$oXkL#jC&Wwj6KiK=vfu9EXn zCDcH65)Lt-!lp-Yc%PQaI95n2mz7bGl*dpi6;5WR%GCg0au}XiN-qpdOT>w;KC3Hz-|_Z#+hUUabqCxQZg7R zq-X*GCLRg|xOG_9E(Exkl68h?3X$}8Dbn%$5`;TChoBbzd0fR_r9M$A)!N5et$O2Q zhf(c#T%}UiKGv($6_53_y5du#UY*}NNWKLd2rdjnKSV$lE;Ts6ZmZUg)tQEv;r-)Hzt*)(1ZQD$dimE|jT@Cm=pqlj zVCh(nTg{Zo-0~o#)T`#wFxn-#%H~})mv5Pl(J#rRSv8lTWK1cxP`hd_6JuUdmu}Tu z7RI_Hmwwe;<&14fF2f3=V=u`&Wn6)$XuL|9R^V_JC1GBH!&T(4tU8)*#U(P zm&sMWLTf6Dt+9!=%Z7OpwarUee%4eLIqaf+L0)?)MJO0U@j$n%R#Y)-mh{mvuN}+h zs$OQsI#~0Xr) zynwGzYpYy_9H#1o;&q$Z7KTf{sCq-ew25kf?P3|g^`Zvg22lraqo@VANi+i7ESdm1 zMKeH`XaVRJtpL4ZIlv0h2C!1J1FRAq0INkOz#7p7uvT;ftP?!|8$>Tai>L<(#sYxr z#9Dw`#5#a$#R`B;VkN+4u?nDHTm!I0tOmGMRKB5jU6HC6>zBRk8{~e%zE%&HEJLpf z4xZaP$YD$>J9zrp(_LW24TeJTB$&KHp#e#S6uK9TTaPxu2!e47j9!m^iD((00BbZ; zuoukj#k6pu?^`$U`zMHo;?XGAj;yMegn_UXj;cTm^5??iUsHfxjE{gpAt9B)$u$5t z2_de>_{DIHfr`+yBI!9k8~_VgIL=5G#3S)oUqCnq*7tLg6$$*gIG7y@6bl;*_P|LE z>}BBy96t_89vKZ3lCXwoG9pOj#T1#;2Q|X?ySe1lF&&V0j289gjXz%p!Hm_Ls_%G>k;iI5;!;X)Q* zxZp)BhEQxsNyPauTKEG=u=w#{9gz+4`hv+m490majt`1V(qbD4X2_3DAQ4XRk_(3- z%=1_fRvQgRB7jRK9I-?Y?D~Yw%nR`ZbdBfpajOtK4@q3S7p#pkNB%LP@K8|B%1;o1 zbMQ_PVL9##AU!Z0;rU)t^?}J$1`0$+}DtiTpeCO>}3S6Am~BBAYc*n0+5VoC6&wJzJl~u5#VW$ zJC7iOAci20Ac5c|1RMe$fq)>1-~xh+2reNQK!BD&?oR+nMrdT|xx^TXgo`l_Pv+cL zk*h5K5X3h?1n$$TetFB6F@G=1=jFZud0&Ko{?~Ah1UrSP{QC1(pTBF`KDPhn;Twm? zk4&7)R&7ro%GsQwO(RWXZ4=I{ZGHOKti?9Gd35{8_OWf_o3i%CtfeV^WIk~-+{m+* zW+0km&K=`jS-U@LYDpiOHCTu3qwW#+m}}ggEnk~8G^P(MPt;q6Ril~_&6s-ZLe|=t z)i7~6bv`;G0H`Yn@e zwsLpYwI|&9VXEJSve`(LIJ&~>H$k0`@o;72kn|(L>GL28%*_^H3oAvC= z(9YS)nsL>8np>Jo^OGN_v%W*w%EKAjGwUn}1uC1|nDuVWI=7`eN@KEf{3R5>r3J>s z<{IXSa8eUY*1b7v+X6#cAnVSCoTBYk`pNC)0Ke(QMFz7b-U>M?QRg{);~`p7+}f1>_} zP2X*rY|Xap%{up`JMNh(e^1qz%RaO?=Ts2>N4a-S4dEwcl&xY;gP4|bR?g`V(^I~N zIRj!w%3V2ULd;Bg8s{vCS*ePaxpKt7Uf`OuBj!NNiI|IWRL{8)^C0F$tb+13&s8E; zg?ZN?R*ke8#C%lU&beB|>Zr=rxq8GJsOn8~YY}V2dYcey#+LdKYoScmxpj!OQcmAo z8)EI4x*o9&m}4Vin=o}VVq2)1EpuBD+eTHj&uvF+2e#!2#C9TW7h=1y1$zKXADY{z zpwxyLx^jxH96LTu`_Z(#ZESbO(3GK@^7cef@#2T4YS4DDC&H!nif8^ce>r<1qqtps zqD$dPyl6=*mPq0Klu_RLdtnKyKyez^QBn4Un;1W z3i_|Ob{S;#)S^ltT9Os?Z2!vXsdBNLD_@~s<@5Tb=|8w~DbR0C|KXL>Q#MFP`}Cr3 zo~=T;>?pP{>*P_A+p=n&R;F!n9?^EYUDj>V)?H%&TgncRHTxG^=gwi^emIfNP2~tijm+}rR%NkaP&!3N* zMF+FxEgJONNy4r3wYZN;xpWk0sJg#vX+81`O}Rx^|LLXaqPx_JYxVV%fRAV7YoIt9 z&FsU>7sc4Z_u)L;sgSLt9(P#YeSm1D`~fhGP$Z^799yfBR_kZyx^DH}3!B zJOBC9YmfeZ?BgH2>jREKQ(GGwyAbB$F?7w5vmlmrgR5`ce{Hhh(RlyoU-|gQe|_)gw;o*nA-s;gBNvn04cI(y zKi`9)H}hHM`2+eMy#2lVZ%^dKbpQI;qbt{8$UgqT&tSBnWqR4p1YRFTz*#Um2=Vja zUS*gkf4`a=hq@#Kx^!`QZz=8;w&UAKHbG|s#m*dB;8mmm1~BI-9>W?!{kZ}>a}{4# z5+9dSc`F6Tnh*EIl2Ld-MbAKZn?eIfZzR~q!?}p^`O^6dxH)g!2mrUha?!!6XfPaW z-yMmEf)W17_Jyhc206*w(|^O`sl#(sM~?G@4bKx#(l7bC&_@Q{L}hnT=off(`g-G> zZ&2xLmK1(sRpLTW5GaraXK*|zJi`7AHp|64%LYT>CgX#dL54mOj)sL+a5;@95>R?O z_hn$`&>4e!5kU|D%+;dF1p1!r1=mQ)2p%_JY(cFP1`6h{v-<`;az*ga&qG_|I6y43 z(>G{u^_@arG~SoD{t%)|W48C>$QOCF8STz67BW z7d{T}G#p%;@-v2*Pb4iE!dP^cCGV;ECuj|_yo>qCtO$Zdvg_#KqaCLt4TPr-K6`@0 z1FA%aW4&=nOG#cavI2;^F2L-|7DD+S=fc27L3lTlo+5)lo z#b8+A-Udd$S1Kc-;<(qaY1c5bnkZO7qvGf$3~$-saxCeJhCFTrxp5`nUdJr@VA0W5 z(xcN>AxBw$+H&7TCNuU9Ert0>l7GiPj48VO?1FUe`xu9JeIA$RA7BJLIKi+8)=zaW zSR_F_7;qDi#A5?iGLP-cYbA|Z$lV3T+nB=%!vHEbYjVz*8m3GQnT;o=O&wr3qV+Sh zdy00C=_gKQb{xyl?rHk?eCmPB_A`X~T#0I$NM?3*6YBFNs%5g`gZ|93&t(pt&Ab>R zsqs>(VdBUgCUZ1G=+P40czg35I$T z2K_Fw`Z_S48QtlFv*oqvXFfDoZ|G;u6`9JjS#x(r+kMYl{gxXnLDuTAKbaiNoIITg z_05F%sSrPH71Bp%>(@`JCQsdYVrJKgsa+?s^&L~RFMVj}(D3ou^6Ie*Rm`}Wr(DgSnl!|SVWY}j z>7$?0O11Y>E!Z=zoVa}An~kGwBW>f=)AWW}T3?{FkF<}sPtzOk0p-}`V;S4F$qSR` z?(}@%_(k^bfr7ylvcItLtEvXQf2by+_j@G zkGwn)8h#l}b;IwQ( z_0!hIoV9u!BK5;&aOE4_^Nl^@U!D|xocdAf?%D&hj*8J^BgZnnXQmy;at`nBD=B-` zTs5V$TsK@bTrm$lZVkAA4J=CtM+t#?Z69rI6X8-54v?!4SNO*?ZY z`;4h>%2by%H54YFcZ&9wY?EIce>UrE1~j*6o^Fw=2`KJF~kpbMlKbCwrz&_GCIk87nhQvqjG4 ziK@v^rg`U`x;tk+=(=;_m$r-*>{%y3U?xlYU`}tIQ>t|}IeYbt-9H7kO#8Z_19Mu+ zWV?R+>hUqhw6S{5L>Vmq{pkq>1#+hpCa+}m%&8#!pE*xq0A4fw&!;;ns}quSwhxWv zkgT(P21Z4#={FAV(1BT_?fS8+$1)WgCmSYR8PASi8=v?@ML8?xRKUXHt?(C5C_DF3 zzu0Z-Tu=Sd{8Sr6er2+rbWp!quj|~c`qd5pSF$n`vQVi1Zanmw7c`3S5I;uK@`tGJYJ|^3MdrdHKm~cOG_#0KcXzZp-jJ_4F675 ze?-}TOL+nRh|>Lr(&s4E742p170YGIuo9moMlB#EXfd%FjAWQ7n zrm~6h*b&)|P0IF+=p1*A1g=o{woTr87jz>~#7>i}f*Q=vW!|N&n2f z4=8}5NVCtGk9iVr-FM%8+kNl8<-UtgZ8kFlq3XX|liN2k%Mq>8af(D zBgX`odWN@DF}!t`mR8S@jeI3{gyz$;5Ph9F#}EzZik8=aZfHR-EWD16 zik`RfEX54Gjn`Am$lG}X#Z0_|H&U#Eck(8RnfVH088nA0728u$a%ganBnG3IG`U{~ zHI42vtCR$n0r_z8XeL@1o`K}WL>!32i2)KFh7mk*>M++2!e}?gbj#Y#0uFJ_r!P|# z7c@P^X}yexD!n@0dY0!dGO5!_U{Xa4yJ^WBt}xvSVGZfM?i@cgLG-QKb=y{Nhv8xnWzX!I+*&3K9znR(zPRT z#hOV+a8|?=86niX72xwiTKN~xr$S7=jBq1v7P;zic z(Lu3_K9)*L3B{O-N|{JT3bP6;B~k;V3K~v00E#vw#0_AQE|(W zbSygzrAu4Rko1=mF+h&>MxKhs&Vy2Bwn#CO90A%0u2B(OqY~{SmlRVZk`$AfNJQ~H zq!jHcy$NcQt^*ij^1hlWaZ;S&XX|BO+k$V$yl;o>+cnOym@5z-iB09M_(>`o!BkH_p9zZno~Nh-_(Hbk*c) zx6kdJ+nn3*gzS1UXMHliW6wBy%T%3zY=6$QA2L?JXX40rPQqt~eb+R*^N0KLty{<0 zSFN8yh?xRAhJ#Pn`m zE&smfLH!zO8Bo&7pe_)i{@+uFpl`&sloYvKTJKZpqJ%98YrF0h(+-9$Bm{UtH9((+ zM_?{XihdxKPLsmi*7YajiY_XhCk@DHhMgu#Kv|*$@cwKv6{ol*k{yAyIf0R|frOEl z#TA=uN7PVMF5NJcW*UIWEe$2&k^$0CX*iB-rfh{m6wkx6zChgy9MT&A#+XH$-)`AYXxU@`!d_Ur9(Wq90>cet-zym}ERE!SJ-9rZJ@d3$(V2Qz)a z5}L>VO?j*HTKCoN>0`65Th`XR+n={oE_hq!y)Ck(CGW4EdTH_{+21mLVkKkscz51p zT`&daO@V3mEmIv-M%tlivE_RUPHQ^hp8(`_931$r447jO(~6U2dAd3Q8kz5F;E+K_ zfb0Q|u!c%l!rF?WgE7lWLyG%h$x$L!FX1@DVzIpU&|i?zOIKf#9bC@D6*{n7=dek% z1BG3`*O>xBAqZN!)R($TV!A-gd-@NoCbll3s9N;oS)2BAi}gdND44iuKK z1BE5*Kxql{2Vb=o#8K)5WW`QET=VHTAg?ohrOw0XmAVc|#UN-@wF3c^c>7A-hg>-S zSA>Uhm1+mkmqo~}B0@zs9KA@T2Ut>EDMDRAgh!yVg6yzlRqe=Q5K)3FL|4}56Ob&S z_JQJg{L}1>it80KyJSxorMKwzO=PAnPF|ex%I;>MnDXALSIqgc2uodu5^!y%B2)lb zOC?qp=X!!u*Z7C{u)Y;xRlZgBgjM-g*(FpyxZi2pjiN@-G%{gb7d_X6jfGPJu7Rt~ zZ^dvSor3d$%GUyXboGs9R%3@HfG(`MemLghj4Q62bWM0tusnvZhhaLP*7j&MPULqB33#lb%7?+NDY!X1c(83FX9yd6az_QvPAro zC7z6BxUX_uX)yua?nVae0@Q}Q;wB-x*ZV_+omeTIs%u#n+NfFP)YKM5DaZ!w=} zwR+P7lS#krfyb)%JgCv>>mHbO`l<(JR$uj*)vPb7-Oj%kFpw=yL@SOEsK98(M2 zcmg?~t7Qf>VHPd`WK^Hc475E4H)2yljHU649@f#hudtV*!zragiVh?qsdO|BH&%8e zni*0w1Is7BV!=eX?}`J-LDCQH!jq+hhc{TSDs1ENiNiC^zpmIc8(oC$?!}j1oQ}#4 zZo$zs^VDZL#=LookK{71o2!y{J(A0^#^|`q@)oUY@RV$4qHPThZ%GpN zH8^}F4#&Eq>E{EFwBNaIuHY)UD%WUDRjD;D!PO6UqGd95ElXQ{-c*-3+=6>WZg)AY z4|R}+HCj=_Z+N7io@Fgrz4p*5v(~$Au3DaZq%PmOEv#Fmh5mI%qMmPfq^`iaxf)l= z6Z)2dSMw_Us$OH1T1q2Yvj#`F#Ia!wj!h+wP+!UTvp*0Judk z0^BN80Nf^+0B#qo0CxyBfL_54&?h(m`UNMzpil|0N^k+J7Tf@91P{QF;00JK_yE=k zet`8t0AQmK1n3aV0CfTjaI;VcaHmiYaHCKKuvw@E*do*b3=10oZW2NOcL~~vX17Lc z5E@pqA_x|>pRl(z^gg~*2j|>9UGzk!7>)`(0uKh{Xe^e_f-x-?yQJul!VZAh>^>gL zz-$KQwEMq8)F6$3Av|6*L@YgtX~~hnH?L#(2Z+Yf!@~sc9E$EMnM+A0+y;Rdm4=ct z-_n4^O-g~KVni{(K{*0A{X*P~@#m9b94bP4jbbKJG6J@>WIC=m5KpDW!ANEZ4F5xl z6A98#8tfTGiX$OL`{B3;mbfGw?FNr20TqoDldy*2Y$~HvmQqyGAk+xqcTXxFT1{F^ zU5Z5e(-*)#kCsF`z!5ymq|f(*1(0456jLmnPCJdj zAPqpVqWx7Zhv3HjN!yv^Xc+Q~pW+v~R{OS8kLoO=Cw^ zCz>4-x+&wNaauopL3TFD=H{`(i*O%x-|%1e&-iBja&VjM-ad9J?+r{nJ^A$XS!j^A zX{;;nubO&(^7-k^YcJ$jPu}Ld_Vm@KCwgUD2)gr7Y_ZZa!PC6wW+5+_3%87QtV+!L zf>VQ&gSpTa*|&A9J8$&O8|xRVYG)jB)z)!GvC3TSHrc-&D>eG(jSY*{T&`}LT)jPK z3M~4nrgz-fbA3;)Vdq>zuHG;E4vcj#);7-Y?>+s_)48_ezjDhPPs_DkIksjouwgoO zWAOT5u4(Vh9dhVNIdCw?dKar}XLRow-!bM|p7>BNbI0WB<2g34=q(BbDx2FT2Y1Qd z-D6$lG1)is6%@Z?6O4(=Hz85syf7P={X1mWP8iY}ZpQtd{~doWeDK4Ka@`5J=46fy z=7TjT>0I3|Ik-E=`X1@yiuBv=hMB|f9e?NeY=_*qM|M9pc4`qwN8UU2&MDcw4PuVa z^jBxk$&P(vCvJPgvkgCK{(kdZtGww+*?VxT>$a`>4@|Ay@EeDBNeAD5sSGaZ;roe! zaaAoD5i>E~>ZJ@p4OTh_z$t7R0t?FBCJ$EHEs>5tv%b+wDMOSZ;s(NiuMq5HlTJd{yM{i7{SuBqs*;hd=uXcy6ot~M7onLPd=Ky%yyw&b=)`co7K@(+p=z+ zR=#az9>K-8)3wmp)A|3q6(U%U_$|2M^aD`wZVN~VF9<7w=n*_Z1-{+$O>OYH!*6Ya zT%vc~`Lhkm>snD5L_tfpLkd1YtO(#N!>TfdRb{>TxkK>qI~}+hc4LAM8L;vTJ~wj$_88H>u0(bV{^brw9CI4psx46ZAp8Rh!)R5}Id2s| zK6p2<#1jQC8n~2>n%de54*_ShXui4kqc`ro{ulSZ`Rcv@_G9juL#I!{8-j@>zLEvU zzc~2#BvK=orQlVR#V7KQzxSzc(x9>?tk|`-Ff$oyYFAQ_w6fC>%Dh>cKh%CBX|6%Ghm82 zu&sUjp0E!1p}Mq4ODz;;ZfgU3K^wj*X2Vd*-M7c?y!+<8@7%aMJ^{#`xyidTe}let z+?|PUe|+T!cV3>obNTJNKl(0|#%3;oRRCL9D9h2-2Ck27LkaK_=}$z#nGp;F1T8Ay z85zXFufBHY)w!a><(*%A{o@b*>h>?*xqJC1@C^4owV3RFz?L~9a(BnyC}dgYuE^be z`v-U4o-K&!&b8_LSFXX3ef;CUgVBbTnN?d9xSUu4Pk?D6lRgj5U6y6?59`VIp)SRO z4rQd^t3`f*?f6S1+n_U%QfH1mgf6(I368z(2G^|W`%x%(@!n6c^sCn85x+-DDN}Au>qE_zYAE4ujTc zJRX5mN+XldGQ~_ektosEM8!~iTEqo~;PG9t_JD~Kghm|*32sRQ$AiuoYGP4LU@S|Z zFE4$NB^;(25&Q^1VOB)JYRR8GezNPVVubJ6qdljI0aMweIFMHK)X$1cU?vs;9^dI3 zIelDK%*9CoTB(@O+K1W;W>gp+`tVG>tiYvmGyxut>ERK0XpR&I#Gbe~f;LYWb0ABK zQOXVvN695Jg4I_*LGYRiJya}U;fyDW5i{81B4YaaXfi|o0vN+V#XvS5XLaHACt0jmBKjeK#l5iynPX3YA>VpqVVCcAV`fI1Z(=^pK*)|ip#co|>%|%N4 zWcy6}Eq2>&pmct%Gw0epcVTYmX8(ttUxxlZlskAPcjgN@XU{E`2T5Hyb@|jcx_;;N zjdd+)oqFGIUDZokoh_L6*G;`R`QmJB;>DbQ`vm*I0LG!|_`4?;-0k!3_TM-Ic~{MX zt6|;+HZ9o|9zXnBhb!;&Eja7uopmz}x13FRXJ`f@4HI_oAe=fdbztV%4_!Owj~2oS-s%o=AE4EtRFl1Tca~? zvM-o|^QPc*_?D^hchL0i%iXtFZ{Fr!u+`7o>SbGFanc3n*Us0QPhZfx7d9ZW3H;*4#GBGySwNqD5O?z%xL!a0fi{oD(oYpX&;HQkn7W~K= zSkl4w-pZ<@DG1)%y1jmmoT3y#VU+&CRZJTSH z^W_4M{ZH%OKj;{5)shZaB)lp9a-;D~1@p@`NB17)m;0a6LF6Aj&NCtAANQEg7<4%e z3zVE*gSY`xa}};L)w*2Bd}gyQw@CvK9gc9RiA2B^>NzNrxv)x3G2K4uy*~{0>DbIQ-(zQ?evWHyrXT zCTr6YxYA#e3Jt&o9*(+6g6>X=G1@Ot&l_kctf1%(Rf#{uXisC2_}ddWU(o$c@x&R@v3m@b>HI4(OTwD@E( z<(PELXwg#tp5qjy;$y-m#T!-xJyv3Zl!)*O0X3IY_ZJ%ik8toq$ z0H4_z&7RK;do=D(7yu748m*@Jf%&9H)AEO2?Q#PLJ$=as(=&~rCKeX#a^@au4dPO zEh9Nns?;Z&6Gkb5BM}_<6LPH73n@W$r&OxiUV1}?Q!nj|?G03F51GUFe$DTj-S2`y zKcMGtJ)pk@0r-t6?(sB)vnw0`8vp^s8DPH7xp}^(3n@4+azF#gfSY!~lT8=0E4@Ob zv`7204Kvs)V$x~-j4&QHP0nmF8r>9+EBY2MLTuW z`gLxJYa*Sh&5gR?dC`3+bb^;p0(IrZCt!=e@MS^tHM%QDPiaw($d-z2O`)69YywS7 zOB0yT;|U@d<_ZyGhG}b2k#MV+!Ha#2u!UlVOO(TGv4o4{S|fMWG%_^X(PK-Iy9pIL z1kTbPMEnfvF>O2wiRkE#p%HmiUDa)+p=2*9-E|LE({arH;Z2)JDRoIhI<|=yYnGwLG~!>uIt^kiiD+Rx z?L4H&nRuyFXBh z7NaMUzZ5Els-fm`XE9lAX)BMf_qeRNwLG-$tG2e6=hpkqWslE)Do8z^b0Epy!rjwW zAWA>Lj_0tW+R{~)DqN+n^7`T1w+}<_SKE84?cwU1@0|L8*m({_(R1DmMDOFthm&P# zXYd$aqyEbH_SELo?yUp<=*q3b@ZiDd!P3E(&#oS}P824q(EE7m;Z*s<7qIsP`VIrV zJ91_1(Er{s>_4NI0z^`jl&Yr{B^SJRfBOE+m@;vrpDr)wrRo6H2|sZo8p6FS*ke(+ zR2#EznzS>#jBGze-%2RvLQ;!4HVOVq#bFi+YwPmT?WR@wVrk3T5)~rd8J)SOjD0#y zVA9mnBrrWXr;Lrxjxt*feiqD<6y+(v*J&~QE~_YglrmAq&_2c=P?i1hY=@Flf#bL{ z58(VifwrH)8^6fG(r|IOG*TR?+}s}C9Nr$;9NE2n6dpX1hYC`)sim}1Tq)&?`O3u3 z;;weoG*}4y3A?y~)0+SWzRrCwbpHt3%hMZk>vJ0q)*tL#KWIAY_~cpi+u36{eJTLh HQ=94^DRQf1 literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/plot_indicators.cpython-312.pyc b/Q Research/scripts/__pycache__/plot_indicators.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..44f3232e0e84c49df44e6f942702f28231f36c6c GIT binary patch literal 5744 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 QZ>J6IpHm0L2+ZpL0^hSUEC2ui literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/scenario_utils.cpython-312.pyc b/Q Research/scripts/__pycache__/scenario_utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2547ac918f020536d6d666fa8dd835249c7ff745 GIT binary patch literal 4842 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 Rm|Cu9skX0ph6-3`_&1(>o_7EM literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/scenario_utils.cpython-313.pyc b/Q Research/scripts/__pycache__/scenario_utils.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5a37e02b79037e35244f371a47c62400f1444888 GIT binary patch literal 4934 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 UKhU~Aq?oTCd#EL-I(dfw544kx;Q#;t literal 0 HcmV?d00001 diff --git a/Q Research/scripts/__pycache__/validate_dataset.cpython-313.pyc b/Q Research/scripts/__pycache__/validate_dataset.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..99d0c87286fbab46c8335c475986e4070c227d86 GIT binary patch literal 10905 zcma)CYj7Lab>0OQzygaG0gwbph+I;nL`Zzdl1Q1`wg0{Uujw<2rwO z?k;x;QIPB_@#5~@bI*O9bMAM}-3KcA7t~U&t253afW#gcEz=A9T3}ObX$hy|v zhU{{C2Xex%X|QU*gre3C_|@Dx*QwoQGrTh!-hT!&P%uxm3&5 za5lN*V&LNlTI3MSfOZ8kGS0$JHTs`NKOPkI&aPGcwf{4-taeh-X(Y-c* zmPqIm`hY+CrsYgDm5fI-JPTP=FkSp5R_{?-F8N-B`v(Ov;^fq3GqVW1(P(>yMm#rZXm9vV4LE93UkoDhTOJG?PpV;z&Rz(Rj#sAQFx9 zY+B%fih2%M1@sctpNwTBdUP7oM^lnv3&8RuicCd?X5CyZu~;7)v8ij!BS_$#g1?M=+6!CRkx+iid${O45l1G;?*DM{Z~?k0oG~ z=qO4|N0G=&_QNOvO@z#I_z_9gXgnT)TKS#i>_2w=aA;^OGJJUC)bV57m}HnprK1_i zBDcnbA{KP{35nRUuu`Zi1f0aK*yK!9=;}|$WL{#|b13~H9|L(lJ{~y|jeR8plHPS1 zXfi=w> z9t|jC`DhYgFGoP4tYfE!k427-4uytCILQpa49H9bTg`~V6m5lyXW&nK4zgL|Lr49b zzQovXZM(7U1IAx+*IXO;)Id1>_l<A>b@e=m1nwEbnlpo z%(DgNS&hC9=&eq)379^zeRvt=?Yjb-TE2aiHmw;s!$dLo0?vKMQ`pyTm(oI%~nO0{MEI3I7Tw&aW*urcbBpc`-I zOnu*0q+8I1bQNb~lHq_d8*G2%RMsDe_QnMB(y6_uzhL<3kuk{_OY_$G!z&ORnlTiWesS(Uuk{1zH9LaziiXt8aKoCW0 zYm&kQk0Q{RN5C;6^0BlK7ZFxz{3l+A46K)#nQyq(uxMQBU-9P6eMR%(y!mj!e026m z$z*=z@;5IpRK5JQlEb}fwa=fpc4Fbkty4En6+JC^PfM<~yWr_5diLi%`+sxs-N|21 z7Ca~J!qa%cdUp0$$y&A8bR&?r`k`#HzLNcBcH#8P-@v>W=l5K&;ycyf8A@N zV~MaP;2}~K5W!IXHdwWxYj{-08q~yaohGb_VG!)`#1%MG;IPQ$%qpF)-8U5Xd>E)@>QPDu|y-m!oQKZ{rW~?f@~@? z7bYUQ1^T4In%SsiC;$&qA;L~@h7e^Zc2Vj`FlP>0AEpv(zZ2vHq4WZ3D3R!M!%SwP zpLC=Y$f9m84Rogq^z!w1=s+!fPpdg3fMq(uOvl`iPO`(AW<0Z zN)!N#xMV(dCC1B08sX7Prpr+zKnIdu%pl1Ga3`I*#7m3{-y)eb!WJ5ij>~Y=B=}5# zmTW3Ii{SnwD+bWuY$#8C8|IygL{k~b0B8tZ74cCC8Z9S9t=)Kp z8c2`GWatc#9fv=$2-z%AV!TDBG0!v>nWj9`1PXUwXD|S$s4LnU^R~t%{c<9QOFg%n zS8MBUU48B9lI!)C=1%@aPuTZ7v=EGaE>*D9&mLX1S1q&`U0d?5Ed^Iw(X~78+Wk*s zzZ(DL_&=U2xDFNUhi6Zes(iQnH~foo^4J;5xH5B>qn#7$xcn1RY>C9JgyQqX2Y>LNL^x8m6;=tKHS07q4vDDVyGT8NMy zlyV*`$T)>zPOt(1Qq6^62pPVJ=P-0oHs%Uvax*a=s&w+Iy>x)6Lq8SFmW(9G{iHev z48g$R*LA>{RfQFdVa=$GNAZ~)tlqF_@Tm5swE~k=&UF~hq;V9YFuEGPK`2lYRx3b_ z0KzvVg;;7P&VK@XhdpE}9#2mM7|AB`DU8o#5GNumCz26-kc)6Ov-rWlPfcg8B8=An z2wnu0pW>qe)@IOFI3S@kGZmmwD<&{&{)|McUcO{qb7?UA6A>6T0`+2IQ&#qYM9C0K z&j^_c`dSC9r=kds_=w6w<%%YO#q;nd{tPlu+s3BZlO=a`(Y-nE-u!m(7iZr(yF8e4 zZ_c@&ExHfo-G^p}N+x&FIhma^I-E)o`N$j?y_}iK&|3H^1-qR?n)- zb8E|MTNbzcbnlP${{8-Z-Hw$rzdHBMxjUy<&gHsK6uXD+K9lzi7hEHArd6kV;rV=Z zThY^z_jD9IT?OZkIm3sHbFRDOZ(8844}a>wXx2$sJ+q_AQC)t#VtJGG>Im4u3+^w2 zP&FCQhp__433LbB(G7`v#1WWs1C@CL2|TK@^)yWi+Me7Fj;i%TU9XMKz>$KH(+MUx zj_8m(Q$iZOdcj;aPje**@MUJ8jmsD^KTX4ewc6mcg34d8 zgf+kr(n4`CJ6fCFcVKqd&6QISdqEo#z#~hrVZ%#!3^qOfjx(yGu2fsrPxIq#)s~#; z5#-N=Xu&%E&mk*EcfcZRK7cLUs`3b9BO<4eP2~~}sPD>Z8MT%(s{rp2%Vr91S6hwm zR4D{|7a=$}i{Kn{bENEHfc@hb!5MM{J5>7hU(Txb!V)1PwD=x)Sg^uRX<-W)0T|gs z2FM+|H17%8n>eT39(M65?W=&!i2-6U57 zAy+y?1sm7rsg_h(D?2s7KCs}Ic9CvPSJgEUszOy!=38fgFDpH`foE4-l;wXcdMoKUv(vd5J1_O)U^!b zBS%gl?c5!6hYTS)WDJ=?RLC9z>Er4<8Nn6OPU|RuL-5iuuHTS2zuM~)>NkBseZv>j zH-14qtJZT(oraK$YsM-Ya>04Oc|`V`6Y7Y^5Y}9@4QF_rQ5R%uwVxeCXWZK@d9XOjY}#CF)NS>4vo#MeAOKk=X0Zy(HdjH%Io zIrh&2Sm(jXc&3$2Pe3chy%yOvgk$ylv--~66IorLQ=+ifkZl`D3msV=rPnVJ6gEmw z2^g(4d?en3&sM-OZU`DVq0y(DBWEQMn2%fRU8YMa65^>YO62sLB zc1nYnq#vE04rMY+<-F=0p<)Eg+ZZd$RIMa9^8|Ba_9u2?~g@KB#(9}B!>f) zOCiY&mqHPFyb^dBbRw09x%wH=x_0;U&7hfarOSrgFLvVn`}GOnt6Z1a>D84Ytu#v>UK^~oGS!og|4H!S!@55?gn z`Wnu%xY{Cbqq3z&Mfzny5j{}5=8%{F98z@0{;0yBn?Q}VP5dv=z|WLuTm7~Ag|ULU zZuZD(m1iM%Yy8IeV)RBB(EIE_2{8NC8(Uuw-0HZ|@p@O$709~+1=sf3!H*qHOU(sG zD|iT1E+BNeXHS*9{v1{PCzE5<=9s^5?ZV=bH-?tux1Y`1`sZ}3)!s$do15O)^n=Et zuRZT;FZenO)m?KI@Gx%m-spXOZ_&Li@7`8$x6je5{+%n&7X15iwC_)hf7MfS>%?m( z7Eiu8`o?I{zccUOncH==;O{T`2lM{He>?K;Cx3Uc;1A`_PUQU)1y5q`SgE#c4ty8y zl6U#&j~ep6uDpB4T>q+f)8ECGT7Hmxd-L0|mDZoVc)O{vZC}1&U%|V7?$oLyunbe` zn?3oFqYCEbsD{i`CC3Qeb#ruycFsFqb>zI;@6qkJ;_%B~FO^j+Zds~ZvgX={?@r!5 zmwWzf&U@}29WI%i3%d)Z+H%TV-HE&ATrhO!8@V&%Iq%sI=yN5rYvJ_uO(nN)y`#D# zcY5z~ce-+?xtw?G9vy-Wy62g^J6IgLkRQ5G82X#yP$I`q3kP@>?OEL2UxR=~*m z3nuTQ9Ui=6xw|WOaP%(9d4nI&T*>TSh+c1aw7p7$cSV!SlBxl$~;X_p*N4EhydYa>iuTkt83$5^k`19JFpiPxV zsuegRXX5By9G^CX@tv)lg4pP6NZ?)?I5TJA7<^~FF+Lq4TZkveYjN#bEY&(PhOdbs zTndpy5n`!0CK^e9C2MOv+d9=6kF=g_9d6|$GLkjOUj#_xWpn_>f(}CV3C0!2fq^k8 z#DRfi^qOBShiK`8gZ9*N{QP#pKGH>Q-r2k@n_k?~-SQ%HdKs-09Arzjprm1-RMG+gg?) z1zY!=uH>jMI@r8}ougK3*rkh0W6M>`yO+1D7;>9?@-;oLG9T466>9?dnn0mu`y5lE z-9@?%)S^hUd754J+y?eg+W(Q+Q#AYXX5ZrGWm689cB~v(rLFVkYvzS5OQ%;t@6&rB z7;AavBc>)t)%?W(#e3!*%G2i}4!8W)ReNNY{Aq+N9}_ui-%NsCD__}Yk_z%EiBo2@>he76K&u&^p(?PFzVlywE=&r8_L0An+}fWP_(IFv6V zu;?i`&I0f%D#T~tmQvPhbQG$js^f42Gm_58p4c&bb1DN@`Tj!2O8C~~N@OiS8p+D~ zDj5}>SMFjcfOl#5nu)@neJB>-I_?!)2lQjeM7%Vz0Z{axngZVfVWs~UT>Cr70EaQv z*Z0l#uexg&sW;7Um|s6wba&+49R+t+(Y+_{-cxY*La>#oEiw&xrlH8Nd4^rJH|CnV zS5hk%a%_LWek?~H`>5JCJ5n++ue|iiORK*2x5rjme%0|#N1@};9l%?obM}vI^@~Hd zTZ(-{`M#k7{yJpUwP|tU-M#Pb{c(JG_b>LowfEkZJ$L(a!3zafWOi_svb-|*t-+jY z>#}h0r*J@gp!b`(B=gtqPUGh0A(Gj>0N(zw(HpGa}%z)eJg5{C# zz&c03GK$mD7$2Zy0R6djI^K!*{5Zlka|PBTYg0c53WG_00SIxVA!`OgPPt{4sT>-W z{Qb=fV3S9HPQLn5c0%5NO#8WAqs1SlVbWGi`VP)wIP>95W^2b3xZ`&0O}_wf_#?PZ z<|S|KFY)7dA8CZJVFF5d*Kt|i`s{kI__MedHVjH;3C=L?RieNz=L3zBDH4gNW08nt zi9{x5VD0!w1Yu=Fpu@@uq#RjTtnfjZnM@@w%B&nWyXv8Eg-Ne~XaE4_VZ#F*bV2}cS z2O%p$k#lq(OQlroUi|JVs~^PdUF9|-0{V(T9X?}tQN zo@o0s;ravN{v+Z0Z}?Q@;H3TS#vy!zB8*Jq6`z%3O=duO8@Ank_o#XfQ1XU-G@c#k1PZ19Q literal 0 HcmV?d00001 diff --git a/Q Research/scripts/aggregate_data_quality.py b/Q Research/scripts/aggregate_data_quality.py new file mode 100644 index 0000000..c3b0dd5 --- /dev/null +++ b/Q Research/scripts/aggregate_data_quality.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Aggregate data-quality reports (results/data_quality/*.json) into a tabular summary. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from datetime import datetime +from pathlib import Path +from typing import List, Dict + + +DEFAULT_DIR = Path("results/data_quality") +DEFAULT_OUTPUT = Path("metrics/data_quality_summary.csv") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Aggregate data-quality reports.") + parser.add_argument("--input", default=str(DEFAULT_DIR), help="Directory containing data_quality JSON reports.") + parser.add_argument("--output", default=str(DEFAULT_OUTPUT), help="CSV file to write summary (default metrics/data_quality_summary.csv).") + parser.add_argument("--print", action="store_true", help="Print summary to stdout.") + return parser.parse_args() + + +def load_reports(report_dir: Path) -> List[Dict]: + rows: List[Dict] = [] + if not report_dir.exists(): + return rows + for path in sorted(report_dir.glob("*.json")): + try: + data = json.load(path.open("r", encoding="utf-8")) + except Exception: + continue + manifest = data.get("manifest") or {} + dataset_path = data.get("dataset_path") or manifest.get("path") + rows.append({ + "generated_at": data.get("generated_at"), + "dataset_path": dataset_path, + "symbol": infer_symbol(path.name, dataset_path), + "severity": data.get("severity"), + "gap_ratio": data.get("gap_ratio"), + "duplicate_timestamps": data.get("duplicate_timestamps"), + "null_max": max((data.get("null_counts") or {}).values() or [0]), + "outlier_columns": ", ".join((data.get("numeric_outliers") or {}).keys()), + "hash": manifest.get("sha256"), + "report_file": str(path), + }) + return rows + + +def infer_symbol(filename: str, dataset_path: str | None) -> str: + if dataset_path: + stem = Path(dataset_path).stem + parts = stem.split("_") + if parts: + return parts[0] + if "_" in filename: + return filename.split("_")[1] + return "UNKNOWN" + + +def write_csv(rows: List[Dict], output: Path) -> None: + output.parent.mkdir(parents=True, exist_ok=True) + headers = ["generated_at", "dataset_path", "symbol", "severity", "gap_ratio", "duplicate_timestamps", "null_max", "outlier_columns", "hash", "report_file"] + with output.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=headers) + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def print_table(rows: List[Dict]) -> None: + if not rows: + print("No reports found.") + return + print(f"{'Generated':25} {'Symbol':8} {'Severity':7} {'Gap%':7} {'Dup':5} {'Hash':64}") + for row in rows: + gap = f"{row['gap_ratio']:.4f}" if isinstance(row["gap_ratio"], (int, float)) else "n/a" + print( + f"{row['generated_at'][:23] if row['generated_at'] else '':25} " + f"{row['symbol']:8} {row['severity']:7} {gap:7} " + f"{row['duplicate_timestamps']!s:5} {row['hash'] or ''}" + ) + + +def main() -> None: + args = parse_args() + report_dir = Path(args.input) + rows = load_reports(report_dir) + if args.print: + print_table(rows) + output_path = Path(args.output) + write_csv(rows, output_path) + print(f"Wrote summary to {output_path} ({len(rows)} rows).") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/analyze_cost_profiles.py b/Q Research/scripts/analyze_cost_profiles.py new file mode 100644 index 0000000..b64dfec --- /dev/null +++ b/Q Research/scripts/analyze_cost_profiles.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import argparse +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +import pandas as pd +import yaml +from loguru import logger + + +DEFAULT_SESSIONS: List[Dict[str, Any]] = [ + {"name": "asia_open", "weekdays": [0, 1, 2, 3, 4], "start_hour": 0, "end_hour": 7}, + {"name": "europe", "weekdays": [0, 1, 2, 3, 4], "start_hour": 7, "end_hour": 13}, + {"name": "us_session", "weekdays": [0, 1, 2, 3, 4], "start_hour": 13, "end_hour": 22}, +] + + +def _load_sessions(path: str | None) -> List[Dict[str, Any]]: + if not path: + return DEFAULT_SESSIONS + fp = Path(path) + if not fp.exists(): + raise FileNotFoundError(f"Session file not found: {path}") + if fp.suffix.lower() in {".yml", ".yaml"}: + data = yaml.safe_load(fp.read_text(encoding="utf-8")) or {} + else: + data = json.loads(fp.read_text(encoding="utf-8")) + if isinstance(data, dict): + sessions = data.get("sessions") or data.get("profiles") + else: + sessions = data + if not isinstance(sessions, list): + raise ValueError("session file must contain a list of session definitions") + return sessions + + +def _match_session(row: pd.Series, session: Dict[str, Any]) -> bool: + hour = row["hour"] + weekday = row["weekday"] + weekdays = session.get("weekdays") + if weekdays and weekday not in weekdays: + return False + start = session.get("start_hour") + end = session.get("end_hour") + if start is None and end is None: + return True + start = 0 if start is None else float(start) + end = 24 if end is None else float(end) + if start < end: + return start <= hour < end + return hour >= start or hour < end + + +def main(): + parser = argparse.ArgumentParser(description="Aggregate spread/slippage samples into cost profiles.") + parser.add_argument("--input", required=True, help="CSV with columns ts,spread_pips,slip_pips (plus optional fields).") + parser.add_argument("--symbol", required=True, help="Symbol name, e.g. USDJPY.") + parser.add_argument("--out", default="data/cost_profiles/profile.yaml", help="Output YAML path.") + parser.add_argument("--sessions", help="Optional YAML/JSON file describing session windows.") + parser.add_argument("--min-samples", type=int, default=20, help="Minimum rows required for a session to be emitted.") + args = parser.parse_args() + + df = pd.read_csv(args.input) + if "ts" not in df.columns: + raise ValueError("input CSV must contain 'ts' column (timestamp).") + if "spread_pips" not in df.columns or "slip_pips" not in df.columns: + raise ValueError("input CSV must contain 'spread_pips' and 'slip_pips'.") + + df["ts"] = pd.to_datetime(df["ts"], utc=True, errors="coerce") + df = df.dropna(subset=["ts"]) + df["hour"] = df["ts"].dt.hour + df["ts"].dt.minute / 60.0 + df["weekday"] = df["ts"].dt.weekday + + sessions = _load_sessions(args.sessions) + profiles: List[Dict[str, Any]] = [] + for session in sessions: + mask = df.apply(lambda row: _match_session(row, session), axis=1) + subset = df.loc[mask] + if len(subset) < args.min_samples: + logger.warning(f"Session {session.get('name')} skipped (samples={len(subset)} < {args.min_samples}).") + continue + profile = { + "name": session.get("name", f"session_{len(profiles)}"), + "weekdays": session.get("weekdays"), + "start_hour": session.get("start_hour"), + "end_hour": session.get("end_hour"), + "spread": round(subset["spread_pips"].mean(), 4), + "slip": round(subset["slip_pips"].mean(), 4), + "comm": session.get("comm"), + "samples": int(len(subset)), + "spread_p95": round(subset["spread_pips"].quantile(0.95), 4), + "slip_p95": round(subset["slip_pips"].quantile(0.95), 4), + } + if session.get("priority") is not None: + profile["priority"] = session["priority"] + profiles.append(profile) + + if not profiles: + raise RuntimeError("No sessions met the minimum sample requirement; nothing to write.") + + default_profile = min(profiles, key=lambda p: p.get("priority", float("inf"))) + default_profile["default"] = True + + payload = { + "symbol": args.symbol.upper(), + "generated_at": datetime.utcnow().isoformat() + "Z", + "source": os.path.abspath(args.input), + "profiles": profiles, + } + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + with out_path.open("w", encoding="utf-8") as fh: + yaml.safe_dump(payload, fh, allow_unicode=True, sort_keys=False) + logger.info(f"Cost profile saved to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/backfill_risk_metrics.py b/Q Research/scripts/backfill_risk_metrics.py new file mode 100644 index 0000000..382e520 --- /dev/null +++ b/Q Research/scripts/backfill_risk_metrics.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +Backfill results/risk/metrics.csv using historical execution runs. + +For each run under results/execution//, the script inspects +sim_results.json, derives reject/kill counts, infers status (pass/fail), +and appends any missing records to results/risk/metrics.csv. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional + +ROOT = Path(__file__).resolve().parents[1] +EXECUTION_DIR = ROOT / "results" / "execution" +METRICS_PATH = ROOT / "results" / "risk" / "metrics.csv" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Backfill risk metrics from historical execution runs.") + parser.add_argument( + "--runs", + type=str, + help="Comma-separated run IDs to backfill. Defaults to all directories in results/execution/.", + ) + parser.add_argument( + "--force", + action="store_true", + help="Re-write entries even if run_id already exists in metrics.csv (otherwise skipped).", + ) + return parser.parse_args() + + +def list_runs(filter_ids: Optional[List[str]]) -> List[str]: + if filter_ids: + return filter_ids + if not EXECUTION_DIR.exists(): + return [] + return sorted([p.name for p in EXECUTION_DIR.iterdir() if p.is_dir()]) + + +def load_sim_results(run_id: str) -> Optional[Dict]: + sim_path = EXECUTION_DIR / run_id / "sim_results.json" + if not sim_path.exists(): + return None + return json.loads(sim_path.read_text(encoding="utf-8")) + + +def infer_timestamp(run_id: str) -> str: + summary_path = ROOT / "results" / run_id / "summary.json" + if summary_path.exists(): + try: + data = json.loads(summary_path.read_text(encoding="utf-8")) + if "created_at" in data: + return data["created_at"] + except json.JSONDecodeError: + pass + sim_path = EXECUTION_DIR / run_id / "sim_results.json" + if sim_path.exists(): + return datetime.fromtimestamp(sim_path.stat().st_mtime, tz=timezone.utc).isoformat() + return datetime.now(timezone.utc).isoformat() + + +def summarize_run(run_id: str) -> Optional[Dict[str, str]]: + payload = load_sim_results(run_id) + if payload is None: + return None + rejects = len(payload.get("rejects", [])) + kills = len(payload.get("kill_switch_events", [])) + status = "pass" if rejects == 0 and kills == 0 else "fail" + return { + "timestamp": infer_timestamp(run_id), + "run_id": run_id, + "rejects": str(rejects), + "kills": str(kills), + "status": status, + } + + +def load_existing() -> Dict[str, Dict[str, str]]: + if not METRICS_PATH.exists(): + return {} + with METRICS_PATH.open("r", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + return {row["run_id"]: row for row in reader if row.get("run_id")} + + +def append_rows(rows: List[Dict[str, str]], replace: bool = False) -> None: + METRICS_PATH.parent.mkdir(parents=True, exist_ok=True) + existing = load_existing() + if replace: + for row in rows: + existing[row["run_id"]] = row + with METRICS_PATH.open("w", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=["timestamp", "run_id", "rejects", "kills", "status"]) + writer.writeheader() + for row in sorted(existing.values(), key=lambda r: r["timestamp"]): + writer.writerow(row) + else: + new_file = not METRICS_PATH.exists() + with METRICS_PATH.open("a", encoding="utf-8", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=["timestamp", "run_id", "rejects", "kills", "status"]) + if new_file: + writer.writeheader() + for row in rows: + writer.writerow(row) + + +def main() -> None: + args = parse_args() + runs = list_runs(args.runs.split(",") if args.runs else None) + if not runs: + print("No runs to process.") + return + existing = load_existing() + new_rows: List[Dict[str, str]] = [] + replacements: List[Dict[str, str]] = [] + for run_id in runs: + record = summarize_run(run_id) + if not record: + print(f"[skip] sim_results.json missing for {run_id}") + continue + if run_id in existing and not args.force: + print(f"[skip] {run_id} already in metrics.csv") + continue + if args.force and run_id in existing: + replacements.append(record) + else: + new_rows.append(record) + if replacements: + append_rows(replacements, replace=True) + print(f"Replaced {len(replacements)} entries (force mode).") + if new_rows: + append_rows(new_rows, replace=False) + print(f"Appended {len(new_rows)} new entries.") + if not replacements and not new_rows: + print("No changes made.") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/backtest_strategy.py b/Q Research/scripts/backtest_strategy.py new file mode 100644 index 0000000..0092a10 --- /dev/null +++ b/Q Research/scripts/backtest_strategy.py @@ -0,0 +1,828 @@ +from __future__ import annotations + +import os +import sys +from pathlib import Path +from datetime import datetime, timezone + +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +import json +import pandas as pd +import yaml +import numpy as np +from queue import Empty, Queue +from typing import Any, List, Optional +from loguru import logger + +from core.backtest.strategy_engine import ( + FXRateProvider, + StrategyEngine, + StrategySpec, + parse_strategy_specs, + _coerce_fx_rates, + _merge_fx_rates, +) +from data.csv_feed import CSVFeed # 你的CSVFeed +from scripts.validate_dataset import ( + DEFAULT_MANIFEST, + compute_report, + load_manifest_entry, +) + +# ===== FX 元数据与换算工具 ===== +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +DATA_DIR = os.path.join(BASE_DIR, "data") +RAW_DATA_DIR = os.path.join(DATA_DIR, "raw") +DERIVED_DATA_DIR = os.path.join(DATA_DIR, "derived") +OUTPUT_DIR = os.path.join(DATA_DIR, "outputs") +EQUITY_DIR = os.path.join(OUTPUT_DIR, "equity") +TRADES_DIR = os.path.join(OUTPUT_DIR, "trades") +STATS_DIR = os.path.join(OUTPUT_DIR, "stats") +DATA_REPORT_DIR = os.path.join(STATS_DIR, "data_reports") +RESULTS_DIR = os.path.join(BASE_DIR, "results") +GRID_DIR = os.path.join(DATA_DIR, "grid") +PARAMS_DIR = os.path.join(DATA_DIR, "params") + +for _dir in [RAW_DATA_DIR, DERIVED_DATA_DIR, EQUITY_DIR, TRADES_DIR, STATS_DIR, DATA_REPORT_DIR, RESULTS_DIR, GRID_DIR, PARAMS_DIR]: + os.makedirs(_dir, exist_ok=True) + + +def _load_manifest_entry(csv_path: Path, manifest_path: Optional[str]) -> Optional[dict]: + if not manifest_path: + manifest_path = os.path.join(DATA_DIR, "_manifest.json") + manifest_file = Path(manifest_path) + if not manifest_file.exists(): + return None + try: + entry = load_manifest_entry(manifest_file, csv_path) + return entry + except Exception as exc: + logger.warning(f"Failed to read manifest entry for {csv_path}: {exc}") + return None + + +def _validate_input_dataset(csv_path: str, manifest_path: Optional[str] = DEFAULT_MANIFEST) -> dict: + dataset_path = Path(csv_path).expanduser().resolve() + manifest_entry = _load_manifest_entry(dataset_path, manifest_path) + report = compute_report(dataset_path, manifest_entry, z_threshold=5.0) + severity = report.get("severity") + gap_ratio = report.get("gap_ratio") + gap_ratio_str = f"{gap_ratio:.4f}" if isinstance(gap_ratio, (int, float)) else "n/a" + logger.info( + f"Data validation severity={severity} " + f"duplicates={report.get('duplicate_timestamps')} gap_ratio={gap_ratio_str}" + ) + if severity == "error": + raise RuntimeError( + f"Dataset validation failed for {dataset_path}. Messages: {report.get('messages')}" + ) + return report + + +def _relpath_or_abs(path: Optional[str]) -> Optional[str]: + if not path: + return None + try: + return str(Path(path).resolve().relative_to(BASE_DIR)) + except Exception: + return str(path) + + +def _load_structured_data(path: Optional[str]): + if not path: + return None + file_path = Path(path).expanduser() + if not file_path.exists(): + raise FileNotFoundError(f"Config file not found: {file_path}") + with file_path.open("r", encoding="utf-8") as fh: + if file_path.suffix.lower() in (".yaml", ".yml"): + return yaml.safe_load(fh) + return json.load(fh) + + +def _write_data_report(report: Optional[dict], symbol: str, fast_win: int, slow_win: int, suffix: str) -> Optional[str]: + if not report: + return None + report_path = Path(DATA_REPORT_DIR) / f"data_{symbol}_H1_{fast_win}x{slow_win}_{suffix}.json" + with report_path.open("w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, ensure_ascii=False) + try: + return str(report_path.relative_to(BASE_DIR)) + except ValueError: + return str(report_path) + + +def _prepare_run_dir(enabled: bool, results_dir: Optional[str]) -> tuple[Optional[str], Optional[Path]]: + if not enabled or not results_dir: + return None, None + run_id = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + run_path = Path(results_dir) / run_id + run_path.mkdir(parents=True, exist_ok=True) + return run_id, run_path + + +def _write_run_summary(run_path: Path, summary: dict) -> str: + summary_path = run_path / "summary.json" + with summary_path.open("w", encoding="utf-8") as fh: + json.dump(summary, fh, indent=2, ensure_ascii=False) + metrics_path = run_path / "metrics.json" + with metrics_path.open("w", encoding="utf-8") as fh: + json.dump(summary.get("metrics", {}), fh, indent=2, ensure_ascii=False) + return str(summary_path) + + +def run_once( + symbol: str = "EURUSD", + csv_path: str = os.path.join(RAW_DATA_DIR, "EURUSD_H1.csv"), + initial_cash: float = 100000.0, + qty: int = 10_000, + account_ccy: str = "USD", + fx_rates: FXRateProvider = None, + fast_win: int = 20, # 改为更敏感的短期均线 + slow_win: int = 100, # 改为中期均线 + spread_pips: float = 2.0, + commission_per_million: float = 0.25, + slippage_pips: float = 0.3, + stop_loss_pips: float = 50, + take_profit_pips: float | None = None, + atr_sl: float | None = 1.5, # 默认使用1.5倍ATR止损 + atr_tp: float | None = 3.0, # 默认使用3倍ATR止盈 + atr_window: int = 14, # ATR窗口保持14天 + # RSI & trailing defaults + rsi_period: int = 14, + rsi_long_thresh: Optional[float] = None, + rsi_short_thresh: Optional[float] = None, + enable_trailing: bool = False, + trailing_enable_atr_mult: float = 1.0, + trailing_atr_mult: float = 0.5, + htf_factor: int = 4, + htf_ema_window: Optional[int] = None, + htf_rsi_period: Optional[int] = None, + regime_ema_window: int = 200, + regime_slope_min: Optional[float] = None, + regime_atr_min: Optional[float] = None, + regime_atr_percentile_min: Optional[float] = None, + regime_atr_percentile_window: int = 500, + regime_trend_min_bars: int = 0, + long_only_above_slow: bool = False, + slope_lookback: int = 0, + cooldown: int = 0, + allow_short: bool = True, + short_only_below_slow: bool = False, + strategies: Optional[List[StrategySpec]] = None, + cost_profiles: Optional[Any] = None, + slippage_model: Optional[Any] = None, + strategy_mode: str = "first_hit", + strategy_vote_threshold: float = 0.0, + stress_cost_spread_mult: float = 1.0, + stress_cost_comm_mult: float = 1.0, + stress_slippage_mult: float = 1.0, + stress_price_vol_mult: float = 1.0, + stress_skip_trade_pct: float = 0.0, + risk_per_trade_pct: Optional[float] = None, + max_drawdown_pct: Optional[float] = None, + max_position_units: Optional[float] = None, + skip_outlier_entries: bool = False, + validate_data: bool = True, + manifest_path: Optional[str] = DEFAULT_MANIFEST, + results_dir: Optional[str] = RESULTS_DIR, + write_summary: bool = True, +): + os.makedirs(EQUITY_DIR, exist_ok=True) + os.makedirs(TRADES_DIR, exist_ok=True) + os.makedirs(STATS_DIR, exist_ok=True) + + run_id, run_dir = _prepare_run_dir(write_summary, results_dir) + + data_report = None + if validate_data and csv_path: + try: + data_report = _validate_input_dataset(csv_path, manifest_path) + except RuntimeError: + raise + except Exception as exc: + raise RuntimeError(f"Data validation error: {exc}") from exc + + engine = StrategyEngine( + symbol=symbol, + fast_win=fast_win, + slow_win=slow_win, + spread_pips=spread_pips, + commission_per_million=commission_per_million, + slippage_pips=slippage_pips, + stop_loss_pips=stop_loss_pips, + take_profit_pips=take_profit_pips, + atr_sl=atr_sl, + atr_tp=atr_tp, + atr_window=atr_window, + regime_ema_window=regime_ema_window, + regime_slope_min=regime_slope_min, + regime_atr_min=regime_atr_min, + regime_atr_percentile_min=regime_atr_percentile_min, + regime_atr_percentile_window=regime_atr_percentile_window, + regime_trend_min_bars=regime_trend_min_bars, + rsi_period=rsi_period, + rsi_long_thresh=rsi_long_thresh, + rsi_short_thresh=rsi_short_thresh, + enable_trailing=enable_trailing, + trailing_enable_atr_mult=trailing_enable_atr_mult, + trailing_atr_mult=trailing_atr_mult, + htf_factor=htf_factor, + htf_ema_window=htf_ema_window, + htf_rsi_period=htf_rsi_period, + long_only_above_slow=long_only_above_slow, + slope_lookback=slope_lookback, + cooldown=cooldown, + qty=qty, + account_ccy=account_ccy, + fx_rates=fx_rates, + strategy_specs=strategies, + cost_profiles=cost_profiles, + slippage_model=slippage_model, + strategy_combine_mode=strategy_mode, + strategy_vote_threshold=strategy_vote_threshold, + stress_cost_spread_mult=stress_cost_spread_mult, + stress_cost_comm_mult=stress_cost_comm_mult, + stress_slippage_mult=stress_slippage_mult, + stress_price_vol_mult=stress_price_vol_mult, + stress_skip_trade_pct=stress_skip_trade_pct, + skip_outlier_bars=skip_outlier_entries, + allow_short=allow_short, + short_only_below_slow=short_only_below_slow, + risk_per_trade_pct=risk_per_trade_pct, + max_drawdown_pct=max_drawdown_pct, + max_position_units=max_position_units, + output_dirs={ + "equity": EQUITY_DIR, + "trades": TRADES_DIR, + "stats": STATS_DIR, + }, + ) + engine.set_initial_cash(initial_cash) + + q = Queue() + data = CSVFeed(q, path=str(csv_path), symbol=symbol) + logger.info(f"Using CSV: {csv_path} for {symbol}") + data.start() + + while True: + try: + ev = q.get(timeout=0.05) + except Empty: + if hasattr(data, "pump"): + data.pump(n=50) + if getattr(data, "finished", False): + break + continue + if ev.get("type") != "bar": + continue + engine.handle_bar(ev) + + engine.finalize() + + suffix = engine.compute_suffix() + output_files = engine.export_outputs(fast_win, slow_win, suffix) + data_report_path = _write_data_report(data_report, symbol, fast_win, slow_win, suffix) + result = engine.summary(fast_win, slow_win, suffix) + + final_equity = result["final_equity"] if result["final_equity"] is not None else engine.cash + ret_pct = (final_equity / initial_cash - 1.0) * 100.0 + logger.info(f"Bars processed: {engine.bar_count}, Trades executed: {engine.trade_count}") + logger.info(f"策略最终权益: {final_equity:.2f},累计收益: {ret_pct:.4f}%") + if all(result.get(k) is not None for k in ("sharpe", "ann_return", "ann_vol", "max_drawdown")): + logger.info( + f"Sharpe={result['sharpe']:.3f} AnnRet={result['ann_return']*100:.2f}% " + f"AnnVol={result['ann_vol']*100:.2f}% MaxDD={result['max_drawdown']*100:.2f}%" + ) + + data_summary = None + if data_report: + manifest_info = (data_report.get("manifest") or {}) + data_summary = { + "severity": data_report.get("severity"), + "gap_ratio": data_report.get("gap_ratio"), + "duplicate_timestamps": data_report.get("duplicate_timestamps"), + "hash": manifest_info.get("sha256"), + "path": manifest_info.get("path"), + } + logger.info( + "Data signature: severity={} hash={}", + data_summary["severity"], + data_summary["hash"], + ) + + if data_report_path: + result["data_report"] = data_report_path + result["data_validation"] = { + "severity": data_summary["severity"] if data_summary else None, + "messages": data_report.get("messages") if data_report else None, + } + + if run_dir: + param_snapshot = { + "fast_win": fast_win, + "slow_win": slow_win, + "spread_pips": spread_pips, + "commission_per_million": commission_per_million, + "slippage_pips": slippage_pips, + "stop_loss_pips": stop_loss_pips, + "take_profit_pips": take_profit_pips, + "atr_sl": atr_sl, + "atr_tp": atr_tp, + "atr_window": atr_window, + "rsi_period": rsi_period, + "regime_ema_window": regime_ema_window, + "skip_outlier_entries": skip_outlier_entries, + "strategy_mode": strategy_mode, + "strategy_vote_threshold": strategy_vote_threshold, + "stress_cost_spread_mult": stress_cost_spread_mult, + "stress_cost_comm_mult": stress_cost_comm_mult, + "stress_slippage_mult": stress_slippage_mult, + "stress_price_vol_mult": stress_price_vol_mult, + "stress_skip_trade_pct": stress_skip_trade_pct, + } + summary = { + "run_id": run_id, + "timestamp": datetime.now(timezone.utc).isoformat(), + "symbol": symbol, + "csv_path": os.path.relpath(csv_path, BASE_DIR) if csv_path else None, + "parameters": param_snapshot, + "metrics": result, + "data_report": data_summary, + "artifacts": { + "equity": _relpath_or_abs(output_files.get("equity")), + "trades": _relpath_or_abs(output_files.get("trades")), + "trade_stats": _relpath_or_abs(output_files.get("trade_stats")), + }, + } + summary_path = _write_run_summary(run_dir, summary) + result["run_id"] = run_id + result["summary_path"] = summary_path + + return result + +def main(**kwargs): + """ + Backwards-compatible wrapper for legacy callers that imported + scripts.backtest_strategy.main. It simply proxies to run_once(). + """ + return run_once(**kwargs) + +def grid_search(symbol="EURUSD", + csv_path=None, + qty=10_000, + initial_cash=100000.0, + account_ccy="USD", + fx_rates: FXRateProvider = None, + # 单值默认;若传入 *_list 则以列表为准 + spread=1.0, + slip=0.2, + comm=2.0, + atr_window=14, + skip_outlier_entries: bool = False, + # 维度开关;传 None 使用默认网格 + fast_list=None, + slow_list=None, + atr_sl_list=None, + atr_tp_list=None, + long_only_list=None, + cooldown_list=None, + slope_list=None, + spread_list=None, + slip_list=None, + comm_list=None): + """ + 多维参数网格搜索。 + - 若 *_list 为 None,则采用合理的默认网格;否则使用传入列表。 + - 结果会输出: + data/grid/grid_{symbol}_H1_ATR.csv + data/grid/grid_top10_by_sharpe_{symbol}.csv + data/params/best_params_grid_{symbol}.json + """ + import pandas as pd + import json + + # --- 默认网格(可被参数列表覆盖) --- + fast_list = fast_list or [20, 30, 50] + slow_list = slow_list or [100, 150, 200] + atr_sl_list = atr_sl_list or [1.5, 2.0] # 止损倍数 + atr_tp_list = atr_tp_list or [None, 2.0, 3.0] # 含不设止盈 + long_only_list = long_only_list or [False, True] + cooldown_list = cooldown_list or [0, 6, 12, 24] + slope_list = slope_list or [0, 3] + spread_list = spread_list or [spread] + slip_list = slip_list or [slip] + comm_list = comm_list or [comm] + + rows = [] + total = 0 + for f in fast_list: + for s in slow_list: + if f >= s: + continue + for k in atr_sl_list: + for m in atr_tp_list: + for lo in long_only_list: + for cd in cooldown_list: + for slp in slope_list: + for sp in spread_list: + for sp_slip in slip_list: + for cm in comm_list: + total += 1 + logger.info( + f"[GRID] sym={symbol} fast={f} slow={s} " + f"SL=ATR×{k} TP={'None' if m is None else 'ATR×'+str(m)} " + f"ABOVE={lo} CD={cd} SLOPE={slp} " + f"spread={sp} slip={sp_slip} comm={cm}" + ) + res = run_once( + symbol=symbol, + csv_path=csv_path, + fast_win=int(f), slow_win=int(s), + spread_pips=float(sp), + commission_per_million=float(cm), + slippage_pips=float(sp_slip), + # 关闭固定 pips,启用 ATR + stop_loss_pips=None, + take_profit_pips=None, + atr_sl=float(k) if k is not None else None, + atr_tp=float(m) if m is not None else None, + atr_window=int(atr_window), + qty=int(qty), + initial_cash=float(initial_cash), + account_ccy=str(account_ccy), + fx_rates=fx_rates, + long_only_above_slow=bool(lo), + cooldown=int(cd), + slope_lookback=int(slp), + skip_outlier_entries=skip_outlier_entries, + write_summary=False, + ) + # 把当前维度也写入结果,便于回看 + res.update({ + "symbol": symbol, + "spread": float(sp), + "slip": float(sp_slip), + "comm": float(cm), + "long_only_above_slow": bool(lo), + "cooldown": int(cd), + "slope_lookback": int(slp), + }) + rows.append(res) + + df = pd.DataFrame(rows) + os.makedirs(GRID_DIR, exist_ok=True) + os.makedirs(PARAMS_DIR, exist_ok=True) + out = os.path.join(GRID_DIR, f"grid_{symbol}_H1_ATR.csv") + df.to_csv(out, index=False) + logger.info(f"[GRID] 扫描完成(组合数={total}),已保存: {out}") + + try: + if df.empty: + logger.warning("[GRID] 无结果,跳过排名/保存。") + return + # 确保 sharpe 可排序 + df["sharpe"] = pd.to_numeric(df["sharpe"], errors="coerce") + df_sorted = df.sort_values("sharpe", ascending=False, na_position="last") + + logger.info("\n[GRID] Top 10 by Sharpe:\n" + df_sorted.head(10).to_string(index=False)) + + # 保存最优参数 + best = df_sorted.iloc[0].to_dict() + best_path = os.path.join(PARAMS_DIR, f"best_params_grid_{symbol}.json") + with open(best_path, "w", encoding="utf-8") as f: + json.dump(best, f, ensure_ascii=False, indent=2) + logger.info(f"[GRID] 最优参数已保存: {best_path}") + + # 保存 Top-10 + top10_path = os.path.join(GRID_DIR, f"grid_top10_by_sharpe_{symbol}.csv") + df_sorted.head(10).to_csv(top10_path, index=False) + logger.info(f"[GRID] Top 10 已保存: {top10_path}") + except Exception as e: + logger.warning(f"[GRID] 排序/保存失败: {e}") + +if __name__ == "__main__": + import argparse + ap = argparse.ArgumentParser() + ap.add_argument("--grid", action="store_true", help="启用参数网格扫描(含 ATR)") + ap.add_argument("--symbol", type=str, default="EURUSD", help="交易品种") + ap.add_argument("--csv", type=str, default=os.path.join(RAW_DATA_DIR, "EURUSD_H1.csv"), help="CSV 路径") + ap.add_argument("--fast", type=int, default=50, help="快速均线窗口") + ap.add_argument("--slow", type=int, default=200, help="慢速均线窗口(应 > fast)") + ap.add_argument("--qty", type=int, default=10_000, help="下单数量(名义)") + ap.add_argument("--cash", type=float, default=100000.0, help="初始资金") + ap.add_argument("--account-ccy", type=str, default="USD", help="账户结算货币(默认 USD)") + ap.add_argument("--spread", type=float, default=1.0, help="点差(pips)") + ap.add_argument("--slip", type=float, default=0.2, help="滑点(pips)") + ap.add_argument("--comm", type=float, default=2.0, help="佣金($ per $1,000,000 名义)") + ap.add_argument("--sl", type=float, default=50.0, help="止损(pips)") + ap.add_argument("--tp", type=float, default=None, help="止盈(pips,可空)") + ap.add_argument("--atr-sl", type=float, default=None, help="ATR 止损倍数(k_SL),例如 2.0 表示 2×ATR") + ap.add_argument("--atr-tp", type=float, default=None, help="ATR 止盈倍数(m_TP),例如 3.0 表示 3×ATR;缺省表示不用 ATR 止盈") + ap.add_argument("--atr-window", type=int, default=14, help="ATR 窗口(默认 14)") + ap.add_argument("--regime-ema-window", dest="regime_ema_window", type=int, default=200, help="Regime 过滤使用的 EMA 窗口长度") + ap.add_argument("--regime-slope-min", dest="regime_slope_min", type=float, default=None, help="EMA 斜率阈值(价格单位)判定趋势 regime") + ap.add_argument("--regime-atr-min", dest="regime_atr_min", type=float, default=None, help="ATR 下限,用于判定趋势 regime") + ap.add_argument("--regime-atr-percentile-min", dest="regime_atr_percentile_min", type=float, default=None, help="ATR 百分位下限(0-1),用来过滤低波动段") + ap.add_argument("--regime-atr-percentile-window", dest="regime_atr_percentile_window", type=int, default=500, help="ATR 百分位计算窗口长度(条数)") + ap.add_argument("--regime-trend-min-bars", dest="regime_trend_min_bars", type=int, default=0, help="趋势 regime 需要至少持续多少根 K 才允许入场") + ap.add_argument("--htf-factor", dest="htf_factor", type=int, default=4, help="高时间框聚合倍数(例如 4 表示 4 根低频合成一根高频)") + ap.add_argument("--htf-ema-window", dest="htf_ema_window", type=int, default=None, help="高时间框 EMA 窗口") + ap.add_argument("--htf-rsi-period", dest="htf_rsi_period", type=int, default=None, help="高时间框 RSI 周期") + ap.add_argument("--rsi-period", type=int, default=14, help="RSI 窗口(默认 14)") + ap.add_argument("--rsi-long-thresh", type=float, default=None, help="做多入场最低 RSI(例如 55)") + ap.add_argument("--rsi-short-thresh", type=float, default=None, help="做空入场最高 RSI(例如 45)") + ap.add_argument("--enable-trailing", action="store_true", help="启用基于 ATR 的 trailing stop") + ap.add_argument("--trailing-enable-atr-mult", type=float, default=1.0, help="盈利达到多少倍 entry_atr 时启用 trailing(默认1.0)") + ap.add_argument("--trailing-atr-mult", type=float, default=0.5, help="trailing 步长,按 curr_atr 的倍数移动止损(默认0.5)") + ap.add_argument("--long-only-above-slow", action="store_true", help="仅当 close > SMA_slow 时允许做多") + ap.add_argument("--slope-lookback", type=int, default=0, help="fast SMA 斜率确认(>0 开启, 单位=bar)") + ap.add_argument("--cooldown", type=int, default=0, help="平仓后冷却 N 根bar 才允许再次进场") + ap.add_argument("--config", type=str, default=None, help="YAML 配置路径(命令行显式参数将覆盖配置)") + ap.add_argument( + "--fx-rate", + action="append", + default=None, + help="额外换汇报价(可重复),格式示例:GBPUSD=1.27 或 EUR/JPY=161.3", + ) + ap.add_argument("--no-short", action="store_true", help="禁用做空信号") + ap.add_argument("--short-only-below-slow", action="store_true", help="仅当 close < SMA_slow 时允许做空") + ap.add_argument("--skip-outlier-entries", action="store_true", help="标记为 outlier 的 bar 上禁止开新仓位") + ap.add_argument("--strategy-mode", choices=["first_hit", "weighted"], default="first_hit", help="多策略组合模式(first_hit 或 weighted)") + ap.add_argument("--strategy-vote-threshold", type=float, default=0.0, help="weighted 模式下投票阈值(默认 0)") + ap.add_argument("--cost-profile-file", type=str, help="JSON/YAML 文件路径,定义成本/点差 profile") + ap.add_argument("--slippage-model-file", type=str, help="JSON/YAML 文件路径,定义滑点模型") + ap.add_argument("--stress-cost-spread-mult", type=float, default=1.0, help="压力测试:点差乘子(默认1)") + ap.add_argument("--stress-cost-comm-mult", type=float, default=1.0, help="压力测试:佣金乘子(默认1)") + ap.add_argument("--stress-slippage-mult", type=float, default=1.0, help="压力测试:滑点乘子(默认1)") + ap.add_argument("--stress-price-vol-mult", type=float, default=1.0, help="压力测试:高低点范围乘子(默认1)") + ap.add_argument("--stress-skip-trade-pct", type=float, default=0.0, help="压力测试:随机跳过交易的概率(0-1)") + ap.add_argument("--risk-percent", type=float, default=None, help="每笔风险占当前权益比例(如 0.01 表示 1%)") + ap.add_argument("--max-drawdown", type=float, default=None, help="最大允许回撤(小数,如 0.2 表示 20%),超出后停止开仓") + ap.add_argument("--max-units", type=float, default=None, help="仓位上限(基准货币单位)") + ap.add_argument( + "--use-best", + action="store_true", + help="从 data/params/best_params_grid_{symbol}.json 读取最优参数并运行(命令行显式参数仍可覆盖)" + ) + args = ap.parse_args() + cli_fx_rates = _coerce_fx_rates(args.fx_rate) + + if args.grid: + grid_search( + symbol=args.symbol, + csv_path=args.csv, + qty=args.qty, + initial_cash=args.cash, + account_ccy=args.account_ccy, + fx_rates=cli_fx_rates, + spread=args.spread, + slip=args.slip, + comm=args.comm, + atr_window=args.atr_window, + skip_outlier_entries=args.skip_outlier_entries, + ) + else: + # ---- 加载 YAML 配置并与命令行合并(命令行显式参数优先) ---- + cfg = {} + if args.config: + with open(args.config, "r", encoding="utf-8") as f: + raw = yaml.safe_load(f) or {} + # 允许大小写/短名对齐 argparse 名称 + key_map = { + "symbol": "symbol", + "csv": "csv_path", + "cash": "cash", + "qty": "qty", + "account_ccy": "account_ccy", + "fast": "fast", + "slow": "slow", + "spread": "spread", + "slip": "slip", + "comm": "comm", + "sl": "sl", + "tp": "tp", + "atr_sl": "atr_sl", + "atr_tp": "atr_tp", + "atr_window": "atr_window", + "regime_ema_window": "regime_ema_window", + "regime_slope_min": "regime_slope_min", + "regime_atr_min": "regime_atr_min", + "regime_atr_percentile_min": "regime_atr_percentile_min", + "regime_atr_percentile_window": "regime_atr_percentile_window", + "regime_trend_min_bars": "regime_trend_min_bars", + "htf_factor": "htf_factor", + "htf_ema_window": "htf_ema_window", + "htf_rsi_period": "htf_rsi_period", + "rsi_period": "rsi_period", + "rsi_long_thresh": "rsi_long_thresh", + "rsi_short_thresh": "rsi_short_thresh", + "enable_trailing": "enable_trailing", + "trailing_enable_atr_mult": "trailing_enable_atr_mult", + "trailing_atr_mult": "trailing_atr_mult", + "long_only_above_slow": "long_only_above_slow", + "slope_lookback": "slope_lookback", + "cooldown": "cooldown", + "fx_rates": "fx_rates", + "allow_short": "allow_short", + "short_only_below_slow": "short_only_below_slow", + "risk_per_trade_pct": "risk_per_trade_pct", + "max_drawdown_pct": "max_drawdown_pct", + "max_position_units": "max_position_units", + "skip_outlier_entries": "skip_outlier_entries", + "strategies": "strategies", + "cost_profiles": "cost_profiles", + "slippage_model": "slippage_model", + } + # 规范化键名 + norm = {} + for k, v in raw.items(): + kk = k.strip() + if kk in key_map: + norm[key_map[kk]] = v + else: + norm[kk] = v + cfg = norm + else: + cfg = {} + cfg_fx_rates = _coerce_fx_rates(cfg.get("fx_rates")) if cfg else None + cfg_strategies = parse_strategy_specs(cfg.get("strategies")) if cfg else None + cfg_cost_profiles = cfg.get("cost_profiles") if cfg else None + cfg_slippage_model = cfg.get("slippage_model") if cfg else None + if args.cost_profile_file: + cfg_cost_profiles = _load_structured_data(args.cost_profile_file) + if args.slippage_model_file: + cfg_slippage_model = _load_structured_data(args.slippage_model_file) + # [PATCH B START] 载入 best_params_grid.json(若 --use-best),并做类型规范化 + best_cfg = {} + if args.use_best: + try: + import json, math + os.makedirs(PARAMS_DIR, exist_ok=True) + best_path = os.path.join(PARAMS_DIR, f"best_params_grid_{args.symbol}.json") + with open(best_path, "r", encoding="utf-8") as f: + best = json.load(f) or {} + + def _is_nan(x): + return isinstance(x, float) and math.isnan(x) + + def _to_int_or_none(x): + if x is None or _is_nan(x): + return None + if isinstance(x, (int, np.integer)): + return int(x) + if isinstance(x, (float, np.floating)): + return int(round(float(x))) + # 其他类型尝试转 + try: + return int(float(x)) + except Exception: + return None + + def _to_float_or_none(x): + if x is None or _is_nan(x): + return None + if isinstance(x, (int, float, np.integer, np.floating)): + return float(x) + try: + v = float(x) + return v if not math.isnan(v) else None + except Exception: + return None + + # 将网格结果列名映射为参数名,并做规范化 + best_cfg = { + "fast": _to_int_or_none(best.get("fast")), + "slow": _to_int_or_none(best.get("slow")), + "atr_sl": _to_float_or_none(best.get("atr_sl")), + "atr_tp": _to_float_or_none(best.get("atr_tp")), # NaN -> None + "atr_window": _to_int_or_none(best.get("atr_window")), + } + # 去掉 None 的键,避免覆盖有效默认值 + best_cfg = {k: v for k, v in best_cfg.items() if v is not None} + + logger.info(f"[BEST] 已载入 {args.symbol} 最优参数: {best_cfg}") + except Exception as e: + logger.warning(f"[BEST] 读取最优参数失败,忽略 --use-best:{e}") + + # 合并 best_cfg 到 cfg(优先级:命令行 > best_cfg > cfg > 默认) + for k, v in (best_cfg or {}).items(): + if k not in cfg: + cfg[k] = v + # [PATCH B END] + + + + # 构造 run_once 的最终参数(先用 cfg 的,若命令行显式传入则覆盖) + def override(val, default, cfg_val): + """ + 如果命令行传入值 != argparse 的 default,说明用户显式设置 => 用命令行;否则用 cfg;再否则用 default + """ + if val != default: + return val + return cfg_val if (cfg_val is not None) else default + + # 取 argparse 默认值(用于判断是否显式覆盖) + defaults = vars(ap.parse_args([])) # 空参解析拿到默认表 + + kwargs = dict( + symbol = override(args.symbol, defaults["symbol"], cfg.get("symbol")), + csv_path = override(args.csv, defaults["csv"], cfg.get("csv_path")), + initial_cash = override(args.cash, defaults["cash"], cfg.get("cash")), + qty = override(args.qty, defaults["qty"], cfg.get("qty")), + account_ccy = override(args.account_ccy, defaults["account_ccy"], cfg.get("account_ccy")), + fast_win = override(args.fast, defaults["fast"], cfg.get("fast")), + slow_win = override(args.slow, defaults["slow"], cfg.get("slow")), + spread_pips = override(args.spread, defaults["spread"], cfg.get("spread")), + commission_per_million = override(args.comm, defaults["comm"], cfg.get("comm")), + slippage_pips = override(args.slip, defaults["slip"], cfg.get("slip")), + stop_loss_pips = override(args.sl, defaults["sl"], cfg.get("sl")), + take_profit_pips = override(args.tp, defaults["tp"], cfg.get("tp")), + atr_sl = override(args.atr_sl, defaults["atr_sl"], cfg.get("atr_sl")), + atr_tp = override(args.atr_tp, defaults["atr_tp"], cfg.get("atr_tp")), + atr_window = override(args.atr_window, defaults["atr_window"], cfg.get("atr_window")), + regime_ema_window = override(args.regime_ema_window, defaults["regime_ema_window"], cfg.get("regime_ema_window")), + regime_slope_min = override(args.regime_slope_min, defaults["regime_slope_min"], cfg.get("regime_slope_min")), + regime_atr_min = override(args.regime_atr_min, defaults["regime_atr_min"], cfg.get("regime_atr_min")), + regime_atr_percentile_min = override(args.regime_atr_percentile_min, defaults["regime_atr_percentile_min"], cfg.get("regime_atr_percentile_min")), + regime_atr_percentile_window = override(args.regime_atr_percentile_window, defaults["regime_atr_percentile_window"], cfg.get("regime_atr_percentile_window")), + regime_trend_min_bars = override(args.regime_trend_min_bars, defaults["regime_trend_min_bars"], cfg.get("regime_trend_min_bars")), + htf_factor = override(args.htf_factor, defaults["htf_factor"], cfg.get("htf_factor")), + htf_ema_window = override(args.htf_ema_window, defaults["htf_ema_window"], cfg.get("htf_ema_window")), + htf_rsi_period = override(args.htf_rsi_period, defaults["htf_rsi_period"], cfg.get("htf_rsi_period")), + rsi_period = override(args.rsi_period, defaults["rsi_period"], cfg.get("rsi_period")), + rsi_long_thresh = override(args.rsi_long_thresh, defaults["rsi_long_thresh"], cfg.get("rsi_long_thresh")), + rsi_short_thresh = override(args.rsi_short_thresh, defaults["rsi_short_thresh"], cfg.get("rsi_short_thresh")), + enable_trailing = override(args.enable_trailing, defaults["enable_trailing"], cfg.get("enable_trailing")), + trailing_enable_atr_mult = override(args.trailing_enable_atr_mult, defaults["trailing_enable_atr_mult"], cfg.get("trailing_enable_atr_mult")), + trailing_atr_mult = override(args.trailing_atr_mult, defaults["trailing_atr_mult"], cfg.get("trailing_atr_mult")), + long_only_above_slow = override(args.long_only_above_slow, defaults["long_only_above_slow"], cfg.get("long_only_above_slow")), + slope_lookback = override(args.slope_lookback, defaults["slope_lookback"], cfg.get("slope_lookback")), + cooldown = override(args.cooldown, defaults["cooldown"], cfg.get("cooldown")), + short_only_below_slow = override(args.short_only_below_slow, defaults["short_only_below_slow"], cfg.get("short_only_below_slow")), + risk_per_trade_pct = override(args.risk_percent, defaults["risk_percent"], cfg.get("risk_per_trade_pct")), + max_drawdown_pct = override(args.max_drawdown, defaults["max_drawdown"], cfg.get("max_drawdown_pct")), + max_position_units = override(args.max_units, defaults["max_units"], cfg.get("max_position_units")), + skip_outlier_entries = override(args.skip_outlier_entries, defaults["skip_outlier_entries"], cfg.get("skip_outlier_entries")), + cost_profiles = cfg_cost_profiles, + slippage_model = cfg_slippage_model, + strategy_mode = override(args.strategy_mode, defaults["strategy_mode"], cfg.get("strategy_mode")), + strategy_vote_threshold = override(args.strategy_vote_threshold, defaults["strategy_vote_threshold"], cfg.get("strategy_vote_threshold")), + stress_cost_spread_mult = override(args.stress_cost_spread_mult, defaults["stress_cost_spread_mult"], cfg.get("stress_cost_spread_mult")), + stress_cost_comm_mult = override(args.stress_cost_comm_mult, defaults["stress_cost_comm_mult"], cfg.get("stress_cost_comm_mult")), + stress_slippage_mult = override(args.stress_slippage_mult, defaults["stress_slippage_mult"], cfg.get("stress_slippage_mult")), + stress_price_vol_mult = override(args.stress_price_vol_mult, defaults["stress_price_vol_mult"], cfg.get("stress_price_vol_mult")), + stress_skip_trade_pct = override(args.stress_skip_trade_pct, defaults["stress_skip_trade_pct"], cfg.get("stress_skip_trade_pct")), + ) + # [PATCH C START] 关键窗口参数安全转为 int + try: + kwargs["fast_win"] = int(kwargs["fast_win"]) + kwargs["slow_win"] = int(kwargs["slow_win"]) + kwargs["atr_window"] = int(kwargs["atr_window"]) + kwargs["rsi_period"] = int(kwargs.get("rsi_period", 14)) + except Exception as e: + logger.error(f"参数类型转换错误,请检查 fast/slow/atr_window:{e}") + raise + # [PATCH C END] + + kwargs["short_only_below_slow"] = bool(kwargs["short_only_below_slow"]) + if kwargs["risk_per_trade_pct"] is not None: + kwargs["risk_per_trade_pct"] = float(kwargs["risk_per_trade_pct"]) + if kwargs["max_drawdown_pct"] is not None: + kwargs["max_drawdown_pct"] = float(kwargs["max_drawdown_pct"]) + if kwargs["max_position_units"] is not None: + kwargs["max_position_units"] = float(kwargs["max_position_units"]) + # RSI / trailing types + if kwargs.get("rsi_long_thresh") is not None: + kwargs["rsi_long_thresh"] = float(kwargs["rsi_long_thresh"]) + if kwargs.get("rsi_short_thresh") is not None: + kwargs["rsi_short_thresh"] = float(kwargs["rsi_short_thresh"]) + kwargs["enable_trailing"] = bool(kwargs.get("enable_trailing", False)) + kwargs["trailing_enable_atr_mult"] = float(kwargs.get("trailing_enable_atr_mult", 1.0)) + kwargs["trailing_atr_mult"] = float(kwargs.get("trailing_atr_mult", 0.5)) + kwargs["regime_ema_window"] = int(kwargs.get("regime_ema_window") or 0) + if kwargs.get("regime_slope_min") is not None: + kwargs["regime_slope_min"] = float(kwargs["regime_slope_min"]) + if kwargs.get("regime_atr_min") is not None: + kwargs["regime_atr_min"] = float(kwargs["regime_atr_min"]) + if kwargs.get("regime_atr_percentile_min") is not None: + kwargs["regime_atr_percentile_min"] = float(kwargs["regime_atr_percentile_min"]) + kwargs["regime_atr_percentile_window"] = int(kwargs.get("regime_atr_percentile_window") or 0) + kwargs["regime_trend_min_bars"] = int(kwargs.get("regime_trend_min_bars") or 0) + kwargs["htf_factor"] = int(kwargs.get("htf_factor") or 1) + if kwargs.get("htf_ema_window") is not None: + kwargs["htf_ema_window"] = int(kwargs["htf_ema_window"]) + if kwargs.get("htf_rsi_period") is not None: + kwargs["htf_rsi_period"] = int(kwargs["htf_rsi_period"]) + + if args.no_short != defaults["no_short"]: + allow_short = not args.no_short + else: + cfg_allow = cfg.get("allow_short") if cfg else None + allow_short = bool(cfg_allow) if cfg_allow is not None else True + kwargs["allow_short"] = allow_short + + kwargs["fx_rates"] = _merge_fx_rates(cfg_fx_rates, cli_fx_rates) + kwargs["strategies"] = cfg_strategies + + run_once(**kwargs) diff --git a/Q Research/scripts/build_dataset_manifest.py b/Q Research/scripts/build_dataset_manifest.py new file mode 100644 index 0000000..f999488 --- /dev/null +++ b/Q Research/scripts/build_dataset_manifest.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +""" +Generate a manifest describing datasets stored under `data/`. + +For each supported file (CSV/Parquet/Feather) this script records: + - relative path + - file size, mtime, and SHA256 checksum + - row count, columns, dtypes + - detected time range (based on common timestamp columns) + +Usage: + python scripts/build_dataset_manifest.py \ + --dirs data/raw data/derived \ + --output data/_manifest.json +""" + +from __future__ import annotations + +import argparse +import json +import hashlib +import os +from datetime import datetime, timezone +from pathlib import Path +from typing import Iterable, List, Optional + +import pandas as pd +from loguru import logger + + +SUPPORTED_EXTS = {".csv", ".parquet", ".feather"} +TIME_COLUMNS = ["ts", "time", "timestamp", "datetime", "date"] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Build dataset manifest.") + parser.add_argument( + "--dirs", + nargs="+", + default=["data/raw", "data/derived"], + help="Directories to scan for datasets.", + ) + parser.add_argument( + "--output", + default="data/_manifest.json", + help="Path to write manifest JSON.", + ) + parser.add_argument( + "--limit", + type=int, + default=None, + help="Optional limit on number of rows to load when summarizing large files.", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Scan files but do not write manifest.", + ) + return parser.parse_args() + + +def sha256sum(path: Path, chunk_size: int = 1 << 20) -> str: + """Compute SHA256 hash for a file.""" + h = hashlib.sha256() + with path.open("rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + h.update(chunk) + return h.hexdigest() + + +def load_dataframe(path: Path, limit: Optional[int]) -> pd.DataFrame: + """Load a dataframe from supported formats with optional row limit.""" + if path.suffix == ".csv": + return pd.read_csv(path, nrows=limit) + if path.suffix == ".parquet": + df = pd.read_parquet(path) + return df.head(limit) if limit else df + if path.suffix == ".feather": + df = pd.read_feather(path) + return df.head(limit) if limit else df + raise ValueError(f"Unsupported file format: {path}") + + +def detect_time_range(df: pd.DataFrame) -> tuple[Optional[str], Optional[str]]: + """Try to detect timestamp column and return ISO8601 min/max.""" + col = next((c for c in TIME_COLUMNS if c in df.columns), None) + if col is None: + for candidate in df.columns: + if pd.api.types.is_datetime64_any_dtype(df[candidate]): + col = candidate + break + if col is None: + return None, None + ts = pd.to_datetime(df[col], utc=True, errors="coerce").dropna() + if ts.empty: + return None, None + return ts.min().isoformat(), ts.max().isoformat() + + +def summarize_file(path: Path, repo_root: Path, limit: Optional[int]) -> dict: + """Collect metadata for the given dataset file.""" + rel_path = path.relative_to(repo_root) + stat = path.stat() + size = stat.st_size + mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat() + checksum = sha256sum(path) + + try: + df = load_dataframe(path, limit) + except Exception as exc: + logger.error(f"Failed to load {rel_path}: {exc}") + df = None + + rows = int(df.shape[0]) if df is not None else None + columns: List[str] = df.columns.tolist() if df is not None else [] + dtypes = {col: str(dtype) for col, dtype in df.dtypes.items()} if df is not None else {} + t_start, t_end = detect_time_range(df) if df is not None else (None, None) + + return { + "path": str(rel_path), + "extension": path.suffix, + "size_bytes": size, + "modified_at": mtime, + "sha256": checksum, + "rows": rows, + "columns": columns, + "dtypes": dtypes, + "time_start": t_start, + "time_end": t_end, + } + + +def iter_dataset_files(dirs: Iterable[str], repo_root: Path) -> List[Path]: + files: List[Path] = [] + for d in dirs: + target = (repo_root / d).resolve() + if not target.exists(): + logger.warning(f"Skip missing directory: {target}") + continue + for path in target.rglob("*"): + if path.is_file() and path.suffix in SUPPORTED_EXTS: + files.append(path) + return files + + +def main() -> None: + args = parse_args() + repo_root = Path(__file__).resolve().parents[1] + files = iter_dataset_files(args.dirs, repo_root) + if not files: + logger.warning("No dataset files found.") + logger.info(f"Found {len(files)} dataset files.") + + entries = [] + for path in files: + logger.info(f"Summarizing {path}") + entry = summarize_file(path, repo_root, args.limit) + entries.append(entry) + + manifest = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "inputs": args.dirs, + "file_count": len(entries), + "files": entries, + } + + if args.dry_run: + logger.info("Dry-run enabled; manifest not written.") + print(json.dumps(manifest, indent=2, ensure_ascii=False)) + return + + output_path = (repo_root / args.output).resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as fh: + json.dump(manifest, fh, indent=2, ensure_ascii=False) + logger.info(f"Manifest written to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/check_data_integrity.py b/Q Research/scripts/check_data_integrity.py new file mode 100644 index 0000000..e1a6163 --- /dev/null +++ b/Q Research/scripts/check_data_integrity.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +""" +CI helper that ensures watched datasets keep their expected signatures and pass validation. + +Fail conditions: + 1. Dataset listed in baseline JSON is missing. + 2. Manifest entry hash differs from the baseline (meaning data changed without approval). + 3. Validation severity == "error". +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from loguru import logger + +ROOT = Path(__file__).resolve().parents[1] + +sys.path.append(str(ROOT)) + +from scripts.validate_dataset import compute_report, DEFAULT_MANIFEST # noqa + + +def load_manifest(manifest_path: Path) -> dict: + if not manifest_path.exists(): + raise FileNotFoundError(f"Manifest not found: {manifest_path}") + with manifest_path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Check dataset hashes & validation severity.") + parser.add_argument("--baseline", default="data/signature_baseline.json", help="Baseline hash file.") + parser.add_argument("--manifest", default=DEFAULT_MANIFEST, help="Manifest JSON to compare against.") + parser.add_argument("--max-outlier-z", type=float, default=5.0, help="Z-score threshold used for validation.") + return parser.parse_args() + + +def load_baseline_entries(path: Path) -> list[dict]: + if not path.exists(): + raise FileNotFoundError(f"Baseline file missing: {path}") + with path.open("r", encoding="utf-8") as fh: + data = json.load(fh) + if isinstance(data, dict): + return [{"path": k, "sha256": v, "allow_drift": False} for k, v in data.items()] + if isinstance(data, list): + return data + raise ValueError("Baseline must be a dict or list of entries.") + + +def main() -> None: + args = parse_args() + baseline_path = (ROOT / args.baseline).resolve() + manifest_path = (ROOT / args.manifest).resolve() + + baseline_entries = load_baseline_entries(baseline_path) + + manifest = load_manifest(manifest_path) + manifest_index = {entry["path"]: entry for entry in manifest.get("files", [])} + + errors = [] + warnings = [] + for item in baseline_entries: + rel_path = item["path"] + expected_hash = item.get("sha256") + allow_drift = bool(item.get("allow_drift", False)) + dataset_path = (ROOT / rel_path).resolve() + manifest_entry = manifest_index.get(rel_path) + label = rel_path + if manifest_entry is None: + errors.append(f"{label}: missing from manifest {manifest_path}") + continue + actual_hash = manifest_entry.get("sha256") + if actual_hash != expected_hash: + message = ( + f"{label}: hash drift detected (expected {expected_hash}, actual {actual_hash}). " + "Update the baseline with justification if intentional." + ) + if allow_drift: + warnings.append(message) + else: + errors.append(message) + if not dataset_path.exists(): + errors.append(f"{label}: dataset file not found on disk") + continue + report = compute_report(dataset_path, manifest_entry, args.max_outlier_z) + severity = report.get("severity") + msg = f"{label}: validation severity={severity} ({'; '.join(report.get('messages', []))})" + if severity == "error": + errors.append(msg) + elif severity == "warn": + warnings.append(msg) + + for warn in warnings: + logger.warning(warn) + + if errors: + logger.error("Data integrity check failed:") + for err in errors: + logger.error(" - {}", err) + sys.exit(1) + + logger.info(f"Data integrity checks passed for {len(baseline_entries)} dataset(s).") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/check_risk_report.py b/Q Research/scripts/check_risk_report.py new file mode 100644 index 0000000..aab4166 --- /dev/null +++ b/Q Research/scripts/check_risk_report.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Fail CI if risk report exceeds thresholds.""" + +from __future__ import annotations + +import argparse +import csv +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate risk report thresholds") + parser.add_argument("--report", default="results/risk/report.csv", help="CSV produced by risk_report.py") + parser.add_argument("--max-rejects", type=int, default=0) + parser.add_argument("--max-kill", type=int, default=0) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + report = Path(args.report) + if not report.exists(): + raise SystemExit(f"Report not found: {report}") + rejects = 0 + kills = 0 + with report.open("r", encoding="utf-8") as fh: + reader = csv.DictReader(fh) + for row in reader: + if row.get("event") == "reject": + rejects += 1 + elif row.get("event") == "kill_switch": + kills += 1 + if rejects > args.max_rejects or kills > args.max_kill: + raise SystemExit( + f"Risk report exceeded thresholds: rejects={rejects} (max {args.max_rejects}), " + f"kill_switch={kills} (max {args.max_kill})" + ) + print(f"Risk report OK (rejects={rejects}, kill_switch={kills})") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/cleanup_outputs.py b/Q Research/scripts/cleanup_outputs.py new file mode 100644 index 0000000..952df90 --- /dev/null +++ b/Q Research/scripts/cleanup_outputs.py @@ -0,0 +1,17 @@ +import os, time, glob +from datetime import datetime, timedelta + +base = "data/outputs" +days_to_keep = 3 +cutoff = time.time() - days_to_keep * 86400 + +def clean_dir(path, exts): + for ext in exts: + for f in glob.glob(os.path.join(path, f"*.{ext}")): + if os.path.getmtime(f) < cutoff: + os.remove(f) + print(f"🧹 Deleted old {ext}: {f}") + +clean_dir(os.path.join(base, "equity"), ["csv"]) +clean_dir(os.path.join(base, "trades"), ["csv"]) +print("✅ Cleanup complete.") \ No newline at end of file diff --git a/Q Research/scripts/compare_cost_scenarios.py b/Q Research/scripts/compare_cost_scenarios.py new file mode 100644 index 0000000..0e49d28 --- /dev/null +++ b/Q Research/scripts/compare_cost_scenarios.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import List + +import pandas as pd +from loguru import logger + +METRIC_COLS = [ + "sharpe", + "ann_return", + "ann_vol", + "max_drawdown", + "expectancy", + "trades", + "win_rate", + "return_pct", + "rr", + "median_hold", + "symbol", + "source_file", +] + + +def main(): + parser = argparse.ArgumentParser(description="Compare zero-cost vs real-cost grid results.") + parser.add_argument("--zero", required=True, help="CSV from zero-cost grid run.") + parser.add_argument("--real", required=True, help="CSV from real-cost grid run.") + parser.add_argument("--out", default="data/grid/cost_comparison.csv", help="Output CSV path.") + parser.add_argument("--top", type=int, default=20, help="Print top-N rows with smallest Sharpe delta.") + args = parser.parse_args() + + df_zero = pd.read_csv(args.zero) + df_real = pd.read_csv(args.real) + + shared_cols = [c for c in df_zero.columns if c in df_real.columns] + if not shared_cols: + raise ValueError("No overlapping columns between zero and real CSVs.") + + param_cols: List[str] = [c for c in shared_cols if c not in METRIC_COLS] + if not param_cols: + raise ValueError("Unable to infer parameter columns for join; ensure CSVs contain metrics from METRIC_COLS.") + + z = df_zero.rename(columns={col: f"{col}_zero" for col in METRIC_COLS if col in df_zero.columns}) + r = df_real.rename(columns={col: f"{col}_real" for col in METRIC_COLS if col in df_real.columns}) + + merged = z.merge(r, on=param_cols, how="inner", suffixes=("_zero", "_real")) + if merged.empty: + raise RuntimeError("Join result is empty; ensure both CSVs share the same parameter combinations.") + + if "sharpe_zero" in merged.columns and "sharpe_real" in merged.columns: + merged["delta_sharpe"] = merged["sharpe_real"] - merged["sharpe_zero"] + if "ann_return_zero" in merged.columns and "ann_return_real" in merged.columns: + merged["delta_ann_return"] = merged["ann_return_real"] - merged["ann_return_zero"] + if "expectancy_zero" in merged.columns and "expectancy_real" in merged.columns: + merged["delta_expectancy"] = merged["expectancy_real"] - merged["expectancy_zero"] + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + merged.to_csv(out_path, index=False) + logger.info(f"Cost comparison saved to {out_path} (rows={len(merged)})") + + if "delta_sharpe" in merged.columns: + top_df = merged.sort_values("delta_sharpe", ascending=False).head(args.top) + print(top_df[param_cols + ["sharpe_zero", "sharpe_real", "delta_sharpe"]].to_string(index=False)) + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/compare_fills.py b/Q Research/scripts/compare_fills.py new file mode 100644 index 0000000..19e80d4 --- /dev/null +++ b/Q Research/scripts/compare_fills.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +Compare paper vs. live fills to produce a lightweight TCA summary. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +import pandas as pd + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Compare paper vs live fills.") + parser.add_argument("--paper", required=True, help="Path to paper fills CSV.") + parser.add_argument("--live", required=True, help="Path to live fills CSV.") + parser.add_argument("--out", default="results/execution/tca_summary.json", help="Output JSON path.") + return parser.parse_args() + + +def load(path: str) -> pd.DataFrame: + csv = Path(path) + if not csv.exists(): + raise SystemExit(f"fills CSV not found: {csv}") + df = pd.read_csv(csv) + if df.empty: + raise SystemExit(f"{csv} is empty") + if "ts" in df.columns: + df["ts"] = pd.to_datetime(df["ts"]) + return df + + +def summary(df: pd.DataFrame, label: str) -> dict: + pnl = df["pnl"] if "pnl" in df.columns else pd.Series(dtype=float) + latency = df["adapter_latency_ms"] if "adapter_latency_ms" in df.columns else pd.Series(dtype=float) + return { + f"{label}_trade_count": int(len(df)), + f"{label}_total_pnl": float(pnl.sum()) if not pnl.empty else None, + f"{label}_avg_pnl": float(pnl.mean()) if not pnl.empty else None, + f"{label}_avg_latency_ms": float(latency.mean()) if not latency.empty else None, + } + + +def pnl_gap(paper: pd.DataFrame, live: pd.DataFrame) -> dict: + cols = [] + if "ts" in paper.columns and "ts" in live.columns: + merged = pd.merge( + paper[["ts", "pnl"]].rename(columns={"pnl": "paper_pnl"}), + live[["ts", "pnl"]].rename(columns={"pnl": "live_pnl"}), + on="ts", + how="outer", + ) + else: + merged = pd.DataFrame({"paper_pnl": paper.get("pnl"), "live_pnl": live.get("pnl")}) + merged = merged.fillna(0.0) + merged["pnl_diff"] = merged["live_pnl"] - merged["paper_pnl"] + return { + "pnl_diff_mean": float(merged["pnl_diff"].mean()), + "pnl_diff_std": float(merged["pnl_diff"].std(ddof=0)), + } + + +def main() -> None: + args = parse_args() + paper = load(args.paper) + live = load(args.live) + + report = {"paper_path": args.paper, "live_path": args.live} + report.update(summary(paper, "paper")) + report.update(summary(live, "live")) + report.update(pnl_gap(paper, live)) + + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(report, indent=2, default=float), encoding="utf-8") + print(json.dumps(report, indent=2, default=float)) + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/compute_indicators.py b/Q Research/scripts/compute_indicators.py new file mode 100644 index 0000000..d4fff63 --- /dev/null +++ b/Q Research/scripts/compute_indicators.py @@ -0,0 +1,50 @@ +# compute_indicators.py + +import os +import pandas as pd +from loguru import logger + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +RAW_DATA_DIR = os.path.join(BASE_DIR, "data", "raw") +DERIVED_DATA_DIR = os.path.join(BASE_DIR, "data", "derived") +os.makedirs(DERIVED_DATA_DIR, exist_ok=True) + +def compute_indicators(df: pd.DataFrame) -> pd.DataFrame: + # SMA + df['SMA_20'] = df['close'].rolling(window=20).mean() + + # Bollinger Bands + df['BB_MID'] = df['close'].rolling(window=20).mean() + df['BB_STD'] = df['close'].rolling(window=20).std() + df['BB_UPPER'] = df['BB_MID'] + 2 * df['BB_STD'] + df['BB_LOWER'] = df['BB_MID'] - 2 * df['BB_STD'] + + # MACD + ema12 = df['close'].ewm(span=12, adjust=False).mean() + ema26 = df['close'].ewm(span=26, adjust=False).mean() + df['MACD'] = ema12 - ema26 + df['MACD_signal'] = df['MACD'].ewm(span=9, adjust=False).mean() + + # RSI + def compute_rsi(series, period=14): + delta = series.diff() + gain = delta.clip(lower=0) + loss = -delta.clip(upper=0) + avg_gain = gain.rolling(window=period).mean() + avg_loss = loss.rolling(window=period).mean() + rs = avg_gain / avg_loss + return 100 - (100 / (1 + rs)) + + df['RSI_14'] = compute_rsi(df['close']) + + return df + + +if __name__ == "__main__": + input_path = os.path.join(RAW_DATA_DIR, "EURUSD_H1.csv") + output_path = os.path.join(DERIVED_DATA_DIR, "EURUSD_H1_with_indicators.csv") + + df = pd.read_csv(input_path, parse_dates=["time"]) + df = compute_indicators(df) + df.to_csv(output_path, index=False) + logger.info(f"✅ Saved with indicators to {output_path}") diff --git a/Q Research/scripts/export_metrics_prom.py b/Q Research/scripts/export_metrics_prom.py new file mode 100644 index 0000000..c9ebeee --- /dev/null +++ b/Q Research/scripts/export_metrics_prom.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +""" +Export latest risk metrics row in Prometheus exposition format. + +Usage: + python scripts/export_metrics_prom.py --csv results/risk/metrics.csv --job risk_sim +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import pandas as pd + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Export risk metrics to Prometheus format.") + parser.add_argument("--csv", default="results/risk/metrics.csv") + parser.add_argument("--job", default="risk_sim") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + path = Path(args.csv) + if not path.exists(): + raise SystemExit(f"metrics CSV not found: {path}") + df = pd.read_csv(path) + if df.empty: + raise SystemExit("metrics CSV is empty.") + latest = df.tail(1).iloc[0] + run_id = latest.get("run_id", "unknown") + def value(key: str, default: float = 0.0) -> float: + val = latest.get(key, default) + if isinstance(val, str) and not val: + return default + try: + if pd.isna(val): + return default + except TypeError: + pass + return val + + metrics = { + "risk_rejects": value("rejects", 0), + "risk_kills": value("kills", 0), + "risk_latency_ms_avg": value("latency_ms_avg"), + "risk_latency_ms_p95": value("latency_ms_p95"), + "risk_total_pnl": value("total_pnl"), + "risk_max_gross_notional": value("max_gross_notional"), + "risk_max_symbol_exposure": value("max_symbol_exposure"), + "risk_max_drawdown_pct": value("max_drawdown_pct"), + "risk_live_sharpe_30d": value("rolling_sharpe_30d"), + "risk_live_drawdown_pct": value("live_drawdown_pct"), + "risk_live_latency_ms_p95": value("live_latency_ms_p95"), + "risk_slippage_bps": value("slippage_bps"), + } + labels = f'run="{run_id}",job="{args.job}"' + for name, value in metrics.items(): + print(f'{name}{{{labels}}} {value}') + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/get_candles.py b/Q Research/scripts/get_candles.py new file mode 100644 index 0000000..ce87e95 --- /dev/null +++ b/Q Research/scripts/get_candles.py @@ -0,0 +1,200 @@ +import math +import os +import sys +from datetime import datetime, timedelta, timezone + +import pandas as pd +from loguru import logger + +# 允许从项目根目录导入模块 +PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.path.dirname(PROJECT_ROOT) +sys.path.append(PROJECT_ROOT) +sys.path.append(REPO_ROOT) + +from oandapyV20 import API +import oandapyV20.endpoints.instruments as instruments +from shared.utils.config import OANDA_TOKEN # 确保这里能拿到 token + +# 日志 +logger.remove() +logger.add(sys.stderr, level="INFO", format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {message}") + +def normalize_to_oanda(symbol: str) -> str: + """ + 将用户友好的交易对格式转换为OANDA格式,例如: + 'eurusd', 'EUR-USD', 'eur_usd' -> 'EUR_USD' + """ + s = symbol.upper().replace("-", "_").replace(" ", "").replace("/", "_") + # 如果已经是正确格式,直接返回 + if "_" in s and len(s) == 7: + return s + # 尝试拆分为两部分 + if len(s) == 6: + return s[:3] + "_" + s[3:] + return s + +def default_out_csv(project_root: str, instrument: str, granularity: str) -> str: + """ + 根据交易对和时间粒度生成默认输出路径,如: + data/raw/EURUSD_H1.csv + """ + fname = f"{instrument.replace('_','')}_{granularity}.csv" + raw_dir = os.path.join(project_root, "data", "raw") + os.makedirs(raw_dir, exist_ok=True) + return os.path.join(raw_dir, fname) + +def _bars_per_day(granularity: str) -> float: + """ + Rough estimate of bars per day for常见 OANDA 粒度。 + 用于根据 count 推算需要拉取的天数。 + """ + granularity = granularity.upper() + seconds_map = { + "S5": 5, + "S10": 10, + "S15": 15, + "S30": 30, + "M1": 60, + "M2": 120, + "M4": 240, + "M5": 300, + "M10": 600, + "M15": 900, + "M30": 1800, + "H1": 3600, + "H2": 7200, + "H3": 10800, + "H4": 14400, + "H6": 21600, + "H8": 28800, + "H12": 43200, + "D": 86400, + "W": 86400 * 5, + "M": 86400 * 21, + } + seconds = seconds_map.get(granularity, 3600) + if seconds <= 0: + return 24 + return max(86400 / seconds, 1) + + +def get_candles(symbol="EUR_USD", granularity="H1", start_days_ago=365, target_count: int | None = None) -> pd.DataFrame: + """ + 循环抓取 OANDA 历史K线(默认过去一年),自动分页拼接。 + """ + if not OANDA_TOKEN: + raise RuntimeError("OANDA_TOKEN 为空,请在 utils/config.py 配置或通过环境变量提供。") + + client = API(access_token=OANDA_TOKEN) + + end = datetime.now(timezone.utc) + start = end - timedelta(days=start_days_ago) + cur = start + all_rows = 0 + parts = [] + + logger.info(f"开始下载 {symbol} {granularity}(过去 {start_days_ago} 天)") + + # 每次抓 20 天(H1 ≈ 480 根),避免单次数据过大 + step = timedelta(days=20) + + while cur < end: + to_ts = min(cur + step, end) + params = { + "granularity": granularity, + "price": "M", + "from": cur.isoformat(), + "to": to_ts.isoformat(), + } + r = instruments.InstrumentsCandles(instrument=symbol, params=params) + try: + client.request(r) + except Exception as e: + logger.error(f"请求失败 {cur} ~ {to_ts}: {e}") + break + + candles = r.response.get("candles", []) + if not candles: + logger.warning(f"区间无数据:{cur} ~ {to_ts}") + cur = to_ts + continue + + data = [{ + "time": c["time"], + "open": float(c["mid"]["o"]), + "high": float(c["mid"]["h"]), + "low": float(c["mid"]["l"]), + "close":float(c["mid"]["c"]), + "volume": c["volume"] + } for c in candles if c.get("complete")] + + if data: + df_part = pd.DataFrame(data) + parts.append(df_part) + all_rows += len(df_part) + logger.info(f"抓取区间 {cur:%Y-%m-%d} ~ {to_ts:%Y-%m-%d} 行数={len(df_part)},累计={all_rows}") + + cur = to_ts # 推进窗口 + + if target_count and all_rows >= target_count: + logger.info(f"已满足目标条数 {target_count},停止抓取。") + break + + if not parts: + logger.warning("没有获取到任何数据。") + return pd.DataFrame() + + df = pd.concat(parts, ignore_index=True) + df["time"] = pd.to_datetime(df["time"]) + df = df.sort_values("time").drop_duplicates(subset=["time"]).reset_index(drop=True) + if target_count: + df = df.tail(target_count).reset_index(drop=True) + + logger.info(f"✅ 下载完成:总计 {len(df)} 行({df['time'].min()} ~ {df['time'].max()})") + return df + +def main(): + import argparse + parser = argparse.ArgumentParser() + parser.add_argument("--symbol", default="EUR_USD") + parser.add_argument("--granularity", default="H1") + parser.add_argument("--days", type=int, default=365, help="向前回溯天数") + parser.add_argument("--count", type=int, default=None, help="(可选)需要的 K 线数量,脚本会根据粒度估算天数,抓够后截断") + parser.add_argument("--out", "--output", dest="out", default=None) + args = parser.parse_args() + + # 规范化交易对格式 + args.symbol = normalize_to_oanda(args.symbol) + + # 自动生成输出路径(如果未指定) + if args.out is None: + args.out = default_out_csv(PROJECT_ROOT, args.symbol, args.granularity) + + # 确保输出目录存在 + os.makedirs(os.path.dirname(args.out), exist_ok=True) + + target_count = args.count if args.count and args.count > 0 else None + if target_count: + est_days = math.ceil(target_count / _bars_per_day(args.granularity)) + 5 + if est_days > args.days: + logger.info(f"根据 count={target_count} 估算需要 {est_days} 天数据(原 days={args.days}),已自动扩展。") + args.days = est_days + + try: + df = get_candles(args.symbol, args.granularity, args.days, target_count=target_count) + except Exception as e: + logger.exception(f"下载失败:{e}") + sys.exit(1) + + if df.empty: + logger.warning("结果为空,未保存。") + sys.exit(2) + + df.to_csv(args.out, index=False) + logger.info(f"📦 已保存到:{args.out}") + # 方便你肉眼确认 + logger.info(f"尾部预览:\n{df.tail(3).to_string(index=False)}") + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/grid_search_rsi_trailing.py b/Q Research/scripts/grid_search_rsi_trailing.py new file mode 100644 index 0000000..8c97cab --- /dev/null +++ b/Q Research/scripts/grid_search_rsi_trailing.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import argparse +import itertools +import os +import sys +from copy import deepcopy +from pathlib import Path +from queue import Empty, Queue +from typing import Dict, Iterable, List, Optional + +import pandas as pd +import yaml +from loguru import logger + +# 允许直接 import 项目内模块 +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.backtest.strategy_engine import StrategyEngine, parse_strategy_specs, _coerce_fx_rates # noqa: E402 +from data.csv_feed import CSVFeed # noqa: E402 +from metrics.perf import trade_stats # noqa: E402 + + +BASE_CONFIG_PATH = Path("config/optimized_eurusd_v2_with_rsi.yaml") +DEFAULT_SYMBOL = "EURUSD" +DEFAULT_CSV = Path("data/raw/EURUSD_H1.csv") + +# 重点围绕 ATR / RSI / cooldown 进行调参 +PARAM_GRID = { + "fast": [20], + "slow": [120], + "atr_sl": [1.0, 1.3, 1.6], + "atr_tp": [None, 3.0, 4.5], + "rsi_long_thresh": [60], + "rsi_short_thresh": [40], + "cooldown": [12, 24, 36], + "trailing_enable_atr_mult": [0.5], + "trailing_atr_mult": [0.5], + "htf_factor": [4], + "size_tier_mode": ["base"], + "boll_window": [32, 48, 64], + "boll_enter_z": [1.0, 1.3, 1.6], + "boll_exit_z": [0.2, 0.4], + "boll_allow_short": [False], +} + +SIZE_TIER_PRESETS = { + "base": { + "base_size_mult": 1.0, + "size_tiers": [{"size_mult": 1.0}], + }, + "balanced": { + "base_size_mult": 1.0, + "size_tiers": [ + {"name": "strong", "min_atr_pct": 0.55, "min_trend_bars": 6, "size_mult": 1.3}, + {"name": "base", "size_mult": 1.0}, + ], + }, + "aggressive": { + "base_size_mult": 1.0, + "size_tiers": [ + {"name": "strong", "min_atr_pct": 0.6, "min_trend_bars": 8, "size_mult": 1.6}, + {"name": "mid", "min_atr_pct": 0.45, "min_trend_strength": 0.00008, "size_mult": 1.2}, + ], + }, +} + + +def _apply_size_tier_mode(cfg: Dict[str, object], mode: Optional[str]) -> None: + if not mode: + return + preset = SIZE_TIER_PRESETS.get(mode) + if not preset: + logger.warning(f"[GRID] 未知 size_tier 模式: {mode}") + return + strategies = cfg.get("strategies") + if not isinstance(strategies, list): + return + for strat in strategies: + if isinstance(strat, dict) and strat.get("name") == "regime_sma": + params = strat.setdefault("params", {}) + tiers = preset.get("size_tiers") + if tiers is not None: + params["size_tiers"] = deepcopy(tiers) + if "base_size_mult" in preset: + params["base_size_mult"] = preset["base_size_mult"] + + +def _apply_bollinger_params(cfg: Dict[str, object], overrides: Dict[str, object]) -> None: + strategies = cfg.get("strategies") + if not isinstance(strategies, list): + return + for strat in strategies: + if isinstance(strat, dict) and strat.get("name") == "bollinger_mean_revert": + params = strat.setdefault("params", {}) + if overrides.get("window") is not None: + params["window"] = int(overrides["window"]) + if overrides.get("enter_z") is not None: + params["enter_z"] = float(overrides["enter_z"]) + if overrides.get("exit_z") is not None: + params["exit_z"] = float(overrides["exit_z"]) + if overrides.get("allow_short") is not None: + params["allow_short"] = bool(overrides["allow_short"]) + + +def _to_float(value: Optional[object]) -> Optional[float]: + if value is None: + return None + if isinstance(value, str) and value.lower() in {"none", "null", ""}: + return None + return float(value) + + +def load_base_config(path: Path = BASE_CONFIG_PATH) -> Dict[str, object]: + with path.open("r", encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def evaluate_config(base_cfg: Dict[str, object], overrides: Dict[str, object], symbol: str) -> Optional[Dict[str, object]]: + cfg = deepcopy(base_cfg) + local_overrides = dict(overrides) + size_mode = local_overrides.pop("size_tier_mode", None) + boll_window = local_overrides.pop("boll_window", None) + boll_enter = local_overrides.pop("boll_enter_z", None) + boll_exit = local_overrides.pop("boll_exit_z", None) + boll_allow_short = local_overrides.pop("boll_allow_short", None) + cfg.update(local_overrides) + if size_mode: + _apply_size_tier_mode(cfg, size_mode) + if any(v is not None for v in [boll_window, boll_enter, boll_exit, boll_allow_short]): + _apply_bollinger_params( + cfg, + { + "window": boll_window, + "enter_z": boll_enter, + "exit_z": boll_exit, + "allow_short": boll_allow_short, + }, + ) + + symbol = cfg.get("symbol", DEFAULT_SYMBOL) + csv_path = Path(cfg.get("csv", DEFAULT_CSV)) + if not csv_path.exists(): + logger.error(f"CSV 路径不存在: {csv_path}") + return None + + initial_cash = float(cfg.get("cash", 100_000)) + qty = float(cfg.get("qty", 10_000)) + account_ccy = cfg.get("account_ccy", "USD") + fast = int(cfg.get("fast", 20)) + slow = int(cfg.get("slow", 150)) + spread = float(cfg.get("spread", 1.0)) + slip = float(cfg.get("slip", 0.2)) + comm = float(cfg.get("comm", 2.0)) + stop_loss_pips = _to_float(cfg.get("sl")) + take_profit_pips = _to_float(cfg.get("tp")) + atr_sl = _to_float(cfg.get("atr_sl")) + atr_tp = _to_float(cfg.get("atr_tp")) + atr_window = int(cfg.get("atr_window", 14)) + rsi_period = int(cfg.get("rsi_period", 14)) + rsi_long = _to_float(cfg.get("rsi_long_thresh")) + rsi_short = _to_float(cfg.get("rsi_short_thresh")) + enable_trailing = bool(cfg.get("enable_trailing", False)) + trailing_enable = float(cfg.get("trailing_enable_atr_mult", 1.0)) + trailing_mult = float(cfg.get("trailing_atr_mult", 0.5)) + slope_lookback = int(cfg.get("slope_lookback", 0)) + cooldown = int(cfg.get("cooldown", 0)) + allow_short = bool(cfg.get("allow_short", True)) + long_only_above_slow = bool(cfg.get("long_only_above_slow", False)) + short_only_below_slow = bool(cfg.get("short_only_below_slow", False)) + risk_per_trade_pct = _to_float(cfg.get("risk_per_trade_pct")) + max_drawdown_pct = _to_float(cfg.get("max_drawdown_pct")) + max_position_units = _to_float(cfg.get("max_position_units")) + + cfg_fx_rates = _coerce_fx_rates(cfg.get("fx_rates")) + fx_rates = cfg_fx_rates if cfg_fx_rates else None + + strategy_specs = parse_strategy_specs(cfg.get("strategies")) + + engine = StrategyEngine( + symbol=symbol, + fast_win=fast, + slow_win=slow, + spread_pips=spread, + commission_per_million=comm, + slippage_pips=slip, + stop_loss_pips=stop_loss_pips, + take_profit_pips=take_profit_pips, + atr_sl=atr_sl, + atr_tp=atr_tp, + atr_window=atr_window, + rsi_period=rsi_period, + rsi_long_thresh=rsi_long, + rsi_short_thresh=rsi_short, + enable_trailing=enable_trailing, + trailing_enable_atr_mult=trailing_enable, + trailing_atr_mult=trailing_mult, + long_only_above_slow=long_only_above_slow, + slope_lookback=slope_lookback, + cooldown=cooldown, + qty=qty, + account_ccy=account_ccy, + fx_rates=fx_rates, + strategy_specs=strategy_specs, + allow_short=allow_short, + short_only_below_slow=short_only_below_slow, + risk_per_trade_pct=risk_per_trade_pct, + max_drawdown_pct=max_drawdown_pct, + max_position_units=max_position_units, + ) + engine.set_initial_cash(initial_cash) + + q: Queue = Queue() + feed = CSVFeed(q, path=str(csv_path), symbol=symbol) + feed.start() + + try: + while True: + try: + event = q.get(timeout=0.05) + except Empty: + if hasattr(feed, "pump"): + feed.pump(n=100) + if getattr(feed, "finished", False): + break + continue + + if event.get("type") != "bar": + continue + engine.handle_bar(event) + finally: + engine.finalize() + + summary = engine.summary(fast, slow) + stats = trade_stats(engine.trade_log) if engine.trade_log else {} + + final_equity = summary.get("final_equity", engine.cash) + ret_pct = (final_equity / initial_cash - 1.0) if initial_cash else None + + return { + "params": overrides, + "summary": summary, + "stats": stats, + "final_equity": final_equity, + "return_pct": ret_pct, + "symbol": symbol, + } + + +def param_product(grid: Dict[str, Iterable[object]]) -> Iterable[Dict[str, object]]: + keys = list(grid.keys()) + for combo in itertools.product(*(grid[k] for k in keys)): + params = dict(zip(keys, combo)) + fast_v = params.get("fast") + slow_v = params.get("slow") + if fast_v is not None and slow_v is not None: + if float(fast_v) >= float(slow_v): + continue + long_v = params.get("rsi_long_thresh") + short_v = params.get("rsi_short_thresh") + if long_v is not None and short_v is not None and long_v <= short_v: + continue + yield params + + +def run_grid(base_cfg: Dict[str, object], save_suffix: Optional[str] = None, param_grid: Optional[Dict[str, List[object]]] = None) -> Path: + # 降低日志噪音 + logger.remove() + logger.add(sys.stderr, level="WARNING") + + results: List[Dict[str, object]] = [] + grid_def = deepcopy(param_grid or PARAM_GRID) + combos = list(param_product(grid_def)) + total = len(combos) + print(f"将测试 {total} 种参数组合") + + suffix = f"_{save_suffix}" if save_suffix else "" + out_dir = Path("data/grid") + out_dir.mkdir(parents=True, exist_ok=True) + out_filename = f"grid_rsi_trailing_diagnostics{suffix}.csv" + + def fmt_pct(value: Optional[float]) -> str: + return f"{value:.2%}" if value is not None else "NA" + + def fmt_float(value: Optional[float], digits: int = 3) -> str: + return f"{value:.{digits}f}" if value is not None else "NA" + + for idx, params in enumerate(combos, 1): + print(f"\n[{idx}/{total}] 评估参数: {params}") + res = evaluate_config(base_cfg, params, base_cfg.get("symbol", DEFAULT_SYMBOL)) + if not res: + print(" -> 运行失败") + continue + summary = res["summary"] + stats = res["stats"] + sharpe = summary.get("sharpe") + win_rate = stats.get("win_rate") + exp = stats.get("expectancy") + trades = summary.get("trades") + ret_pct = res["return_pct"] + dd = summary.get("max_drawdown") + print( + " -> Sharpe={} 回撤={} 胜率={} 期望={} 交易数={}".format( + fmt_float(sharpe), + fmt_pct(dd), + fmt_pct(win_rate), + fmt_float(exp, digits=2), + trades if trades is not None else "NA", + ) + ) + res_record = { + "sharpe": sharpe, + "ann_return": summary.get("ann_return"), + "ann_vol": summary.get("ann_vol"), + "max_drawdown": summary.get("max_drawdown"), + "trades": trades, + "return_pct": ret_pct, + "win_rate": win_rate, + "rr": stats.get("rr"), + "expectancy": exp, + "median_hold": stats.get("median_hold"), + "symbol": res.get("symbol", base_cfg.get("symbol", DEFAULT_SYMBOL)), + **params, + "source_file": out_filename, + } + results.append(res_record) + + if not results: + print("\n未得到任何有效结果。") + return + + df = pd.DataFrame(results) + df["sharpe"] = pd.to_numeric(df["sharpe"], errors="coerce") + df["expectancy"] = pd.to_numeric(df["expectancy"], errors="coerce") + df = df.sort_values(by="sharpe", ascending=False) + out_path = out_dir / out_filename + df.to_csv(out_path, index=False) + + print(f"\n已保存全部结果 -> {out_path}") + top_n = df.head(5) + print("\nTop 5 组合:") + for _, row in top_n.iterrows(): + win_rate_val = row.get("win_rate") + win_rate_val = None if pd.isna(win_rate_val) else win_rate_val + cooldown_val = row.get("cooldown") + cooldown_disp = int(cooldown_val) if cooldown_val is not None and not pd.isna(cooldown_val) else "NA" + atr_sl_disp = fmt_float(row.get("atr_sl"), digits=2) + atr_tp_val = row.get("atr_tp") + atr_tp_disp = "None" if atr_tp_val is None or (isinstance(atr_tp_val, float) and pd.isna(atr_tp_val)) else fmt_float(atr_tp_val, digits=2) + rsi_short = row.get("rsi_short_thresh") + rsi_long = row.get("rsi_long_thresh") + fast_val = row.get("fast") + slow_val = row.get("slow") + print( + " Sharpe={} 回撤={} 胜率={} fast/slow={}/{} cooldown={} atr_sl={} atr_tp={} RSI=({}/{})".format( + fmt_float(row.get("sharpe")), + fmt_pct(row.get("max_drawdown")), + fmt_pct(win_rate_val), + fmt_float(fast_val, digits=0) if fast_val is not None and not pd.isna(fast_val) else "NA", + fmt_float(slow_val, digits=0) if slow_val is not None and not pd.isna(slow_val) else "NA", + cooldown_disp, + atr_sl_disp, + atr_tp_disp, + fmt_float(rsi_short, digits=1) if rsi_short is not None and not pd.isna(rsi_short) else "NA", + fmt_float(rsi_long, digits=1) if rsi_long is not None and not pd.isna(rsi_long) else "NA", + ) + ) + + usdjpy_mask = ( + df["symbol"].astype(str).str.upper().eq("USDJPY") + & df["sharpe"].gt(1.5) + & df["expectancy"].gt(2.0) + ) + top_usdjpy = df.loc[usdjpy_mask].head(3) + if not top_usdjpy.empty: + print("\nUSDJPY Sharpe>1.5 & Expectancy>$2 (Top 3):") + for _, row in top_usdjpy.iterrows(): + print( + " Sharpe={:.3f} Expectancy=${:.2f} Trades={} atr_sl={} atr_tp={} cooldown={}".format( + row["sharpe"], + row["expectancy"], + row.get("trades", "NA"), + fmt_float(row.get("atr_sl"), digits=2), + "None" if pd.isna(row.get("atr_tp")) or row.get("atr_tp") is None else fmt_float(row.get("atr_tp"), digits=2), + int(row.get("cooldown")) if row.get("cooldown") is not None and not pd.isna(row.get("cooldown")) else "NA", + ) + ) + best_out = out_dir / "grid_usdjpy_top3.csv" + top_usdjpy.to_csv(best_out, index=False) + print(f"\n已保存 USDJPY 筛选结果 -> {best_out}") + else: + print("\nUSDJPY 暂无满足 Sharpe>1.5 & Expectancy>$2 的组合。") + + return out_path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Grid search for ATR/RSI/trailing parameters.") + parser.add_argument( + "--config", + type=str, + default=str(BASE_CONFIG_PATH), + help="YAML 配置路径(默认使用 optimized_eurusd_v2_with_rsi.yaml)", + ) + parser.add_argument("--symbol", type=str, default=None, help="覆盖配置中的 symbol(可选)") + parser.add_argument("--csv", type=str, default=None, help="覆盖配置中的 csv 路径(可选)") + parser.add_argument("--suffix", type=str, default=None, help="输出文件名后缀(默认取 symbol)") + parser.add_argument("--atr-sl", type=str, default=None, help="自定义 atr_sl 列表,例如 '1.0,1.3,1.6'") + parser.add_argument("--atr-tp", type=str, default=None, help="自定义 atr_tp 列表,例如 'None,3.0,4.0'") + parser.add_argument("--cooldown-list", type=str, default=None, help="自定义 cooldown 列表,例如 '12,24,36'") + parser.add_argument("--htf-factor-list", type=str, default=None, help="自定义 htf_factor 列表,例如 '2,4,6'") + parser.add_argument("--size-tier-mode-list", type=str, default=None, help="size_tier 模式列表,例如 'base,aggressive'") + parser.add_argument("--boll-window-list", type=str, default=None, help="Bollinger 窗口列表,例如 '32,48,64'") + parser.add_argument("--boll-enter-list", type=str, default=None, help="Bollinger 入场 Z 值列表,例如 '1.0,1.3'") + parser.add_argument("--boll-exit-list", type=str, default=None, help="Bollinger 退出 Z 值列表,例如 '0.2,0.4'") + parser.add_argument("--boll-allow-short", type=str, default=None, help="Bollinger 是否允许做空,示例 'true,false'") + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + cfg_path = Path(args.config) + base_cfg = load_base_config(cfg_path) + if args.symbol: + base_cfg["symbol"] = args.symbol + if args.csv: + base_cfg["csv"] = args.csv + + suffix = args.suffix or base_cfg.get("symbol") + + grid_override = deepcopy(PARAM_GRID) + + def _parse_float_list(raw: Optional[str]) -> Optional[List[Optional[float]]]: + if raw is None: + return None + values: List[Optional[float]] = [] + for token in raw.split(","): + token = token.strip() + if not token: + continue + if token.lower() in {"none", "null"}: + values.append(None) + else: + values.append(float(token)) + return values or None + + atr_sl_list = _parse_float_list(args.atr_sl) + atr_tp_list = _parse_float_list(args.atr_tp) + cooldown_list = None + if args.cooldown_list: + cooldown_list = [int(item.strip()) for item in args.cooldown_list.split(",") if item.strip()] + htf_factor_list = None + if args.htf_factor_list: + htf_factor_list = [int(item.strip()) for item in args.htf_factor_list.split(",") if item.strip()] + size_mode_list = None + if args.size_tier_mode_list: + size_mode_list = [item.strip() for item in args.size_tier_mode_list.split(",") if item.strip()] + boll_window_list = None + if args.boll_window_list: + boll_window_list = [int(item.strip()) for item in args.boll_window_list.split(",") if item.strip()] + boll_enter_list = _parse_float_list(args.boll_enter_list) + boll_exit_list = _parse_float_list(args.boll_exit_list) + boll_allow_short_list = None + if args.boll_allow_short: + mapping = {"true": True, "false": False, "1": True, "0": False} + boll_allow_short_list = [ + mapping.get(item.strip().lower(), item.strip().lower() in {"true", "1"}) for item in args.boll_allow_short.split(",") if item.strip() + ] + + if atr_sl_list: + grid_override["atr_sl"] = atr_sl_list + if atr_tp_list: + grid_override["atr_tp"] = atr_tp_list + if cooldown_list: + grid_override["cooldown"] = cooldown_list + if htf_factor_list: + grid_override["htf_factor"] = htf_factor_list + if size_mode_list: + grid_override["size_tier_mode"] = size_mode_list + if boll_window_list: + grid_override["boll_window"] = boll_window_list + if boll_enter_list: + grid_override["boll_enter_z"] = boll_enter_list + if boll_exit_list: + grid_override["boll_exit_z"] = boll_exit_list + if boll_allow_short_list: + grid_override["boll_allow_short"] = boll_allow_short_list + + out_csv = run_grid(base_cfg, suffix, grid_override) + print(f"结果已写入: {out_csv}") diff --git a/Q Research/scripts/ingest_oanda.py b/Q Research/scripts/ingest_oanda.py new file mode 100644 index 0000000..39cae3c --- /dev/null +++ b/Q Research/scripts/ingest_oanda.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +""" +Automated OANDA ingestion with retries, logging, and manifest refresh. + +- Accepts direct CLI arguments (symbol/granularity/days/target count/output). +- Or accepts a schedule YAML describing multiple jobs: + - symbol: EUR_USD + - granularity: H1 + - days: 365 + - target_count: 8000 + - output: data/raw/EURUSD_H1.csv (optional; defaults to auto path) + +Each ingestion: + * retries on failure with exponential backoff + * appends a JSON line to metrics/ingest.log + * updates metrics/ingest_status.json with latest state per (symbol, granularity) + * triggers build_dataset_manifest.py to refresh hashes. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Optional + +import yaml +from loguru import logger + +PROJECT_ROOT = Path(__file__).resolve().parents[1] +sys.path.append(str(PROJECT_ROOT)) + +from scripts.get_candles import get_candles, default_out_csv, normalize_to_oanda # noqa + +METRICS_DIR = PROJECT_ROOT / "metrics" +METRICS_DIR.mkdir(exist_ok=True) +INGEST_LOG = METRICS_DIR / "ingest.log" +INGEST_STATUS = METRICS_DIR / "ingest_status.json" +INGEST_METRICS = METRICS_DIR / "ingest_metrics.csv" + +MANIFEST_SCRIPT = PROJECT_ROOT / "scripts" / "build_dataset_manifest.py" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Ingest OANDA candles into data/raw.") + parser.add_argument("--symbol", help="Instrument symbol (e.g., EUR_USD)") + parser.add_argument("--granularity", default="H1", help="OANDA granularity (default H1)") + parser.add_argument("--days", type=int, default=365, help="Lookback days for ingestion") + parser.add_argument("--target-count", type=int, default=None, help="Optional target bar count") + parser.add_argument("--output", help="Explicit CSV output path") + parser.add_argument("--retries", type=int, default=3, help="Number of retries on failure") + parser.add_argument("--backoff", type=float, default=15.0, help="Base backoff seconds between retries") + parser.add_argument("--schedule", help="YAML file listing ingestion jobs") + parser.add_argument("--manifest-output", default="data/_manifest.json", help="Manifest path to refresh") + return parser.parse_args() + + +def load_schedule(path: str) -> List[Dict]: + schedule_path = PROJECT_ROOT / path + if not schedule_path.exists(): + raise FileNotFoundError(f"Schedule file not found: {schedule_path}") + with schedule_path.open("r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or [] + if not isinstance(data, list): + raise ValueError("Schedule YAML must be a list of jobs.") + return data + + +def resolve_output(symbol: str, granularity: str, explicit: Optional[str]) -> Path: + if explicit: + return (PROJECT_ROOT / explicit).resolve() + norm = normalize_to_oanda(symbol) + return Path(default_out_csv(str(PROJECT_ROOT), norm, granularity)) + + +def log_ingest(entry: Dict) -> None: + line = json.dumps(entry, ensure_ascii=False) + with INGEST_LOG.open("a", encoding="utf-8") as fh: + fh.write(line + "\n") + + existing = {} + if INGEST_STATUS.exists(): + try: + existing = json.load(INGEST_STATUS.open("r", encoding="utf-8")) or {} + except Exception: + existing = {} + key = f"{entry['symbol']}::{entry['granularity']}" + existing[key] = entry + with INGEST_STATUS.open("w", encoding="utf-8") as fh: + json.dump(existing, fh, indent=2, ensure_ascii=False) + + append_ingest_metric(entry) + + +def append_ingest_metric(entry: Dict) -> None: + headers = ["timestamp", "symbol", "granularity", "status", "rows", "duration_sec"] + INGEST_METRICS.parent.mkdir(exist_ok=True) + write_header = not INGEST_METRICS.exists() + with INGEST_METRICS.open("a", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + if write_header: + writer.writerow(headers) + writer.writerow([ + entry.get("timestamp"), + entry.get("symbol"), + entry.get("granularity"), + entry.get("status"), + entry.get("rows"), + entry.get("duration_sec"), + ]) + + +def run_job(job: Dict, retries: int, backoff: float) -> bool: + symbol = job["symbol"] + granularity = job.get("granularity", "H1") + days = int(job.get("days", 365)) + target_count = job.get("target_count") + target_count = int(target_count) if target_count else None + output_path = resolve_output(symbol, granularity, job.get("output")) + output_path.parent.mkdir(parents=True, exist_ok=True) + + attempt = 0 + start_time = time.time() + errors = [] + while attempt <= retries: + attempt += 1 + try: + logger.info(f"[INGEST] {symbol} {granularity} attempt {attempt}/{retries+1} (days={days})") + df = get_candles(symbol=symbol, granularity=granularity, start_days_ago=days, target_count=target_count) + if df.empty: + raise RuntimeError("Fetched DataFrame is empty.") + df.to_csv(output_path, index=False) + duration = time.time() - start_time + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "symbol": symbol, + "granularity": granularity, + "rows": int(len(df)), + "output": str(output_path.relative_to(PROJECT_ROOT)), + "status": "success", + "duration_sec": round(duration, 2), + } + log_ingest(entry) + logger.info(f"[INGEST] Success {symbol} {granularity}: {len(df)} rows -> {output_path}") + return True + except Exception as exc: + err_msg = f"{type(exc).__name__}: {exc}" + errors.append(err_msg) + logger.error(f"[INGEST] Failed {symbol} {granularity} attempt {attempt}: {err_msg}") + if attempt <= retries: + sleep_time = backoff * attempt + logger.info(f"[INGEST] Sleeping {sleep_time:.1f}s before retry...") + time.sleep(sleep_time) + + duration = time.time() - start_time + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "symbol": symbol, + "granularity": granularity, + "rows": None, + "output": str(output_path.relative_to(PROJECT_ROOT)), + "status": "failure", + "duration_sec": round(duration, 2), + "errors": errors[-retries:], + } + log_ingest(entry) + return False + + +def refresh_manifest(manifest_output: str) -> None: + logger.info("[MANIFEST] Refreshing dataset manifest…") + cmd = [ + sys.executable, + str(MANIFEST_SCRIPT), + "--dirs", + "data/raw", + "data/derived", + "--output", + manifest_output, + ] + subprocess.run(cmd, cwd=PROJECT_ROOT, check=True) + + +def main() -> None: + args = parse_args() + jobs: List[Dict] + if args.schedule: + schedule_jobs = load_schedule(args.schedule) + jobs = schedule_jobs + elif args.symbol: + jobs = [{ + "symbol": args.symbol, + "granularity": args.granularity, + "days": args.days, + "target_count": args.target_count, + "output": args.output, + }] + else: + raise ValueError("Either --symbol or --schedule must be provided.") + + successes = 0 + for job in jobs: + if run_job(job, args.retries, args.backoff): + successes += 1 + + if successes > 0: + refresh_manifest(args.manifest_output) + else: + logger.warning("No successful ingestions; manifest not updated.") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/notify_risk_metrics.sh b/Q Research/scripts/notify_risk_metrics.sh new file mode 100644 index 0000000..6201ed4 --- /dev/null +++ b/Q Research/scripts/notify_risk_metrics.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash +set -euo pipefail +if [ -z "${SLACK_RISK_WEBHOOK:-}" ]; then + echo "SLACK_RISK_WEBHOOK not set" >&2 + exit 1 +fi +cd "$(dirname "$0")/.." +set +e +output=$(python scripts/watch_ops_metrics.py --csv results/risk/metrics.csv 2>&1) +status=$? +set -e +export OUTPUT="$output" +if [ $status -ne 0 ]; then + payload=$(python - <<'PY' +import json, os +msg = os.environ['OUTPUT'] +print(json.dumps({"text": f"[Risk Alert] {msg}"})) +PY +) + curl -s -X POST -H 'Content-type: application/json' --data "$payload" "$SLACK_RISK_WEBHOOK" + echo "$output" + exit 1 +else + echo "$output" +fi diff --git a/Q Research/scripts/patch_missing_summaries.py b/Q Research/scripts/patch_missing_summaries.py new file mode 100644 index 0000000..18dbf1e --- /dev/null +++ b/Q Research/scripts/patch_missing_summaries.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python3 +""" +Generate minimal summary.json and placeholder trades for runs that lack metadata. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import yaml +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List + +ROOT = Path(__file__).resolve().parents[1] +RESULTS_DIR = ROOT / "results" +TRADES_DIR = ROOT / "data" / "outputs" / "trades" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Patch runs missing summary.json artifacts.") + parser.add_argument("--runs", required=True, help="Comma-separated run IDs (e.g. 20251106_142210,20251106_142422)") + parser.add_argument("--symbol", default="EURUSD", help="Fallback symbol when unknown.") + parser.add_argument("--csv-path", default=None, help="Optional CSV path override (defaults to data/raw/_H1.csv).") + return parser.parse_args() + + +def ensure_placeholder_trades(run_id: str, symbol: str) -> str: + TRADES_DIR.mkdir(parents=True, exist_ok=True) + path = TRADES_DIR / f"trades_PLACEHOLDER_{run_id}.csv" + if path.exists(): + return str(path.as_posix()) + timestamp = datetime.strptime(run_id, "%Y%m%d_%H%M%S").replace(tzinfo=timezone.utc) + row = [ + timestamp.isoformat(), + timestamp.isoformat(), + symbol, + "long", + "0", + "1.0", + "1.0", + "0.0", + "default", + ] + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.writer(fh) + writer.writerow(["ts_entry", "ts_exit", "symbol", "direction", "qty", "price_entry", "exit", "pnl", "strategy"]) + writer.writerow(row) + return str(path.as_posix()) + + +def load_performance(run_dir: Path) -> Dict[str, float]: + perf_path = run_dir / "performance.yml" + if not perf_path.exists(): + return {} + data = yaml.safe_load(perf_path.read_text(encoding="utf-8")) or {} + mapping = { + "annualized_return": "ann_return", + "volatility": "ann_vol", + "sharpe_ratio": "sharpe", + "max_drawdown": "max_drawdown", + "total_return": "total_return", + } + metrics: Dict[str, float] = {} + for src, dest in mapping.items(): + value = data.get(src) + if value is not None: + metrics[dest] = value + return metrics + + +def write_summary(run_id: str, symbol: str, csv_path: str) -> None: + run_dir = RESULTS_DIR / run_id + run_dir.mkdir(parents=True, exist_ok=True) + summary_path = run_dir / "summary.json" + metrics_path = run_dir / "metrics.json" + equity_path = run_dir / "equity_curve.csv" + trades_path = ensure_placeholder_trades(run_id, symbol) + + perf_metrics = load_performance(run_dir) + timestamp = datetime.strptime(run_id, "%Y%m%d_%H%M%S").replace(tzinfo=timezone.utc).isoformat() + + summary = { + "run_id": run_id, + "timestamp": timestamp, + "symbol": symbol, + "csv_path": csv_path, + "parameters": {"note": "Placeholder summary generated via patch_missing_summaries.py"}, + "metrics": perf_metrics, + "data_report": {"severity": "unknown", "messages": []}, + "artifacts": { + "equity": str(equity_path.relative_to(ROOT)) if equity_path.exists() else "", + "trades": trades_path, + "trade_stats": "", + }, + } + + summary_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + metrics_path.write_text(json.dumps(perf_metrics, indent=2), encoding="utf-8") + print(f"Patched summary for {run_id}") + + +def main() -> None: + args = parse_args() + runs: List[str] = [item.strip() for item in args.runs.split(",") if item.strip()] + if not runs: + raise SystemExit("No runs provided.") + csv_path = args.csv_path or f"data/raw/{args.symbol}_H1.csv" + for run_id in runs: + summary_file = RESULTS_DIR / run_id / "summary.json" + if summary_file.exists(): + print(f"{run_id}: summary already exists, skipping.") + continue + write_summary(run_id, args.symbol, csv_path) + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/plot_backtest_diagnostics.py b/Q Research/scripts/plot_backtest_diagnostics.py new file mode 100644 index 0000000..4096c6d --- /dev/null +++ b/Q Research/scripts/plot_backtest_diagnostics.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Generate Phase 2 diagnostics charts (batch heatmap, Monte Carlo box, walk-forward timeline, equity plots).""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Dict, Optional, Tuple + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +import pandas as pd +from loguru import logger + +DEFAULT_OUT = Path("charts") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plot diagnostics from batch/Monte Carlo/walk-forward outputs.") + parser.add_argument("--batch-csv", help="Path to batch_backtests_*.csv") + parser.add_argument("--walkforward-csv", help="Path to walkforward/metrics.csv") + parser.add_argument("--mc-summary", help="Path to stress/mc_summary.json") + parser.add_argument("--mc-iterations", help="Path to stress/mc_iterations.csv") + parser.add_argument("--equity-csv", help="Path to equity curve CSV (ts,equity)") + parser.add_argument("--underwater-csv", help="Path to underwater/drawdown CSV (ts,drawdown)") + parser.add_argument("--out", default=str(DEFAULT_OUT), help="Output directory (default charts/)") + parser.add_argument("--x-col", default="fast_win", help="Batch heatmap X-axis column") + parser.add_argument("--y-col", default="slow_win", help="Batch heatmap Y-axis column") + parser.add_argument("--metric", default="sharpe", help="Batch heatmap metric column") + parser.add_argument( + "--facet-scenario", + action="store_true", + help="If set, creates one heatmap per scenario column when available.", + ) + parser.add_argument("--format", default="png", choices=["png", "pdf"], help="Image format") + return parser.parse_args() + + +def ensure_dir(path: Path) -> Path: + path.mkdir(parents=True, exist_ok=True) + return path + + +def plot_heatmap( + batch_csv: Path, + out_dir: Path, + x_col: str, + y_col: str, + metric: str, + fmt: str, + facet: bool, +) -> Tuple[Dict[str, Optional[str]], Dict[str, Dict]]: + outputs: Dict[str, Optional[str]] = {} + data: Dict[str, Dict] = {} + if not batch_csv: + return outputs, data + df = pd.read_csv(batch_csv) + if not {x_col, y_col, metric}.issubset(df.columns): + logger.warning("Batch CSV missing columns required for heatmap (%s, %s, %s)", x_col, y_col, metric) + return outputs, data + + def _render(sub_df: pd.DataFrame, suffix: str) -> Optional[Path]: + pivot = sub_df.pivot_table(index=y_col, columns=x_col, values=metric, aggfunc="mean") + if pivot.empty: + return None + fig, ax = plt.subplots(figsize=(6, 4)) + c = ax.imshow(pivot.values, origin="lower", aspect="auto", cmap="viridis") + ax.set_xticks(np.arange(len(pivot.columns))) + ax.set_yticks(np.arange(len(pivot.index))) + ax.set_xticklabels(pivot.columns) + ax.set_yticklabels(pivot.index) + ax.set_xlabel(x_col) + ax.set_ylabel(y_col) + ax.set_title(f"{metric.title()} heatmap ({suffix})") + fig.colorbar(c, ax=ax, label=metric) + for i in range(len(pivot.index)): + for j in range(len(pivot.columns)): + value = pivot.values[i, j] + if not np.isnan(value): + ax.text(j, i, f"{value:.2f}", ha="center", va="center", color="white", fontsize=8) + filename = f"heatmap_{metric}_{suffix}.{fmt}" if suffix else f"heatmap_{metric}.{fmt}" + out_path = out_dir / filename + fig.tight_layout() + fig.savefig(out_path) + plt.close(fig) + data[suffix or "all"] = pivot.to_dict() + return out_path + + if facet and "scenario" in df.columns: + for scenario, sub in df.groupby("scenario"): + outputs[scenario] = str(_render(sub, scenario) or "") + else: + outputs["all"] = str(_render(df, "all") or "") + return outputs, data + + +def plot_mc_box(itr_csv: Path, summary_json: Path, out_dir: Path, fmt: str) -> Tuple[Optional[str], Dict[str, float]]: + if not itr_csv or not summary_json: + return None, {} + if not itr_csv.exists() or not summary_json.exists(): + logger.warning("Monte Carlo files missing: %s or %s", itr_csv, summary_json) + return None, {} + df = pd.read_csv(itr_csv) + if "sharpe" not in df.columns: + logger.warning("Monte Carlo iterations missing 'sharpe' column; skipping box plot") + return None, {} + series = df["sharpe"].dropna() + summary = json.loads(summary_json.read_text(encoding="utf-8")) + scenario = summary.get("scenario", "unknown") + fig, ax = plt.subplots(figsize=(5, 4)) + ax.boxplot(series, labels=[scenario]) + ax.set_ylabel("Sharpe") + ax.set_title("Monte Carlo Sharpe distribution") + out_path = out_dir / f"monte_carlo_box.{fmt}" + fig.tight_layout() + fig.savefig(out_path) + plt.close(fig) + logger.info("Saved Monte Carlo box plot to %s", out_path) + stats = { + "mean": float(series.mean()) if not series.empty else None, + "p05": float(series.quantile(0.05)) if not series.empty else None, + "p50": float(series.quantile(0.5)) if not series.empty else None, + "p95": float(series.quantile(0.95)) if not series.empty else None, + "count": int(series.size), + } + return str(out_path), stats + + +def plot_walkforward(wf_csv: Path, out_dir: Path, fmt: str) -> Tuple[Optional[str], list]: + if not wf_csv: + return None, [] + if not wf_csv.exists(): + logger.warning("Walk-forward metrics file missing: %s", wf_csv) + return None, [] + df = pd.read_csv(wf_csv) + if "window" not in df.columns or "sharpe" not in df.columns: + logger.warning("Walk-forward metrics missing 'window' or 'sharpe'; skipping timeline") + return None, [] + status = df.get("status", pd.Series(["unknown"] * len(df))) + colors = status.map({"pass": "#2ca02c", "fail": "#d62728"}).fillna("#1f77b4") + fig, ax = plt.subplots(figsize=(6, 3)) + ax.scatter(df["window"], df["sharpe"], c=colors) + ax.plot(df["window"], df["sharpe"], color="#cccccc", linewidth=1, alpha=0.5) + ax.set_xlabel("Window") + ax.set_ylabel("Sharpe") + ax.set_title("Walk-forward Sharpe timeline") + out_path = out_dir / f"walkforward_timeline.{fmt}" + fig.tight_layout() + fig.savefig(out_path) + plt.close(fig) + logger.info("Saved walk-forward timeline to %s", out_path) + return str(out_path), df[["window", "sharpe", *([ "status"] if "status" in df.columns else [])]].to_dict(orient="records") + + +def _resolve_column(df: pd.DataFrame, candidates) -> Optional[str]: + for col in candidates: + if col in df.columns: + return col + return None + + +def plot_equity(equity_csv: Path, out_dir: Path, fmt: str) -> Optional[str]: + if not equity_csv: + return None + if not equity_csv.exists(): + logger.warning("Equity CSV missing: %s", equity_csv) + return None + df = pd.read_csv(equity_csv) + ts_col = _resolve_column(df, ["ts", "timestamp", "time", "datetime"]) + equity_col = _resolve_column(df, ["equity", "capital", "balance"]) + if not ts_col or not equity_col: + logger.warning( + "Equity CSV missing timestamp/equity columns (looked for %s / %s)", + ["ts", "timestamp", "time", "datetime"], + ["equity", "capital", "balance"], + ) + return None + df[ts_col] = pd.to_datetime(df[ts_col]) + fig, ax = plt.subplots(figsize=(6, 3)) + ax.plot(df[ts_col], df[equity_col], color="#1f77b4") + ax.set_xlabel("Time") + ax.set_ylabel("Equity") + ax.set_title("Equity Curve") + fig.autofmt_xdate() + out_path = out_dir / f"equity_curve.{fmt}" + fig.tight_layout() + fig.savefig(out_path) + plt.close(fig) + logger.info("Saved equity curve to %s", out_path) + return str(out_path) + + +def plot_underwater(underwater_csv: Path, out_dir: Path, fmt: str) -> Optional[str]: + if not underwater_csv: + return None + if not underwater_csv.exists(): + logger.warning("Underwater CSV missing: %s", underwater_csv) + return None + df = pd.read_csv(underwater_csv) + required = {"ts", "drawdown"} + if not required.issubset(df.columns): + logger.warning("Underwater CSV missing columns %s", required) + return None + df["ts"] = pd.to_datetime(df["ts"]) + fig, ax = plt.subplots(figsize=(6, 3)) + ax.fill_between(df["ts"], df["drawdown"], color="#d62728", alpha=0.6) + ax.set_xlabel("Time") + ax.set_ylabel("Drawdown") + ax.set_title("Underwater Curve") + fig.autofmt_xdate() + out_path = out_dir / f"underwater_curve.{fmt}" + fig.tight_layout() + fig.savefig(out_path) + plt.close(fig) + logger.info("Saved underwater curve to %s", out_path) + return str(out_path) + + +def main() -> None: + args = parse_args() + out_dir = ensure_dir(Path(args.out).expanduser()) / Path( + f"diagnostics_{pd.Timestamp.now().strftime('%Y%m%d_%H%M%S')}" + ) + ensure_dir(out_dir) + + artifacts: Dict[str, Optional[str]] = {} + data_dump: Dict[str, Dict] = {} + + if args.batch_csv: + path = Path(args.batch_csv).expanduser() + heatmap_paths, heatmap_data = plot_heatmap( + path, out_dir, args.x_col, args.y_col, args.metric, args.format, args.facet_scenario + ) + artifacts["batch_heatmap"] = heatmap_paths.get("all") + if args.facet_scenario and "scenario" in heatmap_paths: + artifacts["batch_heatmap_scenarios"] = heatmap_paths + data_dump["batch_heatmap"] = heatmap_data + if args.mc_iterations and args.mc_summary: + itr = Path(args.mc_iterations).expanduser() + summary = Path(args.mc_summary).expanduser() + box_path, mc_stats = plot_mc_box(itr, summary, out_dir, args.format) + artifacts["mc_boxplot"] = box_path + data_dump["monte_carlo"] = mc_stats + if args.walkforward_csv: + wf = Path(args.walkforward_csv).expanduser() + timeline_path, wf_records = plot_walkforward(wf, out_dir, args.format) + artifacts["walkforward_timeline"] = timeline_path + data_dump["walkforward"] = wf_records + if args.equity_csv: + equity_path = plot_equity(Path(args.equity_csv).expanduser(), out_dir, args.format) + artifacts["equity_curve"] = equity_path + if args.underwater_csv: + underwater_path = plot_underwater(Path(args.underwater_csv).expanduser(), out_dir, args.format) + artifacts["underwater_curve"] = underwater_path + + meta = { + "generated_at": pd.Timestamp.now(tz="UTC").isoformat(), + "inputs": { + "batch_csv": args.batch_csv, + "walkforward_csv": args.walkforward_csv, + "mc_summary": args.mc_summary, + "mc_iterations": args.mc_iterations, + "equity_csv": args.equity_csv, + "underwater_csv": args.underwater_csv, + }, + "artifacts": artifacts, + } + meta_path = out_dir / "diagnostics_metadata.json" + meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8") + logger.info("Diagnostics metadata written to %s", meta_path) + + data_path = out_dir / "diagnostics_data.json" + data_path.write_text(json.dumps(data_dump, indent=2, default=str), encoding="utf-8") + logger.info("Diagnostics data written to %s", data_path) + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/plot_candles.py b/Q Research/scripts/plot_candles.py new file mode 100644 index 0000000..4108914 --- /dev/null +++ b/Q Research/scripts/plot_candles.py @@ -0,0 +1,36 @@ +# scripts/plot_candles.py + +import os +import pandas as pd +import plotly.graph_objects as go + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +RAW_DATA_DIR = os.path.join(BASE_DIR, "data", "raw") + +def plot_csv_candles(file_path, symbol, granularity): + df = pd.read_csv(file_path, parse_dates=["time"]) + + fig = go.Figure(data=[ + go.Candlestick( + x=df["time"], + open=df["open"], + high=df["high"], + low=df["low"], + close=df["close"] + ) + ]) + fig.update_layout( + title=f"{symbol} {granularity} Candlestick Chart", + xaxis_title="Time", + yaxis_title="Price", + xaxis_rangeslider_visible=False + ) + + # 创建 charts 文件夹(如果不存在) + os.makedirs("charts", exist_ok=True) + output_file = f"charts/{symbol.replace('/', '')}_{granularity}.html" + fig.write_html(output_file) + print(f"✅ Saved chart to {output_file}") + +if __name__ == "__main__": + plot_csv_candles(os.path.join(RAW_DATA_DIR, "EURUSD_H1.csv"), "EUR/USD", "H1") diff --git a/Q Research/scripts/plot_indicators.py b/Q Research/scripts/plot_indicators.py new file mode 100644 index 0000000..9554cc7 --- /dev/null +++ b/Q Research/scripts/plot_indicators.py @@ -0,0 +1,138 @@ +# scripts/plot_indicators.py +import os +from datetime import datetime + +import numpy as np +import pandas as pd +import plotly.graph_objects as go +from plotly.subplots import make_subplots + +BASE_DIR = os.path.dirname(os.path.dirname(__file__)) +DERIVED_DATA_DIR = os.path.join(BASE_DIR, "data", "derived") +CHARTS_DIR = os.path.join(BASE_DIR, "charts") +os.makedirs(CHARTS_DIR, exist_ok=True) + +DATA_PATH = os.path.join(DERIVED_DATA_DIR, "EURUSD_H1_with_indicators.csv") + +# 自动生成带时间戳的输出文件名 +timestamp = datetime.now().strftime("%Y%m%d_%H%M") +OUTPUT_PATH = os.path.join(CHARTS_DIR, f"EURUSD_H1_with_indicators_{timestamp}.html") + +df = pd.read_csv(DATA_PATH, parse_dates=["time"]) + +# 计算均线(若已存在将覆盖为最新计算) +if {"close"}.issubset(df.columns): + df["SMA_20"] = df["close"].rolling(20).mean() + df["SMA_200"] = df["close"].rolling(200).mean() + +# 创建两行子图,共享x轴 +fig = make_subplots(rows=2, cols=1, shared_xaxes=True, + vertical_spacing=0.1, + row_heights=[0.7, 0.3], + specs=[[{"type": "candlestick"}], + [{"secondary_y": True}]]) + +# 第一个子图:蜡烛图 + 均线 +fig.add_trace(go.Candlestick( + x=df['time'], + open=df['open'], + high=df['high'], + low=df['low'], + close=df['close'], + name='Candlestick' +), row=1, col=1) + +if 'SMA_20' in df.columns: + fig.add_trace(go.Scatter( + x=df['time'], y=df['SMA_20'], + line=dict(color='blue', width=1), + name='SMA 20' + ), row=1, col=1) + +if 'EMA_50' in df.columns: + fig.add_trace(go.Scatter( + x=df['time'], y=df['EMA_50'], + line=dict(color='orange', width=1), + name='EMA 50' + ), row=1, col=1) + +# 绘制 SMA 200 +if 'SMA_200' in df.columns: + fig.add_trace(go.Scatter( + x=df['time'], y=df['SMA_200'], + line=dict(width=1), + name='SMA 200' + ), row=1, col=1) + +# 金叉/死叉标记:SMA20 与 SMA200 交叉 +if 'SMA_20' in df.columns and 'SMA_200' in df.columns: + sign = np.sign(df["SMA_20"] - df["SMA_200"]) + cross = sign.diff().fillna(0).ne(0) & df["SMA_20"].notna() & df["SMA_200"].notna() + golden = cross & (sign > 0) + dead = cross & (sign < 0) + + # 金叉 + fig.add_trace(go.Scatter( + x=df.loc[golden, "time"], y=df.loc[golden, "SMA_20"], + mode="markers", name="Golden Cross", + marker_symbol="triangle-up", marker_size=9 + ), row=1, col=1) + + # 死叉 + fig.add_trace(go.Scatter( + x=df.loc[dead, "time"], y=df.loc[dead, "SMA_20"], + mode="markers", name="Dead Cross", + marker_symbol="triangle-down", marker_size=9 + ), row=1, col=1) + +# 第二个子图:RSI 和 MACD +rsi_exists = 'RSI' in df.columns +macd_exists = 'MACD' in df.columns and 'MACD_signal' in df.columns and 'MACD_hist' in df.columns + +if rsi_exists: + fig.add_trace(go.Scatter( + x=df['time'], y=df['RSI'], + line=dict(color='purple', width=1), + name='RSI' + ), row=2, col=1, secondary_y=False) + +if macd_exists: + # MACD 柱状图 + fig.add_trace(go.Bar( + x=df['time'], y=df['MACD_hist'], + marker_color='grey', + name='MACD Hist' + ), row=2, col=1, secondary_y=True) + # MACD 线 + fig.add_trace(go.Scatter( + x=df['time'], y=df['MACD'], + line=dict(color='blue', width=1), + name='MACD' + ), row=2, col=1, secondary_y=True) + # MACD 信号线 + fig.add_trace(go.Scatter( + x=df['time'], y=df['MACD_signal'], + line=dict(color='orange', width=1, dash='dot'), + name='MACD Signal' + ), row=2, col=1, secondary_y=True) + +# 布局设置 +fig.update_layout( + title="EUR/USD with Technical Indicators", + xaxis_title="Time", + yaxis_title="Price", + xaxis_rangeslider_visible=False, + legend=dict(orientation="h", yanchor="bottom", y=1.02, xanchor="right", x=1) +) + +# RSI y轴范围限制 +if rsi_exists: + fig.update_yaxes(title_text="RSI", row=2, col=1, secondary_y=False, range=[0, 100]) + +# MACD y轴标题 +if macd_exists: + fig.update_yaxes(title_text="MACD", row=2, col=1, secondary_y=True) + +# 保存图表 +fig.write_html(OUTPUT_PATH) +print(f"✅ 图表已保存至 {OUTPUT_PATH}") diff --git a/Q Research/scripts/plot_risk_metrics.py b/Q Research/scripts/plot_risk_metrics.py new file mode 100644 index 0000000..ef60c0d --- /dev/null +++ b/Q Research/scripts/plot_risk_metrics.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Quick visualization/report for results/risk/metrics.csv. + +Usage: + python scripts/plot_risk_metrics.py --csv results/risk/metrics.csv --out charts/risk_metrics.png +""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +import matplotlib.pyplot as plt +import pandas as pd + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Plot risk metrics history (reject counts, status).") + parser.add_argument("--csv", default="results/risk/metrics.csv", help="Path to metrics CSV.") + parser.add_argument("--out", default="charts/risk_metrics.png", help="Output image path.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + csv_path = Path(args.csv) + if not csv_path.exists(): + raise SystemExit(f"metrics CSV not found: {csv_path}") + df = pd.read_csv(csv_path) + if df.empty: + raise SystemExit("metrics CSV is empty.") + df["timestamp"] = pd.to_datetime(df["timestamp"]) + df.sort_values("timestamp", inplace=True) + df["status"] = df["status"].fillna("unknown").str.lower() + + fig, ax1 = plt.subplots(figsize=(10, 4)) + ax1.plot(df["timestamp"], df["rejects"], marker="o", label="Rejects") + ax1.set_ylabel("Reject count") + ax1.set_xlabel("Timestamp") + ax1.set_title("Risk simulation rejects over time") + + fail_mask = df["status"] == "fail" + ax1.scatter(df.loc[fail_mask, "timestamp"], df.loc[fail_mask, "rejects"], color="red", label="Fail", zorder=5) + ax1.legend(loc="upper left") + + ax2 = ax1.twinx() + status_numeric = df["status"].map({"pass": 1, "fail": 0}).fillna(0.5) + ax2.plot(df["timestamp"], status_numeric, color="gray", alpha=0.3, label="Status (1=pass,0=fail)") + ax2.set_ylim(-0.1, 1.1) + ax2.set_yticks([0, 0.5, 1]) + ax2.set_yticklabels(["fail", "unknown", "pass"]) + + fig.tight_layout() + out_path = Path(args.out) + out_path.parent.mkdir(parents=True, exist_ok=True) + fig.savefig(out_path) + plt.close(fig) + print(f"Saved risk metrics plot to {out_path}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/risk_report.py b/Q Research/scripts/risk_report.py new file mode 100644 index 0000000..f30f4fd --- /dev/null +++ b/Q Research/scripts/risk_report.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Summarize risk events logged by simulate_execution or live adapters.""" + +from __future__ import annotations + +import argparse +import json +from collections import Counter +from datetime import datetime, timezone +import os +from pathlib import Path + +import pandas as pd + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Aggregate risk event logs") + parser.add_argument("--log", default="results/risk/events.jsonl", help="Path to risk events JSONL") + parser.add_argument("--out", default="results/risk/report.csv", help="CSV summary output") + parser.add_argument("--run-id", help="Run identifier for metrics logging (defaults to $RUN)") + parser.add_argument("--metrics-path", default="results/risk/metrics.csv", help="Aggregate metrics CSV") + parser.add_argument( + "--status", + default="unknown", + help="Execution status recorded in metrics (e.g., pending/pass/fail).", + ) + parser.add_argument("--skip-report", action="store_true", help="Do not write the detailed CSV report.") + parser.add_argument("--skip-metrics", action="store_true", help="Do not append to metrics CSV.") + return parser.parse_args() + + +def load_events(path: Path) -> list[dict]: + if not path.exists(): + return [] + events = [] + with path.open("r", encoding="utf-8") as fh: + for line in fh: + try: + events.append(json.loads(line)) + except json.JSONDecodeError: + continue + return events + + +def main() -> None: + args = parse_args() + path = Path(args.log) + events = load_events(path) + if events: + df = pd.DataFrame(events) + counts = Counter(df["event"].fillna("unknown")) + print("Event counts:") + for event, count in counts.items(): + print(f" {event}: {count}") + else: + print(f"No events found in {path}") + df = pd.DataFrame(columns=["event", "ts", "symbol", "strategy", "reason"]) + counts = Counter() + if not args.skip_report: + df.to_csv(args.out, index=False) + if events: + print(f"Detailed report saved to {args.out}") + else: + print(f"Empty report written to {args.out}") + + run_id = args.run_id or os.environ.get("RUN") + if not run_id: + print("Run ID not provided; skipping metrics append.") + return + if args.skip_metrics: + print("skip-metrics enabled; metrics append skipped.") + return + reject_count = counts.get("reject", 0) + kill_count = counts.get("kill_switch", 0) + exec_dir = Path(f"results/execution/{run_id}") + append_metrics(Path(args.metrics_path), run_id, reject_count, kill_count, status=args.status, exec_dir=exec_dir) + + +def append_metrics(path: Path, run_id: str, rejects: int, kills: int, status: str = "unknown", exec_dir: Path | None = None) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + is_new = not path.exists() + timestamp = datetime.now(timezone.utc).isoformat() + latency_avg = latency_p95 = total_pnl = max_drawdown = 0.0 + max_gross = max_symbol = 0.0 + if exec_dir and (exec_dir / "fills.csv").exists(): + df = pd.read_csv(exec_dir / "fills.csv") + if "adapter_latency_ms" in df.columns and not df["adapter_latency_ms"].dropna().empty: + series = df["adapter_latency_ms"].fillna(0.0) + latency_avg = float(series.mean()) + latency_p95 = float(series.quantile(0.95)) + if "pnl" in df.columns: + total_pnl = float(df["pnl"].sum()) + if exec_dir and (exec_dir / "sim_results.json").exists(): + data = json.loads((exec_dir / "sim_results.json").read_text(encoding="utf-8")) + max_gross = float(data.get("max_gross_notional", 0.0)) + symbol_peaks = data.get("max_symbol_exposure", {}) + if isinstance(symbol_peaks, dict) and symbol_peaks: + max_symbol = float(max(abs(v) for v in symbol_peaks.values())) + max_drawdown = float(data.get("max_drawdown_pct", 0.0)) + with path.open("a", encoding="utf-8") as fh: + if is_new: + fh.write("timestamp,run_id,rejects,kills,status,latency_ms_avg,latency_ms_p95,total_pnl,max_gross_notional,max_symbol_exposure,max_drawdown_pct\n") + fh.write(f"{timestamp},{run_id},{rejects},{kills},{status},{latency_avg},{latency_p95},{total_pnl},{max_gross},{max_symbol},{max_drawdown}\n") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/run_batch_backtests.py b/Q Research/scripts/run_batch_backtests.py new file mode 100644 index 0000000..4b49ac6 --- /dev/null +++ b/Q Research/scripts/run_batch_backtests.py @@ -0,0 +1,268 @@ +from __future__ import annotations + +"""Batch backtests for multiple configs/symbols.""" + +import argparse +import json +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence + +import pandas as pd +import yaml +from loguru import logger + +sys.path.append(str(Path(__file__).resolve().parents[1])) +from core.backtest.strategy_engine import parse_strategy_specs # type: ignore +from scripts.backtest_strategy import run_once # type: ignore +from scripts.scenario_utils import load_scenarios + + +KEY_MAP = { + "csv": "csv_path", + "cash": "initial_cash", + "qty": "qty", + "account_ccy": "account_ccy", + "fast": "fast_win", + "slow": "slow_win", + "spread": "spread_pips", + "slip": "slippage_pips", + "comm": "commission_per_million", + "sl": "stop_loss_pips", + "tp": "take_profit_pips", + "atr_sl": "atr_sl", + "atr_tp": "atr_tp", + "atr_window": "atr_window", + "rsi_period": "rsi_period", + "rsi_long_thresh": "rsi_long_thresh", + "rsi_short_thresh": "rsi_short_thresh", + "enable_trailing": "enable_trailing", + "trailing_enable_atr_mult": "trailing_enable_atr_mult", + "trailing_atr_mult": "trailing_atr_mult", + "long_only_above_slow": "long_only_above_slow", + "slope_lookback": "slope_lookback", + "cooldown": "cooldown", + "allow_short": "allow_short", + "short_only_below_slow": "short_only_below_slow", + "risk_per_trade_pct": "risk_per_trade_pct", + "max_drawdown_pct": "max_drawdown_pct", + "max_position_units": "max_position_units", + "regime_ema_window": "regime_ema_window", + "regime_slope_min": "regime_slope_min", + "regime_atr_min": "regime_atr_min", + "strategies": "strategies", + "htf_factor": "htf_factor", + "htf_ema_window": "htf_ema_window", + "htf_rsi_period": "htf_rsi_period", + "cost_profiles": "cost_profiles", + "slippage_model": "slippage_model", + "strategy_mode": "strategy_mode", + "strategy_vote_threshold": "strategy_vote_threshold", + "stress_cost_spread_mult": "stress_cost_spread_mult", + "stress_cost_comm_mult": "stress_cost_comm_mult", + "stress_slippage_mult": "stress_slippage_mult", + "stress_price_vol_mult": "stress_price_vol_mult", + "stress_skip_trade_pct": "stress_skip_trade_pct", +} + +SCENARIO_FIELDS = [ + "stress_cost_spread_mult", + "stress_cost_comm_mult", + "stress_slippage_mult", + "stress_price_vol_mult", + "stress_skip_trade_pct", +] + +PARAM_COLUMNS = [ + "fast_win", + "slow_win", + "atr_sl", + "atr_tp", + "atr_window", + "cooldown", + "allow_short", + "long_only_above_slow", + "short_only_below_slow", + "slope_lookback", + "strategy_vote_threshold", + "spread_pips", + "slippage_pips", + "commission_per_million", + "risk_per_trade_pct", +] + + +def normalize_params(raw: Dict[str, Any]) -> Dict[str, Any]: + params: Dict[str, Any] = {} + for k, v in (raw or {}).items(): + key = KEY_MAP.get(k, k) + params[key] = v + return params + + +def load_params(cfg_path: Path) -> Dict[str, Any]: + with cfg_path.open("r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + params = normalize_params(raw) + params["symbol"] = params.get("symbol", cfg_path.stem.upper()) + params["config_name"] = cfg_path.name + return params + + +def run_job( + params: Dict[str, Any], + scenario_name: Optional[str], + scenario_cfg: Optional[Dict[str, Any]], + extra_cols: Optional[Sequence[str]] = None, +) -> Dict[str, Any]: + label = params.get("label") + config_name = params.get("config_name") + job_params = {k: v for k, v in params.items() if k not in ("label", "config_name")} + if isinstance(job_params.get("strategies"), (list, dict)): + job_params["strategies"] = parse_strategy_specs(job_params["strategies"]) + symbol = job_params.get("symbol", "UNKNOWN").upper() + logger.info(f"Running {symbol} ({label or config_name})") + result = run_once(**job_params) + data_validation = result.get("data_validation") or {} + summary = { + "symbol": symbol, + "label": label, + "config_name": config_name, + "sharpe": result.get("sharpe"), + "ann_return": result.get("ann_return"), + "ann_vol": result.get("ann_vol"), + "max_drawdown": result.get("max_drawdown"), + "trades": result.get("trades"), + "final_equity": result.get("final_equity"), + "sortino": result.get("sortino"), + "calmar": result.get("calmar"), + "run_id": result.get("run_id"), + "summary_path": result.get("summary_path"), + "data_severity": data_validation.get("severity"), + "strategy_mode": job_params.get("strategy_mode"), + "stress_cost_spread_mult": job_params.get("stress_cost_spread_mult"), + "stress_cost_comm_mult": job_params.get("stress_cost_comm_mult"), + "stress_slippage_mult": job_params.get("stress_slippage_mult"), + "stress_price_vol_mult": job_params.get("stress_price_vol_mult"), + "stress_skip_trade_pct": job_params.get("stress_skip_trade_pct"), + "scenario": scenario_name, + "scenario_overrides": scenario_cfg, + } + for col in PARAM_COLUMNS: + value = job_params.get(col) + if isinstance(value, (int, float, bool, str)) or value is None: + summary[col] = value + if extra_cols: + for col in extra_cols: + if col in summary: + continue + if col in job_params: + summary[col] = job_params.get(col) + elif col in result: + summary[col] = result.get(col) + return summary + + +def load_jobs(symbols: List[str], config_dir: Path, schedule_path: Optional[str]) -> List[Dict[str, Any]]: + jobs: List[Dict[str, Any]] = [] + if schedule_path: + schedule_file = Path(schedule_path).expanduser() + data = yaml.safe_load(schedule_file.open("r", encoding="utf-8")) or [] + if not isinstance(data, list): + raise ValueError("Schedule YAML must be a list of job definitions") + for idx, raw in enumerate(data): + job = normalize_params(dict(raw or {})) + job["label"] = job.get("label") or f"job_{idx}" + if "symbol" not in job: + raise ValueError(f"Job {job['label']} missing 'symbol'") + jobs.append(job) + else: + for sym in symbols: + cfg_path = config_dir / f"{sym.lower()}_regime.yaml" + if not cfg_path.exists(): + logger.error(f"Config not found for {sym}: {cfg_path}") + continue + jobs.append(load_params(cfg_path)) + return jobs + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--symbols", type=str, default="EURUSD", help="Comma separated symbols (ignored if --schedule provided)") + parser.add_argument("--config-dir", type=str, default="config", help="Config directory") + parser.add_argument("--out", type=str, default="data/results", help="Output dir for summaries") + parser.add_argument("--schedule", type=str, help="YAML schedule of jobs") + parser.add_argument("--scenario", type=str, default=None, help="Default stress scenario for all jobs") + parser.add_argument( + "--scenario-file", + type=str, + default="config/stress_scenarios.yaml", + help="Scenario definition file (default: config/stress_scenarios.yaml)", + ) + parser.add_argument( + "--extra-cols", + type=str, + default="", + help="Comma-separated list of additional columns to include in the output (copied from params/result).", + ) + args = parser.parse_args() + + config_dir = Path(args.config_dir) + symbols = [s.strip().upper() for s in args.symbols.split(",")] + jobs = load_jobs(symbols, config_dir, args.schedule) + if not jobs: + logger.error("No jobs to run") + sys.exit(1) + + scenario_path = Path(args.scenario_file).expanduser() + scenario_required = bool(args.scenario) or any(job.get("scenario") for job in jobs) + scenarios = load_scenarios(scenario_path) if scenario_required else {} + + extra_cols = [c.strip() for c in (args.extra_cols or "").split(",") if c.strip()] + rows = [] + for job in jobs: + scenario_name = job.get("scenario") or args.scenario + scenario_cfg = None + if scenario_name: + if scenario_name not in scenarios: + raise ValueError(f"Scenario '{scenario_name}' not found in {scenario_path}") + scenario_cfg = scenarios[scenario_name] + job_copy = dict(job) + job_params = {k: v for k, v in job_copy.items() if k not in ("scenario",)} + if scenario_cfg: + for field in SCENARIO_FIELDS: + if field in scenario_cfg and field not in job_params: + job_params[field] = scenario_cfg[field] + try: + rows.append(run_job(job_params, scenario_name, scenario_cfg, extra_cols=extra_cols)) + except Exception as exc: + label = job.get("label") or job.get("symbol") + logger.exception(f"Backtest failed for job {label}: {exc}") + + if not rows: + logger.error("All backtests failed") + sys.exit(1) + + df = pd.DataFrame(rows) + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + csv_path = out_dir / f"batch_backtests_{ts}.csv" + df.to_csv(csv_path, index=False) + json_path = out_dir / f"batch_backtests_{ts}.json" + json_path.write_text(df.to_json(orient="records", indent=2), encoding="utf-8") + stats = { + "timestamp": ts, + "job_count": len(rows), + "symbols": sorted(df["symbol"].unique()), + "sharpe_max": df["sharpe"].max(), + "sharpe_min": df["sharpe"].min(), + } + (out_dir / f"batch_backtests_{ts}_stats.json").write_text(json.dumps(stats, indent=2), encoding="utf-8") + print(df.to_string(index=False)) + print(f"Saved summary to {csv_path} / {json_path}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/run_ci_diagnostics.sh b/Q Research/scripts/run_ci_diagnostics.sh new file mode 100644 index 0000000..ce1f14c --- /dev/null +++ b/Q Research/scripts/run_ci_diagnostics.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +# Auto-select newest artifacts unless explicit paths supplied. +TARGET=${1:-latest} +if [ "$TARGET" = "latest" ]; then + BATCH=$(ls -t data/results/batch_backtests_*.csv 2>/dev/null | head -n1 || true) + WALKFORWARD=$(ls -t results/*/walkforward/metrics.csv 2>/dev/null | head -n1 || true) + MC_SUMMARY=$(ls -t results/*/stress/mc_summary.json 2>/dev/null | head -n1 || true) + MC_ITER=$(ls -t results/*/stress/mc_iterations.csv 2>/dev/null | head -n1 || true) + EQUITY=$(ls -t results/*/equity.csv 2>/dev/null | head -n1 || true) + UNDERWATER=$(ls -t results/*/stats/underwater.csv 2>/dev/null | head -n1 || true) +fi +BATCH=${BATCH:-tests/fixtures/batch_results_sample.csv} +WALKFORWARD=${WALKFORWARD:-tests/fixtures/walkforward_metrics_sample.csv} +MC_SUMMARY=${MC_SUMMARY:-tests/fixtures/mc_summary_sample.json} +MC_ITER=${MC_ITER:-tests/fixtures/mc_iterations_sample.csv} +EQUITY=${EQUITY:-tests/fixtures/equity_sample.csv} +UNDERWATER=${UNDERWATER:-tests/fixtures/underwater_sample.csv} + +OUT_DIR="charts/ci/diagnostics_$(date +%Y%m%d_%H%M%S)" + +python scripts/plot_backtest_diagnostics.py \ + --batch-csv "$BATCH" \ + --walkforward-csv "$WALKFORWARD" \ + --mc-summary "$MC_SUMMARY" \ + --mc-iterations "$MC_ITER" \ + --equity-csv "$EQUITY" \ + --underwater-csv "$UNDERWATER" \ + --facet-scenario \ + --out "$OUT_DIR" \ + --format png + +echo "Diagnostics artifacts saved to $OUT_DIR" diff --git a/Q Research/scripts/run_monte_carlo.py b/Q Research/scripts/run_monte_carlo.py new file mode 100644 index 0000000..2f1515b --- /dev/null +++ b/Q Research/scripts/run_monte_carlo.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +Monte Carlo / stress test for an existing backtest run. +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import List, Optional + +import numpy as np +import pandas as pd +from loguru import logger + +BASE_DIR = Path(__file__).resolve().parents[1] +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from metrics.perf import compute_metrics +from scripts.scenario_utils import get_scenario + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Monte Carlo stress on a backtest result.") + parser.add_argument("--run", help="Path to results/ directory.") + parser.add_argument("--equity", help="Explicit equity CSV (ts,equity). Overrides --run artifacts.") + parser.add_argument("--iterations", type=int, default=500, help="Number of bootstrap iterations.") + parser.add_argument("--method", choices=["bootstrap", "block"], default="bootstrap", help="Resampling method.") + parser.add_argument("--block-size", type=int, default=None, help="Block size for block bootstrap (overrides scenario).") + parser.add_argument("--return-scale", type=float, default=None, help="Scale factor applied to resampled returns (overrides scenario).") + parser.add_argument("--ruin-threshold", type=float, default=0.8, help="Final equity / initial equity threshold to count as ruin.") + parser.add_argument("--seed", type=int, default=None, help="Random seed.") + parser.add_argument("--scenario", default=None, help="Optional stress scenario label stored in outputs.") + parser.add_argument( + "--scenario-file", + default="config/stress_scenarios.yaml", + help="Scenario definitions file (default: config/stress_scenarios.yaml).", + ) + return parser.parse_args() + + +def load_equity_series(run_path: Path | None, explicit_csv: str | None) -> pd.Series: + if explicit_csv: + path = Path(explicit_csv).expanduser() + else: + if not run_path: + raise ValueError("Either --run or --equity must be provided.") + summary_file = run_path / "summary.json" + if not summary_file.exists(): + raise FileNotFoundError(f"summary.json not found in {run_path}") + summary = json.load(summary_file.open("r", encoding="utf-8")) + artifacts = summary.get("artifacts") or {} + equity_path = artifacts.get("equity") + if not equity_path: + raise FileNotFoundError("Equity artifact missing in summary; rerun backtest after upgrading.") + path = (BASE_DIR / equity_path).resolve() + if not path.exists(): + raise FileNotFoundError(f"Equity file not found: {path}") + df = pd.read_csv(path) + if "equity" not in df.columns: + raise ValueError(f"Equity CSV missing 'equity' column: {path}") + return df["equity"].astype(float) + + +def block_bootstrap(returns: np.ndarray, size: int, block_size: int, rng: np.random.Generator) -> np.ndarray: + if returns.size == 0: + raise ValueError("Cannot run block bootstrap on an empty return series.") + if block_size <= 0: + raise ValueError("Block size must be a positive integer.") + if block_size > len(returns): + raise ValueError(f"Block size {block_size} exceeds return series length {len(returns)}.") + out = [] + while len(out) < size: + start = rng.integers(0, len(returns) - block_size + 1) + block = returns[start : start + block_size] + out.extend(block.tolist()) + return np.array(out[:size]) + + +def run_iteration(returns: np.ndarray, args, rng: np.random.Generator) -> dict: + if returns.size == 0: + raise ValueError("Return series is empty; cannot run Monte Carlo.") + if args.method == "bootstrap": + draw = rng.choice(returns, size=returns.size, replace=True) + else: + draw = block_bootstrap(returns, returns.size, args.block_size, rng) + draw = draw * args.return_scale + equity = np.cumprod(1.0 + draw) + equity_series = list(enumerate(equity, start=1)) + metrics = compute_metrics(equity_series) + return metrics + + +def summarize(metrics_list: List[dict], ruin_threshold: float, initial_equity: float) -> dict: + df = pd.DataFrame(metrics_list) + summary = {} + for column in ["sharpe", "sortino", "calmar", "ann_return", "max_drawdown"]: + if column in df.columns: + summary[column] = { + "mean": float(df[column].mean()), + "std": float(df[column].std()), + "p05": float(df[column].quantile(0.05)), + "p50": float(df[column].quantile(0.5)), + "p95": float(df[column].quantile(0.95)), + } + ruin = (df["final_equity"] <= initial_equity * ruin_threshold).mean() if "final_equity" in df else None + summary["p_ruin"] = float(ruin) if ruin is not None else None + return summary + + +def _load_scenario(args: argparse.Namespace) -> Optional[dict]: + if not args.scenario: + return None + scenario_path = Path(args.scenario_file).expanduser() + try: + scenario = get_scenario(args.scenario, scenario_path) + except KeyError as exc: + raise ValueError(f"Scenario '{args.scenario}' not found in {scenario_path}") from exc + return scenario + + +def _apply_scenario(args: argparse.Namespace, scenario_cfg: Optional[dict]) -> None: + if not scenario_cfg: + args.return_scale = args.return_scale if args.return_scale is not None else 1.0 + args.block_size = args.block_size if args.block_size is not None else 20 + return + if args.return_scale is None and scenario_cfg.get("return_scale") is not None: + args.return_scale = float(scenario_cfg["return_scale"]) + if args.block_size is None and scenario_cfg.get("block_size") is not None: + args.block_size = int(scenario_cfg["block_size"]) + args.return_scale = args.return_scale if args.return_scale is not None else scenario_cfg.get("return_scale", 1.0) + args.block_size = args.block_size if args.block_size is not None else scenario_cfg.get("block_size", 20) + + +def main(): + args = parse_args() + scenario_cfg = _load_scenario(args) + _apply_scenario(args, scenario_cfg) + run_path = Path(args.run).expanduser().resolve() if args.run else None + equity = load_equity_series(run_path, args.equity) + returns = np.diff(equity.values) / equity.values[:-1] + if returns.size == 0: + raise ValueError("Equity series must contain at least two points.") + initial_equity = float(equity.iloc[0]) + rng = np.random.default_rng(args.seed) + metrics_list: List[dict] = [] + + logger.info( + "Running Monte Carlo | method={method} iterations={iterations} scenario={scenario} seed={seed}", + method=args.method, + iterations=args.iterations, + scenario=args.scenario or "default", + seed=args.seed, + ) + for _ in range(args.iterations): + metrics = run_iteration(returns, args, rng) + metrics_list.append(metrics) + + summary = summarize(metrics_list, args.ruin_threshold, initial_equity) + summary["iterations"] = args.iterations + summary["method"] = args.method + summary["return_scale"] = args.return_scale + summary["scenario"] = args.scenario or "default" + summary["seed"] = args.seed + summary["scenario_overrides"] = scenario_cfg + + output_dir = run_path / "stress" if run_path else BASE_DIR / "results" / "stress" + output_dir.mkdir(parents=True, exist_ok=True) + iterations_csv = output_dir / "mc_iterations.csv" + pd.DataFrame(metrics_list).to_csv(iterations_csv, index=False) + summary_json = output_dir / "mc_summary.json" + with summary_json.open("w", encoding="utf-8") as fh: + json.dump(summary, fh, indent=2, ensure_ascii=False) + logger.info(f"Monte Carlo summary saved to {summary_json}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/run_risk_sim.sh b/Q Research/scripts/run_risk_sim.sh new file mode 100644 index 0000000..75187c0 --- /dev/null +++ b/Q Research/scripts/run_risk_sim.sh @@ -0,0 +1,55 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +RUN_ID=${RUN:-$(date +"risk_%Y%m%d_%H%M%S")} +RISK_LOG="results/risk/events.jsonl" + +# 每次运行前清理旧的风险事件,避免历史数据干扰统计 +mkdir -p "$(dirname "$RISK_LOG")" +: > "$RISK_LOG" + +# 自动发现 trades.csv(优先使用显式 TRADES,未设置则读取 summary.json 中的 artifacts.trades) +if [ -z "${TRADES:-}" ]; then + SUMMARY="results/${RUN_ID}/summary.json" + if [ -f "$SUMMARY" ]; then + TRADES=$(python - <<'PY' "$SUMMARY" +import json, sys +with open(sys.argv[1], encoding="utf-8") as fh: + summary = json.load(fh) +print(summary.get("artifacts", {}).get("trades", "")) +PY +) + fi +fi + +# 如果 summary 中给的是相对路径,补全为绝对路径 +if [ -n "${TRADES:-}" ] && [ ! -f "$TRADES" ] && [ -f "$PWD/$TRADES" ]; then + TRADES="$PWD/$TRADES" +fi + +if [ -z "${TRADES:-}" ] || [ ! -f "$TRADES" ]; then + echo "Trades CSV not found. Provide TRADES env or ensure results/${RUN_ID}/summary.json.artifacts.trades 指向有效文件。" >&2 + exit 1 +fi + +python scripts/simulate_execution.py --trades-csv "$TRADES" \ + --risk-limits-yaml ../QuantTrader/config/risk_limits_sim.yaml \ + --adapter paper \ + --paper-latency-ms 25 \ + --paper-slippage-pips 0.05 \ + --run-id "$RUN_ID" \ + --risk-log "$RISK_LOG" +python scripts/risk_report.py --log "$RISK_LOG" --out results/risk/report.csv --run-id "$RUN_ID" --skip-metrics +if python scripts/check_risk_report.py --report results/risk/report.csv --max-rejects 0 --max-kill 0; then + STATUS="pass" +else + STATUS="fail" +fi +python scripts/risk_report.py --log "$RISK_LOG" --out results/risk/report.csv --run-id "$RUN_ID" --status "$STATUS" --skip-report +if [ -s "$RISK_LOG" ]; then + cp "$RISK_LOG" "results/risk/events_${RUN_ID}.jsonl" +fi +if [ "$STATUS" != "pass" ]; then + exit 1 +fi diff --git a/Q Research/scripts/run_walkforward.py b/Q Research/scripts/run_walkforward.py new file mode 100644 index 0000000..58901e2 --- /dev/null +++ b/Q Research/scripts/run_walkforward.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +""" +Rolling walk-forward runner that slices a dataset into train/test windows +and executes the upgraded backtest pipeline for each slice. +""" + +from __future__ import annotations + +import argparse +import copy +import hashlib +import inspect +import json +import sys +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +import pandas as pd +import yaml +from loguru import logger + +BASE_DIR = Path(__file__).resolve().parents[1] +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from core.backtest.strategy_engine import parse_strategy_specs # type: ignore +from scripts import validate_dataset as dq # type: ignore +from scripts.backtest_strategy import run_once # type: ignore + +TIME_COLUMNS = ["ts", "time", "timestamp", "datetime", "date"] +DEFAULT_MANIFEST = "data/_manifest.json" +DEFAULT_RESULTS = "results" + +RUN_ONCE_PARAMS = set(inspect.signature(run_once).parameters.keys()) + +KEY_MAP = { + "csv": "csv_path", + "cash": "initial_cash", + "qty": "qty", + "account_ccy": "account_ccy", + "fast": "fast_win", + "slow": "slow_win", + "spread": "spread_pips", + "slip": "slippage_pips", + "comm": "commission_per_million", + "sl": "stop_loss_pips", + "tp": "take_profit_pips", + "atr_sl": "atr_sl", + "atr_tp": "atr_tp", + "atr_window": "atr_window", + "rsi_period": "rsi_period", + "rsi_long_thresh": "rsi_long_thresh", + "rsi_short_thresh": "rsi_short_thresh", + "enable_trailing": "enable_trailing", + "trailing_enable_atr_mult": "trailing_enable_atr_mult", + "trailing_atr_mult": "trailing_atr_mult", + "long_only_above_slow": "long_only_above_slow", + "slope_lookback": "slope_lookback", + "cooldown": "cooldown", + "allow_short": "allow_short", + "short_only_below_slow": "short_only_below_slow", + "risk_per_trade_pct": "risk_per_trade_pct", + "max_drawdown_pct": "max_drawdown_pct", + "max_position_units": "max_position_units", + "regime_ema_window": "regime_ema_window", + "regime_slope_min": "regime_slope_min", + "regime_atr_min": "regime_atr_min", + "regime_atr_percentile_min": "regime_atr_percentile_min", + "regime_atr_percentile_window": "regime_atr_percentile_window", + "regime_trend_min_bars": "regime_trend_min_bars", + "strategies": "strategies", + "htf_factor": "htf_factor", + "htf_ema_window": "htf_ema_window", + "htf_rsi_period": "htf_rsi_period", + "cost_profiles": "cost_profiles", + "slippage_model": "slippage_model", + "strategy_mode": "strategy_mode", +} + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run walk-forward analysis across rolling windows.") + parser.add_argument("--config", required=True, help="YAML config with base strategy parameters.") + parser.add_argument("--csv", help="Override CSV path (defaults to config csv_path).") + parser.add_argument("--train-bars", type=int, default=3000, help="Number of bars in each training window.") + parser.add_argument("--test-bars", type=int, default=1000, help="Number of bars in each test window.") + parser.add_argument( + "--step-bars", + type=int, + default=None, + help="Step size between windows (defaults to test-bars).", + ) + parser.add_argument("--max-windows", type=int, default=None, help="Optional cap on number of windows.") + parser.add_argument( + "--output-root", + default=DEFAULT_RESULTS, + help="Directory where aggregated walk-forward artifacts will be stored.", + ) + parser.add_argument("--manifest", default=DEFAULT_MANIFEST, help="Manifest path for optional validation.") + parser.add_argument("--label", default=None, help="Optional label recorded in summary.json.") + parser.add_argument( + "--sharpe-threshold", + type=float, + default=1.0, + help="Minimum Sharpe to mark a window as pass.", + ) + parser.add_argument( + "--max-dd-threshold", + type=float, + default=0.1, + help="Maximum allowed drawdown magnitude (positive value).", + ) + parser.add_argument( + "--validate-base-data", + action="store_true", + help="Run data validation once on the source CSV before slicing.", + ) + parser.add_argument( + "--keep-train-csv", + action="store_true", + help="Export the train slices alongside test slices for auditing (default: only test).", + ) + return parser.parse_args() + + +def normalize_params(raw: Dict[str, Any]) -> Dict[str, Any]: + normalized: Dict[str, Any] = {} + for key, value in (raw or {}).items(): + canon_key = KEY_MAP.get(key, key) + if canon_key in RUN_ONCE_PARAMS: + normalized[canon_key] = value + return normalized + + +def load_config(cfg_path: Path) -> Dict[str, Any]: + if not cfg_path.exists(): + raise FileNotFoundError(f"Config not found: {cfg_path}") + with cfg_path.open("r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + cfg = normalize_params(raw) + strategies = cfg.get("strategies") + if strategies: + cfg["strategies"] = parse_strategy_specs(strategies) + return cfg + + +def detect_time_column(df: pd.DataFrame) -> str: + for col in TIME_COLUMNS: + if col in df.columns: + return col + for col in df.columns: + if pd.api.types.is_datetime64_any_dtype(df[col]): + return col + raise ValueError(f"No timestamp column found in dataset; expected any of {TIME_COLUMNS}") + + +def prepare_dataset(csv_path: Path) -> Tuple[pd.DataFrame, str]: + df = pd.read_csv(csv_path) + time_col = detect_time_column(df) + df["ts"] = pd.to_datetime(df[time_col], utc=True, errors="coerce") + df = df.dropna(subset=["ts"]).sort_values("ts").reset_index(drop=True) + return df, time_col + + +def compute_windows( + df: pd.DataFrame, + train: int, + test: int, + step: int, + max_windows: Optional[int] = None, +) -> List[Tuple[int, slice, slice]]: + total = len(df) + if train <= 0 or test <= 0: + raise ValueError("train-bars and test-bars must be positive.") + if train + test > total: + raise ValueError(f"Dataset length ({total}) insufficient for a single window of train+test={train + test}.") + windows: List[Tuple[int, slice, slice]] = [] + idx = 0 + win_id = 0 + while idx + train + test <= total: + train_slice = slice(idx, idx + train) + test_slice = slice(idx + train, idx + train + test) + windows.append((win_id, train_slice, test_slice)) + win_id += 1 + if max_windows is not None and win_id >= max_windows: + break + idx += step + return windows + + +def file_sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as fh: + for chunk in iter(lambda: fh.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def params_fingerprint(params: Dict[str, Any]) -> str: + ignore = {"csv_path", "results_dir", "manifest_path", "validate_data", "write_summary"} + filtered = {k: v for k, v in params.items() if k not in ignore} + blob = json.dumps(filtered, sort_keys=True, default=str) + return hashlib.sha256(blob.encode("utf-8")).hexdigest() + + +def export_slice(df: pd.DataFrame, indices: slice, path: Path) -> None: + subset = df.iloc[indices] + subset.to_csv(path, index=False) + + +def maybe_validate_dataset(csv_path: Path, manifest: Path) -> Optional[Dict[str, Any]]: + if not csv_path.exists(): + raise FileNotFoundError(f"Dataset for validation not found: {csv_path}") + manifest_entry = dq.load_manifest_entry(Path(manifest), csv_path) if manifest else None + report = dq.compute_report(csv_path, manifest_entry, z_threshold=5.0) + severity = report.get("severity") + logger.info( + "Base dataset validation: severity=%s rows=%s gap_ratio=%.6f", + severity, + report.get("total_rows"), + report.get("gap_ratio", 0.0), + ) + if severity == "error": + raise RuntimeError(f"Dataset validation failed for {csv_path}: {report.get('messages')}") + return report + + +def build_summary(stats: List[Dict[str, Any]]) -> Dict[str, Any]: + df = pd.DataFrame(stats) + aggregates: Dict[str, Dict[str, float]] = {} + for metric in ["sharpe", "ann_return", "ann_vol", "max_drawdown", "sortino", "calmar"]: + if metric in df.columns and not df[metric].dropna().empty: + series = df[metric].dropna() + aggregates[metric] = { + "mean": float(series.mean()), + "median": float(series.median()), + "std": float(series.std(ddof=0)), + "p05": float(series.quantile(0.05)), + "p95": float(series.quantile(0.95)), + } + summary = { + "windows": len(stats), + "aggregates": aggregates, + "run_ids": [row.get("run_id") for row in stats], + "passes": int((df["status"] == "pass").sum()) if "status" in df.columns else None, + "fails": int((df["status"] == "fail").sum()) if "status" in df.columns else None, + } + return summary + + +def main(): + args = parse_args() + cfg_path = Path(args.config).expanduser().resolve() + cfg = load_config(cfg_path) + csv_path = Path(args.csv or cfg.get("csv_path") or cfg.get("csv", "")).expanduser() + if not csv_path: + raise ValueError("CSV path must be provided via --csv or config file.") + if not csv_path.exists(): + raise FileNotFoundError(f"CSV file not found: {csv_path}") + + df, _ = prepare_dataset(csv_path) + train = args.train_bars + test = args.test_bars + step = args.step_bars or test + windows = compute_windows(df, train, test, step, args.max_windows) + if not windows: + raise RuntimeError("No walk-forward windows could be generated with the provided parameters.") + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + label = args.label or cfg_path.stem + session_dir = Path(args.output_root).expanduser().resolve() / f"walkforward_{label}_{timestamp}" + wf_dir = session_dir / "walkforward" + wf_dir.mkdir(parents=True, exist_ok=True) + + base_validation_report = None + if args.validate_base_data: + base_validation_report = maybe_validate_dataset(csv_path, Path(args.manifest).expanduser().resolve()) + + base_params = copy.deepcopy(cfg) + base_params["validate_data"] = False # slices derive from validated dataset + base_params.setdefault("symbol", label.upper()) + + stats: List[Dict[str, Any]] = [] + for win_id, train_slice, test_slice in windows: + test_csv_path = wf_dir / f"window_{win_id:03d}_test.csv" + export_slice(df, test_slice, test_csv_path) + if args.keep_train_csv: + train_csv_path = wf_dir / f"window_{win_id:03d}_train.csv" + export_slice(df, train_slice, train_csv_path) + + params = copy.deepcopy(base_params) + params["csv_path"] = str(test_csv_path) + params["results_dir"] = str(session_dir) + params["manifest_path"] = args.manifest + + logger.info( + "Walk-forward window {win} | train={train} bars test={test} bars ({start} → {end})", + win=win_id, + train=train, + test=test, + start=df.iloc[test_slice.start]["ts"], + end=df.iloc[test_slice.stop - 1]["ts"], + ) + + result = run_once(**params) + + record = { + "window": win_id, + "train_rows": train_slice.stop - train_slice.start, + "test_rows": test_slice.stop - test_slice.start, + "train_start": df.iloc[train_slice.start]["ts"].isoformat(), + "train_end": df.iloc[train_slice.stop - 1]["ts"].isoformat(), + "test_start": df.iloc[test_slice.start]["ts"].isoformat(), + "test_end": df.iloc[test_slice.stop - 1]["ts"].isoformat(), + "run_id": result.get("run_id"), + "summary_path": result.get("summary_path"), + "sharpe": result.get("sharpe"), + "ann_return": result.get("ann_return"), + "ann_vol": result.get("ann_vol"), + "max_drawdown": result.get("max_drawdown"), + "sortino": result.get("sortino"), + "calmar": result.get("calmar"), + "final_equity": result.get("final_equity"), + "trades": result.get("trades"), + "data_hash": file_sha256(test_csv_path), + "data_path": str(test_csv_path.relative_to(BASE_DIR)) if test_csv_path.is_relative_to(BASE_DIR) else str(test_csv_path), + "param_fingerprint": params_fingerprint(params), + } + max_dd = abs(record.get("max_drawdown") or 0.0) + sharpe = record.get("sharpe") or 0.0 + record["status"] = "pass" if sharpe >= args.sharpe_threshold and max_dd <= args.max_dd_threshold else "fail" + stats.append(record) + + metrics_csv = wf_dir / "metrics.csv" + pd.DataFrame(stats).to_csv(metrics_csv, index=False) + + summary = { + "label": label, + "session_dir": str(session_dir.relative_to(BASE_DIR)) if session_dir.is_relative_to(BASE_DIR) else str(session_dir), + "source_csv": str(csv_path.relative_to(BASE_DIR)) if csv_path.is_relative_to(BASE_DIR) else str(csv_path), + "train_bars": train, + "test_bars": test, + "step_bars": step, + "created_at": datetime.now(timezone.utc).isoformat(), + "sharpe_threshold": args.sharpe_threshold, + "max_dd_threshold": args.max_dd_threshold, + "base_validation": base_validation_report, + } + summary.update(build_summary(stats)) + summary_path = wf_dir / "summary.json" + summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8") + + logger.info("Walk-forward run complete: %s", summary_path) + logger.info("Metrics CSV saved to %s", metrics_csv) + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/scenario_utils.py b/Q Research/scripts/scenario_utils.py new file mode 100644 index 0000000..6343ee6 --- /dev/null +++ b/Q Research/scripts/scenario_utils.py @@ -0,0 +1,99 @@ +"""Helpers for loading and validating stress scenarios.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +ALLOWED_FIELDS = { + "description", + "notes", + "stress_cost_spread_mult", + "stress_cost_comm_mult", + "stress_slippage_mult", + "stress_price_vol_mult", + "stress_skip_trade_pct", + "return_scale", + "block_size", +} + +NUMERIC_FIELDS = { + "stress_cost_spread_mult": (0.0, None), + "stress_cost_comm_mult": (0.0, None), + "stress_slippage_mult": (0.0, None), + "stress_price_vol_mult": (0.0, None), + "stress_skip_trade_pct": (0.0, 1.0), + "return_scale": (0.0, None), +} + +INT_FIELDS = { + "block_size": (1, None), +} + + +def _validate_mapping(name: str, config: Any) -> List[str]: + errors: List[str] = [] + if not isinstance(config, dict): + errors.append(f"Scenario '{name}' must be a mapping of field -> value.") + return errors + unknown = set(config.keys()) - ALLOWED_FIELDS + if unknown: + errors.append(f"Scenario '{name}' has unknown fields: {sorted(unknown)}") + for field, (min_val, max_val) in NUMERIC_FIELDS.items(): + if field in config and config[field] is not None: + try: + value = float(config[field]) + except (TypeError, ValueError): + errors.append(f"Scenario '{name}' field '{field}' must be numeric.") + continue + if min_val is not None and value < min_val: + errors.append(f"Scenario '{name}' field '{field}' must be >= {min_val}. Got {value}.") + if max_val is not None and value > max_val: + errors.append(f"Scenario '{name}' field '{field}' must be <= {max_val}. Got {value}.") + for field, (min_val, max_val) in INT_FIELDS.items(): + if field in config and config[field] is not None: + try: + value = int(config[field]) + except (TypeError, ValueError): + errors.append(f"Scenario '{name}' field '{field}' must be an integer.") + continue + if min_val is not None and value < min_val: + errors.append(f"Scenario '{name}' field '{field}' must be >= {min_val}. Got {value}.") + if max_val is not None and value > max_val: + errors.append(f"Scenario '{name}' field '{field}' must be <= {max_val}. Got {value}.") + return errors + + +def validate_data(data: Any) -> List[str]: + if not isinstance(data, dict): + return ["Scenario file must contain a mapping of scenario_name -> config"] + errors: List[str] = [] + for name, config in data.items(): + errors.extend(_validate_mapping(name, config)) + return errors + + +def load_scenarios(path: Path) -> Dict[str, Dict[str, Any]]: + if not path.exists(): + raise FileNotFoundError(f"Scenario file not found: {path}") + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + errors = validate_data(raw) + if errors: + raise ValueError("Invalid scenario definitions:\n" + "\n".join(errors)) + return {name: dict(config or {}) for name, config in raw.items()} + + +def get_scenario(name: str, path: Path) -> Dict[str, Any]: + scenarios = load_scenarios(path) + if name not in scenarios: + raise KeyError(f"Scenario '{name}' not found in {path}") + return scenarios[name] + + +def apply_defaults(values: Dict[str, Any], defaults: Dict[str, Any]) -> Dict[str, Any]: + merged = dict(defaults) + merged.update({k: v for k, v in values.items() if v is not None}) + return merged + diff --git a/Q Research/scripts/simulate_execution.py b/Q Research/scripts/simulate_execution.py new file mode 100644 index 0000000..129a805 --- /dev/null +++ b/Q Research/scripts/simulate_execution.py @@ -0,0 +1,277 @@ +#!/usr/bin/env python3 +"""Replay orders through ExecutionAdapter + RiskEngine with per-strategy configs.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Dict, List, Optional + +import pandas as pd +import yaml + +BASE_DIR = Path(__file__).resolve().parents[1] +ROOT_DIR = BASE_DIR.parent +for path in (BASE_DIR, ROOT_DIR): + if str(path) not in sys.path: + sys.path.insert(0, str(path)) + +from QuantTrader.core.risk.risk_engine import RiskEngine, RiskLimits +from QuantTrader.execution.adapter import MockAdapter, OrderParams +from QuantTrader.execution.paper_adapter import PaperAdapter + + +def infer_symbol_from_path(path: Path) -> Optional[str]: + stem = path.stem + for token in stem.split("_"): + clean = "".join(ch for ch in token if ch.isalpha()) + if not clean: + continue + if clean.lower() in {"trade", "trades"}: + continue + if 3 <= len(clean) <= 10: + return clean.upper() + return None + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Simulate execution with risk checks.") + parser.add_argument("--orders", help="CSV with columns ts,symbol,side,qty,price,notional,strategy(optional)") + parser.add_argument("--trades-csv", help="Optional trades.csv (ts_entry,symbol,direction,qty,price_entry,pnl,strategy)") + parser.add_argument("--symbol", help="Fallback symbol when trades CSV lacks column") + parser.add_argument("--risk-config", help="JSON risk config (single engine)") + parser.add_argument("--risk-limits-yaml", help="YAML mapping strategies -> limits") + parser.add_argument("--run-id", help="Run identifier (defaults to timestamp)") + parser.add_argument("--output", default=None, help="Summary output path (auto from run-id if omitted)") + parser.add_argument("--risk-log", default="results/risk/events.jsonl", help="Risk event log path") + parser.add_argument("--adapter", choices=["mock", "paper"], default="mock", help="Execution adapter to use.") + parser.add_argument("--paper-latency-ms", type=float, default=50.0, help="Paper adapter latency (ms).") + parser.add_argument("--paper-slippage-pips", type=float, default=0.1, help="Paper adapter slippage (pips).") + return parser.parse_args() + + +def load_risk_engine(config_path: Path) -> RiskEngine: + cfg = json.loads(config_path.read_text(encoding="utf-8")) + limits = RiskLimits( + max_position_notional=cfg["max_position_notional"], + max_gross_leverage=cfg["max_gross_leverage"], + max_daily_loss=cfg["max_daily_loss"], + max_drawdown=cfg["max_drawdown"], + ) + return RiskEngine(limits=limits, starting_equity=cfg["starting_equity"]) + + +def load_strategy_engines(yaml_path: Path) -> Dict[str, RiskEngine]: + data = yaml.safe_load(yaml_path.read_text(encoding="utf-8")) or {} + engines: Dict[str, RiskEngine] = {} + global_cfg = data.get("global", {}) + global_limits = global_cfg.get("limits", {}) + default_engine = RiskEngine( + limits=RiskLimits( + max_position_notional=global_limits.get("max_position_notional", 0), + max_gross_leverage=global_limits.get("max_gross_leverage", 0), + max_daily_loss=global_limits.get("max_daily_loss", 0), + max_drawdown=global_limits.get("max_drawdown", 0), + ), + starting_equity=global_cfg.get("starting_equity", 0), + ) + engines["default"] = default_engine + for name, cfg in (data.get("strategies") or {}).items(): + limits_cfg = cfg.get("limits", {}) + engines[name] = RiskEngine( + limits=RiskLimits( + max_position_notional=limits_cfg.get("max_position_notional", global_limits.get("max_position_notional", 0)), + max_gross_leverage=limits_cfg.get("max_gross_leverage", global_limits.get("max_gross_leverage", 0)), + max_daily_loss=limits_cfg.get("max_daily_loss", global_limits.get("max_daily_loss", 0)), + max_drawdown=limits_cfg.get("max_drawdown", global_limits.get("max_drawdown", 0)), + ), + starting_equity=cfg.get("starting_equity", global_cfg.get("starting_equity", 0)), + ) + return engines + + +def append_event(path: Path, event: Dict) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(event) + "\n") + + +def load_orders(args: argparse.Namespace) -> pd.DataFrame: + if args.trades_csv: + trades_path = Path(args.trades_csv) + trades = pd.read_csv(trades_path) + + if "direction" not in trades.columns and "side" in trades.columns: + trades["direction"] = trades["side"] + if "price_entry" not in trades.columns: + if "entry_price" in trades.columns: + trades["price_entry"] = trades["entry_price"] + elif "entry" in trades.columns: + trades["price_entry"] = trades["entry"] + + if "symbol" not in trades.columns: + inferred = infer_symbol_from_path(trades_path) + symbol = args.symbol or inferred + if symbol: + trades["symbol"] = symbol + else: + raise ValueError("trades_csv missing 'symbol' and no --symbol fallback provided") + + required = {"ts_entry", "ts_exit", "symbol", "direction", "qty", "price_entry"} + missing = required - set(trades.columns) + if missing: + raise ValueError(f"trades_csv missing columns: {missing}") + + trades["direction"] = trades["direction"].str.lower() + trades["price_entry"] = trades["price_entry"].astype(float) + trades["qty"] = trades["qty"].astype(float) + + records: List[Dict] = [] + for _, row in trades.iterrows(): + direction = str(row["direction"]).lower() + symbol = row["symbol"] + qty = float(row["qty"]) + price_entry = float(row["price_entry"]) + notional = qty * price_entry + strategy = row.get("strategy") if isinstance(row.get("strategy"), str) else "default" + pnl_value = float(row["pnl"]) if not pd.isna(row.get("pnl")) else 0.0 + + ts_entry = row.get("ts_entry") + ts_exit = row.get("ts_exit") + exit_price = row.get("exit") + + if pd.notna(ts_entry): + side = "buy" if direction == "long" else "sell" + records.append( + { + "ts": ts_entry, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price_entry, + "notional": notional, + "pnl": 0.0, + "strategy": strategy, + } + ) + + if pd.notna(ts_exit): + side = "sell" if direction == "long" else "buy" + price_use = float(exit_price) if exit_price and not pd.isna(exit_price) else price_entry + records.append( + { + "ts": ts_exit, + "symbol": symbol, + "side": side, + "qty": qty, + "price": price_use, + "notional": notional, + "pnl": pnl_value, + "strategy": strategy, + } + ) + + if not records: + raise ValueError("No usable rows found in trades CSV.") + return pd.DataFrame.from_records(records) + if not args.orders: + raise ValueError("Either --orders or --trades-csv must be provided") + return pd.read_csv(Path(args.orders)) + + +def simulate() -> None: + args = parse_args() + orders_df = load_orders(args) + + if args.risk_limits_yaml: + engines = load_strategy_engines(Path(args.risk_limits_yaml)) + default_engine = engines.get("default") + elif args.risk_config: + engine = load_risk_engine(Path(args.risk_config)) + engines = {"default": engine} + default_engine = engine + else: + raise ValueError("Provide either --risk-config or --risk-limits-yaml") + + latency_ms = args.paper_latency_ms if args.adapter == "paper" else 0.0 + if args.adapter == "paper": + adapter = PaperAdapter(latency_ms=latency_ms, slippage_pips=args.paper_slippage_pips) + else: + adapter = MockAdapter() + + symbol_exposure_peaks: Dict[str, float] = {} + max_gross_notional = 0.0 + total_pnl_sum = 0.0 + run_id = args.run_id or pd.Timestamp.now().strftime("exec_%Y%m%d_%H%M%S") + run_dir = Path(f"results/execution/{run_id}") + run_dir.mkdir(parents=True, exist_ok=True) + risk_log_path = Path(args.risk_log) + + fills: List[Dict] = [] + rejects: List[Dict] = [] + kill_events: List[Dict] = [] + + for _, row in orders_df.iterrows(): + strategy = row.get("strategy", "default") + risk_engine = engines.get(strategy, default_engine) + ok, reason = risk_engine.evaluate_order(row["symbol"], row["side"], row["notional"]) + if not ok: + reject = {"ts": row["ts"], "symbol": row["symbol"], "strategy": strategy, "reason": reason} + rejects.append(reject) + append_event(risk_log_path, {"event": "reject", **reject}) + continue + ack = adapter.submit( + OrderParams( + symbol=row["symbol"], + side=row["side"], + quantity=row["qty"], + price=row.get("price"), + metadata={"strategy": strategy}, + ) + ) + pnl = row.get("pnl", 0.0) + risk_engine.record_fill(row["symbol"], row["side"], row["notional"], pnl) + exposures = risk_engine.state.exposures + current_gross = sum(abs(v) for v in exposures.values()) + max_gross_notional = max(max_gross_notional, current_gross) + for sym, val in exposures.items(): + peak = symbol_exposure_peaks.get(sym, 0.0) + symbol_exposure_peaks[sym] = max(peak, abs(val)) + total_pnl_sum += pnl + ok_loss, reason_loss = risk_engine.check_loss_limits() + if not ok_loss: + event = {"event": "kill_switch", "strategy": strategy, "reason": reason_loss} + kill_events.append(event) + append_event(risk_log_path, event) + lat = getattr(ack, "latency_ms", latency_ms) + fills.append({ + "order_id": ack.order_id, + "ts": row["ts"], + "symbol": row["symbol"], + "pnl": pnl, + "strategy": strategy, + "adapter_latency_ms": lat, + }) + + summary = { + "fills": fills, + "rejects": rejects, + "kill_switch_events": kill_events, + "run_id": run_id, + "max_symbol_exposure": symbol_exposure_peaks, + "max_gross_notional": max_gross_notional, + "total_pnl": total_pnl_sum, + "max_drawdown_pct": risk_engine.max_drawdown_pct(), + } + out_path = Path(args.output) if args.output else run_dir / "sim_results.json" + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(summary, indent=2), encoding="utf-8") + pd.DataFrame(fills).to_csv(run_dir / "fills.csv", index=False) + pd.DataFrame(rejects).to_csv(run_dir / "rejects.csv", index=False) + print(f"Simulation summary saved to {out_path} (run_id={run_id})") + + +if __name__ == "__main__": + simulate() diff --git a/Q Research/scripts/train_xgb_usdjpy.py b/Q Research/scripts/train_xgb_usdjpy.py new file mode 100644 index 0000000..33c570a --- /dev/null +++ b/Q Research/scripts/train_xgb_usdjpy.py @@ -0,0 +1,234 @@ +#!/usr/bin/env python3 +""" +Train an XGBoost classifier on USDJPY H1 and export model artifacts. + +Artifacts are written to: QuantResearch/artifacts/models/usdjpy_h1_xgb// + - model.json (xgboost Booster) + - feature_list.json (ordered feature names) + - thresholds.json (p_long, p_exit, val/test metrics) + - meta.json (dataset/costs/params/seed/etc.) + +Also updates: QuantResearch/artifacts/models/usdjpy_h1_xgb_latest.json + {"model_dir": "QuantResearch/artifacts/models/usdjpy_h1_xgb/"} +""" + +from __future__ import annotations + +import argparse +import json +import math +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, List, Tuple + +import numpy as np +import pandas as pd + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="Train XGB (long-only) on USDJPY H1") + p.add_argument("--csv", default="QuantResearch/data/raw/USDJPY_H1.csv") + p.add_argument("--symbol", default="USDJPY") + p.add_argument("--horizon", type=int, default=6) + p.add_argument("--train-ratio", type=float, default=0.6) + p.add_argument("--val-ratio", type=float, default=0.2) + p.add_argument("--seed", type=int, default=42) + # Unified costs (match backtest/YAML) + p.add_argument("--spread-pips", type=float, default=2.0) + p.add_argument("--slip-pips", type=float, default=0.3) + p.add_argument("--comm-per-million", type=float, default=0.25) + # Model params (conservative defaults) + p.add_argument("--max-depth", type=int, default=4) + p.add_argument("--n-estimators", type=int, default=300) + p.add_argument("--learning-rate", type=float, default=0.05) + p.add_argument("--min-child-weight", type=float, default=1.0) + p.add_argument("--reg-lambda", type=float, default=1.0) + # Output base dir + p.add_argument("--out", default="QuantResearch/artifacts/models/usdjpy_h1_xgb") + return p.parse_args() + + +def _pip_value(symbol: str) -> float: + return 0.01 if symbol.upper().endswith("JPY") else 0.0001 + + +def compute_cost_return(symbol: str, price: pd.Series, spread_pips: float, slip_pips: float, comm_per_million: float) -> pd.Series: + pip = _pip_value(symbol) + # Approx trade cost (fractional): spread + 2*slippage in price terms + commission fraction + frac_px = (spread_pips + 2.0 * slip_pips) * pip / price + frac_comm = (comm_per_million / 1_000_000.0) + return frac_px + frac_comm + + +def rsi(series: pd.Series, period: int = 14) -> pd.Series: + delta = series.diff() + up = delta.clip(lower=0) + down = -delta.clip(upper=0) + roll_up = up.rolling(period).mean() + roll_down = down.rolling(period).mean() + rs = roll_up / roll_down + return 100 - (100 / (1 + rs)) + + +def atr(high: pd.Series, low: pd.Series, close: pd.Series, period: int = 14) -> pd.Series: + prev_close = close.shift(1) + tr = pd.concat([(high - low).abs(), (high - prev_close).abs(), (low - prev_close).abs()], axis=1).max(axis=1) + return tr.rolling(period).mean() + + +def add_time_features(df: pd.DataFrame) -> pd.DataFrame: + ts = pd.to_datetime(df["time"], utc=True) + hour = ts.dt.hour.astype(float) + dow = ts.dt.dayofweek.astype(float) + df["hour_sin"], df["hour_cos"] = np.sin(2 * np.pi * hour / 24.0), np.cos(2 * np.pi * hour / 24.0) + df["dow_sin"], df["dow_cos"] = np.sin(2 * np.pi * dow / 7.0), np.cos(2 * np.pi * dow / 7.0) + return df + + +def build_features(df: pd.DataFrame, fast: int = 20, slow: int = 80, rsi_p: int = 14, atr_p: int = 14) -> Tuple[pd.DataFrame, List[str]]: + out = df.copy() + out = add_time_features(out) + out["ret_1"] = out["close"].pct_change(1) + out["ret_3"] = out["close"].pct_change(3) + out["ret_6"] = out["close"].pct_change(6) + out["vol_24"] = out["close"].pct_change(1).rolling(24).std() + sma_f = out["close"].rolling(fast).mean() + sma_s = out["close"].rolling(slow).mean() + out["sma_diff"] = (sma_f - sma_s) / out["close"] + out["rsi"] = rsi(out["close"], rsi_p) + out["atr_norm"] = atr(out["high"], out["low"], out["close"], atr_p) / out["close"] + feats = [ + "ret_1", "ret_3", "ret_6", "vol_24", + "sma_diff", "rsi", "atr_norm", + "hour_sin", "hour_cos", "dow_sin", "dow_cos", + ] + return out, feats + + +def forward_return(close: pd.Series, horizon: int) -> pd.Series: + return close.shift(-horizon) / close - 1.0 + + +def pick_thresholds(p_val: np.ndarray, fwd_val: np.ndarray, cost_val: np.ndarray) -> Dict[str, float]: + best = {"thr_long": 0.6, "thr_exit": 0.5, "sharpe": -np.inf, "trades": 0} + for thr in np.linspace(0.55, 0.8, 11): + for thr_exit in np.linspace(0.45, 0.6, 16): + chosen = p_val >= thr + if not np.any(chosen): + continue + net = (fwd_val - cost_val)[chosen] + if net.size < 50: + continue + mu = float(np.nanmean(net)) + sd = float(np.nanstd(net, ddof=1)) + sharpe = (mu / sd) * math.sqrt(252 * 24) if sd > 0 else -np.inf + if sharpe > best["sharpe"]: + best = {"thr_long": float(thr), "thr_exit": float(thr_exit), "sharpe": sharpe, "trades": int(net.size)} + return best + + +def main() -> None: + try: + import xgboost as xgb # requires xgboost==1.7.6 per requirements + except Exception as exc: + raise SystemExit("xgboost is required. Please install xgboost==1.7.6.") from exc + + args = parse_args() + np.random.seed(args.seed) + + df = pd.read_csv(args.csv) + if "time" not in df.columns: + raise SystemExit("CSV must include 'time' column") + df = df[["time", "open", "high", "low", "close", "volume"]].copy() + + df_feat, feat_list = build_features(df) + fwd = forward_return(df_feat["close"], args.horizon) + costs = compute_cost_return(args.symbol, df_feat["close"], args.spread_pips, args.slip_pips, args.comm_per_million) + # Label with cost margin + y = pd.Series(np.where(fwd > costs, 1, np.where(fwd < -costs, 0, np.nan)), index=df_feat.index) + + data = df_feat.assign(y=y, cost=costs).dropna(subset=feat_list + ["y"]).reset_index(drop=True) + X = data[feat_list].to_numpy() + y_arr = data["y"].astype(int).to_numpy() + fwd_arr = fwd.loc[data.index].to_numpy() + cost_arr = data["cost"].to_numpy() + + n = len(data) + i_train = int(n * args.train_ratio) + i_val = int(n * (args.train_ratio + args.val_ratio)) + if i_val >= n: + i_val = n - max(1, n // 10) + + X_train, y_train = X[:i_train], y_arr[:i_train] + X_val, y_val = X[i_train:i_val], y_arr[i_train:i_val] + X_test, y_test = X[i_val:], y_arr[i_val:] + fwd_val, cost_val = fwd_arr[i_train:i_val], cost_arr[i_train:i_val] + fwd_test, cost_test = fwd_arr[i_val:], cost_arr[i_val:] + + dtrain = xgb.DMatrix(X_train, label=y_train, feature_names=feat_list) + dval = xgb.DMatrix(X_val, label=y_val, feature_names=feat_list) + dtest = xgb.DMatrix(X_test, label=y_test, feature_names=feat_list) + + params = { + "objective": "binary:logistic", + "eval_metric": "logloss", + "max_depth": args.max_depth, + "eta": args.learning_rate, + "lambda": args.reg_lambda, + "min_child_weight": args.min_child_weight, + "subsample": 0.9, + "colsample_bytree": 0.9, + "seed": args.seed, + } + evals = [(dtrain, "train"), (dval, "val")] + booster = xgb.train(params, dtrain, num_boost_round=args.n_estimators, evals=evals, early_stopping_rounds=50, verbose_eval=False) + + p_val = booster.predict(dval) + th = pick_thresholds(p_val, fwd_val, cost_val) + p_test = booster.predict(dtest) + chosen = p_test >= th["thr_long"] + net = (fwd_test - cost_test)[chosen] + test_trades = int(net.size) + mu = float(np.nanmean(net)) if net.size else 0.0 + sd = float(np.nanstd(net, ddof=1)) if net.size > 1 else 0.0 + test_sharpe = (mu / sd) * math.sqrt(252 * 24) if sd > 0 else 0.0 + + ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + model_dir = Path(args.out).with_suffix("") / ts + model_dir.mkdir(parents=True, exist_ok=True) + # Save artifacts + booster.save_model(str(model_dir / "model.json")) + (model_dir / "feature_list.json").write_text(json.dumps(feat_list, indent=2), encoding="utf-8") + thresholds = { + "p_long": float(th["thr_long"]), + "p_exit": float(th["thr_exit"]), + "val_sharpe": float(th["sharpe"]), + "val_trades": int(th["trades"]), + "test_sharpe": float(test_sharpe), + "test_trades": int(test_trades), + } + (model_dir / "thresholds.json").write_text(json.dumps(thresholds, indent=2), encoding="utf-8") + meta = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "symbol": args.symbol, + "csv": args.csv, + "horizon": args.horizon, + "splits": {"train": int(i_train), "val": int(i_val - i_train), "test": int(n - i_val)}, + "seed": int(args.seed), + "features": feat_list, + "xgb_params": params, + "best_iteration": int(getattr(booster, "best_iteration", 0) or 0), + "thresholds": thresholds, + "costs": {"spread_pips": args.spread_pips, "slip_pips": args.slip_pips, "comm_per_million": args.comm_per_million}, + "git_commit": None, + } + (model_dir / "meta.json").write_text(json.dumps(meta, indent=2), encoding="utf-8") + + latest_path = Path(args.out).with_suffix("").parent / "usdjpy_h1_xgb_latest.json" + latest_path.parent.mkdir(parents=True, exist_ok=True) + latest_path.write_text(json.dumps({"model_dir": str(model_dir)}, indent=2), encoding="utf-8") + print(f"Saved model artifacts to {model_dir}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/update_metrics_from_tca.py b/Q Research/scripts/update_metrics_from_tca.py new file mode 100644 index 0000000..ec2d19e --- /dev/null +++ b/Q Research/scripts/update_metrics_from_tca.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +Append a metrics row using TCA summary + KPI overrides. + +Example: + python scripts/update_metrics_from_tca.py \ + --tca QuantTrader/results/execution/tca_summary.json \ + --run-id 20251112_parallel_demo_live \ + --status pass --latency-avg 28 --latency-p95 45 \ + --total-pnl 27.3 --max-exposure 500000 --max-drawdown 0.02 \ + --rolling-sharpe 1.5 --live-drawdown 0.03 \ + --live-latency-p95 45 --slippage-bps 1.2 +""" + +from __future__ import annotations + +import argparse +import csv +import json +from datetime import datetime, timezone +from pathlib import Path + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Append metrics row from TCA summary.") + parser.add_argument("--tca", required=True, help="Path to tca_summary.json") + parser.add_argument("--metrics", default="results/risk/metrics.csv", help="Metrics CSV path") + parser.add_argument("--run-id", required=True) + parser.add_argument("--status", default="pass") + parser.add_argument("--latency-avg", type=float, required=True) + parser.add_argument("--latency-p95", type=float, required=True) + parser.add_argument("--total-pnl", type=float, required=True) + parser.add_argument("--max-exposure", type=float, required=True) + parser.add_argument("--max-drawdown", type=float, required=True) + parser.add_argument("--rolling-sharpe", type=float, required=True) + parser.add_argument("--live-drawdown", type=float, required=True) + parser.add_argument("--live-latency-p95", type=float, required=True) + parser.add_argument("--slippage-bps", type=float, required=True) + parser.add_argument("--rejects", type=int, default=0) + parser.add_argument("--kills", type=int, default=0) + parser.add_argument("--max-gross-notional", type=float, default=0.0) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + tca_path = Path(args.tca) + if not tca_path.exists(): + raise SystemExit(f"TCA summary not found: {tca_path}") + with tca_path.open("r", encoding="utf-8") as f: + tca = json.load(f) + + timestamp = datetime.now(timezone.utc).isoformat() + row = { + "timestamp": timestamp, + "run_id": args.run_id, + "rejects": args.rejects, + "kills": args.kills, + "status": args.status, + "latency_ms_avg": args.latency_avg, + "latency_ms_p95": args.latency_p95, + "total_pnl": args.total_pnl, + "max_gross_notional": args.max_gross_notional or args.max_exposure, + "max_symbol_exposure": args.max_exposure, + "max_drawdown_pct": args.max_drawdown, + "rolling_sharpe_30d": args.rolling_sharpe, + "live_drawdown_pct": args.live_drawdown, + "live_latency_ms_p95": args.live_latency_p95, + "slippage_bps": args.slippage_bps, + "paper_trade_count": tca.get("paper_trade_count"), + "paper_total_pnl": tca.get("paper_total_pnl"), + "live_trade_count": tca.get("live_trade_count"), + "live_total_pnl": tca.get("live_total_pnl"), + "pnl_diff_mean": tca.get("pnl_diff_mean"), + "pnl_diff_std": tca.get("pnl_diff_std"), + } + + metrics_path = Path(args.metrics) + metrics_path.parent.mkdir(parents=True, exist_ok=True) + write_header = not metrics_path.exists() + with metrics_path.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=row.keys()) + if write_header: + writer.writeheader() + writer.writerow(row) + print(f"Appended metrics row for run={args.run_id}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/validate_dataset.py b/Q Research/scripts/validate_dataset.py new file mode 100644 index 0000000..902e585 --- /dev/null +++ b/Q Research/scripts/validate_dataset.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +""" +Validate dataset quality (missing data, duplicate timestamps, gaps, outliers). + +Example: + python scripts/validate_dataset.py --path data/raw/EURUSD_H1.csv +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone +from pathlib import Path +from typing import Dict, Optional, Tuple + +import numpy as np +import pandas as pd +from loguru import logger + + +TIME_COLUMNS = ["ts", "time", "timestamp", "datetime", "date"] +DEFAULT_MANIFEST = "data/_manifest.json" +REPORT_DIR = Path("results/data_quality") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate dataset quality.") + parser.add_argument("--path", required=True, help="Dataset file path (relative or absolute).") + parser.add_argument( + "--manifest", + default=DEFAULT_MANIFEST, + help="Optional manifest JSON to enrich report.", + ) + parser.add_argument( + "--output", + default=None, + help="Optional explicit path for JSON report.", + ) + parser.add_argument( + "--max-outlier-z", + type=float, + default=5.0, + help="Z-score threshold to flag numeric outliers.", + ) + return parser.parse_args() + + +def detect_time_column(df: pd.DataFrame) -> Optional[str]: + for col in TIME_COLUMNS: + if col in df.columns: + return col + for col in df.columns: + if pd.api.types.is_datetime64_any_dtype(df[col]): + return col + return None + + +def infer_interval_seconds(series: pd.Series) -> Optional[float]: + ts = pd.to_datetime(series, utc=True, errors="coerce").dropna() + if ts.size < 3: + return None + diffs = np.diff(ts.values.astype("datetime64[ns]").astype(np.int64) // 1_000_000_000) + diffs = diffs[diffs > 0] + if diffs.size == 0: + return None + return float(np.median(diffs)) + + +def load_manifest_entry(manifest_path: Path, dataset_path: Path) -> Optional[Dict]: + if not manifest_path.exists(): + return None + try: + with manifest_path.open("r", encoding="utf-8") as fh: + manifest = json.load(fh) + except Exception as exc: + logger.warning(f"Failed to parse manifest {manifest_path}: {exc}") + return None + rel = str(dataset_path) + rel_alt = str(dataset_path.resolve().relative_to(Path.cwd())) + for entry in manifest.get("files", []): + if entry.get("path") in (rel, rel_alt): + return entry + return None + + +def summarize_numeric_outliers(df: pd.DataFrame, threshold: float) -> Dict[str, int]: + outliers: Dict[str, int] = {} + numeric_cols = df.select_dtypes(include=[np.number]).columns + if numeric_cols.empty: + return outliers + for col in numeric_cols: + series = df[col].dropna() + if series.empty: + continue + zscores = np.abs((series - series.mean()) / (series.std(ddof=1) or 1)) + count = int((zscores > threshold).sum()) + if count: + outliers[col] = count + return outliers + + +def compute_report(path: Path, manifest_entry: Optional[Dict], z_threshold: float) -> Dict: + df = pd.read_csv(path) + total_rows = int(df.shape[0]) + null_counts = df.isna().sum().to_dict() + + time_col = detect_time_column(df) + duplicates = 0 + gap_ratio = 0.0 + inferred_interval = None + if time_col: + ts = pd.to_datetime(df[time_col], utc=True, errors="coerce") + duplicates = int(ts.duplicated().sum()) + valid_ts = ts.dropna().sort_values() + inferred_interval = infer_interval_seconds(valid_ts) + if inferred_interval: + diffs = np.diff(valid_ts.values.astype("datetime64[ns]").astype(np.int64) // 1_000_000_000) + expected = inferred_interval + gaps = diffs[diffs > expected * 1.5] + gap_ratio = float(len(gaps) / max(len(diffs), 1)) + outliers = summarize_numeric_outliers(df, z_threshold) + + messages = [] + severity = "pass" + if total_rows == 0: + severity = "error" + messages.append("Dataset is empty.") + if duplicates > 0: + severity = "error" + messages.append(f"Found {duplicates} duplicate timestamps in column '{time_col}'.") + if gap_ratio > 0.01: + severity = "error" + messages.append(f"Timestamp gaps exceed 1% of intervals (ratio={gap_ratio:.4f}).") + elif gap_ratio > 0: + severity = "warn" + messages.append(f"Non-zero timestamp gaps detected (ratio={gap_ratio:.4f}).") + null_ratio = max((count / total_rows) if total_rows else 0 for count in null_counts.values() or [0]) + if null_ratio > 0.05: + severity = "error" + messages.append(f"Columns contain >5% nulls (max ratio={null_ratio:.4f}).") + elif null_ratio > 0: + severity = "warn" + messages.append(f"Columns contain nulls (max ratio={null_ratio:.4f}).") + if outliers: + if severity == "pass": + severity = "warn" + messages.append(f"Detected numeric outliers (threshold z>{z_threshold}).") + + report = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "dataset_path": str(path), + "total_rows": total_rows, + "time_column": time_col, + "inferred_interval_seconds": inferred_interval, + "duplicate_timestamps": duplicates, + "gap_ratio": gap_ratio, + "null_counts": null_counts, + "numeric_outliers": outliers, + "severity": severity, + "messages": messages, + } + if manifest_entry: + report["manifest"] = { + "path": manifest_entry.get("path"), + "sha256": manifest_entry.get("sha256"), + "rows": manifest_entry.get("rows"), + "time_start": manifest_entry.get("time_start"), + "time_end": manifest_entry.get("time_end"), + } + return report + + +def save_report(report: Dict, output_path: Optional[Path], dataset_path: Path) -> Path: + if output_path is None: + REPORT_DIR.mkdir(parents=True, exist_ok=True) + ts = datetime.now().strftime("%Y%m%d_%H%M%S") + name = dataset_path.stem + output_path = REPORT_DIR / f"{ts}_{name}.json" + else: + output_path = output_path.resolve() + output_path.parent.mkdir(parents=True, exist_ok=True) + + with output_path.open("w", encoding="utf-8") as fh: + json.dump(report, fh, indent=2, ensure_ascii=False) + return output_path + + +def main() -> None: + args = parse_args() + dataset_path = Path(args.path).expanduser().resolve() + if not dataset_path.exists(): + raise FileNotFoundError(f"Dataset not found: {dataset_path}") + + manifest_entry = None + if args.manifest: + manifest_entry = load_manifest_entry(Path(args.manifest), dataset_path) + + report = compute_report(dataset_path, manifest_entry, args.max_outlier_z) + output_path = save_report(report, Path(args.output) if args.output else None, dataset_path) + logger.info(f"Validation severity: {report['severity']}") + logger.info(f"Report written to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/validate_results.py b/Q Research/scripts/validate_results.py new file mode 100644 index 0000000..7c3b084 --- /dev/null +++ b/Q Research/scripts/validate_results.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +""" +Validate a backtest result folder produced by run_once/grid/batch. + +Checks: + - summary.json / metrics.json exist + - core KPI fields present and not None + - data report metadata available + referenced file存在(可选) +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import List + +BASE_DIR = Path(__file__).resolve().parents[1] + +REQUIRED_FILES = ["summary.json", "metrics.json"] +KPI_KEYS = [ + "final_equity", + "ann_return", + "ann_vol", + "sharpe", + "sortino", + "calmar", + "max_drawdown", + "max_drawdown_duration_bars", + "recovery_time_bars", +] + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate contents of a results/ directory.") + parser.add_argument("path", help="Path to results/ directory.") + parser.add_argument( + "--require-data-report", + action="store_true", + help="Fail if data_report metadata or referenced file is missing.", + ) + return parser.parse_args() + + +def load_json(path: Path): + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def main() -> None: + args = parse_args() + run_path = Path(args.path).expanduser().resolve() + if not run_path.exists(): + print(f"[ERROR] Run directory not found: {run_path}", file=sys.stderr) + sys.exit(1) + + errors: List[str] = [] + + files = {} + for name in REQUIRED_FILES: + file_path = run_path / name + if not file_path.exists(): + errors.append(f"Missing required file: {file_path}") + else: + files[name] = load_json(file_path) + + summary = files.get("summary.json") or {} + metrics = files.get("metrics.json") or summary.get("metrics") or {} + + for key in KPI_KEYS: + if key not in metrics or metrics[key] is None: + errors.append(f"Metric '{key}' missing or null in metrics.json") + + if args.require_data_report: + data_meta = summary.get("data_report") + if not data_meta: + errors.append("data_report metadata missing in summary.json") + else: + if data_meta.get("severity") is None: + errors.append("data_report.severity missing in summary.json") + report_path = metrics.get("data_report") + if not report_path: + errors.append("metrics.data_report missing (expected relative path to JSON)") + else: + report_path_obj = Path(report_path) + report_full = report_path_obj if report_path_obj.is_absolute() else (BASE_DIR / report_path_obj).resolve() + if not report_full.exists(): + errors.append(f"Referenced data report not found: {report_path}") + + if errors: + print("[FAIL] Result validation failed:") + for err in errors: + print(f" - {err}") + sys.exit(1) + + print(f"[OK] Result folder {run_path.name} passed validation.") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/validate_stress_scenarios.py b/Q Research/scripts/validate_stress_scenarios.py new file mode 100644 index 0000000..57c1f16 --- /dev/null +++ b/Q Research/scripts/validate_stress_scenarios.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Validate config/stress_scenarios.yaml definitions.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parents[1] +import sys + +if str(BASE_DIR) not in sys.path: + sys.path.insert(0, str(BASE_DIR)) + +from scripts.scenario_utils import ALLOWED_FIELDS, load_scenarios, validate_data + +DEFAULT_PATH = Path("config/stress_scenarios.yaml") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Validate stress scenario catalog") + parser.add_argument( + "--path", + default=str(DEFAULT_PATH), + help="Path to stress_scenarios YAML (default: config/stress_scenarios.yaml)", + ) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + path = Path(args.path).expanduser() + try: + raw = load_scenarios(path) + except Exception as exc: + print(f"Validation failed: {exc}") + sys.exit(1) + errors = validate_data(raw) + if errors: + print("Validation failed:") + for err in errors: + print(f" - {err}") + sys.exit(1) + print(f"{path} OK ({len(ALLOWED_FIELDS)} allowed fields validated, {len(raw)} scenarios).") + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/watch_ops_metrics.py b/Q Research/scripts/watch_ops_metrics.py new file mode 100644 index 0000000..fecf400 --- /dev/null +++ b/Q Research/scripts/watch_ops_metrics.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +""" +Run multiple metric validators (risk, latency, pnl) in a single command. +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import pandas as pd + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Watch aggregated risk/ops metrics.") + parser.add_argument("--csv", default="results/risk/metrics.csv", help="Metrics CSV path.") + parser.add_argument("--max-rejects", type=int, default=0) + parser.add_argument("--max-latency-ms", type=float, default=500.0) + parser.add_argument("--min-pnl", type=float, default=-3000.0) + parser.add_argument("--max-exposure", type=float, default=2_000_000.0) + parser.add_argument("--max-drawdown", type=float, default=0.1) + parser.add_argument("--min-live-sharpe", type=float, default=1.4) + parser.add_argument("--max-live-drawdown", type=float, default=0.05) + parser.add_argument("--max-live-latency-ms", type=float, default=500.0) + parser.add_argument("--max-slippage-bps", type=float, default=2.0) + return parser.parse_args() + + +def main() -> None: + args = parse_args() + csv_path = Path(args.csv) + if not csv_path.exists(): + raise SystemExit(f"metrics CSV not found: {csv_path}") + df = pd.read_csv(csv_path) + if df.empty: + raise SystemExit("metrics CSV is empty.") + latest = df.tail(1).iloc[0] + rejects = latest.get("rejects", 0) + status = str(latest.get("status", "unknown")).lower() + latency = latest.get("latency_ms_avg", 0.0) + pnl = latest.get("total_pnl", 0.0) + max_exposure = latest.get("max_symbol_exposure", 0.0) + drawdown = latest.get("max_drawdown_pct", 0.0) + live_sharpe = latest.get("rolling_sharpe_30d", float("nan")) + live_drawdown = latest.get("live_drawdown_pct", float("nan")) + live_latency = latest.get("live_latency_ms_p95", float("nan")) + slippage_bps = latest.get("slippage_bps", float("nan")) + + errors = [] + if rejects > args.max_rejects or status != "pass": + errors.append(f"Rejects/status violation (rejects={rejects}, status={status})") + if latency > args.max_latency_ms: + errors.append(f"Latency {latency:.1f}ms > threshold {args.max_latency_ms}") + if pnl < args.min_pnl: + errors.append(f"Total PnL {pnl:.2f} < min {args.min_pnl}") + if max_exposure > args.max_exposure: + errors.append(f"Exposure {max_exposure:.2f} > max {args.max_exposure}") + if drawdown > args.max_drawdown: + errors.append(f"Drawdown {drawdown:.3f} > max {args.max_drawdown}") + if not pd.isna(live_sharpe) and live_sharpe < args.min_live_sharpe: + errors.append(f"Live Sharpe {live_sharpe:.2f} < min {args.min_live_sharpe}") + if not pd.isna(live_drawdown) and live_drawdown > args.max_live_drawdown: + errors.append(f"Live drawdown {live_drawdown:.3f} > max {args.max_live_drawdown}") + if not pd.isna(live_latency) and live_latency > args.max_live_latency_ms: + errors.append(f"Live latency p95 {live_latency:.1f}ms > max {args.max_live_latency_ms}") + if not pd.isna(slippage_bps) and slippage_bps > args.max_slippage_bps: + errors.append(f"Slippage {slippage_bps:.2f}bps > max {args.max_slippage_bps}") + print( + f"[watch_ops_metrics] run={latest.get('run_id')} status={status} " + f"rejects={rejects} latency_avg={latency} pnl={pnl} exposure={max_exposure} drawdown={drawdown} " + f"live_sharpe={live_sharpe} live_drawdown={live_drawdown} live_latency_p95={live_latency} slippage_bps={slippage_bps}" + ) + if errors: + raise SystemExit("; ".join(errors)) + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/watch_quality.py b/Q Research/scripts/watch_quality.py new file mode 100644 index 0000000..cedea0f --- /dev/null +++ b/Q Research/scripts/watch_quality.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +""" +Scan recent data-quality reports and raise alerts when severity >= warn. +""" + +from __future__ import annotations + +import argparse +import json +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import List, Dict +from urllib import request, error + +REPORT_DIR = Path("results/data_quality") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Watch data-quality reports.") + parser.add_argument("--hours", type=float, default=24.0, help="Lookback window in hours (default 24).") + parser.add_argument("--report-dir", default=str(REPORT_DIR), help="Directory with data quality JSON files.") + parser.add_argument("--webhook-url", help="Optional webhook URL for POST notifications.") + return parser.parse_args() + + +def load_recent_reports(report_dir: Path, min_ts: datetime) -> List[Dict]: + rows: List[Dict] = [] + if not report_dir.exists(): + return rows + for path in report_dir.glob("*.json"): + try: + data = json.load(path.open("r", encoding="utf-8")) + except Exception: + continue + ts_raw = data.get("generated_at") + if not ts_raw: + continue + ts = datetime.fromisoformat(ts_raw.replace("Z", "+00:00")) + if ts >= min_ts: + data["__file"] = str(path) + rows.append(data) + return rows + + +def notify(webhook: str, message: str) -> None: + payload = json.dumps({"text": message}).encode("utf-8") + req = request.Request(webhook, data=payload, headers={"Content-Type": "application/json"}) + try: + request.urlopen(req, timeout=10) + except error.URLError as exc: + print(f"Failed to deliver webhook notification: {exc}") + + +def main() -> None: + args = parse_args() + cutoff = datetime.now(timezone.utc) - timedelta(hours=args.hours) + reports = load_recent_reports(Path(args.report_dir), cutoff) + alerts = [ + r for r in reports + if r.get("severity") in {"warn", "error"} + ] + if not alerts: + print("No alerts in the selected window.") + return + lines = [] + for r in alerts: + manifest = r.get("manifest") or {} + lines.append( + f"{r.get('generated_at')} | {manifest.get('path')} | severity={r.get('severity')} " + f"gap_ratio={r.get('gap_ratio')} file={r.get('__file')}" + ) + message = "\n".join(["Data quality alerts detected:"] + lines) + print(message) + if args.webhook_url: + notify(args.webhook_url, message) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/Q Research/scripts/watch_risk_metrics.py b/Q Research/scripts/watch_risk_metrics.py new file mode 100644 index 0000000..be580eb --- /dev/null +++ b/Q Research/scripts/watch_risk_metrics.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +""" +Watchdog script for risk metrics. + +If the latest entry in results/risk/metrics.csv has status=fail or rejects>0, +exit with non-zero status (can be used in cron/CI). +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +import pandas as pd + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Check latest risk metrics entry.") + parser.add_argument("--csv", default="results/risk/metrics.csv", help="Metrics CSV path.") + parser.add_argument("--allow-rejects", type=int, default=0, help="Maximum rejects allowed before alert.") + parser.add_argument("--allow-status", choices=["pass", "any"], default="pass", help="Expected status for latest run.") + return parser.parse_args() + + +def main() -> None: + args = parse_args() + csv_path = Path(args.csv) + if not csv_path.exists(): + raise SystemExit(f"metrics CSV not found: {csv_path}") + df = pd.read_csv(csv_path) + if df.empty: + raise SystemExit("metrics CSV is empty.") + latest = df.tail(1).iloc[0] + rejects = int(latest.get("rejects", 0)) + status = str(latest.get("status", "unknown")).lower() + + ok = True + messages = [] + if rejects > args.allow_rejects: + ok = False + messages.append(f"Rejects={rejects} exceed allow_rejects={args.allow_rejects}") + if args.allow_status == "pass" and status != "pass": + ok = False + messages.append(f"Status={status}") + + print( + f"[watch_risk_metrics] latest run={latest.get('run_id')} " + f"status={status} rejects={rejects} kills={latest.get('kills')}" + ) + if not ok: + raise SystemExit(" ; ".join(messages)) + + +if __name__ == "__main__": + main() diff --git a/Q Research/strategies/__init__.py b/Q Research/strategies/__init__.py new file mode 100644 index 0000000..6d12133 --- /dev/null +++ b/Q Research/strategies/__init__.py @@ -0,0 +1,37 @@ +# strategies/__init__.py +from typing import Dict, Callable, Any + +# 策略注册表:name -> class +_REGISTRY: Dict[str, Callable[..., Any]] = {} + +def register(name: str): + """用作装饰器:@register('sma_atr')""" + def deco(cls): + _REGISTRY[name] = cls + return cls + return deco + +def _lazy_import_all(): + """ + 懒加载:首次 load_strategy 时再导入具体策略文件, + 这样不会因为循环依赖或路径问题导致注册表是空的。 + """ + # 在这里逐个导入具体策略模块;导入发生时模块内的 @register 会把类放进 _REGISTRY + from . import sma_atr # noqa: F401 + from . import regime_sma # noqa: F401 + from . import band_mean_revert # noqa: F401 + from . import bollinger_mean_revert # noqa: F401 + from . import ma_crossover # noqa: F401 + from . import momentum # noqa: F401 + from . import xgb_signal # noqa: F401 + +def load_strategy(name: str, **kwargs): + # 第一次用时尝试懒加载,填充注册表 + if not _REGISTRY: + _lazy_import_all() + if name not in _REGISTRY: + # 再尝试一次(防止用户后来才添加文件) + _lazy_import_all() + if name not in _REGISTRY: + raise ValueError(f"Unknown strategy: {name}. Available: {list(_REGISTRY.keys())}") + return _REGISTRY[name](**kwargs) diff --git a/Q Research/strategies/__pycache__/__init__.cpython-312.pyc b/Q Research/strategies/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..06e99fd637f5a3cba6586b628358e74e5eff8d83 GIT binary patch literal 1750 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 literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/band_mean_revert.cpython-312.pyc b/Q Research/strategies/__pycache__/band_mean_revert.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0567f0ddf5b374c5fd06a2e0c2c10301b96b0b8f GIT binary patch literal 2887 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 F@W0XT=rRBR literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/base.cpython-312.pyc b/Q Research/strategies/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..69c0db18f55de11a4fb19a22a3add9fbcf1e2edf GIT binary patch literal 1302 zcmbtTO^6&t6t3#->F$}$+R+UXA;N4Qgt=sL77Z9@O`^-JmMa((6pmSk-fw z{IM%5n?u58vxkYIIEzRU17QP^4N)}rTmtJMl!rhzV>Ty8M-XyKz3yofK~EO+`>FTp z)%(7Fb!T+6fYjP|Yb5Mrgf_v+R(@y<3>E1hiYTrjFV)z?49^-_>N7oSJ+m{5p?}TH z>St+6|L@F^=ZXzYW?ohO!}>3c0h3m<>b7Us^Ije!4edu%UqIBjs-@>&91GH@;<%IZn8>2tp>q{&&_ z;!!mi@|}*Ndh3+sD!H}%wcGNRl#aAi6h`xvG`xbHu_(XK8?!7>#ydyIK0o*z0#yB@JP!5v0gpy$R<6%q9146p`2Jl+cQ@~UU@gY>wtI|yQk_M1+ zMWNu3!(UKh2!aEAX{NLvcmD1p-EbgrR0-9^_MI1ByX%bKwZ}Q^l=9Gjy&SK-eRFK>z?IW~f}2)`qNNe3HH5mj zEu_)(<70}QW|Ku!S~TR)uC)AVkwg@-nJ__P#_eVp9Zi}}F(eKCvuSC+k;320$JI?R zVPZ;&zK${8MEPG(;U3Z#?Rk6AnRm`>9c$U@ILl5~yJW3emz-5+Oqg{U)dF=jylt{2V1zce_8vp%FrN ziztbx>qASLmXIon8${apP*SL*RH^<6h4@ge4?@$m$&;%q^wo1_c5Q|F z?)ko%+v)Gm5?H71_JwUq$TkMOmFa?x2ALJ&5|@q)$H_|Gz8yo@v&FCF3~EkL$mD4ml}f=BUN3(G1Vz$PjUes~#Y(c3er%^6VVV>$?~f z^NQ4lLqC@4>rvfL^QkquLFX)h6`;eZOSs}vuDS}>T(zXTnyVkvT;l+_pmD=BOJ>O` z>BkfuPPp2$5mZ@8RmRQ0^?E)lEjQ*~9L&{sQRZ3$Ot!W3?auA5c5Z+DaN&#A((?p$gue*W8)o2|>Awys><`R=3L#`5FuuRgxHy1npWwi2;vkLPyo zU2T2*O-{tn84h9CxSM;=@%%WfM9!-@XQX&;WRi_f6yI_R&dAi6b3J@|Vtm+nAA3oo z>Tz#gNP`D)jYmblD>{rw2#ScGrZWI$g-i{U!KZ7EO%>8mZjU{%4AG!^T3afEfmcuDx>KK6KwY#8D}Zn&tbDLgdW{ zKq1T6d}XFqM!biycWYj`o3R<24Y`QRVH89aNSln!RD8xbzEb`I8e||<(1`Cxw2v`l z8v4wOV;)Y|KtibBho#B&I2Ff#Ix(n|cn!=a*Uuk(M;Q zX^~_=f!Q!&ZN&9z7|kVIQfkS7{~lUeCsLVC^22Zw{)%D(Ok1UtZj;O}B>RA<4QtVA z*o*dMWz}3ZSM6oHsa!YL%}N9mD>?W?%TVV} I0w&?|H%3rHf&c&j literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/bollinger_mean_revert.cpython-312.pyc b/Q Research/strategies/__pycache__/bollinger_mean_revert.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5d7bb962eecd2df3db94e4eb09603e050a6e2513 GIT binary patch literal 3480 zcmb7G-A^3X6~D7PAN#?6z`}m;Q3hgTPcV=s?neZ*Bg@2&l;GA-HQKtG3_Al1%+BCD zgJH=~xss)>tyTqD6>GOrwok>yQrf5Xq5nYDmjzs9HF6|NX&?Ny7OoogB|Ud$cfBU& zp_gUm%suD+?m6e4kNfvXB!pnl6EXFxAVOb|kH%mG=sc5UV9qZy8k^^`9EE&HVPtlf@(_IJ5|X)FNDe66uHT#6^uDVJ{*Ff_S4`W)*FKY_llo^?obeW%(Ohx92*1W5=3Fh)<-7wxl8x1GI!?URG|Xo(K6^MKe}; zYe8{0I|67f7}(;?LO!opfCz8}{~zSSyL^ub$8F(CYX|2;#XB`LDfMQ zkUB;c4`^mVHF@|E(=FaIKtVodWhHJP_L8~u_&$fj(c?YC*0vDt^zZX>Xf1w3!qR3lhzB-TR| zAq!HX&iA$Oo2y#*{uX{6|E2YG3%{wo)!xKsTKLUfTl8bsDANZ?)~bh_x&%S?f!5&$ zRsjkADS>s^RaKXbRfp48@}gB4r8t?E7%E$ zqN>BeijH5^Eljp?m^samEZhb(j*v%`%+j-ffMeu4PQGKJuB=%i?1~HGtb~PtLz{{= z=VBaL+_EeviaAX(O;I$-*^)ehar3`F8T+9LnJ|`HSdsLxi$-oG576e=_p!04R zhU|YBXr$f-XLGWD>GVr_Hq**xCr1z-HG$7n@rLsf{3%7_>+t|HyDH58B|n zm$92Oq8)^lBmj?$Fk0#cZPG4s=Mg4+uERg1e$#{C-4O9Epe4U9jZYo7&l0rw|7({p z*`;xot{oR$1Y$?}>pO!TWCwEkZ7;1nM&7lWNJmH8XiteE-VSB`56B z)0eMYopFMbAHFx^&4*mlofaaFe_pX1zozJ374+%L4kKYK6)-s)hbDc^@k6P_#QR=l zB=O)CH&P>o5Ka+M2HLfyIIL@piTjAqHwYu_6>U+LED4_>(<83D+w>i7T{LqBR!ous zZdr66n8c;S8ak=6>)o%aVIGeFn)jhR+(gy((5c{r=n~s0u|BmQ*!w5OUnxIQNxe~tzfp^$_DpH05*w%l25K=BO_r!T z*S!74?~Rwv?8k-({BR{TT8WR=LapK)il<5^J{i6@ydOK+qQsMUx&etH;gP^TeG^{u z@K44&b)t5D{{B5O{&s%G>(WuLmy*upHB=IaUf{VDi8(hoy%6jopKe5w6%LCPYX$2L zJ!R;MK;uy|Ny}=^!rvm2-+>3*ap4A_OL+xu03lJ#8}f=q=r&Qjz9MOL4kwDTkrPF? zjpDZmP^8|+bBd;kqCjC%7);We-f;sKlY5T)AcVOHk1ubbXEe(MYMm&Rsb+e~FWpn^ z<*VsTEtX;ip2QP12Hp?{9jO{er~sjYgaTJ5YhgmQp=5V0LZ~Q8B;A#8ZlUPE+hOiM zZw@kpuAAM>g&;9&ZUsC6W?|CL90smTa3y%1&BfeFtm z=kNvK6AMk^znY(-sL#>h7pUW3DET?+e#RnSs1h1Dpa!4&E>hHB>GUBY??XTGz4pwV Ha$)}mI>9*u literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/bollinger_mean_revert.cpython-313.pyc b/Q Research/strategies/__pycache__/bollinger_mean_revert.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5408d0af0e5ab539dcf957f34a66a3ebcd53137d GIT binary patch literal 3561 zcmb6bO>Z05@h!PuBt?-DEt2|hv{G!@Ygr#n?IM!=5g1nN*ooY7MO3JiZkFUqT6?)G zzg<~YViZ0FG~A+6UBh%6z|5&OdI)-I5BUSVQDBM?iv%#5_MjVG8AxvFyj_xF8Trr= zwL3dAZ{Ezj`FMNba1a5|XCm770ffFG9}QqVgxXsIg<%AT& zFm1)wOv9SnCCh=xV|7KdY!wq+vaw>TE1OPF#?*DqSW&T@R~19X>Ma%9*}7WyMSCCA zUJj67f(uIsQxd{Hi2~Don0^iI|4HmV-i}FJ8l9pfe;>NeU#G8An2~sihA$ek5`%$z zmcslIBnF(=g@#%0fmJEjs9lIGQZ9l!K}pN6>8j8a8p|j~PRJ;hniGf{g6oC_{tH&! zQ^C5S>Z)xTz43=O1SceAHZ5rtKgiFQ*UWr zIE9R=n;U|?s=6DsD6>{gYzx+AKCjxK4ODFk$F*ev5OYIzxuPq6TeGd!UA>T7V1D+7 zu%X$jngKR|)GJIF56-V@mHNU)rCh(@dI`s3}%Ji$L`t zO*L{fjr4kEBD6@fx{&s@@SCez`2H4ty9}@+P&D-)X%i=o4dBy$DXB**M)OF zgrLe=Epls@uq7YZA8Ke_L85<7WF2-xGjisJ!x`&&*|KvES5FX!^-c-uZU2lNK5Lpf z$Qu}TLSl!*SnF#lc6eFV3^-KT@oR>SJAmmh%etx9I1D)MB##K8(izZzqvV^o=tN{g zy=}{|E8dhd3Kn^Xwp4xD)zP-NWm!O$v$|qgvTX67M)2US^ZrEIg6yBjuC6P_#6>f^ zo(I*|#80t#UCjdW_JVu?a*-s;#Lqy+PN|lv;Oy!|y_huBnmDj;jIV9t7%_RES(^Lx zJKhKYk+AN-@Qw^^4?Rbk~{&-aCJ~9 z(Es2E_F#XMB}dc%l9+B0{1d1TA>mV`bGCuhOm)Ne3JIU&KV8>r_+KGIx5&_GU#1t* zy;(S|<&vs*-&QlGyP*#PU1B75k~)GGn+=&gIPI z-R}XM;OyM|Y)XFj+T0~4Q@Y|uubaVXBa50=-y+4wE=(RYKg<=qs5J889%Z}=>t~VAQBtimVguK=*=M-DP zcUNDb86BfM}Qkzq}P^jfFypibuTPJ*2E8)y=DQMD`nsCT=! zc)rp#T41Z)iAUnLSbV2+v(haVxN0oy_7Exp89-tCw`$2sz&<0j12BX z1}l-F0$uIw{jGV=Ed8ipmOGCY{MAtB*3kVoTUAASwr-S$x6Mj)q`-h3+hw39k2OO^jNK^Yh*e@AM`uX&>HdlAxYO z7bCgoCass6q;a^D8814GHHa6bsj za^B3X>jdwR<(un@Ugtz)d0E4j4L5exFoDgm|&eK7I#-d76d!L?IJV; zk8j@?W`_TR;NhO0YX*b_0pAU-~Xm)Nt|m9wP5O NKk~i)!X>$C{|gGL-l(kfAQ)p49<_}}=tqLsgDV0X9RSQC5s6Gc>SHhyBCw<1(Z^!8kHcID zHJ~?;$Zkg>*XgjNFrVNB=Ndr-#^mG5rK}=2%!+erEP3*>qGwF6s3vq-qe#pWvgV2+ zvhg+mvuFfiMnu>lGME)TqGN;;*%4mk;NwS}9d6ON9gT5g40egG?FhR?H?Bxj2wu}Q zaO^lv8^#(qhD@h%B{iDXOy0Pd#u<~-(t5(=4E19r<}4ma7Q)NIxA7%RvuLQJs|KMs zIJFHa%uqlBsx^i}8dHKh3`Z1cMb{AmnCPHm*meg+S&=!y`~l|(P9@2ybbw^Z8jtmP3Z(; z$I}M(!M5Op3sJNS*bfL*gGqK^FPR8~L2L`rVfMC14FwzRv5Q$*Z$FWaWm6DUqx~#S zk0~(#P7O)Dax9rqj7+0J4W>d&XMjuSq zP{AL(adpYvG{QKjcPb0D^#HmH!G_N&Hb5JSm4XBaxlkJHDVS%`a4DcsX1K&>XY^ho4&yWH~?*|KS7wpM7v{_8|dzLw1tdULOAQ2)J4+>#6gK4vHWQrA$$0UnQhjQ6uQ=pPJpne(F zq8WsjRzpl~-gKvRNuj;1RL*P)(Qh0C^RJ;*Z{76h9pRO?^*e9tLd~Pb#m2|+Z<1dp zSA=7Q+Pb+zGl%AT@1DASYUaIMY`I35JYMhxZicUiU-?>=eJ#0NE51F1GH`w1l`pjH z3*~B8e7jfuHPan)Ju^Lde@pJfgZ_K{d4GE$P`?`7HqYIyx?PnIzWH$1Li~}wsOR_f zEC+igPg;AGw=46(Jr8Ra&OJK6cs?(5F9*9PPyXPqy*Y7xV*bF5PjYqln+t*9oO8xG z=b7=`>CWK?*?U>gm0V0dIrOaK#gXSn@=XIPfpDQ|$22$VE>zdeHOw^3fA~dnuJeA+ z-wPjpcH+gE=V$Uw;g!H(p{bb&uk)z&z;yF^Bih%?EO}dTBixYK@bFalOY!k@=$bUkEAHU z2HLZ=J3Bi&yE{Aco8jLA0Uv>Ht|O9O6$$wo2hGoA6WqZf5RZvUl0@ZHKEq}CB+nu3 z$T+e>Qph@!P8bWSGvms-lWs=4GM=nA>E#GdG`H&BQM#gVP=I(x)UVswIc5^16;N5|I_C6SHJd2@LkKMO+&giGkfjUb5>(eEK^!pAfuz!;j8&MoIbk+!m|rA#y=)w9Ped4-8f z(zs-RF__{>Mxw&j+?+z?w7MdlOXtjZi@U5FX*9W<&*6E`&1ZC_ZCcglm3+pOKQ;?T z=2Ff!OQR!#t8fSZ0pj=EV`8=@A3BTVw;)^OihR*g6#l|(HlKxQFOdujk~GSJlUj*LYvOk?ka&(u`onAHagVJ z!<@glzChx_OkA+MMtW6~vtT$Ykka*xs^81WV1L63YAb0|p3BeAYgAqa2N#tLJd$iI z>eQrov2UBo;sMKPIvAApQ`OEWZurWPj~xRBP2 z$@ZX~ST4{IY_K};;((fHUcU>3j3PAO-zU#Ih_~aRyXxt#M+WLWXX~-idiUvl2;%5- z;`I8S`$^A0xloJ7OTs4|yM5A=(E0}(f!g4!wZ7L%!k3<&-GSjJiH*>PUK@F%Ht;6$ zV!OQq>td~U3omO}q-e}DQe9z&yNv*jd(A$C-f}Y5YkOvrWfo^x3ZUMBkLaV8RkR`ArZ9hRP z#13WFJg^qM{@8a2CTrfJtLR?pA%NKwG9W_G(ljA!KF0s(2p@J_6N^3?E#jFlgpPfy zFowIdKuh%&y{hjIJXlDiBe3Swu>~;vKeUN0Z4T96^x$r*u-BTuXpg~4Y#b;$RRM27 zy>OpE%TfXMRyc>Y3&Wfrr40XzPiCNXJ8(_&o#Vk7%hOCCHpKwL-AEh^oveE;H0@HM9@) zxggzwQCIt3f_L`xIEWfg07~wYda(0%g@=W5Pc1lH5{`~`PlX?8kJF!~*S#CB)FR{C zkqcWVYo{)j+`FOfa`=zY&!Uyn7q=3%=;iI`TTc_U(AAP>PYjl(%9qMh_0Cv%z7iU# z_(%3SyC2{D^k&IhZxKEH3;^wJPwcFSv2|}ndZm4pc`%BymNA- zdUB-Xss}?m!J%q!X#K~vAmH&{xPLuf4UcYzU*8h8%rC@}uihoCU$1tJZ+HDo!uDTIX_i}|I$=x`b&Rbz5o2ybTxFj;=lZr*j1k1iH&W?#$G5mx;0x3 zU8(r5>;bA@+=zTWRqg#z1rkO$T28FL`q@X-@cEK&PYgX$elt~`Uca&(8^;SQRYK=0 z{`1X=*~;*x?Y>J-r+21qY){=liA*IlTJewWo#-x~sRRcro zloj~#6Wp)$Ve}9xj-#4Q4;H7q$2j$LtVSU&PN~Cf`jYRf13GxrW(9Z z@mye*i3>Ci3mJqk$e4}$?3#mxHY;FQq$8f&XU8a}nDH$a)yzBvbeYj}8fe0s{Lq0v z4?cv7VoZXc2*0FLDE^O;S-}>pfUb<&>-j<&;mBv$~qkAnljskMl~V zG2@nHRZqz>D@^ERRE{^d0*#s|rDZa*9Oq~ZxCZ9?w`_Z2Wk|8XKjRnBeE)zv6Fg4O zem98?)cgADgTwWhv@do#Pw$1g_npx1yGVEMz8fhIlr8&SqHhT#MZ{Z$unh}u0|()Io-r0v{9mS-j$r@* literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/ma_crossover.cpython-313.pyc b/Q Research/strategies/__pycache__/ma_crossover.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8424c0822073ac3dd44ffdd33eda33f3a20ba7d4 GIT binary patch literal 3913 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 literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/momentum.cpython-312.pyc b/Q Research/strategies/__pycache__/momentum.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a0d0c7ba5379726018cd3df2ad9521422d420317 GIT binary patch literal 4800 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 literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/momentum.cpython-313.pyc b/Q Research/strategies/__pycache__/momentum.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0fcbbbccd9def98e896865bfb1bcdb178ef99945 GIT binary patch literal 4884 zcmb7I-ESMm5#J+^-(Qj_F`_=tk}ZXDM9FgG)`}C`shp;EqBuG;6(FaFBYC3EChwHJ zV{931VYEe|AbF6H22s!eQGfz*-u&2yJoPUi%M@Y{Dj=W;0>83(w==V|+>1nl1lnpeq5dU8$UpJLX}IF>J`JHhfzK;B6=C+7o(z&Q326ULH0$ChK`d|%xZhRf!xat z;2ws+H2F;zA;FBH7y_a=t}l(}mSwG=2>QDU6=c9oTT%;U%AhUk`pvAIyD6Bu0J{JT zswSArijdb!swQiukXNaaLsZkj;8mH*B?Y*Q;CQnIn>uDd*bEBL=E|ik=+c*jtV|6d ztC+VGMZ+yyk*R9H7Nvu1Imuk6im|L0^TLdH@ zdm@FFnp7zRTh+*($1%K}Imrm7W$72QyoPYE(rz(UzH6-~*fgNAxXkxJ#FdC+heP*;j78n=Sa zJlgB{Si^Jk%90Fx(mUoVEC;xNE?f)zJFfsTauE!X02PYLv$|enp#FSWft{fY0QsgO z*bKIit;g|T0l_i^O({S;ysM;xIna^UZ)p3NTda3y zypF9cGEGZGH&_^U^cF~ppG$cyzY`=@?2zAE(Su%U1)81(Ic?t)tjK{)r1+5(F-t`( zk5OJxsO6U=h#OOqEO$x1ZMh*DX$&|l4~s0?h9d3Qb>JwEo<$J?+KKOBDO=QYH>I*> zs>PJw;ti#^#1tHoY>^2_Qm!Z)h9nt?D#kvvyVlhS$UxMX$Ss#;ZDLl>l`+X16IZEz z1Ja2x@ydeqGUOdoG0cfKfybOz3`M57<%vf22e+GW{5id{N)u>e@^H#aJD)7TwDAFS z-`^#VJ4hhzv9vI%7s|}pSm)`BYRR%6I9dq*0I6z%B&=?_*dU}ZPp50TKL&D>9sfJerg37DZcu~{57EGu3egE z<0%&%0bxifaB5m1dxCMLLYBLrm=u5`G>JTMX<0TTMbpcL<)&RR35yv?Uy|%%#!h3s z$RCxOR-$%0S0Q37O{ILa54k)B)P{=XWuI%%VI1>2f|i@=IeHSt40LXm!vuj2Ad^>T zr>6DHkPz%CKv1&HFiq~4zRIC(F*alY1ma?QKl(Y3C8tlm>M1NTqW660HmsR!+~ zj?-(t?RfV_*JsI3l9l1p55!vX>{jx^pT%1I;+lUa7T=1ER%4_0y<4$~YHXt3*|)J& ziJz#1PwYfHwxUDT=+NewT6Ap9y%UPB%kQ7t_{rALbaiOD);EpJH!Jb6N_ec^oE$z= z9X?YTI#cUAgY(5o{A4A3awpWW)iqM>8mWawTGzn!hpMrm&FNYUl3hI%-Rc;rb}%+! zbZhKf75;^DYqMX6`|AT^4`!?Jvz72!k5K_YJdh_LYVAC^1O(>E93Wia7Ms~J!!0)Rs*|@`W1=2W@j~aqT)Ys6iGZ@8Isq5_+Jj>jElJ2^E78l6Po$Pe zuZ8NNL^U)}k3WNmE!JYkHfO(>`+Tk%o2vMy7%Gq`F`e$$b}dC8o`lV8?Pc zN}$K!4aVZ5 za6=A_XeeennB1K(V-bESuyw;&P-}4Eg@QULNhLjBE+QS4q<6}4u`v^qq$QOaW)Xgq z&~%vgNm5?VNfN7ADgNfc5(IN~%672}s%8A8A}7IZlB!v_;6edyMH;nqPAL{8DaFx# zkTo!TFWA9?#S6s_FB`^hq5J;#w)L4m-Gzo`jGOI7?Cr~gHuW6r~@noEW06)+0HS}MZ^0$3D%*z%gID{#T37##LCvCK-S zA7}Zj40i>r5Sf}UiLH{IzW#GI>@QIf<`E0y1?YC&9LN2gT=|9!eM?5FWaJx?_y_5E io%1|GYvbKL3m+j|6`dv4+ye8h&B^8W(+$zkUJ literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/regime_sma.cpython-312.pyc b/Q Research/strategies/__pycache__/regime_sma.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..28df1fb79fd0bda0346803cf7c5958bb4ed160a5 GIT binary patch literal 11184 zcmb_CX>c3YdAqnT5(GgIAV`8B2_6thQPC}tI;e;`9GQ+KIhI*F2#f_uP#{R}E-jJ) zM|8(Cfo!=Vr=ru$7^*TgdrZq|om%cpXEKxiz(EJAjWVN7Bs>1o(2>*0q%)m< z-&^bgltH(hc168?$M?SXy@&7K_tT0B69tbrZjHRvMp3`ShWzNH3jHVwm1T;d7&=N# z(8Dy1byZX~p&C{}UCn5s>Iu!ThSarD?SyVvN9ww$e!?(ppeYp<)-d|pw1iPbounAU zs}y4l>u#&1zT47^8aB?F{N_)w<)=k+FczESgM4H%#zE7-1oSZ$=r z{bS7KdbjRlED36aW>l|M3~QJQM*XU0Sj$v08hGm%E2D+Co~dGV@HQ|uMh|ZzQw_(W z88$I?#t3gSV|tYu20SzLaK$;wUnAPilKq@}azNhf^uNIlOkQ}NWrI_kmmdpzVYqAH z{8=woI1ulp2tVcxdb!BNR5a|B&cr(siNe8cH<8Ib-iL3ee)YGnKYZPki#d zzx2xM48}$yv9R~!Yl{#5@y4&;zw%)1^H9rewSzIusSB#)*K* zSfn@fmhPc2I0tV=HD|_I0lhqfx5>4*wm^L;B}Mvl3#zy-W-6gbS3;3OMaj6KfUlHt zSY3L6#yYuPe8&|b1{y2me!fwz#f>FvT9sZC+a5PP55Lg7Wxk?>0!s;Wl?+`nUQsfx zoI|XV(ZcEqJfnk_G8#8ayBft)td?=cX{k3t&C`5`+{@@ARGj`H{YNyw*2ulHl;1EU zYDS_#ezeeclm*(UPo&nykwW+3qf%wz>7%zOzeCh=(^FwqGz0>XScDG*L~V5P5_Fi@ zU~DuTn3!b3qIM)Y8RW%Esf*Z9Y9$J7PVL)SFvs^kZag_ESjK@=)i@^S+FhGr;1LBsu!~dcV$2j)y}~IdLIyEEu}ThdI9YX@KF+hPiN% z4UP2{h>wK1-lDPCJ~hiCN6opvpm4X#zb@{Gvwjt+_-CetQ+d@HMXoA$~Ark8*ZVgHQ%%|)3kH-e16Z# z4E!~n{K>A2XGj9|G_P#Ww(MCQ$a(gX9%26z>HTLjExXq*ypaCpRPMq{*xf`H$odYf zhH{>R5}@0YclT!8y{o32yN?jj*u2t{Yutgu?z?7};ApvWB5`a{b+5^{a`wh-;`q9y zL8*UaX}I6iO8S6C>FxGq-P=+sM0VfVnsxeD*o5Vt$CvT!UWr4I(0pX5e*`B(g-~MT z2lXM*(gO4@qgE`q0x|%;eW{ej* zK^m4|Ek$1!@v_x%P5F_Fa0;xNG0m%EO#o5Gw!C7GYhSU(aTjGq$$S;gS7uDsWZp@ zDp3~gh0bGla~rA+fq<5(cp{WsHmTgU5vq)Xe)F}Y7><^u{UDnW)0772YN*< zffBVuON!dTq4R@hLGOZw7d3+yPM;SIQcA9e-7ptGqab;VqK1itz#0YQWr(>EDNl}I zLM-6o!=gbx26hMb7VHD4-f-|Dy9>L_1;d4t3;V<^F``AXjM8~>C>BM<&u)VziySiN z`l6ziHg*6;xi>-GEKq{onYa5hc7LiZXWx-9+%?+;k8gD*V?Ua<9KG*sSe{*)UGc2e z<(z$>)tv6+o|^}*A4s?DUu(|Q_h;(|?$9~sv4lma^W3bvUYBm&m8;vGs1$15`C5Ob z)}OE4k*VFW>d4jZ%hn!R+nF#6wua@VrKS~EYH!ZAGoib0b|&{_%`Jk}p0~DStt~6@ zRVKgpbY}1AY{zM#seQ>J*c^FVXU5i&*N1 zWqkYAba%8l--*S+Wc$*SLcJHz*&0`zSzD*zY)MzOZ0IP9OQO>5Nn1SFuFjhqGUkTl zH`dKQLGE=mH1Li~Hbrr)Re^3@TYj(U2@`4Y$dF zfP&Ew_1hd*fodv*h^1>^e4ebTCF5m`!A0OYVej=WN=Acn29b3oICZ@+4G`FYMgm$=%i>wy$~Q( z6R8pqzgBY1`Vc|UKSLeq*AVe#kKxc0SP(J7GePN-s^FTV8J zA;DU`$Vft#wfY68uh7w%sqM^KfpJ?mOjK3PhLNhQP13JDpA4;>&$sQ*wC&G&_5;HT z6^=69O(2F650<(YSSLLApHSI~2RV8c+>^3Ky~urFPJ#Q>Tfiw{7q}l90!K4i!ht$4 z^lHKtIGbj4CB4X1TjMNsShTM+5rdDv+G7vm<|KEZ93L0fn57{QIxp%`_lGBfCq!*1 zIthOE8NkMV4GL)pm=O2{U3iwTk{4^uQ22Ec7opjbSn2Cndm4+gP;j_5@{`O_hoL>^ zE5lJ=FhUqBb4ySFTcIj#&RUz1r>dRH{w06%bgsHRp%={6i6esDB~&?Q=>naEOfj8x_M?)t%cQUi@WVYv|;A~L%so($sffS{O|Ae28N;-|^tl-*G<79Ai5ur!Hp%|q=Z_&r8 z7vPY0Wrjqf=nlr9pXySUQ z#+5L?I3up`E=^rMuYtr0*9cr*26F42$}3@7rmU-}h76(RM_QROgo7lQZb&pL@hw{k zG=<<;l?M+9yiNL1J5;ucG)gxNDAhykd4MKL)K3#8h8!ZzbBI_{cA}v_8k~5M2_Bx? zU4}m&3$nNOM<+wUD0dht(yZ%%iA1go)JpK(@H^qu*Vb(7?R`Z1oCk+deK9;M>WNR0HF+0m)pj0As4bYrx`R_ON2EcLOhhl-cYpoF+-W6ZIWlyGMPr7Dr+P*Js*(W%h z3DeJO>JrEA*&CD0509_g{b22d8rSmarPFVIEirhnxqW5&-Pw0$SL@e8xsGGG=C39O z7rQbRk6@|ITN*Q##$@;-i*LhBIozLFs491{|J$kK`R*f`?jvc>(RIs!V5q)ky<*KY z^xZk1u&x`P5Uh1+L){nEIT~Lgehd*mUg4H4)Upx}6r#jU>YPy*j666NWtG1o7@*Y4 zLuh5jpVyYetmR>Kj9O;@0yRqRZyvH<hy&ru-mYQT=J_+t;kfIOj_!h}bHkbnxj#Lr4eG5{q4 zCb|2CToy(Eu~B}E-HF3Q2vMTJa40-3g({dTf)E4QI4}|9A*kVqh=8OZ&y$FOm>AJO z0tr}F)RX8b#Ip=e))k_SZ$X1Yo**Ish2+c}g`PQY87bHl<-CoP{~U@1>Ym+|bYK(! znVh<>Wb5{?p3B(}6de|OZNYq`8@hAVJz$MC2MzaZHH*(IKezN;8ZtivImh0tqYuIi zTYo}_0`t`Oo=O~-Ok2j9QXh&)z)0bN#6+PN7_B@+Q7|Cn zFN>qdtcEQhisEA20-`7`rY#_fDFt&}_lhm9dZjY1DzTc%_(ibF;5+H#dIDwL1f_UR z#BWC^13;+(SE&Uy$fu&Uo+$ck=lyEQO(PP9 zc>upflA&)wokd|K0>h4B4P7@;U!Vyp5Kk z`08M${XnUE`R&zkrUPOfS6OdSF;@wY(=Uxa$|psOQi)M)LCM2WDi26eIaRO7s?6b# z3@q!T9;X)R7d{6Q15ry^OhuX2# zCKZXysTc#m=?NS})qD|dvy4k{1~7n;O&)T&(=iqkwFN`0=ts#Y!cI+Or@zFQ@9KBA z*o~4tm9T)PZv^kXoKLQN8k;x8O>yJP5K{d=P+9(rl|-pRl{sz(M`9K~*loc@ma@N= zk{(sKsN#P$-zv|O-}9C63iyRi#4DjZ9j}P1L#i1Sal&-*N_<2AGcj;Xu3q@Ro{#L_ zEYeq5_&uxe_vBR2n%!a#i9f{i1>c__+!X)GAAgD^VRTE0I}rocr+NS4C&T z59}$&YRkGGJma7F`|dThtn7Yw|2z9rPpv(jYdw-}J$~maxu(H&*WhoAR8u?T46B{V zo^@LXY$WgUXI%c2@4e1jovZtD+xl{@e%KD?_B%4Q9VyLwhFgYJ$8Bq_cK@RK=XS@9 zwmF&c{vdxV{=${{^s%(-+4osmu=$#Xf+*I($F=t&8sM1C2 z!`h5nWWD8l&pE2Z;?X?%6XdyFSP?#kL5_)6Y);7sFbHZPuL;+midv)G=fW-WHL7B; zDr@g^R4Y}?S^3~2B~3j?U48lzHKTrxx7ou%Yazh+3R05vc*D=Pw#9-h;6?bvZ} z#}2;%vX4P7$bt#-&^!W^UP zp1r>Csmk|W_pR<#ZtYZV+hERqB5gVGyU%S@P2+DUy25c6Qr>EK|HtRmR8<{x8C`b) zgBsqy=URcNCF?*3^`Xi)u$TIGoJe{YV${XMyd+yx;MNjBMo6ETlB*UV4D&9y%u)4RRo^#V*Po!f%I--SZd zv0;9ijfom0(65p*&zL@gEcG#j!aDmV6m#&YlPxR`)(3+fmh5FS7>k6!oqz+=mj;H(nw6UBmHB?F=?G!TH)ryqJr z*7K+ot)Y@&vA|%C%RuqFSEI?0hOW}Oh&CpB9P*p+gh!@xkgEL!<^Ba#|Cx?b enG)SO+WEOEqNZI*!y^jckJOaP^%?0RsQ(Ka0Pd&& literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/regime_sma.cpython-313.pyc b/Q Research/strategies/__pycache__/regime_sma.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2436c2acd0b7b5976296b432f0ffb46705187a4a GIT binary patch literal 11829 zcmb_CTW}lKb-Q>j9wZ2YAV3fVK@xmGBt=@*iy|qBlBl;Rn=9CKWE+J*kb(??^e$+L zN?I#znm~0jA{#4&YA2$ac1$&yn0-vcX`Nch^dmEEX9%wxtT&3HP9!`23N=!jO#0Jv z?qU}p1-j`>R@B9PoO|xMchCFoqp~tH1&?&j7Qfm|QNO^3@)+a_{U`yIs}xJI^fBrv z&C!0WYmRA-YB&wlwXE)#_Nb22k-Gkv{-}X7khk%1H^nQ?p~7Um}+(fk7BM<+MvNB;_ymtk`+QXI`voQ9=2Evw;l zKC09|wyad|#(ivgh}ufCS`T%OIj1{Eb9&as>Y%0L3~U9fCp9B$XAPvru$8Ql)J&`c z4+!AQY!z!FH4E!x&7@{!Egp(1W33$E@25FifC^Si_F=MD_QZg45T9CMchh&FFh9>l zrg(qqY|Ibi?E|NV{e1qk{O9ATv;K&mk55b`Vt)C+{A2M1oa$z?xzqpP?ZsdI;~NkD z?EU=Muii_4^5)DyoV<=1W~OUV-+TzU7?OP4=>J@~<&Bho1DQ+f1I0iL2CeMv)6 zyXmXcX=Q{u1;_Ogj4Qn6r$U;3x-CCG3h=6m{C90EM&)XT!KU zyJ%cjG@h3#iV9J~MD`Rc@-Orj?V@BsD)m5lnbLo{PN{`VMXTG?UbFI6BF#Xr zc^asm`i~--tVOdblvzdNWkuuaDV;25^+1IR&l;emj)pAj_t}M~U8!IX(d);%sU&^6 zRT*cE-BgG^KyRmER+Z8_P6e4ENjH{=q{j1odpXz*^@-eC-IMR$y;rVWdUo$kD(IB- z{Pa|elbCQgo{Xo$VM(8uJP#daE|MINg(oIQW0HO>F&Rlo<#HFt$HQmi<7cI^LL25N z!?6p1W+FZ+Y2!(llj35@QD`T_XCfRgRYp==7#57ilBswC@W+!<`I!h0x-)(en$wAt zR1r>2aubmRcBJAlm}Mq|a$R?3axx*6olT8}0c1Qm0hlBUHjyyIS7<~oz&_&qD`9Rr z5#u=q&O)I`(Mv&_q~l|WG07@ZIu+p}6Fi3p%T;6H!oq_^Efzj3yjb|5kmGA!#;3ZT1(?)ujE_aQ=-IA3@$nenRj@ppr)D_hBFXcFhTg2aT_=q5Hn3{H zxkNqoQl|3D#w_E$&3MGx=3H&pVs*~5J?q&ncy^|BU$x!wH7yJYzNgc=+f419y2gcm zq3$Vcc*OdqT>X}8{g$OuxowBC@K=B6$4_T{Lo%qZVPSKwaof^>;M-1m#GOa8JBPE4 zTW^oNwDR(lF!CyP*OLWufu5zP;M*kwdVD!gSJuxZ|UbX(A)qAhLiSz-5@~%teHNb5diY9GP5P^h!i2lXe>@4KM9idr=9Rj3e93DJuDCp96Bs!KpOvvf$qYBp;E z05h!W^1@v7X13+mOKMN6+I$0OJ!?>)*J}qD$y1ipZ7^HajY-f;!>VN{P&2G*?m}N- zZ|heCePjq3O74nbX@$4Kctu%J~89Aw!9FY5G;0q|g*m=2OF{%+@`H z_nb6^OzIqPU0Eh%>ZUj3;PNNViOgYXoWlYq;BU|dR_XA`;|GEo$qEYhHyVB4=U=$vB;SB?)7aJ>M;>Wi%edo=N3vEeO{Q`;qhnO41W8Ea?Y_P7Mx& zUIz;z=>|uRoRXNSNj{FN2d$Dl96cM0f>BT{gQOjgr8smexB#LvOcL4(Y9B06D#E3B zbYi8-@DynE7#~KXBYUutZZsYRYZq3QA?8dflg$D&#*>jm(9D6C0mK5XR7_%&W8k)6 zZ{GfZYL7);;m{M5EP2C-lk@w;EsaW6+49Qg$)ht>uq|9WG`Zb4-~FT^kT=!?qx?Lm zjZ2j1aOE7qtRuMCEI7K;%w3B^^aYkKWF31~tb6adyz?`2GYh_@8o|{Ideh~}Y`flb zt!Jfq=W>Hk+n1{yxJ3)DeQB#$R_(AJ6qko(kR*-Ia?$AEqo!j{YZBE5ux>nSl=>d z746QPy)BD>TSZ3$68my)cPP6%By@(vrcJZQMO$s=ykKit+@5Ri%eMC|Kc8zK%(f4T zuG*YybJn$a@wJ?*KkMoT=(*$fz5az-!P_}IC7uJ*xN)`<4Q&2s)4e)Wf~p66{`>1l{t$yYw>2D%UJ?hOF&cx++MITSr?wCt2=GI zYjKHH-b`Y}(Y|7B$BFhUJ(qiCKbN!kvljn77#mx0G_P2j#R_+(WyRL8!ZdK`h_5k6 zAX>6H^1@pq<_J30KP86upbc={6RM~ zgE}J4+yNXqfd#I@->M03qMv&}mN-%em(vCR2IXFbTI%#6i84A$X4 z{iyOYm{GALqsV074VCm43hW4$nXsdF14uO%1TROIhoo6OVM<`Pk^O{4a4l_!W(`IC z$YyeXX&Q>XBmJ3r;zg8VaPUM%Wgnvyd#kIA(-4WBl8k5^ViS>rl0KT41V{fQ92z$S zg**gY2@K@NPQqIaSOe*zf(Dtb(5}h6bqZ@CES`gc$JLR+6c*bHZ7EQK#h$QI7jgP! zD1gsU*EYZ(GFhc-J~$W591$v8(nirzd3lfMaElerdDEOJ(<)RnruCww;&MOsTI{ng z3YLb2*taM1&6hxRFHC>?b!@K3l(qOoTXn7`n5_u{S1vxE+i@tnZD3&bEY82dxiVf*ln)q^>VKpOBm=RJr+E1N; z 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 literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/sma_atr.cpython-312.pyc b/Q Research/strategies/__pycache__/sma_atr.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..30812756f817dcf47634b6a76813fee170fdd522 GIT binary patch literal 3385 zcmbVOT}&I<6`t|$_#Zs>crXS#5E>i^W>=dkKnUI4Wbw8MbP1Ir)M_;L4B%jU$js2d zW@xgjRC1(t7q6OPuT}!2R`CjLmZ~qb%}RM(seKsRFO$($0yQ+7r))%@`qJJz{)tJ+ z10Bg{&i&?m=icw$>wE5B9S$o3o}E87C#GEp@(kmRlphGHC-tOn3YfKb9L$BPGW4XH3=tQPVz6Qr7FsU*kE#pw9lU?`Cmt2yH! zJ;Pm3H)7MYAX3*82QWC8moYFsV346m2s+|?)d5715UTylFQ5FT@c7RkeS3dv@2k7d z9{lR@_OJIoD?It)w@>c=e(w(-etUo8$^BI@2ijQTx`wGX03WW}{@;MNibyDfwt(ic z4tP4v(@W^p+Iln)U+jQo!z&^Tn^oSj@f8uqdV~q|XVUsJzarMmV4KyEFJrHWu{6Y3 zKt`*U(JCQV8#Px|9IDDzACc_{EPI`0KLX3aIO~cWN8q^{R638qYii)Rj=*bX-1Tub z9f5~8=xaU#&r_dG_Yqj$It!PKALu00EdxU^z65$1k@O5U*Nq^HC~rg&$taoT&VxPf z0XX>*4))2iS90(c$-|o^kOf8xI>rg2oqQ)ikBP?G-$1g~`jnh7j&GK%Obgs|@GUvt z2WKs8X*rBAN4k>9Rn2dfusP5pUqL;*PjY|Yl?=D^T5OYK5Y6?z+_k=5j0i*#;OCQ? z_~aoPp%wz*EiY+=+I>^p;&qKLM&#w;I{LMB^l5&p=6f_hp!tO6w?V(Q=Hd1+)fi850;^*4oRAO`Tw2xh zLPEu0#eqj}#e)Z~YL3x7m0%WCUz%MMDK;(gOH>sD$9a0@DO?AYJmjlZ6jVnoDK!h~ znu^tqcF;%lKvzA7)tz@#=fRqxVzo-4+8XN6r0PAi`lv1-NgE+0&ho4l-8&x4sop8f|ss^!9!cPFWf<71<$RdX*#$0sI%KQ?(`qJrBhzV-oBT>T6y zs-}6Gr&EHePqJy%IGyBZF-)okflW@UMnME6UPa?Pyk_t=Xzb89pm9P2ZXMu{0*xCQ z92yTaUT9#!=Y7zS(7=BW?}w%p7}Y$(XBxC5zK{YdE<}FJb643oaNfB>y#?yG$O>ZQJrE;KumVf-@!5z_xx*w_ zAyU1#^vy4+CN2$k%lnSXjij&Ag!+VCFyFTj#ooMf;?7tGRd6!rFXG2DnBv4gPTWc4 zjAdtgMi21Sf$TtTs*HDoZwFg$U(Ql=cVx^;$BBF4jj-H%;o-G%$Gb8XR64uw4Qvd^ zr$)bSE_eP&#zG3&mg9RfhimCJJLsjbN}F_^I_ zq2u>#8@79{4VT38>5QHo5Mc2sQ7~`W(fK7GvBz}E5l{ivGuW%>-8P7YduyZ!-Y%R z9oxSs4qhtuj+MwUrM)vdx>F;0@@B<7&)D_x za{C)4@(sXW8QsB2FfCqx#`?%cC}e2;&Ag-J`#~YPwOH&O$wU=@$9gQ^S892?u(;h_ zjJ%ck;jZiW=cGcmj4;0&)!jFdN+o1b^2hcb_SD z43q)`fS<#5Z5}w$b25HXwx4`t^TJkA#(QLY&m)`rKnsVhl#KVv_Fkol%zaOG9h0$R z`(URdV_~C;QB;~vu@t3RDJsP=3rXnPDeBq+oveN_QxwC+DT6x~Doe@BR2)RGC8QP010vnbk;xw4PI_r4y{d7o>LmhHb?9B26e literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/sma_atr.cpython-313.pyc b/Q Research/strategies/__pycache__/sma_atr.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26082da64b8f4dea66e0ef6b2384613c357b3e28 GIT binary patch literal 3633 zcmbVPUrZe55&xF^%l(Dh`?DMeoWYJcgE``cDu6Lf8VBM((6GJ=HLg~B#{yT5UA(*0 zAggs1wbfZ_6Hr5e#4;eYF@31(sxP#4B&$z(+|xdIwMs3j8asY+BHAi%?R>j;$KjX< zx)QT9^UZI*nfc~+K6brc7lLQ|Pqo=`KSKY+Lg}#=ncYEPmJp3-;ygM>h(sLoRp+bD zSwu@5UA8nM+R{as))WHC8b!42GSOu*bzSBQiPi~w!toSaCJ5b0%OWdt6Z4oCxrwYK zbE58$rdfuS#Y_=nyo$cjZX<|G=rR%s8i`djAzJ7v(MnsQNVL&b(LP~I*mXNS&Auj! zPiGXK6IUo=LCL&5%}CZ z!={BFa*UJ{uJ**!f{=qnHU|WOE08T@3x z-I#EdFUjMqBr{jDWf^Z;DMK9~ka#kV8F{A2005leZ2tPQCm(D+`pbKt-?_W<$Pk#UFCriKG`Qy8v-`RL_XBl#IWvVLbX$2l1&FM%od?)dSJ+g8N0;Hz zl|mzxITy2dVOH*PMD9bNJY|&U5GXJ0D`(_A1kPWPrSA~9nhKo%5V%@8P>!?a5IC|T zzuH6Kg5_!k4uJ}lQDn-NhMrE@Gtk|1n2vN3M^VZ`JA2^k)km}z&!2_WI2!$K&rhM> zgRo8=unxV$hf>}$&Ii7OWyyii=u15xAT$Pcv=2P_%4;X~S+*eb4sJ-}Nx4e#rhFL9 z2Ef)u*I@(vPWev4=NLsu`|`6IYYmSIzKq)g;T+tk^LXDrai;X4L4-$aC6y zL%Vl&rftm7?!{+n8?d(2<%z~2-ImD-5~n+71SuH&$JJPJk zWXI<82+z&Q49CmjJX6%cz~U#K;?B^i7pxj1PxqFbGLtZ}b!Taa!5+o&r3YU$P2bdg z`=_GrEX_mRT`^%V>7f_SR^1ON84)t_q{vB=dJR^XwndGd%^2BR2Zt}bAwpN8r~idV zuRc5a)+Oe`@X&znGFWysVrDWanb)%{Cnbw_vW}T~-7fIBTXe5^ z$?7mgoTOBMdjAAzp4x@uH-_?rMp7%$0(ms=)O_Uo*&Epvo9b)KTR^|uz1Y3-J(X;Q zwf#~cvQk|LH0K>!^YPn>jfB#E_Wl*M`7OoSq_wo(?%wEDP7ZuptG1k1oN(`oA zo0LTN*2`*LU*5M5ym>_>Uj~Qa=}BGj;6yZHSF!M;tZkT{*M{tqDI9}j53 z(626N(WV6lRzz=2eC7`<^r`+M>qDyl)HZc&Jzby@n-{m5w|-pcxmai)QmG-Wv1MW4 ztL+jhc*~*r!>Yft?1x;qUWlC1{DB+SK)2}Frs{8U>%(f}D=PJhf!-!5C`TxocRh56 zH7dUT+N$?KKIWtm!w$l-5_(=596CZJ-~!gSfC$3VA~DoOtLCVJWAO zZHlMup*yfoBC#~BknM`6U8|v1UQ+x=6z7p$=xp>mG?Z}YPKM#xX^vrZ7sE^oW3xHT zdl=@*ESoE?_!(wAD@t-M%X7Q{%MOMa6EX}Vc7h*0#4z}?kh2x13C|Bk}{Agc2Tl?eW;YLXzD^P{^6zjrODD)gnHGPeC6 DLK#j} literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/xgb_signal.cpython-312.pyc b/Q Research/strategies/__pycache__/xgb_signal.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a49a3b92641d07b0da8416ec9d07135f8fad5014 GIT binary patch literal 9533 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 literal 0 HcmV?d00001 diff --git a/Q Research/strategies/__pycache__/xgb_signal.cpython-313.pyc b/Q Research/strategies/__pycache__/xgb_signal.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ac244d128f7b6de7c4c0ed91a4b78fce4eecc68 GIT binary patch literal 14035 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 a_f$QjQFY%oKB4gYL_?{4pOGxm>;DC=^ None: + super().__init__( + band_atr_mult=band_atr_mult, + rsi_long=rsi_long, + rsi_short=rsi_short, + exit_rsi_mid=exit_rsi_mid, + allow_short=allow_short, + ) + self.band_atr_mult = float(band_atr_mult) + self.rsi_long = float(rsi_long) + self.rsi_short = float(rsi_short) + self.exit_rsi_mid = float(exit_rsi_mid) + self.allow_short = bool(allow_short) + + def on_bar(self, state: dict) -> dict: + close = state.get("close") + sma_slow = state.get("sma_slow") + atr = state.get("curr_atr") + rsi = state.get("rsi") + position = state.get("position", 0) + + if close is None or sma_slow is None or atr is None or rsi is None: + return {"action": "HOLD"} + + upper = sma_slow + self.band_atr_mult * atr + lower = sma_slow - self.band_atr_mult * atr + + if position == 0: + if close <= lower and rsi <= self.rsi_long: + return {"action": "ENTER_LONG"} + if self.allow_short and close >= upper and rsi >= self.rsi_short: + return {"action": "ENTER_SHORT"} + elif position > 0: + if close >= sma_slow or rsi >= self.exit_rsi_mid: + return {"action": "EXIT_LONG"} + elif position < 0: + if close <= sma_slow or rsi <= self.exit_rsi_mid: + return {"action": "EXIT_SHORT"} + + return {"action": "HOLD"} diff --git a/Q Research/strategies/base.py b/Q Research/strategies/base.py new file mode 100644 index 0000000..d121483 --- /dev/null +++ b/Q Research/strategies/base.py @@ -0,0 +1,16 @@ +# strategies/base.py +import os +import sys +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from typing import Dict, Any + +class Strategy: + """ + 只负责“发信号”,不做撮合和资金结算。 + on_bar 输入 state,输出 {"action": "ENTER_LONG"/"EXIT_LONG"/"HOLD"}。 + """ + def __init__(self, **params: Any) -> None: + self.params = params + + def on_bar(self, state: Dict[str, Any]) -> Dict[str, Any]: + return {"action": "HOLD"} \ No newline at end of file diff --git a/Q Research/strategies/bollinger_mean_revert.py b/Q Research/strategies/bollinger_mean_revert.py new file mode 100644 index 0000000..5785f5e --- /dev/null +++ b/Q Research/strategies/bollinger_mean_revert.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import numpy as np + +from . import register +from .base import Strategy + + +@register("bollinger_mean_revert") +class BollingerMeanRevert(Strategy): + """ + Simple Bollinger-band based mean reversion strategy skeleton. + - Enters long when price falls `enter_z` standard deviations below the mean. + - Enters short symmetrically (if allow_short). + - Exits when price reverts back within `exit_z` standard deviations. + This is meant to be combined with other sleeves in portfolio tests. + """ + + def __init__( + self, + window: int = 50, + num_std: float = 2.0, + enter_z: float = 1.0, + exit_z: float = 0.2, + allow_short: bool = True, + cooldown: int = 0, + ) -> None: + super().__init__( + window=window, + num_std=num_std, + enter_z=enter_z, + exit_z=exit_z, + allow_short=allow_short, + cooldown=cooldown, + ) + self.window = int(window) + self.num_std = float(num_std) + self.enter_z = float(enter_z) + self.exit_z = float(exit_z) + self.allow_short = bool(allow_short) + self.cooldown = int(cooldown or 0) + self._next_entry_bar = 0 + + def on_bar(self, state: dict) -> dict: + closes = state.get("close_history") + bar_idx = state.get("bar_idx", 0) + position = state.get("position", 0) + if closes is None or len(closes) < self.window: + return {"action": "HOLD"} + + window_data = np.array(closes[-self.window:], dtype=float) + mean = window_data.mean() + std = window_data.std(ddof=0) + if std == 0: + return {"action": "HOLD"} + + price = float(window_data[-1]) + z_score = (price - mean) / std + + # Enforce cooldown between fresh entries + if bar_idx < self._next_entry_bar and position == 0: + return {"action": "HOLD"} + + if position == 0: + if z_score <= -self.enter_z: + self._next_entry_bar = bar_idx + self.cooldown + return {"action": "ENTER_LONG"} + if self.allow_short and z_score >= self.enter_z: + self._next_entry_bar = bar_idx + self.cooldown + return {"action": "ENTER_SHORT"} + elif position > 0: + if z_score >= -self.exit_z: + return {"action": "EXIT_LONG"} + else: # position < 0 + if z_score <= self.exit_z: + return {"action": "EXIT_SHORT"} + + return {"action": "HOLD"} diff --git a/Q Research/strategies/ma_cross.py b/Q Research/strategies/ma_cross.py new file mode 100644 index 0000000..3d0da9b --- /dev/null +++ b/Q Research/strategies/ma_cross.py @@ -0,0 +1,33 @@ +# FX_BACKTEST/strategies/ma_cross.py +import os +import sys +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from collections import deque +from core.events import TickEvent, SignalEvent + +class MACross: + def __init__(self, q, symbol: str, short: int = 20, long: int = 50, size: float = 10000.0): + assert short < long + self.q = q + self.symbol = symbol + self.short_n, self.long_n = short, long + self.short_win, self.long_win = deque(maxlen=short), deque(maxlen=long) + self.pos = 0.0 # 当前方向(>0 多 / <0 空 / =0 空仓) + self.size = size + + def on_event(self, ev): + if isinstance(ev, TickEvent) and ev.symbol == self.symbol: + mid = (ev.bid + ev.ask) / 2.0 + self.short_win.append(mid) + self.long_win.append(mid) + if len(self.long_win) < self.long_n: + return + sma_s = sum(self.short_win) / len(self.short_win) + sma_l = sum(self.long_win) / len(self.long_win) + # 交叉信号 + if self.pos <= 0 and sma_s > sma_l: # 金叉 -> 做多 + self.q.put(SignalEvent(ev.ts, self.symbol, "LONG", self.size)) + self.pos = 1 + elif self.pos >= 0 and sma_s < sma_l: # 死叉 -> 做空 + self.q.put(SignalEvent(ev.ts, self.symbol, "SHORT", self.size)) + self.pos = -1 \ No newline at end of file diff --git a/Q Research/strategies/ma_crossover.py b/Q Research/strategies/ma_crossover.py new file mode 100644 index 0000000..6e39c56 --- /dev/null +++ b/Q Research/strategies/ma_crossover.py @@ -0,0 +1,94 @@ +"""Simple moving-average crossover strategy registered for StrategyEngine combos.""" + +from __future__ import annotations + +from typing import Dict, Any + +from . import register +from .base import Strategy + + +@register("ma_crossover") +class MovingAverageCrossover(Strategy): + """ + Emits ENTER/EXIT signals when a fast SMA crosses a slow SMA. + + State inputs expected from StrategyEngine: + - sma_fast / sma_slow + - bar_idx (int) + - position_units (float) + - default_qty (float) + """ + + def __init__( + self, + size_mult: float = 1.0, + cooldown_bars: int = 0, + exit_buffer_pct: float = 0.0, + allow_short: bool = True, + ) -> None: + super().__init__( + size_mult=size_mult, + cooldown_bars=cooldown_bars, + exit_buffer_pct=exit_buffer_pct, + allow_short=allow_short, + ) + self.size_mult = float(size_mult) + self.cooldown_bars = int(max(0, cooldown_bars)) + self.exit_buffer_pct = float(max(0.0, exit_buffer_pct)) + self.allow_short = bool(allow_short) + + self._prev_fast: float | None = None + self._prev_slow: float | None = None + self._block_until: int = 0 + + def on_bar(self, state: Dict[str, Any]) -> Dict[str, Any]: + fast = state.get("sma_fast") + slow = state.get("sma_slow") + bar_idx = int(state.get("bar_idx", 0) or 0) + position = float(state.get("position_units", 0.0) or 0.0) + + if fast is None or slow is None: + return {"action": "HOLD"} + + prev_fast = self._prev_fast + prev_slow = self._prev_slow + self._prev_fast = fast + self._prev_slow = slow + + if prev_fast is None or prev_slow is None: + return {"action": "HOLD"} + + if bar_idx < self._block_until: + return {"action": "HOLD"} + + buffer = self.exit_buffer_pct + size = self._position_size(state) + + crossed_up = prev_fast <= prev_slow and fast > slow + crossed_down = prev_fast >= prev_slow and fast < slow + + if crossed_up: + self._block_until = bar_idx + self.cooldown_bars + return {"action": "ENTER_LONG", "size": size} + + if crossed_down and self.allow_short: + self._block_until = bar_idx + self.cooldown_bars + return {"action": "ENTER_SHORT", "size": size} + + slow_with_buffer = slow * (1.0 + buffer) + slow_lower = slow * (1.0 - buffer) + + if position > 0 and fast < slow_lower: + return {"action": "EXIT_LONG"} + + if position < 0 and (fast > slow_with_buffer or not self.allow_short): + return {"action": "EXIT_SHORT"} + + return {"action": "HOLD"} + + def _position_size(self, state: Dict[str, Any]) -> float | None: + default_qty = state.get("default_qty") + if default_qty is None: + return None + return float(default_qty) * self.size_mult diff --git a/Q Research/strategies/mean_reversion.py b/Q Research/strategies/mean_reversion.py new file mode 100644 index 0000000..d166b1f --- /dev/null +++ b/Q Research/strategies/mean_reversion.py @@ -0,0 +1,17 @@ +import pandas as pd + +def mean_reversion_strategy(df: pd.DataFrame, short_window=20, long_window=50): + """ + 简单的均值回归策略(示例): + - 价格高于长期均线则卖出 + - 价格低于长期均线则买入 + """ + df = df.copy() + df["short_ma"] = df["close"].rolling(window=short_window).mean() + df["long_ma"] = df["close"].rolling(window=long_window).mean() + + df["signal"] = 0 + df.loc[df["short_ma"] > df["long_ma"], "signal"] = 1 # 买入 + df.loc[df["short_ma"] < df["long_ma"], "signal"] = -1 # 卖出 + + return df \ No newline at end of file diff --git a/Q Research/strategies/momentum.py b/Q Research/strategies/momentum.py new file mode 100644 index 0000000..c543d5f --- /dev/null +++ b/Q Research/strategies/momentum.py @@ -0,0 +1,104 @@ +"""Basic momentum breakout strategy usable inside StrategyEngine combos.""" + +from __future__ import annotations + +from typing import Dict, Any, Sequence + +from . import register +from .base import Strategy + + +@register("momentum_breakout") +class MomentumBreakout(Strategy): + """ + Uses rate-of-change over a configurable lookback to enter in the dominant direction. + + Parameters + ---------- + lookback : int + Number of bars between comparisons. + enter_threshold : float + Minimum absolute return (%) to trigger an entry (e.g. 0.002 = 20 bps). + exit_threshold : float + Momentum magnitude below which existing positions are flattened. + size_mult : float + Multiplier applied to StrategyEngine default_qty when sizing trades. + allow_short : bool + Whether to take short trades when momentum turns negative. + cooldown_bars : int + Minimum bars between successive entries. + """ + + def __init__( + self, + lookback: int = 24, + enter_threshold: float = 0.0015, + exit_threshold: float = 0.0005, + size_mult: float = 1.0, + allow_short: bool = True, + cooldown_bars: int = 0, + ) -> None: + super().__init__( + lookback=lookback, + enter_threshold=enter_threshold, + exit_threshold=exit_threshold, + size_mult=size_mult, + allow_short=allow_short, + cooldown_bars=cooldown_bars, + ) + self.lookback = max(1, int(lookback)) + self.enter_threshold = float(enter_threshold) + self.exit_threshold = float(exit_threshold) + self.size_mult = float(size_mult) + self.allow_short = bool(allow_short) + self.cooldown_bars = int(max(0, cooldown_bars)) + + self._block_until: int = 0 + + def on_bar(self, state: Dict[str, Any]) -> Dict[str, Any]: + closes = state.get("close_history") + bar_idx = int(state.get("bar_idx", 0) or 0) + position = float(state.get("position_units", 0.0) or 0.0) + + if not self._has_enough_history(closes): + return {"action": "HOLD"} + + roc = self._rate_of_change(closes) + + if position > 0 and roc < self.exit_threshold: + return {"action": "EXIT_LONG"} + if position < 0 and roc > -self.exit_threshold: + return {"action": "EXIT_SHORT"} + + if bar_idx < self._block_until: + return {"action": "HOLD"} + + size = self._position_size(state) + if roc >= self.enter_threshold: + self._block_until = bar_idx + self.cooldown_bars + return {"action": "ENTER_LONG", "size": size} + if roc <= -self.enter_threshold and self.allow_short: + self._block_until = bar_idx + self.cooldown_bars + return {"action": "ENTER_SHORT", "size": size} + + return {"action": "HOLD"} + + def _has_enough_history(self, closes: Any) -> bool: + if closes is None: + return False + if isinstance(closes, Sequence): + return len(closes) > self.lookback + return False + + def _rate_of_change(self, closes: Sequence[float]) -> float: + recent = float(closes[-1]) + past = float(closes[-(self.lookback + 1)]) + if past == 0: + return 0.0 + return (recent - past) / past + + def _position_size(self, state: Dict[str, Any]) -> float | None: + default_qty = state.get("default_qty") + if default_qty is None: + return None + return float(default_qty) * self.size_mult diff --git a/Q Research/strategies/regime_sma.py b/Q Research/strategies/regime_sma.py new file mode 100644 index 0000000..42af889 --- /dev/null +++ b/Q Research/strategies/regime_sma.py @@ -0,0 +1,246 @@ +# strategies/regime_sma.py + +from __future__ import annotations + +from datetime import datetime + +from . import register +from .base import Strategy +from .sma_atr import SmaAtr + + +@register("regime_sma") +class RegimeSMAStrategy(Strategy): + """ + Wraps the SMA+ATR strategy with a simple regime filter. + + - 在趋势 regime(由 StrategyEngine 提供)下,沿用 SmaAtr 信号。 + - 在震荡 regime 下,可选择保持空仓或使用简单的 RSI 均值回归。 + """ + + def __init__( + self, + trend_params: dict | None = None, + range_mode: str = "flat", + range_rsi_high: float = 75.0, + range_rsi_low: float = 25.0, + trend_min_bars: int = 0, + atr_percentile_min: float | None = None, + size_tiers: list | None = None, + base_size_mult: float = 1.0, + htf_alignment: bool = False, + htf_rsi_range: tuple[float, float] | list | None = None, + risk_rules: list | None = None, + ) -> None: + super().__init__() + self.range_mode = (range_mode or "flat").lower() + self.range_rsi_high = float(range_rsi_high) + self.range_rsi_low = float(range_rsi_low) + self.range_exit_mid = (self.range_rsi_high + self.range_rsi_low) / 2.0 + self.trend_min_bars = int(trend_min_bars or 0) + self.atr_percentile_min = atr_percentile_min if atr_percentile_min is None else float(atr_percentile_min) + self.base_size_mult = float(base_size_mult or 1.0) + self.size_tiers = self._normalize_tiers(size_tiers) + self.htf_alignment = bool(htf_alignment) + if htf_rsi_range: + lo, hi = htf_rsi_range + self.htf_rsi_min = float(lo) + self.htf_rsi_max = float(hi) + else: + self.htf_rsi_min = 30.0 + self.htf_rsi_max = 70.0 + self.risk_rules = risk_rules or [] + + trend_params = trend_params or {} + self.trend_strategy = SmaAtr(**trend_params) + + def on_bar(self, state: dict): + cooldown = self._check_risk_rules(state) + if cooldown: + return {"action": "HOLD", "cooldown_bars": cooldown} + + regime_label = state.get("regime_label", "unknown") + trend_streak = int(state.get("regime_trend_bars", 0) or 0) + atr_percentile = state.get("atr_percentile") + base_signal = self.trend_strategy.on_bar(state) or {} + action = base_signal.get("action", "HOLD") + + if regime_label == "trend": + if self.trend_min_bars and trend_streak < self.trend_min_bars: + regime_label = "range" + elif self.atr_percentile_min is not None: + if atr_percentile is None or atr_percentile < self.atr_percentile_min: + regime_label = "range" + if regime_label == "trend" and action.startswith("ENTER"): + if not self._passes_htf_filter(action, state): + regime_label = "range" + else: + sized = dict(base_signal) + sized["size"] = self._position_size(action, state) + return sized + elif regime_label == "trend": + return base_signal + + # 在趋势 regime,直接沿用趋势策略的信号 + if regime_label == "trend": + return base_signal + + # 非趋势 regime:允许趋势策略发出的平仓指令生效,但屏蔽入场 + if action.startswith("EXIT"): + return base_signal + + # 根据 range_mode 决定行为 + range_action = self._range_signal(state) + if range_action: + return {"action": range_action} + + # 默认保持空仓 + if state.get("position"): + # 持仓状态下交给风控(risk exit)或趋势策略的平仓指令处理 + return {"action": "HOLD"} + return {"action": "HOLD"} + + def _range_signal(self, state: dict) -> str | None: + if self.range_mode != "mean_revert": + return None + rsi = state.get("rsi") + if rsi is None: + return None + + position = state.get("position", 0) + if position == 0: + if rsi >= self.range_rsi_high: + return "ENTER_SHORT" + if rsi <= self.range_rsi_low: + return "ENTER_LONG" + elif position > 0 and rsi >= self.range_exit_mid: + return "EXIT_LONG" + elif position < 0 and rsi <= self.range_exit_mid: + return "EXIT_SHORT" + return None + + def _passes_htf_filter(self, action: str, state: dict) -> bool: + if not self.htf_alignment: + return True + htf_ema = state.get("htf_ema") + if htf_ema is None: + return False + close = state.get("close") + if close is None: + return False + if action == "ENTER_LONG" and close < htf_ema: + return False + if action == "ENTER_SHORT" and close > htf_ema: + return False + htf_rsi = state.get("htf_rsi") + if htf_rsi is not None: + if htf_rsi < self.htf_rsi_min or htf_rsi > self.htf_rsi_max: + return False + return True + + def _normalize_tiers(self, tiers: list | None) -> list: + if not tiers: + return [{"size_mult": self.base_size_mult}] + normalized = [] + for tier in tiers: + if not isinstance(tier, dict): + continue + entry = tier.copy() + entry["size_mult"] = float(entry.get("size_mult", 1.0)) + normalized.append(entry) + if not normalized: + normalized.append({"size_mult": self.base_size_mult}) + normalized.sort(key=lambda t: t.get("size_mult", 0), reverse=True) + return normalized + + def _position_size(self, action: str, state: dict) -> float: + base_qty = float(state.get("default_qty") or 0.0) + if base_qty <= 0: + return 0.0 + atr_pct = state.get("atr_percentile") + trend_strength = state.get("trend_strength") + streak = int(state.get("regime_trend_bars", 0) or 0) + for tier in self.size_tiers: + if self._tier_matches(tier, atr_pct, trend_strength, streak, action): + return base_qty * tier.get("size_mult", 1.0) + return base_qty * self.base_size_mult + + def _tier_matches(self, tier: dict, atr_pct, trend_strength, streak: int, action: str) -> bool: + min_atr = tier.get("min_atr_pct") + max_atr = tier.get("max_atr_pct") + min_strength = tier.get("min_trend_strength") + min_streak = tier.get("min_trend_bars") + allow_short = tier.get("allow_short") + allow_long = tier.get("allow_long") + if min_atr is not None: + if atr_pct is None or atr_pct < float(min_atr): + return False + if max_atr is not None and atr_pct is not None: + if atr_pct > float(max_atr): + return False + if min_strength is not None: + if trend_strength is None or trend_strength < float(min_strength): + return False + if min_streak is not None and streak < int(min_streak): + return False + if action == "ENTER_LONG" and allow_long is False: + return False + if action == "ENTER_SHORT" and allow_short is False: + return False + return True + + def _check_risk_rules(self, state: dict) -> int: + if not self.risk_rules: + return 0 + atr_pct = state.get("atr_percentile") + ts = self._to_datetime(state.get("ts")) + for rule in self.risk_rules: + rtype = rule.get("type") + if rtype == "atr_percentile": + min_v = rule.get("min") + max_v = rule.get("max") + triggered = False + if min_v is not None: + if atr_pct is None or atr_pct < float(min_v): + triggered = True + if max_v is not None and atr_pct is not None and atr_pct > float(max_v): + triggered = True + if triggered: + return int(rule.get("cooldown_bars", 0) or 0) + elif rtype == "calendar": + if ts is None: + continue + dates = rule.get("dates") or [] + date_str = ts.strftime("%Y-%m-%d") + if date_str in dates: + return int(rule.get("cooldown_bars", 0) or 0) + windows = rule.get("windows") or [] + for window in windows: + start = self._to_datetime(window.get("start")) + end = self._to_datetime(window.get("end")) + if start and end and start <= ts <= end: + return int(rule.get("cooldown_bars", 0) or 0) + elif rtype == "time_window": + start = self._to_datetime(rule.get("start")) + end = self._to_datetime(rule.get("end")) + if ts and start and end and start <= ts <= end: + return int(rule.get("cooldown_bars", 0) or 0) + return 0 + + def _to_datetime(self, value): + if value is None: + return None + if hasattr(value, "to_pydatetime"): + try: + return value.to_pydatetime() + except Exception: + pass + if isinstance(value, datetime): + return value + text = str(value) + if not text: + return None + try: + return datetime.fromisoformat(text.replace("Z", "+00:00")) + except Exception: + return None diff --git a/Q Research/strategies/sma_atr.py b/Q Research/strategies/sma_atr.py new file mode 100644 index 0000000..bb51a96 --- /dev/null +++ b/Q Research/strategies/sma_atr.py @@ -0,0 +1,110 @@ +# strategies/sma_atr.py +from .base import Strategy +from . import register + +@register("sma_atr") +class SmaAtr(Strategy): + """ + 参数(与 runner 对齐): + - long_only_above_slow: bool + - allow_short: bool + - short_only_below_slow: bool + - slope_lookback: int + - cooldown: int + - fast_win: int + - slow_win: int + - atr_sl / atr_tp / atr_window(仅用于记录,不在策略层计算) + """ + def on_bar(self, state): + c = state["close"] + position = state["position"] + rsi = state.get("rsi") + sma_fast = state.get("sma_fast") + sma_slow = state.get("sma_slow") + bar_idx = state["bar_idx"] + next_entry_bar_idx_long = state.get("next_entry_bar_idx_long", 0) + next_entry_bar_idx_short = state.get("next_entry_bar_idx_short", 0) + sma_fast_hist = state["sma_fast_hist"] # deque,最近值在右侧 + + fast_win = self.params.get("fast_win") + slow_win = self.params.get("slow_win") + long_only_above_slow = self.params.get("long_only_above_slow", False) + slope_lookback = self.params.get("slope_lookback", 0) + cooldown = self.params.get("cooldown", 0) + allow_short = self.params.get("allow_short", True) + short_only_below_slow = self.params.get("short_only_below_slow", False) + rsi_long_thresh = self.params.get("rsi_long_thresh") + rsi_short_thresh = self.params.get("rsi_short_thresh") + + # 均线就绪才判断 + if sma_fast is None or sma_slow is None: + return {"action": "HOLD"} + + go_long = (position == 0) and (sma_fast > sma_slow) + exit_long = (position == 1) and (sma_fast < sma_slow) + + # 仅做多需在慢均线上方 + if long_only_above_slow and go_long: + if not (c > sma_slow): + go_long = False + + # fast 斜率确认 + if slope_lookback and go_long: + if len(sma_fast_hist) > slope_lookback: + # 最近一个值与 L 根前比较 + if not (sma_fast_hist[-1] > sma_fast_hist[-1 - slope_lookback]): + go_long = False + else: + go_long = False + + # 冷却 + if cooldown and go_long: + if bar_idx < next_entry_bar_idx_long: + go_long = False + + # RSI 过滤(如果配置了阈值且 RSI 可用) + if go_long and rsi_long_thresh is not None: + if rsi is None: + go_long = False + else: + if not (rsi > float(rsi_long_thresh)): + go_long = False + + go_short = False + exit_short = False + if allow_short: + go_short = (position == 0) and (sma_fast < sma_slow) + exit_short = (position == -1) and (sma_fast > sma_slow) + + if short_only_below_slow and go_short: + if not (c < sma_slow): + go_short = False + + if slope_lookback and go_short: + if len(sma_fast_hist) > slope_lookback: + if not (sma_fast_hist[-1] < sma_fast_hist[-1 - slope_lookback]): + go_short = False + else: + go_short = False + + if cooldown and go_short: + if bar_idx < next_entry_bar_idx_short: + go_short = False + + # RSI 过滤(空头侧) + if go_short and rsi_short_thresh is not None: + if rsi is None: + go_short = False + else: + if not (rsi < float(rsi_short_thresh)): + go_short = False + + if exit_long: + return {"action": "EXIT_LONG"} + if exit_short: + return {"action": "EXIT_SHORT"} + if go_long: + return {"action": "ENTER_LONG"} + if go_short: + return {"action": "ENTER_SHORT"} + return {"action": "HOLD"} diff --git a/Q Research/strategies/xgb_signal.py b/Q Research/strategies/xgb_signal.py new file mode 100644 index 0000000..eaf3b84 --- /dev/null +++ b/Q Research/strategies/xgb_signal.py @@ -0,0 +1,274 @@ +"""XGBoost-based signal strategy (long-only v1). + +Loads a trained Booster + feature list + thresholds and emits ENTER_LONG/EXIT_LONG +decisions based on predicted probability compared to configured thresholds. + +Registration name: "xgb_signal" +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +import numpy as np +from loguru import logger + +from . import register +from .base import Strategy + + +def _safe_get(d: Dict[str, Any], key: str, default=None): + v = d.get(key, default) + return v if v is not None else default + + +def _rsi_from_series(arr: np.ndarray, period: int = 14) -> float | None: + if arr.size < period + 1: + return None + diffs = np.diff(arr) + gains = diffs[diffs > 0] + losses = -diffs[diffs < 0] + avg_gain = gains.mean() if gains.size > 0 else 0.0 + avg_loss = losses.mean() if losses.size > 0 else 0.0 + if avg_loss == 0.0 and avg_gain == 0.0: + return 50.0 + if avg_loss == 0.0: + return 100.0 + rs = avg_gain / avg_loss + return 100.0 - (100.0 / (1.0 + rs)) + + +@register("xgb_signal") +class XGBSignal(Strategy): + def __init__( + self, + model_dir: Optional[str] = None, + latest_ptr: str = "QuantResearch/artifacts/models/usdjpy_h1_xgb_latest.json", + prob_long: Optional[float] = None, + prob_exit: Optional[float] = None, + size_mult: float = 1.0, + cooldown_bars: int = 0, + min_atr_pct: Optional[float] = None, + low_atr_pct: Optional[float] = None, + prob_long_low: Optional[float] = None, + cooldown_low: Optional[int] = None, + min_vol_24: Optional[float] = None, + atr_relax_pct: Optional[float] = None, + prob_long_relaxed: Optional[float] = None, + cooldown_relaxed: Optional[int] = None, + debug_log_hits: bool = False, + ) -> None: + super().__init__() + # Resolve model directory + if not model_dir: + ptr = Path(latest_ptr) + if not ptr.exists(): + raise RuntimeError(f"latest.json not found: {ptr}") + latest = json.loads(ptr.read_text(encoding="utf-8")) + model_dir = latest.get("model_dir") + if not model_dir: + raise RuntimeError("latest.json missing 'model_dir'") + self.model_dir = Path(model_dir) + # Load artifacts + self.feature_list: List[str] = json.loads((self.model_dir / "feature_list.json").read_text(encoding="utf-8")) + thr = json.loads((self.model_dir / "thresholds.json").read_text(encoding="utf-8")) + self.p_long = float(prob_long) if prob_long is not None else float(thr.get("p_long", 0.6)) + self.p_exit = float(prob_exit) if prob_exit is not None else float(thr.get("p_exit", 0.5)) + try: + import xgboost as xgb + except Exception as exc: + raise RuntimeError("xgboost is required at runtime for xgb_signal.") from exc + self._xgb = xgb + self._booster = xgb.Booster() + self._booster.load_model(str(self.model_dir / "model.json")) + + self.size_mult = float(size_mult) + self.cooldown_bars = int(max(0, cooldown_bars)) + self.cooldown_relaxed = int(max(0, cooldown_relaxed)) if cooldown_relaxed is not None else None + self.min_atr_pct = float(min_atr_pct) if min_atr_pct is not None else None + self.low_atr_pct = float(low_atr_pct) if low_atr_pct is not None else None + self.prob_long_low = float(prob_long_low) if prob_long_low is not None else None + self.cooldown_low = int(max(0, cooldown_low)) if cooldown_low is not None else None + self.min_vol_24 = float(min_vol_24) if min_vol_24 is not None else None + self.atr_relax_pct = float(atr_relax_pct) if atr_relax_pct is not None else None + self.p_long_relaxed = float(prob_long_relaxed) if prob_long_relaxed is not None else None + self.debug_log_hits = bool(debug_log_hits) + self._block_until: int = 0 + self._debug_max = 0.0 + self._debug_none = 0 + + def _note_feature_miss(self, reason: str) -> None: + self._debug_none += 1 + if self.debug_log_hits and self._debug_none <= 10: + logger.warning(f"[xgb_signal] feature unavailable ({reason})") + + def _features_from_state(self, state: Dict[str, Any]) -> tuple[Optional[np.ndarray], Optional[float]]: + # Close history for returns/volatility + ch = state.get("close_history") + if ch is None: + self._note_feature_miss("close_history missing") + return None, None + closes = np.asarray(ch, dtype=float) + if closes.size < 80: # need at least slow window context + self._note_feature_miss("insufficient history") + return None, None + close = float(state.get("close", closes[-1])) + + # Returns & rolling vol + def pct_change(arr: np.ndarray, k: int) -> float | None: + if arr.size <= k: + self._note_feature_miss(f"ret_{k} insufficient") + return None, None + a, b = arr[-k - 1], arr[-1] + return (b - a) / a if a else None + + ret_1 = pct_change(closes, 1) + ret_3 = pct_change(closes, 3) + ret_6 = pct_change(closes, 6) + vol_24 = None + if closes.size >= 25: + rets = np.diff(closes[-25:]) / closes[-25:-1] + vol_24 = float(np.std(rets)) if rets.size else None + + # SMA diff + sma_fast = _safe_get(state, "sma_fast") + sma_slow = _safe_get(state, "sma_slow") + if sma_fast is None or sma_slow is None: + sma_fast = float(np.mean(closes[-20:])) if closes.size >= 20 else None + sma_slow = float(np.mean(closes[-80:])) if closes.size >= 80 else None + if sma_fast is None or sma_slow is None: + self._note_feature_miss("sma missing") + return None, None + sma_diff = (float(sma_fast) - float(sma_slow)) / close if close else 0.0 + + # RSI (prefer engine state, else compute) + rsi_val = state.get("rsi") + if rsi_val is None: + r = _rsi_from_series(closes, 14) + rsi_val = r if r is not None else 50.0 + + # ATR normalized + curr_atr = state.get("curr_atr") + atr_norm = float(curr_atr) / close if (curr_atr is not None and close) else 0.0 + + # Time features from ts + ts = state.get("ts") + if ts is None: + self._note_feature_miss("timestamp missing") + return None, None + try: + import pandas as pd + ts_pd = pd.Timestamp(ts) + hour = float(ts_pd.hour) + dow = float(ts_pd.dayofweek) + except Exception: + self._note_feature_miss("timestamp parse") + return None, None + hour_sin, hour_cos = np.sin(2 * np.pi * hour / 24.0), np.cos(2 * np.pi * hour / 24.0) + dow_sin, dow_cos = np.sin(2 * np.pi * dow / 7.0), np.cos(2 * np.pi * dow / 7.0) + + feat_map = { + "ret_1": ret_1, + "ret_3": ret_3, + "ret_6": ret_6, + "vol_24": vol_24, + "sma_diff": sma_diff, + "rsi": float(rsi_val), + "atr_norm": atr_norm, + "hour_sin": float(hour_sin), + "hour_cos": float(hour_cos), + "dow_sin": float(dow_sin), + "dow_cos": float(dow_cos), + } + vec = [] + for name in self.feature_list: + val = feat_map.get(name) + if val is None or (isinstance(val, float) and (np.isnan(val) or np.isinf(val))): + self._note_feature_miss(f"feature {name} invalid") + return None, None + vec.append(float(val)) + return np.asarray(vec, dtype=float), float(vol_24) if vol_24 is not None else None + + def on_bar(self, state: Dict[str, Any]) -> Dict[str, Any]: + bar_idx = int(state.get("bar_idx", 0) or 0) + position_units = float(state.get("position_units", 0.0) or 0.0) + default_qty = float(state.get("default_qty", 0.0) or 0.0) + close = float(state.get("close", 0.0) or 0.0) + + atr_pct = state.get("atr_percentile") + if self.min_atr_pct is not None: + if atr_pct is None or float(atr_pct) < self.min_atr_pct: + return {"action": "HOLD"} + + # Determine per-bar entry threshold / cooldown after ATR gating + effective_prob_long = self.p_long + effective_cooldown = self.cooldown_bars + if ( + self.low_atr_pct is not None + and atr_pct is not None + and float(atr_pct) < self.low_atr_pct + ): + if self.prob_long_low is not None: + effective_prob_long = self.prob_long_low + if self.cooldown_low is not None: + effective_cooldown = self.cooldown_low + if ( + self.atr_relax_pct is not None + and atr_pct is not None + and float(atr_pct) >= self.atr_relax_pct + ): + if self.p_long_relaxed is not None: + effective_prob_long = self.p_long_relaxed + if self.cooldown_relaxed is not None: + effective_cooldown = self.cooldown_relaxed + + # Cooldown gate (updated per bar) + if bar_idx < self._block_until: + return {"action": "HOLD"} + + feats, vol_24 = self._features_from_state(state) + if feats is None: + return {"action": "HOLD"} + if self.min_vol_24 is not None: + if vol_24 is None or vol_24 < self.min_vol_24: + return {"action": "HOLD"} + + dmat = self._xgb.DMatrix(feats.reshape(1, -1), feature_names=self.feature_list) + p_up = float(self._booster.predict(dmat)[0]) + if p_up > self._debug_max: + self._debug_max = p_up + if self.debug_log_hits: + logger.info( + "[xgb_signal] new max prob %.4f (ts=%s close=%.5f position=%s)", + p_up, + state.get("ts"), + close, + position_units, + ) + + # Long-only logic + if position_units == 0.0: + if p_up >= effective_prob_long and default_qty > 0.0: + self._block_until = bar_idx + effective_cooldown + if self.debug_log_hits: + logger.info( + "[xgb_signal] ENTER signal p=%.4f (thr=%.4f) ts=%s", + p_up, + effective_prob_long, + state.get("ts"), + ) + return {"action": "ENTER_LONG", "size": default_qty * self.size_mult} + return {"action": "HOLD"} + else: + if p_up < self.p_exit: + if self.debug_log_hits: + logger.info( + "[xgb_signal] EXIT signal p=%.4f (thr=%.4f) ts=%s", + p_up, + self.p_exit, + state.get("ts"), + ) + return {"action": "EXIT_LONG"} + return {"action": "HOLD"}