0.991
This commit is contained in:
@@ -1,40 +0,0 @@
|
||||
"""平掉 MT5 账户上本策略 magic 的全部持仓。"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from config import Config
|
||||
from execution.trader import MT5Trader
|
||||
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
except ImportError:
|
||||
print("MT5 not available")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
trader = MT5Trader()
|
||||
trader.connect()
|
||||
magic = Config.MAGIC_NUMBER
|
||||
# 平掉账户全部持仓(含非本策略 magic)
|
||||
all_pos = list(trader.get_positions())
|
||||
if not all_pos:
|
||||
print("无持仓")
|
||||
trader.close()
|
||||
return
|
||||
|
||||
symbols = sorted({p.symbol for p in all_pos})
|
||||
print(f"待平仓品种: {symbols} 共 {len(all_pos)} 笔")
|
||||
for sym in symbols:
|
||||
ok = trader.close_all_positions(sym, filter_magic=False)
|
||||
print(f" {sym}: {'OK' if ok else 'PARTIAL/FAIL'}")
|
||||
|
||||
remaining = trader.get_positions()
|
||||
print(f"剩余持仓: {len(remaining)}")
|
||||
trader.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,412 +0,0 @@
|
||||
"""
|
||||
execution/trader.py — MT5 订单执行模块
|
||||
|
||||
MT5Trader 负责通过 MetaTrader5 Python API 执行市价单买卖及账户查询。
|
||||
全同步接口,无 asyncio。
|
||||
"""
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
_MT5_AVAILABLE = True
|
||||
except ImportError:
|
||||
_MT5_AVAILABLE = False
|
||||
# 测试环境占位,使与真实 MT5 常量一致的整数值
|
||||
class _MT5Stub:
|
||||
ORDER_TYPE_BUY = 0
|
||||
ORDER_TYPE_SELL = 1
|
||||
TRADE_ACTION_DEAL = 1
|
||||
TRADE_RETCODE_DONE = 10009
|
||||
|
||||
def initialize(self): # noqa: D401
|
||||
return False
|
||||
|
||||
def last_error(self):
|
||||
return (0, "MT5 not available")
|
||||
|
||||
def account_info(self):
|
||||
return None
|
||||
|
||||
def order_send(self, request):
|
||||
return None
|
||||
|
||||
def shutdown(self):
|
||||
pass
|
||||
|
||||
mt5 = _MT5Stub()
|
||||
|
||||
from loguru import logger
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# 支持从项目根目录或子模块直接运行时都能找到 Config
|
||||
_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if _root not in sys.path:
|
||||
sys.path.insert(0, _root)
|
||||
|
||||
from config import Config
|
||||
|
||||
|
||||
class MT5Trader:
|
||||
"""通过 MT5 Python API 执行市价订单的执行层。
|
||||
|
||||
职责:
|
||||
- 连接 MT5 终端并验证账户可交易状态
|
||||
- 以市价单发起买入 / 卖出
|
||||
- 每次下单前查询账户净值与可用保证金
|
||||
- 将 Config.MAGIC_NUMBER 附加到所有订单
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connected: bool = False
|
||||
|
||||
# ──────────────────────────────────────────────────────
|
||||
# 连接管理
|
||||
# ──────────────────────────────────────────────────────
|
||||
|
||||
def connect(self) -> None:
|
||||
"""连接 MT5 终端并验证账户可交易状态。
|
||||
|
||||
Raises:
|
||||
ConnectionError: 若 mt5.initialize() 失败。
|
||||
RuntimeError: 若账户不可交易。
|
||||
"""
|
||||
if not mt5.initialize():
|
||||
err = mt5.last_error()
|
||||
logger.error(f"MT5 initialize() failed: {err}")
|
||||
raise ConnectionError(f"MT5 connection failed: {err}")
|
||||
|
||||
info = mt5.account_info()
|
||||
if info is None:
|
||||
logger.error("MT5 account_info() returned None after connect")
|
||||
raise RuntimeError("Unable to retrieve MT5 account info after connection")
|
||||
|
||||
if hasattr(info, "trade_allowed") and not info.trade_allowed:
|
||||
logger.error(f"MT5 account {info.login} is not allowed to trade")
|
||||
raise RuntimeError(f"MT5 account {info.login} is not tradeable")
|
||||
|
||||
self._connected = True
|
||||
logger.info(
|
||||
f"MT5 connected — login={getattr(info, 'login', '?')}, "
|
||||
f"server={getattr(info, 'server', '?')}, "
|
||||
f"balance={getattr(info, 'balance', '?')}"
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""释放 MT5 连接。"""
|
||||
mt5.shutdown()
|
||||
self._connected = False
|
||||
logger.info("MT5 connection closed")
|
||||
|
||||
# ──────────────────────────────────────────────────────
|
||||
# 账户信息
|
||||
# ──────────────────────────────────────────────────────
|
||||
|
||||
def get_account_info(self) -> dict | None:
|
||||
"""查询账户净值与可用保证金。
|
||||
|
||||
Returns:
|
||||
{"equity": float, "free_margin": float},查询失败返回 None。
|
||||
"""
|
||||
info = mt5.account_info()
|
||||
if info is None:
|
||||
logger.error("mt5.account_info() failed")
|
||||
return None
|
||||
return {
|
||||
"equity": float(info.equity),
|
||||
"free_margin": float(info.margin_free),
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────
|
||||
# 买入
|
||||
# ──────────────────────────────────────────────────────
|
||||
|
||||
def buy(self, symbol: str, lot: float) -> bool:
|
||||
"""发起 ORDER_TYPE_BUY 市价买单。
|
||||
|
||||
流程:
|
||||
1. 查询账户信息,失败则中止并返回 False(Req 6.7)
|
||||
2. 构建 MqlTradeRequest(Req 6.2、6.6)
|
||||
3. 调用 mt5.order_send()
|
||||
4. retcode != 10009 时记录并返回 False(Req 6.5)
|
||||
|
||||
Args:
|
||||
symbol: MT5 品种标识符,例如 "XAUUSD"。
|
||||
lot: 下单手数,支持小数以实现部分平仓(Req 6.4)。
|
||||
|
||||
Returns:
|
||||
成功返回 True,否则返回 False。
|
||||
"""
|
||||
# Step 1: 查询账户信息
|
||||
account = self.get_account_info()
|
||||
if account is None:
|
||||
logger.error(f"[buy] Aborted for {symbol}: account_info() failed")
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"[buy] {symbol} {lot} lot | "
|
||||
f"equity={account['equity']:.2f}, free_margin={account['free_margin']:.2f}"
|
||||
)
|
||||
|
||||
# Step 2: 构建订单请求
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": float(lot),
|
||||
"type": mt5.ORDER_TYPE_BUY,
|
||||
"magic": Config.MAGIC_NUMBER,
|
||||
"comment": "MT5AlphaGPT_buy",
|
||||
"type_time": 0, # ORDER_TIME_GTC
|
||||
"type_filling": mt5.ORDER_FILLING_FOK, # ORDER_FILLING_IOC(部分成交)
|
||||
}
|
||||
|
||||
# Step 3: 发送订单
|
||||
result = mt5.order_send(request)
|
||||
if result is None:
|
||||
logger.error(f"[buy] mt5.order_send() returned None for {symbol}")
|
||||
return False
|
||||
|
||||
# Step 4: 检查 retcode
|
||||
retcode = result.retcode
|
||||
if retcode != 10009:
|
||||
comment = getattr(result, "comment", "")
|
||||
logger.error(
|
||||
f"[buy] Order rejected for {symbol}: retcode={retcode}, comment={comment}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"[buy] Success — {symbol} {lot} lot, ticket={result.order}")
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────────────
|
||||
# 卖出 / 平仓
|
||||
# ──────────────────────────────────────────────────────
|
||||
|
||||
def sell(self, symbol: str, lot: float) -> bool:
|
||||
"""发起 ORDER_TYPE_SELL 市价卖单(用于平多仓或做空)。
|
||||
|
||||
支持通过指定 lot 进行部分平仓(Req 6.3、6.4)。
|
||||
|
||||
Args:
|
||||
symbol: MT5 品种标识符。
|
||||
lot: 卖出手数。
|
||||
|
||||
Returns:
|
||||
成功返回 True,否则返回 False。
|
||||
"""
|
||||
# Step 1: 查询账户信息
|
||||
account = self.get_account_info()
|
||||
if account is None:
|
||||
logger.error(f"[sell] Aborted for {symbol}: account_info() failed")
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"[sell] {symbol} {lot} lot | "
|
||||
f"equity={account['equity']:.2f}, free_margin={account['free_margin']:.2f}"
|
||||
)
|
||||
|
||||
# Step 2: 构建订单请求
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": float(lot),
|
||||
"type": mt5.ORDER_TYPE_SELL,
|
||||
"magic": Config.MAGIC_NUMBER,
|
||||
"comment": "MT5AlphaGPT_sell",
|
||||
"type_time": 0, # ORDER_TIME_GTC
|
||||
"type_filling": mt5.ORDER_FILLING_FOK, # ORDER_FILLING_IOC
|
||||
}
|
||||
|
||||
# Step 3: 发送订单
|
||||
result = mt5.order_send(request)
|
||||
if result is None:
|
||||
logger.error(f"[sell] mt5.order_send() returned None for {symbol}")
|
||||
return False
|
||||
|
||||
# Step 4: 检查 retcode
|
||||
retcode = result.retcode
|
||||
if retcode != 10009:
|
||||
comment = getattr(result, "comment", "")
|
||||
logger.error(
|
||||
f"[sell] Order rejected for {symbol}: retcode={retcode}, comment={comment}"
|
||||
)
|
||||
return False
|
||||
|
||||
logger.info(f"[sell] Success — {symbol} {lot} lot, ticket={result.order}")
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────────────
|
||||
# 精确平仓(按 ticket)
|
||||
# ──────────────────────────────────────────────────────
|
||||
|
||||
def close_position(self, symbol: str, lot: float, direction: str,
|
||||
ticket: int = 0) -> bool:
|
||||
"""按仓位方向精确平仓。
|
||||
|
||||
"BUY" 方向的多仓用 ORDER_TYPE_SELL 平;
|
||||
"SELL" 方向的空仓用 ORDER_TYPE_BUY 平。
|
||||
若 ticket > 0,则在请求里附加 position 字段,确保平的是指定订单,
|
||||
而不是盲目发反向市价单(避免误开新仓)。
|
||||
|
||||
Args:
|
||||
symbol: 交易品种。
|
||||
lot: 平仓手数(支持部分平仓)。
|
||||
direction: 持仓方向,"BUY" 或 "SELL"。
|
||||
ticket: MT5 订单号;0 表示不指定(仅在无法取到 ticket 时使用)。
|
||||
|
||||
Returns:
|
||||
成功返回 True,否则返回 False。
|
||||
"""
|
||||
account = self.get_account_info()
|
||||
if account is None:
|
||||
logger.error(f"[close_position] Aborted for {symbol}: account_info() failed")
|
||||
return False
|
||||
|
||||
close_type = (mt5.ORDER_TYPE_SELL if direction == "BUY"
|
||||
else mt5.ORDER_TYPE_BUY)
|
||||
comment = (f"MT5AlphaGPT_close_long"
|
||||
if direction == "BUY" else "MT5AlphaGPT_close_short")
|
||||
|
||||
request: dict = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": float(lot),
|
||||
"type": close_type,
|
||||
"magic": Config.MAGIC_NUMBER,
|
||||
"comment": comment,
|
||||
"type_time": 0,
|
||||
"type_filling": mt5.ORDER_FILLING_FOK,
|
||||
}
|
||||
if ticket > 0:
|
||||
request["position"] = ticket # 平指定 ticket,不误开新仓
|
||||
|
||||
logger.info(
|
||||
f"[close_position] {symbol} {direction} lot={lot} ticket={ticket}"
|
||||
)
|
||||
result = mt5.order_send(request)
|
||||
if result is None:
|
||||
logger.error(f"[close_position] order_send() returned None for {symbol}")
|
||||
return False
|
||||
if result.retcode != 10009:
|
||||
logger.error(
|
||||
f"[close_position] rejected: {symbol} retcode={result.retcode} "
|
||||
f"comment={getattr(result, 'comment', '')}"
|
||||
)
|
||||
return False
|
||||
logger.info(f"[close_position] Success — {symbol} ticket={result.order}")
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────────────
|
||||
# 开空仓
|
||||
# ──────────────────────────────────────────────────────
|
||||
|
||||
def open_short(self, symbol: str, lot: float) -> bool:
|
||||
"""发起 ORDER_TYPE_SELL 开空仓(区别于 close_position 的平多)。
|
||||
|
||||
使用独立的 comment 字段以便在 MT5 日志中区分开空与平多。
|
||||
|
||||
Args:
|
||||
symbol: 交易品种。
|
||||
lot: 开仓手数。
|
||||
|
||||
Returns:
|
||||
成功返回 True,否则返回 False。
|
||||
"""
|
||||
account = self.get_account_info()
|
||||
if account is None:
|
||||
logger.error(f"[open_short] Aborted for {symbol}: account_info() failed")
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"[open_short] {symbol} {lot} lot | "
|
||||
f"equity={account['equity']:.2f}"
|
||||
)
|
||||
request = {
|
||||
"action": mt5.TRADE_ACTION_DEAL,
|
||||
"symbol": symbol,
|
||||
"volume": float(lot),
|
||||
"type": mt5.ORDER_TYPE_SELL,
|
||||
"magic": Config.MAGIC_NUMBER,
|
||||
"comment": "MT5AlphaGPT_open_short",
|
||||
"type_time": 0,
|
||||
"type_filling": mt5.ORDER_FILLING_FOK,
|
||||
}
|
||||
result = mt5.order_send(request)
|
||||
if result is None:
|
||||
logger.error(f"[open_short] order_send() returned None for {symbol}")
|
||||
return False
|
||||
if result.retcode != 10009:
|
||||
logger.error(
|
||||
f"[open_short] rejected: {symbol} retcode={result.retcode} "
|
||||
f"comment={getattr(result, 'comment', '')}"
|
||||
)
|
||||
return False
|
||||
logger.info(f"[open_short] Success — {symbol} {lot} lot, ticket={result.order}")
|
||||
return True
|
||||
|
||||
# ──────────────────────────────────────────────────────
|
||||
# 持仓查询 / 批量平仓
|
||||
# ──────────────────────────────────────────────────────
|
||||
|
||||
def get_positions(
|
||||
self,
|
||||
symbol: str | None = None,
|
||||
magic: int | None = None,
|
||||
) -> list:
|
||||
"""查询 MT5 当前持仓,可按品种与 magic 过滤。"""
|
||||
if not _MT5_AVAILABLE or mt5 is None:
|
||||
return []
|
||||
if symbol:
|
||||
live = mt5.positions_get(symbol=symbol)
|
||||
else:
|
||||
live = mt5.positions_get()
|
||||
if live is None:
|
||||
return []
|
||||
positions = list(live)
|
||||
if magic is not None:
|
||||
positions = [p for p in positions if getattr(p, "magic", 0) == magic]
|
||||
return positions
|
||||
|
||||
def close_all_positions(
|
||||
self,
|
||||
symbol: str,
|
||||
magic: int | None = None,
|
||||
*,
|
||||
filter_magic: bool = True,
|
||||
) -> bool:
|
||||
"""平掉指定品种下全部持仓(按 position ticket)。
|
||||
|
||||
filter_magic=False 时平掉该品种所有 magic 的仓位。
|
||||
"""
|
||||
eff_magic = None
|
||||
if filter_magic:
|
||||
eff_magic = Config.MAGIC_NUMBER if magic is None else magic
|
||||
ok_all = True
|
||||
|
||||
while True:
|
||||
positions = self.get_positions(symbol, eff_magic)
|
||||
if not positions:
|
||||
return ok_all
|
||||
|
||||
closed_any = False
|
||||
for p in positions:
|
||||
direction = "BUY" if getattr(p, "type", 0) == 0 else "SELL"
|
||||
ticket = int(getattr(p, "ticket", 0))
|
||||
lot = float(getattr(p, "volume", 0.0))
|
||||
if ticket <= 0 or lot <= 0:
|
||||
continue
|
||||
if self.close_position(symbol, lot, direction, ticket):
|
||||
closed_any = True
|
||||
continue
|
||||
|
||||
# 可能已被前一笔平掉;若 ticket 已不在持仓列表则视为成功
|
||||
still_open = {
|
||||
int(getattr(x, "ticket", 0))
|
||||
for x in self.get_positions(symbol, eff_magic)
|
||||
}
|
||||
if ticket not in still_open:
|
||||
closed_any = True
|
||||
else:
|
||||
ok_all = False
|
||||
|
||||
if not closed_any:
|
||||
return ok_all
|
||||
@@ -0,0 +1,96 @@
|
||||
"""实时信号计算:因子公式 + 实时 K 线 → 方向 + 强度。
|
||||
|
||||
与回测走完全相同的计算链(compute_features → StackVM → tanh → 阈值),
|
||||
保证实时信号与回测/训练目标一致。信号取最后一根已收盘 bar。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
import torch
|
||||
|
||||
from model_core.features import MT5FeatureEngineer
|
||||
from model_core.vm import StackVM
|
||||
|
||||
# 与回测/实盘共用的无信号阈值(Config.MIN_TRADE_EXPOSURE)
|
||||
try:
|
||||
from config import Config
|
||||
_MIN_EXPOSURE = float(getattr(Config, "MIN_TRADE_EXPOSURE", 0.05))
|
||||
except Exception: # noqa: BLE001
|
||||
_MIN_EXPOSURE = 0.05
|
||||
|
||||
# 特征滚动窗口需要足够历史才能稳定(_NORM_WINDOW=200 等)
|
||||
MIN_BARS = 200
|
||||
|
||||
_VM = StackVM()
|
||||
|
||||
DIR_LONG = "LONG"
|
||||
DIR_SHORT = "SHORT"
|
||||
DIR_FLAT = "FLAT"
|
||||
|
||||
|
||||
def min_exposure() -> float:
|
||||
return _MIN_EXPOSURE
|
||||
|
||||
|
||||
def evaluate_signal(formula: list[int], raw_dict: dict[str, Any]) -> dict[str, Any]:
|
||||
"""在实时 K 线上计算因子信号。
|
||||
|
||||
Args:
|
||||
formula: 策略因子的 token 序列。
|
||||
raw_dict: {open,high,low,close,volume} torch 张量 [1, T],升序。
|
||||
|
||||
Returns:
|
||||
dict:state / direction / strength / factor_value / position / bars_used / message
|
||||
"""
|
||||
close = raw_dict.get("close")
|
||||
if close is None or close.ndim != 2:
|
||||
return {"state": "error", "message": "行情数据格式无效"}
|
||||
|
||||
n_bars = int(close.shape[1])
|
||||
if n_bars < MIN_BARS:
|
||||
return {
|
||||
"state": "insufficient",
|
||||
"bars_used": n_bars,
|
||||
"message": f"历史 bar 不足({n_bars}/{MIN_BARS}),无法稳定计算特征",
|
||||
}
|
||||
|
||||
try:
|
||||
feats = MT5FeatureEngineer.compute_features(raw_dict) # [1, F, T]
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"state": "error", "bars_used": n_bars, "message": f"特征计算失败: {exc}"}
|
||||
|
||||
try:
|
||||
factor = _VM.execute([int(t) for t in formula], feats) # [1, T] or None
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return {"state": "error", "bars_used": n_bars, "message": f"公式执行失败: {exc}"}
|
||||
|
||||
if factor is None or factor.ndim != 2 or factor.shape[1] == 0:
|
||||
return {"state": "error", "bars_used": n_bars, "message": "公式无有效输出"}
|
||||
|
||||
factor_last = float(factor[0, -1])
|
||||
if not math.isfinite(factor_last):
|
||||
return {"state": "error", "bars_used": n_bars, "message": "因子值非有限"}
|
||||
|
||||
position = math.tanh(factor_last) # 连续仓位 [-1, 1]
|
||||
strength = abs(position) # 信号强度 [0, 1]
|
||||
thr = _MIN_EXPOSURE
|
||||
|
||||
if position >= thr:
|
||||
direction = DIR_LONG
|
||||
elif position <= -thr:
|
||||
direction = DIR_SHORT
|
||||
else:
|
||||
direction = DIR_FLAT
|
||||
|
||||
return {
|
||||
"state": "ok",
|
||||
"direction": direction,
|
||||
"strength": round(strength, 4),
|
||||
"position": round(position, 4),
|
||||
"factor_value": round(factor_last, 6),
|
||||
"threshold": thr,
|
||||
"bars_used": n_bars,
|
||||
"message": "",
|
||||
}
|
||||
@@ -40,7 +40,48 @@ from config import Config
|
||||
from data_pipeline.fetcher import MT5DataFetcher
|
||||
from data_pipeline.data_manager import MT5DataManager
|
||||
from execution.price_feed import MT5PriceFeed
|
||||
from execution.trader import MT5Trader
|
||||
try:
|
||||
from execution.trader import MT5Trader
|
||||
except ImportError:
|
||||
# execution/trader.py(真正下单的模块)已被移除:本项目不需要自动交易功能。
|
||||
# 用占位类代替,保证 strategy_manager.runner 仍可被导入(训练/回测/测试依赖它),
|
||||
# 但任何试图真正连接/下单的调用都会明确报错,而不是静默假装成功。
|
||||
class MT5Trader: # type: ignore[no-redef]
|
||||
def __init__(self) -> None:
|
||||
self._connected = False
|
||||
|
||||
def _unavailable(self, action: str):
|
||||
raise RuntimeError(
|
||||
f"execution.trader 已被移除(本项目不提供自动交易功能):"
|
||||
f"无法执行 {action}。MT5StrategyRunner 仅可用于信号计算,不能实盘下单。"
|
||||
)
|
||||
|
||||
def connect(self):
|
||||
self._unavailable("connect()")
|
||||
|
||||
def close(self):
|
||||
pass
|
||||
|
||||
def get_account_info(self):
|
||||
return None
|
||||
|
||||
def get_positions(self, symbol=None, magic=None):
|
||||
return []
|
||||
|
||||
def buy(self, symbol, lot):
|
||||
self._unavailable("buy()")
|
||||
|
||||
def sell(self, symbol, lot):
|
||||
self._unavailable("sell()")
|
||||
|
||||
def open_short(self, symbol, lot):
|
||||
self._unavailable("open_short()")
|
||||
|
||||
def close_position(self, symbol, lot, direction, ticket=0):
|
||||
self._unavailable("close_position()")
|
||||
|
||||
def close_all_positions(self, symbol, magic=None, *, filter_magic=True):
|
||||
self._unavailable("close_all_positions()")
|
||||
from model_core.vm import StackVM
|
||||
from strategy_manager.portfolio import MT5PortfolioManager
|
||||
from strategy_manager.risk import MT5RiskEngine
|
||||
|
||||
@@ -1,193 +0,0 @@
|
||||
# Feature: mt5-alphagpt-refactor, Property 8: 订单方向构造正确性
|
||||
# Feature: mt5-alphagpt-refactor, Property 9: 所有订单附带正确 Magic Number
|
||||
"""
|
||||
Property-based tests for execution.trader (MT5Trader).
|
||||
|
||||
Property 8 Validates: Requirements 6.2, 6.3
|
||||
Property 9 Validates: Requirements 6.6
|
||||
"""
|
||||
|
||||
import types
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from hypothesis import given, settings, strategies as st
|
||||
|
||||
from execution.trader import MT5Trader
|
||||
from config import Config
|
||||
|
||||
|
||||
# ── Shared helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_ok_result():
|
||||
"""Build a fake mt5.order_send() result with retcode == 10009 (success)."""
|
||||
result = MagicMock()
|
||||
result.retcode = 10009
|
||||
result.order = 12345
|
||||
return result
|
||||
|
||||
|
||||
def _make_account_info():
|
||||
"""Build a fake mt5.account_info() result."""
|
||||
info = MagicMock()
|
||||
info.equity = 10000.0
|
||||
info.margin_free = 5000.0
|
||||
return info
|
||||
|
||||
|
||||
# ── Shared Hypothesis strategies ─────────────────────────────────────────────
|
||||
|
||||
# Valid MT5 symbol strings: alphanumeric, 2-12 characters
|
||||
symbol_strategy = st.text(
|
||||
alphabet=st.sampled_from("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"),
|
||||
min_size=2,
|
||||
max_size=12,
|
||||
)
|
||||
|
||||
# Valid lot sizes: positive floats in realistic MT5 range
|
||||
lot_strategy = st.floats(
|
||||
min_value=0.01,
|
||||
max_value=100.0,
|
||||
allow_nan=False,
|
||||
allow_infinity=False,
|
||||
)
|
||||
|
||||
|
||||
# ── Property 8: 订单方向构造正确性 ────────────────────────────────────────────
|
||||
# Validates: Requirements 6.2, 6.3
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
symbol=symbol_strategy,
|
||||
lot=lot_strategy,
|
||||
)
|
||||
def test_property8_order_type_correctness(symbol: str, lot: float):
|
||||
"""
|
||||
For any symbol/lot combination:
|
||||
- MT5Trader.buy() must pass a request with type == ORDER_TYPE_BUY (0)
|
||||
- MT5Trader.sell() must pass a request with type == ORDER_TYPE_SELL (1)
|
||||
|
||||
Validates: Requirements 6.2, 6.3
|
||||
"""
|
||||
# MT5 constants (stub values match the real MT5 API integers)
|
||||
ORDER_TYPE_BUY = 0
|
||||
ORDER_TYPE_SELL = 1
|
||||
TRADE_ACTION_DEAL = 1
|
||||
|
||||
captured_buy_request = {}
|
||||
captured_sell_request = {}
|
||||
|
||||
def fake_order_send_buy(request):
|
||||
captured_buy_request.update(request)
|
||||
return _make_ok_result()
|
||||
|
||||
def fake_order_send_sell(request):
|
||||
captured_sell_request.update(request)
|
||||
return _make_ok_result()
|
||||
|
||||
trader = MT5Trader()
|
||||
|
||||
# ── Test buy() ────────────────────────────────────────────────────────────
|
||||
with patch("execution.trader.mt5") as mock_mt5:
|
||||
mock_mt5.ORDER_TYPE_BUY = ORDER_TYPE_BUY
|
||||
mock_mt5.ORDER_TYPE_SELL = ORDER_TYPE_SELL
|
||||
mock_mt5.TRADE_ACTION_DEAL = TRADE_ACTION_DEAL
|
||||
mock_mt5.account_info.return_value = _make_account_info()
|
||||
mock_mt5.order_send.side_effect = fake_order_send_buy
|
||||
|
||||
result = trader.buy(symbol, lot)
|
||||
|
||||
assert result is True, f"buy() should return True on success (symbol={symbol}, lot={lot})"
|
||||
assert "type" in captured_buy_request, "buy() request must contain 'type' key"
|
||||
assert captured_buy_request["type"] == ORDER_TYPE_BUY, (
|
||||
f"buy() must use ORDER_TYPE_BUY={ORDER_TYPE_BUY}, "
|
||||
f"got {captured_buy_request['type']}"
|
||||
)
|
||||
|
||||
# ── Test sell() ───────────────────────────────────────────────────────────
|
||||
with patch("execution.trader.mt5") as mock_mt5:
|
||||
mock_mt5.ORDER_TYPE_BUY = ORDER_TYPE_BUY
|
||||
mock_mt5.ORDER_TYPE_SELL = ORDER_TYPE_SELL
|
||||
mock_mt5.TRADE_ACTION_DEAL = TRADE_ACTION_DEAL
|
||||
mock_mt5.account_info.return_value = _make_account_info()
|
||||
mock_mt5.order_send.side_effect = fake_order_send_sell
|
||||
|
||||
result = trader.sell(symbol, lot)
|
||||
|
||||
assert result is True, f"sell() should return True on success (symbol={symbol}, lot={lot})"
|
||||
assert "type" in captured_sell_request, "sell() request must contain 'type' key"
|
||||
assert captured_sell_request["type"] == ORDER_TYPE_SELL, (
|
||||
f"sell() must use ORDER_TYPE_SELL={ORDER_TYPE_SELL}, "
|
||||
f"got {captured_sell_request['type']}"
|
||||
)
|
||||
|
||||
|
||||
# ── Property 9: 所有订单附带正确 Magic Number ─────────────────────────────────
|
||||
# Validates: Requirements 6.6
|
||||
|
||||
|
||||
@settings(max_examples=100, deadline=None)
|
||||
@given(
|
||||
symbol=symbol_strategy,
|
||||
lot=lot_strategy,
|
||||
)
|
||||
def test_property9_magic_number_on_all_orders(symbol: str, lot: float):
|
||||
"""
|
||||
For any order sent through MT5Trader (buy or sell, any symbol/lot),
|
||||
the MqlTradeRequest's `magic` field must equal Config.MAGIC_NUMBER.
|
||||
|
||||
This ensures every order is tagged for strategy identification regardless
|
||||
of the trading direction or instrument.
|
||||
|
||||
Validates: Requirements 6.6
|
||||
"""
|
||||
ORDER_TYPE_BUY = 0
|
||||
ORDER_TYPE_SELL = 1
|
||||
TRADE_ACTION_DEAL = 1
|
||||
|
||||
captured_buy_request = {}
|
||||
captured_sell_request = {}
|
||||
|
||||
def fake_order_send_buy(request):
|
||||
captured_buy_request.update(request)
|
||||
return _make_ok_result()
|
||||
|
||||
def fake_order_send_sell(request):
|
||||
captured_sell_request.update(request)
|
||||
return _make_ok_result()
|
||||
|
||||
trader = MT5Trader()
|
||||
|
||||
# ── Assert magic on buy() ─────────────────────────────────────────────────
|
||||
with patch("execution.trader.mt5") as mock_mt5:
|
||||
mock_mt5.ORDER_TYPE_BUY = ORDER_TYPE_BUY
|
||||
mock_mt5.ORDER_TYPE_SELL = ORDER_TYPE_SELL
|
||||
mock_mt5.TRADE_ACTION_DEAL = TRADE_ACTION_DEAL
|
||||
mock_mt5.account_info.return_value = _make_account_info()
|
||||
mock_mt5.order_send.side_effect = fake_order_send_buy
|
||||
|
||||
result = trader.buy(symbol, lot)
|
||||
|
||||
assert result is True, f"buy() should return True (symbol={symbol}, lot={lot})"
|
||||
assert "magic" in captured_buy_request, "buy() request must contain 'magic' key"
|
||||
assert captured_buy_request["magic"] == Config.MAGIC_NUMBER, (
|
||||
f"buy() magic field must equal Config.MAGIC_NUMBER={Config.MAGIC_NUMBER}, "
|
||||
f"got {captured_buy_request['magic']} (symbol={symbol}, lot={lot})"
|
||||
)
|
||||
|
||||
# ── Assert magic on sell() ────────────────────────────────────────────────
|
||||
with patch("execution.trader.mt5") as mock_mt5:
|
||||
mock_mt5.ORDER_TYPE_BUY = ORDER_TYPE_BUY
|
||||
mock_mt5.ORDER_TYPE_SELL = ORDER_TYPE_SELL
|
||||
mock_mt5.TRADE_ACTION_DEAL = TRADE_ACTION_DEAL
|
||||
mock_mt5.account_info.return_value = _make_account_info()
|
||||
mock_mt5.order_send.side_effect = fake_order_send_sell
|
||||
|
||||
result = trader.sell(symbol, lot)
|
||||
|
||||
assert result is True, f"sell() should return True (symbol={symbol}, lot={lot})"
|
||||
assert "magic" in captured_sell_request, "sell() request must contain 'magic' key"
|
||||
assert captured_sell_request["magic"] == Config.MAGIC_NUMBER, (
|
||||
f"sell() magic field must equal Config.MAGIC_NUMBER={Config.MAGIC_NUMBER}, "
|
||||
f"got {captured_sell_request['magic']} (symbol={symbol}, lot={lot})"
|
||||
)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""
|
||||
tests/unit/test_trader.py — MT5Trader 单元测试
|
||||
|
||||
涵盖:
|
||||
- 订单被拒绝(retcode != 10009)→ buy()/sell() 返回 False(Req 6.5)
|
||||
- 账户信息查询失败 → buy()/sell() 中止并返回 False,且 order_send() 不被调用(Req 6.7)
|
||||
- 订单成功(retcode == 10009)→ buy()/sell() 返回 True(Req 6.5)
|
||||
"""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import execution.trader as trader_module
|
||||
from execution.trader import MT5Trader
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 辅助:构建一个符合 MT5 接口规范的 mock mt5 对象
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_mock_mt5(
|
||||
account_equity: float = 10000.0,
|
||||
account_margin_free: float = 5000.0,
|
||||
retcode: int = 10009,
|
||||
order_ticket: int = 123456,
|
||||
account_returns_none: bool = False,
|
||||
) -> MagicMock:
|
||||
"""构建模拟的 MetaTrader5 模块对象。
|
||||
|
||||
Args:
|
||||
account_equity: account_info().equity 的值
|
||||
account_margin_free: account_info().margin_free 的值
|
||||
retcode: order_send().retcode 的值
|
||||
order_ticket: order_send().order 的值
|
||||
account_returns_none: 若为 True,则 account_info() 返回 None
|
||||
"""
|
||||
mock_mt5 = MagicMock()
|
||||
|
||||
# MT5 常量(trader.py 直接引用,必须作为属性存在)
|
||||
mock_mt5.ORDER_TYPE_BUY = 0
|
||||
mock_mt5.ORDER_TYPE_SELL = 1
|
||||
mock_mt5.TRADE_ACTION_DEAL = 1
|
||||
|
||||
# account_info()
|
||||
if account_returns_none:
|
||||
mock_mt5.account_info.return_value = None
|
||||
else:
|
||||
acct = MagicMock()
|
||||
acct.equity = account_equity
|
||||
acct.margin_free = account_margin_free
|
||||
mock_mt5.account_info.return_value = acct
|
||||
|
||||
# order_send()
|
||||
result = MagicMock()
|
||||
result.retcode = retcode
|
||||
result.order = order_ticket
|
||||
result.comment = ""
|
||||
mock_mt5.order_send.return_value = result
|
||||
|
||||
return mock_mt5
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 测试 buy() — 订单被拒绝(retcode != 10009)→ 返回 False(Req 6.5)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_buy_order_rejected_returns_false():
|
||||
"""retcode=10006(TRADE_RETCODE_REJECT)时 buy() 必须返回 False。"""
|
||||
mock_mt5 = _make_mock_mt5(retcode=10006)
|
||||
|
||||
with patch.object(trader_module, "mt5", mock_mt5):
|
||||
trader = MT5Trader()
|
||||
result = trader.buy("XAUUSD", 0.01)
|
||||
|
||||
assert result is False, "buy() 在 retcode!=10009 时应返回 False"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 测试 buy() — 账户信息查询失败 → 中止下单,order_send() 不被调用(Req 6.7)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_buy_account_info_fails_aborts_order():
|
||||
"""account_info() 返回 None 时,buy() 应中止并返回 False,且不调用 order_send()。"""
|
||||
mock_mt5 = _make_mock_mt5(account_returns_none=True)
|
||||
|
||||
with patch.object(trader_module, "mt5", mock_mt5):
|
||||
trader = MT5Trader()
|
||||
result = trader.buy("XAUUSD", 0.01)
|
||||
|
||||
assert result is False, "account_info()=None 时 buy() 应返回 False"
|
||||
mock_mt5.order_send.assert_not_called()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 测试 sell() — 订单被拒绝(retcode != 10009)→ 返回 False(Req 6.5)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_sell_order_rejected_returns_false():
|
||||
"""retcode=10006 时 sell() 必须返回 False。"""
|
||||
mock_mt5 = _make_mock_mt5(retcode=10006)
|
||||
|
||||
with patch.object(trader_module, "mt5", mock_mt5):
|
||||
trader = MT5Trader()
|
||||
result = trader.sell("XAUUSD", 0.01)
|
||||
|
||||
assert result is False, "sell() 在 retcode!=10009 时应返回 False"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 测试 sell() — 账户信息查询失败 → 中止下单,order_send() 不被调用(Req 6.7)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_sell_account_info_fails_aborts_order():
|
||||
"""account_info() 返回 None 时,sell() 应中止并返回 False,且不调用 order_send()。"""
|
||||
mock_mt5 = _make_mock_mt5(account_returns_none=True)
|
||||
|
||||
with patch.object(trader_module, "mt5", mock_mt5):
|
||||
trader = MT5Trader()
|
||||
result = trader.sell("XAUUSD", 0.01)
|
||||
|
||||
assert result is False, "account_info()=None 时 sell() 应返回 False"
|
||||
mock_mt5.order_send.assert_not_called()
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 测试 buy() — 订单成功(retcode == 10009)→ 返回 True(Req 6.5)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_buy_order_success_returns_true():
|
||||
"""retcode=10009(TRADE_RETCODE_DONE)时 buy() 必须返回 True。"""
|
||||
mock_mt5 = _make_mock_mt5(retcode=10009, order_ticket=123456)
|
||||
|
||||
with patch.object(trader_module, "mt5", mock_mt5):
|
||||
trader = MT5Trader()
|
||||
result = trader.buy("XAUUSD", 0.01)
|
||||
|
||||
assert result is True, "buy() 在 retcode==10009 时应返回 True"
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# 测试 sell() — 订单成功(retcode == 10009)→ 返回 True(Req 6.5)
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_sell_order_success_returns_true():
|
||||
"""retcode=10009 时 sell() 必须返回 True。"""
|
||||
mock_mt5 = _make_mock_mt5(retcode=10009, order_ticket=789012)
|
||||
|
||||
with patch.object(trader_module, "mt5", mock_mt5):
|
||||
trader = MT5Trader()
|
||||
result = trader.sell("XAUUSD", 0.01)
|
||||
|
||||
assert result is True, "sell() 在 retcode==10009 时应返回 True"
|
||||
+95
-1
@@ -37,10 +37,18 @@ from web.server_log import (
|
||||
setup_logging,
|
||||
)
|
||||
from web.settings import load_settings, save_settings
|
||||
from web.strategy_file import inspect_strategy_file, resolve_strategy_file, sync_best_strategy_for_symbol
|
||||
from web.strategy_file import (
|
||||
inspect_strategy_file,
|
||||
resolve_strategy_file,
|
||||
strategy_path_for_symbol,
|
||||
sync_best_strategy_for_symbol,
|
||||
)
|
||||
from web.training_manager import training_manager
|
||||
from web.training_package import build_training_export_zip, import_training_package
|
||||
from web.backtest_manager import backtest_manager
|
||||
from web.realtime_manager import realtime_manager
|
||||
from web.data_sources.factory import list_sources
|
||||
from strategy_manager.live_signal import min_exposure
|
||||
|
||||
STATIC_DIR = Path(__file__).resolve().parent / "static"
|
||||
BACKTEST_OUTPUT_DIR = ROOT / "backtest_output"
|
||||
@@ -89,6 +97,17 @@ class StartBacktestRequest(BaseModel):
|
||||
slippage_pct: float | None = None
|
||||
|
||||
|
||||
class AddWatchRequest(BaseModel):
|
||||
source: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
strategy_file: str
|
||||
|
||||
|
||||
class RemoveWatchRequest(BaseModel):
|
||||
id: str
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def log_requests(request: Request, call_next):
|
||||
started = time.perf_counter()
|
||||
@@ -829,6 +848,81 @@ def api_backtest_chart(name: str):
|
||||
return FileResponse(path, media_type="image/png")
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# 实时行情分析 API
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
def _startup_realtime() -> None:
|
||||
try:
|
||||
realtime_manager.load_persisted()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
log_error("realtime load_persisted failed", exc)
|
||||
|
||||
|
||||
@app.get("/api/realtime/sources")
|
||||
def api_realtime_sources() -> dict[str, Any]:
|
||||
return {"sources": list_sources(), "min_exposure": min_exposure()}
|
||||
|
||||
|
||||
@app.get("/api/realtime/strategies")
|
||||
def api_realtime_strategies() -> dict[str, Any]:
|
||||
"""已保存的 best_*.json 策略,供因子来源下拉。"""
|
||||
rows = []
|
||||
for s in list_strategies():
|
||||
sym = s.get("symbol")
|
||||
if not sym:
|
||||
continue
|
||||
path = strategy_path_for_symbol(sym)
|
||||
if not path.exists():
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"symbol": sym,
|
||||
"timeframe": s.get("timeframe"),
|
||||
"best_score": s.get("best_score"),
|
||||
"formula_decoded": s.get("formula_decoded"),
|
||||
"strategy_file": str(path.resolve()),
|
||||
}
|
||||
)
|
||||
return {"strategies": rows}
|
||||
|
||||
|
||||
@app.get("/api/realtime/status")
|
||||
def api_realtime_status() -> dict[str, Any]:
|
||||
return realtime_manager.status()
|
||||
|
||||
|
||||
@app.post("/api/realtime/watch")
|
||||
def api_realtime_watch(req: AddWatchRequest) -> dict[str, Any]:
|
||||
try:
|
||||
watch = realtime_manager.add_watch(
|
||||
req.source, req.symbol, req.timeframe, req.strategy_file
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
return {"ok": True, "watch": watch}
|
||||
|
||||
|
||||
@app.post("/api/realtime/unwatch")
|
||||
def api_realtime_unwatch(req: RemoveWatchRequest) -> dict[str, Any]:
|
||||
removed = realtime_manager.remove_watch(req.id)
|
||||
return {"ok": removed}
|
||||
|
||||
|
||||
@app.post("/api/realtime/start")
|
||||
def api_realtime_start() -> dict[str, Any]:
|
||||
realtime_manager.start()
|
||||
return {"ok": True, **realtime_manager.status()}
|
||||
|
||||
|
||||
@app.post("/api/realtime/stop")
|
||||
def api_realtime_stop() -> dict[str, Any]:
|
||||
realtime_manager.stop()
|
||||
return {"ok": True, "running": False}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index() -> FileResponse:
|
||||
return FileResponse(STATIC_DIR / "index.html")
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""实时行情数据源包。"""
|
||||
@@ -0,0 +1,87 @@
|
||||
"""实时行情数据源抽象层。
|
||||
|
||||
统一接口,供 realtime_manager 拉取 K 线并转换为 AlphaMaster 特征引擎所需的
|
||||
raw_dict(torch 张量 [1, T],升序=最旧在前)。
|
||||
|
||||
参考 PA_Agent 的 DataSource 设计,但做成自包含、可选依赖优雅降级。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
# 项目统一的周期字符串(各源在内部映射到自己的常量)
|
||||
CANON_TIMEFRAMES: tuple[str, ...] = ("1m", "5m", "15m", "30m", "1h", "4h", "1d", "1w", "1M")
|
||||
|
||||
|
||||
class DataSourceError(Exception):
|
||||
"""数据源通用错误。"""
|
||||
|
||||
|
||||
class DataSourceUnavailable(DataSourceError):
|
||||
"""依赖缺失或无法连接(前端据此灰显该源)。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Bar:
|
||||
"""单根 K 线(ts = 开盘时间的 Unix 秒)。"""
|
||||
ts: int
|
||||
open: float
|
||||
high: float
|
||||
low: float
|
||||
close: float
|
||||
volume: float
|
||||
|
||||
|
||||
class DataSource(ABC):
|
||||
"""K 线数据源统一接口。"""
|
||||
|
||||
kind: str = ""
|
||||
label: str = ""
|
||||
|
||||
@abstractmethod
|
||||
def available(self) -> tuple[bool, str]:
|
||||
"""返回 (是否可用, 提示文案)。用于前端灰显与安装引导。"""
|
||||
|
||||
@abstractmethod
|
||||
def supported_timeframes(self) -> list[str]:
|
||||
"""返回该源支持的周期字符串列表(CANON_TIMEFRAMES 子集)。"""
|
||||
|
||||
@abstractmethod
|
||||
def preset_symbols(self) -> list[str]:
|
||||
"""返回预设/常用品种列表(不阻塞网络)。"""
|
||||
|
||||
@abstractmethod
|
||||
def fetch_bars(
|
||||
self, symbol: str, timeframe: str, n: int, drop_forming: bool = True
|
||||
) -> list[Bar]:
|
||||
"""拉取最近 n 根已收盘 K 线,升序(最旧在前)。
|
||||
|
||||
drop_forming=True 时剔除当前正在形成的 bar,使最后一根为「最后已收盘 bar」。
|
||||
"""
|
||||
|
||||
def connect(self) -> None: # noqa: B027 - 可选
|
||||
"""建立/复用连接(可选)。"""
|
||||
|
||||
def disconnect(self) -> None: # noqa: B027 - 可选
|
||||
"""断开连接(可选)。"""
|
||||
|
||||
|
||||
def bars_to_raw_dict(bars: list[Bar]):
|
||||
"""将升序 Bar 列表转换为 AlphaMaster 特征引擎所需的 raw_dict(torch [1, T])。"""
|
||||
import torch
|
||||
|
||||
if not bars:
|
||||
raise DataSourceError("空 K 线序列")
|
||||
|
||||
def col(vals: list[float]):
|
||||
return torch.tensor([vals], dtype=torch.float32)
|
||||
|
||||
return {
|
||||
"open": col([b.open for b in bars]),
|
||||
"high": col([b.high for b in bars]),
|
||||
"low": col([b.low for b in bars]),
|
||||
"close": col([b.close for b in bars]),
|
||||
"volume": col([max(b.volume, 0.0) for b in bars]),
|
||||
"time": col([float(b.ts) for b in bars]),
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
"""东方财富数据源(A 股 / 指数,直连 push2his HTTP API,无需第三方库)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from web.data_sources.base import Bar, DataSource, DataSourceUnavailable
|
||||
|
||||
# 项目周期 -> 东财 klt
|
||||
_KLT = {
|
||||
"1m": "1",
|
||||
"5m": "5",
|
||||
"15m": "15",
|
||||
"30m": "30",
|
||||
"1h": "60",
|
||||
"1d": "101",
|
||||
"1w": "102",
|
||||
"1M": "103",
|
||||
}
|
||||
|
||||
_PRESETS = ["600519", "000001", "300750", "601318", "000858", "sh000300", "sz399006"]
|
||||
|
||||
_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||
|
||||
|
||||
def _secid(symbol: str) -> str:
|
||||
"""把 A 股代码转成东财 secid(1.=上交所, 0.=深交所)。"""
|
||||
s = symbol.strip().lower()
|
||||
if s.startswith("sh"):
|
||||
return f"1.{s[2:]}"
|
||||
if s.startswith("sz"):
|
||||
return f"0.{s[2:]}"
|
||||
code = s
|
||||
if not code.isdigit() or len(code) != 6:
|
||||
raise DataSourceUnavailable("A 股代码需为 6 位数字,或 sh/sz 前缀的指数代码")
|
||||
# 6/5/9 开头(含指数 000xxx 需前缀 sh)→ 上交所;0/3 → 深交所
|
||||
if code[0] in ("6", "5", "9") or code.startswith("11") or code.startswith("13"):
|
||||
return f"1.{code}"
|
||||
return f"0.{code}"
|
||||
|
||||
|
||||
def _parse_dt(s: str) -> int:
|
||||
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d"):
|
||||
try:
|
||||
return int(datetime.strptime(s, fmt).replace(tzinfo=timezone.utc).timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
class EastMoneySource(DataSource):
|
||||
kind = "eastmoney"
|
||||
label = "东方财富"
|
||||
|
||||
def available(self) -> tuple[bool, str]:
|
||||
try:
|
||||
import requests # noqa: F401
|
||||
except ImportError:
|
||||
return (False, "未安装 requests:pip install requests")
|
||||
return (True, "A 股 / 指数 · 6 位代码或 sh/sz 指数")
|
||||
|
||||
def supported_timeframes(self) -> list[str]:
|
||||
return list(_KLT.keys())
|
||||
|
||||
def preset_symbols(self) -> list[str]:
|
||||
return list(_PRESETS)
|
||||
|
||||
def fetch_bars(
|
||||
self, symbol: str, timeframe: str, n: int, drop_forming: bool = True
|
||||
) -> list[Bar]:
|
||||
if timeframe not in _KLT:
|
||||
raise DataSourceUnavailable(f"东方财富不支持周期 {timeframe}")
|
||||
try:
|
||||
import requests
|
||||
except ImportError as exc:
|
||||
raise DataSourceUnavailable("未安装 requests") from exc
|
||||
|
||||
secid = _secid(symbol)
|
||||
lmt = n + 2 if drop_forming else n + 1
|
||||
params = {
|
||||
"secid": secid,
|
||||
"klt": _KLT[timeframe],
|
||||
"fqt": "1", # 前复权
|
||||
"fields1": "f1,f2,f3,f4,f5",
|
||||
"fields2": "f51,f52,f53,f54,f55,f56,f57",
|
||||
"beg": "0",
|
||||
"end": "20500101",
|
||||
"lmt": str(lmt),
|
||||
}
|
||||
headers = {"User-Agent": "Mozilla/5.0", "Referer": "https://quote.eastmoney.com/"}
|
||||
try:
|
||||
resp = requests.get(_KLINE_URL, params=params, headers=headers, timeout=8)
|
||||
resp.raise_for_status()
|
||||
payload = resp.json()
|
||||
except Exception as exc:
|
||||
raise DataSourceUnavailable(f"东方财富拉取失败: {exc}") from exc
|
||||
|
||||
klines = (payload.get("data") or {}).get("klines") or []
|
||||
if not klines:
|
||||
raise DataSourceUnavailable(f"东方财富无数据:{symbol}")
|
||||
|
||||
bars: list[Bar] = []
|
||||
for line in klines:
|
||||
# date,open,close,high,low,volume,amount
|
||||
parts = line.split(",")
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=_parse_dt(parts[0]),
|
||||
open=float(parts[1]),
|
||||
close=float(parts[2]),
|
||||
high=float(parts[3]),
|
||||
low=float(parts[4]),
|
||||
volume=float(parts[5]),
|
||||
)
|
||||
)
|
||||
# 已升序;剔除最后一根(盘中为正在形成的 bar)
|
||||
if drop_forming and len(bars) > 1:
|
||||
bars = bars[:-1]
|
||||
return bars[-n:]
|
||||
@@ -0,0 +1,66 @@
|
||||
"""数据源工厂 + 单例注册(复用连接)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from web.data_sources.base import DataSource
|
||||
|
||||
SOURCE_KINDS: tuple[tuple[str, str], ...] = (
|
||||
("mt5", "MT5"),
|
||||
("tradingview", "TradingView"),
|
||||
("eastmoney", "东方财富"),
|
||||
("tongdaxin", "通达信"),
|
||||
)
|
||||
|
||||
_INSTANCES: dict[str, DataSource] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def _build(kind: str) -> DataSource:
|
||||
if kind == "mt5":
|
||||
from web.data_sources.mt5_source import MT5Source
|
||||
return MT5Source()
|
||||
if kind == "tradingview":
|
||||
from web.data_sources.tradingview_source import TradingViewSource
|
||||
return TradingViewSource()
|
||||
if kind == "eastmoney":
|
||||
from web.data_sources.eastmoney_source import EastMoneySource
|
||||
return EastMoneySource()
|
||||
if kind == "tongdaxin":
|
||||
from web.data_sources.tongdaxin_source import TongdaxinSource
|
||||
return TongdaxinSource()
|
||||
raise ValueError(f"未知数据源: {kind}")
|
||||
|
||||
|
||||
def get_source(kind: str) -> DataSource:
|
||||
"""返回该 kind 的单例数据源(懒创建,复用连接)。"""
|
||||
with _LOCK:
|
||||
inst = _INSTANCES.get(kind)
|
||||
if inst is None:
|
||||
inst = _build(kind)
|
||||
_INSTANCES[kind] = inst
|
||||
return inst
|
||||
|
||||
|
||||
def list_sources() -> list[dict]:
|
||||
"""列出所有数据源及其可用状态(供前端灰显/引导)。"""
|
||||
out = []
|
||||
for kind, label in SOURCE_KINDS:
|
||||
try:
|
||||
src = get_source(kind)
|
||||
ok, hint = src.available()
|
||||
tfs = src.supported_timeframes()
|
||||
presets = src.preset_symbols()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
ok, hint, tfs, presets = False, str(exc), [], []
|
||||
out.append(
|
||||
{
|
||||
"id": kind,
|
||||
"label": label,
|
||||
"available": ok,
|
||||
"hint": hint,
|
||||
"timeframes": tfs,
|
||||
"presets": presets,
|
||||
}
|
||||
)
|
||||
return out
|
||||
@@ -0,0 +1,115 @@
|
||||
"""MT5 数据源(MetaTrader5 终端,需已登录运行)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from web.data_sources.base import Bar, DataSource, DataSourceUnavailable
|
||||
|
||||
_TF = {
|
||||
"1m": "TIMEFRAME_M1",
|
||||
"5m": "TIMEFRAME_M5",
|
||||
"15m": "TIMEFRAME_M15",
|
||||
"30m": "TIMEFRAME_M30",
|
||||
"1h": "TIMEFRAME_H1",
|
||||
"4h": "TIMEFRAME_H4",
|
||||
"1d": "TIMEFRAME_D1",
|
||||
"1w": "TIMEFRAME_W1",
|
||||
"1M": "TIMEFRAME_MN1",
|
||||
}
|
||||
|
||||
_PRESETS = [
|
||||
"XAUUSD", "XAGUSD", "EURUSD", "USDJPY", "GBPUSD",
|
||||
"US30.cash", "US100.cash", "US500.cash", "US2000.cash", "JP225.cash",
|
||||
]
|
||||
|
||||
|
||||
class MT5Source(DataSource):
|
||||
kind = "mt5"
|
||||
label = "MT5"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._connected = False
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def available(self) -> tuple[bool, str]:
|
||||
try:
|
||||
import MetaTrader5 # noqa: F401
|
||||
except ImportError:
|
||||
return (False, "未安装 MetaTrader5:pip install MetaTrader5")
|
||||
return (True, "需 MT5 终端已登录运行")
|
||||
|
||||
def supported_timeframes(self) -> list[str]:
|
||||
return list(_TF.keys())
|
||||
|
||||
def preset_symbols(self) -> list[str]:
|
||||
return list(_PRESETS)
|
||||
|
||||
def connect(self) -> None:
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
except ImportError as exc:
|
||||
raise DataSourceUnavailable("未安装 MetaTrader5") from exc
|
||||
if self._connected:
|
||||
return
|
||||
if not mt5.initialize():
|
||||
raise DataSourceUnavailable(
|
||||
f"MT5 初始化失败 {mt5.last_error()};请确认终端已打开并登录"
|
||||
)
|
||||
self._connected = True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._connected:
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
mt5.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
self._connected = False
|
||||
|
||||
def fetch_bars(
|
||||
self, symbol: str, timeframe: str, n: int, drop_forming: bool = True
|
||||
) -> list[Bar]:
|
||||
if timeframe not in _TF:
|
||||
raise DataSourceUnavailable(f"MT5 不支持周期 {timeframe}")
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
except ImportError as exc:
|
||||
raise DataSourceUnavailable("未安装 MetaTrader5") from exc
|
||||
|
||||
with self._lock:
|
||||
self.connect()
|
||||
tf_const = getattr(mt5, _TF[timeframe])
|
||||
try:
|
||||
mt5.symbol_select(symbol, True)
|
||||
except Exception:
|
||||
pass
|
||||
fetch_n = n + 1 if drop_forming else n
|
||||
rates = mt5.copy_rates_from_pos(symbol, tf_const, 0, fetch_n)
|
||||
|
||||
if rates is None or len(rates) == 0:
|
||||
raise DataSourceUnavailable(
|
||||
f"MT5 无法获取 {symbol} {timeframe} 数据 {mt5.last_error()}"
|
||||
)
|
||||
|
||||
# copy_rates_from_pos 返回升序,最后一根为正在形成的 bar
|
||||
rows = list(rates)
|
||||
if drop_forming and len(rows) > 1:
|
||||
rows = rows[:-1]
|
||||
|
||||
bars: list[Bar] = []
|
||||
for r in rows:
|
||||
try:
|
||||
vol = float(r["tick_volume"])
|
||||
except Exception:
|
||||
vol = float(r["real_volume"]) if "real_volume" in r.dtype.names else 0.0
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=int(r["time"]),
|
||||
open=float(r["open"]),
|
||||
high=float(r["high"]),
|
||||
low=float(r["low"]),
|
||||
close=float(r["close"]),
|
||||
volume=vol,
|
||||
)
|
||||
)
|
||||
return bars
|
||||
@@ -0,0 +1,137 @@
|
||||
"""通达信数据源(pytdx,免费行情服务器,A 股 / 指数)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from web.data_sources.base import Bar, DataSource, DataSourceUnavailable
|
||||
|
||||
# 通达信行情服务器(多个备选)
|
||||
_SERVERS = [
|
||||
("115.238.90.165", 7709),
|
||||
("180.153.18.170", 7709),
|
||||
("119.147.212.81", 7709),
|
||||
("14.17.75.71", 7709),
|
||||
("59.173.18.77", 7709),
|
||||
]
|
||||
|
||||
# 项目周期 -> pytdx category
|
||||
_CAT = {
|
||||
"1m": 8,
|
||||
"5m": 0,
|
||||
"15m": 1,
|
||||
"30m": 2,
|
||||
"1h": 3,
|
||||
"1d": 9,
|
||||
"1w": 5,
|
||||
"1M": 6,
|
||||
}
|
||||
|
||||
_PRESETS = ["600519", "000001", "300750", "601318", "000858", "sh000001", "sz399006"]
|
||||
|
||||
|
||||
def _parse_market(code: str) -> tuple[int, str]:
|
||||
"""返回 (market, pure_code)。1=上海, 0=深圳。"""
|
||||
c = code.strip().upper()
|
||||
if c.startswith("SH"):
|
||||
return 1, c[2:]
|
||||
if c.startswith("SZ"):
|
||||
return 0, c[2:]
|
||||
if c[:1] in ("6", "5", "9") or c.startswith("11") or c.startswith("13"):
|
||||
return 1, c
|
||||
return 0, c
|
||||
|
||||
|
||||
def _parse_dt(s: str) -> int:
|
||||
for fmt in ("%Y-%m-%d %H:%M", "%Y-%m-%d %H:%M:%S", "%Y-%m-%d"):
|
||||
try:
|
||||
return int(datetime.strptime(str(s), fmt).replace(tzinfo=timezone.utc).timestamp())
|
||||
except ValueError:
|
||||
continue
|
||||
return 0
|
||||
|
||||
|
||||
class TongdaxinSource(DataSource):
|
||||
kind = "tongdaxin"
|
||||
label = "通达信"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._api = None
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def available(self) -> tuple[bool, str]:
|
||||
try:
|
||||
import pytdx # noqa: F401
|
||||
except ImportError:
|
||||
return (False, "未安装 pytdx:pip install pytdx")
|
||||
return (True, "免费行情服务器 · A 股 / 指数")
|
||||
|
||||
def supported_timeframes(self) -> list[str]:
|
||||
return list(_CAT.keys())
|
||||
|
||||
def preset_symbols(self) -> list[str]:
|
||||
return list(_PRESETS)
|
||||
|
||||
def connect(self) -> None:
|
||||
if self._api is not None:
|
||||
return
|
||||
try:
|
||||
from pytdx.hq import TdxHq_API
|
||||
except ImportError as exc:
|
||||
raise DataSourceUnavailable("未安装 pytdx") from exc
|
||||
api = TdxHq_API()
|
||||
for host, port in _SERVERS:
|
||||
try:
|
||||
if api.connect(host, port):
|
||||
self._api = api
|
||||
return
|
||||
except Exception:
|
||||
continue
|
||||
raise DataSourceUnavailable("通达信所有行情服务器连接失败")
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._api is not None:
|
||||
try:
|
||||
self._api.disconnect()
|
||||
except Exception:
|
||||
pass
|
||||
self._api = None
|
||||
|
||||
def fetch_bars(
|
||||
self, symbol: str, timeframe: str, n: int, drop_forming: bool = True
|
||||
) -> list[Bar]:
|
||||
if timeframe not in _CAT:
|
||||
raise DataSourceUnavailable(f"通达信不支持周期 {timeframe}")
|
||||
market, code = _parse_market(symbol)
|
||||
cat = _CAT[timeframe]
|
||||
want = min(max(n + 2, 20), 800) # 单次上限 800
|
||||
|
||||
with self._lock:
|
||||
self.connect()
|
||||
try:
|
||||
raw = self._api.get_security_bars(cat, market, code, 0, want)
|
||||
except Exception as exc:
|
||||
# 连接可能失效,重连一次
|
||||
self._api = None
|
||||
self.connect()
|
||||
raw = self._api.get_security_bars(cat, market, code, 0, want)
|
||||
|
||||
if not raw:
|
||||
raise DataSourceUnavailable(f"通达信无数据:{symbol}")
|
||||
|
||||
bars: list[Bar] = []
|
||||
for r in raw:
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=_parse_dt(r.get("datetime", "")),
|
||||
open=float(r["open"]),
|
||||
high=float(r["high"]),
|
||||
low=float(r["low"]),
|
||||
close=float(r["close"]),
|
||||
volume=float(r.get("vol", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
bars.sort(key=lambda b: b.ts) # 保证升序
|
||||
if drop_forming and len(bars) > 1:
|
||||
bars = bars[:-1]
|
||||
return bars[-n:]
|
||||
@@ -0,0 +1,142 @@
|
||||
"""TradingView 数据源(tvdatafeed,匿名可用)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from web.data_sources.base import Bar, DataSource, DataSourceUnavailable
|
||||
|
||||
_TF = {
|
||||
"1m": "in_1_minute",
|
||||
"5m": "in_5_minute",
|
||||
"15m": "in_15_minute",
|
||||
"30m": "in_30_minute",
|
||||
"1h": "in_1_hour",
|
||||
"4h": "in_4_hour",
|
||||
"1d": "in_daily",
|
||||
"1w": "in_weekly",
|
||||
"1M": "in_monthly",
|
||||
}
|
||||
|
||||
# 自动探测交易所(symbol 未显式带 EXCHANGE: 前缀时)
|
||||
_PROBE_EXCHANGES = ["", "OANDA", "FX_IDC", "TVC", "NASDAQ", "NYSE", "SSE", "SZSE", "BINANCE"]
|
||||
|
||||
_PRESETS = ["XAUUSD", "EURUSD", "GBPUSD", "USDJPY", "NASDAQ:AAPL", "SSE:600519", "BINANCE:BTCUSDT"]
|
||||
|
||||
|
||||
class TradingViewSource(DataSource):
|
||||
kind = "tradingview"
|
||||
label = "TradingView"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._tv = None
|
||||
self._lock = threading.Lock()
|
||||
self._exchange_cache: dict[str, str] = {}
|
||||
|
||||
def available(self) -> tuple[bool, str]:
|
||||
try:
|
||||
import tvDatafeed # noqa: F401
|
||||
except ImportError:
|
||||
return (
|
||||
False,
|
||||
"未安装 tvdatafeed:pip install git+https://github.com/rongardF/tvdatafeed.git",
|
||||
)
|
||||
return (True, "匿名访问 · 交易所自动探测")
|
||||
|
||||
def supported_timeframes(self) -> list[str]:
|
||||
return list(_TF.keys())
|
||||
|
||||
def preset_symbols(self) -> list[str]:
|
||||
return list(_PRESETS)
|
||||
|
||||
def connect(self) -> None:
|
||||
if self._tv is not None:
|
||||
return
|
||||
try:
|
||||
from tvDatafeed import TvDatafeed
|
||||
except ImportError as exc:
|
||||
raise DataSourceUnavailable("未安装 tvdatafeed") from exc
|
||||
try:
|
||||
self._tv = TvDatafeed()
|
||||
try:
|
||||
setattr(self._tv, "_TvDatafeed__ws_timeout", 10.0)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as exc:
|
||||
raise DataSourceUnavailable(f"TradingView 连接失败: {exc}") from exc
|
||||
|
||||
def _split_symbol(self, symbol: str) -> tuple[str, str | None]:
|
||||
s = symbol.strip()
|
||||
if ":" in s:
|
||||
ex, code = s.split(":", 1)
|
||||
return code.strip(), ex.strip().upper()
|
||||
return s, None
|
||||
|
||||
def fetch_bars(
|
||||
self, symbol: str, timeframe: str, n: int, drop_forming: bool = True
|
||||
) -> list[Bar]:
|
||||
if timeframe not in _TF:
|
||||
raise DataSourceUnavailable(f"TradingView 不支持周期 {timeframe}")
|
||||
try:
|
||||
from tvDatafeed import Interval
|
||||
except ImportError as exc:
|
||||
raise DataSourceUnavailable("未安装 tvdatafeed") from exc
|
||||
|
||||
code, exchange = self._split_symbol(symbol)
|
||||
interval = getattr(Interval, _TF[timeframe])
|
||||
want = n + 1 if drop_forming else n
|
||||
|
||||
with self._lock:
|
||||
self.connect()
|
||||
df = None
|
||||
if exchange is not None:
|
||||
df = self._get_hist(code, exchange, interval, want)
|
||||
else:
|
||||
cached = self._exchange_cache.get(symbol)
|
||||
probe = [cached] + _PROBE_EXCHANGES if cached else _PROBE_EXCHANGES
|
||||
for ex in probe:
|
||||
df = self._get_hist(code, ex, interval, want)
|
||||
if df is not None and not df.empty:
|
||||
self._exchange_cache[symbol] = ex
|
||||
break
|
||||
|
||||
if df is None or df.empty:
|
||||
raise DataSourceUnavailable(
|
||||
f"TradingView 无数据:{symbol}(可用 EXCHANGE:CODE 指定交易所)"
|
||||
)
|
||||
|
||||
rows = list(df.itertuples(index=True))
|
||||
if drop_forming and len(rows) > 1:
|
||||
rows = rows[:-1]
|
||||
|
||||
bars: list[Bar] = []
|
||||
for row in rows:
|
||||
dt = row.Index
|
||||
ts = int(dt.timestamp()) if hasattr(dt, "timestamp") else 0
|
||||
bars.append(
|
||||
Bar(
|
||||
ts=ts,
|
||||
open=float(row.open),
|
||||
high=float(row.high),
|
||||
low=float(row.low),
|
||||
close=float(row.close),
|
||||
volume=float(getattr(row, "volume", 0.0) or 0.0),
|
||||
)
|
||||
)
|
||||
return bars
|
||||
|
||||
def _get_hist(self, code, exchange, interval, n):
|
||||
try:
|
||||
return self._tv.get_hist(
|
||||
symbol=code, exchange=exchange, interval=interval, n_bars=n
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
finally:
|
||||
# tvdatafeed 每次 get_hist 都新建 socket 且不关闭,主动关掉防泄漏
|
||||
ws = getattr(self._tv, "ws", None)
|
||||
if ws is not None:
|
||||
try:
|
||||
ws.close()
|
||||
self._tv.ws = None
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,310 @@
|
||||
"""实时信号引擎:多品种 / 多周期并发调度。
|
||||
|
||||
- 每个「监控项」= (数据源, 品种, 周期, 策略因子)。
|
||||
- 后台线程按周期自适应节奏轮询,出现新 bar 才重算,信号取最后已收盘 bar。
|
||||
- 共享 (源,品种,周期) 的 K 线抓取结果做短 TTL 缓存,避免重复请求。
|
||||
- 监控清单持久化到 web_settings,重启恢复。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from model_core.vocab import VOCAB_VERSION
|
||||
from strategy_manager.live_signal import evaluate_signal, min_exposure
|
||||
from web.data_sources.base import bars_to_raw_dict
|
||||
from web.data_sources.factory import SOURCE_KINDS, get_source
|
||||
from web.settings import load_settings, save_settings
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# 每个周期的轮询节奏(秒)
|
||||
_CADENCE = {
|
||||
"1m": 15, "5m": 30, "15m": 45, "30m": 60,
|
||||
"1h": 60, "4h": 120, "1d": 300, "1w": 600, "1M": 600,
|
||||
}
|
||||
_DEFAULT_CADENCE = 60
|
||||
_N_BARS = 500 # 每次拉取的历史 bar 数(喂给特征引擎)
|
||||
_HISTORY_LEN = 60 # 保留的信号强度历史点数(供 sparkline)
|
||||
_VALID_KINDS = {k for k, _ in SOURCE_KINDS}
|
||||
|
||||
|
||||
def _cadence_for(tf: str) -> int:
|
||||
return _CADENCE.get(tf, _DEFAULT_CADENCE)
|
||||
|
||||
|
||||
def _load_strategy_meta(path: str) -> dict[str, Any]:
|
||||
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if isinstance(data, list):
|
||||
return {"formula": data, "vocab_version": "legacy", "symbol": None, "timeframe": None, "best_score": None}
|
||||
return {
|
||||
"formula": data.get("formula"),
|
||||
"vocab_version": data.get("vocab_version"),
|
||||
"symbol": data.get("symbol"),
|
||||
"timeframe": data.get("timeframe"),
|
||||
"best_score": data.get("best_score"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class WatchTask:
|
||||
id: str
|
||||
source: str
|
||||
symbol: str
|
||||
timeframe: str
|
||||
strategy_file: str
|
||||
strategy_name: str
|
||||
formula: list[int]
|
||||
vocab_version: str | None
|
||||
strategy_symbol: str | None
|
||||
strategy_timeframe: str | None
|
||||
best_score: float | None
|
||||
cadence_s: int
|
||||
# 运行时状态
|
||||
state: str = "pending" # pending|ok|insufficient|error
|
||||
direction: str | None = None
|
||||
strength: float | None = None
|
||||
position: float | None = None
|
||||
factor_value: float | None = None
|
||||
bars_used: int | None = None
|
||||
last_bar_ts: int | None = None
|
||||
updated_at: float | None = None
|
||||
message: str = ""
|
||||
warn: str = ""
|
||||
next_due: float = 0.0
|
||||
history: deque = field(default_factory=lambda: deque(maxlen=_HISTORY_LEN))
|
||||
|
||||
def to_public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"source": self.source,
|
||||
"symbol": self.symbol,
|
||||
"timeframe": self.timeframe,
|
||||
"strategy_name": self.strategy_name,
|
||||
"strategy_symbol": self.strategy_symbol,
|
||||
"strategy_timeframe": self.strategy_timeframe,
|
||||
"best_score": self.best_score,
|
||||
"state": self.state,
|
||||
"direction": self.direction,
|
||||
"strength": self.strength,
|
||||
"position": self.position,
|
||||
"factor_value": self.factor_value,
|
||||
"bars_used": self.bars_used,
|
||||
"last_bar_ts": self.last_bar_ts,
|
||||
"updated_at": self.updated_at,
|
||||
"message": self.message,
|
||||
"warn": self.warn,
|
||||
"threshold": min_exposure(),
|
||||
"history": list(self.history),
|
||||
}
|
||||
|
||||
def persist_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"id": self.id,
|
||||
"source": self.source,
|
||||
"symbol": self.symbol,
|
||||
"timeframe": self.timeframe,
|
||||
"strategy_file": self.strategy_file,
|
||||
}
|
||||
|
||||
|
||||
class RealtimeManager:
|
||||
def __init__(self) -> None:
|
||||
self._tasks: dict[str, WatchTask] = {}
|
||||
self._lock = threading.RLock()
|
||||
self._running = False
|
||||
self._thread: threading.Thread | None = None
|
||||
self._executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="rt")
|
||||
self._inflight: set[str] = set()
|
||||
self._inflight_lock = threading.Lock()
|
||||
# K线缓存:(kind,symbol,tf) -> (monotonic_ts, bars)
|
||||
self._bar_cache: dict[tuple[str, str, str], tuple[float, list]] = {}
|
||||
self._loaded = False
|
||||
|
||||
# ── 持久化 ──────────────────────────────────────────────────────────
|
||||
def load_persisted(self) -> None:
|
||||
if self._loaded:
|
||||
return
|
||||
self._loaded = True
|
||||
watches = load_settings().get("realtime_watches") or []
|
||||
for w in watches:
|
||||
try:
|
||||
self._add_task_internal(
|
||||
w["source"], w["symbol"], w["timeframe"], w["strategy_file"],
|
||||
persist=False,
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
if self._tasks:
|
||||
self._ensure_thread()
|
||||
|
||||
def _persist(self) -> None:
|
||||
save_settings({"realtime_watches": [t.persist_dict() for t in self._tasks.values()]})
|
||||
|
||||
# ── 增删 ────────────────────────────────────────────────────────────
|
||||
def add_watch(self, source: str, symbol: str, timeframe: str, strategy_file: str) -> dict[str, Any]:
|
||||
task = self._add_task_internal(source, symbol, timeframe, strategy_file, persist=True)
|
||||
self._ensure_thread()
|
||||
return task.to_public()
|
||||
|
||||
def _add_task_internal(
|
||||
self, source: str, symbol: str, timeframe: str, strategy_file: str, persist: bool
|
||||
) -> WatchTask:
|
||||
source = (source or "").strip()
|
||||
symbol = (symbol or "").strip()
|
||||
timeframe = (timeframe or "").strip()
|
||||
if source not in _VALID_KINDS:
|
||||
raise ValueError(f"未知数据源: {source}")
|
||||
if not symbol:
|
||||
raise ValueError("请填写品种")
|
||||
src = get_source(source)
|
||||
if timeframe not in src.supported_timeframes():
|
||||
raise ValueError(f"{src.label} 不支持周期 {timeframe}")
|
||||
|
||||
path = strategy_file
|
||||
if not Path(path).is_absolute():
|
||||
path = str((PROJECT_ROOT / path).resolve())
|
||||
if not Path(path).exists():
|
||||
raise ValueError(f"策略文件不存在: {strategy_file}")
|
||||
meta = _load_strategy_meta(path)
|
||||
if not meta.get("formula"):
|
||||
raise ValueError("策略文件缺少 formula")
|
||||
|
||||
name = Path(path).stem
|
||||
task_id = f"{source}:{symbol}:{timeframe}:{name}"
|
||||
|
||||
warn = ""
|
||||
if meta.get("vocab_version") and meta["vocab_version"] not in (VOCAB_VERSION, "legacy"):
|
||||
warn = f"词表版本不符({meta['vocab_version']} vs {VOCAB_VERSION}),信号可能失真"
|
||||
elif meta.get("symbol") and meta["symbol"] != symbol:
|
||||
warn = f"该因子为 {meta['symbol']} 训练,跨品种运行仅供参考"
|
||||
|
||||
task = WatchTask(
|
||||
id=task_id,
|
||||
source=source,
|
||||
symbol=symbol,
|
||||
timeframe=timeframe,
|
||||
strategy_file=path,
|
||||
strategy_name=name,
|
||||
formula=[int(t) for t in meta["formula"]],
|
||||
vocab_version=meta.get("vocab_version"),
|
||||
strategy_symbol=meta.get("symbol"),
|
||||
strategy_timeframe=meta.get("timeframe"),
|
||||
best_score=meta.get("best_score"),
|
||||
cadence_s=_cadence_for(timeframe),
|
||||
warn=warn,
|
||||
next_due=0.0,
|
||||
)
|
||||
with self._lock:
|
||||
self._tasks[task_id] = task
|
||||
if persist:
|
||||
self._persist()
|
||||
return task
|
||||
|
||||
def remove_watch(self, task_id: str) -> bool:
|
||||
with self._lock:
|
||||
existed = self._tasks.pop(task_id, None) is not None
|
||||
if existed:
|
||||
self._persist()
|
||||
return existed
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._tasks.clear()
|
||||
self._persist()
|
||||
|
||||
# ── 状态 ────────────────────────────────────────────────────────────
|
||||
def status(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
watches = [t.to_public() for t in self._tasks.values()]
|
||||
return {"running": self._running, "count": len(watches), "watches": watches}
|
||||
|
||||
# ── 调度线程 ────────────────────────────────────────────────────────
|
||||
def start(self) -> None:
|
||||
self._ensure_thread()
|
||||
|
||||
def _ensure_thread(self) -> None:
|
||||
with self._lock:
|
||||
if self._running and self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._running = True
|
||||
self._thread = threading.Thread(target=self._loop, name="realtime", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._running = False
|
||||
|
||||
def _loop(self) -> None:
|
||||
while self._running:
|
||||
try:
|
||||
self._tick()
|
||||
except Exception:
|
||||
pass
|
||||
time.sleep(1.5)
|
||||
|
||||
def _tick(self) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
due = [t for t in self._tasks.values() if now >= t.next_due]
|
||||
for task in due:
|
||||
with self._inflight_lock:
|
||||
if task.id in self._inflight:
|
||||
continue
|
||||
self._inflight.add(task.id)
|
||||
# 预置下次到期,避免重复提交
|
||||
task.next_due = now + task.cadence_s
|
||||
self._executor.submit(self._evaluate_task, task)
|
||||
|
||||
def _get_bars(self, source: str, symbol: str, timeframe: str):
|
||||
"""带短 TTL 缓存的 K 线抓取(同一 源/品种/周期 的多因子复用)。"""
|
||||
key = (source, symbol, timeframe)
|
||||
ttl = max(10.0, _cadence_for(timeframe) * 0.8)
|
||||
now = time.monotonic()
|
||||
cached = self._bar_cache.get(key)
|
||||
if cached and (now - cached[0]) < ttl:
|
||||
return cached[1]
|
||||
src = get_source(source)
|
||||
bars = src.fetch_bars(symbol, timeframe, _N_BARS, drop_forming=True)
|
||||
self._bar_cache[key] = (now, bars)
|
||||
return bars
|
||||
|
||||
def _evaluate_task(self, task: WatchTask) -> None:
|
||||
try:
|
||||
bars = self._get_bars(task.source, task.symbol, task.timeframe)
|
||||
if not bars:
|
||||
self._set_error(task, "未获取到 K 线")
|
||||
return
|
||||
last_ts = bars[-1].ts
|
||||
raw = bars_to_raw_dict(bars)
|
||||
result = evaluate_signal(task.formula, raw)
|
||||
|
||||
task.state = result.get("state", "error")
|
||||
task.message = result.get("message", "")
|
||||
task.bars_used = result.get("bars_used", len(bars))
|
||||
task.last_bar_ts = last_ts
|
||||
task.updated_at = time.time()
|
||||
if task.state == "ok":
|
||||
task.direction = result["direction"]
|
||||
task.strength = result["strength"]
|
||||
task.position = result["position"]
|
||||
task.factor_value = result["factor_value"]
|
||||
task.history.append(round(result["strength"], 4))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self._set_error(task, str(exc))
|
||||
finally:
|
||||
with self._inflight_lock:
|
||||
self._inflight.discard(task.id)
|
||||
|
||||
def _set_error(self, task: WatchTask, message: str) -> None:
|
||||
task.state = "error"
|
||||
task.message = message
|
||||
task.updated_at = time.time()
|
||||
|
||||
|
||||
realtime_manager = RealtimeManager()
|
||||
+450
-45
@@ -14,6 +14,8 @@ let lastDebugViewContent = "";
|
||||
let currentPage = "train";
|
||||
let btActive = false;
|
||||
let btBuster = ""; // 图表缓存刷新键(用 job 时间戳)
|
||||
let btPortfolioSig = ""; // 绩效卡签名:变化时才重建 + 播放数字动画,避免每次轮询重播
|
||||
let lastEquityData = null; // 最近一次资金曲线数据,供绩效卡 sparkline 复用
|
||||
let lastTrainingActive = false;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -264,9 +266,8 @@ function updateFileProgress(progress) {
|
||||
}
|
||||
|
||||
const CHART_SERIES = [
|
||||
{ key: "best_score", label: "最优分数", borderColor: "#5eead4", fillRGB: "94, 234, 212", yAxisID: "y" },
|
||||
{ key: "val_score", label: "验证分数", borderColor: "#818cf8", fillRGB: "129, 140, 248", yAxisID: "y" },
|
||||
{ key: "entropy", label: "策略熵", borderColor: "#fbbf24", fillRGB: "251, 191, 36", yAxisID: "y1" },
|
||||
{ key: "best_score", label: "最优分数", borderColor: "#34f5c8", fillRGB: "52, 245, 200", yAxisID: "y" },
|
||||
{ key: "val_score", label: "验证分数", borderColor: "#38bdf8", fillRGB: "56, 189, 248", yAxisID: "y" },
|
||||
];
|
||||
|
||||
// 让曲线在填充区形成竖向渐变
|
||||
@@ -338,16 +339,11 @@ const CHART_OPTIONS = {
|
||||
border: { color: "rgba(120,190,235,0.12)" },
|
||||
},
|
||||
y: {
|
||||
title: { display: true, text: "分数(最优 / 验证)", color: "#7dd3fc", font: { family: "'DM Sans'", size: 10, weight: "600" } },
|
||||
ticks: { color: "#6b7d92", font: { family: "'JetBrains Mono'", size: 10 } },
|
||||
grid: { color: "rgba(120,190,235,0.05)" },
|
||||
border: { color: "rgba(120,190,235,0.12)" },
|
||||
},
|
||||
y1: {
|
||||
position: "right",
|
||||
ticks: { color: "#fbbf24", font: { family: "'JetBrains Mono'", size: 10 } },
|
||||
grid: { drawOnChartArea: false },
|
||||
border: { color: "rgba(251,191,36,0.2)" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -626,7 +622,7 @@ function updateAiChannelHint() {
|
||||
|
||||
if (resolved.provider === "deepseek") {
|
||||
if (headHint) headHint.textContent = "DeepSeek · deepseek-v4-flash";
|
||||
hint.textContent = "当前:DeepSeek(deepseek-v4-flash · https://api.deepseek.com)。也可在 Key 中输入 openclaw 或 openclaw_wb。";
|
||||
hint.textContent = "当前:DeepSeek(deepseek-v4-flash · https://api.deepseek.com)。";
|
||||
} else if (resolved.provider === "openclaw") {
|
||||
if (headHint) headHint.textContent = row?.available ? "openclaw (QClaw) · 已匹配" : "openclaw (QClaw) · 未就绪";
|
||||
hint.textContent = row?.hint || "已匹配 openclaw:将自动使用本地 QClaw token。";
|
||||
@@ -975,7 +971,7 @@ async function stopTraining() {
|
||||
// 分页切换
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
function switchPage(page) {
|
||||
if (page !== "train" && page !== "backtest") return;
|
||||
if (page !== "train" && page !== "backtest" && page !== "realtime") return;
|
||||
currentPage = page;
|
||||
document.querySelectorAll(".stepper .step").forEach((s) => {
|
||||
s.classList.toggle("active", s.dataset.page === page);
|
||||
@@ -986,6 +982,9 @@ function switchPage(page) {
|
||||
if (page === "backtest") {
|
||||
loadBacktestStrategyContext();
|
||||
refreshBacktest();
|
||||
} else if (page === "realtime") {
|
||||
initRealtimeOnce();
|
||||
refreshRealtime();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1092,12 +1091,15 @@ async function refreshBacktestReport() {
|
||||
}
|
||||
if (!data.available || !data.report) {
|
||||
if ($("btPortfolioHint")) $("btPortfolioHint").textContent = "尚未运行回测";
|
||||
lastEquityData = null;
|
||||
btPortfolioSig = "";
|
||||
renderEquity(null);
|
||||
return;
|
||||
}
|
||||
// 先取资金曲线(写入 lastEquityData),再渲染绩效卡,让 sparkline 用上真实数据
|
||||
await refreshEquityCurve();
|
||||
renderPortfolio(data.report);
|
||||
renderBacktestTable(data.report.symbols || {});
|
||||
await refreshEquityCurve();
|
||||
}
|
||||
|
||||
async function refreshEquityCurve() {
|
||||
@@ -1107,12 +1109,100 @@ async function refreshEquityCurve() {
|
||||
: "/api/backtest/equity";
|
||||
try {
|
||||
const data = await fetchJSON(url);
|
||||
lastEquityData = data?.available ? data.data : null;
|
||||
renderEquity(data);
|
||||
} catch (_) {
|
||||
lastEquityData = null;
|
||||
renderEquity(null);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 迷你 sparkline + 数字滚动动画(终端仪表盘质感)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
const METRIC_FMT = {
|
||||
pct: (v) => (v >= 0 ? "+" : "") + (v * 100).toFixed(2) + "%",
|
||||
signed: (v) => (v >= 0 ? "+" : "") + v.toFixed(3),
|
||||
ratio: (v) => v.toFixed(3),
|
||||
int: (v) => Math.round(v).toLocaleString(),
|
||||
winrate: (v) => (v * 100).toFixed(1) + "%",
|
||||
strength: (v) => Math.round(v * 100) + "%",
|
||||
};
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return !!(window.matchMedia && window.matchMedia("(prefers-reduced-motion: reduce)").matches);
|
||||
}
|
||||
|
||||
// 短促 count-up(≈420ms, easeOutCubic),克制不浮夸
|
||||
function animateCount(el, to, fmt) {
|
||||
const fn = METRIC_FMT[fmt] || ((v) => String(v));
|
||||
if (!Number.isFinite(to)) {
|
||||
el.textContent = "—";
|
||||
return;
|
||||
}
|
||||
if (prefersReducedMotion()) {
|
||||
el.textContent = fn(to);
|
||||
return;
|
||||
}
|
||||
const dur = 420;
|
||||
const t0 = performance.now();
|
||||
function frame(now) {
|
||||
const p = Math.min(1, (now - t0) / dur);
|
||||
const e = 1 - Math.pow(1 - p, 3); // easeOutCubic
|
||||
el.textContent = fn(to * e);
|
||||
if (p < 1) requestAnimationFrame(frame);
|
||||
else el.textContent = fn(to);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
function runCountUp(root) {
|
||||
if (!root) return;
|
||||
root.querySelectorAll("[data-count]").forEach((el) => {
|
||||
animateCount(el, parseFloat(el.dataset.count), el.dataset.fmt || "");
|
||||
});
|
||||
}
|
||||
|
||||
// 均匀降采样为 <= target 个有限点
|
||||
function downsampleSeries(arr, target) {
|
||||
const clean = (arr || [])
|
||||
.map(Number)
|
||||
.filter((v) => Number.isFinite(v));
|
||||
if (clean.length <= target) return clean;
|
||||
const out = [];
|
||||
const step = (clean.length - 1) / (target - 1);
|
||||
for (let i = 0; i < target; i++) out.push(clean[Math.round(i * step)]);
|
||||
return out;
|
||||
}
|
||||
|
||||
// 生成极小趋势微线(内联 SVG,轻量、清晰)
|
||||
function sparklineSVG(values, { color = "#5eead4", fillRGB = null, w = 74, h = 22 } = {}) {
|
||||
const v = downsampleSeries(values, 56);
|
||||
if (v.length < 2) return "";
|
||||
const min = Math.min(...v);
|
||||
const max = Math.max(...v);
|
||||
const range = max - min || 1;
|
||||
const n = v.length;
|
||||
const x = (i) => (i / (n - 1)) * w;
|
||||
const y = (val) => h - 2 - ((val - min) / range) * (h - 4);
|
||||
const line = "M" + v.map((val, i) => `${x(i).toFixed(1)} ${y(val).toFixed(1)}`).join(" L ");
|
||||
const area = fillRGB
|
||||
? `<path d="${line} L ${w} ${h} L 0 ${h} Z" fill="rgba(${fillRGB},0.14)" stroke="none"/>`
|
||||
: "";
|
||||
const dot = `<circle cx="${x(n - 1).toFixed(1)}" cy="${y(v[n - 1]).toFixed(1)}" r="1.6" fill="${color}"/>`;
|
||||
return `<svg class="spark-svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" aria-hidden="true">${area}<path d="${line}" fill="none" stroke="${color}" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round"/>${dot}</svg>`;
|
||||
}
|
||||
|
||||
// 取当前主资金曲线序列(组合优先,否则第一个品种)
|
||||
function mainEquitySeries() {
|
||||
const d = lastEquityData;
|
||||
if (!d) return null;
|
||||
if (d.portfolio) return d.portfolio;
|
||||
const syms = d.symbols || {};
|
||||
const names = Object.keys(syms);
|
||||
return names.length ? syms[names[0]] : null;
|
||||
}
|
||||
|
||||
function renderPortfolio(report) {
|
||||
const grid = $("btPortfolioGrid");
|
||||
if (!grid) return;
|
||||
@@ -1122,39 +1212,56 @@ function renderPortfolio(report) {
|
||||
|
||||
if (!Object.keys(p).length) {
|
||||
grid.innerHTML = '<div class="metric-empty">回测结果无绩效数据</div>';
|
||||
btPortfolioSig = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const plSrc = symData?.profit_loss_ratio ?? p.profit_loss_ratio;
|
||||
const plNum = Number(plSrc);
|
||||
const plText = Number.isFinite(plNum) ? plNum.toFixed(3) : "—";
|
||||
const plNum = Number(symData?.profit_loss_ratio ?? p.profit_loss_ratio);
|
||||
const nTrades = symData?.n_trades ?? p.n_trades;
|
||||
const winRate = symData?.win_rate;
|
||||
|
||||
// sparkline 数据源:主资金曲线 + 滚动夏普
|
||||
const eq = mainEquitySeries();
|
||||
const posColor = p.total_return >= 0 ? "#4ade80" : "#f87171";
|
||||
const posRGB = p.total_return >= 0 ? "74, 222, 128" : "248, 113, 113";
|
||||
const equitySpark = eq ? sparklineSVG(eq.equity, { color: posColor, fillRGB: posRGB }) : "";
|
||||
const rollSpark = eq ? sparklineSVG(eq.rolling_sharpe, { color: "#5eead4", fillRGB: "94, 234, 212" }) : "";
|
||||
|
||||
const cards = [
|
||||
{ label: "总收益", value: fmtPct(p.total_return), cls: p.total_return >= 0 ? "pos" : "neg" },
|
||||
{ label: "Sharpe", value: fmtSigned(p.sharpe), cls: "accent" },
|
||||
{ label: "Sortino", value: fmtSigned(p.sortino), cls: "accent" },
|
||||
{ label: "盈亏比", value: plText, cls: Number.isFinite(plNum) ? "accent" : "" },
|
||||
{
|
||||
label: "交易数",
|
||||
value: symData?.n_trades ?? p.n_trades ?? "—",
|
||||
cls: "",
|
||||
},
|
||||
{
|
||||
label: "胜率",
|
||||
value: symData?.win_rate != null ? (symData.win_rate * 100).toFixed(1) + "%" : "—",
|
||||
cls: "",
|
||||
},
|
||||
{ label: "总收益", raw: p.total_return, fmt: "pct", cls: p.total_return >= 0 ? "pos" : "neg", spark: equitySpark },
|
||||
{ label: "Sharpe", raw: p.sharpe, fmt: "signed", cls: "accent", spark: rollSpark },
|
||||
{ label: "Sortino", raw: p.sortino, fmt: "signed", cls: "accent", spark: rollSpark },
|
||||
{ label: "盈亏比", raw: Number.isFinite(plNum) ? plNum : null, fmt: "ratio", cls: Number.isFinite(plNum) ? "accent" : "" },
|
||||
{ label: "交易数", raw: Number.isFinite(Number(nTrades)) ? Number(nTrades) : null, fmt: "int", cls: "" },
|
||||
{ label: "胜率", raw: winRate != null ? Number(winRate) : null, fmt: "winrate", cls: "" },
|
||||
];
|
||||
|
||||
// 签名守卫:数值/焦点/资金曲线未变则不重建,避免每次轮询重播动画
|
||||
const sig = [focus, btEquitySig, ...cards.map((c) => c.raw)].join("|");
|
||||
if (sig === btPortfolioSig) {
|
||||
if ($("btPortfolioHint")) $("btPortfolioHint").textContent = focus ? `${focus} 回测绩效` : "回测绩效";
|
||||
return;
|
||||
}
|
||||
btPortfolioSig = sig;
|
||||
|
||||
grid.innerHTML = cards
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="metric-card ${c.cls === "pos" || c.cls === "neg" ? c.cls : ""}">
|
||||
.map((c) => {
|
||||
const cardCls = c.cls === "pos" || c.cls === "neg" ? c.cls : "";
|
||||
const finite = c.raw != null && Number.isFinite(c.raw);
|
||||
const finalText = finite ? METRIC_FMT[c.fmt](c.raw) : "—";
|
||||
const countAttr = finite ? ` data-count="${c.raw}" data-fmt="${c.fmt}"` : "";
|
||||
const spark = c.spark ? `<div class="metric-spark">${c.spark}</div>` : "";
|
||||
return `
|
||||
<div class="metric-card ${cardCls}">
|
||||
<div class="metric-label">${c.label}</div>
|
||||
<div class="metric-value ${c.cls}">${c.value}</div>
|
||||
</div>`
|
||||
)
|
||||
<div class="metric-value ${c.cls}"${countAttr}>${finalText}</div>
|
||||
${spark}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
runCountUp(grid);
|
||||
|
||||
if ($("btPortfolioHint")) {
|
||||
$("btPortfolioHint").textContent = focus ? `${focus} 回测绩效` : "回测绩效";
|
||||
}
|
||||
@@ -1319,28 +1426,34 @@ function renderEquityStats(name, series) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const plNum = Number(pl);
|
||||
const cards = [
|
||||
{ label: "总收益", value: fmtPct(series.total_return), cls: series.total_return >= 0 ? "pos" : "neg" },
|
||||
{ label: "夏普", value: fmtSigned(series.sharpe), cls: "accent" },
|
||||
{ label: "索提诺", value: fmtSigned(series.sortino), cls: "accent" },
|
||||
{ label: "盈亏比", value: plText, cls: "accent" },
|
||||
{ label: "总收益", raw: series.total_return, fmt: "pct", cls: series.total_return >= 0 ? "pos" : "neg" },
|
||||
{ label: "夏普", raw: series.sharpe, fmt: "signed", cls: "accent" },
|
||||
{ label: "索提诺", raw: series.sortino, fmt: "signed", cls: "accent" },
|
||||
{ label: "盈亏比", raw: Number.isFinite(plNum) ? plNum : null, fmt: "ratio", cls: "accent" },
|
||||
{
|
||||
label: "最新滚动夏普",
|
||||
value: lastRoll != null ? fmtSigned(lastRoll) : "—",
|
||||
raw: lastRoll,
|
||||
fmt: "signed",
|
||||
cls: lastRoll == null ? "" : lastRoll >= 0 ? "accent" : "neg",
|
||||
},
|
||||
];
|
||||
el.innerHTML =
|
||||
`<span class="equity-stat-name">${name}</span>` +
|
||||
cards
|
||||
.map(
|
||||
(c) => `
|
||||
.map((c) => {
|
||||
const finite = c.raw != null && Number.isFinite(c.raw);
|
||||
const finalText = finite ? METRIC_FMT[c.fmt](c.raw) : "—";
|
||||
const countAttr = finite ? ` data-count="${c.raw}" data-fmt="${c.fmt}"` : "";
|
||||
return `
|
||||
<div class="equity-stat">
|
||||
<span class="equity-stat-label">${c.label}</span>
|
||||
<span class="equity-stat-value ${c.cls}">${c.value}</span>
|
||||
</div>`
|
||||
)
|
||||
<span class="equity-stat-value ${c.cls}"${countAttr}>${finalText}</span>
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
runCountUp(el);
|
||||
}
|
||||
|
||||
function buildEquityChart(labels, symbols, portfolio) {
|
||||
@@ -1504,11 +1617,290 @@ async function stopBacktest() {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 实时行情分析(信号雷达)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
let rtInited = false;
|
||||
let rtEngineRunning = false;
|
||||
let rtSources = [];
|
||||
let rtSourceById = {};
|
||||
let rtImportedStrategy = null; // {path, name}
|
||||
let rtGridSig = "";
|
||||
|
||||
const RT_DIR = {
|
||||
LONG: { label: "↑ 预期上涨", cls: "rt-long", color: "#4ade80" },
|
||||
SHORT: { label: "↓ 预期下跌", cls: "rt-short", color: "#f87171" },
|
||||
FLAT: { label: "— 无信号", cls: "rt-flat", color: "#7a8a9e" },
|
||||
};
|
||||
const RT_STATE_LABEL = {
|
||||
pending: "等待首次计算",
|
||||
ok: "运行中",
|
||||
insufficient: "历史不足",
|
||||
error: "错误",
|
||||
};
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"]/g, (c) =>
|
||||
({ "&": "&", "<": "<", ">": ">", '"': """ }[c])
|
||||
);
|
||||
}
|
||||
|
||||
function rtClock(ts) {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts * 1000).toLocaleTimeString();
|
||||
}
|
||||
|
||||
async function initRealtimeOnce() {
|
||||
if (rtInited) return;
|
||||
rtInited = true;
|
||||
try {
|
||||
const data = await fetchJSON("/api/realtime/sources");
|
||||
rtSources = data.sources || [];
|
||||
rtSourceById = {};
|
||||
rtSources.forEach((s) => (rtSourceById[s.id] = s));
|
||||
const sel = $("rtSourceSelect");
|
||||
if (sel) {
|
||||
sel.innerHTML = rtSources
|
||||
.map((s) => `<option value="${s.id}">${escHtml(s.label)}${s.available ? "" : " · 未就绪"}</option>`)
|
||||
.join("");
|
||||
}
|
||||
if (data.min_exposure != null && $("rtThresholdHint")) {
|
||||
$("rtThresholdHint").textContent = `无信号阈值 |tanh(因子)| < ${data.min_exposure}`;
|
||||
}
|
||||
onRtSourceChange();
|
||||
} catch (e) {
|
||||
await logClientError("加载数据源失败: " + e.message);
|
||||
}
|
||||
await loadRtStrategies();
|
||||
}
|
||||
|
||||
async function loadRtStrategies() {
|
||||
const sel = $("rtStrategySelect");
|
||||
if (!sel) return;
|
||||
let rows = [];
|
||||
try {
|
||||
const data = await fetchJSON("/api/realtime/strategies");
|
||||
rows = data.strategies || [];
|
||||
} catch (_) {}
|
||||
const opts = ['<option value="">— 选择已保存策略 —</option>'];
|
||||
if (rtImportedStrategy) {
|
||||
opts.push(`<option value="${escHtml(rtImportedStrategy.path)}">导入: ${escHtml(rtImportedStrategy.name)}</option>`);
|
||||
}
|
||||
rows.forEach((r) => {
|
||||
const score = r.best_score != null ? Number(r.best_score).toFixed(3) : "—";
|
||||
const tf = r.timeframe ? ` ${r.timeframe}` : "";
|
||||
opts.push(`<option value="${escHtml(r.strategy_file)}">${escHtml(r.symbol)}${tf} · 分数 ${score}</option>`);
|
||||
});
|
||||
const prev = sel.value;
|
||||
sel.innerHTML = opts.join("");
|
||||
if (rtImportedStrategy) sel.value = rtImportedStrategy.path;
|
||||
else if (prev) sel.value = prev;
|
||||
onRtStrategyChange();
|
||||
}
|
||||
|
||||
function onRtSourceChange() {
|
||||
const src = rtSourceById[$("rtSourceSelect")?.value];
|
||||
const tfSel = $("rtTimeframeSelect");
|
||||
const presets = $("rtSymbolPresets");
|
||||
const hint = $("rtSourceHint");
|
||||
if (!src) return;
|
||||
if (tfSel) {
|
||||
const cur = tfSel.value;
|
||||
tfSel.innerHTML = (src.timeframes || []).map((t) => `<option value="${t}">${t}</option>`).join("");
|
||||
if (src.timeframes && src.timeframes.includes(cur)) tfSel.value = cur;
|
||||
else if (src.timeframes && src.timeframes.includes("1h")) tfSel.value = "1h";
|
||||
}
|
||||
if (presets) {
|
||||
presets.innerHTML = (src.presets || []).map((s) => `<option value="${escHtml(s)}"></option>`).join("");
|
||||
}
|
||||
if (hint) {
|
||||
hint.textContent = `${src.label}:${src.hint || ""}`;
|
||||
hint.classList.toggle("bad", !src.available);
|
||||
}
|
||||
}
|
||||
|
||||
function onRtStrategyChange() {
|
||||
const sel = $("rtStrategySelect");
|
||||
const picked = $("rtStrategyPicked");
|
||||
if (!sel || !picked) return;
|
||||
const opt = sel.options[sel.selectedIndex];
|
||||
picked.textContent = sel.value
|
||||
? `因子来源:${opt ? opt.textContent : sel.value}。信号取最后已收盘 bar。`
|
||||
: "因子来源:从已保存策略下拉选择,或「导入策略」选本地 JSON。信号取最后已收盘 bar。";
|
||||
}
|
||||
|
||||
async function rtBrowseStrategy() {
|
||||
try {
|
||||
const res = await fetchJSON("/api/strategy-file/browse", { method: "POST" });
|
||||
if (res.cancelled) return;
|
||||
rtImportedStrategy = { path: res.strategy_file, name: res.filename || res.strategy_file };
|
||||
await loadRtStrategies();
|
||||
} catch (e) {
|
||||
await logClientError("导入策略失败: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rtAddWatch() {
|
||||
const source = $("rtSourceSelect")?.value;
|
||||
const symbol = ($("rtSymbolInput")?.value || "").trim();
|
||||
const timeframe = $("rtTimeframeSelect")?.value;
|
||||
const strategy_file = $("rtStrategySelect")?.value;
|
||||
const picked = $("rtStrategyPicked");
|
||||
if (!symbol) {
|
||||
if (picked) { picked.textContent = "请填写品种"; picked.classList.add("bad"); }
|
||||
return;
|
||||
}
|
||||
if (!strategy_file) {
|
||||
if (picked) { picked.textContent = "请选择或导入策略因子"; picked.classList.add("bad"); }
|
||||
return;
|
||||
}
|
||||
const btn = $("rtAddBtn");
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
await fetchJSON("/api/realtime/watch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ source, symbol, timeframe, strategy_file }),
|
||||
});
|
||||
if (picked) picked.classList.remove("bad");
|
||||
rtEngineRunning = true;
|
||||
if ($("rtEngineToggle")) $("rtEngineToggle").checked = true;
|
||||
rtGridSig = "";
|
||||
await refreshRealtime();
|
||||
} catch (e) {
|
||||
if (picked) { picked.textContent = "添加失败: " + e.message; picked.classList.add("bad"); }
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rtRemoveWatch(id) {
|
||||
try {
|
||||
await fetchJSON("/api/realtime/unwatch", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
rtGridSig = "";
|
||||
await refreshRealtime();
|
||||
} catch (e) {
|
||||
await logClientError("移除监控失败: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function rtToggleEngine(on) {
|
||||
try {
|
||||
await fetchJSON(`/api/realtime/${on ? "start" : "stop"}`, { method: "POST" });
|
||||
rtEngineRunning = !!on;
|
||||
await refreshRealtime();
|
||||
} catch (e) {
|
||||
await logClientError("引擎切换失败: " + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRealtime() {
|
||||
let st;
|
||||
try {
|
||||
st = await fetchJSON("/api/realtime/status");
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
rtEngineRunning = !!st.running;
|
||||
const toggle = $("rtEngineToggle");
|
||||
if (toggle) toggle.checked = rtEngineRunning;
|
||||
const hint = $("rtStatusHint");
|
||||
if (hint) {
|
||||
hint.textContent = st.count
|
||||
? `${rtEngineRunning ? "运行中" : "已暂停"} · ${st.count} 项 · ${new Date().toLocaleTimeString()}`
|
||||
: (rtEngineRunning ? "运行中 · 无监控项" : "未启动");
|
||||
}
|
||||
renderRealtimeGrid(st.watches || []);
|
||||
}
|
||||
|
||||
// 半环表盘(180° 上半环,值弧按强度填充)
|
||||
const RT_ARC_LEN = 150.8; // π * 48
|
||||
function halfRingGauge(strength, colorHex) {
|
||||
const s = Math.max(0, Math.min(1, strength || 0));
|
||||
const off = RT_ARC_LEN * (1 - s);
|
||||
return `<svg class="rt-gauge-svg" viewBox="0 0 120 74" aria-hidden="true">
|
||||
<path class="rt-gauge-track" d="M12 62 A 48 48 0 0 1 108 62" />
|
||||
<path class="rt-gauge-val" d="M12 62 A 48 48 0 0 1 108 62"
|
||||
style="stroke:${colorHex};stroke-dasharray:${RT_ARC_LEN};stroke-dashoffset:${off.toFixed(1)};" />
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function renderRealtimeGrid(watches) {
|
||||
const grid = $("rtGrid");
|
||||
if (!grid) return;
|
||||
if (!watches.length) {
|
||||
grid.innerHTML =
|
||||
'<div class="metric-empty">尚无监控项。添加「数据源 + 品种 + 周期 + 因子」后开始实时分析。</div>';
|
||||
rtGridSig = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// 签名:只在信号相关字段变化时重建(避免每次轮询重播动画)
|
||||
const sig = watches
|
||||
.map((w) => [w.id, w.state, w.direction, w.strength, w.warn, w.message].join("~"))
|
||||
.join("|");
|
||||
if (sig === rtGridSig) return;
|
||||
rtGridSig = sig;
|
||||
|
||||
grid.innerHTML = watches
|
||||
.map((w) => {
|
||||
const dir = w.state === "ok" ? RT_DIR[w.direction] || RT_DIR.FLAT : null;
|
||||
const color = dir ? dir.color : "#7a8a9e";
|
||||
const strength = w.state === "ok" ? w.strength || 0 : 0;
|
||||
const dirLabel = dir ? dir.label : RT_STATE_LABEL[w.state] || w.state;
|
||||
const dirCls = dir ? dir.cls : "rt-flat";
|
||||
const srcLabel = (rtSourceById[w.source] || {}).label || w.source;
|
||||
const factorText = w.factor_value != null ? Number(w.factor_value).toFixed(4) : "—";
|
||||
const warn = w.warn ? `<div class="rt-warn" title="${escHtml(w.warn)}">⚠ ${escHtml(w.warn)}</div>` : "";
|
||||
const msg =
|
||||
w.state !== "ok" && w.message
|
||||
? `<div class="rt-msg">${escHtml(w.message)}</div>`
|
||||
: "";
|
||||
const strengthAttr =
|
||||
w.state === "ok" ? ` data-count="${strength}" data-fmt="strength"` : "";
|
||||
const strengthText = w.state === "ok" ? METRIC_FMT.strength(strength) : "—";
|
||||
return `
|
||||
<div class="rt-card ${dirCls}" data-id="${escHtml(w.id)}">
|
||||
<button class="rt-remove" data-remove="${escHtml(w.id)}" title="移除监控">×</button>
|
||||
<div class="rt-card-head">
|
||||
<span class="rt-sym">${escHtml(w.symbol)}</span>
|
||||
<span class="rt-tf">${escHtml(w.timeframe)}</span>
|
||||
<span class="rt-src">${escHtml(srcLabel)}</span>
|
||||
</div>
|
||||
<div class="rt-gauge">
|
||||
${halfRingGauge(strength, color)}
|
||||
<div class="rt-gauge-center">
|
||||
<div class="rt-strength"${strengthAttr}>${strengthText}</div>
|
||||
<div class="rt-dir ${dirCls}">${dirLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rt-meta">
|
||||
<span class="rt-meta-item">因子 <b>${factorText}</b></span>
|
||||
<span class="rt-meta-item">${escHtml(w.strategy_name)}</span>
|
||||
</div>
|
||||
<div class="rt-foot">
|
||||
<span class="rt-state ${w.state}">${RT_STATE_LABEL[w.state] || w.state}</span>
|
||||
<span class="rt-time">更新 ${rtClock(w.updated_at)}</span>
|
||||
</div>
|
||||
${warn}
|
||||
${msg}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
runCountUp(grid);
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(() => {
|
||||
refreshOverview();
|
||||
if (currentPage === "backtest" || btActive) refreshBacktest();
|
||||
if (currentPage === "realtime" || rtEngineRunning) refreshRealtime();
|
||||
}, 4000);
|
||||
}
|
||||
|
||||
@@ -1553,6 +1945,19 @@ async function init() {
|
||||
el.addEventListener("change", updateBtCostHint);
|
||||
});
|
||||
|
||||
// 实时分析控制
|
||||
if ($("rtSourceSelect")) $("rtSourceSelect").addEventListener("change", onRtSourceChange);
|
||||
if ($("rtStrategySelect")) $("rtStrategySelect").addEventListener("change", onRtStrategyChange);
|
||||
if ($("rtBrowseStrategyBtn")) $("rtBrowseStrategyBtn").addEventListener("click", rtBrowseStrategy);
|
||||
if ($("rtAddBtn")) $("rtAddBtn").addEventListener("click", rtAddWatch);
|
||||
if ($("rtEngineToggle")) $("rtEngineToggle").addEventListener("change", (e) => rtToggleEngine(e.target.checked));
|
||||
if ($("rtGrid")) {
|
||||
$("rtGrid").addEventListener("click", (e) => {
|
||||
const btn = e.target.closest("[data-remove]");
|
||||
if (btn) rtRemoveWatch(btn.dataset.remove);
|
||||
});
|
||||
}
|
||||
|
||||
startPolling();
|
||||
}
|
||||
|
||||
|
||||
+69
-13
@@ -7,7 +7,7 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700;800&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=14" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=16" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -57,6 +57,14 @@
|
||||
<span class="step-sub">资金曲线 · 绩效评估</span>
|
||||
</span>
|
||||
</button>
|
||||
<span class="step-link"></span>
|
||||
<button type="button" class="step" data-page="realtime">
|
||||
<span class="step-index">03</span>
|
||||
<span class="step-body">
|
||||
<span class="step-title">实时分析</span>
|
||||
<span class="step-sub">信号雷达 · 多品种并发</span>
|
||||
</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<!-- ══════════════ 步骤一:训练页 ══════════════ -->
|
||||
@@ -99,26 +107,19 @@
|
||||
<summary>指标说明</summary>
|
||||
<ul class="metrics-list">
|
||||
<li>
|
||||
<span class="metric-swatch" style="--swatch: #5eead4"></span>
|
||||
<span class="metric-swatch" style="--swatch: #34f5c8"></span>
|
||||
<div>
|
||||
<strong>最优分数</strong>
|
||||
<p>到目前为止找到的最好因子公式得分,越高表示回测表现越好,曲线阶梯上升说明发现了更优公式。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="metric-swatch" style="--swatch: #818cf8"></span>
|
||||
<span class="metric-swatch" style="--swatch: #38bdf8"></span>
|
||||
<div>
|
||||
<strong>验证分数</strong>
|
||||
<p>当前这一步在验证集上的平均得分,反映近期一批公式的整体质量,会有起伏属正常。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="metric-swatch" style="--swatch: #fbbf24"></span>
|
||||
<div>
|
||||
<strong>策略熵</strong>
|
||||
<p>模型生成公式时的探索随机程度。偏高说明还在广泛尝试,逐渐走低说明策略趋于固定。</p>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
<div class="formula-box" id="formulaBox">
|
||||
@@ -178,12 +179,12 @@
|
||||
<div class="ai-row">
|
||||
<label class="ai-field ai-key-field" id="aiKeyField">
|
||||
<span>API Key</span>
|
||||
<input type="password" id="aiApiKeyInput" placeholder="填 DeepSeek Key,或输入 openclaw / openclaw_wb" autocomplete="off" />
|
||||
<input type="password" id="aiApiKeyInput" placeholder="填入 DeepSeek API Key" autocomplete="off" />
|
||||
</label>
|
||||
<button type="button" class="btn btn-primary" id="aiAnalyzeBtn">开始分析</button>
|
||||
<button type="button" class="btn btn-unlimited" id="aiUnlimitedBtn">获取无限分析功能</button>
|
||||
</div>
|
||||
<small class="field-hint" id="aiChannelHint">默认 DeepSeek(deepseek-v4-flash · https://api.deepseek.com)。若要走本地 Agent,在 API Key 里直接输入 openclaw 或 openclaw_wb。</small>
|
||||
<small class="field-hint" id="aiChannelHint">使用 DeepSeek(deepseek-v4-flash · https://api.deepseek.com),填入你的 API Key 即可。</small>
|
||||
</div>
|
||||
<div class="ai-answer" id="aiAnswerView">点击「开始分析」后,AI 将回答:当前训练情况怎么样,是否值得继续,以及最新因子的含义与原理。</div>
|
||||
</section>
|
||||
@@ -363,9 +364,64 @@
|
||||
</details>
|
||||
</section>
|
||||
</div><!-- /page-backtest -->
|
||||
|
||||
<!-- ══════════════ 步骤三:实时分析页 ══════════════ -->
|
||||
<div class="page" id="page-realtime">
|
||||
<section class="panel launch-panel">
|
||||
<span class="hud-corner tl"></span><span class="hud-corner tr"></span>
|
||||
<span class="hud-corner bl"></span><span class="hud-corner br"></span>
|
||||
<h2>添加监控</h2>
|
||||
<div class="rt-form">
|
||||
<label class="rt-field">
|
||||
<span>数据源</span>
|
||||
<select id="rtSourceSelect"></select>
|
||||
</label>
|
||||
<label class="rt-field">
|
||||
<span>品种</span>
|
||||
<input type="text" id="rtSymbolInput" list="rtSymbolPresets" placeholder="如 XAUUSD / 600519" autocomplete="off" />
|
||||
<datalist id="rtSymbolPresets"></datalist>
|
||||
</label>
|
||||
<label class="rt-field">
|
||||
<span>周期</span>
|
||||
<select id="rtTimeframeSelect"></select>
|
||||
</label>
|
||||
<label class="rt-field rt-field-wide">
|
||||
<span>因子来源</span>
|
||||
<select id="rtStrategySelect"></select>
|
||||
</label>
|
||||
<div class="rt-actions">
|
||||
<button type="button" class="btn btn-secondary" id="rtBrowseStrategyBtn" title="导入本地策略 JSON">导入策略</button>
|
||||
<button type="button" class="btn btn-primary" id="rtAddBtn">加入监控</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rt-form-foot">
|
||||
<span class="rt-source-hint" id="rtSourceHint">—</span>
|
||||
<span class="rt-thr-hint" id="rtThresholdHint">无信号阈值 |tanh(因子)| < 0.05</span>
|
||||
</div>
|
||||
<small class="field-hint" id="rtStrategyPicked">因子来源:从已保存策略下拉选择,或「导入策略」选本地 JSON。信号取最后已收盘 bar。</small>
|
||||
</section>
|
||||
|
||||
<section class="panel rt-radar-panel">
|
||||
<span class="hud-corner tl"></span><span class="hud-corner tr"></span>
|
||||
<span class="hud-corner bl"></span><span class="hud-corner br"></span>
|
||||
<div class="panel-head">
|
||||
<h2>信号雷达</h2>
|
||||
<div class="rt-head-right">
|
||||
<span class="hint" id="rtStatusHint">未启动</span>
|
||||
<label class="rt-engine-toggle">
|
||||
<input type="checkbox" id="rtEngineToggle" />
|
||||
<span>引擎</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rt-grid" id="rtGrid">
|
||||
<div class="metric-empty">尚无监控项。添加「数据源 + 品种 + 周期 + 因子」后开始实时分析。</div>
|
||||
</div>
|
||||
</section>
|
||||
</div><!-- /page-realtime -->
|
||||
</div>
|
||||
|
||||
<script src="/static/bg.js?v=1"></script>
|
||||
<script src="/static/app.js?v=36"></script>
|
||||
<script src="/static/app.js?v=40"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1294,8 +1294,29 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
border: 1px solid var(--border-strong);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 84px;
|
||||
}
|
||||
|
||||
/* 右下角迷你趋势微线 */
|
||||
.metric-spark {
|
||||
position: absolute;
|
||||
right: 12px;
|
||||
bottom: 9px;
|
||||
width: 74px;
|
||||
height: 22px;
|
||||
opacity: 0.9;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.metric-spark .spark-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
filter: drop-shadow(0 0 4px rgba(94, 234, 212, 0.22));
|
||||
}
|
||||
|
||||
.metric-card .metric-value { position: relative; z-index: 1; }
|
||||
|
||||
.metric-card::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -1653,3 +1674,234 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
border: 1px solid var(--border-hover);
|
||||
box-shadow: 0 30px 80px -20px rgba(0, 0, 0, 0.9);
|
||||
}
|
||||
|
||||
/* ══════════════════════ 实时分析(信号雷达)══════════════════════ */
|
||||
.rt-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.rt-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.rt-field span {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.rt-field-wide { flex: 1 1 260px; }
|
||||
|
||||
.rt-field select,
|
||||
.rt-field input[type="text"] {
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.rt-field input[type="text"] { font-family: var(--mono); font-size: 0.85rem; }
|
||||
.rt-field select:focus,
|
||||
.rt-field input[type="text"]:focus { border-color: var(--accent); }
|
||||
|
||||
.rt-actions { display: flex; gap: 10px; align-items: flex-end; }
|
||||
|
||||
.rt-form-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.rt-source-hint,
|
||||
.rt-thr-hint {
|
||||
font-size: 0.75rem;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.rt-source-hint.bad { color: var(--danger); }
|
||||
|
||||
.rt-head-right { display: flex; align-items: center; gap: 14px; }
|
||||
|
||||
.rt-engine-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.rt-engine-toggle input { accent-color: var(--accent); width: 15px; height: 15px; }
|
||||
|
||||
.rt-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.rt-card {
|
||||
position: relative;
|
||||
padding: 14px 14px 12px;
|
||||
border-radius: 14px;
|
||||
background: linear-gradient(160deg, rgba(14, 20, 31, 0.9), rgba(10, 15, 24, 0.6));
|
||||
border: 1px solid var(--border-strong);
|
||||
overflow: hidden;
|
||||
transition: border-color 0.3s, box-shadow 0.3s;
|
||||
}
|
||||
|
||||
.rt-card.rt-long {
|
||||
border-color: rgba(74, 222, 128, 0.4);
|
||||
box-shadow: inset 0 0 26px rgba(74, 222, 128, 0.07), 0 0 20px -8px rgba(74, 222, 128, 0.3);
|
||||
}
|
||||
.rt-card.rt-short {
|
||||
border-color: rgba(248, 113, 113, 0.4);
|
||||
box-shadow: inset 0 0 26px rgba(248, 113, 113, 0.07), 0 0 20px -8px rgba(248, 113, 113, 0.3);
|
||||
}
|
||||
.rt-card.rt-flat { border-color: var(--border-strong); }
|
||||
|
||||
.rt-remove {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
background: rgba(14, 20, 31, 0.7);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
z-index: 3;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
}
|
||||
.rt-remove:hover { color: var(--danger); border-color: rgba(248, 113, 113, 0.5); }
|
||||
|
||||
.rt-card-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.rt-card-head .rt-sym {
|
||||
font-family: var(--mono);
|
||||
font-weight: 600;
|
||||
font-size: 0.98rem;
|
||||
color: var(--accent);
|
||||
text-shadow: 0 0 12px var(--accent-glow);
|
||||
}
|
||||
.rt-card-head .rt-tf {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.7rem;
|
||||
color: var(--text);
|
||||
padding: 1px 7px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: rgba(14, 20, 31, 0.7);
|
||||
}
|
||||
.rt-card-head .rt-src { font-size: 0.7rem; color: var(--text-muted); margin-left: auto; }
|
||||
|
||||
.rt-gauge {
|
||||
position: relative;
|
||||
height: 92px;
|
||||
margin: 2px 0 6px;
|
||||
}
|
||||
.rt-gauge-svg { display: block; width: 100%; height: 100%; }
|
||||
|
||||
.rt-gauge-track {
|
||||
fill: none;
|
||||
stroke: rgba(120, 190, 235, 0.12);
|
||||
stroke-width: 9;
|
||||
stroke-linecap: round;
|
||||
}
|
||||
.rt-gauge-val {
|
||||
fill: none;
|
||||
stroke-width: 9;
|
||||
stroke-linecap: round;
|
||||
filter: drop-shadow(0 0 5px currentColor);
|
||||
transition: stroke-dashoffset 0.7s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.rt-gauge-center {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 4px;
|
||||
text-align: center;
|
||||
}
|
||||
.rt-strength {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
color: var(--text);
|
||||
}
|
||||
.rt-dir {
|
||||
margin-top: 4px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.rt-dir.rt-long { color: var(--success); }
|
||||
.rt-dir.rt-short { color: var(--danger); }
|
||||
.rt-dir.rt-flat { color: var(--text-muted); }
|
||||
|
||||
.rt-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.rt-meta-item { font-family: var(--mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rt-meta-item b { color: var(--text); font-weight: 600; }
|
||||
|
||||
.rt-foot {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
margin-top: 6px;
|
||||
}
|
||||
.rt-state.ok { color: var(--success); }
|
||||
.rt-state.error { color: var(--danger); }
|
||||
.rt-state.insufficient { color: var(--warn); }
|
||||
|
||||
.rt-warn {
|
||||
margin-top: 8px;
|
||||
font-size: 0.68rem;
|
||||
color: var(--warn);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.rt-msg {
|
||||
margin-top: 8px;
|
||||
font-size: 0.68rem;
|
||||
color: #f0c4c4;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user