From c44dc2a68c1904248a92c3812ca9f6222dccc0a4 Mon Sep 17 00:00:00 2001 From: bofhus <37267161+bofhus@users.noreply.github.com> Date: Sat, 20 Dec 2025 09:24:01 +0100 Subject: [PATCH] Add adaptive day trading bot with risk controls --- bot.py | 303 +++++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 219 insertions(+), 84 deletions(-) diff --git a/bot.py b/bot.py index 7881cdc..eb3fca0 100644 --- a/bot.py +++ b/bot.py @@ -1,10 +1,11 @@ +import json import os -import sys -import time import sqlite3 +import sys import logging from dataclasses import dataclass from datetime import datetime, timedelta, timezone +from typing import Dict, Iterable, List import numpy as np import pandas as pd @@ -23,15 +24,25 @@ # ----------------------------- @dataclass class Config: - symbol: str = "SPY" # Byt till valfri US-aktie/ETF + symbols: List[str] = None # Lista med tickers att scanna timeframe: TimeFrame = TimeFrame.Minute - lookback_minutes: int = 3000 # ~2 handelsdagar av 1-min bars (räcker för SMA50) - sma_fast: int = 20 - sma_slow: int = 50 - max_position_pct: float = 0.25 # max 25% av equity i denna symbol - min_equity_usd: float = 100.0 # kill switch om equity “försvinner” + lookback_minutes: int = 800 # ~en handelsdag av 1-min bars + momentum_fast: int = 15 + momentum_slow: int = 60 + evaluation_horizon_minutes: int = 60 # Efter hur lång tid vi utvärderar signalerna + max_position_pct: float = 0.15 # max 15% av equity i en symbol + max_daily_budget_usd: float = 500.0 # max USD att köpa för per dag + fee_pct: float = 0.0005 # antagen courtage/avgift + stop_loss_pct: float = 0.01 # säljer om priset faller X% från entry + take_profit_pct: float = 0.02 # kan sälja om vinsten passerar denna nivå + min_equity_usd: float = 100.0 # kill switch om equity “försvinner” db_path: str = "bot_state.sqlite3" + def __post_init__(self): + if self.symbols is None: + # Default-universum av stora, likvida US-aktier/ETF:er + self.symbols = ["SPY", "QQQ", "AAPL", "MSFT", "AMZN", "TSLA"] + def setup_logging() -> None: logging.basicConfig( @@ -53,12 +64,14 @@ def get_env_bool(name: str, default: bool = False) -> bool: # ----------------------------- def init_db(db_path: str) -> None: with sqlite3.connect(db_path) as conn: - conn.execute(""" + conn.execute( + """ CREATE TABLE IF NOT EXISTS state ( k TEXT PRIMARY KEY, v TEXT NOT NULL ) - """) + """ + ) conn.commit() @@ -75,6 +88,21 @@ def state_set(db_path: str, key: str, value: str) -> None: conn.commit() +def state_get_json(db_path: str, key: str, default): + raw = state_get(db_path, key, "") + if not raw: + return default + try: + return json.loads(raw) + except json.JSONDecodeError: + logging.warning("Kunde inte tolka JSON för %s, reset.", key) + return default + + +def state_set_json(db_path: str, key: str, value) -> None: + state_set(db_path, key, json.dumps(value)) + + # ----------------------------- # Strategy # ----------------------------- @@ -82,30 +110,43 @@ def compute_sma(series: pd.Series, window: int) -> pd.Series: return series.rolling(window=window).mean() -def generate_signal(df: pd.DataFrame, fast: int, slow: int) -> str | None: +def momentum_score(df: pd.DataFrame, fast: int, slow: int) -> float: """ - Returnerar "buy", "sell" eller None. - SMA-cross: fast korsar över slow => buy, under => sell. + Enkel momentum-score baserat på kort vs lång avkastning och trend. + Returnerar högre värden för positivt momentum. """ - close = df["close"] - sma_f = compute_sma(close, fast) - sma_s = compute_sma(close, slow) - - if len(df) < slow + 2: - return None - - # senaste två punkter för kors - f_prev, f_now = sma_f.iloc[-2], sma_f.iloc[-1] - s_prev, s_now = sma_s.iloc[-2], sma_s.iloc[-1] + if len(df) < slow + 5: + return float("nan") - if np.isnan([f_prev, f_now, s_prev, s_now]).any(): - return None - - if f_prev <= s_prev and f_now > s_now: - return "buy" - if f_prev >= s_prev and f_now < s_now: - return "sell" - return None + close = df["close"] + ret = close.pct_change() + fast_ret = ret.tail(fast).mean() + slow_ret = ret.tail(slow).mean() + trend = close.iloc[-1] / close.rolling(window=slow).mean().iloc[-1] - 1 + vol_penalty = ret.tail(slow).std() + + score = 0.5 * fast_ret + 0.3 * (fast_ret - slow_ret) + 0.2 * trend + if vol_penalty > 0: + score -= vol_penalty * 0.1 + return float(score) + + +def ranked_candidates( + data: Dict[str, pd.DataFrame], + cfg: Config, + skills: Dict[str, float], + max_candidates: int = 3, +) -> List[str]: + ranked: List[tuple[str, float]] = [] + for symbol, df in data.items(): + sc = momentum_score(df, cfg.momentum_fast, cfg.momentum_slow) + if np.isnan(sc): + continue + skill_bonus = skills.get(symbol, 0.0) + ranked.append((symbol, sc + 0.1 * skill_bonus)) + + ranked.sort(key=lambda x: x[1], reverse=True) + return [s for s, _ in ranked[:max_candidates] if _ > 0] # ----------------------------- @@ -117,28 +158,34 @@ def get_clients(api_key: str, secret_key: str, paper: bool): return trading, data -def fetch_bars(data_client: StockHistoricalDataClient, cfg: Config) -> pd.DataFrame: +def fetch_bars( + data_client: StockHistoricalDataClient, cfg: Config, symbols: Iterable[str] +) -> Dict[str, pd.DataFrame]: end = datetime.now(timezone.utc) start = end - timedelta(minutes=cfg.lookback_minutes) req = StockBarsRequest( - symbol_or_symbols=cfg.symbol, + symbol_or_symbols=list(symbols), timeframe=cfg.timeframe, start=start, - end=end + end=end, ) bars = data_client.get_stock_bars(req) df = bars.df + result: Dict[str, pd.DataFrame] = {} - # alpaca-py returnerar MultiIndex (symbol, timestamp) om flera symboler - if isinstance(df.index, pd.MultiIndex): - df = df.xs(cfg.symbol) + if not isinstance(df.index, pd.MultiIndex): + df = df.sort_index().rename(columns=str.lower) + result[req.symbol_or_symbols] = df + return result - df = df.sort_index() - # standardisera kolumnnamn - df = df.rename(columns=str.lower) - return df + for symbol in req.symbol_or_symbols: + sub = df.xs(symbol) + sub = sub.sort_index().rename(columns=str.lower) + result[symbol] = sub + + return result def get_equity(trading: TradingClient) -> float: @@ -146,13 +193,15 @@ def get_equity(trading: TradingClient) -> float: return float(acct.equity) -def get_position_qty(trading: TradingClient, symbol: str) -> int: - positions = trading.get_all_positions() - for p in positions: - if p.symbol == symbol: - # qty kan vara str - return int(float(p.qty)) - return 0 +def get_positions(trading: TradingClient) -> Dict[str, dict]: + positions = {} + for p in trading.get_all_positions(): + positions[p.symbol] = { + "qty": int(float(p.qty)), + "avg_entry": float(p.avg_entry_price), + "market_value": float(p.market_value), + } + return positions def target_qty_for_buy(equity: float, last_price: float, max_pct: float) -> int: @@ -176,6 +225,115 @@ def submit_market_order(trading: TradingClient, symbol: str, qty: int, side: str logging.info("Skickade %s market order: %s x %d", side.upper(), symbol, qty) +def reset_daily_budget(db_path: str) -> float: + today = datetime.now(timezone.utc).date().isoformat() + saved_date = state_get(db_path, "daily_date", "") + if saved_date != today: + state_set(db_path, "daily_date", today) + state_set(db_path, "daily_spent", "0.0") + spent = float(state_get(db_path, "daily_spent", "0.0")) + return spent + + +def add_daily_spent(db_path: str, amount: float) -> None: + spent = float(state_get(db_path, "daily_spent", "0.0")) + state_set(db_path, "daily_spent", str(spent + amount)) + + +def update_skills_from_pending(db_path: str, prices: Dict[str, float], cfg: Config) -> Dict[str, float]: + skills: Dict[str, float] = state_get_json(db_path, "skills", {}) + pending = state_get_json(db_path, "pending_eval", {}) + now = datetime.now(timezone.utc) + horizon = timedelta(minutes=cfg.evaluation_horizon_minutes) + updated_pending = {} + + for symbol, payload in pending.items(): + ts = datetime.fromisoformat(payload["ts"]) + entry_price = payload["entry"] + if now - ts < horizon: + updated_pending[symbol] = payload + continue + if symbol not in prices: + updated_pending[symbol] = payload + continue + + ret = (prices[symbol] - entry_price) / entry_price + skills[symbol] = skills.get(symbol, 0.0) + float(ret) + logging.info("Utvärderade %s: avkastning %.2f%% -> ny skill %.4f", symbol, ret * 100, skills[symbol]) + + state_set_json(db_path, "skills", skills) + state_set_json(db_path, "pending_eval", updated_pending) + return skills + + +def record_pending_eval(db_path: str, symbol: str, entry_price: float) -> None: + pending = state_get_json(db_path, "pending_eval", {}) + pending[symbol] = { + "entry": entry_price, + "ts": datetime.now(timezone.utc).isoformat(), + } + state_set_json(db_path, "pending_eval", pending) + + +def enforce_stop_rules(trading: TradingClient, prices: Dict[str, float], positions: Dict[str, dict], cfg: Config) -> None: + for symbol, pos in positions.items(): + if symbol not in prices: + continue + qty = pos["qty"] + if qty <= 0: + continue + entry = pos["avg_entry"] + last = prices[symbol] + change = (last - entry) / entry + if change <= -cfg.stop_loss_pct: + logging.info("Stop loss triggat för %s: %.2f%%", symbol, change * 100) + submit_market_order(trading, symbol, qty, "sell") + elif change >= cfg.take_profit_pct: + logging.info("Tar vinst för %s: %.2f%%", symbol, change * 100) + submit_market_order(trading, symbol, qty, "sell") + + +def buy_candidates( + trading: TradingClient, + candidates: Iterable[str], + prices: Dict[str, float], + positions: Dict[str, dict], + equity: float, + cfg: Config, + db_path: str, +) -> None: + spent_today = reset_daily_budget(db_path) + remaining_budget = max(cfg.max_daily_budget_usd - spent_today, 0) + + for symbol in candidates: + if remaining_budget <= 0: + logging.info("Dagsbudget förbrukad. Bryter köp.") + break + if positions.get(symbol, {}).get("qty", 0) > 0: + logging.info("Redan long %s, hoppar över köp.", symbol) + continue + + price = prices.get(symbol) + if price is None or price <= 0: + continue + + max_for_symbol = min(equity * cfg.max_position_pct, remaining_budget) + if max_for_symbol <= 0: + continue + + qty = int((max_for_symbol / (1 + cfg.fee_pct)) // price) + if qty <= 0: + logging.info("Budget otillräcklig för %s.", symbol) + continue + + submit_market_order(trading, symbol, qty, "buy") + cost_estimate = price * qty * (1 + cfg.fee_pct) + add_daily_spent(db_path, cost_estimate) + remaining_budget -= cost_estimate + record_pending_eval(db_path, symbol, price) + logging.info("Köpte %s x %d för ca %.2f USD (avgift %.4f%%).", symbol, qty, cost_estimate, cfg.fee_pct * 100) + + # ----------------------------- # Main # ----------------------------- @@ -201,48 +359,25 @@ def main(): logging.error("Equity (%.2f) under min (%.2f). Avbryter.", equity, cfg.min_equity_usd) return - # Hämta data och signal - df = fetch_bars(data, cfg) - if df.empty: + # Hämta data för alla symboler + data_by_symbol = fetch_bars(data, cfg, cfg.symbols) + if not data_by_symbol: logging.warning("Ingen data returnerad. Avbryter.") return - last_price = float(df["close"].iloc[-1]) - signal = generate_signal(df, cfg.sma_fast, cfg.sma_slow) - logging.info("Symbol=%s last=%.2f signal=%s", cfg.symbol, last_price, signal) + prices = {s: float(df["close"].iloc[-1]) for s, df in data_by_symbol.items() if not df.empty} - if signal is None: - return + # Självförbättring: uppdatera skills baserat på tidigare prediktioner + skills = update_skills_from_pending(cfg.db_path, prices, cfg) - # Anti-spam: agera bara om signalen ändrats sen sist - last_signal = state_get(cfg.db_path, "last_signal", "") - if signal == last_signal: - logging.info("Signal oförändrad (%s). Ingen ny order.", signal) - return + # Riskhantering för öppna positioner + positions = get_positions(trading) + enforce_stop_rules(trading, prices, positions, cfg) - # Nuvarande position - pos_qty = get_position_qty(trading, cfg.symbol) - logging.info("Nuvarande position qty=%d", pos_qty) - - if signal == "buy": - if pos_qty > 0: - logging.info("Redan long. Gör inget.") - else: - # Om du råkar vara short (ovanligt i cash-konto), köp tillbaka först: - if pos_qty < 0: - submit_market_order(trading, cfg.symbol, abs(pos_qty), "buy") - - qty = target_qty_for_buy(equity, last_price, cfg.max_position_pct) - submit_market_order(trading, cfg.symbol, qty, "buy") - - elif signal == "sell": - if pos_qty <= 0: - logging.info("Ingen long att sälja. Gör inget.") - else: - submit_market_order(trading, cfg.symbol, pos_qty, "sell") - - # Spara senaste signal (efter action) - state_set(cfg.db_path, "last_signal", signal) + # Välj kandidater och försök köpa inom dagsbudget + candidates = ranked_candidates(data_by_symbol, cfg, skills) + logging.info("Kandidater: %s", ", ".join(candidates) if candidates else "inga") + buy_candidates(trading, candidates, prices, positions, equity, cfg, cfg.db_path) if __name__ == "__main__":