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:
2026-02-09 20:32:18 -06:00
parent ab7abc2ea5
commit c5b941b487
11 changed files with 4362 additions and 1 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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"
}

View File

@ -0,0 +1,7 @@
{
"cash": 10000.0,
"positions": {},
"total_realized_pnl": 0,
"total_fees_paid": 0,
"total_funding_paid": 0
}

View File

@ -0,0 +1,4 @@
{
"peak_pnl": {},
"last_alert": null
}

View 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"
}
]
}
]

View 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")

View 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()

View 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()

View File

@ -1,5 +1,5 @@
{
"last_check": "2026-02-10T02:13:59.845747+00:00",
"last_check": "2026-02-10T02:31:59.875011+00:00",
"total_tracked": 3100,
"new_this_check": 0
}