326 lines
14 KiB
Python
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,
|
||
|
|
}
|