#!/usr/bin/env python3 """ Crypto Leverage Trading Game Engine Paper trading with longs, shorts, and configurable leverage. Tracks liquidation prices, unrealized PnL, and funding costs. """ import json import os import uuid import time import urllib.request from datetime import datetime, date, timezone from pathlib import Path DATA_DIR = Path(__file__).parent / "data" / "leverage-game" GAMES_DIR = DATA_DIR / "games" BINANCE_TICKER = "https://api.binance.us/api/v3/ticker/price" def _load(path, default=None): if path.exists(): return json.loads(path.read_text()) return default if default is not None else {} def _save(path, data): path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(data, indent=2, default=str)) def _game_path(game_id): return GAMES_DIR / game_id / "game.json" def _player_path(game_id, username): return GAMES_DIR / game_id / "players" / username / "portfolio.json" def _trades_path(game_id, username): return GAMES_DIR / game_id / "players" / username / "trades.json" def _snapshots_path(game_id, username): return GAMES_DIR / game_id / "players" / username / "snapshots.json" # ── Price Fetching ── def get_price(symbol): """Get current price from Binance US.""" if not symbol.endswith("USDT"): symbol = f"{symbol}USDT" url = f"{BINANCE_TICKER}?symbol={symbol}" req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'}) try: resp = urllib.request.urlopen(req, timeout=10) return float(json.loads(resp.read())['price']) except: return None # ── Game Management ── def create_game(name, starting_cash=10_000.0, max_leverage=20, creator="system"): """Create a new leverage trading game.""" game_id = str(uuid.uuid4())[:8] config = { "game_id": game_id, "name": name, "starting_cash": starting_cash, "max_leverage": max_leverage, "funding_rate_8h": 0.01, # 0.01% per 8h (typical perp funding) "maker_fee": 0.02, # 0.02% "taker_fee": 0.05, # 0.05% "start_date": date.today().isoformat(), "creator": creator, "created_at": datetime.now(timezone.utc).isoformat(), "players": [], "status": "active", } _save(_game_path(game_id), config) return game_id def list_games(active_only=True): """List all leverage games.""" games = [] if not GAMES_DIR.exists(): return games for gid in os.listdir(GAMES_DIR): gp = _game_path(gid) if gp.exists(): config = _load(gp) if active_only and config.get("status") != "active": continue games.append(config) return sorted(games, key=lambda g: g.get("created_at", ""), reverse=True) def get_game(game_id): return _load(_game_path(game_id)) def join_game(game_id, username): """Add player to game.""" config = get_game(game_id) if not config: return {"error": "Game not found"} if username in config["players"]: return {"error": f"{username} already in game"} config["players"].append(username) _save(_game_path(game_id), config) _save(_player_path(game_id, username), { "cash": config["starting_cash"], "positions": {}, "total_realized_pnl": 0, "total_fees_paid": 0, "total_funding_paid": 0, }) _save(_trades_path(game_id, username), []) _save(_snapshots_path(game_id, username), []) return {"success": True, "game_id": game_id, "username": username} # ── Position Math ── def calc_liquidation_price(entry_price, leverage, direction): """ Simplified liquidation price. Long: liq = entry * (1 - 1/leverage) Short: liq = entry * (1 + 1/leverage) """ if direction == "long": return entry_price * (1 - 1 / leverage) else: # short return entry_price * (1 + 1 / leverage) def calc_unrealized_pnl(entry_price, current_price, size_usd, leverage, direction): """ Calculate unrealized PnL for a leveraged position. size_usd = margin (collateral). Notional = size_usd * leverage. """ notional = size_usd * leverage shares = notional / entry_price if direction == "long": pnl = (current_price - entry_price) * shares else: # short pnl = (entry_price - current_price) * shares return pnl def is_liquidated(entry_price, current_price, leverage, direction): """Check if position would be liquidated.""" liq_price = calc_liquidation_price(entry_price, leverage, direction) if direction == "long": return current_price <= liq_price else: return current_price >= liq_price # ── Trading ── def open_position(game_id, username, symbol, direction, margin_usd, leverage, reason="Manual"): """ Open a leveraged position. margin_usd: collateral put up leverage: multiplier (e.g., 10x) direction: 'long' or 'short' """ pf = _load(_player_path(game_id, username)) game = get_game(game_id) if not pf or not game: return {"error": "Player or game not found"} if direction not in ("long", "short"): return {"error": "Direction must be 'long' or 'short'"} if leverage > game.get("max_leverage", 20): return {"error": f"Max leverage is {game['max_leverage']}x"} if margin_usd > pf["cash"]: return {"error": f"Insufficient cash. Need ${margin_usd:.2f}, have ${pf['cash']:.2f}"} symbol = symbol.upper().replace("USDT", "") price = get_price(symbol) if not price: return {"error": f"Could not fetch price for {symbol}"} notional = margin_usd * leverage fee = notional * game.get("taker_fee", 0.05) / 100 # Deduct margin + entry fee from cash pf["cash"] -= (margin_usd + fee) pf["total_fees_paid"] = pf.get("total_fees_paid", 0) + fee pos_id = f"{symbol}_{direction}_{str(uuid.uuid4())[:4]}" liq_price = calc_liquidation_price(price, leverage, direction) pf["positions"][pos_id] = { "symbol": symbol, "direction": direction, "leverage": leverage, "margin_usd": margin_usd, "notional": round(notional, 2), "entry_price": price, "current_price": price, "liquidation_price": round(liq_price, 4), "unrealized_pnl": 0, "entry_fee": round(fee, 4), "opened_at": datetime.now(timezone.utc).isoformat(), "reason": reason, } _save(_player_path(game_id, username), pf) # Log trade trades = _load(_trades_path(game_id, username), []) trades.append({ "action": "OPEN", "pos_id": pos_id, "symbol": symbol, "direction": direction, "leverage": leverage, "margin_usd": margin_usd, "notional": round(notional, 2), "entry_price": price, "liquidation_price": round(liq_price, 4), "fee": round(fee, 4), "reason": reason, "timestamp": datetime.now(timezone.utc).isoformat(), }) _save(_trades_path(game_id, username), trades) return { "success": True, "pos_id": pos_id, "symbol": symbol, "direction": direction, "leverage": leverage, "entry_price": price, "margin": margin_usd, "notional": round(notional, 2), "liquidation_price": round(liq_price, 4), "fee": round(fee, 4), } def close_position(game_id, username, pos_id, reason="Manual"): """Close a leveraged position.""" pf = _load(_player_path(game_id, username)) game = get_game(game_id) if not pf or not game: return {"error": "Player or game not found"} if pos_id not in pf["positions"]: return {"error": f"Position {pos_id} not found"} pos = pf["positions"][pos_id] price = get_price(pos["symbol"]) if not price: return {"error": f"Could not fetch price for {pos['symbol']}"} # Calculate PnL pnl = calc_unrealized_pnl( pos["entry_price"], price, pos["margin_usd"], pos["leverage"], pos["direction"] ) # Check liquidation liquidated = is_liquidated(pos["entry_price"], price, pos["leverage"], pos["direction"]) if liquidated: pnl = -pos["margin_usd"] # Lose entire margin # Exit fee notional = pos["margin_usd"] * pos["leverage"] fee = notional * game.get("taker_fee", 0.05) / 100 # Return margin + PnL - fee to cash returned = pos["margin_usd"] + pnl - fee if returned < 0: returned = 0 # Can't lose more than margin (no negative balance) pf["cash"] += returned pf["total_realized_pnl"] = pf.get("total_realized_pnl", 0) + pnl pf["total_fees_paid"] = pf.get("total_fees_paid", 0) + fee del pf["positions"][pos_id] _save(_player_path(game_id, username), pf) # Log trade pnl_pct = (pnl / pos["margin_usd"] * 100) if pos["margin_usd"] > 0 else 0 trades = _load(_trades_path(game_id, username), []) trades.append({ "action": "LIQUIDATED" if liquidated else "CLOSE", "pos_id": pos_id, "symbol": pos["symbol"], "direction": pos["direction"], "leverage": pos["leverage"], "entry_price": pos["entry_price"], "exit_price": price, "margin_usd": pos["margin_usd"], "pnl": round(pnl, 2), "pnl_pct": round(pnl_pct, 2), "fee": round(fee, 4), "liquidated": liquidated, "reason": reason, "timestamp": datetime.now(timezone.utc).isoformat(), }) _save(_trades_path(game_id, username), trades) return { "success": True, "pos_id": pos_id, "symbol": pos["symbol"], "direction": pos["direction"], "entry_price": pos["entry_price"], "exit_price": price, "pnl": round(pnl, 2), "pnl_pct": round(pnl_pct, 2), "liquidated": liquidated, "returned_to_cash": round(returned, 2), } def update_prices(game_id, username): """Update all position prices and check for liquidations.""" pf = _load(_player_path(game_id, username)) if not pf: return [] liquidations = [] to_liquidate = [] for pos_id, pos in pf["positions"].items(): price = get_price(pos["symbol"]) if not price: continue pos["current_price"] = price pos["unrealized_pnl"] = round( calc_unrealized_pnl(pos["entry_price"], price, pos["margin_usd"], pos["leverage"], pos["direction"]), 2 ) if is_liquidated(pos["entry_price"], price, pos["leverage"], pos["direction"]): to_liquidate.append(pos_id) time.sleep(0.1) _save(_player_path(game_id, username), pf) # Process liquidations for pos_id in to_liquidate: result = close_position(game_id, username, pos_id, reason="LIQUIDATED") liquidations.append(result) return liquidations # ── Portfolio View ── def get_portfolio(game_id, username): """Get full portfolio with live PnL.""" pf = _load(_player_path(game_id, username)) game = get_game(game_id) if not pf or not game: return None starting = game["starting_cash"] total_unrealized = sum(p.get("unrealized_pnl", 0) for p in pf["positions"].values()) total_margin_locked = sum(p["margin_usd"] for p in pf["positions"].values()) equity = pf["cash"] + total_margin_locked + total_unrealized total_pnl = equity - starting return { "username": username, "game_id": game_id, "cash": round(pf["cash"], 2), "margin_locked": round(total_margin_locked, 2), "unrealized_pnl": round(total_unrealized, 2), "realized_pnl": round(pf.get("total_realized_pnl", 0), 2), "total_fees": round(pf.get("total_fees_paid", 0), 2), "equity": round(equity, 2), "total_pnl": round(total_pnl, 2), "pnl_pct": round(total_pnl / starting * 100, 2), "num_positions": len(pf["positions"]), "positions": pf["positions"], } def get_trades(game_id, username): return _load(_trades_path(game_id, username), []) def daily_snapshot(game_id, username): """Take daily snapshot.""" p = get_portfolio(game_id, username) if not p: return None snapshots = _load(_snapshots_path(game_id, username), []) today = date.today().isoformat() snapshots = [s for s in snapshots if s["date"] != today] snapshots.append({ "date": today, "equity": p["equity"], "total_pnl": p["total_pnl"], "pnl_pct": p["pnl_pct"], "cash": p["cash"], "num_positions": p["num_positions"], "realized_pnl": p["realized_pnl"], "total_fees": p["total_fees"], }) _save(_snapshots_path(game_id, username), snapshots) return snapshots[-1] def get_leaderboard(game_id): """Leaderboard sorted by equity.""" game = get_game(game_id) if not game: return [] board = [] for username in game["players"]: p = get_portfolio(game_id, username) if p: trades = get_trades(game_id, username) closed = [t for t in trades if t.get("action") in ("CLOSE", "LIQUIDATED")] wins = [t for t in closed if t.get("pnl", 0) > 0] liquidations = [t for t in closed if t.get("liquidated")] board.append({ "username": username, "equity": p["equity"], "total_pnl": p["total_pnl"], "pnl_pct": p["pnl_pct"], "num_positions": p["num_positions"], "trades_closed": len(closed), "win_rate": round(len(wins) / len(closed) * 100, 1) if closed else 0, "liquidations": len(liquidations), "total_fees": p["total_fees"], }) return sorted(board, key=lambda x: x["pnl_pct"], reverse=True) # ── Auto-Trader (Scanner Integration) ── def auto_trade_from_scanner(game_id, username, scan_results, margin_per_trade=200, leverage=10): """ Automatically open positions based on scanner results. Short scanner (score >= 50) → open short Spot scanner (score >= 40) → open long """ opened = [] for r in scan_results: symbol = r["symbol"] score = r["score"] # Determine direction based on which scanner produced this direction = r.get("direction", "short") # Default to short for short scanner if score < 40: continue # Scale leverage with conviction if score >= 70: lev = min(leverage, 15) elif score >= 50: lev = min(leverage, 10) else: lev = min(leverage, 5) result = open_position(game_id, username, symbol, direction, margin_per_trade, lev, reason=f"Scanner score:{score} | {', '.join(r.get('reasons', []))}") if result.get("success"): opened.append(result) return opened # ── Initialize ── def ensure_default_game(): """Create default Leverage Challenge game.""" for g in list_games(): if g["name"] == "Leverage Challenge": return g["game_id"] game_id = create_game("Leverage Challenge", starting_cash=10_000.0, max_leverage=20, creator="case") join_game(game_id, "case") return game_id if __name__ == "__main__": game_id = ensure_default_game() game = get_game(game_id) print(f"Game: {game['name']} ({game_id})") print(f"Players: {game['players']}") print(f"Starting cash: ${game['starting_cash']:,.2f}") print(f"Max leverage: {game['max_leverage']}x") board = get_leaderboard(game_id) for entry in board: print(f" {entry['username']}: ${entry['equity']:,.2f} ({entry['pnl_pct']:+.2f}%) " f"| {entry['trades_closed']} trades | {entry['win_rate']}% win | {entry['liquidations']} liquidated")