diff --git a/README.md b/README.md index 5938b7c..d9bacb1 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,8 @@ On the 1st of each month (UTC 00:00), the **Monthly Execution Report** workflow An **AI Monthly Review** workflow triggers on that issue label and posts a bilingual (English + Chinese) analysis covering trade execution quality, no-trade / gating reasons, circuit breaker events, degraded mode episodes, PnL breakdown, upstream pool impact, error patterns, and earn buffer efficiency. +Structured review output can feed monthly optimization task issues. The auto optimization workflow only edits low-risk `auto-pr-safe` tasks, opens a PR, and leaves production execution changes behind manual review guardrails. Ready automation PRs are auto-merged only after CI succeeds and the merge guard confirms no sensitive runtime files were changed. + ### Workflows | Workflow | File | Trigger | Runner | @@ -106,6 +108,8 @@ An **AI Monthly Review** workflow triggers on that issue label and posts a bilin | CI | `ci.yml` | push to main | ubuntu-latest | | Monthly Report | `monthly_report.yml` | 1st of month + manual | ubuntu-latest | | AI Review | `ai_review.yml` | issue labeled `monthly-review` | ubuntu-latest | +| Auto Optimization PR | `auto_optimization_pr.yml` | issue labeled `monthly-optimization-task` + manual | ubuntu-latest | +| Auto Merge Optimization PR | `auto_merge_optimization_pr.yml` | successful CI workflow run | ubuntu-latest | ### Required Secrets @@ -115,6 +119,7 @@ An **AI Monthly Review** workflow triggers on that issue label and posts a bilin | `BINANCE_API_SECRET` | Runtime | | `TG_TOKEN` | Runtime | | `ANTHROPIC_API_KEY` | AI Review | +| `OPENAI_API_KEY` | AI Review secondary pass | ## Strategy Overview @@ -446,6 +451,7 @@ This emits a structured JSON report with: - BTC DCA intents - earn subscribe/redeem intents - explicit gating / no-trade reason counts +- zero-trade diagnostics grouped by strategy sleeve and gate - suppressed vs executed side-effect counts ### Tests diff --git a/docs/operator_runbook.md b/docs/operator_runbook.md index fbb2177..b7d61ad 100644 --- a/docs/operator_runbook.md +++ b/docs/operator_runbook.md @@ -52,6 +52,7 @@ Runtime output should stay operational: - upstream official pool and current local execution pool logged as separate concepts - current execution targets and intents - explicit gating / no-trade reasons and side-effect suppression counts +- zero-trade diagnostics grouped by BTC core / trend sleeve and gate - exceptions, circuit breakers, and alert-worthy failures The monthly execution pool is locked to the accepted upstream `version` / `as_of_date`. It is rebuilt when upstream release metadata changes and otherwise reused across cycles. diff --git a/scripts/prepare_auto_optimization_pr.py b/scripts/prepare_auto_optimization_pr.py index a2f876b..acd7811 100644 --- a/scripts/prepare_auto_optimization_pr.py +++ b/scripts/prepare_auto_optimization_pr.py @@ -180,8 +180,8 @@ def _is_completed_low_risk_task(action: dict[str, Any], repo_root: Path) -> bool ): monthly_report = _read_text(repo_root / "scripts" / "run_monthly_report_bundle.py") return ( - "No explicit gating or no-trade reasons were recorded this month." in monthly_report - and "gating_summary" in monthly_report + "## Zero-Trade Diagnostics" in monthly_report + and "by_category_and_gate" in monthly_report ) return False diff --git a/scripts/render_monthly_ai_review.py b/scripts/render_monthly_ai_review.py index e010782..7e7cbde 100644 --- a/scripts/render_monthly_ai_review.py +++ b/scripts/render_monthly_ai_review.py @@ -77,7 +77,12 @@ def build_full_review_markdown( primary_title: str, secondary_review_payload: dict[str, Any] | None = None, ) -> str: - lines = [f"## {primary_title}", "", primary_review_text.strip()] + normalized_primary_review = primary_review_text.strip() + duplicate_title = f"## {primary_title}" + if normalized_primary_review.startswith(duplicate_title): + normalized_primary_review = normalized_primary_review[len(duplicate_title) :].lstrip() + + lines = [f"## {primary_title}", "", normalized_primary_review] if secondary_review_payload is not None: lines.extend(["", "---", "", render_secondary_review_markdown(secondary_review_payload)]) return "\n".join(lines).strip() + "\n" diff --git a/scripts/run_monthly_report_bundle.py b/scripts/run_monthly_report_bundle.py index ed8c2eb..0b511b6 100644 --- a/scripts/run_monthly_report_bundle.py +++ b/scripts/run_monthly_report_bundle.py @@ -65,6 +65,8 @@ def aggregate_hourly_reports(hourly_dir: str, report_month: str) -> dict[str, An executed_side_effects = 0 suppressed_side_effects = 0 gating_counts: dict[str, int] = {} + gating_counts_by_category: dict[str, int] = {} + gating_counts_by_category_and_gate: dict[str, dict[str, int]] = {} # --- trade summary accumulators --- btc_buys = 0 @@ -123,6 +125,13 @@ def aggregate_hourly_reports(hourly_dir: str, report_month: str) -> dict[str, An gate_name = str(gate) gating_counts[gate_name] = gating_counts.get(gate_name, 0) + int(count or 0) + for event in report.get("gating_events", []) or []: + gate_name = str((event or {}).get("gate", "")).strip() or "unknown_gate" + category_name = str((event or {}).get("category", "")).strip() or "uncategorized" + gating_counts_by_category[category_name] = gating_counts_by_category.get(category_name, 0) + 1 + category_bucket = gating_counts_by_category_and_gate.setdefault(category_name, {}) + category_bucket[gate_name] = category_bucket.get(gate_name, 0) + 1 + # BTC DCA intents for intent in report.get("btc_dca_intents", []) or []: side = (intent.get("side") or "").upper() @@ -197,6 +206,14 @@ def aggregate_hourly_reports(hourly_dir: str, report_month: str) -> dict[str, An "execution_gating": { "total_events": int(sum(gating_counts.values())), "counts": dict(sorted(gating_counts.items())), + "by_category": { + category: int(count) + for category, count in sorted(gating_counts_by_category.items()) + }, + "by_category_and_gate": { + category: dict(sorted(gates.items(), key=lambda item: (-item[1], item[0]))) + for category, gates in sorted(gating_counts_by_category_and_gate.items()) + }, }, "trade_summary": { "btc_core": { @@ -305,6 +322,32 @@ def format_review_markdown(bundle: dict[str, Any]) -> str: lines.append("_No explicit gating or no-trade reasons were recorded this month._") lines.append("") + lines.append("## Zero-Trade Diagnostics") + lines.append("") + zero_trade_sections = [ + ("btc_dca", "BTC Core (DCA)", trade["btc_core"]), + ("trend", "Trend Rotation", trade["trend_rotation"]), + ] + for category_name, heading, trade_bucket in zero_trade_sections: + lines.append(f"### {heading}") + lines.append("") + lines.append("| Metric | Value |") + lines.append("|--------|-------|") + lines.append(f"| Recorded buys | {trade_bucket['buys']} |") + lines.append(f"| Recorded sells | {trade_bucket['sells']} |") + lines.append(f"| Recorded USDT | {trade_bucket['total_usdt']} |") + lines.append("") + + gate_counts = dict(gating.get("by_category_and_gate", {}).get(category_name, {})) + if gate_counts: + lines.append("| Gate | Count |") + lines.append("|------|-------|") + for gate_name, count in sorted(gate_counts.items(), key=lambda item: (-item[1], item[0])): + lines.append(f"| {gate_name} | {count} |") + else: + lines.append("_No explicit no-trade reasons were recorded for this sleeve this month._") + lines.append("") + # PnL overview lines.append("## PnL Overview") lines.append("") diff --git a/tests/test_monthly_report_bundle.py b/tests/test_monthly_report_bundle.py index b95ee23..4b17d5e 100644 --- a/tests/test_monthly_report_bundle.py +++ b/tests/test_monthly_report_bundle.py @@ -10,7 +10,7 @@ def _make_report(self, run_id, status="ok", total_equity=1000.0, degraded_level=None, pool_symbols=None, buy_sell_intents=None, btc_dca_intents=None, redemption_intents=None, errors=None, - gating_summary=None, dry_run=False, + gating_summary=None, gating_events=None, dry_run=False, executed_calls=0, suppressed_calls=0): return { "status": status, @@ -33,7 +33,7 @@ def _make_report(self, run_id, status="ok", total_equity=1000.0, "notifications": [], "state_write_intents": [], "gating_summary": gating_summary or {}, - "gating_events": [], + "gating_events": gating_events or [], "side_effect_summary": { "executed_call_count": executed_calls, "suppressed_call_count": suppressed_calls, @@ -128,7 +128,13 @@ def test_format_review_markdown(self): from scripts.run_monthly_report_bundle import aggregate_hourly_reports, format_review_markdown reports = { - "2026-03-01T0000.json": self._make_report("r1"), + "2026-03-01T0000.json": self._make_report( + "r1", + gating_events=[ + {"gate": "btc_dca_pool_too_small", "category": "btc_dca"}, + {"gate": "trend_no_selected_candidate", "category": "trend"}, + ], + ), } with tempfile.TemporaryDirectory() as td: hourly_dir = os.path.join(td, "hourly", "2026-03") @@ -147,6 +153,9 @@ def test_format_review_markdown(self): self.assertIn("external balance flows", md) self.assertIn("recorded strategy intents", md) self.assertIn("Execution Gating / No-Trade Reasons", md) + self.assertIn("Zero-Trade Diagnostics", md) + self.assertIn("btc_dca_pool_too_small", md) + self.assertIn("trend_no_selected_candidate", md) def test_aggregate_gating_and_side_effects(self): from scripts.run_monthly_report_bundle import aggregate_hourly_reports, format_review_markdown @@ -158,12 +167,20 @@ def test_aggregate_gating_and_side_effects(self): executed_calls=1, suppressed_calls=3, gating_summary={"trend_buy_below_min_budget": 2}, + gating_events=[ + {"gate": "trend_buy_below_min_budget", "category": "trend"}, + {"gate": "trend_buy_below_min_budget", "category": "trend"}, + ], ), "2026-03-01T0100.json": self._make_report( "r2", executed_calls=2, suppressed_calls=0, gating_summary={"btc_dca_pool_too_small": 1, "trend_buy_below_min_budget": 1}, + gating_events=[ + {"gate": "btc_dca_pool_too_small", "category": "btc_dca"}, + {"gate": "trend_buy_below_min_budget", "category": "trend"}, + ], ), } with tempfile.TemporaryDirectory() as td: @@ -181,5 +198,8 @@ def test_aggregate_gating_and_side_effects(self): self.assertEqual(bundle["side_effect_summary"]["suppressed_call_count"], 3) self.assertEqual(bundle["execution_gating"]["counts"]["trend_buy_below_min_budget"], 3) self.assertEqual(bundle["execution_gating"]["counts"]["btc_dca_pool_too_small"], 1) + self.assertEqual(bundle["execution_gating"]["by_category"]["trend"], 3) + self.assertEqual(bundle["execution_gating"]["by_category"]["btc_dca"], 1) self.assertIn("trend_buy_below_min_budget", md) + self.assertIn("Zero-Trade Diagnostics", md) self.assertIn("Dry-run runs", md) diff --git a/tests/test_prepare_auto_optimization_pr.py b/tests/test_prepare_auto_optimization_pr.py index 5770376..2132d46 100644 --- a/tests/test_prepare_auto_optimization_pr.py +++ b/tests/test_prepare_auto_optimization_pr.py @@ -60,6 +60,23 @@ def test_build_payload_keeps_runtime_sensitive_low_risk_task_as_draft_only(self) self.assertFalse(payload["task_level_auto_merge_allowed"]) self.assertIn("guarded_keyword:dca", payload["draft_only_actions"][0]["auto_merge_blocker"]) + def test_completion_check_does_not_treat_old_gating_summary_as_zero_trade_diagnostics(self) -> None: + with tempfile.TemporaryDirectory() as temp_dir: + repo_root = Path(temp_dir) / "BinancePlatform" + scripts_dir = repo_root / "scripts" + scripts_dir.mkdir(parents=True) + (scripts_dir / "run_monthly_report_bundle.py").write_text( + "gating_summary = {}\n" + "message = 'No explicit gating or no-trade reasons were recorded this month.'\n", + encoding="utf-8", + ) + + payload = build_payload(self.issue_context, repo_root=repo_root) + + self.assertTrue(payload["should_run"]) + self.assertEqual(payload["safe_task_count"], 1) + self.assertEqual(payload["skipped_task_count"], 0) + def test_build_payload_marks_readme_note_as_auto_merge_candidate(self) -> None: issue_context = { "number": 30, diff --git a/tests/test_render_monthly_ai_review.py b/tests/test_render_monthly_ai_review.py index 8239ad7..8944eda 100644 --- a/tests/test_render_monthly_ai_review.py +++ b/tests/test_render_monthly_ai_review.py @@ -36,7 +36,7 @@ def test_render_secondary_review_markdown_includes_actions_and_flags(self) -> No def test_build_full_review_markdown_includes_primary_and_secondary_sections(self) -> None: markdown = build_full_review_markdown( - "## English\nPrimary review", + "## Claude Primary Review\n\n## English\nPrimary review", primary_title="Claude Primary Review", secondary_review_payload={ "provider_display_name": "GPT Secondary Review", @@ -53,6 +53,7 @@ def test_build_full_review_markdown_includes_primary_and_secondary_sections(self self.assertIn("## Claude Primary Review", markdown) self.assertIn("## Secondary Review (GPT Secondary Review)", markdown) self.assertIn("## English", markdown) + self.assertEqual(markdown.count("## Claude Primary Review"), 1) if __name__ == "__main__":