163 lines
5.1 KiB
Python
163 lines
5.1 KiB
Python
"""公開獲利帳本(Proof of Yield)模組。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from dataclasses import dataclass
|
||
import json
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from uuid import uuid4
|
||
from datetime import datetime
|
||
|
||
|
||
def _as_float(value: Any, *, default: float = 0.0) -> float:
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return default
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ProofYieldRecord:
|
||
recommendation_id: str
|
||
match_id: str
|
||
market_type: str
|
||
selection: str
|
||
stake: float
|
||
recommended_odds: float
|
||
closing_odds: float | None
|
||
is_win: bool
|
||
settled_at: str
|
||
clv_ratio: float | None
|
||
clv_percent: float | None
|
||
pnl: float
|
||
created_at: str
|
||
|
||
|
||
def compute_clv(recommended_odds: float, closing_odds: float) -> float:
|
||
"""CLV = (推薦賠率 / 收盤賠率) - 1。"""
|
||
|
||
if recommended_odds <= 0 or closing_odds <= 0:
|
||
raise ValueError('推薦賠率與收盤賠率都必須大於 0')
|
||
return (recommended_odds / closing_odds) - 1
|
||
|
||
|
||
def compute_pnl(stake: float, is_win: bool, closing_odds: float | None) -> float:
|
||
if closing_odds is None or stake <= 0:
|
||
return 0.0
|
||
return stake * (closing_odds - 1) if is_win else -stake
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class LedgerSummary:
|
||
total_recommendations: int
|
||
hit_count: int
|
||
win_rate_percent: float
|
||
total_stake: float
|
||
total_pnl: float
|
||
roi_percent: float
|
||
avg_clv_percent: float
|
||
|
||
|
||
class ProofOfYieldStore:
|
||
"""本地持久化透明帳本(先以 JSON 做可追溯快啟動)。"""
|
||
|
||
def __init__(self, file_path: str | None = None) -> None:
|
||
self.path = Path(file_path or 'data/proof_of_yield_ledger.json')
|
||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||
|
||
def _load(self) -> list[dict[str, Any]]:
|
||
if not self.path.exists():
|
||
return []
|
||
raw = self.path.read_text(encoding='utf-8')
|
||
if not raw.strip():
|
||
return []
|
||
parsed = json.loads(raw)
|
||
if not isinstance(parsed, list):
|
||
return []
|
||
return parsed
|
||
|
||
def _save(self, rows: list[dict[str, Any]]) -> None:
|
||
self.path.write_text(json.dumps(rows, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
|
||
def upsert_settlements(self, items: list[dict[str, Any]]) -> list[ProofYieldRecord]:
|
||
current = self._load()
|
||
idx = {row['recommendation_id']: i for i, row in enumerate(current)}
|
||
|
||
for item in items:
|
||
recommendation_id = str(item.get('recommendation_id') or uuid4().hex)
|
||
stake = _as_float(item.get('stake'), default=100.0)
|
||
recommended_odds = _as_float(item.get('recommended_odds'))
|
||
closing_odds = item.get('closing_odds')
|
||
is_win = bool(item.get('is_win', False))
|
||
closing = _as_float(closing_odds) if closing_odds is not None else None
|
||
|
||
clv = None
|
||
clv_pct = None
|
||
if closing is not None and recommended_odds > 0:
|
||
clv = compute_clv(recommended_odds, closing)
|
||
clv_pct = clv * 100
|
||
|
||
pnl = compute_pnl(stake, is_win, closing)
|
||
record = {
|
||
'recommendation_id': recommendation_id,
|
||
'match_id': str(item.get('match_id', 'UNKNOWN')),
|
||
'market_type': str(item.get('market_type', '1x2')),
|
||
'selection': str(item.get('selection', 'home')),
|
||
'stake': round(stake, 4),
|
||
'recommended_odds': round(recommended_odds, 6),
|
||
'closing_odds': round(closing, 6) if closing is not None else None,
|
||
'is_win': is_win,
|
||
'settled_at': str(item.get('settled_at') or datetime.utcnow().isoformat()),
|
||
'clv_ratio': round(clv, 6) if clv is not None else None,
|
||
'clv_percent': round(clv_pct, 4) if clv_pct is not None else None,
|
||
'pnl': round(pnl, 4),
|
||
'created_at': str(item.get('created_at') or datetime.utcnow().isoformat()),
|
||
}
|
||
|
||
if recommendation_id in idx:
|
||
current[idx[recommendation_id]] = record
|
||
else:
|
||
current.append(record)
|
||
|
||
self._save(current)
|
||
return [ProofYieldRecord(**row) for row in current]
|
||
|
||
def query_ledger(self, *, limit: int = 200) -> list[ProofYieldRecord]:
|
||
rows = sorted(self._load(), key=lambda row: row.get('created_at', ''), reverse=True)
|
||
return [ProofYieldRecord(**row) for row in rows[:limit]]
|
||
|
||
@staticmethod
|
||
def summarize(records: list[ProofYieldRecord]) -> LedgerSummary:
|
||
total = len(records)
|
||
if total == 0:
|
||
return LedgerSummary(
|
||
total_recommendations=0,
|
||
hit_count=0,
|
||
win_rate_percent=0.0,
|
||
total_stake=0.0,
|
||
total_pnl=0.0,
|
||
roi_percent=0.0,
|
||
avg_clv_percent=0.0,
|
||
)
|
||
|
||
hit = sum(1 for row in records if row.is_win)
|
||
total_stake = sum(row.stake for row in records)
|
||
total_pnl = sum(row.pnl for row in records)
|
||
clv_values = [row.clv_percent for row in records if row.clv_percent is not None]
|
||
avg_clv = sum(clv_values) / len(clv_values) if clv_values else 0.0
|
||
roi = (total_pnl / total_stake) * 100 if total_stake > 0 else 0.0
|
||
win_rate = (hit / total) * 100
|
||
|
||
return LedgerSummary(
|
||
total_recommendations=total,
|
||
hit_count=hit,
|
||
win_rate_percent=round(win_rate, 4),
|
||
total_stake=round(total_stake, 4),
|
||
total_pnl=round(total_pnl, 4),
|
||
roi_percent=round(roi, 4),
|
||
avg_clv_percent=round(avg_clv, 4),
|
||
)
|
||
|