You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1119 lines
47 KiB
Python

"""CLI for strategy improvement tracking: record, leaderboard, check-duplicate."""
from __future__ import annotations
import argparse
import datetime as dt
import json
import sys
from pathlib import Path
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.table import Table
from libs.backtest.domain import (
ConfigDelta,
JournalEntry,
OverlayWindowSummary,
RobustnessMatrixSummary,
SplitResult,
WalkForwardSummary,
)
from libs.backtest.tracker import (
_hydrate_split_result,
append_journal_entry,
attach_out_of_time_robustness_summary,
attach_robustness_summary,
attach_walk_forward_summary,
build_split_result,
check_duplicate,
compute_deployment_score,
compute_public_sqs,
compute_public_sqs_v2,
compute_promotion_score,
compute_oot_robustness_gate,
compute_overlay_public_sqs,
compute_overlay_stress_sqs,
compute_robustness_gate,
compute_rqs,
compute_sqs,
compute_sqs_v2,
compute_unified_score,
compute_wfqs,
compute_wfqs_v2,
filter_overlay_registry_entries,
filter_registry_entries,
get_next_entry_id,
journal_lock,
load_journal,
refresh_public_scores,
rebuild_registry,
scan_runs_for_experiment,
sync_official_manifests,
)
from libs.common.time_utils import utc_now
_console = Console(width=140)
_DEFAULT_JOURNAL_DIR = "journal"
_COL_NAME = 38
def _fmt(val: float | None, fmt: str) -> str:
return format(val, fmt) if val is not None else "-"
def _ratio(numerator: float | None, denominator: float | None) -> float | None:
if numerator is None or denominator is None or denominator == 0:
return None
return numerator / denominator
def _entry_matches_target(entry: JournalEntry, target: str) -> bool:
target_upper = target.upper()
return entry.entry_id == target_upper or entry.experiment_name == target
def _ordered_splits(results: dict[str, SplitResult]) -> list[tuple[str, SplitResult]]:
preferred = ["train", "valid", "test"]
ordered: list[tuple[str, SplitResult]] = []
seen: set[str] = set()
for name in preferred:
if name in results:
ordered.append((name, results[name]))
seen.add(name)
for name in sorted(results.keys()):
if name not in seen:
ordered.append((name, results[name]))
return ordered
def _sync_and_rebuild(
journal_path: Path,
registry_path: Path,
leaderboard_path: Path,
) -> object:
sync_official_manifests(journal_path, Path("runs"))
return rebuild_registry(journal_path, registry_path, leaderboard_path)
def _load_optional_wfv_summary(path_str: str | None) -> WalkForwardSummary | None:
if not path_str:
return None
path = Path(path_str)
if not path.exists():
print(f"ERROR: walk-forward summary not found: {path}")
sys.exit(1)
return WalkForwardSummary.model_validate_json(path.read_text())
def _load_optional_robustness_summary(path_str: str | None) -> RobustnessMatrixSummary | None:
if not path_str:
return None
path = Path(path_str)
if not path.exists():
print(f"ERROR: robustness summary not found: {path}")
sys.exit(1)
return RobustnessMatrixSummary.model_validate_json(path.read_text())
def _build_overlay_window_summary(spec_path: str, summary_path: str) -> OverlayWindowSummary:
spec = json.loads(Path(spec_path).read_text())
summary = json.loads(Path(summary_path).read_text())
start_date = spec.get("start_date")
end_date = spec.get("end_date")
if not start_date or not end_date:
print("ERROR: overlay spec must include start_date and end_date")
sys.exit(1)
start = dt.date.fromisoformat(start_date)
end = dt.date.fromisoformat(end_date)
initial_equity = float(spec.get("initial_equity", 10_000.0))
final_equity = summary.get("final_equity")
annualized_return_pct = None
if final_equity is not None and initial_equity > 0:
calendar_days = max((end - start).days, 1)
annualized_return_pct = ((float(final_equity) / initial_equity) ** (365.25 / calendar_days) - 1.0) * 100.0
return OverlayWindowSummary(
window_name=summary.get("overlay_name", Path(spec_path).stem),
overlay_name=summary.get("overlay_name", Path(spec_path).stem),
start_date=start,
end_date=end,
initial_equity=initial_equity,
final_equity=summary.get("final_equity"),
return_pct=summary.get("return_pct"),
annualized_return_pct=annualized_return_pct,
max_drawdown_pct=summary.get("max_dd_pct"),
sharpe_ratio=summary.get("sharpe"),
day_count=int(summary.get("day_count") or 0),
books=list(summary.get("books") or []),
allocations=dict(summary.get("allocations") or {}),
regime_day_counts=dict(summary.get("regime_day_counts") or {}),
)
def _score_sort_key(sort_by: str, entry) -> tuple:
if sort_by == "promotion":
return (
entry.promotion_score is None,
-(entry.promotion_score or 0.0),
-(entry.sqs_score or 0.0),
)
if sort_by in {"deployment", "dep"}:
return (
entry.deployment_score is None,
-(entry.deployment_score or 0.0),
entry.wfqs_score is None,
-(entry.wfqs_score or 0.0),
-(entry.rqs_score or 0.0),
-(entry.sqs_score or 0.0),
)
if sort_by == "wfqs":
return (
entry.wfqs_score is None,
-(entry.wfqs_score or 0.0),
-(entry.deployment_score or 0.0),
-(entry.rqs_score or 0.0),
-(entry.sqs_score or 0.0),
)
if sort_by == "rqs":
return (
entry.rqs_score is None,
-(entry.rqs_score or 0.0),
-(entry.deployment_score or 0.0),
-(entry.sqs_score or 0.0),
)
if sort_by == "unified":
return (
entry.unified_score is None,
-(entry.unified_score or 0.0),
-(entry.sqs_score or 0.0),
)
if sort_by == "sqs2":
return (
entry.wfqs_v2_score is None,
-(entry.wfqs_v2_score or 0.0),
-(entry.wfqs_score or 0.0),
-(entry.sqs_score or 0.0),
)
return (-(entry.sqs_score or 0.0),)
def _validation_pending_reason(entry: JournalEntry) -> str | None:
missing: list[str] = []
if entry.walk_forward_summary is None:
missing.append("WFV")
if entry.robustness_matrix_summary is None:
missing.append("robustness")
if entry.out_of_time_robustness_summary is None:
missing.append("OOT robustness")
if not missing:
return None
return "missing " + " + ".join(missing)
def _title_for_sort(sort_by: str) -> str:
if sort_by == "promotion":
return "Promotion 기준 내림차순"
if sort_by in {"deployment", "dep"}:
return "Deployment 기준 내림차순"
if sort_by == "wfqs":
return "WFQS 기준 내림차순"
if sort_by == "rqs":
return "RQS 기준 내림차순"
if sort_by == "unified":
return "Unified 기준 내림차순"
if sort_by == "sqs2":
return "WFQS v2 기준 내림차순"
return "SQS 기준 내림차순"
def cmd_record(args: argparse.Namespace) -> None:
"""Record an experiment to the improvement journal."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
runs_dir = Path(args.runs_dir)
if not runs_dir.exists():
print(f"ERROR: runs directory not found: {runs_dir}")
sys.exit(1)
split_runs = scan_runs_for_experiment(runs_dir, args.experiment)
if not split_runs:
print(f"ERROR: no runs found for experiment '{args.experiment}' in {runs_dir}")
sys.exit(1)
results: dict[str, SplitResult] = {}
for split_name, (run_id, metrics) in split_runs.items():
results[split_name] = build_split_result(split_name, run_id, metrics)
test_metrics = None
for preferred in ["test", "valid", "train"]:
if preferred in split_runs:
_, test_metrics = split_runs[preferred]
break
sqs_score = None
sqs_breakdown: dict[str, float] = {}
sqs_v2_score = None
sqs_v2_breakdown: dict[str, float] = {}
if test_metrics is not None:
sqs_score, sqs_breakdown = compute_sqs(test_metrics)
sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(test_metrics)
promotion_score, promotion_breakdown = compute_promotion_score(
results.get("test"),
results.get("valid"),
)
unified_score, unified_breakdown = compute_unified_score(
results.get("test"),
results.get("valid"),
)
rqs_score, rqs_breakdown = compute_rqs(
results.get("train"),
results.get("valid"),
results.get("test"),
)
walk_forward_summary = _load_optional_wfv_summary(args.walk_forward_summary)
robustness_matrix_summary = _load_optional_robustness_summary(args.robustness_summary)
out_of_time_robustness_summary = _load_optional_robustness_summary(
getattr(args, "out_of_time_robustness_summary", None)
)
wfqs_score = None
wfqs_breakdown: dict[str, float] = {}
wfqs_v2_score = None
wfqs_v2_breakdown: dict[str, float] = {}
deployment_score = None
deployment_breakdown: dict[str, float] = {}
if walk_forward_summary is not None:
wfqs_score, wfqs_breakdown = compute_wfqs(walk_forward_summary)
wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(walk_forward_summary)
deployment_score, deployment_breakdown = compute_deployment_score(
results.get("train"),
results.get("valid"),
results.get("test"),
walk_forward_summary,
rqs_score=rqs_score,
wfqs_score=wfqs_score,
)
public_sqs, public_breakdown, public_source = compute_public_sqs(
results.get("train"),
results.get("valid"),
results.get("test"),
walk_forward_summary=walk_forward_summary,
robustness_matrix_summary=robustness_matrix_summary,
out_of_time_robustness_summary=out_of_time_robustness_summary,
rqs_score=rqs_score,
wfqs_score=wfqs_v2_score,
)
stress_sqs, stress_breakdown, _ = compute_public_sqs_v2(
results.get("train"),
results.get("valid"),
results.get("test"),
walk_forward_summary=walk_forward_summary,
robustness_matrix_summary=robustness_matrix_summary,
out_of_time_robustness_summary=out_of_time_robustness_summary,
rqs_score=rqs_score,
wfqs_v2_score=wfqs_v2_score,
)
if public_sqs is not None:
sqs_score = public_sqs
sqs_breakdown = public_breakdown
config_delta = None
if args.baseline:
config_delta = ConfigDelta(base_experiment=args.baseline, changes={})
tags = [tag for tag in args.experiment.replace("-", "_").split("_") if tag]
with journal_lock(journal_path):
dupes = check_duplicate(journal_path, args.experiment)
if dupes and not args.force:
print(f"WARNING: experiment '{args.experiment}' already in journal ({len(dupes)} entries).")
print("Use --force to add anyway.")
sys.exit(1)
entry_id = get_next_entry_id(journal_path)
entry = JournalEntry(
entry_id=entry_id,
timestamp=utc_now().isoformat(),
experiment_name=args.experiment,
hypothesis=args.hypothesis or "",
config_delta=config_delta,
results=results,
walk_forward_summary=walk_forward_summary,
robustness_matrix_summary=robustness_matrix_summary,
out_of_time_robustness_summary=out_of_time_robustness_summary,
sqs_score=sqs_score,
sqs_breakdown=sqs_breakdown,
stress_sqs_score=stress_sqs,
stress_sqs_breakdown=stress_breakdown,
sqs_v2_score=sqs_v2_score,
sqs_v2_breakdown=sqs_v2_breakdown,
promotion_score=promotion_score,
promotion_breakdown=promotion_breakdown,
unified_score=unified_score,
unified_breakdown=unified_breakdown,
rqs_score=rqs_score,
rqs_breakdown=rqs_breakdown,
wfqs_score=wfqs_score,
wfqs_breakdown=wfqs_breakdown,
wfqs_v2_score=wfqs_v2_score,
wfqs_v2_breakdown=wfqs_v2_breakdown,
deployment_score=deployment_score,
deployment_breakdown=deployment_breakdown,
verdict=args.verdict or "unknown",
verdict_reasoning=args.reasoning or "",
next_direction=args.next or "",
tags=tags,
)
append_journal_entry(journal_path, entry)
_sync_and_rebuild(journal_path, registry_path, leaderboard_path)
if sqs_score is None:
print(f"Recorded {entry_id}: {args.experiment} (SQS=pending validation)")
else:
source_label = f", source={public_source}" if public_source else ""
stress_label = f", stress={stress_sqs:.1f}" if stress_sqs is not None else ""
print(f"Recorded {entry_id}: {args.experiment} (SQS={sqs_score}{stress_label}{source_label})")
for split_name, sr in _ordered_splits(results):
pf = f"PF={sr.profit_factor:.2f}" if sr.profit_factor is not None else "PF=-"
ret = f"Ret={sr.total_return_pct:+.2f}%" if sr.total_return_pct is not None else "Ret=-"
print(f" {split_name}: {sr.trade_count} trades, {pf}, {ret}")
print(f"Leaderboard updated: {leaderboard_path}")
def cmd_record_overlay(args: argparse.Namespace) -> None:
"""Record an overlay evaluation to the official journal and leaderboard."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
overlay_summary = _build_overlay_window_summary(args.spec, args.summary)
overlay_stress_summary = _build_overlay_window_summary(args.stress_spec, args.stress_summary)
sqs_score, sqs_breakdown, source = compute_overlay_public_sqs(
overlay_summary,
overlay_stress_summary,
)
stress_sqs_score, stress_sqs_breakdown, _ = compute_overlay_stress_sqs(
overlay_summary,
overlay_stress_summary,
)
if sqs_score is None:
print(f"ERROR: overlay score unavailable ({source})")
sys.exit(1)
spec = json.loads(Path(args.spec).read_text())
experiment_name = spec.get("overlay_name") or Path(args.spec).stem
tags = ["overlay", *list(getattr(args, "tags", []) or [])]
with journal_lock(journal_path):
dupes = check_duplicate(journal_path, experiment_name)
if dupes and not args.force:
print(f"WARNING: overlay '{experiment_name}' already in journal ({len(dupes)} entries).")
print("Use --force to add anyway.")
sys.exit(1)
entry_id = get_next_entry_id(journal_path)
entry = JournalEntry(
entry_id=entry_id,
timestamp=utc_now().isoformat(),
experiment_name=experiment_name,
hypothesis=args.hypothesis or "",
overlay_common_window_summary=overlay_summary,
overlay_stress_window_summary=overlay_stress_summary,
sqs_score=sqs_score,
sqs_breakdown=sqs_breakdown,
sqs_v3_score=sqs_score,
sqs_v3_breakdown=sqs_breakdown,
stress_sqs_score=stress_sqs_score,
stress_sqs_breakdown=stress_sqs_breakdown,
verdict=args.verdict or "unknown",
verdict_reasoning=args.reasoning or "",
next_direction=args.next or "",
tags=tags,
)
append_journal_entry(journal_path, entry)
_sync_and_rebuild(journal_path, registry_path, leaderboard_path)
stress_label = f", stress={stress_sqs_score:.1f}" if stress_sqs_score is not None else ""
print(f"Recorded {entry_id}: {experiment_name} (SQS={sqs_score:.1f}{stress_label}, source={source})")
print(
" common-window:"
f" Ret={overlay_summary.return_pct:+.2f}%"
f", Ann={overlay_summary.annualized_return_pct:+.2f}%"
f", DD={overlay_summary.max_drawdown_pct:.2f}%"
f", Sharpe={overlay_summary.sharpe_ratio:.2f}"
)
print(
" stress-window:"
f" Ret={overlay_stress_summary.return_pct:+.2f}%"
f", Ann={overlay_stress_summary.annualized_return_pct:+.2f}%"
f", DD={overlay_stress_summary.max_drawdown_pct:.2f}%"
f", Sharpe={overlay_stress_summary.sharpe_ratio:.2f}"
)
print(f"Leaderboard updated: {leaderboard_path}")
def cmd_leaderboard(args: argparse.Namespace) -> None:
"""Show or regenerate the leaderboard."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
overlay_leaderboard_path = journal_dir / "OVERLAY_LEADERBOARD.md"
if not journal_path.exists():
journal_path.parent.mkdir(parents=True, exist_ok=True)
journal_path.touch()
with journal_lock(journal_path):
registry = _sync_and_rebuild(journal_path, registry_path, leaderboard_path)
overlay_only = getattr(args, "overlay_only", False)
if overlay_only:
ranked_source = filter_overlay_registry_entries(
registry.entries,
include_retired=getattr(args, "include_retired", False),
)
displayed_leaderboard_path = overlay_leaderboard_path
else:
ranked_source = filter_registry_entries(
registry.entries,
include_retired=getattr(args, "include_retired", False),
include_overlays=False,
)
displayed_leaderboard_path = leaderboard_path
sort_by = getattr(args, "sort", "sqs")
ranked_entries = sorted(ranked_source, key=lambda entry: _score_sort_key(sort_by, entry))
total = len(ranked_entries)
top_n = getattr(args, "top", 10)
diagnostics = (not overlay_only) and sort_by in {"deployment", "dep", "wfqs", "rqs", "promotion", "unified", "sqs2"}
title = (
f"[bold cyan]Top {top_n} / {total}[/] [dim]· overlay official SQS · Common=full-window Stress=OOT[/]"
if overlay_only
else f"[bold cyan]Top {top_n} / {total}[/] [dim]· {_title_for_sort(sort_by)} · Tr=train V=valid T=test[/]"
)
tbl = Table(
box=box.SIMPLE_HEAD,
show_header=True,
header_style="bold yellow",
row_styles=["", "dim"],
padding=(0, 1),
title=title,
title_justify="left",
expand=False,
)
tbl.add_column("#", justify="right", style="bold", no_wrap=True, min_width=3)
tbl.add_column("Experiment", no_wrap=True, min_width=30)
tbl.add_column("SQS", justify="right", style="bold cyan", no_wrap=True, min_width=5)
if diagnostics:
if sort_by == "promotion":
tbl.add_column("Promo", justify="right", style="bright_magenta", no_wrap=True, min_width=5)
else:
tbl.add_column("DEP", justify="right", style="bold green", no_wrap=True, min_width=5)
tbl.add_column("WFQS", justify="right", style="bright_cyan", no_wrap=True, min_width=5)
tbl.add_column("RQS", justify="right", style="bold magenta", no_wrap=True, min_width=5)
tbl.add_column("Tr.Ret%", justify="right", style="bright_blue", no_wrap=True, min_width=7)
tbl.add_column("V.Ret%", justify="right", style="green", no_wrap=True, min_width=6)
tbl.add_column("T.Ret%", justify="right", no_wrap=True, min_width=6)
tbl.add_column("T.Ann%", justify="right", no_wrap=True, min_width=7)
tbl.add_column("T.DD%", justify="right", no_wrap=True, min_width=5)
tbl.add_column("T.Gross%", justify="right", no_wrap=True, min_width=8)
tbl.add_column("T.DIM%", justify="right", no_wrap=True, min_width=6)
tbl.add_column("T.R/G", justify="right", no_wrap=True, min_width=5)
for rank, entry in enumerate(ranked_entries[:top_n], 1):
name = entry.experiment_name
if len(name) > _COL_NAME:
name = name[: _COL_NAME - 1] + ""
is_overlay = entry.strategy_family == "overlay" and entry.overlay_common_window_summary is not None
row = [
str(rank),
name,
_fmt(entry.sqs_score, ".1f"),
]
if diagnostics:
if sort_by == "promotion":
row.append(_fmt(entry.promotion_score, ".1f"))
else:
row.extend([
_fmt(entry.deployment_score, ".1f"),
_fmt(entry.wfqs_score, ".1f"),
_fmt(entry.rqs_score, ".1f"),
])
row.extend([
"-" if is_overlay else _fmt(entry.train_total_return_pct, "+.1f"),
"-" if is_overlay else _fmt(entry.valid_total_return_pct, "+.1f"),
_fmt(entry.total_return_pct, "+.1f"),
_fmt(entry.annualized_return_pct, "+.1f"),
_fmt(entry.max_drawdown_pct, ".1f"),
"-" if is_overlay else _fmt(entry.avg_gross_exposure_pct, ".1f"),
"-" if is_overlay else _fmt(entry.days_in_market_pct, ".1f"),
"-" if is_overlay else _fmt(_ratio(entry.total_return_pct, entry.avg_gross_exposure_pct), ".2f"),
])
tbl.add_row(*row)
_console.print()
_console.print(tbl)
_console.print(f" [dim]LEADERBOARD.md → {displayed_leaderboard_path}[/]\n")
def cmd_show(args: argparse.Namespace) -> None:
"""Show details of a specific journal entry."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
with journal_lock(journal_path):
_sync_and_rebuild(journal_path, registry_path, leaderboard_path)
entries = load_journal(journal_path)
target = args.entry_id
found = [entry for entry in entries if _entry_matches_target(entry, target)]
if not found:
found = [entry for entry in entries if target.lower() in entry.experiment_name.lower()]
if not found:
print(f"No entry found for: {target}")
sys.exit(1)
for entry in found:
train_result = _hydrate_split_result(entry.results.get("train"))
valid_result = _hydrate_split_result(entry.results.get("valid"))
test_result = _hydrate_split_result(entry.results.get("test"))
fresh_rqs_score, fresh_rqs_breakdown = compute_rqs(
train_result,
valid_result,
test_result,
)
fresh_wfqs_score, fresh_wfqs_breakdown = compute_wfqs(entry.walk_forward_summary)
fresh_wfqs_v2_score, _ = compute_wfqs_v2(entry.walk_forward_summary)
fresh_deployment_score, fresh_deployment_breakdown = compute_deployment_score(
train_result,
valid_result,
test_result,
entry.walk_forward_summary,
rqs_score=fresh_rqs_score,
wfqs_score=fresh_wfqs_score,
)
public_sqs, public_breakdown, public_source = compute_public_sqs(
train_result,
valid_result,
test_result,
walk_forward_summary=entry.walk_forward_summary,
robustness_matrix_summary=entry.robustness_matrix_summary,
out_of_time_robustness_summary=entry.out_of_time_robustness_summary,
rqs_score=fresh_rqs_score,
wfqs_score=fresh_wfqs_v2_score,
)
stress_sqs, _, _ = compute_public_sqs_v2(
train_result,
valid_result,
test_result,
walk_forward_summary=entry.walk_forward_summary,
robustness_matrix_summary=entry.robustness_matrix_summary,
out_of_time_robustness_summary=entry.out_of_time_robustness_summary,
rqs_score=fresh_rqs_score,
wfqs_v2_score=fresh_wfqs_v2_score,
)
if public_sqs is None:
public_breakdown = public_breakdown or {}
public_source = public_source or "pending_validation"
print(f"\n{entry.entry_id}{entry.experiment_name}")
print(f" Timestamp: {entry.timestamp}")
print(f" Hypothesis: {entry.hypothesis}")
print(f" Verdict: {entry.verdict}")
pending_reason = _validation_pending_reason(entry)
if public_sqs is None:
print(f" SQS: pending [{public_source}]")
if pending_reason:
print(f" Validation: {pending_reason}")
else:
print(f" SQS: {public_sqs} [{public_source}]")
if stress_sqs is not None:
print(f" Stress SQS: {stress_sqs}")
if entry.config_delta:
print(f" Baseline: {entry.config_delta.base_experiment}")
for key, value in entry.config_delta.changes.items():
print(f" {key}: {value}")
hydrated_results = {
"train": train_result,
"valid": valid_result,
"test": test_result,
**{
name: result
for name, result in entry.results.items()
if name not in {"train", "valid", "test"}
},
}
for split_name, split_result in _ordered_splits(hydrated_results):
pf = f"PF={split_result.profit_factor:.2f}" if split_result.profit_factor is not None else "PF=-"
ret = f"Ret={split_result.total_return_pct:+.2f}%" if split_result.total_return_pct is not None else "Ret=-"
ann = (
f"Ann={split_result.annualized_return_pct:+.2f}%"
if split_result.annualized_return_pct is not None
else "Ann=-"
)
dd = f"DD={split_result.max_drawdown_pct:.2f}%" if split_result.max_drawdown_pct is not None else "DD=-"
gross = (
f"Gross={split_result.avg_gross_exposure_pct:.2f}%"
if split_result.avg_gross_exposure_pct is not None
else "Gross=-"
)
dim = (
f"DIM={split_result.days_in_market_pct:.2f}%"
if split_result.days_in_market_pct is not None
else "DIM=-"
)
ret_on_gross = _ratio(split_result.total_return_pct, split_result.avg_gross_exposure_pct)
rog = f"R/G={ret_on_gross:.2f}" if ret_on_gross is not None else "R/G=-"
print(f" {split_name}: {split_result.trade_count} trades, {pf}, {ret}, {ann}, {dd}, {gross}, {dim}, {rog}")
if entry.walk_forward_summary is not None:
summary = entry.walk_forward_summary
test = summary.test_aggregate
gap = summary.gap_stats
print(
" WFV: "
f"{summary.fold_count} folds, "
f"mean={_fmt(test.mean_return_pct, '+.2f')}%, "
f"median={_fmt(test.median_return_pct, '+.2f')}%, "
f"worst={_fmt(test.worst_return_pct, '+.2f')}%, "
f"positive={_fmt(test.positive_fold_rate_pct, '.1f')}%"
)
print(
" WFV Gap: "
f"mean train-test gap={_fmt(gap.mean_train_test_return_gap_pct, '.2f')}%, "
f"worst={_fmt(gap.worst_train_test_return_gap_pct, '.2f')}%"
)
if entry.robustness_matrix_summary is not None:
summary = entry.robustness_matrix_summary
h63 = next((item for item in summary.horizon_summaries if item.horizon_days == 63), None)
h252 = next((item for item in summary.horizon_summaries if item.horizon_days == 252), None)
print(
" Robust: "
f"windows={summary.overall_window_count}, "
f"positive={_fmt(summary.overall_positive_window_rate_pct, '.1f')}%, "
f"worst={_fmt(summary.overall_worst_return_pct, '+.2f')}%, "
f"63d med={_fmt(h63.median_return_pct if h63 else None, '+.2f')}%, "
f"252d med={_fmt(h252.median_return_pct if h252 else None, '+.2f')}%"
)
for horizon in summary.horizon_summaries:
print(
" "
f"{horizon.horizon_days:>3}d: "
f"n={horizon.window_count}, "
f"mean={_fmt(horizon.mean_return_pct, '+.2f')}%, "
f"median={_fmt(horizon.median_return_pct, '+.2f')}%, "
f"worst={_fmt(horizon.worst_return_pct, '+.2f')}%, "
f"positive={_fmt(horizon.positive_window_rate_pct, '.1f')}%, "
f"dd={_fmt(horizon.mean_max_drawdown_pct, '.2f')}%"
)
if entry.out_of_time_robustness_summary is not None:
summary = entry.out_of_time_robustness_summary
h63 = next((item for item in summary.horizon_summaries if item.horizon_days == 63), None)
h252 = next((item for item in summary.horizon_summaries if item.horizon_days == 252), None)
print(
" OOT Robust: "
f"windows={summary.overall_window_count}, "
f"positive={_fmt(summary.overall_positive_window_rate_pct, '.1f')}%, "
f"worst={_fmt(summary.overall_worst_return_pct, '+.2f')}%, "
f"63d med={_fmt(h63.median_return_pct if h63 else None, '+.2f')}%, "
f"252d med={_fmt(h252.median_return_pct if h252 else None, '+.2f')}%"
)
if args.diagnostics:
promotion_score = entry.promotion_score
promotion_breakdown = entry.promotion_breakdown
if promotion_score is None:
promotion_score, promotion_breakdown = compute_promotion_score(
test_result,
valid_result,
)
unified_score = entry.unified_score
unified_breakdown = entry.unified_breakdown
if unified_score is None:
unified_score, unified_breakdown = compute_unified_score(
test_result,
valid_result,
)
rqs_score = fresh_rqs_score
rqs_breakdown = fresh_rqs_breakdown
wfqs_score = fresh_wfqs_score
wfqs_breakdown = fresh_wfqs_breakdown
deployment_score = fresh_deployment_score
deployment_breakdown = fresh_deployment_breakdown
robustness_gate_factor, robustness_breakdown = compute_robustness_gate(
entry.robustness_matrix_summary,
)
oot_gate_factor, oot_breakdown = compute_oot_robustness_gate(
entry.out_of_time_robustness_summary,
)
print(" Diagnostics:")
print(f" Public SQS: {public_sqs} {public_breakdown}")
if rqs_score is not None:
print(f" RQS: {rqs_score} {rqs_breakdown}")
if wfqs_score is not None:
print(f" WFQS: {wfqs_score} {wfqs_breakdown}")
wfqs_v2_score = entry.wfqs_v2_score
wfqs_v2_breakdown = entry.wfqs_v2_breakdown
if wfqs_v2_score is None:
wfqs_v2_score, wfqs_v2_breakdown = compute_wfqs_v2(entry.walk_forward_summary)
if wfqs_v2_score is not None:
print(f" WFQS v2: {wfqs_v2_score} {wfqs_v2_breakdown}")
if deployment_score is not None:
print(f" Deploy: {deployment_score} {deployment_breakdown}")
if promotion_score is not None:
print(f" Promotion: {promotion_score} {promotion_breakdown}")
if unified_score is not None:
print(f" Unified: {unified_score} {unified_breakdown}")
if entry.robustness_matrix_summary is not None:
print(f" RobustGate: {robustness_gate_factor:.2f} {robustness_breakdown}")
if entry.out_of_time_robustness_summary is not None:
print(f" OOTGate: {oot_gate_factor:.2f} {oot_breakdown}")
if entry.verdict_reasoning:
print(f" Reasoning: {entry.verdict_reasoning}")
if entry.next_direction:
print(f" Next: {entry.next_direction}")
def cmd_check_duplicate(args: argparse.Namespace) -> None:
"""Check if an experiment has been recorded already."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
dupes = check_duplicate(journal_path, args.experiment)
if dupes:
print(f"FOUND {len(dupes)} existing entries for '{args.experiment}':")
for entry in dupes:
print(f" {entry.entry_id} ({entry.timestamp[:10]}) — SQS={entry.sqs_score}, verdict={entry.verdict}")
else:
print(f"No existing entries for '{args.experiment}'.")
def cmd_attach_wfv(args: argparse.Namespace) -> None:
"""Attach a walk-forward summary to an existing journal entry."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
summary_path = Path(args.summary)
if not journal_path.exists():
print("No journal found. Run 'record' first.")
sys.exit(1)
if not summary_path.exists():
print(f"ERROR: walk-forward summary not found: {summary_path}")
sys.exit(1)
summary = WalkForwardSummary.model_validate_json(summary_path.read_text())
with journal_lock(journal_path):
updated = attach_walk_forward_summary(journal_path, args.target, summary)
_sync_and_rebuild(journal_path, registry_path, leaderboard_path)
print(
f"Attached WFV to {updated.entry_id}: {updated.experiment_name} "
f"(WFQS={_fmt(updated.wfqs_score, '.1f')}, DEP={_fmt(updated.deployment_score, '.1f')}, folds={summary.fold_count})"
)
print(f"Leaderboard updated: {leaderboard_path}")
def cmd_attach_robustness(args: argparse.Namespace) -> None:
"""Attach a robustness matrix summary to an existing journal entry."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
summary_path = Path(args.summary)
if not journal_path.exists():
print("No journal found. Run 'record' first.")
sys.exit(1)
if not summary_path.exists():
print(f"ERROR: robustness summary not found: {summary_path}")
sys.exit(1)
summary = RobustnessMatrixSummary.model_validate_json(summary_path.read_text())
with journal_lock(journal_path):
updated = attach_robustness_summary(journal_path, args.target, summary)
_sync_and_rebuild(journal_path, registry_path, leaderboard_path)
gate_factor, _ = compute_robustness_gate(summary)
print(
f"Attached robustness matrix to {updated.entry_id}: {updated.experiment_name} "
f"(windows={summary.overall_window_count}, gate={gate_factor:.2f})"
)
print(f"Leaderboard updated: {leaderboard_path}")
def cmd_attach_oot_robustness(args: argparse.Namespace) -> None:
"""Attach out-of-time robustness matrix summary to an existing journal entry."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
summary_path = Path(args.summary)
if not journal_path.exists():
print("No journal found. Run 'record' first.")
sys.exit(1)
if not summary_path.exists():
print(f"ERROR: robustness summary not found: {summary_path}")
sys.exit(1)
summary = RobustnessMatrixSummary.model_validate_json(summary_path.read_text())
with journal_lock(journal_path):
updated = attach_out_of_time_robustness_summary(journal_path, args.target, summary)
_sync_and_rebuild(journal_path, registry_path, leaderboard_path)
gate_factor, _ = compute_oot_robustness_gate(summary)
print(
f"Attached out-of-time robustness to {updated.entry_id}: {updated.experiment_name} "
f"(windows={summary.overall_window_count}, gate={gate_factor:.2f})"
)
print(f"Leaderboard updated: {leaderboard_path}")
def cmd_rescore_public(args: argparse.Namespace) -> None:
"""Recompute stored public scores for matching journal entries."""
journal_dir = Path(args.journal_dir)
journal_path = journal_dir / "improvement_journal.jsonl"
registry_path = journal_dir / "experiment_registry.json"
leaderboard_path = journal_dir / "LEADERBOARD.md"
prefix = args.prefix or ""
def _matches(entry: JournalEntry) -> bool:
if prefix and not entry.experiment_name.startswith(prefix):
return False
if args.min_version is None and args.max_version is None:
return True
if not prefix:
return False
suffix = entry.experiment_name[len(prefix):]
try:
version = float(suffix)
except ValueError:
return False
if args.min_version is not None and version < args.min_version:
return False
if args.max_version is not None and version > args.max_version:
return False
return True
with journal_lock(journal_path):
updated_count = refresh_public_scores(journal_path, selector=_matches)
_sync_and_rebuild(journal_path, registry_path, leaderboard_path)
print(f"Rescored entries: {updated_count}")
print(f"Leaderboard updated: {leaderboard_path}")
def _print_help() -> None:
_console.print()
_console.print(Panel(
"[bold cyan]fithia2[/] — ACE-F Strategy Research & Paper Trading\n"
"[dim]백테스트 실험 추적 + Alpaca 페이퍼 트레이딩[/]",
border_style="cyan",
padding=(0, 2),
))
table = Table(box=box.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2))
table.add_column("Command", style="bold green", no_wrap=True)
table.add_column("Description")
table.add_column("Key Options", style="dim")
table.add_row(
"leaderboard [dim]lb[/]",
"기본 public SQS 순위표 출력 및 LEADERBOARD.md 재생성",
"-n N --sort sqs|rqs|wfqs|deployment|promotion --include-retired",
)
table.add_row(
"record [dim]rec[/]",
"실험 결과를 저널에 기록",
"-e NAME -H TEXT --walk-forward-summary PATH --robustness-summary PATH --out-of-time-robustness-summary PATH",
)
table.add_row(
"show [dim]s[/]",
"특정 저널 항목 상세 조회",
"ENTRY_ID --diagnostics",
)
table.add_row(
"paper",
"Alpaca 페이퍼 트레이딩 [dim](fithia2 paper 로 상세 확인)[/]",
"start run positions status trades ...",
)
table.add_row(
"pipeline",
"데이터 파이프라인 실행 [dim](fithia2 pipeline 로 상세 확인)[/]",
"run [dim]--step poller|fetcher|parser|features|labels[/]",
)
_console.print(table)
_console.print(
" [dim]공통 옵션:[/] [bold]--journal-dir[/] [dim](기본: journal/)[/]"
" [bold]--runs-dir[/] [dim](기본: runs/)[/]\n"
)
_console.print(" [bold]예시[/]")
_console.print(" [green]fithia2 leaderboard[/]")
_console.print(" [green]fithia2 record --experiment pead_v2 --hypothesis '...' --verdict better[/]")
_console.print(" [green]fithia2 paper[/]")
_console.print()
def main() -> None:
# Delegate `fithia2 paper ...` to the paper trader CLI
if len(sys.argv) >= 2 and sys.argv[1] == "paper":
sys.argv = [sys.argv[0]] + sys.argv[2:]
from apps.paper_trader.cli import main as paper_main
paper_main()
return
# Delegate `fithia2 pipeline ...` to the pipeline CLI
if len(sys.argv) >= 2 and sys.argv[1] == "pipeline":
sys.argv = [sys.argv[0]] + sys.argv[2:]
from apps.pipeline.cli import main as pipeline_main
pipeline_main()
return
if len(sys.argv) == 1:
_print_help()
sys.exit(0)
parser = argparse.ArgumentParser(description="Strategy Improvement Tracker", add_help=True)
sub = parser.add_subparsers(dest="command", required=True)
journal_kwargs = {
"default": _DEFAULT_JOURNAL_DIR,
"help": f"Path to journal/ directory (default: {_DEFAULT_JOURNAL_DIR})",
}
for name in ("record", "rec"):
subparser = sub.add_parser(name, help="Record an experiment to the journal")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("--runs-dir", default="runs", help="Path to runs output directory (default: runs)")
subparser.add_argument("--experiment", "-e", required=True, help="Experiment name (matches manifest)")
subparser.add_argument("--hypothesis", "-H", help="What you expected this change to do")
subparser.add_argument("--baseline", "-b", help="Baseline experiment name for comparison")
subparser.add_argument("--verdict", "-v", choices=["better", "worse", "neutral", "unknown"], default="unknown")
subparser.add_argument("--reasoning", "-r", help="Why this verdict")
subparser.add_argument("--next", "-n", help="Next experiment direction")
subparser.add_argument("--walk-forward-summary", help="Optional path to walk_forward_summary.json")
subparser.add_argument("--robustness-summary", help="Optional path to robustness_matrix_summary.json")
subparser.add_argument(
"--out-of-time-robustness-summary",
help="Optional path to out-of-time robustness_matrix_summary.json",
)
subparser.add_argument("--force", "-f", action="store_true", help="Allow duplicate experiment names")
for name in ("leaderboard", "lb"):
subparser = sub.add_parser(name, help="Show/regenerate the leaderboard")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("--top", "-n", type=int, default=10, help="Show top N entries (default: 10)")
subparser.add_argument(
"--sort",
choices=["sqs", "rqs", "wfqs", "deployment", "dep", "promotion", "unified", "sqs2"],
default="sqs",
help="Sort by public SQS (default) or internal diagnostics",
)
subparser.add_argument(
"--include-retired",
action="store_true",
help="Include retired legacy PEAD / short-core / exact-pocket families",
)
subparser.add_argument(
"--overlay-only",
action="store_true",
help="Show the separate overlay/book-of-books leaderboard instead of the default single-book leaderboard",
)
for name in ("show", "s"):
subparser = sub.add_parser(name, help="Show details of a journal entry")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("entry_id", help="Entry ID (IMP-0001) or experiment name")
subparser.add_argument("--diagnostics", action="store_true", help="Show internal RQS/WFQS/DEP diagnostics")
for name in ("check-duplicate", "dup"):
subparser = sub.add_parser(name, help="Check if experiment already recorded")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("--experiment", "-e", required=True, help="Experiment name to check")
for name in ("attach-wfv", "awf"):
subparser = sub.add_parser(name, help="Attach walk-forward summary to an existing journal entry")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("target", help="Entry ID or experiment name to update")
subparser.add_argument("--summary", required=True, help="Path to walk_forward_summary.json")
for name in ("attach-robustness", "arb"):
subparser = sub.add_parser(name, help="Attach robustness matrix summary to an existing journal entry")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("target", help="Entry ID or experiment name to update")
subparser.add_argument("--summary", required=True, help="Path to robustness_matrix_summary.json")
for name in ("attach-oot-robustness", "aoot"):
subparser = sub.add_parser(name, help="Attach out-of-time robustness summary to an existing journal entry")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("target", help="Entry ID or experiment name to update")
subparser.add_argument("--summary", required=True, help="Path to robustness_matrix_summary.json")
for name in ("record-overlay", "rovl"):
subparser = sub.add_parser(name, help="Record an official overlay evaluation")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("--spec", required=True, help="Path to overlay spec JSON for the main window")
subparser.add_argument("--summary", required=True, help="Path to overlay_summary.json for the main window")
subparser.add_argument("--stress-spec", required=True, help="Path to overlay spec JSON for the stress window")
subparser.add_argument("--stress-summary", required=True, help="Path to overlay_summary.json for the stress window")
subparser.add_argument("--hypothesis", default="", help="Short hypothesis / notes")
subparser.add_argument("--verdict", help="Initial verdict label")
subparser.add_argument("--reasoning", help="Verdict reasoning")
subparser.add_argument("--next", help="Next direction")
subparser.add_argument("--force", action="store_true", help="Allow duplicate overlay names")
subparser.add_argument("--tags", nargs="*", default=[], help="Extra tags")
for name in ("rescore-public", "rsp"):
subparser = sub.add_parser(name, help="Recompute stored public scores for journal entries")
subparser.add_argument("--journal-dir", **journal_kwargs)
subparser.add_argument("--prefix", help="Only rescore experiment names with this prefix")
subparser.add_argument("--min-version", type=float, help="Minimum numeric suffix after --prefix")
subparser.add_argument("--max-version", type=float, help="Maximum numeric suffix after --prefix")
args = parser.parse_args()
dispatch = {
"record": cmd_record,
"rec": cmd_record,
"leaderboard": cmd_leaderboard,
"lb": cmd_leaderboard,
"show": cmd_show,
"s": cmd_show,
"check-duplicate": cmd_check_duplicate,
"dup": cmd_check_duplicate,
"attach-wfv": cmd_attach_wfv,
"awf": cmd_attach_wfv,
"attach-robustness": cmd_attach_robustness,
"arb": cmd_attach_robustness,
"attach-oot-robustness": cmd_attach_oot_robustness,
"aoot": cmd_attach_oot_robustness,
"record-overlay": cmd_record_overlay,
"rovl": cmd_record_overlay,
"rescore-public": cmd_rescore_public,
"rsp": cmd_rescore_public,
}
dispatch[args.command](args)
if __name__ == "__main__":
main()