This commit is contained in:
qq564020069
2026-07-11 18:49:18 +08:00
parent d9b62df636
commit 149a3f468d
18 changed files with 1982 additions and 855 deletions
-40
View File
@@ -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()
-412
View File
@@ -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
+96
View File
@@ -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": "",
}
+42 -1
View File
@@ -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
-193
View File
@@ -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})"
)
-150
View File
@@ -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
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""实时行情数据源包。"""
+87
View File
@@ -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]),
}
+120
View File
@@ -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:]
+66
View File
@@ -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
+115
View File
@@ -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
+137
View File
@@ -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:]
+142
View File
@@ -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
+310
View File
@@ -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
View File
@@ -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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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
View File
@@ -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(因子)| &lt; 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>
+252
View File
@@ -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;
}