first commit
This commit is contained in:
228
bot.py
Normal file
228
bot.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""
|
||||
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 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()
|
||||
Reference in New Issue
Block a user