"""
Venue-agnostic execution layer for the Renko line bot.

The strategy decides LONG / SHORT / CLOSE at the line. This file decides *how* that
becomes an order, and how the bot recovers if it was switched off mid-trade.

  PaperBroker   — works today. Fills at the line (or line +/- slip), charges fees,
                  keeps a CSV, reconciles against its own state file.
  LighterBroker — the real venue. Deliberately refuses to trade until it is given a
                  testnet/mainnet client and an API key, because sending invented
                  calls to a live exchange is how accounts die.

LIGHTER FACTS (verified 2026-11-11, docs.lighter.xyz + apidocs.lighter.xyz):
  * official Python SDK:  pip install lighter-sdk     (elliottech/lighter-python)
  * REST + WebSocket + transaction signing are all in the SDK
  * API keys are created with ChangePubKey, signed by the L1 wallet, and can
    trade / cancel / change leverage WITHOUT withdrawal rights — use that
  * testnet is a separate environment with separate accounts, balances, API keys,
    nonces and chain id. Get the exact URLs from
    https://docs.lighter.xyz/developers -> "Environments & endpoints"
  * Standard accounts pay 0% maker / 0% taker on regular orders, 300ms taker latency
  * funding is charged hourly on notional (it is not a trading fee and cannot be avoided)

Usage:
    from broker import PaperBroker
    b = PaperBroker(capital=100, leverage=20)
    b.open("LONG", price=1150.0, line=1150.0)
    b.close(price=1151.15, reason="line touch")
    b.reconcile(expected="LONG")     # what to call on every boot

Before real money: run this against Lighter TESTNET, then mainnet with $50.
"""
from __future__ import annotations

import csv
import json
import os
from dataclasses import dataclass, asdict
from datetime import datetime, timezone
from decimal import Decimal as D, ROUND_DOWN


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


@dataclass
class Position:
    side: str                 # "LONG" | "SHORT"
    qty: D
    entry: D
    notional: D
    fee_paid: D
    opened: str
    stop: D | None = None     # exchange-side disaster stop, 1 box beyond the line
    carried: bool = False     # held through the run

    def jsonable(self):
        d = asdict(self)
        return {k: (str(v) if isinstance(v, D) else v) for k, v in d.items()}


class Broker:
    """Interface. Every venue implements these five calls and nothing else."""

    def open(self, side, price, line):
        raise NotImplementedError

    def close(self, price, reason="line touch"):
        raise NotImplementedError

    def position(self):
        raise NotImplementedError

    def reconcile(self, expected_side=None):
        raise NotImplementedError

    def move_to_savings(self, amount):
        raise NotImplementedError


class PaperBroker(Broker):
    """Paper fills. 'line' fills at the line exactly; 'market' adds one tick."""

    def __init__(self, capital=D("100"), leverage=20, fee=D("0"), qty_step=D("0.001"),
                 min_notional=D("5"), tick=D("0.01"), fill="line",
                 state_file="broker_state.json", log_file="broker_fills.csv"):
        self.capital = D(str(capital))
        self.savings = D("0")
        self.leverage, self.fee, self.qty_step = D(str(leverage)), D(str(fee)), qty_step
        self.min_notional, self.tick, self.fill = min_notional, tick, fill
        self.state_file, self.log_file = state_file, log_file
        self.pos = None
        self._load()

    # -- helpers -----------------------------------------------------------
    def _load(self):
        if os.path.exists(self.state_file):
            s = json.load(open(self.state_file))
            self.capital, self.savings = D(s["capital"]), D(s["savings"])
            p = s.get("pos")
            if p:
                self.pos = Position(p["side"], D(p["qty"]), D(p["entry"]), D(p["notional"]),
                                    D(p["fee_paid"]), p["opened"],
                                    D(p["stop"]) if p.get("stop") else None,
                                    p.get("carried", False))

    def _save(self):
        json.dump({"capital": str(self.capital), "savings": str(self.savings),
                   "pos": self.pos.jsonable() if self.pos else None},
                  open(self.state_file, "w"), indent=1)

    def _log(self, row):
        new = not os.path.exists(self.log_file)
        with open(self.log_file, "a", newline="") as f:
            w = csv.writer(f)
            if new:
                w.writerow(["time", "action", "side", "qty", "price", "fee", "pnl",
                            "capital", "savings", "reason"])
            w.writerow(row)

    def _size(self, price):
        raw = self.capital * D("0.95") * self.leverage / price
        return (raw / self.qty_step).to_integral_value(rounding=ROUND_DOWN) * self.qty_step

    # -- interface ---------------------------------------------------------
    def open(self, side, price, line, stop_boxes=D("1")):
        if self.pos:
            raise RuntimeError("already in a position")
        price = D(str(price))
        px = price if self.fill == "line" else price + (self.tick if side == "LONG" else -self.tick)
        qty = self._size(px)
        notional = qty * px
        if notional < self.min_notional:
            raise ValueError(f"notional {notional:.2f} < venue minimum {self.min_notional}")
        fee = notional * self.fee
        self.capital -= fee
        off = D(str(line)) * D("0.001") * stop_boxes        # 1 box beyond the line
        stop = D(str(line)) - off if side == "LONG" else D(str(line)) + off
        self.pos = Position(side, qty, px, notional, fee, now(), stop)
        self._log([now(), "OPEN", side, str(qty), f"{px:.2f}", f"{fee:.4f}", "",
                   f"{self.capital:.4f}", f"{self.savings:.4f}", "line touch"])
        self._save()
        return self.pos

    def close(self, price, reason="line touch"):
        if not self.pos:
            raise RuntimeError("no position")
        p, price = self.pos, D(str(price))
        px = price if self.fill == "line" else price - (self.tick if p.side == "LONG" else -self.tick)
        direction = D("1") if p.side == "LONG" else D("-1")
        gross = direction * (px - p.entry) * p.qty
        fee_c = px * p.qty * self.fee
        net = gross - p.fee_paid - fee_c
        self.capital += gross - fee_c
        self.pos = None
        self._log([now(), "CLOSE", p.side, str(p.qty), f"{px:.2f}", f"{fee_c:.4f}",
                   f"{net:.4f}", f"{self.capital:.4f}", f"{self.savings:.4f}", reason])
        self._save()
        return net

    def position(self):
        return self.pos

    def reconcile(self, expected_side=None):
        """Call on every boot. Never assume flat."""
        have, want = (self.pos.side if self.pos else None), expected_side
        if have == want:
            self._log([now(), "RECONCILE", have or "flat", "", "", "", "",
                       f"{self.capital:.4f}", f"{self.savings:.4f}", "in sync"])
            return f"in sync ({have or 'flat'})"
        if have is None and want:
            return f"MISMATCH: bot wanted {want}, venue is flat - treat as flat"
        if have and want is None:
            self.pos.carried = True
            self._save()
            return f"MISMATCH: venue holds {have} unknown to the bot - adopted, marked carried"
        return f"MISMATCH: venue {have} vs bot {want} - stop and fix before trading"

    def move_to_savings(self, amount):
        amount = min(D(str(amount)), max(self.capital, D("0")))
        self.capital -= amount
        self.savings += amount
        self._log([now(), "SAVINGS", "", "", "", "", "", f"{self.capital:.4f}",
                   f"{self.savings:.4f}", f"moved {amount:.4f}"])
        self._save()
        return amount


class LighterBroker(Broker):
    """
    The real venue. Intentionally inert until wired to a SIGNED client.

    Wiring it up (in this order, no shortcuts):
      1. pip install lighter-sdk
      2. read https://docs.lighter.xyz/developers -> Environments & endpoints
      3. TESTNET first: create an account, create an API key with ChangePubKey signed by
         the L1 wallet (trade/cancel only - no withdrawal rights)
      4. implement open/close as a MARKET order on the ZEC market, plus a STOP order at
         Position.stop as the disaster exit
      5. call reconcile() on boot before any strategy decision

    Do not fill these in from memory. The SDK's own examples in
    elliottech/lighter-python are the reference; a wrong payload here is a wrong fill.
    """

    def __init__(self, client=None, market="ZEC", leverage=20, fee=D("0"), testnet=True):
        self.client, self.market = client, market
        self.leverage, self.fee, self.testnet = leverage, D(str(fee)), testnet
        self._pos = None

    def _guard(self):
        if self.client is None:
            raise RuntimeError(
                "LighterBroker has no signed client. Wire it against TESTNET first - "
                "see this docstring and OPS.md section 6.")

    def open(self, side, price, line):
        self._guard()
        raise NotImplementedError("market order + stop order: implement from the SDK examples")

    def close(self, price, reason="line touch"):
        self._guard()
        raise NotImplementedError("market close: implement from the SDK examples")

    def position(self):
        return self._pos

    def reconcile(self, expected_side=None):
        self._guard()
        raise NotImplementedError("read positions from the venue, compare with expected_side")

    def move_to_savings(self, amount):
        raise NotImplementedError(
            "withdrawals need a withdrawal-capable path; decide the custody trade-off first "
            "(OPS.md section 6) rather than reusing the trading key")


if __name__ == "__main__":
    b = PaperBroker(capital=100, leverage=20, fee=0)
    b.open("LONG", price=1150.00, line=1150.00)
    pnl = b.close(price=1151.15, reason="held trade closed at the line")
    print("paper PnL:", pnl, "| capital", b.capital, "| stop was", )
    print("reconcile (flat as expected):", b.reconcile(None))
    b.open("SHORT", price=1150.00, line=1150.00)
    print("reconcile (bot thought flat):", b.reconcile(None))
    print("reconcile (both short):", b.reconcile("SHORT"))
    print("savings move:", b.move_to_savings(D("0.10")))
