← back to Zero Code Trading Bot

assets/bot-template/ledger.py

116 lines

"""
ledger.py — append-only paper-trading ledger + position/P&L accounting.

Every fill is appended as one JSON line to data/ledger.jsonl (append-only = you
can always reconstruct state and never silently lose a trade). Open positions and
realized/unrealized P&L are derived by replaying the log — there is no mutable
"current state" file to corrupt.

Paper only. The live path (executor.py) is gated and does not write here until a
real broker fill is confirmed.
"""

import json
import os
from datetime import datetime, timezone

LEDGER_PATH = os.path.join(os.path.dirname(__file__), "data", "ledger.jsonl")


def _now():
    return datetime.now(timezone.utc).isoformat()


def _ensure_dir():
    os.makedirs(os.path.dirname(LEDGER_PATH), exist_ok=True)


def record_fill(market_id, title, side, action, price, size_usd, reason, mode):
    """Append one fill. action is 'open' or 'close'."""
    _ensure_dir()
    row = {
        "ts": _now(), "market_id": market_id, "title": title, "side": side,
        "action": action, "price": round(float(price), 4),
        "size_usd": round(float(size_usd), 2), "reason": reason, "mode": mode,
    }
    with open(LEDGER_PATH, "a") as f:
        f.write(json.dumps(row) + "\n")
    return row


def _read_all():
    if not os.path.exists(LEDGER_PATH):
        return []
    with open(LEDGER_PATH) as f:
        return [json.loads(line) for line in f if line.strip()]


def open_positions():
    """Replay the log → dict of market_id -> open position."""
    positions = {}
    for r in _read_all():
        mid = r["market_id"]
        if r["action"] == "open":
            positions[mid] = {
                "market_id": mid, "title": r["title"], "side": r["side"],
                "entry_price": r["price"], "size_usd": r["size_usd"],
                "shares": r["size_usd"] / r["price"] if r["price"] else 0.0,
                "opened": r["ts"],
            }
        elif r["action"] == "close":
            positions.pop(mid, None)
    return positions


def realized_pnl():
    """Sum realized P&L across all closed round-trips."""
    entries, pnl = {}, 0.0
    for r in _read_all():
        mid = r["market_id"]
        if r["action"] == "open":
            entries[mid] = r
        elif r["action"] == "close" and mid in entries:
            e = entries.pop(mid)
            shares = e["size_usd"] / e["price"] if e["price"] else 0.0
            pnl += shares * (r["price"] - e["price"])
    return round(pnl, 2)


def total_exposure_usd():
    return round(sum(p["size_usd"] for p in open_positions().values()), 2)


def pnl_today_usd():
    today = datetime.now(timezone.utc).date().isoformat()
    entries, pnl = {}, 0.0
    for r in _read_all():
        mid = r["market_id"]
        if r["action"] == "open":
            entries[mid] = r
        elif r["action"] == "close" and mid in entries:
            e = entries.pop(mid)
            if r["ts"][:10] == today:
                shares = e["size_usd"] / e["price"] if e["price"] else 0.0
                pnl += shares * (r["price"] - e["price"])
    return round(pnl, 2)


def summary(mark_fn=None):
    """Snapshot for the dashboard/CLI. mark_fn(market_id)->price gives unrealized."""
    pos = open_positions()
    unreal = 0.0
    for p in pos.values():
        mark = mark_fn(p["market_id"]) if mark_fn else None
        if mark is not None:
            unreal += p["shares"] * (mark - p["entry_price"])
            p["mark"] = round(mark, 4)
            p["unrealized_usd"] = round(p["shares"] * (mark - p["entry_price"]), 2)
    return {
        "open_positions": list(pos.values()),
        "open_count": len(pos),
        "exposure_usd": total_exposure_usd(),
        "realized_pnl_usd": realized_pnl(),
        "unrealized_pnl_usd": round(unreal, 2),
        "pnl_today_usd": pnl_today_usd(),
    }