0.992
This commit is contained in:
@@ -1,28 +1,148 @@
|
||||
# AlphaMaster
|
||||
|
||||

|
||||
基于深度神经网络强化学习的量化因子挖掘中心:从 Parquet / MT5 K 线自动搜索可解释因子公式,支持 Web 端训练、回测与实时信号分析。
|
||||
|
||||
*在 AlphaGPT 开源后,社区曾使用开源版本管理了 1.66 亿人民币规模资金。*
|
||||
[](LICENSE)
|
||||
|
||||
[时隔数月后,项目作者再次受到了人身安全威胁。](https://github.com/imbue-bit/fuck_all_tian)
|
||||

|
||||
|
||||
也许金融真的是一个意外死亡率很高的行业🤔
|
||||
仓库地址:[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 实时分析** | 多数据源监控,收盘后更新信号;可选飞书转折提醒 |
|
||||
|
||||
### 模型训练
|
||||
|
||||

|
||||
|
||||
- Parquet 命名:`{品种}_{周期}.parquet`,例如 `BTCUSDT_H1.parquet`、`XAUUSD_H1.parquet`
|
||||
- **开始训练**:有检查点则断点续训
|
||||
- **重新训练**:清除检查点从头搜索;已有更优策略作为分数下限,不会被弱结果覆盖
|
||||
- 展示最优分数、验证分数、训练曲线与最优公式;可选 AI 分析当前训练情况
|
||||
|
||||
### 策略回测
|
||||
|
||||

|
||||
|
||||
- 仓位:`position = tanh(factor)`,信号越强仓位越大
|
||||
- 成本:手续费 + 滑点(默认约 0.02% / 0.01%)
|
||||
- 输出:总收益、夏普、索提诺、盈亏比、滚动夏普与资金曲线
|
||||
|
||||

|
||||
|
||||
### 实时分析
|
||||
|
||||

|
||||
|
||||
- 数据源: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 很有用,可以通过以下渠道捐助作者:
|
||||
|
||||

|
||||
本项目采用 [GNU Affero General Public License v3.0 (AGPL-3.0)](LICENSE)。
|
||||
修改、分发或通过网络提供服务时,须按相同协议公开对应源代码。
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#imbue-bit/AlphaGPT&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
Binary file not shown.
|
Before Width: | Height: | Size: 177 KiB |
@@ -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")))
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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; }
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user