""" crypto-trader — Kraken momentum bot Strategy: 1. Check open positions → apply risk rules (trailing stop, hard stop, take profit, time limit) 2. Scan for USD pairs with high volume and 5–10% 24h price change 3. Split available balance equally across the top qualifying assets (by volume) 4. If a position was closed, immediately re-scan for replacements Usage: python bot.py # run once (paper trading by default) python bot.py --live # run once in live mode (real orders) Run this on a schedule via cron or a systemd timer. The positions.json file persists state between runs. """ from __future__ import annotations import argparse import logging import math import os import sys from datetime import datetime, timezone from dotenv import load_dotenv from config import Config from kraken_client import KrakenClient, KrakenError from portfolio import Portfolio, Position from risk_manager import RiskManager from scanner import Scanner load_dotenv() def setup_logging(log_file: str) -> None: fmt = "%(asctime)s %(levelname)-8s %(name)s: %(message)s" logging.basicConfig( level=logging.INFO, format=fmt, handlers=[ logging.StreamHandler(sys.stdout), logging.FileHandler(log_file, encoding="utf-8"), ], ) def available_usd(client: KrakenClient, config: Config, portfolio: Portfolio) -> float: if config.paper_trading: total = float(os.getenv("PAPER_BALANCE_USD", "1000")) invested = sum(pos.cost_usd for pos in portfolio.all()) return max(0.0, total - invested) try: return client.get_usd_balance() except KrakenError as exc: logging.getLogger("bot").error("Failed to fetch balance: %s", exc) return 0.0 def run_cycle(config: Config) -> bool: """ Execute one full trading cycle. Returns True if at least one position was closed (signals caller to re-scan). """ log = logging.getLogger("bot") client = KrakenClient(config.api_key, config.api_secret) portfolio = Portfolio(config.positions_file) risk = RiskManager(config) scanner = Scanner(client, config) log.info("─" * 60) log.info( "Cycle start mode=%s positions=%d/%d", "PAPER" if config.paper_trading else "LIVE", len(portfolio), config.max_positions, ) # ── Phase 1: Manage existing positions ─────────────────────────────────── closed: set[str] = set() if portfolio.positions: log.info("Checking %d open position(s)...", len(portfolio)) try: tickers = client.get_tickers(list(portfolio.open_pairs())) except KrakenError as exc: log.error("Could not fetch position tickers: %s", exc) tickers = {} # Build a price lookup tolerant of altname vs internal key differences price_lookup: dict[str, float] = {} for key, ticker in tickers.items(): try: price_lookup[key] = float(ticker["c"][0]) except (KeyError, ValueError): pass for position in portfolio.all(): current_price = price_lookup.get(position.pair) if current_price is None: log.warning("No price found for %s — skipping risk check", position.pair) continue signal = risk.check(position, current_price) if signal is None: continue sell_qty = position.quantity if not config.paper_trading: # Kraken's fill may differ slightly from our stored quantity — sell # whatever we actually hold to avoid "Insufficient funds" errors. try: balances = client.get_all_balances() # Derive base asset from pair altname: "PENDLEUSD" -> "PENDLE" base = position.pair.removesuffix("USD") actual = balances.get(base, balances.get("X" + base)) if actual is not None and actual < sell_qty: log.info( "%s: adjusting sell qty %.8f -> %.8f to match Kraken balance", position.pair, sell_qty, actual, ) sell_qty = actual except KrakenError as exc: log.warning("Could not fetch balances before sell: %s", exc) try: order_id = client.market_sell( position.pair, sell_qty, paper=config.paper_trading ) except KrakenError as exc: log.error("Sell failed for %s: %s — will retry next run", position.pair, exc) continue pnl_usd = (current_price - position.entry_price) * position.quantity log.info( "CLOSED %-10s reason=%-14s entry=$%.6f exit=$%.6f " "pnl=%+.2f%% ($%+.2f) order=%s", position.pair, signal.reason.value, position.entry_price, current_price, signal.pnl_pct, pnl_usd, order_id, ) portfolio.remove(position.pair) closed.add(position.pair) # ── Phase 2: Open new positions ─────────────────────────────────────────── open_slots = config.max_positions - len(portfolio) if open_slots <= 0: log.info("Portfolio full — no new positions to open.") return bool(closed) balance = available_usd(client, config, portfolio) log.info("Available balance: $%.2f | Open slots: %d", balance, open_slots) if balance < config.min_order_usd: log.info("Balance too low for new positions (min $%.2f).", config.min_order_usd) return bool(closed) try: cooled_down = { pair for pair in list(portfolio._cooldowns) if portfolio.on_cooldown(pair, config.sold_cooldown_hours) } opportunities = scanner.scan(exclude_pairs=portfolio.open_pairs() | cooled_down) except KrakenError as exc: log.error("Market scan failed: %s", exc) return bool(closed) if not opportunities: log.info("No opportunities matching criteria this cycle.") return bool(closed) # Sort by 24h change descending — strongest movers get priority when funds are limited opportunities.sort(key=lambda o: o.change_pct, reverse=True) # How many can we actually afford at the minimum order size? affordable = int(balance // config.min_order_usd) num_to_buy = min(open_slots, affordable, len(opportunities)) if num_to_buy == 0: log.info("Balance $%.2f too low for even one minimum order ($%.2f).", balance, config.min_order_usd) return bool(closed) to_buy = opportunities[:num_to_buy] # Deduct fee reserve before splitting so each order has room for Kraken's taker fee. # Floor to the nearest cent so floating point never pushes us over the available balance. fee_multiplier = 1 - (config.taker_fee_pct / 100) alloc_per_asset = math.floor((balance * fee_multiplier / num_to_buy) * 100) / 100 log.info( "Buying %d asset(s) @ $%.2f each (total $%.2f, %.1f%% fee reserve applied)", num_to_buy, alloc_per_asset, alloc_per_asset * num_to_buy, config.taker_fee_pct, ) for opp in to_buy: quantity = math.floor(alloc_per_asset / opp.last_price * 10 ** opp.lot_decimals) / 10 ** opp.lot_decimals if opp.order_min > 0 and quantity < opp.order_min: log.warning( "%s: quantity %.8f below Kraken minimum %.8f — skipping", opp.pair, quantity, opp.order_min, ) continue try: order_id = client.market_buy(opp.pair, quantity, paper=config.paper_trading) except KrakenError as exc: log.error("Order failed for %s: %s — skipping", opp.pair, exc) continue portfolio.add(Position( pair=opp.pair, entry_price=opp.last_price, quantity=quantity, entry_time=datetime.now(tz=timezone.utc).isoformat(), peak_price=opp.last_price, cost_usd=alloc_per_asset, 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, ) return bool(closed) def main() -> None: parser = argparse.ArgumentParser(description="Kraken momentum trading bot") parser.add_argument( "--live", action="store_true", help="Execute real orders (default is paper trading)", ) # Always work relative to the script's own directory so that bot.log and # positions.json land in /opt/crypto-trader regardless of the caller's cwd. os.chdir(os.path.dirname(os.path.abspath(__file__))) args = parser.parse_args() config = Config() if args.live: if not config.api_key or not config.api_secret: print("ERROR: KRAKEN_API_KEY and KRAKEN_API_SECRET must be set for live trading.") sys.exit(1) config.paper_trading = False print("⚠ LIVE MODE — real orders will be placed.") else: print("Paper trading mode — no real orders will be placed.") setup_logging(config.log_file) log = logging.getLogger("bot") log.info("crypto-trader starting paper=%s", config.paper_trading) closed_something = run_cycle(config) # If a position closed, re-scan immediately so freed capital is put to work if closed_something: log.info("Position(s) closed — running follow-up scan...") run_cycle(config) log.info("Bot run complete.") if __name__ == "__main__": main()