0.993
This commit is contained in:
@@ -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
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user