mirror of
https://github.com/silencesdg/mt5_python_ea_suite.git
synced 2026-07-29 11:47:44 +00:00
重构项目架构,新增 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:
@@ -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
|
||||
@@ -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):
|
||||
"""平仓"""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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]
|
||||
Reference in New Issue
Block a user