first commit

This commit is contained in:
jerick
2026-05-03 23:37:55 -04:00
commit 8b96dd1465
18 changed files with 1048 additions and 0 deletions

228
bot.py Normal file
View 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 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()