272 lines
9.8 KiB
Python
272 lines
9.8 KiB
Python
"""
|
||
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()
|