重构项目架构,新增 MT5 代理服务

- 重构核心模块:DataProvider 依赖注入、RiskController 门面、信号注册表
- 新增 FastAPI 代理服务 (run/server.py),支持局域网远程调用 MT5
- 新增 RemoteDataProvider + AttrDict,远端无缝替代 LiveDataProvider
- 新增序列化模块,MT5 对象转 JSON 兼容格式
- 重构入口点至 run/ 包,支持 python -m run.realtime/server/backtest/optimize
- 更新 CLAUDE.md 文档

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>
This commit is contained in:
silencesdg
2026-05-11 12:00:45 +08:00
parent e8226edd96
commit 4cb4f4a15e
46 changed files with 2993 additions and 2279 deletions
+6
View File
@@ -0,0 +1,6 @@
from core.data.abc import DataProvider
from core.data.live import LiveDataProvider
from core.data.dryrun import DryRunDataProvider
from core.data.backtest import BacktestDataProvider
from core.data.multi_tf import MultiTimeframeDataStore
from core.data.remote import RemoteDataProvider
+45
View File
@@ -0,0 +1,45 @@
from abc import ABC, abstractmethod
class DataProvider(ABC):
"""数据提供者抽象基类 — 定义所有数据访问的统一接口"""
@property
def is_live(self):
return False
@abstractmethod
def initialize(self):
"""初始化连接"""
@abstractmethod
def shutdown(self):
"""关闭连接"""
@abstractmethod
def get_current_price(self, symbol):
"""获取当前价格: {'bid', 'ask', 'last', 'time'}"""
@abstractmethod
def get_historical_data(self, symbol, timeframe, count, **kwargs):
"""获取历史K线数据"""
@abstractmethod
def get_account_info(self):
"""获取账户信息"""
@abstractmethod
def get_positions(self, symbol):
"""获取当前持仓"""
@abstractmethod
def get_symbol_info(self, symbol):
"""获取品种信息(合约规格等)"""
@abstractmethod
def send_order(self, symbol, order_type, volume):
"""发送订单"""
@abstractmethod
def close_position(self, ticket, symbol, volume):
"""平仓"""
+80
View File
@@ -0,0 +1,80 @@
import numpy as np
from logger import logger
from core.data.abc import DataProvider
from core.data.multi_tf import MultiTimeframeDataStore
from config import SIMULATION_CONFIG
class BacktestDataProvider(DataProvider):
"""回测数据提供者(重写版) — 通过 MultiTimeframeDataStore 支持多周期数据
关键改进:
- get_historical_data() 的 timeframe 参数现在真正生效
- 内部持有 MultiTimeframeDataStore,按需从M1生成更高周期数据
- current_index 推进时各周期数据指针同步
"""
def __init__(self, multi_tf_store: MultiTimeframeDataStore,
initial_equity: float = 10000):
self.multi_tf = multi_tf_store
self.current_index = 0
self.equity = initial_equity
self.simulated_ticket_counter = 0
self.spread = SIMULATION_CONFIG.get("spread", 16)
@property
def total_length(self) -> int:
return self.multi_tf.length
def initialize(self):
return True
def shutdown(self):
pass
def get_account_info(self):
return {'equity': self.equity}
def get_positions(self, symbol):
return []
def get_symbol_info(self, symbol):
return {
'trade_contract_size': SIMULATION_CONFIG['contract_size'],
'volume_step': SIMULATION_CONFIG['volume_step'],
'volume_min': SIMULATION_CONFIG['volume_min'],
'volume_max': SIMULATION_CONFIG['volume_max'],
}
def get_current_price(self, symbol):
if self.current_index >= self.total_length:
return None
row = self.multi_tf.main_df.iloc[self.current_index]
spread_half = self.spread * 0.01 / 2
return {
'bid': row.close - spread_half,
'ask': row.close + spread_half,
'last': row.close,
'time': row.name
}
def get_historical_data(self, symbol, timeframe, count, **kwargs):
"""获取历史数据 — timeframe 参数现在真正生效"""
return self.multi_tf.get_historical_data(
symbol, timeframe, count, self.current_index
)
def send_order(self, symbol, order_type, volume):
self.simulated_ticket_counter += 1
logger.info(f"[回测模式] 下单: {order_type} {volume:.2f}{symbol}")
return {'order': self.simulated_ticket_counter}
def close_position(self, ticket, symbol, volume):
logger.info(f"[回测模式] 平仓: Ticket {ticket}")
return True
def tick(self) -> bool:
if self.current_index < self.total_length - 1:
self.current_index += 1
return True
return False
+62
View File
@@ -0,0 +1,62 @@
import pandas as pd
from logger import logger
from core.data.abc import DataProvider
from core.data.live import LiveDataProvider
from config import SIMULATION_CONFIG
class DryRunDataProvider(DataProvider):
"""纸上交易数据提供者 — 使用实时价格但不发送真实订单"""
def __init__(self, initial_equity=10000, leverage=100):
self.simulated_ticket_counter = 0
self.equity = initial_equity
self.leverage = leverage
self._live_data_source = LiveDataProvider()
self.spread = SIMULATION_CONFIG.get("spread", 16)
def initialize(self):
logger.info("纸上交易模式初始化...")
return self._live_data_source.initialize()
def shutdown(self):
logger.info("纸上交易模式关闭.")
self._live_data_source.shutdown()
def get_current_price(self, symbol):
price_data = self._live_data_source.get_current_price(symbol)
if price_data:
spread_half = self.spread * 0.01 / 2
mid_price = price_data['last']
return {
'bid': mid_price - spread_half,
'ask': mid_price + spread_half,
'last': mid_price,
'time': price_data['time']
}
return None
def get_historical_data(self, symbol, timeframe, count, **kwargs):
return self._live_data_source.get_historical_data(symbol, timeframe, count, **kwargs)
def get_symbol_info(self, symbol):
return self._live_data_source.get_symbol_info(symbol)
def get_account_info(self):
return {'equity': self.equity}
def get_positions(self, symbol):
return []
def send_order(self, symbol, order_type, volume):
self.simulated_ticket_counter += 1
price_data = self.get_current_price(symbol)
price = price_data['last'] if price_data else "N/A"
logger.info(f"[纸上交易] 模拟下单: {order_type} {volume:.2f}{symbol} @ {price}")
return {'order': self.simulated_ticket_counter}
def close_position(self, ticket, symbol, volume):
price_data = self.get_current_price(symbol)
price = price_data['last'] if price_data else "N/A"
logger.info(f"[纸上交易] 模拟平仓: Ticket {ticket} @ {price}")
return True
+172
View File
@@ -0,0 +1,172 @@
import MetaTrader5 as mt5
import pandas as pd
from logger import logger
from core.data.abc import DataProvider
class LiveDataProvider(DataProvider):
"""实盘数据提供者 — 封装真实MT5 API调用"""
@property
def is_live(self):
return True
def initialize(self):
if not mt5.initialize():
logger.error("MT5初始化失败")
return False
logger.info("MT5连接成功")
return True
def shutdown(self):
mt5.shutdown()
logger.info("MT5连接已关闭")
def get_current_price(self, symbol):
tick = mt5.symbol_info_tick(symbol)
if tick:
last_price = tick.last if tick.last != 0 else (tick.bid + tick.ask) / 2
return {
'bid': tick.bid, 'ask': tick.ask, 'last': last_price,
'time': pd.to_datetime(tick.time, unit='s')
}
logger.error(f"无法获取 {symbol} 的价格数据")
return None
def get_historical_data(self, symbol, timeframe, count, **kwargs):
return mt5.copy_rates_from_pos(symbol, timeframe, 0, count)
def get_account_info(self):
return mt5.account_info()
def get_positions(self, symbol):
return mt5.positions_get(symbol=symbol)
def get_symbol_info(self, symbol):
return mt5.symbol_info(symbol)
def send_order(self, symbol, order_type, volume):
price_data = self.get_current_price(symbol)
if not price_data:
logger.error(f"无法获取 {symbol} 价格,无法下单")
return None
price = price_data['ask'] if order_type == "buy" else price_data['bid']
if not mt5.terminal_info().trade_allowed:
logger.error("MT5终端未启用自动交易")
return None
account_info = mt5.account_info()
if account_info and not account_info.trade_allowed:
logger.error("当前账户不允许自动交易")
return None
symbol_info = mt5.symbol_info(symbol)
if not symbol_info:
logger.error(f"无法获取 {symbol} 的品种信息")
return None
# MT5 filling_mode 是位图,用位与判断
fm = symbol_info.filling_mode
# MQL5 filling_mode 位图: FOK=1, IOC=2
if fm & 1:
filling_mode = mt5.ORDER_FILLING_FOK
elif fm & 2:
filling_mode = mt5.ORDER_FILLING_IOC
else:
filling_mode = mt5.ORDER_FILLING_RETURN
order_type_mt5 = mt5.ORDER_TYPE_BUY if order_type == "buy" else mt5.ORDER_TYPE_SELL
request = {
"action": mt5.TRADE_ACTION_DEAL,
"symbol": symbol,
"volume": volume,
"type": order_type_mt5,
"price": price,
"deviation": 20,
"magic": 234000,
"comment": f"{order_type} order",
"type_filling": filling_mode,
}
result = mt5.order_send(request)
if result and hasattr(result, 'retcode'):
if result.retcode == 10027:
logger.error("自动交易被禁用")
elif result.retcode == 10030:
if filling_mode != mt5.ORDER_FILLING_IOC:
request["type_filling"] = mt5.ORDER_FILLING_IOC
result = mt5.order_send(request)
if result and result.retcode == 10030 and filling_mode != mt5.ORDER_FILLING_FOK:
request["type_filling"] = mt5.ORDER_FILLING_FOK
result = mt5.order_send(request)
elif result.retcode != 10009:
logger.error(f"下单失败,错误代码: {result.retcode}")
return result
def close_position(self, ticket, symbol, volume):
positions = self.get_positions(symbol)
if not positions:
logger.error("未找到任何持仓")
return False
target_position = None
for pos in positions:
if pos.ticket == ticket:
target_position = pos
break
if not target_position:
logger.error(f"未找到ticket为 {ticket} 的持仓")
return False
tick = mt5.symbol_info_tick(symbol)
if not tick:
logger.error(f"无法获取 {symbol} 的当前价格")
return False
if target_position.type == mt5.POSITION_TYPE_BUY:
close_price = tick.bid
order_type = mt5.ORDER_TYPE_SELL
else:
close_price = tick.ask
order_type = mt5.ORDER_TYPE_BUY
symbol_info = mt5.symbol_info(symbol)
if not symbol_info:
return False
fm = symbol_info.filling_mode
# MQL5 filling_mode 位图: FOK=1, IOC=2
if fm & 1:
filling_mode = mt5.ORDER_FILLING_FOK
elif fm & 2:
filling_mode = mt5.ORDER_FILLING_IOC
else:
filling_mode = mt5.ORDER_FILLING_IOC
request = {
"action": mt5.TRADE_ACTION_DEAL,
"position": target_position.ticket,
"symbol": symbol,
"volume": volume,
"type": order_type,
"price": close_price,
"deviation": 20,
"magic": 234000,
"comment": f"Close position {ticket}",
"type_filling": filling_mode,
}
result = mt5.order_send(request)
if result is None:
logger.error(f"平仓请求返回None: Ticket={ticket}")
return False
if result.retcode == mt5.TRADE_RETCODE_DONE:
logger.info(f"平仓成功: Ticket {ticket}")
return True
else:
logger.error(f"平仓失败: Ticket={ticket}, 错误码={result.retcode}")
return False
+154
View File
@@ -0,0 +1,154 @@
import numpy as np
import pandas as pd
from utils.constants import TIMEFRAME_TO_MINUTES, RESAMPLE_RULES, PERIOD_M1
class MultiTimeframeDataStore:
"""多周期数据存储 — 从M1数据按需生成更高周期的OHLC数据
回测/优化器模式:加载M1数据后,通过 pandas resample 生成 H1/H4/D1 等。
实盘模式:不需要此组件(DataProvider 直接返回 MT5 原生数据)。
用法:
store = MultiTimeframeDataStore()
store.load_m1_data(m1_rates_array)
h1_data = store.get_historical_data("XAUUSD", PERIOD_H1, 100, current_index=5000)
"""
def __init__(self):
self._main_df = None
self._main_timeframe = PERIOD_M1
self._cache = {} # timeframe: DataFrame
def load_m1_data(self, rates: np.ndarray) -> None:
"""加载M1原始数据并初始化缓存"""
df = pd.DataFrame(rates)
df['time'] = pd.to_datetime(df['time'], unit='s')
df.set_index('time', inplace=True)
df.sort_index(inplace=True)
self._main_df = df
self._cache = {PERIOD_M1: df}
@property
def main_df(self) -> pd.DataFrame:
return self._main_df
@property
def length(self) -> int:
if self._main_df is None:
return 0
return len(self._main_df)
def _resample(self, timeframe: int) -> pd.DataFrame:
"""从M1数据 resample 到目标周期"""
if timeframe == PERIOD_M1:
return self._main_df
rule = RESAMPLE_RULES.get(timeframe)
if rule is None:
raise ValueError(f"不支持的周期: {timeframe}")
# 确保 M1 DataFrame 包含 OHLCV 列
m1 = self._main_df[['open', 'high', 'low', 'close']].copy()
if 'tick_volume' in self._main_df.columns:
m1['tick_volume'] = self._main_df['tick_volume']
resampled = m1.resample(rule, label='right', closed='right').agg({
'open': 'first',
'high': 'max',
'low': 'min',
'close': 'last',
})
if 'tick_volume' in m1.columns:
resampled['tick_volume'] = m1['tick_volume'].resample(
rule, label='right', closed='right').sum()
# 生成 time 列(Unix秒)
resampled['time'] = [int(ts.timestamp()) for ts in resampled.index]
# 丢掉由未来数据填充出来的最后一个未完成 bar
last_m1_time = self._main_df.index[-1]
last_complete = resampled[resampled.index <= last_m1_time]
if len(last_complete) > 0:
resampled = last_complete
elif len(resampled) > 0:
logger_warning = None
try:
from logger import logger
logger_warning = logger
except Exception:
pass
if logger_warning:
logger_warning.warning(
f"resample {rule}: 所有bar都在最后M1时间之后,返回空DataFrame"
)
resampled = resampled.iloc[0:0]
return resampled
def ensure_timeframe(self, timeframe: int) -> pd.DataFrame:
"""确保指定周期的数据已缓存,若未缓存则从M1生成"""
if timeframe in self._cache:
return self._cache[timeframe]
if self._main_df is None:
raise RuntimeError("必须先调用 load_m1_data()")
tf_df = self._resample(timeframe)
self._cache[timeframe] = tf_df
return tf_df
def _find_tf_index(self, timeframe: int, m1_bar_index: int) -> int:
"""将M1 bar索引映射到目标周期的已完成bar索引
返回目标周期中已经完成(不会看到未来)的最后一个 bar 的索引。
即:目标周期 bar 的结束时间 <= 当前M1 bar 的时间。
"""
if self._main_df is None:
return -1
tf_df = self.ensure_timeframe(timeframe)
current_time = self._main_df.index[m1_bar_index]
# 找到结束时间 <= current_time 的最后一个目标周期 bar
positions = tf_df.index.get_indexer([current_time], method='bfill')
tf_idx = positions[0]
# bfill 返回的 bar 可能结束时间 > current_time(未完成),需要退回一个
if tf_idx >= 0 and tf_idx < len(tf_df):
if tf_df.index[tf_idx] > current_time:
tf_idx -= 1
return tf_idx
def get_historical_data(self, symbol: str, timeframe: int,
count: int, current_index: int) -> np.ndarray | None:
"""从 current_index 位置向前获取 count 条指定周期的历史数据
Args:
symbol: 品种名(当前未使用,为接口一致性保留)
timeframe: MT5周期常量
count: 需要的bar数量
current_index: 当前M1 bar的索引(0-based
Returns:
numpy recarray 或 None(数据不足时)
"""
if self._main_df is None or current_index >= self.length:
return None
tf_idx = self._find_tf_index(timeframe, current_index)
if tf_idx < count - 1:
return None
tf_df = self.ensure_timeframe(timeframe)
start = tf_idx - count + 1
end = tf_idx + 1
# 转换为MT5兼容的记录数组格式
# 避免 index name 与 'time' 列冲突
records = tf_df.iloc[start:end].reset_index(drop=True).to_records(index=False)
return np.array(records)
+119
View File
@@ -0,0 +1,119 @@
import pandas as pd
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from core.data.abc import DataProvider
class AttrDict(dict):
"""支持属性访问的 dict,兼容 isinstance(x, dict) 检查"""
def __getattr__(self, key):
try:
return self[key]
except KeyError:
raise AttributeError(key)
class RemoteDataProvider(DataProvider):
"""远端数据提供者 — 通过 HTTP 调用 MT5 代理服务"""
def __init__(self, host="127.0.0.1", port=5555):
self.base_url = f"http://{host}:{port}/api"
self._session = requests.Session()
retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[502, 503, 504])
self._session.mount("http://", HTTPAdapter(max_retries=retry))
@property
def is_live(self):
return True
def initialize(self):
try:
resp = self._session.post(f"{self.base_url}/initialize", timeout=10)
return resp.json().get("success", False)
except Exception:
return False
def shutdown(self):
try:
self._session.post(f"{self.base_url}/shutdown", timeout=5)
except Exception:
pass
def get_current_price(self, symbol):
try:
resp = self._session.get(f"{self.base_url}/price/{symbol}", timeout=10)
if resp.status_code != 200:
return None
data = resp.json()
data['time'] = pd.to_datetime(data['time'], unit='s')
return data
except Exception:
return None
def get_historical_data(self, symbol, timeframe, count, **kwargs):
try:
resp = self._session.get(
f"{self.base_url}/historical/{symbol}/{timeframe}/{count}",
timeout=30,
)
if resp.status_code != 200:
return None
return resp.json()["rates"]
except Exception:
return None
def get_account_info(self):
try:
resp = self._session.get(f"{self.base_url}/account", timeout=10)
if resp.status_code != 200:
return None
return AttrDict(resp.json())
except Exception:
return None
def get_positions(self, symbol):
try:
resp = self._session.get(f"{self.base_url}/positions/{symbol}", timeout=10)
if resp.status_code != 200:
return None
return [AttrDict(p) for p in resp.json()["positions"]]
except Exception:
return None
def get_symbol_info(self, symbol):
try:
resp = self._session.get(f"{self.base_url}/symbol/{symbol}", timeout=10)
if resp.status_code != 200:
return None
return AttrDict(resp.json())
except Exception:
return None
def send_order(self, symbol, order_type, volume):
try:
resp = self._session.post(
f"{self.base_url}/order",
json={"symbol": symbol, "order_type": order_type, "volume": volume},
timeout=10,
)
if resp.status_code != 200:
return None
return AttrDict(resp.json())
except Exception:
return None
def close_position(self, ticket, symbol, volume):
try:
resp = self._session.post(
f"{self.base_url}/close",
json={"ticket": ticket, "symbol": symbol, "volume": volume},
timeout=10,
)
if resp.status_code != 200:
return False
return resp.json().get("success", False)
except Exception:
return False
+46
View File
@@ -0,0 +1,46 @@
import numpy as np
def _to_native(val):
"""numpy 类型转 Python 原生类型"""
if isinstance(val, (np.integer,)):
return int(val)
if isinstance(val, (np.floating,)):
return float(val)
if isinstance(val, np.ndarray):
return val.tolist()
return val
def _mt5_to_dict(obj):
"""将 MT5 对象转为 dict,兼容 _asdict() / _fields / __dict__"""
if hasattr(obj, '_asdict'):
return {k: _to_native(v) for k, v in obj._asdict().items()}
if hasattr(obj, '_fields'):
return {f: _to_native(getattr(obj, f)) for f in obj._fields}
if hasattr(obj, '__dict__'):
return {k: _to_native(v) for k, v in obj.__dict__.items()}
return {}
def serialize_account_info(info):
return _mt5_to_dict(info) if info else None
def serialize_position(pos):
return _mt5_to_dict(pos) if pos else None
def serialize_symbol_info(info):
return _mt5_to_dict(info) if info else None
def serialize_order_result(result):
return _mt5_to_dict(result) if result else None
def serialize_rates(rates):
if rates is None:
return None
names = rates.dtype.names
return [{name: _to_native(row[name]) for name in names} for row in rates]