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:
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