mirror of
https://github.com/xavierchuan/FX-ML-Trading-Engine.git
synced 2026-07-27 18:17:44 +00:00
Add files via upload
This commit is contained in:
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
@@ -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/<strategy>.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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,3 @@
|
||||
# core.data package init
|
||||
from . import base
|
||||
from . import oanda
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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})")
|
||||
@@ -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
|
||||
@@ -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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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]
|
||||
@@ -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
|
||||
@@ -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),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,2 @@
|
||||
# core.risk package init
|
||||
from . import base
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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)
|
||||
@@ -0,0 +1,3 @@
|
||||
# core.strategy package init
|
||||
from . import base
|
||||
from . import rsi_mean_reversion
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
OANDA 定价流适配器,用于从模拟/实盘账户拉取实时报价并投递 TickEvent。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from queue import Queue
|
||||
from typing import Iterable, List, Optional, Sequence
|
||||
|
||||
import pandas as pd
|
||||
from loguru import logger
|
||||
from oandapyV20 import API
|
||||
try:
|
||||
from oandapyV20.endpoints.pricing import PricingStream
|
||||
except ImportError:
|
||||
PricingStream = None
|
||||
|
||||
from core.events import TickEvent
|
||||
|
||||
|
||||
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 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))
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
@@ -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"),
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -0,0 +1,14 @@
|
||||
2025-11-04 16:30:59.195 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:34:28.561 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:35:55.938 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:35:56.617 | INFO | __main__:<module>:23 - 💰 Balance: 965985.9487 GBP
|
||||
2025-11-04 16:47:20.949 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:47:27.446 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:47:34.147 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:47:41.773 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:49:19.573 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:51:07.455 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:51:12.478 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:53:59.135 | INFO | __main__:<module>:18 - ✅ Connected to OANDA
|
||||
2025-11-04 16:53:59.837 | INFO | __main__:<module>:23 - 💰 Balance: 965985.9487 GBP
|
||||
2025-11-04 17:14:25.105 | INFO | __main__:<module>:21 - 📈 策略累计收益: 0.0123
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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/<run_id>/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 <your fork url>
|
||||
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/<run_id>/`. 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/<batch>/`.
|
||||
- **Stress testing:** `python QuantResearch/scripts/validate_stress_scenarios.py --config ...` replays adverse cost scenarios to validate drawdown budgets.
|
||||
- **Risk sims:** `RUN=<run_id> ./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/<run_id>` 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.
|
||||
|
||||
@@ -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
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
|
Reference in New Issue
Block a user