Files
maxbot/calculator.py
T
george 2be8b491d0 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
2026-05-27 07:02:58 +01:00

326 lines
14 KiB
Python

"""
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,
}