This commit is contained in:
qq564020069
2026-07-14 15:33:15 +08:00
parent babe401406
commit 3728825adc
6 changed files with 376 additions and 20 deletions
+58
View File
@@ -0,0 +1,58 @@
"""Tests for per-symbol training time accounting."""
from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from web import training_time as tt
@pytest.fixture
def isolated_training_time(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(tt, "PROJECT_ROOT", tmp_path)
monkeypatch.setattr(tt, "LOG_DIR", tmp_path / "logs")
(tmp_path / "logs").mkdir(parents=True, exist_ok=True)
tt._backfilled.clear()
yield tmp_path
def test_record_and_summarize_session(isolated_training_time: Path) -> None:
start = datetime(2026, 7, 14, 10, 0, 0, tzinfo=timezone.utc)
end = start + timedelta(hours=2, minutes=30)
tt.record_training_session(
symbol="XAUUSD",
started_at=start.isoformat(),
finished_at=end.isoformat(),
log_path="logs/train_XAUUSD_20260714_100000.log",
)
summary = tt.get_training_time_summary("XAUUSD")
assert summary.history_total_seconds == 9000
assert summary.session_seconds is None
job = {
"symbol": "XAUUSD",
"started_at": (end + timedelta(minutes=5)).isoformat(),
"finished_at": None,
}
live = tt.get_training_time_summary("XAUUSD", job=job, active=True)
assert live.session_seconds == pytest.approx(0, abs=5)
assert live.history_total_seconds >= 9000
def test_backfill_from_logs(isolated_training_time: Path) -> None:
log = isolated_training_time / "logs" / "train_XAUUSD_20260714_120000.log"
log.write_text("train\n", encoding="utf-8")
start_ts = datetime(2026, 7, 14, 12, 0, 0, tzinfo=timezone.utc).timestamp()
end_ts = start_ts + 3600
import os
os.utime(log, (start_ts, end_ts))
summary = tt.get_training_time_summary("XAUUSD")
assert summary.history_total_seconds >= 3500
data = json.loads((isolated_training_time / "training_time_XAUUSD.json").read_text(encoding="utf-8"))
assert len(data["sessions"]) >= 1
+26
View File
@@ -0,0 +1,26 @@
{
"symbol": "XAUUSD",
"sessions": [
{
"log_path": "logs/train_XAUUSD_20260713_100546.log",
"started_at": "2026-07-13T10:05:46+00:00",
"finished_at": "2026-07-13T12:31:26.268836+00:00",
"seconds": 8740,
"source": "log_backfill"
},
{
"log_path": "logs/train_XAUUSD_20260713_133709.log",
"started_at": "2026-07-13T13:37:09+00:00",
"finished_at": "2026-07-14T03:17:27.415464+00:00",
"seconds": 49218,
"source": "log_backfill"
},
{
"log_path": "logs/train_XAUUSD_20260714_033057.log",
"started_at": "2026-07-14T03:30:57+00:00",
"finished_at": "2026-07-14T07:23:03.906968+00:00",
"seconds": 13926,
"source": "log_backfill"
}
]
}
+30 -5
View File
@@ -44,6 +44,7 @@ from web.strategy_file import (
sync_best_strategy_for_symbol,
)
from web.training_manager import training_manager
from web.training_time import get_training_time_summary
from web.training_package import build_training_export_zip, import_training_package
from web.backtest_manager import backtest_manager
from web.realtime_manager import realtime_manager
@@ -492,6 +493,22 @@ def _progress_with_live_step(symbol: str, active: bool) -> dict[str, Any]:
}
def _attach_training_time(
row: dict[str, Any] | None,
*,
symbol: str | None,
job: dict[str, Any] | None,
active: bool,
) -> dict[str, Any] | None:
if not row or not symbol:
return row
summary = get_training_time_summary(symbol, job=job, active=active)
row = dict(row)
row["session_seconds"] = summary.session_seconds
row["history_total_seconds"] = summary.history_total_seconds
return row
@app.get("/api/overview")
def api_overview() -> dict[str, Any]:
settings = load_settings()
@@ -499,10 +516,15 @@ def api_overview() -> dict[str, Any]:
file_info = None
progress = None
training = training_manager.status()
job = training.get("job")
active = bool(training.get("active"))
if data_file:
try:
file_info = inspect_parquet_file(data_file)
row = _progress_with_live_step(file_info["symbol"], active=False)
sym = file_info.get("symbol")
row = _progress_with_live_step(sym, active=False)
progress = {
"symbol": row["symbol"],
"status": row["status"],
@@ -515,13 +537,15 @@ def api_overview() -> dict[str, Any]:
"has_checkpoint": row.get("has_checkpoint", False),
"has_strategy": row.get("has_strategy", False),
}
progress = _attach_training_time(
progress, symbol=sym, job=job, active=active and job and job.get("symbol") == sym
)
except Exception as e:
file_info = {"data_file": data_file, "valid": False, "message": str(e)}
training = training_manager.status()
job = training.get("job")
if job and job.get("symbol") and training.get("active"):
row = _progress_with_live_step(job["symbol"], active=True)
if job and job.get("symbol") and active:
sym = job["symbol"]
row = _progress_with_live_step(sym, active=True)
progress = {
"symbol": row["symbol"],
"status": "running_job",
@@ -534,6 +558,7 @@ def api_overview() -> dict[str, Any]:
"has_checkpoint": row.get("has_checkpoint", False),
"has_strategy": row.get("has_strategy", False),
}
progress = _attach_training_time(progress, symbol=sym, job=job, active=True)
return {
"data_file": file_info,
+42 -15
View File
@@ -257,7 +257,8 @@ function renderDataFileCard(info) {
<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>
<div class="item"><span class="label">已训练</span><span class="value" id="fileElapsedTime">—</span></div>
<div class="item"><span class="label">本次训练时长</span><span class="value" id="fileElapsedTime">—</span></div>
<div class="item"><span class="label">历史训练总时长</span><span class="value" id="fileHistoryElapsedTime">—</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>
@@ -327,15 +328,46 @@ function renderStrategyFileCard(info) {
updateBtStartBtn();
}
function formatElapsed(startedAtIso) {
function formatElapsed(startedAtIso, endAtIso) {
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);
const end = endAtIso ? new Date(endAtIso).getTime() : Date.now();
if (Number.isNaN(end)) return "—";
return formatDurationSeconds(Math.max(0, Math.floor((end - started) / 1000)));
}
function formatDurationSeconds(secs) {
if (secs == null || secs < 0) return "—";
const total = Math.floor(secs);
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
if (h > 0) return `${h}小时${m}分`;
return `${m}分钟`;
if (m > 0) return `${m}分钟`;
return `${total}秒`;
}
function updateTrainingTimeFields(progress, training) {
const sessionEl = $("fileElapsedTime");
const historyEl = $("fileHistoryElapsedTime");
if (!sessionEl && !historyEl) return;
if (historyEl) {
const hist = progress?.history_total_seconds;
historyEl.textContent = hist != null ? formatDurationSeconds(hist) : "—";
}
const job = training?.job;
const active = !!training?.active;
if (!sessionEl) return;
if (!job || job.state === "idle") {
sessionEl.textContent = "—";
return;
}
const elapsed = formatElapsed(job.started_at, active ? null : job.finished_at);
sessionEl.textContent = active || elapsed === "—" ? elapsed : `${elapsed}(已停)`;
}
function updateFileProgress(progress) {
@@ -552,7 +584,7 @@ function renderStrategies(rows) {
.join("");
}
function updateTrainingUI(training) {
function updateTrainingUI(training, progress) {
const job = training?.job;
const active = training?.active;
const pill = $("jobPill");
@@ -560,8 +592,6 @@ function updateTrainingUI(training) {
const retrainBtn = $("retrainBtn");
const stopBtn = $("stopBtn");
const elapsedEl = $("fileElapsedTime");
if (!job || job.state === "idle") {
pill.innerHTML = '<i class="pill-dot"></i>空闲';
pill.className = "pill";
@@ -569,7 +599,7 @@ function updateTrainingUI(training) {
if (retrainBtn) retrainBtn.disabled = !selectedDataFile;
stopBtn.disabled = true;
$("logHint").textContent = "—";
if (elapsedEl) elapsedEl.textContent = "—";
updateTrainingTimeFields(progress, training);
return;
}
@@ -588,10 +618,7 @@ function updateTrainingUI(training) {
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}(已停)`;
}
updateTrainingTimeFields(progress, training);
const logView = $("logView");
const atBottom = isViewAtBottom(logView);
@@ -620,7 +647,7 @@ async function refreshOverview() {
updateFileProgress(overview.progress);
updateExportBtn(overview.progress, strategies.strategies);
updateTrainingBtns(overview.progress, training);
updateTrainingUI(training);
updateTrainingUI(training, overview.progress);
renderStrategies(strategies.strategies);
const sym = overview.progress?.symbol || selectedSymbol || training?.job?.symbol;
+21
View File
@@ -67,6 +67,7 @@ class TrainingManager:
self._job: TrainingJob | None = None
self._log_fp = None
self._stopped_by_user = False
self._recorded_log_paths: set[str] = set()
def status(self) -> dict[str, Any]:
with self._lock:
@@ -211,7 +212,27 @@ class TrainingManager:
except Exception:
pass
self._log_fp = None
self._record_session_time()
self._proc = None
def _record_session_time(self) -> None:
job = self._job
if job is None or not job.log_path or not job.started_at:
return
rel = job.log_path.replace("\\", "/")
if rel in self._recorded_log_paths:
return
if job.state == JobState.RUNNING:
return
from web.training_time import record_training_session
record_training_session(
symbol=job.symbol,
started_at=job.started_at,
finished_at=job.finished_at,
log_path=rel,
)
self._recorded_log_paths.add(rel)
training_manager = TrainingManager()
+199
View File
@@ -0,0 +1,199 @@
"""Per-symbol cumulative training session durations for the Web UI."""
from __future__ import annotations
import json
import re
import threading
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
PROJECT_ROOT = Path(__file__).resolve().parents[1]
LOG_DIR = PROJECT_ROOT / "logs"
_lock = threading.Lock()
_backfilled: set[str] = set()
def _stats_path(symbol: str) -> Path:
safe = symbol.replace(".", "_")
return PROJECT_ROOT / f"training_time_{safe}.json"
def _parse_iso(ts: str | None) -> datetime | None:
if not ts:
return None
try:
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
except ValueError:
return None
def _duration_seconds(started_at: str, finished_at: str | None) -> int:
start = _parse_iso(started_at)
if start is None:
return 0
end = _parse_iso(finished_at) if finished_at else datetime.now(timezone.utc)
if end.tzinfo is None:
end = end.replace(tzinfo=timezone.utc)
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
return max(0, int((end - start).total_seconds()))
def _load(symbol: str) -> dict[str, Any]:
path = _stats_path(symbol)
if not path.exists():
return {"symbol": symbol, "sessions": []}
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
return {"symbol": symbol, "sessions": []}
if not isinstance(data.get("sessions"), list):
data["sessions"] = []
data["symbol"] = symbol
return data
def _save(symbol: str, data: dict[str, Any]) -> None:
path = _stats_path(symbol)
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _known_log_paths(data: dict[str, Any]) -> set[str]:
out: set[str] = set()
for row in data.get("sessions") or []:
if isinstance(row, dict):
lp = str(row.get("log_path") or "").strip()
if lp:
out.add(lp.replace("\\", "/"))
return out
def _backfill_from_logs(symbol: str, data: dict[str, Any]) -> dict[str, Any]:
"""One-time import of past train logs (mtime - ctime) for this symbol."""
if symbol in _backfilled:
return data
_backfilled.add(symbol)
safe = symbol.replace(".", "_")
pattern = re.compile(rf"^train_{re.escape(safe)}_(\d{{8}})_(\d{{6}})\.log$")
known = _known_log_paths(data)
sessions: list[dict[str, Any]] = list(data.get("sessions") or [])
if not LOG_DIR.exists():
return data
for path in sorted(LOG_DIR.glob(f"train_{safe}_*.log")):
rel = str(path.relative_to(PROJECT_ROOT)).replace("\\", "/")
if rel in known:
continue
m = pattern.match(path.name)
if not m:
continue
try:
st = path.stat()
except OSError:
continue
date_part, time_part = m.group(1), m.group(2)
try:
started = datetime.strptime(
f"{date_part}{time_part}", "%Y%m%d%H%M%S"
).replace(tzinfo=timezone.utc)
except ValueError:
started = datetime.fromtimestamp(st.st_ctime, tz=timezone.utc)
finished = datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
seconds = max(0, int((finished - started).total_seconds()))
if seconds <= 0:
seconds = max(1, int(finished.timestamp() - st.st_ctime))
sessions.append(
{
"log_path": rel,
"started_at": started.isoformat(),
"finished_at": finished.isoformat(),
"seconds": seconds,
"source": "log_backfill",
}
)
known.add(rel)
data["sessions"] = sessions
if sessions:
_save(symbol, data)
return data
def record_training_session(
*,
symbol: str,
started_at: str,
finished_at: str | None,
log_path: str,
) -> None:
"""Persist one completed training subprocess duration."""
if not symbol or not started_at or not log_path:
return
rel_log = log_path.replace("\\", "/")
seconds = _duration_seconds(started_at, finished_at)
if seconds <= 0:
return
with _lock:
data = _backfill_from_logs(symbol, _load(symbol))
if rel_log in _known_log_paths(data):
return
sessions = list(data.get("sessions") or [])
sessions.append(
{
"log_path": rel_log,
"started_at": started_at,
"finished_at": finished_at,
"seconds": seconds,
"source": "web_job",
}
)
data["sessions"] = sessions
_save(symbol, data)
@dataclass
class TrainingTimeSummary:
session_seconds: int | None
history_total_seconds: int
def get_training_time_summary(
symbol: str,
*,
job: dict[str, Any] | None = None,
active: bool = False,
) -> TrainingTimeSummary:
"""Return current-session and cumulative seconds for *symbol*."""
if not symbol:
return TrainingTimeSummary(session_seconds=None, history_total_seconds=0)
with _lock:
data = _backfill_from_logs(symbol, _load(symbol))
past_seconds = sum(
int(row.get("seconds") or 0)
for row in (data.get("sessions") or [])
if isinstance(row, dict)
)
session_seconds: int | None = None
if job and str(job.get("symbol") or "") == symbol:
session_seconds = _duration_seconds(
str(job.get("started_at") or ""),
None if active else job.get("finished_at"),
)
if session_seconds and active:
history_total = past_seconds + session_seconds
else:
history_total = past_seconds
return TrainingTimeSummary(
session_seconds=session_seconds,
history_total_seconds=history_total,
)