Skip to content

Commit d155d6b

Browse files
committed
fix: require monthly shadow build and deterministic tiebreak
1 parent bc863b2 commit d155d6b

7 files changed

Lines changed: 126 additions & 8 deletions

File tree

.github/workflows/monthly_publish.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,12 @@ jobs:
5252
- name: Download Or Update Raw History
5353
run: python scripts/download_history.py --top-liquid "${DOWNLOAD_TOP_LIQUID}" --force-exchange-info
5454

55-
- name: Build Production v1 Live Pool
56-
run: python scripts/build_live_pool.py --universe-mode "${PUBLISH_MODE}"
55+
- name: Build Production v1 Live Pool And Shadow Tracks
56+
run: |
57+
python scripts/run_monthly_shadow_build.py \
58+
--universe-mode "${PUBLISH_MODE}" \
59+
--shadow-universe-mode "${PUBLISH_MODE}" \
60+
--skip-publish-dry-run
5761
5862
- name: Publish Production v1 Release
5963
run: python scripts/publish_release.py --mode "${PUBLISH_MODE}"

README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,8 @@ The monthly operator workflow is now:
427427
2. run the baseline publish dry-run check
428428
3. refresh the dual-track shadow candidate histories
429429

430+
The GitHub monthly publish workflow now runs this shadow-build wrapper before the real publish step, so the monthly report and AI review always receive same-cycle `official_baseline` and `challenger_topk_60` coverage.
431+
430432
Canonical command:
431433

432434
```bash
@@ -463,6 +465,14 @@ Track identity fields to rely on:
463465

464466
Baseline remains the official production reference. `challenger_topk_60` remains shadow-only.
465467

468+
Monthly ranking tie-break rule for `core_major` live exports:
469+
470+
1. `final_score` descending
471+
2. `confidence` descending
472+
3. `liquidity_stability` descending
473+
4. `avg_quote_vol_180` descending
474+
5. `symbol` ascending
475+
466476
## Monthly Build Telegram Notify
467477

468478
Optional short build/publish health notification:

docs/operator_runbook.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ Operator-facing summary entrypoints:
6161
- `scripts/run_monthly_build_telegram.py` for the optional short Telegram health notification or local preview text
6262
- `scripts/run_monthly_report_bundle.py` for the standard monthly report bundle used by Actions artifacts and AI review handoff
6363
- `scripts/write_release_heartbeat.py` for the lightweight logs-branch heartbeat record
64+
- Monthly live-pool ordering uses a deterministic tie-break: `final_score`, then `confidence`, then `liquidity_stability`, then `avg_quote_vol_180`, then `symbol`
6465

6566
Boundary rules:
6667

src/export.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import pandas as pd
66

7+
from .ranking import sort_ranking_snapshot
78
from .utils import date_to_str, write_json
89

910

@@ -20,6 +21,7 @@ def export_latest_ranking(panel: pd.DataFrame, output_dir: str | Any, as_of_date
2021
"""Export the latest ranking cross section to CSV."""
2122
snapshot = panel.xs(as_of_date, level="date").copy()
2223
snapshot = snapshot.loc[snapshot["in_universe"] | snapshot["selected_flag"]].copy()
24+
snapshot = sort_ranking_snapshot(snapshot)
2325
snapshot["as_of_date"] = date_to_str(as_of_date)
2426
snapshot["symbol"] = snapshot.index
2527
columns = [
@@ -34,7 +36,7 @@ def export_latest_ranking(panel: pd.DataFrame, output_dir: str | Any, as_of_date
3436
"selected_flag",
3537
"current_rank",
3638
]
37-
exported = snapshot[columns].sort_values("final_score", ascending=False).reset_index(drop=True)
39+
exported = snapshot[columns].reset_index(drop=True)
3840
exported.to_csv(output_dir / "latest_ranking.csv", index=False)
3941
return exported
4042

@@ -62,7 +64,7 @@ def build_live_pool_payload(
6264
selection_meta_fields: list[str] | None = None,
6365
) -> tuple[dict[str, Any], dict[str, Any]]:
6466
"""Build additive live-pool payloads without performing I/O."""
65-
selected = ranking_snapshot.sort_values("final_score", ascending=False).head(pool_size).copy()
67+
selected = sort_ranking_snapshot(ranking_snapshot).head(pool_size).copy()
6668
symbols = selected.index.tolist()
6769
metadata_indexed = metadata.set_index("symbol")
6870
as_of_date_str = date_to_str(as_of_date)

src/ranking.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,25 @@
99
from .utils import normalize_component_by_date
1010

1111

12+
def sort_ranking_snapshot(snapshot: pd.DataFrame) -> pd.DataFrame:
13+
"""Apply a deterministic ranking order with explicit tie-breaks."""
14+
ordered = snapshot.copy()
15+
added_columns: list[str] = []
16+
for column in ("confidence", "liquidity_stability", "avg_quote_vol_180"):
17+
if column not in ordered.columns:
18+
ordered[column] = np.nan
19+
added_columns.append(column)
20+
21+
ordered["_sort_symbol"] = pd.Index(ordered.index).astype(str).str.upper()
22+
ordered = ordered.sort_values(
23+
["final_score", "confidence", "liquidity_stability", "avg_quote_vol_180", "_sort_symbol"],
24+
ascending=[False, False, False, False, True],
25+
na_position="last",
26+
kind="mergesort",
27+
)
28+
return ordered.drop(columns=["_sort_symbol", *added_columns], errors="ignore")
29+
30+
1231
def merge_predictions(panel: pd.DataFrame, predictions: pd.DataFrame) -> pd.DataFrame:
1332
"""Attach model prediction columns to the main panel."""
1433
if predictions.empty:
@@ -68,9 +87,9 @@ def build_final_scores(panel: pd.DataFrame, config: dict[str, Any]) -> pd.DataFr
6887
eligible = group.loc[group["in_universe"] & group["final_score"].notna()].copy()
6988
if eligible.empty:
7089
continue
71-
ranks = eligible["final_score"].rank(ascending=False, method="first")
72-
panel.loc[ranks.index, "current_rank"] = ranks
73-
selected = eligible["final_score"].nlargest(pool_size)
90+
ordered = sort_ranking_snapshot(eligible)
91+
panel.loc[ordered.index, "current_rank"] = np.arange(1, len(ordered) + 1, dtype=float)
92+
selected = ordered.head(pool_size)
7493
panel.loc[selected.index, "selected_flag"] = True
7594

7695
if "prediction_window_count" in panel.columns:
@@ -87,5 +106,5 @@ def latest_ranking_snapshot(panel: pd.DataFrame, as_of_date: pd.Timestamp | str)
87106
"""Return one date slice sorted by the current final score."""
88107
snapshot = panel.xs(pd.Timestamp(as_of_date), level="date").copy()
89108
if "final_score" in snapshot.columns:
90-
snapshot = snapshot.sort_values("final_score", ascending=False)
109+
snapshot = sort_ranking_snapshot(snapshot)
91110
return snapshot

tests/test_monthly_publish_workflow_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ def test_monthly_review_issue_creation_does_not_require_gh_cli(self) -> None:
2727
self.assertNotIn("gh label create", workflow)
2828
self.assertNotIn("gh issue create", workflow)
2929
self.assertNotIn("gh workflow run", workflow)
30+
self.assertIn("run_monthly_shadow_build.py", workflow)
31+
self.assertIn("--skip-publish-dry-run", workflow)
32+
self.assertIn("--shadow-universe-mode", workflow)
3033
self.assertIn("https://api.github.com/repos/{repository}", workflow)
3134
self.assertIn('GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}', workflow)
3235
self.assertIn("issue_number=", workflow)

tests/test_ranking.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
from __future__ import annotations
2+
3+
import unittest
4+
from unittest.mock import patch
5+
6+
import pandas as pd
7+
8+
from src.export import build_live_pool_payload
9+
from src.ranking import build_final_scores, latest_ranking_snapshot
10+
11+
12+
class RankingTieBreakTests(unittest.TestCase):
13+
def test_tie_break_prefers_confidence_then_liquidity_then_symbol(self) -> None:
14+
as_of_date = pd.Timestamp("2026-04-01")
15+
index = pd.MultiIndex.from_tuples(
16+
[
17+
(as_of_date, "AAAUSDT"),
18+
(as_of_date, "BBBUSDT"),
19+
(as_of_date, "CCCUSDT"),
20+
],
21+
names=["date", "symbol"],
22+
)
23+
panel = pd.DataFrame(
24+
{
25+
"in_universe": [True, True, True],
26+
"rule_score": [1.0, 0.5, 0.5],
27+
"linear_score_raw": [0.0, 0.5, 0.5],
28+
"ml_score_raw": [0.5, 0.5, 0.5],
29+
"regime": ["risk_off", "risk_off", "risk_off"],
30+
"liquidity_stability": [0.70, 0.90, 0.80],
31+
"avg_quote_vol_180": [20_000_000.0, 30_000_000.0, 30_000_000.0],
32+
},
33+
index=index,
34+
)
35+
config = {
36+
"ensemble": {"default_weights": {"rule_score": 1.0, "linear_score": 1.0, "ml_score": 1.0}},
37+
"regime_weights": {},
38+
"ranking": {"selected_pool_size": 2},
39+
}
40+
41+
with patch("src.ranking.normalize_component_by_date", side_effect=lambda frame, column, mask: frame[column]):
42+
scored = build_final_scores(panel, config)
43+
44+
snapshot = latest_ranking_snapshot(scored, as_of_date)
45+
self.assertEqual(snapshot.index.tolist(), ["BBBUSDT", "CCCUSDT", "AAAUSDT"])
46+
self.assertEqual(snapshot["current_rank"].tolist(), [1.0, 2.0, 3.0])
47+
self.assertEqual(snapshot.loc[snapshot["selected_flag"]].index.tolist(), ["BBBUSDT", "CCCUSDT"])
48+
49+
def test_live_pool_payload_uses_same_deterministic_tie_break(self) -> None:
50+
as_of_date = pd.Timestamp("2026-04-01")
51+
ranking_snapshot = pd.DataFrame(
52+
{
53+
"final_score": [0.5, 0.5, 0.5],
54+
"confidence": [0.6, 0.6, 0.6],
55+
"liquidity_stability": [0.80, 0.80, 0.80],
56+
"avg_quote_vol_180": [15_000_000.0, 25_000_000.0, 25_000_000.0],
57+
},
58+
index=pd.Index(["CCCUSDT", "BBBUSDT", "AAAUSDT"], name="symbol"),
59+
)
60+
metadata = pd.DataFrame(
61+
{
62+
"symbol": ["AAAUSDT", "BBBUSDT", "CCCUSDT"],
63+
"base_asset": ["AAA", "BBB", "CCC"],
64+
}
65+
)
66+
67+
payload, _ = build_live_pool_payload(
68+
ranking_snapshot=ranking_snapshot,
69+
metadata=metadata,
70+
as_of_date=as_of_date,
71+
pool_size=2,
72+
mode="core_major",
73+
)
74+
75+
self.assertEqual(payload["symbols"], ["AAAUSDT", "BBBUSDT"])
76+
77+
78+
if __name__ == "__main__":
79+
unittest.main()

0 commit comments

Comments
 (0)