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:
@@ -0,0 +1,334 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user