Skip to content

Commit abc222c

Browse files
authored
Validate live pool ranking order (#62)
1 parent 4b506f8 commit abc222c

4 files changed

Lines changed: 116 additions & 1 deletion

File tree

scripts/run_release_status_summary.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ def _safe_int(value: Any, default: int = 0) -> int:
7474
return default
7575

7676

77+
def _ranking_preview(ranking: pd.DataFrame, size: int) -> pd.DataFrame:
78+
preview_size = max(0, int(size))
79+
if preview_size == 0:
80+
return ranking.head(0)
81+
82+
if "current_rank" not in ranking.columns:
83+
return ranking.head(preview_size)
84+
85+
ordered = ranking.copy()
86+
ordered["_current_rank_numeric"] = pd.to_numeric(ordered["current_rank"], errors="coerce")
87+
if ordered["_current_rank_numeric"].notna().any():
88+
ordered = ordered.sort_values("_current_rank_numeric", na_position="last", kind="mergesort")
89+
return ordered.drop(columns=["_current_rank_numeric"], errors="ignore").head(preview_size)
90+
91+
7792
def build_release_status_payload(
7893
output_dir: Path | str,
7994
*,
@@ -103,7 +118,7 @@ def build_release_status_payload(
103118

104119
selected_mask = ranking["selected_flag"].map(_coerce_bool) if "selected_flag" in ranking.columns else pd.Series(dtype=bool)
105120
ranking_preview_rows = []
106-
preview = ranking.head(max(0, int(ranking_preview_size)))
121+
preview = _ranking_preview(ranking, ranking_preview_size)
107122
for _, row in preview.iterrows():
108123
ranking_preview_rows.append(
109124
{

src/release_contract.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,43 @@ def _coerce_selected_flag(series: pd.Series) -> pd.Series:
174174
)
175175

176176

177+
def _selected_symbols_ordered_by_rank(
178+
latest_ranking: pd.DataFrame,
179+
selected_mask: pd.Series,
180+
errors: list[str],
181+
) -> list[str]:
182+
selected_rows = latest_ranking.loc[selected_mask, ["symbol", "current_rank"]].copy()
183+
if selected_rows.empty:
184+
return []
185+
186+
selected_rows["_symbol_normalized"] = selected_rows["symbol"].astype(str).str.strip().str.upper()
187+
selected_rows["_current_rank_numeric"] = pd.to_numeric(selected_rows["current_rank"], errors="coerce")
188+
189+
invalid_rank_symbols = selected_rows.loc[
190+
selected_rows["_current_rank_numeric"].isna(),
191+
"_symbol_normalized",
192+
].tolist()
193+
if invalid_rank_symbols:
194+
errors.append(
195+
"latest_ranking.csv selected rows must have numeric current_rank values: "
196+
+ ", ".join(invalid_rank_symbols)
197+
)
198+
return []
199+
200+
duplicated_rank_symbols = selected_rows.loc[
201+
selected_rows["_current_rank_numeric"].duplicated(keep=False),
202+
"_symbol_normalized",
203+
].tolist()
204+
if duplicated_rank_symbols:
205+
errors.append(
206+
"latest_ranking.csv selected current_rank values must be unique: "
207+
+ ", ".join(duplicated_rank_symbols)
208+
)
209+
210+
ordered = selected_rows.sort_values("_current_rank_numeric", kind="mergesort")
211+
return ordered["_symbol_normalized"].tolist()
212+
213+
177214
def validate_release_outputs(
178215
output_dir: Path | str,
179216
*,
@@ -399,10 +436,17 @@ def validate_release_outputs(
399436
errors,
400437
)
401438
)
439+
selected_symbols_by_rank = _selected_symbols_ordered_by_rank(latest_ranking, selected_mask, errors)
402440
if live_pool_symbols and not set(live_pool_symbols).issubset(set(ranking_symbols)):
403441
errors.append("live_pool.json symbols must all be present in latest_ranking.csv")
404442
if live_pool_symbols and not set(live_pool_symbols).issubset(selected_symbols):
405443
errors.append("live_pool.json symbols must all be selected in latest_ranking.csv")
444+
if live_pool_symbols and selected_symbols_by_rank:
445+
expected_live_pool_symbols = selected_symbols_by_rank[: len(live_pool_symbols)]
446+
if live_pool_symbols != expected_live_pool_symbols:
447+
errors.append(
448+
"live_pool.json symbols must match selected latest_ranking.csv symbols ordered by current_rank"
449+
)
406450

407451
if manifest_present:
408452
manifest_mode = str(manifest.get("mode", "")).strip()

tests/test_release_contract.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,37 @@ def test_validate_release_outputs_rejects_mismatched_manifest_payload(self) -> N
217217
validation["errors"],
218218
)
219219

220+
def test_validate_release_outputs_rejects_live_pool_order_mismatch(self) -> None:
221+
with tempfile.TemporaryDirectory() as tmp_dir:
222+
root = Path(tmp_dir)
223+
self.build_outputs(root)
224+
output_dir = root / "data" / "output"
225+
live_pool_path = output_dir / "live_pool.json"
226+
artifact_manifest_path = output_dir / "artifact_manifest.json"
227+
228+
live_pool = json.loads(live_pool_path.read_text(encoding="utf-8"))
229+
live_pool["symbols"] = [
230+
"TRXUSDT",
231+
"ETHUSDT",
232+
"NEARUSDT",
233+
"BCHUSDT",
234+
"SOLUSDT",
235+
]
236+
write_json(live_pool_path, live_pool)
237+
238+
artifact_manifest = json.loads(artifact_manifest_path.read_text(encoding="utf-8"))
239+
artifact_manifest["symbols"] = live_pool["symbols"]
240+
artifact_manifest["artifacts"]["live_pool"]["sha256"] = sha256_file(live_pool_path)
241+
write_json(artifact_manifest_path, artifact_manifest)
242+
243+
validation = validate_release_outputs(root / "data" / "output", require_artifact_manifest=True)
244+
245+
self.assertFalse(validation["ok"])
246+
self.assertIn(
247+
"live_pool.json symbols must match selected latest_ranking.csv symbols ordered by current_rank",
248+
validation["errors"],
249+
)
250+
220251
def test_validate_release_outputs_rejects_stale_outputs_when_required(self) -> None:
221252
with tempfile.TemporaryDirectory() as tmp_dir:
222253
root = Path(tmp_dir)

tests/test_release_status_summary.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,31 @@ def test_build_release_status_payload_reports_ok_for_consistent_release(self) ->
176176
self.assertEqual(len(payload["artifact_summary"]["ranking_preview"]), 3)
177177
self.assertTrue(payload["validation"]["ok"])
178178

179+
def test_build_release_status_payload_orders_preview_by_current_rank(self) -> None:
180+
with tempfile.TemporaryDirectory() as tmp_dir:
181+
output_dir = self.write_outputs(Path(tmp_dir))
182+
ranking_path = output_dir / "latest_ranking.csv"
183+
ranking = pd.read_csv(ranking_path)
184+
ranking = ranking.iloc[[2, 0, 1, 3, 4]]
185+
ranking.to_csv(ranking_path, index=False)
186+
artifact_manifest_path = output_dir / "artifact_manifest.json"
187+
artifact_manifest = json.loads(artifact_manifest_path.read_text(encoding="utf-8"))
188+
artifact_manifest["artifacts"]["latest_ranking"]["sha256"] = sha256_file(ranking_path)
189+
write_json(artifact_manifest_path, artifact_manifest)
190+
191+
payload = MODULE.build_release_status_payload(
192+
output_dir,
193+
max_age_days=45,
194+
require_freshness=False,
195+
ranking_preview_size=3,
196+
reference_date="2026-04-01",
197+
)
198+
199+
self.assertEqual(
200+
[row["symbol"] for row in payload["artifact_summary"]["ranking_preview"]],
201+
["TRXUSDT", "ETHUSDT", "BCHUSDT"],
202+
)
203+
179204
def test_build_release_status_payload_reports_error_when_manifest_missing(self) -> None:
180205
with tempfile.TemporaryDirectory() as tmp_dir:
181206
output_dir = self.write_outputs(Path(tmp_dir), include_manifest=False)

0 commit comments

Comments
 (0)