Better coin velocity logic

This commit is contained in:
2026-05-16 00:08:45 -04:00
parent fe5fb5f3f1
commit 3394392e49
4 changed files with 114 additions and 33 deletions

5
bot.py
View File

@@ -222,8 +222,9 @@ def run_cycle(config: Config) -> bool:
order_id=order_id, order_id=order_id,
)) ))
log.info( log.info(
"OPENED %-10s qty=%.8f price=$%.6f cost=$%.2f change_24h=%+.2f%%", "OPENED %-10s qty=%.8f price=$%.6f cost=$%.2f change_24h=%+.2f%% rsi=%.1f recent=%+.2f%%",
opp.pair, quantity, opp.last_price, alloc_per_asset, opp.change_pct, opp.pair, quantity, opp.last_price, alloc_per_asset,
opp.change_pct, opp.rsi, opp.recent_change_pct,
) )
return bool(closed) return bool(closed)

View File

@@ -45,6 +45,16 @@ class Config:
# Prevents immediately re-entering a coin whose momentum has stalled or reversed. # Prevents immediately re-entering a coin whose momentum has stalled or reversed.
sold_cooldown_hours: float = 4.0 sold_cooldown_hours: float = 4.0
# ── Velocity / momentum quality filters ──────────────────────────────────
# RSI (14-period, hourly candles): skip coins that are already overbought.
# Above 70 = likely exhausted move; 5065 is the sweet spot for entry.
rsi_period: int = 14
rsi_max: float = 70.0
# Recent momentum: the coin must also be up over the last N hours, not just
# on the day. Filters coins that spiked early and have since stalled/reversed.
recent_candles: int = 2
# ── Execution ──────────────────────────────────────────────────────────── # ── Execution ────────────────────────────────────────────────────────────
# ALWAYS start with paper_trading=True and verify behaviour before going live. # ALWAYS start with paper_trading=True and verify behaviour before going live.
# Set to False only after you understand the bot's decisions. # Set to False only after you understand the bot's decisions.

View File

@@ -66,6 +66,19 @@ class KrakenClient:
# ── Public market data ──────────────────────────────────────────────────── # ── Public market data ────────────────────────────────────────────────────
def get_ohlc(self, pair: str, interval: int = 60, lookback_hours: int = 50) -> list:
"""
Fetch OHLC candles for a single pair.
interval is in minutes (60 = hourly).
Each candle: [time, open, high, low, close, vwap, volume, count]
"""
since = int(time.time()) - lookback_hours * 3600
data = self._public("OHLC", params={"pair": pair, "interval": interval, "since": since})
for key, val in data.items():
if key != "last" and isinstance(val, list):
return val
return []
def get_asset_pairs(self) -> dict[str, dict]: def get_asset_pairs(self) -> dict[str, dict]:
"""Return all asset pair metadata keyed by Kraken's internal pair name.""" """Return all asset pair metadata keyed by Kraken's internal pair name."""
return self._public("AssetPairs") return self._public("AssetPairs")

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass, field
from config import Config from config import Config
from kraken_client import KrakenClient, KrakenError from kraken_client import KrakenClient, KrakenError
@@ -18,6 +18,25 @@ class Opportunity:
volume_usd: float # 24h volume in USD volume_usd: float # 24h volume in USD
lot_decimals: int # Decimal precision for order quantity lot_decimals: int # Decimal precision for order quantity
order_min: float # Kraken's minimum order quantity order_min: float # Kraken's minimum order quantity
rsi: float = field(default=0.0)
recent_change_pct: float = field(default=0.0)
def _calculate_rsi(closes: list[float], period: int) -> float:
"""Wilder's smoothed RSI. Returns 50 (neutral) if there isn't enough data."""
if len(closes) < period + 1:
return 50.0
deltas = [closes[i] - closes[i - 1] for i in range(1, len(closes))]
gains = [max(d, 0.0) for d in deltas]
losses = [max(-d, 0.0) for d in deltas]
avg_gain = sum(gains[:period]) / period
avg_loss = sum(losses[:period]) / period
for i in range(period, len(gains)):
avg_gain = (avg_gain * (period - 1) + gains[i]) / period
avg_loss = (avg_loss * (period - 1) + losses[i]) / period
if avg_loss == 0:
return 100.0
return 100.0 - (100.0 / (1 + avg_gain / avg_loss))
class Scanner: class Scanner:
@@ -25,27 +44,56 @@ class Scanner:
self.client = client self.client = client
self.config = config self.config = config
def _velocity_check(self, pair: str) -> tuple[bool, float, float]:
"""
Fetch hourly OHLC and verify:
1. RSI is below rsi_max (not overbought)
2. Price is higher now than it was recent_candles hours ago (still moving up)
Returns (passes, rsi, recent_change_pct).
"""
try:
candles = self.client.get_ohlc(pair, interval=60)
except KrakenError as exc:
log.debug("OHLC fetch failed for %s: %s", pair, exc)
return False, 0.0, 0.0
needed = self.config.rsi_period + self.config.recent_candles + 2
if len(candles) < needed:
log.debug("%s: only %d candles, need %d — skipping", pair, len(candles), needed)
return False, 0.0, 0.0
closes = [float(c[4]) for c in candles]
rsi = _calculate_rsi(closes, self.config.rsi_period)
# Compare current close to the close N hours ago
ago_close = closes[-(self.config.recent_candles + 1)]
recent_change = (closes[-1] - ago_close) / ago_close * 100 if ago_close else 0.0
passes = rsi <= self.config.rsi_max and recent_change > 0
return passes, rsi, recent_change
def scan(self, exclude_pairs: set[str] | None = None) -> list[Opportunity]: def scan(self, exclude_pairs: set[str] | None = None) -> list[Opportunity]:
""" """
Returns a list of trading opportunities sorted by volume (most liquid first). Two-phase scan:
Filters by min_volume_usd and the configured 24h price change range. Phase 1 (cheap): bulk ticker call — filter by 24h change and volume.
Phase 2 (per-coin): OHLC call — filter by RSI and recent hourly momentum.
""" """
exclude = exclude_pairs or set() exclude = exclude_pairs or set()
# Fetch all pair metadata in one call # ── Phase 1: bulk ticker pre-filter ──────────────────────────────────
all_pairs = self.client.get_asset_pairs() all_pairs = self.client.get_asset_pairs()
# Filter to online USD-quoted pairs that aren't already held usd_pairs: dict[str, dict] = {}
usd_pairs: dict[str, dict] = {} # altname → pair_info internal_to_alt: dict[str, str] = {}
internal_to_alt: dict[str, str] = {} # internal_key → altname
for internal_key, info in all_pairs.items(): for internal_key, info in all_pairs.items():
altname = info.get("altname", "") altname = info.get("altname", "")
quote = info.get("quote", "")
if ( if (
quote in ("ZUSD", "USD") info.get("quote") in ("ZUSD", "USD")
and info.get("status") == "online" and info.get("status") == "online"
and not altname.endswith(".d") # skip dark-pool pairs and not altname.endswith(".d")
and altname not in exclude and altname not in exclude
): ):
usd_pairs[altname] = info usd_pairs[altname] = info
@@ -63,7 +111,6 @@ class Scanner:
log.error("Ticker fetch failed: %s", exc) log.error("Ticker fetch failed: %s", exc)
return [] return []
# Kraken may key the ticker response by altname or internal name — handle both
ticker_by_alt: dict[str, dict] = {} ticker_by_alt: dict[str, dict] = {}
for key, ticker in raw_tickers.items(): for key, ticker in raw_tickers.items():
if key in usd_pairs: if key in usd_pairs:
@@ -71,18 +118,16 @@ class Scanner:
elif key in internal_to_alt: elif key in internal_to_alt:
ticker_by_alt[internal_to_alt[key]] = ticker ticker_by_alt[internal_to_alt[key]] = ticker
opportunities: list[Opportunity] = [] candidates: list[Opportunity] = []
for altname, info in usd_pairs.items(): for altname, info in usd_pairs.items():
ticker = ticker_by_alt.get(altname) ticker = ticker_by_alt.get(altname)
if ticker is None: if ticker is None:
log.debug("No ticker for %s — skipping", altname)
continue continue
try: try:
last_price = float(ticker["c"][0]) # last trade price last_price = float(ticker["c"][0])
open_price = float(ticker["o"]) # opening price (midnight UTC) open_price = float(ticker["o"])
volume_24h = float(ticker["v"][1]) # base volume over last 24h volume_24h = float(ticker["v"][1])
if open_price <= 0 or last_price <= 0: if open_price <= 0 or last_price <= 0:
continue continue
@@ -92,11 +137,10 @@ class Scanner:
if volume_usd < self.config.min_volume_usd: if volume_usd < self.config.min_volume_usd:
continue continue
if not (self.config.min_price_change_pct <= change_pct <= self.config.max_price_change_pct): if not (self.config.min_price_change_pct <= change_pct <= self.config.max_price_change_pct):
continue continue
opportunities.append(Opportunity( candidates.append(Opportunity(
pair=altname, pair=altname,
last_price=last_price, last_price=last_price,
open_price=open_price, open_price=open_price,
@@ -105,23 +149,36 @@ class Scanner:
lot_decimals=int(info.get("lot_decimals", 8)), lot_decimals=int(info.get("lot_decimals", 8)),
order_min=float(info.get("ordermin", 0)), order_min=float(info.get("ordermin", 0)),
)) ))
except (KeyError, ValueError, ZeroDivisionError): except (KeyError, ValueError, ZeroDivisionError):
log.debug("Bad ticker data for %s — skipping", altname)
continue continue
# Most liquid first so we prefer established assets when slots are limited log.info(
opportunities.sort(key=lambda o: o.volume_usd, reverse=True) "Phase 1 complete: %d pairs checked, %d candidates pass volume/change filter",
len(usd_pairs), len(candidates),
)
if not candidates:
return []
# ── Phase 2: velocity check (OHLC per candidate) ─────────────────────
opportunities: list[Opportunity] = []
for opp in candidates:
passes, rsi, recent_change = self._velocity_check(opp.pair)
log.info(
" %-12s change=%+.2f%% rsi=%.1f recent_%dh=%+.2f%% %s",
opp.pair, opp.change_pct, rsi,
self.config.recent_candles, recent_change,
"OK" if passes else "SKIP",
)
if passes:
opp.rsi = rsi
opp.recent_change_pct = recent_change
opportunities.append(opp)
log.info( log.info(
"Scan complete: %d pairs checked, %d opportunities found", "Phase 2 complete: %d of %d candidates passed velocity check",
len(usd_pairs), len(opportunities), len(candidates),
len(opportunities),
) )
for opp in opportunities:
log.info(
" %-12s change=%+.2f%% volume=$%.0f",
opp.pair, opp.change_pct, opp.volume_usd,
)
return opportunities return opportunities