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