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:
+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,
|
||||
}
|
||||
Reference in New Issue
Block a user