Better coin velocity logic
This commit is contained in:
5
bot.py
5
bot.py
@@ -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)
|
||||||
|
|||||||
10
config.py
10
config.py
@@ -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; 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 ────────────────────────────────────────────────────────────
|
# ── 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.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
119
scanner.py
119
scanner.py
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user