Night shift: tweet analyzer, data connectors, feed monitor, market watch portal
This commit is contained in:
File diff suppressed because one or more lines are too long
3
projects/feed-hunter/data/anoin123-tracking/stats.json
Normal file
3
projects/feed-hunter/data/anoin123-tracking/stats.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"last_check": "2026-02-12T06:14:23.639458+00:00"
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
{
|
||||
"timestamp": "2026-02-12T06:11:18.980961+00:00",
|
||||
"total_scraped": 44,
|
||||
"new_posts": 22,
|
||||
"money_posts": 7,
|
||||
"posts": [
|
||||
{
|
||||
"text": "We\u2019re in the business of supporting traders. Get the tools you need to make your way in the market.",
|
||||
"userName": "tastytrade\n@tastytrade",
|
||||
"timestamp": "",
|
||||
"link": "/tastytrade/status/1971214019274080389/analytics"
|
||||
},
|
||||
{
|
||||
"text": "agentic e2e regression testing solved.\nprompt \u2192 test in under 2 mins.\nif you're still writing tests by hand... why",
|
||||
"userName": "Bug0\n@bug0inc",
|
||||
"timestamp": "",
|
||||
"link": "/bug0inc/status/2019756209390375399/analytics"
|
||||
},
|
||||
{
|
||||
"text": " New GPT crypto price predictions:\n\n$ETH -0.285% => $1945.73\n\n$ZRO +0.086% => $2.329\n\n$UNI 0.0% => $3.492\n\n$BTC -0.116% => $67481.29\n\n$XRP +0.181% => $1.3796\n\n$PENGU +0.116% => $0.006021\n\n$SOL +0.063% => $79.72\n\n$TRUMP +0.094% => $3.195",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\n10h",
|
||||
"timestamp": "2026-02-11T20:00:50.000Z",
|
||||
"link": "/OctoBotGPT/status/2021675819580481813"
|
||||
},
|
||||
{
|
||||
"text": " New GPT crypto price predictions:\n\n$ADA 0.0% => $0.2633\n\n$DOGE 0.0% => $0.09372\n\n$SOL 0.0% => $84.78\n\n$XRP +0.266% => $1.427\n\n$SENT +0.594% => $0.02863\n\n$PAXG 0.0% => $5033.12\n\n$PEPE +98.997% => $0.000367\n\n$SUI +0.864% => $0.9487",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\nFeb 10",
|
||||
"timestamp": "2026-02-10T08:00:48.000Z",
|
||||
"link": "/OctoBotGPT/status/2021132226792947802"
|
||||
},
|
||||
{
|
||||
"text": "$ZAMA 0.0% => $0.02688\n\n$LIT -1.503% => $0.732\n\n$ZRO -0.259% => $1.927\n\n$BTC -0.078% => $68890.0\n\n$ETH -0.53% => $1997.59\n\n$HBAR +0.512% => $0.09188\n\n$TRX -0.036% => $0.277\n\n$SHIB 0.0% => $5.98e-06",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\nFeb 10",
|
||||
"timestamp": "2026-02-10T08:00:48.000Z",
|
||||
"link": "/OctoBotGPT/status/2021132227921301730"
|
||||
},
|
||||
{
|
||||
"text": " New GPT crypto price predictions:\n\n$ZRO +0.959% => $2.398\n\n$DOGE 0.0% => $0.09053\n\n$LINK 0.0% => $8.32\n\n$SOL -0.671% => $80.5\n\n$PENGU -0.169% => $0.005916\n\n$USD1 +0.02% => $1.0\n\n$SUI -0.403% => $0.893\n\n$BTC 0.0% => $67046.23",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\n22h",
|
||||
"timestamp": "2026-02-11T08:00:52.000Z",
|
||||
"link": "/OctoBotGPT/status/2021494632635408429"
|
||||
},
|
||||
{
|
||||
"text": "$ETH +1.022% => $1971.45\n\n$BNB 0.0% => $600.95\n\n$ZAMA -0.714% => $0.0196\n\n$SHIB 0.0% => $5.84e-06\n\n$XRP -0.242% => $1.3646\n\n$ASTER +0.459% => $0.653\n\n$ADA -0.393% => $0.2546\n\n$TRX 0.0% => $0.275",
|
||||
"userName": "OctoBot - GPT crypto price predictions\n@OctoBotGPT\n\u00b7\n22h",
|
||||
"timestamp": "2026-02-11T08:00:52.000Z",
|
||||
"link": "/OctoBotGPT/status/2021494633759408338"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -1,5 +1,5 @@
|
||||
{
|
||||
"last_check": "2026-02-11T00:55:59.787647+00:00",
|
||||
"last_check": "2026-02-12T06:14:23.952343+00:00",
|
||||
"total_tracked": 3100,
|
||||
"new_this_check": 0
|
||||
}
|
||||
1
projects/feed-hunter/data/seen_posts.json
Normal file
1
projects/feed-hunter/data/seen_posts.json
Normal file
@ -0,0 +1 @@
|
||||
["37f5b094ed19f0ae", "f013475eaef8b32f", "6f9f9b6f8da5cdc9", "952f5caf7819fde5", "6043a216215e23f0", "ff1b1af5e65905a8", "6217530e01f15dd0", "6e1a48e8344a1d6f", "6d61e4a9dfb604ea", "529a533d4da86360", "d2217d5c918df581", "8b4a57cd97ae6c34", "5bf0ad8e5e2f9dfe", "2ee63742fbc6e541", "7d74e6eca55f346f", "9f29b74a37377015", "3ccdb8471c5c19b6", "8a48151a19597742", "e6a24f45f8a6e8bb", "899377e3ae43e396", "05e47b3d9e24c860", "643b1c7d00ad50f2", "b7a2c548992c278c", "a1a8dddd35fd08d5", "149ace9c327e7700", "7d767e4d0e3a12a3", "0c6a5029b97b5a30", "b7354be18fa9f71a", "7b10b1cb595f2006", "ca4011161b3ce92d", "ffbe4a2b11671722", "cd2ce19326f75133", "60fd088f8f1ae9b2", "8ebbe21b036415d8", "84da8ad2dd424e2b", "7d4648af05013346", "137b80809666db54", "1151a6c9c0f2915c", "36db4785c888600f", "e8c4ec6aa2a9a563", "f9ded3bb072f01fe", "f30bff813bce39b8", "e1a98d7590fe46ee", "a767ef4ed21a86f3", "3525848121516057", "3d6e2887b81b3016", "cb6375e11d10b745", "493d2dc82b844b36", "60dced6f08edb3c2", "7d97ce308ff157b2", "db27f494858bd743", "176776613d96cdbe", "eb7a8395aa02f113"]
|
||||
231
projects/feed-hunter/feed_monitor.py
Executable file
231
projects/feed-hunter/feed_monitor.py
Executable file
@ -0,0 +1,231 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Feed Monitor — Scrapes X home timeline via Chrome CDP (localhost:9222).
|
||||
Deduplicates, filters for money/trading topics, saves captures, sends Telegram alerts.
|
||||
"""
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import http.client
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
PROJECT_DIR = Path(__file__).parent
|
||||
DATA_DIR = PROJECT_DIR / "data"
|
||||
SEEN_FILE = DATA_DIR / "seen_posts.json"
|
||||
CAPTURES_DIR = DATA_DIR / "feed_captures"
|
||||
CAPTURES_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
CDP_HOST = "localhost"
|
||||
CDP_PORT = 9222
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
MONEY_KEYWORDS = [
|
||||
"polymarket", "trade", "trading", "profit", "arbitrage", "crypto",
|
||||
"bitcoin", "btc", "ethereum", "eth", "solana", "sol", "stock",
|
||||
"stocks", "market", "portfolio", "defi", "token", "whale",
|
||||
"bullish", "bearish", "short", "long", "pnl", "alpha", "degen",
|
||||
"usdc", "usdt", "wallet", "airdrop", "memecoin", "nft",
|
||||
"yield", "staking", "leverage", "futures", "options", "hedge",
|
||||
"pump", "dump", "rug", "moon", "bag", "position", "signal",
|
||||
]
|
||||
|
||||
|
||||
def send_telegram(message: str):
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
print(f"[ALERT] {message}")
|
||||
return
|
||||
url = f"https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendMessage"
|
||||
data = json.dumps({
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": "HTML",
|
||||
"disable_web_page_preview": True,
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f" Telegram error: {e}")
|
||||
|
||||
|
||||
def cdp_send(ws, method: str, params: dict = None, msg_id: int = 1):
|
||||
"""Send a CDP command over websocket and return the result."""
|
||||
import websocket
|
||||
payload = {"id": msg_id, "method": method}
|
||||
if params:
|
||||
payload["params"] = params
|
||||
ws.send(json.dumps(payload))
|
||||
while True:
|
||||
resp = json.loads(ws.recv())
|
||||
if resp.get("id") == msg_id:
|
||||
return resp.get("result", {})
|
||||
|
||||
|
||||
def get_x_tab_ws():
|
||||
"""Find an X.com tab in Chrome and return its websocket URL."""
|
||||
conn = http.client.HTTPConnection(CDP_HOST, CDP_PORT, timeout=5)
|
||||
conn.request("GET", "/json")
|
||||
tabs = json.loads(conn.getresponse().read())
|
||||
conn.close()
|
||||
|
||||
for t in tabs:
|
||||
url = t.get("url", "")
|
||||
if "x.com" in url or "twitter.com" in url:
|
||||
ws_url = t.get("webSocketDebuggerUrl")
|
||||
if ws_url:
|
||||
return ws_url, t.get("url")
|
||||
return None, None
|
||||
|
||||
|
||||
def scrape_feed_via_cdp():
|
||||
"""Navigate to X home, scroll, extract posts via DOM evaluation."""
|
||||
import websocket
|
||||
|
||||
ws_url, current_url = get_x_tab_ws()
|
||||
if not ws_url:
|
||||
print("ERROR: No X.com tab found in Chrome at localhost:9222")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"Connected to tab: {current_url}")
|
||||
ws = websocket.create_connection(ws_url, timeout=30)
|
||||
|
||||
# Navigate to home timeline
|
||||
cdp_send(ws, "Page.navigate", {"url": "https://x.com/home"}, 1)
|
||||
time.sleep(5)
|
||||
|
||||
all_posts = []
|
||||
seen_texts = set()
|
||||
|
||||
for scroll_i in range(6):
|
||||
# Extract posts from timeline
|
||||
js = """
|
||||
(() => {
|
||||
const posts = [];
|
||||
document.querySelectorAll('article[data-testid="tweet"]').forEach(article => {
|
||||
try {
|
||||
const textEl = article.querySelector('[data-testid="tweetText"]');
|
||||
const text = textEl ? textEl.innerText : '';
|
||||
const userEl = article.querySelector('[data-testid="User-Name"]');
|
||||
const userName = userEl ? userEl.innerText : '';
|
||||
const timeEl = article.querySelector('time');
|
||||
const timestamp = timeEl ? timeEl.getAttribute('datetime') : '';
|
||||
const linkEl = article.querySelector('a[href*="/status/"]');
|
||||
const link = linkEl ? linkEl.getAttribute('href') : '';
|
||||
posts.push({ text, userName, timestamp, link });
|
||||
} catch(e) {}
|
||||
});
|
||||
return JSON.stringify(posts);
|
||||
})()
|
||||
"""
|
||||
result = cdp_send(ws, "Runtime.evaluate", {"expression": js, "returnByValue": True}, 10 + scroll_i)
|
||||
raw = result.get("result", {}).get("value", "[]")
|
||||
posts = json.loads(raw) if isinstance(raw, str) else []
|
||||
|
||||
for p in posts:
|
||||
sig = p.get("text", "")[:120]
|
||||
if sig and sig not in seen_texts:
|
||||
seen_texts.add(sig)
|
||||
all_posts.append(p)
|
||||
|
||||
# Scroll down
|
||||
cdp_send(ws, "Runtime.evaluate", {"expression": "window.scrollBy(0, 2000)"}, 100 + scroll_i)
|
||||
time.sleep(2)
|
||||
|
||||
ws.close()
|
||||
return all_posts
|
||||
|
||||
|
||||
def post_hash(post: dict) -> str:
|
||||
text = post.get("text", "") + post.get("userName", "")
|
||||
return hashlib.sha256(text.encode()).hexdigest()[:16]
|
||||
|
||||
|
||||
def is_money_related(text: str) -> bool:
|
||||
lower = text.lower()
|
||||
return any(kw in lower for kw in MONEY_KEYWORDS)
|
||||
|
||||
|
||||
def load_seen() -> set:
|
||||
if SEEN_FILE.exists():
|
||||
try:
|
||||
return set(json.loads(SEEN_FILE.read_text()))
|
||||
except:
|
||||
pass
|
||||
return set()
|
||||
|
||||
|
||||
def save_seen(seen: set):
|
||||
# Keep last 10k
|
||||
items = list(seen)[-10000:]
|
||||
SEEN_FILE.write_text(json.dumps(items))
|
||||
|
||||
|
||||
def main():
|
||||
now = datetime.now(timezone.utc)
|
||||
print(f"=== Feed Monitor === {now.strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
|
||||
posts = scrape_feed_via_cdp()
|
||||
print(f"Scraped {len(posts)} posts from timeline")
|
||||
|
||||
seen = load_seen()
|
||||
new_posts = []
|
||||
money_posts = []
|
||||
|
||||
for p in posts:
|
||||
h = post_hash(p)
|
||||
if h in seen:
|
||||
continue
|
||||
seen.add(h)
|
||||
new_posts.append(p)
|
||||
if is_money_related(p.get("text", "")):
|
||||
money_posts.append(p)
|
||||
|
||||
save_seen(seen)
|
||||
|
||||
print(f"New posts: {len(new_posts)}")
|
||||
print(f"Money-related: {len(money_posts)}")
|
||||
|
||||
# Save capture
|
||||
ts = now.strftime("%Y%m%d-%H%M")
|
||||
capture = {
|
||||
"timestamp": now.isoformat(),
|
||||
"total_scraped": len(posts),
|
||||
"new_posts": len(new_posts),
|
||||
"money_posts": len(money_posts),
|
||||
"posts": money_posts,
|
||||
}
|
||||
capture_file = CAPTURES_DIR / f"feed-{ts}.json"
|
||||
capture_file.write_text(json.dumps(capture, indent=2))
|
||||
print(f"Saved capture: {capture_file}")
|
||||
|
||||
# Alert on money posts
|
||||
if money_posts:
|
||||
print(f"\n🔔 {len(money_posts)} money-related posts found!")
|
||||
for p in money_posts[:8]:
|
||||
user = p.get("userName", "").split("\n")[0]
|
||||
snippet = p.get("text", "")[:250].replace("\n", " ")
|
||||
link = p.get("link", "")
|
||||
full_link = f"https://x.com{link}" if link and not link.startswith("http") else link
|
||||
|
||||
print(f" • {user}: {snippet[:100]}...")
|
||||
|
||||
msg = f"🔍 <b>{user}</b>\n\n{snippet}"
|
||||
if full_link:
|
||||
msg += f"\n\n{full_link}"
|
||||
send_telegram(msg)
|
||||
else:
|
||||
print("No new money-related posts.")
|
||||
|
||||
return len(money_posts)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
count = main()
|
||||
sys.exit(0)
|
||||
296
projects/market-watch/portal/index.html
Normal file
296
projects/market-watch/portal/index.html
Normal file
@ -0,0 +1,296 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Market Watch — Paper Trading Dashboard</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0e1a; color: #e0e6f0; min-height: 100vh; }
|
||||
.header { background: linear-gradient(135deg, #0f1629, #1a2342); padding: 20px 30px; border-bottom: 1px solid #1e2a4a; display: flex; justify-content: space-between; align-items: center; }
|
||||
.header h1 { font-size: 1.5rem; font-weight: 600; }
|
||||
.header h1 span { color: #4ecdc4; }
|
||||
.header .meta { font-size: 0.85rem; color: #7a8bb5; }
|
||||
.container { max-width: 1400px; margin: 0 auto; padding: 20px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 16px; margin-bottom: 20px; }
|
||||
.card { background: linear-gradient(145deg, #111827, #0f1520); border: 1px solid #1e2a4a; border-radius: 12px; padding: 20px; }
|
||||
.card h2 { font-size: 1rem; color: #7a8bb5; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; font-weight: 500; }
|
||||
.card h2 .icon { margin-right: 6px; }
|
||||
.stat-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
|
||||
.stat-label { color: #7a8bb5; font-size: 0.85rem; }
|
||||
.stat-value { font-size: 1.1rem; font-weight: 600; }
|
||||
.stat-big { font-size: 2rem; font-weight: 700; }
|
||||
.green { color: #4ecdc4; }
|
||||
.red { color: #ff6b6b; }
|
||||
.neutral { color: #7a8bb5; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
|
||||
.badge-green { background: rgba(78,205,196,0.15); color: #4ecdc4; }
|
||||
.badge-red { background: rgba(255,107,107,0.15); color: #ff6b6b; }
|
||||
.badge-blue { background: rgba(100,149,237,0.15); color: #6495ed; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; color: #7a8bb5; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 1px; padding: 8px; border-bottom: 1px solid #1e2a4a; }
|
||||
td { padding: 8px; font-size: 0.9rem; border-bottom: 1px solid #111827; }
|
||||
tr:hover td { background: rgba(255,255,255,0.02); }
|
||||
.chart-wrap { height: 250px; position: relative; }
|
||||
.tabs { display: flex; gap: 8px; margin-bottom: 20px; }
|
||||
.tab { padding: 8px 16px; border-radius: 8px; cursor: pointer; font-size: 0.9rem; border: 1px solid #1e2a4a; background: transparent; color: #7a8bb5; transition: all 0.2s; }
|
||||
.tab.active { background: #4ecdc4; color: #0a0e1a; border-color: #4ecdc4; font-weight: 600; }
|
||||
.tab:hover:not(.active) { border-color: #4ecdc4; color: #4ecdc4; }
|
||||
.game-section { display: none; }
|
||||
.game-section.active { display: block; }
|
||||
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid #1e2a4a; border-top-color: #4ecdc4; border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.empty { text-align: center; color: #7a8bb5; padding: 40px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>📊 <span>Market Watch</span> — Paper Trading</h1>
|
||||
<div class="meta">Auto-refresh 60s · <span id="lastUpdate">Loading...</span></div>
|
||||
</div>
|
||||
<div class="container">
|
||||
<div class="tabs" id="gameTabs"></div>
|
||||
<div id="gameContent"><div class="empty"><div class="spinner"></div> Loading games...</div></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let games = [];
|
||||
let activeGameIdx = 0;
|
||||
|
||||
async function fetchJSON(url) {
|
||||
const r = await fetch(url);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function pnlClass(v) { return v > 0 ? 'green' : v < 0 ? 'red' : 'neutral'; }
|
||||
function pnlSign(v) { return v > 0 ? '+' : ''; }
|
||||
function fmt(v, d=2) { return v != null ? Number(v).toFixed(d) : '—'; }
|
||||
function fmtK(v) { return v >= 1e6 ? (v/1e6).toFixed(1)+'M' : v >= 1e3 ? (v/1e3).toFixed(1)+'K' : fmt(v); }
|
||||
|
||||
function renderTabs() {
|
||||
const el = document.getElementById('gameTabs');
|
||||
el.innerHTML = games.map((g, i) =>
|
||||
`<button class="tab ${i === activeGameIdx ? 'active' : ''}" onclick="switchGame(${i})">${g.name || g.game_id.slice(0,8)}</button>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
function switchGame(idx) {
|
||||
activeGameIdx = idx;
|
||||
renderTabs();
|
||||
renderGame(games[idx]);
|
||||
}
|
||||
|
||||
async function renderGame(game) {
|
||||
const el = document.getElementById('gameContent');
|
||||
const gid = game.game_id;
|
||||
|
||||
// Fetch details in parallel
|
||||
const [detail, trades, portfolio] = await Promise.all([
|
||||
fetchJSON(`/api/game/${gid}`),
|
||||
fetchJSON(`/api/game/${gid}/trades`),
|
||||
fetchJSON(`/api/game/${gid}/portfolio`)
|
||||
]);
|
||||
|
||||
const player = game.players?.[0] || 'unknown';
|
||||
const pf = portfolio[player] || {};
|
||||
const board = detail.leaderboard || [];
|
||||
const entry = board[0] || {};
|
||||
const starting = game.starting_cash || 100000;
|
||||
const totalVal = pf.total_value || entry.total_value || starting;
|
||||
const totalPnl = totalVal - starting;
|
||||
const pnlPct = (totalPnl / starting * 100);
|
||||
|
||||
const sells = trades.filter(t => t.action === 'SELL');
|
||||
const wins = sells.filter(t => (t.realized_pnl||0) > 0);
|
||||
const winRate = sells.length ? (wins.length / sells.length * 100) : null;
|
||||
const totalFees = trades.reduce((s,t) => s + (t.fees||0), 0);
|
||||
const realizedPnl = sells.reduce((s,t) => s + (t.realized_pnl||0), 0);
|
||||
|
||||
// Equity curve from snapshots
|
||||
const snapshots = detail.snapshots?.[player] || [];
|
||||
|
||||
let html = `
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2><span class="icon">💰</span>Portfolio Value</h2>
|
||||
<div class="stat-big ${pnlClass(totalPnl)}">$${fmtK(totalVal)}</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">P&L</span>
|
||||
<span class="stat-value ${pnlClass(totalPnl)}">${pnlSign(totalPnl)}$${fmt(totalPnl)} (${pnlSign(pnlPct)}${fmt(pnlPct)}%)</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Starting Cash</span>
|
||||
<span class="stat-value">$${fmtK(starting)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Cash Available</span>
|
||||
<span class="stat-value">$${fmtK(pf.cash || 0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2><span class="icon">📈</span>Trading Stats</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Trades</span>
|
||||
<span class="stat-value">${trades.length}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Win Rate</span>
|
||||
<span class="stat-value ${winRate && winRate > 50 ? 'green' : winRate ? 'red' : 'neutral'}">${winRate != null ? fmt(winRate,1)+'%' : '—'}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Realized P&L</span>
|
||||
<span class="stat-value ${pnlClass(realizedPnl)}">${pnlSign(realizedPnl)}$${fmt(realizedPnl)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Fees</span>
|
||||
<span class="stat-value red">-$${fmt(totalFees)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2><span class="icon">🎯</span>Game Info</h2>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Game</span>
|
||||
<span class="stat-value">${game.name || gid.slice(0,8)}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Type</span>
|
||||
<span class="stat-value"><span class="badge badge-blue">${game.game_type || 'stock'}</span></span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Player</span>
|
||||
<span class="stat-value">${player}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Open Positions</span>
|
||||
<span class="stat-value">${Object.keys(pf.positions || {}).length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Equity Chart -->
|
||||
${snapshots.length > 1 ? `
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h2><span class="icon">📊</span>Equity Curve</h2>
|
||||
<div class="chart-wrap"><canvas id="equityChart"></canvas></div>
|
||||
</div>` : ''}
|
||||
|
||||
<!-- Positions -->
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h2><span class="icon">📋</span>Open Positions</h2>
|
||||
${Object.keys(pf.positions||{}).length ? `
|
||||
<table>
|
||||
<thead><tr><th>Symbol</th><th>Shares/Qty</th><th>Avg Cost</th><th>Current</th><th>Value</th><th>P&L</th></tr></thead>
|
||||
<tbody>
|
||||
${Object.entries(pf.positions||{}).map(([sym, pos]) => {
|
||||
const upnl = pos.unrealized_pnl || ((pos.current_price||pos.avg_cost) - pos.avg_cost) * (pos.shares||pos.quantity||0);
|
||||
const val = pos.market_value || (pos.current_price||pos.avg_cost) * (pos.shares||pos.quantity||0);
|
||||
return `<tr>
|
||||
<td><strong>${sym}</strong></td>
|
||||
<td>${pos.shares||pos.quantity||0}</td>
|
||||
<td>$${fmt(pos.avg_cost)}</td>
|
||||
<td>$${fmt(pos.current_price||pos.live_price||pos.avg_cost)}</td>
|
||||
<td>$${fmtK(val)}</td>
|
||||
<td class="${pnlClass(upnl)}">${pnlSign(upnl)}$${fmt(upnl)}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>` : '<div class="empty">No open positions</div>'}
|
||||
</div>
|
||||
|
||||
<!-- Recent Trades -->
|
||||
<div class="card">
|
||||
<h2><span class="icon">🔄</span>Recent Trades (last 25)</h2>
|
||||
${trades.length ? `
|
||||
<table>
|
||||
<thead><tr><th>Time</th><th>Action</th><th>Symbol</th><th>Qty</th><th>Price</th><th>P&L</th></tr></thead>
|
||||
<tbody>
|
||||
${trades.slice(0,25).map(t => {
|
||||
const rpnl = t.realized_pnl || 0;
|
||||
const actionClass = t.action === 'BUY' ? 'badge-green' : t.action === 'SELL' ? 'badge-red' : 'badge-blue';
|
||||
return `<tr>
|
||||
<td style="font-size:0.8rem;color:#7a8bb5">${t.timestamp ? new Date(t.timestamp).toLocaleString() : '—'}</td>
|
||||
<td><span class="badge ${actionClass}">${t.action}</span></td>
|
||||
<td><strong>${t.ticker||t.symbol||'—'}</strong></td>
|
||||
<td>${t.shares||t.quantity||'—'}</td>
|
||||
<td>$${fmt(t.price)}</td>
|
||||
<td class="${pnlClass(rpnl)}">${t.action==='SELL' ? pnlSign(rpnl)+'$'+fmt(rpnl) : '—'}</td>
|
||||
</tr>`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>` : '<div class="empty">No trades yet</div>'}
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// Draw equity chart
|
||||
if (snapshots.length > 1) {
|
||||
const ctx = document.getElementById('equityChart').getContext('2d');
|
||||
const labels = snapshots.map(s => {
|
||||
const d = s.timestamp || s.date;
|
||||
if (!d) return '—';
|
||||
const dt = new Date(d);
|
||||
return isNaN(dt) ? String(d).slice(0,10) : dt.toLocaleDateString();
|
||||
});
|
||||
const values = snapshots.map(s => s.total_value || s.equity || starting);
|
||||
new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [{
|
||||
label: 'Equity',
|
||||
data: values,
|
||||
borderColor: '#4ecdc4',
|
||||
backgroundColor: 'rgba(78,205,196,0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
borderWidth: 2
|
||||
}, {
|
||||
label: 'Starting',
|
||||
data: Array(labels.length).fill(starting),
|
||||
borderColor: '#7a8bb544',
|
||||
borderDash: [5,5],
|
||||
borderWidth: 1,
|
||||
pointRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: '#1e2a4a' }, ticks: { color: '#7a8bb5', maxTicksLimit: 8 } },
|
||||
y: { grid: { color: '#1e2a4a' }, ticks: { color: '#7a8bb5', callback: v => '$'+fmtK(v) } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
games = await fetchJSON('/api/games');
|
||||
if (!games.length) {
|
||||
document.getElementById('gameContent').innerHTML = '<div class="empty">No games found</div>';
|
||||
return;
|
||||
}
|
||||
renderTabs();
|
||||
await renderGame(games[activeGameIdx]);
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
} catch (e) {
|
||||
document.getElementById('gameContent').innerHTML = `<div class="empty">Error: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
setInterval(async () => {
|
||||
try {
|
||||
games = await fetchJSON('/api/games');
|
||||
await renderGame(games[activeGameIdx]);
|
||||
document.getElementById('lastUpdate').textContent = new Date().toLocaleTimeString();
|
||||
} catch(e) {}
|
||||
}, 60000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -1,424 +1,173 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Market Watch Web Portal - Multiplayer GARP Paper Trading."""
|
||||
"""Market Watch Web Portal — modern dark-themed dashboard."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import datetime
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from socketserver import ThreadingMixIn
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
import game_engine
|
||||
|
||||
PORT = 8889
|
||||
PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
SCANS_DIR = os.path.join(PROJECT_DIR, "data", "scans")
|
||||
PORTAL_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
||||
daemon_threads = True
|
||||
|
||||
|
||||
CSS = """:root{--bg-primary:#0d1117;--bg-secondary:#161b22;--bg-tertiary:#21262d;--text-primary:#f0f6fc;--text-secondary:#8b949e;--border-color:#30363d;--accent-blue:#58a6ff;--accent-purple:#bc8cff;--positive-green:#3fb950;--negative-red:#f85149;--gold:#f0c000;--silver:#c0c0c0;--bronze:#cd7f32}
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg-primary);color:var(--text-primary);line-height:1.5}
|
||||
a{color:var(--accent-blue);text-decoration:none}a:hover{text-decoration:underline}
|
||||
.navbar{background:var(--bg-secondary);border-bottom:1px solid var(--border-color);padding:1rem 2rem;display:flex;align-items:center;justify-content:space-between}
|
||||
.nav-brand{font-size:1.5rem;font-weight:bold;color:var(--accent-blue)}
|
||||
.nav-links{display:flex;gap:1.5rem}
|
||||
.nav-links a{color:var(--text-secondary);text-decoration:none;padding:.5rem 1rem;border-radius:6px;transition:all .2s}
|
||||
.nav-links a:hover{color:var(--text-primary);background:var(--bg-tertiary)}
|
||||
.nav-links a.active{color:var(--accent-blue);background:var(--bg-tertiary)}
|
||||
.container{max-width:1400px;margin:0 auto;padding:2rem}
|
||||
.card{background:var(--bg-secondary);border:1px solid var(--border-color);border-radius:8px;padding:1.5rem;margin-bottom:1.5rem}
|
||||
.card h3{color:var(--text-primary);margin-bottom:1rem;font-size:1.1rem}
|
||||
.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:1.5rem;margin-bottom:2rem}
|
||||
.metric-large{font-size:2rem;font-weight:bold;margin-bottom:.3rem}
|
||||
.metric-small{color:var(--text-secondary);font-size:.85rem}
|
||||
.positive{color:var(--positive-green)!important}.negative{color:var(--negative-red)!important}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{padding:.6rem .8rem;text-align:left;border-bottom:1px solid var(--border-color)}
|
||||
th{color:var(--text-secondary);font-size:.8rem;text-transform:uppercase}
|
||||
td{font-size:.9rem}
|
||||
.rank-1{color:var(--gold);font-weight:bold}.rank-2{color:var(--silver)}.rank-3{color:var(--bronze)}
|
||||
.btn{display:inline-block;padding:.5rem 1.2rem;background:var(--accent-blue);color:#fff;border:none;border-radius:6px;cursor:pointer;font-size:.9rem;text-decoration:none;transition:opacity .2s}
|
||||
.btn:hover{opacity:.85;text-decoration:none}
|
||||
.btn-outline{background:transparent;border:1px solid var(--border-color);color:var(--text-primary)}
|
||||
.btn-outline:hover{border-color:var(--accent-blue)}
|
||||
.btn-green{background:var(--positive-green)}.btn-red{background:var(--negative-red)}
|
||||
input,select{background:var(--bg-tertiary);border:1px solid var(--border-color);color:var(--text-primary);padding:.5rem .8rem;border-radius:6px;font-size:.9rem}
|
||||
.form-row{display:flex;gap:1rem;align-items:end;flex-wrap:wrap;margin-bottom:1rem}
|
||||
.form-group{display:flex;flex-direction:column;gap:.3rem}
|
||||
.form-group label{font-size:.8rem;color:var(--text-secondary);text-transform:uppercase}
|
||||
.badge{display:inline-block;padding:.15rem .5rem;border-radius:4px;font-size:.75rem;font-weight:bold}
|
||||
.badge-ai{background:var(--accent-purple);color:#fff}
|
||||
.badge-human{background:var(--accent-blue);color:#fff}
|
||||
.player-link{color:var(--text-primary);font-weight:500}
|
||||
@media(max-width:768px){.navbar{flex-direction:column;gap:1rem}.cards{grid-template-columns:1fr}.container{padding:1rem}.form-row{flex-direction:column}}"""
|
||||
def _fetch_live_prices(tickers):
|
||||
"""Fetch live prices via yfinance. Returns {ticker: price}."""
|
||||
try:
|
||||
import yfinance as yf
|
||||
data = yf.download(tickers, period="1d", progress=False)
|
||||
prices = {}
|
||||
if len(tickers) == 1:
|
||||
t = tickers[0]
|
||||
if "Close" in data.columns and len(data) > 0:
|
||||
prices[t] = float(data["Close"].iloc[-1])
|
||||
else:
|
||||
if "Close" in data.columns:
|
||||
for t in tickers:
|
||||
try:
|
||||
val = data["Close"][t].iloc[-1]
|
||||
if val == val: # not NaN
|
||||
prices[t] = float(val)
|
||||
except Exception:
|
||||
pass
|
||||
return prices
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def nav(active=""):
|
||||
return f"""<nav class="navbar">
|
||||
<a href="/" style="text-decoration:none"><div class="nav-brand">📊 Market Watch</div></a>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="{'active' if active=='home' else ''}">Games</a>
|
||||
<a href="/scans" class="{'active' if active=='scans' else ''}">Scans</a>
|
||||
</div></nav>"""
|
||||
|
||||
|
||||
class MarketWatchHandler(BaseHTTPRequestHandler):
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
try:
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
params = parse_qs(parsed.query)
|
||||
|
||||
if path == "" or path == "/":
|
||||
self.serve_home()
|
||||
elif path == "/create-game":
|
||||
self.serve_create_game()
|
||||
elif path.startswith("/game/") and "/player/" in path:
|
||||
parts = path.split("/") # /game/{gid}/player/{user}
|
||||
self.serve_player(parts[2], parts[4])
|
||||
elif path.startswith("/game/"):
|
||||
game_id = path.split("/")[2]
|
||||
self.serve_game(game_id)
|
||||
elif path == "/scans":
|
||||
self.serve_scans()
|
||||
# API
|
||||
elif path.startswith("/api/games") and len(path.split("/")) == 3:
|
||||
self.send_json(game_engine.list_games(active_only=False))
|
||||
elif path.startswith("/api/games/") and path.endswith("/leaderboard"):
|
||||
gid = path.split("/")[3]
|
||||
self.send_json(game_engine.get_leaderboard(gid))
|
||||
elif "/portfolio" in path:
|
||||
parts = path.split("/")
|
||||
self.send_json(game_engine.get_portfolio(parts[3], parts[5]))
|
||||
else:
|
||||
self.send_error(404)
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
|
||||
|
||||
def do_POST(self):
|
||||
try:
|
||||
content_len = int(self.headers.get("Content-Length", 0))
|
||||
body = self.rfile.read(content_len).decode() if content_len else ""
|
||||
parsed = urlparse(self.path)
|
||||
path = parsed.path.rstrip("/")
|
||||
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
|
||||
if path == "/":
|
||||
return self._serve_file("index.html", "text/html")
|
||||
|
||||
# API endpoints
|
||||
if path == "/api/games":
|
||||
data = parse_form(body)
|
||||
name = data.get("name", "Untitled Game")
|
||||
cash = float(data.get("starting_cash", 100000))
|
||||
end_date = data.get("end_date") or None
|
||||
gid = game_engine.create_game(name, cash, end_date)
|
||||
self.redirect(f"/game/{gid}")
|
||||
|
||||
elif path.endswith("/join"):
|
||||
data = parse_form(body)
|
||||
parts = path.split("/")
|
||||
games = game_engine.list_games(active_only=False)
|
||||
# Enrich with summary
|
||||
for g in games:
|
||||
board = game_engine.get_leaderboard(g["game_id"])
|
||||
g["leaderboard"] = board
|
||||
trades_all = []
|
||||
for p in g.get("players", []):
|
||||
trades_all.extend(game_engine.get_trades(g["game_id"], p))
|
||||
g["total_trades"] = len(trades_all)
|
||||
sells = [t for t in trades_all if t.get("action") == "SELL"]
|
||||
wins = [t for t in sells if t.get("realized_pnl", 0) > 0]
|
||||
g["win_rate"] = round(len(wins)/len(sells)*100, 1) if sells else None
|
||||
return self._json(games)
|
||||
|
||||
# /api/game/{id}
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 4 and parts[1] == "api" and parts[2] == "game":
|
||||
gid = parts[3]
|
||||
username = data.get("username", "").strip().lower()
|
||||
if username:
|
||||
game_engine.join_game(gid, username)
|
||||
self.redirect(f"/game/{gid}")
|
||||
|
||||
elif path.endswith("/trade"):
|
||||
data = parse_form(body)
|
||||
parts = path.split("/")
|
||||
gid, username = parts[3], parts[5]
|
||||
action = data.get("action", "").upper()
|
||||
ticker = data.get("ticker", "").upper().strip()
|
||||
shares = int(data.get("shares", 0))
|
||||
|
||||
if ticker and shares > 0:
|
||||
import yfinance as yf
|
||||
price = yf.Ticker(ticker).info.get("currentPrice") or yf.Ticker(ticker).info.get("regularMarketPrice", 0)
|
||||
if price and price > 0:
|
||||
if action == "BUY":
|
||||
game_engine.buy(gid, username, ticker, shares, price, reason="Manual trade")
|
||||
elif action == "SELL":
|
||||
game_engine.sell(gid, username, ticker, shares, price, reason="Manual trade")
|
||||
if len(parts) == 4:
|
||||
game = game_engine.get_game(gid)
|
||||
if not game:
|
||||
return self._json({"error": "not found"}, 404)
|
||||
game["leaderboard"] = game_engine.get_leaderboard(gid)
|
||||
# Add snapshots for each player
|
||||
game["snapshots"] = {}
|
||||
for p in game.get("players", []):
|
||||
game["snapshots"][p] = game_engine.get_snapshots(gid, p)
|
||||
return self._json(game)
|
||||
|
||||
self.redirect(f"/game/{gid}/player/{username}")
|
||||
else:
|
||||
self.send_error(404)
|
||||
if len(parts) == 5 and parts[4] == "trades":
|
||||
game = game_engine.get_game(gid)
|
||||
if not game:
|
||||
return self._json({"error": "not found"}, 404)
|
||||
all_trades = []
|
||||
for p in game.get("players", []):
|
||||
for t in game_engine.get_trades(gid, p):
|
||||
t["player"] = p
|
||||
all_trades.append(t)
|
||||
all_trades.sort(key=lambda x: x.get("timestamp", ""), reverse=True)
|
||||
return self._json(all_trades)
|
||||
|
||||
if len(parts) == 5 and parts[4] == "portfolio":
|
||||
game = game_engine.get_game(gid)
|
||||
if not game:
|
||||
return self._json({"error": "not found"}, 404)
|
||||
portfolios = {}
|
||||
all_tickers = []
|
||||
for p in game.get("players", []):
|
||||
pf = game_engine.get_portfolio(gid, p)
|
||||
if pf:
|
||||
portfolios[p] = pf
|
||||
all_tickers.extend(pf["positions"].keys())
|
||||
|
||||
# Fetch live prices
|
||||
all_tickers = list(set(all_tickers))
|
||||
if all_tickers:
|
||||
live = _fetch_live_prices(all_tickers)
|
||||
for p, pf in portfolios.items():
|
||||
total_value = pf["cash"]
|
||||
for ticker, pos in pf["positions"].items():
|
||||
if ticker in live:
|
||||
pos["live_price"] = live[ticker]
|
||||
pos["current_price"] = live[ticker]
|
||||
pos["unrealized_pnl"] = round((live[ticker] - pos["avg_cost"]) * pos["shares"], 2)
|
||||
pos["market_value"] = round(live[ticker] * pos["shares"], 2)
|
||||
total_value += pos["market_value"]
|
||||
pf["total_value"] = round(total_value, 2)
|
||||
starting = game.get("starting_cash", 100000)
|
||||
pf["total_pnl"] = round(total_value - starting, 2)
|
||||
pf["pnl_pct"] = round((total_value - starting) / starting * 100, 2)
|
||||
|
||||
return self._json(portfolios)
|
||||
|
||||
self._error(404)
|
||||
except Exception as e:
|
||||
self.send_response(500)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"<h1>500</h1><pre>{e}</pre>".encode())
|
||||
self._json({"error": str(e), "trace": traceback.format_exc()}, 500)
|
||||
|
||||
def serve_home(self):
|
||||
games = game_engine.list_games(active_only=False)
|
||||
rows = ""
|
||||
for g in games:
|
||||
players = len(g.get("players", []))
|
||||
status_badge = '<span class="badge badge-ai">Active</span>' if g["status"] == "active" else '<span class="badge">Ended</span>'
|
||||
rows += f"""<tr>
|
||||
<td><a href="/game/{g['game_id']}" class="player-link">{g['name']}</a></td>
|
||||
<td>{players}</td>
|
||||
<td>${g['starting_cash']:,.0f}</td>
|
||||
<td>{g['start_date']}</td>
|
||||
<td>{g.get('end_date', '—') or '—'}</td>
|
||||
<td>{status_badge}</td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Market Watch</title><style>{CSS}</style></head><body>
|
||||
{nav('home')}
|
||||
<div class="container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.5rem">
|
||||
<h2>🎮 Active Games</h2>
|
||||
<a href="/create-game" class="btn">+ New Game</a>
|
||||
</div>
|
||||
<div class="card">
|
||||
<table><thead><tr><th>Game</th><th>Players</th><th>Starting Cash</th><th>Started</th><th>Ends</th><th>Status</th></tr></thead>
|
||||
<tbody>{rows if rows else '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary)">No games yet — create one!</td></tr>'}</tbody></table>
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def serve_create_game(self):
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Create Game - Market Watch</title><style>{CSS}</style></head><body>
|
||||
{nav()}
|
||||
<div class="container">
|
||||
<div class="card">
|
||||
<h3>🎮 Create New Game</h3>
|
||||
<form method="POST" action="/api/games">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Game Name</label><input type="text" name="name" placeholder="GARP Challenge" required></div>
|
||||
<div class="form-group"><label>Starting Cash ($)</label><input type="number" name="starting_cash" value="100000" min="1000" step="1000"></div>
|
||||
<div class="form-group"><label>End Date (optional)</label><input type="date" name="end_date"></div>
|
||||
</div>
|
||||
<button type="submit" class="btn">Create Game</button>
|
||||
</form>
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def serve_game(self, game_id):
|
||||
game = game_engine.get_game(game_id)
|
||||
if not game:
|
||||
return self.send_error(404)
|
||||
|
||||
board = game_engine.get_leaderboard(game_id)
|
||||
|
||||
rank_rows = ""
|
||||
for i, entry in enumerate(board):
|
||||
rank_class = f"rank-{i+1}" if i < 3 else ""
|
||||
medal = ["🥇", "🥈", "🥉"][i] if i < 3 else f"#{i+1}"
|
||||
pnl_class = "positive" if entry["pnl_pct"] >= 0 else "negative"
|
||||
badge = ' <span class="badge badge-ai">AI</span>' if entry["username"] == "case" else ""
|
||||
rank_rows += f"""<tr>
|
||||
<td class="{rank_class}">{medal}</td>
|
||||
<td><a href="/game/{game_id}/player/{entry['username']}" class="player-link">{entry['username']}</a>{badge}</td>
|
||||
<td>${entry['total_value']:,.2f}</td>
|
||||
<td class="{pnl_class}">{entry['pnl_pct']:+.2f}%</td>
|
||||
<td class="{pnl_class}">${entry['total_pnl']:+,.2f}</td>
|
||||
<td>{entry['num_positions']}</td>
|
||||
<td>{entry['num_trades']}</td>
|
||||
</tr>"""
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{game['name']} - Market Watch</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
|
||||
{nav()}
|
||||
<div class="container">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:.5rem">
|
||||
<h2>🏆 {game['name']}</h2>
|
||||
<span class="badge badge-ai">{game['status'].upper()}</span>
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);margin-bottom:1.5rem">Started {game['start_date']} · ${game['starting_cash']:,.0f} starting cash · {len(game['players'])} players</p>
|
||||
|
||||
<div class="card">
|
||||
<h3>Leaderboard</h3>
|
||||
<table><thead><tr><th>Rank</th><th>Player</th><th>Portfolio</th><th>Return</th><th>P&L</th><th>Positions</th><th>Trades</th></tr></thead>
|
||||
<tbody>{rank_rows if rank_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No players yet</td></tr>'}</tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>Join This Game</h3>
|
||||
<form method="POST" action="/api/games/{game_id}/join">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Username</label><input type="text" name="username" placeholder="your name" required pattern="[a-zA-Z0-9_-]+" title="Letters, numbers, dashes, underscores only"></div>
|
||||
<button type="submit" class="btn">Join Game</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def serve_player(self, game_id, username):
|
||||
game = game_engine.get_game(game_id)
|
||||
p = game_engine.get_portfolio(game_id, username)
|
||||
if not game or not p:
|
||||
return self.send_error(404)
|
||||
|
||||
trades = game_engine.get_trades(game_id, username)
|
||||
snapshots = game_engine.get_snapshots(game_id, username)
|
||||
|
||||
pnl_class = "positive" if p["total_pnl"] >= 0 else "negative"
|
||||
is_ai = username == "case"
|
||||
badge = '<span class="badge badge-ai">AI Player</span>' if is_ai else '<span class="badge badge-human">Human</span>'
|
||||
|
||||
# Positions table
|
||||
pos_rows = ""
|
||||
for ticker, pos in sorted(p["positions"].items()):
|
||||
pc = "positive" if pos["unrealized_pnl"] >= 0 else "negative"
|
||||
pos_rows += f"""<tr>
|
||||
<td><strong>{ticker}</strong></td><td>{pos['shares']}</td>
|
||||
<td>${pos['avg_cost']:.2f}</td><td>${pos['current_price']:.2f}</td>
|
||||
<td>${pos['market_value']:,.2f}</td><td class="{pc}">${pos['unrealized_pnl']:+,.2f}</td>
|
||||
<td>${pos.get('trailing_stop',0):.2f}</td>
|
||||
</tr>"""
|
||||
if not pos_rows:
|
||||
pos_rows = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No positions</td></tr>'
|
||||
|
||||
# Trade log
|
||||
trade_rows = ""
|
||||
for t in reversed(trades[-30:]):
|
||||
action_class = "positive" if t["action"] == "BUY" else "negative"
|
||||
pnl_cell = ""
|
||||
if t["action"] == "SELL":
|
||||
rpnl = t.get("realized_pnl", 0)
|
||||
rpnl_class = "positive" if rpnl >= 0 else "negative"
|
||||
pnl_cell = f'<span class="{rpnl_class}">${rpnl:+,.2f}</span>'
|
||||
trade_rows += f"""<tr>
|
||||
<td class="{action_class}">{t['action']}</td><td>{t['ticker']}</td><td>{t['shares']}</td>
|
||||
<td>${t['price']:.2f}</td><td>{pnl_cell}</td>
|
||||
<td>{t.get('reason','')[:40]}</td><td>{t['timestamp'][:16]}</td>
|
||||
</tr>"""
|
||||
|
||||
chart_labels = json.dumps([s["date"] for s in snapshots])
|
||||
chart_values = json.dumps([s["total_value"] for s in snapshots])
|
||||
starting = game.get("starting_cash", 100000)
|
||||
|
||||
# Trade form (only for humans)
|
||||
trade_form = "" if is_ai else f"""
|
||||
<div class="card">
|
||||
<h3>📝 Place Trade</h3>
|
||||
<form method="POST" action="/api/games/{game_id}/players/{username}/trade">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Action</label>
|
||||
<select name="action"><option value="BUY">BUY</option><option value="SELL">SELL</option></select></div>
|
||||
<div class="form-group"><label>Ticker</label><input type="text" name="ticker" placeholder="AAPL" required style="text-transform:uppercase"></div>
|
||||
<div class="form-group"><label>Shares</label><input type="number" name="shares" min="1" value="10" required></div>
|
||||
<button type="submit" class="btn btn-green">Execute</button>
|
||||
</div>
|
||||
<p style="color:var(--text-secondary);font-size:.8rem;margin-top:.5rem">Trades execute at current market price via Yahoo Finance</p>
|
||||
</form>
|
||||
</div>"""
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>{username} - {game['name']}</title><script src="https://cdn.jsdelivr.net/npm/chart.js"></script><style>{CSS}</style></head><body>
|
||||
{nav()}
|
||||
<div class="container">
|
||||
<div style="margin-bottom:.5rem"><a href="/game/{game_id}" style="color:var(--text-secondary)">← {game['name']}</a></div>
|
||||
<h2>{username} {badge}</h2>
|
||||
<div class="cards" style="margin-top:1rem">
|
||||
<div class="card"><h3>Portfolio Value</h3><div class="metric-large">${p['total_value']:,.2f}</div><div class="metric-small">Started at ${starting:,.0f}</div></div>
|
||||
<div class="card"><h3>Cash</h3><div class="metric-large">${p['cash']:,.2f}</div><div class="metric-small">{p['cash']/max(p['total_value'],1)*100:.1f}% available</div></div>
|
||||
<div class="card"><h3>Return</h3><div class="metric-large {pnl_class}">{p['pnl_pct']:+.2f}%</div><div class="metric-small {pnl_class}">${p['total_pnl']:+,.2f}</div></div>
|
||||
<div class="card"><h3>Positions</h3><div class="metric-large">{p['num_positions']}</div><div class="metric-small">{len(trades)} total trades</div></div>
|
||||
</div>
|
||||
|
||||
<div class="card"><h3>Performance</h3><canvas id="chart" height="80"></canvas></div>
|
||||
|
||||
{trade_form}
|
||||
|
||||
<div class="card"><h3>Positions</h3>
|
||||
<table><thead><tr><th>Ticker</th><th>Shares</th><th>Avg Cost</th><th>Price</th><th>Value</th><th>P&L</th><th>Stop</th></tr></thead>
|
||||
<tbody>{pos_rows}</tbody></table>
|
||||
</div>
|
||||
|
||||
<div class="card"><h3>Trade Log</h3>
|
||||
<table><thead><tr><th>Action</th><th>Ticker</th><th>Shares</th><th>Price</th><th>P&L</th><th>Reason</th><th>Time</th></tr></thead>
|
||||
<tbody>{trade_rows if trade_rows else '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary)">No trades yet</td></tr>'}</tbody></table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const ctx = document.getElementById('chart').getContext('2d');
|
||||
const labels = {chart_labels}; const values = {chart_values};
|
||||
if (labels.length > 0) {{
|
||||
new Chart(ctx, {{type:'line',data:{{labels:labels,datasets:[
|
||||
{{label:'Portfolio',data:values,borderColor:'#58a6ff',backgroundColor:'rgba(88,166,255,0.1)',fill:true,tension:0.3}},
|
||||
{{label:'Starting',data:labels.map(()=>{starting}),borderColor:'#30363d',borderDash:[5,5],pointRadius:0}}
|
||||
]}},options:{{responsive:true,plugins:{{legend:{{labels:{{color:'#f0f6fc'}}}}}},scales:{{x:{{ticks:{{color:'#8b949e'}},grid:{{color:'#21262d'}}}},y:{{ticks:{{color:'#8b949e',callback:v=>'$'+v.toLocaleString()}},grid:{{color:'#21262d'}}}}}}}}
|
||||
}});
|
||||
}} else {{ ctx.canvas.parentElement.innerHTML += '<div style="text-align:center;color:#8b949e;padding:2rem">Chart populates after first trading day</div>'; }}
|
||||
</script></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def serve_scans(self):
|
||||
rows = ""
|
||||
if os.path.exists(SCANS_DIR):
|
||||
for sf in sorted(os.listdir(SCANS_DIR), reverse=True)[:30]:
|
||||
if not sf.endswith(".json"): continue
|
||||
data = {}
|
||||
with open(os.path.join(SCANS_DIR, sf)) as f:
|
||||
data = json.load(f)
|
||||
n = data.get("candidates_found", len(data.get("candidates", [])))
|
||||
top = ", ".join(c.get("ticker","?") for c in data.get("candidates", [])[:8])
|
||||
rows += f'<tr><td>{sf.replace(".json","")}</td><td>{data.get("total_scanned",0)}</td><td>{n}</td><td>{top or "—"}</td></tr>'
|
||||
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1.0">
|
||||
<title>Scans - Market Watch</title><style>{CSS}</style></head><body>
|
||||
{nav('scans')}
|
||||
<div class="container"><div class="card"><h3>📡 GARP Scan History</h3>
|
||||
<table><thead><tr><th>Date</th><th>Scanned</th><th>Candidates</th><th>Top Picks</th></tr></thead>
|
||||
<tbody>{rows if rows else '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary)">No scans yet</td></tr>'}</tbody></table>
|
||||
</div></div></body></html>"""
|
||||
self.send_html(html)
|
||||
|
||||
def redirect(self, url):
|
||||
self.send_response(303)
|
||||
self.send_header("Location", url)
|
||||
self.end_headers()
|
||||
|
||||
def send_html(self, content):
|
||||
def _serve_file(self, filename, content_type):
|
||||
filepath = os.path.join(PORTAL_DIR, filename)
|
||||
with open(filepath, "rb") as f:
|
||||
data = f.read()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "text/html")
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.end_headers()
|
||||
self.wfile.write(content.encode())
|
||||
self.wfile.write(data)
|
||||
|
||||
def send_json(self, data):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-type", "application/json")
|
||||
def _json(self, data, code=200):
|
||||
body = json.dumps(data, default=str).encode()
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(json.dumps(data, default=str).encode())
|
||||
self.wfile.write(body)
|
||||
|
||||
def log_message(self, format, *args):
|
||||
def _error(self, code):
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "text/plain")
|
||||
self.end_headers()
|
||||
self.wfile.write(f"{code}".encode())
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass
|
||||
|
||||
|
||||
def parse_form(body):
|
||||
"""Parse URL-encoded form data."""
|
||||
result = {}
|
||||
for pair in body.split("&"):
|
||||
if "=" in pair:
|
||||
k, v = pair.split("=", 1)
|
||||
from urllib.parse import unquote_plus
|
||||
result[unquote_plus(k)] = unquote_plus(v)
|
||||
return result
|
||||
|
||||
|
||||
def main():
|
||||
game_engine.ensure_default_game()
|
||||
print(f"📊 Market Watch Portal starting on localhost:{PORT}")
|
||||
server = ThreadedHTTPServer(("0.0.0.0", PORT), MarketWatchHandler)
|
||||
print(f"📊 Market Watch Portal → http://localhost:{PORT}")
|
||||
server = ThreadedHTTPServer(("0.0.0.0", PORT), Handler)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("\nPortal stopped")
|
||||
print("\nStopped")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user