""" 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)