Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/operator_runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions scripts/prepare_auto_optimization_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion scripts/render_monthly_ai_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 43 additions & 0 deletions scripts/run_monthly_report_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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("")
Expand Down
26 changes: 23 additions & 3 deletions tests/test_monthly_report_bundle.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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:
Expand All @@ -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)
17 changes: 17 additions & 0 deletions tests/test_prepare_auto_optimization_pr.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion tests/test_render_monthly_ai_review.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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__":
Expand Down