This commit is contained in:
qq564020069
2026-07-12 12:54:23 +08:00
parent 7502cee8ab
commit 06df8f2b7f
15 changed files with 448 additions and 59 deletions
+133 -13
View File
@@ -1,28 +1,148 @@
# AlphaMaster
![](showcase.png)
基于深度神经网络强化学习的量化因子挖掘中心:从 Parquet / MT5 K 线自动搜索可解释因子公式,支持 Web 端训练、回测与实时信号分析。
*在 AlphaGPT 开源后,社区曾使用开源版本管理了 1.66 亿人民币规模资金。*
[![License: AGPL v3](https://img.shields.io/badge/License-AGPL%20v3-blue.svg)](LICENSE)
[时隔数月后,项目作者再次受到了人身安全威胁。](https://github.com/imbue-bit/fuck_all_tian)
![Web 控制台总览](docs/images/00_hero.png)
也许金融真的是一个意外死亡率很高的行业🤔
仓库地址:[github.com/rosemarycox5334-debug/AlphaMaster](https://github.com/rosemarycox5334-debug/AlphaMaster)
---
## 它做什么
AlphaMaster 把「挖因子」做成一条可操作的流水线:
1. **训练**:用强化学习在特征 + 算子空间里搜索公式,按验证集表现选优
2. **回测**:用 `tanh(因子)` 连续仓位在历史行情上模拟交易,看资金曲线与绩效
3. **实时分析**:按周期收盘后重算信号,展示方向与把握;方向转折可推飞书提醒
公式以 token 序列保存(如 `strategies/best_BTCUSDT.json`),可用 StackVM 解释执行,训练 / 回测 / 实时共用同一套信号逻辑。
---
## Web 控制台(推荐入口)
```bash
pip install -r requirements.txt
python run_web.py --port 8765
```
浏览器打开 [http://127.0.0.1:8765](http://127.0.0.1:8765)。界面分三步:
| 步骤 | 作用 |
|------|------|
| **01 模型训练** | 选 Parquet、开始 / 重新训练、看曲线与日志、导出策略与检查点 |
| **02 策略回测** | 选策略 JSON,设手续费 / 滑点,看绩效与资金曲线 |
| **03 实时分析** | 多数据源监控,收盘后更新信号;可选飞书转折提醒 |
### 模型训练
![训练页](docs/images/01_train.png)
- Parquet 命名:`{品种}_{周期}.parquet`,例如 `BTCUSDT_H1.parquet`、`XAUUSD_H1.parquet`
- **开始训练**:有检查点则断点续训
- **重新训练**:清除检查点从头搜索;已有更优策略作为分数下限,不会被弱结果覆盖
- 展示最优分数、验证分数、训练曲线与最优公式;可选 AI 分析当前训练情况
### 策略回测
![回测页](docs/images/02_backtest.png)
- 仓位:`position = tanh(factor)`,信号越强仓位越大
- 成本:手续费 + 滑点(默认约 0.02% / 0.01%)
- 输出:总收益、夏普、索提诺、盈亏比、滚动夏普与资金曲线
![资金曲线示例](docs/images/04_equity.png)
### 实时分析
![实时分析页](docs/images/03_realtime.png)
- 数据源:MT5 / OKX 等(以界面可用源为准)
- **只在当前周期 K 线收盘后**重新判断;未收盘 bar 不参与信号
- 卡片展示方向(看涨 / 看跌 / 不确定)与把握程度
- 可选飞书 Webhook:仅在方向转折时推送文字提醒
---
## 项目结构
```
AlphaMaster/
├── web/ # FastAPI Web UI(训练 / 回测 / 实时)
├── model_core/ # 特征、算子、StackVM、训练引擎、回测评分
├── data_pipeline/ # Parquet / MT5 K 线加载与对齐
├── strategy_manager/ # 实盘信号与仓位逻辑(与回测口径一致)
├── execution/ # MT5 下单接口
├── backtest_viz/ # 回测引擎与图表
├── strategies/ # best_{symbol}.json 策略文件
├── checkpoints/ # 训练检查点
├── run_web.py # 启动 Web 控制台
├── train_file.py # CLI:从单个 Parquet 训练
└── requirements.txt
```
---
## 环境要求
- Python **3.10+**(建议 3.11)
- PyTorch、pandas、FastAPI、uvicorn 等(见 `requirements.txt`)
- 可选:MetaTrader 5 终端(实时 MT5 源 / 实盘相关脚本)
- 复制 `.env.example` 为 `.env` 填写 MT5 等凭证(`.env` 已 gitignore)
```bash
pip install -r requirements.txt
# 可选可视化等:pip install -r requirements-optional.txt
```
---
## 常用命令
```bash
# Web 控制台
python run_web.py --port 8765
# CLI 训练(自动续训;加 --from-scratch 则重新训练)
python train_file.py --data-file D:\K线数据\BTCUSDT_H1.parquet
python train_file.py --data-file D:\K线数据\BTCUSDT_H1.parquet --from-scratch
```
策略输出默认在 `strategies/best_{symbol}.json`。
---
## 信号口径(训练 / 回测 / 实时一致)
- 因子经 StackVM 算出标量序列
- `position = tanh(factor)` ∈ (-1, 1)
- `|position|` 小于阈值时视为无信号(观望)
- 实时侧只用**已收盘** K 线,避免盘中抖动与回测不一致
---
## 截图更新
仓库内展示图由当前 Web UI 截取,可用:
```bash
python scripts/capture_readme_shots.py
```
(需本机已启动 `python run_web.py --port 8765`,并已安装 Playwright + Chromium。)
---
## License
This project is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE).
Derivative works and network deployments must provide corresponding source code under the same license.
---
如果您觉得 AlphaGPT 很有用,可以通过以下渠道捐助作者:
![](helpme.jpg)
本项目采用 [GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE)。
修改、分发或通过网络提供服务时,须按相同协议公开对应源代码。
---
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=imbue-bit/AlphaGPT&type=date&legend=top-left)](https://www.star-history.com/#imbue-bit/AlphaGPT&type=date&legend=top-left)
[![Star History Chart](https://api.star-history.com/svg?repos=rosemarycox5334-debug/AlphaMaster&type=date&legend=top-left)](https://www.star-history.com/#rosemarycox5334-debug/AlphaMaster&type=date&legend=top-left)
Binary file not shown.

After

Width:  |  Height:  |  Size: 682 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 866 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

+25
View File
@@ -0,0 +1,25 @@
from pathlib import Path
from playwright.sync_api import sync_playwright
out = Path(__file__).resolve().parents[1] / "docs" / "images"
out.mkdir(parents=True, exist_ok=True)
url = "http://127.0.0.1:8765/"
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={"width": 1440, "height": 900}, device_scale_factor=1.25)
page.goto(url, wait_until="networkidle", timeout=60000)
page.wait_for_timeout(2000)
page.screenshot(path=str(out / "00_hero.png"), full_page=False)
page.screenshot(path=str(out / "01_train.png"), full_page=True)
page.locator('button[data-page="backtest"]').click()
page.wait_for_timeout(1000)
page.screenshot(path=str(out / "02_backtest.png"), full_page=True)
page.locator('button[data-page="realtime"]').click()
page.wait_for_timeout(1500)
page.screenshot(path=str(out / "03_realtime.png"), full_page=True)
browser.close()
print("wrote", sorted(p.name for p in out.glob("*.png")))
+5 -7
View File
@@ -4,14 +4,12 @@
"formula": [
39,
89,
97,
4,
87,
21,
115,
87,
115
31,
126,
121
],
"best_score": 1.3544840812683105,
"formula_decoded": "YANG_ZHANG_VOL → TS_MAX_10 → SLOPE20 → MOMENTUM_5 → BOLL_POS → MIN → MOMENTUM_5 → MIN",
"train_step": 0
"best_score": 1.4946367740631104
}
+60 -5
View File
@@ -28,7 +28,7 @@ from model_core.engine import AlphaEngine
from model_core.vocab import VOCAB_VERSION
def train_from_file(data_file: str) -> AlphaEngine | None:
def train_from_file(data_file: str, *, from_scratch: bool = False) -> AlphaEngine | None:
info = inspect_parquet_file(data_file)
symbol = info["symbol"]
timeframe = info["timeframe"]
@@ -40,6 +40,7 @@ def train_from_file(data_file: str) -> AlphaEngine | None:
print(f" 周期: {timeframe}")
print(f" 训练步数: {ModelConfig.TRAIN_STEPS}")
print(f" K线数: {info['bars']}")
print(f" 模式: {'重新训练(从头)' if from_scratch else '自动续训'}")
print(f"{'='*60}")
try:
@@ -57,7 +58,25 @@ def train_from_file(data_file: str) -> AlphaEngine | None:
ckpt_files = sorted(_glob.glob(ckpt_pattern))
start_step = 0
if ckpt_files:
if from_scratch:
removed = 0
for p in ckpt_files:
try:
pathlib.Path(p).unlink(missing_ok=True)
removed += 1
except OSError as e:
print(f" [警告] 无法删除检查点 {p}: {e}")
hist_path = pathlib.Path(f"training_history_{symbol}.json")
if hist_path.exists():
try:
hist_path.unlink()
except OSError:
pass
print(f" [重新训练] 已清除 {removed} 个检查点,从第 0 步开始")
# 保留已有最优策略作为分数下限,避免开局弱公式覆盖 strategies/best_*.json
_seed_best_from_strategy(engine, symbol)
ckpt_files = []
elif ckpt_files:
latest = ckpt_files[-1]
try:
start_step = engine.load_checkpoint(latest)
@@ -70,7 +89,7 @@ def train_from_file(data_file: str) -> AlphaEngine | None:
_save_strategy(engine, symbol, timeframe, data_file)
return engine
if start_step == 0:
if start_step == 0 and not from_scratch:
hist_path = pathlib.Path(f"training_history_{symbol}.json")
if hist_path.exists():
hist_path.unlink()
@@ -84,9 +103,44 @@ def train_from_file(data_file: str) -> AlphaEngine | None:
return engine
def _seed_best_from_strategy(engine: AlphaEngine, symbol: str) -> None:
"""把已有 best_{symbol}.json 当作重新训练的分数下限。"""
path = pathlib.Path("strategies") / f"best_{symbol}.json"
if not path.exists():
return
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as e:
print(f" [警告] 读取已有策略失败: {e}")
return
formula = data.get("formula")
score = data.get("best_score")
if not formula or score is None:
return
try:
engine.best_formula = [int(t) for t in formula]
engine.best_score = float(score)
print(f" [重新训练] 保留已有最优分数下限={engine.best_score:.4f},仅更好时才会覆盖策略文件")
except (TypeError, ValueError) as e:
print(f" [警告] 已有策略无法用作下限: {e}")
def _save_strategy(engine: AlphaEngine, symbol: str, timeframe: str, data_file: str) -> None:
path = pathlib.Path("strategies") / f"best_{symbol}.json"
path.parent.mkdir(exist_ok=True)
# 若磁盘上已有更高分,不要用更弱结果覆盖
if path.exists() and engine.best_formula is not None:
try:
old = json.loads(path.read_text(encoding="utf-8"))
old_score = old.get("best_score")
if old_score is not None and float(old_score) > float(engine.best_score):
print(
f" [策略] 保留磁盘更优结果 {float(old_score):.4f} "
f"> 本次 {float(engine.best_score):.4f},未覆盖 {path}"
)
return
except (json.JSONDecodeError, OSError, TypeError, ValueError):
pass
data = {
"vocab_version": VOCAB_VERSION,
"symbol": symbol,
@@ -108,7 +162,7 @@ if __name__ == "__main__":
ModelConfig.REWARD_MODE = "ftmo"
if "--data-file" not in sys.argv:
print("用法: python train_file.py --data-file PATH\\TO\\SYMBOL_TF.parquet")
print("用法: python train_file.py --data-file PATH\\TO\\SYMBOL_TF.parquet [--from-scratch]")
print("示例: python train_file.py --data-file D:\\K线数据\\AAPL_H1.parquet")
sys.exit(1)
@@ -118,8 +172,9 @@ if __name__ == "__main__":
sys.exit(1)
data_file = sys.argv[idx + 1]
from_scratch = "--from-scratch" in sys.argv
t0 = time.time()
eng = train_from_file(data_file)
eng = train_from_file(data_file, from_scratch=from_scratch)
elapsed = time.time() - t0
if eng:
+21 -1
View File
@@ -67,6 +67,7 @@ app.add_middleware(
class StartTrainingRequest(BaseModel):
data_file: str
from_scratch: bool = False
class ClientLogRequest(BaseModel):
@@ -467,12 +468,21 @@ def _progress_with_live_step(symbol: str, active: bool) -> dict[str, Any]:
current_step = max(current_step, live)
train_steps = p.train_steps
progress_pct = min(100.0, 100.0 * current_step / train_steps) if train_steps > 0 else 0.0
val_score = None
hist = p.history or {}
vals = hist.get("val_score") or []
if vals:
try:
val_score = float(vals[-1])
except (TypeError, ValueError):
val_score = None
return {
"symbol": p.symbol,
"current_step": current_step,
"train_steps": train_steps,
"progress_pct": round(progress_pct, 1),
"best_score": p.best_score,
"val_score": val_score,
"formula_decoded": p.formula_decoded,
"status": p.status,
"history": p.history,
@@ -499,6 +509,7 @@ def api_overview() -> dict[str, Any]:
"train_steps": row["train_steps"],
"progress_pct": row["progress_pct"],
"best_score": row["best_score"],
"val_score": row.get("val_score"),
"formula_decoded": row["formula_decoded"],
"has_checkpoint": row.get("has_checkpoint", False),
"has_strategy": row.get("has_strategy", False),
@@ -517,6 +528,7 @@ def api_overview() -> dict[str, Any]:
"train_steps": row["train_steps"],
"progress_pct": row["progress_pct"],
"best_score": row["best_score"],
"val_score": row.get("val_score"),
"formula_decoded": row["formula_decoded"],
"has_checkpoint": row.get("has_checkpoint", False),
"has_strategy": row.get("has_strategy", False),
@@ -637,10 +649,18 @@ def api_training_start(req: StartTrainingRequest) -> dict[str, Any]:
symbol=info["symbol"],
timeframe=info["timeframe"],
mode="ftmo",
from_scratch=bool(req.from_scratch),
)
except RuntimeError as e:
raise HTTPException(409, str(e)) from e
return {"ok": True, "job": job.to_dict(), "data_file": info}
if req.from_scratch:
invalidate_checkpoint_cache()
return {
"ok": True,
"job": job.to_dict(),
"data_file": info,
"from_scratch": bool(req.from_scratch),
}
@app.post("/api/training/stop")
+80 -7
View File
@@ -176,6 +176,7 @@ function renderDataFileCard(info) {
selectedDataFile = null;
selectedSymbol = null;
startBtn.disabled = true;
if ($("retrainBtn")) $("retrainBtn").disabled = true;
if ($("exportBtn")) $("exportBtn").disabled = true;
if ($("exportTrainingBtn")) $("exportTrainingBtn").disabled = true;
if ($("importTrainingBtn")) $("importTrainingBtn").disabled = true;
@@ -192,22 +193,29 @@ function renderDataFileCard(info) {
<div class="data-file-path">${info.data_file}</div>
`;
startBtn.disabled = true;
if ($("retrainBtn")) $("retrainBtn").disabled = true;
if ($("exportTrainingBtn")) $("exportTrainingBtn").disabled = true;
if ($("importTrainingBtn")) $("importTrainingBtn").disabled = true;
return;
}
card.className = "data-file-card valid";
const yearsText = info.years_h1 != null ? `${info.years_h1} 年` : "—";
card.innerHTML = `
<div class="data-file-row">
<div class="item"><span class="label">品种</span><span class="value sym">${info.symbol}</span></div>
<div class="item"><span class="label">周期</span><span class="value">${info.timeframe}</span></div>
<div class="item"><span class="label">K线</span><span class="value">${info.bars?.toLocaleString()}</span></div>
<div class="item"><span class="label">数据年限</span><span class="value">${yearsText}</span></div>
<div class="item"><span class="label">进度</span><span class="value" id="fileProgressPct">—</span></div>
<span class="path" title="${info.data_file}">${info.filename || info.data_file}</span>
<div class="item"><span class="label">已训练</span><span class="value" id="fileElapsedTime">—</span></div>
<div class="item"><span class="label">最优分数</span><span class="value score-best" id="fileBestScore">—</span></div>
<div class="item"><span class="label">验证分数</span><span class="value score-val" id="fileValScore">—</span></div>
</div>
<div class="path" title="${info.data_file}">${info.filename || info.data_file}</div>
`;
startBtn.disabled = false;
if ($("retrainBtn")) $("retrainBtn").disabled = false;
}
function updateBtStartBtn() {
@@ -248,21 +256,50 @@ function renderStrategyFileCard(info) {
selectedStrategyFile = info.strategy_file;
selectedStrategySymbol = info.symbol || null;
card.className = "data-file-card valid";
const timeframeItem = info.timeframe
? `<div class="item"><span class="label">周期</span><span class="value">${info.timeframe}</span></div>`
: "";
card.innerHTML = `
<div class="data-file-row">
<div class="item"><span class="label">品种</span><span class="value sym">${info.symbol || "—"}</span></div>
<div class="item"><span class="label">分数</span><span class="value">${formatScore(info.best_score)}</span></div>
<div class="item"><span class="label">版本</span><span class="value">${info.vocab_version || "—"}</span></div>
<span class="path" title="${info.strategy_file}">${info.filename || info.strategy_file}</span>
${timeframeItem}
<div class="item"><span class="label">最优分数</span><span class="value score-best">${formatScore(info.best_score)}</span></div>
<div class="item"><span class="label">词表版本</span><span class="value">${info.vocab_version || "—"}</span></div>
<div class="item"><span class="label">公式长度</span><span class="value">${info.formula_decoded ? info.formula_decoded.split("→").length : "—"}</span></div>
</div>
<div class="path" title="${info.strategy_file}">${info.filename || info.strategy_file}</div>
`;
updateBtStartBtn();
}
function formatElapsed(startedAtIso) {
if (!startedAtIso) return "—";
const started = new Date(startedAtIso).getTime();
if (Number.isNaN(started)) return "—";
const secs = Math.max(0, Math.floor((Date.now() - started) / 1000));
const h = Math.floor(secs / 3600);
const m = Math.floor((secs % 3600) / 60);
if (h > 0) return `${h}小时${m}分`;
return `${m}分钟`;
}
function updateFileProgress(progress) {
const el = document.getElementById("fileProgressPct");
if (!el || !progress) return;
el.textContent = `${progress.current_step} / ${progress.train_steps} (${progress.progress_pct}%)`;
if (el && progress) {
el.textContent = `${progress.current_step} / ${progress.train_steps} (${progress.progress_pct}%)`;
}
const bestEl = document.getElementById("fileBestScore");
if (bestEl) {
bestEl.textContent = progress ? formatScore(progress.best_score) : "—";
}
const valEl = document.getElementById("fileValScore");
if (valEl) {
let val = progress?.val_score;
if (val == null && progress?.history?.val_score?.length) {
val = progress.history.val_score[progress.history.val_score.length - 1];
}
valEl.textContent = progress ? formatScore(val) : "—";
}
}
const CHART_SERIES = [
@@ -465,14 +502,19 @@ function updateTrainingUI(training) {
const active = training?.active;
const pill = $("jobPill");
const startBtn = $("startBtn");
const retrainBtn = $("retrainBtn");
const stopBtn = $("stopBtn");
const elapsedEl = $("fileElapsedTime");
if (!job || job.state === "idle") {
pill.innerHTML = '<i class="pill-dot"></i>空闲';
pill.className = "pill";
startBtn.disabled = !selectedDataFile;
if (retrainBtn) retrainBtn.disabled = !selectedDataFile;
stopBtn.disabled = true;
$("logHint").textContent = "—";
if (elapsedEl) elapsedEl.textContent = "—";
return;
}
@@ -488,8 +530,13 @@ function updateTrainingUI(training) {
pill.className = "pill " + (job.state === "running" ? "running" : job.state);
startBtn.disabled = active;
if (retrainBtn) retrainBtn.disabled = active;
stopBtn.disabled = !active;
$("logHint").textContent = job.log_path || "—";
if (elapsedEl) {
const elapsed = formatElapsed(job.started_at);
elapsedEl.textContent = active || elapsed === "—" ? elapsed : `${elapsed}(已停)`;
}
const logView = $("logView");
const atBottom = isViewAtBottom(logView);
@@ -798,7 +845,32 @@ async function startTraining() {
const res = await fetchJSON("/api/training/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_file: selectedDataFile }),
body: JSON.stringify({ data_file: selectedDataFile, from_scratch: false }),
});
selectedSymbol = res.data_file?.symbol || res.job?.symbol;
renderDataFileCard(res.data_file);
await refreshOverview();
} catch (e) {
$("debugView").scrollIntoView({ behavior: "smooth", block: "nearest" });
}
}
async function retrainFromScratch() {
if (!selectedDataFile) {
await logClientError("请先选择数据文件");
return;
}
const ok = window.confirm(
"重新训练会清除该品种的检查点,从第 0 步重新搜索。\n" +
"已有的更优策略会保留,只有挖到更高分才会覆盖。\n\n" +
"确定要重新训练吗?"
);
if (!ok) return;
try {
const res = await fetchJSON("/api/training/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ data_file: selectedDataFile, from_scratch: true }),
});
selectedSymbol = res.data_file?.symbol || res.job?.symbol;
renderDataFileCard(res.data_file);
@@ -2176,6 +2248,7 @@ async function init() {
}
$("browseBtn").addEventListener("click", browseDataFile);
$("startBtn").addEventListener("click", startTraining);
if ($("retrainBtn")) $("retrainBtn").addEventListener("click", retrainFromScratch);
$("stopBtn").addEventListener("click", stopTraining);
$("exportBtn").addEventListener("click", exportStrategy);
$("exportTrainingBtn").addEventListener("click", exportTraining);
+57 -15
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=23" />
<link rel="stylesheet" href="/static/style.css?v=27" />
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
</head>
<body>
@@ -74,20 +74,22 @@
<span class="hud-corner bl"></span><span class="hud-corner br"></span>
<h2>启动训练</h2>
<div class="launch-form launch-form-file">
<div class="launch-row">
<button type="button" class="btn btn-secondary" id="browseBtn">选择数据文件</button>
<div class="launch-row launch-row-main">
<button type="button" class="btn btn-secondary btn-browse" id="browseBtn">选择数据文件</button>
<div class="data-file-card" id="dataFileCard">
<div class="data-file-empty">尚未选择</div>
</div>
<div class="launch-actions">
<button class="btn btn-primary" id="startBtn" disabled>开始训练</button>
<button class="btn btn-danger" id="stopBtn" disabled>停止</button>
<button class="btn btn-secondary" id="exportBtn" disabled title="下载当前品种的最优策略 JSON">导出策略</button>
<button class="btn btn-secondary" id="exportTrainingBtn" disabled title="打包 checkpoint、训练曲线与策略为 zip">导出训练</button>
<button class="btn btn-secondary" id="importTrainingBtn" disabled title="上传 .zip 或 .pt,下次训练断点续训">导入训练</button>
<input type="file" id="importTrainingFile" accept=".zip,.pt" hidden />
<div class="data-file-empty">尚未选择数据文件</div>
</div>
</div>
<div class="launch-actions" role="group" aria-label="训练操作">
<button class="btn btn-primary" id="startBtn" disabled>开始训练</button>
<button class="btn btn-secondary" id="retrainBtn" disabled title="清除检查点,从头重新搜索(保留已有更优策略)">重新训练</button>
<button class="btn btn-danger" id="stopBtn" disabled>停止</button>
<span class="action-sep" aria-hidden="true"></span>
<button class="btn btn-secondary" id="exportBtn" disabled title="下载当前品种的最优策略 JSON">导出策略</button>
<button class="btn btn-secondary" id="exportTrainingBtn" disabled title="打包 checkpoint、训练曲线与策略为 zip">导出训练</button>
<button class="btn btn-secondary" id="importTrainingBtn" disabled title="上传 .zip 或 .pt,下次训练断点续训">导入训练</button>
<input type="file" id="importTrainingFile" accept=".zip,.pt" hidden />
</div>
<small class="field-hint">Parquet 命名:<code>{品种}_{周期}.parquet</code>,如 <code>XAUUSD_H1.parquet</code></small>
</div>
</section>
@@ -139,6 +141,46 @@
</section>
</div>
<section class="panel howto-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>
<details class="chart-metrics-help bt-howto" open>
<summary>训练说明:并行?最优分数 / 验证分数是什么?</summary>
<ol class="bt-howto-list">
<li>
<strong>单品种单周期就已经把 CPU 吃满了</strong>
<p>挖因子这一步计算很重:特征、公式搜索、回测验证会把机器算力用到接近满载。这时再并行开第二个品种或另一个周期,大家只是在抢同一批 CPU,谁也跑不快。</p>
</li>
<li>
<strong>并行并不等于整体更快</strong>
<p>两路一起跑,往往各自都变慢,总完工时间通常不比「先训完一个再训下一个」更短,有时还更拖。所以默认一次只训一路,把算力集中用在当前任务上更划算。</p>
</li>
<li>
<strong>只盯一个品种,更好观察训练情况</strong>
<p>曲线、日志、最优公式都只对应这一组数据,涨没涨分一眼能看清。多品种并行时页面和日志会搅在一起,反而不容易判断「这个品种还值不值得继续挖」。</p>
</li>
<li>
<strong>最优分数是什么</strong>
<p>到目前为止挖到的<strong>最好那条公式</strong>的得分。它一般只升不降:一旦发现更好的公式,这个数就会抬一截;长时间不动,说明这段时间还没挖到更强的。</p>
</li>
<li>
<strong>验证分数是什么</strong>
<p>当前这一步在<strong>验证集</strong>上算出来的平均表现,反映「最近这批公式整体好不好」。它会上下波动很正常;更值得看的是整体有没有慢慢抬高,而不是盯着单次跳动。</p>
</li>
<li>
<strong>为什么验证曲线会一截一截地掉下去再爬起来?</strong>
<p>图上的验证分数不是「当前最优那一条」的得分,而是<strong>这一步随机抽出来的一整批公式的平均分</strong>。挖着挖着,模型会越来越爱重复同一类公式(探索变少),系统就会主动「搅一搅」——加大随机、换一批新配方继续搜。刚搅完时,新配方里好坏掺半,平均值会突然掉一截;随后又慢慢学到更好的组合,曲线再往上爬。所以会看到锯齿状起伏。</p>
<p>绿色的最优分数只记「历史最好的那一次」,所以不会跟着掉下去,只会阶梯往上抬。锯齿本身多半是在重新探索,不等于训练坏了。</p>
</li>
<li>
<strong>重新训练有机会找出更好的量化因子</strong>
<p>「开始训练」会接着上次的检查点继续挖;「重新训练」会清掉检查点,从零换一条随机路径再搜一遍。因为搜索带随机性,有时能挖到比现在更高的分数。</p>
<p>已有的最优策略会当作分数下限留着——只有找到更好的才会覆盖,不会一开局就被弱公式冲掉。代价是要再花一整轮算力,适合本轮训完或最优分数长期不动时再用。</p>
</li>
</ol>
</details>
</section>
<section class="panel strategies-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>
@@ -217,8 +259,8 @@
<span class="hud-corner bl"></span><span class="hud-corner br"></span>
<h2>启动回测</h2>
<div class="launch-form launch-form-file">
<div class="launch-row">
<button type="button" class="btn btn-secondary" id="btBrowseStrategyBtn">选择策略</button>
<div class="launch-row launch-row-main">
<button type="button" class="btn btn-secondary btn-browse" id="btBrowseStrategyBtn">选择策略</button>
<div class="data-file-card" id="btStrategyCard">
<div class="data-file-empty">尚未选择策略文件</div>
</div>
@@ -500,6 +542,6 @@
</div>
<script src="/static/bg.js?v=1"></script>
<script src="/static/app.js?v=52"></script>
<script src="/static/app.js?v=55"></script>
</body>
</html>
+63 -11
View File
@@ -438,6 +438,7 @@ select:focus { border-color: var(--accent); }
.launch-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-left: auto;
}
@@ -547,6 +548,33 @@ select:focus { border-color: var(--accent); }
.launch-row .launch-actions { margin-left: auto; }
/* 主行:选择按钮与信息卡等高并排 */
.launch-row-main { align-items: stretch; }
.btn-browse {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
}
/* 操作按钮独占一行(训练页):不再右推,左对齐成组 */
.launch-form-file > .launch-actions {
margin-left: 0;
margin-top: 2px;
}
/* 控制组 / 文件操作组之间的竖线分隔 */
.action-sep {
align-self: stretch;
width: 1px;
min-height: 22px;
margin: 0 5px;
background: var(--border-strong);
opacity: 0.7;
}
.bt-cost-row {
align-items: flex-end;
gap: 14px;
@@ -600,10 +628,15 @@ select:focus { border-color: var(--accent); }
.data-file-card {
flex: 1 1 280px;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
gap: 4px;
min-height: 50px;
background: rgba(14, 20, 31, 0.6);
border: 1px dashed var(--border-strong);
border-radius: 10px;
padding: 9px 13px;
padding: 7px 16px;
transition: border-color 0.2s, box-shadow 0.2s;
}
@@ -617,34 +650,53 @@ select:focus { border-color: var(--accent); }
.data-file-empty { color: var(--text-muted); font-size: 0.85rem; }
/* 信息卡:仪表盘统计条(每个指标竖向小格 + 细竖线分隔)*/
.data-file-row {
display: flex;
align-items: center;
gap: 16px;
gap: 0;
flex-wrap: wrap;
font-size: 0.85rem;
}
.data-file-row .item { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; }
.data-file-row .item {
display: flex;
flex-direction: column;
gap: 3px;
padding: 2px 13px;
border-left: 1px solid var(--border);
white-space: nowrap;
}
.data-file-row .item:first-child { border-left: none; padding-left: 2px; }
.data-file-row .label {
font-size: 0.68rem;
font-size: 0.6rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
letter-spacing: 0.08em;
}
.data-file-row .value { font-family: var(--mono); color: var(--text); }
.data-file-row .value.sym { font-weight: 600; color: var(--accent); text-shadow: 0 0 12px var(--accent-glow); }
.data-file-row .path {
.data-file-row .value {
font-family: var(--mono);
font-size: 0.72rem;
font-size: 0.95rem;
line-height: 1.1;
color: var(--text);
}
.data-file-row .value.sym { font-weight: 600; color: var(--accent); text-shadow: 0 0 12px var(--accent-glow); }
.data-file-row .value.score-best { color: #34f5c8; font-weight: 600; }
.data-file-row .value.score-val { color: #38bdf8; font-weight: 600; }
/* 文件名:独立一行放在指标条下方,带顶部细分割线,避免右侧留白 */
.data-file-card > .path {
margin-top: 4px;
padding-top: 5px;
border-top: 1px solid var(--border);
font-family: var(--mono);
font-size: 0.7rem;
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 200px;
}
.data-file-error { color: var(--danger); font-size: 0.82rem; }
+4
View File
@@ -82,6 +82,8 @@ class TrainingManager:
symbol: str,
timeframe: str,
mode: str = "ftmo",
*,
from_scratch: bool = False,
) -> TrainingJob:
with self._lock:
self._refresh_state()
@@ -106,6 +108,8 @@ class TrainingManager:
"--data-file",
data_file,
]
if from_scratch:
cmd.append("--from-scratch")
self._log_fp = open(log_path, "w", encoding="utf-8", buffering=1)
env = os.environ.copy()