Skip to content

Commit e8ed850

Browse files
authored
Merge pull request #168 from QuantStrategyLab/codex/automation-registry-closed-loop
[codex] Add strategy automation registry
2 parents 40634ab + b844cb4 commit e8ed850

2 files changed

Lines changed: 135 additions & 0 deletions

File tree

python/scripts/build_config.py

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
STRATEGY_PROFILES_ASSET = ROOT / "web" / "strategy-switch-console" / "strategy_profiles_asset.js"
2727
INDEX_HTML = ROOT / "web" / "strategy-switch-console" / "index.html"
2828
LIVE_CANDIDATE_QUEUE_STAGES = {"ai_monitored_candidate", "shadow_candidate", "live_candidate"}
29+
AUTOMATION_REGISTRY_SCHEMA_VERSION = "strategy_automation_registry.v1"
2930
CRITICAL_STRATEGY_PROFILE_FIELDS = {
3031
"profile",
3132
"domain",
@@ -168,6 +169,81 @@ def build_live_candidate_queue(strategy_catalog: object | None = None) -> list[d
168169
return sorted(queue, key=lambda item: (stage_rank.get(str(item["lifecycle_stage"]), 99), str(item["domain"]), str(item["profile"])))
169170

170171

172+
def _automation_policy_for_strategy(profile: str, strategy: dict) -> dict[str, object]:
173+
lifecycle_stage = str(strategy.get("lifecycle_stage") or "").strip()
174+
can_switch_live = strategy.get("can_switch_live") is True
175+
runtime_enabled = strategy.get("runtime_enabled") is True
176+
blocked_reason = str(strategy.get("blocked_live_reason") or "").strip()
177+
features = strategy.get("features") if isinstance(strategy.get("features"), dict) else {}
178+
if runtime_enabled and can_switch_live and lifecycle_stage == "runtime_enabled":
179+
lane = "live_equivalent_optimization"
180+
triggers = ["health_degradation", "parameter_drift", "scheduled_retest", "market_regime_shift"]
181+
max_autonomy = "auto_pr_or_trusted_live_equivalent"
182+
approval_required = False
183+
evidence_required = ["backtest", "shadow_or_regression", "rollback_plan"]
184+
elif lifecycle_stage == "live_candidate":
185+
lane = "promotion_review"
186+
triggers = ["evidence_package_ready", "shadow_outperformance"]
187+
max_autonomy = "human_review_required"
188+
approval_required = True
189+
evidence_required = ["live_candidate_evidence", "operator_approval"]
190+
elif lifecycle_stage in {"shadow_candidate", "ai_monitored_candidate"}:
191+
lane = "shadow_research"
192+
triggers = ["shadow_disagreement", "web_research_signal", "scheduled_retest"]
193+
max_autonomy = "auto_pr_research_only"
194+
approval_required = True
195+
evidence_required = ["shadow_metrics", "risk_review"]
196+
else:
197+
lane = "research_backlog"
198+
triggers = ["web_research_signal", "manual_request", "scheduled_research"]
199+
max_autonomy = "auto_pr_research_only"
200+
approval_required = True
201+
evidence_required = ["backtest", "design_review"]
202+
return {
203+
"profile": profile,
204+
"label": strategy.get("label_zh") or strategy.get("label") or profile,
205+
"domain": strategy.get("domain", ""),
206+
"lifecycle_stage": lifecycle_stage,
207+
"automation_lane": lane,
208+
"max_autonomy": max_autonomy,
209+
"approval_required": approval_required,
210+
"can_switch_live": can_switch_live,
211+
"blocked_live_reason": blocked_reason,
212+
"triggers": triggers,
213+
"evidence_required": evidence_required,
214+
"position_control_sensitive": bool(features.get("combo") or features.get("option_overlay")),
215+
}
216+
217+
218+
def build_strategy_automation_registry(config: dict | None = None) -> dict[str, object]:
219+
"""Build strategy-level automation policy for AIAuditBridge and management UIs."""
220+
config = config if config is not None else load_config()
221+
profiles = [
222+
_automation_policy_for_strategy(profile, strategy)
223+
for profile, strategy in sorted(config.get("strategies", {}).items())
224+
if isinstance(strategy, dict)
225+
]
226+
lane_counts: dict[str, int] = {}
227+
for item in profiles:
228+
lane = str(item["automation_lane"])
229+
lane_counts[lane] = lane_counts.get(lane, 0) + 1
230+
return {
231+
"schema_version": AUTOMATION_REGISTRY_SCHEMA_VERSION,
232+
"summary": {
233+
"strategy_profile_count": len(profiles),
234+
"lane_counts": lane_counts,
235+
"live_switchable_count": sum(1 for item in profiles if item["can_switch_live"]),
236+
"approval_required_count": sum(1 for item in profiles if item["approval_required"]),
237+
},
238+
"profiles": profiles,
239+
"guardrails": [
240+
"Do not auto-promote new or reconstructed strategies to live.",
241+
"Live-equivalent optimization still requires trusted proof before auto-merge.",
242+
"Position-control-sensitive changes require human review unless service proof explicitly narrows the change.",
243+
],
244+
}
245+
246+
171247
def report_strategy_profile_derivation_drift(config: dict, strategy_catalog: object | None = None) -> list[str]:
172248
"""Report whether the generated strategy profile catalog drifts from platform-config.json."""
173249
expected = strategy_to_json_compat(config.get("strategies", {}))
@@ -209,6 +285,7 @@ def build_platform_health_report(
209285
default_profile_errors = report_default_strategy_profile_drift(config, catalog)
210286
derivation_errors = report_strategy_profile_derivation_drift(config, catalog)
211287
live_candidate_queue = build_live_candidate_queue(catalog)
288+
automation_registry = build_strategy_automation_registry(config)
212289
runtime_enabled_profiles = [
213290
profile
214291
for profile, strategy in catalog.items()
@@ -264,8 +341,10 @@ def build_platform_health_report(
264341
"strategy_profile_count": len(catalog),
265342
"runtime_enabled_switchable_count": len(runtime_enabled_profiles),
266343
"live_candidate_queue_count": len(live_candidate_queue),
344+
"automation_lane_counts": automation_registry["summary"]["lane_counts"],
267345
},
268346
"live_candidate_queue": live_candidate_queue,
347+
"automation_registry": automation_registry,
269348
"codex_repair_context": {
270349
"safe_to_attempt": bool(failed_checks),
271350
"scope": "QuantRuntimeSettings platform-config and generated strategy switch assets",
@@ -503,13 +582,17 @@ def main() -> int:
503582
parser.add_argument("--check", action="store_true", help="Only validate config, don't write files")
504583
parser.add_argument("--live-candidate-queue", action="store_true", help="Print live-candidate queue JSON and exit")
505584
parser.add_argument("--platform-health-report", action="store_true", help="Print platform health report JSON and exit")
585+
parser.add_argument("--automation-registry", action="store_true", help="Print strategy automation registry JSON and exit")
506586
args = parser.parse_args()
507587

508588
config = load_config()
509589
if args.platform_health_report:
510590
report = build_platform_health_report(config)
511591
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
512592
return 0 if report["status"] != "unhealthy" else 1
593+
if args.automation_registry:
594+
print(json.dumps(build_strategy_automation_registry(config), ensure_ascii=False, indent=2, sort_keys=True))
595+
return 0
513596

514597
errors = validate(config)
515598
if args.check:

python/tests/test_runtime_settings.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,56 @@ def test_live_candidate_queue_cli_outputs_json_only(self):
215215
printed.assert_called_once()
216216
self.assertEqual(json.loads(printed.call_args.args[0]), [{"profile": "candidate"}])
217217

218+
def test_strategy_automation_registry_classifies_lanes(self):
219+
registry = build_config.build_strategy_automation_registry(
220+
{
221+
"strategies": {
222+
"live": {
223+
"label": "Live",
224+
"domain": "us_equity",
225+
"runtime_enabled": True,
226+
"lifecycle_stage": "runtime_enabled",
227+
"can_switch_live": True,
228+
"features": {"option_overlay": True},
229+
},
230+
"candidate": {
231+
"label": "Candidate",
232+
"domain": "cn_equity",
233+
"runtime_enabled": False,
234+
"lifecycle_stage": "live_candidate",
235+
"can_switch_live": False,
236+
"features": {},
237+
},
238+
"research": {
239+
"label": "Research",
240+
"domain": "crypto",
241+
"runtime_enabled": False,
242+
"lifecycle_stage": "research_backtest_only",
243+
"can_switch_live": False,
244+
"features": {},
245+
},
246+
},
247+
}
248+
)
249+
250+
lanes = {item["profile"]: item["automation_lane"] for item in registry["profiles"]}
251+
self.assertEqual(registry["schema_version"], "strategy_automation_registry.v1")
252+
self.assertEqual(lanes["live"], "live_equivalent_optimization")
253+
self.assertEqual(lanes["candidate"], "promotion_review")
254+
self.assertEqual(lanes["research"], "research_backlog")
255+
self.assertTrue(next(item for item in registry["profiles"] if item["profile"] == "live")["position_control_sensitive"])
256+
257+
def test_automation_registry_cli_outputs_json(self):
258+
with (
259+
patch.object(sys, "argv", ["build_config.py", "--automation-registry"]),
260+
patch.object(build_config, "load_config", return_value={"strategies": {}}),
261+
patch("builtins.print") as printed,
262+
):
263+
self.assertEqual(build_config.main(), 0)
264+
265+
printed.assert_called_once()
266+
self.assertEqual(json.loads(printed.call_args.args[0])["schema_version"], "strategy_automation_registry.v1")
267+
218268
def test_platform_health_report_summarizes_current_config(self):
219269
config = json.loads((ROOT / "platform-config.json").read_text(encoding="utf-8"))
220270
catalog = json.loads(
@@ -227,6 +277,8 @@ def test_platform_health_report_summarizes_current_config(self):
227277
self.assertEqual(report["schema_version"], "platform_health_report.v1")
228278
self.assertGreaterEqual(report["summary"]["runtime_enabled_switchable_count"], 1)
229279
self.assertIn("codex_repair_context", report)
280+
self.assertIn("automation_registry", report)
281+
self.assertIn("automation_lane_counts", report["summary"])
230282
self.assertIn("python3 python/scripts/build_config.py --check", report["codex_repair_context"]["suggested_commands"])
231283

232284
def test_platform_health_report_fails_on_default_profile_drift(self):

0 commit comments

Comments
 (0)