research: add focused portfolio capacity matrix
This commit is contained in:
@@ -0,0 +1,665 @@
|
||||
'''Pure helpers for the focused daily portfolio-capacity research matrix.'''
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import random
|
||||
import statistics
|
||||
from collections import defaultdict
|
||||
from datetime import date, timedelta
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
ARMS: tuple[dict[str, Any], ...] = (
|
||||
{
|
||||
'id': 'cap10_incumbent',
|
||||
'label': 'Cap 10, arrival-order incumbents',
|
||||
'max_positions': 10,
|
||||
'min_initial_risk_fraction': None,
|
||||
'weekly_top_n_rebalance': False,
|
||||
},
|
||||
{
|
||||
'id': 'cash_unbounded',
|
||||
'label': 'Cash-constrained, no count cap',
|
||||
'max_positions': None,
|
||||
'min_initial_risk_fraction': 0.005,
|
||||
'weekly_top_n_rebalance': False,
|
||||
},
|
||||
{
|
||||
'id': 'cap10_weekly_top10',
|
||||
'label': 'Cap 10, weekly current-rank top 10',
|
||||
'max_positions': 10,
|
||||
'min_initial_risk_fraction': None,
|
||||
'weekly_top_n_rebalance': True,
|
||||
},
|
||||
{
|
||||
'id': 'cap15_incumbent',
|
||||
'label': 'Cap 15, arrival-order incumbents',
|
||||
'max_positions': 15,
|
||||
'min_initial_risk_fraction': None,
|
||||
'weekly_top_n_rebalance': False,
|
||||
},
|
||||
)
|
||||
|
||||
ARM_BY_ID = {arm['id']: arm for arm in ARMS}
|
||||
COSTS_PER_SIDE_PCT = (0.1, 0.2)
|
||||
ANCHOR_YEARS = tuple(range(2019, 2026))
|
||||
SCORING_SESSIONS = 504
|
||||
MEASUREMENT_SESSIONS = 252
|
||||
RESIDUAL_BENCHMARK_SESSIONS = 252
|
||||
WARM_SEED_MIN_OFFSET = 63
|
||||
WARM_SEED_MAX_OFFSET = 126
|
||||
BOOTSTRAP_REPLICATES = 10_000
|
||||
BOOTSTRAP_SEED = 20260805
|
||||
PRIMARY_METRICS = (
|
||||
'ev_net_r',
|
||||
'calmar',
|
||||
'profit_factor',
|
||||
'gain_to_pain',
|
||||
'sortino',
|
||||
)
|
||||
PAIRED_METRICS = (
|
||||
*PRIMARY_METRICS,
|
||||
'cagr_pct',
|
||||
'max_drawdown_pct',
|
||||
'total_return_pct',
|
||||
'sharpe',
|
||||
)
|
||||
|
||||
|
||||
def _end_exclusive(
|
||||
sessions: list[date], start_index: int, count: int
|
||||
) -> date:
|
||||
end_index = start_index + count
|
||||
if end_index < len(sessions):
|
||||
return sessions[end_index]
|
||||
return sessions[-1] + timedelta(days=1)
|
||||
|
||||
|
||||
def build_cohort_manifest(session_dates: Iterable[date]) -> dict[str, Any]:
|
||||
sessions = sorted(set(session_dates))
|
||||
minimum = RESIDUAL_BENCHMARK_SESSIONS + SCORING_SESSIONS
|
||||
if len(sessions) <= minimum + MEASUREMENT_SESSIONS:
|
||||
raise ValueError('Snapshot is too short for the frozen cohort design')
|
||||
|
||||
index_of = {session: index for index, session in enumerate(sessions)}
|
||||
first_eligible_index = RESIDUAL_BENCHMARK_SESSIONS - 1 + SCORING_SESSIONS
|
||||
last_eligible_index = len(sessions) - MEASUREMENT_SESSIONS
|
||||
|
||||
first_by_month: dict[tuple[int, int], date] = {}
|
||||
for session in sessions:
|
||||
first_by_month.setdefault((session.year, session.month), session)
|
||||
|
||||
empty: list[dict[str, Any]] = []
|
||||
for (year, month), session in sorted(first_by_month.items()):
|
||||
index = index_of[session]
|
||||
if year not in ANCHOR_YEARS:
|
||||
continue
|
||||
if index < first_eligible_index or index > last_eligible_index:
|
||||
continue
|
||||
empty.append({
|
||||
'protocol': 'empty_book',
|
||||
'path_id': f'empty-{year:04d}-{month:02d}',
|
||||
'cluster': year,
|
||||
'simulation_start': session.isoformat(),
|
||||
'measurement_start': session.isoformat(),
|
||||
'hard_end_exclusive': _end_exclusive(
|
||||
sessions, index, MEASUREMENT_SESSIONS
|
||||
).isoformat(),
|
||||
})
|
||||
|
||||
first_by_year: dict[int, date] = {}
|
||||
for session in sessions:
|
||||
first_by_year.setdefault(session.year, session)
|
||||
|
||||
warm: list[dict[str, Any]] = []
|
||||
warm_seed_counts: dict[str, int] = {}
|
||||
for year in ANCHOR_YEARS:
|
||||
anchor = first_by_year.get(year)
|
||||
if anchor is None:
|
||||
continue
|
||||
anchor_index = index_of[anchor]
|
||||
if (
|
||||
anchor_index < WARM_SEED_MAX_OFFSET
|
||||
or anchor_index > last_eligible_index
|
||||
):
|
||||
continue
|
||||
seed_window = sessions[
|
||||
anchor_index - WARM_SEED_MAX_OFFSET:
|
||||
anchor_index - WARM_SEED_MIN_OFFSET + 1
|
||||
]
|
||||
first_by_iso_week: dict[tuple[int, int], date] = {}
|
||||
for session in seed_window:
|
||||
iso = session.isocalendar()
|
||||
first_by_iso_week.setdefault((iso.year, iso.week), session)
|
||||
seeds = sorted(first_by_iso_week.values())
|
||||
warm_seed_counts[str(year)] = len(seeds)
|
||||
for seed_index, seed in enumerate(seeds, 1):
|
||||
warm.append({
|
||||
'protocol': 'warm_book',
|
||||
'path_id': f'warm-{year}-seed-{seed_index:02d}',
|
||||
'cluster': year,
|
||||
'simulation_start': seed.isoformat(),
|
||||
'measurement_start': anchor.isoformat(),
|
||||
'hard_end_exclusive': _end_exclusive(
|
||||
sessions, anchor_index, MEASUREMENT_SESSIONS
|
||||
).isoformat(),
|
||||
'seed_offset_sessions': anchor_index - index_of[seed],
|
||||
})
|
||||
|
||||
return {
|
||||
'snapshot_first_session': sessions[0].isoformat(),
|
||||
'snapshot_last_session': sessions[-1].isoformat(),
|
||||
'session_count': len(sessions),
|
||||
'expected_clusters': list(ANCHOR_YEARS),
|
||||
'empty_book': empty,
|
||||
'warm_book': warm,
|
||||
'empty_cluster_counts': dict(
|
||||
sorted(
|
||||
(
|
||||
str(year),
|
||||
sum(1 for row in empty if row['cluster'] == year),
|
||||
)
|
||||
for year in {row['cluster'] for row in empty}
|
||||
)
|
||||
),
|
||||
'warm_seed_counts': warm_seed_counts,
|
||||
'empty_cluster_count': len({row['cluster'] for row in empty}),
|
||||
'warm_cluster_count': len({row['cluster'] for row in warm}),
|
||||
}
|
||||
|
||||
|
||||
def validate_cohort_manifest(manifest: dict[str, Any]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
expected = set(ANCHOR_YEARS)
|
||||
empty_clusters = {row['cluster'] for row in manifest['empty_book']}
|
||||
warm_clusters = {row['cluster'] for row in manifest['warm_book']}
|
||||
if empty_clusters != expected:
|
||||
errors.append(
|
||||
f'empty-book clusters {sorted(empty_clusters)} != {sorted(expected)}'
|
||||
)
|
||||
if warm_clusters != expected:
|
||||
errors.append(
|
||||
f'warm-book clusters {sorted(warm_clusters)} != {sorted(expected)}'
|
||||
)
|
||||
for year in ANCHOR_YEARS:
|
||||
seed_count = int(manifest['warm_seed_counts'].get(str(year), 0))
|
||||
if seed_count < 12:
|
||||
errors.append(f'warm anchor {year} has only {seed_count} seeds')
|
||||
return errors
|
||||
|
||||
|
||||
def build_cells(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
paths = [*manifest['empty_book'], *manifest['warm_book']]
|
||||
cells: list[dict[str, Any]] = []
|
||||
for cost in COSTS_PER_SIDE_PCT:
|
||||
for path in paths:
|
||||
for arm in ARMS:
|
||||
cell_id = (
|
||||
f'{arm["id"]}|{path["protocol"]}|{path["path_id"]}'
|
||||
f'|cost={cost:.1f}'
|
||||
)
|
||||
cells.append({
|
||||
**path,
|
||||
'cell_id': cell_id,
|
||||
'arm_id': arm['id'],
|
||||
'cost_per_side_pct': cost,
|
||||
})
|
||||
return cells
|
||||
|
||||
|
||||
def percentile(values: Iterable[float], probability: float) -> float | None:
|
||||
ordered = sorted(float(value) for value in values if value is not None)
|
||||
if not ordered:
|
||||
return None
|
||||
if len(ordered) == 1:
|
||||
return ordered[0]
|
||||
location = (len(ordered) - 1) * probability
|
||||
lower = math.floor(location)
|
||||
upper = math.ceil(location)
|
||||
if lower == upper:
|
||||
return ordered[lower]
|
||||
weight = location - lower
|
||||
return ordered[lower] * (1.0 - weight) + ordered[upper] * weight
|
||||
|
||||
|
||||
def iqr(values: Iterable[float]) -> float | None:
|
||||
q25 = percentile(values, 0.25)
|
||||
q75 = percentile(values, 0.75)
|
||||
if q25 is None or q75 is None:
|
||||
return None
|
||||
return q75 - q25
|
||||
|
||||
|
||||
def median(values: Iterable[float | None]) -> float | None:
|
||||
clean = [float(value) for value in values if value is not None]
|
||||
return statistics.median(clean) if clean else None
|
||||
|
||||
|
||||
def _safe_ratio(numerator: float | None, denominator: float | None) -> float | None:
|
||||
if numerator is None or denominator is None:
|
||||
return None
|
||||
if abs(denominator) <= 1e-12:
|
||||
return 1.0 if abs(numerator) <= 1e-12 else None
|
||||
return numerator / denominator
|
||||
|
||||
|
||||
def _stable_seed(*parts: object) -> int:
|
||||
digest = hashlib.sha256('|'.join(map(str, parts)).encode('utf-8')).digest()
|
||||
return BOOTSTRAP_SEED + int.from_bytes(digest[:4], 'big')
|
||||
|
||||
|
||||
def bootstrap_median_interval(
|
||||
values: Iterable[float | None],
|
||||
*,
|
||||
seed_parts: tuple[object, ...],
|
||||
replicates: int = BOOTSTRAP_REPLICATES,
|
||||
) -> dict[str, float | int | None]:
|
||||
clean = [float(value) for value in values if value is not None]
|
||||
if not clean:
|
||||
return {'n': 0, 'point': None, 'p05': None, 'p95': None}
|
||||
rng = random.Random(_stable_seed(*seed_parts))
|
||||
draws = [
|
||||
statistics.median(rng.choices(clean, k=len(clean)))
|
||||
for _ in range(replicates)
|
||||
]
|
||||
return {
|
||||
'n': len(clean),
|
||||
'replicates': replicates,
|
||||
'point': statistics.median(clean),
|
||||
'p05': percentile(draws, 0.05),
|
||||
'p95': percentile(draws, 0.95),
|
||||
}
|
||||
|
||||
|
||||
def _monthly_returns(
|
||||
equity_curve: list[dict[str, Any]], base_equity: float
|
||||
) -> list[float]:
|
||||
month_ends: dict[tuple[int, int], float] = {}
|
||||
for point in equity_curve:
|
||||
point_date = date.fromisoformat(str(point['date']))
|
||||
month_ends[(point_date.year, point_date.month)] = float(point['equity'])
|
||||
previous = float(base_equity)
|
||||
returns: list[float] = []
|
||||
for month in sorted(month_ends):
|
||||
equity = month_ends[month]
|
||||
if previous > 0:
|
||||
returns.append(equity / previous - 1.0)
|
||||
previous = equity
|
||||
return returns
|
||||
|
||||
|
||||
def _time_underwater(equities: list[float]) -> tuple[int, float]:
|
||||
peak = float('-inf')
|
||||
current = 0
|
||||
longest = 0
|
||||
underwater = 0
|
||||
for equity in equities:
|
||||
peak = max(peak, equity)
|
||||
if peak > 0 and equity < peak - 1e-9:
|
||||
current += 1
|
||||
underwater += 1
|
||||
longest = max(longest, current)
|
||||
else:
|
||||
current = 0
|
||||
percentage = underwater / len(equities) * 100.0 if equities else 0.0
|
||||
return longest, percentage
|
||||
|
||||
|
||||
def summarize_simulation(sim: dict[str, Any]) -> dict[str, Any]:
|
||||
trades = list(sim.get('trade_details') or [])
|
||||
equity_curve = list(sim.get('equity_curve') or [])
|
||||
net_rs = [float(trade['net_r']) for trade in trades]
|
||||
positive_rs = [value for value in net_rs if value > 0]
|
||||
negative_rs = [value for value in net_rs if value < 0]
|
||||
ev_net_r = statistics.fmean(net_rs) if net_rs else None
|
||||
profit_factor = (
|
||||
sum(positive_rs) / abs(sum(negative_rs))
|
||||
if negative_rs
|
||||
else None
|
||||
)
|
||||
|
||||
base_equity = float(
|
||||
sim.get('measurement_start_equity') or sim.get('starting_capital') or 0.0
|
||||
)
|
||||
curve_equities = [float(point['equity']) for point in equity_curve]
|
||||
daily_equities = [base_equity, *curve_equities]
|
||||
daily_returns = [
|
||||
current / previous - 1.0
|
||||
for previous, current in zip(daily_equities, daily_equities[1:])
|
||||
if previous > 0
|
||||
]
|
||||
downside_deviation = (
|
||||
math.sqrt(
|
||||
statistics.fmean(min(value, 0.0) ** 2 for value in daily_returns)
|
||||
)
|
||||
if daily_returns
|
||||
else None
|
||||
)
|
||||
sortino = (
|
||||
statistics.fmean(daily_returns) / downside_deviation * math.sqrt(252.0)
|
||||
if downside_deviation is not None and downside_deviation > 0
|
||||
else None
|
||||
)
|
||||
monthly_returns = _monthly_returns(equity_curve, base_equity)
|
||||
negative_monthly = sum(value for value in monthly_returns if value < 0)
|
||||
gain_to_pain = (
|
||||
sum(monthly_returns) / abs(negative_monthly)
|
||||
if negative_monthly < 0
|
||||
else None
|
||||
)
|
||||
longest_underwater, underwater_pct = _time_underwater(daily_equities)
|
||||
|
||||
transaction_cost = sum(
|
||||
float(trade.get('transaction_cost') or 0.0) for trade in trades
|
||||
)
|
||||
traded_notional = sum(
|
||||
float(trade.get('shares') or 0.0)
|
||||
* (float(trade.get('entry') or 0.0) + float(trade.get('fill') or 0.0))
|
||||
for trade in trades
|
||||
)
|
||||
turnover_multiple = (
|
||||
traded_notional / base_equity if base_equity > 0 else None
|
||||
)
|
||||
|
||||
ordered_rs = sorted(net_rs, reverse=True)
|
||||
ev_without_best: dict[str, float | None] = {}
|
||||
for count in (1, 5, 10):
|
||||
remaining = ordered_rs[count:]
|
||||
ev_without_best[str(count)] = (
|
||||
statistics.fmean(remaining) if remaining else None
|
||||
)
|
||||
|
||||
events = list(sim.get('weekly_rebalance_events') or [])
|
||||
entrant_sizes = [int(event['fresh_entrant_pool']) for event in events]
|
||||
eligible_sizes = [
|
||||
int(event['rank_eligible_entrant_pool']) for event in events
|
||||
]
|
||||
replacements = [int(event['replacements']) for event in events]
|
||||
|
||||
capacity_skips = int(
|
||||
sim.get('measurement_skipped_book_full', sim.get('skipped_book_full', 0))
|
||||
)
|
||||
opened = int(sim.get('opened_positions', sim.get('trades', 0)))
|
||||
capacity_opportunities = opened + capacity_skips
|
||||
|
||||
result = {
|
||||
'start_date': sim.get('start_date'),
|
||||
'end_date': sim.get('end_date'),
|
||||
'simulation_start_date': sim.get('simulation_start_date'),
|
||||
'measurement_start_equity': base_equity,
|
||||
'measurement_start_positions': sim.get('measurement_start_positions', 0),
|
||||
'trades': len(trades),
|
||||
'ev_net_r': ev_net_r,
|
||||
'profit_factor': profit_factor,
|
||||
'gain_to_pain': gain_to_pain,
|
||||
'sortino': sortino,
|
||||
'ev_without_best': ev_without_best,
|
||||
'total_return_pct': sim.get('total_return_pct'),
|
||||
'cagr_pct': sim.get('cagr_pct'),
|
||||
'max_drawdown_pct': sim.get('max_drawdown_pct'),
|
||||
'calmar': sim.get('calmar'),
|
||||
'sharpe': sim.get('sharpe'),
|
||||
'win_rate': sim.get('win_rate'),
|
||||
'avg_hold_days': sim.get('avg_hold_days'),
|
||||
'longest_underwater_sessions': longest_underwater,
|
||||
'underwater_pct': underwater_pct,
|
||||
'transaction_cost': transaction_cost,
|
||||
'turnover_multiple': turnover_multiple,
|
||||
'skipped_book_full': capacity_skips,
|
||||
'opened_positions': opened,
|
||||
'capacity_opportunities': capacity_opportunities,
|
||||
'blocked_fraction': (
|
||||
capacity_skips / capacity_opportunities
|
||||
if capacity_opportunities
|
||||
else 0.0
|
||||
),
|
||||
'skipped_min_initial_risk': int(
|
||||
sim.get('measurement_skipped_min_initial_risk', 0)
|
||||
),
|
||||
'avg_positions': sim.get('avg_positions'),
|
||||
'peak_positions': sim.get('peak_positions'),
|
||||
'sessions_at_capacity': sim.get('sessions_at_capacity'),
|
||||
'sessions_measured': sim.get('sessions_measured'),
|
||||
'avg_cash_pct': sim.get('avg_cash_pct'),
|
||||
'avg_gross_exposure_pct': sim.get('avg_gross_exposure_pct'),
|
||||
'exit_reasons': sim.get('exit_reasons'),
|
||||
}
|
||||
if events:
|
||||
result['weekly_rebalance'] = {
|
||||
'events': len(events),
|
||||
'zero_entrant_fraction': (
|
||||
sum(1 for value in entrant_sizes if value == 0) / len(events)
|
||||
),
|
||||
'entrant_pool_mean': statistics.fmean(entrant_sizes),
|
||||
'entrant_pool_median': statistics.median(entrant_sizes),
|
||||
'entrant_pool_p90': percentile(entrant_sizes, 0.9),
|
||||
'eligible_pool_mean': statistics.fmean(eligible_sizes),
|
||||
'replacements': sum(replacements),
|
||||
'weekly_rank_rejected_entries': int(
|
||||
sim.get('weekly_rank_rejected_entries', 0)
|
||||
),
|
||||
'reentries_within_5_sessions': int(
|
||||
sim.get('rebalance_reentries_within_5_sessions', 0)
|
||||
),
|
||||
'reentries_within_10_sessions': int(
|
||||
sim.get('rebalance_reentries_within_10_sessions', 0)
|
||||
),
|
||||
'reentries_within_20_sessions': int(
|
||||
sim.get('rebalance_reentries_within_20_sessions', 0)
|
||||
),
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def _cluster_rows(
|
||||
cells: list[dict[str, Any]],
|
||||
*,
|
||||
arm_id: str,
|
||||
protocol: str,
|
||||
cost: float,
|
||||
) -> list[dict[str, Any]]:
|
||||
treatment = {
|
||||
row['path_id']: row
|
||||
for row in cells
|
||||
if row['arm_id'] == arm_id
|
||||
and row['protocol'] == protocol
|
||||
and float(row['cost_per_side_pct']) == cost
|
||||
}
|
||||
control = {
|
||||
row['path_id']: row
|
||||
for row in cells
|
||||
if row['arm_id'] == 'cap10_incumbent'
|
||||
and row['protocol'] == protocol
|
||||
and float(row['cost_per_side_pct']) == cost
|
||||
}
|
||||
shared_paths = sorted(set(treatment) & set(control))
|
||||
by_cluster: dict[int, list[tuple[dict, dict]]] = defaultdict(list)
|
||||
for path_id in shared_paths:
|
||||
row = treatment[path_id]
|
||||
by_cluster[int(row['cluster'])].append((row, control[path_id]))
|
||||
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for cluster, pairs in sorted(by_cluster.items()):
|
||||
metrics: dict[str, Any] = {}
|
||||
for metric in PAIRED_METRICS:
|
||||
arm_values = [
|
||||
pair[0]['metrics'].get(metric)
|
||||
for pair in pairs
|
||||
if pair[0]['metrics'].get(metric) is not None
|
||||
and math.isfinite(float(pair[0]['metrics'][metric]))
|
||||
]
|
||||
control_values = [
|
||||
pair[1]['metrics'].get(metric)
|
||||
for pair in pairs
|
||||
if pair[1]['metrics'].get(metric) is not None
|
||||
and math.isfinite(float(pair[1]['metrics'][metric]))
|
||||
]
|
||||
deltas = [
|
||||
float(arm['metrics'][metric])
|
||||
- float(base['metrics'][metric])
|
||||
for arm, base in pairs
|
||||
if arm['metrics'].get(metric) is not None
|
||||
and base['metrics'].get(metric) is not None
|
||||
and math.isfinite(float(arm['metrics'][metric]))
|
||||
and math.isfinite(float(base['metrics'][metric]))
|
||||
]
|
||||
arm_median = median(arm_values)
|
||||
control_median = median(control_values)
|
||||
metrics[metric] = {
|
||||
'arm_median': arm_median,
|
||||
'control_median': control_median,
|
||||
'paired_delta_median': median(deltas),
|
||||
'arm_control_ratio': _safe_ratio(
|
||||
arm_median, control_median
|
||||
),
|
||||
'paired_paths': len(deltas),
|
||||
}
|
||||
summaries.append({
|
||||
'cluster': cluster,
|
||||
'paths': len(pairs),
|
||||
'metrics': metrics,
|
||||
})
|
||||
return summaries
|
||||
|
||||
|
||||
def aggregate_results(cells: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
paired: list[dict[str, Any]] = []
|
||||
for cost in COSTS_PER_SIDE_PCT:
|
||||
for protocol in ('empty_book', 'warm_book'):
|
||||
for arm in ARMS:
|
||||
arm_id = str(arm['id'])
|
||||
clusters = _cluster_rows(
|
||||
cells,
|
||||
arm_id=arm_id,
|
||||
protocol=protocol,
|
||||
cost=float(cost),
|
||||
)
|
||||
headline: dict[str, Any] = {}
|
||||
for metric in PAIRED_METRICS:
|
||||
deltas = [
|
||||
cluster['metrics'][metric]['paired_delta_median']
|
||||
for cluster in clusters
|
||||
]
|
||||
arm_levels = [
|
||||
cluster['metrics'][metric]['arm_median']
|
||||
for cluster in clusters
|
||||
]
|
||||
control_levels = [
|
||||
cluster['metrics'][metric]['control_median']
|
||||
for cluster in clusters
|
||||
]
|
||||
arm_level = median(arm_levels)
|
||||
control_level = median(control_levels)
|
||||
metric_summary: dict[str, Any] = {
|
||||
'paired_delta_median': median(deltas),
|
||||
'arm_median': arm_level,
|
||||
'control_median': control_level,
|
||||
'arm_control_ratio': _safe_ratio(
|
||||
arm_level, control_level
|
||||
),
|
||||
}
|
||||
if metric in ('ev_net_r', 'calmar'):
|
||||
metric_summary['bootstrap_90'] = (
|
||||
bootstrap_median_interval(
|
||||
deltas,
|
||||
seed_parts=(
|
||||
arm_id,
|
||||
protocol,
|
||||
cost,
|
||||
metric,
|
||||
'paired-delta',
|
||||
),
|
||||
)
|
||||
)
|
||||
headline[metric] = metric_summary
|
||||
paired.append({
|
||||
'arm_id': arm_id,
|
||||
'protocol': protocol,
|
||||
'cost_per_side_pct': cost,
|
||||
'clusters': clusters,
|
||||
'headline': headline,
|
||||
})
|
||||
|
||||
warm_rows = [
|
||||
row for row in cells if row['protocol'] == 'warm_book'
|
||||
]
|
||||
warm_dispersion: list[dict[str, Any]] = []
|
||||
for cost in COSTS_PER_SIDE_PCT:
|
||||
for arm in ARMS:
|
||||
arm_id = str(arm['id'])
|
||||
anchor_rows: list[dict[str, Any]] = []
|
||||
for cluster in ANCHOR_YEARS:
|
||||
arm_paths = [
|
||||
row
|
||||
for row in warm_rows
|
||||
if row['arm_id'] == arm_id
|
||||
and int(row['cluster']) == cluster
|
||||
and float(row['cost_per_side_pct']) == float(cost)
|
||||
]
|
||||
control_by_path = {
|
||||
row['path_id']: row
|
||||
for row in warm_rows
|
||||
if row['arm_id'] == 'cap10_incumbent'
|
||||
and int(row['cluster']) == cluster
|
||||
and float(row['cost_per_side_pct']) == float(cost)
|
||||
}
|
||||
metric_rows: dict[str, Any] = {}
|
||||
for metric in ('ev_net_r', 'calmar'):
|
||||
arm_spread = iqr(
|
||||
row['metrics'].get(metric) for row in arm_paths
|
||||
)
|
||||
control_spread = iqr(
|
||||
control_by_path[row['path_id']]['metrics'].get(metric)
|
||||
for row in arm_paths
|
||||
if row['path_id'] in control_by_path
|
||||
)
|
||||
metric_rows[metric] = {
|
||||
'arm_iqr': arm_spread,
|
||||
'control_iqr': control_spread,
|
||||
'iqr_ratio': _safe_ratio(
|
||||
arm_spread, control_spread
|
||||
),
|
||||
}
|
||||
anchor_rows.append({
|
||||
'cluster': cluster,
|
||||
'seeds': len(arm_paths),
|
||||
'metrics': metric_rows,
|
||||
})
|
||||
|
||||
headline: dict[str, Any] = {}
|
||||
for metric in ('ev_net_r', 'calmar'):
|
||||
ratios = [
|
||||
row['metrics'][metric]['iqr_ratio']
|
||||
for row in anchor_rows
|
||||
]
|
||||
headline[metric] = {
|
||||
'median_iqr_ratio': median(ratios),
|
||||
'bootstrap_90': bootstrap_median_interval(
|
||||
ratios,
|
||||
seed_parts=(
|
||||
arm_id,
|
||||
cost,
|
||||
metric,
|
||||
'warm-iqr-ratio',
|
||||
),
|
||||
),
|
||||
}
|
||||
warm_dispersion.append({
|
||||
'arm_id': arm_id,
|
||||
'cost_per_side_pct': cost,
|
||||
'anchors': anchor_rows,
|
||||
'headline': headline,
|
||||
})
|
||||
|
||||
return {
|
||||
'paired_per_year': paired,
|
||||
'warm_seed_dispersion': warm_dispersion,
|
||||
'bootstrap': {
|
||||
'replicates': BOOTSTRAP_REPLICATES,
|
||||
'seed': BOOTSTRAP_SEED,
|
||||
'interval': 'central 90% percentile, context only',
|
||||
'resampling_unit': 'seven annual paired summaries',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
'''Shared production-style historical ranking helpers for research runners.'''
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
def _period_percentiles(
|
||||
observations: list[dict], value_key: str
|
||||
) -> dict[tuple[str, str], float]:
|
||||
'''Rank one deterministic ticker observation per historical period.'''
|
||||
by_period: dict[tuple, list[dict]] = {}
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for row in observations:
|
||||
identity = (str(row['symbol']), str(row['date']))
|
||||
if identity in seen:
|
||||
raise ValueError(f'Duplicate universe rank observation: {identity}')
|
||||
seen.add(identity)
|
||||
if row.get(value_key) is None:
|
||||
continue
|
||||
period = tuple(row['ranking_period'])
|
||||
by_period.setdefault(period, []).append(row)
|
||||
|
||||
result: dict[tuple[str, str], float] = {}
|
||||
for group in by_period.values():
|
||||
ordered = sorted(
|
||||
group,
|
||||
key=lambda row: (float(row[value_key]), str(row['symbol'])),
|
||||
)
|
||||
denominator = len(ordered) - 1
|
||||
for rank, row in enumerate(ordered):
|
||||
result[(str(row['symbol']), str(row['date']))] = round(
|
||||
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||
2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _live_universe_rank_map(
|
||||
observations: list[dict],
|
||||
benchmark_closes: dict[date, float],
|
||||
momentum_weight: float,
|
||||
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||
'''Historical equivalent of production compute_activation_ranks.
|
||||
|
||||
Every ticker contributes at most once per session. Residual momentum starts
|
||||
only once 252 benchmark closes were point-in-time available; earlier dates
|
||||
use the same raw-momentum fallback as production.
|
||||
'''
|
||||
identities = [(str(row['symbol']), str(row['date'])) for row in observations]
|
||||
if len(identities) != len(set(identities)):
|
||||
raise ValueError('Universe ranking requires one observation per ticker/date')
|
||||
|
||||
raw_pct = _period_percentiles(observations, 'momentum')
|
||||
residual_pct = _period_percentiles(observations, 'residual_momentum')
|
||||
vol_pct = _period_percentiles(observations, 'vol_6m')
|
||||
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||
|
||||
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||
for row in observations:
|
||||
identity = (str(row['symbol']), str(row['date']))
|
||||
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||
momentum_pct = (
|
||||
residual_pct.get(identity)
|
||||
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||
else raw_pct.get(identity)
|
||||
)
|
||||
volatility_pct = vol_pct.get(identity)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * momentum_weight
|
||||
+ volatility_pct * (1.0 - momentum_weight),
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and volatility_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[identity] = {
|
||||
'momentum_percentile': momentum_pct,
|
||||
'volatility_percentile': volatility_pct,
|
||||
'strategy_rank': strategy_rank,
|
||||
}
|
||||
return ranks
|
||||
@@ -29,6 +29,11 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
_period_percentiles,
|
||||
)
|
||||
|
||||
POLICY_NAMES = (
|
||||
"immediate",
|
||||
"next_session",
|
||||
@@ -107,85 +112,6 @@ def _default_output_path() -> Path:
|
||||
return Path("reports") / f"daily-reentry-matrix-{stamp}.json"
|
||||
|
||||
|
||||
def _period_percentiles(
|
||||
observations: list[dict], value_key: str
|
||||
) -> dict[tuple[str, str], float]:
|
||||
"""Production-style percentiles, one deterministic symbol row per period."""
|
||||
by_period: dict[tuple, list[dict]] = {}
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
if identity in seen:
|
||||
raise ValueError(f"Duplicate universe rank observation: {identity}")
|
||||
seen.add(identity)
|
||||
if row.get(value_key) is None:
|
||||
continue
|
||||
period = tuple(row["ranking_period"])
|
||||
by_period.setdefault(period, []).append(row)
|
||||
|
||||
result: dict[tuple[str, str], float] = {}
|
||||
for group in by_period.values():
|
||||
ordered = sorted(
|
||||
group,
|
||||
key=lambda row: (float(row[value_key]), str(row["symbol"])),
|
||||
)
|
||||
denominator = len(ordered) - 1
|
||||
for rank, row in enumerate(ordered):
|
||||
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||
2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _live_universe_rank_map(
|
||||
observations: list[dict],
|
||||
benchmark_closes: dict[date, float],
|
||||
momentum_weight: float,
|
||||
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||
"""Historical equivalent of ``compute_activation_ranks``.
|
||||
|
||||
Every ticker contributes at most once per session. Residual momentum starts
|
||||
only once 252 benchmark closes were point-in-time available; earlier dates
|
||||
use the same raw-momentum fallback as production.
|
||||
"""
|
||||
identities = [(str(row["symbol"]), str(row["date"])) for row in observations]
|
||||
if len(identities) != len(set(identities)):
|
||||
raise ValueError("Universe ranking requires one observation per ticker/date")
|
||||
|
||||
raw_pct = _period_percentiles(observations, "momentum")
|
||||
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||
|
||||
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||
momentum_pct = (
|
||||
residual_pct.get(identity)
|
||||
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||
else raw_pct.get(identity)
|
||||
)
|
||||
volatility_pct = vol_pct.get(identity)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * momentum_weight
|
||||
+ volatility_pct * (1.0 - momentum_weight),
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and volatility_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[identity] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": volatility_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
}
|
||||
return ranks
|
||||
|
||||
|
||||
class PrecomputedDailyEngine:
|
||||
"""Exact date/symbol lookup over the already-ranked production gate."""
|
||||
|
||||
|
||||
@@ -55,6 +55,11 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
_period_percentiles,
|
||||
)
|
||||
|
||||
# Must match Phase A cache when reusing research-cands.pkl
|
||||
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
||||
|
||||
@@ -104,66 +109,6 @@ def _parse_args() -> argparse.Namespace:
|
||||
return p.parse_args()
|
||||
|
||||
|
||||
def _period_percentiles(
|
||||
observations: list[dict], value_key: str
|
||||
) -> dict[tuple[str, str], float]:
|
||||
by_period: dict[tuple, list[dict]] = {}
|
||||
for row in observations:
|
||||
if row.get(value_key) is None:
|
||||
continue
|
||||
period = tuple(row["ranking_period"])
|
||||
by_period.setdefault(period, []).append(row)
|
||||
result: dict[tuple[str, str], float] = {}
|
||||
for group in by_period.values():
|
||||
ordered = sorted(
|
||||
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
|
||||
)
|
||||
denominator = len(ordered) - 1
|
||||
for rank, row in enumerate(ordered):
|
||||
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||
2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _live_universe_rank_map(
|
||||
observations: list[dict],
|
||||
benchmark_closes: dict[date, float],
|
||||
momentum_weight: float,
|
||||
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||
raw_pct = _period_percentiles(observations, "momentum")
|
||||
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||
momentum_pct = (
|
||||
residual_pct.get(identity)
|
||||
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||
else raw_pct.get(identity)
|
||||
)
|
||||
volatility_pct = vol_pct.get(identity)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * momentum_weight
|
||||
+ volatility_pct * (1.0 - momentum_weight),
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and volatility_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[identity] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": volatility_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
}
|
||||
return ranks
|
||||
|
||||
|
||||
def _window(arm: dict, name: str) -> dict | None:
|
||||
for row in arm.get("windows") or []:
|
||||
if row.get("window") == name:
|
||||
|
||||
@@ -0,0 +1,952 @@
|
||||
'''Run the focused four-arm daily portfolio-capacity research matrix.
|
||||
|
||||
The expensive point-in-time daily replay and full-universe ranks are cached
|
||||
once. Empty-book monthly paths and warm-book weekly seeds are then evaluated
|
||||
under cap 10, cap 15, cash-only unbounded, and weekly current-rank top 10.
|
||||
'''
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import multiprocessing
|
||||
import os
|
||||
import pickle
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from datetime import date, datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.portfolio_capacity_research import ( # noqa: E402
|
||||
ANCHOR_YEARS,
|
||||
ARM_BY_ID,
|
||||
ARMS,
|
||||
BOOTSTRAP_REPLICATES,
|
||||
BOOTSTRAP_SEED,
|
||||
COSTS_PER_SIDE_PCT,
|
||||
aggregate_results,
|
||||
build_cells,
|
||||
build_cohort_manifest,
|
||||
median,
|
||||
summarize_simulation,
|
||||
validate_cohort_manifest,
|
||||
)
|
||||
from scripts.research_rankings import _live_universe_rank_map # noqa: E402
|
||||
|
||||
|
||||
CACHE_VERSION = 'portfolio-capacity-candidates-v1-zero-horizon'
|
||||
RUNNER_VERSION = 'portfolio-capacity-bracket-v1'
|
||||
SPEC_PATH = ROOT / 'docs' / 'research' / 'portfolio-capacity-bracket.md'
|
||||
DEFAULT_RUN_ID = 'prod505-capacity-bracket-daily-v1'
|
||||
_WORKER_CONTEXT: dict[str, Any] | None = None
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('snapshot', help='SQLite backtest snapshot')
|
||||
parser.add_argument('--run-id', default=DEFAULT_RUN_ID)
|
||||
parser.add_argument(
|
||||
'--workers',
|
||||
default='auto',
|
||||
help='Worker count or auto',
|
||||
)
|
||||
parser.add_argument('--resume', action='store_true')
|
||||
parser.add_argument('--validate-only', action='store_true')
|
||||
parser.add_argument('--candidate-cache', default=None)
|
||||
parser.add_argument('--checkpoint', default=None)
|
||||
parser.add_argument('--out', default=None)
|
||||
parser.add_argument('--quiet', action='store_true')
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _worker_count(raw: str) -> int:
|
||||
if str(raw).lower() == 'auto':
|
||||
return max(1, min(6, (multiprocessing.cpu_count() or 2) - 1))
|
||||
value = int(raw)
|
||||
if value <= 0:
|
||||
raise ValueError('--workers must be positive or auto')
|
||||
return value
|
||||
|
||||
|
||||
def _sqlite_url(path: Path) -> str:
|
||||
return f'sqlite+aiosqlite:///{path.resolve().as_posix()}'
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open('rb') as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b''):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _json_hash(value: Any) -> str:
|
||||
payload = json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(',', ':'),
|
||||
default=str,
|
||||
).encode('utf-8')
|
||||
return hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def _atomic_json(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + '.tmp')
|
||||
with temporary.open('w', encoding='utf-8', newline='\n') as handle:
|
||||
json.dump(value, handle, indent=2, sort_keys=True, allow_nan=False)
|
||||
handle.write('\n')
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _atomic_text(path: Path, value: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + '.tmp')
|
||||
with temporary.open('w', encoding='utf-8', newline='\n') as handle:
|
||||
handle.write(value.rstrip() + '\n')
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _atomic_pickle(path: Path, value: Any) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temporary = path.with_suffix(path.suffix + '.tmp')
|
||||
with temporary.open('wb') as handle:
|
||||
pickle.dump(value, handle, protocol=pickle.HIGHEST_PROTOCOL)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _git_output(*args: str) -> str:
|
||||
completed = subprocess.run(
|
||||
['git', *args],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return completed.stdout.strip()
|
||||
|
||||
|
||||
def _assert_clean_worktree() -> None:
|
||||
dirty = _git_output('status', '--porcelain')
|
||||
if dirty:
|
||||
raise SystemExit(
|
||||
'Authoritative research refuses a dirty worktree; commit first:\n'
|
||||
+ dirty
|
||||
)
|
||||
|
||||
|
||||
def _worker_init(context: dict[str, Any]) -> None:
|
||||
global _WORKER_CONTEXT
|
||||
os.environ['BACKTEST_SNAPSHOT_OFFLINE'] = '1'
|
||||
os.environ['BACKTEST_ALLOW_SPAWN'] = '1'
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
context = dict(context)
|
||||
context['post_stop_reentry_fn'] = bt._make_gate_reset_reentry_fn(
|
||||
context['qualified_candidates'],
|
||||
context['prices'],
|
||||
cadence='daily',
|
||||
ranking_key=context['ranking_key'],
|
||||
evaluation_horizon_sessions=0,
|
||||
)
|
||||
_WORKER_CONTEXT = context
|
||||
|
||||
|
||||
def _worker_run_cell(cell: dict[str, Any]) -> dict[str, Any]:
|
||||
if _WORKER_CONTEXT is None:
|
||||
raise RuntimeError('Portfolio-capacity worker was not initialized')
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
context = _WORKER_CONTEXT
|
||||
arm = ARM_BY_ID[str(cell['arm_id'])]
|
||||
measurement_start = date.fromisoformat(str(cell['measurement_start']))
|
||||
hard_end = date.fromisoformat(str(cell['hard_end_exclusive']))
|
||||
sim = bt._simulate_portfolio(
|
||||
context['qualified_candidates'],
|
||||
context['prices'],
|
||||
context['benchmark_closes'],
|
||||
context['exit_policy'],
|
||||
context['hold_days'],
|
||||
ranking_key=context['ranking_key'],
|
||||
max_positions=arm['max_positions'],
|
||||
risk_per_trade=context['risk_per_trade'],
|
||||
atr_trail_multiplier=context['atr_trail_multiplier'],
|
||||
cost_per_side=float(cell['cost_per_side_pct']) / 100.0,
|
||||
post_stop_reentry_fn=context['post_stop_reentry_fn'],
|
||||
start_date=date.fromisoformat(str(cell['simulation_start'])),
|
||||
end_date=hard_end,
|
||||
measurement_start_date=measurement_start,
|
||||
hard_end_date=hard_end,
|
||||
fill_mode=bt.FILL_MODE_CLOSE,
|
||||
min_initial_risk_fraction=arm['min_initial_risk_fraction'],
|
||||
weekly_top_n_rebalance=bool(arm['weekly_top_n_rebalance']),
|
||||
daily_rank_map=(
|
||||
context['daily_rank_map']
|
||||
if arm['weekly_top_n_rebalance']
|
||||
else None
|
||||
),
|
||||
include_curve=True,
|
||||
include_trades=True,
|
||||
include_capacity_diagnostics=True,
|
||||
)
|
||||
if sim is None:
|
||||
raise RuntimeError(f'Cell produced no trades: {cell["cell_id"]}')
|
||||
return {
|
||||
**cell,
|
||||
'metrics': summarize_simulation(sim),
|
||||
}
|
||||
|
||||
|
||||
async def _load_snapshot(
|
||||
snapshot: Path,
|
||||
*,
|
||||
quiet: bool,
|
||||
) -> dict[str, Any]:
|
||||
from app.models.ticker import Ticker
|
||||
from app.services import backtest_service as bt
|
||||
from app.services.admin_service import get_activation_config
|
||||
from app.services.paper_trade_service import get_exit_policy
|
||||
from app.services.recommendation_service import get_recommendation_config
|
||||
|
||||
engine = create_async_engine(_sqlite_url(snapshot), pool_pre_ping=True)
|
||||
Session = async_sessionmaker(
|
||||
engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
try:
|
||||
async with Session() as db:
|
||||
recommendation_config = await get_recommendation_config(db)
|
||||
activation = await get_activation_config(db)
|
||||
exit_config = await get_exit_policy(db)
|
||||
benchmark_closes = await bt._load_benchmark_closes_for_backtest(
|
||||
db,
|
||||
days=None,
|
||||
refresh=False,
|
||||
)
|
||||
ticker_result = await db.execute(
|
||||
select(Ticker).order_by(Ticker.symbol)
|
||||
)
|
||||
symbols = [
|
||||
ticker.symbol for ticker in ticker_result.scalars().all()
|
||||
]
|
||||
prices: dict[str, tuple] = {}
|
||||
for index, symbol in enumerate(symbols, 1):
|
||||
columns = await bt._fetch_columns(db, symbol)
|
||||
if columns is not None:
|
||||
prices[symbol] = columns
|
||||
if not quiet and index % 50 == 0:
|
||||
print(
|
||||
f'loaded prices: {index}/{len(symbols)}',
|
||||
flush=True,
|
||||
)
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
if not prices or not benchmark_closes:
|
||||
raise SystemExit('Snapshot has no usable prices or benchmark history')
|
||||
|
||||
strategy = next(
|
||||
row
|
||||
for row in bt.PORTFOLIO_MONITOR_STRATEGIES
|
||||
if row.get('is_production')
|
||||
)
|
||||
entry_config = bt._entry_variant_config(str(strategy['entry_variant']))
|
||||
if entry_config is None:
|
||||
raise RuntimeError('Production entry configuration is missing')
|
||||
ranking_key = str(
|
||||
entry_config.get('ranking_key') or entry_config['percentile_key']
|
||||
)
|
||||
exit_policy = bt.LIVE_EXIT_MODE_TO_SIM.get(
|
||||
str(exit_config.get('mode', 'atr_trailing')),
|
||||
'atr_trail3',
|
||||
)
|
||||
hold_days = int(exit_config.get('hold_days', 30))
|
||||
risk_per_trade = float(entry_config['risk_per_trade'])
|
||||
atr_trail_multiplier = float(
|
||||
exit_config.get('atr_multiplier', bt.ATR_TRAIL_MULTIPLIER)
|
||||
)
|
||||
threshold = float(
|
||||
activation.get('min_momentum_percentile', 80.0)
|
||||
)
|
||||
expected = {
|
||||
'ranking_key': bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY,
|
||||
'exit_policy': 'atr_trail3',
|
||||
'hold_days': 30,
|
||||
'risk_per_trade': 0.01,
|
||||
'max_positions': 10,
|
||||
'threshold': 80.0,
|
||||
}
|
||||
realized = {
|
||||
'ranking_key': ranking_key,
|
||||
'exit_policy': exit_policy,
|
||||
'hold_days': hold_days,
|
||||
'risk_per_trade': risk_per_trade,
|
||||
'max_positions': int(entry_config['max_positions']),
|
||||
'threshold': threshold,
|
||||
}
|
||||
if realized != expected:
|
||||
raise SystemExit(
|
||||
'Snapshot runtime configuration is not the frozen Phase A control: '
|
||||
+ json.dumps({'expected': expected, 'realized': realized}, sort_keys=True)
|
||||
)
|
||||
|
||||
return {
|
||||
'recommendation_config': recommendation_config,
|
||||
'activation': activation,
|
||||
'exit_config': exit_config,
|
||||
'benchmark_closes': benchmark_closes,
|
||||
'prices': prices,
|
||||
'symbols': symbols,
|
||||
'universe_manifest': {
|
||||
'ticker_rows': len(symbols),
|
||||
'symbols_with_prices': len(prices),
|
||||
'symbols_sha256': _json_hash(sorted(symbols)),
|
||||
},
|
||||
'ranking_key': ranking_key,
|
||||
'exit_policy': exit_policy,
|
||||
'hold_days': hold_days,
|
||||
'risk_per_trade': risk_per_trade,
|
||||
'atr_trail_multiplier': atr_trail_multiplier,
|
||||
'threshold': threshold,
|
||||
'runtime_config': realized,
|
||||
}
|
||||
|
||||
|
||||
def _build_candidate_cache(
|
||||
snapshot_data: dict[str, Any],
|
||||
*,
|
||||
snapshot: Path,
|
||||
snapshot_sha256: str,
|
||||
cache_path: Path,
|
||||
workers: int,
|
||||
quiet: bool,
|
||||
) -> dict[str, Any]:
|
||||
from app.services import backtest_service as bt
|
||||
|
||||
cache_key = {
|
||||
'version': CACHE_VERSION,
|
||||
'snapshot': str(snapshot.resolve()),
|
||||
'snapshot_sha256': snapshot_sha256,
|
||||
'cadence': 'daily',
|
||||
'outcome_horizon_sessions': 0,
|
||||
'recommendation_config_hash': _json_hash(
|
||||
snapshot_data['recommendation_config']
|
||||
),
|
||||
'activation_hash': _json_hash(snapshot_data['activation']),
|
||||
'runtime_config': snapshot_data['runtime_config'],
|
||||
'universe_manifest': snapshot_data['universe_manifest'],
|
||||
}
|
||||
if cache_path.exists():
|
||||
with cache_path.open('rb') as handle:
|
||||
cached = pickle.load(handle) # noqa: S301 - trusted local cache
|
||||
if cached.get('key') == cache_key:
|
||||
if not quiet:
|
||||
print(f'loaded candidate/rank cache: {cache_path}', flush=True)
|
||||
return cached
|
||||
if not quiet:
|
||||
print(f'candidate cache mismatch; rebuilding: {cache_path}', flush=True)
|
||||
|
||||
replay_rows: list[dict[str, Any]] = []
|
||||
prices = snapshot_data['prices']
|
||||
replay_args = [
|
||||
(
|
||||
symbol,
|
||||
columns,
|
||||
snapshot_data['recommendation_config'],
|
||||
snapshot_data['activation'],
|
||||
snapshot_data['benchmark_closes'],
|
||||
date(1900, 1, 1),
|
||||
'daily',
|
||||
True,
|
||||
True,
|
||||
0,
|
||||
)
|
||||
for symbol, columns in prices.items()
|
||||
]
|
||||
if workers == 1:
|
||||
for index, args in enumerate(replay_args, 1):
|
||||
replay_rows.extend(bt._replay_candidates_for_period(*args))
|
||||
if not quiet and index % 25 == 0:
|
||||
print(
|
||||
f'daily replay: {index}/{len(replay_args)} tickers',
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
context = bt._mp_context() or multiprocessing.get_context('spawn')
|
||||
with ProcessPoolExecutor(
|
||||
max_workers=workers,
|
||||
mp_context=context,
|
||||
) as pool:
|
||||
futures = [
|
||||
pool.submit(bt._replay_candidates_for_period, *args)
|
||||
for args in replay_args
|
||||
]
|
||||
for index, future in enumerate(as_completed(futures), 1):
|
||||
replay_rows.extend(future.result())
|
||||
if not quiet and index % 25 == 0:
|
||||
print(
|
||||
f'daily replay: {index}/{len(futures)} tickers',
|
||||
flush=True,
|
||||
)
|
||||
|
||||
setup_candidates = [
|
||||
row for row in replay_rows if not row.get('_rank_only')
|
||||
]
|
||||
rank_observations = [
|
||||
row for row in replay_rows if row.get('_universe_rank_observation')
|
||||
]
|
||||
daily_rank_map = _live_universe_rank_map(
|
||||
rank_observations,
|
||||
snapshot_data['benchmark_closes'],
|
||||
bt.STRATEGY_RANK_MOMENTUM_WEIGHT,
|
||||
)
|
||||
qualified: list[dict[str, Any]] = []
|
||||
for setup in setup_candidates:
|
||||
if setup.get('direction') != 'long':
|
||||
continue
|
||||
identity = (str(setup['symbol']), str(setup['date']))
|
||||
rank = daily_rank_map.get(identity)
|
||||
if rank is None:
|
||||
continue
|
||||
candidate = {
|
||||
key: value
|
||||
for key, value in setup.items()
|
||||
if not key.startswith('_universe_')
|
||||
}
|
||||
candidate[bt.PRODUCTION_PERCENTILE_KEY] = rank['momentum_percentile']
|
||||
candidate[bt.VOL_PERCENTILE_KEY] = rank['volatility_percentile']
|
||||
candidate[bt.RESIDUAL_HIGH_VOL_BLEND_80_20_KEY] = rank['strategy_rank']
|
||||
candidate['qualified'] = bt._momentum_qualifies(
|
||||
candidate,
|
||||
snapshot_data['threshold'],
|
||||
)
|
||||
if candidate['qualified']:
|
||||
qualified.append(candidate)
|
||||
|
||||
qualified.sort(
|
||||
key=lambda row: (
|
||||
str(row['date']),
|
||||
-float(row.get(snapshot_data['ranking_key']) or 0.0),
|
||||
str(row['symbol']),
|
||||
float(row.get('entry') or 0.0),
|
||||
float(row.get('stop') or 0.0),
|
||||
float(row.get('target') or 0.0),
|
||||
str(row.get('action') or ''),
|
||||
)
|
||||
)
|
||||
rank_dates = sorted({identity[1] for identity in daily_rank_map})
|
||||
cached = {
|
||||
'key': cache_key,
|
||||
'qualified_candidates': qualified,
|
||||
'daily_rank_map': daily_rank_map,
|
||||
'setup_candidate_count': len(setup_candidates),
|
||||
'qualified_long_count': len(qualified),
|
||||
'entry_candidates_by_direction': dict(
|
||||
Counter(str(row['direction']) for row in setup_candidates)
|
||||
),
|
||||
'rank_observation_count': len(rank_observations),
|
||||
'rank_first_date': rank_dates[0] if rank_dates else None,
|
||||
'rank_last_date': rank_dates[-1] if rank_dates else None,
|
||||
}
|
||||
_atomic_pickle(cache_path, cached)
|
||||
if not quiet:
|
||||
print(f'wrote candidate/rank cache: {cache_path}', flush=True)
|
||||
return cached
|
||||
|
||||
|
||||
def _checkpoint_state(
|
||||
checkpoint_dir: Path,
|
||||
fingerprint: str,
|
||||
*,
|
||||
resume: bool,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
manifest_path = checkpoint_dir / 'manifest.json'
|
||||
if checkpoint_dir.exists() and not resume:
|
||||
existing_cells = list(checkpoint_dir.glob('cell-*.json'))
|
||||
if existing_cells:
|
||||
raise SystemExit(
|
||||
f'Checkpoint cells already exist at {checkpoint_dir}; use --resume '
|
||||
'or a new --run-id'
|
||||
)
|
||||
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
if manifest_path.exists():
|
||||
existing = json.loads(manifest_path.read_text(encoding='utf-8'))
|
||||
if existing.get('fingerprint') != fingerprint:
|
||||
raise SystemExit(
|
||||
f'Checkpoint fingerprint mismatch at {checkpoint_dir}'
|
||||
)
|
||||
else:
|
||||
_atomic_json(
|
||||
manifest_path,
|
||||
{
|
||||
'runner_version': RUNNER_VERSION,
|
||||
'fingerprint': fingerprint,
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
|
||||
completed: dict[str, dict[str, Any]] = {}
|
||||
if resume:
|
||||
for path in sorted(checkpoint_dir.glob('cell-*.json')):
|
||||
row = json.loads(path.read_text(encoding='utf-8'))
|
||||
completed[str(row['cell_id'])] = row
|
||||
return completed
|
||||
|
||||
|
||||
def _write_cell_checkpoint(
|
||||
checkpoint_dir: Path,
|
||||
row: dict[str, Any],
|
||||
) -> None:
|
||||
name = hashlib.sha256(str(row['cell_id']).encode('utf-8')).hexdigest()
|
||||
_atomic_json(checkpoint_dir / f'cell-{name}.json', row)
|
||||
|
||||
|
||||
def _fmt(value: Any, digits: int = 3) -> str:
|
||||
if value is None:
|
||||
return 'n/a'
|
||||
if isinstance(value, float):
|
||||
return f'{value:.{digits}f}'
|
||||
return str(value)
|
||||
|
||||
|
||||
def _operational_summary(cells: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
rows: list[dict[str, Any]] = []
|
||||
for arm in ARMS:
|
||||
arm_cells = [
|
||||
row
|
||||
for row in cells
|
||||
if row['arm_id'] == arm['id']
|
||||
and float(row['cost_per_side_pct']) == 0.1
|
||||
]
|
||||
weekly = [
|
||||
row['metrics']['weekly_rebalance']
|
||||
for row in arm_cells
|
||||
if row['metrics'].get('weekly_rebalance')
|
||||
]
|
||||
rows.append({
|
||||
'arm_id': arm['id'],
|
||||
'paths': len(arm_cells),
|
||||
'median_trades': median(
|
||||
row['metrics'].get('trades') for row in arm_cells
|
||||
),
|
||||
'median_blocked_fraction': median(
|
||||
row['metrics'].get('blocked_fraction') for row in arm_cells
|
||||
),
|
||||
'median_avg_positions': median(
|
||||
row['metrics'].get('avg_positions') for row in arm_cells
|
||||
),
|
||||
'peak_positions': max(
|
||||
(
|
||||
int(row['metrics'].get('peak_positions') or 0)
|
||||
for row in arm_cells
|
||||
),
|
||||
default=0,
|
||||
),
|
||||
'median_turnover_multiple': median(
|
||||
row['metrics'].get('turnover_multiple') for row in arm_cells
|
||||
),
|
||||
'min_risk_rejections': sum(
|
||||
int(row['metrics'].get('skipped_min_initial_risk') or 0)
|
||||
for row in arm_cells
|
||||
),
|
||||
'weekly_zero_entrant_fraction': median(
|
||||
item.get('zero_entrant_fraction') for item in weekly
|
||||
),
|
||||
'weekly_entrant_pool_median': median(
|
||||
item.get('entrant_pool_median') for item in weekly
|
||||
),
|
||||
'weekly_replacements': sum(
|
||||
int(item.get('replacements') or 0) for item in weekly
|
||||
),
|
||||
'weekly_reentries_within_10_sessions': sum(
|
||||
int(item.get('reentries_within_10_sessions') or 0)
|
||||
for item in weekly
|
||||
),
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def _markdown(report: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
'# Focused daily portfolio-capacity matrix',
|
||||
'',
|
||||
f'Generated: {report["generated_at"]}',
|
||||
'',
|
||||
'## Question',
|
||||
'',
|
||||
'The current daily Phase A control admitted 472 trades and rejected 519 '
|
||||
'qualified opportunities because the ten-slot book was full. This run '
|
||||
'brackets the economic cost of that binding constraint; it has no formal '
|
||||
'promotion gate.',
|
||||
'',
|
||||
'> Universe caveat: today\'s production membership is projected backward. '
|
||||
'Use paired arm-versus-control differences, not absolute profitability, '
|
||||
'for construction conclusions.',
|
||||
'',
|
||||
'## Paired annual medians',
|
||||
'',
|
||||
]
|
||||
paired = report['analysis']['paired_per_year']
|
||||
for protocol in ('empty_book', 'warm_book'):
|
||||
lines.extend([
|
||||
f'### {protocol.replace("_", " ").title()} — 0.10% per fill',
|
||||
'',
|
||||
'| Arm | ΔEV net R | 90% context | ΔCalmar | 90% context |',
|
||||
'|---|---:|---:|---:|---:|',
|
||||
])
|
||||
for arm in ARMS:
|
||||
row = next(
|
||||
item
|
||||
for item in paired
|
||||
if item['arm_id'] == arm['id']
|
||||
and item['protocol'] == protocol
|
||||
and float(item['cost_per_side_pct']) == 0.1
|
||||
)
|
||||
ev = row['headline']['ev_net_r']
|
||||
calmar = row['headline']['calmar']
|
||||
ev_ci = ev['bootstrap_90']
|
||||
calmar_ci = calmar['bootstrap_90']
|
||||
lines.append(
|
||||
f'| {arm["id"]} | {_fmt(ev["paired_delta_median"])} | '
|
||||
f'[{_fmt(ev_ci["p05"])}, {_fmt(ev_ci["p95"])}] | '
|
||||
f'{_fmt(calmar["paired_delta_median"])} | '
|
||||
f'[{_fmt(calmar_ci["p05"])}, {_fmt(calmar_ci["p95"])}] |'
|
||||
)
|
||||
lines.append('')
|
||||
lines.extend([
|
||||
'| Arm | ΔPF | ΔGain-to-Pain | ΔSortino | ΔCAGR pp | ΔMaxDD pp |',
|
||||
'|---|---:|---:|---:|---:|---:|',
|
||||
])
|
||||
for arm in ARMS:
|
||||
row = next(
|
||||
item
|
||||
for item in paired
|
||||
if item['arm_id'] == arm['id']
|
||||
and item['protocol'] == protocol
|
||||
and float(item['cost_per_side_pct']) == 0.1
|
||||
)
|
||||
headline = row['headline']
|
||||
lines.append(
|
||||
f'| {arm["id"]} | '
|
||||
f'{_fmt(headline["profit_factor"]["paired_delta_median"])} | '
|
||||
f'{_fmt(headline["gain_to_pain"]["paired_delta_median"])} | '
|
||||
f'{_fmt(headline["sortino"]["paired_delta_median"])} | '
|
||||
f'{_fmt(headline["cagr_pct"]["paired_delta_median"])} | '
|
||||
f'{_fmt(headline["max_drawdown_pct"]["paired_delta_median"])} |'
|
||||
)
|
||||
lines.append('')
|
||||
|
||||
lines.extend([
|
||||
'## Warm-seed initialization dispersion',
|
||||
'',
|
||||
'| Arm | Cost/fill | Median EV IQR ratio | Median Calmar IQR ratio |',
|
||||
'|---|---:|---:|---:|',
|
||||
])
|
||||
for row in report['analysis']['warm_seed_dispersion']:
|
||||
lines.append(
|
||||
f'| {row["arm_id"]} | {row["cost_per_side_pct"]:.2f}% | '
|
||||
f'{_fmt(row["headline"]["ev_net_r"]["median_iqr_ratio"])} | '
|
||||
f'{_fmt(row["headline"]["calmar"]["median_iqr_ratio"])} |'
|
||||
)
|
||||
|
||||
lines.extend([
|
||||
'',
|
||||
'## Capacity and operations — 0.10% per fill',
|
||||
'',
|
||||
'| Arm | Median trades | Median blocked | Median positions | Peak | '
|
||||
'Turnover | Min-risk rejects |',
|
||||
'|---|---:|---:|---:|---:|---:|---:|',
|
||||
])
|
||||
for row in report['operational_summary']:
|
||||
blocked = row['median_blocked_fraction']
|
||||
blocked_text = (
|
||||
f'{float(blocked) * 100.0:.1f}%' if blocked is not None else 'n/a'
|
||||
)
|
||||
lines.append(
|
||||
f'| {row["arm_id"]} | {_fmt(row["median_trades"], 1)} | '
|
||||
f'{blocked_text} | {_fmt(row["median_avg_positions"], 2)} | '
|
||||
f'{row["peak_positions"]} | '
|
||||
f'{_fmt(row["median_turnover_multiple"], 2)} | '
|
||||
f'{row["min_risk_rejections"]} |'
|
||||
)
|
||||
|
||||
weekly = next(
|
||||
row
|
||||
for row in report['operational_summary']
|
||||
if row['arm_id'] == 'cap10_weekly_top10'
|
||||
)
|
||||
lines.extend([
|
||||
'',
|
||||
'## Weekly-ranking opportunity set',
|
||||
'',
|
||||
f'- Median fresh entrant pool: '
|
||||
f'{_fmt(weekly["weekly_entrant_pool_median"], 1)}.',
|
||||
f'- Median zero-entrant fraction: '
|
||||
f'{_fmt(weekly["weekly_zero_entrant_fraction"], 3)}.',
|
||||
f'- Replacements across reported paths: {weekly["weekly_replacements"]}.',
|
||||
f'- Same-symbol re-entries within 10 sessions: '
|
||||
f'{weekly["weekly_reentries_within_10_sessions"]}.',
|
||||
'',
|
||||
'Bootstrap intervals above resample seven annual summaries and are '
|
||||
'descriptive context only. They are not gates or independent-population '
|
||||
'confidence claims.',
|
||||
])
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
async def _main() -> None:
|
||||
args = _parse_args()
|
||||
snapshot = Path(args.snapshot)
|
||||
if not snapshot.exists():
|
||||
raise SystemExit(f'Snapshot does not exist: {snapshot}')
|
||||
if not SPEC_PATH.exists():
|
||||
raise SystemExit(f'Frozen specification is missing: {SPEC_PATH}')
|
||||
|
||||
os.environ['BACKTEST_SNAPSHOT_OFFLINE'] = '1'
|
||||
os.environ['BACKTEST_ALLOW_SPAWN'] = '1'
|
||||
workers = _worker_count(str(args.workers))
|
||||
snapshot_sha256 = _sha256_file(snapshot)
|
||||
specification_sha256 = _sha256_file(SPEC_PATH)
|
||||
|
||||
snapshot_data = await _load_snapshot(snapshot, quiet=bool(args.quiet))
|
||||
cache_path = (
|
||||
Path(args.candidate_cache)
|
||||
if args.candidate_cache
|
||||
else ROOT / 'reports' / '.cache' / f'{args.run_id}-candidates.pkl'
|
||||
)
|
||||
candidate_cache = _build_candidate_cache(
|
||||
snapshot_data,
|
||||
snapshot=snapshot,
|
||||
snapshot_sha256=snapshot_sha256,
|
||||
cache_path=cache_path,
|
||||
workers=workers,
|
||||
quiet=bool(args.quiet),
|
||||
)
|
||||
|
||||
cohort_manifest = build_cohort_manifest(
|
||||
snapshot_data['benchmark_closes'].keys()
|
||||
)
|
||||
cohort_errors = validate_cohort_manifest(cohort_manifest)
|
||||
cells = build_cells(cohort_manifest)
|
||||
validation_payload = {
|
||||
'runner_version': RUNNER_VERSION,
|
||||
'snapshot': str(snapshot.resolve()),
|
||||
'snapshot_sha256': snapshot_sha256,
|
||||
'snapshot_sessions': {
|
||||
'count': cohort_manifest['session_count'],
|
||||
'first': cohort_manifest['snapshot_first_session'],
|
||||
'last': cohort_manifest['snapshot_last_session'],
|
||||
},
|
||||
'candidate_rank_coverage': {
|
||||
'first': candidate_cache['rank_first_date'],
|
||||
'last': candidate_cache['rank_last_date'],
|
||||
'observations': candidate_cache['rank_observation_count'],
|
||||
'qualified_longs': candidate_cache['qualified_long_count'],
|
||||
},
|
||||
'universe_manifest': snapshot_data['universe_manifest'],
|
||||
'empty_cluster_counts': cohort_manifest['empty_cluster_counts'],
|
||||
'warm_seed_counts': cohort_manifest['warm_seed_counts'],
|
||||
'empty_cluster_count': cohort_manifest['empty_cluster_count'],
|
||||
'warm_cluster_count': cohort_manifest['warm_cluster_count'],
|
||||
'expected_clusters': list(ANCHOR_YEARS),
|
||||
'matrix_cells': len(cells),
|
||||
'cache_path': str(cache_path.resolve()),
|
||||
'cache_key_hash': _json_hash(candidate_cache['key']),
|
||||
'errors': cohort_errors,
|
||||
}
|
||||
print(json.dumps(validation_payload, indent=2, sort_keys=True), flush=True)
|
||||
if cohort_errors:
|
||||
raise SystemExit(
|
||||
'Cohort validation failed; revise and re-hash the specification'
|
||||
)
|
||||
if args.validate_only:
|
||||
return
|
||||
|
||||
_assert_clean_worktree()
|
||||
git_commit = _git_output('rev-parse', 'HEAD')
|
||||
fingerprint_payload = {
|
||||
'runner_version': RUNNER_VERSION,
|
||||
'git_commit': git_commit,
|
||||
'snapshot_sha256': snapshot_sha256,
|
||||
'specification_sha256': specification_sha256,
|
||||
'candidate_cache_key': candidate_cache['key'],
|
||||
'universe_manifest': snapshot_data['universe_manifest'],
|
||||
'cohort_manifest': cohort_manifest,
|
||||
'arms': list(ARMS),
|
||||
'costs_per_side_pct': list(COSTS_PER_SIDE_PCT),
|
||||
'bootstrap': {
|
||||
'replicates': BOOTSTRAP_REPLICATES,
|
||||
'seed': BOOTSTRAP_SEED,
|
||||
},
|
||||
}
|
||||
fingerprint = _json_hash(fingerprint_payload)
|
||||
checkpoint_dir = (
|
||||
Path(args.checkpoint)
|
||||
if args.checkpoint
|
||||
else ROOT / 'reports' / '.cache' / f'{args.run_id}-checkpoint'
|
||||
)
|
||||
completed = _checkpoint_state(
|
||||
checkpoint_dir,
|
||||
fingerprint,
|
||||
resume=bool(args.resume),
|
||||
)
|
||||
expected_ids = {str(cell['cell_id']) for cell in cells}
|
||||
unknown = set(completed) - expected_ids
|
||||
if unknown:
|
||||
raise SystemExit(
|
||||
f'Checkpoint contains {len(unknown)} unknown matrix cells'
|
||||
)
|
||||
remaining = [
|
||||
cell for cell in cells if str(cell['cell_id']) not in completed
|
||||
]
|
||||
if not args.quiet:
|
||||
print(
|
||||
f'matrix cells: {len(completed)} resumed, {len(remaining)} remaining',
|
||||
flush=True,
|
||||
)
|
||||
|
||||
worker_context = {
|
||||
'qualified_candidates': candidate_cache['qualified_candidates'],
|
||||
'daily_rank_map': candidate_cache['daily_rank_map'],
|
||||
'prices': snapshot_data['prices'],
|
||||
'benchmark_closes': snapshot_data['benchmark_closes'],
|
||||
'ranking_key': snapshot_data['ranking_key'],
|
||||
'exit_policy': snapshot_data['exit_policy'],
|
||||
'hold_days': snapshot_data['hold_days'],
|
||||
'risk_per_trade': snapshot_data['risk_per_trade'],
|
||||
'atr_trail_multiplier': snapshot_data['atr_trail_multiplier'],
|
||||
}
|
||||
if workers == 1:
|
||||
_worker_init(worker_context)
|
||||
for index, cell in enumerate(remaining, 1):
|
||||
row = _worker_run_cell(cell)
|
||||
completed[str(row['cell_id'])] = row
|
||||
_write_cell_checkpoint(checkpoint_dir, row)
|
||||
if not args.quiet:
|
||||
print(
|
||||
f'portfolio cells: {len(completed)}/{len(cells)} '
|
||||
f'({row["cell_id"]})',
|
||||
flush=True,
|
||||
)
|
||||
elif remaining:
|
||||
context = multiprocessing.get_context('spawn')
|
||||
with ProcessPoolExecutor(
|
||||
max_workers=workers,
|
||||
mp_context=context,
|
||||
initializer=_worker_init,
|
||||
initargs=(worker_context,),
|
||||
) as pool:
|
||||
futures = {
|
||||
pool.submit(_worker_run_cell, cell): str(cell['cell_id'])
|
||||
for cell in remaining
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
row = future.result()
|
||||
completed[str(row['cell_id'])] = row
|
||||
_write_cell_checkpoint(checkpoint_dir, row)
|
||||
if not args.quiet:
|
||||
print(
|
||||
f'portfolio cells: {len(completed)}/{len(cells)} '
|
||||
f'({row["cell_id"]})',
|
||||
flush=True,
|
||||
)
|
||||
|
||||
missing = expected_ids - set(completed)
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f'Incomplete matrix: {len(missing)} cells are missing'
|
||||
)
|
||||
result_cells = sorted(
|
||||
completed.values(),
|
||||
key=lambda row: str(row['cell_id']),
|
||||
)
|
||||
analysis = aggregate_results(result_cells)
|
||||
requirements_path = ROOT / 'requirements.txt'
|
||||
report: dict[str, Any] = {
|
||||
'run_id': args.run_id,
|
||||
'status': 'complete',
|
||||
'generated_at': datetime.now(timezone.utc).isoformat(),
|
||||
'research_question': (
|
||||
'Bracket the economic cost of the binding ten-position cap and '
|
||||
'test whether weekly current-rank selection beats arrival order.'
|
||||
),
|
||||
'decision_rule': (
|
||||
'No formal promotion gate. Report paired annual medians, warm-seed '
|
||||
'EV/Calmar IQR ratios, and simple bootstrap intervals as context.'
|
||||
),
|
||||
'survivorship_bias_caveat': (
|
||||
'The current production universe is projected backward; construction '
|
||||
'conclusions rely on paired relative comparisons, not absolute levels.'
|
||||
),
|
||||
'motivation': {
|
||||
'source': 'reports/research-matrix-phase-a.json a0_control full window',
|
||||
'trades': 472,
|
||||
'skipped_book_full': 519,
|
||||
'blocked_fraction': 519 / (519 + 472),
|
||||
'stale_claim_corrected': (
|
||||
'The older weekly pre-gate-reset claim that cap 10 never bound '
|
||||
'does not apply to the current daily configuration.'
|
||||
),
|
||||
},
|
||||
'fingerprint': fingerprint,
|
||||
'fingerprint_payload': fingerprint_payload,
|
||||
'environment': {
|
||||
'python': sys.version,
|
||||
'platform': platform.platform(),
|
||||
'requirements_sha256': (
|
||||
_sha256_file(requirements_path)
|
||||
if requirements_path.exists()
|
||||
else None
|
||||
),
|
||||
'command': [sys.executable, *sys.argv],
|
||||
},
|
||||
'validation': validation_payload,
|
||||
'runtime_config': snapshot_data['runtime_config'],
|
||||
'universe_manifest': snapshot_data['universe_manifest'],
|
||||
'candidate_cache': {
|
||||
key: value
|
||||
for key, value in candidate_cache.items()
|
||||
if key
|
||||
not in {
|
||||
'qualified_candidates',
|
||||
'daily_rank_map',
|
||||
}
|
||||
},
|
||||
'cohort_manifest': cohort_manifest,
|
||||
'arms': list(ARMS),
|
||||
'costs_per_side_pct': list(COSTS_PER_SIDE_PCT),
|
||||
'cell_count': len(result_cells),
|
||||
'cells': result_cells,
|
||||
'analysis': analysis,
|
||||
'operational_summary': _operational_summary(result_cells),
|
||||
}
|
||||
out_path = (
|
||||
Path(args.out)
|
||||
if args.out
|
||||
else ROOT
|
||||
/ 'reports'
|
||||
/ f'portfolio-construction-{args.run_id}.json'
|
||||
)
|
||||
_atomic_json(out_path, report)
|
||||
_atomic_text(out_path.with_suffix('.md'), _markdown(report))
|
||||
if not args.quiet:
|
||||
print(f'wrote {out_path}', flush=True)
|
||||
print(f'wrote {out_path.with_suffix(".md")}', flush=True)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
asyncio.run(_main())
|
||||
@@ -68,6 +68,11 @@ ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from scripts.research_rankings import ( # noqa: E402
|
||||
_live_universe_rank_map,
|
||||
_period_percentiles,
|
||||
)
|
||||
|
||||
CACHE_VERSION = "research-matrix-v1-daily-prod"
|
||||
|
||||
# Pre-registered arm catalogue (order is report order). Control is a0.
|
||||
@@ -210,66 +215,6 @@ def _sqlite_url(path: Path) -> str:
|
||||
return f"sqlite+aiosqlite:///{path.resolve().as_posix()}"
|
||||
|
||||
|
||||
def _period_percentiles(
|
||||
observations: list[dict], value_key: str
|
||||
) -> dict[tuple[str, str], float]:
|
||||
by_period: dict[tuple, list[dict]] = {}
|
||||
for row in observations:
|
||||
if row.get(value_key) is None:
|
||||
continue
|
||||
period = tuple(row["ranking_period"])
|
||||
by_period.setdefault(period, []).append(row)
|
||||
result: dict[tuple[str, str], float] = {}
|
||||
for group in by_period.values():
|
||||
ordered = sorted(
|
||||
group, key=lambda row: (float(row[value_key]), str(row["symbol"]))
|
||||
)
|
||||
denominator = len(ordered) - 1
|
||||
for rank, row in enumerate(ordered):
|
||||
result[(str(row["symbol"]), str(row["date"]))] = round(
|
||||
rank / denominator * 100.0 if denominator > 0 else 100.0,
|
||||
2,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _live_universe_rank_map(
|
||||
observations: list[dict],
|
||||
benchmark_closes: dict[date, float],
|
||||
momentum_weight: float,
|
||||
) -> dict[tuple[str, str], dict[str, float | None]]:
|
||||
raw_pct = _period_percentiles(observations, "momentum")
|
||||
residual_pct = _period_percentiles(observations, "residual_momentum")
|
||||
vol_pct = _period_percentiles(observations, "vol_6m")
|
||||
benchmark_ords = sorted(value.toordinal() for value in benchmark_closes)
|
||||
residual_start_ord = benchmark_ords[251] if len(benchmark_ords) >= 252 else None
|
||||
ranks: dict[tuple[str, str], dict[str, float | None]] = {}
|
||||
for row in observations:
|
||||
identity = (str(row["symbol"]), str(row["date"]))
|
||||
asof_ord = date.fromisoformat(identity[1]).toordinal()
|
||||
momentum_pct = (
|
||||
residual_pct.get(identity)
|
||||
if residual_start_ord is not None and asof_ord >= residual_start_ord
|
||||
else raw_pct.get(identity)
|
||||
)
|
||||
volatility_pct = vol_pct.get(identity)
|
||||
strategy_rank = (
|
||||
round(
|
||||
momentum_pct * momentum_weight
|
||||
+ volatility_pct * (1.0 - momentum_weight),
|
||||
2,
|
||||
)
|
||||
if momentum_pct is not None and volatility_pct is not None
|
||||
else momentum_pct
|
||||
)
|
||||
ranks[identity] = {
|
||||
"momentum_percentile": momentum_pct,
|
||||
"volatility_percentile": volatility_pct,
|
||||
"strategy_rank": strategy_rank,
|
||||
}
|
||||
return ranks
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
|
||||
Reference in New Issue
Block a user