fix: isolate production universe in capacity research
This commit is contained in:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user