regime_lite_run.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. from __future__ import annotations
  2. import argparse
  3. import json
  4. from pathlib import Path
  5. import sys
  6. from typing import Any
  7. ROOT = Path(__file__).resolve().parents[1]
  8. if str(ROOT) not in sys.path:
  9. sys.path.insert(0, str(ROOT))
  10. import pandas as pd
  11. from backtest.engine import compute_metrics
  12. from config.loader import load_config
  13. from data.io import load_point_in_time_panel
  14. from pipelines.regime_lite_support import (
  15. LITE_EXECUTION_PROFILES,
  16. LITE_POST_PROMOTION_REVIEW_DECISIONS,
  17. LITE_POST_PROMOTION_REVIEW_WINDOWS,
  18. apply_entry_specific_exit_overlay,
  19. build_empty_post_promotion_review,
  20. build_governance_context,
  21. build_post_promotion_review_window,
  22. evaluate_post_promotion_review,
  23. evaluate_runtime_health,
  24. executed_exposure_by_timing,
  25. resolve_execution_profile,
  26. resolve_rollback_reference_profile_id,
  27. run_backtest_with_execution,
  28. )
  29. def _safe_series(df: pd.DataFrame, column: str, default: float) -> pd.Series:
  30. if column not in df.columns:
  31. return pd.Series(default, index=df.index, dtype=float)
  32. return pd.to_numeric(df[column], errors='coerce').fillna(default)
  33. def _compute_lite_signals(df: pd.DataFrame) -> pd.DataFrame:
  34. out = df.copy()
  35. close = _safe_series(out, 'close', 0.0)
  36. ret_1 = close.pct_change().fillna(0.0)
  37. ma_20 = close.rolling(20, min_periods=5).mean()
  38. trend_score = close / ma_20 - 1.0
  39. stress_raw = ret_1.rolling(20, min_periods=5).std().fillna(0.0)
  40. stress_base = stress_raw.expanding(min_periods=20).median().replace(0.0, pd.NA).ffill()
  41. stress_score = (stress_raw / stress_base).fillna(1.0)
  42. breadth_20 = _safe_series(out, 'pct_constituents_above_20dma', 0.5)
  43. breadth_proxy = breadth_20 - 0.5
  44. drawdown_20 = close / close.rolling(20, min_periods=5).max() - 1.0
  45. out['trend_score_lite'] = trend_score.fillna(0.0)
  46. out['stress_score_lite'] = stress_score
  47. out['breadth_proxy_lite'] = breadth_proxy.fillna(0.0)
  48. out['drawdown_20_lite'] = drawdown_20.fillna(0.0)
  49. return out
  50. def _classify_state(
  51. row: pd.Series,
  52. *,
  53. risk_off_drawdown: float,
  54. risk_off_stress: float,
  55. trend_score_min: float,
  56. trend_breadth_min: float,
  57. trend_stress_max: float,
  58. ) -> str:
  59. if row['drawdown_20_lite'] <= risk_off_drawdown or row['stress_score_lite'] >= risk_off_stress:
  60. return 'risk_off'
  61. if (
  62. row['trend_score_lite'] >= trend_score_min
  63. and row['breadth_proxy_lite'] >= trend_breadth_min
  64. and row['stress_score_lite'] <= trend_stress_max
  65. ):
  66. return 'trend'
  67. return 'chop'
  68. def _bounded_targets(base_exposure: pd.Series, *, max_step: float, min_exposure: float = 0.0, max_exposure: float = 1.0) -> pd.Series:
  69. targets: list[float] = []
  70. prev = 0.0
  71. for value in base_exposure.fillna(0.0).astype(float):
  72. lower = max(min_exposure, prev - max_step)
  73. upper = min(max_exposure, prev + max_step)
  74. bounded = min(max(value, lower), upper)
  75. targets.append(float(bounded))
  76. prev = float(bounded)
  77. return pd.Series(targets, index=base_exposure.index, dtype=float)
  78. def _trend_reentry_speed(ledger: pd.DataFrame, threshold: float = 0.60) -> dict[str, float | None]:
  79. state = ledger['state'].fillna('chop').astype(str)
  80. executed = pd.to_numeric(ledger['executed_exposure'], errors='coerce').fillna(0.0)
  81. entry_mask = (state == 'trend') & (state.shift(1).fillna('chop') != 'trend')
  82. entry_positions = [int(pos) for pos in range(len(ledger)) if bool(entry_mask.iloc[pos])]
  83. if not entry_positions:
  84. return {
  85. 'trend_entry_event_count': 0,
  86. 'trend_entry_reached_count': 0,
  87. 'avg_days_to_reach_exposure_after_trend_entry': None,
  88. }
  89. delays: list[int] = []
  90. for start_pos in entry_positions:
  91. reached = executed.iloc[start_pos:]
  92. reached = reached[reached >= float(threshold)]
  93. if reached.empty:
  94. continue
  95. delays.append(int(reached.index.get_loc(reached.index[0])) + 0)
  96. delays[-1] = int(ledger.index.get_loc(reached.index[0])) - start_pos
  97. avg_delay = float(sum(delays) / len(delays)) if delays else None
  98. return {
  99. 'trend_entry_event_count': int(len(entry_positions)),
  100. 'trend_entry_reached_count': int(len(delays)),
  101. 'avg_days_to_reach_exposure_after_trend_entry': avg_delay,
  102. }
  103. def _window_info(df: pd.DataFrame, *, label: str | None = None) -> dict[str, Any]:
  104. info = {
  105. 'row_count': int(len(df)),
  106. 'date_start': df.index.min().date().isoformat() if len(df) else None,
  107. 'date_end': df.index.max().date().isoformat() if len(df) else None,
  108. }
  109. if label is not None:
  110. info['label'] = str(label)
  111. return info
  112. def _slice_review_windows(ledger: pd.DataFrame, window_sizes: tuple[int, ...]) -> list[dict[str, Any]]:
  113. windows: list[dict[str, Any]] = []
  114. for size in window_sizes:
  115. if len(ledger) < int(size):
  116. continue
  117. window_df = ledger.tail(int(size))
  118. windows.append({'label': f'recent_{int(size)}d', 'data': window_df, 'window': _window_info(window_df, label=f'recent_{int(size)}d')})
  119. return windows
  120. def _metrics_from_window(window_df: pd.DataFrame, config: dict[str, Any]) -> dict[str, Any]:
  121. metrics = compute_metrics(
  122. strategy_returns=window_df['strategy_return_net'],
  123. benchmark_returns=window_df['asset_exec_return'],
  124. turnover=window_df['turnover'],
  125. tracking_difference=window_df['tracking_difference'],
  126. annualization=int((config or {}).get('trading', {}).get('annualization', 252)),
  127. )
  128. return {
  129. **{k: float(v) for k, v in metrics.items()},
  130. **_trend_reentry_speed(window_df),
  131. }
  132. def _build_state_conditioned_view(
  133. active_window_df: pd.DataFrame,
  134. rollback_window_df: pd.DataFrame,
  135. config: dict[str, Any],
  136. *,
  137. window_label: str,
  138. ) -> dict[str, Any]:
  139. states = [str(state) for state in active_window_df['state'].dropna().astype(str).unique().tolist()]
  140. states = [state for state in ('risk_off', 'chop', 'trend') if state in states]
  141. if not states:
  142. return {
  143. 'view_name': 'state_conditioned_view',
  144. 'status': 'not_run',
  145. 'basis': 'lite_states_present_in_recent_primary_window',
  146. 'window_label': window_label,
  147. 'segments': [],
  148. }
  149. segments: list[dict[str, Any]] = []
  150. for state in states:
  151. active_state_df = active_window_df.loc[active_window_df['state'].astype(str) == state]
  152. rollback_state_df = rollback_window_df.loc[rollback_window_df['state'].astype(str) == state]
  153. if active_state_df.empty or rollback_state_df.empty:
  154. continue
  155. active_metrics = _metrics_from_window(active_state_df, config)
  156. rollback_metrics = _metrics_from_window(rollback_state_df, config)
  157. segments.append(
  158. {
  159. 'state': state,
  160. 'row_count': int(len(active_state_df)),
  161. 'active_metrics': active_metrics,
  162. 'rollback_reference_metrics': rollback_metrics,
  163. 'delta_vs_rollback_reference': {
  164. 'annual_return': (
  165. active_metrics['annual_return'] - rollback_metrics['annual_return']
  166. if active_metrics.get('annual_return') is not None and rollback_metrics.get('annual_return') is not None
  167. else None
  168. ),
  169. 'max_drawdown': (
  170. active_metrics['max_drawdown'] - rollback_metrics['max_drawdown']
  171. if active_metrics.get('max_drawdown') is not None and rollback_metrics.get('max_drawdown') is not None
  172. else None
  173. ),
  174. 'annual_turnover': (
  175. active_metrics['annual_turnover'] - rollback_metrics['annual_turnover']
  176. if active_metrics.get('annual_turnover') is not None and rollback_metrics.get('annual_turnover') is not None
  177. else None
  178. ),
  179. 'avg_days_to_reach_exposure_after_trend_entry': (
  180. active_metrics['avg_days_to_reach_exposure_after_trend_entry']
  181. - rollback_metrics['avg_days_to_reach_exposure_after_trend_entry']
  182. if active_metrics.get('avg_days_to_reach_exposure_after_trend_entry') is not None
  183. and rollback_metrics.get('avg_days_to_reach_exposure_after_trend_entry') is not None
  184. else None
  185. ),
  186. },
  187. }
  188. )
  189. return {
  190. 'view_name': 'state_conditioned_view',
  191. 'status': 'ok' if segments else 'not_run',
  192. 'basis': 'lite_states_present_in_recent_primary_window',
  193. 'window_label': window_label,
  194. 'segments': segments,
  195. }
  196. def _build_report_markdown(summary: dict[str, Any]) -> str:
  197. metrics = summary['metrics']
  198. execution_profile = summary['execution_profile']
  199. governance = summary['governance']
  200. runtime_health = summary.get('runtime_health', {})
  201. post_promotion_review = summary.get('post_promotion_review', {})
  202. lines = [
  203. '# Regime Lite Report',
  204. '',
  205. f"- input_path: `{summary['input']['pit_path']}`",
  206. f"- row_count: `{summary['input']['row_count']}`",
  207. f"- date_range: `{summary['input']['date_start']}` to `{summary['input']['date_end']}`",
  208. '',
  209. '## Execution Profile',
  210. f"- selected_profile_id: `{summary['selected_profile_id']}`",
  211. f"- source_variant_id: `{execution_profile['source_variant_id']}`",
  212. f"- timing_mode: `{execution_profile['timing_mode']}`",
  213. f"- overlay_mode: `{execution_profile['overlay_mode']}`",
  214. f"- adaptive_hold_mode: `{execution_profile['adaptive_hold_mode']}`",
  215. f"- adaptive_hold_context: `{json.dumps(execution_profile['adaptive_hold_context'], ensure_ascii=False, sort_keys=True)}`",
  216. '',
  217. '## Core Metrics',
  218. f"- annual_return: `{metrics['annual_return']:.4f}`",
  219. f"- max_drawdown: `{metrics['max_drawdown']:.4f}`",
  220. f"- sharpe: `{metrics['sharpe']:.4f}`",
  221. f"- annual_turnover: `{metrics['annual_turnover']:.4f}`",
  222. '',
  223. '## State Mix',
  224. f"- state_mix: `{json.dumps(summary['state_mix'], ensure_ascii=False, sort_keys=True)}`",
  225. '',
  226. '## Exposure',
  227. f"- mean_target_exposure: `{summary['mean_target_exposure']:.4f}`",
  228. f"- max_daily_step_observed: `{summary['max_daily_step_observed']:.4f}`",
  229. '',
  230. '## Governance',
  231. f"- active_profile_id: `{governance['active_profile_id']}`",
  232. f"- rollback_reference_profile_id: `{governance['rollback_reference_profile_id']}`",
  233. f"- operating_mode: `{governance['operating_mode']}`",
  234. '',
  235. '## Runtime Health',
  236. f"- status: `{runtime_health.get('status', 'unknown')}`",
  237. f"- recommended_action: `{runtime_health.get('recommended_action', 'unknown')}`",
  238. f"- reasons: `{json.dumps(runtime_health.get('reason_lines', []), ensure_ascii=False)}`",
  239. '',
  240. '## Post-Promotion Review',
  241. f"- decision: `{post_promotion_review.get('decision', 'not_run')}`",
  242. f"- remains_preferred_over_rollback_reference: `{post_promotion_review.get('remains_preferred_over_rollback_reference', False)}`",
  243. f"- evidence_context_split: `{json.dumps(post_promotion_review.get('evidence_context_split', {}), ensure_ascii=False, sort_keys=True)}`",
  244. f"- latest_window: `{json.dumps(post_promotion_review.get('latest_window', {}), ensure_ascii=False, sort_keys=True)}`",
  245. f"- review_windows: `{json.dumps(post_promotion_review.get('review_windows', []), ensure_ascii=False, sort_keys=True)}`",
  246. f"- segmented_diagnostics: `{json.dumps(post_promotion_review.get('segmented_diagnostics', {}), ensure_ascii=False, sort_keys=True)}`",
  247. f"- reasons: `{json.dumps(post_promotion_review.get('reason_lines', []), ensure_ascii=False)}`",
  248. ]
  249. return '\n'.join(lines) + '\n'
  250. def main() -> None:
  251. parser = argparse.ArgumentParser(description='Run a minimal 3-state regime pipeline for small-account operations.')
  252. parser.add_argument('--pit-csv', type=str, required=True, help='PIT CSV/parquet input with daily bars.')
  253. parser.add_argument('--output-dir', type=str, default='outputs/regime_lite', help='Output directory.')
  254. parser.add_argument(
  255. '--profile',
  256. type=str,
  257. default='baseline',
  258. choices=sorted(LITE_EXECUTION_PROFILES.keys()),
  259. help='Named lite execution profile to apply.',
  260. )
  261. parser.add_argument('--risk-off-exposure', type=float, default=0.0)
  262. parser.add_argument('--chop-exposure', type=float, default=0.35)
  263. parser.add_argument('--trend-exposure', type=float, default=0.80)
  264. parser.add_argument('--max-daily-step', type=float, default=0.20)
  265. parser.add_argument('--risk-off-drawdown', type=float, default=-0.08)
  266. parser.add_argument('--risk-off-stress', type=float, default=1.25)
  267. parser.add_argument('--trend-score-min', type=float, default=0.02)
  268. parser.add_argument('--trend-breadth-min', type=float, default=0.0)
  269. parser.add_argument('--trend-stress-max', type=float, default=0.85)
  270. parser.add_argument('--config', type=str, default=None, help='Optional config YAML path for trading settings.')
  271. args = parser.parse_args()
  272. output_dir = Path(args.output_dir)
  273. output_dir.mkdir(parents=True, exist_ok=True)
  274. raw = load_point_in_time_panel(args.pit_csv)
  275. if 'close' not in raw.columns:
  276. raise ValueError('PIT input must include close column for regime-lite runtime.')
  277. enriched = _compute_lite_signals(raw)
  278. enriched['state'] = enriched.apply(
  279. _classify_state,
  280. axis=1,
  281. risk_off_drawdown=float(args.risk_off_drawdown),
  282. risk_off_stress=float(args.risk_off_stress),
  283. trend_score_min=float(args.trend_score_min),
  284. trend_breadth_min=float(args.trend_breadth_min),
  285. trend_stress_max=float(args.trend_stress_max),
  286. )
  287. mapping = {
  288. 'risk_off': float(args.risk_off_exposure),
  289. 'chop': float(args.chop_exposure),
  290. 'trend': float(args.trend_exposure),
  291. }
  292. enriched['base_exposure'] = enriched['state'].map(mapping).fillna(float(args.chop_exposure)).astype(float)
  293. enriched['target_exposure'] = _bounded_targets(
  294. enriched['base_exposure'],
  295. max_step=float(args.max_daily_step),
  296. min_exposure=0.0,
  297. max_exposure=1.0,
  298. )
  299. profile = resolve_execution_profile(args.profile)
  300. rollback_reference_profile_id = resolve_rollback_reference_profile_id(str(profile['profile_id']))
  301. governance = build_governance_context(
  302. active_profile_id=str(profile['profile_id']),
  303. rollback_reference_profile_id=rollback_reference_profile_id,
  304. operating_mode='normal',
  305. decision_rationale_inputs={
  306. 'selected_profile_id': str(profile['profile_id']),
  307. 'source_variant_id': str(profile['source_variant_id']),
  308. 'timing_mode': str(profile['timing_mode']),
  309. 'overlay_mode': str(profile['overlay_mode']),
  310. 'adaptive_hold_mode': str(profile.get('adaptive_hold_mode', 'none')),
  311. },
  312. )
  313. target_overlay = apply_entry_specific_exit_overlay(
  314. enriched,
  315. enriched['target_exposure'],
  316. mode=str(profile['overlay_mode']),
  317. hold_days=int(profile['hold_days']),
  318. hold_floor=float(profile['hold_floor']),
  319. stop_drawdown=float(profile['stop_drawdown']),
  320. stop_stress=float(profile['stop_stress']),
  321. chop_exposure=float(args.chop_exposure),
  322. adaptive_hold_mode=str(profile.get('adaptive_hold_mode', 'none')),
  323. )
  324. target_final = _bounded_targets(
  325. target_overlay,
  326. max_step=float(args.max_daily_step),
  327. min_exposure=0.0,
  328. max_exposure=1.0,
  329. )
  330. executed = executed_exposure_by_timing(target_final, str(profile['timing_mode']))
  331. enriched['target_exposure'] = target_final
  332. config = load_config(args.config)
  333. ledger, metrics = run_backtest_with_execution(enriched, config, executed)
  334. ledger.index.name = 'date'
  335. active_health_metrics = {
  336. **{k: float(v) for k, v in metrics.items()},
  337. **_trend_reentry_speed(ledger),
  338. }
  339. rollback_ledger = None
  340. rollback_health_metrics = None
  341. if str(profile['profile_id']) != rollback_reference_profile_id:
  342. rollback_profile = resolve_execution_profile(rollback_reference_profile_id)
  343. rollback_overlay = apply_entry_specific_exit_overlay(
  344. enriched,
  345. enriched['target_exposure'],
  346. mode=str(rollback_profile['overlay_mode']),
  347. hold_days=int(rollback_profile['hold_days']),
  348. hold_floor=float(rollback_profile['hold_floor']),
  349. stop_drawdown=float(rollback_profile['stop_drawdown']),
  350. stop_stress=float(rollback_profile['stop_stress']),
  351. chop_exposure=float(args.chop_exposure),
  352. adaptive_hold_mode=str(rollback_profile.get('adaptive_hold_mode', 'none')),
  353. )
  354. rollback_target = _bounded_targets(
  355. rollback_overlay,
  356. max_step=float(args.max_daily_step),
  357. min_exposure=0.0,
  358. max_exposure=1.0,
  359. )
  360. rollback_executed = executed_exposure_by_timing(rollback_target, str(rollback_profile['timing_mode']))
  361. rollback_plan = enriched.copy()
  362. rollback_plan['target_exposure'] = rollback_target
  363. rollback_ledger, rollback_metrics = run_backtest_with_execution(rollback_plan, config, rollback_executed)
  364. rollback_health_metrics = {
  365. **{k: float(v) for k, v in rollback_metrics.items()},
  366. **_trend_reentry_speed(rollback_ledger),
  367. }
  368. runtime_health = evaluate_runtime_health(
  369. governance_context=governance,
  370. active_metrics=active_health_metrics,
  371. rollback_metrics=rollback_health_metrics,
  372. )
  373. post_promotion_review = build_empty_post_promotion_review(
  374. governance_context=governance,
  375. reason_lines=['PASS: active profile is already the rollback reference; post-promotion review not required.'],
  376. )
  377. if str(profile['profile_id']) != rollback_reference_profile_id and rollback_health_metrics is not None:
  378. recent_window_evidence = []
  379. rollback_review_plan = rollback_ledger if 'rollback_ledger' in locals() else None
  380. recent_primary_window_df = None
  381. rollback_recent_primary_window_df = None
  382. for window_spec in _slice_review_windows(ledger, LITE_POST_PROMOTION_REVIEW_WINDOWS):
  383. label = str(window_spec['label'])
  384. active_window_df = window_spec['data']
  385. active_window_metrics = _metrics_from_window(active_window_df, config)
  386. rollback_window_df = rollback_review_plan.tail(window_spec['window']['row_count'])
  387. rollback_window_metrics = _metrics_from_window(rollback_window_df, config)
  388. if recent_primary_window_df is None:
  389. recent_primary_window_df = active_window_df
  390. rollback_recent_primary_window_df = rollback_window_df
  391. recent_window_evidence.append(
  392. build_post_promotion_review_window(
  393. {
  394. **window_spec['window'],
  395. 'label': label,
  396. },
  397. active_window_metrics,
  398. rollback_window_metrics,
  399. )
  400. )
  401. full_history_reference = build_post_promotion_review_window(
  402. _window_info(ledger, label='full_history_reference'),
  403. active_health_metrics,
  404. rollback_health_metrics,
  405. )
  406. state_conditioned_view = _build_state_conditioned_view(
  407. recent_primary_window_df,
  408. rollback_recent_primary_window_df,
  409. config,
  410. window_label=str(recent_window_evidence[0]['window']['label']) if recent_window_evidence else 'recent_primary_window',
  411. )
  412. post_promotion_review = evaluate_post_promotion_review(
  413. governance_context=governance,
  414. recent_window_evidence=recent_window_evidence,
  415. full_history_reference=full_history_reference,
  416. state_conditioned_view=state_conditioned_view,
  417. )
  418. daily_path = output_dir / 'regime_lite_daily_ledger.csv'
  419. ledger.to_csv(daily_path)
  420. state_mix = ledger['state'].fillna('unknown').astype(str).value_counts(normalize=True).sort_index()
  421. target_diff = ledger['target_exposure'].diff().abs().fillna(0.0)
  422. summary = {
  423. 'input': {
  424. 'pit_path': str(args.pit_csv),
  425. 'row_count': int(len(ledger)),
  426. 'date_start': ledger.index.min().date().isoformat() if len(ledger) else None,
  427. 'date_end': ledger.index.max().date().isoformat() if len(ledger) else None,
  428. },
  429. 'selected_profile_id': str(profile['profile_id']),
  430. 'execution_profile': {
  431. 'source_variant_id': str(profile['source_variant_id']),
  432. 'timing_mode': str(profile['timing_mode']),
  433. 'overlay_mode': str(profile['overlay_mode']),
  434. 'adaptive_hold_mode': str(profile.get('adaptive_hold_mode', 'none')),
  435. 'adaptive_hold_context': dict(profile.get('adaptive_hold_context', {})),
  436. 'hold_days': int(profile['hold_days']),
  437. 'hold_floor': float(profile['hold_floor']),
  438. 'stop_drawdown': float(profile['stop_drawdown']),
  439. 'stop_stress': float(profile['stop_stress']),
  440. 'rollback_reference_profile_id': rollback_reference_profile_id,
  441. },
  442. 'governance': governance,
  443. 'params': {
  444. 'risk_off_exposure': float(args.risk_off_exposure),
  445. 'chop_exposure': float(args.chop_exposure),
  446. 'trend_exposure': float(args.trend_exposure),
  447. 'max_daily_step': float(args.max_daily_step),
  448. 'risk_off_drawdown': float(args.risk_off_drawdown),
  449. 'risk_off_stress': float(args.risk_off_stress),
  450. 'trend_score_min': float(args.trend_score_min),
  451. 'trend_breadth_min': float(args.trend_breadth_min),
  452. 'trend_stress_max': float(args.trend_stress_max),
  453. },
  454. 'metrics': {k: float(v) for k, v in metrics.items()},
  455. 'runtime_health': runtime_health,
  456. 'post_promotion_review': post_promotion_review,
  457. 'state_mix': {state: float(weight) for state, weight in state_mix.items()},
  458. 'mean_target_exposure': float(ledger['target_exposure'].mean()) if len(ledger) else 0.0,
  459. 'max_daily_step_observed': float(target_diff.max()) if len(target_diff) else 0.0,
  460. 'breadth_proxy_source': (
  461. 'pct_constituents_above_20dma' if 'pct_constituents_above_20dma' in raw.columns else 'neutral_fallback_0.5'
  462. ),
  463. }
  464. with (output_dir / 'regime_lite_summary.json').open('w', encoding='utf-8') as fh:
  465. json.dump(summary, fh, ensure_ascii=False, indent=2)
  466. with (output_dir / 'regime_lite_runtime_health.json').open('w', encoding='utf-8') as fh:
  467. json.dump(runtime_health, fh, ensure_ascii=False, indent=2)
  468. with (output_dir / 'regime_lite_post_promotion_review.json').open('w', encoding='utf-8') as fh:
  469. json.dump(post_promotion_review, fh, ensure_ascii=False, indent=2)
  470. (output_dir / 'regime_lite_report.md').write_text(_build_report_markdown(summary), encoding='utf-8')
  471. if __name__ == '__main__':
  472. main()