678 lines
21 KiB
Python
678 lines
21 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import sqlite3
|
|
from datetime import date, timedelta
|
|
|
|
import pytest
|
|
|
|
from app.services import backtest_service as bt
|
|
from scripts.portfolio_capacity_research import (
|
|
ANCHOR_YEARS,
|
|
aggregate_results,
|
|
bootstrap_median_interval,
|
|
build_cells,
|
|
build_cohort_manifest,
|
|
summarize_simulation,
|
|
validate_cohort_manifest,
|
|
)
|
|
from scripts.run_portfolio_construction_matrix import (
|
|
_assert_clean_worktree,
|
|
_checkpoint_state,
|
|
_load_snapshot,
|
|
_markdown,
|
|
_operational_summary,
|
|
_worker_init,
|
|
_worker_run_cell,
|
|
_write_cell_checkpoint,
|
|
)
|
|
|
|
|
|
def _prices(ords: list[int], close: float = 100.0) -> tuple:
|
|
closes = [close] * len(ords)
|
|
return (
|
|
ords,
|
|
list(closes),
|
|
[value + 1.0 for value in closes],
|
|
[value - 1.0 for value in closes],
|
|
list(closes),
|
|
[1_000_000] * len(ords),
|
|
)
|
|
|
|
|
|
def _candidate(
|
|
symbol: str,
|
|
day: date,
|
|
*,
|
|
entry: float = 100.0,
|
|
stop: float = 80.0,
|
|
rank: float = 90.0,
|
|
) -> dict:
|
|
return {
|
|
'qualified': True,
|
|
'direction': 'long',
|
|
'symbol': symbol,
|
|
'date': day.isoformat(),
|
|
'entry': entry,
|
|
'stop': stop,
|
|
'target': entry + 100.0,
|
|
'momentum_percentile': rank,
|
|
'activation_momentum_percentile': rank,
|
|
'residual_high_vol_blend_80_20': rank,
|
|
}
|
|
|
|
|
|
def _business_days(start: date, end: date) -> list[date]:
|
|
days: list[date] = []
|
|
current = start
|
|
while current <= end:
|
|
if current.weekday() < 5:
|
|
days.append(current)
|
|
current += timedelta(days=1)
|
|
return days
|
|
|
|
|
|
def test_new_simulator_option_defaults_match_explicit_defaults():
|
|
start = date(2025, 1, 6)
|
|
ords = [start.toordinal() + offset for offset in range(8)]
|
|
prices = {'AAA': _prices(ords)}
|
|
candidates = [_candidate('AAA', start)]
|
|
|
|
legacy = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
3,
|
|
include_trades=True,
|
|
)
|
|
explicit = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
3,
|
|
max_positions=10,
|
|
min_initial_risk_fraction=None,
|
|
weekly_top_n_rebalance=False,
|
|
measurement_start_date=None,
|
|
hard_end_date=None,
|
|
include_capacity_diagnostics=False,
|
|
include_trades=True,
|
|
)
|
|
|
|
assert legacy == explicit
|
|
|
|
|
|
def test_load_snapshot_accepts_pre_sec_ticker_schema(tmp_path, monkeypatch):
|
|
snapshot = tmp_path / 'legacy-research.sqlite'
|
|
with sqlite3.connect(snapshot) as connection:
|
|
connection.executescript(
|
|
'''
|
|
CREATE TABLE tickers (
|
|
id INTEGER PRIMARY KEY,
|
|
symbol VARCHAR(10) NOT NULL UNIQUE,
|
|
name VARCHAR(120),
|
|
created_at DATETIME NOT NULL
|
|
);
|
|
CREATE TABLE ohlcv_records (
|
|
id INTEGER PRIMARY KEY,
|
|
ticker_id INTEGER NOT NULL,
|
|
date DATE NOT NULL,
|
|
open FLOAT NOT NULL,
|
|
high FLOAT NOT NULL,
|
|
low FLOAT NOT NULL,
|
|
close FLOAT NOT NULL,
|
|
volume BIGINT NOT NULL,
|
|
created_at DATETIME NOT NULL
|
|
);
|
|
INSERT INTO tickers VALUES
|
|
(1, 'LEGACY', 'Legacy Co', '2024-01-01 00:00:00');
|
|
INSERT INTO ohlcv_records VALUES
|
|
(1, 1, '2024-01-02', 100, 102, 99, 101, 1000000,
|
|
'2024-01-02 00:00:00');
|
|
'''
|
|
)
|
|
|
|
async def recommendation_config(_db):
|
|
return {}
|
|
|
|
async def activation_config(_db):
|
|
return {'min_momentum_percentile': 80.0}
|
|
|
|
async def exit_policy(_db):
|
|
return {'mode': 'atr_trailing', 'hold_days': 30, 'atr_multiplier': 3.0}
|
|
|
|
async def benchmark_closes(_db, *, days, refresh):
|
|
assert days is None
|
|
assert refresh is False
|
|
return {date(2024, 1, 2): 100.0}
|
|
|
|
monkeypatch.setattr(
|
|
'app.services.recommendation_service.get_recommendation_config',
|
|
recommendation_config,
|
|
)
|
|
monkeypatch.setattr(
|
|
'app.services.admin_service.get_activation_config',
|
|
activation_config,
|
|
)
|
|
monkeypatch.setattr(
|
|
'app.services.paper_trade_service.get_exit_policy',
|
|
exit_policy,
|
|
)
|
|
monkeypatch.setattr(
|
|
'app.services.backtest_service._load_benchmark_closes_for_backtest',
|
|
benchmark_closes,
|
|
)
|
|
|
|
loaded = asyncio.run(_load_snapshot(snapshot, quiet=True))
|
|
|
|
assert loaded['symbols'] == ['LEGACY']
|
|
assert loaded['prices']['LEGACY'] == (
|
|
[date(2024, 1, 2).toordinal()],
|
|
[100.0],
|
|
[102.0],
|
|
[99.0],
|
|
[101.0],
|
|
[1_000_000],
|
|
)
|
|
with sqlite3.connect(snapshot) as connection:
|
|
columns = {
|
|
row[1] for row in connection.execute('PRAGMA table_info(tickers)')
|
|
}
|
|
assert {'cik', 'sic', 'sic_description'}.isdisjoint(columns)
|
|
|
|
|
|
def test_unbounded_count_and_effective_risk_floor():
|
|
start = date(2025, 1, 6)
|
|
ords = [start.toordinal() + offset for offset in range(4)]
|
|
symbols = [f'S{index}' for index in range(25)]
|
|
prices = {symbol: _prices(ords) for symbol in symbols}
|
|
candidates = [
|
|
_candidate(symbol, start, stop=80.0, rank=100.0 - index)
|
|
for index, symbol in enumerate(symbols)
|
|
]
|
|
|
|
capped = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
30,
|
|
max_positions=1,
|
|
hard_end_date=start + timedelta(days=4),
|
|
measurement_start_date=start,
|
|
include_capacity_diagnostics=True,
|
|
)
|
|
unbounded = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
30,
|
|
max_positions=None,
|
|
min_initial_risk_fraction=0.005,
|
|
hard_end_date=start + timedelta(days=4),
|
|
measurement_start_date=start,
|
|
include_capacity_diagnostics=True,
|
|
)
|
|
|
|
assert capped is not None and unbounded is not None
|
|
assert capped['peak_positions'] == 1
|
|
assert capped['measurement_skipped_book_full'] == 24
|
|
assert unbounded['peak_positions'] > 1
|
|
assert unbounded['measurement_skipped_book_full'] == 0
|
|
assert unbounded['skipped_min_initial_risk'] > 0
|
|
assert unbounded['peak_positions'] == unbounded['trades']
|
|
|
|
|
|
def test_measurement_window_carries_state_but_excludes_pre_anchor_trade_ev():
|
|
start = date(2025, 1, 6)
|
|
anchor = start + timedelta(days=2)
|
|
hard_end = start + timedelta(days=7)
|
|
ords = [
|
|
start.toordinal() + offset
|
|
for offset in range((hard_end - start).days)
|
|
]
|
|
prices = {
|
|
'AAA': _prices(ords, 100.0),
|
|
'BBB': _prices(ords, 100.0),
|
|
}
|
|
candidates = [
|
|
_candidate('AAA', start),
|
|
_candidate('BBB', anchor + timedelta(days=1)),
|
|
]
|
|
|
|
sim = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
30,
|
|
start_date=start,
|
|
end_date=hard_end,
|
|
measurement_start_date=anchor,
|
|
hard_end_date=hard_end,
|
|
include_curve=True,
|
|
include_trades=True,
|
|
)
|
|
|
|
assert sim is not None
|
|
assert sim['simulation_start_date'] == start.isoformat()
|
|
assert sim['start_date'] == anchor.isoformat()
|
|
assert sim['measurement_start_positions'] == 1
|
|
assert sim['trades'] == 1
|
|
assert [trade['symbol'] for trade in sim['trade_details']] == ['BBB']
|
|
assert sim['equity_curve'][0]['date'] == anchor.isoformat()
|
|
|
|
|
|
def test_weekly_top10_uses_current_rank_for_both_sides_not_entry_rank():
|
|
monday = date(2025, 1, 6)
|
|
friday = date(2025, 1, 10)
|
|
sessions = _business_days(monday, friday)
|
|
ords = [session.toordinal() for session in sessions]
|
|
prices = {
|
|
'AAA': _prices(ords),
|
|
'BBB': _prices(ords),
|
|
}
|
|
candidates = [
|
|
_candidate('AAA', monday, rank=99.0),
|
|
_candidate('BBB', friday, rank=10.0),
|
|
]
|
|
rank_map = {
|
|
('AAA', friday.isoformat()): {'strategy_rank': 10.0},
|
|
('BBB', friday.isoformat()): {'strategy_rank': 90.0},
|
|
}
|
|
|
|
sim = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
30,
|
|
max_positions=1,
|
|
weekly_top_n_rebalance=True,
|
|
daily_rank_map=rank_map,
|
|
measurement_start_date=monday,
|
|
hard_end_date=friday + timedelta(days=1),
|
|
include_trades=True,
|
|
include_capacity_diagnostics=True,
|
|
)
|
|
|
|
assert sim is not None
|
|
assert [trade['symbol'] for trade in sim['trade_details']] == ['AAA', 'BBB']
|
|
assert sim['trade_details'][0]['reason'] == 'weekly_rebalance'
|
|
event = sim['weekly_rebalance_events'][0]
|
|
assert event['exited_symbols'] == ['AAA']
|
|
assert event['selected_entrant_symbols'] == ['BBB']
|
|
|
|
|
|
def test_weekly_top10_incumbent_wins_exact_current_rank_tie():
|
|
monday = date(2025, 1, 6)
|
|
friday = date(2025, 1, 10)
|
|
sessions = _business_days(monday, friday)
|
|
ords = [session.toordinal() for session in sessions]
|
|
prices = {
|
|
'AAA': _prices(ords),
|
|
'BBB': _prices(ords),
|
|
}
|
|
candidates = [
|
|
_candidate('AAA', monday, rank=10.0),
|
|
_candidate('BBB', friday, rank=99.0),
|
|
]
|
|
rank_map = {
|
|
('AAA', friday.isoformat()): {'strategy_rank': 80.0},
|
|
('BBB', friday.isoformat()): {'strategy_rank': 80.0},
|
|
}
|
|
|
|
sim = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
30,
|
|
max_positions=1,
|
|
weekly_top_n_rebalance=True,
|
|
daily_rank_map=rank_map,
|
|
measurement_start_date=monday,
|
|
hard_end_date=friday + timedelta(days=1),
|
|
include_trades=True,
|
|
)
|
|
|
|
assert sim is not None
|
|
assert [trade['symbol'] for trade in sim['trade_details']] == ['AAA']
|
|
assert sim['trade_details'][0]['reason'] == 'open_at_end'
|
|
assert sim['weekly_rebalance_events'][0]['replacements'] == 0
|
|
|
|
|
|
def test_weekly_rebalance_exit_bypasses_cooldown_and_churn_is_counted():
|
|
first_monday = date(2025, 1, 6)
|
|
friday = date(2025, 1, 10)
|
|
next_monday = date(2025, 1, 13)
|
|
sessions = _business_days(first_monday, next_monday)
|
|
ords = [session.toordinal() for session in sessions]
|
|
prices = {
|
|
'AAA': _prices(ords),
|
|
'BBB': (
|
|
ords,
|
|
[100.0] * len(ords),
|
|
[101.0] * len(ords),
|
|
[99.0] * (len(ords) - 1) + [70.0],
|
|
[100.0] * len(ords),
|
|
[1_000_000] * len(ords),
|
|
),
|
|
}
|
|
candidates = [
|
|
_candidate('AAA', first_monday, rank=99.0),
|
|
_candidate('BBB', friday, rank=10.0),
|
|
_candidate('AAA', next_monday, rank=99.0),
|
|
]
|
|
rank_map = {
|
|
('AAA', friday.isoformat()): {'strategy_rank': 10.0},
|
|
('BBB', friday.isoformat()): {'strategy_rank': 90.0},
|
|
}
|
|
|
|
sim = bt._simulate_portfolio(
|
|
candidates,
|
|
prices,
|
|
None,
|
|
'hold',
|
|
30,
|
|
max_positions=1,
|
|
reentry_cooldown_sessions=5,
|
|
weekly_top_n_rebalance=True,
|
|
daily_rank_map=rank_map,
|
|
measurement_start_date=first_monday,
|
|
hard_end_date=next_monday + timedelta(days=1),
|
|
include_trades=True,
|
|
)
|
|
|
|
assert sim is not None
|
|
assert [trade['symbol'] for trade in sim['trade_details']] == [
|
|
'AAA',
|
|
'BBB',
|
|
'AAA',
|
|
]
|
|
assert sim['trade_details'][0]['reason'] == 'weekly_rebalance'
|
|
assert sim['rebalance_reentries_within_5_sessions'] == 1
|
|
assert sim['skipped_cooldown'] == 0
|
|
|
|
|
|
def test_cohort_manifest_realizes_seven_frozen_clusters():
|
|
sessions = _business_days(date(2016, 1, 4), date(2026, 7, 17))
|
|
manifest = build_cohort_manifest(sessions)
|
|
|
|
assert validate_cohort_manifest(manifest) == []
|
|
assert manifest['empty_cluster_count'] == 7
|
|
assert manifest['warm_cluster_count'] == 7
|
|
assert set(map(int, manifest['empty_cluster_counts'])) == set(ANCHOR_YEARS)
|
|
assert all(
|
|
int(count) >= 12 for count in manifest['warm_seed_counts'].values()
|
|
)
|
|
cells = build_cells(manifest)
|
|
assert len(cells) == (
|
|
len(manifest['empty_book']) + len(manifest['warm_book'])
|
|
) * 4 * 2
|
|
|
|
|
|
def test_zero_outcome_horizon_extends_rank_replay_to_last_session(monkeypatch):
|
|
monkeypatch.setattr(bt, '_window_setups', lambda *_args, **_kwargs: [])
|
|
count = bt.MIN_LOOKBACK + bt.HORIZON
|
|
start = date(2025, 1, 1)
|
|
ords = [start.toordinal() + offset for offset in range(count)]
|
|
columns = _prices(ords)
|
|
|
|
legacy = bt._replay_candidates_for_period(
|
|
'AAA',
|
|
columns,
|
|
{},
|
|
{},
|
|
None,
|
|
date.min,
|
|
'daily',
|
|
True,
|
|
True,
|
|
)
|
|
zero_horizon = bt._replay_candidates_for_period(
|
|
'AAA',
|
|
columns,
|
|
{},
|
|
{},
|
|
None,
|
|
date.min,
|
|
'daily',
|
|
True,
|
|
True,
|
|
0,
|
|
)
|
|
|
|
assert len(zero_horizon) == len(legacy) + bt.HORIZON
|
|
assert zero_horizon[-1]['date'] == date.fromordinal(ords[-1]).isoformat()
|
|
|
|
|
|
def test_gain_to_pain_uses_all_monthly_returns_and_net_r():
|
|
sim = {
|
|
'measurement_start_equity': 100.0,
|
|
'trade_details': [
|
|
{
|
|
'net_r': 1.0,
|
|
'pnl': 10.0,
|
|
'shares': 1.0,
|
|
'entry': 100.0,
|
|
'fill': 110.0,
|
|
'transaction_cost': 0.0,
|
|
},
|
|
{
|
|
'net_r': -0.5,
|
|
'pnl': -5.0,
|
|
'shares': 1.0,
|
|
'entry': 100.0,
|
|
'fill': 95.0,
|
|
'transaction_cost': 0.0,
|
|
},
|
|
],
|
|
'equity_curve': [
|
|
{'date': '2025-01-31', 'equity': 110.0},
|
|
{'date': '2025-02-28', 'equity': 99.0},
|
|
],
|
|
'trades': 2,
|
|
'skipped_book_full': 0,
|
|
}
|
|
|
|
summary = summarize_simulation(sim)
|
|
|
|
assert summary['ev_net_r'] == pytest.approx(0.25)
|
|
assert summary['profit_factor'] == pytest.approx(2.0)
|
|
# Monthly returns are +10% and -10%; all-return numerator is zero.
|
|
assert summary['gain_to_pain'] == pytest.approx(0.0)
|
|
|
|
|
|
def test_simple_cluster_bootstrap_is_deterministic_and_not_a_gate():
|
|
first = bootstrap_median_interval(
|
|
[1, 2, 3, 4, 5, 6, 7],
|
|
seed_parts=('determinism',),
|
|
replicates=500,
|
|
)
|
|
second = bootstrap_median_interval(
|
|
[1, 2, 3, 4, 5, 6, 7],
|
|
seed_parts=('determinism',),
|
|
replicates=500,
|
|
)
|
|
|
|
assert first == second
|
|
assert first['point'] == 4
|
|
assert first['p05'] <= first['point'] <= first['p95']
|
|
|
|
|
|
def test_aggregate_reports_paired_years_and_separate_warm_iqrs():
|
|
cells: list[dict] = []
|
|
for cost in (0.1, 0.2):
|
|
for cluster in ANCHOR_YEARS:
|
|
for seed in range(3):
|
|
path_id = f'warm-{cluster}-{seed}'
|
|
for arm_id, shift in (
|
|
('cap10_incumbent', 0.0),
|
|
('cash_unbounded', 0.2),
|
|
('cap10_weekly_top10', 0.1),
|
|
('cap15_incumbent', 0.05),
|
|
):
|
|
cells.append({
|
|
'arm_id': arm_id,
|
|
'protocol': 'warm_book',
|
|
'path_id': path_id,
|
|
'cluster': cluster,
|
|
'cost_per_side_pct': cost,
|
|
'metrics': {
|
|
'ev_net_r': seed + shift,
|
|
'calmar': 1.0 + seed * 0.1 + shift,
|
|
'profit_factor': 1.5 + shift,
|
|
'gain_to_pain': 2.0 + shift,
|
|
'sortino': 1.0 + shift,
|
|
'cagr_pct': 10.0 + shift,
|
|
'max_drawdown_pct': 5.0,
|
|
'total_return_pct': 10.0 + shift,
|
|
'sharpe': 1.0 + shift,
|
|
},
|
|
})
|
|
for arm_id, shift in (
|
|
('cap10_incumbent', 0.0),
|
|
('cash_unbounded', 0.2),
|
|
('cap10_weekly_top10', 0.1),
|
|
('cap15_incumbent', 0.05),
|
|
):
|
|
cells.append({
|
|
'arm_id': arm_id,
|
|
'protocol': 'empty_book',
|
|
'path_id': f'empty-{cluster}',
|
|
'cluster': cluster,
|
|
'cost_per_side_pct': cost,
|
|
'metrics': {
|
|
'ev_net_r': 1.0 + shift,
|
|
'calmar': 2.0 + shift,
|
|
'profit_factor': 1.5 + shift,
|
|
'gain_to_pain': 2.0 + shift,
|
|
'sortino': 1.0 + shift,
|
|
'cagr_pct': 10.0 + shift,
|
|
'max_drawdown_pct': 5.0,
|
|
'total_return_pct': 10.0 + shift,
|
|
'sharpe': 1.0 + shift,
|
|
},
|
|
})
|
|
|
|
report = aggregate_results(cells)
|
|
|
|
cash_empty = next(
|
|
row
|
|
for row in report['paired_per_year']
|
|
if row['arm_id'] == 'cash_unbounded'
|
|
and row['protocol'] == 'empty_book'
|
|
and row['cost_per_side_pct'] == 0.1
|
|
)
|
|
assert cash_empty['headline']['ev_net_r']['paired_delta_median'] == pytest.approx(
|
|
0.2
|
|
)
|
|
cash_warm = next(
|
|
row
|
|
for row in report['warm_seed_dispersion']
|
|
if row['arm_id'] == 'cash_unbounded'
|
|
and row['cost_per_side_pct'] == 0.1
|
|
)
|
|
assert set(cash_warm['headline']) == {'ev_net_r', 'calmar'}
|
|
assert 'D' not in cash_warm
|
|
markdown = _markdown({
|
|
'generated_at': '2026-08-05T00:00:00Z',
|
|
'analysis': report,
|
|
'operational_summary': _operational_summary(cells),
|
|
})
|
|
assert 'ΔGain-to-Pain' in markdown
|
|
assert 'formal promotion gate' in markdown
|
|
|
|
|
|
def test_synthetic_worker_matrix_covers_four_arms_protocols_and_costs():
|
|
start = date(2025, 1, 6)
|
|
sessions = _business_days(start, date(2025, 1, 17))
|
|
ords = [session.toordinal() for session in sessions]
|
|
symbols = [f'S{index}' for index in range(12)]
|
|
prices = {symbol: _prices(ords) for symbol in symbols}
|
|
candidates = [
|
|
_candidate(symbol, start, rank=99.0 - index)
|
|
for index, symbol in enumerate(symbols[:11])
|
|
]
|
|
friday = date(2025, 1, 10)
|
|
candidates.append(_candidate('S11', friday, rank=99.0))
|
|
rank_map = {
|
|
(symbol, friday.isoformat()): {
|
|
'strategy_rank': 100.0 if symbol == 'S11' else float(index)
|
|
}
|
|
for index, symbol in enumerate(symbols)
|
|
}
|
|
_worker_init({
|
|
'qualified_candidates': candidates,
|
|
'daily_rank_map': rank_map,
|
|
'prices': prices,
|
|
'benchmark_closes': None,
|
|
'ranking_key': 'residual_high_vol_blend_80_20',
|
|
'exit_policy': 'hold',
|
|
'hold_days': 30,
|
|
'risk_per_trade': 0.01,
|
|
'atr_trail_multiplier': 3.0,
|
|
})
|
|
|
|
rows = []
|
|
for protocol, measurement_start in (
|
|
('empty_book', start),
|
|
('warm_book', date(2025, 1, 8)),
|
|
):
|
|
for cost in (0.1, 0.2):
|
|
for arm_id in (
|
|
'cap10_incumbent',
|
|
'cash_unbounded',
|
|
'cap10_weekly_top10',
|
|
'cap15_incumbent',
|
|
):
|
|
rows.append(_worker_run_cell({
|
|
'cell_id': f'{arm_id}|{protocol}|{cost}',
|
|
'arm_id': arm_id,
|
|
'protocol': protocol,
|
|
'path_id': f'{protocol}-synthetic',
|
|
'cluster': 2025,
|
|
'simulation_start': start.isoformat(),
|
|
'measurement_start': measurement_start.isoformat(),
|
|
'hard_end_exclusive': date(2025, 1, 14).isoformat(),
|
|
'cost_per_side_pct': cost,
|
|
}))
|
|
|
|
assert len(rows) == 16
|
|
assert {row['arm_id'] for row in rows} == {
|
|
'cap10_incumbent',
|
|
'cash_unbounded',
|
|
'cap10_weekly_top10',
|
|
'cap15_incumbent',
|
|
}
|
|
assert {row['protocol'] for row in rows} == {'empty_book', 'warm_book'}
|
|
assert {row['cost_per_side_pct'] for row in rows} == {0.1, 0.2}
|
|
assert all('ev_net_r' in row['metrics'] for row in rows)
|
|
|
|
|
|
def test_checkpoint_resume_rejects_fingerprint_mismatch(tmp_path):
|
|
checkpoint = tmp_path / 'checkpoint'
|
|
completed = _checkpoint_state(checkpoint, 'fingerprint-a', resume=False)
|
|
assert completed == {}
|
|
_write_cell_checkpoint(
|
|
checkpoint,
|
|
{'cell_id': 'one', 'metrics': {'ev_net_r': 1.0}},
|
|
)
|
|
resumed = _checkpoint_state(checkpoint, 'fingerprint-a', resume=True)
|
|
assert set(resumed) == {'one'}
|
|
with pytest.raises(SystemExit, match='fingerprint mismatch'):
|
|
_checkpoint_state(checkpoint, 'fingerprint-b', resume=True)
|
|
|
|
|
|
def test_dirty_worktree_guard(monkeypatch):
|
|
monkeypatch.setattr(
|
|
'scripts.run_portfolio_construction_matrix._git_output',
|
|
lambda *_args: ' M changed.py',
|
|
)
|
|
with pytest.raises(SystemExit, match='dirty worktree'):
|
|
_assert_clean_worktree()
|