From 8b96dd146586fc45e07ba598e1a351ca85f2dc07 Mon Sep 17 00:00:00 2001 From: jerick Date: Sun, 3 May 2026 23:37:55 -0400 Subject: [PATCH] first commit --- .env.example | 7 + README.md | 140 +++++++++++++ __pycache__/config.cpython-314.pyc | Bin 0 -> 2091 bytes __pycache__/kraken_client.cpython-314.pyc | Bin 0 -> 8935 bytes __pycache__/portfolio.cpython-314.pyc | Bin 0 -> 8578 bytes __pycache__/risk_manager.cpython-314.pyc | Bin 0 -> 4575 bytes __pycache__/scanner.cpython-314.pyc | Bin 0 -> 6031 bytes bot.py | 228 ++++++++++++++++++++++ config.py | 51 +++++ install.sh | 81 ++++++++ kraken_client.py | 127 ++++++++++++ portfolio.py | 93 +++++++++ requirements.txt | 2 + risk_manager.py | 84 ++++++++ scanner.py | 127 ++++++++++++ setup.sh | 57 ++++++ systemd/crypto-trader.service | 30 +++ systemd/crypto-trader.timer | 21 ++ 18 files changed, 1048 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 __pycache__/config.cpython-314.pyc create mode 100644 __pycache__/kraken_client.cpython-314.pyc create mode 100644 __pycache__/portfolio.cpython-314.pyc create mode 100644 __pycache__/risk_manager.cpython-314.pyc create mode 100644 __pycache__/scanner.cpython-314.pyc create mode 100644 bot.py create mode 100644 config.py create mode 100644 install.sh create mode 100644 kraken_client.py create mode 100644 portfolio.py create mode 100644 requirements.txt create mode 100644 risk_manager.py create mode 100644 scanner.py create mode 100644 setup.sh create mode 100644 systemd/crypto-trader.service create mode 100644 systemd/crypto-trader.timer diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1b9cb69 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Get API keys from: https://www.kraken.com/u/security/api +# Required permissions: Query Funds, Query Open Orders, Create & Modify Orders +KRAKEN_API_KEY=your_api_key_here +KRAKEN_API_SECRET=your_api_secret_here + +# Starting balance for paper trading simulation +PAPER_BALANCE_USD=1000 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9450e2b --- /dev/null +++ b/README.md @@ -0,0 +1,140 @@ +# crypto-trader + +A Python trading bot for [Kraken](https://www.kraken.com) that identifies momentum opportunities and manages positions with automated risk controls. + +## Strategy + +Each run the bot: + +1. **Checks open positions** — evaluates every held asset against the exit rules below and sells if triggered. +2. **Scans the market** — fetches all USD-quoted pairs on Kraken and filters to assets with: + - 24h traded volume ≥ $1,000,000 (confirms the asset is liquid and active) + - 24h price change between **+5% and +10%** (momentum window — enough movement, not yet overextended) +3. **Buys qualifying assets** — splits available USD balance equally across the top results (sorted by volume), up to `max_positions` concurrent holdings. +4. **Re-scans if a position closed** — freed capital is immediately put to work in the same run. + +## Risk management + +Exit rules are checked in priority order on every run: + +| Rule | Default | Behaviour | +|------|---------|-----------| +| Hard stop-loss | −12% from entry | Absolute floor. Protects against a gap-down before the trailing stop can react. | +| Trailing stop | −8% from peak | Follows the price upward. If an asset goes $100 → $130, the stop sits at $119.60. A reversal to $119.60 triggers a sell. | +| Take profit | +25% from entry | Locks in gains when the target is reached. | +| Time limit | 72 hours | Exits stagnant positions so capital isn't tied up indefinitely. | + +All thresholds are in [`config.py`](config.py) and can be tuned without touching any other file. + +## Requirements + +- Python 3.8 or newer +- A Kraken account with an API key that has **Query Funds**, **Query Open Orders & Trades**, and **Create & Modify Orders** permissions + +## Setup + +### Local / manual use + +```bash +# Clone and enter the directory +git clone crypto-trader +cd crypto-trader + +# Create virtual environment and install dependencies +bash setup.sh + +# Add your Kraken API keys +nano .env +``` + +### Ubuntu server (systemd — runs automatically on schedule) + +```bash +sudo bash install.sh +sudo nano /opt/crypto-trader/.env # add API keys +``` + +The installer copies the project to `/opt/crypto-trader`, creates a dedicated `crypto-trader` system user, and registers a systemd timer that runs at **09:00, 13:00, and 17:00 UTC** daily. + +## Running + +```bash +# Paper trading (no real orders — safe to run any time) +./venv/bin/python bot.py + +# Live trading (real orders placed on Kraken) +./venv/bin/python bot.py --live +``` + +Always verify paper trading behaviour for at least one full cycle before switching to live mode. + +## Configuration + +All settings live in [`config.py`](config.py). The most commonly tuned values: + +| Setting | Default | Description | +|---------|---------|-------------| +| `min_volume_usd` | 1,000,000 | Minimum 24h USD volume to consider an asset | +| `min_price_change_pct` | 5.0 | Lower bound of the momentum window (%) | +| `max_price_change_pct` | 10.0 | Upper bound of the momentum window (%) | +| `max_positions` | 5 | Maximum concurrent holdings | +| `trailing_stop_pct` | 8.0 | Sell if price drops this % below its peak | +| `hard_stop_pct` | 12.0 | Sell if price drops this % below entry | +| `take_profit_pct` | 25.0 | Sell when this % profit is reached | +| `max_hold_hours` | 72 | Exit after this many hours regardless | +| `paper_trading` | True | Set to False for live orders | + +## Scheduling + +**Systemd (Ubuntu server):** + +```bash +sudo systemctl start crypto-trader.timer # enable schedule +systemctl list-timers crypto-trader.timer # show next run time +journalctl -u crypto-trader.service -f # stream logs +sudo systemctl start crypto-trader.service # trigger a manual run +``` + +To change the schedule, edit [`systemd/crypto-trader.timer`](systemd/crypto-trader.timer) then: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart crypto-trader.timer +``` + +**Cron (alternative):** + +```bash +crontab -e +# Add one or more lines, e.g. run at 09:00, 13:00, 17:00 UTC: +0 9,13,17 * * * /opt/crypto-trader/venv/bin/python /opt/crypto-trader/bot.py >> /opt/crypto-trader/bot.log 2>&1 +``` + +## Project structure + +``` +crypto-trader/ +├── bot.py # Entry point — orchestrates each trading cycle +├── config.py # All tunable parameters +├── kraken_client.py # Kraken REST API client (auth, tickers, orders) +├── scanner.py # Scans market, filters by volume and price change +├── portfolio.py # Tracks open positions, persists to positions.json +├── risk_manager.py # Trailing stop, hard stop, take profit, time limit +├── setup.sh # One-time local setup (venv + deps) +├── install.sh # Full Ubuntu server install with systemd +├── systemd/ +│ ├── crypto-trader.service # Systemd service unit +│ └── crypto-trader.timer # Systemd timer unit +├── requirements.txt +└── .env.example +``` + +## State and logs + +- **`positions.json`** — open positions, updated after every buy or sell. Safe to inspect at any time. +- **`bot.log`** — full run history including every scan result, order placed, and exit reason. +- On Ubuntu with systemd: `journalctl -u crypto-trader.service` also captures all output. + +## Disclaimer + +This software is provided for educational purposes. Cryptocurrency trading carries significant financial risk. Always test thoroughly in paper trading mode before risking real funds. You are solely responsible for any trading decisions made by this bot. diff --git a/__pycache__/config.cpython-314.pyc b/__pycache__/config.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..26c14b3efb79cfaf5668223830bbdd88fd0ffbc1 GIT binary patch literal 2091 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 S^VnVL+`%<1LSfT5T>k@@M)`OE literal 0 HcmV?d00001 diff --git a/__pycache__/risk_manager.cpython-314.pyc b/__pycache__/risk_manager.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..731dc42d92463b8ece88576247d4d9f52a949602 GIT binary patch literal 4575 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 XDuHU<2 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() diff --git a/config.py b/config.py new file mode 100644 index 0000000..04b6c37 --- /dev/null +++ b/config.py @@ -0,0 +1,51 @@ +import os +from dataclasses import dataclass, field + + +@dataclass +class Config: + # Kraken API credentials — set via .env or environment variables + api_key: str = field(default_factory=lambda: os.getenv("KRAKEN_API_KEY", "")) + api_secret: str = field(default_factory=lambda: os.getenv("KRAKEN_API_SECRET", "")) + + # Quote currency to scan (only USD pairs) + quote_currency: str = "USD" + + # Volume filter: minimum 24h traded volume in USD + # Filters out illiquid/dead coins + min_volume_usd: float = 1_000_000 + + # Momentum filter: 24h price change must be in this range + # Below 5% = not enough momentum; above 10% = may already be overextended + min_price_change_pct: float = 5.0 + max_price_change_pct: float = 10.0 + + # Portfolio limits + max_positions: int = 5 # Maximum concurrent holdings + min_order_usd: float = 15.0 # Kraken minimum is ~$10; keep a small buffer + + # ── Risk management ────────────────────────────────────────────────────── + # Trailing stop: sell if price drops this % below its peak since purchase. + # Example: buy at $100, peaks at $130 → stop triggers at $119.60 + trailing_stop_pct: float = 8.0 + + # Hard stop: sell if price drops this % below entry price, regardless of peak. + # Protects against gapping down before the trailing stop can trigger. + hard_stop_pct: float = 12.0 + + # Take profit: sell when price is this % above entry. + take_profit_pct: float = 25.0 + + # Time limit: sell after this many hours if no stop/TP was hit. + # Prevents dead money from tying up capital indefinitely. + max_hold_hours: int = 72 + + # ── Execution ──────────────────────────────────────────────────────────── + # ALWAYS start with paper_trading=True and verify behaviour before going live. + # Set to False only after you understand the bot's decisions. + paper_trading: bool = True + + # Path for persisting open positions across runs + positions_file: str = "positions.json" + + log_file: str = "bot.log" diff --git a/install.sh b/install.sh new file mode 100644 index 0000000..5b7d10b --- /dev/null +++ b/install.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +# Installs crypto-trader as a systemd service on Ubuntu/Debian. +# Must be run as root (or with sudo): sudo bash install.sh +# +# What this does: +# 1. Copies the project to /opt/crypto-trader +# 2. Creates a dedicated system user 'crypto-trader' +# 3. Sets up a Python virtual environment +# 4. Installs and enables the systemd service + timer +set -euo pipefail + +INSTALL_DIR="/opt/crypto-trader" +SERVICE_USER="crypto-trader" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +if [ "$(id -u)" -ne 0 ]; then + echo "ERROR: Run this with sudo: sudo bash install.sh" + exit 1 +fi + +echo "Installing crypto-trader to $INSTALL_DIR ..." + +# ── 1. Create install directory ─────────────────────────────────────────── +mkdir -p "$INSTALL_DIR" + +# ── 2. Copy project files ───────────────────────────────────────────────── +rsync -a --exclude='.git' --exclude='venv' --exclude='__pycache__' \ + "$SCRIPT_DIR/" "$INSTALL_DIR/" + +# ── 3. Create dedicated system user ────────────────────────────────────── +if ! id -u "$SERVICE_USER" &>/dev/null; then + useradd --system --no-create-home --shell /usr/sbin/nologin "$SERVICE_USER" + echo "Created system user: $SERVICE_USER" +fi + +# ── 4. Set up .env (API keys) ───────────────────────────────────────────── +if [ ! -f "$INSTALL_DIR/.env" ]; then + cp "$INSTALL_DIR/.env.example" "$INSTALL_DIR/.env" + chmod 600 "$INSTALL_DIR/.env" + chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR/.env" + echo "" + echo ">>> Created $INSTALL_DIR/.env — add your Kraken API keys before starting:" + echo " sudo nano $INSTALL_DIR/.env" + echo "" +fi + +# ── 5. Python virtual environment ───────────────────────────────────────── +if ! command -v python3 &>/dev/null; then + echo "Installing python3-venv ..." + apt-get install -y python3-venv python3-pip +fi + +if [ ! -d "$INSTALL_DIR/venv" ]; then + python3 -m venv "$INSTALL_DIR/venv" +fi +"$INSTALL_DIR/venv/bin/pip" install --upgrade pip -q +"$INSTALL_DIR/venv/bin/pip" install -r "$INSTALL_DIR/requirements.txt" -q +echo "Python dependencies installed" + +# ── 6. Ownership ───────────────────────────────────────────────────────── +chown -R "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR" +# positions.json and bot.log need to be writable by the service user +touch "$INSTALL_DIR/positions.json" "$INSTALL_DIR/bot.log" 2>/dev/null || true +chown "$SERVICE_USER:$SERVICE_USER" "$INSTALL_DIR/positions.json" "$INSTALL_DIR/bot.log" 2>/dev/null || true + +# ── 7. Systemd units ────────────────────────────────────────────────────── +cp "$INSTALL_DIR/systemd/crypto-trader.service" /etc/systemd/system/ +cp "$INSTALL_DIR/systemd/crypto-trader.timer" /etc/systemd/system/ + +systemctl daemon-reload +systemctl enable crypto-trader.timer + +echo "" +echo "Installation complete." +echo "" +echo "Next steps:" +echo " 1. Edit API keys: sudo nano $INSTALL_DIR/.env" +echo " 2. Test paper mode: sudo -u $SERVICE_USER $INSTALL_DIR/venv/bin/python $INSTALL_DIR/bot.py" +echo " 3. Start the timer: sudo systemctl start crypto-trader.timer" +echo " 4. View logs: journalctl -u crypto-trader.service -f" +echo " 5. Check schedule: systemctl list-timers crypto-trader.timer" diff --git a/kraken_client.py b/kraken_client.py new file mode 100644 index 0000000..e1fd610 --- /dev/null +++ b/kraken_client.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import base64 +import hashlib +import hmac +import logging +import time +import urllib.parse + +import requests + +log = logging.getLogger(__name__) + +_BASE_URL = "https://api.kraken.com" + + +class KrakenError(Exception): + pass + + +class KrakenClient: + def __init__(self, api_key: str = "", api_secret: str = ""): + self.api_key = api_key + self.api_secret = api_secret + self._session = requests.Session() + self._session.headers["User-Agent"] = "crypto-trader/1.0" + + # ── Auth ───────────────────────────────────────────────────────────────── + + def _sign(self, urlpath: str, data: dict) -> str: + postdata = urllib.parse.urlencode(data) + encoded = (str(data["nonce"]) + postdata).encode() + message = urlpath.encode() + hashlib.sha256(encoded).digest() + mac = hmac.new(base64.b64decode(self.api_secret), message, hashlib.sha512) + return base64.b64encode(mac.digest()).decode() + + # ── Low-level request helpers ───────────────────────────────────────────── + + def _public(self, endpoint: str, params: dict | None = None) -> dict: + url = f"{_BASE_URL}/0/public/{endpoint}" + resp = self._session.get(url, params=params, timeout=15) + resp.raise_for_status() + body = resp.json() + if body.get("error"): + raise KrakenError(body["error"]) + return body["result"] + + def _private(self, endpoint: str, data: dict | None = None) -> dict: + if not self.api_key or not self.api_secret: + raise KrakenError("API credentials not set — check KRAKEN_API_KEY / KRAKEN_API_SECRET") + urlpath = f"/0/private/{endpoint}" + payload = dict(data or {}) + payload["nonce"] = str(int(time.time() * 1000)) + headers = { + "API-Key": self.api_key, + "API-Sign": self._sign(urlpath, payload), + } + resp = self._session.post( + f"{_BASE_URL}{urlpath}", data=payload, headers=headers, timeout=15 + ) + resp.raise_for_status() + body = resp.json() + if body.get("error"): + raise KrakenError(body["error"]) + return body["result"] + + # ── Public market data ──────────────────────────────────────────────────── + + def get_asset_pairs(self) -> dict[str, dict]: + """Return all asset pair metadata keyed by Kraken's internal pair name.""" + return self._public("AssetPairs") + + def get_tickers(self, pairs: list[str]) -> dict[str, dict]: + """ + Fetch ticker data for a list of pair names (altnames or internal names). + Splits into chunks of 100 to stay within API limits. + Returns a dict keyed by whatever name Kraken echoes back. + """ + results: dict[str, dict] = {} + for i in range(0, len(pairs), 100): + chunk = pairs[i : i + 100] + data = self._public("Ticker", params={"pair": ",".join(chunk)}) + results.update(data) + return results + + # ── Private account data ────────────────────────────────────────────────── + + def get_usd_balance(self) -> float: + """Return available USD balance (ZUSD key in Kraken).""" + balance = self._private("Balance") + return float(balance.get("ZUSD", balance.get("USD", 0.0))) + + # ── Order placement ─────────────────────────────────────────────────────── + + def market_buy(self, pair: str, volume: float, paper: bool = True) -> str: + """Place a market buy order. Returns a transaction/order ID.""" + if paper: + order_id = f"PAPER-BUY-{pair}-{int(time.time())}" + log.info("[PAPER] BUY %s qty=%.8f order=%s", pair, volume, order_id) + return order_id + result = self._private("AddOrder", { + "pair": pair, + "type": "buy", + "ordertype": "market", + "volume": f"{volume:.8f}", + }) + txids = result.get("txid", []) + order_id = txids[0] if txids else "unknown" + log.info("BUY order placed: %s pair=%s qty=%.8f", order_id, pair, volume) + return order_id + + def market_sell(self, pair: str, volume: float, paper: bool = True) -> str: + """Place a market sell order. Returns a transaction/order ID.""" + if paper: + order_id = f"PAPER-SELL-{pair}-{int(time.time())}" + log.info("[PAPER] SELL %s qty=%.8f order=%s", pair, volume, order_id) + return order_id + result = self._private("AddOrder", { + "pair": pair, + "type": "sell", + "ordertype": "market", + "volume": f"{volume:.8f}", + }) + txids = result.get("txid", []) + order_id = txids[0] if txids else "unknown" + log.info("SELL order placed: %s pair=%s qty=%.8f", order_id, pair, volume) + return order_id diff --git a/portfolio.py b/portfolio.py new file mode 100644 index 0000000..ce79538 --- /dev/null +++ b/portfolio.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from pathlib import Path + +log = logging.getLogger(__name__) + + +@dataclass +class Position: + pair: str + entry_price: float + quantity: float + entry_time: str # ISO 8601 UTC + peak_price: float # Highest price seen since entry (drives trailing stop) + cost_usd: float # USD spent including the allocation amount + order_id: str = "" + + def update_peak(self, current_price: float) -> None: + if current_price > self.peak_price: + self.peak_price = current_price + + def pnl_pct(self, current_price: float) -> float: + return (current_price - self.entry_price) / self.entry_price * 100 + + def drop_from_peak_pct(self, current_price: float) -> float: + if self.peak_price <= 0: + return 0.0 + return (self.peak_price - current_price) / self.peak_price * 100 + + def hours_held(self) -> float: + entry = datetime.fromisoformat(self.entry_time) + if entry.tzinfo is None: + entry = entry.replace(tzinfo=timezone.utc) + now = datetime.now(tz=timezone.utc) + return (now - entry).total_seconds() / 3600 + + +class Portfolio: + def __init__(self, filepath: str): + self._path = Path(filepath) + self.positions: dict[str, Position] = {} + self._load() + + def _load(self) -> None: + if not self._path.exists(): + return + try: + data = json.loads(self._path.read_text()) + self.positions = {pair: Position(**fields) for pair, fields in data.items()} + log.info("Loaded %d position(s) from %s", len(self.positions), self._path) + except Exception as exc: + log.error("Could not load positions file: %s", exc) + + def _save(self) -> None: + try: + self._path.write_text( + json.dumps( + {pair: asdict(pos) for pair, pos in self.positions.items()}, + indent=2, + ) + ) + except Exception as exc: + log.error("Could not save positions file: %s", exc) + + def add(self, position: Position) -> None: + self.positions[position.pair] = position + self._save() + log.info( + "Position opened: %s qty=%.8f entry=$%.6f cost=$%.2f", + position.pair, position.quantity, position.entry_price, position.cost_usd, + ) + + def remove(self, pair: str) -> Position | None: + pos = self.positions.pop(pair, None) + if pos: + self._save() + return pos + + def get(self, pair: str) -> Position | None: + return self.positions.get(pair) + + def open_pairs(self) -> set[str]: + return set(self.positions.keys()) + + def all(self) -> list[Position]: + return list(self.positions.values()) + + def __len__(self) -> int: + return len(self.positions) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..2b1be2a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +requests>=2.31.0 +python-dotenv>=1.0.0 diff --git a/risk_manager.py b/risk_manager.py new file mode 100644 index 0000000..4141940 --- /dev/null +++ b/risk_manager.py @@ -0,0 +1,84 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from enum import Enum + +from config import Config +from portfolio import Position + +log = logging.getLogger(__name__) + + +class ExitReason(Enum): + HARD_STOP = "hard_stop" # Price dropped too far from entry + TRAILING_STOP = "trailing_stop" # Price dropped too far from peak + TAKE_PROFIT = "take_profit" # Price hit the profit target + TIME_LIMIT = "time_limit" # Position held too long + + +@dataclass +class ExitSignal: + reason: ExitReason + current_price: float + pnl_pct: float + + +class RiskManager: + def __init__(self, config: Config): + self.config = config + + def check(self, position: Position, current_price: float) -> ExitSignal | None: + """ + Evaluate a position against all exit rules. Returns an ExitSignal if any + rule triggers, otherwise None. Also updates the position's peak price. + + Rules are checked in priority order: + 1. Hard stop — worst-case absolute floor + 2. Trailing stop — peak-relative stop, protects accumulated gains + 3. Take profit — locks in the profit target + 4. Time limit — exits stagnant positions to free capital + """ + position.update_peak(current_price) + + pnl_pct = position.pnl_pct(current_price) + drop_from_peak = position.drop_from_peak_pct(current_price) + hours_held = position.hours_held() + + # 1. Hard stop-loss + if pnl_pct <= -self.config.hard_stop_pct: + log.warning( + "%s HARD STOP: pnl=%.2f%% (limit=%.2f%%)", + position.pair, pnl_pct, -self.config.hard_stop_pct, + ) + return ExitSignal(ExitReason.HARD_STOP, current_price, pnl_pct) + + # 2. Trailing stop + if drop_from_peak >= self.config.trailing_stop_pct: + log.warning( + "%s TRAILING STOP: dropped %.2f%% from peak $%.6f (current $%.6f) pnl=%.2f%%", + position.pair, drop_from_peak, position.peak_price, current_price, pnl_pct, + ) + return ExitSignal(ExitReason.TRAILING_STOP, current_price, pnl_pct) + + # 3. Take profit + if pnl_pct >= self.config.take_profit_pct: + log.info( + "%s TAKE PROFIT: pnl=%.2f%% (target=%.2f%%)", + position.pair, pnl_pct, self.config.take_profit_pct, + ) + return ExitSignal(ExitReason.TAKE_PROFIT, current_price, pnl_pct) + + # 4. Time limit + if hours_held >= self.config.max_hold_hours: + log.info( + "%s TIME LIMIT: held %.1fh / %dh pnl=%.2f%%", + position.pair, hours_held, self.config.max_hold_hours, pnl_pct, + ) + return ExitSignal(ExitReason.TIME_LIMIT, current_price, pnl_pct) + + log.debug( + "%s HOLD: pnl=%.2f%% peak_drop=%.2f%% held=%.1fh peak=$%.6f", + position.pair, pnl_pct, drop_from_peak, hours_held, position.peak_price, + ) + return None diff --git a/scanner.py b/scanner.py new file mode 100644 index 0000000..54586cd --- /dev/null +++ b/scanner.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from config import Config +from kraken_client import KrakenClient, KrakenError + +log = logging.getLogger(__name__) + + +@dataclass +class Opportunity: + pair: str # Altname used for orders, e.g. "XBTUSD" + last_price: float + open_price: float + change_pct: float # 24h price change percentage + volume_usd: float # 24h volume in USD + lot_decimals: int # Decimal precision for order quantity + order_min: float # Kraken's minimum order quantity + + +class Scanner: + def __init__(self, client: KrakenClient, config: Config): + self.client = client + self.config = config + + def scan(self, exclude_pairs: set[str] | None = None) -> list[Opportunity]: + """ + Returns a list of trading opportunities sorted by volume (most liquid first). + Filters by min_volume_usd and the configured 24h price change range. + """ + exclude = exclude_pairs or set() + + # Fetch all pair metadata in one call + all_pairs = self.client.get_asset_pairs() + + # Filter to online USD-quoted pairs that aren't already held + usd_pairs: dict[str, dict] = {} # altname → pair_info + internal_to_alt: dict[str, str] = {} # internal_key → altname + + for internal_key, info in all_pairs.items(): + altname = info.get("altname", "") + quote = info.get("quote", "") + if ( + quote in ("ZUSD", "USD") + and info.get("status") == "online" + and not altname.endswith(".d") # skip dark-pool pairs + and altname not in exclude + ): + usd_pairs[altname] = info + internal_to_alt[internal_key] = altname + + if not usd_pairs: + log.info("No eligible USD pairs found after filtering") + return [] + + log.info("Fetching tickers for %d USD pairs...", len(usd_pairs)) + + try: + raw_tickers = self.client.get_tickers(list(usd_pairs.keys())) + except KrakenError as exc: + log.error("Ticker fetch failed: %s", exc) + return [] + + # Kraken may key the ticker response by altname or internal name — handle both + ticker_by_alt: dict[str, dict] = {} + for key, ticker in raw_tickers.items(): + if key in usd_pairs: + ticker_by_alt[key] = ticker + elif key in internal_to_alt: + ticker_by_alt[internal_to_alt[key]] = ticker + + opportunities: list[Opportunity] = [] + + for altname, info in usd_pairs.items(): + ticker = ticker_by_alt.get(altname) + if ticker is None: + log.debug("No ticker for %s — skipping", altname) + continue + + try: + last_price = float(ticker["c"][0]) # last trade price + open_price = float(ticker["o"]) # opening price (midnight UTC) + volume_24h = float(ticker["v"][1]) # base volume over last 24h + + if open_price <= 0 or last_price <= 0: + continue + + change_pct = (last_price - open_price) / open_price * 100 + volume_usd = volume_24h * last_price + + if volume_usd < self.config.min_volume_usd: + continue + + if not (self.config.min_price_change_pct <= change_pct <= self.config.max_price_change_pct): + continue + + opportunities.append(Opportunity( + pair=altname, + last_price=last_price, + open_price=open_price, + change_pct=change_pct, + volume_usd=volume_usd, + lot_decimals=int(info.get("lot_decimals", 8)), + order_min=float(info.get("ordermin", 0)), + )) + + except (KeyError, ValueError, ZeroDivisionError): + log.debug("Bad ticker data for %s — skipping", altname) + continue + + # Most liquid first so we prefer established assets when slots are limited + opportunities.sort(key=lambda o: o.volume_usd, reverse=True) + + log.info( + "Scan complete: %d pairs checked, %d opportunities found", + len(usd_pairs), + len(opportunities), + ) + for opp in opportunities: + log.info( + " %-12s change=%+.2f%% volume=$%,.0f", + opp.pair, opp.change_pct, opp.volume_usd, + ) + + return opportunities diff --git a/setup.sh b/setup.sh new file mode 100644 index 0000000..b316a0f --- /dev/null +++ b/setup.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Sets up a Python virtual environment and installs dependencies. +# Run once after cloning: bash setup.sh +set -euo pipefail + +MIN_PYTHON_MINOR=8 + +# Prefer python3.10+ if available, fall back to whatever python3 is present +if command -v python3.12 &>/dev/null; then PYTHON=python3.12 +elif command -v python3.11 &>/dev/null; then PYTHON=python3.11 +elif command -v python3.10 &>/dev/null; then PYTHON=python3.10 +elif command -v python3.9 &>/dev/null; then PYTHON=python3.9 +elif command -v python3.8 &>/dev/null; then PYTHON=python3.8 +elif command -v python3 &>/dev/null; then PYTHON=python3 +else + echo "ERROR: python3 not found. Install it with: sudo apt install python3 python3-venv python3-pip" + exit 1 +fi + +PYVER=$($PYTHON -c "import sys; print(sys.version_info.minor)") +if [ "$PYVER" -lt "$MIN_PYTHON_MINOR" ]; then + echo "ERROR: Python 3.$MIN_PYTHON_MINOR or newer required (found 3.$PYVER)" + exit 1 +fi + +echo "Using $($PYTHON --version)" + +# Create virtual environment +if [ ! -d venv ]; then + $PYTHON -m venv venv + echo "Created virtual environment in ./venv" +else + echo "Virtual environment already exists, skipping creation" +fi + +# Install dependencies +./venv/bin/pip install --upgrade pip -q +./venv/bin/pip install -r requirements.txt -q +echo "Dependencies installed" + +# Create .env if missing +if [ ! -f .env ]; then + cp .env.example .env + chmod 600 .env + echo "" + echo "Created .env — edit it now and add your Kraken API keys:" + echo " nano .env" +else + echo ".env already exists" +fi + +echo "" +echo "Setup complete. To run the bot in paper trading mode:" +echo " ./venv/bin/python bot.py" +echo "" +echo "To go live:" +echo " ./venv/bin/python bot.py --live" diff --git a/systemd/crypto-trader.service b/systemd/crypto-trader.service new file mode 100644 index 0000000..c22e7e0 --- /dev/null +++ b/systemd/crypto-trader.service @@ -0,0 +1,30 @@ +[Unit] +Description=Kraken Crypto Trader Bot +# Wait for network before running +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot + +# ── Change this path to wherever you cloned the repo ────────────────────── +WorkingDirectory=/opt/crypto-trader +ExecStart=/opt/crypto-trader/venv/bin/python bot.py +# ────────────────────────────────────────────────────────────────────────── + +# Run as a dedicated low-privilege user (created by install.sh) +User=crypto-trader +Group=crypto-trader + +# Harden the service: read-only filesystem except the working directory +ProtectSystem=full +ReadWritePaths=/opt/crypto-trader +NoNewPrivileges=true +PrivateTmp=true + +# Logs go to journald; view with: journalctl -u crypto-trader.service +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/systemd/crypto-trader.timer b/systemd/crypto-trader.timer new file mode 100644 index 0000000..315fbae --- /dev/null +++ b/systemd/crypto-trader.timer @@ -0,0 +1,21 @@ +[Unit] +Description=Run Kraken Crypto Trader Bot on schedule +Requires=crypto-trader.service + +[Timer] +# Runs at 09:00, 13:00, and 17:00 UTC every day. +# Adjust times to suit your strategy — the bot is quick (~10s) so running +# 2–4 times a day is a reasonable frequency for a daily momentum strategy. +OnCalendar=*-*-* 09:00:00 UTC +OnCalendar=*-*-* 13:00:00 UTC +OnCalendar=*-*-* 17:00:00 UTC + +# If the system was off at a scheduled time, run once when it comes back up +Persistent=true + +# Randomise start time by up to 60 seconds to avoid thundering-herd issues +# if you run multiple bots (safe to remove if you only have one) +RandomizedDelaySec=60 + +[Install] +WantedBy=timers.target