Files
crypto-trader/bot.py

272 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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 510% 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 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()