594 lines
18 KiB
Python
594 lines
18 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Crypto Market Watch - Paper Trading Game Engine
|
|
Scans top 150 cryptos using VWAP + RSI + volume analysis.
|
|
Makes autonomous paper trades with full tracking.
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import urllib.request
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from threading import Lock
|
|
|
|
DATA_DIR = Path(__file__).parent / "data"
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
|
PORTFOLIO_FILE = DATA_DIR / "portfolio.json"
|
|
TRADES_FILE = DATA_DIR / "trades.json"
|
|
SNAPSHOTS_FILE = DATA_DIR / "snapshots.json"
|
|
WATCHLIST_FILE = DATA_DIR / "watchlist.json"
|
|
|
|
|
|
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.write_text(json.dumps(data, indent=2))
|
|
|
|
|
|
# ── Portfolio Management ──
|
|
|
|
def get_portfolio():
|
|
default = {
|
|
"cash": 100000.0,
|
|
"positions": {},
|
|
"starting_balance": 100000.0,
|
|
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
return _load(PORTFOLIO_FILE, default)
|
|
|
|
|
|
def save_portfolio(portfolio):
|
|
_save(PORTFOLIO_FILE, portfolio)
|
|
|
|
|
|
def get_trades():
|
|
return _load(TRADES_FILE, [])
|
|
|
|
|
|
def save_trades(trades):
|
|
_save(TRADES_FILE, trades)
|
|
|
|
|
|
def buy(symbol, price, amount_usd, reason=""):
|
|
"""Buy a crypto position."""
|
|
portfolio = get_portfolio()
|
|
trades = get_trades()
|
|
|
|
if amount_usd > portfolio["cash"]:
|
|
return None, "Insufficient cash"
|
|
|
|
qty = amount_usd / price
|
|
portfolio["cash"] -= amount_usd
|
|
|
|
pos = portfolio["positions"].get(symbol, {
|
|
"qty": 0, "avg_price": 0, "total_cost": 0
|
|
})
|
|
|
|
new_total_cost = pos["total_cost"] + amount_usd
|
|
new_qty = pos["qty"] + qty
|
|
pos["avg_price"] = new_total_cost / new_qty if new_qty > 0 else 0
|
|
pos["qty"] = new_qty
|
|
pos["total_cost"] = new_total_cost
|
|
pos["last_buy"] = datetime.now(timezone.utc).isoformat()
|
|
|
|
portfolio["positions"][symbol] = pos
|
|
save_portfolio(portfolio)
|
|
|
|
trade = {
|
|
"id": len(trades) + 1,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"action": "BUY",
|
|
"symbol": symbol,
|
|
"price": price,
|
|
"qty": qty,
|
|
"amount_usd": amount_usd,
|
|
"reason": reason,
|
|
}
|
|
trades.append(trade)
|
|
save_trades(trades)
|
|
return trade, None
|
|
|
|
|
|
def sell(symbol, price, pct=100, reason=""):
|
|
"""Sell a position (partial or full)."""
|
|
portfolio = get_portfolio()
|
|
trades = get_trades()
|
|
|
|
pos = portfolio["positions"].get(symbol)
|
|
if not pos or pos["qty"] <= 0:
|
|
return None, f"No position in {symbol}"
|
|
|
|
sell_qty = pos["qty"] * (pct / 100)
|
|
sell_value = sell_qty * price
|
|
cost_basis = pos["avg_price"] * sell_qty
|
|
pnl = sell_value - cost_basis
|
|
pnl_pct = (pnl / cost_basis) * 100 if cost_basis > 0 else 0
|
|
|
|
portfolio["cash"] += sell_value
|
|
pos["qty"] -= sell_qty
|
|
pos["total_cost"] -= cost_basis
|
|
|
|
if pos["qty"] < 0.0000001:
|
|
del portfolio["positions"][symbol]
|
|
else:
|
|
portfolio["positions"][symbol] = pos
|
|
|
|
save_portfolio(portfolio)
|
|
|
|
trade = {
|
|
"id": len(trades) + 1,
|
|
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
"action": "SELL",
|
|
"symbol": symbol,
|
|
"price": price,
|
|
"qty": sell_qty,
|
|
"amount_usd": sell_value,
|
|
"pnl": round(pnl, 2),
|
|
"pnl_pct": round(pnl_pct, 2),
|
|
"reason": reason,
|
|
}
|
|
trades.append(trade)
|
|
save_trades(trades)
|
|
return trade, None
|
|
|
|
|
|
def get_portfolio_value(prices):
|
|
"""Calculate total portfolio value."""
|
|
portfolio = get_portfolio()
|
|
positions_value = 0
|
|
position_details = []
|
|
|
|
for symbol, pos in portfolio["positions"].items():
|
|
current_price = prices.get(symbol, pos["avg_price"])
|
|
value = pos["qty"] * current_price
|
|
pnl = value - pos["total_cost"]
|
|
pnl_pct = (pnl / pos["total_cost"]) * 100 if pos["total_cost"] > 0 else 0
|
|
positions_value += value
|
|
position_details.append({
|
|
"symbol": symbol,
|
|
"qty": pos["qty"],
|
|
"avg_price": pos["avg_price"],
|
|
"current_price": current_price,
|
|
"value": round(value, 2),
|
|
"pnl": round(pnl, 2),
|
|
"pnl_pct": round(pnl_pct, 2),
|
|
})
|
|
|
|
total_value = portfolio["cash"] + positions_value
|
|
total_pnl = total_value - portfolio["starting_balance"]
|
|
total_pnl_pct = (total_pnl / portfolio["starting_balance"]) * 100
|
|
|
|
return {
|
|
"cash": round(portfolio["cash"], 2),
|
|
"positions_value": round(positions_value, 2),
|
|
"total_value": round(total_value, 2),
|
|
"total_pnl": round(total_pnl, 2),
|
|
"total_pnl_pct": round(total_pnl_pct, 2),
|
|
"positions": sorted(position_details, key=lambda x: -x["value"]),
|
|
"num_positions": len(position_details),
|
|
}
|
|
|
|
|
|
def take_snapshot(prices):
|
|
"""Save a point-in-time portfolio snapshot."""
|
|
snapshots = _load(SNAPSHOTS_FILE, [])
|
|
value = get_portfolio_value(prices)
|
|
value["timestamp"] = datetime.now(timezone.utc).isoformat()
|
|
snapshots.append(value)
|
|
# Keep last 1000
|
|
_save(SNAPSHOTS_FILE, snapshots[-1000:])
|
|
return value
|
|
|
|
|
|
# ── Market Data ──
|
|
|
|
def get_top_coins(limit=150):
|
|
"""Fetch top coins by market cap from CoinGecko."""
|
|
coins = []
|
|
for page in range(1, (limit // 100) + 2):
|
|
per_page = min(100, limit - len(coins))
|
|
if per_page <= 0:
|
|
break
|
|
url = (
|
|
f"https://api.coingecko.com/api/v3/coins/markets?"
|
|
f"vs_currency=usd&order=market_cap_desc&per_page={per_page}&page={page}"
|
|
f"&sparkline=false&price_change_percentage=1h,24h,7d"
|
|
)
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=15)
|
|
batch = json.loads(resp.read())
|
|
coins.extend(batch)
|
|
except Exception as e:
|
|
print(f"Error fetching page {page}: {e}")
|
|
break
|
|
time.sleep(1) # Rate limit
|
|
return coins
|
|
|
|
|
|
def get_ohlcv(coin_id, days=2):
|
|
"""Get OHLCV data from CoinGecko for VWAP calculation."""
|
|
url = f"https://api.coingecko.com/api/v3/coins/{coin_id}/ohlc?vs_currency=usd&days={days}"
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
try:
|
|
resp = urllib.request.urlopen(req, timeout=10)
|
|
raw = json.loads(resp.read())
|
|
# CoinGecko OHLC: [timestamp, open, high, low, close]
|
|
return [{"t": r[0], "o": r[1], "h": r[2], "l": r[3], "c": r[4]} for r in raw]
|
|
except:
|
|
return []
|
|
|
|
|
|
def get_binance_klines(symbol, interval="1h", limit=48):
|
|
"""Get klines from Binance US for VWAP + volume."""
|
|
url = f"https://api.binance.us/api/v3/klines?symbol={symbol}USDT&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 [{"t": k[0], "o": float(k[1]), "h": float(k[2]), "l": float(k[3]),
|
|
"c": float(k[4]), "v": float(k[5])} for k in raw]
|
|
except:
|
|
return []
|
|
|
|
|
|
# ── Technical Analysis ──
|
|
|
|
def calc_vwap(candles):
|
|
"""Calculate VWAP with standard deviation bands."""
|
|
if not candles:
|
|
return None
|
|
|
|
cum_tpv = cum_vol = cum_sq = 0
|
|
for c in candles:
|
|
vol = c.get("v", 1) # Default volume 1 if not available
|
|
tp = (c["h"] + c["l"] + c["c"]) / 3
|
|
cum_tpv += tp * vol
|
|
cum_vol += vol
|
|
|
|
vwap = cum_tpv / cum_vol if cum_vol > 0 else candles[-1]["c"]
|
|
|
|
# Standard deviation
|
|
for c in candles:
|
|
vol = c.get("v", 1)
|
|
tp = (c["h"] + c["l"] + c["c"]) / 3
|
|
cum_sq += vol * (tp - vwap) ** 2
|
|
|
|
variance = cum_sq / cum_vol if cum_vol > 0 else 0
|
|
std = variance ** 0.5
|
|
|
|
return {
|
|
"vwap": vwap,
|
|
"std": std,
|
|
"upper1": vwap + std,
|
|
"lower1": vwap - std,
|
|
"upper2": vwap + 2 * std,
|
|
"lower2": vwap - 2 * std,
|
|
}
|
|
|
|
|
|
def calc_rsi(closes, period=14):
|
|
"""Calculate RSI."""
|
|
if len(closes) < period + 1:
|
|
return 50 # Default neutral
|
|
|
|
gains = []
|
|
losses = []
|
|
for i in range(1, len(closes)):
|
|
diff = closes[i] - closes[i - 1]
|
|
gains.append(max(diff, 0))
|
|
losses.append(max(-diff, 0))
|
|
|
|
avg_gain = sum(gains[-period:]) / period
|
|
avg_loss = sum(losses[-period:]) / period
|
|
|
|
if avg_loss == 0:
|
|
return 100
|
|
rs = avg_gain / avg_loss
|
|
return 100 - (100 / (1 + rs))
|
|
|
|
|
|
def analyze_coin(coin, klines=None):
|
|
"""Full VWAP + RSI + momentum analysis for a single coin."""
|
|
symbol = coin["symbol"].upper()
|
|
price = coin["current_price"]
|
|
|
|
if not klines:
|
|
klines = get_binance_klines(symbol, "1h", 48)
|
|
|
|
if not klines or len(klines) < 10:
|
|
return None
|
|
|
|
# VWAP
|
|
vwap_data = calc_vwap(klines)
|
|
vwap = vwap_data["vwap"]
|
|
vwap_diff = (price - vwap) / vwap * 100
|
|
|
|
# RSI
|
|
closes = [k["c"] for k in klines]
|
|
rsi = calc_rsi(closes)
|
|
|
|
# Volume trend
|
|
recent_vol = sum(k["v"] for k in klines[-6:]) # Last 6h
|
|
avg_vol = sum(k["v"] for k in klines) / (len(klines) / 6)
|
|
vol_ratio = recent_vol / avg_vol if avg_vol > 0 else 1
|
|
|
|
# Momentum (last 4h vs prior 4h)
|
|
if len(klines) >= 8:
|
|
recent_mom = (klines[-1]["c"] - klines[-4]["c"]) / klines[-4]["c"] * 100
|
|
prior_mom = (klines[-4]["c"] - klines[-8]["c"]) / klines[-8]["c"] * 100
|
|
else:
|
|
recent_mom = prior_mom = 0
|
|
|
|
# 24h change from CoinGecko
|
|
change_24h = coin.get("price_change_percentage_24h", 0) or 0
|
|
|
|
# Score: -100 to +100
|
|
score = 0
|
|
signals = []
|
|
|
|
# VWAP position
|
|
if vwap_diff < -2:
|
|
score += 30
|
|
signals.append(f"Deep below VWAP ({vwap_diff:.1f}%)")
|
|
elif vwap_diff < -0.5:
|
|
score += 15
|
|
signals.append(f"Below VWAP ({vwap_diff:.1f}%)")
|
|
elif vwap_diff > 2:
|
|
score -= 20
|
|
signals.append(f"Extended above VWAP ({vwap_diff:.1f}%)")
|
|
elif vwap_diff > 0.5:
|
|
score += 5
|
|
signals.append(f"Above VWAP ({vwap_diff:.1f}%)")
|
|
|
|
# RSI
|
|
if rsi < 30:
|
|
score += 30
|
|
signals.append(f"RSI oversold ({rsi:.0f})")
|
|
elif rsi < 40:
|
|
score += 15
|
|
signals.append(f"RSI low ({rsi:.0f})")
|
|
elif rsi > 70:
|
|
score -= 25
|
|
signals.append(f"RSI overbought ({rsi:.0f})")
|
|
elif rsi > 60:
|
|
score -= 10
|
|
signals.append(f"RSI elevated ({rsi:.0f})")
|
|
|
|
# Volume confirmation
|
|
if vol_ratio > 1.5:
|
|
score += 10 if vwap_diff < 0 else -10 # High vol at support = bullish
|
|
signals.append(f"High volume ({vol_ratio:.1f}x)")
|
|
elif vol_ratio < 0.5:
|
|
score -= 5
|
|
signals.append(f"Low volume ({vol_ratio:.1f}x)")
|
|
|
|
# Momentum reversal
|
|
if recent_mom > 0 and prior_mom < 0:
|
|
score += 15
|
|
signals.append("Momentum reversal (bullish)")
|
|
elif recent_mom < 0 and prior_mom > 0:
|
|
score -= 15
|
|
signals.append("Momentum reversal (bearish)")
|
|
|
|
# 24h trend
|
|
if change_24h < -5:
|
|
score += 10 # Potential bounce
|
|
signals.append(f"24h dump ({change_24h:.1f}%)")
|
|
elif change_24h > 5:
|
|
score -= 10 # Potential pullback
|
|
signals.append(f"24h pump ({change_24h:.1f}%)")
|
|
|
|
return {
|
|
"symbol": symbol,
|
|
"name": coin["name"],
|
|
"price": price,
|
|
"market_cap_rank": coin.get("market_cap_rank", 0),
|
|
"vwap": round(vwap, 6),
|
|
"vwap_diff_pct": round(vwap_diff, 2),
|
|
"rsi": round(rsi, 1),
|
|
"vol_ratio": round(vol_ratio, 2),
|
|
"momentum_4h": round(recent_mom, 2),
|
|
"change_24h": round(change_24h, 2),
|
|
"score": score,
|
|
"signals": signals,
|
|
"vwap_bands": {k: round(v, 6) for k, v in vwap_data.items()},
|
|
}
|
|
|
|
|
|
# ── Trading Logic ──
|
|
|
|
def should_buy(analysis):
|
|
"""Determine if we should buy based on analysis score."""
|
|
if not analysis:
|
|
return False, ""
|
|
|
|
score = analysis["score"]
|
|
rsi = analysis["rsi"]
|
|
vwap_diff = analysis["vwap_diff_pct"]
|
|
|
|
# Strong buy: oversold + below VWAP
|
|
if score >= 40:
|
|
return True, f"Strong buy signal (score {score}): {', '.join(analysis['signals'])}"
|
|
|
|
# Moderate buy: decent score + below VWAP
|
|
if score >= 20 and vwap_diff < -0.5:
|
|
return True, f"Buy signal (score {score}): {', '.join(analysis['signals'])}"
|
|
|
|
return False, ""
|
|
|
|
|
|
def should_sell(analysis, position):
|
|
"""Determine if we should sell a position."""
|
|
if not analysis:
|
|
return False, 0, ""
|
|
|
|
price = analysis["price"]
|
|
avg_price = position["avg_price"]
|
|
pnl_pct = (price - avg_price) / avg_price * 100
|
|
rsi = analysis["rsi"]
|
|
vwap_diff = analysis["vwap_diff_pct"]
|
|
score = analysis["score"]
|
|
|
|
# Take profit: +5% gain with overbought signals
|
|
if pnl_pct >= 5 and (rsi > 65 or vwap_diff > 1):
|
|
return True, 100, f"Take profit ({pnl_pct:.1f}%, RSI {rsi:.0f})"
|
|
|
|
# Strong take profit: +10% gain
|
|
if pnl_pct >= 10:
|
|
return True, 50, f"Partial take profit ({pnl_pct:.1f}%)"
|
|
|
|
# Stop loss: -8%
|
|
if pnl_pct <= -8:
|
|
return True, 100, f"Stop loss ({pnl_pct:.1f}%)"
|
|
|
|
# Bearish signals on losing position
|
|
if pnl_pct < -3 and score <= -20:
|
|
return True, 100, f"Cut loss (score {score}, PnL {pnl_pct:.1f}%)"
|
|
|
|
# Extended above VWAP with big gain
|
|
if pnl_pct >= 3 and vwap_diff > 2:
|
|
return True, 50, f"Extended above VWAP ({vwap_diff:.1f}%), lock gains"
|
|
|
|
return False, 0, ""
|
|
|
|
|
|
# ── Main Scanner ──
|
|
|
|
def run_scan(max_positions=10, position_size=5000):
|
|
"""
|
|
Full scan: analyze top 150 cryptos, make buy/sell decisions.
|
|
max_positions: max simultaneous positions
|
|
position_size: USD per position
|
|
"""
|
|
print(f"=== Crypto Market Watch Scan ===")
|
|
print(f"Time: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}")
|
|
print()
|
|
|
|
# Get top coins
|
|
print("Fetching top 150 coins...", flush=True)
|
|
coins = get_top_coins(150)
|
|
# Deduplicate by symbol (CoinGecko sometimes returns dupes)
|
|
seen_symbols = set()
|
|
unique_coins = []
|
|
for c in coins:
|
|
sym = c["symbol"].upper()
|
|
if sym not in seen_symbols:
|
|
seen_symbols.add(sym)
|
|
unique_coins.append(c)
|
|
coins = unique_coins
|
|
print(f"Got {len(coins)} unique coins")
|
|
|
|
# Map symbols to Binance format
|
|
# Only analyze coins available on Binance US
|
|
binance_symbols = set()
|
|
try:
|
|
url = "https://api.binance.us/api/v3/exchangeInfo"
|
|
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
|
|
resp = urllib.request.urlopen(req, timeout=15)
|
|
exchange = json.loads(resp.read())
|
|
for s in exchange["symbols"]:
|
|
if s["quoteAsset"] == "USDT" and s["status"] == "TRADING":
|
|
binance_symbols.add(s["baseAsset"])
|
|
except:
|
|
pass
|
|
|
|
print(f"Binance US has {len(binance_symbols)} USDT pairs")
|
|
|
|
# Analyze each coin
|
|
analyses = []
|
|
prices = {}
|
|
|
|
for coin in coins:
|
|
symbol = coin["symbol"].upper()
|
|
prices[symbol] = coin["current_price"]
|
|
|
|
if symbol not in binance_symbols:
|
|
continue
|
|
|
|
analysis = analyze_coin(coin)
|
|
if analysis:
|
|
analyses.append(analysis)
|
|
|
|
time.sleep(0.2) # Rate limit
|
|
|
|
print(f"Analyzed {len(analyses)} coins with Binance data")
|
|
|
|
# Get current portfolio
|
|
portfolio = get_portfolio()
|
|
current_positions = set(portfolio["positions"].keys())
|
|
|
|
# ── SELL DECISIONS ──
|
|
sells = []
|
|
for symbol, pos in list(portfolio["positions"].items()):
|
|
analysis = next((a for a in analyses if a["symbol"] == symbol), None)
|
|
if not analysis:
|
|
# Can't analyze — skip (don't sell blind)
|
|
continue
|
|
|
|
do_sell, sell_pct, reason = should_sell(analysis, pos)
|
|
if do_sell:
|
|
trade, err = sell(symbol, analysis["price"], sell_pct, reason)
|
|
if trade:
|
|
sells.append(trade)
|
|
print(f" 📤 SELL {symbol} @ ${analysis['price']:,.4f} ({sell_pct}%) — {reason}")
|
|
|
|
# ── BUY DECISIONS ──
|
|
portfolio = get_portfolio() # Refresh after sells
|
|
num_positions = len(portfolio["positions"])
|
|
available_slots = max_positions - num_positions
|
|
|
|
# Sort by score (best opportunities first)
|
|
buy_candidates = sorted(
|
|
[a for a in analyses if a["symbol"] not in portfolio["positions"]],
|
|
key=lambda x: -x["score"]
|
|
)
|
|
|
|
buys = []
|
|
for analysis in buy_candidates[:available_slots * 2]: # Check 2x candidates
|
|
if len(buys) >= available_slots:
|
|
break
|
|
if portfolio["cash"] < position_size:
|
|
break
|
|
|
|
do_buy, reason = should_buy(analysis)
|
|
if do_buy:
|
|
trade, err = buy(analysis["symbol"], analysis["price"], position_size, reason)
|
|
if trade:
|
|
buys.append(trade)
|
|
portfolio = get_portfolio() # Refresh
|
|
print(f" 📥 BUY {analysis['symbol']} @ ${analysis['price']:,.4f} — {reason}")
|
|
|
|
# Take snapshot
|
|
snapshot = take_snapshot(prices)
|
|
|
|
# Summary
|
|
print(f"\n{'='*50}")
|
|
print(f"Buys: {len(buys)} | Sells: {len(sells)}")
|
|
print(f"Portfolio: ${snapshot['total_value']:,.2f} ({snapshot['total_pnl_pct']:+.2f}%)")
|
|
print(f"Cash: ${snapshot['cash']:,.2f} | Positions: {snapshot['num_positions']}")
|
|
|
|
if snapshot["positions"]:
|
|
print(f"\nOpen Positions:")
|
|
for p in snapshot["positions"]:
|
|
emoji = "🟢" if p["pnl"] >= 0 else "🔴"
|
|
print(f" {emoji} {p['symbol']}: ${p['value']:,.2f} ({p['pnl_pct']:+.1f}%)")
|
|
|
|
# Top opportunities not taken
|
|
print(f"\nTop Scoring Coins:")
|
|
for a in sorted(analyses, key=lambda x: -x["score"])[:10]:
|
|
held = "📌" if a["symbol"] in portfolio.get("positions", {}) else " "
|
|
print(f" {held} {a['symbol']:<8} score:{a['score']:>4} | RSI:{a['rsi']:.0f} | VWAP:{a['vwap_diff_pct']:+.1f}% | 24h:{a['change_24h']:+.1f}%")
|
|
|
|
return {"buys": buys, "sells": sells, "snapshot": snapshot, "analyses": analyses}
|
|
|
|
|
|
if __name__ == "__main__":
|
|
result = run_scan()
|