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:
+22
@@ -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
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user