"""
Advanced Crypto Bot Suite — Master Bot
=======================================
Includes:
  1. Grid Bot (XRP + ADA) — tight ranges, auto-compounding profits
  2. Trailing Stop Loss — protects profits during crashes
  3. Rebalancing Bot — keeps portfolio allocations on target
  4. Momentum Bot — rides strong price moves

Notifications handled automatically by Kraken.
All activity logged to master_bot.log

SETUP:
  pip install krakenex requests
  Fill in config_advanced.py
  Run: python3 master_bot.py
"""

import krakenex
import time
import json
import logging
import os
import threading
from datetime import datetime
from config_advanced import (
    API_KEY, API_SECRET, DRY_RUN, CHECK_INTERVAL_SECONDS,
    XRP_GRID_CONFIG, ADA_GRID_CONFIG, SOL_GRID_CONFIG, DOGE_GRID_CONFIG,
    TRAILING_STOP_CONFIG, REBALANCE_CONFIG, ASSET_PAIRS,
    MOMENTUM_POSITION_USD,
)

# ── Logging ───────────────────────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("master_bot.log"),
        logging.StreamHandler()
    ]
)
log = logging.getLogger(__name__)

api = krakenex.API(key=API_KEY, secret=API_SECRET)

# ── Shared State ──────────────────────────────────────────────────────────────
portfolio_state = {
    "XRP": {"balance": 0.0, "avg_buy": 0.0, "invested": 0.0},
    "ADA": {"balance": 0.0, "avg_buy": 0.0, "invested": 0.0},
    "USD": {"balance": 0.0},
}
total_profit = {"XRP": 0.0, "ADA": 0.0, "SOL": 0.0, "DOGE": 0.0, "total": 0.0}
trade_history = []


# ── Mac Desktop Notifications ─────────────────────────────────────────────────
def mac_notify(title, message="", is_error=False):
    try:
        icon = "ERROR" if is_error else "Bot"
        msg = str(message).replace("'","").replace('"','')[:100]
        t = str(title).replace("'","").replace('"','')[:60]
        os.system(f"osascript -e 'display notification \"{msg}\" with title \"Crypto {icon}: {t}\"'")
    except Exception:
        pass

def alert(subject, body="", sms=False, error=False):
    log.info(f"ALERT: {subject}")
    if body:
        log.info(f"   {body}")
    mac_notify(subject, body, is_error=error)

# ── Bot Control Panel ─────────────────────────────────────────────────────────
CONTROL_FILE = "bot_control.json"

def read_control() -> dict:
    """Read control flags from bot_control.json (written by the control panel UI)."""
    defaults = {"grid_enabled": False, "trailing_enabled": True, 
                "momentum_enabled": True, "rebalance_enabled": True}
    try:
        if os.path.exists(CONTROL_FILE):
            with open(CONTROL_FILE) as f:
                data = json.load(f)
                defaults.update(data)
    except Exception:
        pass
    return defaults

def write_control(flags: dict):
    """Write current control state back to file."""
    try:
        with open(CONTROL_FILE, "w") as f:
            json.dump(flags, f, indent=2)
    except Exception:
        pass




# ── Kraken Helpers ────────────────────────────────────────────────────────────

def get_price(pair: str) -> float:
    resp = api.query_public("Ticker", {"pair": pair})
    if resp.get("error"):
        raise RuntimeError(f"Ticker error: {resp['error']}")
    data = list(resp["result"].values())[0]
    return (float(data["b"][0]) + float(data["a"][0])) / 2


def get_balances() -> dict:
    resp = api.query_private("Balance")
    if resp.get("error"):
        raise RuntimeError(f"Balance error: {resp['error']}")
    return {k: float(v) for k, v in resp["result"].items()}


def place_order(side: str, pair: str, volume: float, price: float) -> str:
    if DRY_RUN:
        oid = f"DRY-{side.upper()}-{price:.4f}-{datetime.now().strftime('%H%M%S%f')}"
        log.info(f"[DRY RUN] {side.upper()} {volume:.6f} {pair} @ ${price:.4f} → {oid}")
        return oid
    resp = api.query_private("AddOrder", {
        "pair": pair,
        "type": side,
        "ordertype": "limit",
        "price": str(round(price, 5)),
        "volume": str(round(volume, 6)),
    })
    if resp.get("error"):
        raise RuntimeError(f"Order error: {resp['error']}")
    oid = resp["result"]["txid"][0]
    log.info(f"ORDER: {side.upper()} {volume:.6f} {pair} @ ${price:.4f} → {oid}")
    return oid


def cancel_order(oid: str):
    if DRY_RUN:
        return
    api.query_private("CancelOrder", {"txid": oid})


def save_trade(trade: dict):
    trade_history.append(trade)
    path = "trades_advanced.json"
    existing = []
    if os.path.exists(path):
        with open(path) as f:
            try:
                existing = json.load(f)
            except Exception:
                existing = []
    existing.append(trade)
    with open(path, "w") as f:
        json.dump(existing, f, indent=2)


# ── 1. GRID BOT ───────────────────────────────────────────────────────────────

class GridBot:
    def __init__(self, config: dict):
        self.name = config["name"]
        self.pair = config["pair"]
        self.asset = config["asset"]
        self.lower = config["lower"]
        self.upper = config["upper"]
        self.levels = config["levels"]
        self.investment = config["investment"]
        self.compound = config.get("compound", True)

        self.step = round((self.upper - self.lower) / self.levels, 5)
        self.grid_prices = [round(self.lower + i * self.step, 5) for i in range(self.levels + 1)]
        self.order_size = self.investment / self.levels

        self.active_orders = {}
        self.total_profit = 0.0
        self.trade_count = 0
        self.compounded = 0.0

        log.info(f"[{self.name}] Grid initialized | Range: ${self.lower}–${self.upper} | Step: ${self.step} | Per grid: ${self.order_size:.2f}")

    def volume_for(self, price: float) -> float:
        return self.order_size / price

    def initialize(self, current_price: float):
        log.info(f"[{self.name}] Placing BUY orders below ${current_price:.4f}")
        placed = 0
        for price in self.grid_prices:
            if price < current_price and abs(price - current_price) > self.step * 0.1:
                oid = place_order("buy", self.pair, self.volume_for(price), price)
                self.active_orders[price] = oid
                placed += 1
                time.sleep(0.3)
        log.info(f"[{self.name}] {placed} BUY orders placed. Waiting for fills...")
        alert(
            f"{self.name} Started",
            f"Grid bot started on {self.pair}\nRange: ${self.lower}–${self.upper}\n{placed} orders placed"
        )

    def handle_buy_fill(self, buy_price: float):
        log.info(f"[{self.name}] ✅ BUY filled @ ${buy_price:.4f}")
        del self.active_orders[buy_price]
        sell_price = round(buy_price + self.step, 5)
        if sell_price <= self.upper:
            vol = self.volume_for(sell_price)
            oid = place_order("sell", self.pair, vol, sell_price)
            self.active_orders[sell_price] = oid

    def handle_sell_fill(self, sell_price: float):
        buy_price = round(sell_price - self.step, 5)
        vol = self.volume_for(sell_price)
        gross = (sell_price - buy_price) * vol
        fees = (sell_price * vol * 0.0025) + (buy_price * vol * 0.0025)
        net = gross - fees

        self.total_profit += net
        self.trade_count += 1
        total_profit[self.asset] = total_profit.get(self.asset, 0) + net
        total_profit["total"] += net

        log.info(f"[{self.name}] 💰 SELL filled @ ${sell_price:.4f} | Net profit: ${net:.4f} | Total: ${self.total_profit:.4f}")

        # Compound profits — reinvest into order size
        if self.compound and net > 0:
            self.compounded += net
            if self.compounded >= self.order_size * 0.1:
                self.order_size += self.compounded * 0.5
                log.info(f"[{self.name}] 🔄 Compounding! New order size: ${self.order_size:.2f}")
                self.compounded = 0.0

        alert(
            f"💰 {self.name} Trade Profit: ${net:.4f}",
            f"SELL filled @ ${sell_price:.4f}\nBuy was @ ${buy_price:.4f}\nNet profit: ${net:.4f}\nTotal P&L: ${self.total_profit:.4f}\nTrades: {self.trade_count}",
            sms=True
        )

        save_trade({
            "time": datetime.now().isoformat(),
            "bot": self.name,
            "pair": self.pair,
            "buy_price": buy_price,
            "sell_price": sell_price,
            "volume": vol,
            "profit_usd": round(net, 6),
            "total_profit": round(self.total_profit, 4),
        })

        del self.active_orders[sell_price]
        if buy_price >= self.lower:
            oid = place_order("buy", self.pair, self.volume_for(buy_price), buy_price)
            self.active_orders[buy_price] = oid

    def check_fills(self, current_price: float):
        if DRY_RUN:
            return
        filled = self._get_filled_ids()
        for price_level, oid in list(self.active_orders.items()):
            if oid in filled:
                log.info(f"[{self.name}] Fill detected for order {oid} @ level ${price_level:.4f}")
                if price_level < current_price:
                    self.handle_buy_fill(price_level)
                else:
                    self.handle_sell_fill(price_level)

    def _get_filled_ids(self) -> list:
        """
        Get orders that were genuinely filled (not cancelled).
        Kraken uses vol_exec > 0 to indicate a fill occurred.
        """
        try:
            resp = api.query_private("ClosedOrders")
            if resp.get("error"):
                return []
            closed = resp["result"].get("closed", {})
            filled = []
            for oid, info in closed.items():
                # vol_exec > 0 means the order was actually filled
                vol_exec = float(info.get("vol_exec", 0))
                status = info.get("status", "")
                if status == "closed" and vol_exec > 0:
                    filled.append(oid)
            return filled
        except Exception as e:
            log.warning(f"[{self.name}] Error checking fills: {e}")
            return []

    def in_range(self, price: float) -> bool:
        return self.lower <= price <= self.upper

    def shift_grid(self, current_price: float, api_cancel_fn):
        """Shift the entire grid to re-center around current price."""
        log.info(f"[{self.name}] Grid shift triggered! Price {current_price:.4f} outside {self.lower}-{self.upper}")

        # Cancel all existing open orders
        cancelled = 0
        for price_level, oid in list(self.active_orders.items()):
            try:
                api_cancel_fn(oid)
                cancelled += 1
            except Exception as e:
                log.warning(f"[{self.name}] Could not cancel {oid}: {e}")
        self.active_orders.clear()
        log.info(f"[{self.name}] Cancelled {cancelled} old orders")

        # Recalculate grid centered around current price
        half_range = (self.upper - self.lower) / 2
        self.lower = round(current_price - half_range, 5)
        self.upper = round(current_price + half_range, 5)
        self.grid_prices = [round(self.lower + i * self.step, 5) for i in range(self.levels + 1)]

        log.info(f"[{self.name}] New range: {self.lower}-{self.upper}")
        alert(f"{self.name} Grid Shifted!", f"New range: {self.lower}-{self.upper}\nCurrent price: {current_price:.4f}")

        # Place fresh buy orders below current price
        placed = 0
        for price in self.grid_prices:
            if price < current_price and abs(price - current_price) > self.step * 0.1:
                try:
                    oid = place_order("buy", self.pair, self.volume_for(price), price)
                    self.active_orders[price] = oid
                    placed += 1
                    time.sleep(0.3)
                except Exception as e:
                    log.warning(f"[{self.name}] Could not place order at {price}: {e}")
        log.info(f"[{self.name}] Grid shifted! {placed} new BUY orders placed. Range: {self.lower}-{self.upper}")


# ── 2. TRAILING STOP LOSS ─────────────────────────────────────────────────────

class TrailingStopBot:
    def __init__(self, config: dict):
        self.pair = config["pair"]
        self.asset = config["asset"]
        self.trail_pct = config["trail_pct"]
        self.min_profit_pct = config.get("min_profit_pct", 0.02)
        self.peak_price = 0.0
        self.stop_price = 0.0
        self.active = False
        self.entry_price = 0.0
        log.info(f"[TrailingStop-{self.asset}] Initialized | Trail: {self.trail_pct*100:.1f}%")

    def update(self, current_price: float, balance: float):
        if not self.active:
            self.entry_price = current_price
            self.peak_price = current_price
            self.stop_price = current_price * (1 - self.trail_pct)
            self.active = True
            log.info(f"[TrailingStop-{self.asset}] Activated @ ${current_price:.4f} | Stop: ${self.stop_price:.4f}")
            return False

        # Update peak and trail stop upward
        if current_price > self.peak_price:
            self.peak_price = current_price
            new_stop = current_price * (1 - self.trail_pct)
            if new_stop > self.stop_price:
                self.stop_price = new_stop
                log.info(f"[TrailingStop-{self.asset}] Stop raised to ${self.stop_price:.4f} (peak: ${self.peak_price:.4f})")

        # Check if stop triggered
        if current_price <= self.stop_price:
            profit_pct = (current_price - self.entry_price) / self.entry_price
            if profit_pct >= self.min_profit_pct or current_price < self.entry_price * 0.95:
                log.warning(f"[TrailingStop-{self.asset}] 🛑 STOP TRIGGERED @ ${current_price:.4f} | Was: ${self.entry_price:.4f}")
                alert(
                    f"🛑 TRAILING STOP TRIGGERED: {self.asset}",
                    f"Stop triggered @ ${current_price:.4f}\nEntry was: ${self.entry_price:.4f}\nPeak was: ${self.peak_price:.4f}\nSelling {balance:.4f} {self.asset}",
                    sms=True
                )
                if balance > 0 and not DRY_RUN:
                    place_order("sell", self.pair, balance, current_price * 0.999)
                self.active = False
                return True
        return False


# ── 3. REBALANCING BOT ────────────────────────────────────────────────────────

class RebalancingBot:
    def __init__(self, config: dict):
        self.targets = config["targets"]
        self.threshold = config.get("threshold", 0.05)
        self.interval = config.get("interval_hours", 24)
        self.last_rebalance = datetime.now()
        log.info(f"[Rebalancer] Targets: {self.targets} | Threshold: {self.threshold*100:.0f}%")

    def should_rebalance(self) -> bool:
        hours_since = (datetime.now() - self.last_rebalance).total_seconds() / 3600
        return hours_since >= self.interval

    def rebalance(self, balances: dict, prices: dict):
        if not self.should_rebalance():
            return

        log.info("[Rebalancer] 🔄 Checking portfolio balance...")

        total_usd = 0.0
        values = {}
        for asset, target in self.targets.items():
            pair = ASSET_PAIRS.get(asset)
            if not pair:
                continue
            price = prices.get(asset, 0)
            bal = balances.get(asset, 0)
            val = bal * price
            values[asset] = {"balance": bal, "price": price, "value": val}
            total_usd += val

        usd_bal = balances.get("USD", 0)
        total_usd += usd_bal

        if total_usd == 0:
            return

        actions = []
        for asset, target_pct in self.targets.items():
            if asset not in values:
                continue
            current_pct = values[asset]["value"] / total_usd
            drift = current_pct - target_pct

            if abs(drift) > self.threshold:
                target_usd = total_usd * target_pct
                current_usd = values[asset]["value"]
                diff_usd = target_usd - current_usd
                price = values[asset]["price"]
                volume = abs(diff_usd) / price

                if diff_usd < 0:
                    log.info(f"[Rebalancer] SELL ${abs(diff_usd):.2f} of {asset} (overweight by {abs(drift)*100:.1f}%)")
                    actions.append(f"SELL ${abs(diff_usd):.2f} {asset}")
                    if not DRY_RUN:
                        place_order("sell", ASSET_PAIRS[asset], volume, price * 0.999)
                else:
                    log.info(f"[Rebalancer] BUY ${diff_usd:.2f} of {asset} (underweight by {drift*100:.1f}%)")
                    actions.append(f"BUY ${diff_usd:.2f} {asset}")
                    if not DRY_RUN:
                        place_order("buy", ASSET_PAIRS[asset], volume, price * 1.001)

        if actions:
            alert(
                "🔄 Portfolio Rebalanced",
                f"Rebalancing actions:\n" + "\n".join(actions) + f"\nTotal portfolio: ${total_usd:.2f}"
            )

        self.last_rebalance = datetime.now()


# ── 4. MOMENTUM BOT ───────────────────────────────────────────────────────────

class MomentumBot:
    def __init__(self, config: dict):
        self.pair = config["pair"]
        self.asset = config["asset"]
        self.trigger_pct = config["trigger_pct"]
        self.exit_pct = config["exit_pct"]
        self.position_usd = config["position_usd"]
        self.price_history = []
        self.in_position = False
        self.entry_price = 0.0
        self.position_volume = 0.0
        log.info(f"[Momentum-{self.asset}] Trigger: {self.trigger_pct*100:.1f}% | Exit: {self.exit_pct*100:.1f}%")

    def update(self, current_price: float):
        self.price_history.append(current_price)
        if len(self.price_history) > 20:
            self.price_history.pop(0)

        if len(self.price_history) < 5:
            return

        # Calculate momentum over last 5 readings
        oldest = self.price_history[-5]
        momentum = (current_price - oldest) / oldest

        if not self.in_position:
            if momentum >= self.trigger_pct:
                # Strong upward momentum — BUY
                volume = self.position_usd / current_price
                log.info(f"[Momentum-{self.asset}] 🚀 BUY signal! Momentum: {momentum*100:.2f}% @ ${current_price:.4f}")
                place_order("buy", self.pair, volume, current_price * 1.001)
                self.entry_price = current_price
                self.position_volume = volume
                self.in_position = True
                alert(
                    f"🚀 Momentum BUY: {self.asset}",
                    f"Strong momentum detected: {momentum*100:.2f}%\nBuying {volume:.4f} {self.asset} @ ${current_price:.4f}",
                    sms=True
                )

        else:
            profit_pct = (current_price - self.entry_price) / self.entry_price

            # Exit if momentum reverses or profit target hit
            if momentum <= -self.exit_pct or profit_pct >= self.trigger_pct * 2:
                log.info(f"[Momentum-{self.asset}] 🏁 SELL signal! P&L: {profit_pct*100:.2f}% @ ${current_price:.4f}")
                place_order("sell", self.pair, self.position_volume, current_price * 0.999)
                net = (current_price - self.entry_price) * self.position_volume
                self.in_position = False
                alert(
                    f"🏁 Momentum SELL: {self.asset} | P&L: ${net:.4f}",
                    f"Exiting momentum trade\nEntry: ${self.entry_price:.4f}\nExit: ${current_price:.4f}\nP&L: ${net:.4f}",
                    sms=True
                )


# ── MASTER RUNNER ─────────────────────────────────────────────────────────────

def run_suite():
    # CONTROL_FILE_PATCH — read control flags before starting
    ctrl_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'bot_control.json')
    ctrl = {'grid_enabled': False, 'trailing_enabled': True, 'momentum_enabled': True, 'rebalance_enabled': True}
    try:
        if os.path.exists(ctrl_path):
            with open(ctrl_path) as f:
                ctrl.update(json.load(f))
        else:
            # Write default control file — grid OFF by default
            with open(ctrl_path, 'w') as f:
                json.dump(ctrl, f, indent=2)
            log.info("[Control] Created bot_control.json — grid disabled by default")
    except Exception as e:
        log.warning(f"[Control] Could not read bot_control.json: {e}")

    log.info("=" * 70)
    log.info("🤖 ADVANCED BOT SUITE STARTING")
    log.info(f"   Bots: Grid(XRP) + Grid(ADA) + Grid(SOL) + TrailingStop + Rebalancer + Momentum")
    log.info(f"   Dry run: {DRY_RUN}")
    log.info("=" * 70)

    # Initialize grid bots (only if enabled in bot_control.json)
    if ctrl.get('grid_enabled', False):
        xrp_grid = GridBot(XRP_GRID_CONFIG)
        ada_grid = GridBot(ADA_GRID_CONFIG)
        sol_grid = GridBot(SOL_GRID_CONFIG)
        doge_grid = GridBot(DOGE_GRID_CONFIG)
        log.info("[Control] Grid bot ENABLED — placing orders")
    else:
        xrp_grid = None
        ada_grid = None
        sol_grid = None
        doge_grid = None
        log.info("[Control] Grid bot DISABLED — no grid orders will be placed")

    # Initialize trailing stop bots
    xrp_stop = TrailingStopBot({"pair": "XXRPZUSD", "asset": "XRP", "trail_pct": 0.04, "min_profit_pct": 0.02})
    ada_stop = TrailingStopBot({"pair": "ADAUSD", "asset": "ADA", "trail_pct": 0.04, "min_profit_pct": 0.02})
    sol_stop = TrailingStopBot({"pair": "SOLUSD", "asset": "SOL", "trail_pct": 0.05, "min_profit_pct": 0.02})
    doge_stop = TrailingStopBot({"pair": "XDGUSD", "asset": "DOGE", "trail_pct": 0.05, "min_profit_pct": 0.02})

    # Initialize rebalancer
    rebalancer = RebalancingBot(REBALANCE_CONFIG)

    # Initialize momentum bots
    xrp_momentum = MomentumBot({"pair": "XXRPZUSD", "asset": "XRP", "trigger_pct": 0.015, "exit_pct": 0.008, "position_usd": MOMENTUM_POSITION_USD})
    ada_momentum = MomentumBot({"pair": "ADAUSD", "asset": "ADA", "trigger_pct": 0.015, "exit_pct": 0.008, "position_usd": MOMENTUM_POSITION_USD})
    sol_momentum = MomentumBot({"pair": "SOLUSD", "asset": "SOL", "trigger_pct": 0.02, "exit_pct": 0.01, "position_usd": MOMENTUM_POSITION_USD})
    doge_momentum = MomentumBot({"pair": "XDGUSD", "asset": "DOGE", "trigger_pct": 0.02, "exit_pct": 0.01, "position_usd": MOMENTUM_POSITION_USD})

    # Get initial prices and initialize grids
    xrp_price = get_price("XXRPZUSD")
    ada_price = get_price("ADAUSD")
    sol_price = get_price("SOLUSD")
    doge_price = get_price("XDGUSD")

    if ctrl.get('grid_enabled', False) and xrp_grid:
        xrp_grid.initialize(xrp_price)
        time.sleep(2)
        ada_grid.initialize(ada_price)
        time.sleep(2)
        sol_grid.initialize(sol_price)
        time.sleep(2)
        doge_grid.initialize(doge_price)

    alert(
        "🤖 Advanced Bot Suite Started",
        f"All bots active!\nXRP Grid: ${XRP_GRID_CONFIG['lower']}–${XRP_GRID_CONFIG['upper']}\nADA Grid: ${ADA_GRID_CONFIG['lower']}–${ADA_GRID_CONFIG['upper']}\nSOL Grid: ${SOL_GRID_CONFIG['lower']}–${SOL_GRID_CONFIG['upper']}\nDry run: {DRY_RUN}"
    )

    # Out of range counters — shift grid after 3 consecutive cycles outside range
    xrp_oor = 0
    ada_oor = 0
    sol_oor = 0
    doge_oor = 0

    cycle = 0
    while True:
        try:
            cycle += 1
            xrp_price = get_price("XXRPZUSD")
            ada_price = get_price("ADAUSD")
            sol_price = get_price("SOLUSD")
            doge_price = get_price("XDGUSD")

            balances = get_balances() if not DRY_RUN else {"ZUSD": 1000, "XXRP": 0, "ADA": 0, "SOL": 0}
            usd_bal  = balances.get("ZUSD", balances.get("USD", 0))
            xrp_bal  = balances.get("XXRP", balances.get("XRP", 0))
            ada_bal  = balances.get("ADA", 0)
            sol_bal  = balances.get("SOL", 0)
            doge_bal = balances.get("XDGE", balances.get("DOGE", 0))

            # ── Grid Bots ──
            ctrl = read_control()
            if ctrl.get("grid_enabled", False) and xrp_grid:
                # XRP grid
                if xrp_grid.in_range(xrp_price):
                    xrp_grid.check_fills(xrp_price)
                    xrp_oor = 0
                else:
                    xrp_oor += 1
                    log.warning(f"[XRP Grid] Price {xrp_price:.4f} outside range {xrp_grid.lower}-{xrp_grid.upper} (OOR count: {xrp_oor})")
                    if xrp_oor >= 3:
                        log.info(f"[XRP Grid] Shifting grid to follow price {xrp_price:.4f}")
                        xrp_grid.shift_grid(xrp_price, cancel_order)
                        xrp_oor = 0

                # ADA grid
                if ada_grid.in_range(ada_price):
                    ada_grid.check_fills(ada_price)
                    ada_oor = 0
                else:
                    ada_oor += 1
                    log.warning(f"[ADA Grid] Price {ada_price:.4f} outside range {ada_grid.lower}-{ada_grid.upper} (OOR count: {ada_oor})")
                    if ada_oor >= 3:
                        log.info(f"[ADA Grid] Shifting grid to follow price {ada_price:.4f}")
                        ada_grid.shift_grid(ada_price, cancel_order)
                        ada_oor = 0

                # SOL grid
                if sol_grid.in_range(sol_price):
                    sol_grid.check_fills(sol_price)
                    sol_oor = 0
                else:
                    sol_oor += 1
                    log.warning(f"[SOL Grid] Price {sol_price:.2f} outside range {sol_grid.lower}-{sol_grid.upper} (OOR count: {sol_oor})")
                    if sol_oor >= 3:
                        log.info(f"[SOL Grid] Shifting grid to follow price {sol_price:.2f}")
                        sol_grid.shift_grid(sol_price, cancel_order)
                        sol_oor = 0

                # DOGE grid
                if doge_grid.in_range(doge_price):
                    if doge_grid: doge_grid.check_fills(doge_price)
                    doge_oor = 0
                else:
                    doge_oor += 1
                    if doge_grid:
                        log.warning(f"[DOGE Grid] Price {doge_price:.5f} outside range {doge_grid.lower}-{doge_grid.upper} (OOR count: {doge_oor})")
                        if doge_oor >= 3:
                            log.info(f"[DOGE Grid] Shifting grid to follow price {doge_price:.5f}")
                            doge_grid.shift_grid(doge_price, cancel_order)
                            doge_oor = 0
            else:
                log.info("[Grid] Grid trading PAUSED via control panel")

            # ── Trailing Stops ──
            if ctrl.get("trailing_enabled", True) and xrp_bal > 0: xrp_stop.update(xrp_price, xrp_bal)
            if ctrl.get("trailing_enabled", True) and ada_bal > 0: ada_stop.update(ada_price, ada_bal)
            if ctrl.get("trailing_enabled", True) and sol_bal > 0: sol_stop.update(sol_price, sol_bal)
            if ctrl.get("trailing_enabled", True) and doge_bal > 0: doge_stop.update(doge_price, doge_bal)

            # ── Momentum Bots ──
            if ctrl.get("momentum_enabled", True):
              xrp_momentum.update(xrp_price)
              ada_momentum.update(ada_price)
              sol_momentum.update(sol_price)
              doge_momentum.update(doge_price)

            # ── Rebalancer ──
            if ctrl.get("rebalance_enabled", True):
             rebalancer.rebalance(
                {"XRP": xrp_bal, "ADA": ada_bal, "SOL": sol_bal, "USD": usd_bal},
                {"XRP": xrp_price, "ADA": ada_price, "SOL": sol_price}
             )

            # ── Status every 10 cycles ──
            if cycle % 10 == 0:
                log.info(
                    f"STATUS | XRP: {xrp_price:.4f} | ADA: {ada_price:.4f} | SOL: {sol_price:.2f} | "
                    f"Grid disabled | "
                    f"Total P&L: {total_profit['total']:.4f}"
                )

            time.sleep(CHECK_INTERVAL_SECONDS)

        except KeyboardInterrupt:
            log.info("\n Bot stopped. Cancelling all orders...")
            grid_orders = []
            if xrp_grid: grid_orders += list(xrp_grid.active_orders.values()) + list(ada_grid.active_orders.values()) + list(sol_grid.active_orders.values())
            for oid in grid_orders:
                cancel_order(oid)
            alert(
                "Bot Suite Stopped",
                f"Final P&L: {total_profit['total']:.4f}"
            )
            break

        except RuntimeError as e:
            log.error(f"Error: {e}")
            alert(f"Bot Error: {str(e)[:100]}", error=True)
            time.sleep(15)
        except Exception as e:
            import traceback
            tb = traceback.format_exc()
            log.error(f"Unexpected error: {e}")
            log.error(f"TRACEBACK: {tb}")
            print(f"TRACEBACK: {tb}", flush=True)
            alert(f"Unexpected Error: {str(e)[:100]}", error=True)
            time.sleep(15)


if __name__ == "__main__":
    run_suite()
