From 3394392e490f4133a63ce27c78355a1ab24c44fa Mon Sep 17 00:00:00 2001 From: jerick Date: Sat, 16 May 2026 00:08:45 -0400 Subject: [PATCH] Better coin velocity logic --- bot.py | 5 +- config.py | 10 ++++ kraken_client.py | 13 ++++++ scanner.py | 119 +++++++++++++++++++++++++++++++++++------------ 4 files changed, 114 insertions(+), 33 deletions(-) diff --git a/bot.py b/bot.py index e2e6185..970276b 100644 --- a/bot.py +++ b/bot.py @@ -222,8 +222,9 @@ def run_cycle(config: Config) -> bool: order_id=order_id, )) log.info( - "OPENED %-10s qty=%.8f price=$%.6f cost=$%.2f change_24h=%+.2f%%", - opp.pair, quantity, opp.last_price, alloc_per_asset, opp.change_pct, + "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.rsi, opp.recent_change_pct, ) return bool(closed) diff --git a/config.py b/config.py index c35ac8f..55ab4ff 100644 --- a/config.py +++ b/config.py @@ -45,6 +45,16 @@ class Config: # Prevents immediately re-entering a coin whose momentum has stalled or reversed. 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; 50–65 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 ──────────────────────────────────────────────────────────── # ALWAYS start with paper_trading=True and verify behaviour before going live. # Set to False only after you understand the bot's decisions. diff --git a/kraken_client.py b/kraken_client.py index 1d0d7c6..9f50c6e 100644 --- a/kraken_client.py +++ b/kraken_client.py @@ -66,6 +66,19 @@ class KrakenClient: # ── 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]: """Return all asset pair metadata keyed by Kraken's internal pair name.""" return self._public("AssetPairs") diff --git a/scanner.py b/scanner.py index eefb205..4856124 100644 --- a/scanner.py +++ b/scanner.py @@ -1,7 +1,7 @@ from __future__ import annotations import logging -from dataclasses import dataclass +from dataclasses import dataclass, field from config import Config from kraken_client import KrakenClient, KrakenError @@ -18,6 +18,25 @@ class Opportunity: volume_usd: float # 24h volume in USD lot_decimals: int # Decimal precision for 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: @@ -25,27 +44,56 @@ class Scanner: self.client = client 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]: """ - Returns a list of trading opportunities sorted by volume (most liquid first). - Filters by min_volume_usd and the configured 24h price change range. + Two-phase scan: + 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() - # Fetch all pair metadata in one call + # ── Phase 1: bulk ticker pre-filter ────────────────────────────────── all_pairs = self.client.get_asset_pairs() - # Filter to online USD-quoted pairs that aren't already held - usd_pairs: dict[str, dict] = {} # altname → pair_info - internal_to_alt: dict[str, str] = {} # internal_key → altname + usd_pairs: dict[str, dict] = {} + internal_to_alt: dict[str, str] = {} for internal_key, info in all_pairs.items(): altname = info.get("altname", "") - quote = info.get("quote", "") if ( - quote in ("ZUSD", "USD") + info.get("quote") in ("ZUSD", "USD") and info.get("status") == "online" - and not altname.endswith(".d") # skip dark-pool pairs + and not altname.endswith(".d") and altname not in exclude ): usd_pairs[altname] = info @@ -63,7 +111,6 @@ class Scanner: log.error("Ticker fetch failed: %s", exc) return [] - # Kraken may key the ticker response by altname or internal name — handle both ticker_by_alt: dict[str, dict] = {} for key, ticker in raw_tickers.items(): if key in usd_pairs: @@ -71,18 +118,16 @@ class Scanner: elif key in internal_to_alt: ticker_by_alt[internal_to_alt[key]] = ticker - opportunities: list[Opportunity] = [] + candidates: list[Opportunity] = [] for altname, info in usd_pairs.items(): ticker = ticker_by_alt.get(altname) if ticker is None: - log.debug("No ticker for %s — skipping", altname) continue - try: - last_price = float(ticker["c"][0]) # last trade price - open_price = float(ticker["o"]) # opening price (midnight UTC) - volume_24h = float(ticker["v"][1]) # base volume over last 24h + last_price = float(ticker["c"][0]) + open_price = float(ticker["o"]) + volume_24h = float(ticker["v"][1]) if open_price <= 0 or last_price <= 0: continue @@ -92,11 +137,10 @@ class Scanner: if volume_usd < self.config.min_volume_usd: continue - if not (self.config.min_price_change_pct <= change_pct <= self.config.max_price_change_pct): continue - opportunities.append(Opportunity( + candidates.append(Opportunity( pair=altname, last_price=last_price, open_price=open_price, @@ -105,23 +149,36 @@ class Scanner: lot_decimals=int(info.get("lot_decimals", 8)), order_min=float(info.get("ordermin", 0)), )) - except (KeyError, ValueError, ZeroDivisionError): - log.debug("Bad ticker data for %s — skipping", altname) continue - # Most liquid first so we prefer established assets when slots are limited - opportunities.sort(key=lambda o: o.volume_usd, reverse=True) + log.info( + "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( - "Scan complete: %d pairs checked, %d opportunities found", - len(usd_pairs), - len(opportunities), + "Phase 2 complete: %d of %d candidates passed velocity check", + len(opportunities), len(candidates), ) - for opp in opportunities: - log.info( - " %-12s change=%+.2f%% volume=$%.0f", - opp.pair, opp.change_pct, opp.volume_usd, - ) return opportunities