Files
AlphaMaster/web/training_time.py
T
2026-07-14 15:33:15 +08:00

200 lines
5.8 KiB
Python

"""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,
)