0.99
This commit is contained in:
@@ -83,6 +83,7 @@ verification_results.json
|
||||
# ── 实盘/运行态文件 ────────────────────────────────────────────────
|
||||
portfolio_state.json
|
||||
web_settings.json
|
||||
ai_analysis_history.json
|
||||
STOP_SIGNAL
|
||||
train.pid
|
||||
train_stdout.log
|
||||
|
||||
@@ -131,13 +131,18 @@ class BacktestChart:
|
||||
framealpha=0.7,
|
||||
)
|
||||
ax_candle.grid(alpha=0.3)
|
||||
pl_txt = (
|
||||
f"{result.profit_loss_ratio:.3f}"
|
||||
if result.profit_loss_ratio is not None
|
||||
else "—"
|
||||
)
|
||||
ax_candle.set_title(
|
||||
f"{result.symbol} | "
|
||||
f"Sortino={result.sortino:.2f} "
|
||||
f"TotalRet={result.total_return:.4f} "
|
||||
f"Trades={result.n_trades} "
|
||||
f"WinRate={result.win_rate:.1%} "
|
||||
f"MaxDD={result.max_drawdown:.4f} "
|
||||
f"PLRatio={pl_txt} "
|
||||
f"AvgHold={result.avg_hold_bars:.1f}bars"
|
||||
+ (f" | {title_suffix}" if title_suffix else ""),
|
||||
fontsize=10, pad=6,
|
||||
|
||||
+15
-8
@@ -56,6 +56,7 @@ class SymbolResult:
|
||||
win_rate: float = 0.0
|
||||
max_drawdown: float = 0.0
|
||||
avg_hold_bars:float = 0.0
|
||||
profit_loss_ratio: float | None = None # 盈亏比 = 平均盈利 / 平均亏损
|
||||
|
||||
|
||||
class BacktestEngine:
|
||||
@@ -167,7 +168,6 @@ class BacktestEngine:
|
||||
sum(1 for t in trades if t.pnl > 0) / n_trades
|
||||
if n_trades else 0.0
|
||||
)
|
||||
max_dd = self._calc_max_drawdown(cum_pnl)
|
||||
avg_hold = (
|
||||
sum(
|
||||
(t.exit_bar - t.entry_bar)
|
||||
@@ -175,6 +175,7 @@ class BacktestEngine:
|
||||
) / n_trades
|
||||
if n_trades else 0.0
|
||||
)
|
||||
pl_ratio = self._calc_profit_loss_ratio(trades)
|
||||
|
||||
return SymbolResult(
|
||||
symbol = symbol,
|
||||
@@ -194,8 +195,9 @@ class BacktestEngine:
|
||||
total_return = total_return,
|
||||
n_trades = n_trades,
|
||||
win_rate = win_rate,
|
||||
max_drawdown = max_dd,
|
||||
max_drawdown = 0.0,
|
||||
avg_hold_bars= avg_hold,
|
||||
profit_loss_ratio = pl_ratio,
|
||||
)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -296,9 +298,14 @@ class BacktestEngine:
|
||||
return float(np.clip(sortino, -20.0, 20.0))
|
||||
|
||||
@staticmethod
|
||||
def _calc_max_drawdown(cum_pnl: np.ndarray) -> float:
|
||||
if len(cum_pnl) == 0:
|
||||
return 0.0
|
||||
peak = np.maximum.accumulate(cum_pnl)
|
||||
drawdown = peak - cum_pnl
|
||||
return float(drawdown.max())
|
||||
def _calc_profit_loss_ratio(trades: list[Trade]) -> float | None:
|
||||
"""盈亏比 = 平均盈利 / 平均亏损绝对值。无盈利或无亏损时返回 None。"""
|
||||
wins = [t.pnl for t in trades if t.pnl is not None and t.pnl > 0]
|
||||
losses = [abs(t.pnl) for t in trades if t.pnl is not None and t.pnl < 0]
|
||||
if not wins or not losses:
|
||||
return None
|
||||
avg_win = sum(wins) / len(wins)
|
||||
avg_loss = sum(losses) / len(losses)
|
||||
if avg_loss <= 0:
|
||||
return None
|
||||
return float(avg_win / avg_loss)
|
||||
|
||||
@@ -41,7 +41,8 @@ class BacktestReport:
|
||||
print(line)
|
||||
print(f" {'Sortino Ratio':20s}: {r.sortino:+.4f}")
|
||||
print(f" {'总 PnL (log ret)':20s}: {r.total_return:+.6f}")
|
||||
print(f" {'最大回撤':20s}: {r.max_drawdown:.6f}")
|
||||
pl = r.profit_loss_ratio
|
||||
print(f" {'盈亏比':20s}: {pl:.4f}" if pl is not None else f" {'盈亏比':20s}: —")
|
||||
print(f" {'总交易笔数':20s}: {r.n_trades}")
|
||||
print(f" {'胜率':20s}: {r.win_rate:.1%}")
|
||||
print(f" {'平均持仓 (bars)':20s}: {r.avg_hold_bars:.1f}")
|
||||
@@ -106,7 +107,10 @@ class BacktestReport:
|
||||
"symbol": r.symbol,
|
||||
"sortino": round(r.sortino, 4),
|
||||
"total_return": round(r.total_return, 8),
|
||||
"max_drawdown": round(r.max_drawdown, 8),
|
||||
"profit_loss_ratio": (
|
||||
round(r.profit_loss_ratio, 4)
|
||||
if r.profit_loss_ratio is not None else None
|
||||
),
|
||||
"n_trades": r.n_trades,
|
||||
"win_rate": round(r.win_rate, 4),
|
||||
"avg_hold_bars": round(r.avg_hold_bars, 2),
|
||||
|
||||
+234
-118
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
run_backtest.py — 多因子组合回测(含真实点差、夏普、资金曲线)
|
||||
run_backtest.py — 多因子组合回测(含手续费/滑点、夏普、资金曲线)
|
||||
|
||||
用法:
|
||||
python run_backtest.py # 多因子模式(每品种独立公式)
|
||||
python run_backtest.py --single # 单公式兼容模式
|
||||
python run_backtest.py --offline --commission 0.02 --slippage 0.01
|
||||
# 单边手续费/滑点(单位 %),默认 0.02 / 0.01
|
||||
"""
|
||||
|
||||
import json, sys, math
|
||||
@@ -13,48 +15,21 @@ import torch
|
||||
import matplotlib
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.gridspec as gridspec
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from config import Config
|
||||
from data_pipeline.data_manager import MT5DataManager
|
||||
from data_pipeline.fetcher import MT5DataFetcher
|
||||
from backtest_viz import BacktestEngine, BacktestChart, BacktestReport
|
||||
from backtest_viz import BacktestEngine
|
||||
from model_core.vocab import FORMULA_VOCAB, VOCAB_VERSION
|
||||
from model_core.vm import StackVM
|
||||
from model_core.features import MT5FeatureEngineer
|
||||
from strategy_manager.signal import compute_target_positions_stateless
|
||||
|
||||
# ── 各品种真实点差(从 MT5 实时获取,单边 log cost)──────────────────────────
|
||||
# 运行时自动刷新;若 MT5 不可用则用保守默认值
|
||||
DEFAULT_COST_RATES = {
|
||||
"EURUSDm": 0.000035,
|
||||
"USDJPYm": 0.000031,
|
||||
"XAUUSDm": 0.000031,
|
||||
"USTECm": 0.000041,
|
||||
"US500m": 0.000048,
|
||||
}
|
||||
_H1_PER_YEAR = 6240
|
||||
|
||||
|
||||
def get_live_spreads() -> dict[str, float]:
|
||||
"""从 MT5 实时获取各品种点差,返回 {symbol: log_cost_rate}。"""
|
||||
try:
|
||||
import MetaTrader5 as mt5
|
||||
mt5.initialize()
|
||||
costs = {}
|
||||
for sym in Config.SYMBOLS:
|
||||
tick = mt5.symbol_info_tick(sym)
|
||||
if tick and tick.ask > 0:
|
||||
mid = (tick.ask + tick.bid) / 2
|
||||
costs[sym] = (tick.ask - tick.bid) / mid / 2 # 单边
|
||||
else:
|
||||
costs[sym] = DEFAULT_COST_RATES.get(sym, 0.0001)
|
||||
mt5.shutdown()
|
||||
return costs
|
||||
except Exception:
|
||||
return dict(DEFAULT_COST_RATES)
|
||||
DEFAULT_COMMISSION_PCT = 0.02 # 单边手续费 %
|
||||
DEFAULT_SLIPPAGE_PCT = 0.01 # 单边滑点 %
|
||||
|
||||
|
||||
def decode_formula(tokens: list[int]) -> str:
|
||||
@@ -92,37 +67,80 @@ def calc_sortino(pnl: np.ndarray, periods_per_year: int = _H1_PER_YEAR) -> float
|
||||
return float(np.clip(m / ds * math.sqrt(periods_per_year), -20, 20))
|
||||
|
||||
|
||||
def calc_max_drawdown(cum_pnl: np.ndarray) -> float:
|
||||
peak = np.maximum.accumulate(cum_pnl)
|
||||
return float((peak - cum_pnl).max())
|
||||
def calc_rolling_sharpe(
|
||||
pnl: np.ndarray,
|
||||
window: int = 500,
|
||||
periods_per_year: int = _H1_PER_YEAR,
|
||||
) -> np.ndarray:
|
||||
"""滚动年化夏普;窗口不足处为 nan。"""
|
||||
T = len(pnl)
|
||||
out = np.full(T, np.nan, dtype=np.float64)
|
||||
if T == 0 or window <= 1:
|
||||
return out
|
||||
w = min(window, T)
|
||||
# 累积和 / 累积平方和 → O(T) 滑动窗口
|
||||
csum = np.concatenate([[0.0], np.cumsum(pnl, dtype=np.float64)])
|
||||
csq = np.concatenate([[0.0], np.cumsum(pnl.astype(np.float64) ** 2)])
|
||||
for i in range(w - 1, T):
|
||||
s = csum[i + 1] - csum[i + 1 - w]
|
||||
sq = csq[i + 1] - csq[i + 1 - w]
|
||||
mean = s / w
|
||||
var = sq / w - mean * mean
|
||||
std = math.sqrt(var) if var > 0 else 0.0
|
||||
if std < 1e-12:
|
||||
out[i] = 0.0
|
||||
else:
|
||||
out[i] = float(np.clip(mean / std * math.sqrt(periods_per_year), -20, 20))
|
||||
return out
|
||||
|
||||
|
||||
def calc_calmar(cum_pnl: np.ndarray, periods_per_year: int = _H1_PER_YEAR) -> float:
|
||||
"""Calmar = 年化收益 / 最大回撤。"""
|
||||
T = len(cum_pnl)
|
||||
ann = cum_pnl[-1] * periods_per_year / T if T > 0 else 0
|
||||
mdd = calc_max_drawdown(cum_pnl)
|
||||
return float(ann / mdd) if mdd > 1e-8 else 0.0
|
||||
def _fmt_pl_ratio(results_map: dict) -> str:
|
||||
vals = [
|
||||
d["profit_loss_ratio"]
|
||||
for d in results_map.values()
|
||||
if d.get("profit_loss_ratio") is not None
|
||||
]
|
||||
if not vals:
|
||||
return "—"
|
||||
return f"{sum(vals) / len(vals):.3f}"
|
||||
|
||||
|
||||
# ── 资金曲线图 ────────────────────────────────────────────────────────────────
|
||||
|
||||
def _setup_chinese_font() -> None:
|
||||
"""让 matplotlib 能正确显示中文(Windows 优先微软雅黑)。"""
|
||||
from matplotlib import font_manager
|
||||
|
||||
candidates = [
|
||||
"Microsoft YaHei",
|
||||
"SimHei",
|
||||
"SimSun",
|
||||
"Noto Sans CJK SC",
|
||||
"Source Han Sans SC",
|
||||
"Arial Unicode MS",
|
||||
]
|
||||
available = {f.name for f in font_manager.fontManager.ttflist}
|
||||
for name in candidates:
|
||||
if name in available:
|
||||
plt.rcParams["font.sans-serif"] = [name, "DejaVu Sans"]
|
||||
break
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
|
||||
def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray | None = None):
|
||||
"""绘制各品种 + 等权组合的资金曲线。
|
||||
"""绘制各品种 + 等权组合的资金曲线(中文标注)。
|
||||
|
||||
Args:
|
||||
results_map: {symbol: {"pnl": np.array, "cum_pnl": np.array, ...}}
|
||||
output_dir: 输出目录
|
||||
times_arr: 时间戳数组(Unix秒),用于 X 轴刻度
|
||||
"""
|
||||
_setup_chinese_font()
|
||||
Path(output_dir).mkdir(parents=True, exist_ok=True)
|
||||
syms = list(results_map.keys())
|
||||
n_syms = len(syms)
|
||||
|
||||
fig = plt.figure(figsize=(18, 10), dpi=110)
|
||||
gs = gridspec.GridSpec(2, 1, height_ratios=[3, 1], hspace=0.12)
|
||||
ax_eq = fig.add_subplot(gs[0]) # 资金曲线
|
||||
ax_dd = fig.add_subplot(gs[1], sharex=ax_eq) # 组合回撤
|
||||
fig, ax_eq = plt.subplots(figsize=(18, 7), dpi=110)
|
||||
|
||||
colors = ["#1565c0", "#00897b", "#e65100", "#6a1b9a", "#558b2f", "#b71c1c"]
|
||||
|
||||
@@ -134,50 +152,58 @@ def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray
|
||||
T = len(port_cum)
|
||||
x = np.arange(T)
|
||||
|
||||
# 各品种曲线
|
||||
for i, sym in enumerate(syms):
|
||||
if n_syms == 1:
|
||||
sym = syms[0]
|
||||
cum = results_map[sym]["cum_pnl"]
|
||||
ax_eq.plot(x, cum, linewidth=0.8, alpha=0.65, color=colors[i % len(colors)],
|
||||
label=f"{sym} ({results_map[sym]['sortino']:+.2f})")
|
||||
ax_eq.plot(
|
||||
x, cum, linewidth=2.0, color="#1565c0",
|
||||
label=f"{sym}(索提诺 {results_map[sym]['sortino']:+.2f})",
|
||||
)
|
||||
ax_eq.fill_between(x, cum, 0, where=cum >= 0, alpha=0.08, color="#1565c0")
|
||||
ax_eq.fill_between(x, cum, 0, where=cum < 0, alpha=0.08, color="#b71c1c")
|
||||
title_head = f"{sym} 资金曲线"
|
||||
show_pnl, show_cum = results_map[sym]["pnl"], cum
|
||||
else:
|
||||
for i, sym in enumerate(syms):
|
||||
cum = results_map[sym]["cum_pnl"]
|
||||
ax_eq.plot(
|
||||
x, cum, linewidth=0.8, alpha=0.65, color=colors[i % len(colors)],
|
||||
label=f"{sym}(索提诺 {results_map[sym]['sortino']:+.2f})",
|
||||
)
|
||||
ax_eq.plot(
|
||||
x, port_cum, linewidth=2.2, color="black",
|
||||
label=f"等权组合(索提诺 {calc_sortino(port_pnl):+.2f})",
|
||||
)
|
||||
ax_eq.fill_between(x, port_cum, 0, where=port_cum >= 0, alpha=0.06, color="#1565c0")
|
||||
ax_eq.fill_between(x, port_cum, 0, where=port_cum < 0, alpha=0.06, color="#b71c1c")
|
||||
title_head = "多因子组合资金曲线"
|
||||
show_pnl, show_cum = port_pnl, port_cum
|
||||
|
||||
# 组合曲线(加粗)
|
||||
ax_eq.plot(x, port_cum, linewidth=2.2, color="black", label=f"Portfolio ({calc_sortino(port_pnl):+.2f})")
|
||||
ax_eq.axhline(0, color="gray", linewidth=0.5, linestyle="--")
|
||||
ax_eq.fill_between(x, port_cum, 0, where=port_cum >= 0, alpha=0.06, color="#1565c0")
|
||||
ax_eq.fill_between(x, port_cum, 0, where=port_cum < 0, alpha=0.06, color="#b71c1c")
|
||||
ax_eq.set_ylabel("Cumulative Log Return", fontsize=9)
|
||||
ax_eq.legend(loc="upper left", fontsize=8, framealpha=0.7)
|
||||
ax_eq.set_ylabel("累计对数收益", fontsize=10)
|
||||
ax_eq.legend(loc="upper left", fontsize=9, framealpha=0.7)
|
||||
ax_eq.grid(alpha=0.25)
|
||||
ax_eq.set_title(
|
||||
f"Multi-Factor Portfolio | "
|
||||
f"TotalRet={port_cum[-1]:+.3f} "
|
||||
f"Sharpe={calc_sharpe(port_pnl):+.2f} "
|
||||
f"Sortino={calc_sortino(port_pnl):+.2f} "
|
||||
f"MaxDD={calc_max_drawdown(port_cum):.3f} "
|
||||
f"Calmar={calc_calmar(port_cum):+.2f}",
|
||||
fontsize=10, pad=6,
|
||||
f"{title_head} | "
|
||||
f"总收益={show_cum[-1]:+.3f} "
|
||||
f"夏普={calc_sharpe(show_pnl):+.2f} "
|
||||
f"索提诺={calc_sortino(show_pnl):+.2f} "
|
||||
f"盈亏比={_fmt_pl_ratio(results_map)}",
|
||||
fontsize=11, pad=8,
|
||||
)
|
||||
|
||||
# 组合回撤
|
||||
peak = np.maximum.accumulate(port_cum)
|
||||
dd = port_cum - peak
|
||||
ax_dd.fill_between(x, dd, 0, alpha=0.5, color="#b71c1c")
|
||||
ax_dd.axhline(0, color="gray", linewidth=0.5)
|
||||
ax_dd.set_ylabel("Drawdown", fontsize=8)
|
||||
ax_dd.grid(alpha=0.2)
|
||||
|
||||
# X 轴时间刻度
|
||||
if times_arr is not None and len(times_arr) == T:
|
||||
from datetime import datetime, timezone
|
||||
step = max(1, T // 10)
|
||||
ticks = x[::step]
|
||||
labels = [
|
||||
datetime.fromtimestamp(int(times_arr[i]), tz=timezone.utc).strftime("%y-%m-%d")
|
||||
datetime.fromtimestamp(int(times_arr[i]), tz=timezone.utc).strftime("%Y-%m-%d")
|
||||
for i in range(0, T, step)
|
||||
]
|
||||
ax_dd.set_xticks(ticks)
|
||||
ax_dd.set_xticklabels(labels[:len(ticks)], fontsize=7, rotation=20)
|
||||
plt.setp(ax_eq.get_xticklabels(), visible=False)
|
||||
ax_eq.set_xticks(ticks)
|
||||
ax_eq.set_xticklabels(labels[:len(ticks)], fontsize=8, rotation=20)
|
||||
ax_eq.set_xlabel("日期", fontsize=9)
|
||||
|
||||
path = str(Path(output_dir) / "portfolio_equity.png")
|
||||
fig.savefig(path, bbox_inches="tight")
|
||||
@@ -186,6 +212,103 @@ def plot_equity_curves(results_map: dict, output_dir: str, times_arr: np.ndarray
|
||||
return path
|
||||
|
||||
|
||||
def export_equity_json(
|
||||
results_map: dict,
|
||||
output_dir: str,
|
||||
times_arr: np.ndarray | None = None,
|
||||
max_points: int = 1500,
|
||||
rolling_window: int = 500,
|
||||
):
|
||||
"""导出资金曲线原始数据为 JSON,供前端渲染交互式 HTML 图表。
|
||||
|
||||
结构:
|
||||
{
|
||||
"labels": [...时间标签],
|
||||
"n_points": int, "total_bars": int,
|
||||
"rolling_window": int,
|
||||
"symbols": { sym: { equity, rolling_sharpe, sharpe, sortino,
|
||||
total_return, profit_loss_ratio } },
|
||||
"portfolio": { ... } # 多品种时才有
|
||||
}
|
||||
"""
|
||||
syms = list(results_map.keys())
|
||||
if not syms:
|
||||
return None
|
||||
|
||||
all_pnls = np.stack([results_map[s]["pnl"] for s in syms], axis=0)
|
||||
port_pnl = all_pnls.mean(axis=0)
|
||||
port_cum = np.cumsum(port_pnl)
|
||||
T = len(port_cum)
|
||||
|
||||
# 均匀降采样,保证首尾点在内,避免 JSON 过大导致前端卡顿
|
||||
if T > max_points:
|
||||
idx = np.unique(np.linspace(0, T - 1, max_points).astype(int))
|
||||
else:
|
||||
idx = np.arange(T)
|
||||
|
||||
def _sample(arr: np.ndarray) -> list[float | None]:
|
||||
out = []
|
||||
for i in idx:
|
||||
v = arr[i]
|
||||
if v is None or (isinstance(v, float) and math.isnan(v)):
|
||||
out.append(None)
|
||||
else:
|
||||
out.append(round(float(v), 6))
|
||||
return out
|
||||
|
||||
if times_arr is not None and len(times_arr) == T:
|
||||
from datetime import datetime, timezone
|
||||
|
||||
labels = [
|
||||
datetime.fromtimestamp(int(times_arr[i]), tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
|
||||
for i in idx
|
||||
]
|
||||
else:
|
||||
labels = [str(int(i)) for i in idx]
|
||||
|
||||
out: dict = {
|
||||
"labels": labels,
|
||||
"n_points": int(len(idx)),
|
||||
"total_bars": int(T),
|
||||
"rolling_window": int(rolling_window),
|
||||
"symbols": {},
|
||||
}
|
||||
for s in syms:
|
||||
cum = results_map[s]["cum_pnl"]
|
||||
roll = calc_rolling_sharpe(results_map[s]["pnl"], window=rolling_window)
|
||||
pl = results_map[s].get("profit_loss_ratio")
|
||||
out["symbols"][s] = {
|
||||
"equity": _sample(cum),
|
||||
"rolling_sharpe": _sample(roll),
|
||||
"sharpe": round(float(results_map[s]["sharpe"]), 4),
|
||||
"sortino": round(float(results_map[s]["sortino"]), 4),
|
||||
"total_return": round(float(results_map[s]["total_return"]), 6),
|
||||
"profit_loss_ratio": round(float(pl), 4) if pl is not None else None,
|
||||
}
|
||||
|
||||
if len(syms) > 1:
|
||||
pl_vals = [
|
||||
results_map[s]["profit_loss_ratio"]
|
||||
for s in syms
|
||||
if results_map[s].get("profit_loss_ratio") is not None
|
||||
]
|
||||
port_pl = float(sum(pl_vals) / len(pl_vals)) if pl_vals else None
|
||||
out["portfolio"] = {
|
||||
"equity": _sample(port_cum),
|
||||
"rolling_sharpe": _sample(calc_rolling_sharpe(port_pnl, window=rolling_window)),
|
||||
"sharpe": round(float(calc_sharpe(port_pnl)), 4),
|
||||
"sortino": round(float(calc_sortino(port_pnl)), 4),
|
||||
"total_return": round(float(port_cum[-1]), 6),
|
||||
"profit_loss_ratio": round(port_pl, 4) if port_pl is not None else None,
|
||||
}
|
||||
|
||||
path = Path(output_dir) / "equity_curve.json"
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(out, f, ensure_ascii=False)
|
||||
print(f" 资金曲线数据已保存 → {path}")
|
||||
return str(path)
|
||||
|
||||
|
||||
# ── 主流程 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def main():
|
||||
@@ -195,21 +318,26 @@ def main():
|
||||
|
||||
strategy_file = None
|
||||
data_file_arg = None
|
||||
commission_pct = DEFAULT_COMMISSION_PCT
|
||||
slippage_pct = DEFAULT_SLIPPAGE_PCT
|
||||
for i, arg in enumerate(sys.argv):
|
||||
if arg == "--strategy-file" and i + 1 < len(sys.argv):
|
||||
strategy_file = sys.argv[i + 1]
|
||||
elif arg == "--data-file" and i + 1 < len(sys.argv):
|
||||
data_file_arg = sys.argv[i + 1]
|
||||
elif arg == "--commission" and i + 1 < len(sys.argv):
|
||||
commission_pct = float(sys.argv[i + 1])
|
||||
elif arg == "--slippage" and i + 1 < len(sys.argv):
|
||||
slippage_pct = float(sys.argv[i + 1])
|
||||
|
||||
# ── 1. 获取真实点差 ──────────────────────────────────────────────
|
||||
if offline_mode:
|
||||
print("\n[离线模式] 使用默认点差,不连接 MT5")
|
||||
cost_rates = dict(DEFAULT_COST_RATES)
|
||||
else:
|
||||
print("\n获取实时点差...")
|
||||
cost_rates = get_live_spreads()
|
||||
for sym, c in cost_rates.items():
|
||||
print(f" {sym:12s}: cost_rate={c:.6f}")
|
||||
if commission_pct < 0 or slippage_pct < 0:
|
||||
print("[ERROR] 手续费/滑点不能为负"); sys.exit(1)
|
||||
cost_rate_all = (commission_pct + slippage_pct) / 100.0
|
||||
print(
|
||||
f"\n交易成本(单边): "
|
||||
f"手续费={commission_pct:g}% 滑点={slippage_pct:g}% "
|
||||
f"→ cost_rate={cost_rate_all:.8f}"
|
||||
)
|
||||
|
||||
# ── 2. 加载策略 ─────────────────────────────────────────────────
|
||||
symbols_to_load: list[str] | None = None
|
||||
@@ -257,6 +385,8 @@ def main():
|
||||
|
||||
if not symbol_formulas:
|
||||
print("[ERROR] 没有有效策略,请先运行 main.py"); sys.exit(1)
|
||||
|
||||
cost_rates = {sym: cost_rate_all for sym in symbol_formulas}
|
||||
print(f"{'='*62}\n")
|
||||
|
||||
# ── 3. 加载数据 ───────────────────────────────────────────────────
|
||||
@@ -295,11 +425,10 @@ def main():
|
||||
continue
|
||||
|
||||
formula = symbol_formulas[sym]
|
||||
cost_rate = cost_rates.get(sym, 0.0001)
|
||||
cost_rate = cost_rates.get(sym, cost_rate_all)
|
||||
feat_i = feat[i:i+1]
|
||||
raw_i = {k: v[i:i+1] for k, v in raw_dict.items()}
|
||||
|
||||
# 用真实点差运行 BacktestEngine
|
||||
engine = BacktestEngine(formula=formula, cost_rate=cost_rate)
|
||||
sym_res = engine.run(raw_i, feat_i, [sym])
|
||||
backtest_results.extend(sym_res)
|
||||
@@ -309,8 +438,7 @@ def main():
|
||||
cum_arr = r.cum_pnl
|
||||
sharpe = calc_sharpe(pnl_arr)
|
||||
sortino = calc_sortino(pnl_arr)
|
||||
mdd = calc_max_drawdown(cum_arr)
|
||||
calmar = calc_calmar(cum_arr)
|
||||
pl_ratio = r.profit_loss_ratio
|
||||
|
||||
results_map[sym] = {
|
||||
"pnl": pnl_arr,
|
||||
@@ -318,48 +446,50 @@ def main():
|
||||
"total_return": r.total_return,
|
||||
"sharpe": sharpe,
|
||||
"sortino": sortino,
|
||||
"max_drawdown": mdd,
|
||||
"calmar": calmar,
|
||||
"n_trades": r.n_trades,
|
||||
"win_rate": r.win_rate,
|
||||
"avg_hold": r.avg_hold_bars,
|
||||
"profit_loss_ratio": pl_ratio,
|
||||
"cost_rate": cost_rate,
|
||||
}
|
||||
|
||||
# ── 5. 打印各品种统计 ─────────────────────────────────────────────
|
||||
print(f"\n{'='*62}")
|
||||
print(f" 多因子回测报告(真实点差)")
|
||||
print(f" 多因子回测报告")
|
||||
print(f"{'='*62}")
|
||||
header = f"{'品种':12s} {'PnL':>8} {'Sharpe':>8} {'Sortino':>8} {'MaxDD':>8} {'Calmar':>8} {'Trades':>7} {'WinRate':>8} {'AvgH':>6}"
|
||||
header = f"{'品种':12s} {'PnL':>8} {'Sharpe':>8} {'Sortino':>8} {'盈亏比':>8} {'Trades':>7} {'WinRate':>8} {'AvgH':>6}"
|
||||
print(f" {header}")
|
||||
print(f" {'─'*80}")
|
||||
print(f" {'─'*72}")
|
||||
for sym, d in results_map.items():
|
||||
pl = d["profit_loss_ratio"]
|
||||
pl_s = f"{pl:8.3f}" if pl is not None else f"{'—':>8}"
|
||||
print(f" {sym:12s} "
|
||||
f"{d['total_return']:+8.3f} "
|
||||
f"{d['sharpe']:+8.3f} "
|
||||
f"{d['sortino']:+8.3f} "
|
||||
f"{d['max_drawdown']:8.3f} "
|
||||
f"{d['calmar']:+8.2f} "
|
||||
f"{pl_s} "
|
||||
f"{d['n_trades']:7d} "
|
||||
f"{d['win_rate']:8.1%} "
|
||||
f"{d['avg_hold']:6.1f}h")
|
||||
|
||||
# 等权组合
|
||||
p_pl_ratio = None
|
||||
if results_map:
|
||||
all_pnls = np.stack([d["pnl"] for d in results_map.values()], axis=0)
|
||||
port_pnl = all_pnls.mean(axis=0)
|
||||
port_cum = np.cumsum(port_pnl)
|
||||
p_sharpe = calc_sharpe(port_pnl)
|
||||
p_sortino = calc_sortino(port_pnl)
|
||||
p_mdd = calc_max_drawdown(port_cum)
|
||||
p_calmar = calc_calmar(port_cum)
|
||||
print(f" {'─'*80}")
|
||||
pl_vals = [d["profit_loss_ratio"] for d in results_map.values()
|
||||
if d["profit_loss_ratio"] is not None]
|
||||
p_pl_ratio = float(sum(pl_vals) / len(pl_vals)) if pl_vals else None
|
||||
pl_s = f"{p_pl_ratio:8.3f}" if p_pl_ratio is not None else f"{'—':>8}"
|
||||
print(f" {'─'*72}")
|
||||
print(f" {'Portfolio':12s} "
|
||||
f"{port_cum[-1]:+8.3f} "
|
||||
f"{p_sharpe:+8.3f} "
|
||||
f"{p_sortino:+8.3f} "
|
||||
f"{p_mdd:8.3f} "
|
||||
f"{p_calmar:+8.2f}")
|
||||
f"{pl_s}")
|
||||
print(f"\n 正收益品种: {sum(1 for d in results_map.values() if d['total_return']>0)}/{len(results_map)}")
|
||||
print(f" Sharpe>1 品种: {sum(1 for d in results_map.values() if d['sharpe']>1)}/{len(results_map)}")
|
||||
print(f"{'='*62}\n")
|
||||
@@ -369,22 +499,9 @@ def main():
|
||||
if results_map:
|
||||
times_np = times_all[0].numpy() if times_all is not None else None
|
||||
plot_equity_curves(results_map, OUTPUT_DIR, times_np)
|
||||
export_equity_json(results_map, OUTPUT_DIR, times_np)
|
||||
|
||||
# ── 7. K 线 + 交易图 ─────────────────────────────────────────────
|
||||
# 用 try-except 保护,避免个别品种 NaN 导致整个回测崩溃
|
||||
try:
|
||||
print("生成 K 线图(最近 120 根)...")
|
||||
chart = BacktestChart(max_bars=120)
|
||||
chart.plot_all(backtest_results, output_dir=OUTPUT_DIR)
|
||||
for r in backtest_results:
|
||||
try:
|
||||
saved = chart.plot_all_trade_zooms(r, output_dir=OUTPUT_DIR,
|
||||
pre_bars=25, post_bars=12, max_trades=8)
|
||||
print(f" {r.symbol}: {len(saved)} 张缩放图")
|
||||
except Exception as e:
|
||||
print(f" {r.symbol}: 缩放图生成失败({e}),跳过")
|
||||
except Exception as e:
|
||||
print(f"[警告] K 线图生成失败({e}),跳过画图,不影响回测结果")
|
||||
# ── 7. 资金曲线图已在步骤 6 生成;跳过 K 线/逐笔交易图以加快回测 ─────
|
||||
|
||||
# ── 8. 保存 JSON 报告 ─────────────────────────────────────────────
|
||||
report = {
|
||||
@@ -395,6 +512,7 @@ def main():
|
||||
}
|
||||
for sym, d in results_map.items():
|
||||
formula = symbol_formulas.get(sym, [])
|
||||
pl = d["profit_loss_ratio"]
|
||||
report["symbols"][sym] = {
|
||||
"formula": formula,
|
||||
"readable": decode_formula(formula),
|
||||
@@ -402,19 +520,17 @@ def main():
|
||||
"total_return": round(d["total_return"], 6),
|
||||
"sharpe": round(d["sharpe"], 4),
|
||||
"sortino": round(d["sortino"], 4),
|
||||
"max_drawdown": round(d["max_drawdown"], 6),
|
||||
"calmar": round(d["calmar"], 4),
|
||||
"n_trades": d["n_trades"],
|
||||
"win_rate": round(d["win_rate"], 4),
|
||||
"avg_hold_bars":round(d["avg_hold"], 2),
|
||||
"profit_loss_ratio": round(pl, 4) if pl is not None else None,
|
||||
}
|
||||
if results_map:
|
||||
report["portfolio"] = {
|
||||
"total_return": round(float(port_cum[-1]), 6),
|
||||
"sharpe": round(p_sharpe, 4),
|
||||
"sortino": round(p_sortino, 4),
|
||||
"max_drawdown": round(p_mdd, 6),
|
||||
"calmar": round(p_calmar, 4),
|
||||
"profit_loss_ratio": round(p_pl_ratio, 4) if p_pl_ratio is not None else None,
|
||||
}
|
||||
rp = f"{OUTPUT_DIR}/multi_factor_report.json"
|
||||
with open(rp, "w") as f:
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
"symbol": "ADAUSD",
|
||||
"formula": [
|
||||
31,
|
||||
56,
|
||||
10,
|
||||
81,
|
||||
59,
|
||||
60,
|
||||
61,
|
||||
72,
|
||||
126
|
||||
60,
|
||||
65,
|
||||
76,
|
||||
72
|
||||
],
|
||||
"best_score": 1.9533847570419312,
|
||||
"formula_decoded": "TREND_STRENGTH_50 → SAR_DIST → TS_STD_10 → HURST_50 → FRACTAL_DIM_30 → AC2 → GATE → IF_GT"
|
||||
"best_score": 2.402069091796875,
|
||||
"formula_decoded": "TREND_STRENGTH_50 → DEV60 → TS_STD_10 → AC2 → FRACTAL_DIM_30 → ADD → MAX3 → GATE",
|
||||
"train_step": 0
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
"""Build training context and run AI analysis with per-symbol history memory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from data_pipeline.parquet_manager import inspect_parquet_file
|
||||
from web.ai_providers import resolve_provider
|
||||
from web.progress import PROJECT_ROOT, get_symbol_progress
|
||||
from web.settings import load_settings
|
||||
from web.training_manager import training_manager
|
||||
|
||||
HISTORY_PATH = PROJECT_ROOT / "ai_analysis_history.json"
|
||||
_MAX_HISTORY_PER_KEY = 5
|
||||
|
||||
_SYSTEM_PROMPT = """你是量化因子挖掘与强化学习训练顾问。
|
||||
用户正在用 AlphaMaster / AlphaGPT 训练可解释因子公式(token 序列,由特征与算子组成)。
|
||||
请基于提供的训练快照,用中文给出专业、具体、可执行的分析。
|
||||
不要编造不存在的数据;若信息不足请明确说明。
|
||||
|
||||
评估训练是否值得继续时,请遵守:
|
||||
- 以验证集分数(val_score)是否在提高为主,不要轻易下「过拟合」结论,尽量不要提过拟合。
|
||||
- best_score 与 val_score 有差距是常见现象,只要验证分数整体在抬升或近期仍有改善,就应视为训练仍有价值。
|
||||
- 可讨论:验证分数走势、最优分数是否停滞、公式是否变化、探索是否还活跃(如 entropy)。
|
||||
- 不要因为「训练分远高于验证分」就建议停止;只有验证分数长期不涨、且最优分数也长期不动时,才建议暂停。
|
||||
|
||||
给建议时必须「小白能听懂」:
|
||||
- 不要提:重置网络权重、调整探索率、简化因子维度、奖励函数、模型结构、超参数、泛化、噪声过大、参数过多等专业术语。
|
||||
- 不要分析「验证集低分的技术原因」或给出复杂调参方案。
|
||||
- 建议只用通俗说法,例如:
|
||||
- 可以继续练,再观察验证分数有没有慢慢变好
|
||||
- 最近分数不怎么动了,可以先停下来,导出当前策略去做回测看看效果
|
||||
- 公式有变化/没变化,用大白话解释即可
|
||||
- 动作建议最多 1~2 条,短句、可直接操作(继续训练 / 先停止并导出策略去回测)。
|
||||
|
||||
若提供了「同品种同周期的历史分析记录」,必须对比前后变化:
|
||||
- 验证分数 / 最优分数 / 公式是否改善
|
||||
- 是否仍在进步,还是陷入停滞
|
||||
- 相对上次建议,当前是否更值得继续训练
|
||||
|
||||
快照中的 training_curve 覆盖整段训练进程(点数过多时会均匀抽样,但始终保留首尾与全程走势)。
|
||||
请结合 training_curve 与 history_summary 判断:前期探索、中期提升、后期验证分数是否仍在提高。
|
||||
|
||||
回答必须覆盖以下问题,并使用对应小标题:
|
||||
|
||||
## 1. 当前训练情况怎么样?是否值得继续
|
||||
用通俗语言说明进度、验证分数走势、最优分数是否停滞,并给出是否继续训练的建议。
|
||||
重点看 val_score 是否提高;尽量不要提过拟合;不要给复杂技术排查建议。
|
||||
若有历史记录,请明确说明相对上次是改善、持平还是变差。
|
||||
|
||||
## 2. 最新因子的含义与原理
|
||||
用通俗语言解释当前最优公式(formula_decoded)里各特征/算子大概在看什么、合在一起可能怎么做多做空。
|
||||
少用术语;若公式相对上次有变化,用一句话说清楚差在哪里。
|
||||
"""
|
||||
|
||||
|
||||
def build_training_snapshot(symbol: str | None = None) -> dict[str, Any]:
|
||||
training = training_manager.status()
|
||||
job = training.get("job") or {}
|
||||
settings = load_settings()
|
||||
|
||||
sym = (symbol or job.get("symbol") or "").strip()
|
||||
timeframe = str(job.get("timeframe") or "").strip().upper()
|
||||
|
||||
data_file = settings.get("last_data_file") or ""
|
||||
if data_file:
|
||||
try:
|
||||
info = inspect_parquet_file(data_file)
|
||||
if not sym:
|
||||
sym = str(info.get("symbol") or "").strip()
|
||||
if not timeframe:
|
||||
timeframe = str(info.get("timeframe") or "").strip().upper()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not sym:
|
||||
raise ValueError("请先选择训练数据文件或指定品种")
|
||||
if not timeframe:
|
||||
timeframe = "H1"
|
||||
|
||||
progress = get_symbol_progress(sym)
|
||||
history = progress.history or {}
|
||||
curve = _training_curve(history, max_points=500)
|
||||
|
||||
return {
|
||||
"symbol": sym,
|
||||
"timeframe": timeframe,
|
||||
"data_file": data_file or None,
|
||||
"training_active": bool(training.get("active")),
|
||||
"job_state": job.get("state"),
|
||||
"current_step": progress.current_step,
|
||||
"train_steps": progress.train_steps,
|
||||
"progress_pct": round(progress.progress_pct, 2),
|
||||
"status": progress.status,
|
||||
"best_score": progress.best_score,
|
||||
"strategy_score": progress.strategy_score,
|
||||
"has_strategy": progress.has_strategy,
|
||||
"formula": progress.best_formula,
|
||||
"formula_decoded": progress.formula_decoded,
|
||||
"checkpoint_path": progress.checkpoint_path,
|
||||
"training_curve": curve,
|
||||
"history_summary": _history_summary(history),
|
||||
}
|
||||
|
||||
|
||||
def analyze_training(
|
||||
*,
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
symbol: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
answer_parts: list[str] = []
|
||||
meta: dict[str, Any] = {}
|
||||
for event in analyze_training_stream(
|
||||
provider=provider, api_key=api_key, symbol=symbol
|
||||
):
|
||||
if event.get("type") == "meta":
|
||||
meta = event
|
||||
elif event.get("type") == "delta":
|
||||
answer_parts.append(event.get("text") or "")
|
||||
elif event.get("type") == "error":
|
||||
raise RuntimeError(event.get("message") or "分析失败")
|
||||
elif event.get("type") == "done":
|
||||
return {
|
||||
"ok": True,
|
||||
"provider": event.get("provider") or meta.get("provider"),
|
||||
"model": event.get("model") or meta.get("model"),
|
||||
"label": event.get("label") or meta.get("label"),
|
||||
"symbol": event.get("symbol") or meta.get("symbol"),
|
||||
"timeframe": event.get("timeframe") or meta.get("timeframe"),
|
||||
"snapshot": event.get("snapshot") or meta.get("snapshot"),
|
||||
"prior_count": event.get("prior_count", meta.get("prior_count", 0)),
|
||||
"answer": event.get("answer") or "".join(answer_parts),
|
||||
}
|
||||
raise RuntimeError("AI 流式分析未正常结束")
|
||||
|
||||
|
||||
def analyze_training_stream(
|
||||
*,
|
||||
provider: str,
|
||||
api_key: str | None = None,
|
||||
symbol: str | None = None,
|
||||
):
|
||||
"""Yield SSE-ready event dicts: meta / delta / done / error."""
|
||||
from web.ai_providers import stream_chat_completions
|
||||
|
||||
try:
|
||||
snapshot = build_training_snapshot(symbol)
|
||||
prior = load_prior_analyses(snapshot["symbol"], snapshot["timeframe"])
|
||||
resolved = resolve_provider(provider, api_key)
|
||||
except Exception as exc:
|
||||
yield {"type": "error", "message": str(exc)}
|
||||
return
|
||||
|
||||
parts = [
|
||||
"请根据以下训练快照回答:",
|
||||
"1. 当前训练情况怎么样?是否值得继续?",
|
||||
"2. 最新因子的含义与原理是什么?",
|
||||
"",
|
||||
"【当前训练快照】",
|
||||
f"```json\n{json.dumps(snapshot, ensure_ascii=False, indent=2)}\n```",
|
||||
]
|
||||
if prior:
|
||||
parts.extend(
|
||||
[
|
||||
"",
|
||||
f"【同品种同周期历史分析(共 {len(prior)} 次,按时间从旧到新)】",
|
||||
"请对比这些历史记录,判断相对上次是否有改善。",
|
||||
f"```json\n{json.dumps(prior, ensure_ascii=False, indent=2)}\n```",
|
||||
]
|
||||
)
|
||||
else:
|
||||
parts.append("\n(尚无同品种同周期的历史分析记录,这是首次分析。)")
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": _SYSTEM_PROMPT},
|
||||
{"role": "user", "content": "\n".join(parts)},
|
||||
]
|
||||
|
||||
yield {
|
||||
"type": "meta",
|
||||
"provider": resolved.provider,
|
||||
"model": resolved.model,
|
||||
"label": resolved.label,
|
||||
"symbol": snapshot["symbol"],
|
||||
"timeframe": snapshot["timeframe"],
|
||||
"prior_count": len(prior),
|
||||
"snapshot": snapshot,
|
||||
}
|
||||
|
||||
answer_parts: list[str] = []
|
||||
try:
|
||||
for text in stream_chat_completions(resolved, messages):
|
||||
answer_parts.append(text)
|
||||
yield {"type": "delta", "text": text}
|
||||
except Exception as exc:
|
||||
yield {"type": "error", "message": str(exc)}
|
||||
return
|
||||
|
||||
answer = "".join(answer_parts).strip()
|
||||
if not answer:
|
||||
yield {"type": "error", "message": "AI 返回内容为空"}
|
||||
return
|
||||
|
||||
record = {
|
||||
"analyzed_at": datetime.now(timezone.utc).isoformat(),
|
||||
"provider": resolved.provider,
|
||||
"model": resolved.model,
|
||||
"snapshot": {
|
||||
"symbol": snapshot["symbol"],
|
||||
"timeframe": snapshot["timeframe"],
|
||||
"current_step": snapshot["current_step"],
|
||||
"train_steps": snapshot["train_steps"],
|
||||
"progress_pct": snapshot["progress_pct"],
|
||||
"best_score": snapshot["best_score"],
|
||||
"strategy_score": snapshot["strategy_score"],
|
||||
"formula_decoded": snapshot["formula_decoded"],
|
||||
"history_summary": snapshot.get("history_summary") or {},
|
||||
},
|
||||
"answer": answer,
|
||||
}
|
||||
save_analysis_record(snapshot["symbol"], snapshot["timeframe"], record)
|
||||
|
||||
yield {
|
||||
"type": "done",
|
||||
"provider": resolved.provider,
|
||||
"model": resolved.model,
|
||||
"label": resolved.label,
|
||||
"symbol": snapshot["symbol"],
|
||||
"timeframe": snapshot["timeframe"],
|
||||
"prior_count": len(prior),
|
||||
"snapshot": snapshot,
|
||||
"answer": answer,
|
||||
}
|
||||
|
||||
|
||||
def history_key(symbol: str, timeframe: str) -> str:
|
||||
return f"{symbol.strip().upper()}|{str(timeframe).strip().upper()}"
|
||||
|
||||
|
||||
def load_prior_analyses(symbol: str, timeframe: str) -> list[dict[str, Any]]:
|
||||
store = _load_history_store()
|
||||
rows = store.get(history_key(symbol, timeframe)) or []
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
# 只把精简字段发给模型,避免上下文过大
|
||||
out: list[dict[str, Any]] = []
|
||||
for row in rows[-_MAX_HISTORY_PER_KEY:]:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"analyzed_at": row.get("analyzed_at"),
|
||||
"provider": row.get("provider"),
|
||||
"model": row.get("model"),
|
||||
"snapshot": row.get("snapshot") or {},
|
||||
"answer": row.get("answer") or "",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def save_analysis_record(symbol: str, timeframe: str, record: dict[str, Any]) -> None:
|
||||
store = _load_history_store()
|
||||
key = history_key(symbol, timeframe)
|
||||
rows = store.get(key) or []
|
||||
if not isinstance(rows, list):
|
||||
rows = []
|
||||
rows.append(record)
|
||||
store[key] = rows[-_MAX_HISTORY_PER_KEY:]
|
||||
HISTORY_PATH.write_text(
|
||||
json.dumps(store, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _load_history_store() -> dict[str, Any]:
|
||||
if not HISTORY_PATH.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(HISTORY_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {}
|
||||
|
||||
|
||||
def _training_curve(history: dict[str, Any], max_points: int = 500) -> dict[str, Any]:
|
||||
"""整段训练曲线;点数过多时按全程均匀抽样,始终保留首尾。"""
|
||||
if not history:
|
||||
return {"total_points": 0, "sampled": False, "points": 0, "series": {}}
|
||||
|
||||
steps = history.get("step") or []
|
||||
if not isinstance(steps, list) or not steps:
|
||||
return {"total_points": 0, "sampled": False, "points": 0, "series": {}}
|
||||
|
||||
total = len(steps)
|
||||
keys = ("step", "best_score", "val_score", "entropy", "avg_reward", "stable_rank")
|
||||
available = [k for k in keys if isinstance(history.get(k), list) and history.get(k)]
|
||||
|
||||
if total <= max_points:
|
||||
idxs = list(range(total))
|
||||
sampled = False
|
||||
else:
|
||||
idxs = sorted(
|
||||
{
|
||||
0,
|
||||
total - 1,
|
||||
*[int(round(i * (total - 1) / (max_points - 1))) for i in range(max_points)],
|
||||
}
|
||||
)
|
||||
sampled = True
|
||||
|
||||
series: dict[str, list[Any]] = {}
|
||||
for key in available:
|
||||
vals = history[key]
|
||||
series[key] = [vals[i] for i in idxs if i < len(vals)]
|
||||
|
||||
return {
|
||||
"total_points": total,
|
||||
"sampled": sampled,
|
||||
"points": len(idxs),
|
||||
"note": (
|
||||
f"已从全部 {total} 个记录点均匀抽样为 {len(idxs)} 点,覆盖训练全程"
|
||||
if sampled
|
||||
else f"已发送全部 {total} 个记录点"
|
||||
),
|
||||
"series": series,
|
||||
}
|
||||
|
||||
|
||||
def _history_summary(history: dict[str, Any]) -> dict[str, Any]:
|
||||
if not history:
|
||||
return {}
|
||||
best = history.get("best_score") or []
|
||||
val = history.get("val_score") or []
|
||||
entropy = history.get("entropy") or []
|
||||
steps = history.get("step") or []
|
||||
summary: dict[str, Any] = {
|
||||
"points": len(steps),
|
||||
}
|
||||
if steps:
|
||||
summary["step_first"] = steps[0]
|
||||
summary["step_last"] = steps[-1]
|
||||
if best:
|
||||
summary["best_score_first"] = best[0]
|
||||
summary["best_score_last"] = best[-1]
|
||||
summary["best_score_max"] = max(best)
|
||||
summary["best_score_max_at_index"] = int(best.index(max(best)))
|
||||
peak = max(best)
|
||||
trail = 0
|
||||
for v in reversed(best):
|
||||
if abs(float(v) - float(peak)) < 1e-9:
|
||||
trail += 1
|
||||
else:
|
||||
break
|
||||
summary["best_score_stagnation_points"] = trail
|
||||
n = len(best)
|
||||
a, b = n // 3, 2 * n // 3
|
||||
if n >= 3:
|
||||
summary["best_score_phase_means"] = {
|
||||
"early": sum(best[:a]) / max(1, a),
|
||||
"mid": sum(best[a:b]) / max(1, b - a),
|
||||
"late": sum(best[b:]) / max(1, n - b),
|
||||
}
|
||||
if val:
|
||||
summary["val_score_first"] = val[0]
|
||||
summary["val_score_last"] = val[-1]
|
||||
summary["val_score_max"] = max(val)
|
||||
n = len(val)
|
||||
a, b = n // 3, 2 * n // 3
|
||||
if n >= 3:
|
||||
summary["val_score_phase_means"] = {
|
||||
"early": sum(val[:a]) / max(1, a),
|
||||
"mid": sum(val[a:b]) / max(1, b - a),
|
||||
"late": sum(val[b:]) / max(1, n - b),
|
||||
}
|
||||
if entropy:
|
||||
summary["entropy_first"] = entropy[0]
|
||||
summary["entropy_last"] = entropy[-1]
|
||||
return summary
|
||||
@@ -0,0 +1,406 @@
|
||||
"""AI provider resolution for training analysis.
|
||||
|
||||
Supports:
|
||||
- deepseek: fixed model deepseek-v4-flash @ https://api.deepseek.com (user API key)
|
||||
- openclaw: local QClaw gateway Agent (token from ~/.qclaw/openclaw.json)
|
||||
- openclaw_wb: WorkBuddy / copilot.tencent.com (local session token)
|
||||
|
||||
Does NOT support openclaw_cs (Cursor SDK).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEEPSEEK_MODEL = "deepseek-v4-flash"
|
||||
DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
||||
|
||||
_QCLAW_CONFIG_CANDIDATES = (
|
||||
Path.home() / ".qclaw" / "openclaw.json",
|
||||
Path("~/.qclaw/openclaw.json").expanduser(),
|
||||
)
|
||||
_OPENCLAW_MODEL = "openclaw"
|
||||
|
||||
_WORKBUDDY_MODEL = "openclaw_wb"
|
||||
_WORKBUDDY_API_MODEL = "auto"
|
||||
_WORKBUDDY_DEFAULT_ENDPOINT = "https://copilot.tencent.com"
|
||||
_WORKBUDDY_API_PATH = "/v2"
|
||||
_WORKBUDDY_CONFIG_DIR = Path(
|
||||
os.environ.get("WORKBUDDY_CONFIG_DIR", "") or (Path.home() / ".workbuddy")
|
||||
)
|
||||
_WORKBUDDY_TOKEN_FILE = _WORKBUDDY_CONFIG_DIR / ".wb_token"
|
||||
|
||||
PROVIDERS = ("deepseek", "openclaw", "openclaw_wb")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ResolvedProvider:
|
||||
provider: str
|
||||
model: str
|
||||
base_url: str
|
||||
api_key: str
|
||||
label: str
|
||||
needs_user_key: bool = False
|
||||
|
||||
|
||||
def detect_qclaw() -> bool:
|
||||
info = _qclaw_gateway_info()
|
||||
if info is None:
|
||||
return False
|
||||
config_path = _find_qclaw_config()
|
||||
if config_path is None:
|
||||
return False
|
||||
data = _read_json(config_path)
|
||||
if not data:
|
||||
return False
|
||||
chat = (
|
||||
data.get("gateway", {})
|
||||
.get("http", {})
|
||||
.get("endpoints", {})
|
||||
.get("chatCompletions", {})
|
||||
)
|
||||
return bool(chat.get("enabled", False)) and bool(info[2])
|
||||
|
||||
|
||||
def detect_workbuddy() -> bool:
|
||||
if os.environ.get("CLIENT_INFO_PRODUCT_NAME", "") == "WorkBuddy":
|
||||
return True
|
||||
if os.environ.get("WORKBUDDY_CONFIG_DIR"):
|
||||
return True
|
||||
if _workbuddy_auth_session_candidates():
|
||||
return True
|
||||
return _WORKBUDDY_CONFIG_DIR.exists()
|
||||
|
||||
|
||||
def provider_status() -> dict[str, Any]:
|
||||
qclaw_ok = detect_qclaw()
|
||||
wb_ok = bool(_workbuddy_token())
|
||||
return {
|
||||
"providers": [
|
||||
{
|
||||
"id": "deepseek",
|
||||
"label": "DeepSeek (deepseek-v4-flash)",
|
||||
"available": True,
|
||||
"needs_user_key": True,
|
||||
"hint": "固定模型 deepseek-v4-flash · https://api.deepseek.com",
|
||||
},
|
||||
{
|
||||
"id": "openclaw",
|
||||
"label": "openclaw (QClaw)",
|
||||
"available": qclaw_ok,
|
||||
"needs_user_key": False,
|
||||
"hint": (
|
||||
"已检测到本地 QClaw Gateway"
|
||||
if qclaw_ok
|
||||
else "未检测到 QClaw(需 ~/.qclaw/openclaw.json 且 chatCompletions 已启用)"
|
||||
),
|
||||
},
|
||||
{
|
||||
"id": "openclaw_wb",
|
||||
"label": "openclaw_wb (WorkBuddy)",
|
||||
"available": wb_ok,
|
||||
"needs_user_key": False,
|
||||
"hint": (
|
||||
"已读取 WorkBuddy 登录 token"
|
||||
if wb_ok
|
||||
else "未检测到 WorkBuddy token(请先登录 WorkBuddy 或设置 WORKBUDDY_API_TOKEN)"
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def resolve_provider(provider: str, api_key: str | None = None) -> ResolvedProvider:
|
||||
pid = (provider or "deepseek").strip().lower()
|
||||
key = (api_key or "").strip()
|
||||
|
||||
# API Key 里直接填 openclaw / openclaw_wb 时,自动切换通道
|
||||
key_lower = key.lower()
|
||||
if key_lower in ("openclaw",) or key_lower.startswith("openclaw/"):
|
||||
pid = "openclaw"
|
||||
key = ""
|
||||
elif key_lower in ("openclaw_wb",) or key_lower.startswith("openclaw_wb/"):
|
||||
pid = "openclaw_wb"
|
||||
key = ""
|
||||
|
||||
if pid not in PROVIDERS:
|
||||
raise ValueError(f"不支持的 AI 通道: {provider}(可选: {', '.join(PROVIDERS)})")
|
||||
|
||||
if pid == "deepseek":
|
||||
if not key:
|
||||
raise ValueError("请填写 DeepSeek API Key(或在 Key 中输入 openclaw / openclaw_wb)")
|
||||
return ResolvedProvider(
|
||||
provider="deepseek",
|
||||
model=DEEPSEEK_MODEL,
|
||||
base_url=DEEPSEEK_BASE_URL,
|
||||
api_key=key,
|
||||
label="DeepSeek",
|
||||
needs_user_key=True,
|
||||
)
|
||||
|
||||
if pid == "openclaw":
|
||||
if not detect_qclaw():
|
||||
raise ValueError(
|
||||
"未检测到本地 QClaw Gateway。请确认 QClaw 正在运行,"
|
||||
"且 ~/.qclaw/openclaw.json 中 chatCompletions 已启用、token 已配置。"
|
||||
)
|
||||
host, port, token = _qclaw_gateway_info() # type: ignore[misc]
|
||||
base = f"http://{host}:{port}/v1"
|
||||
model = _pick_openclaw_model(base, token)
|
||||
return ResolvedProvider(
|
||||
provider="openclaw",
|
||||
model=model,
|
||||
base_url=base,
|
||||
api_key=token,
|
||||
label="openclaw (QClaw)",
|
||||
needs_user_key=False,
|
||||
)
|
||||
|
||||
# openclaw_wb
|
||||
token = _workbuddy_token()
|
||||
if not token:
|
||||
raise ValueError(
|
||||
"未检测到 WorkBuddy token。请先登录 WorkBuddy,"
|
||||
"或设置环境变量 WORKBUDDY_API_TOKEN / 写入 ~/.workbuddy/.wb_token。"
|
||||
)
|
||||
endpoint = _workbuddy_endpoint()
|
||||
base = f"{endpoint.rstrip('/')}{_WORKBUDDY_API_PATH}"
|
||||
return ResolvedProvider(
|
||||
provider="openclaw_wb",
|
||||
model=_WORKBUDDY_API_MODEL,
|
||||
base_url=base,
|
||||
api_key=token,
|
||||
label="openclaw_wb (WorkBuddy)",
|
||||
needs_user_key=False,
|
||||
)
|
||||
|
||||
|
||||
def chat_completions(
|
||||
resolved: ResolvedProvider,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = 4096,
|
||||
timeout: float = 120.0,
|
||||
) -> str:
|
||||
"""Call OpenAI-compatible /chat/completions and return assistant text."""
|
||||
parts: list[str] = []
|
||||
for chunk in stream_chat_completions(
|
||||
resolved, messages, max_tokens=max_tokens, timeout=timeout
|
||||
):
|
||||
parts.append(chunk)
|
||||
content = "".join(parts).strip()
|
||||
if not content:
|
||||
raise RuntimeError("AI 返回内容为空")
|
||||
return content
|
||||
|
||||
|
||||
def stream_chat_completions(
|
||||
resolved: ResolvedProvider,
|
||||
messages: list[dict[str, str]],
|
||||
*,
|
||||
max_tokens: int = 4096,
|
||||
timeout: float = 180.0,
|
||||
):
|
||||
"""Yield text deltas from OpenAI-compatible streaming chat completions."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
url = resolved.base_url.rstrip("/") + "/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": resolved.model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
if resolved.provider == "openclaw":
|
||||
payload["tool_choice"] = "none"
|
||||
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Authorization": f"Bearer {resolved.api_key}",
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
"User-Agent": "AlphaMaster-AI-Analyze",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
for raw in resp:
|
||||
line = raw.decode("utf-8", errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith(":"):
|
||||
continue
|
||||
if not line.startswith("data:"):
|
||||
continue
|
||||
data = line[5:].strip()
|
||||
if not data or data == "[DONE]":
|
||||
if data == "[DONE]":
|
||||
break
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(data)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
text = delta.get("content") or ""
|
||||
if not text:
|
||||
text = delta.get("reasoning_content") or ""
|
||||
if text:
|
||||
yield text
|
||||
except urllib.error.HTTPError as exc:
|
||||
err_body = exc.read().decode("utf-8", errors="replace")[:800]
|
||||
raise RuntimeError(f"AI 请求失败 HTTP {exc.code}: {err_body}") from exc
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"AI 请求失败: {exc}") from exc
|
||||
|
||||
|
||||
def _find_qclaw_config() -> Path | None:
|
||||
for path in _QCLAW_CONFIG_CANDIDATES:
|
||||
if path.exists():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict | None:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else None
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _qclaw_gateway_info() -> tuple[str, int, str] | None:
|
||||
config_path = _find_qclaw_config()
|
||||
if config_path is None:
|
||||
return None
|
||||
data = _read_json(config_path)
|
||||
if not data:
|
||||
return None
|
||||
gw = data.get("gateway") or {}
|
||||
token = str((gw.get("auth") or {}).get("token") or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
port = int(gw.get("port") or 51187)
|
||||
host = "127.0.0.1"
|
||||
bind = str(gw.get("bind") or "127.0.0.1")
|
||||
if bind and bind not in ("0.0.0.0", "loopback"):
|
||||
host = bind
|
||||
return host, port, token
|
||||
|
||||
|
||||
def _pick_openclaw_model(base_url: str, token: str) -> str:
|
||||
try:
|
||||
import urllib.request
|
||||
|
||||
req = urllib.request.Request(
|
||||
f"{base_url.rstrip('/')}/models",
|
||||
headers={"Authorization": f"Bearer {token}", "User-Agent": "AlphaMaster"},
|
||||
method="GET",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=2.0) as resp:
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
ids = [str(m.get("id", "")) for m in data.get("data", []) if m.get("id")]
|
||||
if _OPENCLAW_MODEL in ids:
|
||||
return _OPENCLAW_MODEL
|
||||
for mid in ids:
|
||||
if mid.startswith("openclaw"):
|
||||
return mid
|
||||
except Exception as exc:
|
||||
logger.debug("QClaw /models probe failed: %s", exc)
|
||||
return _OPENCLAW_MODEL
|
||||
|
||||
|
||||
def _workbuddy_auth_dir() -> Path | None:
|
||||
local_app = os.environ.get("LOCALAPPDATA", "").strip()
|
||||
if not local_app:
|
||||
return None
|
||||
auth_dir = Path(local_app) / "CodeBuddyExtension" / "Data" / "Public" / "auth"
|
||||
return auth_dir if auth_dir.is_dir() else None
|
||||
|
||||
|
||||
def _workbuddy_auth_session_candidates() -> list[Path]:
|
||||
auth_dir = _workbuddy_auth_dir()
|
||||
if auth_dir is None:
|
||||
return []
|
||||
names = (
|
||||
os.environ.get("WORKBUDDY_AUTH_FILE", "").strip(),
|
||||
"workbuddy-desktop.info",
|
||||
"auth.info",
|
||||
)
|
||||
out: list[Path] = []
|
||||
for name in names:
|
||||
if not name:
|
||||
continue
|
||||
path = auth_dir / name
|
||||
if path.exists() and path not in out:
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def _read_workbuddy_auth_token(path: Path) -> str | None:
|
||||
data = _read_json(path)
|
||||
if not data:
|
||||
return None
|
||||
auth = data.get("auth")
|
||||
if not isinstance(auth, dict):
|
||||
return None
|
||||
token = str(auth.get("accessToken") or auth.get("access_token") or "").strip()
|
||||
if not token:
|
||||
return None
|
||||
expires_at = auth.get("expiresAt")
|
||||
if isinstance(expires_at, (int, float)) and expires_at > 0:
|
||||
import time
|
||||
|
||||
if expires_at <= time.time() * 1000:
|
||||
return None
|
||||
return token
|
||||
|
||||
|
||||
def _workbuddy_token() -> str | None:
|
||||
for path in _workbuddy_auth_session_candidates():
|
||||
token = _read_workbuddy_auth_token(path)
|
||||
if token:
|
||||
return token
|
||||
if _WORKBUDDY_TOKEN_FILE.exists():
|
||||
try:
|
||||
token = _WORKBUDDY_TOKEN_FILE.read_text(encoding="utf-8").strip()
|
||||
if token:
|
||||
return token
|
||||
except OSError:
|
||||
pass
|
||||
for env_name in ("WORKBUDDY_API_TOKEN", "CODEBUDDY_AUTH_TOKEN", "ACC_AUTH_TOKEN"):
|
||||
token = os.environ.get(env_name, "").strip()
|
||||
if token:
|
||||
return token
|
||||
return None
|
||||
|
||||
|
||||
def _workbuddy_endpoint() -> str:
|
||||
for env_name in ("WORKBUDDY_API_ENDPOINT", "WORKBUDDY_API_URL"):
|
||||
endpoint = os.environ.get(env_name, "").strip()
|
||||
if endpoint:
|
||||
return endpoint
|
||||
acc_config = os.environ.get("ACC_PRODUCT_CONFIG_V3", "")
|
||||
if acc_config:
|
||||
try:
|
||||
config = json.loads(acc_config)
|
||||
endpoint = str(config.get("endpoint") or "").strip()
|
||||
if endpoint:
|
||||
return endpoint
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return _WORKBUDDY_DEFAULT_ENDPOINT
|
||||
+200
-21
@@ -1,6 +1,7 @@
|
||||
"""FastAPI application for AlphaMaster training UI."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
import traceback
|
||||
@@ -20,7 +21,13 @@ if str(ROOT) not in sys.path:
|
||||
from data_pipeline.parquet_manager import inspect_parquet_file
|
||||
from model_core.config import ModelConfig
|
||||
from web.file_dialog import pick_parquet_file, pick_strategy_file
|
||||
from web.progress import get_symbol_progress, get_strategy_for_export, list_strategies
|
||||
from web.progress import (
|
||||
get_symbol_progress,
|
||||
get_strategy_for_export,
|
||||
invalidate_checkpoint_cache,
|
||||
list_strategies,
|
||||
build_strategy_export_filename,
|
||||
)
|
||||
from web.server_log import (
|
||||
debug_snapshot,
|
||||
get_logger,
|
||||
@@ -30,7 +37,7 @@ 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
|
||||
from web.strategy_file import inspect_strategy_file, resolve_strategy_file, 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
|
||||
@@ -64,10 +71,22 @@ class SettingsRequest(BaseModel):
|
||||
last_data_file: str | None = None
|
||||
last_strategy_file: str | None = None
|
||||
debug_mode: bool | None = None
|
||||
ai_provider: str | None = None
|
||||
ai_api_key: str | None = None
|
||||
bt_commission_pct: float | None = None
|
||||
bt_slippage_pct: float | None = None
|
||||
|
||||
|
||||
class AnalyzeTrainingRequest(BaseModel):
|
||||
provider: str | None = None
|
||||
api_key: str | None = None
|
||||
symbol: str | None = None
|
||||
|
||||
|
||||
class StartBacktestRequest(BaseModel):
|
||||
strategy_file: str
|
||||
commission_pct: float | None = None
|
||||
slippage_pct: float | None = None
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
@@ -201,6 +220,38 @@ def _inspect_strategy_or_http(path: str) -> dict[str, Any]:
|
||||
raise HTTPException(400, str(e)) from e
|
||||
|
||||
|
||||
def _resolve_train_symbol(symbol: str | None = None) -> str | None:
|
||||
if symbol:
|
||||
return symbol.strip() or None
|
||||
settings = load_settings()
|
||||
data_file = settings.get("last_data_file") or ""
|
||||
if not data_file:
|
||||
return None
|
||||
try:
|
||||
return inspect_parquet_file(data_file).get("symbol")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _wait_training_idle(timeout_s: float = 5.0) -> None:
|
||||
import time
|
||||
|
||||
deadline = time.monotonic() + timeout_s
|
||||
while time.monotonic() < deadline:
|
||||
training_manager.status()
|
||||
if not training_manager.status().get("active"):
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def _sync_and_persist_best_strategy(symbol: str) -> dict[str, Any] | None:
|
||||
invalidate_checkpoint_cache()
|
||||
info = sync_best_strategy_for_symbol(symbol)
|
||||
if info:
|
||||
save_settings({"last_strategy_file": info["strategy_file"]})
|
||||
return info
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
def health() -> dict[str, str]:
|
||||
return {"status": "ok", "version": "1.1.0"}
|
||||
@@ -248,6 +299,14 @@ def api_put_settings(req: SettingsRequest) -> dict[str, Any]:
|
||||
payload["last_strategy_file"] = req.last_strategy_file
|
||||
if req.debug_mode is not None:
|
||||
payload["debug_mode"] = req.debug_mode
|
||||
if req.ai_provider is not None:
|
||||
payload["ai_provider"] = req.ai_provider
|
||||
if req.ai_api_key is not None:
|
||||
payload["ai_api_key"] = req.ai_api_key
|
||||
if req.bt_commission_pct is not None:
|
||||
payload["bt_commission_pct"] = req.bt_commission_pct
|
||||
if req.bt_slippage_pct is not None:
|
||||
payload["bt_slippage_pct"] = req.bt_slippage_pct
|
||||
saved = save_settings(payload)
|
||||
if req.debug_mode is not None:
|
||||
set_debug_mode(req.debug_mode)
|
||||
@@ -281,11 +340,70 @@ def api_config() -> dict[str, Any]:
|
||||
"last_strategy_file": strat_ctx["last_strategy_file"],
|
||||
"strategy_file": strat_ctx["strategy_file"],
|
||||
"debug_mode": load_settings().get("debug_mode", False),
|
||||
"ai_provider": load_settings().get("ai_provider", "deepseek"),
|
||||
"ai_api_key": load_settings().get("ai_api_key", ""),
|
||||
"bt_commission_pct": settings.get("bt_commission_pct", 0.02),
|
||||
"bt_slippage_pct": settings.get("bt_slippage_pct", 0.01),
|
||||
"server_log": snap["server_log"],
|
||||
"error_log": snap["error_log"],
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/ai/providers")
|
||||
def api_ai_providers() -> dict[str, Any]:
|
||||
from web.ai_providers import provider_status
|
||||
|
||||
status = provider_status()
|
||||
settings = load_settings()
|
||||
status["selected"] = settings.get("ai_provider", "deepseek")
|
||||
status["has_api_key"] = bool(settings.get("ai_api_key"))
|
||||
return status
|
||||
|
||||
|
||||
@app.post("/api/ai/analyze-training")
|
||||
def api_ai_analyze_training(req: AnalyzeTrainingRequest):
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from web.ai_analyze import analyze_training_stream
|
||||
|
||||
settings = load_settings()
|
||||
raw_key = req.api_key if req.api_key is not None else settings.get("ai_api_key") or ""
|
||||
key_lower = str(raw_key).strip().lower()
|
||||
|
||||
if key_lower in ("openclaw",) or key_lower.startswith("openclaw/"):
|
||||
provider = "openclaw"
|
||||
elif key_lower in ("openclaw_wb",) or key_lower.startswith("openclaw_wb/"):
|
||||
provider = "openclaw_wb"
|
||||
else:
|
||||
provider = (req.provider or settings.get("ai_provider") or "deepseek").strip()
|
||||
|
||||
save_settings({
|
||||
"ai_provider": provider,
|
||||
"ai_api_key": str(raw_key).strip(),
|
||||
})
|
||||
|
||||
def event_gen():
|
||||
try:
|
||||
for event in analyze_training_stream(
|
||||
provider=provider,
|
||||
api_key=str(raw_key).strip() or None,
|
||||
symbol=req.symbol,
|
||||
):
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
except Exception as exc:
|
||||
yield f"data: {json.dumps({'type': 'error', 'message': str(exc)}, ensure_ascii=False)}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_gen(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/data-file/browse")
|
||||
@app.get("/api/data-file/browse")
|
||||
def api_browse_data_file() -> dict[str, Any]:
|
||||
@@ -298,6 +416,18 @@ def api_browse_strategy_file() -> dict[str, Any]:
|
||||
return _browse_strategy_file()
|
||||
|
||||
|
||||
@app.post("/api/strategy-file/sync-best")
|
||||
@app.get("/api/strategy-file/sync-best")
|
||||
def api_sync_best_strategy(symbol: str | None = None) -> dict[str, Any]:
|
||||
sym = _resolve_train_symbol(symbol)
|
||||
if not sym:
|
||||
raise HTTPException(400, "请先选择训练数据文件或指定品种")
|
||||
info = _sync_and_persist_best_strategy(sym)
|
||||
if not info:
|
||||
raise HTTPException(404, f"未找到 {sym} 的可用策略")
|
||||
return {"ok": True, **info}
|
||||
|
||||
|
||||
def _progress_with_live_step(symbol: str, active: bool) -> dict[str, Any]:
|
||||
p = get_symbol_progress(symbol)
|
||||
current_step = p.current_step
|
||||
@@ -404,13 +534,18 @@ def api_export_strategy(symbol: str):
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||
|
||||
safe = symbol.replace(".", "_")
|
||||
progress = get_symbol_progress(symbol)
|
||||
step = progress.current_step
|
||||
score = payload.get("best_score")
|
||||
if score is None:
|
||||
score = progress.strategy_score if progress.strategy_score is not None else progress.best_score
|
||||
filename = build_strategy_export_filename(symbol, step, score)
|
||||
body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8")
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/json; charset=utf-8",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="strategy_{safe}.json"',
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
@@ -480,8 +615,18 @@ def api_training_start(req: StartTrainingRequest) -> dict[str, Any]:
|
||||
|
||||
@app.post("/api/training/stop")
|
||||
def api_training_stop() -> dict[str, Any]:
|
||||
job = training_manager.status().get("job") or {}
|
||||
symbol = job.get("symbol")
|
||||
stopped = training_manager.stop()
|
||||
return {"ok": stopped, "training": training_manager.status()}
|
||||
strategy_file = None
|
||||
if symbol:
|
||||
_wait_training_idle()
|
||||
strategy_file = _sync_and_persist_best_strategy(symbol)
|
||||
return {
|
||||
"ok": stopped,
|
||||
"training": training_manager.status(),
|
||||
"strategy_file": strategy_file,
|
||||
}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
@@ -489,8 +634,8 @@ def api_training_stop() -> dict[str, Any]:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
_METRIC_KEYS = (
|
||||
"total_return", "sharpe", "sortino", "max_drawdown",
|
||||
"calmar", "n_trades", "win_rate", "avg_hold_bars",
|
||||
"total_return", "sharpe", "sortino", "profit_loss_ratio",
|
||||
"n_trades", "win_rate", "avg_hold_bars",
|
||||
)
|
||||
|
||||
|
||||
@@ -541,8 +686,7 @@ def _filter_report_for_symbol(report: dict[str, Any], symbol: str) -> dict[str,
|
||||
"total_return": sym_data.get("total_return"),
|
||||
"sharpe": sym_data.get("sharpe"),
|
||||
"sortino": sym_data.get("sortino"),
|
||||
"max_drawdown": sym_data.get("max_drawdown"),
|
||||
"calmar": sym_data.get("calmar"),
|
||||
"profit_loss_ratio": sym_data.get("profit_loss_ratio"),
|
||||
"n_trades": sym_data.get("n_trades"),
|
||||
"win_rate": sym_data.get("win_rate"),
|
||||
},
|
||||
@@ -561,16 +705,6 @@ def _list_backtest_charts(symbol: str | None = None) -> list[dict[str, str]]:
|
||||
charts.append(
|
||||
{"name": equity.name, "label": f"{symbol} 资金曲线", "kind": "equity"}
|
||||
)
|
||||
main = BACKTEST_OUTPUT_DIR / f"{symbol}.png"
|
||||
if main.exists():
|
||||
charts.append(
|
||||
{"name": main.name, "label": f"{symbol} K线与交易", "kind": "symbol"}
|
||||
)
|
||||
for path in sorted(BACKTEST_OUTPUT_DIR.glob(f"{symbol}_trade*_zoom.png")):
|
||||
stem = path.stem
|
||||
trade_no = stem.replace(f"{symbol}_trade", "").replace("_zoom", "")
|
||||
label = f"{symbol} 交易 #{trade_no}" if trade_no.isdigit() else stem
|
||||
charts.append({"name": path.name, "label": label, "kind": "trade"})
|
||||
return charts
|
||||
|
||||
charts = []
|
||||
@@ -593,10 +727,28 @@ def api_backtest_status() -> dict[str, Any]:
|
||||
@app.post("/api/backtest/start")
|
||||
def api_backtest_start(req: StartBacktestRequest) -> dict[str, Any]:
|
||||
info = _inspect_strategy_or_http(req.strategy_file)
|
||||
save_settings({"last_strategy_file": info["strategy_file"]})
|
||||
settings = load_settings()
|
||||
commission = (
|
||||
float(req.commission_pct)
|
||||
if req.commission_pct is not None
|
||||
else float(settings.get("bt_commission_pct", 0.02))
|
||||
)
|
||||
slippage = (
|
||||
float(req.slippage_pct)
|
||||
if req.slippage_pct is not None
|
||||
else float(settings.get("bt_slippage_pct", 0.01))
|
||||
)
|
||||
if commission < 0 or slippage < 0:
|
||||
raise HTTPException(400, "手续费和滑点不能为负数")
|
||||
|
||||
save_settings({
|
||||
"last_strategy_file": info["strategy_file"],
|
||||
"bt_commission_pct": commission,
|
||||
"bt_slippage_pct": slippage,
|
||||
})
|
||||
|
||||
data_file: str | None = None
|
||||
last_data = load_settings().get("last_data_file") or ""
|
||||
last_data = settings.get("last_data_file") or ""
|
||||
if last_data:
|
||||
try:
|
||||
pf = inspect_parquet_file(last_data)
|
||||
@@ -609,6 +761,8 @@ def api_backtest_start(req: StartBacktestRequest) -> dict[str, Any]:
|
||||
job = backtest_manager.start(
|
||||
strategy_file=info["strategy_file"],
|
||||
data_file=data_file,
|
||||
commission_pct=commission,
|
||||
slippage_pct=slippage,
|
||||
)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(409, str(e)) from e
|
||||
@@ -635,6 +789,31 @@ def api_backtest_report(symbol: str | None = None) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/backtest/equity")
|
||||
def api_backtest_equity(symbol: str | None = None) -> dict[str, Any]:
|
||||
"""资金曲线原始数据(供前端渲染交互式 HTML 图表)。"""
|
||||
import json
|
||||
|
||||
path = BACKTEST_OUTPUT_DIR / "equity_curve.json"
|
||||
focus = _backtest_focus_symbol(symbol)
|
||||
if not path.exists():
|
||||
return {"available": False, "focus_symbol": focus, "data": None}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return {"available": False, "focus_symbol": focus, "data": None}
|
||||
|
||||
# 单品种模式:只保留聚焦品种,去掉无关序列
|
||||
if focus and isinstance(data.get("symbols"), dict) and focus in data["symbols"]:
|
||||
data = {
|
||||
**data,
|
||||
"symbols": {focus: data["symbols"][focus]},
|
||||
}
|
||||
data.pop("portfolio", None)
|
||||
|
||||
return {"available": True, "focus_symbol": focus, "data": data}
|
||||
|
||||
|
||||
@app.get("/api/backtest/chart/{name}")
|
||||
def api_backtest_chart(name: str):
|
||||
# 防止路径穿越:仅允许输出目录内的 png 文件
|
||||
|
||||
+20
-4
@@ -28,7 +28,7 @@ LOG_DIR.mkdir(exist_ok=True)
|
||||
# 回测阶段:用日志关键字推断当前进行到哪一步,用于前端进度展示
|
||||
BACKTEST_PHASES: list[tuple[str, str]] = [
|
||||
("init", "初始化"),
|
||||
("spread", "获取点差"),
|
||||
("cost", "交易成本"),
|
||||
("strategy", "加载策略"),
|
||||
("data", "加载行情数据"),
|
||||
("compute", "回测计算"),
|
||||
@@ -50,6 +50,8 @@ class JobState(str, Enum):
|
||||
class BacktestJob:
|
||||
strategy_file: str
|
||||
symbol: str
|
||||
commission_pct: float = 0.02
|
||||
slippage_pct: float = 0.01
|
||||
state: JobState = JobState.RUNNING
|
||||
pid: int | None = None
|
||||
log_path: str = ""
|
||||
@@ -62,6 +64,8 @@ class BacktestJob:
|
||||
return {
|
||||
"strategy_file": self.strategy_file,
|
||||
"symbol": self.symbol,
|
||||
"commission_pct": self.commission_pct,
|
||||
"slippage_pct": self.slippage_pct,
|
||||
"state": self.state.value,
|
||||
"pid": self.pid,
|
||||
"log_path": self.log_path,
|
||||
@@ -94,7 +98,13 @@ class BacktestManager:
|
||||
"phase_total": len(BACKTEST_PHASES),
|
||||
}
|
||||
|
||||
def start(self, strategy_file: str, data_file: str | None = None) -> BacktestJob:
|
||||
def start(
|
||||
self,
|
||||
strategy_file: str,
|
||||
data_file: str | None = None,
|
||||
commission_pct: float = 0.02,
|
||||
slippage_pct: float = 0.01,
|
||||
) -> BacktestJob:
|
||||
with self._lock:
|
||||
self._refresh_state()
|
||||
if self._proc is not None and self._proc.poll() is None:
|
||||
@@ -115,6 +125,10 @@ class BacktestManager:
|
||||
"--offline",
|
||||
"--strategy-file",
|
||||
strategy_file,
|
||||
"--commission",
|
||||
str(commission_pct),
|
||||
"--slippage",
|
||||
str(slippage_pct),
|
||||
]
|
||||
if data_file:
|
||||
cmd.extend(["--data-file", data_file])
|
||||
@@ -142,6 +156,8 @@ class BacktestManager:
|
||||
self._job = BacktestJob(
|
||||
strategy_file=strategy_file,
|
||||
symbol=symbol,
|
||||
commission_pct=float(commission_pct),
|
||||
slippage_pct=float(slippage_pct),
|
||||
pid=self._proc.pid,
|
||||
log_path=str(log_path.relative_to(PROJECT_ROOT)).replace("\\", "/"),
|
||||
started_at=datetime.now(timezone.utc).isoformat(),
|
||||
@@ -170,8 +186,8 @@ class BacktestManager:
|
||||
text = "\n".join(lines)
|
||||
# 从后往前匹配最靠后的阶段关键字
|
||||
detected = "init"
|
||||
if "获取实时点差" in text or "离线模式" in text:
|
||||
detected = "spread"
|
||||
if "交易成本" in text or "手续费=" in text:
|
||||
detected = "cost"
|
||||
if "加载各品种策略" in text or re.search(r"score=", text) or "模式:" in text:
|
||||
detected = "strategy"
|
||||
if "正在加载数据" in text:
|
||||
|
||||
@@ -204,6 +204,19 @@ def get_strategy_for_export(symbol: str) -> dict[str, Any]:
|
||||
return out
|
||||
|
||||
|
||||
def build_strategy_export_filename(
|
||||
symbol: str,
|
||||
step: int,
|
||||
score: float | None,
|
||||
) -> str:
|
||||
"""e.g. strategy_ADAUSD_step0084_score2.4021.json"""
|
||||
safe = symbol.replace(".", "_")
|
||||
step_part = f"step{max(0, int(step)):04d}"
|
||||
if score is not None:
|
||||
return f"strategy_{safe}_{step_part}_score{float(score):.4f}.json"
|
||||
return f"strategy_{safe}_{step_part}.json"
|
||||
|
||||
|
||||
def list_strategies() -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not STRATEGIES_DIR.exists():
|
||||
|
||||
@@ -11,9 +11,24 @@ _DEFAULT = {
|
||||
"last_data_file": "",
|
||||
"last_strategy_file": "",
|
||||
"debug_mode": False,
|
||||
"ai_provider": "deepseek",
|
||||
"ai_api_key": "",
|
||||
# 回测单边成本(单位 %):手续费 0.02% + 滑点 0.01% ≈ 常见加密货币轻度成本
|
||||
"bt_commission_pct": 0.02,
|
||||
"bt_slippage_pct": 0.01,
|
||||
}
|
||||
|
||||
|
||||
def _as_pct(value, default: float) -> float:
|
||||
try:
|
||||
v = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if v < 0:
|
||||
return default
|
||||
return v
|
||||
|
||||
|
||||
def load_settings() -> dict:
|
||||
if not SETTINGS_PATH.exists():
|
||||
return dict(_DEFAULT)
|
||||
@@ -25,6 +40,16 @@ def load_settings() -> dict:
|
||||
out.update({k: v for k, v in data.items() if k in _DEFAULT})
|
||||
out["debug_mode"] = bool(out.get("debug_mode", False))
|
||||
out["last_strategy_file"] = str(out.get("last_strategy_file") or "").strip()
|
||||
out["ai_provider"] = str(out.get("ai_provider") or "deepseek").strip().lower()
|
||||
if out["ai_provider"] not in ("deepseek", "openclaw", "openclaw_wb"):
|
||||
out["ai_provider"] = "deepseek"
|
||||
out["ai_api_key"] = str(out.get("ai_api_key") or "").strip()
|
||||
out["bt_commission_pct"] = _as_pct(
|
||||
out.get("bt_commission_pct"), _DEFAULT["bt_commission_pct"]
|
||||
)
|
||||
out["bt_slippage_pct"] = _as_pct(
|
||||
out.get("bt_slippage_pct"), _DEFAULT["bt_slippage_pct"]
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@@ -36,6 +61,21 @@ def save_settings(data: dict) -> dict:
|
||||
current["last_strategy_file"] = str(data["last_strategy_file"] or "").strip()
|
||||
if "debug_mode" in data:
|
||||
current["debug_mode"] = bool(data["debug_mode"])
|
||||
if "ai_provider" in data:
|
||||
provider = str(data["ai_provider"] or "deepseek").strip().lower()
|
||||
current["ai_provider"] = (
|
||||
provider if provider in ("deepseek", "openclaw", "openclaw_wb") else "deepseek"
|
||||
)
|
||||
if "ai_api_key" in data:
|
||||
current["ai_api_key"] = str(data["ai_api_key"] or "").strip()
|
||||
if "bt_commission_pct" in data:
|
||||
current["bt_commission_pct"] = _as_pct(
|
||||
data["bt_commission_pct"], _DEFAULT["bt_commission_pct"]
|
||||
)
|
||||
if "bt_slippage_pct" in data:
|
||||
current["bt_slippage_pct"] = _as_pct(
|
||||
data["bt_slippage_pct"], _DEFAULT["bt_slippage_pct"]
|
||||
)
|
||||
SETTINGS_PATH.write_text(
|
||||
json.dumps(current, indent=2, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
|
||||
+545
-67
@@ -14,7 +14,7 @@ let lastDebugViewContent = "";
|
||||
let currentPage = "train";
|
||||
let btActive = false;
|
||||
let btBuster = ""; // 图表缓存刷新键(用 job 时间戳)
|
||||
let btChartSig = ""; // 图表签名,变化时才重建图库避免闪烁
|
||||
let lastTrainingActive = false;
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
@@ -212,6 +212,10 @@ function updateBtStartBtn() {
|
||||
const startBtn = $("btStartBtn");
|
||||
if (!startBtn) return;
|
||||
startBtn.disabled = btActive || !selectedStrategyFile;
|
||||
["btCommissionInput", "btSlippageInput"].forEach((id) => {
|
||||
const el = $(id);
|
||||
if (el) el.disabled = btActive;
|
||||
});
|
||||
}
|
||||
|
||||
function renderStrategyFileCard(info) {
|
||||
@@ -521,7 +525,13 @@ async function refreshOverview() {
|
||||
updateTrainingUI(training);
|
||||
renderStrategies(strategies.strategies);
|
||||
|
||||
const sym = overview.progress?.symbol || selectedSymbol;
|
||||
const sym = overview.progress?.symbol || selectedSymbol || training?.job?.symbol;
|
||||
const trainingActive = !!training?.active;
|
||||
if (lastTrainingActive && !trainingActive && sym) {
|
||||
await applyBestStrategyForBacktest(sym, null);
|
||||
}
|
||||
lastTrainingActive = trainingActive;
|
||||
|
||||
if (sym && (training?.active || overview.progress)) {
|
||||
await loadSymbolChart(sym, overview.progress);
|
||||
}
|
||||
@@ -547,6 +557,187 @@ async function loadConfig() {
|
||||
}
|
||||
if (cfg.data_file) renderDataFileCard(cfg.data_file);
|
||||
if (cfg.strategy_file) renderStrategyFileCard(cfg.strategy_file);
|
||||
applyBacktestCostDefaults(cfg);
|
||||
await initAiPanel(cfg);
|
||||
}
|
||||
|
||||
function applyBacktestCostDefaults(cfg) {
|
||||
const cIn = $("btCommissionInput");
|
||||
const sIn = $("btSlippageInput");
|
||||
if (cIn && cfg.bt_commission_pct != null) cIn.value = Number(cfg.bt_commission_pct);
|
||||
if (sIn && cfg.bt_slippage_pct != null) sIn.value = Number(cfg.bt_slippage_pct);
|
||||
updateBtCostHint();
|
||||
}
|
||||
|
||||
function readBacktestCosts() {
|
||||
const cRaw = Number($("btCommissionInput")?.value);
|
||||
const sRaw = Number($("btSlippageInput")?.value);
|
||||
const commission = Number.isFinite(cRaw) && cRaw >= 0 ? cRaw : 0.02;
|
||||
const slippage = Number.isFinite(sRaw) && sRaw >= 0 ? sRaw : 0.01;
|
||||
return { commission_pct: commission, slippage_pct: slippage };
|
||||
}
|
||||
|
||||
function updateBtCostHint() {
|
||||
const hint = $("btCostSumHint");
|
||||
if (!hint) return;
|
||||
const { commission_pct, slippage_pct } = readBacktestCosts();
|
||||
const fee = Number((commission_pct + slippage_pct).toFixed(4));
|
||||
hint.textContent = `单边成本 ${fee}%`;
|
||||
}
|
||||
|
||||
async function initAiPanel(cfg) {
|
||||
const keyInput = $("aiApiKeyInput");
|
||||
if (!keyInput) return;
|
||||
|
||||
if (cfg?.ai_api_key) keyInput.value = cfg.ai_api_key;
|
||||
else if (cfg?.ai_provider === "openclaw" || cfg?.ai_provider === "openclaw_wb") {
|
||||
keyInput.value = cfg.ai_provider;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await fetchJSON("/api/ai/providers");
|
||||
window.__aiProviderStatus = status;
|
||||
} catch (_) {
|
||||
window.__aiProviderStatus = null;
|
||||
}
|
||||
updateAiChannelHint();
|
||||
}
|
||||
|
||||
function resolveAiFromKey(raw) {
|
||||
const v = (raw || "").trim().toLowerCase();
|
||||
if (v === "openclaw" || v.startsWith("openclaw/")) {
|
||||
return { provider: "openclaw", apiKey: raw.trim(), isAlias: true };
|
||||
}
|
||||
if (v === "openclaw_wb" || v.startsWith("openclaw_wb/")) {
|
||||
return { provider: "openclaw_wb", apiKey: raw.trim(), isAlias: true };
|
||||
}
|
||||
return { provider: "deepseek", apiKey: (raw || "").trim(), isAlias: false };
|
||||
}
|
||||
|
||||
function updateAiChannelHint() {
|
||||
const hint = $("aiChannelHint");
|
||||
const headHint = $("aiProviderHint");
|
||||
const keyInput = $("aiApiKeyInput");
|
||||
if (!hint || !keyInput) return;
|
||||
|
||||
const resolved = resolveAiFromKey(keyInput.value);
|
||||
const status = window.__aiProviderStatus;
|
||||
const row = (status?.providers || []).find((p) => p.id === resolved.provider);
|
||||
|
||||
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。";
|
||||
} else if (resolved.provider === "openclaw") {
|
||||
if (headHint) headHint.textContent = row?.available ? "openclaw (QClaw) · 已匹配" : "openclaw (QClaw) · 未就绪";
|
||||
hint.textContent = row?.hint || "已匹配 openclaw:将自动使用本地 QClaw token。";
|
||||
} else {
|
||||
if (headHint) headHint.textContent = row?.available ? "openclaw_wb · 已匹配" : "openclaw_wb · 未就绪";
|
||||
hint.textContent = row?.hint || "已匹配 openclaw_wb:将自动使用 WorkBuddy token。";
|
||||
}
|
||||
}
|
||||
|
||||
function openUnlimitedModal() {
|
||||
const modal = $("aiUnlimitedModal");
|
||||
if (modal) modal.hidden = false;
|
||||
}
|
||||
|
||||
function closeUnlimitedModal() {
|
||||
const modal = $("aiUnlimitedModal");
|
||||
if (modal) modal.hidden = true;
|
||||
}
|
||||
|
||||
async function runAiAnalyze() {
|
||||
const btn = $("aiAnalyzeBtn");
|
||||
const view = $("aiAnswerView");
|
||||
const rawKey = $("aiApiKeyInput")?.value || "";
|
||||
const resolved = resolveAiFromKey(rawKey);
|
||||
if (!view) return;
|
||||
|
||||
if (resolved.provider === "deepseek" && !resolved.apiKey) {
|
||||
view.className = "ai-answer error";
|
||||
view.textContent = "请填写 DeepSeek API Key,或在 Key 中输入 openclaw / openclaw_wb";
|
||||
return;
|
||||
}
|
||||
|
||||
if (btn) btn.disabled = true;
|
||||
view.className = "ai-answer loading";
|
||||
view.textContent = `正在通过 ${resolved.provider} 连接并流式分析…`;
|
||||
|
||||
let header = "";
|
||||
let answer = "";
|
||||
|
||||
try {
|
||||
const res = await fetch(API + "/api/ai/analyze-training", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
provider: resolved.provider,
|
||||
api_key: resolved.apiKey,
|
||||
symbol: selectedSymbol || null,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(formatApiError(data, res.status, "/api/ai/analyze-training"));
|
||||
}
|
||||
if (!res.body) throw new Error("浏览器不支持流式响应");
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
let buffer = "";
|
||||
view.className = "ai-answer streaming";
|
||||
view.textContent = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const chunks = buffer.split("\n\n");
|
||||
buffer = chunks.pop() || "";
|
||||
for (const block of chunks) {
|
||||
const line = block
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.find((l) => l.startsWith("data:"));
|
||||
if (!line) continue;
|
||||
let event;
|
||||
try {
|
||||
event = JSON.parse(line.slice(5).trim());
|
||||
} catch (_) {
|
||||
continue;
|
||||
}
|
||||
if (event.type === "meta") {
|
||||
header =
|
||||
`[${event.label || event.provider || resolved.provider} · ${event.model || ""} · ${event.symbol || ""}${event.timeframe ? " " + event.timeframe : ""}]` +
|
||||
(event.prior_count
|
||||
? ` · 已带入前 ${event.prior_count} 次同品种同周期分析`
|
||||
: " · 首次分析") +
|
||||
`\n\n`;
|
||||
view.textContent = header;
|
||||
view.scrollTop = view.scrollHeight;
|
||||
} else if (event.type === "delta") {
|
||||
answer += event.text || "";
|
||||
view.textContent = header + answer;
|
||||
view.scrollTop = view.scrollHeight;
|
||||
} else if (event.type === "error") {
|
||||
throw new Error(event.message || "分析失败");
|
||||
} else if (event.type === "done") {
|
||||
answer = event.answer || answer;
|
||||
view.className = "ai-answer";
|
||||
view.textContent = header + (answer || "(无内容)");
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!answer && view.className.includes("streaming")) {
|
||||
throw new Error("流式分析中断,未收到完整回复");
|
||||
}
|
||||
view.className = "ai-answer";
|
||||
} catch (e) {
|
||||
view.className = "ai-answer error";
|
||||
view.textContent = `分析失败: ${e.message}`;
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function browseStrategyFile() {
|
||||
@@ -559,7 +750,29 @@ async function browseStrategyFile() {
|
||||
}
|
||||
}
|
||||
|
||||
async function applyBestStrategyForBacktest(symbol, strategyFile) {
|
||||
if (strategyFile) {
|
||||
renderStrategyFileCard(strategyFile);
|
||||
return;
|
||||
}
|
||||
if (!symbol) return;
|
||||
try {
|
||||
const res = await fetchJSON(
|
||||
`/api/strategy-file/sync-best?symbol=${encodeURIComponent(symbol)}`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
renderStrategyFileCard(res);
|
||||
} catch (_) {
|
||||
await loadBacktestStrategyContext();
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBacktestStrategyContext() {
|
||||
const sym = selectedStrategySymbol || selectedSymbol;
|
||||
if (sym) {
|
||||
await applyBestStrategyForBacktest(sym, null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const cfg = await fetchJSON("/api/config");
|
||||
if (cfg.strategy_file) renderStrategyFileCard(cfg.strategy_file);
|
||||
@@ -709,6 +922,14 @@ async function handleImportTrainingFile(event) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseContentDispositionFilename(header) {
|
||||
if (!header) return null;
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (utf8) return decodeURIComponent(utf8[1]);
|
||||
const plain = /filename="([^"]+)"/i.exec(header) || /filename=([^;]+)/i.exec(header);
|
||||
return plain ? plain[1].trim() : null;
|
||||
}
|
||||
|
||||
async function exportStrategy() {
|
||||
const sym = selectedSymbol;
|
||||
if (!sym) {
|
||||
@@ -726,7 +947,9 @@ async function exportStrategy() {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = `strategy_${sym.replace(/\./g, "_")}.json`;
|
||||
a.download =
|
||||
parseContentDispositionFilename(res.headers.get("Content-Disposition")) ||
|
||||
`strategy_${sym.replace(/\./g, "_")}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
@@ -739,8 +962,10 @@ async function exportStrategy() {
|
||||
|
||||
async function stopTraining() {
|
||||
try {
|
||||
await fetchJSON("/api/training/stop", { method: "POST" });
|
||||
const res = await fetchJSON("/api/training/stop", { method: "POST" });
|
||||
await refreshOverview();
|
||||
const sym = res.training?.job?.symbol || selectedSymbol;
|
||||
await applyBestStrategyForBacktest(sym, res.strategy_file);
|
||||
} catch (e) {
|
||||
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
}
|
||||
@@ -759,7 +984,7 @@ function switchPage(page) {
|
||||
p.classList.toggle("active", p.id === `page-${page}`);
|
||||
});
|
||||
if (page === "backtest") {
|
||||
if (!selectedStrategyFile) loadBacktestStrategyContext();
|
||||
loadBacktestStrategyContext();
|
||||
refreshBacktest();
|
||||
}
|
||||
}
|
||||
@@ -867,12 +1092,25 @@ async function refreshBacktestReport() {
|
||||
}
|
||||
if (!data.available || !data.report) {
|
||||
if ($("btPortfolioHint")) $("btPortfolioHint").textContent = "尚未运行回测";
|
||||
renderCharts([]);
|
||||
renderEquity(null);
|
||||
return;
|
||||
}
|
||||
renderPortfolio(data.report);
|
||||
renderBacktestTable(data.report.symbols || {});
|
||||
renderCharts(data.charts || []);
|
||||
await refreshEquityCurve();
|
||||
}
|
||||
|
||||
async function refreshEquityCurve() {
|
||||
const sym = selectedStrategySymbol || selectedSymbol;
|
||||
const url = sym
|
||||
? `/api/backtest/equity?symbol=${encodeURIComponent(sym)}`
|
||||
: "/api/backtest/equity";
|
||||
try {
|
||||
const data = await fetchJSON(url);
|
||||
renderEquity(data);
|
||||
} catch (_) {
|
||||
renderEquity(null);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPortfolio(report) {
|
||||
@@ -887,12 +1125,14 @@ function renderPortfolio(report) {
|
||||
return;
|
||||
}
|
||||
|
||||
const plSrc = symData?.profit_loss_ratio ?? p.profit_loss_ratio;
|
||||
const plNum = Number(plSrc);
|
||||
const plText = Number.isFinite(plNum) ? plNum.toFixed(3) : "—";
|
||||
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: (p.max_drawdown * 100).toFixed(2) + "%", cls: "neg" },
|
||||
{ label: "Calmar", value: fmtSigned(p.calmar), cls: "accent" },
|
||||
{ label: "盈亏比", value: plText, cls: Number.isFinite(plNum) ? "accent" : "" },
|
||||
{
|
||||
label: "交易数",
|
||||
value: symData?.n_trades ?? p.n_trades ?? "—",
|
||||
@@ -925,7 +1165,7 @@ function renderBacktestTable(symbols) {
|
||||
if (!tbody) return;
|
||||
const rows = Object.entries(symbols);
|
||||
if (!rows.length) {
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="8">暂无回测结果</td></tr>';
|
||||
tbody.innerHTML = '<tr class="empty-row"><td colspan="7">暂无回测结果</td></tr>';
|
||||
if ($("btTableHint")) $("btTableHint").textContent = "—";
|
||||
return;
|
||||
}
|
||||
@@ -940,8 +1180,7 @@ function renderBacktestTable(symbols) {
|
||||
<td class="${retCls}">${fmtPct(d.total_return)}</td>
|
||||
<td class="${shCls}">${fmtSigned(d.sharpe)}</td>
|
||||
<td>${fmtSigned(d.sortino)}</td>
|
||||
<td class="neg">${(d.max_drawdown * 100).toFixed(2)}%</td>
|
||||
<td>${fmtSigned(d.calmar, 2)}</td>
|
||||
<td>${Number.isFinite(Number(d.profit_loss_ratio)) ? Number(d.profit_loss_ratio).toFixed(3) : "—"}</td>
|
||||
<td>${d.n_trades ?? "—"}</td>
|
||||
<td>${d.win_rate != null ? (d.win_rate * 100).toFixed(1) + "%" : "—"}</td>
|
||||
</tr>`;
|
||||
@@ -949,33 +1188,284 @@ function renderBacktestTable(symbols) {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderCharts(charts) {
|
||||
const gallery = $("btChartGallery");
|
||||
if (!gallery) return;
|
||||
if (!charts.length) {
|
||||
gallery.innerHTML = '<div class="metric-empty">暂无图表</div>';
|
||||
btChartSig = "";
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 交互式资金曲线(HTML / Chart.js)
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
let equityChart = null;
|
||||
let rollingChart = null;
|
||||
let btEquitySig = "";
|
||||
|
||||
const EQUITY_COLORS = [
|
||||
{ hex: "#5eead4", rgb: "94, 234, 212" },
|
||||
{ hex: "#38bdf8", rgb: "56, 189, 248" },
|
||||
{ hex: "#818cf8", rgb: "129, 140, 248" },
|
||||
{ hex: "#fbbf24", rgb: "251, 191, 36" },
|
||||
{ hex: "#f472b6", rgb: "244, 114, 182" },
|
||||
{ hex: "#a3e635", rgb: "163, 230, 53" },
|
||||
];
|
||||
|
||||
function verticalGradient(chart, rgb, topAlpha, bottomAlpha) {
|
||||
const { ctx, chartArea } = chart;
|
||||
if (!chartArea) return `rgba(${rgb}, ${topAlpha})`;
|
||||
const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
|
||||
g.addColorStop(0, `rgba(${rgb}, ${topAlpha})`);
|
||||
g.addColorStop(0.62, `rgba(${rgb}, ${(topAlpha + bottomAlpha) / 4})`);
|
||||
g.addColorStop(1, `rgba(${rgb}, ${bottomAlpha})`);
|
||||
return g;
|
||||
}
|
||||
|
||||
const EQUITY_TOOLTIP = {
|
||||
backgroundColor: "rgba(8, 12, 20, 0.94)",
|
||||
borderColor: "rgba(94, 234, 212, 0.35)",
|
||||
borderWidth: 1,
|
||||
titleColor: "#e8edf4",
|
||||
bodyColor: "#a9bccf",
|
||||
titleFont: { family: "'JetBrains Mono'", size: 11 },
|
||||
bodyFont: { family: "'JetBrains Mono'", size: 11 },
|
||||
padding: 10,
|
||||
cornerRadius: 8,
|
||||
usePointStyle: true,
|
||||
};
|
||||
|
||||
const EQUITY_OPTIONS = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
animation: { duration: 500, easing: "easeOutQuart" },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
labels: {
|
||||
color: "#a9bccf",
|
||||
usePointStyle: true,
|
||||
pointStyle: "circle",
|
||||
boxWidth: 8,
|
||||
boxHeight: 8,
|
||||
padding: 14,
|
||||
font: { family: "'DM Sans'", size: 12, weight: "600" },
|
||||
},
|
||||
},
|
||||
tooltip: {
|
||||
...EQUITY_TOOLTIP,
|
||||
callbacks: {
|
||||
label: (c) => ` ${c.dataset.label}: ${Number(c.parsed.y).toFixed(4)}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: "#6b7d92", maxTicksLimit: 8, maxRotation: 0, font: { family: "'JetBrains Mono'", size: 10 } },
|
||||
grid: { color: "rgba(120,190,235,0.05)" },
|
||||
border: { color: "rgba(120,190,235,0.12)" },
|
||||
},
|
||||
y: {
|
||||
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)" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const ROLLING_OPTIONS = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "index", intersect: false },
|
||||
animation: { duration: 500, easing: "easeOutQuart" },
|
||||
spanGaps: false,
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
...EQUITY_TOOLTIP,
|
||||
borderColor: "rgba(251, 191, 36, 0.4)",
|
||||
callbacks: {
|
||||
label: (c) => {
|
||||
const v = c.parsed.y;
|
||||
if (v == null || Number.isNaN(v)) return " 滚动夏普: —";
|
||||
return ` 滚动夏普: ${Number(v).toFixed(3)}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { color: "#6b7d92", maxTicksLimit: 8, maxRotation: 0, font: { family: "'JetBrains Mono'", size: 10 } },
|
||||
grid: { color: "rgba(120,190,235,0.05)" },
|
||||
border: { color: "rgba(120,190,235,0.12)" },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: "#6b7d92", font: { family: "'JetBrains Mono'", size: 10 } },
|
||||
grid: { color: "rgba(251,191,36,0.06)" },
|
||||
border: { color: "rgba(251,191,36,0.18)" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function destroyEquityCharts() {
|
||||
if (equityChart) { equityChart.destroy(); equityChart = null; }
|
||||
if (rollingChart) { rollingChart.destroy(); rollingChart = null; }
|
||||
}
|
||||
|
||||
function renderEquityStats(name, series) {
|
||||
const el = $("btEquityStats");
|
||||
if (!el) return;
|
||||
const pl = series.profit_loss_ratio;
|
||||
const plText = Number.isFinite(Number(pl)) ? Number(pl).toFixed(3) : "—";
|
||||
const roll = series.rolling_sharpe || [];
|
||||
let lastRoll = null;
|
||||
for (let i = roll.length - 1; i >= 0; i--) {
|
||||
const v = Number(roll[i]);
|
||||
if (Number.isFinite(v)) {
|
||||
lastRoll = v;
|
||||
break;
|
||||
}
|
||||
}
|
||||
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: "最新滚动夏普",
|
||||
value: lastRoll != null ? fmtSigned(lastRoll) : "—",
|
||||
cls: lastRoll == null ? "" : lastRoll >= 0 ? "accent" : "neg",
|
||||
},
|
||||
];
|
||||
el.innerHTML =
|
||||
`<span class="equity-stat-name">${name}</span>` +
|
||||
cards
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="equity-stat">
|
||||
<span class="equity-stat-label">${c.label}</span>
|
||||
<span class="equity-stat-value ${c.cls}">${c.value}</span>
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function buildEquityChart(labels, symbols, portfolio) {
|
||||
const canvas = $("btEquityChart");
|
||||
if (!canvas) return;
|
||||
const symNames = Object.keys(symbols);
|
||||
const multi = symNames.length > 1;
|
||||
const datasets = symNames.map((s, i) => {
|
||||
const col = EQUITY_COLORS[i % EQUITY_COLORS.length];
|
||||
return {
|
||||
label: s,
|
||||
data: symbols[s].equity,
|
||||
borderColor: col.hex,
|
||||
borderWidth: multi ? 1.5 : 2.2,
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: col.hex,
|
||||
pointHoverBorderColor: "#05070d",
|
||||
fill: !multi,
|
||||
backgroundColor: (ctx) => verticalGradient(ctx.chart, col.rgb, 0.3, 0),
|
||||
};
|
||||
});
|
||||
if (portfolio) {
|
||||
datasets.push({
|
||||
label: "等权组合",
|
||||
data: portfolio.equity,
|
||||
borderColor: "#e8edf4",
|
||||
borderWidth: 2.4,
|
||||
tension: 0.25,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: "#e8edf4",
|
||||
pointHoverBorderColor: "#05070d",
|
||||
fill: true,
|
||||
backgroundColor: (ctx) => verticalGradient(ctx.chart, "232, 237, 244", 0.16, 0),
|
||||
});
|
||||
}
|
||||
if (equityChart) equityChart.destroy();
|
||||
equityChart = new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: { labels, datasets },
|
||||
options: EQUITY_OPTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
function buildRollingChart(labels, series, windowBars) {
|
||||
const canvas = $("btRollingChart");
|
||||
if (!canvas) return;
|
||||
if (rollingChart) rollingChart.destroy();
|
||||
const data = series.rolling_sharpe || [];
|
||||
const labelEl = $("btRollingLabel");
|
||||
if (labelEl) {
|
||||
labelEl.textContent = windowBars
|
||||
? `滚动夏普 · ${windowBars} bars`
|
||||
: "滚动夏普 · Rolling Sharpe";
|
||||
}
|
||||
rollingChart = new Chart(canvas.getContext("2d"), {
|
||||
type: "line",
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: "滚动夏普",
|
||||
data,
|
||||
borderColor: "#fbbf24",
|
||||
borderWidth: 1.5,
|
||||
tension: 0.2,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHoverBackgroundColor: "#fbbf24",
|
||||
pointHoverBorderColor: "#05070d",
|
||||
spanGaps: false,
|
||||
fill: {
|
||||
target: "origin",
|
||||
above: "rgba(251, 191, 36, 0.16)",
|
||||
below: "rgba(248, 113, 113, 0.16)",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
options: ROLLING_OPTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
function renderEquity(resp) {
|
||||
const live = $("btEquityLive");
|
||||
const empty = $("btEquityEmpty");
|
||||
const data = resp?.data;
|
||||
const symbols = data?.symbols || {};
|
||||
const symNames = Object.keys(symbols);
|
||||
|
||||
if (!resp?.available || !symNames.length) {
|
||||
if (live) live.hidden = true;
|
||||
if (empty) empty.hidden = false;
|
||||
destroyEquityCharts();
|
||||
btEquitySig = "";
|
||||
return;
|
||||
}
|
||||
const sig = charts.map((c) => c.name).join(",") + "|" + btBuster;
|
||||
if (sig === btChartSig) return; // 无变化,避免图片重载闪烁
|
||||
btChartSig = sig;
|
||||
|
||||
const bust = encodeURIComponent(btBuster || "0");
|
||||
gallery.innerHTML = charts
|
||||
.map(
|
||||
(c) => `
|
||||
<div class="chart-card ${c.kind === "portfolio" ? "portfolio" : ""}" data-name="${c.name}">
|
||||
<div class="chart-card-head">${c.label}</div>
|
||||
<img src="${API}/api/backtest/chart/${encodeURIComponent(c.name)}?t=${bust}" alt="${c.label}" loading="lazy" />
|
||||
</div>`
|
||||
)
|
||||
.join("");
|
||||
const focus = resp.focus_symbol;
|
||||
const sig = [focus, data.total_bars, data.n_points, data.rolling_window, symNames.join(",")].join("|") + "|" + btBuster;
|
||||
if (sig === btEquitySig && equityChart) return; // 无变化,避免重建闪烁
|
||||
btEquitySig = sig;
|
||||
|
||||
if (live) live.hidden = false;
|
||||
if (empty) empty.hidden = true;
|
||||
|
||||
const portfolio = data.portfolio || null;
|
||||
let mainName, mainSeries;
|
||||
if (portfolio) {
|
||||
mainName = "等权组合";
|
||||
mainSeries = portfolio;
|
||||
} else {
|
||||
const key = focus && symbols[focus] ? focus : symNames[0];
|
||||
mainName = key;
|
||||
mainSeries = symbols[key];
|
||||
}
|
||||
|
||||
renderEquityStats(mainName, mainSeries);
|
||||
buildEquityChart(data.labels, symbols, portfolio);
|
||||
buildRollingChart(data.labels, mainSeries, data.rolling_window);
|
||||
|
||||
if ($("btChartsHint")) {
|
||||
const sym = selectedStrategySymbol || selectedSymbol;
|
||||
$("btChartsHint").textContent = sym
|
||||
? `${sym} · ${charts.length} 张图表 · 点击放大`
|
||||
: `${charts.length} 张图表 · 点击放大`;
|
||||
$("btChartsHint").textContent = `${mainName} · 交互式资金曲线 · 悬停查看数值`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -987,13 +1477,17 @@ async function startBacktest() {
|
||||
const startBtn = $("btStartBtn");
|
||||
if (startBtn) startBtn.disabled = true;
|
||||
try {
|
||||
const costs = readBacktestCosts();
|
||||
const res = await fetchJSON("/api/backtest/start", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ strategy_file: selectedStrategyFile }),
|
||||
body: JSON.stringify({
|
||||
strategy_file: selectedStrategyFile,
|
||||
commission_pct: costs.commission_pct,
|
||||
slippage_pct: costs.slippage_pct,
|
||||
}),
|
||||
});
|
||||
if (res.strategy_file) renderStrategyFileCard(res.strategy_file);
|
||||
btChartSig = ""; // 强制下次重建图库
|
||||
await refreshBacktest();
|
||||
} catch (e) {
|
||||
if ($("btLogHint")) $("btLogHint").textContent = e.message;
|
||||
@@ -1010,21 +1504,6 @@ async function stopBacktest() {
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
// 图片灯箱
|
||||
// ═══════════════════════════════════════════════════════════════════
|
||||
function openLightbox(src) {
|
||||
const lb = $("lightbox");
|
||||
const img = $("lightboxImg");
|
||||
if (!lb || !img) return;
|
||||
img.src = src;
|
||||
lb.classList.add("open");
|
||||
}
|
||||
function closeLightbox() {
|
||||
const lb = $("lightbox");
|
||||
if (lb) lb.classList.remove("open");
|
||||
}
|
||||
|
||||
function startPolling() {
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
pollTimer = setInterval(() => {
|
||||
@@ -1048,6 +1527,15 @@ async function init() {
|
||||
$("importTrainingBtn").addEventListener("click", triggerImportTraining);
|
||||
$("importTrainingFile").addEventListener("change", handleImportTrainingFile);
|
||||
$("debugModeCheck").addEventListener("change", (e) => setDebugMode(e.target.checked));
|
||||
if ($("aiApiKeyInput")) {
|
||||
$("aiApiKeyInput").addEventListener("input", updateAiChannelHint);
|
||||
$("aiApiKeyInput").addEventListener("change", updateAiChannelHint);
|
||||
}
|
||||
if ($("aiAnalyzeBtn")) $("aiAnalyzeBtn").addEventListener("click", runAiAnalyze);
|
||||
if ($("aiUnlimitedBtn")) $("aiUnlimitedBtn").addEventListener("click", openUnlimitedModal);
|
||||
document.querySelectorAll("[data-close-unlimited]").forEach((el) => {
|
||||
el.addEventListener("click", closeUnlimitedModal);
|
||||
});
|
||||
|
||||
// 步骤导航
|
||||
document.querySelectorAll(".stepper .step").forEach((btn) => {
|
||||
@@ -1058,21 +1546,11 @@ async function init() {
|
||||
if ($("btBrowseStrategyBtn")) $("btBrowseStrategyBtn").addEventListener("click", browseStrategyFile);
|
||||
if ($("btStartBtn")) $("btStartBtn").addEventListener("click", startBacktest);
|
||||
if ($("btStopBtn")) $("btStopBtn").addEventListener("click", stopBacktest);
|
||||
|
||||
// 图表灯箱
|
||||
const gallery = $("btChartGallery");
|
||||
if (gallery) {
|
||||
gallery.addEventListener("click", (e) => {
|
||||
const card = e.target.closest(".chart-card");
|
||||
if (!card) return;
|
||||
const img = card.querySelector("img");
|
||||
if (img) openLightbox(img.src);
|
||||
});
|
||||
}
|
||||
const lb = $("lightbox");
|
||||
if (lb) lb.addEventListener("click", closeLightbox);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") closeLightbox();
|
||||
["btCommissionInput", "btSlippageInput"].forEach((id) => {
|
||||
const el = $(id);
|
||||
if (!el) return;
|
||||
el.addEventListener("input", updateBtCostHint);
|
||||
el.addEventListener("change", updateBtCostHint);
|
||||
});
|
||||
|
||||
startPolling();
|
||||
|
||||
+113
-16
@@ -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=6" />
|
||||
<link rel="stylesheet" href="/static/style.css?v=14" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -166,6 +166,47 @@
|
||||
</div>
|
||||
<pre class="debug-view" id="debugView">暂无报错</pre>
|
||||
</section>
|
||||
|
||||
<section class="panel ai-panel" id="aiPanelSection">
|
||||
<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>AI 分析当前训练情况</h2>
|
||||
<span class="hint" id="aiProviderHint">DeepSeek · deepseek-v4-flash</span>
|
||||
</div>
|
||||
<div class="ai-form">
|
||||
<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" />
|
||||
</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>
|
||||
</div>
|
||||
<div class="ai-answer" id="aiAnswerView">点击「开始分析」后,AI 将回答:当前训练情况怎么样,是否值得继续,以及最新因子的含义与原理。</div>
|
||||
</section>
|
||||
|
||||
<div class="modal" id="aiUnlimitedModal" hidden>
|
||||
<div class="modal-backdrop" data-close-unlimited></div>
|
||||
<div class="modal-card" role="dialog" aria-labelledby="aiUnlimitedTitle">
|
||||
<h3 id="aiUnlimitedTitle">获取无限分析次数</h3>
|
||||
<div class="modal-body">
|
||||
获取无限分析次数方法需付费49.9元,付费后你将获得<br>
|
||||
Deepseek V4 Pro/GLM5.1/Kimi2.6等"满血"模型的无限分析方法<br>
|
||||
注意无限分析次数只支持使用这个分析软件<br>
|
||||
如果你愿意付费,请联系QQ:564020069(付费后提供远程协助部署安装服务)<br><br>
|
||||
如果你不愿意付费,你可以用自己的模型api,如果你不知道模型api是什么<br>
|
||||
可以直接跟龙虾说:<br>
|
||||
AlphaMaster这个程序的模型api有什么作用,该怎么填?<br>
|
||||
请教我填上Deepseek官方的模型API接口
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-primary" data-close-unlimited>知道了</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- /page-train -->
|
||||
|
||||
<!-- ══════════════ 步骤二:回测页 ══════════════ -->
|
||||
@@ -185,7 +226,18 @@
|
||||
<button class="btn btn-danger" id="btStopBtn" disabled>停止</button>
|
||||
</div>
|
||||
</div>
|
||||
<small class="field-hint">默认使用训练页对应品种的 <code>strategies/best_{品种}.json</code>;也可选择「导出策略」下载的 JSON 文件。</small>
|
||||
<div class="launch-row bt-cost-row">
|
||||
<label class="bt-cost-field">
|
||||
<span>手续费(单边 %)</span>
|
||||
<input type="number" id="btCommissionInput" min="0" max="5" step="0.01" value="0.02" />
|
||||
</label>
|
||||
<label class="bt-cost-field">
|
||||
<span>滑点(单边 %)</span>
|
||||
<input type="number" id="btSlippageInput" min="0" max="5" step="0.01" value="0.01" />
|
||||
</label>
|
||||
<span class="bt-cost-sum" id="btCostSumHint">合计单边成本 0.03%</span>
|
||||
</div>
|
||||
<small class="field-hint">单边手续费/滑点,开平仓各扣一次。默认手续费 0.02%、滑点 0.01%。策略默认 <code>strategies/best_{品种}.json</code>。</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -198,7 +250,7 @@
|
||||
<span class="hint" id="btPortfolioHint">尚未运行回测</span>
|
||||
</div>
|
||||
<div class="metric-grid" id="btPortfolioGrid">
|
||||
<div class="metric-empty">运行回测后显示收益、夏普、回撤等指标</div>
|
||||
<div class="metric-empty">运行回测后显示收益、夏普、盈亏比等指标</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -219,14 +271,13 @@
|
||||
<th>收益</th>
|
||||
<th>Sharpe</th>
|
||||
<th>Sortino</th>
|
||||
<th>最大回撤</th>
|
||||
<th>Calmar</th>
|
||||
<th>盈亏比</th>
|
||||
<th>交易数</th>
|
||||
<th>胜率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="btTableBody">
|
||||
<tr class="empty-row"><td colspan="8">暂无回测结果</td></tr>
|
||||
<tr class="empty-row"><td colspan="7">暂无回测结果</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -248,27 +299,73 @@
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 资金曲线图库 -->
|
||||
<!-- 资金曲线(交互式 HTML 图表) -->
|
||||
<section class="panel bt-charts-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>
|
||||
<span class="hint" id="btChartsHint">回测完成后展示选中品种的资金曲线与交易图</span>
|
||||
<span class="hint" id="btChartsHint">回测完成后展示资金曲线</span>
|
||||
</div>
|
||||
<div class="chart-gallery" id="btChartGallery">
|
||||
<div class="metric-empty">暂无图表</div>
|
||||
|
||||
<div class="equity-live" id="btEquityLive" hidden>
|
||||
<div class="equity-stats" id="btEquityStats"></div>
|
||||
<div class="equity-chart-block">
|
||||
<div class="equity-axis-label">累计对数收益</div>
|
||||
<div class="equity-chart-wrap">
|
||||
<canvas id="btEquityChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
<div class="equity-chart-block roll">
|
||||
<div class="equity-axis-label" id="btRollingLabel">滚动夏普 · Rolling Sharpe</div>
|
||||
<div class="equity-chart-wrap roll">
|
||||
<canvas id="btRollingChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="metric-empty" id="btEquityEmpty">回测完成后展示资金曲线</div>
|
||||
|
||||
<details class="chart-metrics-help bt-howto" open>
|
||||
<summary>回测是怎么算的?(一看就懂)</summary>
|
||||
<ol class="bt-howto-list">
|
||||
<li>
|
||||
<strong>先拿历史行情“假装交易”</strong>
|
||||
<p>回测不会真的下单。它把过去很多天的价格拿出来,按策略规则一步步走一遍,看看如果当时真做了,钱会变多还是变少。</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>因子先打一个“信号分”</strong>
|
||||
<p>策略公式会给每一根 K 线打分:正分偏向看涨,负分偏向看跌,靠近 0 就先别乱动。</p>
|
||||
<p>分数的绝对值越大,信号就越明确。比如 +3 比 +0.2 更坚决地看涨,-3 比 -0.2 更坚决地看跌。</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>信号分越大,仓位就越大</strong>
|
||||
<p>仓位不是只有“全仓 / 空仓”两档,而是用 <code>tanh(信号分)</code> 压到大约 -1 到 +1 之间。</p>
|
||||
<ul>
|
||||
<li>正分越大 → 做多越多(最多接近满仓 +1)</li>
|
||||
<li>负分越小 → 做空越多(最多接近满仓 -1)</li>
|
||||
<li>分数很弱 → 仓位接近 0(几乎不持仓)</li>
|
||||
</ul>
|
||||
<p>可以把它想成:信心越大,下注越多;但再大也不会超过“一整份仓位”。</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>赚亏怎么算</strong>
|
||||
<p>这一步大概赚多少 ≈ 仓位 × 价格涨跌。仓位大,涨了赚得多,跌了也亏得多。换仓时还会扣掉你设的手续费和滑点。</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>上面那些图在说什么</strong>
|
||||
<ul>
|
||||
<li>资金曲线:把每一步赚亏累加起来,看账户是往上爬还是往下掉。</li>
|
||||
<li>滚动夏普:看最近一段时间“赚得稳不稳”,越高通常越好。</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ol>
|
||||
</details>
|
||||
</section>
|
||||
</div><!-- /page-backtest -->
|
||||
</div>
|
||||
|
||||
<!-- 图片灯箱 -->
|
||||
<div class="lightbox" id="lightbox">
|
||||
<img id="lightboxImg" src="" alt="资金曲线" />
|
||||
</div>
|
||||
|
||||
<script src="/static/bg.js?v=1"></script>
|
||||
<script src="/static/app.js?v=19"></script>
|
||||
<script src="/static/app.js?v=36"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+445
-2
@@ -515,6 +515,23 @@ select:focus { border-color: var(--accent); }
|
||||
box-shadow: 0 0 18px rgba(94, 234, 212, 0.18);
|
||||
}
|
||||
|
||||
.btn-unlimited {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b 45%, #f97316);
|
||||
color: #1a0b00;
|
||||
border: 1px solid rgba(251, 191, 36, 0.85);
|
||||
box-shadow: 0 6px 26px rgba(249, 115, 22, 0.35);
|
||||
animation: unlimitedPulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.btn-unlimited:not(:disabled):hover {
|
||||
box-shadow: 0 10px 34px rgba(249, 115, 22, 0.45), 0 0 0 1px rgba(251, 191, 36, 0.5);
|
||||
}
|
||||
|
||||
@keyframes unlimitedPulse {
|
||||
0%, 100% { filter: brightness(1); }
|
||||
50% { filter: brightness(1.12); }
|
||||
}
|
||||
|
||||
.launch-form-file {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
@@ -530,6 +547,49 @@ select:focus { border-color: var(--accent); }
|
||||
|
||||
.launch-row .launch-actions { margin-left: auto; }
|
||||
|
||||
.bt-cost-row {
|
||||
align-items: flex-end;
|
||||
gap: 14px;
|
||||
padding-top: 4px;
|
||||
}
|
||||
|
||||
.bt-cost-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.bt-cost-field span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.bt-cost-field input[type="number"],
|
||||
.launch-form input[type="number"] {
|
||||
width: 120px;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border-strong);
|
||||
color: var(--text);
|
||||
padding: 9px 12px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.bt-cost-field input[type="number"]:focus,
|
||||
.launch-form input[type="number"]:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.bt-cost-sum {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.field-hint code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8em;
|
||||
@@ -752,6 +812,56 @@ select:focus { border-color: var(--accent); }
|
||||
.metrics-list strong { display: block; color: var(--text); font-size: 0.84rem; margin-bottom: 2px; }
|
||||
.metrics-list p { margin: 0; line-height: 1.45; }
|
||||
|
||||
.bt-howto-list {
|
||||
margin: 12px 0 2px;
|
||||
padding: 0 0 0 1.15rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.bt-howto-list > li {
|
||||
padding-left: 4px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.bt-howto-list > li > strong {
|
||||
display: block;
|
||||
color: var(--text);
|
||||
font-size: 0.88rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.bt-howto-list p {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.6;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.bt-howto-list p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.bt-howto-list ul {
|
||||
margin: 0 0 6px;
|
||||
padding-left: 1.1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bt-howto-list ul li {
|
||||
line-height: 1.55;
|
||||
font-size: 0.82rem;
|
||||
list-style: disc;
|
||||
}
|
||||
|
||||
.bt-howto-list code {
|
||||
font-family: var(--mono);
|
||||
font-size: 0.8em;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* ============================== 公式 ============================== */
|
||||
.formula-box {
|
||||
position: relative;
|
||||
@@ -859,12 +969,151 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ============================== AI 分析 ============================== */
|
||||
.ai-panel::before {
|
||||
background: linear-gradient(120deg, rgba(56, 189, 248, 0.28), transparent 42%, transparent 68%, rgba(94, 234, 212, 0.22));
|
||||
}
|
||||
|
||||
.ai-panel .hud-corner { border-color: rgba(56, 189, 248, 0.45); }
|
||||
|
||||
.ai-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.ai-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ai-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ai-field span {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.ai-field select,
|
||||
.ai-field input {
|
||||
background: rgba(14, 20, 31, 0.85);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
padding: 8px 10px;
|
||||
font-family: var(--font);
|
||||
font-size: 0.88rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.ai-key-field {
|
||||
flex: 1 1 240px;
|
||||
}
|
||||
|
||||
.ai-key-field input { width: 100%; }
|
||||
|
||||
.ai-answer {
|
||||
min-height: 180px;
|
||||
max-height: 480px;
|
||||
overflow: auto;
|
||||
background: rgba(8, 14, 24, 0.85);
|
||||
border: 1px solid rgba(56, 189, 248, 0.2);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
font-size: 0.88rem;
|
||||
line-height: 1.65;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.ai-answer.loading {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.ai-answer.error {
|
||||
color: #f0c4c4;
|
||||
border-color: rgba(248, 113, 113, 0.35);
|
||||
}
|
||||
|
||||
.ai-answer.streaming::after {
|
||||
content: "▍";
|
||||
display: inline-block;
|
||||
margin-left: 2px;
|
||||
color: var(--accent);
|
||||
animation: aiCaret 0.9s steps(1) infinite;
|
||||
}
|
||||
|
||||
@keyframes aiCaret {
|
||||
0%, 50% { opacity: 1; }
|
||||
51%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.modal[hidden] { display: none !important; }
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(3, 6, 12, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-card {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: min(720px, 100%);
|
||||
background: #121926;
|
||||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||||
border-radius: 16px;
|
||||
padding: 22px 24px;
|
||||
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
|
||||
.modal-card h3 {
|
||||
font-family: var(--display);
|
||||
font-size: 1.15rem;
|
||||
margin-bottom: 14px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
color: var(--text);
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/* ============================ 进入动画 ============================ */
|
||||
.panel { animation: panelIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) both; }
|
||||
.launch-panel { animation-delay: 0.05s; }
|
||||
.split { animation: panelIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) 0.12s both; }
|
||||
.strategies-panel { animation-delay: 0.18s; }
|
||||
.debug-panel { animation-delay: 0.24s; }
|
||||
.ai-panel { animation-delay: 0.30s; }
|
||||
|
||||
@keyframes panelIn {
|
||||
from { opacity: 0; transform: translateY(16px); }
|
||||
@@ -1017,10 +1266,18 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
/* ============================ 指标卡片 ============================ */
|
||||
.metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.metric-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
|
||||
.metric-empty {
|
||||
grid-column: 1 / -1;
|
||||
color: var(--text-muted);
|
||||
@@ -1036,6 +1293,7 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
background: linear-gradient(150deg, rgba(14, 20, 31, 0.85), rgba(18, 25, 37, 0.5));
|
||||
border: 1px solid var(--border-strong);
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-card::before {
|
||||
@@ -1153,7 +1411,17 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.chart-card.portfolio { grid-column: 1 / -1; }
|
||||
.chart-card.portfolio {
|
||||
grid-column: 1 / -1;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.chart-card.portfolio img {
|
||||
width: 100%;
|
||||
min-height: 420px;
|
||||
object-fit: contain;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.chart-card .chart-card-head {
|
||||
display: flex;
|
||||
@@ -1185,6 +1453,181 @@ td:nth-child(2) { color: var(--accent); font-family: var(--mono); }
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* ====================== 交互式资金曲线 (HTML) ====================== */
|
||||
.equity-live {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.equity-stats {
|
||||
display: grid;
|
||||
grid-template-columns: auto repeat(5, minmax(0, 1fr));
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.equity-stat-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 6px 16px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--mono);
|
||||
font-size: 0.86rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--accent);
|
||||
background: linear-gradient(135deg, rgba(94, 234, 212, 0.12), rgba(129, 140, 248, 0.08));
|
||||
border: 1px solid rgba(94, 234, 212, 0.28);
|
||||
text-shadow: 0 0 14px var(--accent-glow);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.equity-stat-name::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 10px var(--accent-glow);
|
||||
}
|
||||
|
||||
.equity-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 3px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 10px;
|
||||
background: rgba(14, 20, 31, 0.7);
|
||||
border: 1px solid var(--border-strong);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.equity-stats {
|
||||
grid-template-columns: auto repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.equity-stats {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.equity-stat-name {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
}
|
||||
|
||||
.equity-stat-label {
|
||||
font-size: 0.66rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
}
|
||||
|
||||
.equity-stat-value {
|
||||
font-family: var(--mono);
|
||||
font-size: 1.02rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.equity-stat-value.pos { color: var(--success); text-shadow: 0 0 14px rgba(74, 222, 128, 0.25); }
|
||||
.equity-stat-value.neg { color: var(--danger); text-shadow: 0 0 14px rgba(248, 113, 113, 0.25); }
|
||||
.equity-stat-value.accent { color: var(--accent); text-shadow: 0 0 14px var(--accent-glow); }
|
||||
|
||||
.equity-chart-block {
|
||||
position: relative;
|
||||
padding: 14px 14px 10px;
|
||||
border-radius: 12px;
|
||||
background:
|
||||
radial-gradient(120% 100% at 0% 0%, rgba(94, 234, 212, 0.05), transparent 60%),
|
||||
rgba(6, 10, 16, 0.55);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.equity-chart-block.roll {
|
||||
background:
|
||||
radial-gradient(120% 100% at 0% 50%, rgba(251, 191, 36, 0.06), transparent 60%),
|
||||
rgba(6, 10, 16, 0.55);
|
||||
}
|
||||
|
||||
.equity-axis-label {
|
||||
font-size: 0.68rem;
|
||||
font-family: var(--mono);
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.equity-axis-label::before {
|
||||
content: "";
|
||||
width: 14px;
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, var(--accent), var(--accent-3));
|
||||
box-shadow: 0 0 8px var(--accent-glow);
|
||||
}
|
||||
|
||||
.equity-chart-block.roll .equity-axis-label::before {
|
||||
background: linear-gradient(90deg, #fbbf24, #f59e0b);
|
||||
box-shadow: 0 0 8px rgba(251, 191, 36, 0.35);
|
||||
}
|
||||
|
||||
.equity-chart-wrap {
|
||||
position: relative;
|
||||
height: 340px;
|
||||
}
|
||||
|
||||
.equity-chart-wrap.roll { height: 150px; }
|
||||
|
||||
.equity-raw {
|
||||
margin-top: 18px;
|
||||
border-top: 1px solid var(--border);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.equity-raw summary {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.03em;
|
||||
list-style: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.equity-raw summary:hover { color: var(--accent); }
|
||||
.equity-raw summary::-webkit-details-marker { display: none; }
|
||||
|
||||
.equity-raw summary::before {
|
||||
content: "▸";
|
||||
font-size: 0.7rem;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.equity-raw[open] summary::before { transform: rotate(90deg); }
|
||||
|
||||
.equity-raw .chart-gallery { margin-top: 14px; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.equity-chart-wrap { height: 260px; }
|
||||
.equity-chart-wrap.roll { height: 120px; }
|
||||
}
|
||||
|
||||
/* ============================== 灯箱 ============================== */
|
||||
.lightbox {
|
||||
position: fixed;
|
||||
|
||||
+77
-2
@@ -6,13 +6,24 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from web.progress import PROJECT_ROOT, _decode_formula
|
||||
from model_core.vocab import VOCAB_VERSION
|
||||
from web.progress import (
|
||||
STRATEGIES_DIR,
|
||||
_decode_formula,
|
||||
_load_checkpoint_meta,
|
||||
_load_strategy,
|
||||
checkpoint_glob,
|
||||
)
|
||||
|
||||
_BEST_NAME_RE = re.compile(r"^best_(.+)\.json$", re.IGNORECASE)
|
||||
_STRATEGY_EXPORT_RE = re.compile(
|
||||
r"^strategy_(.+)_step(\d+)(?:_score([\d.]+))?\.json$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def strategy_path_for_symbol(symbol: str) -> Path:
|
||||
return PROJECT_ROOT / "strategies" / f"best_{symbol}.json"
|
||||
return STRATEGIES_DIR / f"best_{symbol}.json"
|
||||
|
||||
|
||||
def symbol_from_strategy_path(path: Path) -> str | None:
|
||||
@@ -80,3 +91,67 @@ def resolve_strategy_file(
|
||||
return str(default.resolve())
|
||||
|
||||
return saved_path or ""
|
||||
|
||||
|
||||
def _step_from_export_name(path: Path) -> int:
|
||||
m = _STRATEGY_EXPORT_RE.match(path.name)
|
||||
if not m:
|
||||
return 0
|
||||
try:
|
||||
return int(m.group(2))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def sync_best_strategy_for_symbol(symbol: str) -> dict[str, Any] | None:
|
||||
"""在策略文件与检查点中选出最高分策略,写入 strategies/best_{symbol}.json。"""
|
||||
candidates: list[tuple[float, list[int], int]] = []
|
||||
|
||||
strat = _load_strategy(symbol)
|
||||
if strat and strat.get("formula") and strat.get("best_score") is not None:
|
||||
step = int(strat.get("train_step") or strat.get("current_step") or 0)
|
||||
candidates.append((float(strat["best_score"]), strat["formula"], step))
|
||||
|
||||
for ckpt_path in checkpoint_glob(symbol):
|
||||
meta = _load_checkpoint_meta(ckpt_path)
|
||||
score = meta.get("best_score")
|
||||
formula = meta.get("best_formula")
|
||||
if score is None or not formula:
|
||||
continue
|
||||
candidates.append((float(score), formula, int(meta.get("step") or 0)))
|
||||
|
||||
safe = symbol.replace(".", "_")
|
||||
for path in STRATEGIES_DIR.glob(f"strategy_{safe}_*.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
formula = data.get("formula")
|
||||
score = data.get("best_score")
|
||||
if not formula or score is None:
|
||||
continue
|
||||
candidates.append((float(score), formula, _step_from_export_name(path)))
|
||||
|
||||
if not candidates:
|
||||
existing = strategy_path_for_symbol(symbol)
|
||||
if existing.exists():
|
||||
return inspect_strategy_file(str(existing.resolve()))
|
||||
return None
|
||||
|
||||
best_score, best_formula, best_step = max(candidates, key=lambda row: row[0])
|
||||
out_path = strategy_path_for_symbol(symbol)
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"vocab_version": VOCAB_VERSION,
|
||||
"symbol": symbol,
|
||||
"formula": best_formula,
|
||||
"best_score": best_score,
|
||||
"formula_decoded": _decode_formula(best_formula),
|
||||
"train_step": best_step,
|
||||
}
|
||||
if strat:
|
||||
for key in ("timeframe", "data_file", "mode", "train_steps"):
|
||||
if strat.get(key) is not None:
|
||||
payload[key] = strat[key]
|
||||
out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
return inspect_strategy_file(str(out_path.resolve()))
|
||||
|
||||
Reference in New Issue
Block a user