162 lines
5.1 KiB
Python
162 lines
5.1 KiB
Python
from web.scan_terminal_filters import normalize_scan_terminal_filters
|
|
from web.scan_terminal_metar_gate import _apply_metar_gate_to_row
|
|
from web.scan_terminal_payloads import (
|
|
build_failed_scan_terminal_payload,
|
|
build_scan_terminal_snapshot_id,
|
|
build_stale_scan_terminal_payload,
|
|
)
|
|
from web.scan_terminal_ranker import build_ranked_scan_terminal_result
|
|
from web.routers.scan import router as scan_router
|
|
|
|
|
|
def test_scan_router_does_not_expose_terminal_ai_endpoint():
|
|
routes = {
|
|
getattr(route, "path", None): getattr(route, "methods", set())
|
|
for route in scan_router.routes
|
|
}
|
|
|
|
assert "/api/scan/terminal/ai" not in routes
|
|
|
|
|
|
def test_normalize_scan_terminal_filters_clamps_and_swaps_bounds():
|
|
filters = normalize_scan_terminal_filters(
|
|
{
|
|
"min_price": 1.2,
|
|
"max_price": -0.2,
|
|
"limit": 999,
|
|
"high_liquidity_only": True,
|
|
"min_liquidity": 100,
|
|
"timezone_offset_seconds": "28800",
|
|
}
|
|
)
|
|
|
|
assert filters["min_price"] == 0.0
|
|
assert filters["max_price"] == 1.0
|
|
assert filters["limit"] == 200
|
|
assert filters["min_liquidity"] == 5000.0
|
|
assert filters["timezone_offset_seconds"] == 28800
|
|
|
|
|
|
def test_ranked_scan_terminal_result_sorts_and_summarizes_unique_markets():
|
|
result = build_ranked_scan_terminal_result(
|
|
city_results=[
|
|
{
|
|
"candidate_total": 2,
|
|
"primary_scores": [80.0],
|
|
"rows": [
|
|
{
|
|
"id": "low",
|
|
"market_key": "m1",
|
|
"final_score": 70.0,
|
|
"edge_percent": 4.0,
|
|
"volume": 100,
|
|
},
|
|
{
|
|
"id": "high",
|
|
"market_key": "m1",
|
|
"final_score": 90.0,
|
|
"edge_percent": 2.0,
|
|
"volume": 250,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
"candidate_total": 1,
|
|
"primary_scores": [60.0],
|
|
"rows": [
|
|
{
|
|
"id": "tie-break",
|
|
"market_key": "m2",
|
|
"final_score": 90.0,
|
|
"edge_percent": 5.0,
|
|
"volume": 300,
|
|
}
|
|
],
|
|
},
|
|
],
|
|
filters={"limit": 2},
|
|
total_city_count=3,
|
|
failed_city_count=1,
|
|
)
|
|
|
|
assert [row["id"] for row in result["ranked_rows"]] == ["tie-break", "high"]
|
|
assert [row["rank"] for row in result["ranked_rows"]] == [1, 2]
|
|
assert result["top_signal"]["id"] == "tie-break"
|
|
assert result["summary"]["candidate_total"] == 3
|
|
assert result["summary"]["visible_count"] == 2
|
|
assert result["summary"]["tradable_market_count"] == 2
|
|
assert result["summary"]["total_volume"] == 550
|
|
assert result["summary"]["failed_city_count"] == 1
|
|
|
|
|
|
def test_scan_terminal_payload_helpers_preserve_stale_and_failed_shape():
|
|
success_payload = {
|
|
"generated_at": "2026-04-28T00:00:00Z",
|
|
"snapshot_id": "scan-old",
|
|
"filters": {"scan_mode": "tradable"},
|
|
"rows": [{"id": "row-1"}],
|
|
}
|
|
|
|
stale = build_stale_scan_terminal_payload(
|
|
filters={"scan_mode": "trend"},
|
|
success_payload=success_payload,
|
|
error_message="refresh failed",
|
|
failed_at="2026-04-28T00:01:00Z",
|
|
)
|
|
failed = build_failed_scan_terminal_payload(
|
|
filters={"scan_mode": "trend"},
|
|
error_message="network down",
|
|
failed_at="2026-04-28T00:02:00Z",
|
|
)
|
|
|
|
assert stale["status"] == "stale"
|
|
assert stale["stale"] is True
|
|
assert stale["rows"] == [{"id": "row-1"}]
|
|
assert stale["filters"] == {"scan_mode": "trend"}
|
|
assert stale["last_success_at"] == "2026-04-28T00:00:00Z"
|
|
assert failed["status"] == "failed"
|
|
assert failed["summary"]["candidate_total"] == 0
|
|
assert failed["rows"] == []
|
|
|
|
|
|
def test_scan_terminal_snapshot_id_is_stable_for_same_ranked_inputs():
|
|
summary = {
|
|
"candidate_total": 2,
|
|
"tradable_market_count": 2,
|
|
"avg_edge_percent": 3.5,
|
|
}
|
|
rows = [
|
|
{"id": "a", "edge_percent": 4.0, "final_score": 90.0},
|
|
{"id": "b", "edge_percent": 3.0, "final_score": 80.0},
|
|
]
|
|
|
|
first = build_scan_terminal_snapshot_id({"limit": 2}, rows, summary, rows[0])
|
|
second = build_scan_terminal_snapshot_id({"limit": 2}, rows, summary, rows[0])
|
|
|
|
assert first == second
|
|
assert first.startswith("scan-")
|
|
|
|
|
|
def test_metar_gate_vetoes_yes_when_observed_breaks_above_bucket():
|
|
row = {
|
|
"id": "yes-row",
|
|
"side": "yes",
|
|
"target_lower": 32.0,
|
|
"target_upper": 34.0,
|
|
"target_unit": "°C",
|
|
"metar_context": {
|
|
"obs_count": 6,
|
|
"max_temp": 35.0,
|
|
"last_temp": 35.0,
|
|
"trend_delta": 1.0,
|
|
"stale_for_today": False,
|
|
},
|
|
}
|
|
|
|
_apply_metar_gate_to_row(row)
|
|
|
|
assert row["v4_metar_decision"] == "veto"
|
|
assert row["ai_decision"] == "veto"
|
|
assert "越过目标桶上沿" in row["ai_reason_zh"]
|
|
|