add predictions page with game theory analysis and card stats
Bayesian next-chair predictor (Markov chains, base rate, streak regression), statistical tests (chi-squared, runs test, autocorrelation), theory backtesting with rolling accuracy, and card-level analysis (value/suit distribution, face card frequency, top winning cards).
This commit is contained in:
458
app/db.py
458
app/db.py
@@ -4,6 +4,7 @@ ClickHouse database operations.
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
import clickhouse_connect
|
||||
@@ -816,3 +817,460 @@ def get_hot_cold_players(n: int = 5) -> dict:
|
||||
cold = [p for p in all_players if p["pnl"] < 0][-n:]
|
||||
cold.reverse() # most negative first
|
||||
return {"hot": hot, "cold": cold}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prediction helpers (private, called inside the locked main function)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHAIR_LABELS = ("A", "B", "C")
|
||||
|
||||
|
||||
def _normal_cdf(x):
|
||||
"""Abramowitz-Stegun approximation of the standard normal CDF."""
|
||||
if x < -8:
|
||||
return 0.0
|
||||
if x > 8:
|
||||
return 1.0
|
||||
t = 1.0 / (1.0 + 0.2316419 * abs(x))
|
||||
d = 0.3989422804014327 # 1/sqrt(2*pi)
|
||||
p = d * math.exp(-x * x / 2.0) * (
|
||||
t * (0.319381530 + t * (-0.356563782 + t * (1.781477937 + t * (-1.821255978 + t * 1.330274429))))
|
||||
)
|
||||
return 1.0 - p if x > 0 else p
|
||||
|
||||
|
||||
def _markov_matrix_1(winners):
|
||||
"""1st-order Markov transition matrix P(next | last)."""
|
||||
counts = {a: {b: 0 for b in CHAIR_LABELS} for a in CHAIR_LABELS}
|
||||
for i in range(len(winners) - 1):
|
||||
prev, cur = winners[i], winners[i + 1]
|
||||
if prev in counts and cur in CHAIR_LABELS:
|
||||
counts[prev][cur] += 1
|
||||
matrix = {}
|
||||
for src in CHAIR_LABELS:
|
||||
total = sum(counts[src].values())
|
||||
matrix[src] = {dst: round(counts[src][dst] / total, 4) if total else 0 for dst in CHAIR_LABELS}
|
||||
return matrix, counts
|
||||
|
||||
|
||||
def _markov_matrix_2(winners):
|
||||
"""2nd-order Markov transition matrix P(next | last two)."""
|
||||
counts = {}
|
||||
for a in CHAIR_LABELS:
|
||||
for b in CHAIR_LABELS:
|
||||
key = f"{a}{b}"
|
||||
counts[key] = {c: 0 for c in CHAIR_LABELS}
|
||||
for i in range(len(winners) - 2):
|
||||
key = f"{winners[i]}{winners[i+1]}"
|
||||
nxt = winners[i + 2]
|
||||
if key in counts and nxt in CHAIR_LABELS:
|
||||
counts[key][nxt] += 1
|
||||
matrix = {}
|
||||
for key in counts:
|
||||
total = sum(counts[key].values())
|
||||
matrix[key] = {dst: round(counts[key][dst] / total, 4) if total else 0 for dst in CHAIR_LABELS}
|
||||
return matrix, counts
|
||||
|
||||
|
||||
def _autocorrelation(winners, max_lag=5):
|
||||
"""Pearson autocorrelation at lags 1..max_lag. Chairs encoded A=0,B=1,C=2."""
|
||||
mapping = {"A": 0, "B": 1, "C": 2}
|
||||
seq = [mapping.get(w, 0) for w in winners]
|
||||
n = len(seq)
|
||||
if n < max_lag + 2:
|
||||
return [{"lag": i + 1, "r": 0, "significant": False} for i in range(max_lag)]
|
||||
mean = sum(seq) / n
|
||||
var = sum((x - mean) ** 2 for x in seq)
|
||||
results = []
|
||||
for lag in range(1, max_lag + 1):
|
||||
if var == 0:
|
||||
results.append({"lag": lag, "r": 0, "significant": False})
|
||||
continue
|
||||
cov = sum((seq[i] - mean) * (seq[i + lag] - mean) for i in range(n - lag))
|
||||
r = round(cov / var, 4)
|
||||
threshold = 1.96 / math.sqrt(n)
|
||||
results.append({"lag": lag, "r": r, "significant": abs(r) > threshold})
|
||||
return results
|
||||
|
||||
|
||||
def _chi_squared_test(winners):
|
||||
"""Chi-squared goodness-of-fit for uniform chair distribution (df=2)."""
|
||||
n = len(winners)
|
||||
if n == 0:
|
||||
return {"chi2": 0, "p_value": 1, "significant": False, "counts": {c: 0 for c in CHAIR_LABELS}}
|
||||
observed = {c: 0 for c in CHAIR_LABELS}
|
||||
for w in winners:
|
||||
if w in observed:
|
||||
observed[w] += 1
|
||||
expected = n / 3.0
|
||||
chi2 = sum((observed[c] - expected) ** 2 / expected for c in CHAIR_LABELS)
|
||||
p_value = math.exp(-chi2 / 2.0) # df=2 closed-form
|
||||
return {
|
||||
"chi2": round(chi2, 4),
|
||||
"p_value": round(p_value, 6),
|
||||
"significant": p_value < 0.05,
|
||||
"counts": observed,
|
||||
"expected": round(expected, 1),
|
||||
}
|
||||
|
||||
|
||||
def _runs_test(winners):
|
||||
"""Wald-Wolfowitz runs test for randomness."""
|
||||
if len(winners) < 10:
|
||||
return {"runs": 0, "z_score": 0, "p_value": 1, "interpretation": "Not enough data"}
|
||||
# Count runs (sequences of same chair)
|
||||
runs = 1
|
||||
for i in range(1, len(winners)):
|
||||
if winners[i] != winners[i - 1]:
|
||||
runs += 1
|
||||
n = len(winners)
|
||||
counts = {c: 0 for c in CHAIR_LABELS}
|
||||
for w in winners:
|
||||
if w in counts:
|
||||
counts[w] += 1
|
||||
# Expected runs and variance for k categories
|
||||
n_vals = [counts[c] for c in CHAIR_LABELS if counts[c] > 0]
|
||||
sum_ni2 = sum(ni ** 2 for ni in n_vals)
|
||||
expected_runs = 1 + (n * n - sum_ni2) / n
|
||||
if n <= 1:
|
||||
return {"runs": runs, "z_score": 0, "p_value": 1, "interpretation": "Not enough data"}
|
||||
var_num = sum_ni2 * (sum_ni2 + n * n) - 2 * n * sum(ni ** 3 for ni in n_vals) - n ** 3
|
||||
var_den = n * n * (n - 1)
|
||||
variance = var_num / var_den if var_den > 0 else 1
|
||||
if variance <= 0:
|
||||
return {"runs": runs, "z_score": 0, "p_value": 1, "interpretation": "Not enough data"}
|
||||
z = (runs - expected_runs) / math.sqrt(variance)
|
||||
p_value = 2 * (1 - _normal_cdf(abs(z)))
|
||||
if p_value < 0.05:
|
||||
interpretation = "Too few runs (streaky)" if z < 0 else "Too many runs (alternating)"
|
||||
else:
|
||||
interpretation = "Random (no significant pattern)"
|
||||
return {
|
||||
"runs": runs,
|
||||
"expected_runs": round(expected_runs, 1),
|
||||
"z_score": round(z, 4),
|
||||
"p_value": round(p_value, 6),
|
||||
"significant": p_value < 0.05,
|
||||
"interpretation": interpretation,
|
||||
}
|
||||
|
||||
|
||||
def _bayesian_prediction(winners, markov1, markov2):
|
||||
"""Weighted Bayesian prediction combining 5 signals."""
|
||||
if len(winners) < 3:
|
||||
return {c: round(1 / 3, 4) for c in CHAIR_LABELS}, {}
|
||||
|
||||
# Signal 1: Base rate (overall frequency) — 20%
|
||||
total = len(winners)
|
||||
base = {c: winners.count(c) / total for c in CHAIR_LABELS}
|
||||
|
||||
# Signal 2: 1st-order Markov — 30%
|
||||
last = winners[-1]
|
||||
m1 = markov1.get(last, {c: 1 / 3 for c in CHAIR_LABELS})
|
||||
|
||||
# Signal 3: 2nd-order Markov — 25%
|
||||
key2 = f"{winners[-2]}{winners[-1]}"
|
||||
m2 = markov2.get(key2, {c: 1 / 3 for c in CHAIR_LABELS})
|
||||
|
||||
# Signal 4: Recent 20-game frequency — 15%
|
||||
recent = winners[-20:] if len(winners) >= 20 else winners
|
||||
recent_total = len(recent)
|
||||
rec = {c: recent.count(c) / recent_total for c in CHAIR_LABELS}
|
||||
|
||||
# Signal 5: Streak momentum/regression — 10%
|
||||
streak_chair = winners[-1]
|
||||
streak_len = 0
|
||||
for w in reversed(winners):
|
||||
if w == streak_chair:
|
||||
streak_len += 1
|
||||
else:
|
||||
break
|
||||
# Regression to mean: longer streaks → lower probability of continuation
|
||||
streak = {}
|
||||
for c in CHAIR_LABELS:
|
||||
if c == streak_chair:
|
||||
streak[c] = max(0.1, 1 / 3 - streak_len * 0.05)
|
||||
else:
|
||||
streak[c] = 0
|
||||
# Normalize streak signal
|
||||
s_total = sum(streak.values())
|
||||
if s_total > 0:
|
||||
streak = {c: streak[c] / s_total for c in CHAIR_LABELS}
|
||||
else:
|
||||
streak = {c: 1 / 3 for c in CHAIR_LABELS}
|
||||
|
||||
weights = {"base_rate": 0.20, "markov_1": 0.30, "markov_2": 0.25, "recent_20": 0.15, "streak": 0.10}
|
||||
signals = {"base_rate": base, "markov_1": m1, "markov_2": m2, "recent_20": rec, "streak": streak}
|
||||
|
||||
combined = {c: 0 for c in CHAIR_LABELS}
|
||||
for sig_name, weight in weights.items():
|
||||
for c in CHAIR_LABELS:
|
||||
combined[c] += weight * signals[sig_name].get(c, 1 / 3)
|
||||
|
||||
# Normalize
|
||||
c_total = sum(combined.values())
|
||||
if c_total > 0:
|
||||
combined = {c: round(combined[c] / c_total, 4) for c in CHAIR_LABELS}
|
||||
|
||||
# Round signal values for output
|
||||
signal_detail = {}
|
||||
for sig_name, sig_vals in signals.items():
|
||||
signal_detail[sig_name] = {
|
||||
"weight": weights[sig_name],
|
||||
"probs": {c: round(sig_vals.get(c, 0), 4) for c in CHAIR_LABELS},
|
||||
}
|
||||
|
||||
return combined, signal_detail
|
||||
|
||||
|
||||
def _card_value_distribution(cards_data):
|
||||
"""Count of each card value (A–K) per chair."""
|
||||
value_names = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"]
|
||||
dist = {c: {v: 0 for v in value_names} for c in CHAIR_LABELS}
|
||||
for cards_json_str, _ in cards_data:
|
||||
try:
|
||||
infos = json.loads(cards_json_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for p in infos:
|
||||
chair = config.CHAIRS.get(p.get("country"), None)
|
||||
if chair not in dist:
|
||||
continue
|
||||
for card in p.get("cards", []):
|
||||
val = config.VALUES.get(card.get("cardValue"), None)
|
||||
if val and val in dist[chair]:
|
||||
dist[chair][val] += 1
|
||||
return {"labels": value_names, "chairs": dist}
|
||||
|
||||
|
||||
def _face_card_frequency(cards_data):
|
||||
"""Percentage of face cards (J, Q, K, A) per chair."""
|
||||
face_vals = {"J", "Q", "K", "A"}
|
||||
face_counts = {c: 0 for c in CHAIR_LABELS}
|
||||
total_counts = {c: 0 for c in CHAIR_LABELS}
|
||||
for cards_json_str, _ in cards_data:
|
||||
try:
|
||||
infos = json.loads(cards_json_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for p in infos:
|
||||
chair = config.CHAIRS.get(p.get("country"), None)
|
||||
if chair not in face_counts:
|
||||
continue
|
||||
for card in p.get("cards", []):
|
||||
val = config.VALUES.get(card.get("cardValue"), None)
|
||||
if val:
|
||||
total_counts[chair] += 1
|
||||
if val in face_vals:
|
||||
face_counts[chair] += 1
|
||||
result = {}
|
||||
for c in CHAIR_LABELS:
|
||||
pct = round(face_counts[c] / total_counts[c] * 100, 2) if total_counts[c] else 0
|
||||
result[c] = {"face_cards": face_counts[c], "total_cards": total_counts[c], "pct": pct}
|
||||
return result
|
||||
|
||||
|
||||
def _suit_distribution(cards_data):
|
||||
"""Suit counts per chair."""
|
||||
suit_names = ["\u2660", "\u2665", "\u2663", "\u2666"]
|
||||
dist = {c: {s: 0 for s in suit_names} for c in CHAIR_LABELS}
|
||||
for cards_json_str, _ in cards_data:
|
||||
try:
|
||||
infos = json.loads(cards_json_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for p in infos:
|
||||
chair = config.CHAIRS.get(p.get("country"), None)
|
||||
if chair not in dist:
|
||||
continue
|
||||
for card in p.get("cards", []):
|
||||
suit = config.SUITS.get(card.get("cardColor"), None)
|
||||
if suit and suit in dist[chair]:
|
||||
dist[chair][suit] += 1
|
||||
return {"labels": suit_names, "chairs": dist}
|
||||
|
||||
|
||||
def _winning_card_patterns(cards_data):
|
||||
"""Top 20 individual cards appearing in winning hands."""
|
||||
card_counts = {}
|
||||
for cards_json_str, winner in cards_data:
|
||||
try:
|
||||
infos = json.loads(cards_json_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
for p in infos:
|
||||
chair = config.CHAIRS.get(p.get("country"), None)
|
||||
if chair is None:
|
||||
continue
|
||||
# Check if this chair won: winner is stored as chair_id (1=C, 2=B, 3=A)
|
||||
if config.CHAIRS.get(winner) != chair:
|
||||
continue
|
||||
for card in p.get("cards", []):
|
||||
val = config.VALUES.get(card.get("cardValue"), None)
|
||||
suit = config.SUITS.get(card.get("cardColor"), None)
|
||||
if val and suit:
|
||||
label = f"{val}{suit}"
|
||||
card_counts[label] = card_counts.get(label, 0) + 1
|
||||
sorted_cards = sorted(card_counts.items(), key=lambda x: x[1], reverse=True)[:20]
|
||||
return [{"card": c, "count": n} for c, n in sorted_cards]
|
||||
|
||||
|
||||
def _backtest_theories(winners):
|
||||
"""Backtest all prediction theories on historical data."""
|
||||
warmup = 30
|
||||
if len(winners) <= warmup:
|
||||
return {"error": "Not enough data for backtesting"}
|
||||
|
||||
theories = ["base_rate", "markov_1", "markov_2", "recent_20", "streak", "combined"]
|
||||
correct = {t: 0 for t in theories}
|
||||
total_tested = 0
|
||||
rolling = {t: [] for t in theories} # rolling accuracy over last 200
|
||||
|
||||
for i in range(warmup, len(winners)):
|
||||
history = winners[:i]
|
||||
actual = winners[i]
|
||||
total_tested += 1
|
||||
|
||||
total_h = len(history)
|
||||
# Base rate
|
||||
base = {c: history.count(c) / total_h for c in CHAIR_LABELS}
|
||||
base_pick = max(CHAIR_LABELS, key=lambda c: base[c])
|
||||
|
||||
# Markov-1
|
||||
m1, _ = _markov_matrix_1(history)
|
||||
last = history[-1]
|
||||
m1_probs = m1.get(last, {c: 1 / 3 for c in CHAIR_LABELS})
|
||||
m1_pick = max(CHAIR_LABELS, key=lambda c: m1_probs.get(c, 0))
|
||||
|
||||
# Markov-2
|
||||
m2, _ = _markov_matrix_2(history)
|
||||
key2 = f"{history[-2]}{history[-1]}"
|
||||
m2_probs = m2.get(key2, {c: 1 / 3 for c in CHAIR_LABELS})
|
||||
m2_pick = max(CHAIR_LABELS, key=lambda c: m2_probs.get(c, 0))
|
||||
|
||||
# Recent-20
|
||||
recent = history[-20:] if len(history) >= 20 else history
|
||||
rec = {c: recent.count(c) / len(recent) for c in CHAIR_LABELS}
|
||||
rec_pick = max(CHAIR_LABELS, key=lambda c: rec[c])
|
||||
|
||||
# Streak
|
||||
streak_chair = history[-1]
|
||||
streak_len = 0
|
||||
for w in reversed(history):
|
||||
if w == streak_chair:
|
||||
streak_len += 1
|
||||
else:
|
||||
break
|
||||
streak_probs = {}
|
||||
for c in CHAIR_LABELS:
|
||||
if c == streak_chair:
|
||||
streak_probs[c] = max(0.1, 1 / 3 - streak_len * 0.05)
|
||||
else:
|
||||
streak_probs[c] = 0
|
||||
s_total = sum(streak_probs.values())
|
||||
if s_total > 0:
|
||||
streak_probs = {c: streak_probs[c] / s_total for c in CHAIR_LABELS}
|
||||
else:
|
||||
streak_probs = {c: 1 / 3 for c in CHAIR_LABELS}
|
||||
streak_pick = max(CHAIR_LABELS, key=lambda c: streak_probs[c])
|
||||
|
||||
# Combined Bayesian
|
||||
combined = {c: 0 for c in CHAIR_LABELS}
|
||||
weights = {"base_rate": 0.20, "markov_1": 0.30, "markov_2": 0.25, "recent_20": 0.15, "streak": 0.10}
|
||||
signals = {"base_rate": base, "markov_1": m1_probs, "markov_2": m2_probs, "recent_20": rec, "streak": streak_probs}
|
||||
for sig_name, weight in weights.items():
|
||||
for c in CHAIR_LABELS:
|
||||
combined[c] += weight * signals[sig_name].get(c, 1 / 3)
|
||||
combined_pick = max(CHAIR_LABELS, key=lambda c: combined[c])
|
||||
|
||||
picks = {
|
||||
"base_rate": base_pick, "markov_1": m1_pick, "markov_2": m2_pick,
|
||||
"recent_20": rec_pick, "streak": streak_pick, "combined": combined_pick,
|
||||
}
|
||||
for t in theories:
|
||||
hit = 1 if picks[t] == actual else 0
|
||||
if picks[t] == actual:
|
||||
correct[t] += 1
|
||||
rolling[t].append(hit)
|
||||
|
||||
accuracy = {t: round(correct[t] / total_tested * 100, 2) if total_tested else 0 for t in theories}
|
||||
|
||||
# Rolling accuracy over last 200 games
|
||||
window = 200
|
||||
rolling_accuracy = {t: [] for t in theories}
|
||||
for t in theories:
|
||||
data = rolling[t]
|
||||
for j in range(len(data)):
|
||||
start = max(0, j - window + 1)
|
||||
chunk = data[start:j + 1]
|
||||
rolling_accuracy[t].append(round(sum(chunk) / len(chunk) * 100, 2))
|
||||
# Only keep last 200 points for the chart
|
||||
for t in theories:
|
||||
rolling_accuracy[t] = rolling_accuracy[t][-window:]
|
||||
|
||||
return {
|
||||
"total_tested": total_tested,
|
||||
"accuracy": accuracy,
|
||||
"rolling_accuracy": rolling_accuracy,
|
||||
"random_baseline": 33.33,
|
||||
}
|
||||
|
||||
|
||||
@_with_lock
|
||||
def get_prediction_analysis() -> dict:
|
||||
"""Run all prediction/game-theory analysis and return results."""
|
||||
client = get_client()
|
||||
|
||||
# Query 1: Full winner sequence
|
||||
result = client.query("SELECT winner FROM games ORDER BY game_no ASC")
|
||||
winners = [config.CHAIRS.get(r[0], "?") for r in result.result_rows]
|
||||
winners = [w for w in winners if w in CHAIR_LABELS] # filter unknowns
|
||||
|
||||
# Query 2: Card data for last 500 games
|
||||
cards_result = client.query(
|
||||
"SELECT cards_json, winner FROM games WHERE cards_json != '' ORDER BY game_no DESC LIMIT 500"
|
||||
)
|
||||
cards_data = [(r[0], r[1]) for r in cards_result.result_rows]
|
||||
|
||||
# Markov matrices
|
||||
markov1, markov1_counts = _markov_matrix_1(winners)
|
||||
markov2, markov2_counts = _markov_matrix_2(winners)
|
||||
|
||||
# Autocorrelation
|
||||
autocorrelation = _autocorrelation(winners)
|
||||
|
||||
# Chi-squared test
|
||||
chi_squared = _chi_squared_test(winners)
|
||||
|
||||
# Runs test
|
||||
runs_test = _runs_test(winners)
|
||||
|
||||
# Bayesian prediction
|
||||
prediction, signals = _bayesian_prediction(winners, markov1, markov2)
|
||||
|
||||
# Backtesting
|
||||
backtest = _backtest_theories(winners)
|
||||
|
||||
# Card analysis
|
||||
card_values = _card_value_distribution(cards_data)
|
||||
face_cards = _face_card_frequency(cards_data)
|
||||
suits = _suit_distribution(cards_data)
|
||||
winning_cards = _winning_card_patterns(cards_data)
|
||||
|
||||
return {
|
||||
"total_games": len(winners),
|
||||
"last_winners": winners[-10:] if len(winners) >= 10 else winners,
|
||||
"prediction": prediction,
|
||||
"signals": signals,
|
||||
"markov1": {"matrix": markov1, "counts": {k: dict(v) for k, v in markov1_counts.items()}},
|
||||
"markov2": {"matrix": markov2, "counts": {k: dict(v) for k, v in markov2_counts.items()}},
|
||||
"autocorrelation": autocorrelation,
|
||||
"chi_squared": chi_squared,
|
||||
"runs_test": runs_test,
|
||||
"backtest": backtest,
|
||||
"card_values": card_values,
|
||||
"face_cards": face_cards,
|
||||
"suits": suits,
|
||||
"winning_cards": winning_cards,
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ class WebServer:
|
||||
self.app.router.add_get("/api/analytics", self._handle_analytics)
|
||||
self.app.router.add_get("/patterns", self._handle_patterns_page)
|
||||
self.app.router.add_get("/api/patterns", self._handle_patterns)
|
||||
self.app.router.add_get("/predictions", self._handle_predictions_page)
|
||||
self.app.router.add_get("/api/predictions", self._handle_predictions)
|
||||
self.app.router.add_get("/ws", self._handle_ws)
|
||||
self.app.router.add_static("/static/", STATIC_DIR, name="static")
|
||||
|
||||
@@ -104,6 +106,18 @@ class WebServer:
|
||||
log.error("Pattern analysis query failed: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def _handle_predictions_page(self, request: web.Request) -> web.Response:
|
||||
path = os.path.join(STATIC_DIR, "predictions.html")
|
||||
return web.FileResponse(path)
|
||||
|
||||
async def _handle_predictions(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
data = await _run_sync(db.get_prediction_analysis)
|
||||
return web.json_response(data)
|
||||
except Exception as e:
|
||||
log.error("Prediction analysis query failed: %s", e)
|
||||
return web.json_response({"error": str(e)}, status=500)
|
||||
|
||||
async def _handle_analytics(self, request: web.Request) -> web.Response:
|
||||
period = request.query.get("period", "all")
|
||||
if period not in ("1h", "6h", "24h", "7d", "all"):
|
||||
|
||||
@@ -215,6 +215,7 @@
|
||||
<div style="display:flex;gap:14px">
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<a href="/patterns" class="nav-link">Patterns →</a>
|
||||
<a href="/predictions" class="nav-link">Predictions →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -603,6 +603,7 @@
|
||||
<div class="status">
|
||||
<a href="/analytics" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Analytics →</a>
|
||||
<a href="/patterns" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Patterns →</a>
|
||||
<a href="/predictions" style="color:var(--accent);text-decoration:none;font-size:12px;font-weight:600;margin-right:10px">Predictions →</a>
|
||||
<div id="status-dot" class="status-dot"></div>
|
||||
<span id="status-text">Connecting...</span>
|
||||
</div>
|
||||
|
||||
@@ -123,6 +123,7 @@
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<a href="/analytics" class="nav-link">Analytics →</a>
|
||||
<a href="/predictions" class="nav-link">Predictions →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
528
static/predictions.html
Normal file
528
static/predictions.html
Normal file
@@ -0,0 +1,528 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Predictions & Game Theory</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f1117; --surface: #1a1d27; --surface2: #232736; --surface3: #2a2e42;
|
||||
--border: #2d3148; --text: #e4e6f0; --text2: #8b8fa3; --text3: #5a5f75;
|
||||
--accent: #6c5ce7;
|
||||
--chair-a: #3b82f6; --chair-b: #ec4899; --chair-c: #f59e0b;
|
||||
--green: #10b981; --red: #ef4444;
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg); color: var(--text); }
|
||||
|
||||
.header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 20px; background: var(--surface); border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.header h1 { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
|
||||
.nav-links { display: flex; gap: 14px; }
|
||||
.nav-link {
|
||||
font-size: 12px; color: var(--accent); text-decoration: none;
|
||||
font-weight: 600; transition: color 0.2s;
|
||||
}
|
||||
.nav-link:hover { color: #a78bfa; }
|
||||
|
||||
.content { padding: 16px 20px; max-width: 1400px; margin: 0 auto; }
|
||||
.loading { text-align: center; padding: 60px; color: var(--text2); font-size: 14px; }
|
||||
|
||||
.section { margin-bottom: 24px; }
|
||||
.section-title {
|
||||
font-size: 13px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;
|
||||
color: var(--text2); margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Hero prediction cards */
|
||||
.pred-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; margin-bottom: 16px; }
|
||||
.pred-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 24px; text-align: center; position: relative; transition: all 0.3s;
|
||||
}
|
||||
.pred-card.recommended {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 20px rgba(108, 92, 231, 0.3), 0 0 40px rgba(108, 92, 231, 0.1);
|
||||
}
|
||||
.pred-card .chair-label { font-size: 14px; font-weight: 700; margin-bottom: 8px; }
|
||||
.pred-card .prob { font-size: 42px; font-weight: 800; letter-spacing: -2px; }
|
||||
.pred-card .badge {
|
||||
display: inline-block; margin-top: 8px; padding: 3px 10px; border-radius: 12px;
|
||||
font-size: 11px; font-weight: 600; background: var(--accent); color: #fff;
|
||||
}
|
||||
|
||||
.signal-table { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.signal-table th, .signal-table td {
|
||||
padding: 8px 12px; text-align: center; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.signal-table th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; }
|
||||
|
||||
/* Two-column layout */
|
||||
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.panel {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 16px;
|
||||
}
|
||||
.panel-title { font-size: 12px; font-weight: 700; color: var(--text2); margin-bottom: 12px; text-transform: uppercase; }
|
||||
|
||||
/* Heatmap tables */
|
||||
.heatmap { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.heatmap th, .heatmap td { padding: 6px 8px; text-align: center; border: 1px solid var(--border); }
|
||||
.heatmap th { background: var(--surface2); color: var(--text2); font-weight: 600; font-size: 11px; }
|
||||
|
||||
/* Stat cards */
|
||||
.stat-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
|
||||
.stat-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 20px; text-align: center;
|
||||
}
|
||||
.stat-card .label { font-size: 12px; color: var(--text2); font-weight: 600; margin-bottom: 4px; }
|
||||
.stat-card .value { font-size: 28px; font-weight: 800; }
|
||||
|
||||
/* Test result panels */
|
||||
.test-result { margin-bottom: 8px; }
|
||||
.test-result .test-label { font-size: 11px; color: var(--text3); font-weight: 600; text-transform: uppercase; }
|
||||
.test-result .test-value { font-size: 16px; font-weight: 700; }
|
||||
.test-result .test-interpret { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||||
.significant { color: var(--red); }
|
||||
.not-significant { color: var(--green); }
|
||||
|
||||
/* Chart containers */
|
||||
.chart-container { position: relative; height: 300px; }
|
||||
.chart-container-sm { position: relative; height: 250px; }
|
||||
|
||||
/* Backtest */
|
||||
.backtest-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.bt-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 14px; text-align: center;
|
||||
}
|
||||
.bt-card .bt-name { font-size: 11px; color: var(--text2); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
|
||||
.bt-card .bt-acc { font-size: 24px; font-weight: 800; }
|
||||
.bt-card .bt-acc.above { color: var(--green); }
|
||||
.bt-card .bt-acc.below { color: var(--red); }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pred-cards, .two-col, .stat-cards { grid-template-columns: 1fr; }
|
||||
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.pred-card .prob { font-size: 32px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Predictions & Game Theory</h1>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Live Dashboard →</a>
|
||||
<a href="/analytics" class="nav-link">Analytics →</a>
|
||||
<a href="/patterns" class="nav-link">Patterns →</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div id="loading" class="loading">Loading prediction data...</div>
|
||||
<div id="main" style="display:none">
|
||||
|
||||
<!-- Bayesian Prediction Hero -->
|
||||
<div class="section">
|
||||
<div class="section-title">Bayesian Next-Chair Prediction</div>
|
||||
<div id="pred-cards" class="pred-cards"></div>
|
||||
<div class="panel" style="margin-top:8px">
|
||||
<div class="panel-title">Signal Breakdown</div>
|
||||
<table class="signal-table" id="signal-table">
|
||||
<thead><tr><th>Signal</th><th>Weight</th><th>A</th><th>B</th><th>C</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Markov Matrices -->
|
||||
<div class="section">
|
||||
<div class="section-title">Markov Transition Matrices</div>
|
||||
<div class="two-col">
|
||||
<div class="panel">
|
||||
<div class="panel-title">1st Order — P(next | last)</div>
|
||||
<table class="heatmap" id="markov1-table"></table>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">2nd Order — P(next | last two)</div>
|
||||
<table class="heatmap" id="markov2-table"></table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Autocorrelation -->
|
||||
<div class="section">
|
||||
<div class="section-title">Autocorrelation (Lags 1–5)</div>
|
||||
<div class="panel">
|
||||
<div class="chart-container-sm"><canvas id="autocorr-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistical Tests -->
|
||||
<div class="section">
|
||||
<div class="section-title">Statistical Tests</div>
|
||||
<div class="two-col">
|
||||
<div class="panel">
|
||||
<div class="panel-title">Chi-Squared Goodness of Fit</div>
|
||||
<div id="chi-results"></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">Wald-Wolfowitz Runs Test</div>
|
||||
<div id="runs-results"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Theory Backtesting -->
|
||||
<div class="section">
|
||||
<div class="section-title">Theory Backtesting</div>
|
||||
<div id="backtest-cards" class="backtest-grid"></div>
|
||||
<div class="panel">
|
||||
<div class="panel-title">Rolling Accuracy (Last 200 Games)</div>
|
||||
<div class="chart-container"><canvas id="backtest-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Value Distribution -->
|
||||
<div class="section">
|
||||
<div class="section-title">Card Value Distribution by Chair (Last 500 Games)</div>
|
||||
<div class="panel">
|
||||
<div class="chart-container"><canvas id="card-value-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Face Card Frequency -->
|
||||
<div class="section">
|
||||
<div class="section-title">Face Card Frequency (J, Q, K, A)</div>
|
||||
<div id="face-cards" class="stat-cards"></div>
|
||||
</div>
|
||||
|
||||
<!-- Suit Distribution -->
|
||||
<div class="section">
|
||||
<div class="section-title">Suit Distribution by Chair</div>
|
||||
<div class="panel">
|
||||
<div class="chart-container-sm"><canvas id="suit-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Winning Cards -->
|
||||
<div class="section">
|
||||
<div class="section-title">Top 20 Cards in Winning Hands</div>
|
||||
<div class="panel">
|
||||
<div class="chart-container"><canvas id="winning-cards-chart"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const $ = id => document.getElementById(id);
|
||||
const CHAIRS = ['A', 'B', 'C'];
|
||||
const CHAIR_COLORS = {A:'#3b82f6', B:'#ec4899', C:'#f59e0b'};
|
||||
const CHART_DEFAULTS = {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { labels: { color: '#8b8fa3', font: { size: 11 } } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d3148' } },
|
||||
y: { ticks: { color: '#5a5f75', font: { size: 10 } }, grid: { color: '#2d3148' } }
|
||||
}
|
||||
};
|
||||
|
||||
function heatColor(val) {
|
||||
// val is probability 0-1, center at 0.333
|
||||
const diff = val - 1/3;
|
||||
if (diff > 0) {
|
||||
const intensity = Math.min(diff * 6, 1);
|
||||
return `rgba(16, 185, 129, ${0.15 + intensity * 0.5})`; // green
|
||||
} else {
|
||||
const intensity = Math.min(Math.abs(diff) * 6, 1);
|
||||
return `rgba(239, 68, 68, ${0.15 + intensity * 0.5})`; // red
|
||||
}
|
||||
}
|
||||
|
||||
function pct(v) { return (v * 100).toFixed(1) + '%'; }
|
||||
|
||||
function renderPrediction(data) {
|
||||
const container = $('pred-cards');
|
||||
const best = CHAIRS.reduce((a, b) => data.prediction[a] > data.prediction[b] ? a : b);
|
||||
container.innerHTML = CHAIRS.map(c => {
|
||||
const p = data.prediction[c];
|
||||
const isBest = c === best;
|
||||
return `<div class="pred-card ${isBest ? 'recommended' : ''}">
|
||||
<div class="chair-label" style="color:${CHAIR_COLORS[c]}">Chair ${c}</div>
|
||||
<div class="prob" style="color:${CHAIR_COLORS[c]}">${pct(p)}</div>
|
||||
${isBest ? '<div class="badge">Recommended</div>' : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// Signal table
|
||||
const tbody = $('signal-table').querySelector('tbody');
|
||||
const sigNames = {'base_rate':'Base Rate','markov_1':'Markov-1','markov_2':'Markov-2','recent_20':'Recent 20','streak':'Streak'};
|
||||
tbody.innerHTML = Object.entries(data.signals).map(([key, sig]) =>
|
||||
`<tr><td style="text-align:left">${sigNames[key]||key}</td><td>${(sig.weight*100).toFixed(0)}%</td>` +
|
||||
CHAIRS.map(c => `<td style="color:${CHAIR_COLORS[c]}">${pct(sig.probs[c])}</td>`).join('') + '</tr>'
|
||||
).join('') +
|
||||
`<tr style="font-weight:700;border-top:2px solid var(--border)"><td style="text-align:left">Combined</td><td>100%</td>` +
|
||||
CHAIRS.map(c => `<td style="color:${CHAIR_COLORS[c]}">${pct(data.prediction[c])}</td>`).join('') + '</tr>';
|
||||
}
|
||||
|
||||
function renderMarkov1(m) {
|
||||
const t = $('markov1-table');
|
||||
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">→${c}</th>`).join('') + '</tr>';
|
||||
for (const src of CHAIRS) {
|
||||
html += `<tr><th style="color:${CHAIR_COLORS[src]}">${src}</th>`;
|
||||
for (const dst of CHAIRS) {
|
||||
const v = m.matrix[src]?.[dst] || 0;
|
||||
html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
t.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderMarkov2(m) {
|
||||
const t = $('markov2-table');
|
||||
const keys = [];
|
||||
for (const a of CHAIRS) for (const b of CHAIRS) keys.push(a+b);
|
||||
let html = '<tr><th>From \\ To</th>' + CHAIRS.map(c => `<th style="color:${CHAIR_COLORS[c]}">→${c}</th>`).join('') + '</tr>';
|
||||
for (const key of keys) {
|
||||
html += `<tr><th>${key}</th>`;
|
||||
for (const dst of CHAIRS) {
|
||||
const v = m.matrix[key]?.[dst] || 0;
|
||||
html += `<td style="background:${heatColor(v)}">${pct(v)}</td>`;
|
||||
}
|
||||
html += '</tr>';
|
||||
}
|
||||
t.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderAutocorrelation(ac) {
|
||||
const ctx = $('autocorr-chart').getContext('2d');
|
||||
const threshold = ac.length > 0 && ac[0].significant !== undefined ? 1.96 / Math.sqrt(100) : 0.196;
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: ac.map(a => `Lag ${a.lag}`),
|
||||
datasets: [{
|
||||
label: 'Autocorrelation',
|
||||
data: ac.map(a => a.r),
|
||||
backgroundColor: ac.map(a => a.significant ? '#ef4444' : '#6c5ce7'),
|
||||
borderRadius: 4,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
plugins: {
|
||||
...CHART_DEFAULTS.plugins,
|
||||
annotation: undefined,
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
...CHART_DEFAULTS.scales,
|
||||
y: { ...CHART_DEFAULTS.scales.y, suggestedMin: -0.3, suggestedMax: 0.3 }
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'thresholdLines',
|
||||
afterDraw(chart) {
|
||||
const {ctx, chartArea, scales} = chart;
|
||||
const yScale = scales.y;
|
||||
ctx.save();
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeStyle = '#ef444480';
|
||||
ctx.lineWidth = 1;
|
||||
for (const val of [threshold, -threshold]) {
|
||||
const y = yScale.getPixelForValue(val);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartArea.left, y);
|
||||
ctx.lineTo(chartArea.right, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
function renderChiSquared(chi) {
|
||||
$('chi-results').innerHTML = `
|
||||
<div class="test-result"><div class="test-label">Chi-Squared Statistic</div>
|
||||
<div class="test-value">${chi.chi2}</div></div>
|
||||
<div class="test-result"><div class="test-label">p-value</div>
|
||||
<div class="test-value ${chi.significant ? 'significant' : 'not-significant'}">${chi.p_value}</div></div>
|
||||
<div class="test-result"><div class="test-label">Expected Count (each chair)</div>
|
||||
<div class="test-value">${chi.expected}</div></div>
|
||||
<div class="test-result"><div class="test-label">Observed</div>
|
||||
<div class="test-value">A: ${chi.counts.A} B: ${chi.counts.B} C: ${chi.counts.C}</div></div>
|
||||
<div class="test-result"><div class="test-label">Interpretation</div>
|
||||
<div class="test-interpret ${chi.significant ? 'significant' : 'not-significant'}">
|
||||
${chi.significant ? 'Distribution is significantly non-uniform (p < 0.05)' : 'Distribution is consistent with uniform (p >= 0.05)'}
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
function renderRunsTest(runs) {
|
||||
$('runs-results').innerHTML = `
|
||||
<div class="test-result"><div class="test-label">Observed Runs</div>
|
||||
<div class="test-value">${runs.runs}</div></div>
|
||||
<div class="test-result"><div class="test-label">Expected Runs</div>
|
||||
<div class="test-value">${runs.expected_runs || '—'}</div></div>
|
||||
<div class="test-result"><div class="test-label">Z-Score</div>
|
||||
<div class="test-value">${runs.z_score}</div></div>
|
||||
<div class="test-result"><div class="test-label">p-value</div>
|
||||
<div class="test-value ${runs.significant ? 'significant' : 'not-significant'}">${runs.p_value}</div></div>
|
||||
<div class="test-result"><div class="test-label">Interpretation</div>
|
||||
<div class="test-interpret ${runs.significant ? 'significant' : 'not-significant'}">${runs.interpretation}</div></div>`;
|
||||
}
|
||||
|
||||
function renderBacktest(bt) {
|
||||
if (bt.error) {
|
||||
$('backtest-cards').innerHTML = `<div style="color:var(--text2)">${bt.error}</div>`;
|
||||
return;
|
||||
}
|
||||
const names = {base_rate:'Base Rate',markov_1:'Markov-1',markov_2:'Markov-2',recent_20:'Recent 20',streak:'Streak',combined:'Combined'};
|
||||
$('backtest-cards').innerHTML = Object.entries(bt.accuracy).map(([key, acc]) =>
|
||||
`<div class="bt-card"><div class="bt-name">${names[key]||key}</div>
|
||||
<div class="bt-acc ${acc > bt.random_baseline ? 'above' : 'below'}">${acc}%</div>
|
||||
<div style="font-size:10px;color:var(--text3);margin-top:4px">vs ${bt.random_baseline}% random</div>
|
||||
</div>`
|
||||
).join('');
|
||||
|
||||
if (bt.rolling_accuracy) {
|
||||
const ctx = $('backtest-chart').getContext('2d');
|
||||
const colors = {base_rate:'#8b8fa3',markov_1:'#3b82f6',markov_2:'#ec4899',recent_20:'#f59e0b',streak:'#10b981',combined:'#6c5ce7'};
|
||||
const datasets = Object.entries(bt.rolling_accuracy).map(([key, data]) => ({
|
||||
label: names[key]||key, data,
|
||||
borderColor: colors[key]||'#fff', backgroundColor: 'transparent',
|
||||
borderWidth: key === 'combined' ? 3 : 1.5,
|
||||
pointRadius: 0, tension: 0.3,
|
||||
}));
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: { labels: bt.rolling_accuracy.combined.map((_, i) => i + 1), datasets },
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
scales: {
|
||||
...CHART_DEFAULTS.scales,
|
||||
y: { ...CHART_DEFAULTS.scales.y, title: { display: true, text: 'Accuracy %', color: '#5a5f75' } },
|
||||
x: { ...CHART_DEFAULTS.scales.x, title: { display: true, text: 'Game', color: '#5a5f75' },
|
||||
ticks: { ...CHART_DEFAULTS.scales.x.ticks, maxTicksLimit: 10 } }
|
||||
}
|
||||
},
|
||||
plugins: [{
|
||||
id: 'baselineLine',
|
||||
afterDraw(chart) {
|
||||
const {ctx, chartArea, scales} = chart;
|
||||
const y = scales.y.getPixelForValue(bt.random_baseline);
|
||||
ctx.save();
|
||||
ctx.setLineDash([5, 5]);
|
||||
ctx.strokeStyle = '#ef444480';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(chartArea.left, y);
|
||||
ctx.lineTo(chartArea.right, y);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
}]
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderCardValues(cv) {
|
||||
const ctx = $('card-value-chart').getContext('2d');
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: cv.labels,
|
||||
datasets: CHAIRS.map(c => ({
|
||||
label: `Chair ${c}`, data: cv.labels.map(v => cv.chairs[c]?.[v] || 0),
|
||||
backgroundColor: CHAIR_COLORS[c] + '99', borderColor: CHAIR_COLORS[c],
|
||||
borderWidth: 1, borderRadius: 3,
|
||||
}))
|
||||
},
|
||||
options: CHART_DEFAULTS
|
||||
});
|
||||
}
|
||||
|
||||
function renderFaceCards(fc) {
|
||||
$('face-cards').innerHTML = CHAIRS.map(c => {
|
||||
const d = fc[c];
|
||||
return `<div class="stat-card">
|
||||
<div class="label" style="color:${CHAIR_COLORS[c]}">Chair ${c}</div>
|
||||
<div class="value" style="color:${CHAIR_COLORS[c]}">${d.pct}%</div>
|
||||
<div style="font-size:11px;color:var(--text3);margin-top:4px">${d.face_cards} / ${d.total_cards} cards</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderSuits(s) {
|
||||
const ctx = $('suit-chart').getContext('2d');
|
||||
const suitColors = {'\u2660':'#e4e6f0','\u2665':'#ef4444','\u2663':'#10b981','\u2666':'#3b82f6'};
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: CHAIRS.map(c => `Chair ${c}`),
|
||||
datasets: s.labels.map(suit => ({
|
||||
label: suit, data: CHAIRS.map(c => s.chairs[c]?.[suit] || 0),
|
||||
backgroundColor: (suitColors[suit] || '#8b8fa3') + '99',
|
||||
borderColor: suitColors[suit] || '#8b8fa3',
|
||||
borderWidth: 1, borderRadius: 3,
|
||||
}))
|
||||
},
|
||||
options: CHART_DEFAULTS
|
||||
});
|
||||
}
|
||||
|
||||
function renderWinningCards(wc) {
|
||||
const ctx = $('winning-cards-chart').getContext('2d');
|
||||
// Color hearts/diamonds red, spades/clubs white
|
||||
const colors = wc.map(c => {
|
||||
const suit = c.card.slice(-1);
|
||||
return (suit === '\u2665' || suit === '\u2666') ? '#ef4444' : '#e4e6f0';
|
||||
});
|
||||
new Chart(ctx, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: wc.map(c => c.card),
|
||||
datasets: [{
|
||||
label: 'Appearances in Winning Hands',
|
||||
data: wc.map(c => c.count),
|
||||
backgroundColor: colors.map(c => c + '80'),
|
||||
borderColor: colors,
|
||||
borderWidth: 1, borderRadius: 3,
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
...CHART_DEFAULTS,
|
||||
indexAxis: 'y',
|
||||
plugins: { legend: { display: false } },
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
renderPrediction(data);
|
||||
renderMarkov1(data.markov1);
|
||||
renderMarkov2(data.markov2);
|
||||
renderAutocorrelation(data.autocorrelation);
|
||||
renderChiSquared(data.chi_squared);
|
||||
renderRunsTest(data.runs_test);
|
||||
renderBacktest(data.backtest);
|
||||
renderCardValues(data.card_values);
|
||||
renderFaceCards(data.face_cards);
|
||||
renderSuits(data.suits);
|
||||
renderWinningCards(data.winning_cards);
|
||||
$('loading').style.display = 'none';
|
||||
$('main').style.display = 'block';
|
||||
}
|
||||
|
||||
fetch('/api/predictions')
|
||||
.then(r => r.json())
|
||||
.then(render)
|
||||
.catch(err => { $('loading').textContent = 'Failed to load: ' + err.message; });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user