From ef6e1e278d954a2af9a9fb462ca8c18ac0fa862d Mon Sep 17 00:00:00 2001 From: xiaochuan Date: Fri, 14 Nov 2025 22:56:44 +0000 Subject: [PATCH] Add files via upload --- QuantTrader/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 164 bytes QuantTrader/artifacts/README.md | 17 + .../artifacts/config/eurusd_regime.yaml | 42 +++ .../config/optimized_eurusd_v2_with_rsi.yaml | 27 ++ .../optimized_eurusd_v2_with_rsi_best.yaml | 27 ++ .../artifacts/config/usdjpy_combo.yaml | 77 +++++ .../artifacts/config/usdjpy_regime.yaml | 42 +++ .../artifacts/config/usdjpy_sharpe239.yaml | 58 ++++ .../artifacts/params/best_params_grid.json | 13 + .../params/best_params_grid_USDJPY.json | 13 + .../artifacts/params/usdjpy_sharpe239.json | 27 ++ QuantTrader/bin/post_session.sh | 30 ++ QuantTrader/config/eurusd_multi_strategy.yaml | 33 ++ QuantTrader/config/risk_limits.yaml | 22 ++ QuantTrader/config/risk_limits_sim.yaml | 28 ++ QuantTrader/config/usdjpy_multi_strategy.yaml | 46 +++ QuantTrader/config/usdjpy_xgb.yaml | 38 +++ QuantTrader/core/__init__.py | 10 + .../core/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 402 bytes .../core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 402 bytes .../core/__pycache__/events.cpython-312.pyc | Bin 0 -> 2031 bytes .../core/__pycache__/events.cpython-313.pyc | Bin 0 -> 2139 bytes .../oanda_execution.cpython-312.pyc | Bin 0 -> 4359 bytes .../oanda_execution.cpython-313.pyc | Bin 0 -> 4450 bytes QuantTrader/core/data/__init__.py | 3 + .../data/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 223 bytes .../data/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 223 bytes .../data/__pycache__/base.cpython-312.pyc | Bin 0 -> 5142 bytes .../data/__pycache__/base.cpython-313.pyc | Bin 0 -> 4839 bytes .../data/__pycache__/oanda.cpython-312.pyc | Bin 0 -> 12229 bytes .../data/__pycache__/oanda.cpython-313.pyc | Bin 0 -> 12532 bytes QuantTrader/core/data/base.py | 136 ++++++++ QuantTrader/core/data/oanda.py | 257 +++++++++++++++ QuantTrader/core/events.py | 38 +++ QuantTrader/core/execution/__init__.py | 14 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 444 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 446 bytes .../__pycache__/base.cpython-312.pyc | Bin 0 -> 7744 bytes .../__pycache__/base.cpython-313.pyc | Bin 0 -> 7786 bytes .../__pycache__/oanda_handler.cpython-312.pyc | Bin 0 -> 6893 bytes .../__pycache__/oanda_handler.cpython-313.pyc | Bin 0 -> 7023 bytes QuantTrader/core/execution/base.py | 182 +++++++++++ QuantTrader/core/execution/oanda_handler.py | 117 +++++++ QuantTrader/core/oanda_execution.py | 93 ++++++ QuantTrader/core/risk/__init__.py | 2 + .../risk/__pycache__/__init__.cpython-312.pyc | Bin 0 -> 194 bytes .../risk/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 194 bytes .../risk/__pycache__/base.cpython-312.pyc | Bin 0 -> 5959 bytes .../risk/__pycache__/base.cpython-313.pyc | Bin 0 -> 5846 bytes .../__pycache__/risk_engine.cpython-312.pyc | Bin 0 -> 5729 bytes QuantTrader/core/risk/base.py | 161 ++++++++++ QuantTrader/core/risk/risk_engine.py | 70 +++++ QuantTrader/core/strategy/__init__.py | 3 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 240 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 240 bytes .../strategy/__pycache__/base.cpython-312.pyc | Bin 0 -> 5828 bytes .../strategy/__pycache__/base.cpython-313.pyc | Bin 0 -> 5786 bytes .../rsi_mean_reversion.cpython-312.pyc | Bin 0 -> 6452 bytes .../rsi_mean_reversion.cpython-313.pyc | Bin 0 -> 6300 bytes QuantTrader/core/strategy/base.py | 122 ++++++++ QuantTrader/core/strategy/ma_crossover.py | 65 ++++ QuantTrader/core/strategy/momentum.py | 46 +++ .../core/strategy/rsi_mean_reversion.py | 150 +++++++++ .../__pycache__/oanda_stream.cpython-312.pyc | Bin 0 -> 6860 bytes .../__pycache__/oanda_stream.cpython-313.pyc | Bin 0 -> 7082 bytes QuantTrader/data/oanda_stream.py | 116 +++++++ .../__pycache__/adapter.cpython-312.pyc | Bin 0 -> 5079 bytes .../__pycache__/config_loader.cpython-312.pyc | Bin 0 -> 1555 bytes .../metrics_logger.cpython-312.pyc | Bin 0 -> 3286 bytes .../__pycache__/oanda_adapter.cpython-312.pyc | Bin 0 -> 9440 bytes .../__pycache__/order_store.cpython-312.pyc | Bin 0 -> 2103 bytes .../__pycache__/paper_adapter.cpython-312.pyc | Bin 0 -> 7785 bytes QuantTrader/execution/adapter.py | 102 ++++++ QuantTrader/execution/config_loader.py | 24 ++ QuantTrader/execution/metrics_logger.py | 51 +++ QuantTrader/execution/oanda_adapter.py | 134 ++++++++ QuantTrader/execution/order_store.py | 28 ++ QuantTrader/execution/paper_adapter.py | 111 +++++++ QuantTrader/logs/oanda_trading.log | 14 + QuantTrader/requirements.txt | 10 + QuantTrader/results/execution/live/fills.csv | 3 + QuantTrader/results/execution/paper/fills.csv | 3 + .../results/execution/tca_summary.json | 14 + QuantTrader/scripts/live_trade.py | 58 ++++ QuantTrader/scripts/paper_trade.py | 296 ++++++++++++++++++ QuantTrader/tests/__init__.py | 1 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 170 bytes .../test_execution_adapters.cpython-312.pyc | Bin 0 -> 4087 bytes .../test_risk_engine.cpython-312.pyc | Bin 0 -> 2331 bytes QuantTrader/tests/test_execution_adapters.py | 63 ++++ QuantTrader/tests/test_risk_engine.py | 34 ++ README.md | 168 ++++++++++ docker-compose.yml | 32 ++ main.py | 23 ++ metrics/execution.csv | 5 + 96 files changed, 3300 insertions(+) create mode 100644 QuantTrader/__init__.py create mode 100644 QuantTrader/__pycache__/__init__.cpython-312.pyc create mode 100644 QuantTrader/artifacts/README.md create mode 100644 QuantTrader/artifacts/config/eurusd_regime.yaml create mode 100644 QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi.yaml create mode 100644 QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi_best.yaml create mode 100644 QuantTrader/artifacts/config/usdjpy_combo.yaml create mode 100644 QuantTrader/artifacts/config/usdjpy_regime.yaml create mode 100644 QuantTrader/artifacts/config/usdjpy_sharpe239.yaml create mode 100644 QuantTrader/artifacts/params/best_params_grid.json create mode 100644 QuantTrader/artifacts/params/best_params_grid_USDJPY.json create mode 100644 QuantTrader/artifacts/params/usdjpy_sharpe239.json create mode 100644 QuantTrader/bin/post_session.sh create mode 100644 QuantTrader/config/eurusd_multi_strategy.yaml create mode 100644 QuantTrader/config/risk_limits.yaml create mode 100644 QuantTrader/config/risk_limits_sim.yaml create mode 100644 QuantTrader/config/usdjpy_multi_strategy.yaml create mode 100644 QuantTrader/config/usdjpy_xgb.yaml create mode 100644 QuantTrader/core/__init__.py create mode 100644 QuantTrader/core/__pycache__/__init__.cpython-312.pyc create mode 100644 QuantTrader/core/__pycache__/__init__.cpython-313.pyc create mode 100644 QuantTrader/core/__pycache__/events.cpython-312.pyc create mode 100644 QuantTrader/core/__pycache__/events.cpython-313.pyc create mode 100644 QuantTrader/core/__pycache__/oanda_execution.cpython-312.pyc create mode 100644 QuantTrader/core/__pycache__/oanda_execution.cpython-313.pyc create mode 100644 QuantTrader/core/data/__init__.py create mode 100644 QuantTrader/core/data/__pycache__/__init__.cpython-312.pyc create mode 100644 QuantTrader/core/data/__pycache__/__init__.cpython-313.pyc create mode 100644 QuantTrader/core/data/__pycache__/base.cpython-312.pyc create mode 100644 QuantTrader/core/data/__pycache__/base.cpython-313.pyc create mode 100644 QuantTrader/core/data/__pycache__/oanda.cpython-312.pyc create mode 100644 QuantTrader/core/data/__pycache__/oanda.cpython-313.pyc create mode 100644 QuantTrader/core/data/base.py create mode 100644 QuantTrader/core/data/oanda.py create mode 100644 QuantTrader/core/events.py create mode 100644 QuantTrader/core/execution/__init__.py create mode 100644 QuantTrader/core/execution/__pycache__/__init__.cpython-312.pyc create mode 100644 QuantTrader/core/execution/__pycache__/__init__.cpython-313.pyc create mode 100644 QuantTrader/core/execution/__pycache__/base.cpython-312.pyc create mode 100644 QuantTrader/core/execution/__pycache__/base.cpython-313.pyc create mode 100644 QuantTrader/core/execution/__pycache__/oanda_handler.cpython-312.pyc create mode 100644 QuantTrader/core/execution/__pycache__/oanda_handler.cpython-313.pyc create mode 100644 QuantTrader/core/execution/base.py create mode 100644 QuantTrader/core/execution/oanda_handler.py create mode 100644 QuantTrader/core/oanda_execution.py create mode 100644 QuantTrader/core/risk/__init__.py create mode 100644 QuantTrader/core/risk/__pycache__/__init__.cpython-312.pyc create mode 100644 QuantTrader/core/risk/__pycache__/__init__.cpython-313.pyc create mode 100644 QuantTrader/core/risk/__pycache__/base.cpython-312.pyc create mode 100644 QuantTrader/core/risk/__pycache__/base.cpython-313.pyc create mode 100644 QuantTrader/core/risk/__pycache__/risk_engine.cpython-312.pyc create mode 100644 QuantTrader/core/risk/base.py create mode 100644 QuantTrader/core/risk/risk_engine.py create mode 100644 QuantTrader/core/strategy/__init__.py create mode 100644 QuantTrader/core/strategy/__pycache__/__init__.cpython-312.pyc create mode 100644 QuantTrader/core/strategy/__pycache__/__init__.cpython-313.pyc create mode 100644 QuantTrader/core/strategy/__pycache__/base.cpython-312.pyc create mode 100644 QuantTrader/core/strategy/__pycache__/base.cpython-313.pyc create mode 100644 QuantTrader/core/strategy/__pycache__/rsi_mean_reversion.cpython-312.pyc create mode 100644 QuantTrader/core/strategy/__pycache__/rsi_mean_reversion.cpython-313.pyc create mode 100644 QuantTrader/core/strategy/base.py create mode 100644 QuantTrader/core/strategy/ma_crossover.py create mode 100644 QuantTrader/core/strategy/momentum.py create mode 100644 QuantTrader/core/strategy/rsi_mean_reversion.py create mode 100644 QuantTrader/data/__pycache__/oanda_stream.cpython-312.pyc create mode 100644 QuantTrader/data/__pycache__/oanda_stream.cpython-313.pyc create mode 100644 QuantTrader/data/oanda_stream.py create mode 100644 QuantTrader/execution/__pycache__/adapter.cpython-312.pyc create mode 100644 QuantTrader/execution/__pycache__/config_loader.cpython-312.pyc create mode 100644 QuantTrader/execution/__pycache__/metrics_logger.cpython-312.pyc create mode 100644 QuantTrader/execution/__pycache__/oanda_adapter.cpython-312.pyc create mode 100644 QuantTrader/execution/__pycache__/order_store.cpython-312.pyc create mode 100644 QuantTrader/execution/__pycache__/paper_adapter.cpython-312.pyc create mode 100644 QuantTrader/execution/adapter.py create mode 100644 QuantTrader/execution/config_loader.py create mode 100644 QuantTrader/execution/metrics_logger.py create mode 100644 QuantTrader/execution/oanda_adapter.py create mode 100644 QuantTrader/execution/order_store.py create mode 100644 QuantTrader/execution/paper_adapter.py create mode 100644 QuantTrader/logs/oanda_trading.log create mode 100644 QuantTrader/requirements.txt create mode 100644 QuantTrader/results/execution/live/fills.csv create mode 100644 QuantTrader/results/execution/paper/fills.csv create mode 100644 QuantTrader/results/execution/tca_summary.json create mode 100644 QuantTrader/scripts/live_trade.py create mode 100644 QuantTrader/scripts/paper_trade.py create mode 100644 QuantTrader/tests/__init__.py create mode 100644 QuantTrader/tests/__pycache__/__init__.cpython-312.pyc create mode 100644 QuantTrader/tests/__pycache__/test_execution_adapters.cpython-312.pyc create mode 100644 QuantTrader/tests/__pycache__/test_risk_engine.cpython-312.pyc create mode 100644 QuantTrader/tests/test_execution_adapters.py create mode 100644 QuantTrader/tests/test_risk_engine.py create mode 100644 README.md create mode 100644 docker-compose.yml create mode 100644 main.py create mode 100644 metrics/execution.csv diff --git a/QuantTrader/__init__.py b/QuantTrader/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/QuantTrader/__init__.py @@ -0,0 +1 @@ + diff --git a/QuantTrader/__pycache__/__init__.cpython-312.pyc b/QuantTrader/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23b1467db7387f08ae6129c8fef74018174da31f GIT binary patch literal 164 zcmX@j%ge<81XmdaGZ}&OV-N=&d}aZPOlPQM&}8&m$xy@u1w znwY2WlAm0fo0?ZrtRGO6pOu;nWVl7dJ0&J(m!uY#=m!GTl!O!|rlc0>$H!;pWtPOp k>lIYq;;_lhPbtkwwJTx;nhA15F^KVznURsPh#ANN0Koq!bN~PV literal 0 HcmV?d00001 diff --git a/QuantTrader/artifacts/README.md b/QuantTrader/artifacts/README.md new file mode 100644 index 0000000..5475acf --- /dev/null +++ b/QuantTrader/artifacts/README.md @@ -0,0 +1,17 @@ +# QuantTrader Artifacts + +This directory stores the research outputs that the trading runtime consumes. + +## Structure +- `config/`: strategy configuration snapshots exported from QuantResearch (YAML). +- `params/`: optimized parameter JSONs (`best_params_*.json`). + +## Manual sync process +1. In `QuantResearch/`, run optimization/backtest scripts to produce updated configs/params. +2. Copy the vetted files into this directory: + - `cp QuantResearch/config/.yaml QuantTrader/artifacts/config/` + - `cp QuantResearch/data/params/best_*.json QuantTrader/artifacts/params/` +3. Commit the new artifacts (or upload to storage) alongside the trading release. +4. Runner processes load configs from `artifacts/config/` and parameters from `artifacts/params/` to ensure live trading uses the approved research snapshot. + +Automating this sync (e.g., via CI) is recommended once the promotion flow stabilizes. diff --git a/QuantTrader/artifacts/config/eurusd_regime.yaml b/QuantTrader/artifacts/config/eurusd_regime.yaml new file mode 100644 index 0000000..03a87dc --- /dev/null +++ b/QuantTrader/artifacts/config/eurusd_regime.yaml @@ -0,0 +1,42 @@ +symbol: EURUSD +csv: data/raw/EURUSD_H1.csv +cash: 100000.0 +qty: 10000 +account_ccy: USD +fast: 20 +slow: 120 +spread: 1.0 +slip: 0.2 +comm: 2.0 +atr_sl: 1.5 +atr_tp: 3.0 +atr_window: 21 +cooldown: 12 +allow_short: true +long_only_above_slow: false +short_only_below_slow: false +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +regime_ema_window: 200 +regime_slope_min: 0.0002 +regime_atr_min: 0.0010 +strategies: + - name: regime_sma + params: + trend_params: + fast_win: 20 + slow_win: 120 + long_only_above_slow: false + slope_lookback: 5 + cooldown: 12 + atr_sl: 1.5 + atr_tp: 3.0 + atr_window: 21 + allow_short: true + short_only_below_slow: false + rsi_period: 14 + rsi_long_thresh: 55 + rsi_short_thresh: 45 + range_mode: mean_revert + range_rsi_high: 65 + range_rsi_low: 35 diff --git a/QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi.yaml b/QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi.yaml new file mode 100644 index 0000000..499540d --- /dev/null +++ b/QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi.yaml @@ -0,0 +1,27 @@ +symbol: EURUSD +csv: data/raw/EURUSD_H1.csv +cash: 100000.0 +qty: 10000 +account_ccy: USD +fast: 20 +slow: 150 +spread: 1.0 +slip: 0.2 +comm: 2.0 +atr_sl: 2.0 +atr_tp: 4.0 +atr_window: 21 +slope_lookback: 5 +cooldown: 24 +allow_short: true +long_only_above_slow: false +short_only_below_slow: false +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +# RSI and trailing +rsi_period: 14 +rsi_long_thresh: 55 +rsi_short_thresh: 45 +enable_trailing: true +trailing_enable_atr_mult: 1.0 +trailing_atr_mult: 0.5 diff --git a/QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi_best.yaml b/QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi_best.yaml new file mode 100644 index 0000000..df06073 --- /dev/null +++ b/QuantTrader/artifacts/config/optimized_eurusd_v2_with_rsi_best.yaml @@ -0,0 +1,27 @@ +symbol: EURUSD +csv: data/raw/EURUSD_H1.csv +cash: 100000.0 +qty: 10000 +account_ccy: USD +fast: 20 +slow: 150 +spread: 1.0 +slip: 0.2 +comm: 2.0 +atr_sl: 2.0 +atr_tp: 4.0 +atr_window: 21 +slope_lookback: 5 +cooldown: 24 +allow_short: true +long_only_above_slow: false +short_only_below_slow: false +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +# RSI and trailing - optimized parameters +rsi_period: 14 +rsi_long_thresh: 60 +rsi_short_thresh: 30 +enable_trailing: true +trailing_enable_atr_mult: 0.5 +trailing_atr_mult: 0.3 \ No newline at end of file diff --git a/QuantTrader/artifacts/config/usdjpy_combo.yaml b/QuantTrader/artifacts/config/usdjpy_combo.yaml new file mode 100644 index 0000000..3f2851a --- /dev/null +++ b/QuantTrader/artifacts/config/usdjpy_combo.yaml @@ -0,0 +1,77 @@ +symbol: USDJPY +csv: data/raw/USDJPY_H1.csv +cash: 100000.0 +qty: 10000 +account_ccy: USD +fast: 20 +slow: 120 +spread: 1.2 +slip: 0.2 +comm: 2.0 +atr_sl: 1.2 +atr_tp: 3.5 +atr_window: 21 +cooldown: 24 +allow_short: true +long_only_above_slow: false +short_only_below_slow: false +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +regime_ema_window: 200 +regime_slope_min: 0.0002 +regime_atr_min: 0.0014 +regime_atr_percentile_min: 0.4 +regime_atr_percentile_window: 400 +regime_trend_min_bars: 2 +htf_factor: 4 +htf_ema_window: 60 +htf_rsi_period: 14 +strategies: + - name: regime_sma + params: + trend_params: + fast_win: 20 + slow_win: 120 + long_only_above_slow: false + slope_lookback: 8 + cooldown: 24 + atr_sl: 1.2 + atr_tp: 3.5 + atr_window: 21 + allow_short: true + short_only_below_slow: false + rsi_period: 14 + rsi_long_thresh: 60 + rsi_short_thresh: 40 + range_mode: mean_revert + range_rsi_high: 75 + range_rsi_low: 25 + trend_min_bars: 2 + atr_percentile_min: 0.4 + htf_alignment: true + htf_rsi_range: [35, 65] + base_size_mult: 1.0 + size_tiers: + - name: strong + min_atr_pct: 0.6 + min_trend_bars: 8 + size_mult: 1.5 + - name: base + min_atr_pct: 0.45 + min_trend_strength: 0.00008 + size_mult: 1.1 + risk_rules: + - type: atr_percentile + max: 0.95 + cooldown_bars: 12 + - type: calendar + dates: ["2024-12-06", "2025-01-10"] + cooldown_bars: 24 + - name: bollinger_mean_revert + params: + window: 48 + num_std: 2.0 + enter_z: 1.4 + exit_z: 0.2 + allow_short: false + cooldown: 12 diff --git a/QuantTrader/artifacts/config/usdjpy_regime.yaml b/QuantTrader/artifacts/config/usdjpy_regime.yaml new file mode 100644 index 0000000..c3e71c5 --- /dev/null +++ b/QuantTrader/artifacts/config/usdjpy_regime.yaml @@ -0,0 +1,42 @@ +symbol: USDJPY +csv: data/raw/USDJPY_H1.csv +cash: 100000.0 +qty: 10000 +account_ccy: USD +fast: 20 +slow: 120 +spread: 1.2 +slip: 0.2 +comm: 2.0 +atr_sl: 1.5 +atr_tp: 3.0 +atr_window: 21 +cooldown: 12 +allow_short: true +long_only_above_slow: false +short_only_below_slow: false +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +regime_ema_window: 200 +regime_slope_min: 0.00015 +regime_atr_min: 0.0015 +strategies: + - name: regime_sma + params: + trend_params: + fast_win: 20 + slow_win: 120 + long_only_above_slow: false + slope_lookback: 5 + cooldown: 12 + atr_sl: 1.5 + atr_tp: 3.0 + atr_window: 21 + allow_short: true + short_only_below_slow: false + rsi_period: 14 + rsi_long_thresh: 55 + rsi_short_thresh: 45 + range_mode: mean_revert + range_rsi_high: 65 + range_rsi_low: 35 diff --git a/QuantTrader/artifacts/config/usdjpy_sharpe239.yaml b/QuantTrader/artifacts/config/usdjpy_sharpe239.yaml new file mode 100644 index 0000000..ea24757 --- /dev/null +++ b/QuantTrader/artifacts/config/usdjpy_sharpe239.yaml @@ -0,0 +1,58 @@ +symbol: USDJPY +csv: data/raw/USDJPY_H1.csv +cash: 100000.0 +qty: 10000 +account_ccy: USD +fast: 20 +slow: 120 +spread: 1.2 +slip: 0.2 +comm: 2.0 +atr_sl: 1.0 +atr_tp: 3.0 +atr_window: 21 +cooldown: 36 +allow_short: true +long_only_above_slow: false +short_only_below_slow: false +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +regime_ema_window: 200 +regime_slope_min: 0.00015 +regime_atr_min: 0.0015 +regime_atr_percentile_min: 0.4 +regime_atr_percentile_window: 400 +regime_trend_min_bars: 2 +htf_factor: 4 +htf_ema_window: 60 +htf_rsi_period: 14 +strategies: + - name: regime_sma + params: + trend_params: + fast_win: 20 + slow_win: 120 + long_only_above_slow: false + slope_lookback: 5 + cooldown: 36 + atr_sl: 1.0 + atr_tp: 3.0 + atr_window: 21 + allow_short: true + short_only_below_slow: false + rsi_period: 14 + rsi_long_thresh: 60 + rsi_short_thresh: 40 + range_mode: mean_revert + range_rsi_high: 75 + range_rsi_low: 25 + trend_min_bars: 2 + atr_percentile_min: 0.4 + base_size_mult: 1.0 + size_tiers: + - name: strong + min_atr_pct: 0.6 + min_trend_bars: 8 + size_mult: 1.5 + - name: base + size_mult: 1.0 diff --git a/QuantTrader/artifacts/params/best_params_grid.json b/QuantTrader/artifacts/params/best_params_grid.json new file mode 100644 index 0000000..e5a50c1 --- /dev/null +++ b/QuantTrader/artifacts/params/best_params_grid.json @@ -0,0 +1,13 @@ +{ + "fast": 30.0, + "slow": 100.0, + "final_equity": 100836.47454711428, + "ann_return": 0.00816107907030128, + "ann_vol": 0.006608576787318331, + "sharpe": 1.2349223339527744, + "max_drawdown": -0.003808821586655787, + "trades": 168.0, + "atr_sl": 1.5, + "atr_tp": NaN, + "atr_window": 14.0 +} \ No newline at end of file diff --git a/QuantTrader/artifacts/params/best_params_grid_USDJPY.json b/QuantTrader/artifacts/params/best_params_grid_USDJPY.json new file mode 100644 index 0000000..4cb8f82 --- /dev/null +++ b/QuantTrader/artifacts/params/best_params_grid_USDJPY.json @@ -0,0 +1,13 @@ +{ + "fast": 20.0, + "slow": 100.0, + "final_equity": 100096.73588845377, + "ann_return": 0.0009612082650605203, + "ann_vol": 0.006298998439600644, + "sharpe": 0.15259700002742987, + "max_drawdown": -0.00570671952352962, + "trades": 160.0, + "atr_sl": 1.5, + "atr_tp": NaN, + "atr_window": 14.0 +} \ No newline at end of file diff --git a/QuantTrader/artifacts/params/usdjpy_sharpe239.json b/QuantTrader/artifacts/params/usdjpy_sharpe239.json new file mode 100644 index 0000000..2d16098 --- /dev/null +++ b/QuantTrader/artifacts/params/usdjpy_sharpe239.json @@ -0,0 +1,27 @@ +{ + "symbol": "USDJPY", + "fast": 20, + "slow": 120, + "atr_sl": 1.0, + "atr_tp": 3.0, + "atr_window": 21, + "cooldown": 36, + "rsi_long_thresh": 60, + "rsi_short_thresh": 40, + "spread": 1.2, + "slip": 0.2, + "comm": 2.0, + "risk_per_trade_pct": 0.01, + "max_drawdown_pct": 0.05, + "result_summary": { + "sharpe": 2.3922795335924505, + "ann_return": 0.003221386151178729, + "ann_vol": 0.001346575977407298, + "max_drawdown": -0.0008654020935744954, + "trades": 188, + "expectancy": 2.8389997613201357, + "win_rate": 0.3617021276595745, + "median_hold": "0 days 07:00:00", + "source": "QuantResearch/data/grid/grid_rsi_trailing_diagnostics_USDJPY_grid.csv" + } +} diff --git a/QuantTrader/bin/post_session.sh b/QuantTrader/bin/post_session.sh new file mode 100644 index 0000000..ddf5412 --- /dev/null +++ b/QuantTrader/bin/post_session.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT=$(cd "$(dirname "$0")/.." && pwd) +PAPER=$ROOT/results/execution/paper/fills.csv +LIVE=$ROOT/results/execution/live/fills.csv +TCA_OUT=$ROOT/results/execution/tca_summary.json +RUN_ID=${1:-$(date -u +"%Y%m%d_%H%M%S")} +if [ ! -s "$PAPER" ] || [ ! -s "$LIVE" ]; then + echo "Missing fills CSVs: $PAPER or $LIVE" >&2 + exit 1 +fi +cd "$ROOT" +python ../QuantResearch/scripts/compare_fills.py --paper "$PAPER" --live "$LIVE" --out "$TCA_OUT" +python ../QuantResearch/scripts/update_metrics_from_tca.py \ + --tca "$TCA_OUT" \ + --metrics ../QuantResearch/results/risk/metrics.csv \ + --run-id "$RUN_ID" \ + --status pass \ + --latency-avg ${LATENCY_AVG:-30} \ + --latency-p95 ${LATENCY_P95:-45} \ + --total-pnl ${TOTAL_PNL:-0} \ + --max-exposure ${MAX_EXPOSURE:-500000} \ + --max-drawdown ${MAX_DRAWDOWN:-0.05} \ + --rolling-sharpe ${ROLLING_SHARPE:-1.4} \ + --live-drawdown ${LIVE_DRAWDOWN:-0.05} \ + --live-latency-p95 ${LIVE_LATENCY_P95:-45} \ + --slippage-bps ${SLIPPAGE_BPS:-2} +cd ../QuantResearch +python scripts/watch_ops_metrics.py +source ../.env.demo && python scripts/export_metrics_prom.py --csv results/risk/metrics.csv --job risk_sim | curl --data-binary @- "$PUSHGATEWAY_URL/metrics/job/risk_sim" diff --git a/QuantTrader/config/eurusd_multi_strategy.yaml b/QuantTrader/config/eurusd_multi_strategy.yaml new file mode 100644 index 0000000..e045318 --- /dev/null +++ b/QuantTrader/config/eurusd_multi_strategy.yaml @@ -0,0 +1,33 @@ +symbol: EURUSD +csv: QuantResearch/data/raw/EURUSD_H1.csv +cash: 100000 +qty: 10000 +account_ccy: USD +fast: 20 +slow: 80 +spread: 2.0 +slip: 0.3 +comm: 0.25 +atr_sl: 1.5 +atr_tp: 3.0 +atr_window: 14 +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +allow_short: true +strategies: + - name: ma_crossover + weight: 0.6 + params: + size_mult: 1.0 + cooldown_bars: 6 + exit_buffer_pct: 0.0005 + allow_short: true + - name: momentum_breakout + weight: 0.4 + params: + lookback: 24 + enter_threshold: 0.0015 + exit_threshold: 0.0006 + size_mult: 0.8 + allow_short: true + cooldown_bars: 12 diff --git a/QuantTrader/config/risk_limits.yaml b/QuantTrader/config/risk_limits.yaml new file mode 100644 index 0000000..a54fbf8 --- /dev/null +++ b/QuantTrader/config/risk_limits.yaml @@ -0,0 +1,22 @@ +global: + starting_equity: 50000 + limits: + max_position_notional: 200000 + max_gross_leverage: 3.0 + max_daily_loss: 5000 + max_drawdown: 0.1 +strategies: + sma_atr: + starting_equity: 30000 + limits: + max_position_notional: 80000 + max_gross_leverage: 1.5 + max_daily_loss: 3000 + max_drawdown: 0.08 + bollinger: + starting_equity: 20000 + limits: + max_position_notional: 60000 + max_gross_leverage: 1.2 + max_daily_loss: 2000 + max_drawdown: 0.05 diff --git a/QuantTrader/config/risk_limits_sim.yaml b/QuantTrader/config/risk_limits_sim.yaml new file mode 100644 index 0000000..8a1c29c --- /dev/null +++ b/QuantTrader/config/risk_limits_sim.yaml @@ -0,0 +1,28 @@ +global: + starting_equity: 50000 + fast: 10 + slow: 30 + rsi_long_thresh: 45 + rsi_short_thresh: 55 + qty: 5000 + limits: + # Wide limits for simulation/data gating only. + max_position_notional: 10000000 + max_gross_leverage: 1000.0 + max_daily_loss: 5000 + max_drawdown: 0.1 +strategies: + sma_atr: + starting_equity: 30000 + limits: + max_position_notional: 80000 + max_gross_leverage: 1.5 + max_daily_loss: 3000 + max_drawdown: 0.08 + bollinger: + starting_equity: 20000 + limits: + max_position_notional: 60000 + max_gross_leverage: 1.2 + max_daily_loss: 2000 + max_drawdown: 0.05 diff --git a/QuantTrader/config/usdjpy_multi_strategy.yaml b/QuantTrader/config/usdjpy_multi_strategy.yaml new file mode 100644 index 0000000..912ca52 --- /dev/null +++ b/QuantTrader/config/usdjpy_multi_strategy.yaml @@ -0,0 +1,46 @@ +symbol: USDJPY +csv: QuantResearch/data/raw/USDJPY_H1_full.csv + +cash: 100000 +qty: 10000 +account_ccy: USD + +fast: 20 +slow: 80 +spread: 2.0 +slip: 0.3 +comm: 0.25 +atr_sl: 1.5 +atr_tp: 3.0 +atr_window: 14 +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.05 +allow_short: true + +strategy_mode: weighted +strategy_vote_threshold: 0.0 + +strategies: + - name: xgb_signal + weight: 0.4 + params: + prob_long: 0.64 + prob_exit: 0.50 + cooldown_bars: 8 + size_mult: 1.0 + - name: ma_crossover + weight: 0.35 + params: + size_mult: 1.0 + cooldown_bars: 8 + exit_buffer_pct: 0.0004 + allow_short: true + - name: momentum_breakout + weight: 0.25 + params: + lookback: 36 + enter_threshold: 0.0012 + exit_threshold: 0.0005 + size_mult: 1.0 + allow_short: true + cooldown_bars: 10 diff --git a/QuantTrader/config/usdjpy_xgb.yaml b/QuantTrader/config/usdjpy_xgb.yaml new file mode 100644 index 0000000..c5e0777 --- /dev/null +++ b/QuantTrader/config/usdjpy_xgb.yaml @@ -0,0 +1,38 @@ +symbol: USDJPY +csv: QuantResearch/data/raw/USDJPY_H1_full.csv + +# Trading capital and unit size +cash: 100000.0 +qty: 10000 +account_ccy: USD + +# Cost assumptions (aligned with training defaults) +spread: 2.0 +slip: 0.3 +comm: 0.25 + +# Core engine params (still used for indicators and safety exits) +fast: 20 +slow: 80 +atr_sl: 1.5 +atr_tp: 3.0 +atr_window: 14 +regime_ema_window: 200 +cooldown: 0 + +# Risk controls +risk_per_trade_pct: 0.01 +max_drawdown_pct: 0.03 +allow_short: false + +# Single-model (long-only) strategy +strategies: + - name: xgb_signal + weight: 1.0 + params: + # If omitted, strategy will read QuantResearch/artifacts/models/usdjpy_h1_xgb_latest.json + # model_dir: QuantResearch/artifacts/models/usdjpy_h1_xgb/20250101_120000 + prob_long: 0.64 + prob_exit: 0.50 + size_mult: 1.0 + cooldown_bars: 8 diff --git a/QuantTrader/core/__init__.py b/QuantTrader/core/__init__.py new file mode 100644 index 0000000..792ec5e --- /dev/null +++ b/QuantTrader/core/__init__.py @@ -0,0 +1,10 @@ +""" +QuantTrader core package. + +Expose runtime modules (data, execution, risk, strategy). +""" + +from . import data # noqa: F401 +from . import execution # noqa: F401 +from . import risk # noqa: F401 +from . import strategy # noqa: F401 diff --git a/QuantTrader/core/__pycache__/__init__.cpython-312.pyc b/QuantTrader/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..11a1e052dc5b5c149a5c7d96b9337c2f5bf65d03 GIT binary patch literal 402 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/__pycache__/oanda_execution.cpython-312.pyc b/QuantTrader/core/__pycache__/oanda_execution.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f87ffc8e15ca766d1242a7ac0fca5f8f7a44bd25 GIT binary patch literal 4359 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/__pycache__/oanda_execution.cpython-313.pyc b/QuantTrader/core/__pycache__/oanda_execution.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..632bfd96bcbbad4c65fdc22ff6720aa39b93998a GIT binary patch literal 4450 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 m|AQ=f$h9zxDi^#1O`S(^_%I9Xz8EtF;iwl6!ZR zgqcp_5`t|o*o1d@*wCanlPQ>x;bEJ$`3Ffl(=gk1l9_5H>k}ekhCaUZoVzP|ZQM@p zjLzOY_uRYZea=SM)`Jsv{-jxYHkn3LQ)FgZc|#LsOdZ5-v=Y}Dq(ciUEb z8&CN*feLL7>S%LPC&Syfy4qaS#pe80Hx-wWdg2#qh~Lr8ZL}Nb(jta+0@k(BX5xw& z&J8%Rfb)cTUr`1Ld^XMA^2)1PNpPp4(x9aF%IdCo$j57ZYeZ5tep^IQHP`EXY9t;D zMu9lC*T-qDP*9cCNUz*38J{tr(|pNIz~lt!CY19NYV&i{?zd6iZ|@fTe7DfVBj>w$ z>gaX?|1Q51ey(nJN2T>JW8UQO1Mj=}jph<9f<3P8;%=ea(_}-1Hrf2*Jc<&aDD@Z5 z!<90eXCAK1UqY9Ji+p98t2L+wH_LJ;=@0koV^isi{i%a@ zX0KgLUz-;7D<39r4<)A#%pSR^kDSzR?bFXq85@8)H9nvZ97rAcSReU~sELsnXg1L+ z$5hRYD(j)aURg3t#-u`1;d`zYzzNb-7G-DzUEU~{e+hFwTjE#nChCLd^~;C!;gba> zMX`m3m1fZkWA1~^-sIFrsnf^wW4}&cKI~nU{QV*QxBHsC^_x070-gTWdLP?R;Huf1 zI&~v+>T~_*MC#m_g?`I3^=rKKNZpD6;%Mn`N4CPg)bTQ*NP%gQszYeBFw6pTI&xOT}* z>@fe|QvD3p#n~&DGN<3yuUr7HG?T^I&p|?~PlKkTJ0TRoK|b1_9z9^CMw!lDn4Nr2 zAGm|AYe?p^G08Mneyf5^<*n#e5<3-%Ms~_tc|p7a?#Dn$8xWv4|F?}$p(5nk)Q!PM zWir{KULdlnyC@MWd{?V1@M6Jk!W{)Qiv=~)GFSjHm%*dmSC08829XH)NDKsedgUFl<40iIb0DXGdjaV8S6c%*iyHdh31IeVTm zyPnOQf@fTvcy+R3V#~#Mas*yY5}0Mnfh1>J49A;vIUCFju90C0euaVR!IA3$uvi08 z14@Dx!U09!Xdzew(ShPG7}!)ShKJ_Eg>3*8{cc(m7JWsU2h9M5Ns<*MPBYu!qy&o| zBVx>`I5UB5091d-Yssl2`qkU|kufa8mP|4GhqJR6hclPKn>mzLHIMf~jqHFk74)-ya<`^- zkUw0?G_LXJtMdvXvVeBTtpad@blN)Yer~O;li(2xM<@3~K!t5SdqRPUn7TQw9~@5| z>`$G$YRG8FYfUfsMW_%=1N!LU^kt&}_9S9bv)8H~%r!=q!HTeYbzcx718S$a)u^4& zu)1(7_M@7^AZe#R0%r0><^`B*JkrpYVY;Bic!Hr2v=*jPwdM2GH*c{McVpgx%2G+H zSKO_ty)W2Tii7+}=}_ru{%h9?Q>h{FAC@ZSC1*@4%)k#|0-EKpzgRf(eQgXiZcXhb zVMqSXVA+`8Q$e;s{Y${EzNW`XC-3KwxE{XL#DP2Ue%2qia?f&I4QRLR)o5xj*!T&u zlI*}N!G@CrpClwE>_CTM!*B1jmk>X{13iO{PGO_TeF@pX=jUEh2e*)z&?0PqjhM3@ zu7|v6uOrY>IT8b~Su}#d45r&ROq&mZ(+uf1KTBPo&<_lQ@y#Hybh4;ds7jB78S7u> z7iGf}p(VFEBOHqM-HaJlq%Q$4%q0@oz*;@eo4^>v=2ss%nm#_3IeupL>eXg%yU(HV zQoL_3ZH9GCh^TU}qPaJ{Ey?UcqzQdA5>x3qSoJxK9_Ll0gw9h*L{-Xs(LLvGoUEYM z(ZBQ6vbfJkJVrlIGv6PrYZ7c!P@l{l%d1XRAFV#Ne9&>Xu3_xmGw*&=*Ys6g)1T{} z9eRDRHEVNtws3drR!!7=UDtHCa@l>m-CO=$Ro%Gjt12IOvA2BGajf{hK)laE4_#I@ z==sM3fmE)@5e_!~wQT8W&vtcj$Tq zn0@I61Q>dj>V{rV5g>(v6lQh%UgU%oSx2!TjyF6Y@W|Fy2vy_dmuoN9W(iCu>u;{P zw&o#Djq~snRA*u29Y6O9pdPI|kHh8Jyu3R6LbnqxpDyF_3D;Ml2`;c$QFe3G?RQZz z?1q|Bx;;qu$SRt1)0=Sj%e)OIC$<6*tpy`FbtpM~!%|hwO{A|+r>DpC(f#_heaYL0 z;5wF^8PD{8VBMB7nmBgvGH#X79`yE6=6s@z9}K8_`((+QJuti2;g@Ry0N2c)%#|Cs zau#wETsaGQsB2vmcd!vA^m&*-n#IasT=}p{vXA-%!~5CQuz+Qh3i2@L@J88Mwm?0F z!iE2gDA;v@mzNlqyP7B14R5#v{P?h&SrC{&Q5SI14*yIPZExw=y6KHTYs(ugzM}u` zf6VpJ?3SjB`Q&1*_d3!cKqD%x0P>AhC0V}eevv3te^&`z6g;7es;tdAapoeW6{8zY z+ee=tie=r15y{eJ50N@c02scA$NEY%dvK>jQP-?R)Er>FNGxnHhbe(GI?wNX=jfd8eeQqg^%?@wvENyIPgfDdUvNW8RH{PG|0h%i36@|tIE@iv2OVzIGqTA^%b-Ow}N7JqaSjw;K(zol=eTH@e z^eO$uE>pWn>{Iy*x(eG1NkWZRqW$JBOM4NFs9BBQ)@5(EcNMo6L)*Y=Pbk_QGGaTy z>P`@>p0mFrPwl$Bq(@doaMo%9Q2;4xFM62huQ*CPA%B=SN;-|#F_@DSw4Oj981jUC z!2sU|ZJPCjxR9@lb1DQytIrz}RgLwP`y^ni5%mut008@LzTo_lB{$NK3 zht81z{lHG>&_95DUSFVNUkGuYuInUTOeWB+o{(o7$FYKOj|Uy*Lb$#42p0%>F@%)x z!NcDm=V|C2B%(xT?r0k7opjlm{=?E_T(Tr9dqkhb)6bCF0OWWh~9A9mJ~ytSE!arg8ms zImWz?aB9LO@Q<#YJ#nr7>6!jluD<{F?9fXyFFp17+2>~lp8x#pm_RZDA&^Z1=@!Uk z*Hr+0T?tv+b$molxj==xyE!DN5ZCSZcsW7t=K|2JJ^*5QJcVJH zukUO64$mRJ$$Kd52{g3^z2PpnF23mj6zt@@P}%m7d$Y%TIK=Uxro8|Y+K)Uehnl=W z#5Dmid76Tr0PAV&?h%UIfgtMg_Tb&;Q>clEU-RhpRRsV83EGDN<#ZOHTD zuB6cvqai!hbFyd1Jak~vSkbp*8lcua-8)2HRuv};9Yc2wwG1>2ln*^VVjry-tsE&G zF-#bnCg`T5R@b*<9`-_baV8~2szS~`3zZ)NU2;S_$r2z0$ac9JSQVI6lPdG&tTsj* z1iCz-Z&&1wvpQA|OiZzQRsp3Fn3{r81*H;78cG$EYA9(aHLQ_ULkZZPAlr377bZb} ze@k0yi^N;uR@k2g?v4?=XMZ#{`~1&l-yfLy*&Cm~FeVPno*KCN@2^6~?BFkFUhJQF z;hC9J|2i{%eCDuh4T=wA zE~DK*4zpFruJy`$2^BAvb0|0Q3ig z-A=Wj;5q+6L6hY>zzSmiAo6r^$c49+*&AAln+Vvg#VqTiS-t&b_D=;CBaEiUcJ z@&;U%B+Fd5bX>I-k3=t)Oj-8;w063ve9(Q`Jy`_6{hH*;wNU7i#brn z;&~0KgoV8sdV>bO(dRJz{uJnL7eQi$4%~Z7`-{xERm0EyN_bLSOGc(Y|xb6zd&D+1lUhNuB%x5CBC zqIziQI*YQryRhl6KZx5G}}iyZVmoO%_HATZzX8;eVT>=Dg4b2_H}P7e%$5OQe;uXUSi%#+s!&W(Gi$;lY{4DI3-2&)zQed>!FW}AdEyrTxQNY2U}?{+^|cK3l0^To z0`y?i(5sUu4ao+`{xwl8Z^=0eEL#?(chc>B#O>jtT509pqG(ZP(;_s`WbGX!TYTOj zQ3>#JQRj+$Z~#@Z0BYqeph|P0_7PP?2#Yr{fq`AuYYAbQE~ZYf2Nx&S7Q!M`Oi_!p zf3WQ`oJkVL!G-9x9J3rHB_sV`;XmkFjKy`z!!wKXbUkogfn>-olBj+E0OT3jQ13U$zIH7`2 zMrTDuC)1IU4Xhk%2Ji*)dPCr^NH}VxnVlJ%Duo^A5|`a}B`UQNOWks{49BAlA?#;} zDVp7@O4JgKuoD`~m)q{7M1o5DQVY*O#S%GZpib{hvybSJJGE^Bx#Rl(Lh_kFzYAom zuWv6&pbfAj!U|u!vg^2cy&N)dX=$K&{-`f>NRR`G2`V4Y`Z|0eREmd5f$ZS%F*2fg zBbak;+0A2fjpZYo7MeR9T*!UM2gVrCfX6S|T#Xz2L8$Ya8uQG52+;GdL)J%N2PJYc zGE~+7fFMWSxf86GrFgbI^la1K*DI7g`A#g4*)wtjI7e_Auz@>cU$#aPR`)$cXD+wk+oE3T!ZE$6Dos(!juM6!42!J*YJ zMMgHCesaRJar`dGu9$72{RHQslKypZ&BM!Tx|Y?j`)hVI*X(Jo+1I#SOzk*Ma1aH% z1R0z`GyE~M9x|s4t${*Nc6)&RctPa_C(zGld_pS{`z50k?Gb)_rI@o~h$QT(`tU0uA0u%0bb4!w7+s0}c&nD?tgE z-67bA)D3O43nM>-vCxR)yCGD?q20KtOp%p9a)OLK$XlTg>!F*I#Lzc$Yoy|i42=Z{ zzYfIzHSElMSxu;`{!DM0pf_EymJW8F?i?u_Q@m$**Dz_lC#FrBtb>}8_ZIV)V4#amFxg7zt0>m&Nqc1?Iu> z)8!-b$%5*b@`|Bk=-{NGI_ao8UwNV7T*G*D%C$T0*d2o)hhylwmyK1)^6CLCtWsQ? zus6o-jT24xP1<)QtrZEYD{gg7EZsI~-JV?HN*0%Yp{A^c*tR(XVXI77SH-QXk|j&d z+%tSn(pEM{5w_LygxqF`ZJ*N==&g8Y{qXukNn^aEarEe9$=U%r>8L!@Fx)WGGgdk2 zSUsTnOL@(}HkdpZIUN~seQH~lv@>Un!^Y`)*9F5l!`PAWgYO=jtlu_W?HsKgD>}Dq zTpo9An5^D-b4OMEsCA4wS2o7So$Dv7HcZ#JM%RqhoZC2VjxXIfS+i-n7I0i}opX&n zH2(Oct7Wow^A~!R)A1#tDlYkgB9_$7F+^E?Y|q>-k}#GI9h@>$f6+z~Ci}FxXmG>n z4KHn)&{bU38&5rY^3kFCNqtpJmZUWa+7YK6Bh4cX*_1fjSR2F{Tp&*y4HBUX1g;e|-q&CHWCJPcTP>=lWObEUdIQN8+d_3BtCVPxV) zW`f2Fhu-)P9*g?pC1s=+|2-*X{_6YClm4qD1a72pB5N#lVg!qlWa_|-9HXi;6E@0% zBL^H+p8kAJpM;e!fH|365DX2Vr zPr&O73MPEKC>#htq(_X~pj9xAuXM2pa6DzYs5DWuEMBy1vZzUnwiwN)qR&Q0R-N7O z=7vPox_H&PsjB9r&XmxV$93hO>XQN8{jJdWHQZz*mmSxC0{21K&G1eM#7ZN3nC z_Dtc-^V~El&EMadF->97bYjW#IZhXggOnMJA*}?JFT|5IvEgpz;>}WF90~%@Z1$J2 zYvTQOq%qu!jVG+bgW*tHWXU7q*+0glUq!HI1Xl+|5C_{Efe@C%L@+h?wTt?&g722mdX<+7tT9LQW_q9>;Asnz2Qzt&cMF}wG3FEP{^K!}6&y_T{*mWi_oJF3q zeu326nJ2g=1`_jxtKsnng5#aol3A2sMEN=;xp`Q=I8|Z1gPDN~GtZ7*{ouXXkq>8o z@l=~=@`5o1!bDKA9S9wSO$oTiD;Pmbf|ixKsuc|krdGnV2KoYg6R$M zY19ZEf^xqYk`}aBUZq75dK`n|!$!~J40`~n&~hKtatIea1MNu3LV{f&)z0Gy#~_3E zpcI7;wVv5MygO04B3`;;s&r*iSC~u@a)P!)U>Tyt(>5kytBc#}Mnj3Fd*V&^TvANg zaO7BrAwh$uRQ(iPnx^QaVZ~VWdrj{)O{~~_(RFG6f4TqQp4k1^#ADx^(7C^q%hc&O za@wIh!^(sPbOTsc4S`p8~P)>vLHN}U;`7%BW&_~=54cACdNne5PD}G(uLQXG;WA{LR_sE@BCZt4?+AqV1{`a;?Mqvs9eGi zal#b}9Le~W*dfl^ht(>?-!d!(@wY%Pj>>;aT!?xYqW-;#V~QR!l!;^KV?1C;1!g{2 zBTqaUMpU_M8N#o$V(P>}g+(bpj_kQed+I-x+FLdn=%htvzxL6y6@& z6c`bW3!dQ_AJ2pXo+BQg-*dpvH8NX4eIPE!pT!W^VFm9D)RO4bfwQbmONE`Sq9+z9 zzlAUnUCd@CQrO5mC_Qe$CIBOY#Q2L*D7+s(1~1r^pnDJ=$3RtX6+Ki@Xo%WS2^J4l zh%Puo(c?|IfLiw<(G_-g@g3+e9>ZK9s32I0?WWY@RU7W2ct6K=J1NmX%5fs)FcVzZ zC%in4kcrNs=pUjVL36Pl&5>{K!7#4`9_b^lYRe|HHPeOVb275vu6eoKY?veDdczz| zn3)mF*(Gl-nJ8EhQzi?ogRaxAL}69DuxhHXX4+bxbd;ZI8g2rw=?0bZwrHJx`66%Y zzrowulC+gz$0}i~kK5{}Y_31r%aRs*!crZ#RFCZY)Z&~|5vA+q^n}@#T_jOh^A)_e zVc$drXkn=NbaTR79XD4`nQNyl#n@djoHit_tH$4schP1e%nYv*8+<^BRua@h&BS5@57F4IJ8#=pFzmPk0VwA9(Zu)maP$o1hDV0ubb(DuRJ^*9D~tp6ch)1EH5ZL{9!)z7=$C; z*YQ-0@Pozd#WE-eru)aeBNfuzJsv+8 z_YhlVuuZ^8e+kgbn0oXg-k=2?fI|G>BONSexdY)2)QNk&IAb9b^boJ^?vWzaVu(nL zU!>er{E0&9kAk01K-`;$FTlY2^#wS=j(vnoGyTm9?>Wc`7W{YJd@vZu=|wnhgkHlL z*6&VjN`b$D@zS8X-S|4t+j#sZI6I58QJjfqOo^u>DDZ;l40Q7#r&4NO)b***2znJd zZ&LPI)8aCocuiFHSIpMIwWrs@+sr-iS((;6@cql$%B0bpFjibPR%F6|E5@qct9!R@ zy!PL)LorddWy;hFZpsyyaP+dad|FqCUF?aH)nkXo+9!(dozk_y@SLJb-9{#JmS|nj z_@mi29D<^=P?goG;$vDPVF8AOFmmQ%-`JvLH(*7cTa+FqAjQg!CZ z;U|;jb?}uj6?^F!&9Ek6UmLft9ans0_?6)j^{KrLLeIs!$TAt*!00zOG=!-bAmMEB!MB}kn<(2f&X02!@4D!{_?=4?6D1Js*b~?7 z!7~8rYyLjC@sC}#+gB35F4$~=;7|EixShFjF6uc{$*HH$;3E5XCh%aPvik`C)rL{>09zCk&3ar#US1h(U3Q8busHt#`CW`EHYFyJ0 zHpiS6*K~xncutRN2BNrp&WLL!!e06%#xb}25t1srVgyXM0;mkuIh9z|5E|>e7FXvZ zG8wgLo~EdZd4rN#J8x1`nt5w6wfygEny5BXd@&XG42M~U_b*;2K6Tfyo;sBv*02MEof3q-S%hP12pi4#9Tog0UX;cH1^PL>bNKwTbqUBlr4B#LxwwTS=g99Rap z&Oj#68&bru3H=V5ASDSN$K`>QNb)nH@-KwpPlWa}qU_Ja@=0R(p9u43gzZm+=`*5u zhH%Wu`=8-%RjCnA^$&@G4O5x literal 0 HcmV?d00001 diff --git a/QuantTrader/core/data/__pycache__/oanda.cpython-313.pyc b/QuantTrader/core/data/__pycache__/oanda.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5033ed81242f799f068ad92b4c6a3de3a436e7a1 GIT binary patch literal 12532 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/data/base.py b/QuantTrader/core/data/base.py new file mode 100644 index 0000000..36f9fc8 --- /dev/null +++ b/QuantTrader/core/data/base.py @@ -0,0 +1,136 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Any +from datetime import datetime +import pandas as pd + +class DataFeed(ABC): + """ + 数据源的基础抽象类 + 定义了获取市场数据的标准接口 + """ + + def __init__(self, instrument: str, timeframe: str): + """ + 初始化数据源 + + Args: + instrument: 交易品种 (例如: "EUR_USD") + timeframe: 时间周期 (例如: "H1", "D") + """ + self.instrument = instrument + self.timeframe = timeframe + + @abstractmethod + async def get_historical_data( + self, + start: datetime, + end: datetime, + **kwargs + ) -> pd.DataFrame: + """ + 获取历史数据 + + Args: + start: 开始时间 + end: 结束时间 + **kwargs: 额外参数 + + Returns: + 包含历史数据的DataFrame,至少应该包含以下列: + - datetime: 时间戳 + - open: 开盘价 + - high: 最高价 + - low: 最低价 + - close: 收盘价 + - volume: 成交量(如果可用) + """ + pass + + @abstractmethod + async def get_latest_data(self) -> Dict[str, Any]: + """ + 获取最新的市场数据 + + Returns: + 包含最新市场数据的字典 + """ + pass + + @abstractmethod + async def subscribe(self, callback) -> None: + """ + 订阅实时数据更新 + + Args: + callback: 处理实时数据的回调函数 + """ + pass + + @abstractmethod + async def unsubscribe(self) -> None: + """ + 取消订阅实时数据 + """ + pass + +class DataProcessor: + """ + 数据处理器基类 + 用于对原始市场数据进行预处理和计算指标 + """ + + def __init__(self): + self.indicators = {} + + def add_indicator(self, name: str, func, **params): + """ + 添加技术指标计算 + + Args: + name: 指标名称 + func: 计算指标的函数 + **params: 指标参数 + """ + self.indicators[name] = { + 'function': func, + 'params': params + } + + def process_data(self, data: pd.DataFrame) -> pd.DataFrame: + """ + 处理数据并计算所有已注册的指标 + + Args: + data: 原始市场数据 + + Returns: + 添加了技术指标的DataFrame + """ + result = data.copy() + for name, indicator in self.indicators.items(): + try: + result[name] = indicator['function']( + data, + **indicator['params'] + ) + except Exception as e: + print(f"计算指标 {name} 时发生错误: {str(e)}") + return result + +class MarketDataEvent: + """ + 市场数据事件类 + 用于在系统各层之间传递市场数据更新 + """ + + def __init__( + self, + instrument: str, + timestamp: datetime, + data: Dict[str, Any], + event_type: str = "MARKET_DATA" + ): + self.event_type = event_type + self.instrument = instrument + self.timestamp = timestamp + self.data = data \ No newline at end of file diff --git a/QuantTrader/core/data/oanda.py b/QuantTrader/core/data/oanda.py new file mode 100644 index 0000000..a70a761 --- /dev/null +++ b/QuantTrader/core/data/oanda.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio +import time +from datetime import datetime +from typing import Dict, Any, Optional, Sequence +import pandas as pd +from queue import Queue +import threading +from loguru import logger + +from oandapyV20 import API +try: + from oandapyV20.endpoints.pricing import PricingStream as OandaPricingStream +except ImportError: # pragma: no cover + OandaPricingStream = None # type: ignore + +from .base import DataFeed, MarketDataEvent + +def _normalize_instrument(symbol: str) -> str: + """规范化交易品种名称""" + s = symbol.upper().replace(" ", "").replace("/", "_").replace("-", "_") + if "_" in s and len(s) == 7: + return s + stripped = s.replace("_", "") + if len(stripped) == 6: + return f"{stripped[:3]}_{stripped[3:]}" + return s + +class OANDADataFeed(DataFeed): + """ + OANDA数据源实现 + 提供实时和历史市场数据 + """ + + def __init__( + self, + instrument: str, + timeframe: str, + account_id: str, + access_token: str, + environment: str = "practice", + reconnect_wait: float = 5.0, + log_heartbeat: bool = False + ): + super().__init__(instrument, timeframe) + self.account_id = account_id + self.access_token = access_token + self.environment = environment + self.reconnect_wait = reconnect_wait + self.log_heartbeat = log_heartbeat + + self.client = API(access_token=access_token, environment=environment) + self._stop = threading.Event() + self._thread: Optional[threading.Thread] = None + self._callback = None + self._latest_data = None + self._loop: Optional[asyncio.AbstractEventLoop] = None + + async def get_historical_data( + self, + start: datetime, + end: datetime, + **kwargs + ) -> pd.DataFrame: + """获取历史数据 + + Args: + start: 开始时间 + end: 结束时间 + **kwargs: 额外参数,支持: + - granularity: str, 时间周期 (如 "H1", "D") + - count: int, 返回的K线数量 + + Returns: + DataFrame包含以下列:datetime, open, high, low, close, volume + """ + from oandapyV20 import API + import oandapyV20.endpoints.instruments as instruments + + granularity = kwargs.get("granularity", self.timeframe) + price = kwargs.get("price", "M") + + # OANDA 的单次请求有最大返回数限制(例如 5000 candles),对长区间需要分段请求 + MAX_CANDLES = 5000 + + # 估算每个candle的时间长度(秒),支持常见的granularity + def granularity_seconds(g: str) -> int: + if g.endswith('H'): + return int(g[:-1]) * 3600 + if g.endswith('D'): + return int(g[:-1]) * 86400 if g[:-1].isdigit() else 86400 + if g.endswith('M') and len(g) > 1 and g[0].isdigit(): + # 例如 M1, M5 (分钟) + return int(g[1:]) * 60 if g[0] == 'M' else 30 + # 默认按小时处理 + return 3600 + + step_seconds = granularity_seconds(granularity) * MAX_CANDLES + + all_data = [] + current_start = pd.to_datetime(start) + end_ts = pd.to_datetime(end) + + while current_start < end_ts: + current_end = current_start + pd.Timedelta(seconds=step_seconds) + if current_end > end_ts: + current_end = end_ts + + params = { + "from": current_start.strftime("%Y-%m-%dT%H:%M:%S.000000Z"), + "to": current_end.strftime("%Y-%m-%dT%H:%M:%S.000000Z"), + "granularity": granularity, + "price": price + } + + request = instruments.InstrumentsCandles( + instrument=self.instrument, + params=params + ) + + try: + response = self.client.request(request) + candles = response.get("candles", []) + + for candle in candles: + if candle.get("complete"): + all_data.append({ + "datetime": pd.to_datetime(candle["time"]), + "open": float(candle["mid"]["o"]), + "high": float(candle["mid"]["h"]), + "low": float(candle["mid"]["l"]), + "close": float(candle["mid"]["c"]), + "volume": int(candle.get("volume", 0)) + }) + + except Exception as e: + logger.error(f"获取历史数据失败: {str(e)}") + raise + + # 推进起点 + current_start = current_end + + if not all_data: + return pd.DataFrame() + + df = pd.DataFrame(all_data) + df.drop_duplicates(subset=["datetime"], inplace=True) + df.sort_values(by="datetime", inplace=True) + df.set_index("datetime", inplace=True) + return df + + async def get_latest_data(self) -> Dict[str, Any]: + """获取最新数据""" + return self._latest_data if self._latest_data else {} + + async def subscribe(self, callback) -> None: + """订阅实时数据""" + self._callback = callback + self._loop = asyncio.get_running_loop() + if not self._thread or not self._thread.is_alive(): + self._start_stream() + + async def unsubscribe(self) -> None: + """取消订阅""" + self._stop.set() + if self._thread: + self._thread.join(timeout=2.0) + self._loop = None + logger.info("[OANDA] Pricing stream stopped.") + + def _start_stream(self) -> None: + """启动价格流""" + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._run_stream, daemon=True) + self._thread.start() + logger.info( + f"[OANDA] Pricing stream started for {self.instrument} (account={self.account_id})" + ) + + def _run_stream(self) -> None: + """运行价格流""" + if OandaPricingStream is None: + raise RuntimeError( + "oandapyV20.endpoints.pricing.PricingStream is unavailable. " + "Ensure oandapyV20 is installed." + ) + params = {"instruments": self.instrument} + while not self._stop.is_set(): + request = OandaPricingStream( + accountID=self.account_id, + params=params + ) + try: + for msg in self.client.request(request): + if self._stop.is_set(): + break + self._handle_msg(msg) + except Exception as exc: + if self._stop.is_set(): + break + logger.warning( + f"[OANDA] Pricing stream error: {exc}. " + f"Reconnecting in {self.reconnect_wait}s" + ) + time.sleep(self.reconnect_wait) + + def _handle_msg(self, msg: dict) -> None: + """处理价格消息""" + msg_type = msg.get("type") + if msg_type == "HEARTBEAT": + if self.log_heartbeat: + logger.debug(f"[OANDA] Heartbeat {msg.get('time')}") + return + + if msg_type != "PRICE": + logger.debug(f"[OANDA] Skip message type={msg_type}") + return + + try: + bids = msg.get("bids") + asks = msg.get("asks") + if not bids or not asks: + return + + bid = float(bids[0]["price"]) + ask = float(asks[0]["price"]) + timestamp = pd.to_datetime(msg["time"]).to_pydatetime() + + self._latest_data = { + "bid": bid, + "ask": ask, + "timestamp": timestamp, + "mid": (bid + ask) / 2 + } + + if self._callback and self._loop: + event = MarketDataEvent( + instrument=self.instrument, + timestamp=timestamp, + data=self._latest_data + ) + try: + coro = self._callback(event) + if asyncio.iscoroutine(coro): + asyncio.run_coroutine_threadsafe(coro, self._loop) + else: + self._loop.call_soon_threadsafe(self._callback, event) + except RuntimeError as exc: + logger.warning(f"[OANDA] Failed to dispatch callback: {exc}") + elif self._callback and not self._loop: + logger.warning("[OANDA] Callback set but event loop missing; dropping tick") + + except Exception as exc: + logger.warning(f"[OANDA] Malformed price message: {msg} ({exc})") diff --git a/QuantTrader/core/events.py b/QuantTrader/core/events.py new file mode 100644 index 0000000..984257a --- /dev/null +++ b/QuantTrader/core/events.py @@ -0,0 +1,38 @@ +# fx_backtest/core/events.py +import os +import sys +sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from dataclasses import dataclass +from datetime import datetime +from typing import Literal, Optional + +@dataclass(frozen=True) +class TickEvent: + ts: datetime + symbol: str + bid: float + ask: float + +@dataclass(frozen=True) +class SignalEvent: + ts: datetime + symbol: str + direction: Literal["LONG", "SHORT", "EXIT"] + size: float # 单位:合约单位/手,随你定义 + +@dataclass(frozen=True) +class OrderEvent: + ts: datetime + symbol: str + side: Literal["BUY", "SELL"] + size: float + price: Optional[float] = None # 市价可为 None;限价时填价格 + +@dataclass(frozen=True) +class FillEvent: + ts: datetime + symbol: str + side: Literal["BUY", "SELL"] + size: float + price: float + commission: float \ No newline at end of file diff --git a/QuantTrader/core/execution/__init__.py b/QuantTrader/core/execution/__init__.py new file mode 100644 index 0000000..c73ef6b --- /dev/null +++ b/QuantTrader/core/execution/__init__.py @@ -0,0 +1,14 @@ +# 执行层包初始化文件 +from .base import ExecutionHandler, OrderEvent, FillEvent +try: + from .oanda_handler import OANDAExecutionHandler +except Exception: + # optional: OANDA handler may not be available if dependencies missing + OANDAExecutionHandler = None + +__all__ = [ + "ExecutionHandler", + "OrderEvent", + "FillEvent", + "OANDAExecutionHandler", +] \ No newline at end of file diff --git a/QuantTrader/core/execution/__pycache__/__init__.cpython-312.pyc b/QuantTrader/core/execution/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6cfc1b43e0494a2f1860e3ba95d5f5d45b4f8c30 GIT binary patch literal 444 zcmZ8cJx{_=6uq~v1wAg@JWbZ2KZZmy30 z1t%R}Vq)T8+z2d&$ybzxJKUW6b&Lb2VhwrbV&{Z>ikL~`-iDA(eYh^bRTy);9% zHWRr(%9ynKC$?M_>H5%Oz1V|DQfj&`rH?R|B(P}c=aM7Tt#04+^;%%{J?Sgd&vz4r)jv9=j_*X29`!~db?gOp-(@E}FKOf7!?H|Mf{0&Gd4EW}slgxW49svDis`G=kqw0@WzfNt4-ivN@dg}cRIn6)GMJbtCFm{;U>lcUfU3%;aDpo4 zi>)!WqtNlaJ{#kG%6Erz%r?{ z#^n<$pfi8G;0qgzoc){5o3hV z2h^XT9!oMRzM|S!PMMRg)$ce9+EM}%D!#3)zo3;^5%?42#neq^74EbOzDE`O3uljW AsQ>@~ literal 0 HcmV?d00001 diff --git a/QuantTrader/core/execution/__pycache__/base.cpython-312.pyc b/QuantTrader/core/execution/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a5c151a670012995c481b0359284ad961ba5b267 GIT binary patch literal 7744 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/execution/__pycache__/base.cpython-313.pyc b/QuantTrader/core/execution/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a2c970b6214d2dc1d76d8d5939be74be929c85d4 GIT binary patch literal 7786 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 TeBJR^9Jg&+{T;zCWA1+dt2WV} literal 0 HcmV?d00001 diff --git a/QuantTrader/core/execution/__pycache__/oanda_handler.cpython-312.pyc b/QuantTrader/core/execution/__pycache__/oanda_handler.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..14fc7c49dc12106e5602764ba72beccca34c779e GIT binary patch literal 6893 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/execution/__pycache__/oanda_handler.cpython-313.pyc b/QuantTrader/core/execution/__pycache__/oanda_handler.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4277fa217402e7d24e07ef259f509561060610ce GIT binary patch literal 7023 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 PDNWbx_%DKhLGynADM$n( literal 0 HcmV?d00001 diff --git a/QuantTrader/core/execution/base.py b/QuantTrader/core/execution/base.py new file mode 100644 index 0000000..1af2e4c --- /dev/null +++ b/QuantTrader/core/execution/base.py @@ -0,0 +1,182 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Any +from datetime import datetime +import asyncio +from loguru import logger + +from ..strategy.base import SignalEvent, Position + +class OrderEvent: + """订单事件""" + + def __init__( + self, + instrument: str, + order_type: str, # "MARKET", "LIMIT", "STOP" + direction: str, # "BUY", "SELL" + quantity: float, + timestamp: datetime, + price: Optional[float] = None, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None, + order_id: Optional[str] = None + ): + self.event_type = "ORDER" + self.instrument = instrument + self.order_type = order_type + self.direction = direction + self.quantity = quantity + self.timestamp = timestamp + self.price = price + self.stop_loss = stop_loss + self.take_profit = take_profit + self.order_id = order_id + self.status = "CREATED" # CREATED, SUBMITTED, FILLED, CANCELLED, REJECTED + +class FillEvent: + """成交事件""" + + def __init__( + self, + instrument: str, + direction: str, + quantity: float, + price: float, + timestamp: datetime, + commission: float = 0.0, + order_id: Optional[str] = None + ): + self.event_type = "FILL" + self.instrument = instrument + self.direction = direction + self.quantity = quantity + self.price = price + self.timestamp = timestamp + self.commission = commission + self.order_id = order_id + +class ExecutionHandler(ABC): + """ + 执行处理器基类 + 负责订单执行和管理 + """ + + def __init__(self): + self.orders: Dict[str, OrderEvent] = {} # order_id -> OrderEvent + self.positions: Dict[str, Position] = {} # instrument -> Position + self.fills: List[FillEvent] = [] + self._order_callbacks = [] + self._fill_callbacks = [] + + async def process_signal(self, signal: SignalEvent, price: Optional[float] = None) -> OrderEvent: + """ + 处理交易信号并创建订单 + + Args: + signal: 交易信号 + + Returns: + 创建的订单事件 + """ + order = OrderEvent( + instrument=signal.instrument, + order_type="MARKET", # 默认为市价单 + direction="BUY" if signal.signal_type == "LONG" else "SELL", + quantity=abs(signal.strength), + timestamp=signal.timestamp, + stop_loss=signal.stop_loss, + take_profit=signal.take_profit, + price=price + ) + + # 生成订单ID + order.order_id = f"{order.instrument}_{order.timestamp.strftime('%Y%m%d_%H%M%S')}" + self.orders[order.order_id] = order + + # 执行订单 + try: + await self.execute_order(order) + except Exception as e: + logger.error(f"订单执行失败: {str(e)}") + order.status = "REJECTED" + + return order + + @abstractmethod + async def execute_order(self, order: OrderEvent) -> None: + """ + 执行订单 + + Args: + order: 要执行的订单 + """ + pass + + @abstractmethod + async def cancel_order(self, order_id: str) -> bool: + """ + 取消订单 + + Args: + order_id: 要取消的订单ID + + Returns: + 是否成功取消 + """ + pass + + def add_order_callback(self, callback): + """添加订单状态更新回调""" + self._order_callbacks.append(callback) + + def add_fill_callback(self, callback): + """添加成交更新回调""" + self._fill_callbacks.append(callback) + + async def _notify_order(self, order: OrderEvent): + """通知订单状态更新""" + for callback in self._order_callbacks: + await callback(order) + + async def _notify_fill(self, fill: FillEvent): + """通知成交更新""" + for callback in self._fill_callbacks: + await callback(fill) + + def get_position(self, instrument: str) -> Optional[Position]: + """获取某个品种的持仓""" + return self.positions.get(instrument) + + def get_all_positions(self) -> List[Position]: + """获取所有持仓""" + return list(self.positions.values()) + + def update_position(self, fill: FillEvent) -> None: + """根据成交更新持仓""" + instrument = fill.instrument + position = self.positions.get(instrument) + + if position is None: + # 新建仓位 + position = Position( + instrument=instrument, + direction=fill.direction, + size=fill.quantity, + entry_price=fill.price, + entry_time=fill.timestamp + ) + self.positions[instrument] = position + else: + # 更新现有仓位 + if fill.direction == position.direction: + # 同向加仓 + new_size = position.size + fill.quantity + position.entry_price = (position.entry_price * position.size + + fill.price * fill.quantity) / new_size + position.size = new_size + else: + # 反向减仓 + position.size -= fill.quantity + if position.size <= 0: + # 清仓 + del self.positions[instrument] diff --git a/QuantTrader/core/execution/oanda_handler.py b/QuantTrader/core/execution/oanda_handler.py new file mode 100644 index 0000000..a95dcd7 --- /dev/null +++ b/QuantTrader/core/execution/oanda_handler.py @@ -0,0 +1,117 @@ +from typing import Optional +from datetime import datetime +from loguru import logger +from oandapyV20 import API +import oandapyV20.endpoints.orders as orders +import csv +from pathlib import Path + +from .base import ExecutionHandler, OrderEvent, FillEvent + +class OANDAExecutionHandler(ExecutionHandler): + """实盘环境下的 OANDA 执行实现。""" + + def __init__(self, account_id: str, access_token: str, environment: str = "practice", fills_path: Optional[str] = None): + super().__init__() + self.client = API(access_token=access_token, environment=environment) + self.account_id = account_id + default_dir = Path("results/execution/live") if environment == "live" else Path("results/execution/paper") + default_dir.mkdir(parents=True, exist_ok=True) + self._fills_csv = Path(fills_path) if fills_path else default_dir / "fills.csv" + if not self._fills_csv.exists(): + self._init_csv() + + def _init_csv(self) -> None: + with self._fills_csv.open("w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter( + f, + fieldnames=["order_id", "ts", "symbol", "pnl", "adapter_latency_ms", "direction", "price", "quantity"], + ) + writer.writeheader() + + async def execute_order(self, order: OrderEvent) -> None: + # 将 OrderEvent 转换为 OANDA 下单请求 + instrument = order.instrument.replace("_", "_") + units = int(order.quantity) if order.direction.upper() in ("BUY", "LONG") else -int(order.quantity) + + order_body = { + "instrument": instrument, + "units": str(units), + "timeInForce": "FOK", + "positionFill": "DEFAULT", + } + if order.price: + order_body["type"] = "LIMIT" + order_body["price"] = f"{order.price:.5f}" + order_body["timeInForce"] = "GTC" + else: + order_body["type"] = "MARKET" + + payload = {"order": order_body} + req = orders.OrderCreate(accountID=self.account_id, data=payload) + try: + resp = self.client.request(req) + except Exception as exc: + logger.error(f"[OANDA] Order submission failed for {instrument}: {exc}") + order.status = "REJECTED" + await self._notify_order(order) + return + + order.status = "SUBMITTED" + await self._notify_order(order) + + fill_txn = resp.get("orderFillTransaction") + if not fill_txn: + logger.warning(f"[OANDA] Order accepted but no fill: {resp}") + return + + try: + price = float(fill_txn["price"]) + filled_units = abs(float(fill_txn["units"])) + commission = float(fill_txn.get("commission", 0)) + ts = datetime.fromisoformat(fill_txn["time"].replace("Z", "+00:00")) + side = "BUY" if float(fill_txn["units"]) > 0 else "SELL" + except Exception as exc: + logger.error(f"[OANDA] Unable to parse fill transaction: {fill_txn} ({exc})") + return + + fill = FillEvent( + instrument=order.instrument, + direction=side, + quantity=filled_units, + price=price, + timestamp=ts, + commission=abs(commission), + order_id=order.order_id + ) + + order.status = "FILLED" + await self._notify_order(order) + await self._notify_fill(fill) + self.fills.append(fill) + self.update_position(fill) + self._append_fill_csv(fill) + + def _append_fill_csv(self, fill: FillEvent) -> None: + record = { + "order_id": fill.order_id or "", + "ts": fill.timestamp.isoformat(), + "symbol": fill.instrument, + "pnl": 0.0, + "adapter_latency_ms": None, + "direction": fill.direction, + "price": fill.price, + "quantity": fill.quantity, + } + with self._fills_csv.open("a", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=record.keys()) + writer.writerow(record) + + async def cancel_order(self, order_id: str) -> bool: + # OANDA 取消需要调用 OrderCancel 或交易 API;简单实现为更新状态 + if order_id in self.orders: + order = self.orders[order_id] + order.status = "CANCELLED" + await self._notify_order(order) + return True + return False diff --git a/QuantTrader/core/oanda_execution.py b/QuantTrader/core/oanda_execution.py new file mode 100644 index 0000000..3fc54eb --- /dev/null +++ b/QuantTrader/core/oanda_execution.py @@ -0,0 +1,93 @@ +""" +OANDA 执行适配器:把 OrderEvent 转换为 OANDA API 下单,并回写 FillEvent。 +""" + +from __future__ import annotations + +from queue import Queue + +import pandas as pd +from loguru import logger +from oandapyV20 import API +import oandapyV20.endpoints.orders as orders + +from .events import FillEvent, OrderEvent + + +def _normalize_instrument(symbol: str) -> str: + s = symbol.upper().replace(" ", "").replace("/", "_").replace("-", "_") + if "_" in s and len(s) == 7: + return s + stripped = s.replace("_", "") + if len(stripped) == 6: + return f"{stripped[:3]}_{stripped[3:]}" + return s + + +class OandaExecution: + """ + 把 OrderEvent 翻译为 OANDA 订单;成交后投递 FillEvent。 + """ + + def __init__( + self, + q: Queue, + account_id: str, + access_token: str, + environment: str = "practice", + ) -> None: + self.q = q + self.account_id = account_id + self.client = API(access_token=access_token, environment=environment) + + def on_event(self, ev) -> None: + if not isinstance(ev, OrderEvent): + return + instrument = _normalize_instrument(ev.symbol) + units = ev.size if ev.side == "BUY" else -ev.size + order_body = { + "instrument": instrument, + "units": str(int(units)), + "timeInForce": "FOK", + "positionFill": "DEFAULT", + } + if ev.price is None: + order_body["type"] = "MARKET" + else: + order_body["type"] = "LIMIT" + order_body["timeInForce"] = "GTC" + order_body["price"] = f"{ev.price:.5f}" + + payload = {"order": order_body} + req = orders.OrderCreate(accountID=self.account_id, data=payload) + try: + resp = self.client.request(req) + except Exception as exc: + logger.error(f"[OANDA] Order submission failed for {instrument}: {exc}") + return + + fill_txn = resp.get("orderFillTransaction") + if not fill_txn: + logger.warning(f"[OANDA] Order accepted but no fill: {resp}") + return + + try: + price = float(fill_txn["price"]) + filled_units = abs(float(fill_txn["units"])) + commission = float(fill_txn.get("commission", 0)) + ts = pd.to_datetime(fill_txn["time"]).to_pydatetime() + side = "BUY" if float(fill_txn["units"]) > 0 else "SELL" + except Exception as exc: + logger.error(f"[OANDA] Unable to parse fill transaction: {fill_txn} ({exc})") + return + + self.q.put( + FillEvent( + ts=ts, + symbol=ev.symbol, + side=side, + size=filled_units, + price=price, + commission=abs(commission), + ) + ) diff --git a/QuantTrader/core/risk/__init__.py b/QuantTrader/core/risk/__init__.py new file mode 100644 index 0000000..959b38c --- /dev/null +++ b/QuantTrader/core/risk/__init__.py @@ -0,0 +1,2 @@ +# core.risk package init +from . import base diff --git a/QuantTrader/core/risk/__pycache__/__init__.cpython-312.pyc b/QuantTrader/core/risk/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b206003ca915c9449d9de73606a366398c69d2e GIT binary patch literal 194 zcmX@j%ge<81V`8LWa zxJAS}B_?N=q!yRxC+8QX>KA1eXY0quXXa&=#K-FuRNmsS$<0qG%}KQ@;sBZmvaT4! R_`uA_$oPywsE7^70RUwmFaH1l literal 0 HcmV?d00001 diff --git a/QuantTrader/core/risk/__pycache__/__init__.cpython-313.pyc b/QuantTrader/core/risk/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b08b424b814ccd18b667b7bc0b74746b01be3af0 GIT binary patch literal 194 zcmey&%ge<81V`8LWaz*Q}arS^#h9Xvr?0R z47Z4Qr^Mv!lGNf7{p9?jRQ;mN;%xo+_{_Y_lK6PNg34PQHo5sJr8%i~MI1mALDm(6 R7$2A!85y532odx5L$G#U>*ss3#R@j|b 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/risk/base.py b/QuantTrader/core/risk/base.py new file mode 100644 index 0000000..21537e5 --- /dev/null +++ b/QuantTrader/core/risk/base.py @@ -0,0 +1,161 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Any +from datetime import datetime +import pandas as pd + +from ..strategy.base import Position, SignalEvent + +class RiskEvent: + """风险事件""" + + def __init__( + self, + event_type: str, # "RISK_LIMIT", "STOP_LOSS", "MARGIN_CALL" etc. + instrument: str, + timestamp: datetime, + message: str, + severity: str = "WARNING", # "INFO", "WARNING", "CRITICAL" + data: Optional[Dict[str, Any]] = None + ): + self.event_type = event_type + self.instrument = instrument + self.timestamp = timestamp + self.message = message + self.severity = severity + self.data = data or {} + +class PositionSizer(ABC): + """ + 仓位管理器基类 + 负责计算每笔交易的具体仓位大小 + """ + + @abstractmethod + def calculate_position_size( + self, + signal: SignalEvent, + portfolio_value: float, + risk_per_trade: float + ) -> float: + """ + 计算交易仓位大小 + + Args: + signal: 交易信号 + portfolio_value: 当前组合总价值 + risk_per_trade: 每笔交易的风险比例 + + Returns: + 建议的仓位大小 + """ + pass + +class RiskManager(ABC): + """ + 风险管理器基类 + 负责风险控制和监控 + """ + + def __init__( + self, + max_position_size: float, + max_portfolio_risk: float, + max_drawdown: float + ): + self.max_position_size = max_position_size + self.max_portfolio_risk = max_portfolio_risk + self.max_drawdown = max_drawdown + self.current_drawdown = 0.0 + self.peak_value = 0.0 + + @abstractmethod + async def check_signal(self, signal: SignalEvent) -> bool: + """ + 检查交易信号是否符合风险控制要求 + + Args: + signal: 交易信号 + + Returns: + True if signal is acceptable, False otherwise + """ + pass + + @abstractmethod + async def check_position(self, position: Position) -> List[RiskEvent]: + """ + 检查持仓的风险状况 + + Args: + position: 当前持仓 + + Returns: + 风险事件列表 + """ + pass + + def update_drawdown(self, portfolio_value: float) -> Optional[RiskEvent]: + """ + 更新和检查回撤状况 + + Args: + portfolio_value: 当前组合价值 + + Returns: + 如果超过最大回撤限制,返回风险事件 + """ + if portfolio_value > self.peak_value: + self.peak_value = portfolio_value + self.current_drawdown = 0.0 + else: + self.current_drawdown = (self.peak_value - portfolio_value) / self.peak_value + + if self.current_drawdown > self.max_drawdown: + return RiskEvent( + event_type="MAX_DRAWDOWN_BREACH", + instrument="PORTFOLIO", + timestamp=datetime.now(), + message=f"Maximum drawdown breached: {self.current_drawdown:.2%}", + severity="CRITICAL", + data={"drawdown": self.current_drawdown} + ) + return None + +class SimpleRiskManager(RiskManager): + """ + 简单风险管理器实现 + 实现基本的风险控制功能 + """ + + async def check_signal(self, signal: SignalEvent) -> bool: + """检查交易信号""" + # 实现基本的信号检查逻辑 + if not signal.stop_loss: + return False # 要求必须有止损 + return True + + async def check_position(self, position: Position) -> List[RiskEvent]: + """检查持仓风险""" + events = [] + + # 检查持仓规模 + if abs(position.size) > self.max_position_size: + events.append(RiskEvent( + event_type="POSITION_SIZE_LIMIT", + instrument=position.instrument, + timestamp=datetime.now(), + message=f"Position size {position.size} exceeds limit {self.max_position_size}", + severity="WARNING" + )) + + # 检查止损 + if not position.stop_loss: + events.append(RiskEvent( + event_type="MISSING_STOP_LOSS", + instrument=position.instrument, + timestamp=datetime.now(), + message="Position has no stop loss", + severity="WARNING" + )) + + return events \ No newline at end of file diff --git a/QuantTrader/core/risk/risk_engine.py b/QuantTrader/core/risk/risk_engine.py new file mode 100644 index 0000000..a41d5d1 --- /dev/null +++ b/QuantTrader/core/risk/risk_engine.py @@ -0,0 +1,70 @@ +"""Lightweight risk engine enforcing exposure, leverage, and loss caps.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Tuple + + +@dataclass +class RiskLimits: + max_position_notional: float + max_gross_leverage: float + max_daily_loss: float + max_drawdown: float + + +@dataclass +class RiskState: + equity: float = 0.0 + peak_equity: float = 0.0 + min_equity: float = float("inf") + realized_pnl: float = 0.0 + gross_notional: float = 0.0 + exposures: Dict[str, float] = field(default_factory=dict) + + +class RiskViolation(Exception): + """Raised when orders violate limits.""" + + +class RiskEngine: + def __init__(self, limits: RiskLimits, starting_equity: float): + self.limits = limits + self.state = RiskState(equity=starting_equity, peak_equity=starting_equity, min_equity=starting_equity) + + def evaluate_order(self, symbol: str, side: str, notional: float) -> Tuple[bool, str]: + exposure = self.state.exposures.get(symbol, 0.0) + proposed = exposure + (notional if side.lower() == "buy" else -notional) + if abs(proposed) > self.limits.max_position_notional: + return False, f"symbol_exposure_limit:{symbol}" + + gross = self.state.gross_notional + abs(notional) + leverage = gross / self.state.equity if self.state.equity else float("inf") + if leverage > self.limits.max_gross_leverage: + return False, "gross_leverage_limit" + return True, "ok" + + def record_fill(self, symbol: str, side: str, notional: float, pnl: float) -> None: + delta = notional if side.lower() == "buy" else -notional + self.state.exposures[symbol] = self.state.exposures.get(symbol, 0.0) + delta + self.state.gross_notional = sum(abs(v) for v in self.state.exposures.values()) + + self.state.realized_pnl += pnl + self.state.equity += pnl + self.state.peak_equity = max(self.state.peak_equity, self.state.equity) + self.state.min_equity = min(self.state.min_equity, self.state.equity) + + def check_loss_limits(self) -> Tuple[bool, str]: + if -self.state.realized_pnl > self.limits.max_daily_loss: + return False, "daily_loss_limit" + drawdown = (self.state.equity - self.state.peak_equity) / self.state.peak_equity if self.state.peak_equity else 0.0 + if drawdown < -self.limits.max_drawdown: + return False, "drawdown_limit" + return True, "ok" + + def max_drawdown_pct(self) -> float: + if not self.state.peak_equity: + return 0.0 + trough = self.state.min_equity if self.state.min_equity != float("inf") else self.state.equity + return abs((trough - self.state.peak_equity) / self.state.peak_equity) diff --git a/QuantTrader/core/strategy/__init__.py b/QuantTrader/core/strategy/__init__.py new file mode 100644 index 0000000..4095fb8 --- /dev/null +++ b/QuantTrader/core/strategy/__init__.py @@ -0,0 +1,3 @@ +# core.strategy package init +from . import base +from . import rsi_mean_reversion diff --git a/QuantTrader/core/strategy/__pycache__/__init__.cpython-312.pyc b/QuantTrader/core/strategy/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24da7db8237588876f06acae8243fc3e2cd1828f GIT binary patch literal 240 zcmX@j%ge<81V`5KWCjB1#~=<2FhLogWq^$73@HpLj5!Rsj8Tk?AT|?_%@oDN$WY0w z$?}pBs6>PO4oIC(u-oYl^vn#0O?ZM#iTMLiZRHir9gC E05U^93IG5A literal 0 HcmV?d00001 diff --git a/QuantTrader/core/strategy/__pycache__/__init__.cpython-313.pyc b/QuantTrader/core/strategy/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b2cab696fbd79e62289bd3f5b094c244cc42a02 GIT binary patch literal 240 zcmey&%ge<81V`5KWCjB1#~=<2FhLogWq^#S48aV+jNS}hj75wJAT|?_%~Zssz>vPO4oIC(u-oYl^vn#0O?ZM#iTMLiZRHir9gC E0QGP^pa1{> literal 0 HcmV?d00001 diff --git a/QuantTrader/core/strategy/__pycache__/base.cpython-312.pyc b/QuantTrader/core/strategy/__pycache__/base.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fad23e98977b3972d01adda79aaff6d58d2a5247 GIT binary patch literal 5828 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/strategy/__pycache__/base.cpython-313.pyc b/QuantTrader/core/strategy/__pycache__/base.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..58f4439983bb711752dac745dc0515e3fba2a5bf GIT binary patch literal 5786 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 Oj;Wa3{SCpVYUV$5Q_^z) literal 0 HcmV?d00001 diff --git a/QuantTrader/core/strategy/__pycache__/rsi_mean_reversion.cpython-313.pyc b/QuantTrader/core/strategy/__pycache__/rsi_mean_reversion.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1ae9a49fa3dc0e09d62f0db06c2dcce81d847a02 GIT binary patch literal 6300 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/core/strategy/base.py b/QuantTrader/core/strategy/base.py new file mode 100644 index 0000000..a6e29ce --- /dev/null +++ b/QuantTrader/core/strategy/base.py @@ -0,0 +1,122 @@ +from abc import ABC, abstractmethod +from typing import Dict, List, Optional, Any +from datetime import datetime +import pandas as pd + +from ..data.base import MarketDataEvent + +class Position: + """持仓类,表示当前市场头寸""" + + def __init__( + self, + instrument: str, + direction: str, # "LONG" or "SHORT" + size: float, + entry_price: float, + entry_time: datetime, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None + ): + self.instrument = instrument + self.direction = direction + self.size = size + self.entry_price = entry_price + self.entry_time = entry_time + self.stop_loss = stop_loss + self.take_profit = take_profit + self.unrealized_pnl = 0.0 + self.realized_pnl = 0.0 + +class SignalEvent: + """交易信号事件""" + + def __init__( + self, + instrument: str, + timestamp: datetime, + signal_type: str, # "LONG", "SHORT", "EXIT" + direction: str, + strength: float = 1.0, + stop_loss: Optional[float] = None, + take_profit: Optional[float] = None + ): + self.event_type = "SIGNAL" + self.instrument = instrument + self.timestamp = timestamp + self.signal_type = signal_type + self.direction = direction + self.strength = strength + self.stop_loss = stop_loss + self.take_profit = take_profit + +class Strategy(ABC): + """ + 策略基类 + 定义了策略开发的标准接口 + """ + + def __init__( + self, + instrument: str, + position_size: float = 1.0, + max_positions: int = 1 + ): + self.instrument = instrument + self.position_size = position_size + self.max_positions = max_positions + self.positions: List[Position] = [] + self.historical_data: Optional[pd.DataFrame] = None + + @abstractmethod + async def on_data(self, event: MarketDataEvent) -> Optional[SignalEvent]: + """ + 处理市场数据更新 + + Args: + event: 市场数据事件 + + Returns: + 如果产生交易信号,返回SignalEvent;否则返回None + """ + pass + + @abstractmethod + async def calculate_signals(self, data: pd.DataFrame) -> List[SignalEvent]: + """ + 基于历史数据计算交易信号 + + Args: + data: 历史市场数据 + + Returns: + 交易信号列表 + """ + pass + + def update_position(self, position: Position, current_price: float) -> None: + """ + 更新持仓的未实现盈亏 + + Args: + position: 需要更新的持仓 + current_price: 当前市场价格 + """ + if position.direction == "LONG": + position.unrealized_pnl = (current_price - position.entry_price) * position.size + else: + position.unrealized_pnl = (position.entry_price - current_price) * position.size + + def can_open_position(self) -> bool: + """检查是否可以开新仓位""" + return len(self.positions) < self.max_positions + + def get_position_value(self) -> float: + """获取当前持仓的总价值""" + return sum(abs(pos.unrealized_pnl) for pos in self.positions) + + def get_total_pnl(self) -> float: + """获取总盈亏(已实现 + 未实现)""" + unrealized = sum(pos.unrealized_pnl for pos in self.positions) + realized = sum(pos.realized_pnl for pos in self.positions) + return realized + unrealized \ No newline at end of file diff --git a/QuantTrader/core/strategy/ma_crossover.py b/QuantTrader/core/strategy/ma_crossover.py new file mode 100644 index 0000000..4bb2e25 --- /dev/null +++ b/QuantTrader/core/strategy/ma_crossover.py @@ -0,0 +1,65 @@ +from typing import List, Optional +import pandas as pd +from datetime import datetime + +from .base import Strategy, SignalEvent +from .base import Position +from ..data.base import MarketDataEvent + +class MACrossoverStrategy(Strategy): + """ + 简单移动平均交叉策略 + 快线上穿慢线做多,下穿做空 + """ + def __init__(self, instrument: str, fast_period: int = 50, slow_period: int = 200, position_size: float = 1.0): + super().__init__(instrument, position_size) + self.fast_period = fast_period + self.slow_period = slow_period + + async def on_data(self, event: MarketDataEvent) -> Optional[SignalEvent]: + if self.historical_data is None: + return None + # 更新收盘价 + close = event.data.get('close') or event.data.get('mid') + self.historical_data.loc[event.timestamp] = { + 'open': event.data.get('open', close), + 'high': event.data.get('high', close), + 'low': event.data.get('low', close), + 'close': close + } + if len(self.historical_data) < self.slow_period: + return None + + fast = self.historical_data['close'].rolling(self.fast_period).mean() + slow = self.historical_data['close'].rolling(self.slow_period).mean() + + if fast.iloc[-2] <= slow.iloc[-2] and fast.iloc[-1] > slow.iloc[-1]: + return SignalEvent( + instrument=self.instrument, + timestamp=event.timestamp, + signal_type="LONG", + direction="BUY", + strength=self.position_size + ) + if fast.iloc[-2] >= slow.iloc[-2] and fast.iloc[-1] < slow.iloc[-1]: + return SignalEvent( + instrument=self.instrument, + timestamp=event.timestamp, + signal_type="SHORT", + direction="SELL", + strength=self.position_size + ) + return None + + async def calculate_signals(self, data: pd.DataFrame) -> List[SignalEvent]: + signals = [] + self.historical_data = data.copy() + fast = data['close'].rolling(self.fast_period).mean() + slow = data['close'].rolling(self.slow_period).mean() + for i in range(self.slow_period, len(data)): + ts = data.index[i] + if fast.iloc[i-1] <= slow.iloc[i-1] and fast.iloc[i] > slow.iloc[i]: + signals.append(SignalEvent(instrument=self.instrument, timestamp=ts, signal_type="LONG", direction="BUY", strength=self.position_size)) + if fast.iloc[i-1] >= slow.iloc[i-1] and fast.iloc[i] < slow.iloc[i]: + signals.append(SignalEvent(instrument=self.instrument, timestamp=ts, signal_type="SHORT", direction="SELL", strength=self.position_size)) + return signals diff --git a/QuantTrader/core/strategy/momentum.py b/QuantTrader/core/strategy/momentum.py new file mode 100644 index 0000000..5b9a8c4 --- /dev/null +++ b/QuantTrader/core/strategy/momentum.py @@ -0,0 +1,46 @@ +from typing import List, Optional +import pandas as pd +from datetime import datetime + +from .base import Strategy, SignalEvent +from ..data.base import MarketDataEvent + +class MomentumStrategy(Strategy): + """ + 简单动量策略:当价格高于N日均线时做多,低于时做空 + """ + def __init__(self, instrument: str, lookback: int = 20, position_size: float = 1.0): + super().__init__(instrument, position_size) + self.lookback = lookback + + async def on_data(self, event: MarketDataEvent) -> Optional[SignalEvent]: + if self.historical_data is None: + return None + close = event.data.get('close') or event.data.get('mid') + self.historical_data.loc[event.timestamp] = { + 'open': event.data.get('open', close), + 'high': event.data.get('high', close), + 'low': event.data.get('low', close), + 'close': close + } + if len(self.historical_data) < self.lookback: + return None + ma = self.historical_data['close'].rolling(self.lookback).mean() + if close > ma.iloc[-1] and self.can_open_position(): + return SignalEvent(self.instrument, event.timestamp, "LONG", "BUY", strength=self.position_size) + if close < ma.iloc[-1] and self.can_open_position(): + return SignalEvent(self.instrument, event.timestamp, "SHORT", "SELL", strength=self.position_size) + return None + + async def calculate_signals(self, data: pd.DataFrame) -> List[SignalEvent]: + signals = [] + self.historical_data = data.copy() + ma = data['close'].rolling(self.lookback).mean() + for i in range(self.lookback, len(data)): + ts = data.index[i] + price = data['close'].iloc[i] + if price > ma.iloc[i-1]: + signals.append(SignalEvent(self.instrument, ts, "LONG", "BUY", strength=self.position_size)) + elif price < ma.iloc[i-1]: + signals.append(SignalEvent(self.instrument, ts, "SHORT", "SELL", strength=self.position_size)) + return signals diff --git a/QuantTrader/core/strategy/rsi_mean_reversion.py b/QuantTrader/core/strategy/rsi_mean_reversion.py new file mode 100644 index 0000000..3776aa9 --- /dev/null +++ b/QuantTrader/core/strategy/rsi_mean_reversion.py @@ -0,0 +1,150 @@ +from typing import List, Optional +import pandas as pd +import numpy as np +from datetime import datetime + +from ..data.base import MarketDataEvent +from .base import Strategy, SignalEvent + +class RSIMeanReversionStrategy(Strategy): + """ + RSI均值回归策略 + 当RSI超买时做空,超卖时做多 + """ + + def __init__( + self, + instrument: str, + position_size: float = 1.0, + max_positions: int = 1, + rsi_period: int = 14, + overbought: float = 70.0, + oversold: float = 30.0, + stop_loss_atr: float = 2.0, + atr_period: int = 14 + ): + super().__init__(instrument, position_size, max_positions) + self.rsi_period = rsi_period + self.overbought = overbought + self.oversold = oversold + self.stop_loss_atr = stop_loss_atr + self.atr_period = atr_period + self.last_rsi = None + self.last_atr = None + + @staticmethod + def calculate_rsi(data: pd.Series, period: int = 14) -> pd.Series: + """计算RSI指标""" + delta = data.diff() + gain = (delta.where(delta > 0, 0)).rolling(window=period).mean() + loss = (-delta.where(delta < 0, 0)).rolling(window=period).mean() + rs = gain / loss + return 100 - (100 / (1 + rs)) + + @staticmethod + def calculate_atr(data: pd.DataFrame, period: int = 14) -> pd.Series: + """计算ATR指标""" + high = data['high'] + low = data['low'] + close = data['close'] + + tr1 = high - low + tr2 = abs(high - close.shift()) + tr3 = abs(low - close.shift()) + tr = pd.concat([tr1, tr2, tr3], axis=1).max(axis=1) + + return tr.rolling(window=period).mean() + + async def on_data(self, event: MarketDataEvent) -> Optional[SignalEvent]: + """ + 处理实时市场数据 + + Args: + event: 市场数据事件 + + Returns: + 如果触发信号则返回SignalEvent,否则返回None + """ + if self.historical_data is None: + return None + + # 更新数据 + current_price = event.data['mid'] + self.historical_data.loc[event.timestamp] = current_price + + # 计算指标 + close_prices = self.historical_data['close'] + rsi = self.calculate_rsi(close_prices, self.rsi_period).iloc[-1] + atr = self.calculate_atr(self.historical_data, self.atr_period).iloc[-1] + + self.last_rsi = rsi + self.last_atr = atr + + # 生成信号 + if self.can_open_position(): + if rsi > self.overbought: + return SignalEvent( + instrument=self.instrument, + timestamp=event.timestamp, + signal_type="SHORT", + direction="SELL", + strength=self.position_size, + stop_loss=current_price + self.stop_loss_atr * atr + ) + elif rsi < self.oversold: + return SignalEvent( + instrument=self.instrument, + timestamp=event.timestamp, + signal_type="LONG", + direction="BUY", + strength=self.position_size, + stop_loss=current_price - self.stop_loss_atr * atr + ) + + return None + + async def calculate_signals(self, data: pd.DataFrame) -> List[SignalEvent]: + """ + 基于历史数据计算交易信号 + + Args: + data: 历史市场数据 + + Returns: + 交易信号列表 + """ + signals = [] + self.historical_data = data.copy() + + # 计算指标 + close_prices = data['close'] + rsi = self.calculate_rsi(close_prices, self.rsi_period) + atr = self.calculate_atr(data, self.atr_period) + + # 生成信号 + for i in range(self.rsi_period, len(data)): + timestamp = data.index[i] + current_price = close_prices[i] + current_rsi = rsi[i] + current_atr = atr[i] + + if current_rsi > self.overbought: + signals.append(SignalEvent( + instrument=self.instrument, + timestamp=timestamp, + signal_type="SHORT", + direction="SELL", + strength=self.position_size, + stop_loss=current_price + self.stop_loss_atr * current_atr + )) + elif current_rsi < self.oversold: + signals.append(SignalEvent( + instrument=self.instrument, + timestamp=timestamp, + signal_type="LONG", + direction="BUY", + strength=self.position_size, + stop_loss=current_price - self.stop_loss_atr * current_atr + )) + + return signals \ No newline at end of file diff --git a/QuantTrader/data/__pycache__/oanda_stream.cpython-312.pyc b/QuantTrader/data/__pycache__/oanda_stream.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..23b35d1239afb646626c589450a21baec6de7602 GIT binary patch literal 6860 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/data/__pycache__/oanda_stream.cpython-313.pyc b/QuantTrader/data/__pycache__/oanda_stream.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dc15d170e48e004fd46d963f773faf9ed535c5ba GIT binary patch literal 7082 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 str: + s = symbol.upper().replace(" ", "").replace("/", "_").replace("-", "_") + if "_" in s and len(s) == 7: + return s + stripped = s.replace("_", "") + if len(stripped) == 6: + return f"{stripped[:3]}_{stripped[3:]}" + return s + + +class OandaPricingStream: + """ + 使用 OANDA Pricing Stream API 推送 TickEvent。 + """ + + def __init__( + self, + q: Queue, + account_id: str, + instruments: Sequence[str], + access_token: str, + environment: str = "practice", + reconnect_wait: float = 5.0, + log_heartbeat: bool = False, + ) -> None: + self.q = q + self.account_id = account_id + self.instruments = [_normalize_instrument(sym) for sym in instruments] + self.log_heartbeat = log_heartbeat + self.client = API(access_token=access_token, environment=environment) + self.reconnect_wait = reconnect_wait + self._thread: Optional[threading.Thread] = None + self._stop = threading.Event() + + def start(self) -> None: + if PricingStream is None: + raise RuntimeError( + "oandapyV20 未暴露 PricingStream(需 0.7.2+)。请执行 `pip install --upgrade oandapyV20` 后重试。" + ) + if self._thread and self._thread.is_alive(): + return + self._stop.clear() + self._thread = threading.Thread(target=self._run, daemon=True) + self._thread.start() + logger.info( + f"[OANDA] Pricing stream started for {','.join(self.instruments)} (account={self.account_id})" + ) + + def stop(self) -> None: + self._stop.set() + if self._thread: + self._thread.join(timeout=2.0) + logger.info("[OANDA] Pricing stream stopped.") + + def _run(self) -> None: + if PricingStream is None: + logger.error("无法启动价格流:缺少 PricingStream 类") + return + params = {"instruments": ",".join(self.instruments)} + while not self._stop.is_set(): + request = PricingStream(accountID=self.account_id, params=params) + try: + for msg in self.client.request(request): + if self._stop.is_set(): + break + self._handle_msg(msg) + except Exception as exc: + if self._stop.is_set(): + break + logger.warning(f"[OANDA] Pricing stream error: {exc}. Reconnecting in {self.reconnect_wait}s") + time.sleep(self.reconnect_wait) + + def _handle_msg(self, msg: dict) -> None: + msg_type = msg.get("type") + if msg_type == "HEARTBEAT": + if self.log_heartbeat: + logger.debug(f"[OANDA] Heartbeat {msg.get('time')}") + return + if msg_type != "PRICE": + logger.debug(f"[OANDA] Skip message type={msg_type}") + return + try: + symbol = msg["instrument"].replace("_", "") + bids = msg.get("bids") + asks = msg.get("asks") + if not bids or not asks: + return + bid = float(bids[0]["price"]) + ask = float(asks[0]["price"]) + ts = pd.Timestamp(msg["time"]).floor("us").to_pydatetime() + except Exception as exc: + logger.warning(f"[OANDA] Malformed price message: {msg} ({exc})") + return + self.q.put(TickEvent(ts=ts, symbol=symbol, bid=bid, ask=ask)) diff --git a/QuantTrader/execution/__pycache__/adapter.cpython-312.pyc b/QuantTrader/execution/__pycache__/adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..10152db3a5c4040e0bf4530f7617257491082839 GIT binary patch literal 5079 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/execution/__pycache__/config_loader.cpython-312.pyc b/QuantTrader/execution/__pycache__/config_loader.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d28a21be1e255033dee54403dc033daa28f3665 GIT binary patch literal 1555 zcmah}O=ufO6rPntACkC*L)qf`_p zSxGu-MeXSlcB+y>Nu(m>>qI5pOcYbC%gflVW8!Pn(`*kS4L7hEP|I^Qh+v&!qM4ps zwQ2!@LOcCJBvF)D2D+|Cdpe(s@-;};i*XiTaGOkCv`os>mAZ(4cG;9f&|rxQc`ef6 zk6haZ^vJde8j|0uWN)CmC2dL@gSw+|vF{B8KSvXh9>q4L@4yeSDE2FfCL`O18ga)v zKRI(OMI4I%EJr6qdp|1++=^|+HnNfD@s1La=S%DgO76w_{x9)RQR)PbG|HUdF@&-w zc;rsVa~O?8C!o>qlb?d(E9KV3j|1q^&j>`A=zS>YM;I|Y=ps1XgpS?HuPl@o7Y1n2 zI5xDZ7r1CvYb63&y>x|xM8&S@Axd;Hu5==?cScB$GuUf73ANpXPWgOTrgw4YK z3xOgwNz<%7<0VFNKJu2!_a@xItOGhAIC&axXE} ztk(J|$_P^Ei`+F$`}Y(5y3fC=Zm#o|=!>2iIN0S$UnAZIHaSyTH9q98K(SBt|F`X^ zUxNnruE%7$-ejX9lYSeGes@WMUngqyf8}Hg><^#%X7S0`Qm3#K4wtr;yO~pW=5NnG z%@p=?uXWDPKD_eqt&X-7=1Lv4w4WKiGk1G#=gr?IryfsEJ*agir^3m(Ff;!ov-I1$ zTZ?~$*LOC<@u}`uzP-G?yi@5E%i-8cH!|J79*)m+C(hl=-_7sp4`#xN_q%7# zwj0}x-R%7@!ZYu5^M!lrU3K@>Fn_swdZN9$y}EO=^Y+#7^hbXWt3%2GP!q}_&z=ve za#1FE&b6}9IQrbDK@HvP}lJp07 a?>RXC9OVB57aoHP&p_rMB`Ybt=>Gt7Sc)6~ literal 0 HcmV?d00001 diff --git a/QuantTrader/execution/__pycache__/metrics_logger.cpython-312.pyc b/QuantTrader/execution/__pycache__/metrics_logger.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d2ef1d85534151cd186fca78a3608a96d4bb87c9 GIT binary patch literal 3286 zcmaJ@O>7&-6`uX&k|ITsw*LGh(Msc#bt*NklL)9{N0Ox`YD~GY9LIE`70q4Aq?cTE zc4e6a6}Yg9T4({qy~subSVtT3!9a89A&0a*_F`57$WCRXK+r=kg$lVa;8Wk*<&v^0 zcL=`ucHY~anK$2iZ~hz#1qif*2O^n|WJ12h2d_yk;I@AQV1;NTNi@%8j!DNslx{oJ6CYrKHH2)g! z&bL;ZKN)g#0njyhx@5B!(t?X(GF%Yi&BeaK*;zfW#q{fXdd|+6`B+Z3X(nyOY%?}| z?xWbWNn<%PpRrAv$kG6x0zy6O{Z!`W3pAqT{ewk*jwJEX~Rs}pR#}f zd&s8?YR&>9*zugj{J1B;x!FWqWQwZhQ#oB#SwK~DrZ#6F4yx+U=2AvY6IRt74pY?! zWE*$<{9NC8OQ%*}`pR4?-*?(f&*flUR^M4_X7x0H(T~-kRQjq7)AjunZ0re|f+hFW z&#SL?UaDcv%;>atwm^}OjtBv;j)3?D`Fm6I&9Q28~bR~WbzKpFzRMACz!fyfu1$dsxpO3t~ykc6)Y>-g^Twkn?MJra%hh74inLxOvEH z7YPTy0=X_+B=cN6kYH^iACC;5pLl;fp`IC;`0)MVbL!c_iT8@FHTE|6+?%$pO+Z5N z&88@Z0#o$s8Ov79tBjjx zZ(7W!=dWd`nPW$e8>b?1q+;pjCO%$Y@IUE zfJL~G)#`lXs$Q^I(>!Xg=opf5iHVkOOw;e7KIny|GZ#M%^EtNELQco9Q9Los9vR54 zs#oH++GC|_L-|oPyl=U%RH%fz%i->g@R4eyb*1xm z=StV@u2tdwhrfUA^RA7^(ASqYI}hFKy4!W1|I+?yyfSd9JaDNpFjXFyS|7N){&sq! zQ~O-5wzO4Rdde+58!g{?CXwhc_c$`NC6Jb$Zz4kgpIaDIf80M98a^ic>6kQp!nb!) zXmgVS$%6UrmaT=u&T&ivPG~klg}3X;A`4}BcPsI>-y5^*;hzn9V~sxhf5N#QDw0S2 z1)(Hl_Xy@j;FdV=EwA4zd|nzna)NOw8iVyw%;dBO#j7CV0f%X{7YF)K`~bxpC~$)m zc}kCiV2VpUbDm;ijEnRHj>;}Y>04;`HV6x~4Q2{ZQHqflNeM%^3M&Z$_!x>TUtGGl zJh?P^XZU{f(af#MjnK)jdp27;Rwi#xt`2>9;;V3_|3bO{LZ$zca{njm{gdlIR5x0u zZjDxh(dF@_@h?VK-}-H0BX|t>(|oEDI#~{#d~V^f+#75kJSF_$lr*IH;vC#Dob47v z^#Tp?_$llKE4{N}CR5#>qj@GK%)B0#XcRS=Kq=-<+K=KL6m2M+iig2PF<&|Yhn%?= z$y44Zz4|mNNUytwXp`Q_t0ow53fd|FC}>hiEP~s!wIT$A_2#8bjVl627i7E6Xiu}? zEBW>+c00-&l*|%el1oBK%GQ}&pQ2uH9`b9oRW{gGAaNlvi+I|FO+o4L^(Q@0-^|tRm4f*_m{|J6b zc8)UM>@FG)&~<`?98WmN@q~kh(FEN|PuI~&M5jPNeetQPW~SYX-}D@C4=#Qb57&)H z+y`r_Ux;QbR--!Pk5`RJtKOr4Y$iXd{@J-KnncKHTKx_-}xP_0;q9N|} Oe-jW-V?oY!(tiP5SH9f< literal 0 HcmV?d00001 diff --git a/QuantTrader/execution/__pycache__/oanda_adapter.cpython-312.pyc b/QuantTrader/execution/__pycache__/oanda_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7fba5a10f7f21d955aa4f1d64b0b45975cc98e1a GIT binary patch literal 9440 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 literal 0 HcmV?d00001 diff --git a/QuantTrader/execution/__pycache__/order_store.cpython-312.pyc b/QuantTrader/execution/__pycache__/order_store.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0bc9f02f7ed8e31dc35fb3cd7dddfd773597cd35 GIT binary patch literal 2103 zcmb7F-ER{|5Z}EopY1pygwQ}jV!jd%sWwni^#fH(i^@l<5EPIaSLAx!UdWmIaCgrF zTR}oq9uf&DMXeehd8q_Skn#ugCC_~!B305WJRz#~t!ST z-JRde+>g0j2ElN%?KTk+dd?TgkYWa}e*w%KBB+E2CW3=qp(J3=i;h?lVU`@pl}mCY zlO4rPl~NI}II5d2r6WG&WZY~ii;;lNAfiqolAagh6G|;LQOneij@WL+vGV6fE)0E| z_bIVxo&`R&^5Z_u8&zTlMQT+XqgMEx8)`Vr8lL9|Mqv9M8-lfBFk+hlaMG|5Ou($3 z3oL3}bu6xZf%_absNpg*K02oGaz5~SKhQa3p%RAI3PdIf!BaB4F-3$asU#7Vh%n1F zNlS;B$lVCPq1kvz)o9SRAH)*)oCo_%r=O!@M3?EGS+Co!}BRAi_oK zre#(+D8&dxJwmZisfDVp+nychdcEUy@Cu2rm7jeS$Zhm=SJ%B=-<+S8?xfe$mf7@7 zdR6UOR=ZZ!-etA-ey~U$tGz#|@2`V4G8_1D9x3pTqQ3$RWG$Y=Hg3W|NHF*w-x4Rc zk3A35mO%s}E=Wn7Poi7Wq_iC#K{_!kN0pVV;(1a`PA765M9B&&B>E-(IgSU>am4Tq zsI*ap;a5X9#ZhzvYsyfl#&NYth>Z=o8jQdB&O^v`%QJmqd)Gpo(GE=4zAS+B+3Y=j8K7m2LGWjKEZe-@_f+tNX4jtCsJ>3LN6BW zgl%>a$ZfQ?v*+H#4s-K6)@PJ+_iNv3PZ@rFHh~ z%-L@*&6Vz!?ki6^2Ob<+?kudd6kmy`^{v(1iRIjh4VDG3kB$uXic7un;6TdUtQ?SY zv=8!H`xTf^AWCxxFa>_=C^zRwV{^VSGvOK<`7gWeasj&Ff|y_hxd1tK8eQ3g&@Ca@ ztkJT%^$yShHnZ0StN3hDucDjCS*leOw|t zzCoBK#u)GCBFV8mi}6@&Vk&X1dRr5sT)^G4%RqiZe*>g#9kaEW+G<lkdUj{6ld?Q+QAE4A;{2Fr(|PPpS^*TXg-)s^P>tlxH_Rz{wFH4-xI) zJTD2#>pdb-=j5U(^imMyQ|qcIAA6aR<$+k6D{8qgrR&5uHmng;SpR;l8G(TiA?J!5~HjzIss<;`Z$mh vAJDg9gVz%~1mq79WBfBZ_5vMvj literal 0 HcmV?d00001 diff --git a/QuantTrader/execution/__pycache__/paper_adapter.cpython-312.pyc b/QuantTrader/execution/__pycache__/paper_adapter.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d45595ef07e76a003894867c7d3d22045ede30ef GIT binary patch literal 7785 zcmcgxTWl0rdamlKYFFQVcN^Qdx!BNvZ3YHdz+mnJ3}d{&3`-B2R=cY(ZTiNkYQUZL z%slX5qn+5ED6+|FMWaN)2$Xf6;>k$ zM4Ucn#N~6*K1;YdQsb+kb!)gbQs=8<858pXBiSA@l3ljH;EXl->PI-QBiTO?h|9#I zl7JM74tWAnAg|ZRD};K zCB3S~h#WMwY~=a@vL(_LxdOlX*SbBoD6M<;YnOHCp!DHzeAhBUN2$gd`@Sh3NU_V zGI{HG^U9_>KB9|$e<&JK{r+S_xlnW$nrnc)@))u&n3Za#rDKf$kt^4>Ym8qOH|1J7 z7h8NUcV!OTSlZ>oCU}u37OFD#9n1Ff zxlJu&{Fkm(SR4~9N+o=t8x#tzz(YDAheM6R{rf}G`a{h(cOX5E{esDf_6gS` z*UWpG*!ra_SJO1ZKXE;FWtw(oT;7c6B|CvEn3q|oG7M@1y1Tyvj?;`<-1%@h6(wbu zg5-{xN?K~TsBiK_P{6*6TBg(rCuD;;@*JqffbaFG$b*AfiV|L;N)@%@C}_LkqK+%4 zNbx020W_7gTwRI2P)l(zcYfEPhu`fa1huFe;l5#p;l{#zrBdQR*9$N5Cq;K&LC2M} zG;nVzj81VdT6oD93_c)7=pUlJCniqlW+gEg38}gm2nJ=a`OaxGPF853Rs_^spC$@i!Grux#KXmz`n#XY&Yrs-o-$NsW! zOw2VjXB#@ShK{tn)UapF3c_OOj+utEby?h9{5Et+TAT$HviFsgO&9-G%e@5&#uC%BgfCJX465Rw9G!d25J_s%gJ~TlZvJ0|u z9q&{|qCtPrXGpfJh5edNJPE}P8R&ag-Na|(pJl|hw-*Hj#-i}NrWtTmP5sxNzs=fm zTLzgNlmlwAaShjEdml`opwB`XzKv+uy1{7=ZC~LXfOWl?-OV zYQM@-Pr>D-X461-o-nf(ykcqJr(WeUgc%is``KvarFK%v}@fgkM^3`iVBLhE2a zkqiDc`sKO>d}wfC@zsh~fyyGUEe^tG(!CZdB|CH$dX>*11F$q~n*Ma^)5V61=`+tR zKfRpYen{JXXkPed``7k`;L`SsU_a2g+@iU*7!-47|MAj4Tw2;>zfJ@*rDx?I7r#l-!v<~A#Y+XtMmlS7Xs8pD# z`(D8zOquiUdr<~A*motzk*&BBrXbraGs#9bSKf!QdS&N^y=Oou1aJcRJ5W>VH>3kS zLVT1CYadspgVstX1VRF?s|imCPcoKiySINLA4wK-pbYJ~cpHg}X*W7X7O7 znqW2b-f&Hl@V~vgcUXDFlUTQ#0=9Nk-qrB`2@BC?4AWGSsf|wwuw7AZ>A8pnbdbgk z(JdyywSvcK5#7;-KS2u~SUD|lXNK$7i522x!&`-bjJcnD1T@A=1uEdop@P@NyBJD< zP>XH}+=HkD4U0GvQ6dMzp`WKLbuMa7ii*$53^Rt zbS@r?Q|fOuOy^Pw2! zHT&k7#FNzH)azSMW%wTZwW-#Y&d18NTPb<$xpN?wo_vh>86JNJzO=rJp)*7!&a<3Y<%^vt^ z@AKXTZ|1r$b0@mo7@OpBJ^Lp4Y3G#F;JsMA=S^$-laC&MGz%8*y}7Ge%lGE@X)VVl z#awm$wB;+y%n!8cj=!?3oh^G^(ymLH#DnF=5ty^CHO+o?JXi0Y?w{(<)_2007$-J& z?N{|j-t0d(c?NsZ2cGpl?bYh{=IY!t2c8^%d|a#Z!c)-ZoUD0tsQjG%;=-G?r?#_e zjGu6gyJiI3^P<@Cwx@{k8|o>3W_XGf{eJ4%y`z^{u-0doA0A_vn=opWqhXE;zGFoT z%z-woplJ)12<#^z^~*N(D4@BwDjoHB2q z8H=Hk!q`$s>BkH{G9ed!!WTRS`1OPS6;{tOBg{7DJ$N|XhhJT8z_SZm9&k69`z+`i zoedDwQUuxP>>i!9g8D$UK$V6rKqxh$Q0+jd0-{g6K z0xCRJx8WT7kj88TR1MwpXLM8~KC;~$Qln>>wS42WuE>pHc%&h))m1s%twx?^K!a`xKE z;B$2nU{FR*DIdOjc z{EQ{*-mSTJFS)xiP2IDBIrHp;Ig)8OvTQ&49~|R6l64%`9EZQ7ug#zHou~G5Ki@B$ z>aFq$dA)w&#L!ks#2csA9aQdlt@N?43%A0tfNHq?Q8)=2UrN?o z#*jVz(Z`eW^v9wy*^Zr*q62C=r>F#i`DP-k2@)kSXafs_?*Rh73q#_Vp(H@-UaTX9 z(TX+@-jqm@bW8B#)u%Am9?zOzp?;Vr(D}hwES$GpWpm&{-sVSJ&1I`V!(%KOx#>La z1co7$jQ9s#Cc()Z7(9;#BC_8P4_kjECMCjHxBLBIF~dd=*?}W>W7dV)3Czx5_5o&S zg9tux2-+%w_#@{qgJ&IMV6qx(nBl+j0J4V+K&5Rg}{Z<056t+(AIYX{uMk7Q#)bQ6E z^>#QkX#9p^R!8Fa3k|AAy(<4h1dSaLG~g5f1|Cr5>COl|Gebee55I>D$%KY4Dd6Z2 zIP_T7otG~58aHvAq+kY&YbY34TH#oh{S9;NUzn|bU>v_?Y_FNN*Npo$)AX;*C%}T2DOx?DBhadV_W5@puHBL#Y literal 0 HcmV?d00001 diff --git a/QuantTrader/execution/adapter.py b/QuantTrader/execution/adapter.py new file mode 100644 index 0000000..fa1240b --- /dev/null +++ b/QuantTrader/execution/adapter.py @@ -0,0 +1,102 @@ +"""Execution adapter interfaces and data models for Phase 3.""" + +from __future__ import annotations + +import abc +from dataclasses import dataclass, field +from datetime import datetime +from typing import Dict, Optional + + +@dataclass +class OrderParams: + symbol: str + side: str # "buy" or "sell" + quantity: float + price: Optional[float] = None + tif: str = "GTC" + client_order_id: Optional[str] = None + metadata: Optional[Dict[str, str]] = None + + +@dataclass +class OrderAck: + order_id: str + status: str # accepted/rejected + timestamp: datetime + reason: Optional[str] = None + + +@dataclass +class CancelAck: + order_id: str + status: str + timestamp: datetime + reason: Optional[str] = None + + +@dataclass +class FillEvent: + order_id: str + fill_id: str + symbol: str + side: str + quantity: float + price: float + timestamp: datetime + + +@dataclass +class PositionState: + symbol: str + quantity: float + avg_price: float + unrealized_pnl: float = 0.0 + + +class ExecutionAdapter(abc.ABC): + """Abstract interface for broker/order routing adapters.""" + + @abc.abstractmethod + def submit(self, order: OrderParams) -> OrderAck: + """Submit a new order to the venue.""" + + @abc.abstractmethod + def cancel(self, order_id: str) -> CancelAck: + """Cancel an existing order.""" + + @abc.abstractmethod + def sync_positions(self) -> Dict[str, PositionState]: + """Return latest position snapshot.""" + + @abc.abstractmethod + def heartbeat(self) -> bool: + """Quick connectivity check.""" + + +class MockAdapter(ExecutionAdapter): + """MVP mock adapter used in simulation/testing pipelines.""" + + def __init__(self): + self._order_counter = 0 + self._orders: Dict[str, OrderParams] = {} + + def _next_id(self) -> str: + self._order_counter += 1 + return f"SIM-{self._order_counter}" + + def submit(self, order: OrderParams) -> OrderAck: + order_id = self._next_id() + self._orders[order_id] = order + return OrderAck(order_id=order_id, status="accepted", timestamp=datetime.utcnow()) + + def cancel(self, order_id: str) -> CancelAck: + status = "cancelled" if order_id in self._orders else "not_found" + self._orders.pop(order_id, None) + return CancelAck(order_id=order_id, status=status, timestamp=datetime.utcnow()) + + def sync_positions(self) -> Dict[str, PositionState]: + return {} + + def heartbeat(self) -> bool: + return True diff --git a/QuantTrader/execution/config_loader.py b/QuantTrader/execution/config_loader.py new file mode 100644 index 0000000..644f40f --- /dev/null +++ b/QuantTrader/execution/config_loader.py @@ -0,0 +1,24 @@ +"""Helpers to load execution adapter configuration.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Dict, Optional + +import yaml + + +def load_oanda_config(path: Optional[str] = None) -> Dict[str, Any]: + cfg_path = Path(path or "QuantTrader/config/execution_oanda.yaml") + if not cfg_path.exists(): + raise FileNotFoundError(f"OANDA config not found: {cfg_path}") + data = yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {} + return { + "account_id": data.get("account_id"), + "base_url": data.get("base_url", "https://api-fxpractice.oanda.com/v3"), + "timeout_ms": data.get("timeout_ms", 10000), + "retry_backoff": data.get("retry_backoff", 1.0), + "max_retries": data.get("max_retries", 3), + "metrics_path": data.get("metrics_path"), + "error_log": data.get("error_log", "results/execution/errors.log"), + } diff --git a/QuantTrader/execution/metrics_logger.py b/QuantTrader/execution/metrics_logger.py new file mode 100644 index 0000000..609251f --- /dev/null +++ b/QuantTrader/execution/metrics_logger.py @@ -0,0 +1,51 @@ +"""Append execution metrics to CSV for monitoring.""" + +from __future__ import annotations + +import csv +import os +from dataclasses import asdict, dataclass +from datetime import datetime +from pathlib import Path +from typing import Optional + + +@dataclass +class ExecutionMetric: + event: str + order_id: str + symbol: str + latency_ms: Optional[float] + status: str + timestamp: str + + +class MetricsLogger: + def __init__(self, path: Optional[str] = None): + metrics_path = path or os.environ.get("EXECUTION_METRICS_PATH", "metrics/execution.csv") + self.path = Path(metrics_path) + self.path.parent.mkdir(parents=True, exist_ok=True) + if not self.path.exists(): + with self.path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(ExecutionMetric.__annotations__.keys())) + writer.writeheader() + + def log(self, metric: ExecutionMetric) -> None: + with self.path.open("a", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=list(ExecutionMetric.__annotations__.keys())) + writer.writerow(asdict(metric)) + + +def log_event(event: str, order_id: str, symbol: str, status: str, start_ts: datetime, end_ts: datetime) -> None: + latency_ms = (end_ts - start_ts).total_seconds() * 1000.0 + logger = MetricsLogger() + logger.log( + ExecutionMetric( + event=event, + order_id=order_id, + symbol=symbol, + latency_ms=latency_ms, + status=status, + timestamp=end_ts.isoformat(), + ) + ) diff --git a/QuantTrader/execution/oanda_adapter.py b/QuantTrader/execution/oanda_adapter.py new file mode 100644 index 0000000..fdf9818 --- /dev/null +++ b/QuantTrader/execution/oanda_adapter.py @@ -0,0 +1,134 @@ +"""OANDA REST adapter (mock/stub for Phase 3).""" + +from __future__ import annotations + +import os +from dataclasses import asdict +from datetime import datetime +from pathlib import Path +from typing import Dict, Optional + +import requests + +from .adapter import CancelAck, ExecutionAdapter, OrderAck, OrderParams, PositionState +from .config_loader import load_oanda_config +from .metrics_logger import MetricsLogger, log_event +from .order_store import OrderStore + + +class OandaAdapter(ExecutionAdapter): + BASE_URL = "https://api-fxpractice.oanda.com/v3" + + def __init__( + self, + account_id: Optional[str] = None, + token: Optional[str] = None, + order_store: Optional[OrderStore] = None, + base_url: Optional[str] = None, + timeout_ms: int = 10000, + retry_backoff: float = 1.0, + max_retries: int = 3, + metrics_path: Optional[str] = None, + error_log: str = "results/execution/errors.log", + ) -> None: + self.account_id = account_id or os.environ.get("OANDA_ACCOUNT_ID", "") + self.token = token or os.environ.get("OANDA_TOKEN", "") + self.base_url = base_url or self.BASE_URL + self.timeout_ms = timeout_ms + self.retry_backoff = retry_backoff + self.max_retries = max_retries + self.metrics_logger = MetricsLogger(metrics_path) + self.error_log = Path(error_log) + self.session = requests.Session() + self.session.headers.update({"Authorization": f"Bearer {self.token}", "Content-Type": "application/json"}) + self.order_store = order_store or OrderStore() + + def _endpoint(self, path: str) -> str: + return f"{self.base_url}{path}" + + @classmethod + def from_config(cls, path: Optional[str] = None, order_store: Optional[OrderStore] = None) -> "OandaAdapter": + cfg = load_oanda_config(path) + return cls(order_store=order_store, **cfg) + + def _log_error(self, context: str, message: str) -> None: + self.error_log.parent.mkdir(parents=True, exist_ok=True) + with self.error_log.open("a", encoding="utf-8") as fh: + fh.write(f"[{datetime.utcnow().isoformat()}] {context}: {message}\n") + + def _request(self, method: str, url: str, **kwargs): + backoff = self.retry_backoff + for attempt in range(self.max_retries): + try: + resp = self.session.request(method, url, timeout=self.timeout_ms / 1000.0, **kwargs) + except requests.RequestException as exc: + self._log_error("network", str(exc)) + time.sleep(backoff) + backoff *= 2 + continue + if resp.status_code >= 500: + self._log_error("server_error", resp.text) + time.sleep(backoff) + backoff *= 2 + continue + if resp.status_code >= 400: + self._log_error("client_error", resp.text) + resp.raise_for_status() + resp.raise_for_status() + return resp.json() + raise RuntimeError(f"Failed {method} {url} after {self.max_retries} retries") + + def submit(self, order: OrderParams) -> OrderAck: + start = datetime.utcnow() + payload = { + "order": { + "instrument": order.symbol, + "units": int(order.quantity if order.side.lower() == "buy" else -order.quantity), + "type": "MARKET" if order.price is None else "LIMIT", + "timeInForce": order.tif.upper(), + } + } + if order.price is not None: + payload["order"]["price"] = f"{order.price:.5f}" + if order.client_order_id: + payload["order"]["clientExtensions"] = {"clientOrderID": order.client_order_id} + url = self._endpoint(f"/accounts/{self.account_id}/orders") + data = self._request("POST", url, json=payload) + order_id = data.get("orderCreateTransaction", {}).get("id", "") + if order_id: + self.order_store.append(order_id, order) + end = datetime.utcnow() + log_event("submit", order_id or "", order.symbol, "accepted", start, end) + ts = data.get("time") + timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else datetime.utcnow() + return OrderAck(order_id=order_id, status="accepted", timestamp=timestamp) + + def cancel(self, order_id: str) -> CancelAck: + start = datetime.utcnow() + url = self._endpoint(f"/accounts/{self.account_id}/orders/{order_id}/cancel") + data = self._request("PUT", url) + end = datetime.utcnow() + log_event("cancel", order_id, "", "cancelled", start, end) + ts = data.get("time") + timestamp = datetime.fromisoformat(ts.replace("Z", "+00:00")) if ts else datetime.utcnow() + return CancelAck(order_id=order_id, status="cancelled", timestamp=timestamp) + + def sync_positions(self) -> Dict[str, PositionState]: + url = self._endpoint(f"/accounts/{self.account_id}/positions") + data = self._request("GET", url) + positions = {} + for pos in data.get("positions", []): + symbol = pos["instrument"] + net = float(pos["netUnrealizedPL"]) + qty = float(pos.get("long", {}).get("units", 0)) + float(pos.get("short", {}).get("units", 0)) + avg_price = float(pos.get("avgPrice", 0)) + positions[symbol] = PositionState(symbol=symbol, quantity=qty, avg_price=avg_price, unrealized_pnl=net) + return positions + + def heartbeat(self) -> bool: + url = self._endpoint("/accounts") + try: + self._request("GET", url) + return True + except Exception: + return False diff --git a/QuantTrader/execution/order_store.py b/QuantTrader/execution/order_store.py new file mode 100644 index 0000000..ac535bb --- /dev/null +++ b/QuantTrader/execution/order_store.py @@ -0,0 +1,28 @@ +"""Simple JSONL order store for audit/replay.""" + +from __future__ import annotations + +import json +from dataclasses import asdict +from pathlib import Path +from typing import Iterable + +from QuantTrader.execution.adapter import OrderParams + + +class OrderStore: + def __init__(self, path: str = "results/execution/orders.log"): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + + def append(self, order_id: str, params: OrderParams) -> None: + record = {"order_id": order_id, **asdict(params)} + with self.path.open("a", encoding="utf-8") as fh: + fh.write(json.dumps(record) + "\n") + + def load(self) -> Iterable[dict]: + if not self.path.exists(): + return [] + with self.path.open("r", encoding="utf-8") as fh: + for line in fh: + yield json.loads(line) diff --git a/QuantTrader/execution/paper_adapter.py b/QuantTrader/execution/paper_adapter.py new file mode 100644 index 0000000..2d57310 --- /dev/null +++ b/QuantTrader/execution/paper_adapter.py @@ -0,0 +1,111 @@ +"""Paper trading adapter that simulates fills with configurable latency/slippage.""" + +from __future__ import annotations + +import random +import time +from datetime import datetime +from pathlib import Path +from typing import Dict + +from .adapter import CancelAck, ExecutionAdapter, OrderAck, OrderParams, PositionState +from .metrics_logger import log_event +from .order_store import OrderStore + + +class PaperAdapter(ExecutionAdapter): + def __init__( + self, + latency_ms: float = 50.0, + slippage_pips: float = 0.1, + order_store: OrderStore | None = None, + starting_equity: float = 100000.0, + equity_log_path: str = "results/execution/paper_equity.csv", + ): + self.latency_ms = latency_ms + self.slippage_pips = slippage_pips + self.order_store = order_store or OrderStore("results/execution/paper_orders.log") + self.positions: Dict[str, PositionState] = {} + self.last_price: Dict[str, float] = {} + self.cash = starting_equity + self.equity_path = Path(equity_log_path) + self.equity_path.parent.mkdir(parents=True, exist_ok=True) + if not self.equity_path.exists(): + self.equity_path.write_text("ts,equity\n", encoding="utf-8") + self._order_seq = 0 + + def _next_id(self) -> str: + self._order_seq += 1 + return f"PAPER-{self._order_seq}" + + def submit(self, order: OrderParams) -> OrderAck: + start = datetime.utcnow() + time.sleep(self.latency_ms / 1000.0) + order_id = self._next_id() + fill_price = self._fill_price(order) + self._apply_fill(order, fill_price) + self.order_store.append(order_id, order) + end = datetime.utcnow() + log_event("submit", order_id, order.symbol, "accepted", start, end) + self._record_equity(end) + return OrderAck(order_id=order_id, status="accepted", timestamp=end) + + def cancel(self, order_id: str) -> CancelAck: + start = datetime.utcnow() + time.sleep(self.latency_ms / 2000.0) + end = datetime.utcnow() + log_event("cancel", order_id, "", "cancelled", start, end) + return CancelAck(order_id=order_id, status="cancelled", timestamp=end) + + def sync_positions(self) -> dict[str, PositionState]: + return self.positions + + def heartbeat(self) -> bool: + return True + + def _pip_value(self, symbol: str) -> float: + return 0.01 if symbol.endswith("JPY") else 0.0001 + + def _fill_price(self, order: OrderParams) -> float: + base_price = order.price or self.last_price.get(order.symbol, 1.0) + slip = self.slippage_pips * self._pip_value(order.symbol) + direction = 1 if order.side.lower() == "buy" else -1 + return base_price + direction * slip * random.choice([1, -1]) + + def _apply_fill(self, order: OrderParams, price: float) -> None: + qty = order.quantity if order.side.lower() == "buy" else -order.quantity + pos = self.positions.get(order.symbol) + if pos is None: + pos = PositionState(symbol=order.symbol, quantity=0.0, avg_price=price, unrealized_pnl=0.0) + total_qty = pos.quantity + qty + if total_qty == 0: + realized = (price - pos.avg_price) * (-qty) # closing position + self.cash += realized + self.positions.pop(order.symbol, None) + else: + if pos.quantity == 0 or (pos.quantity > 0 and qty > 0) or (pos.quantity < 0 and qty < 0): + avg = ((pos.quantity * pos.avg_price) + (qty * price)) / total_qty + pos = PositionState(symbol=order.symbol, quantity=total_qty, avg_price=avg, unrealized_pnl=0.0) + self.positions[order.symbol] = pos + else: + realized = (pos.avg_price - price) * qty * -1 + self.cash += realized + pos = PositionState(symbol=order.symbol, quantity=total_qty, avg_price=pos.avg_price, unrealized_pnl=0.0) + if total_qty == 0: + self.positions.pop(order.symbol, None) + else: + self.positions[order.symbol] = pos + notional = price * order.quantity + if qty > 0: + self.cash -= notional + else: + self.cash += notional + self.last_price[order.symbol] = price + + def _record_equity(self, timestamp: datetime) -> None: + equity = self.cash + for symbol, pos in self.positions.items(): + mark = self.last_price.get(symbol, pos.avg_price) + equity += pos.quantity * mark + with self.equity_path.open("a", encoding="utf-8") as fh: + fh.write(f"{timestamp.isoformat()},{equity}\n") diff --git a/QuantTrader/logs/oanda_trading.log b/QuantTrader/logs/oanda_trading.log new file mode 100644 index 0000000..6d26d3b --- /dev/null +++ b/QuantTrader/logs/oanda_trading.log @@ -0,0 +1,14 @@ +2025-11-04 16:30:59.195 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:34:28.561 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:35:55.938 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:35:56.617 | INFO | __main__::23 - 💰 Balance: 965985.9487 GBP +2025-11-04 16:47:20.949 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:47:27.446 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:47:34.147 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:47:41.773 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:49:19.573 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:51:07.455 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:51:12.478 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:53:59.135 | INFO | __main__::18 - ✅ Connected to OANDA +2025-11-04 16:53:59.837 | INFO | __main__::23 - 💰 Balance: 965985.9487 GBP +2025-11-04 17:14:25.105 | INFO | __main__::21 - 📈 策略累计收益: 0.0123 diff --git a/QuantTrader/requirements.txt b/QuantTrader/requirements.txt new file mode 100644 index 0000000..7d627e8 --- /dev/null +++ b/QuantTrader/requirements.txt @@ -0,0 +1,10 @@ +# 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 diff --git a/QuantTrader/results/execution/live/fills.csv b/QuantTrader/results/execution/live/fills.csv new file mode 100644 index 0000000..35c9fa1 --- /dev/null +++ b/QuantTrader/results/execution/live/fills.csv @@ -0,0 +1,3 @@ +order_id,ts,symbol,pnl,adapter_latency_ms,direction,price,quantity +LIVE-1,2025-11-12T09:00:00Z,EURUSD,42.5,30,BUY,1.1593,10000 +LIVE-2,2025-11-12T10:00:00Z,EURUSD,-15.2,28,SELL,1.1591,8000 diff --git a/QuantTrader/results/execution/paper/fills.csv b/QuantTrader/results/execution/paper/fills.csv new file mode 100644 index 0000000..00992a8 --- /dev/null +++ b/QuantTrader/results/execution/paper/fills.csv @@ -0,0 +1,3 @@ +order_id,ts,symbol,pnl,adapter_latency_ms,direction,price,quantity +PAPER-1,2025-11-12T09:00:00Z,EURUSD,40.0,20,BUY,1.1593,10000 +PAPER-2,2025-11-12T10:00:00Z,EURUSD,-10.0,18,SELL,1.1591,8000 diff --git a/QuantTrader/results/execution/tca_summary.json b/QuantTrader/results/execution/tca_summary.json new file mode 100644 index 0000000..3a2ff25 --- /dev/null +++ b/QuantTrader/results/execution/tca_summary.json @@ -0,0 +1,14 @@ +{ + "paper_path": "/Users/chuan/Documents/Projects/FX_Backtest/QuantTrader/results/execution/paper/fills.csv", + "live_path": "/Users/chuan/Documents/Projects/FX_Backtest/QuantTrader/results/execution/live/fills.csv", + "paper_trade_count": 2, + "paper_total_pnl": 30.0, + "paper_avg_pnl": 15.0, + "paper_avg_latency_ms": 19.0, + "live_trade_count": 2, + "live_total_pnl": 27.3, + "live_avg_pnl": 13.65, + "live_avg_latency_ms": 29.0, + "pnl_diff_mean": -1.3499999999999996, + "pnl_diff_std": 3.8499999999999996 +} \ No newline at end of file diff --git a/QuantTrader/scripts/live_trade.py b/QuantTrader/scripts/live_trade.py new file mode 100644 index 0000000..fd7a967 --- /dev/null +++ b/QuantTrader/scripts/live_trade.py @@ -0,0 +1,58 @@ +""" +简易实盘脚本示例(使用 OANDA) +将实时数据传入策略,策略产生信号 -> 风险检查 -> 下单执行 + +注意:在真实交易前请先在沙盒/模拟账户充分测试。 +""" +import asyncio +import os +from dotenv import load_dotenv +from loguru import logger + +from core.data.oanda import OANDADataFeed +from core.execution.oanda_handler import OANDAExecutionHandler +from core.strategy.rsi_mean_reversion import RSIMeanReversionStrategy +from core.risk.base import SimpleRiskManager +from core.data.base import MarketDataEvent + +load_dotenv() + +async def main(): + account_id = os.getenv('OANDA_ACCOUNT_ID') + token = os.getenv('OANDA_TOKEN') + env = os.getenv('OANDA_ENVIRONMENT', 'practice') + instrument = 'EUR_USD' + + data_feed = OANDADataFeed( + instrument=instrument, + timeframe='H1', + account_id=account_id, + access_token=token, + environment=env + ) + + strategy = RSIMeanReversionStrategy(instrument=instrument) + risk_manager = SimpleRiskManager(max_position_size=1.0, max_portfolio_risk=0.02, max_drawdown=0.1) + execution = OANDAExecutionHandler(account_id=account_id, access_token=token, environment=env) + + async def on_market(event: MarketDataEvent): + signal = await strategy.on_data(event) + if not signal: + return + if await risk_manager.check_signal(signal): + await execution.process_signal(signal) + + # 订阅实时数据并运行 + await data_feed.subscribe(on_market) + logger.info('已订阅实时数据,开始监听(按 Ctrl+C 退出)') + + try: + while True: + await asyncio.sleep(1) + except asyncio.CancelledError: + pass + finally: + await data_feed.unsubscribe() + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/QuantTrader/scripts/paper_trade.py b/QuantTrader/scripts/paper_trade.py new file mode 100644 index 0000000..06d1b4c --- /dev/null +++ b/QuantTrader/scripts/paper_trade.py @@ -0,0 +1,296 @@ +""" +Paper trading driver that wires live OANDA pricing into the strategy engine. +""" + +from __future__ import annotations + +import argparse +import signal +import time +from queue import Empty, Queue + +import pandas as pd +from loguru import logger +import yaml + +import os +import sys + +TRADER_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +REPO_ROOT = os.path.dirname(TRADER_ROOT) +RESEARCH_ROOT = os.path.join(REPO_ROOT, "QuantResearch") +sys.path.extend([TRADER_ROOT, REPO_ROOT, RESEARCH_ROOT]) + +from data.oanda_stream import OandaPricingStream +from core.oanda_execution import OandaExecution +from core.events import TickEvent, OrderEvent +from QuantResearch.core.backtest.strategy_engine import ( + StrategyEngine, + StrategySpec, + parse_strategy_specs, + _coerce_fx_rates, + _merge_fx_rates, +) +from shared.utils.config import OANDA_ACCOUNT_ID, OANDA_TOKEN + + +class BarAggregator: + """ + Aggregate tick data into fixed timeframe OHLC bars. + """ + + def __init__(self, symbol: str, timeframe: str): + self.symbol = symbol.replace("_", "").upper() + tf = timeframe.lower() if isinstance(timeframe, str) else timeframe + self.timeframe = pd.to_timedelta(tf) + if self.timeframe <= pd.Timedelta(0): + raise ValueError(f"Invalid timeframe: {timeframe}") + self.current_bucket: pd.Timestamp | None = None + self.open = self.high = self.low = self.close = None + self.last_ts: pd.Timestamp | None = None + + def update(self, tick: TickEvent) -> dict | None: + ts = pd.Timestamp(tick.ts) + bucket = ts.floor(self.timeframe) + mid = (tick.bid + tick.ask) / 2.0 + if self.current_bucket is None: + self._start_bar(bucket, mid, ts) + return None + if bucket != self.current_bucket: + finished = self._build_bar() + self._start_bar(bucket, mid, ts) + return finished + self._update_bar(mid, ts) + return None + + def flush(self) -> dict | None: + if self.current_bucket is None: + return None + return self._build_bar() + + def _start_bar(self, bucket: pd.Timestamp, price: float, ts: pd.Timestamp) -> None: + self.current_bucket = bucket + self.open = self.high = self.low = self.close = price + self.last_ts = ts + + def _update_bar(self, price: float, ts: pd.Timestamp) -> None: + self.high = max(self.high, price) + self.low = min(self.low, price) + self.close = price + self.last_ts = ts + + def _build_bar(self) -> dict: + bar = { + "symbol": self.symbol, + "ts": self.last_ts, + "open": float(self.open), + "high": float(self.high), + "low": float(self.low), + "close": float(self.close), + "volume": 0, + } + return bar + + +def load_config(path: str) -> dict: + with open(path, "r", encoding="utf-8") as f: + return yaml.safe_load(f) or {} + + +def build_engine(cfg: dict, args, execution_handler): + symbol = args.symbol or cfg.get("symbol", "EURUSD") + account_ccy = cfg.get("account_ccy", "USD") + fast_win = int(cfg.get("fast", 50)) + slow_win = int(cfg.get("slow", 200)) + spread = float(cfg.get("spread", 1.0)) + slip = float(cfg.get("slip", 0.2)) + comm = float(cfg.get("comm", 2.0)) + qty = float(cfg.get("qty", 10_000)) + initial_cash = float(cfg.get("cash", 100_000)) + stop_loss_pips = cfg.get("sl", 50) + take_profit_pips = cfg.get("tp") + atr_sl = cfg.get("atr_sl") + atr_tp = cfg.get("atr_tp") + atr_window = int(cfg.get("atr_window", 14)) + regime_ema_window = int(cfg.get("regime_ema_window", 200)) + regime_slope_min = cfg.get("regime_slope_min") + if regime_slope_min is not None: + regime_slope_min = float(regime_slope_min) + regime_atr_min = cfg.get("regime_atr_min") + if regime_atr_min is not None: + regime_atr_min = float(regime_atr_min) + rsi_period = int(cfg.get("rsi_period", 14)) + rsi_long_thresh = cfg.get("rsi_long_thresh") + if rsi_long_thresh is not None: + rsi_long_thresh = float(rsi_long_thresh) + rsi_short_thresh = cfg.get("rsi_short_thresh") + if rsi_short_thresh is not None: + rsi_short_thresh = float(rsi_short_thresh) + enable_trailing = bool(cfg.get("enable_trailing", False)) + trailing_enable_atr_mult = float(cfg.get("trailing_enable_atr_mult", 1.0)) + trailing_atr_mult = float(cfg.get("trailing_atr_mult", 0.5)) + long_only_above_slow = bool(cfg.get("long_only_above_slow", False)) + slope_lookback = int(cfg.get("slope_lookback", 0)) + cooldown = int(cfg.get("cooldown", 0)) + allow_short = bool(cfg.get("allow_short", True)) + short_only_below_slow = bool(cfg.get("short_only_below_slow", False)) + risk_per_trade_pct = cfg.get("risk_per_trade_pct") + max_drawdown_pct = cfg.get("max_drawdown_pct") + max_position_units = cfg.get("max_position_units") + htf_factor = int(cfg.get("htf_factor", 4)) + htf_ema_window = cfg.get("htf_ema_window") + if htf_ema_window is not None: + htf_ema_window = int(htf_ema_window) + htf_rsi_period = cfg.get("htf_rsi_period") + if htf_rsi_period is not None: + htf_rsi_period = int(htf_rsi_period) + + cfg_fx_rates = _coerce_fx_rates(cfg.get("fx_rates")) + cli_fx_rates = _coerce_fx_rates(args.fx_rate) + fx_rates = _merge_fx_rates(cfg_fx_rates, cli_fx_rates) + + strategy_specs = parse_strategy_specs(cfg.get("strategies")) + + engine = StrategyEngine( + symbol=symbol, + fast_win=fast_win, + slow_win=slow_win, + 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, + regime_ema_window=regime_ema_window, + regime_slope_min=regime_slope_min, + regime_atr_min=regime_atr_min, + 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, + 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, + htf_factor=htf_factor, + htf_ema_window=htf_ema_window, + htf_rsi_period=htf_rsi_period, + execution_handler=execution_handler, + ) + engine.set_initial_cash(initial_cash) + return engine, symbol, initial_cash + + +def main(): + parser = argparse.ArgumentParser(description="OANDA paper trading driver") + parser.add_argument("--config", required=True, help="策略配置 YAML") + parser.add_argument("--symbol", default=None, help="覆盖配置中的交易品种") + parser.add_argument("--timeframe", default="60s", help="K线时间粒度,默认 60s") + parser.add_argument("--environment", default="practice", choices=["practice", "live"], help="OANDA 环境") + parser.add_argument("--fx-rate", action="append", default=None, help="额外汇率,示例 GBPUSD=1.27") + parser.add_argument("--max-bars", type=int, default=None, help="最多生成多少根 bar 后自动停止") + parser.add_argument("--log-heartbeat", action="store_true", help="打印 OANDA 心跳信息") + args = parser.parse_args() + + cfg = load_config(args.config) + token = OANDA_TOKEN + account_id = OANDA_ACCOUNT_ID + if not token or not account_id: + raise RuntimeError("OANDA_TOKEN 或 OANDA_ACCOUNT_ID 未在环境变量中设置") + + order_queue: Queue = Queue() + execution = OandaExecution(order_queue, account_id=account_id, access_token=token, environment=args.environment) + engine, symbol, initial_cash = build_engine(cfg, args, execution.on_event) + aggregator = BarAggregator(symbol, args.timeframe) + + tick_queue: Queue = Queue() + stream = OandaPricingStream( + tick_queue, + account_id=account_id, + instruments=[symbol], + access_token=token, + environment=args.environment, + log_heartbeat=args.log_heartbeat, + ) + + stop_flag = False + + def handle_sigterm(signum, frame): + nonlocal stop_flag + stop_flag = True + + signal.signal(signal.SIGINT, handle_sigterm) + signal.signal(signal.SIGTERM, handle_sigterm) + + logger.info(f"[Paper] Starting pricing stream for {symbol} ({args.timeframe})") + stream.start() + + bars_processed = 0 + try: + while not stop_flag: + try: + tick = tick_queue.get(timeout=1.0) + except Empty: + continue + if not isinstance(tick, TickEvent): + continue + bar = aggregator.update(tick) + if bar: + engine.handle_bar(bar) + bars_processed += 1 + if args.max_bars and bars_processed >= args.max_bars: + logger.info("[Paper] Reached max bar limit, stopping.") + break + finally: + stream.stop() + + # flush last partially built bar + final_bar = aggregator.flush() + if final_bar: + engine.handle_bar(final_bar) + + engine.finalize() + suffix = engine.compute_suffix() + engine.export_outputs( + fast_win=int(cfg.get("fast", 50)), + slow_win=int(cfg.get("slow", 200)), + suffix=suffix, + ) + result = engine.summary( + fast_win=int(cfg.get("fast", 50)), + slow_win=int(cfg.get("slow", 200)), + suffix=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"[Paper] Bars processed: {engine.bar_count}, Trades executed: {engine.trade_count}") + logger.info(f"[Paper] Final equity: {final_equity:.2f} ({ret_pct:.2f}%)") + + # Drain any fills left in queue + fills = [] + while True: + try: + fill = order_queue.get_nowait() + except Empty: + break + else: + fills.append(fill) + if fills: + for fill in fills: + logger.info(f"[Paper] Fill received: {fill}") + + +if __name__ == "__main__": + main() diff --git a/QuantTrader/tests/__init__.py b/QuantTrader/tests/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/QuantTrader/tests/__init__.py @@ -0,0 +1 @@ + diff --git a/QuantTrader/tests/__pycache__/__init__.cpython-312.pyc b/QuantTrader/tests/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a640ccfa0beea20d5265e0c250b9f7acda633c5c GIT binary patch literal 170 zcmX@j%ge<81Wy1w znwY2WlAm0fo0?ZrtRGO6pOu;nWVl7dJ0&J(m!uY#=m!GTl!O!|rlc0>g9M88>i_@% literal 0 HcmV?d00001 diff --git a/QuantTrader/tests/__pycache__/test_execution_adapters.cpython-312.pyc b/QuantTrader/tests/__pycache__/test_execution_adapters.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8a031b8498ae52d5550bbeca7af8f9536a53098d GIT binary patch literal 4087 zcmbW4U2GHC6@bU{V^79E31kz-aYAVUZ6@2;NeC=|yImSWSwTX8L_%I^M-$Iouo?gC zoiU3eN1_r{gR~Or6J)y;^r@_phxDzLDs>-M>Px%{vNao?s!F9#Ea9n7J!i&aC$3Yq zy>ian-*fLh_nhzE{3RTg7)b98>`T8E8RoB8@RCr^Z2t)|j~JcNeFl?szrLi;DYHoy zzO2Dz`6TbdHqH>T{-oc@^F|;mCPgPN7#&$DDLHw+5zK~?AtxU&!r4eN;$v9mXN)f1 zXY>xjW)6CbXnt2XH=pbx`*i8{Aj8o8ECYRyH2ZkDm3qd_t?bE$IX(q{N_HGH)azPin^ zBhu&WL+OFE-u=$E;*YmM6SK>i@OT|(x(k^{3>-LiX-S!Sr0uNs9LvX84HwCvYqq3J zM@t11b%+DcT_7SEg`K9jQ$?Wr;A00;2GMfG0_}kk?ZqmtkT1{!kaJIkMzIo(UZdyp zpXC+~wzr|z{uT~A3gRA96GIP!4}w)ORuN;H;=qE z7#xn3?txG2X{APhLGOC{HSF~_?4r-|*a>XZxn=;CeO_qjywwQ7b|It-xaC#xj@`<7)D%+rYkxvOOh28pNg*}8Apl9o#4i#bb8>$YI!Z|UqS1SOh9AhvU+mO?<=-00Y`h1l50*!b|sDEwwe zMov4w_=uB|05KNk;;nSnxhW{IiZrL*(TpOo1Cw*t)Vb^DZEn6;dii&7FNL%HW+^+L zHvm9s9TxN{;v{XAlujowDEcwQAftGy&G4WYg0`qq@@WyML(xY-S8+a@w(Ov00>G?E zP-fVoyJ1+B4JYJn1^;CC7YSAR7y2J7dAM6#r|E&gGvH5!t5a4qEli+f&Di2oV z!Sw^mv)Fpi@g-?%|H0aU{@VWNmi(iqr#Iwyt!HQ}8hg^W+_!$@Y$f{Mo1nj2S_-`C zV!}sjy|J2n{Ck0WJMxMXqS8|MO^E3~x;DHS`(P=sDau=2v8Sijj(!PAs6~1n z<{so8&uv5wFL6Maswh`P`EmD?{^kCs#Z7S-snhwO^Gp75azh@h%881c*pNq`i(~%| z8$4PEHvjlPVY3Is+vAA^Jh40Q!~uvJ$Y=&YxYMG@!2gT@0;CjaI(QWrDDW zmec~12Ckaj048l-1(^o6nmzRf;F@57S=%A3txj*$qJIyp`P*VGv1W7Viqi7M;z(6e{edrvF{ z9U#fIzJUgi`rm%jAs~YQHv9UYo?JWn^^tEQ*#y!@8?sWBM=J8jhCKFMJmvst zS_!~)qT<_FRc%RCvw6K}U_PX(c#*g*v=25;e}LkB6qiAK#W)(AW1;w;f+H|kOnfTb zXI`_sAiWN;!n?1-$TqEKi)UT4zfV7c24sMVonLW2pYJ=S<2$AkelHmL1#|ds z%<&h@*%!=7kZ=5P-}vK;)&Aj1|L_|I3a{pU3?Eur_?C;-_-{&MQ)Q@^VJPPNmVtJ8>4)Ycjh| zVgwSEXbXp`SdYXcUVF`QCkCCg<C@Gr~P({R4ZjrutSb;YIjEmjAc zt_ZuM5E?l9dC>oCS`H4 z;rf&?xHb};(Kngrs!P4Zb+XvrRcvi9wzTaYQk!b$ zwcRi}*14=bQ8$kc%`0Drc0d>He{a2TV@qfnjSH2kF}`=lJYgeNIG!79gtj8Rg-Gx6duxgH!Hvj;1$ADU zA1LbKZyFXF9!i^ftKdTmp)I|=ptnELm%G=Z>jO{q_D%h4G1{_penrsg&~{P;vX+xvSYyUNToOiC@sUYF?E}!Cj6=y2wZj?3|Olm zZGyZZi0B|yh>QAYs`fke=6;x~)@l~+L>GMpb*BoEQyY=^>+#mJ*1ewa@pf?!Z~cX6 z|3>umYmxT$GySxWw2_#`bwV5?LkZ!vgk-X2&K7)_5b^2)32PF6-zA8s#4E@*_MaRR zP0=TUaRq$m&4QQWe z5k4vP3BNJ;IbUwphRBjvd7_%GQb3nl;n literal 0 HcmV?d00001 diff --git a/QuantTrader/tests/test_execution_adapters.py b/QuantTrader/tests/test_execution_adapters.py new file mode 100644 index 0000000..544c75e --- /dev/null +++ b/QuantTrader/tests/test_execution_adapters.py @@ -0,0 +1,63 @@ +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import MagicMock + +from QuantTrader.execution.adapter import OrderParams +from QuantTrader.execution.oanda_adapter import OandaAdapter +from QuantTrader.execution.order_store import OrderStore +from QuantTrader.execution.paper_adapter import PaperAdapter + + +class ExecutionAdaptersTest(unittest.TestCase): + def setUp(self): + self.tmpdir = tempfile.TemporaryDirectory() + os.environ["EXECUTION_METRICS_PATH"] = str( + (tempfile.NamedTemporaryFile(delete=False, dir=self.tmpdir.name).name) + ) + + def tearDown(self): + self.tmpdir.cleanup() + os.environ.pop("EXECUTION_METRICS_PATH", None) + + def test_oanda_adapter_submit_uses_order_store(self): + store_path = f"{self.tmpdir.name}/orders.log" + adapter = OandaAdapter( + account_id="ACC", + token="TOKEN", + order_store=OrderStore(store_path), + base_url="https://example.com", + metrics_path=os.environ["EXECUTION_METRICS_PATH"], + ) + stub_response = { + "orderCreateTransaction": { + "id": "123", + "time": "2024-01-01T00:00:00.000000Z", + } + } + adapter._request = MagicMock(return_value=stub_response) # type: ignore + order = OrderParams(symbol="EUR_USD", side="buy", quantity=1000) + ack = adapter.submit(order) + self.assertEqual(ack.order_id, "123") + self.assertTrue(Path(store_path).exists()) + + def test_paper_adapter_generates_ids_and_logs_equity(self): + store_path = f"{self.tmpdir.name}/paper.log" + equity_path = f"{self.tmpdir.name}/equity.csv" + adapter = PaperAdapter( + latency_ms=1, + slippage_pips=0.0, + order_store=OrderStore(store_path), + equity_log_path=equity_path, + ) + order = OrderParams(symbol="EURUSD", side="buy", quantity=1000, price=1.1) + ack = adapter.submit(order) + self.assertTrue(ack.order_id.startswith("PAPER-")) + cancel_ack = adapter.cancel(ack.order_id) + self.assertEqual(cancel_ack.status, "cancelled") + self.assertTrue(Path(equity_path).exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/QuantTrader/tests/test_risk_engine.py b/QuantTrader/tests/test_risk_engine.py new file mode 100644 index 0000000..7b30eb4 --- /dev/null +++ b/QuantTrader/tests/test_risk_engine.py @@ -0,0 +1,34 @@ +import unittest + +from QuantTrader.core.risk.risk_engine import RiskEngine, RiskLimits + + +class RiskEngineTest(unittest.TestCase): + def setUp(self): + limits = RiskLimits( + max_position_notional=100000, + max_gross_leverage=2.0, + max_daily_loss=5000, + max_drawdown=0.1, + ) + self.engine = RiskEngine(limits=limits, starting_equity=50000) + + def test_exposure_limit(self): + ok, _ = self.engine.evaluate_order("EURUSD", "buy", 90000) + self.assertTrue(ok) + self.engine.record_fill("EURUSD", "buy", 90000, pnl=0) + ok, reason = self.engine.evaluate_order("EURUSD", "buy", 20000) + self.assertFalse(ok) + self.assertEqual(reason, "symbol_exposure_limit:EURUSD") + + def test_daily_loss_limit(self): + ok, _ = self.engine.check_loss_limits() + self.assertTrue(ok) + self.engine.record_fill("USDJPY", "sell", 50000, pnl=-6000) + ok, reason = self.engine.check_loss_limits() + self.assertFalse(ok) + self.assertEqual(reason, "daily_loss_limit") + + +if __name__ == "__main__": + unittest.main() diff --git a/README.md b/README.md new file mode 100644 index 0000000..9cb7c9b --- /dev/null +++ b/README.md @@ -0,0 +1,168 @@ +# FX Backtest & Execution Stack + +End-to-end foreign-exchange research and trading toolkit that links feature engineering, backtesting, ML-driven signal generation, and OANDA execution into one repo. + +## Highlights + +- Unified `StrategyEngine` (see `QuantResearch/core/backtest/strategy_engine.py`) powers historical backtests, walk-forward studies, paper trading, and the live runner so signals behave identically across environments. +- Strategy registry ships with SMA/ATR trend, Bollinger & band mean-revert, breakout momentum, and an XGBoost probability model (`QuantResearch/strategies/*`), allowing multi-strategy voting through YAML configs such as `QuantTrader/config/usdjpy_multi_strategy.yaml`. +- Research workflows enforce data-manifest validation, risk sims, KPI summaries (`results//summary.json`), and promotion of vetted artifacts into `QuantTrader/artifacts/` before they are allowed to reach trading. +- Runtime layer contains async OANDA data/execution handlers, event-driven risk checks, and pluggable multi-strategy allocation for both paper (`scripts/paper_trade.py`) and live trading (`scripts/live_trade.py`). +- Monitoring stack (Pushgateway + Prometheus + Grafana) ships ready-to-import risk dashboards (`monitoring/grafana/*.json`), custom drilldown plugins (logs/traces/profiles/metrics), and Slack/pushgateway hooks for diagnostics automation. + +## Repository Layout + +- `QuantResearch/` – Research code, datasets, strategy implementations, notebooks/scripts, docs, artifacts, and test suites. +- `QuantTrader/` – Trading runtime with execution/risk/data engines, configs, logging, and artifact promotion targets. +- `monitoring/` – Dockerized observability stack plus Grafana dashboards & plugins for metrics/logs/traces/profiles. +- `shared/` – Cross-cutting helpers (`shared/utils/config.py` loads OANDA/Slack/Pushgateway secrets from `.env`). +- `results/` – Canonical run outputs uploaded with PRs (e.g., walk-forward summaries) for auditing. +- `metrics/` – Lightweight operational CSVs (e.g., execution latencies) that can be pushed to Prometheus. + +## Quick Start + +1. **Clone & create a virtual environment** + + ```bash + git clone + cd FX_Backtest + python -m venv .venv + source .venv/bin/activate + pip install --upgrade pip + pip install -r QuantResearch/requirements.txt + pip install -r QuantTrader/requirements.txt + ``` + + Python 3.10+ is recommended for `pandas`/`xgboost` compatibility. + +2. **Configure secrets** + + ```bash + cp .env.demo .env + # edit .env with your OANDA practice/live credentials + webhook URLs + source .env + ``` + + All scripts that touch OANDA import from `shared.utils.config`, so missing env vars fail fast. + +3. **Prepare data** + + - Drop raw CSVs (e.g., `USDJPY_H1.csv`) under `QuantResearch/data/raw/`. + - Rebuild the manifest + integrity reports any time data changes: + + ```bash + cd QuantResearch + python scripts/build_dataset_manifest.py --dirs data/raw data/derived --output data/_manifest.json + python scripts/check_data_integrity.py + ``` + +4. **Run a backtest** + + ```bash + python QuantResearch/scripts/backtest_strategy.py \ + --csv QuantResearch/data/raw/USDJPY_H1.csv \ + --symbol USDJPY \ + --fast 20 --slow 80 \ + --strategies QuantTrader/config/usdjpy_multi_strategy.yaml + ``` + + The script validates the dataset, runs the engine, and writes KPIs plus `equity/`, `trades/`, and `stats/` artifacts under `QuantResearch/data/outputs/`. + +5. **Train or refresh the XGBoost signal** + + ```bash + python QuantResearch/scripts/train_xgb_usdjpy.py \ + --csv QuantResearch/data/raw/USDJPY_H1.csv \ + --symbol USDJPY \ + --out QuantResearch/artifacts/models/usdjpy_h1_xgb + ``` + + This exports `model.json`, feature lists, thresholds, and updates `usdjpy_h1_xgb_latest.json` so trading configs can point to the latest model. + +6. **Run walk-forward analysis (optional gating)** + + ```bash + python QuantResearch/scripts/run_walkforward.py \ + --config QuantTrader/config/usdjpy_multi_strategy.yaml \ + --csv QuantResearch/data/raw/USDJPY_H1.csv \ + --train-bars 4000 --test-bars 1000 \ + --output-root QuantResearch/results \ + --label usdjpy_xgb + ``` + + Each window produces metrics and a `summary.json` under `QuantResearch/results//`. Reference these run IDs in PRs. + +7. **Promote artifacts to the trader** + + After validating a run, sync configs/params into `QuantTrader/artifacts/` (see `QuantTrader/artifacts/README.md`): + + ```bash + cp QuantTrader/config/usdjpy_multi_strategy.yaml QuantTrader/artifacts/config/ + cp QuantResearch/artifacts/models/usdjpy_h1_xgb_latest.json QuantTrader/artifacts/params/ + ``` + +8. **Paper trading or live execution** + + - Paper (uses live pricing -> StrategyEngine -> simulated fills): + + ```bash + python QuantTrader/scripts/paper_trade.py \ + --config QuantTrader/config/usdjpy_multi_strategy.yaml \ + --symbol USDJPY \ + --timeframe 60s + ``` + + - Live example (direct OANDA handler + RSI strategy template, see `QuantTrader/scripts/live_trade.py`): + + ```bash + python QuantTrader/scripts/live_trade.py + ``` + + Customize the risk manager, strategy, and execution handler before pointing to a funded account. + +9. **Spin up monitoring (optional but recommended)** + + ```bash + docker compose up -d + ``` + + This launches Pushgateway (`:9091`), Prometheus (`:9090`), and Grafana (`:3000`). Import `monitoring/grafana/risk_metrics_dashboard.json` and enable the bundled drilldown plugins for logs/traces/profiles/metrics exploration. + +## Common Workflows + +- **Data quality gating:** `python QuantResearch/scripts/watch_quality.py` or the CI-friendly `scripts/watch_risk_metrics.py` push metrics to Slack/Pushgateway before PRs merge. +- **Batch experiments:** `python QuantResearch/scripts/run_batch_backtests.py --config config/eurusd_grid.yaml` sweeps parameter grids and streams metrics under `results//`. +- **Stress testing:** `python QuantResearch/scripts/validate_stress_scenarios.py --config ...` replays adverse cost scenarios to validate drawdown budgets. +- **Risk sims:** `RUN= ./QuantResearch/scripts/run_risk_sim.sh && ./QuantResearch/bin/backfill_risk.sh` keep `results/risk/metrics.csv` aligned with latest runs. + +## Monitoring & Diagnostics + +- `QuantResearch/scripts/export_metrics_prom.py` streams aggregated KPIs to Pushgateway (`PUSHGATEWAY_URL`). +- `QuantResearch/scripts/notify_risk_metrics.sh` wraps `watch_risk_metrics.py` to send Slack alerts using `SLACK_RISK_WEBHOOK`. +- Grafana plugins under `monitoring/grafana/plugins/grafana-*-app/` document the queryless drilldown experiences for logs (Loki), metrics (Prometheus), traces (Tempo), and profiles (Pyroscope). +- `monitoring/grafana/risk_metrics_dashboard.json` visualizes walk-forward pass rates, tail risk, exposure, and per-strategy attribution. Load it after Grafana boots (`admin/admin` by default). + +## Testing & Validation + +- Unit tests: `pytest QuantResearch/tests QuantTrader/tests`. +- Strategy registry coverage: `QuantResearch/tests/test_strategy_registry.py` ensures new strategies register correctly; add fixtures before contributing. +- Result validation: `python QuantResearch/scripts/validate_results.py QuantResearch/results/` checks KPI completeness + data references. +- Data feed/execution smoke tests: `python QuantTrader/tests/test_execution_adapters.py` mocks OANDA flows. + +## Extending the Stack + +1. Implement a new research strategy under `QuantResearch/strategies/` and decorate it with `@register("my_strategy")`. +2. Reference it inside a config YAML (e.g., `usdjpy_multi_strategy.yaml`) with weights/params. +3. Add risk rules in `QuantTrader/core/risk/` if the position sizing model needs to change. +4. Document any new process in `QuantResearch/docs/` or module-level READMEs so CI reviewers have breadcrumbs. + +## Related Docs + +- `QuantResearch/README.md` – data submission rules, risk/diagnostics workflow. +- `QuantTrader/artifacts/README.md` – promotion checklist for configs/params. +- `monitoring/grafana/plugins/*/README.md` – upstream plugin instructions. + +## License + +No open-source license is declared yet. Keep the repository private or add a LICENSE file before publishing. + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1e2b722 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,32 @@ +version: "3.8" +services: + pushgateway: + image: prom/pushgateway:latest + container_name: pushgateway + ports: + - "9091:9091" + restart: unless-stopped + + prometheus: + image: prom/prometheus:latest + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro + depends_on: + - pushgateway + restart: unless-stopped + + grafana: + image: grafana/grafana:latest + container_name: grafana + ports: + - "3000:3000" + volumes: + - ./monitoring/grafana:/var/lib/grafana + environment: + - GF_SECURITY_ADMIN_PASSWORD=admin + depends_on: + - prometheus + restart: unless-stopped diff --git a/main.py b/main.py new file mode 100644 index 0000000..d8a956b --- /dev/null +++ b/main.py @@ -0,0 +1,23 @@ +# main.py +import oandapyV20 +import oandapyV20.endpoints.accounts as accounts +from shared.utils.config import OANDA_TOKEN, OANDA_ACCOUNT_ID, OANDA_URL +from shared.utils.logger import logger + +def connect_oanda(): + client = oandapyV20.API(access_token=OANDA_TOKEN) + return client + +def get_account_summary(client): + r = accounts.AccountSummary(accountID=OANDA_ACCOUNT_ID) + client.request(r) + return r.response + +if __name__ == "__main__": + client = connect_oanda() + logger.info("✅ Connected to OANDA") + + summary = get_account_summary(client) + balance = summary['account']['balance'] + currency = summary['account']['currency'] + logger.info(f"💰 Balance: {balance} {currency}") diff --git a/metrics/execution.csv b/metrics/execution.csv new file mode 100644 index 0000000..3093553 --- /dev/null +++ b/metrics/execution.csv @@ -0,0 +1,5 @@ +event,order_id,symbol,latency_ms,status,timestamp +submit,PAPER-1,USDJPY,54.391000000000005,accepted,2025-11-11T13:38:55.591742 +submit,PAPER-1,USDJPY,26.575000000000003,accepted,2025-11-11T13:39:09.656374 +submit,PAPER-1,USDJPY,30.724,accepted,2025-11-11T13:39:26.674012 +submit,PAPER-1,USDJPY,26.801,accepted,2025-11-11T13:39:38.077863