Add files via upload

This commit is contained in:
xiaochuan
2025-11-14 22:56:44 +00:00
committed by GitHub
parent cf1944a688
commit ef6e1e278d
96 changed files with 3300 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Binary file not shown.
+17
View File
@@ -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"
}
}
+30
View File
@@ -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
+22
View File
@@ -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
+28
View File
@@ -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
+38
View File
@@ -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
+10
View File
@@ -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.
+3
View File
@@ -0,0 +1,3 @@
# core.data package init
from . import base
from . import oanda
+136
View File
@@ -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
+257
View File
@@ -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})")
+38
View File
@@ -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
+14
View File
@@ -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",
]
+182
View File
@@ -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]
+117
View File
@@ -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
+93
View File
@@ -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),
)
)
+2
View File
@@ -0,0 +1,2 @@
# core.risk package init
from . import base
+161
View File
@@ -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
+70
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
# core.strategy package init
from . import base
from . import rsi_mean_reversion
+122
View File
@@ -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
+65
View File
@@ -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
+46
View File
@@ -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
+116
View File
@@ -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))
+102
View File
@@ -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
+24
View File
@@ -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"),
}
+51
View File
@@ -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(),
)
)
+134
View File
@@ -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
+28
View File
@@ -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)
+111
View File
@@ -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")
+14
View File
@@ -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
+10
View File
@@ -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
1 order_id ts symbol pnl adapter_latency_ms direction price quantity
2 LIVE-1 2025-11-12T09:00:00Z EURUSD 42.5 30 BUY 1.1593 10000
3 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
1 order_id ts symbol pnl adapter_latency_ms direction price quantity
2 PAPER-1 2025-11-12T09:00:00Z EURUSD 40.0 20 BUY 1.1593 10000
3 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
}
+58
View File
@@ -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())
+296
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
@@ -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()
+34
View File
@@ -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()
+168
View File
@@ -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.
+32
View File
@@ -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
+23
View File
@@ -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}")
+5
View File
@@ -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
1 event order_id symbol latency_ms status timestamp
2 submit PAPER-1 USDJPY 54.391000000000005 accepted 2025-11-11T13:38:55.591742
3 submit PAPER-1 USDJPY 26.575000000000003 accepted 2025-11-11T13:39:09.656374
4 submit PAPER-1 USDJPY 30.724 accepted 2025-11-11T13:39:26.674012
5 submit PAPER-1 USDJPY 26.801 accepted 2025-11-11T13:39:38.077863