Files
PolyWeather/web/services/market_overview_api.py
T

170 lines
5.8 KiB
Python

"""Deterministic market overview for scan terminal rows, cached 10 minutes."""
from __future__ import annotations
import hashlib
import json
import threading
import time
from datetime import datetime
from typing import Any, Dict, List, Optional
from src.utils.refresh_policy import MARKET_OVERVIEW_TTL_SEC
_OVERVIEW_CACHE: Dict[str, Dict[str, Any]] = {}
_OVERVIEW_CACHE_LOCK = threading.Lock()
OVERVIEW_CACHE_TTL_SEC = MARKET_OVERVIEW_TTL_SEC
def _safe_float(value: Any) -> Optional[float]:
try:
if value is None or value == "":
return None
number = float(value)
except Exception:
return None
return number if number == number else None
def _row_city(row: Dict[str, Any]) -> str:
return str(row.get("display_name") or row.get("city") or row.get("name") or "").strip()
def _row_edge(row: Dict[str, Any]) -> Optional[float]:
return (
_safe_float(row.get("edge_percent"))
or _safe_float(row.get("edge_pct"))
or _safe_float(row.get("edge"))
)
def _row_score(row: Dict[str, Any]) -> float:
edge = _row_edge(row) or 0.0
final_score = _safe_float(row.get("final_score")) or 0.0
liquidity = _safe_float(row.get("liquidity")) or _safe_float(row.get("liquidity_num")) or 0.0
return edge * 10.0 + final_score + min(liquidity / 1000.0, 25.0)
def _row_liquidity(row: Dict[str, Any]) -> float:
return _safe_float(row.get("liquidity")) or _safe_float(row.get("liquidity_num")) or 0.0
def _row_prob_gap(row: Dict[str, Any]) -> Optional[float]:
model_prob = _safe_float(row.get("model_probability"))
market_prob = _safe_float(row.get("market_probability"))
if model_prob is None or market_prob is None:
return None
return model_prob - market_prob
def _cache_key(rows: List[Dict[str, Any]], locale: str) -> str:
finger = {
"locale": locale,
"rows": [
{
"city": row.get("city") or row.get("name") or "",
"edge": _row_edge(row),
"score": _safe_float(row.get("final_score")),
"liquidity": _row_liquidity(row),
"status": row.get("status") or row.get("signal_status") or "",
}
for row in rows
if isinstance(row, dict)
],
}
raw = json.dumps(finger, sort_keys=True, ensure_ascii=False, default=str)
return "overview:" + hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _build_highlights(rows: List[Dict[str, Any]]) -> List[Dict[str, str]]:
ranked = sorted(
(row for row in rows if isinstance(row, dict) and _row_city(row)),
key=lambda item: (_row_score(item), _row_liquidity(item)),
reverse=True,
)
highlights: List[Dict[str, str]] = []
for row in ranked[:5]:
city = _row_city(row)
edge = _row_edge(row)
liquidity = _row_liquidity(row)
gap = _row_prob_gap(row)
edge_text = f"{edge:.1f}%" if edge is not None else "--"
gap_text = f"{gap * 100:.1f}pp" if gap is not None else "--"
liquidity_text = f"{liquidity:,.0f}" if liquidity else "--"
highlights.append(
{
"city": city,
"note_zh": f"edge {edge_text},模型/市场概率差 {gap_text},流动性 {liquidity_text}。",
"note_en": f"edge {edge_text}, model-market gap {gap_text}, liquidity {liquidity_text}.",
}
)
return highlights
def _build_payload(rows: List[Dict[str, Any]]) -> Dict[str, Any]:
clean_rows = [row for row in rows if isinstance(row, dict)]
total = len(clean_rows)
tradable = sum(1 for row in clean_rows if not row.get("closed") and not row.get("stale_for_today"))
high_risk = sum(
1
for row in clean_rows
if str(row.get("risk_level") or row.get("risk") or "").lower() in {"high", "danger", "red"}
)
avg_edge_values = [_row_edge(row) for row in clean_rows]
avg_edge_nums = [value for value in avg_edge_values if value is not None]
avg_edge = sum(avg_edge_nums) / len(avg_edge_nums) if avg_edge_nums else 0.0
total_liquidity = sum(_row_liquidity(row) for row in clean_rows)
highlights = _build_highlights(clean_rows)
overview_zh = (
f"当前区域共有 {total} 个天气合约,{tradable} 个可交易;"
f"高风险 {high_risk} 个,平均 edge {avg_edge:.1f}%,总流动性 {total_liquidity:,.0f}。"
"优先查看 edge、final score 与流动性同时靠前的城市。"
)
overview_en = (
f"{total} weather contracts are in scope, {tradable} tradable; "
f"{high_risk} high-risk rows, average edge {avg_edge:.1f}%, total liquidity {total_liquidity:,.0f}. "
"Prioritize rows where edge, final score and liquidity align."
)
return {
"overview_zh": overview_zh,
"overview_en": overview_en,
"highlights": highlights,
"generated_at": datetime.utcnow().isoformat() + "Z",
"cache_ttl_sec": OVERVIEW_CACHE_TTL_SEC,
"source": "deterministic",
}
def build_market_overview_payload(
rows: List[Dict[str, Any]],
*,
locale: str = "zh-CN",
force_refresh: bool = False,
) -> Dict[str, Any]:
if not rows:
return {
"overview_zh": "",
"overview_en": "",
"highlights": [],
"generated_at": None,
"cache_ttl_sec": OVERVIEW_CACHE_TTL_SEC,
"source": "deterministic",
}
key = _cache_key(rows, locale)
if not force_refresh:
with _OVERVIEW_CACHE_LOCK:
cached = _OVERVIEW_CACHE.get(key)
if cached and cached.get("expires_at", 0) >= time.time():
return cached["payload"]
payload = _build_payload(rows)
with _OVERVIEW_CACHE_LOCK:
_OVERVIEW_CACHE[key] = {
"expires_at": time.time() + OVERVIEW_CACHE_TTL_SEC,
"payload": payload,
}
return payload