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

7
.env.example Normal file
View File

@@ -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

140
README.md Normal file
View File

@@ -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 <your-repo-url> 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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()

51
config.py Normal file
View File

@@ -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"

81
install.sh Normal file
View File

@@ -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"

127
kraken_client.py Normal file
View File

@@ -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

93
portfolio.py Normal file
View File

@@ -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)

2
requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
requests>=2.31.0
python-dotenv>=1.0.0

84
risk_manager.py Normal file
View File

@@ -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

127
scanner.py Normal file
View File

@@ -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

57
setup.sh Normal file
View File

@@ -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"

View File

@@ -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

View File

@@ -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
# 24 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