Recalibrate public SQS and exposure-aware tracking

main
I Luk Kim 5 months ago
parent 9ec0b26e10
commit c646303423

@ -491,41 +491,50 @@ journal/
```
1. 실험 config 생성 configs/experiments/my_experiment.json
2. 3-split 백테스트 실행 for split in train valid test; do ... done
3. Journal에 기록 python -m apps.tracker.cli record ...
4. Leaderboard 확인 python -m apps.tracker.cli leaderboard ...
3. Journal에 기록 fithia2 rec -e my_experiment ...
4. Leaderboard 확인 fithia2 lb
5. 다음 실험 계획 verdict + SQS breakdown 기반
6. 중복 실험 확인 python -m apps.tracker.cli check-duplicate ...
6. 중복 실험 확인 fithia2 dup -e my_experiment
7. 1번부터 반복
```
### 8.5 Tracker CLI
`pip install -e .``fithia2` 명령어로 실행. `--journal-dir` / `--runs-dir` 생략 시 기본값(`journal/`, `runs/`) 사용.
```bash
# 도움말
fithia2
# 리더보드 출력 (top 10)
fithia2 lb
# 리더보드 — 상위 N개
fithia2 lb -n 20
# 실험 결과 기록
python -m apps.tracker.cli record \
--journal-dir journal/ \
--runs-dir runs/midcap_steps/ \
--experiment pead_midcap_step14_score65 \
--hypothesis "Score threshold 0.60→0.65" \
--baseline pead_midcap_step13_best \
--verdict better \
--reasoning "Test SQS 64.2 > 57.7, Return +0.73% > +0.43%" \
--next "Test exit tuning on top of score 0.65"
# 리더보드 출력
python -m apps.tracker.cli leaderboard --journal-dir journal/
fithia2 rec \
-e pead_midcap_step14_score65 \
-H "Score threshold 0.60→0.65" \
-b pead_midcap_step13_best \
-v better \
-r "Test SQS 64.2 > 57.7, Return +0.73% > +0.43%" \
-n "Test exit tuning on top of score 0.65"
# 엔트리 상세 조회
python -m apps.tracker.cli show --journal-dir journal/ IMP-0015
fithia2 s IMP-0015
# 중복 실험 확인
python -m apps.tracker.cli check-duplicate \
--journal-dir journal/ --experiment my_experiment_name
# 레지스트리 재생성
python -m apps.tracker.cli rebuild-registry --journal-dir journal/
fithia2 dup -e my_experiment_name
```
| 명령 | alias | 설명 |
|------|-------|------|
| `leaderboard` | `lb` | SQS 순위표 출력 및 LEADERBOARD.md 재생성 |
| `record` | `rec` | 실험 결과를 저널에 기록 |
| `show` | `s` | 특정 저널 항목 상세 조회 |
| `check-duplicate` | `dup` | 동일 실험명 중복 여부 확인 |
### 8.6 핵심 발견사항
21개 실험을 통해 얻은 인사이트:
@ -695,18 +704,16 @@ done
```bash
# Journal에 기록
python -m apps.tracker.cli record \
--journal-dir journal/ \
--runs-dir runs/midcap_steps/ \
--experiment pead_midcap_step14_score65 \
--hypothesis "Score threshold 0.60→0.65" \
--baseline pead_midcap_step13_best \
--verdict better \
--reasoning "Test SQS 64.2 > 57.7" \
--next "Exit tuning on top of score 0.65"
fithia2 rec \
-e pead_midcap_step14_score65 \
-H "Score threshold 0.60→0.65" \
-b pead_midcap_step13_best \
-v better \
-r "Test SQS 64.2 > 57.7" \
-n "Exit tuning on top of score 0.65"
# 리더보드 확인
python -m apps.tracker.cli leaderboard --journal-dir journal/
fithia2 lb
```
### 테스트

@ -93,6 +93,22 @@ class BacktestRunner:
self._kill_switch_cooldown_remaining = 0
self._engine_daily_new_risk_used: dict[str, float] = defaultdict(float)
def _compute_portfolio_exposure(self, date: dt.date) -> tuple[float, float]:
"""Return (gross, net) exposure using current close notional when available."""
gross = 0.0
net = 0.0
for pos in self._open_positions:
bar = self.store.get_bar(pos.plan.candidate.symbol, date)
close = (
float(bar["close"])
if bar and bar.get("close") is not None and float(bar["close"]) > 0
else pos.entry_price
)
notional = close * pos.shares_open
gross += abs(notional)
net += -notional if pos.plan.candidate.trade_direction == "short" else notional
return gross, net
def run(self, output_root: str | Path | None = None) -> ExperimentResult:
"""Execute the full simulation. Returns ExperimentResult."""
started_at = utc_now()
@ -369,17 +385,14 @@ class BacktestRunner:
if self._peak_equity > 0
else 0.0
)
gross_exposure, net_exposure = self._compute_portfolio_exposure(date)
self._equity_curve.append(
DailyPortfolioState(
date=date,
equity=self._equity,
cash_available=self._cash,
gross_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
net_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
gross_exposure=gross_exposure,
net_exposure=net_exposure,
reserved_risk_budget=self._daily_new_risk_used,
unrealized_pnl=unrealized_final,
realized_pnl=self._realized_pnl,
@ -563,16 +576,13 @@ class BacktestRunner:
drawdown_pct: float,
unrealized: float,
) -> DailyPortfolioState:
gross_exposure, net_exposure = self._compute_portfolio_exposure(date)
return DailyPortfolioState(
date=date,
equity=self._equity,
cash_available=self._cash,
gross_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
net_exposure=sum(
p.entry_price * p.shares_open for p in self._open_positions
),
gross_exposure=gross_exposure,
net_exposure=net_exposure,
reserved_risk_budget=self._daily_new_risk_used,
unrealized_pnl=unrealized,
realized_pnl=self._realized_pnl,

@ -5,6 +5,14 @@ import argparse
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 rich.text import Text
_console = Console(width=140)
from libs.backtest.domain import (
ConfigDelta,
JournalEntry,
@ -15,7 +23,11 @@ from libs.backtest.tracker import (
append_journal_entry,
build_split_result,
check_duplicate,
compute_public_sqs,
compute_promotion_score,
compute_sqs,
compute_sqs_v2,
compute_unified_score,
get_next_entry_id,
load_journal,
rebuild_registry,
@ -54,17 +66,41 @@ def cmd_record(args: argparse.Namespace) -> None:
for split_name, (run_id, metrics) in split_runs.items():
results[split_name] = build_split_result(split_name, run_id, metrics)
# Compute SQS from test split (or best available)
# Compute public SQS from valid+test when available, or test split fallback.
test_metrics = None
for preferred in ["test", "valid", "train"]:
if preferred in split_runs:
_, test_metrics = split_runs[preferred]
break
sqs_score = 0.0
sqs_score = None
sqs_breakdown: dict[str, float] = {}
sqs_v2_score = None
sqs_v2_breakdown: dict[str, float] = {}
promotion_score = None
promotion_breakdown: dict[str, float] = {}
unified_score = None
unified_breakdown: dict[str, float] = {}
if test_metrics:
sqs_score, sqs_breakdown = compute_sqs(test_metrics)
legacy_sqs_score, legacy_sqs_breakdown = compute_sqs(test_metrics)
sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(test_metrics)
sqs_score = legacy_sqs_score
sqs_breakdown = legacy_sqs_breakdown
promotion_score, promotion_breakdown = compute_promotion_score(
results.get("test"),
results.get("valid"),
)
public_sqs, public_breakdown, public_source = compute_public_sqs(
results.get("test"),
results.get("valid"),
)
if public_sqs is not None:
sqs_score = public_sqs
sqs_breakdown = public_breakdown
unified_score, unified_breakdown = compute_unified_score(
results.get("test"),
results.get("valid"),
)
# Build config delta
config_delta = None
@ -84,6 +120,12 @@ def cmd_record(args: argparse.Namespace) -> None:
results=results,
sqs_score=sqs_score,
sqs_breakdown=sqs_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,
verdict=args.verdict or "unknown",
verdict_reasoning=args.reasoning or "",
next_direction=args.next or "",
@ -91,7 +133,8 @@ def cmd_record(args: argparse.Namespace) -> None:
)
append_journal_entry(journal_path, entry)
print(f"Recorded {entry_id}: {args.experiment} (SQS={sqs_score})")
source_label = f", source={public_source}" if public_source else ""
print(f"Recorded {entry_id}: {args.experiment} (SQS={sqs_score}{source_label})")
# Show splits found
for split_name, sr in results.items():
@ -104,6 +147,15 @@ def cmd_record(args: argparse.Namespace) -> None:
print(f"Leaderboard updated: {leaderboard_path}")
_DEFAULT_JOURNAL_DIR = "journal"
_COL_NAME = 38 # max experiment name width before truncation
def _fmt(val: float | None, fmt: str) -> str:
return format(val, fmt) if val is not None else "-"
def cmd_leaderboard(args: argparse.Namespace) -> None:
"""Show or regenerate the leaderboard."""
journal_dir = Path(args.journal_dir)
@ -117,13 +169,66 @@ def cmd_leaderboard(args: argparse.Namespace) -> None:
registry = rebuild_registry(journal_path, registry_path, leaderboard_path)
# Print to console
print(f"\n{'#':>3} {'Experiment':<40} {'SQS':>5} {'PF':>5} {'Ret%':>6} {'Trades':>6}")
print("-" * 70)
for rank, e in enumerate(registry.entries, 1):
pf = f"{e.profit_factor:.2f}" if e.profit_factor is not None else "-"
ret = f"{e.total_return_pct:+.1f}" if e.total_return_pct is not None else "-"
print(f"{rank:>3} {e.experiment_name:<40} {e.sqs_score:>5.1f} {pf:>5} {ret:>6} {e.trade_count:>6}")
top_n = getattr(args, "top", 10)
sort_by = getattr(args, "sort", "sqs")
if sort_by == "promotion":
ranked_entries = sorted(
registry.entries,
key=lambda e: (e.promotion_score is None, -(e.promotion_score or 0.0), -e.sqs_score),
)
title_mode = "Promotion 기준 내림차순"
elif sort_by in {"sqs", "unified"}:
ranked_entries = sorted(registry.entries, key=lambda e: -e.sqs_score)
title_mode = "SQS 기준 내림차순"
else:
ranked_entries = sorted(registry.entries, key=lambda e: -e.sqs_score)
title_mode = "SQS 기준 내림차순"
total = len(ranked_entries)
tbl = Table(
box=box.SIMPLE_HEAD,
show_header=True,
header_style="bold yellow",
row_styles=["", "dim"],
padding=(0, 1),
title=f"[bold cyan]Top {top_n} / {total}[/] [dim]· {title_mode} · T=test V=valid[/]",
title_justify="left",
expand=False,
)
# Columns: #, Experiment, SQS | Test: PF / Ret% / WR / DD% / N | Valid: PF / Ret% / WR / N
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)
tbl.add_column("T.PF", justify="right", no_wrap=True, min_width=5)
tbl.add_column("T.Ret%", justify="right", no_wrap=True, min_width=6)
tbl.add_column("T.WR%", justify="right", no_wrap=True, min_width=5)
tbl.add_column("T.DD%", justify="right", no_wrap=True, min_width=5)
tbl.add_column("T.N", justify="right", no_wrap=True, min_width=4)
tbl.add_column("V.PF", justify="right", style="green", no_wrap=True, min_width=5)
tbl.add_column("V.Ret%", justify="right", style="green", no_wrap=True, min_width=6)
tbl.add_column("V.WR%", justify="right", style="green", no_wrap=True, min_width=5)
tbl.add_column("V.N", justify="right", style="green", no_wrap=True, min_width=4)
for rank, e in enumerate(ranked_entries[:top_n], 1):
name = e.experiment_name
if len(name) > _COL_NAME:
name = name[: _COL_NAME - 1] + ""
pf = _fmt(e.profit_factor, ".2f")
ret = _fmt(e.total_return_pct, "+.1f")
wr = _fmt(e.win_rate * 100 if e.win_rate is not None else None, ".0f")
dd = _fmt(e.max_drawdown_pct, ".1f")
vpf = _fmt(e.valid_profit_factor, ".2f")
vret = _fmt(e.valid_total_return_pct, "+.1f")
vwr = _fmt(e.valid_win_rate * 100 if e.valid_win_rate is not None else None, ".0f")
tbl.add_row(
str(rank), name, f"{e.sqs_score:.1f}",
pf, ret, wr, dd, str(e.trade_count),
vpf, vret, vwr, str(e.valid_trade_count) if e.valid_trade_count else "-",
)
_console.print()
_console.print(tbl)
_console.print(f" [dim]LEADERBOARD.md → {leaderboard_path}[/]\n")
def cmd_show(args: argparse.Namespace) -> None:
@ -144,11 +249,28 @@ def cmd_show(args: argparse.Namespace) -> None:
sys.exit(1)
for e in found:
promotion_score = e.promotion_score
promotion_breakdown = e.promotion_breakdown
if promotion_score is None:
promotion_score, promotion_breakdown = compute_promotion_score(
e.results.get("test"),
e.results.get("valid"),
)
public_sqs, public_breakdown, public_source = compute_public_sqs(
e.results.get("test"),
e.results.get("valid"),
)
if public_sqs is None:
public_sqs = e.sqs_score
public_breakdown = e.sqs_breakdown
public_source = "stored"
print(f"\n{e.entry_id}{e.experiment_name}")
print(f" Timestamp: {e.timestamp}")
print(f" Hypothesis: {e.hypothesis}")
print(f" Verdict: {e.verdict}")
print(f" SQS: {e.sqs_score} {e.sqs_breakdown}")
print(f" SQS: {public_sqs} {public_breakdown} [{public_source}]")
if promotion_score is not None:
print(f" Promotion: {promotion_score} {promotion_breakdown} [internal]")
if e.config_delta:
print(f" Baseline: {e.config_delta.base_experiment}")
for k, v in e.config_delta.changes.items():
@ -177,43 +299,107 @@ def cmd_check_duplicate(args: argparse.Namespace) -> None:
print(f"No existing entries for '{args.experiment}'.")
def _print_help() -> None:
_console.print()
_console.print(Panel(
"[bold cyan]fithia2[/] — ACE-F Strategy Improvement Tracker\n"
"[dim]백테스트 실험을 기록하고 전략 품질 점수(SQS)로 순위를 매깁니다.[/]",
border_style="cyan",
padding=(0, 2),
))
t = Table(box=box.SIMPLE, show_header=True, header_style="bold yellow", padding=(0, 2))
t.add_column("Command", style="bold green", no_wrap=True)
t.add_column("Description")
t.add_column("Key Options", style="dim")
t.add_row(
"leaderboard [dim]lb[/]",
"SQS 순위표 출력 및 LEADERBOARD.md 재생성",
"-n N --sort sqs|promotion",
)
t.add_row(
"record [dim]rec[/]",
"실험 결과를 저널에 기록",
"-e NAME -H TEXT -v better|worse|neutral -b BASELINE",
)
t.add_row(
"show [dim]s[/]",
"특정 저널 항목 상세 조회",
"ENTRY_ID (예: IMP-0001 또는 실험명)",
)
t.add_row(
"check-duplicate [dim]dup[/]",
"동일 실험명이 이미 기록됐는지 확인",
"-e NAME",
)
_console.print(t)
_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 leaderboard --top 20[/]")
_console.print(" [green]fithia2 leaderboard --sort sqs[/]")
_console.print(" [green]fithia2 leaderboard --sort promotion[/]")
_console.print(" [green]fithia2 record --experiment pead_v2 --hypothesis '...' --verdict better[/]")
_console.print(" [green]fithia2 show IMP-0007[/]")
_console.print()
def main() -> None:
parser = argparse.ArgumentParser(description="Strategy Improvement Tracker")
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)
# record
p_record = sub.add_parser("record", help="Record an experiment to the journal")
p_record.add_argument("--journal-dir", required=True, help="Path to journal/ directory")
p_record.add_argument("--runs-dir", required=True, help="Path to runs output directory")
p_record.add_argument("--experiment", required=True, help="Experiment name (matches manifest)")
p_record.add_argument("--hypothesis", help="What you expected this change to do")
p_record.add_argument("--baseline", help="Baseline experiment name for comparison")
p_record.add_argument("--verdict", choices=["better", "worse", "neutral", "unknown"], default="unknown")
p_record.add_argument("--reasoning", help="Why this verdict")
p_record.add_argument("--next", help="Next experiment direction")
p_record.add_argument("--force", action="store_true", help="Allow duplicate experiment names")
# leaderboard
p_lb = sub.add_parser("leaderboard", help="Show/regenerate the leaderboard")
p_lb.add_argument("--journal-dir", required=True, help="Path to journal/ directory")
# show
p_show = sub.add_parser("show", help="Show details of a journal entry")
p_show.add_argument("--journal-dir", required=True, help="Path to journal/ directory")
p_show.add_argument("entry_id", help="Entry ID (IMP-0001) or experiment name")
# check-duplicate
p_dup = sub.add_parser("check-duplicate", help="Check if experiment already recorded")
p_dup.add_argument("--journal-dir", required=True, help="Path to journal/ directory")
p_dup.add_argument("--experiment", required=True, help="Experiment name to check")
_jdir_kwargs = {"default": _DEFAULT_JOURNAL_DIR, "help": f"Path to journal/ directory (default: {_DEFAULT_JOURNAL_DIR})"}
# record (alias: rec)
for name in ("record", "rec"):
p = sub.add_parser(name, help="Record an experiment to the journal")
p.add_argument("--journal-dir", **_jdir_kwargs)
p.add_argument("--runs-dir", default="runs", help="Path to runs output directory (default: runs)")
p.add_argument("--experiment", "-e", required=True, help="Experiment name (matches manifest)")
p.add_argument("--hypothesis", "-H", help="What you expected this change to do")
p.add_argument("--baseline", "-b", help="Baseline experiment name for comparison")
p.add_argument("--verdict", "-v", choices=["better", "worse", "neutral", "unknown"], default="unknown")
p.add_argument("--reasoning", "-r", help="Why this verdict")
p.add_argument("--next", "-n", help="Next experiment direction")
p.add_argument("--force", "-f", action="store_true", help="Allow duplicate experiment names")
# leaderboard (alias: lb)
for name in ("leaderboard", "lb"):
p = sub.add_parser(name, help="Show/regenerate the leaderboard")
p.add_argument("--journal-dir", **_jdir_kwargs)
p.add_argument("--top", "-n", type=int, default=10, help="Show top N entries (default: 10)")
p.add_argument("--sort", choices=["sqs", "promotion", "unified"], default="sqs", help="Sort leaderboard by public SQS (default) or internal promotion score")
# show (alias: s)
for name in ("show", "s"):
p = sub.add_parser(name, help="Show details of a journal entry")
p.add_argument("--journal-dir", **_jdir_kwargs)
p.add_argument("entry_id", help="Entry ID (IMP-0001) or experiment name")
# check-duplicate (alias: dup)
for name in ("check-duplicate", "dup"):
p = sub.add_parser(name, help="Check if experiment already recorded")
p.add_argument("--journal-dir", **_jdir_kwargs)
p.add_argument("--experiment", "-e", required=True, help="Experiment name to check")
args = parser.parse_args()
dispatch = {
"record": cmd_record,
"leaderboard": cmd_leaderboard,
"show": cmd_show,
"check-duplicate": cmd_check_duplicate,
"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,
}
dispatch[args.command](args)

@ -1,84 +1,84 @@
# Strategy Improvement Leaderboard
_Updated: 2026-03-17T07:57:00.128792+00:00_
_Updated: 2026-03-17T09:17:27.726326+00:00_
| # | Experiment | SQS | PF | Ret% | WR | Sharpe | DD% | Trades | Date |
|---|-----------|-----|-----|------|-----|--------|-----|--------|------|
| 1 | pead_midcap_step35_balanced_sleeves_nofrac_aclong12 | 90.4 | 2.01 | +2.2 | 61% | 4.2 | 0.4 | 56 | 2026-03-17 |
| 2 | pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12 | 90.3 | 2.06 | +2.1 | 61% | 4.5 | 0.4 | 54 | 2026-03-17 |
| 3 | pead_midcap_step44_short_core_macro50 | 87.9 | 2.01 | +1.3 | 57% | 2.6 | 0.7 | 58 | 2026-03-17 |
| 4 | pead_midcap_step47_short_core_macro_block_sdlong25 | 87.0 | 3.43 | +1.3 | 68% | 3.3 | 0.3 | 25 | 2026-03-17 |
| 5 | pead_midcap_step45_short_core_macro_block | 86.7 | 3.78 | +1.2 | 70% | 3.4 | 0.3 | 23 | 2026-03-17 |
| 6 | pead_midcap_step51_short_core_macro_block_crashcap | 86.7 | 4.19 | +1.2 | 73% | 3.6 | 0.2 | 22 | 2026-03-17 |
| 7 | pead_midcap_step46_short_core_macro_block_acshort12 | 86.6 | 4.52 | +1.3 | 71% | 3.6 | 0.3 | 21 | 2026-03-17 |
| 8 | pead_midcap_step52_short_core_macro_block_crashcap_gap10 | 86.3 | 3.35 | +1.0 | 77% | 3.0 | 0.2 | 22 | 2026-03-17 |
| 9 | pead_midcap_step48_short_core_macro_block_nolong | 85.7 | 3.40 | +0.8 | 80% | 2.4 | 0.4 | 20 | 2026-03-17 |
| 10 | pead_midcap_step40_short_core_sdlong12 | 82.1 | 1.77 | +1.5 | 58% | 2.2 | 1.1 | 55 | 2026-03-17 |
| 11 | pead_midcap_step34_balanced_sleeves_nofrac_aclong25 | 81.3 | 1.62 | +1.8 | 56% | 3.8 | 0.6 | 62 | 2026-03-17 |
| 12 | pead_midcap_step43_short_core_sdlong25 | 78.9 | 1.66 | +1.5 | 57% | 2.0 | 1.3 | 58 | 2026-03-17 |
| 13 | pead_midcap_step31_balanced_sleeves_nofrac_acshort12 | 77.9 | 1.49 | +1.6 | 55% | 3.2 | 0.7 | 64 | 2026-03-17 |
| 14 | pead_midcap_step39_balanced_sleeves_sdlong12 | 77.9 | 1.50 | +1.5 | 55% | 3.4 | 0.5 | 62 | 2026-03-17 |
| 15 | pead_midcap_step30_balanced_sleeves_nofrac | 75.2 | 1.41 | +1.4 | 54% | 2.8 | 0.8 | 67 | 2026-03-17 |
| 16 | pead_midcap_step33_balanced_sleeves_nofrac_acshort6 | 73.9 | 1.47 | +1.2 | 52% | 2.3 | 0.7 | 48 | 2026-03-17 |
| 17 | pead_midcap_step37_balanced_sleeves_aclong_vol3 | 73.6 | 1.38 | +1.2 | 54% | 2.5 | 0.8 | 63 | 2026-03-17 |
| 18 | pead_midcap_step41_short_core_sdlong12_acshort50 | 72.6 | 1.53 | +1.2 | 56% | 1.6 | 1.3 | 59 | 2026-03-17 |
| 19 | pead_midcap_step42_short_core_only | 66.3 | 1.44 | +0.8 | 63% | 1.2 | 1.2 | 46 | 2026-03-17 |
| 20 | pead_midcap_step14_score65 | 64.2 | 1.22 | +0.7 | 57% | 1.3 | 0.9 | 72 | 2026-03-17 |
| 21 | pead_midcap_step18_nofrac | 63.2 | 1.23 | +0.8 | 52% | 1.4 | 0.9 | 64 | 2026-03-17 |
| 22 | pead_midcap_step19_hold5 | 62.9 | 1.20 | +0.7 | 57% | 1.2 | 0.9 | 72 | 2026-03-17 |
| 23 | pead_midcap_step20_best3 | 62.0 | 1.22 | +0.7 | 52% | 1.3 | 0.9 | 64 | 2026-03-17 |
| 24 | pead_midcap_step17_target2 | 59.4 | 1.18 | +0.6 | 53% | 1.1 | 0.8 | 66 | 2026-03-17 |
| 25 | pead_midcap_step27_sdlong_close7_budget25 | 57.9 | 1.17 | +0.6 | 54% | 0.9 | 1.3 | 68 | 2026-03-17 |
| 26 | pead_midcap_step13_best | 57.7 | 1.12 | +0.4 | 55% | 0.8 | 0.9 | 75 | 2026-03-16 |
| 27 | pead_midcap_step23_sdlong_close7 | 55.1 | 1.13 | +0.5 | 54% | 0.7 | 1.4 | 69 | 2026-03-17 |
| 28 | pead_midcap_step38_balanced_sleeves_aclong_vol4 | 54.2 | 1.12 | +0.4 | 51% | 0.8 | 0.9 | 61 | 2026-03-17 |
| 29 | pead_midcap_step16_react7_score65 | 53.2 | 0.97 | -0.1 | 56% | -0.2 | 1.6 | 89 | 2026-03-17 |
| 30 | pead_midcap_step5_maxcand3 | 52.5 | 0.95 | -0.2 | 54% | -0.4 | 1.4 | 96 | 2026-03-16 |
| 31 | pead_midcap_portfolio_v2 | 52.0 | 1.08 | +0.3 | 51% | 0.5 | 1.8 | 70 | 2026-03-17 |
| 32 | pead_midcap_step15_react7 | 51.7 | 0.94 | -0.3 | 55% | -0.5 | 1.6 | 91 | 2026-03-17 |
| 33 | pead_midcap_step11_score60 | 50.2 | 1.02 | +0.1 | 52% | 0.2 | 0.9 | 77 | 2026-03-16 |
| 34 | pead_midcap_step12_vol2x | 50.2 | 1.02 | +0.1 | 52% | 0.1 | 0.9 | 77 | 2026-03-16 |
| 35 | pead_midcap_step3_10pct | 49.8 | 1.00 | +0.0 | 50% | 0.0 | 1.4 | 98 | 2026-03-16 |
| 36 | pead_midcap_step10_short | 49.2 | 1.00 | -0.0 | 52% | -0.0 | 0.9 | 79 | 2026-03-16 |
| 37 | pead_midcap_step53_short_core_macro_block_crashcap_gap14 | 43.1 | 4.60 | +1.1 | 84% | 3.5 | 0.2 | 19 | 2026-03-17 |
| 38 | pead_midcap_step50_same_day_short_long_macro_block | 41.9 | 3.21 | +0.8 | 60% | 2.7 | 0.4 | 15 | 2026-03-17 |
| 39 | pead_midcap_step2_notrail | 41.4 | 0.93 | -0.3 | 67% | -0.4 | 1.7 | 54 | 2026-03-16 |
| 40 | pead_midcap_step1_fixedr | 39.7 | 0.91 | -0.5 | 47% | -0.7 | 1.5 | 95 | 2026-03-16 |
| 41 | pead_midcap_combo_10pct_maxcand3 | 39.2 | 0.97 | -0.1 | 51% | -0.2 | 0.9 | 79 | 2026-03-16 |
| 42 | pead_midcap_step49_same_day_short_macro_block | 38.9 | 2.40 | +0.4 | 70% | 1.6 | 0.4 | 10 | 2026-03-17 |
| 43 | pead_midcap_step6_drift | 37.0 | 0.86 | -0.9 | 43% | -1.6 | 1.7 | 100 | 2026-03-16 |
| 44 | pead_midcap_step7_fixedr | 35.4 | 0.94 | -0.2 | 45% | -0.4 | 1.0 | 71 | 2026-03-16 |
| 45 | pead_midcap_step4_longonly | 32.6 | 0.84 | -0.8 | 45% | -1.2 | 1.4 | 78 | 2026-03-16 |
| 46 | pead_midcap_step8_nft | 31.3 | 0.65 | -1.8 | 41% | -3.0 | 2.2 | 71 | 2026-03-16 |
| 47 | pead_midcap_step9_stop2 | 31.1 | 0.77 | -1.7 | 45% | -1.8 | 2.5 | 71 | 2026-03-16 |
| # | Experiment | SQS | [T]PF | [T]Ret% | [T]WR | [T]Sharpe | [T]DD% | [T]N | [T]Gross% | [T]Net% | [T]DIM% | [V]PF | [V]Ret% | [V]WR | [V]Sharpe | [V]DD% | [V]N | [V]Gross% | [V]Net% | [V]DIM% | Date |
|---|-----------|-----|-------|---------|-------|-----------|--------|------|-----------|---------|---------|-------|---------|-------|-----------|--------|------|-----------|---------|---------|------|
| 1 | pead_midcap_step52_short_core_macro_block_crashcap_gap10 | 49.8 | 3.35 | +1.0 | 77% | 3.0 | 0.2 | 22 | 2.9 | -2.2 | 53.2 | 5.66 | +1.8 | 72% | 4.8 | 0.2 | 25 | 3.6 | -2.6 | 56.1 | 2026-03-17 |
| 2 | pead_midcap_step48_short_core_macro_block_nolong | 46.9 | 3.40 | +0.8 | 80% | 2.4 | 0.4 | 20 | 2.6 | +2.6 | 52.2 | 5.73 | +1.3 | 70% | 3.4 | 0.3 | 20 | 3.3 | +3.3 | 51.8 | 2026-03-17 |
| 3 | pead_midcap_step46_short_core_macro_block_acshort12 | 45.8 | 4.52 | +1.3 | 71% | 3.6 | 0.3 | 21 | 2.1 | +2.1 | 40.4 | 2.44 | +1.3 | 69% | 3.2 | 0.4 | 26 | 4.0 | +4.0 | 57.9 | 2026-03-17 |
| 4 | pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12 | 44.5 | 2.06 | +2.1 | 61% | 4.5 | 0.4 | 54 | 6.0 | +6.0 | 76.6 | - | - | - | - | - | 0 | - | - | - | 2026-03-17 |
| 5 | pead_midcap_step51_short_core_macro_block_crashcap | 44.4 | 4.19 | +1.2 | 73% | 3.6 | 0.2 | 22 | 2.3 | -0.8 | 42.6 | 2.31 | +1.3 | 68% | 3.0 | 0.4 | 28 | 4.1 | -1.9 | 57.9 | 2026-03-17 |
| 6 | pead_midcap_step45_short_core_macro_block | 44.3 | 3.78 | +1.2 | 70% | 3.4 | 0.3 | 23 | 2.4 | +2.4 | 42.6 | 2.31 | +1.3 | 68% | 3.0 | 0.4 | 28 | 4.2 | +4.2 | 57.9 | 2026-03-17 |
| 7 | pead_midcap_step53_short_core_macro_block_crashcap_gap14 | 39.5 | 4.60 | +1.1 | 84% | 3.5 | 0.2 | 19 | 2.3 | +2.3 | 53.2 | 8.21 | +2.0 | 76% | 5.3 | 0.2 | 25 | 3.6 | +3.6 | 56.1 | 2026-03-17 |
| 8 | pead_midcap_step50_same_day_short_long_macro_block | 36.2 | 3.21 | +0.8 | 60% | 2.7 | 0.4 | 15 | 1.6 | +1.6 | 38.3 | 2.21 | +1.0 | 65% | 2.7 | 0.3 | 20 | 3.4 | +3.4 | 43.9 | 2026-03-17 |
| 9 | pead_midcap_step30_balanced_sleeves_nofrac | 35.4 | 1.41 | +1.4 | 54% | 2.8 | 0.8 | 67 | 8.0 | +8.0 | 76.6 | - | - | - | - | - | 0 | - | - | - | 2026-03-17 |
| 10 | pead_midcap_step47_short_core_macro_block_sdlong25 | 35.2 | 3.43 | +1.3 | 68% | 3.3 | 0.3 | 25 | 2.9 | +2.9 | 48.9 | 1.77 | +1.0 | 65% | 2.2 | 0.4 | 31 | 4.6 | +4.6 | 57.9 | 2026-03-17 |
| 11 | pead_midcap_step44_short_core_macro50 | 35.0 | 2.01 | +1.3 | 57% | 2.6 | 0.7 | 58 | 4.5 | -1.6 | 72.3 | 1.78 | +1.1 | 55% | 2.5 | 0.4 | 40 | 4.8 | -2.0 | 73.7 | 2026-03-17 |
| 12 | pead_midcap_step33_balanced_sleeves_nofrac_acshort6 | 35.0 | 1.47 | +1.2 | 52% | 2.3 | 0.7 | 48 | 6.2 | +6.2 | 70.2 | - | - | - | - | - | 0 | - | - | - | 2026-03-17 |
| 13 | pead_midcap_step43_short_core_sdlong25 | 31.7 | 1.66 | +1.5 | 57% | 2.0 | 1.3 | 58 | 7.1 | +7.1 | 72.3 | 1.46 | +1.0 | 56% | 1.9 | 0.6 | 45 | 6.2 | +6.2 | 73.7 | 2026-03-17 |
| 14 | pead_midcap_step41_short_core_sdlong12_acshort50 | 30.7 | 1.53 | +1.2 | 56% | 1.6 | 1.3 | 59 | 6.8 | +6.8 | 72.3 | 1.48 | +0.9 | 55% | 1.8 | 0.6 | 40 | 5.6 | +5.6 | 73.7 | 2026-03-17 |
| 15 | pead_midcap_step37_balanced_sleeves_aclong_vol3 | 30.6 | 1.38 | +1.2 | 54% | 2.5 | 0.8 | 63 | 7.5 | +7.5 | 76.6 | 1.38 | +1.1 | 51% | 1.8 | 0.9 | 57 | 7.9 | +7.9 | 77.2 | 2026-03-17 |
| 16 | pead_midcap_step42_short_core_only | 30.5 | 1.44 | +0.8 | 63% | 1.2 | 1.2 | 46 | 5.5 | +5.5 | 71.7 | 2.55 | +1.2 | 58% | 2.8 | 0.5 | 31 | 4.5 | +4.5 | 71.4 | 2026-03-17 |
| 17 | pead_midcap_step14_score65 | 29.9 | 1.22 | +0.7 | 57% | 1.3 | 0.9 | 72 | - | - | - | 1.40 | +1.0 | 56% | 1.6 | 0.7 | 66 | - | - | - | 2026-03-17 |
| 18 | pead_midcap_step18_nofrac | 29.4 | 1.23 | +0.8 | 52% | 1.4 | 0.9 | 64 | - | - | - | 1.46 | +1.2 | 47% | 1.9 | 0.6 | 55 | - | - | - | 2026-03-17 |
| 19 | pead_midcap_step31_balanced_sleeves_nofrac_acshort12 | 29.3 | 1.49 | +1.6 | 55% | 3.2 | 0.7 | 64 | 7.6 | +7.6 | 76.6 | 1.38 | +1.1 | 52% | 1.8 | 0.9 | 58 | 8.0 | +8.0 | 77.2 | 2026-03-17 |
| 20 | pead_midcap_step19_hold5 | 29.2 | 1.20 | +0.7 | 57% | 1.2 | 0.9 | 72 | - | - | - | 1.55 | +1.4 | 57% | 2.2 | 0.7 | 68 | - | - | - | 2026-03-17 |
| 21 | pead_midcap_step49_same_day_short_macro_block | 29.1 | 2.40 | +0.4 | 70% | 1.6 | 0.4 | 10 | 1.0 | +1.0 | 26.1 | 25.09 | +1.2 | 75% | 3.2 | 0.3 | 12 | 2.6 | +2.6 | 32.1 | 2026-03-17 |
| 22 | pead_midcap_step20_best3 | 28.7 | 1.22 | +0.7 | 52% | 1.3 | 0.9 | 64 | - | - | - | 1.61 | +1.6 | 48% | 2.5 | 0.6 | 56 | - | - | - | 2026-03-17 |
| 23 | pead_midcap_step40_short_core_sdlong12 | 28.6 | 1.77 | +1.5 | 58% | 2.2 | 1.1 | 55 | 6.4 | +6.4 | 72.3 | 1.48 | +0.9 | 55% | 1.8 | 0.6 | 40 | 5.6 | +5.6 | 73.7 | 2026-03-17 |
| 24 | pead_midcap_step17_target2 | 27.3 | 1.18 | +0.6 | 53% | 1.1 | 0.8 | 66 | - | - | - | 1.38 | +1.0 | 51% | 1.5 | 0.7 | 59 | - | - | - | 2026-03-17 |
| 25 | pead_midcap_step39_balanced_sleeves_sdlong12 | 27.0 | 1.50 | +1.5 | 55% | 3.4 | 0.5 | 62 | 7.4 | +7.4 | 76.6 | 1.38 | +1.0 | 51% | 1.7 | 0.9 | 53 | 7.5 | +7.5 | 77.2 | 2026-03-17 |
| 26 | pead_midcap_step27_sdlong_close7_budget25 | 26.4 | 1.17 | +0.6 | 54% | 0.9 | 1.3 | 68 | 8.5 | +8.5 | 72.3 | - | - | - | - | - | 0 | - | - | - | 2026-03-17 |
| 27 | pead_midcap_step13_best | 26.3 | 1.12 | +0.4 | 55% | 0.8 | 0.9 | 75 | - | - | - | 1.61 | +1.6 | 59% | 2.2 | 0.7 | 70 | - | - | - | 2026-03-16 |
| 28 | pead_midcap_step23_sdlong_close7 | 24.9 | 1.13 | +0.5 | 54% | 0.7 | 1.4 | 69 | 8.6 | +8.6 | 72.3 | - | - | - | - | - | 0 | - | - | - | 2026-03-17 |
| 29 | pead_midcap_step34_balanced_sleeves_nofrac_aclong25 | 23.9 | 1.62 | +1.8 | 56% | 3.8 | 0.6 | 62 | 7.4 | +7.4 | 76.6 | 1.30 | +0.9 | 51% | 1.4 | 0.9 | 57 | 7.9 | +7.9 | 77.2 | 2026-03-17 |
| 30 | pead_midcap_step16_react7_score65 | 23.8 | 0.97 | -0.1 | 56% | -0.2 | 1.6 | 89 | - | - | - | 2.01 | +2.8 | 63% | 3.7 | 0.8 | 83 | - | - | - | 2026-03-17 |
| 31 | pead_midcap_step5_maxcand3 | 23.4 | 0.95 | -0.2 | 54% | -0.4 | 1.4 | 96 | - | - | - | 2.08 | +3.3 | 65% | 4.0 | 1.1 | 89 | - | - | - | 2026-03-16 |
| 32 | pead_midcap_step38_balanced_sleeves_aclong_vol4 | 23.3 | 1.12 | +0.4 | 51% | 0.8 | 0.9 | 61 | 7.4 | +7.4 | 76.6 | 1.29 | +0.8 | 53% | 1.4 | 0.9 | 53 | 7.6 | +7.6 | 77.2 | 2026-03-17 |
| 33 | pead_midcap_step15_react7 | 23.0 | 0.94 | -0.3 | 55% | -0.5 | 1.6 | 91 | - | - | - | 1.89 | +2.6 | 62% | 3.5 | 0.9 | 84 | - | - | - | 2026-03-17 |
| 34 | pead_midcap_portfolio_v2 | 23.0 | 1.08 | +0.3 | 51% | 0.5 | 1.8 | 70 | 7.9 | +7.9 | 72.3 | - | - | - | - | - | 0 | - | - | - | 2026-03-17 |
| 35 | pead_midcap_step11_score60 | 22.1 | 1.02 | +0.1 | 52% | 0.2 | 0.9 | 77 | - | - | - | 1.91 | +2.3 | 62% | 3.0 | 0.7 | 72 | - | - | - | 2026-03-16 |
| 36 | pead_midcap_step12_vol2x | 22.1 | 1.02 | +0.1 | 52% | 0.1 | 0.9 | 77 | - | - | - | 1.91 | +2.3 | 62% | 3.0 | 0.7 | 72 | - | - | - | 2026-03-16 |
| 37 | pead_midcap_step3_10pct | 21.9 | 1.00 | +0.0 | 50% | 0.0 | 1.4 | 98 | - | - | - | 1.66 | +2.0 | 60% | 2.9 | 0.7 | 78 | - | - | - | 2026-03-16 |
| 38 | pead_midcap_step10_short | 21.5 | 1.00 | -0.0 | 52% | -0.0 | 0.9 | 79 | - | - | - | 1.61 | +1.6 | 59% | 2.2 | 0.7 | 70 | - | - | - | 2026-03-16 |
| 39 | pead_midcap_step35_balanced_sleeves_nofrac_aclong12 | 21.2 | 2.01 | +2.2 | 61% | 4.2 | 0.4 | 56 | 6.2 | +1.1 | 76.6 | 1.24 | +0.7 | 51% | 1.2 | 0.9 | 53 | 7.5 | +1.6 | 77.2 | 2026-03-17 |
| 40 | pead_midcap_step2_notrail | 17.2 | 0.93 | -0.3 | 67% | -0.4 | 1.7 | 54 | - | - | - | 1.07 | +0.4 | 73% | 0.4 | 1.7 | 62 | - | - | - | 2026-03-16 |
| 41 | pead_midcap_step1_fixedr | 16.2 | 0.91 | -0.5 | 47% | -0.7 | 1.5 | 95 | - | - | - | 1.77 | +2.8 | 49% | 3.4 | 1.6 | 69 | - | - | - | 2026-03-16 |
| 42 | pead_midcap_combo_10pct_maxcand3 | 15.9 | 0.97 | -0.1 | 51% | -0.2 | 0.9 | 79 | - | - | - | 1.91 | +2.3 | 62% | 3.0 | 0.7 | 72 | - | - | - | 2026-03-16 |
| 43 | pead_midcap_step6_drift | 14.7 | 0.86 | -0.9 | 43% | -1.6 | 1.7 | 100 | - | - | - | 1.70 | +2.7 | 51% | 3.7 | 1.0 | 75 | - | - | - | 2026-03-16 |
| 44 | pead_midcap_step7_fixedr | 13.8 | 0.94 | -0.2 | 45% | -0.4 | 1.0 | 71 | - | - | - | 2.00 | +2.4 | 53% | 3.1 | 0.7 | 53 | - | - | - | 2026-03-16 |
| 45 | pead_midcap_step4_longonly | 12.2 | 0.84 | -0.8 | 45% | -1.2 | 1.4 | 78 | - | - | - | 1.43 | +1.5 | 61% | 1.7 | 1.6 | 76 | - | - | - | 2026-03-16 |
| 46 | pead_midcap_step8_nft | 11.5 | 0.65 | -1.8 | 41% | -3.0 | 2.2 | 71 | - | - | - | 1.55 | +1.8 | 46% | 2.4 | 1.0 | 57 | - | - | - | 2026-03-16 |
| 47 | pead_midcap_step9_stop2 | 11.4 | 0.77 | -1.7 | 45% | -1.8 | 2.5 | 71 | - | - | - | 1.81 | +3.2 | 53% | 2.8 | 1.2 | 53 | - | - | - | 2026-03-16 |
## Recent Entries
### IMP-0047 (2026-03-17) — pead_midcap_step53_short_core_macro_block_crashcap_gap14
Hypothesis: A stricter same-day long gap filter might further concentrate the overlay into only the strongest continuation setups.
Verdict: **WORSE** (SQS 43.1)
Verdict: **WORSE** (SQS 39.5)
Reasoning: The stricter gap filter over-concentrated the overlay, dropped total trade count below a healthy level, and cratered test SQS.
Next: Use moderate overlay filters only; the strict version is too sparse.
### IMP-0046 (2026-03-17) — pead_midcap_step52_short_core_macro_block_crashcap_gap10
Hypothesis: The same-day long overlay may work better when restricted to larger reaction-day gap moves.
Verdict: **NEUTRAL** (SQS 86.3)
Verdict: **NEUTRAL** (SQS 49.8)
Reasoning: A 10% gap filter made train and valid much stronger but gave back some test performance, so this is a balanced alternative rather than a clear new leader.
Next: If optimizing for robustness across splits, keep exploring overlay quality gates around this variant.
### IMP-0045 (2026-03-17) — pead_midcap_step51_short_core_macro_block_crashcap
Hypothesis: Extreme one-day crash continuations are too stretched for the same-day short sleeve and should be excluded.
Verdict: **BETTER** (SQS 86.7)
Verdict: **BETTER** (SQS 44.4)
Reasoning: Capping same-day shorts at -45% reaction preserved train and valid while modestly improving test return, PF, drawdown, and Sharpe versus step45.
Next: Combine the crash cap with a quality filter on the same-day long overlay.
### IMP-0044 (2026-03-17) — pead_midcap_step50_same_day_short_long_macro_block
Hypothesis: The same-day long overlay may matter, but the after-close short sleeve may be removable.
Verdict: **WORSE** (SQS 41.9)
Verdict: **WORSE** (SQS 36.2)
Reasoning: Dropping the after-close short sleeve reduced both valid and test performance, so step45 still benefits from carrying all three active sleeves.
Next: Refine sleeve quality rather than deleting sleeves wholesale.
### IMP-0043 (2026-03-17) — pead_midcap_step49_same_day_short_macro_block
Hypothesis: The pure same-day short engine might dominate the portfolio and make other sleeves unnecessary.
Verdict: **WORSE** (SQS 38.9)
Verdict: **WORSE** (SQS 29.1)
Reasoning: The single-sleeve version collapsed SQS because trade count and robustness fell too far, even though the kept trades were profitable.
Next: Keep the supporting sleeves and test smaller structural adjustments instead.

File diff suppressed because it is too large Load Diff

@ -26,7 +26,7 @@
{"entry_id":"IMP-0026","timestamp":"2026-03-17T06:59:09.255218+00:00","experiment_name":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","hypothesis":"Shrink the after-close short sleeve again while keeping the rest of the balanced step30 structure intact.","config_delta":{"base_experiment":"pead_midcap_step30_balanced_sleeves_nofrac","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065814_ca861015","trade_count":64,"profit_factor":1.4929664814770336,"total_return_pct":1.5563811867822805,"win_rate":0.546875,"max_drawdown_pct":0.6813257982524192,"sharpe_ratio":3.235108878683093,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8144638886979153},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065750_ca861015","trade_count":426,"profit_factor":1.0908713237524914,"total_return_pct":2.45066812206927,"win_rate":0.45539906103286387,"max_drawdown_pct":2.6845497224397294,"sharpe_ratio":0.3357364835430949,"monthly_win_rate":0.5142857142857142,"equity_curve_r_squared":0.6011932815228401},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065810_ca861015","trade_count":58,"profit_factor":1.37572962559925,"total_return_pct":1.122807593766629,"win_rate":0.5172413793103449,"max_drawdown_pct":0.8884863869932943,"sharpe_ratio":1.8162493466149443,"monthly_win_rate":0.75,"equity_curve_r_squared":0.3292893146712864}},"sqs_score":77.9,"sqs_breakdown":{"profitability":60.9,"risk":100.0,"consistency":82.8,"robustness":80.0},"verdict":"better","verdict_reasoning":"New best. Train/valid/test all held up, with test SQS 77.9, return +1.56%, PF 1.66, Sharpe 2.39, and max drawdown 0.32%. This beat both step30 and step14 by a wide margin.","next_direction":"Use step31 as the new base and only explore very local refinements around sleeve weights or execution if further gains are needed.","tags":["pead","midcap","step31","balanced","sleeves","nofrac","acshort12"]}
{"entry_id":"IMP-0027","timestamp":"2026-03-17T06:59:09.622681+00:00","experiment_name":"pead_midcap_step33_balanced_sleeves_nofrac_acshort6","hypothesis":"Cut the after-close short sleeve even further to see if the balanced portfolio still benefits from the bucket at very low size.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317065700_765096af","trade_count":48,"profit_factor":1.4726487821009215,"total_return_pct":1.2304910166146,"win_rate":0.5208333333333334,"max_drawdown_pct":0.7081594843000599,"sharpe_ratio":2.3026797742321596,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.7956775968800425}},"sqs_score":73.9,"sqs_breakdown":{"profitability":58.6,"risk":100.0,"consistency":74.3,"robustness":70.8},"verdict":"worse","verdict_reasoning":"Test-only run stayed strong but slipped versus step31: SQS 73.9 vs 77.9 and return +1.23% vs +1.56%. The smaller sleeve gave up too much trade-count support.","next_direction":"Keep the 12.5% after-close short sleeve from step31.","tags":["pead","midcap","step33","balanced","sleeves","nofrac","acshort6"]}
{"entry_id":"IMP-0028","timestamp":"2026-03-17T07:05:41.969503+00:00","experiment_name":"pead_midcap_step34_balanced_sleeves_nofrac_aclong25","hypothesis":"Cap the after-close long sleeve to two trades per day so only the best-ranked overnight continuation names survive.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070326_6bef3bf5","trade_count":62,"profit_factor":1.6229787581193362,"total_return_pct":1.809973740790665,"win_rate":0.5645161290322581,"max_drawdown_pct":0.5514994557958487,"sharpe_ratio":3.784062042365747,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8527995261422007},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070447_6bef3bf5","trade_count":57,"profit_factor":1.2977962107392036,"total_return_pct":0.888003662238436,"win_rate":0.5087719298245614,"max_drawdown_pct":0.884279658549619,"sharpe_ratio":1.43172550959332,"monthly_win_rate":0.75,"equity_curve_r_squared":0.26161939040716076}},"sqs_score":81.3,"sqs_breakdown":{"profitability":68.4,"risk":100.0,"consistency":85.8,"robustness":78.9},"verdict":"worse","verdict_reasoning":"Test improved to SQS 81.3 and +1.81% return, but valid slipped to SQS 63.7 and +0.89% versus step31 valid SQS 68.2 and +1.12%. The top-two cap on after-close longs was not robust across splits.","next_direction":"Keep step31 as the robust base. If after-close long needs filtering, prefer a quality gate rather than a blunt daily-cap reduction.","tags":["pead","midcap","step34","balanced","sleeves","nofrac","aclong25"]}
{"entry_id":"IMP-0029","timestamp":"2026-03-17T07:05:47.363036+00:00","experiment_name":"pead_midcap_step35_balanced_sleeves_nofrac_aclong12","hypothesis":"Cap the after-close long sleeve to one trade per day so the portfolio fully leans into the strongest overnight continuation name only.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070326_01aa6342","trade_count":56,"profit_factor":2.009368125746884,"total_return_pct":2.181331690538893,"win_rate":0.6071428571428571,"max_drawdown_pct":0.4424438123900949,"sharpe_ratio":4.216651506745797,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8888944531705611},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070357_01aa6342","trade_count":53,"profit_factor":1.2430238868359724,"total_return_pct":0.7082451550234983,"win_rate":0.5094339622641509,"max_drawdown_pct":0.8845604127450726,"sharpe_ratio":1.1796633240683954,"monthly_win_rate":0.75,"equity_curve_r_squared":0.14646025414461133},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070358_01aa6342","trade_count":393,"profit_factor":1.1135008933147288,"total_return_pct":2.755962139586409,"win_rate":0.46055979643765904,"max_drawdown_pct":2.418955602136075,"sharpe_ratio":0.38778263039611355,"monthly_win_rate":0.4857142857142857,"equity_curve_r_squared":0.6345243468091162}},"sqs_score":90.4,"sqs_breakdown":{"profitability":88.7,"risk":100.0,"consistency":92.9,"robustness":75.6},"verdict":"worse","verdict_reasoning":"This became a new test-only high water mark at SQS 90.4 and +2.18%, but valid deteriorated to SQS 59.8 and +0.71%, materially below step31. The tighter top-one cap overfit to the test window.","next_direction":"Avoid promoting step35. Explore engine-specific quality filters for after-close longs instead of hard caps that reshuffle trade selection too aggressively.","tags":["pead","midcap","step35","balanced","sleeves","nofrac","aclong12"]}
{"entry_id":"IMP-0029","timestamp":"2026-03-17T07:05:47.363036+00:00","experiment_name":"pead_midcap_step35_balanced_sleeves_nofrac_aclong12","hypothesis":"Cap the after-close long sleeve to one trade per day so the portfolio fully leans into the strongest overnight continuation name only.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081806944455_48768969","trade_count":56,"profit_factor":2.009368125746884,"total_return_pct":2.181331690538893,"win_rate":0.6071428571428571,"max_drawdown_pct":0.4424438123900949,"sharpe_ratio":4.216651506745797,"monthly_win_rate":1.0,"equity_curve_r_squared":0.8888944531705611,"avg_gross_exposure_pct":6.227596335660352,"avg_net_exposure_pct":1.1262657538133194,"days_in_market_pct":76.59574468085107},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081817695254_48768969","trade_count":53,"profit_factor":1.2430238868359724,"total_return_pct":0.7082451550234983,"win_rate":0.5094339622641509,"max_drawdown_pct":0.8845604127450726,"sharpe_ratio":1.1796633240683954,"monthly_win_rate":0.75,"equity_curve_r_squared":0.14646025414461133,"avg_gross_exposure_pct":7.5108896612986165,"avg_net_exposure_pct":1.5673421201773916,"days_in_market_pct":77.19298245614034},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070358_01aa6342","trade_count":393,"profit_factor":1.1135008933147288,"total_return_pct":2.755962139586409,"win_rate":0.46055979643765904,"max_drawdown_pct":2.418955602136075,"sharpe_ratio":0.38778263039611355,"monthly_win_rate":0.4857142857142857,"equity_curve_r_squared":0.6345243468091162}},"sqs_score":90.4,"sqs_breakdown":{"profitability":88.7,"risk":100.0,"consistency":92.9,"robustness":75.6},"verdict":"worse","verdict_reasoning":"This became a new test-only high water mark at SQS 90.4 and +2.18%, but valid deteriorated to SQS 59.8 and +0.71%, materially below step31. The tighter top-one cap overfit to the test window.","next_direction":"Avoid promoting step35. Explore engine-specific quality filters for after-close longs instead of hard caps that reshuffle trade selection too aggressively.","tags":["pead","midcap","step35","balanced","sleeves","nofrac","aclong12"],"sqs_v2_score":90.1,"sqs_v2_breakdown":{"profitability":88.7,"risk":100.0,"consistency":92.9,"robustness":75.6,"capital_efficiency":79.0}}
{"entry_id":"IMP-0030","timestamp":"2026-03-17T07:05:52.980782+00:00","experiment_name":"pead_midcap_step36_balanced_sleeves_nofrac_aclong12_sdlong12","hypothesis":"Keep only one same-day long close trade per day alongside the top-one after-close long sleeve, concentrating the portfolio into the single best long continuation setups.","config_delta":{"base_experiment":"pead_midcap_step35_balanced_sleeves_nofrac_aclong12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317070326_18804e7c","trade_count":54,"profit_factor":2.061648751658133,"total_return_pct":2.1478646359501146,"win_rate":0.6111111111111112,"max_drawdown_pct":0.3579823467213594,"sharpe_ratio":4.4957307480895325,"monthly_win_rate":1.0,"equity_curve_r_squared":0.9122676472502861}},"sqs_score":90.3,"sqs_breakdown":{"profitability":88.6,"risk":100.0,"consistency":93.5,"robustness":74.4},"verdict":"worse","verdict_reasoning":"Test stayed extremely strong at SQS 90.3 and +2.15%, but it did not exceed step35 on test and reduced trade support further. Without valid/train confirmation, it is not a better promotion candidate than step31.","next_direction":"Keep the same-day long sleeve at two trades per day if using this family, and focus next on smarter after-close long quality filtering.","tags":["pead","midcap","step36","balanced","sleeves","nofrac","aclong12","sdlong12"]}
{"entry_id":"IMP-0031","timestamp":"2026-03-17T07:11:20.119143+00:00","experiment_name":"pead_midcap_step37_balanced_sleeves_aclong_vol3","hypothesis":"Require stronger volume confirmation for after-close long signals only, while leaving the rest of the step31 sleeve mix unchanged.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110746395_1ae4b33a","trade_count":63,"profit_factor":1.377310183342628,"total_return_pct":1.1862161229211343,"win_rate":0.5396825396825397,"max_drawdown_pct":0.8243191281564817,"sharpe_ratio":2.4626680484273273,"monthly_win_rate":1.0,"equity_curve_r_squared":0.6885324256339993},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110780542_1ae4b33a","trade_count":57,"profit_factor":1.375336580561267,"total_return_pct":1.1216330418461293,"win_rate":0.5087719298245614,"max_drawdown_pct":0.8896384382974281,"sharpe_ratio":1.7701058914354275,"monthly_win_rate":0.75,"equity_curve_r_squared":0.3302488231788083}},"sqs_score":73.6,"sqs_breakdown":{"profitability":53.6,"risk":100.0,"consistency":81.6,"robustness":72.5},"verdict":"worse","verdict_reasoning":"Engine-specific volume gating on after-close longs did not help. Test fell to SQS 73.6 and valid to 67.6, both below the step31 base. The extra volume filter removed too much breadth without improving robustness.","next_direction":"Do not tighten after-close long volume gates further. Keep after-close long breadth and search elsewhere if more robustness is needed.","tags":["pead","midcap","step37","balanced","sleeves","aclong","vol3"]}
{"entry_id":"IMP-0032","timestamp":"2026-03-17T07:11:26.965107+00:00","experiment_name":"pead_midcap_step38_balanced_sleeves_aclong_vol4","hypothesis":"Push the after-close long sleeve to an even stricter volume gate so only the highest-conviction overnight reactions remain.","config_delta":{"base_experiment":"pead_midcap_step37_balanced_sleeves_aclong_vol3","changes":{}},"results":{"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110780988_d9020d34","trade_count":61,"profit_factor":1.1204890397898135,"total_return_pct":0.388000418802214,"win_rate":0.5081967213114754,"max_drawdown_pct":0.9399793760032171,"sharpe_ratio":0.8136845994700913,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.043822307069783864},"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071110695904_d9020d34","trade_count":53,"profit_factor":1.2930052787721988,"total_return_pct":0.8333477612284769,"win_rate":0.5283018867924528,"max_drawdown_pct":0.8783421675410786,"sharpe_ratio":1.4376847128011023,"monthly_win_rate":0.75,"equity_curve_r_squared":0.19294412573010883}},"sqs_score":54.2,"sqs_breakdown":{"profitability":37.6,"risk":80.2,"consistency":72.2,"robustness":31.1},"verdict":"worse","verdict_reasoning":"The stricter after-close long filter clearly broke the portfolio. Test dropped to SQS 54.2 and valid to 63.2, confirming that this sleeve cannot be improved by simply tightening volume thresholds.","next_direction":"Abandon the after-close long volume-threshold path. If that sleeve is revisited, it needs a different filter than raw PEAD volume.","tags":["pead","midcap","step38","balanced","sleeves","aclong","vol4"]}
@ -35,13 +35,13 @@
{"entry_id":"IMP-0035","timestamp":"2026-03-17T07:20:29.978594+00:00","experiment_name":"pead_midcap_step41_short_core_sdlong12_acshort50","hypothesis":"Lean harder into the after-close short sleeve inside the new short-core portfolio.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756232509_4c9fd4ce","trade_count":40,"profit_factor":1.483920817012022,"total_return_pct":0.8662763872782817,"win_rate":0.55,"max_drawdown_pct":0.6476993822793542,"sharpe_ratio":1.8482432920991685,"monthly_win_rate":0.75,"equity_curve_r_squared":0.19435203451054603},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756491852_4c9fd4ce","trade_count":59,"profit_factor":1.5287187084320328,"total_return_pct":1.231827071365813,"win_rate":0.559322033898305,"max_drawdown_pct":1.2759660172387226,"sharpe_ratio":1.6310365384096348,"monthly_win_rate":1.0,"equity_curve_r_squared":0.4227182249282274}},"sqs_score":72.6,"sqs_breakdown":{"profitability":61.4,"risk":92.3,"consistency":84.9,"robustness":53.6},"verdict":"worse","verdict_reasoning":"Increasing after-close short capacity weakened the portfolio: test dropped from SQS 82.1 to 72.6 and valid stayed flat at 68.4. The short core benefits from the bucket, but not at this larger size.","next_direction":"Keep the after-close short sleeve at 25% inside the short-core family.","tags":["pead","midcap","step41","short","core","sdlong12","acshort50"]}
{"entry_id":"IMP-0037","timestamp":"2026-03-17T07:20:29.978670+00:00","experiment_name":"pead_midcap_step43_short_core_sdlong25","hypothesis":"Restore a larger same-day long close sleeve after removing after-close longs, to see if breadth improves the short-core portfolio.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071957079395_9046f612","trade_count":45,"profit_factor":1.4604102163883825,"total_return_pct":0.9859009158709378,"win_rate":0.5555555555555556,"max_drawdown_pct":0.6469365320312458,"sharpe_ratio":1.9269062309154923,"monthly_win_rate":0.75,"equity_curve_r_squared":0.2568675902061873},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071957079892_9046f612","trade_count":58,"profit_factor":1.6628306811204845,"total_return_pct":1.4636686220428092,"win_rate":0.5689655172413793,"max_drawdown_pct":1.2630043081731899,"sharpe_ratio":2.0112049109753753,"monthly_win_rate":1.0,"equity_curve_r_squared":0.57291038217223}},"sqs_score":78.9,"sqs_breakdown":{"profitability":69.0,"risk":98.5,"consistency":86.5,"robustness":62.5},"verdict":"worse","verdict_reasoning":"Restoring more same-day long breadth weakened both splits versus step40: valid moved from SQS 68.4 to 69.7 but test fell from 82.1 to 78.9 and profitability dropped. The smaller 12.5% sleeve remains the better balance.","next_direction":"Keep the same-day long overlay small inside step40.","tags":["pead","midcap","step43","short","core","sdlong25"]}
{"entry_id":"IMP-0034","timestamp":"2026-03-17T07:20:29.978842+00:00","experiment_name":"pead_midcap_step40_short_core_sdlong12","hypothesis":"Drop the unstable after-close long sleeve and reallocate the portfolio to same-day shorts, after-close shorts, and a small same-day close-entry long overlay.","config_delta":{"base_experiment":"pead_midcap_step31_balanced_sleeves_nofrac_acshort12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756453145_9eb08c8d","trade_count":40,"profit_factor":1.483920817012022,"total_return_pct":0.8662763872782817,"win_rate":0.55,"max_drawdown_pct":0.6476993822793542,"sharpe_ratio":1.8482432920991685,"monthly_win_rate":0.75,"equity_curve_r_squared":0.19435203451054603},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071756505329_9eb08c8d","trade_count":55,"profit_factor":1.7695290624299977,"total_return_pct":1.52020169384827,"win_rate":0.5818181818181818,"max_drawdown_pct":1.128135882565315,"sharpe_ratio":2.2087400904317267,"monthly_win_rate":1.0,"equity_curve_r_squared":0.632820774620957},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317071814970965_9eb08c8d","trade_count":306,"profit_factor":1.2169887676968005,"total_return_pct":3.708684730821959,"win_rate":0.4869281045751634,"max_drawdown_pct":2.5507043144769854,"sharpe_ratio":0.5720232080317771,"monthly_win_rate":0.625,"equity_curve_r_squared":0.39497202444939644}},"sqs_score":82.1,"sqs_breakdown":{"profitability":74.6,"risk":99.3,"consistency":88.6,"robustness":64.6},"verdict":"better","verdict_reasoning":"New robust leader. Train improved from SQS 55.5 to 63.1 and return +2.45% to +3.71%. Valid edged up from SQS 68.2 to 68.4 with lower drawdown, and test improved from SQS 77.9 to 82.1 with PF 1.77. Removing after-close longs fixed the biggest unstable sleeve without giving up the same-day long upside.","next_direction":"Use step40 as the new base. Only explore local refinements around the short-core structure if needed.","tags":["pead","midcap","step40","short","core","sdlong12"]}
{"entry_id":"IMP-0038","timestamp":"2026-03-17T07:54:25.486815+00:00","experiment_name":"pead_midcap_step44_short_core_macro50","hypothesis":"Scaling entries down in weak macro regimes will keep the short-core structure while cutting drawdowns.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073536976331_9c3139ba","trade_count":40,"profit_factor":1.7786229517773076,"total_return_pct":1.0842382260887244,"win_rate":0.55,"max_drawdown_pct":0.4065245518278289,"sharpe_ratio":2.4891782517624543,"monthly_win_rate":0.75,"equity_curve_r_squared":0.34564834918762316},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073537015426_9c3139ba","trade_count":58,"profit_factor":2.0052763103391356,"total_return_pct":1.2986802302195721,"win_rate":0.5689655172413793,"max_drawdown_pct":0.7045335236824514,"sharpe_ratio":2.6217356101467053,"monthly_win_rate":1.0,"equity_curve_r_squared":0.7991432493330419},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073555963588_9c3139ba","trade_count":307,"profit_factor":1.2319887960741855,"total_return_pct":3.4232792009141852,"win_rate":0.4820846905537459,"max_drawdown_pct":1.97377752972113,"sharpe_ratio":0.6203457661608955,"monthly_win_rate":0.625,"equity_curve_r_squared":0.4148680874417292}},"sqs_score":87.9,"sqs_breakdown":{"profitability":85.2,"risk":100.0,"consistency":86.5,"robustness":76.6},"verdict":"better","verdict_reasoning":"Half-size macro scaling materially improved valid and test risk-adjusted performance versus step40, confirming that SPY-below-SMA exposure was a real drag.","next_direction":"Try a full macro block to see whether removing weak-regime entries entirely is even cleaner.","tags":["pead","midcap","step44","short","core","macro50"]}
{"entry_id":"IMP-0038","timestamp":"2026-03-17T07:54:25.486815+00:00","experiment_name":"pead_midcap_step44_short_core_macro50","hypothesis":"Scaling entries down in weak macro regimes will keep the short-core structure while cutting drawdowns.","config_delta":{"base_experiment":"pead_midcap_step40_short_core_sdlong12","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081817694940_42f8ea0e","trade_count":40,"profit_factor":1.7786229517773076,"total_return_pct":1.0842382260887244,"win_rate":0.55,"max_drawdown_pct":0.4065245518278289,"sharpe_ratio":2.4891782517624543,"monthly_win_rate":0.75,"equity_curve_r_squared":0.34564834918762316,"avg_gross_exposure_pct":4.823763728554508,"avg_net_exposure_pct":-1.952624774981557,"days_in_market_pct":73.68421052631578},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081807057141_42f8ea0e","trade_count":58,"profit_factor":2.0052763103391356,"total_return_pct":1.2986802302195721,"win_rate":0.5689655172413793,"max_drawdown_pct":0.7045335236824514,"sharpe_ratio":2.6217356101467053,"monthly_win_rate":1.0,"equity_curve_r_squared":0.7991432493330419,"avg_gross_exposure_pct":4.453478837236966,"avg_net_exposure_pct":-1.5519032472252936,"days_in_market_pct":72.3404255319149},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073555963588_9c3139ba","trade_count":307,"profit_factor":1.2319887960741855,"total_return_pct":3.4232792009141852,"win_rate":0.4820846905537459,"max_drawdown_pct":1.97377752972113,"sharpe_ratio":0.6203457661608955,"monthly_win_rate":0.625,"equity_curve_r_squared":0.4148680874417292}},"sqs_score":87.9,"sqs_breakdown":{"profitability":85.2,"risk":100.0,"consistency":86.5,"robustness":76.6},"verdict":"better","verdict_reasoning":"Half-size macro scaling materially improved valid and test risk-adjusted performance versus step40, confirming that SPY-below-SMA exposure was a real drag.","next_direction":"Try a full macro block to see whether removing weak-regime entries entirely is even cleaner.","tags":["pead","midcap","step44","short","core","macro50"],"sqs_v2_score":86.9,"sqs_v2_breakdown":{"profitability":85.2,"risk":100.0,"consistency":86.5,"robustness":76.6,"capital_efficiency":70.8}}
{"entry_id":"IMP-0039","timestamp":"2026-03-17T07:54:25.866482+00:00","experiment_name":"pead_midcap_step45_short_core_macro_block","hypothesis":"If weak-regime entries are mostly noise, hard-blocking them should outperform simple size scaling.","config_delta":{"base_experiment":"pead_midcap_step44_short_core_macro50","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073536967478_c3a3615c","trade_count":28,"profit_factor":2.3084684930008983,"total_return_pct":1.30245295115927,"win_rate":0.6785714285714286,"max_drawdown_pct":0.37472483014430374,"sharpe_ratio":3.0487382658615467,"monthly_win_rate":0.75,"equity_curve_r_squared":0.48762905493056535},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073537015511_c3a3615c","trade_count":23,"profit_factor":3.7827916861128097,"total_return_pct":1.2021184808416436,"win_rate":0.6956521739130435,"max_drawdown_pct":0.2608177180219861,"sharpe_ratio":3.3795660622862123,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.9110928059216074},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073556022527_c3a3615c","trade_count":220,"profit_factor":1.2595187982631553,"total_return_pct":3.200633818982489,"win_rate":0.5045454545454545,"max_drawdown_pct":1.389607178327899,"sharpe_ratio":0.6336391727317187,"monthly_win_rate":0.6551724137931034,"equity_curve_r_squared":0.39958317262208615}},"sqs_score":86.7,"sqs_breakdown":{"profitability":84.8,"risk":100.0,"consistency":95.8,"robustness":57.2},"verdict":"better","verdict_reasoning":"The hard macro block improved valid and test again, with sharper PF and much lower drawdown than the 50% scaler version.","next_direction":"Stress the sleeve mix around the new macro-blocked core.","tags":["pead","midcap","step45","short","core","macro","block"]}
{"entry_id":"IMP-0040","timestamp":"2026-03-17T07:54:26.245073+00:00","experiment_name":"pead_midcap_step46_short_core_macro_block_acshort12","hypothesis":"The after-close short sleeve may be oversized after the macro block and could improve if reduced.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073936915792_e3adb886","trade_count":26,"profit_factor":2.4445635979938296,"total_return_pct":1.339314216731771,"win_rate":0.6923076923076923,"max_drawdown_pct":0.37458871743417604,"sharpe_ratio":3.1583041871985076,"monthly_win_rate":0.75,"equity_curve_r_squared":0.48286315791941375},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073939814431_e3adb886","trade_count":21,"profit_factor":4.522043594902001,"total_return_pct":1.2518263219734362,"win_rate":0.7142857142857143,"max_drawdown_pct":0.2783619920052574,"sharpe_ratio":3.6389410132883055,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.9244229299732519}},"sqs_score":86.6,"sqs_breakdown":{"profitability":85.0,"risk":100.0,"consistency":95.8,"robustness":56.1},"verdict":"worse","verdict_reasoning":"Shrinking the after-close short sleeve slightly degraded both valid and test, so the step45 25% sleeve was not the problem.","next_direction":"Test whether the same-day long sleeve or the short-only core is the real source of edge.","tags":["pead","midcap","step46","short","core","macro","block","acshort12"]}
{"entry_id":"IMP-0041","timestamp":"2026-03-17T07:54:26.612610+00:00","experiment_name":"pead_midcap_step47_short_core_macro_block_sdlong25","hypothesis":"A larger same-day reaction-close long overlay might scale once the macro block removes bad regimes.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073936910739_a38d69e4","trade_count":31,"profit_factor":1.7661839600751525,"total_return_pct":1.03177071332802,"win_rate":0.6451612903225806,"max_drawdown_pct":0.42599257212123065,"sharpe_ratio":2.218739891960781,"monthly_win_rate":0.75,"equity_curve_r_squared":0.4214549671036421},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317073939814560_a38d69e4","trade_count":25,"profit_factor":3.429404855633068,"total_return_pct":1.2833562667310616,"win_rate":0.68,"max_drawdown_pct":0.2608177180219861,"sharpe_ratio":3.32348066596022,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.889835319415119}},"sqs_score":87.0,"sqs_breakdown":{"profitability":85.1,"risk":100.0,"consistency":95.8,"robustness":58.3},"verdict":"worse","verdict_reasoning":"Increasing the same-day long sleeve hurt valid materially and did not produce a cleaner overall profile than step45.","next_direction":"Try sleeve removal experiments instead of scaling overlays up.","tags":["pead","midcap","step47","short","core","macro","block","sdlong25"]}
{"entry_id":"IMP-0042","timestamp":"2026-03-17T07:54:26.967747+00:00","experiment_name":"pead_midcap_step48_short_core_macro_block_nolong","hypothesis":"The macro-blocked short core may be strong enough without the same-day long overlay.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234188388_d9b28f40","trade_count":20,"profit_factor":5.726207009868067,"total_return_pct":1.2584927752282966,"win_rate":0.7,"max_drawdown_pct":0.29982153086364316,"sharpe_ratio":3.3529664036078217,"monthly_win_rate":1.0,"equity_curve_r_squared":0.4654938970631658},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234267904_d9b28f40","trade_count":20,"profit_factor":3.402559985815088,"total_return_pct":0.8451446297187069,"win_rate":0.8,"max_drawdown_pct":0.4239007555767844,"sharpe_ratio":2.4413021645869604,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.7861833255279326}},"sqs_score":85.7,"sqs_breakdown":{"profitability":83.4,"risk":100.0,"consistency":95.8,"robustness":54.7},"verdict":"worse","verdict_reasoning":"Removing the long sleeve weakened both valid and test relative to step45, so the small same-day long overlay still adds useful diversification.","next_direction":"Test whether the same-day short sleeve can stand alone or whether the after-close short sleeve is also required.","tags":["pead","midcap","step48","short","core","macro","block","nolong"]}
{"entry_id":"IMP-0043","timestamp":"2026-03-17T07:54:27.322093+00:00","experiment_name":"pead_midcap_step49_same_day_short_macro_block","hypothesis":"The pure same-day short engine might dominate the portfolio and make other sleeves unnecessary.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074233919071_b5cae2ca","trade_count":12,"profit_factor":25.094599474518425,"total_return_pct":1.1648266582814248,"win_rate":0.75,"max_drawdown_pct":0.33436592553948014,"sharpe_ratio":3.227317987298049,"monthly_win_rate":1.0,"equity_curve_r_squared":0.3679821567954865},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234357252_b5cae2ca","trade_count":10,"profit_factor":2.4025420210691006,"total_return_pct":0.40093684044296973,"win_rate":0.7,"max_drawdown_pct":0.3693400619520335,"sharpe_ratio":1.5580313035787354,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.3032092765977471}},"sqs_score":38.9,"sqs_breakdown":{"profitability":81.6,"risk":92.6,"consistency":95.8,"robustness":19.0},"verdict":"worse","verdict_reasoning":"The single-sleeve version collapsed SQS because trade count and robustness fell too far, even though the kept trades were profitable.","next_direction":"Keep the supporting sleeves and test smaller structural adjustments instead.","tags":["pead","midcap","step49","same","day","short","macro","block"]}
{"entry_id":"IMP-0044","timestamp":"2026-03-17T07:54:27.691772+00:00","experiment_name":"pead_midcap_step50_same_day_short_long_macro_block","hypothesis":"The same-day long overlay may matter, but the after-close short sleeve may be removable.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234172662_a01fa9b8","trade_count":20,"profit_factor":2.2051399980357798,"total_return_pct":0.9530265960178512,"win_rate":0.65,"max_drawdown_pct":0.3444323377542605,"sharpe_ratio":2.687864694846263,"monthly_win_rate":0.75,"equity_curve_r_squared":0.41775889730721794},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074234263930_a01fa9b8","trade_count":15,"profit_factor":3.2060142950020296,"total_return_pct":0.805535692316771,"win_rate":0.6,"max_drawdown_pct":0.42315703199236054,"sharpe_ratio":2.6871013818111624,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.8242465440616173}},"sqs_score":41.9,"sqs_breakdown":{"profitability":83.2,"risk":100.0,"consistency":87.5,"robustness":52.8},"verdict":"worse","verdict_reasoning":"Dropping the after-close short sleeve reduced both valid and test performance, so step45 still benefits from carrying all three active sleeves.","next_direction":"Refine sleeve quality rather than deleting sleeves wholesale.","tags":["pead","midcap","step50","same","day","short","long","macro","block"]}
{"entry_id":"IMP-0045","timestamp":"2026-03-17T07:54:28.043618+00:00","experiment_name":"pead_midcap_step51_short_core_macro_block_crashcap","hypothesis":"Extreme one-day crash continuations are too stretched for the same-day short sleeve and should be excluded.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074801780500_1585063f","trade_count":28,"profit_factor":2.3084684930008983,"total_return_pct":1.30245295115927,"win_rate":0.6785714285714286,"max_drawdown_pct":0.37472483014430374,"sharpe_ratio":3.0487382658615467,"monthly_win_rate":0.75,"equity_curve_r_squared":0.48762905493056535},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074801781828_1585063f","trade_count":22,"profit_factor":4.190184861108528,"total_return_pct":1.2441182731003355,"win_rate":0.7272727272727273,"max_drawdown_pct":0.22380328257556925,"sharpe_ratio":3.5956143566120704,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.918016321908289},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074932001383_1585063f","trade_count":220,"profit_factor":1.2595187982631553,"total_return_pct":3.200633818982489,"win_rate":0.5045454545454545,"max_drawdown_pct":1.389607178327899,"sharpe_ratio":0.6336391727317187,"monthly_win_rate":0.6551724137931034,"equity_curve_r_squared":0.39958317262208615}},"sqs_score":86.7,"sqs_breakdown":{"profitability":85.0,"risk":100.0,"consistency":95.8,"robustness":56.7},"verdict":"better","verdict_reasoning":"Capping same-day shorts at -45% reaction preserved train and valid while modestly improving test return, PF, drawdown, and Sharpe versus step45.","next_direction":"Combine the crash cap with a quality filter on the same-day long overlay.","tags":["pead","midcap","step51","short","core","macro","block","crashcap"]}
{"entry_id":"IMP-0046","timestamp":"2026-03-17T07:54:28.401055+00:00","experiment_name":"pead_midcap_step52_short_core_macro_block_crashcap_gap10","hypothesis":"The same-day long overlay may work better when restricted to larger reaction-day gap moves.","config_delta":{"base_experiment":"pead_midcap_step51_short_core_macro_block_crashcap","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123185892_7592b1dc","trade_count":25,"profit_factor":5.660464878695176,"total_return_pct":1.818984312375629,"win_rate":0.72,"max_drawdown_pct":0.21695852738272095,"sharpe_ratio":4.755652871139783,"monthly_win_rate":1.0,"equity_curve_r_squared":0.6608034205824956},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123226004_7592b1dc","trade_count":22,"profit_factor":3.351800408128271,"total_return_pct":1.0111883417758072,"win_rate":0.7727272727272727,"max_drawdown_pct":0.24257912061402945,"sharpe_ratio":3.031867507475822,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.8468990955221466},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075225071209_7592b1dc","trade_count":201,"profit_factor":1.3153549627707726,"total_return_pct":3.2988485556152156,"win_rate":0.4975124378109453,"max_drawdown_pct":1.5599163185209133,"sharpe_ratio":0.683194016870928,"monthly_win_rate":0.6923076923076923,"equity_curve_r_squared":0.7186047701175057}},"sqs_score":86.3,"sqs_breakdown":{"profitability":84.0,"risk":100.0,"consistency":95.8,"robustness":56.7},"verdict":"neutral","verdict_reasoning":"A 10% gap filter made train and valid much stronger but gave back some test performance, so this is a balanced alternative rather than a clear new leader.","next_direction":"If optimizing for robustness across splits, keep exploring overlay quality gates around this variant.","tags":["pead","midcap","step52","short","core","macro","block","crashcap","gap10"]}
{"entry_id":"IMP-0045","timestamp":"2026-03-17T07:54:28.043618+00:00","experiment_name":"pead_midcap_step51_short_core_macro_block_crashcap","hypothesis":"Extreme one-day crash continuations are too stretched for the same-day short sleeve and should be excluded.","config_delta":{"base_experiment":"pead_midcap_step45_short_core_macro_block","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081833666987_5039805d","trade_count":28,"profit_factor":2.3084684930008983,"total_return_pct":1.30245295115927,"win_rate":0.6785714285714286,"max_drawdown_pct":0.37472483014430374,"sharpe_ratio":3.0487382658615467,"monthly_win_rate":0.75,"equity_curve_r_squared":0.48762905493056535,"avg_gross_exposure_pct":4.09489843643577,"avg_net_exposure_pct":-1.9273432556417505,"days_in_market_pct":57.89473684210527},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081834927778_5039805d","trade_count":22,"profit_factor":4.190184861108528,"total_return_pct":1.2441182731003355,"win_rate":0.7272727272727273,"max_drawdown_pct":0.22380328257556925,"sharpe_ratio":3.5956143566120704,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.918016321908289,"avg_gross_exposure_pct":2.2928950819181733,"avg_net_exposure_pct":-0.8189811228558067,"days_in_market_pct":42.5531914893617},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317074932001383_1585063f","trade_count":220,"profit_factor":1.2595187982631553,"total_return_pct":3.200633818982489,"win_rate":0.5045454545454545,"max_drawdown_pct":1.389607178327899,"sharpe_ratio":0.6336391727317187,"monthly_win_rate":0.6551724137931034,"equity_curve_r_squared":0.39958317262208615}},"sqs_score":86.7,"sqs_breakdown":{"profitability":85.0,"risk":100.0,"consistency":95.8,"robustness":56.7},"verdict":"better","verdict_reasoning":"Capping same-day shorts at -45% reaction preserved train and valid while modestly improving test return, PF, drawdown, and Sharpe versus step45.","next_direction":"Combine the crash cap with a quality filter on the same-day long overlay.","tags":["pead","midcap","step51","short","core","macro","block","crashcap"],"sqs_v2_score":89.6,"sqs_v2_breakdown":{"profitability":85.0,"risk":100.0,"consistency":95.8,"robustness":56.7,"capital_efficiency":100.0}}
{"entry_id":"IMP-0046","timestamp":"2026-03-17T07:54:28.401055+00:00","experiment_name":"pead_midcap_step52_short_core_macro_block_crashcap_gap10","hypothesis":"The same-day long overlay may work better when restricted to larger reaction-day gap moves.","config_delta":{"base_experiment":"pead_midcap_step51_short_core_macro_block_crashcap","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081834927839_7592b1dc","trade_count":25,"profit_factor":5.660464878695176,"total_return_pct":1.818984312375629,"win_rate":0.72,"max_drawdown_pct":0.21695852738272095,"sharpe_ratio":4.755652871139783,"monthly_win_rate":1.0,"equity_curve_r_squared":0.6608034205824956,"avg_gross_exposure_pct":3.5838805580978437,"avg_net_exposure_pct":-2.593156236925373,"days_in_market_pct":56.14035087719298},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317081833658080_7592b1dc","trade_count":22,"profit_factor":3.351800408128271,"total_return_pct":1.0111883417758072,"win_rate":0.7727272727272727,"max_drawdown_pct":0.24257912061402945,"sharpe_ratio":3.031867507475822,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.8468990955221466,"avg_gross_exposure_pct":2.858350680305373,"avg_net_exposure_pct":-2.222595187645864,"days_in_market_pct":53.191489361702125},"train":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075225071209_7592b1dc","trade_count":201,"profit_factor":1.3153549627707726,"total_return_pct":3.2988485556152156,"win_rate":0.4975124378109453,"max_drawdown_pct":1.5599163185209133,"sharpe_ratio":0.683194016870928,"monthly_win_rate":0.6923076923076923,"equity_curve_r_squared":0.7186047701175057}},"sqs_score":86.3,"sqs_breakdown":{"profitability":84.0,"risk":100.0,"consistency":95.8,"robustness":56.7},"verdict":"neutral","verdict_reasoning":"A 10% gap filter made train and valid much stronger but gave back some test performance, so this is a balanced alternative rather than a clear new leader.","next_direction":"If optimizing for robustness across splits, keep exploring overlay quality gates around this variant.","tags":["pead","midcap","step52","short","core","macro","block","crashcap","gap10"],"sqs_v2_score":87.2,"sqs_v2_breakdown":{"profitability":84.0,"risk":100.0,"consistency":95.8,"robustness":56.7,"capital_efficiency":79.5}}
{"entry_id":"IMP-0047","timestamp":"2026-03-17T07:54:28.762757+00:00","experiment_name":"pead_midcap_step53_short_core_macro_block_crashcap_gap14","hypothesis":"A stricter same-day long gap filter might further concentrate the overlay into only the strongest continuation setups.","config_delta":{"base_experiment":"pead_midcap_step52_short_core_macro_block_crashcap_gap10","changes":{}},"results":{"valid":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123208169_66d09993","trade_count":25,"profit_factor":8.207760543782888,"total_return_pct":2.0180349316014032,"win_rate":0.76,"max_drawdown_pct":0.2179161262122437,"sharpe_ratio":5.288977955005757,"monthly_win_rate":1.0,"equity_curve_r_squared":0.6385560108285373},"test":{"run_id":"bt_baseline_swing_v1_midcap-filte_20260317075123225576_66d09993","trade_count":19,"profit_factor":4.599773676047558,"total_return_pct":1.1191525844285641,"win_rate":0.8421052631578947,"max_drawdown_pct":0.21947031826165894,"sharpe_ratio":3.463700507402551,"monthly_win_rate":0.6666666666666666,"equity_curve_r_squared":0.8852420626052896}},"sqs_score":43.1,"sqs_breakdown":{"profitability":84.5,"risk":100.0,"consistency":95.8,"robustness":55.0},"verdict":"worse","verdict_reasoning":"The stricter gap filter over-concentrated the overlay, dropped total trade count below a healthy level, and cratered test SQS.","next_direction":"Use moderate overlay filters only; the strict version is too sparse.","tags":["pead","midcap","step53","short","core","macro","block","crashcap","gap14"]}

@ -165,6 +165,9 @@ class MetricsBundle(BaseModel):
sortino_ratio: float | None = None
avg_daily_pnl: float | None = None
avg_positions_held: float | None = None
avg_gross_exposure_pct: float | None = None
avg_net_exposure_pct: float | None = None
days_in_market_pct: float | None = None
# Stability metrics (4)
trade_skewness: float | None = None
@ -364,6 +367,40 @@ class SQSWeights(BaseModel):
low_trade_penalty_factor: float = 0.5
class SQSv2Weights(BaseModel):
"""Weights for Strategy Quality Score v2 with capital efficiency."""
profitability: float = 0.35
risk: float = 0.25
consistency: float = 0.20
robustness: float = 0.10
capital_efficiency: float = 0.10
low_trade_penalty_threshold: int = 20
low_trade_penalty_factor: float = 0.5
class PromotionScoreWeights(BaseModel):
"""Weights for promotion scoring across valid/test splits."""
valid_quality: float = 0.55
test_quality: float = 0.15
floor_quality: float = 0.30
class UnifiedScoreWeights(BaseModel):
"""Weights for a stricter single ranking score across valid/test splits."""
split_profitability: float = 0.25
split_risk: float = 0.20
split_consistency: float = 0.15
split_robustness: float = 0.20
split_capital_efficiency: float = 0.20
valid_quality: float = 0.45
test_quality: float = 0.20
floor_quality: float = 0.20
gap_quality: float = 0.15
class SplitResult(BaseModel):
"""Metrics for a single backtest split (train/valid/test)."""
@ -376,6 +413,9 @@ class SplitResult(BaseModel):
sharpe_ratio: float | None = None
monthly_win_rate: float | None = None
equity_curve_r_squared: float | None = None
avg_gross_exposure_pct: float | None = None
avg_net_exposure_pct: float | None = None
days_in_market_pct: float | None = None
class ConfigDelta(BaseModel):
@ -396,6 +436,12 @@ class JournalEntry(BaseModel):
results: dict[str, SplitResult] = Field(default_factory=dict) # split_name → SplitResult
sqs_score: float | None = None
sqs_breakdown: dict[str, float] = Field(default_factory=dict)
sqs_v2_score: float | None = None
sqs_v2_breakdown: dict[str, float] = Field(default_factory=dict)
promotion_score: float | None = None
promotion_breakdown: dict[str, float] = Field(default_factory=dict)
unified_score: float | None = None
unified_breakdown: dict[str, float] = Field(default_factory=dict)
verdict: str = "unknown" # better / worse / neutral / unknown
verdict_reasoning: str = ""
next_direction: str = ""
@ -408,12 +454,29 @@ class RegistryEntry(BaseModel):
entry_id: str
experiment_name: str
sqs_score: float
sqs_v2_score: float | None = None
promotion_score: float | None = None
unified_score: float | None = None
# test split metrics
profit_factor: float | None = None
total_return_pct: float | None = None
win_rate: float | None = None
sharpe_ratio: float | None = None
max_drawdown_pct: float | None = None
trade_count: int = 0
avg_gross_exposure_pct: float | None = None
avg_net_exposure_pct: float | None = None
days_in_market_pct: float | None = None
# valid split metrics
valid_profit_factor: float | None = None
valid_total_return_pct: float | None = None
valid_win_rate: float | None = None
valid_sharpe_ratio: float | None = None
valid_max_drawdown_pct: float | None = None
valid_trade_count: int = 0
valid_avg_gross_exposure_pct: float | None = None
valid_avg_net_exposure_pct: float | None = None
valid_days_in_market_pct: float | None = None
timestamp: str = ""

@ -180,6 +180,45 @@ def compute_avg_positions_held(equity_curve: list[DailyPortfolioState]) -> float
return statistics.mean(len(s.open_positions) for s in equity_curve)
def compute_avg_gross_exposure_pct(
equity_curve: list[DailyPortfolioState],
) -> float | None:
if not equity_curve:
return None
exposure_pcts = [
(state.gross_exposure / state.equity) * 100.0
for state in equity_curve
if state.equity > 0
]
if not exposure_pcts:
return None
return statistics.mean(exposure_pcts)
def compute_avg_net_exposure_pct(
equity_curve: list[DailyPortfolioState],
) -> float | None:
if not equity_curve:
return None
exposure_pcts = [
(state.net_exposure / state.equity) * 100.0
for state in equity_curve
if state.equity > 0
]
if not exposure_pcts:
return None
return statistics.mean(exposure_pcts)
def compute_days_in_market_pct(
equity_curve: list[DailyPortfolioState],
) -> float | None:
if not equity_curve:
return None
days_in_market = sum(1 for state in equity_curve if state.gross_exposure > 0)
return days_in_market / len(equity_curve) * 100.0
# ---------------------------------------------------------------------------
# Stability metrics (4)
# ---------------------------------------------------------------------------
@ -409,6 +448,9 @@ def build_metrics_bundle(
sortino_ratio=compute_sortino_ratio(equity_curve),
avg_daily_pnl=compute_avg_daily_pnl(equity_curve),
avg_positions_held=compute_avg_positions_held(equity_curve),
avg_gross_exposure_pct=compute_avg_gross_exposure_pct(equity_curve),
avg_net_exposure_pct=compute_avg_net_exposure_pct(equity_curve),
days_in_market_pct=compute_days_in_market_pct(equity_curve),
# Stability
trade_skewness=compute_trade_skewness(trades),
trade_kurtosis=compute_trade_kurtosis(trades),

@ -1,6 +1,7 @@
"""Strategy improvement tracker: SQS computation, journal I/O, leaderboard."""
from __future__ import annotations
import functools
import json
from pathlib import Path
from typing import Any
@ -10,9 +11,12 @@ from libs.backtest.domain import (
ExperimentRegistry,
JournalEntry,
MetricsBundle,
PromotionScoreWeights,
RegistryEntry,
SplitResult,
SQSWeights,
SQSv2Weights,
UnifiedScoreWeights,
)
from libs.common.logging import get_logger
from libs.common.time_utils import utc_now
@ -20,6 +24,9 @@ from libs.common.time_utils import utc_now
logger = get_logger(__name__)
_DEFAULT_WEIGHTS = SQSWeights()
_DEFAULT_V2_WEIGHTS = SQSv2Weights()
_DEFAULT_PROMOTION_WEIGHTS = PromotionScoreWeights()
_DEFAULT_UNIFIED_WEIGHTS = UnifiedScoreWeights()
# ---------------------------------------------------------------------------
@ -49,6 +56,39 @@ def _normalize_inverse(value: float | None, low: float, high: float) -> float:
return max(0.0, min(100.0, score))
def _normalize_band(
value: float | None,
low_bad: float,
low_good: float,
high_good: float,
high_bad: float,
) -> float:
"""Score 0-100 with an optimal middle band."""
if value is None:
return 0.0
if value <= low_bad or value >= high_bad:
return 0.0
if low_good <= value <= high_good:
return 100.0
if value < low_good:
return (value - low_bad) / (low_good - low_bad) * 100.0
return (high_bad - value) / (high_bad - high_good) * 100.0
def _calibrate_sqs(value: float | None) -> float | None:
"""Compress raw integrated scores into a harsher absolute-looking range."""
if value is None:
return None
return round(max(0.0, value * 0.70 - 7.5), 1)
def _apply_single_split_penalty(value: float | None) -> float | None:
"""Discount scores that have no valid/test confirmation pair."""
if value is None:
return None
return round(value * 0.80, 1)
def compute_sqs(
metrics: MetricsBundle,
weights: SQSWeights | None = None,
@ -101,6 +141,67 @@ def compute_sqs(
return sqs, breakdown
def compute_sqs_v2(
metrics: MetricsBundle,
weights: SQSv2Weights | None = None,
) -> tuple[float | None, dict[str, float]]:
"""Compute SQS v2, adding a capital-efficiency sleeve to the score."""
if metrics.avg_gross_exposure_pct is None or metrics.days_in_market_pct is None:
return None, {}
w = weights or _DEFAULT_V2_WEIGHTS
pf_score = _normalize(metrics.profit_factor, low=0.8, high=2.0)
ret_score = _normalize(metrics.total_return_pct, low=-5.0, high=5.0)
profitability = pf_score * 0.6 + ret_score * 0.4
dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=10.0, high=1.0)
sharpe_score = _normalize(metrics.sharpe_ratio, low=-1.0, high=2.0)
risk = dd_score * 0.5 + sharpe_score * 0.5
wr_score = _normalize(metrics.win_rate, low=0.35, high=0.65)
mwr_score = _normalize(metrics.monthly_win_rate, low=0.30, high=0.70)
consistency = wr_score * 0.5 + mwr_score * 0.5
r2_score = _normalize(metrics.equity_curve_r_squared, low=0.0, high=0.80)
tc_score = _normalize(float(metrics.trade_count), low=10.0, high=100.0)
robustness = r2_score * 0.5 + tc_score * 0.5
return_on_gross_exposure = None
if metrics.total_return_pct is not None and metrics.avg_gross_exposure_pct > 0:
return_on_gross_exposure = metrics.total_return_pct / metrics.avg_gross_exposure_pct
roe_score = _normalize(return_on_gross_exposure, low=0.0, high=0.50)
dim_score = _normalize_band(
metrics.days_in_market_pct,
low_bad=10.0,
low_good=40.0,
high_good=80.0,
high_bad=100.0,
)
capital_efficiency = roe_score * 0.7 + dim_score * 0.3
sqs_v2 = (
profitability * w.profitability
+ risk * w.risk
+ consistency * w.consistency
+ robustness * w.robustness
+ capital_efficiency * w.capital_efficiency
)
if metrics.trade_count < w.low_trade_penalty_threshold:
sqs_v2 *= w.low_trade_penalty_factor
sqs_v2 = round(sqs_v2, 1)
breakdown = {
"profitability": round(profitability, 1),
"risk": round(risk, 1),
"consistency": round(consistency, 1),
"robustness": round(robustness, 1),
"capital_efficiency": round(capital_efficiency, 1),
}
return sqs_v2, breakdown
# ---------------------------------------------------------------------------
# Helper builders
# ---------------------------------------------------------------------------
@ -118,9 +219,248 @@ def build_split_result(split_name: str, run_id: str, metrics: MetricsBundle) ->
sharpe_ratio=metrics.sharpe_ratio,
monthly_win_rate=metrics.monthly_win_rate,
equity_curve_r_squared=metrics.equity_curve_r_squared,
avg_gross_exposure_pct=metrics.avg_gross_exposure_pct,
avg_net_exposure_pct=metrics.avg_net_exposure_pct,
days_in_market_pct=metrics.days_in_market_pct,
)
def _metrics_from_split_result(result: SplitResult | None) -> MetricsBundle | None:
if result is None:
return None
payload = result.model_dump(exclude={"run_id"})
return MetricsBundle.model_validate(payload)
@functools.lru_cache(maxsize=512)
def _load_run_metrics_summary(run_id: str) -> dict[str, Any] | None:
metrics_path = Path("runs") / run_id / "metrics" / "metrics_summary.json"
if not metrics_path.exists():
return None
summary = json.loads(metrics_path.read_text())
needed_fields = (
"avg_gross_exposure_pct",
"avg_net_exposure_pct",
"days_in_market_pct",
)
if all(summary.get(field) is not None for field in needed_fields):
return summary
equity_curve_path = Path("runs") / run_id / "artifacts" / "daily_equity_curve.parquet"
if not equity_curve_path.exists():
return summary
try:
import pyarrow.parquet as pq
rows = pq.read_table(
equity_curve_path,
columns=["equity", "gross_exposure", "net_exposure"],
).to_pylist()
except Exception:
return summary
exposure_rows = [row for row in rows if float(row["equity"]) > 0]
if not exposure_rows:
return summary
if summary.get("avg_gross_exposure_pct") is None:
summary["avg_gross_exposure_pct"] = sum(
float(row["gross_exposure"]) / float(row["equity"]) * 100.0
for row in exposure_rows
) / len(exposure_rows)
if summary.get("avg_net_exposure_pct") is None:
summary["avg_net_exposure_pct"] = sum(
float(row["net_exposure"]) / float(row["equity"]) * 100.0
for row in exposure_rows
) / len(exposure_rows)
if summary.get("days_in_market_pct") is None:
summary["days_in_market_pct"] = (
sum(1 for row in rows if float(row["gross_exposure"]) > 0) / len(rows) * 100.0
)
return summary
def _hydrate_split_result(result: SplitResult | None) -> SplitResult | None:
if result is None:
return None
needed_fields = (
"avg_gross_exposure_pct",
"avg_net_exposure_pct",
"days_in_market_pct",
)
if all(getattr(result, field) is not None for field in needed_fields):
return result
summary = _load_run_metrics_summary(result.run_id)
if summary is None:
return result
updates = {
field: summary.get(field)
for field in needed_fields
if getattr(result, field) is None and summary.get(field) is not None
}
if not updates:
return result
return result.model_copy(update=updates)
def _compute_split_quality_score(
result: SplitResult | None,
) -> tuple[float | None, dict[str, float], str | None]:
metrics = _metrics_from_split_result(_hydrate_split_result(result))
if metrics is None:
return None, {}, None
sqs_v2_score, sqs_v2_breakdown = compute_sqs_v2(metrics)
if sqs_v2_score is not None:
return sqs_v2_score, sqs_v2_breakdown, "sqs_v2"
sqs_score, sqs_breakdown = compute_sqs(metrics)
return sqs_score, sqs_breakdown, "sqs"
def compute_unified_split_quality(
metrics: MetricsBundle,
weights: UnifiedScoreWeights | None = None,
) -> tuple[float | None, dict[str, float]]:
"""Compute a stricter split-level quality score."""
if metrics.avg_gross_exposure_pct is None or metrics.days_in_market_pct is None:
return None, {}
w = weights or _DEFAULT_UNIFIED_WEIGHTS
pf_score = _normalize(metrics.profit_factor, low=1.0, high=3.0)
ret_score = _normalize(metrics.total_return_pct, low=0.0, high=3.0)
profitability = pf_score * 0.5 + ret_score * 0.5
dd_score = _normalize_inverse(metrics.max_drawdown_pct, low=5.0, high=0.5)
sharpe_score = _normalize(metrics.sharpe_ratio, low=0.0, high=3.5)
risk = dd_score * 0.5 + sharpe_score * 0.5
wr_score = _normalize(metrics.win_rate, low=0.50, high=0.65)
mwr_score = _normalize(metrics.monthly_win_rate, low=0.45, high=0.75)
consistency = wr_score * 0.5 + mwr_score * 0.5
r2_score = _normalize(metrics.equity_curve_r_squared, low=0.10, high=0.90)
tc_score = _normalize(float(metrics.trade_count), low=20.0, high=80.0)
robustness = r2_score * 0.5 + tc_score * 0.5
return_on_gross_exposure = None
if metrics.total_return_pct is not None and metrics.avg_gross_exposure_pct > 0:
return_on_gross_exposure = metrics.total_return_pct / metrics.avg_gross_exposure_pct
roe_score = _normalize(return_on_gross_exposure, low=0.05, high=0.40)
dim_score = _normalize_band(
metrics.days_in_market_pct,
low_bad=20.0,
low_good=45.0,
high_good=75.0,
high_bad=90.0,
)
capital_efficiency = roe_score * 0.7 + dim_score * 0.3
split_quality = (
profitability * w.split_profitability
+ risk * w.split_risk
+ consistency * w.split_consistency
+ robustness * w.split_robustness
+ capital_efficiency * w.split_capital_efficiency
)
if metrics.trade_count < 20:
split_quality *= 0.75
split_quality = round(split_quality, 1)
breakdown = {
"profitability": round(profitability, 1),
"risk": round(risk, 1),
"consistency": round(consistency, 1),
"robustness": round(robustness, 1),
"capital_efficiency": round(capital_efficiency, 1),
}
return split_quality, breakdown
def compute_promotion_score(
test_result: SplitResult | None,
valid_result: SplitResult | None,
weights: PromotionScoreWeights | None = None,
) -> tuple[float | None, dict[str, float]]:
"""Compute a promotion score using valid/test quality and a floor term."""
test_score, _, _ = _compute_split_quality_score(test_result)
valid_score, _, _ = _compute_split_quality_score(valid_result)
if test_score is None or valid_score is None:
return None, {}
w = weights or _DEFAULT_PROMOTION_WEIGHTS
floor_score = min(test_score, valid_score)
promotion_score = (
valid_score * w.valid_quality
+ test_score * w.test_quality
+ floor_score * w.floor_quality
)
breakdown = {
"valid_quality": round(valid_score, 1),
"test_quality": round(test_score, 1),
"floor_quality": round(floor_score, 1),
}
return round(promotion_score, 1), breakdown
def compute_unified_score(
test_result: SplitResult | None,
valid_result: SplitResult | None,
weights: UnifiedScoreWeights | None = None,
) -> tuple[float | None, dict[str, float]]:
"""Compute one integrated score for real promotion decisions."""
test_result = _hydrate_split_result(test_result)
valid_result = _hydrate_split_result(valid_result)
test_metrics = _metrics_from_split_result(test_result)
valid_metrics = _metrics_from_split_result(valid_result)
if test_metrics is None or valid_metrics is None:
return None, {}
w = weights or _DEFAULT_UNIFIED_WEIGHTS
test_quality, _ = compute_unified_split_quality(test_metrics, w)
valid_quality, _ = compute_unified_split_quality(valid_metrics, w)
if test_quality is None or valid_quality is None:
return None, {}
floor_quality = min(test_quality, valid_quality)
gap_quality = _normalize_inverse(abs(valid_quality - test_quality), low=35.0, high=5.0)
unified_score = (
valid_quality * w.valid_quality
+ test_quality * w.test_quality
+ floor_quality * w.floor_quality
+ gap_quality * w.gap_quality
)
breakdown = {
"valid_quality": round(valid_quality, 1),
"test_quality": round(test_quality, 1),
"floor_quality": round(floor_quality, 1),
"gap_quality": round(gap_quality, 1),
}
return _calibrate_sqs(unified_score), breakdown
def compute_public_sqs(
test_result: SplitResult | None,
valid_result: SplitResult | None,
) -> tuple[float | None, dict[str, float], str | None]:
"""Return the public-facing SQS.
Prefer the stricter integrated score when both valid/test are available.
Fall back to the best available single-split quality score otherwise,
using the same harsher calibration band.
"""
integrated_score, integrated_breakdown = compute_unified_score(test_result, valid_result)
if integrated_score is not None:
return integrated_score, integrated_breakdown, "integrated"
split_score, split_breakdown, split_source = _compute_split_quality_score(test_result)
if split_score is not None:
return _apply_single_split_penalty(_calibrate_sqs(split_score)), split_breakdown, split_source
return None, {}, None
def compute_config_delta(
current: dict[str, Any],
baseline: dict[str, Any],
@ -195,24 +535,58 @@ def rebuild_registry(
registry_entries: list[RegistryEntry] = []
for je in entries:
test_result = je.results.get("test")
test_result = _hydrate_split_result(je.results.get("test"))
valid_result = _hydrate_split_result(je.results.get("valid"))
computed_sqs_v2 = None
test_metrics = _metrics_from_split_result(test_result)
if test_metrics is not None:
computed_sqs_v2, _ = compute_sqs_v2(test_metrics)
computed_promotion_score, _ = compute_promotion_score(test_result, valid_result)
computed_unified_score, _ = compute_unified_score(test_result, valid_result)
computed_public_sqs, _, _ = compute_public_sqs(test_result, valid_result)
canonical_sqs = computed_public_sqs if computed_public_sqs is not None else je.sqs_score or 0.0
registry_entries.append(
RegistryEntry(
entry_id=je.entry_id,
experiment_name=je.experiment_name,
sqs_score=je.sqs_score or 0.0,
sqs_score=canonical_sqs,
sqs_v2_score=je.sqs_v2_score if je.sqs_v2_score is not None else computed_sqs_v2,
promotion_score=(
je.promotion_score if je.promotion_score is not None else computed_promotion_score
),
unified_score=(
je.unified_score if je.unified_score is not None else computed_unified_score
),
profit_factor=test_result.profit_factor if test_result else None,
total_return_pct=test_result.total_return_pct if test_result else None,
win_rate=test_result.win_rate if test_result else None,
sharpe_ratio=test_result.sharpe_ratio if test_result else None,
max_drawdown_pct=test_result.max_drawdown_pct if test_result else None,
trade_count=test_result.trade_count if test_result else 0,
avg_gross_exposure_pct=test_result.avg_gross_exposure_pct if test_result else None,
avg_net_exposure_pct=test_result.avg_net_exposure_pct if test_result else None,
days_in_market_pct=test_result.days_in_market_pct if test_result else None,
valid_profit_factor=valid_result.profit_factor if valid_result else None,
valid_total_return_pct=valid_result.total_return_pct if valid_result else None,
valid_win_rate=valid_result.win_rate if valid_result else None,
valid_sharpe_ratio=valid_result.sharpe_ratio if valid_result else None,
valid_max_drawdown_pct=valid_result.max_drawdown_pct if valid_result else None,
valid_trade_count=valid_result.trade_count if valid_result else 0,
valid_avg_gross_exposure_pct=valid_result.avg_gross_exposure_pct if valid_result else None,
valid_avg_net_exposure_pct=valid_result.avg_net_exposure_pct if valid_result else None,
valid_days_in_market_pct=valid_result.days_in_market_pct if valid_result else None,
timestamp=je.timestamp,
)
)
# Sort by SQS descending
registry_entries.sort(key=lambda e: e.sqs_score, reverse=True)
# Sort by canonical SQS descending, then promotion.
registry_entries.sort(
key=lambda e: (
-(e.sqs_score or 0.0),
e.promotion_score is None,
-(e.promotion_score or 0.0),
)
)
registry = ExperimentRegistry(
entries=registry_entries,
@ -238,8 +612,8 @@ def _write_leaderboard_md(
lines: list[str] = []
lines.append("# Strategy Improvement Leaderboard")
lines.append(f"_Updated: {registry.updated_at}_\n")
lines.append("| # | Experiment | SQS | PF | Ret% | WR | Sharpe | DD% | Trades | Date |")
lines.append("|---|-----------|-----|-----|------|-----|--------|-----|--------|------|")
lines.append("| # | Experiment | SQS | [T]PF | [T]Ret% | [T]WR | [T]Sharpe | [T]DD% | [T]N | [T]Gross% | [T]Net% | [T]DIM% | [V]PF | [V]Ret% | [V]WR | [V]Sharpe | [V]DD% | [V]N | [V]Gross% | [V]Net% | [V]DIM% | Date |")
lines.append("|---|-----------|-----|-------|---------|-------|-----------|--------|------|-----------|---------|---------|-------|---------|-------|-----------|--------|------|-----------|---------|---------|------|")
for rank, e in enumerate(registry.entries, 1):
pf = f"{e.profit_factor:.2f}" if e.profit_factor is not None else "-"
@ -247,19 +621,35 @@ def _write_leaderboard_md(
wr = f"{e.win_rate:.0%}" if e.win_rate is not None else "-"
sharpe = f"{e.sharpe_ratio:.1f}" if e.sharpe_ratio is not None else "-"
dd = f"{e.max_drawdown_pct:.1f}" if e.max_drawdown_pct is not None else "-"
tgross = f"{e.avg_gross_exposure_pct:.1f}" if e.avg_gross_exposure_pct is not None else "-"
tnet = f"{e.avg_net_exposure_pct:+.1f}" if e.avg_net_exposure_pct is not None else "-"
tdim = f"{e.days_in_market_pct:.1f}" if e.days_in_market_pct is not None else "-"
vpf = f"{e.valid_profit_factor:.2f}" if e.valid_profit_factor is not None else "-"
vret = f"{e.valid_total_return_pct:+.1f}" if e.valid_total_return_pct is not None else "-"
vwr = f"{e.valid_win_rate:.0%}" if e.valid_win_rate is not None else "-"
vsharpe = f"{e.valid_sharpe_ratio:.1f}" if e.valid_sharpe_ratio is not None else "-"
vdd = f"{e.valid_max_drawdown_pct:.1f}" if e.valid_max_drawdown_pct is not None else "-"
vgross = f"{e.valid_avg_gross_exposure_pct:.1f}" if e.valid_avg_gross_exposure_pct is not None else "-"
vnet = f"{e.valid_avg_net_exposure_pct:+.1f}" if e.valid_avg_net_exposure_pct is not None else "-"
vdim = f"{e.valid_days_in_market_pct:.1f}" if e.valid_days_in_market_pct is not None else "-"
ts = e.timestamp[:10] if e.timestamp else "-"
lines.append(
f"| {rank} | {e.experiment_name} | {e.sqs_score:.1f} | {pf} | {ret} | {wr} | {sharpe} | {dd} | {e.trade_count} | {ts} |"
f"| {rank} | {e.experiment_name} | {e.sqs_score:.1f}"
f" | {pf} | {ret} | {wr} | {sharpe} | {dd} | {e.trade_count} | {tgross} | {tnet} | {tdim}"
f" | {vpf} | {vret} | {vwr} | {vsharpe} | {vdd} | {e.valid_trade_count} | {vgross} | {vnet} | {vdim}"
f" | {ts} |"
)
# Recent entries (last 5)
recent = list(reversed(journal_entries))[:5]
if recent:
registry_by_id = {entry.entry_id: entry for entry in registry.entries}
lines.append("\n## Recent Entries")
for je in recent:
canonical_sqs = registry_by_id.get(je.entry_id).sqs_score if je.entry_id in registry_by_id else je.sqs_score
lines.append(f"### {je.entry_id} ({je.timestamp[:10]}) \u2014 {je.experiment_name}")
lines.append(f"Hypothesis: {je.hypothesis}")
lines.append(f"Verdict: **{je.verdict.upper()}** (SQS {je.sqs_score})")
lines.append(f"Verdict: **{je.verdict.upper()}** (SQS {canonical_sqs})")
if je.verdict_reasoning:
lines.append(f"Reasoning: {je.verdict_reasoning}")
if je.next_direction:

@ -38,6 +38,9 @@ dev = [
"types-beautifulsoup4",
]
[project.scripts]
fithia2 = "apps.tracker.cli:main"
[tool.hatch.build.targets.wheel]
packages = ["apps", "libs"]

@ -323,6 +323,7 @@ class TestBacktestRunIntegration:
def test_multi_engine_run_writes_per_engine_metrics(self, tmp_path):
from apps.backtester.run import BacktestRunner
from libs.backtest.domain import ExperimentManifest, StrategyEngineConfig
import json
import pyarrow.parquet as pq
store = _build_multi_engine_store()
@ -382,3 +383,11 @@ class TestBacktestRunIntegration:
trade_blotter = pq.read_table(run_dir / "artifacts" / "trade_blotter.parquet").to_pylist()
engine_ids = {row["engine_id"] for row in trade_blotter}
assert "earnings_after_close_short_v1" not in engine_ids
equity_curve = pq.read_table(run_dir / "artifacts" / "daily_equity_curve.parquet").to_pylist()
assert any(float(row["net_exposure"]) < 0 for row in equity_curve if float(row["gross_exposure"]) > 0)
metrics_summary = json.loads((run_dir / "metrics" / "metrics_summary.json").read_text())
assert "avg_gross_exposure_pct" in metrics_summary
assert "avg_net_exposure_pct" in metrics_summary
assert "days_in_market_pct" in metrics_summary

@ -38,13 +38,19 @@ def _make_trade(
)
def _make_equity_state(date: dt.date, equity: float, n_positions: int = 0) -> DailyPortfolioState:
def _make_equity_state(
date: dt.date,
equity: float,
n_positions: int = 0,
gross_exposure: float = 0.0,
net_exposure: float = 0.0,
) -> DailyPortfolioState:
return DailyPortfolioState(
date=date,
equity=equity,
cash_available=equity,
gross_exposure=0.0,
net_exposure=0.0,
gross_exposure=gross_exposure,
net_exposure=net_exposure,
reserved_risk_budget=0.0,
unrealized_pnl=0.0,
realized_pnl=0.0,
@ -168,6 +174,25 @@ class TestSharpeRatio:
assert compute_sharpe_ratio(curve) is None
class TestExposureMetrics:
def test_avg_gross_and_net_exposure_pct(self):
from libs.backtest.metrics import (
compute_avg_gross_exposure_pct,
compute_avg_net_exposure_pct,
compute_days_in_market_pct,
)
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000, gross_exposure=0.0, net_exposure=0.0),
_make_equity_state(dt.date(2026, 1, 6), 100_000, gross_exposure=20_000.0, net_exposure=-20_000.0),
_make_equity_state(dt.date(2026, 1, 7), 100_000, gross_exposure=10_000.0, net_exposure=5_000.0),
]
assert compute_avg_gross_exposure_pct(curve) == pytest.approx(10.0)
assert compute_avg_net_exposure_pct(curve) == pytest.approx(-5.0)
assert compute_days_in_market_pct(curve) == pytest.approx(66.6666666667)
class TestStopExitRate:
def test_basic(self):
from libs.backtest.metrics import compute_stop_exit_rate
@ -218,6 +243,23 @@ class TestBuildMetricsBundle:
assert m.trade_count == 2
assert m.win_rate == pytest.approx(0.5)
assert m.total_return_pct == pytest.approx(5.0)
assert m.avg_gross_exposure_pct == pytest.approx(0.0)
assert m.avg_net_exposure_pct == pytest.approx(0.0)
assert m.days_in_market_pct == pytest.approx(0.0)
def test_builds_exposure_metrics(self):
from libs.backtest.metrics import build_metrics_bundle
curve = [
_make_equity_state(dt.date(2026, 1, 5), 100_000, gross_exposure=0.0, net_exposure=0.0),
_make_equity_state(dt.date(2026, 1, 6), 100_000, gross_exposure=30_000.0, net_exposure=-10_000.0),
_make_equity_state(dt.date(2026, 1, 7), 100_000, gross_exposure=10_000.0, net_exposure=10_000.0),
]
m = build_metrics_bundle([], curve)
assert m.avg_gross_exposure_pct == pytest.approx(13.3333333333)
assert m.avg_net_exposure_pct == pytest.approx(0.0)
assert m.days_in_market_pct == pytest.approx(66.6666666667)
def test_empty_trades(self):
from libs.backtest.metrics import build_metrics_bundle

@ -14,11 +14,17 @@ from libs.backtest.domain import (
)
from libs.backtest.tracker import (
_normalize,
_normalize_band,
_normalize_inverse,
append_journal_entry,
build_split_result,
check_duplicate,
compute_public_sqs,
compute_promotion_score,
compute_sqs,
compute_sqs_v2,
compute_unified_score,
compute_unified_split_quality,
get_next_entry_id,
load_journal,
rebuild_registry,
@ -66,6 +72,18 @@ class TestNormalizeInverse:
assert _normalize_inverse(None, low=10.0, high=1.0) == 0.0
class TestNormalizeBand:
def test_band_plateau_scores_max(self):
assert _normalize_band(60.0, low_bad=10.0, low_good=40.0, high_good=80.0, high_bad=100.0) == 100.0
def test_below_band_ramps_up(self):
assert _normalize_band(25.0, low_bad=10.0, low_good=40.0, high_good=80.0, high_bad=100.0) == pytest.approx(50.0)
def test_outside_band_scores_zero(self):
assert _normalize_band(5.0, low_bad=10.0, low_good=40.0, high_good=80.0, high_bad=100.0) == 0.0
assert _normalize_band(100.0, low_bad=10.0, low_good=40.0, high_good=80.0, high_bad=100.0) == 0.0
# ---------------------------------------------------------------------------
# compute_sqs
# ---------------------------------------------------------------------------
@ -162,6 +180,257 @@ class TestComputeSQS:
assert sqs_profit > sqs_risk
class TestComputeSQSv2:
def test_missing_exposure_returns_none(self):
m = MetricsBundle(
trade_count=50,
profit_factor=1.5,
total_return_pct=2.0,
max_drawdown_pct=2.0,
sharpe_ratio=1.2,
win_rate=0.55,
monthly_win_rate=0.60,
equity_curve_r_squared=0.50,
)
sqs_v2, breakdown = compute_sqs_v2(m)
assert sqs_v2 is None
assert breakdown == {}
def test_capital_efficiency_can_break_tie(self):
base = dict(
trade_count=50,
profit_factor=1.5,
total_return_pct=2.0,
max_drawdown_pct=2.0,
sharpe_ratio=1.2,
win_rate=0.55,
monthly_win_rate=0.60,
equity_curve_r_squared=0.50,
days_in_market_pct=60.0,
)
efficient = MetricsBundle(
**base,
avg_gross_exposure_pct=4.0,
avg_net_exposure_pct=-1.0,
)
inefficient = MetricsBundle(
**base,
avg_gross_exposure_pct=10.0,
avg_net_exposure_pct=-1.0,
)
efficient_score, _ = compute_sqs_v2(efficient)
inefficient_score, _ = compute_sqs_v2(inefficient)
assert efficient_score is not None
assert inefficient_score is not None
assert efficient_score > inefficient_score
class TestComputePromotionScore:
def test_requires_valid_and_test(self):
score, breakdown = compute_promotion_score(None, None)
assert score is None
assert breakdown == {}
def test_penalizes_test_only_outperformance(self):
overfit_test = SplitResult(
run_id="bt_test",
trade_count=56,
profit_factor=2.0,
total_return_pct=2.2,
win_rate=0.61,
max_drawdown_pct=0.4,
sharpe_ratio=4.2,
monthly_win_rate=1.0,
equity_curve_r_squared=0.88,
avg_gross_exposure_pct=6.2,
avg_net_exposure_pct=1.1,
days_in_market_pct=76.6,
)
overfit_valid = SplitResult(
run_id="bt_valid",
trade_count=53,
profit_factor=1.24,
total_return_pct=0.7,
win_rate=0.51,
max_drawdown_pct=0.9,
sharpe_ratio=1.2,
monthly_win_rate=0.75,
equity_curve_r_squared=0.15,
avg_gross_exposure_pct=7.5,
avg_net_exposure_pct=1.6,
days_in_market_pct=77.2,
)
robust_test = SplitResult(
run_id="bt_test_robust",
trade_count=22,
profit_factor=4.19,
total_return_pct=1.24,
win_rate=0.73,
max_drawdown_pct=0.22,
sharpe_ratio=3.6,
monthly_win_rate=0.67,
equity_curve_r_squared=0.92,
avg_gross_exposure_pct=2.3,
avg_net_exposure_pct=-0.8,
days_in_market_pct=42.6,
)
robust_valid = SplitResult(
run_id="bt_valid_robust",
trade_count=28,
profit_factor=2.31,
total_return_pct=1.3,
win_rate=0.68,
max_drawdown_pct=0.37,
sharpe_ratio=3.05,
monthly_win_rate=0.75,
equity_curve_r_squared=0.49,
avg_gross_exposure_pct=4.1,
avg_net_exposure_pct=-1.9,
days_in_market_pct=57.9,
)
overfit_score, overfit_breakdown = compute_promotion_score(overfit_test, overfit_valid)
robust_score, robust_breakdown = compute_promotion_score(robust_test, robust_valid)
assert overfit_score is not None
assert robust_score is not None
assert robust_score > overfit_score
assert overfit_breakdown["floor_quality"] < overfit_breakdown["test_quality"]
class TestComputeUnifiedScore:
def test_requires_valid_and_test(self):
score, breakdown = compute_unified_score(None, None)
assert score is None
assert breakdown == {}
def test_split_quality_rewards_efficiency(self):
efficient = MetricsBundle(
trade_count=30,
profit_factor=2.0,
total_return_pct=1.2,
max_drawdown_pct=0.4,
sharpe_ratio=2.5,
win_rate=0.60,
monthly_win_rate=0.75,
equity_curve_r_squared=0.70,
avg_gross_exposure_pct=3.0,
avg_net_exposure_pct=-1.0,
days_in_market_pct=55.0,
)
inefficient = efficient.model_copy(
update={"avg_gross_exposure_pct": 8.0, "days_in_market_pct": 85.0}
)
efficient_score, _ = compute_unified_split_quality(efficient)
inefficient_score, _ = compute_unified_split_quality(inefficient)
assert efficient_score is not None
assert inefficient_score is not None
assert efficient_score > inefficient_score
def test_overfit_strategy_scores_below_robust_strategy(self):
overfit_test = SplitResult(
run_id="bt_step35_test",
trade_count=56,
profit_factor=2.009,
total_return_pct=2.181,
win_rate=0.607,
max_drawdown_pct=0.442,
sharpe_ratio=4.217,
monthly_win_rate=1.0,
equity_curve_r_squared=0.889,
avg_gross_exposure_pct=6.228,
avg_net_exposure_pct=1.126,
days_in_market_pct=76.6,
)
overfit_valid = SplitResult(
run_id="bt_step35_valid",
trade_count=53,
profit_factor=1.243,
total_return_pct=0.708,
win_rate=0.509,
max_drawdown_pct=0.885,
sharpe_ratio=1.180,
monthly_win_rate=0.75,
equity_curve_r_squared=0.146,
avg_gross_exposure_pct=7.511,
avg_net_exposure_pct=1.567,
days_in_market_pct=77.2,
)
robust_test = SplitResult(
run_id="bt_step52_test",
trade_count=22,
profit_factor=3.352,
total_return_pct=1.011,
win_rate=0.773,
max_drawdown_pct=0.243,
sharpe_ratio=3.032,
monthly_win_rate=0.667,
equity_curve_r_squared=0.847,
avg_gross_exposure_pct=2.858,
avg_net_exposure_pct=-2.223,
days_in_market_pct=53.2,
)
robust_valid = SplitResult(
run_id="bt_step52_valid",
trade_count=25,
profit_factor=5.660,
total_return_pct=1.819,
win_rate=0.72,
max_drawdown_pct=0.217,
sharpe_ratio=4.756,
monthly_win_rate=1.0,
equity_curve_r_squared=0.661,
avg_gross_exposure_pct=3.584,
avg_net_exposure_pct=-2.593,
days_in_market_pct=56.1,
)
overfit_score, overfit_breakdown = compute_unified_score(overfit_test, overfit_valid)
robust_score, robust_breakdown = compute_unified_score(robust_test, robust_valid)
assert overfit_score is not None
assert robust_score is not None
assert robust_score > overfit_score
assert overfit_breakdown["gap_quality"] < robust_breakdown["gap_quality"]
class TestComputePublicSQS:
def test_public_score_prefers_integrated_and_is_harsher(self):
test_result = SplitResult(
run_id="bt_test",
trade_count=22,
profit_factor=3.352,
total_return_pct=1.011,
win_rate=0.773,
max_drawdown_pct=0.243,
sharpe_ratio=3.032,
monthly_win_rate=0.667,
equity_curve_r_squared=0.847,
avg_gross_exposure_pct=2.858,
avg_net_exposure_pct=-2.223,
days_in_market_pct=53.2,
)
valid_result = SplitResult(
run_id="bt_valid",
trade_count=25,
profit_factor=5.660,
total_return_pct=1.819,
win_rate=0.72,
max_drawdown_pct=0.217,
sharpe_ratio=4.756,
monthly_win_rate=1.0,
equity_curve_r_squared=0.661,
avg_gross_exposure_pct=3.584,
avg_net_exposure_pct=-2.593,
days_in_market_pct=56.1,
)
public_score, _, source = compute_public_sqs(test_result, valid_result)
raw_integrated_score, _ = compute_unified_score(test_result, valid_result)
assert source == "integrated"
assert public_score == raw_integrated_score
assert public_score is not None
assert public_score < 60.0
# ---------------------------------------------------------------------------
# build_split_result
# ---------------------------------------------------------------------------
@ -177,12 +446,18 @@ def test_build_split_result():
sharpe_ratio=0.8,
monthly_win_rate=0.60,
equity_curve_r_squared=0.40,
avg_gross_exposure_pct=18.5,
avg_net_exposure_pct=-6.5,
days_in_market_pct=27.0,
)
sr = build_split_result("test", "bt_run123", m)
assert sr.run_id == "bt_run123"
assert sr.trade_count == 50
assert sr.profit_factor == 1.2
assert sr.total_return_pct == 2.5
assert sr.avg_gross_exposure_pct == 18.5
assert sr.avg_net_exposure_pct == -6.5
assert sr.days_in_market_pct == 27.0
# ---------------------------------------------------------------------------
@ -298,6 +573,11 @@ class TestRebuildRegistry:
profit_factor=1.0 + i * 0.2,
total_return_pct=float(i),
win_rate=0.5,
monthly_win_rate=0.6,
equity_curve_r_squared=0.5,
avg_gross_exposure_pct=5.0,
avg_net_exposure_pct=-1.0,
days_in_market_pct=60.0,
)
entry = JournalEntry(
entry_id=f"IMP-{i+1:04d}",
@ -305,6 +585,8 @@ class TestRebuildRegistry:
experiment_name=name,
hypothesis=f"h{i}",
sqs_score=sqs,
promotion_score=sqs - 5,
unified_score=sqs - 10,
results={"test": test_result},
verdict="better" if sqs > 50 else "worse",
)
@ -312,11 +594,13 @@ class TestRebuildRegistry:
registry = rebuild_registry(journal_path, registry_path, leaderboard_path)
# Sorted by SQS descending
# Sorted by public SQS descending
assert len(registry.entries) == 3
assert registry.entries[0].experiment_name == "exp_high"
assert registry.entries[0].sqs_score == 70.0
assert registry.entries[0].experiment_name == "exp_mid"
assert registry.entries[2].experiment_name == "exp_low"
assert registry.entries[0].sqs_v2_score is not None
assert registry.entries[0].promotion_score is not None
assert registry.entries[0].sqs_score < 70.0
# Files exist
assert registry_path.exists()
@ -327,3 +611,5 @@ class TestRebuildRegistry:
assert "exp_high" in lb_text
assert "exp_low" in lb_text
assert "| # |" in lb_text
assert "| # | Experiment | SQS |" in lb_text
assert "[T]Gross%" in lb_text

Loading…
Cancel
Save