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
This commit is contained in:
2026-05-27 07:02:58 +01:00
commit 2be8b491d0
12 changed files with 3447 additions and 0 deletions
+22
View File
@@ -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
+240
View File
@@ -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
+203
View File
@@ -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
+119
View File
@@ -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"
)
+490
View File
@@ -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()
+325
View File
@@ -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,
}
+687
View File
@@ -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
+200
View File
@@ -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)
+183
View File
@@ -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
+491
View File
@@ -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()
+334
View File
@@ -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)
+153
View File
@@ -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