commit 2be8b491d0867b7d8b7b783d06c25daee35625c2 Author: George Date: Wed May 27 07:02:58 2026 +0100 Initial commit — TSLA grid trading bot - Grid strategy with survival-gated spacing and depth - Full 60% drop simulation for all survival checks - Calibration report with auto-updating survival threshold - Transaction history sync from Capital.com - Dip mode with bottom-two TP rules diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d4131fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,22 @@ +# Credentials — never commit these +.env + +# Database — large binary, regenerated by fetch_history.py +data/ + +# Logs — runtime output, not source code +logs/ + +# Python cache +__pycache__/ +*.pyc +*.pyo + +# OS files +.DS_Store +Thumbs.db + +# Editor files +.vscode/ +.idea/ +*.swp diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..d59df97 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,240 @@ +# MAXBOT — Full Context Prompt + +Paste this at the start of any new conversation to restore full context. + +--- + +## What this is + +A live grid trading bot running on Ubuntu server (portainer.local), trading +TSLA CFDs on Capital.com. Written in Python, running as a systemd service. +Account owner: George, UK retail account, GBP, 5:1 leverage (20% margin). + +--- + +## Project structure + +``` +~/maxbot/ + bot.py — main entry point, loop, startup audit, CLI + config.py — ALL settings (edit this for day-to-day changes) + client.py — Capital.com REST API calls + state.py — account state snapshot, field access helpers + calculator.py — grid maths, survival check, tier lookups + grid.py — order management logic (gap-filling, TP rules, dip mode) + actions.py — dry/confirm/live action handler with visible logging + calibration.py — startup calibration report, history sync + fetch_history.py — fetches full Capital.com transaction history to SQLite + data/history.db — SQLite: all trades, swaps, deposits from Jan 2021 + logs/ + bot.log — full detail every loop + actions.log — actions only (orders placed/cancelled/TP changes) + calibration.log — startup calibration reports + fetch_history.log +``` + +--- + +## The trading strategy + +**Instrument:** TSLA CFD on Capital.com (UK retail, GBP account) +**Type:** Grid / mean-reversion dip buying + +1. Bot places limit buy orders BELOW current price at regular intervals +2. When TSLA dips, orders fill automatically +3. Each position has a take profit set to return exactly £1.00 (0.2 shares) + or £0.50 (0.1 shares) +4. Take profit is set as an ABSOLUTE PRICE LEVEL (not amount/distance) + Formula: tp_pct = (profit_gbp * gbpusd / size) / entry_price + tp_price = entry_price * (1 + tp_pct) +5. Positions close automatically at take profit +6. Strategy works as long as TSLA doesn't drop catastrophically and + long-term price grows + +**Position sizes:** +- 0.2 shares: below SIZE_SMALL_THRESHOLD (highest_open * 0.95) +- 0.1 shares: within top 5% of highest open price (expensive overnight) + +**Profit targets (configurable in config.py):** +- TP_PROFIT_LARGE = 1.00 (£1.00 for 0.2 share positions) +- TP_PROFIT_SMALL = 0.50 (£0.50 for 0.1 share positions) + +--- + +## Grid logic + +**Anchor:** Grid starts just below the LOWEST OPEN POSITION (not current +price). This prevents churn from minute-to-minute price movements. Grid +only shifts when a position actually fills. + +**Normal mode** (price within 25% of highest open): +- Anchor = lowest open position +- Grid fills below anchor, queue_depth orders maintained + +**Dip mode** (price dropped 25%+ from highest open): +- Lowest open position has TP DISABLED (manual close only) +- Anchor = second lowest open position (lowest with TP) +- Grid fills below anchor +- Also fills gaps between no-TP position and anchor if spacing fits +- No orders placed at or above anchor or no-TP position + +**Grid floor:** Always fixed at 60% drop from highest open position. +Floor = highest_open * 0.40. Never changes regardless of other settings. + +**Queue depth:** Always maintain exactly QUEUE_DEPTH pending orders +in the grid window. Scan for gaps anywhere (not just bottom) and fill them. +Cancel orders outside the window. + +--- + +## Bottom-two position rule + +Only activates when price drops 25%+ from highest open: +- Lowest open position → TP DISABLED (manual close, holds for max recovery) +- Second lowest → TP ENABLED at standard level +- When new lower position opens → re-evaluate bottom two + +--- + +## Survival system (CRITICAL — everything depends on this) + +**The survival check simulates a FULL 60% drop from highest open position.** + +It does NOT just check current orders. It simulates: +1. All existing open positions +2. ALL grid levels from lowest existing down to floor (no depth cap) + — because in a real crash, every level fills on the way down + +Formula: +- Floor = highest_open * 0.40 +- For each level: margin = price * size * 0.20 / gbpusd +- For each level: unrealised_loss = (floor - price) * size / gbpusd +- equity_at_floor = current_equity + sum(unrealised_losses) +- margin_level_at_floor = equity_at_floor / total_margin * 100 +- SAFE if margin_level_at_floor >= 50% (Capital.com closeout threshold) + +**Capital.com margin stages:** +- >100%: normal +- ≤100%: warning 1 — bot stops placing orders +- ≤75%: warning 2 — urgent +- ≤50%: auto closeout — Capital.com closes positions + +**SURVIVAL_DROP_PCT in config.py:** This is the bot's operational setting. +It auto-steps (30→35→40→45→50→55→60%) as equity grows. It does NOT +control grid depth — the grid always goes to 60% floor regardless. +SURVIVAL_DROP_PCT is used to gate order placement in the main loop. + +**Auto-update:** Calibration checks max safe % each startup. If account +can survive a higher milestone, config.py is auto-updated. + +--- + +## Grid tier upgrades (survival-gated) + +**ORDER OF OPERATIONS — strictly enforced:** +1. Survival check first (always, against full 60% simulation) +2. Spacing tightens second (only when 60% survival passes at tighter spacing) +3. Depth increases third (only after spacing tightened AND 60% survival passes) + +Spacing tiers (config.py SPACING_TIERS): +- £600: 1.5% (base) +- £800: 1.2% (only if 60% survival passes at 1.2%) +- £1100: 1.0% +- £1500: 0.8% + +Queue depth tiers (config.py QUEUE_TIERS): +- (£600, 10 orders, requires 1.5% spacing) +- (£900, 12 orders, requires 1.2% spacing first) +- (£1200, 15 orders, requires 1.0% spacing first) +- (£1500, 18 orders, requires 0.8% spacing first) + +Depth NEVER increases until spacing has already tightened to required level. +Spacing NEVER tightens until full 60% drop simulation passes. + +--- + +## Calibration report (runs every startup) + +Sections in logic order: +1. ACCOUNT — equity, margin, positions, deposits +2. SURVIVAL — max safe %, progress to 60%, auto-update status +3. GRID — current spacing/depth, next upgrade status (survival-gated) +4. TSLA PERFORMANCE — trades, profit, fees, monthly trend (from Jan 2024) +5. MILESTONES — £1k, £10k, £100k, £1M with ETA based on real profit rate + +Deposit detection: auto-detects weekly average from Capital.com history +(last 12 weeks, needs 3+ deposits). Falls back to WEEKLY_DEPOSIT_GBP +in config.py. + +History sync: fetch_history.py runs incrementally on every startup, +fetching only new days since last run. Uses /history/transactions endpoint. +3 second delay to avoid Capital.com session rate limit (429). + +--- + +## Operating modes + +``` +python3 bot.py --mode dryrun # reads everything, prints what it would do, no actions +python3 bot.py --mode confirm # asks y/n before every action +python3 bot.py --mode live # fully autonomous +``` + +Service: systemd, auto-restart, credentials in ~/maxbot/.env + +--- + +## Capital.com API notes + +- Field names (verified against live API): + Orders: workingOrderData.orderLevel, orderSize, dealId + Positions: position.level, position.size, position.profitLevel, position.dealId + Account: accounts[].balance.balance (equity), .deposit (funds), .available + Price: snapshot.bid, snapshot.offer +- Session tokens expire after 10 minutes, refresh at 9 minutes +- Rate limit: 1 request per 0.1s for order operations +- POST /session: 1 request/second limit +- TP is sent as profitLevel (absolute price), displays as "Price level" in UI +- No custom order tags/references available in API + +--- + +## Current state (as of May 27 2026) + +- Equity: ~£664 +- Highest open position: $450.02 +- Max safe survival: 40% drop +- SURVIVAL_DROP_PCT: 40% +- Grid: 1.5% spacing, 10 orders, FROZEN until 60% survival met +- Account open: Jan 28 2021 +- Current strategy start: Jan 2024 +- Trades (Jan 2024+): 407, profit £484, fees -£74, net £409 +- Blended daily rate: £2.67/day +- Next milestone: £1,000 equity (~126 days at current rate) +- Weekly deposits: none detected yet (last deposit May 26 2026) + +--- + +## Planned future features (not yet built) + +1. Telegram alerts — fills, closures, margin warnings +2. Auto survival threshold stepping already done +3. Claude AI integration — consult Claude on edge cases +4. Web dashboard — browser view of positions, grid, P&L +5. ProQuant-style backtester — test strategies against TSLA history + (data already in history.db, S&P500/NASDAQ planned as context) +6. US100 and S&P500 as secondary instruments (future) + +--- + +## Key design decisions made + +- Grid floor FIXED at 60% — never changes, decoupled from SURVIVAL_DROP_PCT +- TP as price level % of entry (not fixed dollar, not fixed % of current price) +- Grid anchored to lowest OPEN POSITION (not current price) — prevents churn +- Survival always simulates FULL grid to floor (no depth cap) — honest +- Spacing frozen until 60% survival passes — safety before profit frequency +- Depth requires spacing first — correct order of operations +- History uses /history/transactions not /history/activity (richer data) +- Deposits filtered from Jan 1 2024 onwards (new strategy era) +- SURVIVAL_DROP_PCT auto-steps in config.py via calibration — no manual edits diff --git a/README.md b/README.md new file mode 100644 index 0000000..50a4d3b --- /dev/null +++ b/README.md @@ -0,0 +1,203 @@ +# TSLA Grid Trading Bot — Capital.com + +A grid/mean-reversion trading bot for Tesla (TSLA) CFDs on Capital.com. +Runs on Ubuntu server. Written in Python 3. + +--- + +## How the strategy works + +The bot places limit buy orders at regular intervals **below the current price**. +When TSLA dips, orders fill automatically. Each position has a take profit set +to return a fixed GBP amount. Positions close automatically at take profit. + +- Many small £1.00 profits compound over time +- Deeper dips = more positions open = more profit potential on recovery +- Grid self-adjusts as equity grows (tighter spacing = more frequent fills) +- Designed to survive a 60% drop from highest open position (builds toward this) + +--- + +## Project structure + +``` +maxbot/ + bot.py Main entry point — run this + config.py ALL settings — edit this for day-to-day changes + client.py Capital.com API calls + state.py Account state snapshot + field access helpers + calculator.py Grid maths, survival check, gap detection + grid.py Order management (gap-filling, TP rules) + actions.py dry/confirm/live action handler + bot.log Activity log (auto-created on first run) + README.md This file +``` + +**When making changes:** each file has one job. Bug in order placement → `grid.py`. +Change a setting → `config.py`. API field name changed → `client.py` and `state.py`. + +--- + +## Setup + +### 1. Requirements + +Python 3.8+ and requests: +```bash +pip install requests --break-system-packages +``` + +### 2. Capital.com API key + +1. Log into Capital.com +2. Go to **Settings → API integrations** +3. Enable 2FA if not already done (required for API key) +4. Generate API key + +### 3. Set credentials as environment variables + +```bash +# Add to ~/.bashrc so they persist across reboots +echo 'export CAPITAL_API_KEY="your_key_here"' >> ~/.bashrc +echo 'export CAPITAL_IDENTIFIER="your@email.com"' >> ~/.bashrc +echo 'export CAPITAL_PASSWORD="your_password_here"' >> ~/.bashrc +source ~/.bashrc +``` + +### 4. Verify credentials work + +```bash +cd ~/maxbot +python3 bot.py --mode dryrun +``` + +Should connect, read your positions and orders, print the audit. No actions taken. + +--- + +## Running the bot + +### Step 1 — Dry run (safe, always start here) +```bash +python3 bot.py --mode dryrun +``` +Reads everything, prints what it would do. Zero actions. Run this first. + +### Step 2 — Confirm mode (approve each action) +```bash +python3 bot.py --mode confirm +``` +Asks `y/n` before every order placement, cancellation, or TP change. +Run this for several days until you trust the behaviour completely. + +### Step 3 — Live mode (autonomous) +```bash +python3 bot.py --mode live +``` +Fully autonomous. Only switch here after thorough testing. + +--- + +## Running as a system service (auto-start on boot) + +### Create the service file + +```bash +sudo nano /etc/systemd/system/maxbot.service +``` + +Paste this (adjust paths/credentials): + +```ini +[Unit] +Description=TSLA Grid Trading Bot +After=network.target +StartLimitIntervalSec=0 + +[Service] +Type=simple +User=george +WorkingDirectory=/home/george/maxbot +Environment=CAPITAL_API_KEY=your_key_here +Environment=CAPITAL_IDENTIFIER=your@email.com +Environment=CAPITAL_PASSWORD=your_password_here +ExecStart=/usr/bin/python3 /home/george/maxbot/bot.py --mode live +Restart=always +RestartSec=30 + +[Install] +WantedBy=multi-user.target +``` + +### Enable and start + +```bash +sudo systemctl daemon-reload +sudo systemctl enable maxbot +sudo systemctl start maxbot +``` + +### Check status and logs + +```bash +sudo systemctl status maxbot # current status +sudo journalctl -u maxbot -f # live systemd logs +tail -f ~/maxbot/bot.log # bot's own log file +``` + +### Stop / restart + +```bash +sudo systemctl stop maxbot +sudo systemctl restart maxbot +``` + +--- + +## Day-to-day configuration + +**All settings are in `config.py`.** Common changes: + +### Increase survival threshold as equity grows + +```python +# In config.py — increase this as equity grows: +SURVIVAL_DROP_PCT = 30.0 # current (£640 equity) +# → 35.0 at £900 +# → 40.0 at £1200 +# → 50.0 at £1500 +# → 60.0 at £1800 (target) +``` + +### Adjust queue depth + +```python +QUEUE_DEPTH = 10 # default +``` + +### Toggle demo mode for testing + +```python +USE_DEMO = True # points to demo.capital.com +``` + +--- + +## Margin call stages (Capital.com UK retail) + +| Margin level | What happens | +|---|---| +| > 100% | Normal — bot operates | +| ≤ 100% | Warning 1 — bot stops placing new orders | +| ≤ 75% | Warning 2 — urgent alert in logs | +| ≤ 50% | Auto closeout — Capital.com closes positions | + +--- + +## Planned future enhancements + +- [ ] Telegram alerts — ping on fills, closures, margin warnings +- [ ] Profit tracker — log every closed position, running total +- [ ] Automatic survival threshold stepping (reads equity, updates config) +- [ ] Claude AI integration — consult Claude on edge cases +- [ ] Web dashboard — view positions and grid on homelab browser diff --git a/actions.py b/actions.py new file mode 100644 index 0000000..c838f75 --- /dev/null +++ b/actions.py @@ -0,0 +1,119 @@ +""" +actions.py — Action handler for dry/confirm/live modes. +======================================================== +Every action the bot takes (place order, cancel order, update TP) +goes through this handler. The mode controls what actually happens. + +MODES: +------ +dryrun → Logs what it would do. Returns None. No API calls made. +confirm → Prints the action, waits for y/n input. Only proceeds on 'y'. +live → Executes immediately, no prompts. + +All three modes log the action clearly so you can audit what happened. + +WHY THIS MATTERS: +----------------- +Before trusting the bot with live orders, run in dryrun and confirm modes. +Watch the logs for several days. Only switch to live when you're confident +the logic is correct and the orders it wants to place match your expectations. +""" + +import logging +from typing import Optional +from client import CapitalClient +import config + +log = logging.getLogger("maxbot.actions") + + +class ActionHandler: + + def __init__(self, mode: str, client: CapitalClient): + self.mode = mode + self.client = client + self.actions_taken = 0 + self.orders_placed = 0 + self.orders_cancelled = 0 + self.tp_updates = 0 + + def _log_action(self, symbol: str, description: str): + """Print a highly visible action log line.""" + log.warning(f"") + log.warning(f"{'━' * 50}") + log.warning(f" {symbol} {description}") + log.warning(f"{'━' * 50}") + + def _confirm(self, description: str) -> bool: + if self.mode == "dryrun": + log.info(f"[DRY RUN] Would: {description}") + return False + if self.mode == "confirm": + print(f"\n ACTION: {description}") + try: + answer = input(" Proceed? (y/n): ").strip().lower() + except (EOFError, KeyboardInterrupt): + return False + return answer == "y" + return True # live + + # ───────────────────────────────────────── + # ACTIONS + # ───────────────────────────────────────── + + def place_limit_order(self, size: float, limit_price: float, + take_profit: float) -> Optional[dict]: + desc = ( + f"PLACE ORDER entry ${limit_price:.2f} " + f"size {size} TP ${take_profit:.2f}" + ) + if self._confirm(desc): + result = self.client.place_limit_order(size, limit_price, take_profit) + self._log_action("✚", desc) + self.actions_taken += 1 + self.orders_placed += 1 + return result + return None + + def cancel_order(self, deal_id: str, price: float) -> Optional[dict]: + desc = f"CANCEL ORDER ${price:.2f}" + if self._confirm(desc): + result = self.client.cancel_order(deal_id) + self._log_action("✖", desc) + self.actions_taken += 1 + self.orders_cancelled += 1 + return result + return None + + def enable_tp(self, deal_id: str, entry_price: float, + take_profit: float) -> Optional[dict]: + desc = ( + f"ENABLE TP position ${entry_price:.2f} " + f"→ TP ${take_profit:.2f}" + ) + if self._confirm(desc): + result = self.client.update_position_tp(deal_id, take_profit) + self._log_action("✔", desc) + self.actions_taken += 1 + self.tp_updates += 1 + return result + return None + + def disable_tp(self, deal_id: str, entry_price: float) -> Optional[dict]: + desc = f"DISABLE TP position ${entry_price:.2f} (manual close)" + if self._confirm(desc): + result = self.client.update_position_tp(deal_id, None) + self._log_action("⊘", desc) + self.actions_taken += 1 + self.tp_updates += 1 + return result + return None + + def summary(self) -> str: + return ( + f"Session summary: " + f"{self.actions_taken} total actions | " + f"{self.orders_placed} placed | " + f"{self.orders_cancelled} cancelled | " + f"{self.tp_updates} TP updates" + ) diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..a826a12 --- /dev/null +++ b/bot.py @@ -0,0 +1,490 @@ +""" +bot.py — Main entry point for the TSLA grid trading bot. +========================================================= +Run this file to start the bot. + +USAGE: +------ + python3 bot.py --mode dryrun # default — safe, no actions + python3 bot.py --mode confirm # ask before every action + python3 bot.py --mode live # fully autonomous + +FIRST TIME SETUP: +----------------- +1. Get API key from Capital.com → Settings → API integrations +2. Set environment variables: + export CAPITAL_API_KEY="your_key" + export CAPITAL_IDENTIFIER="your@email.com" + export CAPITAL_PASSWORD="your_password" +3. Run dryrun first and verify the output looks correct. +4. Run confirm mode for several days, approve each action manually. +5. Only switch to live mode when fully confident. + +TO RUN AS A SYSTEM SERVICE (auto-start on boot): +------------------------------------------------- +See README.md for systemd setup instructions. + +PROJECT STRUCTURE: +------------------ + bot.py ← YOU ARE HERE — main loop, startup, CLI + config.py ← ALL settings — edit this for day-to-day changes + client.py ← Capital.com API calls + state.py ← Account state snapshot, field access helpers + calculator.py ← Grid maths, survival check, gap detection + grid.py ← Order management logic (gap-filling, TP rules) + actions.py ← dry/confirm/live action handler + bot.log ← All activity logged here (auto-created) + +ENHANCING THE BOT: +------------------ +The code is intentionally split so future additions are isolated: + - Telegram alerts → new file telegram.py, called from run_loop() + - Profit tracking → new file tracker.py, log closed positions + - Claude AI → new file ai_advisor.py, called on edge cases + - Web dashboard → new file dashboard.py, reads state and logs + - Auto threshold → add equity check in run_loop(), update config +Each addition touches one file without breaking anything else. + +WHAT THE BOT DOES EACH LOOP (every 60 seconds): +------------------------------------------------ +1. Refresh API session if tokens approaching expiry +2. Fetch: positions, orders, account balance, TSLA price, GBP/USD rate +3. Check for abnormal price gap (> 20%) — pause if detected +4. Check margin health — stop placing orders if level < 100% +5. Manage bottom two positions (TP rules) +6. Manage order grid (cancel out-of-window, fill gaps) +7. Sleep 60 seconds, repeat +""" + +import argparse +import logging +import os +import sys +import time +from datetime import datetime + +import config +from client import CapitalClient +from state import BotState, pos_level, pos_size, pos_tp, ord_level, ord_size +from calculator import GridCalculator +from grid import GridManager +from actions import ActionHandler +from calibration import Calibrator, setup_calibration_log + +log = logging.getLogger("maxbot") + + +# ───────────────────────────────────────────── +# LOGGING SETUP +# ───────────────────────────────────────────── + +def setup_logging(): + """ + Two log files in the logs/ subfolder: + logs/bot.log — full detail, every loop, for debugging + logs/actions.log — only actions taken (orders placed/cancelled, TP changes) + logs/calibration.log — startup calibration reports, appended each run + + Monitoring tip: + tail -f ~/maxbot/logs/actions.log ← clean, just what the bot did + tail -f ~/maxbot/logs/bot.log ← full detail if something goes wrong + cat ~/maxbot/logs/calibration.log ← progress reports over time + """ + base = os.path.dirname(os.path.abspath(__file__)) + logs_dir = os.path.join(base, "logs") + os.makedirs(logs_dir, exist_ok=True) + fmt = "%(asctime)s [%(levelname)s] %(message)s" + + full_handler = logging.FileHandler(os.path.join(logs_dir, "bot.log")) + full_handler.setFormatter(logging.Formatter(fmt)) + + console_handler = logging.StreamHandler(sys.stdout) + console_handler.setFormatter(logging.Formatter(fmt)) + + root = logging.getLogger() + root.setLevel(logging.INFO) + root.addHandler(full_handler) + root.addHandler(console_handler) + + actions_handler = logging.FileHandler(os.path.join(logs_dir, "actions.log")) + actions_handler.setFormatter(logging.Formatter(fmt)) + actions_handler.setLevel(logging.WARNING) + logging.getLogger("maxbot.actions").addHandler(actions_handler) + + setup_calibration_log() + + +# ───────────────────────────────────────────── +# MAIN BOT CLASS +# ───────────────────────────────────────────── + +class TeslaGridBot: + + def __init__(self, mode: str): + self.mode = mode + self.client = CapitalClient() + self.state = BotState() + self.calculator = GridCalculator() + self.actions = ActionHandler(mode, self.client) + self.grid = GridManager(self.state, self.calculator, self.actions) + self.calibrator = Calibrator(self.state, self.calculator) + self.loop_count = 0 + self.gap_pause = False + self._prev_position_count = None + self._prev_order_count = None + self._prev_equity = None + + # ───────────────────────────────────────── + # STARTUP + # ───────────────────────────────────────── + + def banner(self): + labels = { + "dryrun": "DRY RUN — no actions will be taken", + "confirm": "CONFIRM MODE — will ask before every action", + "live": "LIVE MODE — fully autonomous", + } + print("\n" + "=" * 60) + print(" TSLA Grid Trading Bot — Capital.com") + print(f" Mode: {labels.get(self.mode, self.mode)}") + print(f" Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print("=" * 60 + "\n") + + def fetch_state(self): + """Refresh all state from Capital.com API.""" + self.client.refresh_session_if_needed() + positions = self.client.get_positions() + orders = self.client.get_working_orders() + account = self.client.get_account_details() + price_data = self.client.get_price(config.EPIC) + gbpusd = self.client.get_gbpusd() + self.state.update(positions, orders, account, price_data, gbpusd) + + def print_state(self): + """Print current account and grid status to log.""" + s = self.state + log.info("─" * 50) + log.info(f"TSLA price: ${s.current_price:.2f}") + log.info(f"Equity: £{s.equity:.2f}") + log.info(f"Available: £{s.available:.2f}") + log.info(f"Margin in use: £{s.total_margin_gbp:.2f}") + log.info(f"Margin level: {s.margin_level_pct:.1f}%") + log.info(f"Open positions: {len(s.positions)}") + log.info(f"Pending orders: {len(s.orders)}") + log.info(f"Highest open: ${s.highest_open_price:.2f}") + log.info(f"Lowest level: ${s.lowest_level_price:.2f}") + log.info(f"GBP/USD: {s.gbpusd:.4f}") + calc = self.calculator + spacing = calc.get_spacing_pct(s.equity) + depth = calc.get_queue_depth(s.equity) + full_sim = calc._simulate_grid( + s.highest_open_price, spacing, s.gbpusd, s.all_levels() + ) + survival = calc.survival_check( + s.equity, s.highest_open_price, full_sim, s.gbpusd, + override_pct=60.0 + ) + # Find max safe % for clear display + max_safe = 0.0 + for pct in range(10, 81, 5): + sim = calc._simulate_grid( + s.highest_open_price, spacing, s.gbpusd, s.all_levels() + ) + r = calc.survival_check( + s.equity, s.highest_open_price, sim, s.gbpusd, + override_pct=float(pct) + ) + if r["safe"]: + max_safe = float(pct) + else: + break + + log.info(f"Grid spacing: {spacing}%") + log.info(f"Queue depth: {depth}") + log.info( + f"Survival: max safe {max_safe:.0f}% | " + f"60% sim {'✓' if survival['safe'] else '✗'} " + f"(margin at floor: {survival['margin_level_at_floor_pct']:.1f}%)" + ) + log.info("─" * 50) + + def startup_audit(self): + """ + Full account audit on first start. + Reads and displays everything before taking any action. + """ + log.info("=" * 50) + log.info("STARTUP AUDIT") + log.info("=" * 50) + + self.fetch_state() + self.print_state() + + s = self.state + + # Print all open positions + sorted_pos = s.positions_sorted_asc() + log.info(f"\nOpen positions ({len(sorted_pos)}) — lowest to highest:") + for p in sorted_pos: + tp = pos_tp(p) + tp_str = f"${tp:.2f}" if tp else "NONE (manual close)" + upl = float(p["position"].get("upl", 0)) + log.info( + f" ${pos_level(p):.2f} | " + f"size {p['position']['size']} | " + f"TP {tp_str} | " + f"P&L £{upl / self.state.gbpusd:.2f}" + ) + + # Print all pending orders + sorted_ord = s.orders_sorted_asc() + log.info(f"\nPending orders ({len(sorted_ord)}) — lowest to highest:") + for o in sorted_ord: + log.info(f" ${ord_level(o):.2f} | size {ord_size(o)}") + + # Full survival check + log.info("") + self._log_survival_check() + + log.info("\nStartup audit complete.") + log.info("=" * 50) + + # ───────────────────────────────────────── + # CHECKS + # ───────────────────────────────────────── + + def _log_survival_check(self): + """Run and log full survival check — always simulates full 60% grid.""" + s = self.state + calc = self.calculator + # Always simulate full grid at 60% drop — honest worst case + full_sim = calc._simulate_grid( + s.highest_open_price, calc.get_spacing_pct(s.equity), + s.gbpusd, s.all_levels() + ) + result = calc.survival_check( + s.equity, s.highest_open_price, + full_sim, s.gbpusd, + override_pct=60.0 + ) + status = "✓ SAFE" if result["safe"] else "✗ UNSAFE" + log.info(f"Survival check (full 60% grid simulation): {status}") + log.info(f" Floor price: ${result['floor_price']:.2f}") + log.info(f" Total margin (GBP): £{result['total_margin_gbp']:.2f}") + log.info(f" Equity at floor: £{result['equity_at_floor_gbp']:.2f}") + log.info(f" Margin level there: {result['margin_level_at_floor_pct']:.1f}%") + log.info(f" Unrealised P&L: £{result['unrealised_loss_gbp']:.2f}") + if not result["safe"]: + log.warning( + "SURVIVAL CHECK FAILED — " + "account may be margin called at 60% drop." + ) + return result + + def check_price_gap(self) -> bool: + """ + Detect abnormal price gaps between loops. + A gap > GAP_ALERT_PCT triggers a pause on order placement. + This protects against Monday gap-downs, stock splits, news events. + The bot will resume normal operation next loop after alerting. + To resume immediately, restart the bot. + """ + s = self.state + if s.last_price and s.last_price > 0: + change_pct = abs(s.current_price - s.last_price) / s.last_price * 100 + if change_pct >= config.GAP_ALERT_PCT: + log.warning("!" * 50) + log.warning( + f"PRICE GAP DETECTED: " + f"${s.last_price:.2f} → ${s.current_price:.2f} " + f"({change_pct:.1f}%)" + ) + log.warning( + "This may be a gap-down open, stock split, or major news event." + ) + log.warning( + "Bot is PAUSING order placement this cycle. " + "Review Capital.com manually." + ) + log.warning("!" * 50) + self.gap_pause = True + return True + s.last_price = s.current_price + self.gap_pause = False + return False + + def check_margin_health(self) -> str: + """ + Check current margin level against Capital.com thresholds. + Returns: 'ok', 'warning1', 'warning2', or 'closeout' + + Capital.com UK retail margin call stages: + > 100% → Normal + = 100% → Warning 1: cannot open new positions + = 75% → Warning 2: urgent + = 50% → Auto closeout begins + """ + ml = self.state.margin_level_pct + if ml <= 50: + log.warning( + f"MARGIN CLOSEOUT LEVEL ({ml:.1f}% ≤ 50%). " + "Capital.com may be closing positions!" + ) + return "closeout" + if ml <= 75: + log.warning( + f"MARGIN WARNING 2 ({ml:.1f}% ≤ 75%). " + "Cannot place new orders." + ) + return "warning2" + if ml <= 100: + log.warning( + f"MARGIN WARNING 1 ({ml:.1f}% ≤ 100%). " + "Cannot place new orders." + ) + return "warning1" + return "ok" + + # ───────────────────────────────────────── + # MAIN LOOP + # ───────────────────────────────────────── + + def run_loop(self): + """Single iteration of the main loop.""" + self.loop_count += 1 + + self.fetch_state() + s = self.state + + # Detect meaningful changes since last loop + pos_count = len(s.positions) + ord_count = len(s.orders) + equity_delta = abs((s.equity - self._prev_equity) if self._prev_equity else 0) + state_changed = ( + pos_count != self._prev_position_count or + ord_count != self._prev_order_count or + equity_delta > 0.50 # equity moved more than 50p + ) + + if state_changed: + # Full state print when something meaningful changed + log.info(f"\n{'=' * 50}") + log.info(f"Loop #{self.loop_count} — {datetime.now().strftime('%H:%M:%S')} — STATE CHANGED") + self.print_state() + else: + # Quiet heartbeat — one line only + log.info( + f"Loop #{self.loop_count} — {datetime.now().strftime('%H:%M:%S')} — " + f"no changes " + f"(price ${s.current_price:.2f} " + f"equity £{s.equity:.2f} " + f"pos {pos_count} orders {ord_count})" + ) + + # Update previous state + self._prev_position_count = pos_count + self._prev_order_count = ord_count + self._prev_equity = s.equity + + # Gap detection + if self.check_price_gap(): + log.warning("Gap detected — skipping order management this cycle.") + return + + # Margin health + margin_status = self.check_margin_health() + if margin_status != "ok": + log.warning( + f"Margin [{margin_status}] — managing positions only, " + "no new orders." + ) + self.grid.manage_bottom_two_positions() + return + + # Normal operation + self.grid.manage_bottom_two_positions() + self.grid.manage_orders() + + def run(self): + """Start the bot.""" + self.banner() + + log.info("Connecting to Capital.com...") + try: + self.client.create_session() + except Exception as e: + log.error(f"Connection failed: {e}") + log.error( + "Check credentials: " + "CAPITAL_API_KEY, CAPITAL_IDENTIFIER, CAPITAL_PASSWORD" + ) + sys.exit(1) + + self.startup_audit() + try: + self.calibrator.run() + except Exception as e: + log.error(f"Calibration failed: {e}", exc_info=True) + + if self.mode == "dryrun": + log.info("\nDRY RUN complete — no actions taken.") + log.info("Review output above, then run with --mode confirm") + return + + log.info( + f"\nEntering main loop " + f"(every {config.LOOP_INTERVAL_SECS}s)..." + ) + log.info("Press Ctrl+C to stop.\n") + + try: + while True: + try: + self.run_loop() + except KeyboardInterrupt: + raise + except Exception as e: + log.error(f"Loop error: {e}", exc_info=True) + log.info("Waiting 30s before retry...") + time.sleep(30) + continue + time.sleep(config.LOOP_INTERVAL_SECS) + + except KeyboardInterrupt: + log.info(f"\nBot stopped by user.") + log.info(self.actions.summary()) + + +# ───────────────────────────────────────────── +# CLI +# ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="TSLA Grid Trading Bot — Capital.com", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Modes: + dryrun Connect and read everything. Print what bot would do. No actions. + confirm Ask y/n before every action. Run this for days before going live. + live Fully autonomous. Only use after thorough testing. + +Examples: + python3 bot.py # dryrun (default) + python3 bot.py --mode confirm # confirm mode + python3 bot.py --mode live # live mode + """ + ) + parser.add_argument( + "--mode", + choices=["dryrun", "confirm", "live"], + default="dryrun", + help="Operation mode (default: dryrun)" + ) + args = parser.parse_args() + setup_logging() + TeslaGridBot(mode=args.mode).run() + + +if __name__ == "__main__": + main() diff --git a/calculator.py b/calculator.py new file mode 100644 index 0000000..237c769 --- /dev/null +++ b/calculator.py @@ -0,0 +1,325 @@ +""" +calculator.py — Grid maths and survival check. +=============================================== +All calculations live here. No API calls, no side effects. +Pure functions that take numbers and return numbers. + +KEY CALCULATIONS: +----------------- + +1. GRID SPACING + Percentage-based so it self-adjusts as price changes. + At $420 with 1.5% spacing: $420 → $413.70 → $407.49 → $401.38 etc. + Tighter at lower prices naturally (1.5% of $200 = $3, vs 1.5% of $400 = $6). + Also tightens as equity grows via SPACING_TIERS in config. + +2. TAKE PROFIT PRICE + Target is fixed GBP profit per position (£1.00 or £0.50). + Formula: tp_distance_usd = profit_gbp * gbpusd / size + tp_price = entry_price + tp_distance_usd + Both 0.1 and 0.2 share positions travel the same price distance. + 0.2 shares earns £1.00, 0.1 shares earns £0.50 (same distance, half the size). + +3. SURVIVAL CHECK + Simulates a X% drop from highest_open_price. + Calculates total unrealised loss at the floor price. + Checks if equity_at_floor > 50% of total_margin (Capital.com closeout level). + If not safe, bot stops placing new orders. + +4. GRID GAP DETECTION (KEY FIX) + Instead of just adding orders at the bottom, the bot: + a) Calculates ALL expected grid levels from just below lowest position + down to the survival floor. + b) Checks which levels already have an order within 0.5% tolerance. + c) Places orders for any missing levels (gaps), anywhere in the window. + This means if the top order fills or is cancelled, the gap is filled next loop. +""" + +import logging +import config + +log = logging.getLogger("maxbot.calculator") + + +class GridCalculator: + + # ───────────────────────────────────────── + # SURVIVAL-GATED TIER LOOKUPS + # ───────────────────────────────────────── + + def get_spacing_pct(self, equity: float, + highest_open: float = None, + all_levels: list = None, + gbpusd: float = None) -> float: + """ + Return current grid spacing %. + If survival parameters provided, returns the tightest spacing + that still passes survival check. Otherwise returns equity-based value. + + Order of operations: + 1. Start from current spacing + 2. Check if next tighter tier is affordable (equity threshold met) + 3. Simulate full grid at tighter spacing → survival check + 4. Only apply if survival passes + """ + # Simple equity-based lookup when no survival check needed + if highest_open is None or all_levels is None or gbpusd is None: + result = config.BASE_SPACING_PCT + for threshold, spacing in sorted(config.SPACING_TIERS): + if equity >= threshold: + result = spacing + return result + + # Survival-gated: find tightest spacing that still survives 60% drop + best_spacing = config.BASE_SPACING_PCT + for threshold, spacing in sorted(config.SPACING_TIERS): + if equity < threshold: + break + # Simulate full grid at this spacing + simulated = self._simulate_grid( + highest_open, spacing, gbpusd, all_levels + ) + # Always check against 60% — spacing only tightens when + # account can genuinely survive the full target drop + result = self.survival_check( + equity, highest_open, simulated, gbpusd, + override_pct=60.0 + ) + if result["safe"]: + best_spacing = spacing + else: + break # can't go tighter — stop here + return best_spacing + + def get_queue_depth(self, equity: float, + current_spacing: float = None, + highest_open: float = None, + all_levels: list = None, + gbpusd: float = None) -> int: + """ + Return current queue depth. + Depth tier requires: + a) Equity threshold met + b) Spacing already at required level + c) Survival check passes at new depth + + Order of operations: + 1. Spacing must tighten BEFORE depth can increase + 2. Only increase depth if survival passes at new depth + """ + # Simple lookup when no survival check needed + if current_spacing is None or highest_open is None: + result = config.QUEUE_TIERS[0][1] + for threshold, depth, req_spacing in sorted(config.QUEUE_TIERS): + if equity >= threshold: + result = depth + return result + + # Survival-gated: find deepest queue that still survives 60% drop + best_depth = config.QUEUE_TIERS[0][1] + for threshold, depth, req_spacing in sorted(config.QUEUE_TIERS): + if equity < threshold: + break + # Check spacing requirement — depth only unlocks after spacing tightened + if current_spacing > req_spacing: + break # spacing hasn't tightened enough yet + # Simulate grid at this depth + simulated = self._simulate_grid( + highest_open, current_spacing, gbpusd, + all_levels, depth=depth + ) + # Always check against 60% — depth only increases when + # account can genuinely survive the full target drop + result = self.survival_check( + equity, highest_open, simulated, gbpusd, + override_pct=60.0 + ) + if result["safe"]: + best_depth = depth + else: + break + return best_depth + + def _simulate_grid(self, highest_open: float, spacing_pct: float, + gbpusd: float, existing_levels: list, + depth: int = None) -> list: + """ + Simulate the COMPLETE set of positions/orders that would exist + in a worst-case 60% crash scenario. + + This is used for survival checks — NOT for grid placement. + + In a real crash: + - All existing open positions stay open (accumulating unrealised loss) + - ALL pending orders between current price and floor fill + - Bot keeps placing new orders on the way down (all fill too) + + So we simulate: + 1. All existing open positions (real entries, real sizes) + 2. All grid levels from lowest existing position down to floor + (NO depth cap — every level fills in a crash) + + existing_levels = s.all_levels() — list of {price, size} dicts + depth param ignored — full grid always simulated for honest result + """ + GRID_FLOOR_PCT = 60.0 + floor = highest_open * (1.0 - GRID_FLOOR_PCT / 100.0) + spacing = spacing_pct / 100.0 + + # Include ALL existing levels (both positions and orders) + # These are already real entries with real sizes + simulated = [{"price": lv["price"], "size": lv["size"]} + for lv in existing_levels] + + # Find lowest existing level as starting point for simulation + if simulated: + start = min(lv["price"] for lv in simulated) + else: + start = highest_open * 0.95 + + # Add ALL simulated grid levels from just below start down to floor + # No depth cap — in a crash every level fills + level = start * (1.0 - spacing) + while level >= floor: + size = self.get_size(level, highest_open) + simulated.append({"price": level, "size": size}) + level = level * (1.0 - spacing) + + return simulated + + # ───────────────────────────────────────── + # SIZE AND TP + # ───────────────────────────────────────── + + def get_size(self, price: float, highest_open: float) -> float: + """ + Return position size for a given price level. + 0.1 shares: only within top 5% of highest open (expensive to hold overnight). + 0.2 shares: everything else (faster recovery, more profit per fill). + Example: highest_open=$450 → threshold=$427.50 + Above $427.50 → 0.1 shares + Below $427.50 → 0.2 shares (almost all orders will be here) + """ + threshold = highest_open * config.SIZE_SMALL_THRESHOLD_PCT + return 0.1 if price >= threshold else 0.2 + + def get_tp_price(self, entry_price: float, size: float, + gbpusd: float, reference_price: float = None) -> float: + """ + Calculate take profit as an absolute price level. + + Target profit is configurable in config.py: + TP_PROFIT_LARGE = 1.00 (£1.00 for 0.2 share positions) + TP_PROFIT_SMALL = 0.50 (£0.50 for 0.1 share positions) + Change these values to adjust profit target (e.g. 1.50 for £1.50). + + Formula: + tp_distance = profit_gbp * gbpusd / size (converts £ to $ distance) + tp_pct = tp_distance / entry_price (as % of entry) + tp_price = entry_price * (1 + tp_pct) (absolute price level) + + Result is always sent as an absolute price level to Capital.com. + Capital.com UI displays it correctly as "Price level". + + reference_price parameter kept for compatibility but not used. + """ + profit_gbp = config.TP_PROFIT_LARGE if size >= 0.2 else config.TP_PROFIT_SMALL + profit_usd = profit_gbp * gbpusd + tp_distance = profit_usd / size + tp_pct = tp_distance / entry_price + return entry_price * (1.0 + tp_pct) + + # ───────────────────────────────────────── + # GRID GENERATION + # ───────────────────────────────────────── + + def generate_grid(self, top_price: float, floor_price: float, + equity: float, spacing_pct: float = None) -> list: + """ + Generate all expected grid levels from just below top_price + down to floor_price. Uses provided spacing_pct or calculates + from equity tiers if not provided. + """ + spacing = (spacing_pct if spacing_pct is not None + else self.get_spacing_pct(equity)) / 100.0 + levels = [] + price = top_price * (1.0 - spacing) # start just below top + + while price >= floor_price: + levels.append(round(price, 2)) + price = price * (1.0 - spacing) + + return levels # descending order + + def find_missing_levels(self, expected_levels: list, + existing_order_prices: set, + queue_depth: int) -> list: + """ + Find which expected grid levels are missing an order. + Uses 0.5% tolerance when matching levels to existing orders. + + ALWAYS scans the full grid for gaps regardless of how many + orders already exist. This is critical for correct behaviour + when positions close and the window shifts upward — gaps appear + at the top and must be filled even if the queue is already full. + + The manage_orders function handles cancelling excess bottom orders + before calling this, so the net result is always queue_depth orders + in the right positions. + + Returns gaps in descending order (highest first = closest to price). + """ + missing = [] + + for level in expected_levels: + already_covered = any( + abs(existing - level) / level < 0.005 + for existing in existing_order_prices + ) + if not already_covered: + missing.append(level) + + # Highest gaps first (descending already from generate_grid) + # Limit to queue_depth so we don't flood with orders + return missing[:queue_depth] + + # ───────────────────────────────────────── + # SURVIVAL CHECK + # ───────────────────────────────────────── + + def survival_check(self, equity: float, highest_open: float, + all_levels: list, gbpusd: float, + override_pct: float = None) -> dict: + """ + Simulate a drop from highest_open_price. + Uses SURVIVAL_DROP_PCT from config by default. + Pass override_pct to test a different percentage (used by calibration). + """ + drop_pct = (override_pct if override_pct is not None else config.SURVIVAL_DROP_PCT) / 100.0 + floor_price = highest_open * (1.0 - drop_pct) + + total_margin_gbp = 0.0 + total_unrealised_gbp = 0.0 + + for lv in all_levels: + price = lv["price"] + size = lv["size"] + margin_gbp = price * size * config.MARGIN_RATE / gbpusd + unrealised_gbp = (floor_price - price) * size / gbpusd + total_margin_gbp += margin_gbp + total_unrealised_gbp += unrealised_gbp + + equity_at_floor = equity + total_unrealised_gbp + margin_level_pct = ( + (equity_at_floor / total_margin_gbp * 100) + if total_margin_gbp > 0 else 999.0 + ) + + return { + "safe": margin_level_pct >= 50.0, + "floor_price": floor_price, + "total_margin_gbp": total_margin_gbp, + "equity_at_floor_gbp": equity_at_floor, + "margin_level_at_floor_pct": margin_level_pct, + "unrealised_loss_gbp": total_unrealised_gbp, + } diff --git a/calibration.py b/calibration.py new file mode 100644 index 0000000..913876c --- /dev/null +++ b/calibration.py @@ -0,0 +1,687 @@ +""" +calibration.py — Startup calibration report and analytics. +============================================================ +Runs once after every bot startup, after the initial state fetch. +Also triggered by fetch_history.py after each history sync. + +Writes to logs/calibration.log — each run appends a dated report +so you can track progress over time. + +WHAT IT DOES: +------------- +1. Fetches latest transaction history (incremental — only new days) +2. Analyses TSLA-only performance from real historical data +3. Shows survival analysis with progress toward 60% target +4. Tracks all milestones with real estimated days to reach each +5. Recommends next config change (survival threshold increase) + +MILESTONE TARGETS: +------------------ + Priority 1: 60% survival (account resilience target) + Priority 2: £1,000 equity + Priority 3: £10,000 equity + Priority 4: £100,000 equity + Priority 5: £1,000,000 equity (the dream) + +DATA SOURCE: +------------ +All financial projections use real TSLA trade history from history.db. +Monthly profit trend used for projections (recent months weighted more). +Falls back to estimates if no history available. +""" + +import logging +import os +import sqlite3 +from datetime import datetime, date, timedelta +from state import BotState, pos_level, pos_size, ord_level, ord_size +from calculator import GridCalculator +import config + +log = logging.getLogger("maxbot.calibration") + +DB_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "history.db" +) + + +def setup_calibration_log(): + """Add calibration log handler. Called from bot.py setup_logging.""" + base = os.path.dirname(os.path.abspath(__file__)) + logs_dir = os.path.join(base, "logs") + os.makedirs(logs_dir, exist_ok=True) + handler = logging.FileHandler(os.path.join(logs_dir, "calibration.log")) + handler.setFormatter(logging.Formatter("%(asctime)s %(message)s")) + handler.setLevel(logging.INFO) + logging.getLogger("maxbot.calibration").addHandler(handler) + logging.getLogger("maxbot.calibration").propagate = True + + +# ───────────────────────────────────────────── +# HISTORY READER +# ───────────────────────────────────────────── + +class HistoryReader: + """Read TSLA performance data from history.db.""" + + def __init__(self): + self.available = os.path.exists(DB_PATH) + if self.available: + try: + self.conn = sqlite3.connect(DB_PATH) + self.conn.row_factory = sqlite3.Row + except Exception as e: + log.warning(f"Could not open history DB: {e}") + self.available = False + + def get_deposit_stats(self) -> dict: + """ + Analyse deposit history to detect recurring deposit pattern. + Returns weekly average based on recent deposits if a pattern exists. + Falls back to config.WEEKLY_DEPOSIT_GBP if no pattern detected. + """ + if not self.available: + return { + "total_deposited": 0, + "deposit_count": 0, + "last_deposit": None, + "weekly_avg": getattr(config, "WEEKLY_DEPOSIT_GBP", 0.0), + "pattern": "config", + } + try: + # All deposits from 2024 onwards only + row = self.conn.execute(""" + SELECT COUNT(*) as c, SUM(size_gbp) as total, + MAX(date_utc) as last + FROM transactions + WHERE transaction_type='DEPOSIT' + AND date_utc >= '2024-01-01' + """).fetchone() + + # Recent deposits — last 12 weeks (from 2024 onwards) + recent = self.conn.execute(""" + SELECT COUNT(*) as c, SUM(size_gbp) as total, + MIN(date_utc) as first, MAX(date_utc) as last + FROM transactions + WHERE transaction_type='DEPOSIT' + AND date_utc >= date('now', '-84 days') + AND date_utc >= '2024-01-01' + """).fetchone() + + # Detect pattern: 3+ deposits in last 12 weeks = regular + weekly_avg = 0.0 + pattern = "none" + config_weekly = getattr(config, "WEEKLY_DEPOSIT_GBP", 0.0) + + if recent["c"] >= 3: + # Regular deposit pattern detected + weekly_avg = (recent["total"] or 0) / 12.0 + pattern = f"detected ({recent['c']} deposits in 12 weeks)" + elif config_weekly > 0: + # Use config value + weekly_avg = config_weekly + pattern = "config" + else: + pattern = "none yet — will auto-detect when regular deposits start" + + return { + "total_deposited": row["total"] or 0, + "deposit_count": row["c"] or 0, + "last_deposit": row["last"][:10] if row["last"] else None, + "weekly_avg": weekly_avg, + "pattern": pattern, + } + except Exception as e: + log.warning(f"Could not read deposit stats: {e}") + return { + "total_deposited": 0, + "deposit_count": 0, + "last_deposit": None, + "weekly_avg": getattr(config, "WEEKLY_DEPOSIT_GBP", 0.0), + "pattern": "error", + } + def get_tsla_stats(self) -> dict: + """ + Return TSLA performance stats from new strategy start date. + New strategy detected as starting July 2024 based on trade history — + that's when consistent £1/trade pattern began after previous strategies. + """ + NEW_STRATEGY_DATE = "2024-01-01" + + if not self.available: + return {} + try: + # New strategy period only + row = self.conn.execute(""" + SELECT COUNT(*) as trades, + SUM(size_gbp) as profit, + MIN(date_utc) as first_trade, + MAX(date_utc) as last_trade + FROM transactions + WHERE transaction_type='TRADE' AND instrument='TSLA' + AND date_utc >= ? + """, (NEW_STRATEGY_DATE,)).fetchone() + + swap_row = self.conn.execute(""" + SELECT SUM(size_gbp) as fees + FROM transactions + WHERE transaction_type='SWAP' AND instrument='TSLA' + AND date_utc >= ? + """, (NEW_STRATEGY_DATE,)).fetchone() + + deposit_row = None # deposits tracked separately + + # Monthly profit for last 6 months + monthly = [] + for m in self.conn.execute(""" + SELECT SUBSTR(date_utc,1,7) as month, + COUNT(*) as trades, + SUM(size_gbp) as profit + FROM transactions + WHERE transaction_type='TRADE' AND instrument='TSLA' + AND date_utc >= date('now','-6 months') + AND date_utc >= '2024-01-01' + GROUP BY month ORDER BY month + """): + monthly.append({ + "month": m["month"], + "trades": m["trades"], + "profit": m["profit"], + }) + + # Recent daily average (last 30 days with trades) + recent_row = self.conn.execute(""" + SELECT AVG(daily) FROM ( + SELECT DATE(date_utc) as d, SUM(size_gbp) as daily + FROM transactions + WHERE transaction_type='TRADE' AND instrument='TSLA' + AND date_utc >= date('now','-30 days') + AND date_utc >= '2024-01-01' + GROUP BY DATE(date_utc) + ) + """).fetchone() + + # Last 90 days daily average (more stable) + avg90_row = self.conn.execute(""" + SELECT AVG(daily) FROM ( + SELECT DATE(date_utc) as d, SUM(size_gbp) as daily + FROM transactions + WHERE transaction_type='TRADE' AND instrument='TSLA' + AND date_utc >= date('now','-90 days') + AND date_utc >= '2024-01-01' + GROUP BY DATE(date_utc) + ) + """).fetchone() + + swap_fees = swap_row["fees"] or 0 + trade_profit = row["profit"] or 0 + recent_daily = recent_row[0] or 0 + avg90_daily = avg90_row[0] or 0 + + return { + "trades": row["trades"] or 0, + "trade_profit": trade_profit, + "swap_fees": swap_fees, + "net": trade_profit + swap_fees, + "first_trade": row["first_trade"], + "last_trade": row["last_trade"], + "monthly": monthly, + "recent_daily": recent_daily, + "avg90_daily": avg90_daily, + } + except Exception as e: + log.warning(f"Could not read TSLA stats: {e}") + return {} + + def sync_history(self): + """Run incremental history fetch to get latest data.""" + try: + import subprocess, sys, time + script = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "fetch_history.py" + ) + if os.path.exists(script): + log.info("Syncing transaction history...") + # Wait 3 seconds — main bot already created a session moments ago + # Capital.com rate limits POST /session to 1 request/second + time.sleep(3) + result = subprocess.run( + [sys.executable, script, "--mode", "live"], + capture_output=True, text=True, timeout=300 + ) + if result.returncode == 0: + log.info("History sync complete.") + else: + output = result.stderr or result.stdout + err_lines = [l for l in output.split("\n") + if "ERROR" in l or "error" in l.lower()] + err_msg = err_lines[-1].strip() if err_lines else output[:200] + log.warning(f"History sync warning: {err_msg}") + except Exception as e: + log.warning(f"History sync failed: {e}") + + def close(self): + if self.available: + try: + self.conn.close() + except Exception: + pass + + +# ───────────────────────────────────────────── +# CALIBRATOR +# ───────────────────────────────────────────── + +class Calibrator: + + MILESTONES = [ + ("£1,000", 1_000), + ("£10,000", 10_000), + ("£100,000", 100_000), + ("£1,000,000", 1_000_000), + ] + + SURVIVAL_MILESTONES = [30, 35, 40, 45, 50, 55, 60] + + def __init__(self, state: BotState, calculator: GridCalculator): + self.state = state + self.calculator = calculator + + def run(self): + """Run full calibration and write report.""" + s = self.state + calc = self.calculator + + # Sync latest history first + reader = HistoryReader() + reader.sync_history() + tsla = reader.get_tsla_stats() + deposits = reader.get_deposit_stats() + reader.close() + + # Pre-calculate everything needed + all_levels = s.all_levels() + + # Daily profit rate + if tsla and tsla.get("avg90_daily", 0) > 0: + base_daily = tsla["avg90_daily"] + recent_daily = tsla.get("recent_daily", base_daily) + daily_rate = (recent_daily * 0.6) + (base_daily * 0.4) + else: + daily_rate = calc.get_queue_depth(s.equity) * config.TP_PROFIT_LARGE * 0.3 + + weekly_deposit = deposits.get("weekly_avg", 0.0) + daily_deposit = weekly_deposit / 7.0 + total_daily = daily_rate + daily_deposit + + # Survival + max_safe = self._find_max_safe_pct(s, all_levels) + next_survival = self._next_survival_milestone(max_safe) + + # Grid (survival-gated) + spacing = calc.get_spacing_pct( + s.equity, s.highest_open_price, all_levels, s.gbpusd + ) + depth = calc.get_queue_depth( + s.equity, spacing, s.highest_open_price, all_levels, s.gbpusd + ) + + # Full 60% simulation result (uses current spacing) + full_sim_60 = calc._simulate_grid( + s.highest_open_price, spacing, s.gbpusd, all_levels + ) + survival_60 = calc.survival_check( + s.equity, s.highest_open_price, full_sim_60, s.gbpusd, + override_pct=60.0 + ) + + lines = [] + lines.append("") + lines.append("=" * 60) + lines.append( + f" CALIBRATION REPORT — " + f"{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}" + ) + lines.append("=" * 60) + + # ══════════════════════════════════════ + # 1. ACCOUNT SNAPSHOT + # ══════════════════════════════════════ + lines.append("") + lines.append(" 1. ACCOUNT") + lines.append(f" Equity: £{s.equity:.2f}") + lines.append(f" Available: £{s.available:.2f}") + lines.append(f" Margin in use: £{s.total_margin_gbp:.2f}") + lines.append(f" Margin level: {s.margin_level_pct:.1f}%") + lines.append(f" TSLA price: ${s.current_price:.2f}") + lines.append(f" Open positions: {len(s.positions)}") + lines.append(f" Pending orders: {len(s.orders)}") + if deposits.get("total_deposited"): + lines.append(f" Deposited (2024+): £{deposits['total_deposited']:.2f}") + if deposits.get("last_deposit"): + lines.append(f" Last deposit: {deposits['last_deposit']}") + + # ══════════════════════════════════════ + # 2. SURVIVAL (calculated first — everything else depends on it) + # ══════════════════════════════════════ + progress = min(max_safe / 60.0, 1.0) + bar_filled = int(progress * 20) + surv_bar = "█" * bar_filled + "░" * (20 - bar_filled) + + lines.append("") + lines.append(" 2. SURVIVAL (target: 60% drop resistance)") + lines.append(f" Can survive: {max_safe:.0f}% drop") + lines.append(f" Bot setting: {config.SURVIVAL_DROP_PCT:.0f}%") + lines.append(f" Progress to 60%: [{surv_bar}] {max_safe:.0f}/60%") + + # Auto-update logic — all in one place, clear and unambiguous + old_pct = config.SURVIVAL_DROP_PCT + if max_safe >= 60: + lines.append(f" Status: ✓ TARGET REACHED") + if config.SURVIVAL_DROP_PCT < 60: + self._update_survival_pct(60.0, lines) + elif max_safe > config.SURVIVAL_DROP_PCT: + new_pct = self._next_survival_milestone(config.SURVIVAL_DROP_PCT) + if new_pct <= max_safe: + self._update_survival_pct(new_pct, lines) + lines.append( + f" Status: ↑ Updated {old_pct:.0f}% → " + f"{config.SURVIVAL_DROP_PCT:.0f}% (auto)" + ) + else: + lines.append( + f" Status: → Holding at {config.SURVIVAL_DROP_PCT:.0f}%" + ) + else: + equity_needed = self._equity_for_survival(s, all_levels, next_survival) + gap = max(equity_needed - s.equity, 0) + lines.append( + f" Status: → Holding at {config.SURVIVAL_DROP_PCT:.0f}%" + ) + lines.append( + f" Next milestone: {next_survival:.0f}% needs " + f"~£{equity_needed:.0f} equity" + ) + if gap > 0 and daily_rate > 0: + days = gap / daily_rate + lines.append( + f" Est. time: ~{days:.0f} days " + f"(£{gap:.0f} at £{daily_rate:.2f}/day)" + ) + + # ══════════════════════════════════════ + # 3. GRID (survival-gated — spacing first, then depth) + # ══════════════════════════════════════ + # ══════════════════════════════════════ + # 3. GRID (frozen until 60% survival achieved) + # Spacing tightens only when full 60% drop simulation passes. + # Depth increases only after spacing tightens AND 60% passes. + # ══════════════════════════════════════ + lines.append("") + lines.append(" 3. GRID (locked to 60% survival — spacing first, depth second)") + lines.append(f" Spacing: {spacing}% {'← frozen, 60% survival not yet met' if not survival_60['safe'] else '← active'}") + lines.append(f" Queue depth: {depth} orders") + + # Next spacing upgrade + for threshold, sp in sorted(config.SPACING_TIERS): + if sp < spacing: + gap = max(threshold - s.equity, 0) + days = gap / daily_rate if daily_rate > 0 else 0 + sim = calc._simulate_grid( + s.highest_open_price, sp, s.gbpusd, all_levels + ) + sv = calc.survival_check( + s.equity, s.highest_open_price, sim, s.gbpusd, + override_pct=60.0 + ) + sv_status = "✓ survival ok" if sv["safe"] else "✗ needs more equity" + if gap > 0: + lines.append( + f" Spacing → {sp}%: £{threshold} needed " + f"(£{gap:.0f} away ~{days:.0f}d) {sv_status}" + ) + else: + lines.append( + f" Spacing → {sp}%: equity met {sv_status}" + ) + break + + # Next depth upgrade + for threshold, d, req_sp in sorted(config.QUEUE_TIERS): + if d > depth: + gap = max(threshold - s.equity, 0) + days = gap / daily_rate if daily_rate > 0 else 0 + if spacing > req_sp: + lines.append( + f" Depth → {d} orders: spacing {req_sp}% required first" + ) + elif gap > 0: + lines.append( + f" Depth → {d} orders: £{threshold} needed " + f"(£{gap:.0f} away ~{days:.0f}d)" + ) + else: + lines.append( + f" Depth → {d} orders: equity met — survival check active" + ) + break + + # ══════════════════════════════════════ + # 4. PERFORMANCE (the engine driving everything) + # ══════════════════════════════════════ + lines.append("") + lines.append(" 4. TSLA PERFORMANCE (current strategy — from Jan 2024)") + if tsla: + lines.append(f" Trades: {tsla['trades']}") + lines.append(f" Trade profit: £{tsla['trade_profit']:.2f}") + lines.append(f" Overnight fees: £{tsla['swap_fees']:.2f}") + lines.append(f" Net: £{tsla['net']:.2f}") + lines.append(f" Last 30d avg: £{tsla.get('recent_daily', 0):.2f}/day") + lines.append(f" Last 90d avg: £{tsla.get('avg90_daily', 0):.2f}/day") + lines.append(f" Blended rate: £{daily_rate:.2f}/day (used for projections)") + + if tsla.get("monthly"): + lines.append("") + lines.append(" Monthly (last 6 months):") + for m in tsla["monthly"]: + bar_len = min(int(m["profit"] / 2), 25) + bar = "█" * max(bar_len, 0) + lines.append( + f" {m['month']} " + f"{m['trades']:3} trades " + f"£{m['profit']:6.2f} {bar}" + ) + + # ══════════════════════════════════════ + # 5. MILESTONES (projections based on all the above) + # ══════════════════════════════════════ + lines.append("") + lines.append(" 5. MILESTONES (target: £1,000,000)") + lines.append(f" Current equity: £{s.equity:.2f}") + + if weekly_deposit > 0: + lines.append( + f" Weekly deposits: £{weekly_deposit:.2f}/week " + f"({deposits['pattern']})" + ) + lines.append( + f" Total rate: £{total_daily:.2f}/day " + f"(£{daily_rate:.2f} trading + £{daily_deposit:.2f} deposits)" + ) + else: + lines.append( + f" Weekly deposits: none yet — " + f"auto-detects when regular deposits start" + ) + lines.append("") + + for label, target in self.MILESTONES: + gap = target - s.equity + progress = min(s.equity / target, 1.0) + filled = int(progress * 20) + bar = "█" * filled + "░" * (20 - filled) + pct = progress * 100 + + if s.equity >= target: + lines.append(f" {label:12} [{bar}] ✓ REACHED") + else: + lines.append(f" {label:12} [{bar}] {pct:.1f}%") + if total_daily > 0: + days = gap / total_daily + years = days / 365 + if days < 365: + eta = f"~{days:.0f} days" + elif years < 2: + eta = f"~{years:.1f} years" + else: + eta = f"~{years:.0f} years" + rate_str = ( + f"£{total_daily:.2f}/day" + if weekly_deposit > 0 + else f"£{daily_rate:.2f}/day" + ) + lines.append( + f" {'':12} £{gap:,.0f} needed → {eta} at {rate_str}" + ) + + lines.append("") + lines.append("=" * 60) + lines.append("") + + for line in lines: + log.info(line) + + # ───────────────────────────────────────── + # HELPERS + # ───────────────────────────────────────── + + def _update_survival_pct(self, new_pct: float, lines: list): + """ + Automatically update SURVIVAL_DROP_PCT in config.py. + Does a safe in-place string replacement so all other + config values and comments are preserved. + """ + try: + config_path = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "config.py" + ) + with open(config_path, "r") as f: + content = f.read() + + old_line = f"SURVIVAL_DROP_PCT = {config.SURVIVAL_DROP_PCT:.1f}" + new_line = f"SURVIVAL_DROP_PCT = {new_pct:.1f}" + + if old_line not in content: + # Try without decimal + old_line = f"SURVIVAL_DROP_PCT = {int(config.SURVIVAL_DROP_PCT)}" + + if old_line in content: + new_content = content.replace(old_line, new_line, 1) + with open(config_path, "w") as f: + f.write(new_content) + # Update in-memory config so rest of this run uses new value + config.SURVIVAL_DROP_PCT = new_pct + lines.append( + f" ✓ config.py updated: SURVIVAL_DROP_PCT = {new_pct:.1f}" + ) + log.warning( + f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n" + f" ↑ SURVIVAL THRESHOLD AUTO-UPDATED: {new_pct:.0f}%\n" + f"━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + ) + else: + lines.append( + f" ⚠ Could not auto-update config.py — " + f"update manually: SURVIVAL_DROP_PCT = {new_pct:.1f}" + ) + except Exception as e: + lines.append(f" ⚠ Auto-update failed: {e}") + lines.append( + f" Update manually in config.py: " + f"SURVIVAL_DROP_PCT = {new_pct:.1f}" + ) + + def _find_max_safe_pct(self, s: BotState, all_levels: list) -> float: + """ + Find maximum drop % the account can currently survive. + + IMPORTANT: Does NOT just use current orders — simulates the FULL grid + that would exist at each drop level. In a real crash, as price falls + the bot keeps placing orders on the way down. All of those fill. + So we simulate all grid levels from current price down to the floor, + not just the 10 orders currently queued. + """ + if not s.highest_open_price: + return 0.0 + + max_safe = 0.0 + for pct in range(10, 81, 5): + floor = s.highest_open_price * (1.0 - pct / 100.0) + spacing = self.calculator.get_spacing_pct(s.equity) / 100.0 + + # Build full simulated grid from current price down to floor + # These are all the orders that would fill in a crash of this size + simulated_levels = [] + + # Existing open positions always fill + for p in s.positions: + simulated_levels.append({ + "price": pos_level(p), + "size": pos_size(p) + }) + + # Simulate all grid levels that would be placed between + # current price and the floor + level = s.current_price * (1.0 - spacing) + while level >= floor: + size = self.calculator.get_size(level, s.highest_open_price) + simulated_levels.append({"price": level, "size": size}) + level = level * (1.0 - spacing) + + result = self.calculator.survival_check( + s.equity, s.highest_open_price, + simulated_levels, s.gbpusd, + override_pct=float(pct) + ) + if result["safe"]: + max_safe = float(pct) + else: + return max_safe if pct > 10 else 0.0 + + return 80.0 # survived all tested levels + + def _next_survival_milestone(self, current_pct: float) -> float: + for m in self.SURVIVAL_MILESTONES: + if m > current_pct: + return float(m) + return 60.0 + + def _equity_for_survival(self, s: BotState, all_levels: list, + target_pct: float) -> float: + """ + Estimate equity needed to survive target_pct% drop. + Uses full simulated grid — all levels that would fill in a crash. + Formula: equity_needed = 0.5 * total_margin - total_unrealised_loss + (equity must be above 50% of margin to avoid Capital.com closeout) + """ + try: + spacing = self.calculator.get_spacing_pct(s.equity) + full_sim = self.calculator._simulate_grid( + s.highest_open_price, spacing, s.gbpusd, all_levels + ) + drop = target_pct / 100.0 + floor = s.highest_open_price * (1.0 - drop) + + total_margin = sum( + lv["price"] * lv["size"] * config.MARGIN_RATE / s.gbpusd + for lv in full_sim + ) + total_loss = sum( + (floor - lv["price"]) * lv["size"] / s.gbpusd + for lv in full_sim + ) + # equity_needed + total_loss >= 0.5 * total_margin + # equity_needed >= 0.5 * total_margin - total_loss + return max((0.5 * total_margin) - total_loss, 0) + except Exception: + return 0.0 diff --git a/client.py b/client.py new file mode 100644 index 0000000..ce7b94e --- /dev/null +++ b/client.py @@ -0,0 +1,200 @@ +""" +client.py — Capital.com REST API client. +========================================= +Handles all HTTP communication with Capital.com. +Session management, authentication, and all API calls live here. + +API NOTES: +---------- +- Authentication: POST /session with API key + credentials + Returns CST and X-SECURITY-TOKEN headers, valid for 10 minutes. +- All subsequent requests need CST + X-SECURITY-TOKEN headers. +- Rate limit: 1 request per 0.1s for order operations. +- Working orders use field names: workingOrderData.orderLevel, orderSize, dealId +- Positions use field names: position.level, position.size, position.profitLevel +- Account balance: accounts[].balance.balance (equity), .deposit (funds), .available +- Price snapshot: snapshot.bid, snapshot.offer (mid = average of both) + +FIELD NAME REFERENCE (verified against live API): +-------------------------------------------------- +Working orders response: + o["workingOrderData"]["dealId"] — order ID + o["workingOrderData"]["orderLevel"] — entry price + o["workingOrderData"]["orderSize"] — position size in shares + o["workingOrderData"]["profitDistance"] — TP distance in points + +Positions response: + p["position"]["dealId"] — position ID + p["position"]["level"] — entry price + p["position"]["size"] — position size in shares + p["position"]["profitLevel"] — take profit price (None if not set) + p["position"]["upl"] — unrealised P&L in USD + +Accounts response: + acc["balance"]["balance"] — current equity (GBP) + acc["balance"]["deposit"] — deposited funds (GBP) + acc["balance"]["available"] — available to trade (GBP) +""" + +import time +import logging +from typing import Optional +import requests +import config + +log = logging.getLogger("maxbot.client") + + +class CapitalClient: + """ + Wraps all Capital.com API calls. + Handles session creation, token refresh, and error handling. + """ + + def __init__(self): + self.base = config.DEMO_URL if config.USE_DEMO else config.BASE_URL + self.cst = None + self.security_token = None + self.session_started = None + + # ───────────────────────────────────────── + # SESSION MANAGEMENT + # ───────────────────────────────────────── + + def create_session(self): + """Authenticate and store session tokens.""" + url = f"{self.base}/api/v1/session" + headers = { + "Content-Type": "application/json", + "X-CAP-API-KEY": config.API_KEY, + } + body = { + "identifier": config.IDENTIFIER, + "password": config.PASSWORD, + } + resp = requests.post(url, headers=headers, json=body, timeout=15) + resp.raise_for_status() + self.cst = resp.headers.get("CST") + self.security_token = resp.headers.get("X-SECURITY-TOKEN") + self.session_started = time.time() + log.info("Session created successfully.") + + def refresh_session_if_needed(self): + """Refresh session if tokens are approaching expiry (10 min lifetime).""" + if self.session_started is None: + self.create_session() + return + age = time.time() - self.session_started + if age >= config.SESSION_REFRESH_SECS: + log.info("Refreshing session tokens...") + self.create_session() + + # ───────────────────────────────────────── + # HTTP HELPERS + # ───────────────────────────────────────── + + def _headers(self) -> dict: + h = { + "Content-Type": "application/json", + "X-CAP-API-KEY": config.API_KEY, + } + if self.cst: + h["CST"] = self.cst + if self.security_token: + h["X-SECURITY-TOKEN"] = self.security_token + return h + + def _request(self, method: str, path: str, body: dict = None) -> dict: + """Make an authenticated API request. Auto-retries once on 401.""" + url = f"{self.base}/api/v1{path}" + try: + resp = requests.request( + method, url, + headers=self._headers(), + json=body, + timeout=15 + ) + if resp.status_code == 401: + log.warning("401 received — refreshing session and retrying...") + self.create_session() + resp = requests.request( + method, url, + headers=self._headers(), + json=body, + timeout=15 + ) + resp.raise_for_status() + return resp.json() if resp.text else {} + except requests.RequestException as e: + log.error(f"API error: {method} {path} — {e}") + raise + + # ───────────────────────────────────────── + # DATA FETCHERS + # ───────────────────────────────────────── + + def get_positions(self) -> list: + """Return list of all open positions.""" + return self._request("GET", "/positions").get("positions", []) + + def get_working_orders(self) -> list: + """Return list of all pending working orders.""" + return self._request("GET", "/workingorders").get("workingOrders", []) + + def get_account_details(self) -> dict: + """Return account details including balance.""" + return self._request("GET", "/accounts") + + def get_price(self, epic: str) -> dict: + """Return market snapshot for an epic (bid, offer, status etc).""" + return self._request("GET", f"/markets/{epic}") + + def get_gbpusd(self) -> float: + """Fetch live GBP/USD mid rate. Falls back to config value on failure.""" + try: + data = self._request("GET", "/markets/GBPUSD") + snap = data.get("snapshot", {}) + return (float(snap["bid"]) + float(snap["offer"])) / 2.0 + except Exception: + log.warning(f"GBP/USD fetch failed — using fallback {config.GBPUSD_FALLBACK}") + return config.GBPUSD_FALLBACK + + # ───────────────────────────────────────── + # ORDER OPERATIONS + # ───────────────────────────────────────── + + def place_limit_order(self, size: float, limit_price: float, + take_profit: float) -> dict: + """ + Place a limit buy order for TSLA. + Order fills automatically when price reaches limit_price. + take_profit is the absolute price level (not distance). + """ + body = { + "epic": config.EPIC, + "direction": "BUY", + "size": size, + "level": round(limit_price, 2), + "type": "LIMIT", + "guaranteedStop": False, + "profitLevel": round(take_profit, 2), + } + return self._request("POST", "/workingorders", body) + + def cancel_order(self, deal_id: str) -> dict: + """Cancel a pending working order by deal ID.""" + return self._request("DELETE", f"/workingorders/{deal_id}") + + def update_position_tp(self, deal_id: str, + take_profit: Optional[float]) -> dict: + """ + Update the take profit on an open position. + Pass take_profit=None to remove the TP (manual close only). + Used for the bottom-two position rule: + - Lowest open position: TP removed (held until manual close) + - Second lowest: TP set to standard level + """ + body = { + "profitLevel": round(take_profit, 2) if take_profit is not None else None + } + return self._request("PUT", f"/positions/{deal_id}", body) diff --git a/config.py b/config.py new file mode 100644 index 0000000..f59b0cc --- /dev/null +++ b/config.py @@ -0,0 +1,183 @@ +""" +config.py — All bot settings in one place. +========================================== +This is the ONLY file you need to edit for day-to-day configuration. +All other files import from here. + +STRATEGY SUMMARY: +----------------- +This bot trades TSLA CFDs on Capital.com using a grid/mean-reversion strategy. +It places limit buy orders at regular intervals BELOW the current price. +Each order has a take profit set to generate a fixed GBP profit. +As equity grows, the grid tightens (more orders = more frequent fills = more profit). + +SURVIVAL RULE: +-------------- +The bot calculates whether the account can survive a X% drop from the highest +open position without being margin called. If not safe, no new orders are placed. +Start at 30% and increase as equity grows: + £640 → 30% survival (current) + £900 → 35% + £1200 → 40% + £1500 → 50% + £1800 → 60% (target) + +SIZE RULE: +---------- +0.2 shares for all orders (more profit per fill, faster recovery at lower prices). +0.1 shares only within top 5% of highest open price (expensive to hold overnight). +threshold = highest_open * 0.95 + +PROFIT TARGETS: +--------------- +0.2 shares → £1.00 profit per position (same price travel as 0.1) +0.1 shares → £0.50 profit per position (half size = half profit, same distance) +Take profit distance = profit_gbp * gbpusd / size + +QUEUE MANAGEMENT: +----------------- +Bot maintains exactly QUEUE_DEPTH pending orders covering the grid window. +Window = from just below lowest open position down to survival floor. +On each loop, bot scans for gaps in the grid and fills them. +It does NOT just add to the bottom — it fills gaps anywhere in the window. + +MARGIN STAGES (Capital.com UK retail): +--------------------------------------- +> 100% → Normal operation += 100% → Warning 1: bot stops placing new orders += 75% → Warning 2: urgent alert += 50% → Auto closeout: Capital.com closes positions automatically + +MODES: +------ +dryrun → connects, reads everything, prints what it would do. ZERO actions. +confirm → asks y/n before every single action. Start here. +live → fully autonomous. Only use after confirming behaviour in other modes. +""" + +import os + +# ───────────────────────────────────────────── +# CREDENTIALS +# Set these as environment variables, not hardcoded. +# export CAPITAL_API_KEY="your_key" +# export CAPITAL_IDENTIFIER="your@email.com" +# export CAPITAL_PASSWORD="your_password" +# ───────────────────────────────────────────── + +API_KEY = os.environ.get("CAPITAL_API_KEY", "YOUR_API_KEY_HERE") +IDENTIFIER = os.environ.get("CAPITAL_IDENTIFIER", "your@email.com") +PASSWORD = os.environ.get("CAPITAL_PASSWORD", "YOUR_PASSWORD_HERE") + +# ───────────────────────────────────────────── +# API ENDPOINTS +# ───────────────────────────────────────────── + +BASE_URL = "https://api-capital.backend-capital.com" +DEMO_URL = "https://demo-api-capital.backend-capital.com" +USE_DEMO = False # Set True to point at demo account for testing + +# ───────────────────────────────────────────── +# INSTRUMENT +# ───────────────────────────────────────────── + +EPIC = "TSLA" # Capital.com epic for Tesla Inc CFD + +# ───────────────────────────────────────────── +# GRID STRATEGY +# ───────────────────────────────────────────── + +# % spacing between grid levels. Tightens automatically as equity grows. +# At 1.5%: $414.99 → $408.77 → $402.63 → $396.59 etc. +BASE_SPACING_PCT = 1.5 + +# Number of pending orders to maintain in the grid window at all times. +# Grows with equity — see QUEUE_TIERS below. +QUEUE_DEPTH = 10 + +# Bot will not place orders if simulated X% drop from highest open position +# would result in margin closeout. Increase this as equity grows. +# Current safe value for £640 equity = 30%. Target = 60%. +SURVIVAL_DROP_PCT = 40.0 + +# Capital.com retail margin rate for shares CFDs (5:1 leverage = 20% margin) +MARGIN_RATE = 0.20 + +# Stop placing NEW orders if current margin level drops below this %. +# Capital.com warning stage 1 = 100%. We stop here before it gets worse. +MARGIN_STOP_PCT = 100.0 + +# Alert and pause if price moves more than this % between loops. +# Protects against gap-down opens (e.g. Monday open after weekend news). +GAP_ALERT_PCT = 20.0 + +# Size threshold: orders within top 5% of highest open price use 0.1 shares. +# Everything else uses 0.2 shares. +# Example: highest open = $450 → threshold = $427.50 → above = 0.1, below = 0.2 +SIZE_SMALL_THRESHOLD_PCT = 0.95 # top 5% = 0.1 shares + +# ───────────────────────────────────────────── +# PROFIT TARGETS (GBP) +# ───────────────────────────────────────────── + +TP_PROFIT_LARGE = 1.00 # £1.00 target for 0.2 share positions +TP_PROFIT_SMALL = 0.50 # £0.50 target for 0.1 share positions +# Both use the same price travel distance. 0.1 shares just earns half as much. + +# ───────────────────────────────────────────── +# GBP/USD +# ───────────────────────────────────────────── + +# Bot fetches live GBP/USD rate each loop. Falls back to this if fetch fails. +GBPUSD_FALLBACK = 1.27 + +# ───────────────────────────────────────────── +# EQUITY GROWTH TIERS +# IMPORTANT — ORDER OF OPERATIONS: +# 1. Survival check first (always) +# 2. Spacing tightens second (more frequent fills) +# 3. Queue depth increases third (only after spacing already tightened) +# Never both spacing and depth upgrade in the same calibration run. +# +# Each tier upgrade is SURVIVAL-GATED: +# Before applying an upgrade, the full grid is simulated at the new +# settings. Only if survival check still passes does the upgrade apply. +# ───────────────────────────────────────────── + +# Grid spacing tightens with equity. +# Each tier is only applied if survival check passes at new spacing. +# Spacing must tighten BEFORE queue depth can increase. +SPACING_TIERS = [ + (600, 1.5), # base — current + (800, 1.2), # tightens at £800 IF survival passes + (1100, 1.0), # tightens at £1100 IF survival passes + (1500, 0.8), # tightens at £1500 IF survival passes +] + +# Queue depth increases with equity. +# Each tier is only applied if: +# a) Spacing has already tightened to the corresponding level +# b) Survival check passes at new depth +# Depth at tier N requires spacing to be at tier N-1 already. +QUEUE_TIERS = [ + (600, 10, 1.5), # 10 orders at 1.5% spacing — base + (900, 12, 1.2), # 12 orders — requires spacing already at 1.2% + (1200, 15, 1.0), # 15 orders — requires spacing already at 1.0% + (1500, 18, 0.8), # 18 orders — requires spacing already at 0.8% +] +# Queue tier format: (equity_threshold, depth, required_spacing) + +# ───────────────────────────────────────────── +# DEPOSITS +# Set your planned weekly deposit amount here. +# Used by calibration report for milestone projections. +# Set to 0 if not making regular deposits. +# ───────────────────────────────────────────── + +WEEKLY_DEPOSIT_GBP = 0.0 # £ per week — update when you start depositing + +# Capital.com session tokens expire after 10 minutes. Refresh at 9 minutes. +SESSION_REFRESH_SECS = 540 + +# How often the main monitoring loop runs. +LOOP_INTERVAL_SECS = 60 diff --git a/fetch_history.py b/fetch_history.py new file mode 100644 index 0000000..e00e6a0 --- /dev/null +++ b/fetch_history.py @@ -0,0 +1,491 @@ +#!/usr/bin/env python3 +""" +fetch_history.py — Capital.com full transaction history fetcher. +================================================================ +Fetches ALL account transactions from Capital.com and stores them +in a local SQLite database at data/history.db. + +ENDPOINT USED: +-------------- +GET /history/transactions +Much richer than /history/activity — returns actual GBP amounts, +deposits, trades, overnight fees, all in one place. + +MODES: +------ + python3 fetch_history.py --mode dryrun # preview only, no changes + python3 fetch_history.py --mode confirm # asks before starting bulk fetch + python3 fetch_history.py --mode live # fully automatic + +FIRST RUN: +---------- +Fetches from 28 Jan 2021 (account open date) to today. +~1,900 API calls at 1/second = ~32 minutes. +Progress shown every 10 days. Safe to interrupt — resumes from +last successful day on next run. + +SUBSEQUENT RUNS: +---------------- +Only fetches since last successful fetch date. Runs in seconds. +Called automatically from calibration report on every bot startup. + +DATABASE: data/history.db +-------------------------- +Table: transactions + id TEXT PRIMARY KEY (reference field from Capital.com) + date_utc TEXT (ISO datetime) + transaction_type TEXT (TRADE, SWAP, DEPOSIT, WITHDRAWAL, + TRADE_SLIPPAGE_PROTECTION) + note TEXT (e.g. "Trade closed", "Overnight fee") + instrument TEXT (e.g. "TSLA", "US100", empty for deposits) + size_gbp REAL (GBP amount — positive=credit, negative=cost) + currency TEXT (always GBP for this account) + status TEXT (PROCESSED) + raw TEXT (full JSON for future use) + +Table: fetch_log + fetch_date TEXT PRIMARY KEY (YYYY-MM-DD) + fetched_at TEXT (when we fetched it) + record_count INTEGER (records found that day) + status TEXT (ok / error) + +FINANCIAL LOGIC: +---------------- + TRADE size → always positive (GBP profit from closed position) + SWAP size → always negative (overnight fee cost) + DEPOSIT size → always positive (money added to account) + WITHDRAWAL size → negative (money removed) + Net performance = sum(TRADE) + sum(SWAP) + Net deposited = sum(DEPOSIT) + sum(WITHDRAWAL) + Return on capital = net_performance / net_deposited * 100 + +FUTURE USE: +----------- + Calibration report → real profit/day, real fees, real deposits + ProQuant backtester → full trade history with entry/exit prices + Web dashboard → P&L charts, fee analysis, milestone tracking +""" + +import argparse +import json +import logging +import os +import sqlite3 +import sys +import time +from datetime import datetime, timedelta, date, timezone + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import config +from client import CapitalClient + +# ───────────────────────────────────────────── +# CONSTANTS +# ───────────────────────────────────────────── + +ACCOUNT_OPEN_DATE = date(2021, 1, 28) +DB_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "data", "history.db" +) +LOG_PATH = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "logs", "fetch_history.log" +) +FETCH_DELAY_SECS = 1.1 # slightly over 1s to respect rate limits + + +# ───────────────────────────────────────────── +# LOGGING +# ───────────────────────────────────────────── + +def setup_logging(): + os.makedirs(os.path.dirname(LOG_PATH), exist_ok=True) + fmt = "%(asctime)s [%(levelname)s] %(message)s" + logging.basicConfig( + level=logging.INFO, + format=fmt, + handlers=[ + logging.FileHandler(LOG_PATH), + logging.StreamHandler(sys.stdout), + ] + ) + +log = logging.getLogger("fetch_history") + + +# ───────────────────────────────────────────── +# DATABASE +# ───────────────────────────────────────────── + +class HistoryDB: + + def __init__(self, db_path: str): + os.makedirs(os.path.dirname(db_path), exist_ok=True) + self.conn = sqlite3.connect(db_path) + self.conn.row_factory = sqlite3.Row + self._create_tables() + + def _create_tables(self): + self.conn.executescript(""" + CREATE TABLE IF NOT EXISTS transactions ( + id TEXT PRIMARY KEY, + date_utc TEXT NOT NULL, + transaction_type TEXT, + note TEXT, + instrument TEXT, + size_gbp REAL, + currency TEXT, + status TEXT, + raw TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_tx_date + ON transactions (date_utc); + CREATE INDEX IF NOT EXISTS idx_tx_type + ON transactions (transaction_type); + CREATE INDEX IF NOT EXISTS idx_tx_instrument + ON transactions (instrument); + + CREATE TABLE IF NOT EXISTS fetch_log ( + fetch_date TEXT PRIMARY KEY, + fetched_at TEXT NOT NULL, + record_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'ok' + ); + """) + self.conn.commit() + + def date_already_fetched(self, fetch_date: date) -> bool: + row = self.conn.execute( + "SELECT status FROM fetch_log WHERE fetch_date = ? AND status = 'ok'", + (fetch_date.isoformat(),) + ).fetchone() + return row is not None + + def get_last_fetched_date(self): + row = self.conn.execute( + "SELECT MAX(fetch_date) as d FROM fetch_log WHERE status = 'ok'" + ).fetchone() + if row and row["d"]: + return date.fromisoformat(row["d"]) + return None + + def save_transactions(self, transactions: list) -> int: + """Insert transaction records. Returns number actually inserted.""" + inserted = 0 + for t in transactions: + try: + ref = t.get("reference", "") + # Build a unique ID from reference + date to avoid collisions + tx_id = f"{ref}_{t.get('dateUtc', t.get('date', ''))}" + + self.conn.execute(""" + INSERT OR IGNORE INTO transactions + (id, date_utc, transaction_type, note, instrument, + size_gbp, currency, status, raw) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, ( + tx_id, + t.get("dateUtc", t.get("date", "")), + t.get("transactionType", ""), + t.get("note", ""), + t.get("instrumentName", ""), + float(t.get("size", 0)), + t.get("currency", "GBP"), + t.get("status", ""), + json.dumps(t), + )) + if self.conn.execute("SELECT changes()").fetchone()[0] > 0: + inserted += 1 + except Exception as e: + log.warning(f"Could not insert transaction: {e} — {t}") + + self.conn.commit() + return inserted + + def log_fetch(self, fetch_date: date, record_count: int, status: str = "ok"): + self.conn.execute(""" + INSERT OR REPLACE INTO fetch_log (fetch_date, fetched_at, record_count, status) + VALUES (?, ?, ?, ?) + """, ( + fetch_date.isoformat(), + datetime.now(tz=timezone.utc).isoformat(), + record_count, + status, + )) + self.conn.commit() + + def get_stats(self) -> dict: + """Return summary statistics from the database.""" + def scalar(sql, default=0): + row = self.conn.execute(sql).fetchone() + return row[0] if row and row[0] is not None else default + + total = scalar("SELECT COUNT(*) FROM transactions") + trades = scalar("SELECT COUNT(*) FROM transactions WHERE transaction_type='TRADE'") + swaps = scalar("SELECT COUNT(*) FROM transactions WHERE transaction_type='SWAP'") + deposits = scalar("SELECT COUNT(*) FROM transactions WHERE transaction_type='DEPOSIT'") + other = total - trades - swaps - deposits + fetched_days = scalar("SELECT COUNT(*) FROM fetch_log WHERE status='ok'") + first_date = scalar("SELECT MIN(date_utc) FROM transactions", None) + last_date = scalar("SELECT MAX(date_utc) FROM transactions", None) + + trade_profit = scalar("SELECT SUM(size_gbp) FROM transactions WHERE transaction_type='TRADE'") + swap_fees = scalar("SELECT SUM(size_gbp) FROM transactions WHERE transaction_type='SWAP'") + total_deposits = scalar("SELECT SUM(size_gbp) FROM transactions WHERE transaction_type='DEPOSIT'") + slippage = scalar("SELECT SUM(size_gbp) FROM transactions WHERE transaction_type='TRADE_SLIPPAGE_PROTECTION'") + + # Per-instrument breakdown + instruments = {} + for row in self.conn.execute(""" + SELECT instrument, COUNT(*) as c, SUM(size_gbp) as total + FROM transactions + WHERE transaction_type='TRADE' AND instrument != '' + GROUP BY instrument + ORDER BY total DESC + """): + instruments[row["instrument"]] = { + "trades": row["c"], + "profit": row["total"], + } + + # Daily profit average (only days with trades) + daily_avg_row = self.conn.execute(""" + SELECT AVG(daily_total) FROM ( + SELECT DATE(date_utc) as d, SUM(size_gbp) as daily_total + FROM transactions + WHERE transaction_type='TRADE' + GROUP BY DATE(date_utc) + HAVING daily_total > 0 + ) + """).fetchone() + daily_avg = daily_avg_row[0] if daily_avg_row and daily_avg_row[0] else 0 + + return { + "total_records": total, + "trades": trades, + "swaps": swaps, + "deposits": deposits, + "other": other, + "fetched_days": fetched_days, + "first_date": first_date, + "last_date": last_date, + "trade_profit": trade_profit, + "swap_fees": swap_fees, + "total_deposits": total_deposits, + "slippage_refunds": slippage, + "net_performance": trade_profit + swap_fees + slippage, + "instruments": instruments, + "daily_avg_profit": daily_avg, + } + + def close(self): + self.conn.close() + + +# ───────────────────────────────────────────── +# FETCHER +# ───────────────────────────────────────────── + +class HistoryFetcher: + + def __init__(self, mode: str): + self.mode = mode + self.client = CapitalClient() + self.db = HistoryDB(DB_PATH) + + def fetch_day(self, fetch_date: date): + """ + Fetch all transactions for a single day. + Returns list of records, or None on API error. + Capital.com max date range = 1 day. + """ + from_dt = f"{fetch_date.isoformat()}T00:00:00" + to_dt = f"{fetch_date.isoformat()}T23:59:59" + try: + data = self.client._request( + "GET", + f"/history/transactions?from={from_dt}&to={to_dt}" + ) + return data.get("transactions", []) + except Exception as e: + log.error(f"Failed to fetch {fetch_date}: {e}") + return None + + def print_stats(self): + """Print current database statistics.""" + stats = self.db.get_stats() + log.info("") + log.info(" DATABASE SUMMARY") + log.info(f" Total records: {stats['total_records']}") + log.info(f" Closed trades: {stats['trades']}") + log.info(f" Overnight fees: {stats['swaps']}") + log.info(f" Deposits: {stats['deposits']}") + log.info(f" Other: {stats['other']}") + log.info(f" Days fetched: {stats['fetched_days']}") + if stats['first_date']: + log.info(f" Date range: {stats['first_date'][:10]} → {stats['last_date'][:10]}") + log.info("") + log.info(" FINANCIAL SUMMARY") + log.info(f" Total deposited: £{stats['total_deposits']:.2f}") + log.info(f" Trade profit: £{stats['trade_profit']:.2f}") + log.info(f" Overnight fees: £{stats['swap_fees']:.2f}") + log.info(f" Slippage refunds: £{stats['slippage_refunds']:.2f}") + log.info(f" Net performance: £{stats['net_performance']:.2f}") + if stats['total_deposits'] > 0: + roi = stats['net_performance'] / stats['total_deposits'] * 100 + log.info(f" Return on capital: {roi:.1f}%") + if stats['daily_avg_profit'] > 0: + log.info(f" Avg profit/day: £{stats['daily_avg_profit']:.2f} (days with trades)") + log.info("") + log.info(" BY INSTRUMENT") + for instrument, data in stats['instruments'].items(): + log.info(f" {instrument:12} {data['trades']:4} trades £{data['profit']:.2f}") + + def run(self): + log.info("=" * 60) + log.info(f" History Fetcher — {self.mode.upper()} MODE") + log.info(f" Started: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + log.info("=" * 60) + + log.info("Connecting to Capital.com...") + try: + self.client.create_session() + except Exception as e: + log.error(f"Connection failed: {e}") + sys.exit(1) + log.info("Connected.") + + # Determine start date + today = date.today() + last = self.db.get_last_fetched_date() + if last: + start = last + timedelta(days=1) + log.info(f"Resuming from {start} (last fetched: {last})") + else: + start = ACCOUNT_OPEN_DATE + log.info(f"First run — fetching from {start}") + + total_days = max((today - start).days + 1, 0) + estimated_mins = total_days * FETCH_DELAY_SECS / 60 + + # Show current DB state + existing_stats = self.db.get_stats() + if existing_stats["total_records"] > 0: + log.info( + f"Database has {existing_stats['total_records']} records " + f"({existing_stats['fetched_days']} days already fetched)" + ) + + log.info(f"Days to fetch: {total_days}") + log.info(f"Est. time: {estimated_mins:.0f} minutes") + + if self.mode == "dryrun": + log.info("\n[DRY RUN] No data will be fetched or stored.") + self.print_stats() + self.db.close() + return + + if total_days == 0: + log.info("Already up to date.") + self.print_stats() + self.db.close() + return + + # Confirm in confirm mode + if self.mode == "confirm": + print(f"\n Fetch {total_days} days from {start} to {today}") + print(f" Estimated time: {estimated_mins:.0f} minutes") + answer = input(" Start? (y/n): ").strip().lower() + if answer != "y": + log.info("Aborted.") + self.db.close() + return + + # ── Main fetch loop ── + current = start + total_inserted = 0 + days_done = 0 + days_empty = 0 + + log.info("\nFetching...") + + while current <= today: + # Skip already fetched + if self.db.date_already_fetched(current): + current += timedelta(days=1) + continue + + # Progress every 10 days + if days_done % 10 == 0 and days_done > 0: + pct = (days_done / max(total_days, 1)) * 100 + log.info( + f" {days_done}/{total_days} days ({pct:.0f}%) — " + f"{total_inserted} records inserted" + ) + + transactions = self.fetch_day(current) + + if transactions is None: + self.db.log_fetch(current, 0, "error") + current += timedelta(days=1) + days_done += 1 + time.sleep(FETCH_DELAY_SECS) + continue + + count = len(transactions) + if count > 0: + inserted = self.db.save_transactions(transactions) + total_inserted += inserted + else: + days_empty += 1 + + self.db.log_fetch(current, count) + days_done += 1 + current += timedelta(days=1) + time.sleep(FETCH_DELAY_SECS) + + # ── Final summary ── + log.info("\n" + "=" * 60) + log.info(" FETCH COMPLETE") + log.info("=" * 60) + log.info(f" Days fetched: {days_done}") + log.info(f" Days empty: {days_empty}") + log.info(f" Records added: {total_inserted}") + self.print_stats() + self.db.close() + + +# ───────────────────────────────────────────── +# ENTRY POINT +# ───────────────────────────────────────────── + +def main(): + parser = argparse.ArgumentParser( + description="Capital.com Transaction History Fetcher", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Modes: + dryrun Show current DB state. No API calls beyond login. No writes. + confirm Ask before starting bulk fetch. Use for first run. + live Fully automatic. Use for incremental/scheduled runs. + +To start fresh (recommended after switching from old activity endpoint): + rm ~/maxbot/data/history.db + python3 fetch_history.py --mode confirm + +Database: data/history.db +Log: logs/fetch_history.log + """ + ) + parser.add_argument( + "--mode", + choices=["dryrun", "confirm", "live"], + default="dryrun", + ) + args = parser.parse_args() + setup_logging() + HistoryFetcher(mode=args.mode).run() + + +if __name__ == "__main__": + main() diff --git a/grid.py b/grid.py new file mode 100644 index 0000000..5503c2b --- /dev/null +++ b/grid.py @@ -0,0 +1,334 @@ +""" +grid.py — Core grid management logic. +====================================== +This is the brain of the bot. Every loop it: + 1. Manages the bottom two open positions (TP rules) + 2. Audits the order grid for gaps and fills them + 3. Cancels orders that fall outside the active window + +THE GAP-FILLING FIX (key improvement over v1): +----------------------------------------------- +Old behaviour: bot anchored from lowest existing order and added to bottom. +Problem: if top order filled or was cancelled, gap was never filled at top. +Result: grid drifted downward over time, missing fills near current price. + +New behaviour: + 1. Calculate ALL expected grid levels from top (below lowest position) + down to bottom (survival floor). + 2. Compare expected levels against ALL existing orders. + 3. Place orders for any missing level, anywhere in the window. + 4. Result: gaps are filled wherever they occur, not just at the bottom. + +BOTTOM TWO POSITION RULE: +-------------------------- +Lowest open position → TP DISABLED (hold until manual close) +Second lowest → TP ENABLED at standard level + +Rationale: the lowest position represents a deep dip buy. +We don't know when price will recover to take profit from there. +Holding it open means maximum profit on recovery. +When a new even-lower position opens, the previous lowest gets its TP +re-enabled because recovery to that level is now more likely. + +ORDER WINDOW: +------------- +Top of window = just below lowest open position entry price +Bottom of window = highest_open * (1 - survival_drop_pct / 100) +Orders outside this window are cancelled. +Orders above window_top are too close to open positions (confusing). +Orders below floor are beyond our risk tolerance. +""" + +import logging +import time +from state import BotState, pos_level, pos_size, pos_id, pos_tp, ord_level, ord_id +from calculator import GridCalculator +from actions import ActionHandler +import config + +log = logging.getLogger("maxbot.grid") + + +class GridManager: + + def __init__(self, state: BotState, calculator: GridCalculator, + actions: ActionHandler): + self.state = state + self.calculator = calc = calculator + self.actions = actions + # Track deal IDs we have already attempted to disable TP on. + # Capital.com may not support removing profitLevel via API, + # so we avoid spamming the same call every loop. + self._tp_disabled_ids: set = set() + + # ───────────────────────────────────────── + # BOTTOM TWO POSITIONS + # ───────────────────────────────────────── + + def manage_bottom_two_positions(self): + """ + Enforce TP rules on the two lowest open positions. + Only activates when price has dropped 25%+ from highest open position. + + RATIONALE: + If price is near the top (within 25% of highest open), positions are + likely to recover quickly and the standard TP will close them normally. + The lowest/second-lowest TP rule is only meaningful when price has + dropped significantly and we want to hold the lowest position for + maximum recovery rather than a small TP. + """ + s = self.state + sorted_pos = s.positions_sorted_asc() + + if not sorted_pos: + log.info("No open positions.") + return + + # ── Check 25% drop threshold ── + threshold_price = s.highest_open_price * 0.75 + if s.current_price > threshold_price: + log.info( + f"Price ${s.current_price:.2f} is within 25% of highest open " + f"${s.highest_open_price:.2f} (threshold ${threshold_price:.2f}) " + f"— TP rules not active." + ) + return + + # ── Lowest position: TP must be DISABLED ── + lowest = sorted_pos[0] + l_id = pos_id(lowest) + l_price = pos_level(lowest) + l_tp = pos_tp(lowest) + + if l_id in self._tp_disabled_ids: + log.info(f"Lowest position ${l_price:.2f} — TP disable already attempted. ✓") + elif l_tp is not None: + log.info( + f"Lowest position ${l_price:.2f} has TP ${l_tp:.2f} — disabling." + ) + self.actions.disable_tp(l_id, l_price) + self._tp_disabled_ids.add(l_id) + else: + log.info(f"Lowest position ${l_price:.2f} — TP disabled. ✓") + self._tp_disabled_ids.add(l_id) + + # Clear IDs that are no longer the lowest position + # (position closed or new lower opened) + current_ids = {pos_id(p) for p in sorted_pos} + self._tp_disabled_ids &= current_ids + + # ── Second lowest position: TP must be ENABLED ── + if len(sorted_pos) < 2: + return + + second = sorted_pos[1] + s_id = pos_id(second) + s_price = pos_level(second) + s_tp = pos_tp(second) + s_size = pos_size(second) + + expected_tp = self.calculator.get_tp_price(s_price, s_size, s.gbpusd) + + if s_tp is None: + log.info( + f"Second lowest ${s_price:.2f} has no TP — " + f"enabling at ${expected_tp:.2f}." + ) + self.actions.enable_tp(s_id, s_price, expected_tp) + else: + log.info( + f"Second lowest ${s_price:.2f} — TP at ${s_tp:.2f}. ✓" + ) + + # ───────────────────────────────────────── + # ORDER GRID MANAGEMENT + # ───────────────────────────────────────── + + def manage_orders(self): + """ + Main order management function. Called every loop. + + NORMAL MODE (price within 25% of highest open): + - Anchor = lowest open position + - Grid fills below anchor down to floor + - depth orders maintained + + DIP MODE (price dropped 25%+ from highest open): + - Lowest open position has TP removed (manual hold) — ignored as anchor + - Anchor = second lowest open position (lowest with TP) + - Grid fills below anchor down to floor + - ALSO fills gaps between no-TP position and anchor if spacing fits + - No orders placed at or above anchor + - No orders placed at or above the no-TP position level + + Grid floor is always highest_open * 0.40 (60% drop), fixed. + """ + s = self.state + calc = self.calculator + equity = s.equity + + # Get survival-gated spacing and depth + # Spacing first, then depth (depth requires spacing already tightened) + spacing_pct = calc.get_spacing_pct( + equity, s.highest_open_price, s.all_levels(), s.gbpusd + ) + depth = calc.get_queue_depth( + equity, spacing_pct, s.highest_open_price, + s.all_levels(), s.gbpusd + ) + + GRID_FLOOR_PCT = 60.0 + floor = s.highest_open_price * (1.0 - GRID_FLOOR_PCT / 100.0) + + sorted_pos = s.positions_sorted_asc() + open_prices = [pos_level(p) for p in sorted_pos] + + # ── Determine mode and anchor ── + dip_threshold = s.highest_open_price * 0.75 # 25% drop + in_dip_mode = s.current_price <= dip_threshold + no_tp_price = None # price of the no-TP (manual hold) position + + if not open_prices: + anchor = s.current_price + log.info(f"No open positions — anchoring to current price ${anchor:.2f}") + elif in_dip_mode and len(sorted_pos) >= 2: + # Dip mode: anchor to second lowest (lowest with TP) + no_tp_price = pos_level(sorted_pos[0]) # lowest — no TP + anchor = pos_level(sorted_pos[1]) # second lowest — has TP + log.info( + f"DIP MODE — anchor: ${anchor:.2f} (second lowest, has TP) | " + f"no-TP position: ${no_tp_price:.2f} (manual hold)" + ) + else: + # Normal mode: anchor to lowest open position + anchor = min(open_prices) + log.info(f"NORMAL MODE — anchor: ${anchor:.2f} (lowest open position)") + + log.info( + f"Floor: ${floor:.2f} | depth: {depth} | " + f"spacing: {spacing_pct}% (survival-gated)" + ) + + # ── Generate target slots ── + # Main grid: below anchor down to floor + main_levels = calc.generate_grid(anchor, floor, equity, spacing_pct) + if not main_levels: + log.info("No grid levels fit within current window.") + return + + # In dip mode: also generate levels in the gap between + # no-TP position and anchor (if gap is large enough) + gap_levels = [] + if in_dip_mode and no_tp_price is not None: + spacing = spacing_pct / 100.0 + # Generate levels from just below anchor down to just above no-TP + level = anchor * (1.0 - spacing) + while level > no_tp_price * (1.0 + spacing): + gap_levels.append(round(level, 2)) + level = level * (1.0 - spacing) + if gap_levels: + log.info( + f"Gap levels between ${no_tp_price:.2f} and " + f"${anchor:.2f}: {[f'${l:.2f}' for l in gap_levels]}" + ) + + # Combined target: gap levels + main grid, capped at depth + all_target_levels = (gap_levels + main_levels) + target_slots = all_target_levels[:depth] + + if target_slots: + log.info( + f"Target slots: ${target_slots[-1]:.2f} → ${target_slots[0]:.2f}" + ) + + # ── Scan existing orders — cancel any not matching targets ── + current_orders = {round(ord_level(o), 2): o for o in s.orders} + existing_prices = set(current_orders.keys()) + orders_to_cancel = [] + + for price in sorted(existing_prices): + # Never keep orders at or above anchor + if price >= anchor: + orders_to_cancel.append(price) + continue + # In dip mode: never keep orders at or above no-TP position + if no_tp_price and price >= no_tp_price: + orders_to_cancel.append(price) + continue + # Cancel if not matching any target slot + matches = any( + abs(price - slot) / slot < 0.005 + for slot in target_slots + ) + if not matches: + orders_to_cancel.append(price) + + if orders_to_cancel: + log.info( + f"Cancelling {len(orders_to_cancel)} order(s) " + f"not matching grid: " + f"{[f'${p:.2f}' for p in orders_to_cancel]}" + ) + for price in orders_to_cancel: + oid = ord_id(current_orders[price]) + self.actions.cancel_order(oid, price) + existing_prices.discard(price) + time.sleep(0.15) + + # ── Find and fill gaps ── + missing = calc.find_missing_levels( + target_slots, existing_prices, depth + ) + + if not missing: + log.info( + f"Grid healthy — {len(existing_prices)}/{depth} " + f"orders in place." + ) + return + + log.info( + f"Filling {len(missing)} gap(s): " + f"{[f'${p:.2f}' for p in missing]}" + ) + + for target_price in missing: + # Safety: never place at or above current price + if target_price >= s.current_price: + log.info(f"Skipping ${target_price:.2f} — at/above current price.") + continue + + # Safety: never place at or above anchor + if target_price >= anchor: + log.info(f"Skipping ${target_price:.2f} — at/above anchor ${anchor:.2f}.") + continue + + # Safety: never place at or above no-TP position in dip mode + if no_tp_price and target_price >= no_tp_price: + log.info(f"Skipping ${target_price:.2f} — at/above no-TP position.") + continue + + # Survival check — simulate FULL grid at 60% drop + # (not just current orders — honest worst case) + full_sim = calc._simulate_grid( + s.highest_open_price, spacing_pct, s.gbpusd, + s.all_levels() + ) + survival = calc.survival_check( + s.equity, s.highest_open_price, + full_sim, s.gbpusd, + override_pct=60.0 + ) + if not survival["safe"]: + log.warning( + f"Survival check failed — stopping. " + f"Margin at floor: {survival['margin_level_at_floor_pct']:.1f}%" + ) + break + + size = calc.get_size(target_price, s.highest_open_price) + tp_price = calc.get_tp_price(target_price, size, s.gbpusd) + + self.actions.place_limit_order(size, target_price, tp_price) + existing_prices.add(target_price) + time.sleep(0.15) diff --git a/state.py b/state.py new file mode 100644 index 0000000..fd186ab --- /dev/null +++ b/state.py @@ -0,0 +1,153 @@ +""" +state.py — Account state and position/order field helpers. +=========================================================== +Holds the current snapshot of the account, positions, and orders. +Updated every loop from the API. All other modules read from here. + +FIELD ACCESS HELPERS: +--------------------- +Capital.com uses nested dicts with non-obvious field names. +Rather than repeating o["workingOrderData"]["orderLevel"] everywhere, +use the helper functions at the bottom of this file: + pos_level(p) → float entry price of a position + pos_size(p) → float size in shares + pos_id(p) → deal ID string + pos_tp(p) → take profit price (float) or None if not set + pos_upl(p) → unrealised P&L in USD (float) + ord_level(o) → float entry price of a working order + ord_size(o) → float size in shares + ord_id(o) → deal ID string +""" + +import logging +import config + +log = logging.getLogger("maxbot.state") + + +# ───────────────────────────────────────────── +# FIELD ACCESS HELPERS +# Use these everywhere instead of raw dict access. +# If Capital.com ever changes field names, fix it here only. +# ───────────────────────────────────────────── + +def pos_level(p) -> float: + return float(p["position"]["level"]) + +def pos_size(p) -> float: + return float(p["position"]["size"]) + +def pos_id(p) -> str: + return p["position"]["dealId"] + +def pos_tp(p): + """Returns take profit price as float, or None if not set.""" + tp = p["position"].get("profitLevel") + return float(tp) if tp is not None else None + +def pos_upl(p) -> float: + """Unrealised P&L in USD.""" + return float(p["position"].get("upl", 0)) + +def ord_level(o) -> float: + return float(o["workingOrderData"]["orderLevel"]) + +def ord_size(o) -> float: + return float(o["workingOrderData"]["orderSize"]) + +def ord_id(o) -> str: + return o["workingOrderData"]["dealId"] + + +# ───────────────────────────────────────────── +# BOT STATE +# ───────────────────────────────────────────── + +class BotState: + """ + Snapshot of the account updated every loop. + All values are recalculated from scratch each time fetch() is called. + """ + + def __init__(self): + self.positions: list = [] + self.orders: list = [] + self.equity: float = 0.0 # running equity in GBP + self.funds: float = 0.0 # deposited funds in GBP + self.available: float = 0.0 # available to trade in GBP + self.current_price: float = 0.0 # TSLA mid price in USD + self.gbpusd: float = config.GBPUSD_FALLBACK + self.highest_open_price: float = 0.0 # highest open position entry + self.lowest_level_price: float = 0.0 # lowest of positions OR orders + self.total_margin_gbp: float = 0.0 # estimated margin in use + self.margin_level_pct: float = 999.0 + self.last_price: float = None # previous loop price (gap detection) + + def update(self, positions, orders, account, price_data, gbpusd): + """Recalculate all state from fresh API data.""" + self.positions = positions + self.orders = orders + self.gbpusd = gbpusd + + # Account balance + for acc in account.get("accounts", []): + if acc.get("preferred"): + bal = acc.get("balance", {}) + self.equity = float(bal.get("balance", 0)) + self.funds = float(bal.get("deposit", 0)) + self.available = float(bal.get("available", 0)) + break + + # Current TSLA mid price + snap = price_data.get("snapshot", {}) + self.current_price = ( + float(snap.get("bid", 0)) + + float(snap.get("offer", 0)) + ) / 2.0 + + # Highest open position entry price + open_prices = [pos_level(p) for p in positions] + self.highest_open_price = ( + max(open_prices) if open_prices else self.current_price + ) + + # Lowest level across all positions and orders + order_prices = [ord_level(o) for o in orders] + all_prices = open_prices + order_prices + self.lowest_level_price = ( + min(all_prices) if all_prices else self.current_price + ) + + # Approximate total margin in use (GBP) + # Formula: entry_price * size * margin_rate / gbpusd + self.total_margin_gbp = sum( + pos_level(p) * pos_size(p) * config.MARGIN_RATE / gbpusd + for p in positions + ) + + # Margin level % = equity / margin * 100 + self.margin_level_pct = ( + (self.equity / self.total_margin_gbp * 100) + if self.total_margin_gbp > 0 else 999.0 + ) + + def positions_sorted_asc(self) -> list: + """Open positions sorted by entry price, lowest first.""" + return sorted(self.positions, key=pos_level) + + def orders_sorted_asc(self) -> list: + """Pending orders sorted by level, lowest first.""" + return sorted(self.orders, key=ord_level) + + def all_levels(self) -> list: + """ + Combined list of all open positions and pending orders + as dicts with 'price' and 'size' keys. + Used by the survival check calculator. + """ + levels = [] + for p in self.positions: + levels.append({"price": pos_level(p), "size": pos_size(p)}) + for o in self.orders: + levels.append({"price": ord_level(o), "size": ord_size(o)}) + return levels