- short_scanner.py: RSI/VWAP/MACD/Bollinger-based short signal detection - leverage_game.py: Full game engine with longs/shorts/leverage/liquidations - leverage_trader.py: Auto-trader connecting scanners to game with TP/SL/trailing stops - Leverage Challenge game initialized: $10K, 20x max leverage, player 'case' - systemd timer: every 15min scan + trade - Telegram alerts on opens/closes/liquidations
505 lines
16 KiB
Python
505 lines
16 KiB
Python
#!/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")
|