175 lines
6.8 KiB
Python
Executable File
175 lines
6.8 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""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
|
|
PORTAL_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
|
|
|
|
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
|
|
daemon_threads = True
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
try:
|
|
path = urlparse(self.path).path.rstrip("/") or "/"
|
|
|
|
if path == "/":
|
|
return self._serve_file("index.html", "text/html")
|
|
|
|
# API endpoints
|
|
if path == "/api/games":
|
|
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]
|
|
|
|
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)
|
|
|
|
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._json({"error": str(e), "trace": traceback.format_exc()}, 500)
|
|
|
|
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", content_type)
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
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(body)
|
|
|
|
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 main():
|
|
game_engine.ensure_default_game()
|
|
print(f"📊 Market Watch Portal → http://localhost:{PORT}")
|
|
server = ThreadedHTTPServer(("0.0.0.0", PORT), Handler)
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
print("\nStopped")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|