This commit is contained in:
qq564020069
2026-07-11 14:04:56 +08:00
parent 6076f5e9d4
commit d9b62df636
16 changed files with 2511 additions and 248 deletions
+1
View File
@@ -83,6 +83,7 @@ verification_results.json
# ── 实盘/运行态文件 ────────────────────────────────────────────────
portfolio_state.json
web_settings.json
ai_analysis_history.json
STOP_SIGNAL
train.pid
train_stdout.log
+6 -1
View File
@@ -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
View File
@@ -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)
+6 -2
View File
@@ -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
View File
@@ -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:
+8 -7
View File
@@ -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
}
+382
View File
@@ -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
+406
View File
@@ -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
View File
@@ -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
View File
@@ -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:
+13
View File
@@ -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():
+40
View File
@@ -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
View File
@@ -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
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/static/style.css?v=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
View File
@@ -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
View File
@@ -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()))