Files
crypto-trader/bot.py
2026-05-03 23:37:55 -04:00

229 lines
7.5 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 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) -> float:
if config.paper_trading:
return float(os.getenv("PAPER_BALANCE_USD", "1000"))
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
order_id = client.market_sell(
position.pair, position.quantity, paper=config.paper_trading
)
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)
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:
opportunities = scanner.scan(exclude_pairs=portfolio.open_pairs())
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)
# Cap to available slots and allocate equally
to_buy = opportunities[:open_slots]
alloc_per_asset = balance / len(to_buy)
log.info(
"Buying %d asset(s) @ $%.2f each (total $%.2f)",
len(to_buy), alloc_per_asset, alloc_per_asset * len(to_buy),
)
for opp in to_buy:
if alloc_per_asset < config.min_order_usd:
log.warning(
"Allocation $%.2f < minimum $%.2f — skipping %s",
alloc_per_asset, config.min_order_usd, opp.pair,
)
continue
quantity = round(alloc_per_asset / opp.last_price, 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
order_id = client.market_buy(opp.pair, quantity, paper=config.paper_trading)
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)",
)
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()