Files
AlphaMaster/scripts/test_full_web_suite.py
2026-07-10 18:14:59 +08:00

468 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Full functional test suite for AlphaMaster Web UI + training backend."""
from __future__ import annotations
import json
import mimetypes
import sys
import tempfile
import time
import urllib.error
import urllib.request
import zipfile
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
API = "http://127.0.0.1:8766"
PORT = 8766
passed: list[str] = []
failed: list[str] = []
warnings: list[str] = []
def ok(name: str, detail: str = "") -> None:
passed.append(name)
msg = f" [OK] {name}"
if detail:
msg += f" — {detail}"
print(msg)
def fail(name: str, detail: str) -> None:
failed.append(f"{name}: {detail}")
print(f" [FAIL] {name} — {detail}")
def warn(name: str, detail: str) -> None:
warnings.append(f"{name}: {detail}")
print(f" [WARN] {name} — {detail}")
def get(path: str, timeout: int = 15) -> tuple[int, dict | bytes, dict]:
req = urllib.request.Request(API + path)
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
headers = dict(resp.headers)
ctype = headers.get("Content-Type") or headers.get("content-type") or ""
if "json" in ctype:
return resp.status, json.loads(raw.decode()), headers
return resp.status, raw, headers
except urllib.error.HTTPError as e:
raw = e.read()
try:
body = json.loads(raw.decode())
except Exception:
body = raw
return e.code, body, dict(e.headers)
def post_json(path: str, data: dict | None = None, method: str = "POST") -> tuple[int, dict]:
body = json.dumps(data or {}).encode()
req = urllib.request.Request(
API + path,
data=body,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode())
except Exception:
return e.code, {"detail": e.read().decode(errors="replace")}
def put_json(path: str, data: dict) -> tuple[int, dict]:
return post_json(path, data, method="PUT")
def post_file(path: str, file_path: Path, query: str = "") -> tuple[int, dict]:
boundary = "----AlphaMasterTest"
data = file_path.read_bytes()
fname = file_path.name
ctype = mimetypes.guess_type(fname)[0] or "application/octet-stream"
body = (
f"--{boundary}\r\n"
f'Content-Disposition: form-data; name="file"; filename="{fname}"\r\n'
f"Content-Type: {ctype}\r\n\r\n"
).encode() + data + f"\r\n--{boundary}--\r\n".encode()
req = urllib.request.Request(
API + path + query,
data=body,
method="POST",
headers={"Content-Type": f"multipart/form-data; boundary={boundary}"},
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return resp.status, json.loads(resp.read().decode())
except urllib.error.HTTPError as e:
try:
return e.code, json.loads(e.read().decode())
except Exception:
return e.code, {"detail": e.read().decode(errors="replace")}
def wait_server(max_wait: float = 30.0) -> bool:
deadline = time.time() + max_wait
while time.time() < deadline:
try:
st, body, _ = get("/api/health")
if st == 200 and isinstance(body, dict) and body.get("status") == "ok":
return True
except Exception:
pass
time.sleep(0.5)
return False
def mt5_data_file() -> str:
settings = json.loads((ROOT / "web_settings.json").read_text(encoding="utf-8"))
path = settings.get("last_data_file", "")
if not path or not Path(path).exists():
d = next(x for x in Path("D:/").iterdir() if x.is_dir() and x.name.startswith("MT5"))
path = str(d / "ADAUSD_H1.parquet")
return path
def test_static_and_health() -> str | None:
print("\n== 1. 静态资源与健康检查 ==")
if not wait_server():
fail("server", "8766 无响应")
return None
st, body, _ = get("/api/health")
if st != 200 or body.get("version") != "1.1.0":
fail("health", str(body))
else:
ok("health", body["version"])
st, body, _ = get("/api/routes")
paths = {r["path"] for r in body.get("routes", [])}
required = {
"/api/overview",
"/api/training/start",
"/api/training/import",
"/api/training/{symbol}/export",
}
missing = required - paths
if missing:
fail("routes", f"缺少 {missing}")
else:
ok("routes", f"{len(paths)} 条")
st, html, _ = get("/")
if st != 200 or b"exportTrainingBtn" not in html:
fail("index.html", "缺少导出训练按钮")
else:
ok("index.html", "含导出/导入训练按钮")
for asset in ["/static/app.js?v=14", "/static/style.css?v=5", "/static/bg.js?v=1"]:
st, content, _ = get(asset.split("?")[0])
if st != 200 or len(content) < 100:
fail("static", asset)
else:
ok("static", asset.split("?")[0])
st, body, _ = get("/api/config")
if st != 200 or not body.get("train_steps"):
fail("config", str(body))
else:
ok("config", f"device={body.get('device')} steps={body.get('train_steps')}")
return mt5_data_file()
def test_settings(data_file: str) -> None:
print("\n== 2. 设置与数据文件 ==")
st, body = get("/api/settings")[0:2]
if st != 200:
fail("settings get", str(body))
else:
ok("settings get", body.get("last_data_file", "")[-30:])
st, body = put_json("/api/settings", {"debug_mode": True})
if st != 200 or not body.get("debug_mode"):
fail("settings put debug", str(body))
else:
ok("settings put debug")
st, body, _ = get("/api/config")
if not body.get("debug_mode"):
warn("debug mode", "config 未反映 debug_mode=true")
else:
ok("debug mode sync")
st, body, _ = get("/api/overview")
df = body.get("data_file") or {}
if not df.get("valid"):
fail("overview data_file", df.get("message", "invalid"))
else:
ok("overview data_file", f"{df.get('symbol')} {df.get('timeframe')} {df.get('bars')} bars")
prog = body.get("progress") or {}
if prog.get("symbol") != df.get("symbol"):
fail("overview progress symbol", str(prog))
else:
ok("overview progress", f"step={prog.get('current_step')} strategy={prog.get('has_strategy')}")
st, body, _ = get(f"/api/symbols/{df['symbol']}")
if st != 200 or body.get("symbol") != df["symbol"]:
fail("symbol detail", str(body))
else:
ok("symbol detail", f"has_strategy={body.get('has_strategy')}")
st, body = post_json("/api/training/start", {"data_file": "D:\\no_such_file.parquet"})
if st != 404:
fail("invalid file", f"expected 404 got {st}")
else:
ok("invalid file 404")
put_json("/api/settings", {"debug_mode": False, "last_data_file": data_file})
def test_strategies(symbol: str) -> None:
print("\n== 3. 策略列表与导出 ==")
st, body, _ = get("/api/strategies")
strategies = body.get("strategies") or []
if st != 200 or not strategies:
fail("strategies list", "empty")
else:
ok("strategies list", f"{len(strategies)} 条")
has = any(s.get("symbol") == symbol for s in strategies)
if not has:
warn("strategy for symbol", f"{symbol} 不在列表(可能无 best_*.json)")
else:
ok("strategy exists", symbol)
st, body, _ = get(f"/api/strategies/{symbol}/export")
if st == 200:
if isinstance(body, dict) and body.get("symbol"):
ok("export strategy", f"score={body.get('best_score')}")
elif isinstance(body, bytes):
try:
payload = json.loads(body.decode())
ok("export strategy", f"score={payload.get('best_score')}")
except Exception as e:
fail("export strategy json", str(e))
else:
fail("export strategy", f"unexpected body type {type(body)}")
st, _, _ = get("/api/strategies/NOPE/export")
if st != 404:
fail("export strategy 404", f"got {st}")
else:
ok("export strategy 404")
def test_training_io(symbol: str) -> Path | None:
print("\n== 4. 训练包导出/导入 ==")
st, _, _ = get(f"/api/training/{symbol}/export")
if st == 404:
warn("export training", f"{symbol} 无 checkpoint,创建临时 fixture")
from data_pipeline.parquet_manager import ParquetDataManager
from model_core.engine import AlphaEngine
data_file = mt5_data_file()
ckpt_dir = ROOT / "checkpoints"
ckpt_dir.mkdir(exist_ok=True)
mgr = ParquetDataManager(data_file)
mgr.load()
eng = AlphaEngine(data_manager=mgr, target_symbol=symbol)
eng.training_history = {
"step": [0, 1],
"best_score": [0.1, 0.2],
"val_score": [0.05, 0.15],
"entropy": [1.0, 0.9],
}
eng.best_score = 0.2
eng.best_formula = [1, 2, 3]
eng.save_checkpoint(2)
(ROOT / f"training_history_{symbol}.json").write_text(
json.dumps(eng.training_history, ensure_ascii=False, indent=2),
encoding="utf-8",
)
st, raw, headers = get(f"/api/training/{symbol}/export")
if st != 200 or not isinstance(raw, bytes):
fail("export training", f"HTTP {st}")
return None
ok("export training", f"{len(raw)} bytes")
zip_path = Path(tempfile.gettempdir()) / f"test_{symbol}.zip"
zip_path.write_bytes(raw)
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
if "manifest.json" not in names:
fail("zip manifest", str(names))
else:
ok("zip manifest", f"{len(names)} files")
for p in (ROOT / "checkpoints").glob(f"ckpt_{symbol}_step_*.pt"):
p.unlink()
st, body = post_file("/api/training/import", zip_path, f"?symbol={symbol}")
if st != 200 or not body.get("ok"):
fail("import training", str(body))
else:
ok("import training", body.get("message", "")[:40])
st, body = post_file("/api/training/import", zip_path, "?symbol=WRONG")
if st != 400:
fail("import mismatch", f"expected 400 got {st}")
else:
ok("import mismatch 400")
return zip_path
def test_training_live(data_file: str, symbol: str) -> None:
print("\n== 5. 训练启停 / 日志 / 曲线同步 ==")
post_json("/api/training/stop")
st, body = post_json("/api/training/start", {"data_file": data_file})
if st != 200 or not body.get("ok"):
fail("training start", str(body))
return
ok("training start", f"pid={body.get('job', {}).get('pid')}")
st, body = post_json("/api/training/start", {"data_file": data_file})
if st != 409:
fail("duplicate start", f"expected 409 got {st}")
post_json("/api/training/stop")
else:
ok("duplicate start 409")
sync_ok = False
last_log_len = 0
for i in range(20):
time.sleep(4)
st, status, _ = get("/api/training/status")
st2, ov, _ = get("/api/overview")
st3, sym, _ = get(f"/api/symbols/{symbol}")
active = status.get("active")
log_tail = status.get("log_tail") or []
prog = (ov.get("progress") or {})
hist = (sym.get("history") or {})
steps = hist.get("step") or []
cur = int(prog.get("current_step") or 0)
chart_n = len(steps)
if len(log_tail) > last_log_len:
last_log_len = len(log_tail)
print(
f" t={i*4:02d}s active={active} log={len(log_tail)} "
f"prog={cur} chart={chart_n} status={prog.get('status')}"
)
if log_tail:
last = log_tail[-1]
if any(ord(c) > 127 for c in last):
pass # has non-ascii (Chinese) - good
if "[Web]" in last and i > 2:
warn("log tail", "出现 Web 结束标记,训练可能已停")
if active and chart_n >= 3 and cur >= 3:
# 需观察到步数增长,避免仅用旧 checkpoint 历史误判
if i >= 2 and chart_n > 0 and abs(cur - (int(steps[-1]) + 1)) <= 2:
sync_ok = True
ok("log/chart sync", f"step≈{cur} chart={chart_n}")
break
if not sync_ok:
if last_log_len == 0:
fail("training log", "无日志输出")
else:
warn("log/chart sync", f"60s 内未确认同步 prog={cur} chart={chart_n}")
st, body = post_json("/api/training/stop")
time.sleep(2)
st, status, _ = get("/api/training/status")
job = status.get("job") or {}
state = job.get("state")
if state == "failed":
warn("stop state", f"停止后状态为 failed(Windows 上 exit_code=1),建议显示为已停止")
elif state in ("stopped", "completed"):
ok("training stop", state)
else:
warn("training stop", f"state={state}")
hist_file = ROOT / f"training_history_{symbol}.json"
if hist_file.exists():
hist = json.loads(hist_file.read_text(encoding="utf-8"))
n = len(hist.get("step") or [])
if n >= 1:
ok("training_history file", f"{n} steps")
else:
warn("training_history file", "empty steps")
else:
warn("training_history file", "不存在(训练步数过少或未写入)")
def test_debug() -> None:
print("\n== 6. 调试接口 ==")
st, body = post_json("/api/debug/client-log", {
"level": "error",
"message": "automated test message",
"context": {"suite": "full"},
})
if st != 200:
fail("client-log", str(body))
else:
ok("client-log")
st, body, _ = get("/api/debug/logs?lines=50")
if st != 200:
fail("debug logs", str(body))
else:
ok("debug logs", f"server={len(body.get('server_log', []))} err={len(body.get('error_log', []))}")
def main() -> None:
print("=== AlphaMaster 全功能实测 ===")
data_file = test_static_and_health()
if not data_file:
print_summary()
raise SystemExit(1)
from data_pipeline.parquet_manager import inspect_parquet_file
info = inspect_parquet_file(data_file)
symbol = info["symbol"]
print(f"\n数据文件: {info['filename']} ({symbol} {info['timeframe']})")
test_settings(data_file)
test_strategies(symbol)
test_training_io(symbol)
test_training_live(data_file, symbol)
test_debug()
print_summary()
if failed:
raise SystemExit(1)
def print_summary() -> None:
print("\n" + "=" * 50)
print(f"通过: {len(passed)} 失败: {len(failed)} 警告: {len(warnings)}")
if warnings:
print("\n警告:")
for w in warnings:
print(f" - {w}")
if failed:
print("\n失败:")
for f in failed:
print(f" - {f}")
else:
print("\n未发现阻断性问题。")
if __name__ == "__main__":
main()