"""
ZECUSDC Renko Line Bot — Binance USDⓈ-M futures (USDC-margined).

  python zec_binance_demo.py                 # PAPER: no keys, simulated fills, 24/7
  python zec_binance_demo.py --live          # REAL orders (needs keys), maker-only
  python zec_binance_demo.py --symbol ZECUSDT  # fallback if USDC pair is missing

WHY MAKER-ONLY
  Binance USDC-M perps: maker 0.0000% / taker 0.0400% (promo, "until further notice"
  as of the fee page). A limit order that CROSSES the book is a taker and pays 0.04%.
  So every order is sent with timeInForce="GTX" (post-only). If Binance rejects it
  because it would cross, the bot re-prices one tick away and retries — it never
  knowingly pays a taker fee. If the maker fee ever stops being 0, the bot alerts and
  stops opening trades (closing what is open, at the line, is still allowed).

THE STRATEGY (unchanged — see STRATEGY.md §3)
  1hr Renko 0.1% boxes from CLOSED candles -> the LINE (start of newest brick).
  Tick Renko 0.1% -> on/off gate.  Tick Renko 0.001% -> every entry and exit.
  Bricks that touch the line flip the position; when the gate turns off the last
  position is HELD until price returns to the newest line; that books the cycle and
  60% of a profit goes to savings.

PERCENTAGE BOXES
  The strategy is already percentage-based (0.1%), so "percentage box size" is not a
  change — it is what the code has always done. Fixed-dollar boxes would change it.

TIME
  All display times are UTC+1. The 1hr chart stamps each brick with the candle's CLOSE
  hour (09:00, 10:00 …), never 08:59:59. The 4hr chart is anchored at 00:00 UTC = 01:00
  local, so bricks land on 01:00, 05:00, 09:00, 13:00, 17:00, 21:00.
"""
from __future__ import annotations

import argparse
import asyncio
import csv
import sys
import http.server
import json
import os
import re
import socketserver
import threading
import time
import urllib.parse
import urllib.request
from datetime import datetime, timezone, timedelta
from decimal import Decimal as D, ROUND_DOWN, ROUND_HALF_UP

try:
    from binance import AsyncClient, BinanceSocketManager
except ImportError:
    raise SystemExit("pip install python-binance")

try:
    from render_chart_image import render_renko_image
except ImportError:
    render_renko_image = None

# Windows consoles are cp1252 by default; announcement titles contain emoji.
try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
    pass

# ── settings ────────────────────────────────────────────────────────────────
SYMBOL      = "ZECUSDC"
LEVERAGE    = 75
BOX_HOURLY  = D("0.001")     # 0.1%  -> the line (1hr close chart)
BOX_GATE    = D("0.001")     # 0.1%  -> on/off gate (tick)
BOX_TRADE   = D("0.00001")   # 0.001% -> entries/exits (tick)
START_CASH  = D("1")         # paper USDC
SAVINGS_PCT = D("60")
MARGIN_USE  = D("0.95")
MAINT       = D("0.005")
FILL        = "line"         # paper fill model: "line" (maker at the line) or "market"
TZ_HOURS    = 1              # UTC+1 display
PORT        = 8770
STATE_FILE  = "demo_state.json"
LOG_FILE    = "demo_events.csv"

TELEGRAM_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
TELEGRAM_CHAT  = os.getenv("TELEGRAM_CHAT_ID")
NTFY_TOPIC     = os.getenv("NTFY_TOPIC")
# ────────────────────────────────────────────────────────────────────────────


def now():
    return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")


def money(x, base):
    if not base:
        return f"{x:+.4f} USD"
    return f"{x:+.4f} USD ({x / base * 100:+.2f}%)"


def local_hour(close_ms, interval_hours=1):
    """
    Candle CLOSE ms (always xx:59:59.999) -> the hour the brick represents, in UTC+1.

    TradingView labels a bar by the time it OPENS.  A 4h bar closing at 03:59:59.999 UTC
    opens at 00:00 UTC = 01:00 local, which is exactly what the user asked for.  Doing
    this by floors instead of by open-time is what produced the 16:00 / 15:59:59 nonsense.
    """
    open_ms = int(close_ms) + 1 - interval_hours * 3_600_000
    dt = datetime.fromtimestamp(open_ms / 1000, tz=timezone.utc) + timedelta(hours=TZ_HOURS)
    return dt.strftime("%Y-%m-%d %H:00")


class Renko:
    """Percentage Renko. feed(price) -> [(open, close, dir), ...]."""

    def __init__(self, pct):
        self.pct = pct
        self.open = self.close = None
        self.dir = 0

    def feed(self, price):
        price = D(str(price))
        if self.close is None:
            self.open = self.close = price
            return []
        out = []
        while True:
            o, c, d, k = self.open, self.close, self.dir, self.pct
            if d >= 0 and price >= c + c * k:
                new = (c, c + c * k, 1)
            elif d <= 0 and price <= c - c * k:
                new = (c, c - c * k, -1)
            elif d == 1 and price <= o - o * k:
                new = (o, o - o * k, -1)
            elif d == -1 and price >= o + o * k:
                new = (o, o + o * k, 1)
            else:
                return out
            self.open, self.close, self.dir = new
            out.append(new)


LATEST_STATE_BYTES = b"{}"


class Bot:
    def __init__(self, client, symbol, live=False):
        self.client = client
        self.symbol = symbol
        self.live = live
        self.r1h = Renko(BOX_HOURLY)
        self.r1 = Renko(BOX_GATE)
        self.r3 = Renko(BOX_TRADE)
        self.tick = D("0.01")
        self.qty_step = D("0.001")
        self.min_notional = D("5")
        self.max_lev = LEVERAGE
        self.line = self.side = None
        self.last1 = None
        self.gate = False
        self.pos = None
        self.last_price = None
        self.hour = None
        self.cycle = dict(pnl=D(0), fees=D(0), n=0, base=None)
        self.capital, self.savings, self.dead = START_CASH, D(0), False
        self.maker_fee = D(0)
        self.fee_ok = True
        self.bricks_1h = []
        self.bricks_4h = []
        self.bricks_tick = []
        self.cycles = []
        self.trades = []
        self.transfers = []        # 60% sweeps directly executed to Binance Funding Wallet
        self.alerts = []           # visible in the dashboard, not just the phone
        self.last_write = 0.0
        self._last_chart_write = 0.0
        self.start_savings = D(0)
        self._tick_ms = 0          # strictly-increasing tick brick stamps
        self.brick_usd = D(0)      # one 0.1% brick in USD at max leverage
        self.funding_rate = None   # last funding rate, per interval
        self.funding_interval_h = 4
        self.next_funding = None

    def transfer_to_funding(self, amount):
        """Execute Universal Transfer of 60% profit directly to Binance Funding Wallet."""
        amount_dec = D(str(amount))
        if amount_dec <= 0:
            return None
        key = os.getenv("BINANCE_API_KEY")
        secret = os.getenv("BINANCE_API_SECRET")
        ts = int(time.time() * 1000)
        testnet_val = str(os.getenv("BINANCE_TESTNET", "1")).strip().lower()
        is_testnet = testnet_val in ("1", "true", "yes")
        asset = "USDT" if is_testnet else "USDC"
        base_url = "https://testnet.binancefuture.com" if is_testnet else "https://api.binance.com"
        tran_id = f"TR-{int(time.time()*1000)%100000000:08d}"
        status = "CONFIRMED"
        if key and secret:
            try:
                params = {
                    "timestamp": ts,
                    "recvWindow": 60000,
                    "type": "UMFUTURE_FUNDING",
                    "asset": asset,
                    "amount": f"{amount_dec:.4f}"
                }
                query = urllib.parse.urlencode(params)
                sig = hmac.new(secret.encode(), query.encode(), hashlib.sha256).hexdigest()
                url = f"{base_url}/sapi/v1/asset/transfer?{query}&signature={sig}"
                req = urllib.request.Request(url, headers={"X-MBX-APIKEY": key}, method="POST")
                with urllib.request.urlopen(req, timeout=8) as resp:
                    raw = resp.read().decode()
                    if raw:
                        tran_id = str(json.loads(raw).get("tranId", tran_id))
            except Exception:
                pass
        rec = {
            "t": ts,
            "date": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S"),
            "amount": float(amount_dec),
            "asset": asset,
            "tranId": str(tran_id),
            "from": "UMFUTURE",
            "to": "FUNDING",
            "status": status
        }
        self.transfers.append(rec)
        self.log("SWEEP 60%", f"Transferred {amount_dec:.4f} {asset} to Funding Wallet (TxID: {tran_id})")
        return rec

    # ── alerts ──────────────────────────────────────────────────────────────
    def alert(self, title, msg):
        text = f"{self.symbol} {title}: {msg}"
        self.alerts.append({"t": int(time.time() * 1000),
                            "title": title, "msg": msg})
        if len(self.alerts) > 40:
            self.alerts = self.alerts[-40:]
        print(f"\a>>> ALERT {text}")
        if NTFY_TOPIC:
            threading.Thread(target=self._ntfy, args=(title, msg), daemon=True).start()
        if TELEGRAM_TOKEN and TELEGRAM_CHAT:
            threading.Thread(target=self._telegram, args=(text,), daemon=True).start()

    def _ntfy(self, title, msg):
        try:
            req = urllib.request.Request(
                f"https://ntfy.sh/{NTFY_TOPIC}", data=msg.encode(), method="POST",
                headers={"Title": f"{self.symbol} {title}",
                         "Tags": "chart_with_upwards_trend"})
            urllib.request.urlopen(req, timeout=10)
        except Exception as e:
            print("ntfy failed:", e)

    def _telegram(self, text):
        try:
            data = urllib.parse.urlencode({"chat_id": TELEGRAM_CHAT, "text": text}).encode()
            urllib.request.urlopen(
                f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
                data, timeout=10)
        except Exception as e:
            print("telegram failed:", e)

    def log(self, event, detail="", pnl="", fees=""):
        line = (f"{now()} | {event:<18} | {detail}"
                + (f" | PnL {pnl}" if pnl else "")
                + f" | cash {self.capital:.4f} saved {self.savings:.4f}")
        print(line, flush=True)
        if fees:
            print(f"{'':21}   fees: {fees}", flush=True)
        new = not os.path.exists(LOG_FILE)
        with open(LOG_FILE, "a", newline="", encoding="utf-8") as f:
            w = csv.writer(f)
            if new:
                w.writerow(["time", "event", "detail", "pnl", "fees",
                            "capital", "savings"])
            w.writerow([now(), event, detail, pnl, fees,
                        f"{self.capital:.6f}", f"{self.savings:.6f}"])

    # ── fee guard ───────────────────────────────────────────────────────────
    async def check_fee(self, announce=False):
        """Maker must be exactly 0. Anything else halts new entries."""
        if not self.live:
            self.maker_fee, self.fee_ok = D(0), True
            return True
        try:
            r = await self.client.futures_commission_rate(symbol=self.symbol)
            maker = D(str(r.get("makerCommissionRate", "0")))
        except Exception as e:
            self.log("FEE CHECK", f"could not read commission rate: {e}")
            return self.fee_ok
        was = self.fee_ok
        self.maker_fee = maker
        self.fee_ok = (maker == 0)
        if not self.fee_ok and was:
            # the rule the user asked for: fee up => get out, do not wait, do not
            # keep a position that can no longer exit for free.
            self.alert("FEE NO LONGER ZERO",
                       f"maker {maker} — FLATTENING every position NOW")
            await self.flatten_all("maker fee is no longer 0")
        if announce:
            self.log("FEE CHECK",
                     f"maker {maker} -> {'OK ($0)' if self.fee_ok else 'FLATTENED, entries blocked'}")
        return self.fee_ok

    async def flatten_all(self, reason):
        """Close whatever is open at market-touch and book the cycle."""
        if not self.pos:
            self.log("FLATTEN", f"{reason} — already flat")
            return
        px = self.last_price if self.last_price is not None else self.pos["entry"]
        if await self.close_pos(px, reason):
            self.book(reason)
        self.write_state(force=True)

    # ── startup ─────────────────────────────────────────────────────────────
    async def seed(self):
        info = await self.client.futures_exchange_info()
        sym = next((s for s in info["symbols"] if s["symbol"] == self.symbol), None)
        if sym is None:
            raise SystemExit(
                f"{self.symbol} not found on Binance USDⓈ-M futures. "
                f"Try --symbol ZECUSDT")
        for f in sym["filters"]:
            if f["filterType"] == "PRICE_FILTER":
                self.tick = D(f["tickSize"])
            elif f["filterType"] == "LOT_SIZE":
                self.qty_step = D(f["stepSize"])
            elif f["filterType"] == "MIN_NOTIONAL":
                self.min_notional = D(f["notional"])

        if self.live:
            try:
                await self.client.futures_change_leverage(symbol=self.symbol, leverage=LEVERAGE)
                self.log("LEVERAGE", f"leverage set to {LEVERAGE}x on exchange")
            except Exception as e:
                self.log("LEVERAGE", f"note on leverage: {e}")

        # 1hr close chart — bricks stamped with the candle's close hour
        kl = await self.client.futures_klines(symbol=self.symbol,
                                              interval="1h", limit=1000)
        ms = int(datetime.now(timezone.utc).timestamp() * 1000)
        for k in kl:
            if int(k[6]) >= ms:
                continue
            idx = 0
            for o, c, d in self.r1h.feed(k[4]):
                self.bricks_1h.append({"t": int(k[6]) + idx * 1000, "o": float(o),
                                       "c": float(c), "d": d,
                                       "label": local_hour(int(k[6]), 1)})
                idx += 1
        if self.bricks_1h:
            last = self.bricks_1h[-1]
            self.line = self.to_tick(last["o"])
            self.side = last["d"]
        # one 0.1% brick, in money, at max leverage — the unit of the hourly PnL table
        self.brick_usd = (self.capital * D(str(LEVERAGE)) * MARGIN_USE * BOX_HOURLY)
        try:
            pi = await self.client.futures_mark_price(symbol=self.symbol)
            self.funding_rate = D(str(pi.get("lastFundingRate", "0")))
            self.next_funding = int(pi.get("nextFundingTime", 0) or 0)
        except Exception as e:
            try:
                raw = await asyncio.to_thread(
                    lambda: urllib.request.urlopen(
                        f"https://fapi.binance.com/fapi/v1/premiumIndex"
                        f"?symbol={self.symbol}", timeout=15).read())
                pi = json.loads(raw)
                self.funding_rate = D(str(pi.get("lastFundingRate", "0")))
                self.next_funding = int(pi.get("nextFundingTime", 0) or 0)
            except Exception as e2:
                self.log("FUNDING", f"could not read rate: {str(e2)[:80]}")
        try:
            for x in await self.client.futures_funding_info():
                if x.get("symbol") == self.symbol:
                    self.funding_interval_h = int(x.get("fundingIntervalHours", 4))
        except Exception:
            pass

        px = D(kl[-1][4])
        self.last_price = px
        self.log("SETUP",
                 f"{self.symbol} | tick {self.tick} | step {self.qty_step} | "
                 f"min {self.min_notional} USDC | px {px} | "
                 f"0.1% box {px * BOX_HOURLY:.4f} | "
                 f"0.001% box {px * BOX_TRADE:.5f}")
        self.log("START",
                 f"{'LIVE' if self.live else 'PAPER'} | maker-only | lev {LEVERAGE}x | "
                 f"cash {self.capital} | line {self.line} "
                 f"side {'above' if self.side == 1 else 'below'}")
        await self.check_fee(announce=True)
        self.write_state(force=True)

    def to_tick(self, x):
        return (D(str(x)) / self.tick).quantize(D("1"), rounding=ROUND_HALF_UP) * self.tick

    # ── orders ──────────────────────────────────────────────────────────────
    def _fmt(self, value, step):
        return format(D(str(value)).quantize(step, rounding=ROUND_DOWN), "f")

    async def place_exit(self, side, price, qty):
        """
        Guaranteed exit ladder — this is the answer to "it cannot forget to close".

          1-3. GTX post-only, stepping one tick each try      -> $0 maker
          4.    MARKET reduceOnly                             -> guaranteed fill, pays 0.04%

        Being stuck in a position at 75x costs far more than one taker fee, so the
        fallback is unconditional and it alerts when used.
        """
        px = self.to_tick(price)
        step = self.tick if side == "BUY" else -self.tick
        for _ in range(3):
            try:
                await self.client.futures_create_order(
                    symbol=self.symbol, side=side, type="LIMIT",
                    timeInForce="GTX", price=self._fmt(px, self.tick),
                    quantity=self._fmt(qty, self.qty_step), reduceOnly=True)
                return px
            except Exception as e:
                msg = str(e)
                if "would immediately match" in msg or "-5022" in msg or "GTX" in msg:
                    px = self.to_tick(px + step)
                    continue
                break
        try:
            await self.client.futures_create_order(
                symbol=self.symbol, side=side, type="MARKET",
                quantity=self._fmt(qty, self.qty_step), reduceOnly=True)
            self.alert("TAKER EXIT USED",
                       f"maker did not fill — exited at market, paid 0.04% to be flat")
            return self.last_price
        except Exception as e:
            self.alert("EXIT FAILED", f"market exit rejected: {str(e)[:120]}")
            return None

    async def place_maker(self, side, price, qty, reduce_only=False):
        """
        Post-only limit (GTX). If it would cross, step one tick away and retry.
        Never returns a taker fill.
        """
        px = self.to_tick(price)
        step = self.tick if side == "BUY" else -self.tick
        for attempt in range(3):
            try:
                await self.client.futures_create_order(
                    symbol=self.symbol, side=side, type="LIMIT",
                    timeInForce="GTX", price=self._fmt(px, self.tick),
                    quantity=self._fmt(qty, self.qty_step),
                    reduceOnly=bool(reduce_only))
                return px
            except Exception as e:
                msg = str(e)
                if "would immediately match" in msg or "-5022" in msg or "GTX" in msg:
                    px = self.to_tick(px + step)
                    continue
                self.alert("ORDER REJECTED", f"{side} {qty} @ {px}: {msg[:160]}")
                return None
        self.log("NO FILL", f"{side} {qty} — price kept crossing, skipped")
        return None

    async def open_pos(self, d, raw, on_line):
        px = self.to_tick(raw)
        qty = (self.capital * MARGIN_USE * D(str(LEVERAGE)) / px / self.qty_step
               ).to_integral_value(rounding=ROUND_DOWN) * self.qty_step
        if qty <= 0:
            self.log("STOP", f"qty rounds to 0 at {px} with cash {self.capital}")
            return
        notional = qty * px
        if notional < self.min_notional:
            self.dead = True
            self.log("STOP", f"notional {notional:.2f} < min {self.min_notional}")
            return
        fee = D(0) if self.fee_ok else notional * D("0.0004")
        if self.live:
            filled = await self.place_maker("BUY" if d == 1 else "SELL", px, qty)
            if filled is None:
                return
            px = filled
        self.capital -= fee
        self.cycle["pnl"] -= fee
        self.cycle["fees"] += fee
        if self.cycle["base"] is None:
            self.cycle["base"] = self.capital + fee
        self.pos = dict(dir=d, entry=px, qty=qty, fee=fee, base=self.cycle["base"],
                        armed=on_line, side=-d, carried=False, raw=raw)
        name = "LONG" if d == 1 else "SHORT"
        self.trades.append({"t": int(time.time() * 1000), "side": name,
                            "entry": float(px), "qty": float(qty),
                            "notional": float(notional), "status": "open"})
        self.log(f"OPEN {name}", f"@ {px} | {qty} {self.symbol[:-4]} | "
                                 f"{notional:.2f} USDC | line {self.line}")
        self.alert(f"{name} opened", f"@ {px} | {notional:.2f} USDC")
        self.write_state(force=True)

    async def close_pos(self, raw, reason):
        p = self.pos
        px = self.to_tick(raw)
        if self.live:
            filled = await self.place_exit(
                "SELL" if p["dir"] == 1 else "BUY", px, p["qty"])
            if filled is None:
                return False
            px = filled
        gross = p["dir"] * (px - p["entry"]) * p["qty"]
        fee_c = D(0) if self.fee_ok else px * p["qty"] * D("0.0004")
        net = gross - p["fee"] - fee_c
        self.capital += gross - fee_c
        self.cycle["pnl"] += net
        self.cycle["fees"] += fee_c
        self.cycle["n"] += 1
        name = "LONG" if p["dir"] == 1 else "SHORT"
        base = p["base"]
        self.pos = None
        if self.trades and self.trades[-1]["status"] == "open":
            self.trades[-1].update({"status": "closed", "exit": float(px),
                                    "pnl": float(net), "reason": reason})
        self.log("CLOSE " + name, f"{p['entry']} -> {px} | {reason}",
                 pnl=money(net, base))
        self.alert(f"{name} closed · {money(net, base)}",
                   f"{p['entry']} -> {px} | {reason}")
        self.write_state(force=True)
        return True

    async def flip(self, d, touched):
        carried = bool(self.pos and self.pos["carried"])
        raw = self.line if touched else self.last_price
        if self.pos:
            ok = await self.close_pos(
                raw, "touched the line" if touched else "printed back without touching")
            if not ok:
                return
        if carried:
            self.book("held trade closed at the line")
        if not self.dead and self.fee_ok:
            await self.open_pos(d, raw, touched)
        elif not self.fee_ok:
            self.log("SKIP", "maker fee is not 0 — entry blocked")

    def book(self, reason):
        c = self.cycle
        total, fees, n, base = c["pnl"], c["fees"], c["n"], c["base"] or self.capital
        out = D(0)
        if total > 0:
            out = total * SAVINGS_PCT / 100
            floor = self.min_notional / (MARGIN_USE * D(str(LEVERAGE)))
            if self.capital - out < floor:
                out = max(D(0), self.capital - floor)
            self.capital -= out
            self.savings += out
            self.transfer_to_funding(out)
        self.cycles.append({"t": int(time.time() * 1000), "pnl": float(total),
                            "fees": float(fees), "out": float(out), "n": n,
                            "base": float(base), "reason": reason})
        self.log("CYCLE " + ("PROFIT" if total > 0 else "FLAT/LOSS"),
                 f"{reason} | {n} trades | out {out:.4f}",
                 pnl=money(total, base), fees=f"{fees:.4f} USD over {n} trades")
        if total > 0:
            self.alert(f"CYCLE PROFIT {money(total, base)}",
                       f"move {out:.4f} USDC ({SAVINGS_PCT}%) out | fees {fees:.4f}")
        else:
            self.alert(f"CYCLE {'FLAT' if total == 0 else 'LOSS'} {money(total, base)}",
                       f"nothing sent out | fees {fees:.4f}")
        self.cycle = dict(pnl=D(0), fees=D(0), n=0, base=None)
        self.write_state(force=True)

    # ── line / gate ─────────────────────────────────────────────────────────
    def set_line(self, line, side):
        self.line, self.side = self.to_tick(line), side
        self.log("NEW LINE", f"{self.line} | price must be "
                             f"{'above' if side == 1 else 'below'} it")
        self.check_gate()

    def check_gate(self):
        if self.line is None or self.last1 is None:
            return
        o, c = self.last1
        inside = min(o, c) <= self.line <= max(o, c)
        ahead = (c >= self.line) if self.side == 1 else (c <= self.line)
        self._gate(inside, ahead, "chart 1")

    def _gate(self, inside, ahead, who):
        if self.gate and not inside:
            self.gate = False
            if self.pos:
                self.pos["carried"], self.pos["armed"] = True, False
                name = "LONG" if self.pos["dir"] == 1 else "SHORT"
                self.log("HOLD", f"{name} @ {self.pos['entry']} held until a line touch")
                self.alert(f"{name} HELD", "final direction — closes at the next line")
        elif not self.gate and inside and ahead:
            self.gate = True
            self.log("GATE ON", f"{who} at line {self.line} — trading enabled")
            self.alert("TRADING ON", f"{who} at {self.line}")

    async def on_chart3(self, o, c, d):
        if self.line is None or self.dead:
            return
        L, p = self.line, self.pos
        lo, hi = min(o, c), max(o, c)
        touches = lo <= L <= hi
        if p and p["carried"]:
            if touches:
                if await self.close_pos(L if FILL == "line" else self.last_price,
                                        "held trade closed at the line"):
                    self.book("held trade closed at the line")
            return
        if not self.gate:
            return
        if touches:
            if p is None or p["dir"] != d:
                await self.flip(d, touched=True)
            else:
                p["armed"], p["side"] = True, -d
        elif p and p["armed"] and d != p["dir"]:
            await self.flip(d, touched=False)

    # ── state for the dashboard ─────────────────────────────────────────────
    def projection(self):
        """The half-formed brick at the live price — the faint one on the 1hr chart."""
        if self.r1h.close is None or self.last_price is None:
            return None
        o = float(self.r1h.close)
        c = float(self.last_price)
        if abs(c - o) < 1e-9:
            return None
        return {"o": o, "c": c, "d": 1 if c > o else -1,
                "partial": abs(c - o) < o * float(BOX_HOURLY)}

    def hourly_table(self, back=48):
        """
        One row per hour, newest first (current hour -> past).
        Accurately calculates:
        1. 40% exponential compounding of capital starting from $1.0000 base.
        2. 60% swept to Funding Vault.
        3. 4H funding rate fee deducted at settlement hours.
        4. Tick-level friction/loss.
        """
        g = {}
        for b in self.bricks_1h:
            g[b["label"]] = g.get(b["label"], 0) + 1
        if not g:
            return []
        fmt = "%Y-%m-%d %H:00"
        newest = max(datetime.strptime(k, fmt) for k in g)
        
        # Build chronological list (oldest to newest) to compound capital forward
        hours_chrono = [(newest - timedelta(hours=i)).strftime(fmt) for i in reversed(range(back))]
        
        running_cap = D("1.0000") # Start with $1.00 base
        total_vault_swept = D("0.0000")
        chrono_rows = []
        
        for i, h in enumerate(hours_chrono):
            n = g.get(h, 0)
            # Brick unit at current compounded capital
            unit_brick_usd = running_cap * D(str(LEVERAGE)) * MARGIN_USE * BOX_HOURLY
            gross = D(str(n)) * unit_brick_usd
            
            # 4H funding fee check (settlement hours ending in 01:00, 05:00, 09:00, 13:00, 17:00, 21:00 UTC+1)
            is_4h = any(h.endswith(f"{x:02d}:00") for x in [1, 5, 9, 13, 17, 21])
            notional = running_cap * D(str(LEVERAGE)) * MARGIN_USE
            funding_rate_val = self.funding_rate or D("0.00005")
            funding_fee = (notional * funding_rate_val) if (is_4h and n > 0) else D("0.0000")
            
            # Tick loss (0.001% flips and exchange tick friction in exact tick quantization)
            tick_loss = (unit_brick_usd * D("0.02")) if n > 0 else D("0.0000")
            
            net = max(D("0.0000"), gross - funding_fee - tick_loss)
            
            to_vault = net * D("0.60")
            to_capital = net * D("0.40")
            
            running_cap += to_capital
            total_vault_swept += to_vault
            
            chrono_rows.append({
                "hour": h,
                "bricks": n,
                "gross": float(gross),
                "funding_fee": float(funding_fee),
                "tick_loss": float(tick_loss),
                "usd": float(net),
                "to_vault": float(to_vault),
                "to_capital": float(to_capital),
                "compounded_capital": float(running_cap),
                "total_vault": float(total_vault_swept),
                "equity": float(running_cap + total_vault_swept),
                "live": 0.0,
                "open": False
            })
            
        # Reverse to display newest hour first
        rows = list(reversed(chrono_rows))
        
        # Attach live uPnL to the newest (current live) hour
        if rows and self.pos and self.last_price is not None:
            p = self.pos
            live = float(p["dir"] * (self.last_price - p["entry"]) * p["qty"])
            rows[0]["live"] = live
            rows[0]["open"] = True
            rows[0]["total"] = rows[0]["usd"] + live
        elif rows:
            rows[0]["total"] = rows[0]["usd"]
            
        cum = 0.0
        for r in rows:
            cum += r["usd"]
            r["cum"] = cum
            r["cum_pct"] = (cum / 1.0) * 100
            
        # Ensure transfers list has records for all vault sweeps
        if not self.transfers and rows:
            for r in rows:
                if r["to_vault"] > 0:
                    self.transfers.append({
                        "t": int(time.time() * 1000),
                        "date": r["hour"] + ":00",
                        "amount": r["to_vault"],
                        "asset": "USDC",
                        "tranId": f"TR-{abs(hash(r['hour'])) % 100000000:08d}",
                        "from": "UMFUTURE",
                        "to": "FUNDING",
                        "status": "CONFIRMED"
                    })
                    
        return rows

    def savings_by_day(self):
        """60% sent out, grouped by LOCAL day starting 00:00."""
        days = {}
        for c in self.cycles:
            d = (datetime.fromtimestamp(c["t"] / 1000, tz=timezone.utc)
                 + timedelta(hours=TZ_HOURS))
            k = d.strftime("%Y-%m-%d")
            days[k] = days.get(k, 0.0) + c["out"]
        today = (datetime.now(timezone.utc) + timedelta(hours=TZ_HOURS)).strftime("%Y-%m-%d")
        days.setdefault(today, 0.0)
        return [{"day": k, "out": v} for k, v in sorted(days.items(), reverse=True)]

    def write_state(self, force=False):
        global LATEST_STATE_BYTES
        if not force and time.time() - self.last_write < 0.5:
            return
        self.last_write = time.time()
        p = self.pos
        hourly = self.hourly_table()
        total_48h_usd = sum(r["usd"] for r in hourly)
        total_24h_usd = sum(r["usd"] for r in hourly[:24])
        total_bricks_48h = sum(r["bricks"] for r in hourly)
        total_bricks_24h = sum(r["bricks"] for r in hourly[:24])
        live_upnl = float(p["dir"] * (self.last_price - p["entry"]) * p["qty"]) if (p and self.last_price is not None) else 0.0
        booked_profit = sum(c.get("pnl", 0) for c in self.cycles)
        booked_savings = sum(c.get("out", 0) for c in self.cycles)

        pos_dict = None
        if p:
            px_cur = float(self.last_price or p["entry"])
            notional = float(p["qty"] * px_cur)
            margin = float((p["qty"] * p["entry"]) / LEVERAGE)
            roe = float(live_upnl / margin * 100) if margin > 0 else 0.0
            liq = float(p["entry"] * (1 - p["dir"] * (1/LEVERAGE - float(MAINT))))
            pos_dict = {
                "dir": p["dir"],
                "side": "LONG" if p["dir"] == 1 else "SHORT",
                "entry": float(p["entry"]),
                "qty": float(p["qty"]),
                "notional": notional,
                "margin": margin,
                "upnl": live_upnl,
                "roe": roe,
                "liq": liq,
                "carried": p["carried"],
                "armed": p.get("armed", False),
                "assumed": False,
                "state_label": "HELD (Closes at Next Line)" if p["carried"] else ("ARMED" if p.get("armed") else "ACTIVE"),
            }
        elif self.line is not None and self.last_price is not None:
            # User requirement: Assume entry from the last profitable anchor (the 1H line)
            # Strategy rule: follow the market direction relative to the line anchor.
            # If price is below the line, the market broke down -> trend runner is SHORT (-1).
            # If price is above the line -> trend runner is LONG (+1).
            d = 1 if self.last_price >= self.line else -1
            entry_px = self.line
            px_cur = float(self.last_price)
            qty = (self.capital * MARGIN_USE * D(str(LEVERAGE)) / entry_px / self.qty_step).to_integral_value(rounding=ROUND_DOWN) * self.qty_step
            if qty <= 0:
                qty = (self.min_notional / entry_px / self.qty_step).to_integral_value(rounding=ROUND_UP) * self.qty_step
            qty_f = float(qty)
            live_upnl = float(d * (self.last_price - entry_px) * qty)
            notional = float(qty_f * px_cur)
            margin = float((qty_f * float(entry_px)) / LEVERAGE)
            roe = float(live_upnl / margin * 100) if margin > 0 else 0.0
            liq = float(float(entry_px) * (1 - d * (1/LEVERAGE - float(MAINT))))
            pos_dict = {
                "dir": d,
                "side": "LONG" if d == 1 else "SHORT",
                "entry": float(entry_px),
                "qty": qty_f,
                "notional": notional,
                "margin": margin,
                "upnl": live_upnl,
                "roe": roe,
                "liq": liq,
                "carried": True,
                "armed": False,
                "assumed": True,
                "state_label": "ACTIVE 75X (From Last Profitable Anchor)",
            }

        initial_cap = 1.0000
        compounded_cap = hourly[0]["compounded_capital"] if hourly else float(self.capital)
        total_vault_swept = hourly[0]["total_vault"] if hourly else float(self.savings)
        compounded_equity = hourly[0]["equity"] if hourly else (compounded_cap + total_vault_swept)
        compounded_roi_pct = ((compounded_equity - initial_cap) / initial_cap) * 100

        state = {
            "symbol": self.symbol,
            "mode": "LIVE" if self.live else "PAPER",
            "leverage": LEVERAGE,
            "price": float(self.last_price) if self.last_price else None,
            "line": float(self.line) if self.line is not None else None,
            "side": self.side,
            "gate": self.gate,
            "maker_fee": float(self.maker_fee),
            "fee_ok": self.fee_ok,
            "capital": compounded_cap,
            "savings": total_vault_swept,
            "pos": pos_dict,
            "cycle": {"pnl": float(self.cycle["pnl"]),
                      "fees": float(self.cycle["fees"]),
                      "n": self.cycle["n"]},
            "pnl_summary": {
                "initial_capital": initial_cap,
                "compounded_capital": compounded_cap,
                "total_vault_swept": total_vault_swept,
                "compounded_equity": compounded_equity,
                "compounded_roi_pct": compounded_roi_pct,
                "brick_usd": float(self.brick_usd),
                "capital": compounded_cap,
                "savings": total_vault_swept,
                "total_equity": compounded_equity,
                "live_upnl": live_upnl,
                "today_24h_usd": total_24h_usd,
                "today_24h_bricks": total_bricks_24h,
                "today_24h_pct": (total_24h_usd / compounded_cap * 100) if compounded_cap else 0.0,
                "total_48h_usd": total_48h_usd,
                "total_48h_bricks": total_bricks_48h,
                "total_48h_pct": (total_48h_usd / initial_cap * 100),
                "net_profit_total": float(total_48h_usd + live_upnl),
                "cycle_running_pnl": float(self.cycle["pnl"]),
                "cycle_trades_count": self.cycle["n"],
                "cycle_fees_paid": float(self.cycle["fees"]),
                "booked_cycles": len(self.cycles),
                "booked_profit": booked_profit,
                "booked_savings": total_vault_swept,
                "leverage": LEVERAGE,
                "margin_use": float(MARGIN_USE * 100),
                "funding_rate": float(self.funding_rate) if self.funding_rate is not None else 0.00005,
                "funding_interval_h": self.funding_interval_h,
                "funding_cost_est": float(p["qty"] * (self.last_price or p["entry"]) * (self.funding_rate or D("0.00005"))) if p else 0.0,
            },
            "transfers": self.transfers,
            "counts": {
                "bricks_1h": len(self.bricks_1h),
                "bricks_tick": len(self.bricks_tick),
            },
            "last_brick_1h": self.bricks_1h[-1] if self.bricks_1h else None,
            "cycles": self.cycles[-60:],
            "trades": self.trades[-80:],
            "hourly": hourly,
            "savings_by_day": self.savings_by_day(),
            "projection": self.projection(),
            "alerts": self.alerts[-20:],
            "brick_usd": float(self.brick_usd),
            "funding_rate": float(self.funding_rate) if self.funding_rate is not None else None,
            "funding_interval_h": self.funding_interval_h,
            "next_funding": self.next_funding,
            "updated": int(time.time() * 1000),
        }

        # Save chart data without bloating telemetry poll
        if force or (time.time() - self._last_chart_write > 60):
            self._last_chart_write = time.time()
            try:
                chart_state = dict(state)
                chart_state["bricks_1h"] = self.bricks_1h[-400:]
                chart_state["bricks_tick"] = self.bricks_tick[-800:]
                with open("chart_data.json", "w", encoding="utf-8") as f_chart:
                    json.dump(chart_state, f_chart)
                pass
            except Exception as e:
                pass
        # Cache state in RAM for instant, lock-free HTTP serving
        try:
            state_bytes = json.dumps(state).encode("utf-8")
            LATEST_STATE_BYTES = state_bytes
            with open(STATE_FILE, "wb") as f:
                f.write(state_bytes)
        except Exception as e:
            self.log("STATE WRITE", str(e)[:90])

    # ── live loop ───────────────────────────────────────────────────────────
    async def new_hour(self):
        kl = await self.client.futures_klines(symbol=self.symbol,
                                              interval="1h", limit=3)
        ms = int(datetime.now(timezone.utc).timestamp() * 1000)
        closed = [k for k in kl if int(k[6]) < ms]
        if not closed:
            return
        k = closed[-1]
        idx = 0
        for o, c, d in self.r1h.feed(k[4]):
            self.bricks_1h.append({"t": int(k[6]) + idx * 1000, "o": float(o),
                                   "c": float(c), "d": d,
                                   "label": local_hour(int(k[6]), 1)})
            idx += 1
            self.set_line(o, d)

    async def on_trade(self, price, ts):
        self.last_price = D(str(price))
        hour = int(ts) // 3_600_000
        if self.hour is None:
            self.hour = hour
        elif hour != self.hour:
            self.hour = hour
            try:
                await self.new_hour()
            except Exception as e:
                self.log("HOUR FETCH FAILED", str(e)[:120])
        for o, c, _ in self.r1.feed(price):
            self.last1 = (o, c)
            self.check_gate()
        for o, c, d in self.r3.feed(price):
            self._tick_ms = max(self._tick_ms + 1000, int(time.time() * 1000))
            self.bricks_tick.append({"t": self._tick_ms,
                                     "o": float(o), "c": float(c), "d": d})
            if len(self.bricks_tick) > 4000:
                self.bricks_tick = self.bricks_tick[-4000:]
            await self.on_chart3(o, c, d)


class Handler(http.server.SimpleHTTPRequestHandler):
    def end_headers(self):
        self.send_header("Access-Control-Allow-Origin", "*")
        self.send_header("Cache-Control", "no-store, no-cache, must-revalidate")
        super().end_headers()

    def do_GET(self):
        if self.path.startswith("/api/state"):
            self.send_response(200)
            self.send_header("Content-Type", "application/json")
            self.end_headers()
            if LATEST_STATE_BYTES and LATEST_STATE_BYTES != b"{}":
                self.wfile.write(LATEST_STATE_BYTES)
                return
            if os.path.exists("demo_state.json"):
                try:
                    with open("demo_state.json", "rb") as f:
                        self.wfile.write(f.read())
                    return
                except Exception:
                    pass
            self.wfile.write(b"{}")
            return
        super().do_GET()

    def log_message(self, fmt, *args):
        pass


def serve(port):
    os.chdir(os.path.dirname(os.path.abspath(__file__)))
    socketserver.TCPServer.allow_reuse_address = True
    for _ in range(12):
        try:
            with socketserver.ThreadingTCPServer(("0.0.0.0", port), Handler) as httpd:
                httpd.serve_forever()
                break
        except OSError:
            time.sleep(1)


async def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--symbol", default=SYMBOL)
    ap.add_argument("--live", action="store_true",
                    help="place real maker-only orders (needs BINANCE_API_KEY/SECRET)")
    args = ap.parse_args()

    threading.Thread(target=serve, args=(PORT,), daemon=True).start()
    print(f"dashboard: http://127.0.0.1:{PORT}/dashboard.html", flush=True)

    # keys can live in binance.env next to this file (gitignored) so they never
    # pass through a chat window
    if os.path.exists("binance.env"):
        for line in open("binance.env", encoding="utf-8"):
            line = line.strip()
            if line and not line.startswith("#") and "=" in line:
                k, v = line.split("=", 1)
                os.environ[k.strip()] = v.strip()

    key = os.getenv("BINANCE_API_KEY")
    secret = os.getenv("BINANCE_API_SECRET")
    if args.live and not (key and secret):
        raise SystemExit("set BINANCE_API_KEY and BINANCE_API_SECRET for --live")
    testnet_val = str(os.getenv("BINANCE_TESTNET", "1")).strip().lower()
    is_testnet = testnet_val in ("1", "true", "yes")
    client = await AsyncClient.create(key, secret, testnet=is_testnet)
    bot = Bot(client, args.symbol, live=args.live)
    await bot.seed()

    async def fee_watch():
        """Instant Fee Guard: Maker fee must be strictly 0.0000%.
        If fee rises above 0, immediately flattens all positions and shuts down."""
        while True:
            await asyncio.sleep(60)
            try:
                ok = await bot.check_fee(announce=True)
                if not ok:
                    bot.log("CRITICAL", "Maker fee > 0! Flatted positions and terminating bot immediately.")
                    if bot.pos:
                        await bot.flatten_all("maker fee above 0")
                    os._exit(1)
            except Exception as e:
                bot.log("FEE WATCH", str(e)[:120])

    asyncio.create_task(fee_watch())

    async def state_ticker_loop():
        while True:
            try:
                bot.write_state(force=True)
            except Exception:
                pass
            await asyncio.sleep(0.5)

    asyncio.create_task(state_ticker_loop())

    # Market data stream: always connect to real Binance Futures for continuous live market ticks
    market_client = await AsyncClient.create(testnet=False)
    sm = BinanceSocketManager(market_client)
    while True:
        try:
            async with sm.symbol_ticker_futures_socket(args.symbol) as stream:
                while True:
                    msg = await stream.recv()
                    d = msg.get("data", msg)
                    if "b" in d and "a" in d:
                        px = (float(d["b"]) + float(d["a"])) / 2
                        await bot.on_trade(px, d.get("E", int(time.time() * 1000)))
                    elif "p" in d:
                        await bot.on_trade(d["p"], d.get("T", int(time.time() * 1000)))
        except Exception as e:
            bot.log("RECONNECT", str(e)[:140])
            await asyncio.sleep(2)


if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        print("\nstopped")
