fix: isolate production universe in capacity research

This commit is contained in:
2026-08-05 21:00:19 +02:00
parent 23fe39fd78
commit 6fc82ae857
6 changed files with 349 additions and 97613 deletions
+165 -14
View File
@@ -48,7 +48,10 @@ 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'
RUNNER_VERSION = 'portfolio-capacity-bracket-v2'
CONSTRUCTION_VIEW_VERSION = 'production-book-filter-v1'
MIN_PRODUCTION_UNIVERSE = 450
MAX_PRODUCTION_UNIVERSE = 600
SPEC_PATH = ROOT / 'docs' / 'research' / 'portfolio-capacity-bracket.md'
DEFAULT_RUN_ID = 'prod505-capacity-bracket-daily-v1'
_WORKER_CONTEXT: dict[str, Any] | None = None
@@ -103,6 +106,67 @@ def _json_hash(value: Any) -> str:
return hashlib.sha256(payload).hexdigest()
def _construction_universe_errors(manifest: dict[str, Any]) -> list[str]:
errors: list[str] = []
construction_rows = int(manifest['construction_ticker_rows'])
construction_priced = int(manifest['construction_symbols_with_prices'])
ranking_rows = int(manifest['ranking_ticker_rows'])
rank_only_rows = int(manifest['rank_only_ticker_rows'])
unknown_rank_only = int(manifest['rank_only_unknown_symbols'])
if not MIN_PRODUCTION_UNIVERSE <= construction_rows <= MAX_PRODUCTION_UNIVERSE:
errors.append(
'construction ticker universe must contain '
f'{MIN_PRODUCTION_UNIVERSE}-{MAX_PRODUCTION_UNIVERSE} symbols; '
f'found {construction_rows}'
)
if not MIN_PRODUCTION_UNIVERSE <= construction_priced <= MAX_PRODUCTION_UNIVERSE:
errors.append(
'priced construction universe must contain '
f'{MIN_PRODUCTION_UNIVERSE}-{MAX_PRODUCTION_UNIVERSE} symbols; '
f'found {construction_priced}'
)
if construction_rows + rank_only_rows != ranking_rows:
errors.append(
'construction and rank-only ticker partitions do not cover the '
'ranking universe'
)
if unknown_rank_only:
errors.append(
f'research_rank_only contains {unknown_rank_only} symbols absent '
'from tickers'
)
return errors
def _construction_candidate_view(
cached: dict[str, Any],
snapshot_data: dict[str, Any],
) -> dict[str, Any]:
'''Filter a reusable full-universe rank cache to production setup symbols.'''
allowed = set(snapshot_data['construction_symbols'])
raw_candidates = list(cached['qualified_candidates'])
qualified = [
row for row in raw_candidates if str(row['symbol']) in allowed
]
view_key = {
'version': CONSTRUCTION_VIEW_VERSION,
'base_cache_key_hash': _json_hash(cached['key']),
'construction_universe_manifest': (
snapshot_data['construction_universe_manifest']
),
}
return {
**cached,
'qualified_candidates': qualified,
'raw_full_universe_qualified_long_count': len(raw_candidates),
'qualified_long_count': len(qualified),
'filtered_rank_only_qualified_long_count': (
len(raw_candidates) - len(qualified)
),
'construction_view_key': view_key,
}
def _atomic_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + '.tmp')
@@ -292,12 +356,34 @@ async def _load_snapshot(
f'loaded prices: {index}/{len(symbols)}',
flush=True,
)
rank_only_symbols = await bt._load_research_rank_only_symbols(db)
finally:
await engine.dispose()
if not prices or not benchmark_closes:
raise SystemExit('Snapshot has no usable prices or benchmark history')
ticker_symbols = set(symbols)
price_symbols = set(prices)
known_rank_only = ticker_symbols & rank_only_symbols
unknown_rank_only = rank_only_symbols - ticker_symbols
construction_symbols = ticker_symbols - known_rank_only
priced_construction_symbols = price_symbols & construction_symbols
priced_rank_only_symbols = price_symbols & known_rank_only
construction_universe_manifest = {
'ranking_ticker_rows': len(ticker_symbols),
'ranking_symbols_with_prices': len(price_symbols),
'ranking_symbols_sha256': _json_hash(sorted(ticker_symbols)),
'construction_ticker_rows': len(construction_symbols),
'construction_symbols_with_prices': len(priced_construction_symbols),
'construction_symbols_sha256': _json_hash(sorted(construction_symbols)),
'rank_only_ticker_rows': len(known_rank_only),
'rank_only_symbols_with_prices': len(priced_rank_only_symbols),
'rank_only_symbols_sha256': _json_hash(sorted(known_rank_only)),
'rank_only_unknown_symbols': len(unknown_rank_only),
'rank_only_unknown_symbols_sha256': _json_hash(sorted(unknown_rank_only)),
}
strategy = next(
row
for row in bt.PORTFOLIO_MONITOR_STRATEGIES
@@ -350,11 +436,13 @@ async def _load_snapshot(
'benchmark_closes': benchmark_closes,
'prices': prices,
'symbols': symbols,
'construction_symbols': construction_symbols,
'universe_manifest': {
'ticker_rows': len(symbols),
'symbols_with_prices': len(prices),
'symbols_sha256': _json_hash(sorted(symbols)),
},
'construction_universe_manifest': construction_universe_manifest,
'ranking_key': ranking_key,
'exit_policy': exit_policy,
'hold_days': hold_days,
@@ -639,21 +727,51 @@ def _markdown(report: dict[str, Any]) -> str:
'## Paired annual medians',
'',
]
validation = report.get('validation') or {}
construction = validation.get('construction_universe_manifest') or {}
coverage = validation.get('candidate_rank_coverage') or {}
if construction:
construction_count = construction['construction_symbols_with_prices']
rank_only_count = construction['rank_only_symbols_with_prices']
ranking_count = construction['ranking_symbols_with_prices']
qualified_count = coverage.get('construction_qualified_longs')
filtered_count = coverage.get('filtered_rank_only_qualified_longs')
insertion = lines.index('## Paired annual medians')
lines[insertion:insertion] = [
'## Validated universes',
'',
f'- Tradable setup symbols with prices: '
f'{construction_count}.',
f'- Rank-only symbols with prices: '
f'{rank_only_count}.',
f'- Full ranking symbols with prices: '
f'{ranking_count}.',
f'- Tradable qualified longs: '
f'{qualified_count}.',
f'- Rank-only qualified rows removed: '
f'{filtered_count}.',
'',
]
paired = report['analysis']['paired_per_year']
for protocol in ('empty_book', 'warm_book'):
for cost, protocol in (
(cost, protocol)
for cost in COSTS_PER_SIDE_PCT
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 |',
'|---|---:|---:|---:|---:|',
])
lines[-4] = lines[-4].replace('0.10%', f'{cost:.2f}%')
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
and float(item['cost_per_side_pct']) == float(cost)
)
ev = row['headline']['ev_net_r']
calmar = row['headline']['calmar']
@@ -676,7 +794,7 @@ def _markdown(report: dict[str, Any]) -> str:
for item in paired
if item['arm_id'] == arm['id']
and item['protocol'] == protocol
and float(item['cost_per_side_pct']) == 0.1
and float(item['cost_per_side_pct']) == float(cost)
)
headline = row['headline']
lines.append(
@@ -767,7 +885,7 @@ async def _main() -> None:
if args.candidate_cache
else ROOT / 'reports' / '.cache' / f'{args.run_id}-candidates.pkl'
)
candidate_cache = _build_candidate_cache(
base_candidate_cache = _build_candidate_cache(
snapshot_data,
snapshot=snapshot,
snapshot_sha256=snapshot_sha256,
@@ -775,11 +893,19 @@ async def _main() -> None:
workers=workers,
quiet=bool(args.quiet),
)
candidate_cache = _construction_candidate_view(
base_candidate_cache,
snapshot_data,
)
cohort_manifest = build_cohort_manifest(
snapshot_data['benchmark_closes'].keys()
)
cohort_errors = validate_cohort_manifest(cohort_manifest)
universe_errors = _construction_universe_errors(
snapshot_data['construction_universe_manifest']
)
validation_errors = [*universe_errors, *cohort_errors]
cells = build_cells(cohort_manifest)
validation_payload = {
'runner_version': RUNNER_VERSION,
@@ -794,9 +920,20 @@ async def _main() -> None:
'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'],
'raw_full_universe_qualified_longs': candidate_cache[
'raw_full_universe_qualified_long_count'
],
'filtered_rank_only_qualified_longs': candidate_cache[
'filtered_rank_only_qualified_long_count'
],
'construction_qualified_longs': candidate_cache[
'qualified_long_count'
],
},
'universe_manifest': snapshot_data['universe_manifest'],
'construction_universe_manifest': (
snapshot_data['construction_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'],
@@ -805,12 +942,15 @@ async def _main() -> None:
'matrix_cells': len(cells),
'cache_path': str(cache_path.resolve()),
'cache_key_hash': _json_hash(candidate_cache['key']),
'errors': cohort_errors,
'construction_view_key_hash': _json_hash(
candidate_cache['construction_view_key']
),
'errors': validation_errors,
}
print(json.dumps(validation_payload, indent=2, sort_keys=True), flush=True)
if cohort_errors:
if validation_errors:
raise SystemExit(
'Cohort validation failed; revise and re-hash the specification'
'Research validation failed; do not start the authoritative run'
)
if args.validate_only:
return
@@ -823,7 +963,11 @@ async def _main() -> None:
'snapshot_sha256': snapshot_sha256,
'specification_sha256': specification_sha256,
'candidate_cache_key': candidate_cache['key'],
'construction_view_key': candidate_cache['construction_view_key'],
'universe_manifest': snapshot_data['universe_manifest'],
'construction_universe_manifest': (
snapshot_data['construction_universe_manifest']
),
'cohort_manifest': cohort_manifest,
'arms': list(ARMS),
'costs_per_side_pct': list(COSTS_PER_SIDE_PCT),
@@ -836,7 +980,10 @@ async def _main() -> None:
checkpoint_dir = (
Path(args.checkpoint)
if args.checkpoint
else ROOT / 'reports' / '.cache' / f'{args.run_id}-checkpoint'
else ROOT
/ 'reports'
/ '.cache'
/ f'{args.run_id}-{RUNNER_VERSION}-checkpoint'
)
completed = _checkpoint_state(
checkpoint_dir,
@@ -914,7 +1061,7 @@ async def _main() -> None:
key=lambda row: str(row['cell_id']),
)
analysis = aggregate_results(result_cells)
requirements_path = ROOT / 'requirements.txt'
dependency_manifest = ROOT / 'pyproject.toml'
report: dict[str, Any] = {
'run_id': args.run_id,
'status': 'complete',
@@ -946,9 +1093,10 @@ async def _main() -> None:
'environment': {
'python': sys.version,
'platform': platform.platform(),
'requirements_sha256': (
_sha256_file(requirements_path)
if requirements_path.exists()
'dependency_manifest': str(dependency_manifest.relative_to(ROOT)),
'dependency_manifest_sha256': (
_sha256_file(dependency_manifest)
if dependency_manifest.exists()
else None
),
'command': [sys.executable, *sys.argv],
@@ -956,6 +1104,9 @@ async def _main() -> None:
'validation': validation_payload,
'runtime_config': snapshot_data['runtime_config'],
'universe_manifest': snapshot_data['universe_manifest'],
'construction_universe_manifest': (
snapshot_data['construction_universe_manifest']
),
'candidate_cache': {
key: value
for key, value in candidate_cache.items()