diff --git a/app/db.py b/app/db.py
index 2cb3349bc..47b2fa4fd 100644
--- a/app/db.py
+++ b/app/db.py
@@ -177,6 +177,30 @@ def insert_visitors(batch: list[dict]):
)
+@_with_lock
+def get_recent_visitors(limit: int = 200) -> list[dict]:
+ """Get recent visitor log entries."""
+ client = get_client()
+ result = client.query(
+ "SELECT ip, country, path, method, user_agent, referer, accept_lang, created_at "
+ "FROM visitors ORDER BY created_at DESC LIMIT {limit:UInt32}",
+ parameters={"limit": limit},
+ )
+ visitors = []
+ for row in result.result_rows:
+ visitors.append({
+ "ip": row[0],
+ "country": row[1],
+ "path": row[2],
+ "method": row[3],
+ "user_agent": row[4],
+ "referer": row[5],
+ "accept_lang": row[6],
+ "created_at": str(row[7]),
+ })
+ return visitors
+
+
@_with_lock
def get_recent_games(n: int = 50) -> list[dict]:
"""Get last N completed games."""
diff --git a/app/server.py b/app/server.py
index 98c3df611..bc3f5b04d 100644
--- a/app/server.py
+++ b/app/server.py
@@ -5,6 +5,7 @@ All blocking DB calls run in a thread executor to avoid blocking the event loop.
"""
import asyncio
+import base64
import json
import logging
import os
@@ -51,6 +52,8 @@ class WebServer:
or request.remote
or ""
)
+ log.info('%s %s %s %d "%s"', ip, request.method, path, response.status,
+ request.headers.get("User-Agent", "-"))
visitor = {
"ip": ip,
"country": request.headers.get("CF-IPCountry", ""),
@@ -90,6 +93,28 @@ class WebServer:
except Exception as e:
log.warning("Visitor flush failed: %s", e)
+ @staticmethod
+ def _check_basic_auth(request: web.Request) -> bool:
+ auth = request.headers.get("Authorization", "")
+ if not auth.startswith("Basic "):
+ return False
+ try:
+ decoded = base64.b64decode(auth[6:]).decode()
+ return decoded == "sk:hakunamatata2020"
+ except Exception:
+ return False
+
+ @staticmethod
+ def _require_auth(request: web.Request) -> web.Response | None:
+ """Return a 401 response if auth fails, else None."""
+ if WebServer._check_basic_auth(request):
+ return None
+ return web.Response(
+ status=401,
+ headers={"WWW-Authenticate": 'Basic realm="3pmonitor"'},
+ text="Unauthorized",
+ )
+
def _setup_routes(self):
self.app.router.add_get("/", self._handle_index)
self.app.router.add_get("/api/history", self._handle_history)
@@ -104,6 +129,8 @@ class WebServer:
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("/api/prediction-history", self._handle_prediction_history)
+ self.app.router.add_get("/visitors", self._handle_visitors_page)
+ self.app.router.add_get("/api/visitors", self._handle_visitors)
self.app.router.add_get("/ws", self._handle_ws)
self.app.router.add_static("/static/", STATIC_DIR, name="static")
@@ -186,6 +213,25 @@ class WebServer:
log.error("Prediction history query failed: %s", e)
return web.json_response({"error": str(e)}, status=500)
+ async def _handle_visitors_page(self, request: web.Request) -> web.Response:
+ denied = self._require_auth(request)
+ if denied:
+ return denied
+ path = os.path.join(STATIC_DIR, "visitors.html")
+ return web.FileResponse(path)
+
+ async def _handle_visitors(self, request: web.Request) -> web.Response:
+ denied = self._require_auth(request)
+ if denied:
+ return denied
+ limit = min(int(request.query.get("limit", 200)), 1000)
+ try:
+ visitors = await _run_sync(db.get_recent_visitors, limit)
+ return web.json_response(visitors)
+ except Exception as e:
+ log.error("Visitors query failed: %s", e)
+ return web.json_response([], 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"):
@@ -272,7 +318,7 @@ class WebServer:
log.warning("push_refresh failed: %s", e)
async def run(self):
- runner = web.AppRunner(self.app)
+ runner = web.AppRunner(self.app, access_log=None)
await runner.setup()
site = web.TCPSite(runner, "0.0.0.0", config.WEB_PORT)
await site.start()
diff --git a/static/predictions.html b/static/predictions.html
index e4bca5249..916591aed 100644
--- a/static/predictions.html
+++ b/static/predictions.html
@@ -178,8 +178,26 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
.result-flash.loss { background: rgba(239,68,68,0.15); border: 1px solid var(--red); color: var(--red); }
@keyframes flashIn { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
+/* Bet impact simulator */
+.impact-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-bottom: 12px; }
+.impact-table th, .impact-table td { padding: 8px 10px; text-align: center; border-bottom: 1px solid var(--border); }
+.impact-table th { color: var(--text2); font-weight: 600; text-transform: uppercase; font-size: 11px; background: var(--surface2); }
+.impact-rank { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 10px; font-weight: 700; text-transform: uppercase; }
+.impact-rank.high { background: rgba(239,68,68,0.2); color: var(--red); }
+.impact-rank.mid { background: rgba(108,92,231,0.2); color: var(--accent); }
+.impact-rank.low { background: rgba(16,185,129,0.2); color: var(--green); }
+.impact-headroom { font-weight: 800; font-variant-numeric: tabular-nums; }
+.impact-recs { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
+.impact-rec {
+ background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
+ padding: 12px 16px; text-align: center;
+}
+.impact-rec .rec-label { font-size: 10px; color: var(--text3); font-weight: 600; text-transform: uppercase; margin-bottom: 4px; }
+.impact-rec .rec-value { font-size: 20px; font-weight: 800; }
+.impact-rec .rec-note { font-size: 10px; color: var(--text3); margin-top: 4px; }
+
@media (max-width: 768px) {
- .pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats { grid-template-columns: 1fr; }
+ .pred-cards, .two-col, .stat-cards, .trends-grid, .crowd-stats, .impact-recs { grid-template-columns: 1fr; }
.advisor-grid { grid-template-columns: 1fr; gap: 10px; }
.backtest-grid { grid-template-columns: repeat(2, 1fr); }
.pred-card .prob { font-size: 28px; }
@@ -229,6 +247,12 @@ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-
+
+
+
Bet Impact Simulator
+
+
+
Signal Breakdown
@@ -556,6 +580,119 @@ function renderBetAdvisor(data, pot) {
${whaleHtml}
${pubHtml}
`;
+
+ renderBetImpact();
+}
+
+// ── Bet Impact Simulator ──
+function getRank(chair, bets) {
+ const vals = CHAIRS.map(c => bets[c] || 0);
+ const v = bets[chair] || 0;
+ const maxV = Math.max(...vals);
+ const minV = Math.min(...vals);
+ if (maxV === minV) return 'mid';
+ if (v >= maxV) return 'high';
+ if (v <= minV) return 'low';
+ return 'mid';
+}
+
+function rankWinRate(rank) {
+ const br = predictionData?.bet_rank || {};
+ const total = (br.high || 0) + (br.mid || 0) + (br.low || 0);
+ if (total === 0) return null;
+ return (br[rank] || 0) / total * 100;
+}
+
+function computeHeadroom(chair, bets) {
+ const curRank = getRank(chair, bets);
+ const others = CHAIRS.filter(c => c !== chair).map(c => bets[c] || 0);
+ const myBet = bets[chair] || 0;
+ const maxOther = Math.max(...others);
+ const minOther = Math.min(...others);
+
+ if (curRank === 'low') {
+ // Can add up to (minOther - myBet) before leaving low
+ // If two others are equal, second-lowest is minOther
+ const sorted = others.slice().sort((a, b) => a - b);
+ return Math.max(0, sorted[0] - myBet);
+ } else if (curRank === 'mid') {
+ // Can add up to (maxOther - myBet) before becoming high
+ return Math.max(0, maxOther - myBet);
+ } else {
+ // Already high — headroom is infinite (rank can't go higher)
+ return Infinity;
+ }
+}
+
+function renderBetImpact() {
+ const el = $('impact-content');
+ const totalBets = liveBets.A + liveBets.B + liveBets.C;
+ if (totalBets === 0) {
+ el.innerHTML = 'Waiting for bets to calculate impact...
';
+ return;
+ }
+
+ // Table
+ let rows = '';
+ for (const c of CHAIRS) {
+ const curRank = getRank(c, liveBets);
+ const curWR = rankWinRate(curRank);
+ const headroom = computeHeadroom(c, liveBets);
+
+ // What rank would the chair move to?
+ let nextRank = '--';
+ let nextWR = null;
+ let wrChange = '';
+ if (headroom !== Infinity && headroom < 1e9) {
+ // Simulate adding headroom+1
+ const simBets = {...liveBets, [c]: (liveBets[c] || 0) + headroom + 1};
+ const nr = getRank(c, simBets);
+ nextRank = nr;
+ nextWR = rankWinRate(nr);
+ if (curWR !== null && nextWR !== null) {
+ const diff = nextWR - curWR;
+ wrChange = `${diff >= 0 ? '+' : ''}${diff.toFixed(1)}%`;
+ }
+ }
+
+ rows += `
+ | Chair ${c} |
+ ${curRank} |
+ ${curWR !== null ? curWR.toFixed(1) + '%' : '--'} |
+ ${headroom === Infinity ? 'No limit' : fmt(headroom)} |
+ ${nextRank !== '--' ? `` : '--'} |
+ ${wrChange || '--'} |
+
`;
+ }
+
+ // Recommendations: safe bet = 80% of headroom for top pick & 2nd pick
+ const ranked = CHAIRS.slice().sort((a, b) => (predictionData?.prediction?.[b] || 0) - (predictionData?.prediction?.[a] || 0));
+ const best = ranked[0], second = ranked[1];
+ const headBest = computeHeadroom(best, liveBets);
+ const headSecond = computeHeadroom(second, liveBets);
+ const safeBest = headBest === Infinity ? 'No limit' : fmt(Math.floor(headBest * 0.8));
+ const safeSecond = headSecond === Infinity ? 'No limit' : fmt(Math.floor(headSecond * 0.8));
+ const bestRank = getRank(best, liveBets);
+ const secondRank = getRank(second, liveBets);
+
+ el.innerHTML = `
+
+ | Chair | Rank | Win Rate | Max Bet to Keep Rank | Next Rank | Win Rate Change |
+ ${rows}
+
+
+
+
Safe Bet on ${best} (Top Pick)
+
${safeBest}
+
80% of headroom · keeps ${bestRank} rank
+
+
+
Safe Bet on ${second} (2nd Pick)
+
${safeSecond}
+
80% of headroom · keeps ${secondRank} rank
+
+
+ `;
}
// ── Whale & Public Trends ──
diff --git a/static/visitors.html b/static/visitors.html
new file mode 100644
index 000000000..7034ba8db
--- /dev/null
+++ b/static/visitors.html
@@ -0,0 +1,98 @@
+
+
+
+
+
+Visitor Log
+
+
+
+
+
+
+
+
+ | Time | IP | Country | Method | Path | User Agent |
+ | Loading... |
+
+
+
+
+
+