Add short signal scanner + leverage trading game engine + auto-trader
- 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
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
||||
{
|
||||
"game_id": "1ac7d29c",
|
||||
"name": "Leverage Challenge",
|
||||
"starting_cash": 10000.0,
|
||||
"max_leverage": 20,
|
||||
"funding_rate_8h": 0.01,
|
||||
"maker_fee": 0.02,
|
||||
"taker_fee": 0.05,
|
||||
"start_date": "2026-02-09",
|
||||
"creator": "case",
|
||||
"created_at": "2026-02-10T02:31:27.614107+00:00",
|
||||
"players": [
|
||||
"case"
|
||||
],
|
||||
"status": "active"
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
{
|
||||
"cash": 10000.0,
|
||||
"positions": {},
|
||||
"total_realized_pnl": 0,
|
||||
"total_fees_paid": 0,
|
||||
"total_funding_paid": 0
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -0,0 +1 @@
|
||||
[]
|
||||
@ -0,0 +1,4 @@
|
||||
{
|
||||
"peak_pnl": {},
|
||||
"last_alert": null
|
||||
}
|
||||
487
projects/crypto-signals/data/short-scanner/scan_log.json
Normal file
487
projects/crypto-signals/data/short-scanner/scan_log.json
Normal file
@ -0,0 +1,487 @@
|
||||
[
|
||||
{
|
||||
"timestamp": "2026-02-10T02:31:38.063585+00:00",
|
||||
"coins_scanned": 29,
|
||||
"strong_signals": 0,
|
||||
"results": [
|
||||
{
|
||||
"symbol": "FIL",
|
||||
"price": 0.954,
|
||||
"rsi": 61.7,
|
||||
"vwap_pct": 9.0,
|
||||
"macd_histogram": -0.932576,
|
||||
"bb_position": 0.76,
|
||||
"change_24h": 0.74,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.05,
|
||||
"score": 40,
|
||||
"reasons": [
|
||||
"RSI mildly elevated (61.7)",
|
||||
"Well above VWAP (+9.0%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.839978+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "NEAR",
|
||||
"price": 1.045,
|
||||
"rsi": 60.0,
|
||||
"vwap_pct": 1.62,
|
||||
"macd_histogram": -1.030379,
|
||||
"bb_position": 0.93,
|
||||
"change_24h": -0.85,
|
||||
"change_4h": 2.35,
|
||||
"vol_trend": 1.02,
|
||||
"score": 38,
|
||||
"reasons": [
|
||||
"RSI mildly elevated (60.0)",
|
||||
"Slightly above VWAP (+1.6%)",
|
||||
"MACD bearish + accelerating",
|
||||
"Near upper Bollinger (0.93)"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.678025+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "OP",
|
||||
"price": 0.19,
|
||||
"rsi": 64.2,
|
||||
"vwap_pct": 3.23,
|
||||
"macd_histogram": -0.187435,
|
||||
"bb_position": 0.79,
|
||||
"change_24h": 0.53,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 1.07,
|
||||
"score": 35,
|
||||
"reasons": [
|
||||
"RSI mildly elevated (64.2)",
|
||||
"Above VWAP (+3.2%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.709597+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ARB",
|
||||
"price": 0.114,
|
||||
"rsi": 52.1,
|
||||
"vwap_pct": 3.09,
|
||||
"macd_histogram": -0.113948,
|
||||
"bb_position": 0.72,
|
||||
"change_24h": -3.55,
|
||||
"change_4h": 2.06,
|
||||
"vol_trend": 0.11,
|
||||
"score": 30,
|
||||
"reasons": [
|
||||
"Above VWAP (+3.1%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.470618+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ADA",
|
||||
"price": 0.2693,
|
||||
"rsi": 50.2,
|
||||
"vwap_pct": 1.35,
|
||||
"macd_histogram": -0.269763,
|
||||
"bb_position": 0.62,
|
||||
"change_24h": -1.43,
|
||||
"change_4h": -0.81,
|
||||
"vol_trend": 0.15,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.3%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.520420+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "LINK",
|
||||
"price": 8.88,
|
||||
"rsi": 55.1,
|
||||
"vwap_pct": 1.7,
|
||||
"macd_histogram": -8.839752,
|
||||
"bb_position": 0.73,
|
||||
"change_24h": 0.79,
|
||||
"change_4h": -0.34,
|
||||
"vol_trend": 0.84,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.7%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.968544+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "UNI",
|
||||
"price": 3.519,
|
||||
"rsi": 56.2,
|
||||
"vwap_pct": 2.62,
|
||||
"macd_histogram": -3.494918,
|
||||
"bb_position": 0.65,
|
||||
"change_24h": 2.18,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.72,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+2.6%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.365690+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "AAVE",
|
||||
"price": 113.55,
|
||||
"rsi": 55.0,
|
||||
"vwap_pct": 1.28,
|
||||
"macd_histogram": -112.920164,
|
||||
"bb_position": 0.71,
|
||||
"change_24h": 0.82,
|
||||
"change_4h": 2.23,
|
||||
"vol_trend": 0.55,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.3%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.599689+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "APT",
|
||||
"price": 1.067,
|
||||
"rsi": 49.4,
|
||||
"vwap_pct": 1.06,
|
||||
"macd_histogram": -1.076147,
|
||||
"bb_position": 0.63,
|
||||
"change_24h": -2.65,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.23,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.1%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.993067+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "SUI",
|
||||
"price": 0.9668,
|
||||
"rsi": 50.4,
|
||||
"vwap_pct": 1.54,
|
||||
"macd_histogram": -0.969969,
|
||||
"bb_position": 0.66,
|
||||
"change_24h": -2.0,
|
||||
"change_4h": -0.17,
|
||||
"vol_trend": 0.03,
|
||||
"score": 23,
|
||||
"reasons": [
|
||||
"Slightly above VWAP (+1.5%)",
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.231436+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "BTC",
|
||||
"price": 70249.15,
|
||||
"rsi": 51.0,
|
||||
"vwap_pct": 0.34,
|
||||
"macd_histogram": -70262.247326,
|
||||
"bb_position": 0.63,
|
||||
"change_24h": -1.38,
|
||||
"change_4h": 0.18,
|
||||
"vol_trend": 0.96,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:31.360741+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ETH",
|
||||
"price": 2109.49,
|
||||
"rsi": 55.4,
|
||||
"vwap_pct": 0.97,
|
||||
"macd_histogram": -2104.718634,
|
||||
"bb_position": 0.67,
|
||||
"change_24h": 0.44,
|
||||
"change_4h": 0.2,
|
||||
"vol_trend": 1.27,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:31.596017+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "SOL",
|
||||
"price": 86.88,
|
||||
"rsi": 52.0,
|
||||
"vwap_pct": 0.65,
|
||||
"macd_histogram": -86.940038,
|
||||
"bb_position": 0.65,
|
||||
"change_24h": -0.98,
|
||||
"change_4h": 0.17,
|
||||
"vol_trend": 0.88,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:31.806936+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "XRP",
|
||||
"price": 1.4454,
|
||||
"rsi": 53.8,
|
||||
"vwap_pct": 0.52,
|
||||
"macd_histogram": -1.439527,
|
||||
"bb_position": 0.66,
|
||||
"change_24h": -0.39,
|
||||
"change_4h": 0.59,
|
||||
"vol_trend": 1.67,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.046327+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "DOGE",
|
||||
"price": 0.09629,
|
||||
"rsi": 52.4,
|
||||
"vwap_pct": 0.47,
|
||||
"macd_histogram": -0.096217,
|
||||
"bb_position": 0.7,
|
||||
"change_24h": -0.98,
|
||||
"change_4h": -0.43,
|
||||
"vol_trend": 2.79,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.284061+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "AVAX",
|
||||
"price": 9.01,
|
||||
"rsi": 47.3,
|
||||
"vwap_pct": 0.43,
|
||||
"macd_histogram": -9.05205,
|
||||
"bb_position": 0.56,
|
||||
"change_24h": -1.31,
|
||||
"change_4h": -0.44,
|
||||
"vol_trend": 0.29,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:32.758345+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "DOT",
|
||||
"price": 1.316,
|
||||
"rsi": 47.5,
|
||||
"vwap_pct": 0.69,
|
||||
"macd_histogram": -1.325125,
|
||||
"bb_position": 0.57,
|
||||
"change_24h": -2.52,
|
||||
"change_4h": -0.9,
|
||||
"vol_trend": 0.23,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.205989+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "MATIC",
|
||||
"price": 0.4492,
|
||||
"rsi": 41.9,
|
||||
"vwap_pct": -0.77,
|
||||
"macd_histogram": -0.452491,
|
||||
"bb_position": 0.32,
|
||||
"change_24h": -1.58,
|
||||
"change_4h": 0.22,
|
||||
"vol_trend": 1.07,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.442040+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ATOM",
|
||||
"price": 1.953,
|
||||
"rsi": 49.3,
|
||||
"vwap_pct": 0.52,
|
||||
"macd_histogram": -1.965896,
|
||||
"bb_position": 0.5,
|
||||
"change_24h": 0.93,
|
||||
"change_4h": 0.05,
|
||||
"vol_trend": 1.04,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:33.888750+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "LTC",
|
||||
"price": 54.36,
|
||||
"rsi": 50.1,
|
||||
"vwap_pct": 0.22,
|
||||
"macd_histogram": -54.485415,
|
||||
"bb_position": 0.61,
|
||||
"change_24h": -1.06,
|
||||
"change_4h": -0.22,
|
||||
"vol_trend": 2.73,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:34.128167+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ALGO",
|
||||
"price": 0.0951,
|
||||
"rsi": 45.3,
|
||||
"vwap_pct": -1.14,
|
||||
"macd_histogram": -0.095952,
|
||||
"bb_position": 0.5,
|
||||
"change_24h": -2.56,
|
||||
"change_4h": -2.36,
|
||||
"vol_trend": 1.65,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.076205+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "XLM",
|
||||
"price": 0.1614,
|
||||
"rsi": 52.5,
|
||||
"vwap_pct": 0.89,
|
||||
"macd_histogram": -0.160963,
|
||||
"bb_position": 0.69,
|
||||
"change_24h": -1.34,
|
||||
"change_4h": 1.13,
|
||||
"vol_trend": 2.74,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.314005+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "VET",
|
||||
"price": 0.00791,
|
||||
"rsi": 48.8,
|
||||
"vwap_pct": -0.02,
|
||||
"macd_histogram": -0.007928,
|
||||
"bb_position": 0.55,
|
||||
"change_24h": -3.06,
|
||||
"change_4h": 0.89,
|
||||
"vol_trend": 0.27,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.550100+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ICP",
|
||||
"price": 2.782,
|
||||
"rsi": 40.1,
|
||||
"vwap_pct": -1.9,
|
||||
"macd_histogram": -2.843397,
|
||||
"bb_position": 0.14,
|
||||
"change_24h": 3.81,
|
||||
"change_4h": -2.49,
|
||||
"vol_trend": 10.38,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:35.759005+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "SEI",
|
||||
"price": 0.075,
|
||||
"rsi": 29.9,
|
||||
"vwap_pct": -0.25,
|
||||
"macd_histogram": -0.075645,
|
||||
"bb_position": 0.38,
|
||||
"change_24h": -4.34,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.11,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:36.945126+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "HYPE",
|
||||
"price": 31.49,
|
||||
"rsi": 45.7,
|
||||
"vwap_pct": -1.11,
|
||||
"macd_histogram": -31.681649,
|
||||
"bb_position": 0.39,
|
||||
"change_24h": -5.29,
|
||||
"change_4h": 0.41,
|
||||
"vol_trend": 21.91,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.184127+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "TRUMP",
|
||||
"price": 3.446,
|
||||
"rsi": 50.1,
|
||||
"vwap_pct": 0.08,
|
||||
"macd_histogram": -3.45085,
|
||||
"bb_position": 0.49,
|
||||
"change_24h": 0.35,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 0.81,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.440389+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "PUMP",
|
||||
"price": 0.002041,
|
||||
"rsi": 37.6,
|
||||
"vwap_pct": -2.03,
|
||||
"macd_histogram": -0.002063,
|
||||
"bb_position": 0.31,
|
||||
"change_24h": -4.31,
|
||||
"change_4h": 0.0,
|
||||
"vol_trend": 1.28,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.674937+00:00"
|
||||
},
|
||||
{
|
||||
"symbol": "ASTER",
|
||||
"price": 0.612,
|
||||
"rsi": 50.2,
|
||||
"vwap_pct": 0.21,
|
||||
"macd_histogram": -0.610803,
|
||||
"bb_position": 0.58,
|
||||
"change_24h": -5.85,
|
||||
"change_4h": 0.33,
|
||||
"vol_trend": 0.63,
|
||||
"score": 15,
|
||||
"reasons": [
|
||||
"MACD bearish + accelerating"
|
||||
],
|
||||
"timestamp": "2026-02-10T02:31:37.912733+00:00"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
504
projects/crypto-signals/leverage_game.py
Normal file
504
projects/crypto-signals/leverage_game.py
Normal file
@ -0,0 +1,504 @@
|
||||
#!/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")
|
||||
292
projects/crypto-signals/scripts/leverage_trader.py
Normal file
292
projects/crypto-signals/scripts/leverage_trader.py
Normal file
@ -0,0 +1,292 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automated Leverage Trader
|
||||
Runs short scanner + spot scanner, opens positions in the Leverage Challenge game,
|
||||
manages exits (TP/SL/trailing stop), and reports via Telegram.
|
||||
|
||||
Zero AI tokens — systemd timer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path for imports
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from leverage_game import (
|
||||
ensure_default_game, get_game, get_portfolio, open_position,
|
||||
close_position, update_prices, get_trades, get_leaderboard
|
||||
)
|
||||
from scripts.short_scanner import scan_coin, COINS as SHORT_COINS
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
DATA_DIR = Path(__file__).parent.parent / "data" / "leverage-game"
|
||||
STATE_FILE = DATA_DIR / "trader_state.json"
|
||||
|
||||
# Trading params
|
||||
MARGIN_PER_TRADE = 200 # $200 margin per position
|
||||
DEFAULT_LEVERAGE = 10 # 10x default
|
||||
MAX_OPEN_POSITIONS = 10 # Max simultaneous positions
|
||||
SHORT_SCORE_THRESHOLD = 50 # Min score to open short
|
||||
LONG_SCORE_THRESHOLD = 45 # Min score to open long
|
||||
TP_PCT = 5.0 # Take profit at 5% on margin (50% on notional at 10x)
|
||||
SL_PCT = -3.0 # Stop loss at -3% on margin (30% on notional at 10x)
|
||||
TRAILING_STOP_PCT = 2.0 # Trailing stop: close if drops 2% from peak
|
||||
|
||||
|
||||
def load_state():
|
||||
if STATE_FILE.exists():
|
||||
return json.loads(STATE_FILE.read_text())
|
||||
return {"peak_pnl": {}, "last_alert": None}
|
||||
|
||||
def save_state(state):
|
||||
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
STATE_FILE.write_text(json.dumps(state, indent=2))
|
||||
|
||||
|
||||
def send_telegram(message):
|
||||
if not TELEGRAM_BOT_TOKEN:
|
||||
print(f"[TG] {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"
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={
|
||||
"Content-Type": "application/json", "User-Agent": "Mozilla/5.0"
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"Telegram failed: {e}")
|
||||
|
||||
|
||||
def run_short_scan():
|
||||
"""Run short scanner on all coins."""
|
||||
results = []
|
||||
for symbol in SHORT_COINS:
|
||||
r = scan_coin(symbol)
|
||||
if r:
|
||||
r["direction"] = "short"
|
||||
results.append(r)
|
||||
time.sleep(0.15)
|
||||
return sorted(results, key=lambda x: x['score'], reverse=True)
|
||||
|
||||
|
||||
def run_spot_scan():
|
||||
"""Run spot/long scanner (inverse of short criteria — oversold = buy)."""
|
||||
results = []
|
||||
for symbol in SHORT_COINS:
|
||||
r = scan_coin(symbol)
|
||||
if r:
|
||||
# Invert: low RSI + below VWAP = long opportunity
|
||||
long_score = 0
|
||||
reasons = []
|
||||
|
||||
if r['rsi'] <= 25:
|
||||
long_score += 30
|
||||
reasons.append(f"RSI extremely oversold ({r['rsi']})")
|
||||
elif r['rsi'] <= 30:
|
||||
long_score += 25
|
||||
reasons.append(f"RSI oversold ({r['rsi']})")
|
||||
elif r['rsi'] <= 35:
|
||||
long_score += 15
|
||||
reasons.append(f"RSI low ({r['rsi']})")
|
||||
elif r['rsi'] <= 40:
|
||||
long_score += 5
|
||||
reasons.append(f"RSI mildly low ({r['rsi']})")
|
||||
|
||||
if r['vwap_pct'] < -5:
|
||||
long_score += 20
|
||||
reasons.append(f"Well below VWAP ({r['vwap_pct']:+.1f}%)")
|
||||
elif r['vwap_pct'] < -3:
|
||||
long_score += 15
|
||||
reasons.append(f"Below VWAP ({r['vwap_pct']:+.1f}%)")
|
||||
elif r['vwap_pct'] < -1:
|
||||
long_score += 8
|
||||
reasons.append(f"Slightly below VWAP ({r['vwap_pct']:+.1f}%)")
|
||||
|
||||
if r['change_24h'] < -15:
|
||||
long_score += 15
|
||||
reasons.append(f"Dumped {r['change_24h']:.1f}% 24h")
|
||||
elif r['change_24h'] < -8:
|
||||
long_score += 10
|
||||
reasons.append(f"Down {r['change_24h']:.1f}% 24h")
|
||||
elif r['change_24h'] < -4:
|
||||
long_score += 5
|
||||
reasons.append(f"Down {r['change_24h']:.1f}% 24h")
|
||||
|
||||
if r['bb_position'] < 0:
|
||||
long_score += 15
|
||||
reasons.append(f"Below lower Bollinger ({r['bb_position']:.2f})")
|
||||
elif r['bb_position'] < 0.15:
|
||||
long_score += 10
|
||||
reasons.append(f"Near lower Bollinger ({r['bb_position']:.2f})")
|
||||
|
||||
results.append({
|
||||
"symbol": r["symbol"],
|
||||
"price": r["price"],
|
||||
"rsi": r["rsi"],
|
||||
"vwap_pct": r["vwap_pct"],
|
||||
"change_24h": r["change_24h"],
|
||||
"bb_position": r["bb_position"],
|
||||
"score": long_score,
|
||||
"reasons": reasons,
|
||||
"direction": "long",
|
||||
})
|
||||
time.sleep(0.15)
|
||||
return sorted(results, key=lambda x: x['score'], reverse=True)
|
||||
|
||||
|
||||
def manage_exits(game_id, username, state):
|
||||
"""Check open positions for TP/SL/trailing stop exits."""
|
||||
pf = get_portfolio(game_id, username)
|
||||
if not pf:
|
||||
return []
|
||||
|
||||
exits = []
|
||||
for pos_id, pos in list(pf["positions"].items()):
|
||||
pnl_pct = (pos.get("unrealized_pnl", 0) / pos["margin_usd"] * 100) if pos["margin_usd"] > 0 else 0
|
||||
|
||||
# Track peak PnL for trailing stop
|
||||
peak_key = pos_id
|
||||
if peak_key not in state.get("peak_pnl", {}):
|
||||
state["peak_pnl"][peak_key] = pnl_pct
|
||||
if pnl_pct > state["peak_pnl"].get(peak_key, 0):
|
||||
state["peak_pnl"][peak_key] = pnl_pct
|
||||
|
||||
peak = state["peak_pnl"].get(peak_key, 0)
|
||||
reason = None
|
||||
|
||||
# Take profit
|
||||
if pnl_pct >= TP_PCT:
|
||||
reason = f"TP hit ({pnl_pct:+.1f}%)"
|
||||
# Stop loss
|
||||
elif pnl_pct <= SL_PCT:
|
||||
reason = f"SL hit ({pnl_pct:+.1f}%)"
|
||||
# Trailing stop (only if we were profitable)
|
||||
elif peak >= 2.0 and (peak - pnl_pct) >= TRAILING_STOP_PCT:
|
||||
reason = f"Trailing stop (peak {peak:+.1f}%, now {pnl_pct:+.1f}%)"
|
||||
|
||||
if reason:
|
||||
result = close_position(game_id, username, pos_id, reason=reason)
|
||||
if result.get("success"):
|
||||
exits.append(result)
|
||||
# Clean up peak tracking
|
||||
state["peak_pnl"].pop(peak_key, None)
|
||||
|
||||
return exits
|
||||
|
||||
|
||||
def main():
|
||||
game_id = ensure_default_game()
|
||||
state = load_state()
|
||||
|
||||
print(f"=== Leverage Trader ===")
|
||||
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print(f"Game: {game_id}")
|
||||
|
||||
# 1. Update prices and check liquidations
|
||||
liquidations = update_prices(game_id, "case")
|
||||
for liq in liquidations:
|
||||
msg = f"💀 <b>LIQUIDATED</b>: {liq['symbol']} {liq['direction']} {liq.get('leverage', '?')}x | Lost ${abs(liq.get('pnl', 0)):.2f}"
|
||||
send_telegram(msg)
|
||||
print(msg)
|
||||
|
||||
# 2. Manage exits (TP/SL/trailing)
|
||||
exits = manage_exits(game_id, "case", state)
|
||||
for ex in exits:
|
||||
emoji = "✅" if ex.get("pnl", 0) > 0 else "❌"
|
||||
msg = (f"{emoji} <b>Closed</b>: {ex['symbol']} {ex['direction']} | "
|
||||
f"Entry: ${ex['entry_price']:.4f} → Exit: ${ex['exit_price']:.4f} | "
|
||||
f"PnL: ${ex['pnl']:+.2f} ({ex['pnl_pct']:+.1f}%)")
|
||||
print(msg)
|
||||
|
||||
# 3. Get current portfolio
|
||||
pf = get_portfolio(game_id, "case")
|
||||
num_open = pf["num_positions"] if pf else 0
|
||||
slots = MAX_OPEN_POSITIONS - num_open
|
||||
|
||||
print(f"\nPortfolio: ${pf['equity']:,.2f} ({pf['pnl_pct']:+.2f}%) | {num_open} positions | {slots} slots open")
|
||||
|
||||
# 4. Scan for new opportunities
|
||||
if slots > 0:
|
||||
# Run both scanners
|
||||
shorts = run_short_scan()
|
||||
longs = run_spot_scan()
|
||||
|
||||
# Get existing symbols to avoid doubling up
|
||||
existing_symbols = set()
|
||||
if pf:
|
||||
for pos in pf["positions"].values():
|
||||
existing_symbols.add(pos["symbol"])
|
||||
|
||||
opened = []
|
||||
|
||||
# Open short positions
|
||||
for r in shorts:
|
||||
if slots <= 0:
|
||||
break
|
||||
if r["score"] < SHORT_SCORE_THRESHOLD:
|
||||
break
|
||||
if r["symbol"] in existing_symbols:
|
||||
continue
|
||||
|
||||
lev = 15 if r["score"] >= 70 else 10 if r["score"] >= 60 else 7
|
||||
result = open_position(game_id, "case", r["symbol"], "short", MARGIN_PER_TRADE, lev,
|
||||
reason=f"Short scanner score:{r['score']}")
|
||||
if result.get("success"):
|
||||
opened.append(result)
|
||||
existing_symbols.add(r["symbol"])
|
||||
slots -= 1
|
||||
time.sleep(0.2)
|
||||
|
||||
# Open long positions
|
||||
for r in longs:
|
||||
if slots <= 0:
|
||||
break
|
||||
if r["score"] < LONG_SCORE_THRESHOLD:
|
||||
break
|
||||
if r["symbol"] in existing_symbols:
|
||||
continue
|
||||
|
||||
lev = 15 if r["score"] >= 70 else 10 if r["score"] >= 60 else 7
|
||||
result = open_position(game_id, "case", r["symbol"], "long", MARGIN_PER_TRADE, lev,
|
||||
reason=f"Long scanner score:{r['score']}")
|
||||
if result.get("success"):
|
||||
opened.append(result)
|
||||
existing_symbols.add(r["symbol"])
|
||||
slots -= 1
|
||||
time.sleep(0.2)
|
||||
|
||||
if opened:
|
||||
lines = [f"📊 <b>Opened {len(opened)} positions</b>\n"]
|
||||
for o in opened:
|
||||
lines.append(f"{'🔴' if o['direction']=='short' else '🟢'} {o['symbol']} {o['direction']} {o['leverage']}x @ ${o['entry_price']:.4f} (${o['margin']:.0f} margin)")
|
||||
send_telegram("\n".join(lines))
|
||||
print(f"\nOpened {len(opened)} new positions")
|
||||
|
||||
# 5. Send periodic summary (every 4 hours)
|
||||
if exits or liquidations:
|
||||
pf = get_portfolio(game_id, "case") # Refresh
|
||||
msg = (f"📈 <b>Leverage Challenge Update</b>\n"
|
||||
f"Equity: ${pf['equity']:,.2f} ({pf['pnl_pct']:+.2f}%)\n"
|
||||
f"Positions: {pf['num_positions']} | Cash: ${pf['cash']:,.2f}\n"
|
||||
f"Realized PnL: ${pf['realized_pnl']:+,.2f} | Fees: ${pf['total_fees']:,.2f}")
|
||||
send_telegram(msg)
|
||||
|
||||
save_state(state)
|
||||
print("\nDone.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
336
projects/crypto-signals/scripts/short_scanner.py
Normal file
336
projects/crypto-signals/scripts/short_scanner.py
Normal file
@ -0,0 +1,336 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Crypto Short Signal Scanner
|
||||
Scans for overbought coins ripe for shorting.
|
||||
Criteria: high RSI, price above VWAP, fading momentum, bearish divergence.
|
||||
|
||||
Zero AI tokens — runs as pure Python via systemd timer.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
import math
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# Config
|
||||
DATA_DIR = Path(__file__).parent.parent / "data" / "short-scanner"
|
||||
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||
SCAN_LOG = DATA_DIR / "scan_log.json"
|
||||
|
||||
TELEGRAM_BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "6443752046")
|
||||
|
||||
BINANCE_KLINES = "https://api.binance.us/api/v3/klines"
|
||||
BINANCE_TICKER = "https://api.binance.us/api/v3/ticker/24hr"
|
||||
|
||||
# Coins to scan (popular leveraged trading coins)
|
||||
COINS = [
|
||||
"BTCUSDT", "ETHUSDT", "SOLUSDT", "XRPUSDT", "DOGEUSDT",
|
||||
"ADAUSDT", "AVAXUSDT", "LINKUSDT", "DOTUSDT", "MATICUSDT",
|
||||
"NEARUSDT", "ATOMUSDT", "LTCUSDT", "UNIUSDT", "AAVEUSDT",
|
||||
"FILUSDT", "ALGOUSDT", "XLMUSDT", "VETUSDT", "ICPUSDT",
|
||||
"APTUSDT", "SUIUSDT", "ARBUSDT", "OPUSDT", "SEIUSDT",
|
||||
"HYPEUSDT", "TRUMPUSDT", "PUMPUSDT", "ASTERUSDT",
|
||||
]
|
||||
|
||||
|
||||
def get_klines(symbol, interval='1h', limit=100):
|
||||
"""Fetch klines from Binance US."""
|
||||
url = f"{BINANCE_KLINES}?symbol={symbol}&interval={interval}&limit={limit}"
|
||||
req = urllib.request.Request(url, headers={'User-Agent': 'Mozilla/5.0'})
|
||||
try:
|
||||
resp = urllib.request.urlopen(req, timeout=10)
|
||||
raw = json.loads(resp.read())
|
||||
return [{
|
||||
'open': float(k[1]),
|
||||
'high': float(k[2]),
|
||||
'low': float(k[3]),
|
||||
'close': float(k[4]),
|
||||
'volume': float(k[5]),
|
||||
'close_time': k[6],
|
||||
} for k in raw]
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
def calc_rsi(closes, period=14):
|
||||
"""Calculate RSI."""
|
||||
if len(closes) < period + 1:
|
||||
return 50
|
||||
deltas = [closes[i] - closes[i-1] for i in range(1, len(closes))]
|
||||
gains = [d if d > 0 else 0 for d in deltas]
|
||||
losses = [-d if d < 0 else 0 for d in deltas]
|
||||
|
||||
avg_gain = sum(gains[:period]) / period
|
||||
avg_loss = sum(losses[:period]) / period
|
||||
|
||||
for i in range(period, len(deltas)):
|
||||
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
|
||||
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
|
||||
|
||||
if avg_loss == 0:
|
||||
return 100
|
||||
rs = avg_gain / avg_loss
|
||||
return round(100 - (100 / (1 + rs)), 1)
|
||||
|
||||
|
||||
def calc_vwap(klines):
|
||||
"""Calculate VWAP from klines."""
|
||||
cum_vol = 0
|
||||
cum_tp_vol = 0
|
||||
for k in klines:
|
||||
tp = (k['high'] + k['low'] + k['close']) / 3
|
||||
cum_vol += k['volume']
|
||||
cum_tp_vol += tp * k['volume']
|
||||
if cum_vol == 0:
|
||||
return 0
|
||||
return cum_tp_vol / cum_vol
|
||||
|
||||
|
||||
def calc_ema(values, period):
|
||||
"""Calculate EMA."""
|
||||
if not values:
|
||||
return 0
|
||||
multiplier = 2 / (period + 1)
|
||||
ema = values[0]
|
||||
for v in values[1:]:
|
||||
ema = (v - ema) * multiplier + ema
|
||||
return ema
|
||||
|
||||
|
||||
def calc_macd(closes):
|
||||
"""Calculate MACD (12, 26, 9)."""
|
||||
if len(closes) < 26:
|
||||
return 0, 0, 0
|
||||
ema12 = calc_ema(closes, 12)
|
||||
ema26 = calc_ema(closes, 26)
|
||||
macd_line = ema12 - ema26
|
||||
# Approximate signal line
|
||||
signal = calc_ema(closes[-9:], 9) if len(closes) >= 9 else macd_line
|
||||
histogram = macd_line - signal
|
||||
return macd_line, signal, histogram
|
||||
|
||||
|
||||
def calc_bollinger_position(closes, period=20):
|
||||
"""How far price is from upper Bollinger band. >1 = above upper band."""
|
||||
if len(closes) < period:
|
||||
return 0.5
|
||||
recent = closes[-period:]
|
||||
sma = sum(recent) / period
|
||||
std = (sum((x - sma)**2 for x in recent) / period) ** 0.5
|
||||
if std == 0:
|
||||
return 0.5
|
||||
upper = sma + 2 * std
|
||||
lower = sma - 2 * std
|
||||
band_width = upper - lower
|
||||
if band_width == 0:
|
||||
return 0.5
|
||||
return (closes[-1] - lower) / band_width
|
||||
|
||||
|
||||
def volume_trend(klines, lookback=10):
|
||||
"""Compare recent volume to average. >1 means increasing volume."""
|
||||
if len(klines) < lookback * 2:
|
||||
return 1.0
|
||||
recent_vol = sum(k['volume'] for k in klines[-lookback:]) / lookback
|
||||
older_vol = sum(k['volume'] for k in klines[-lookback*2:-lookback]) / lookback
|
||||
if older_vol == 0:
|
||||
return 1.0
|
||||
return recent_vol / older_vol
|
||||
|
||||
|
||||
def scan_coin(symbol):
|
||||
"""Analyze a single coin for short signals."""
|
||||
# Get 1h candles for RSI/VWAP/indicators
|
||||
klines_1h = get_klines(symbol, '1h', 100)
|
||||
if len(klines_1h) < 30:
|
||||
return None
|
||||
|
||||
closes = [k['close'] for k in klines_1h]
|
||||
current_price = closes[-1]
|
||||
|
||||
# RSI (14-period on 1h)
|
||||
rsi = calc_rsi(closes)
|
||||
|
||||
# VWAP (24h)
|
||||
vwap_24h = calc_vwap(klines_1h[-24:])
|
||||
vwap_pct = ((current_price - vwap_24h) / vwap_24h * 100) if vwap_24h else 0
|
||||
|
||||
# MACD
|
||||
macd_line, signal_line, histogram = calc_macd(closes)
|
||||
macd_bearish = histogram < 0 # Below signal = bearish
|
||||
|
||||
# Bollinger position
|
||||
bb_pos = calc_bollinger_position(closes)
|
||||
|
||||
# Volume trend
|
||||
vol_trend = volume_trend(klines_1h)
|
||||
|
||||
# 24h change
|
||||
price_24h_ago = closes[-24] if len(closes) >= 24 else closes[0]
|
||||
change_24h = ((current_price - price_24h_ago) / price_24h_ago * 100) if price_24h_ago else 0
|
||||
|
||||
# 4h change (momentum)
|
||||
price_4h_ago = closes[-4] if len(closes) >= 4 else closes[0]
|
||||
change_4h = ((current_price - price_4h_ago) / price_4h_ago * 100) if price_4h_ago else 0
|
||||
|
||||
# === SHORT SCORING ===
|
||||
score = 0
|
||||
reasons = []
|
||||
|
||||
# RSI overbought (max 30 pts)
|
||||
if rsi >= 80:
|
||||
score += 30
|
||||
reasons.append(f"RSI extremely overbought ({rsi})")
|
||||
elif rsi >= 70:
|
||||
score += 25
|
||||
reasons.append(f"RSI overbought ({rsi})")
|
||||
elif rsi >= 65:
|
||||
score += 15
|
||||
reasons.append(f"RSI elevated ({rsi})")
|
||||
elif rsi >= 60:
|
||||
score += 5
|
||||
reasons.append(f"RSI mildly elevated ({rsi})")
|
||||
|
||||
# Price above VWAP (max 20 pts)
|
||||
if vwap_pct > 5:
|
||||
score += 20
|
||||
reasons.append(f"Well above VWAP (+{vwap_pct:.1f}%)")
|
||||
elif vwap_pct > 3:
|
||||
score += 15
|
||||
reasons.append(f"Above VWAP (+{vwap_pct:.1f}%)")
|
||||
elif vwap_pct > 1:
|
||||
score += 8
|
||||
reasons.append(f"Slightly above VWAP (+{vwap_pct:.1f}%)")
|
||||
|
||||
# MACD bearish crossover (max 15 pts)
|
||||
if macd_bearish and histogram < -0.001 * current_price:
|
||||
score += 15
|
||||
reasons.append("MACD bearish + accelerating")
|
||||
elif macd_bearish:
|
||||
score += 10
|
||||
reasons.append("MACD bearish crossover")
|
||||
|
||||
# Bollinger band position (max 15 pts)
|
||||
if bb_pos > 1.0:
|
||||
score += 15
|
||||
reasons.append(f"Above upper Bollinger ({bb_pos:.2f})")
|
||||
elif bb_pos > 0.85:
|
||||
score += 10
|
||||
reasons.append(f"Near upper Bollinger ({bb_pos:.2f})")
|
||||
|
||||
# Big recent pump (mean reversion candidate) (max 15 pts)
|
||||
if change_24h > 15:
|
||||
score += 15
|
||||
reasons.append(f"Pumped +{change_24h:.1f}% 24h")
|
||||
elif change_24h > 8:
|
||||
score += 10
|
||||
reasons.append(f"Up +{change_24h:.1f}% 24h")
|
||||
elif change_24h > 4:
|
||||
score += 5
|
||||
reasons.append(f"Up +{change_24h:.1f}% 24h")
|
||||
|
||||
# Volume fading on uptrend (exhaustion) (5 pts)
|
||||
if change_24h > 2 and vol_trend < 0.7:
|
||||
score += 5
|
||||
reasons.append("Volume fading on uptrend (exhaustion)")
|
||||
|
||||
return {
|
||||
"symbol": symbol.replace("USDT", ""),
|
||||
"price": current_price,
|
||||
"rsi": rsi,
|
||||
"vwap_pct": round(vwap_pct, 2),
|
||||
"macd_histogram": round(histogram, 6),
|
||||
"bb_position": round(bb_pos, 2),
|
||||
"change_24h": round(change_24h, 2),
|
||||
"change_4h": round(change_4h, 2),
|
||||
"vol_trend": round(vol_trend, 2),
|
||||
"score": score,
|
||||
"reasons": reasons,
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def send_telegram_alert(message):
|
||||
"""Send alert via Telegram bot API."""
|
||||
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"
|
||||
}).encode()
|
||||
req = urllib.request.Request(url, data=data, headers={
|
||||
"Content-Type": "application/json",
|
||||
"User-Agent": "Mozilla/5.0"
|
||||
})
|
||||
try:
|
||||
urllib.request.urlopen(req, timeout=10)
|
||||
except Exception as e:
|
||||
print(f"Telegram alert failed: {e}")
|
||||
|
||||
|
||||
def main():
|
||||
print(f"=== Crypto Short Signal Scanner ===")
|
||||
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
print()
|
||||
|
||||
results = []
|
||||
for symbol in COINS:
|
||||
result = scan_coin(symbol)
|
||||
if result:
|
||||
results.append(result)
|
||||
time.sleep(0.15) # Rate limiting
|
||||
|
||||
# Sort by score descending
|
||||
results.sort(key=lambda x: x['score'], reverse=True)
|
||||
|
||||
# Print all results
|
||||
for r in results:
|
||||
emoji = "🔴" if r['score'] >= 50 else "🟡" if r['score'] >= 30 else "⚪"
|
||||
print(f"{emoji} {r['symbol']:8s} score:{r['score']:3d} | RSI:{r['rsi']:5.1f} | VWAP:{r['vwap_pct']:+6.1f}% | 24h:{r['change_24h']:+6.1f}% | BB:{r['bb_position']:.2f}")
|
||||
if r['reasons']:
|
||||
for reason in r['reasons']:
|
||||
print(f" → {reason}")
|
||||
|
||||
# Alert on strong short signals (score >= 50)
|
||||
strong = [r for r in results if r['score'] >= 50]
|
||||
if strong:
|
||||
lines = ["🔴 <b>Short Signals Detected</b>\n"]
|
||||
for r in strong:
|
||||
lines.append(f"<b>{r['symbol']}</b> (score: {r['score']})")
|
||||
lines.append(f" Price: ${r['price']:.4f} | RSI: {r['rsi']} | VWAP: {r['vwap_pct']:+.1f}%")
|
||||
lines.append(f" 24h: {r['change_24h']:+.1f}% | BB: {r['bb_position']:.2f}")
|
||||
for reason in r['reasons']:
|
||||
lines.append(f" → {reason}")
|
||||
lines.append("")
|
||||
send_telegram_alert("\n".join(lines))
|
||||
|
||||
# Save scan log
|
||||
log = []
|
||||
if SCAN_LOG.exists():
|
||||
try:
|
||||
log = json.loads(SCAN_LOG.read_text())
|
||||
except:
|
||||
pass
|
||||
|
||||
log.append({
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
"coins_scanned": len(results),
|
||||
"strong_signals": len(strong),
|
||||
"results": results,
|
||||
})
|
||||
log = log[-500:]
|
||||
SCAN_LOG.write_text(json.dumps(log, indent=2))
|
||||
|
||||
print(f"\n📊 Summary: {len(results)} scanned, {len(strong)} strong short signals")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user