File size: 12,795 Bytes
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b7ab0e
1a3ee73
 
 
0f038be
ad0694f
 
2b7ab0e
1a3ee73
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad0694f
 
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad0694f
 
 
 
 
0f038be
 
5c8ea79
 
 
0f038be
 
 
 
 
 
 
5c8ea79
 
 
0f038be
 
 
5c8ea79
 
 
 
0f038be
 
 
 
5c8ea79
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4afe541
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b7ab0e
 
 
 
 
 
 
 
 
 
 
 
 
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad0694f
0f038be
ad0694f
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
"""Positions and fills bookkeeping per book (live, paper_selector).

Paper cash is derived, never stored: cash = CAPITAL_SOL + Σ realized_sol(all) − Σ cost_sol(open).
realized_sol accumulates per position: each partial sale books proceeds − the cost it retires (cost_sol
shrinks by that part) and the close adds proceeds − the remaining cost, so an open position may carry
realized P&L from partial sales.
Live cash is the on-chain SOL balance. Every open/close writes positions rows; every fill writes a
fills row (verified_by = 'balance_delta' for live, 'model' for paper).
"""
from __future__ import annotations

import json
import logging
from datetime import datetime, timezone

from .. import config

log = logging.getLogger(__name__)


def open_positions(conn, book: str) -> list[dict]:
    """Open positions of ``book`` with what trading them needs; ``decision_ts`` is the entry decision's minute (its ``ts``,
    the minute end a paper position opens at): a live position's hold runs from it, not from its fill 10–20 s later.
    ``take_profit_x``: the entry decision's take-profit line, a multiple of the entry price (None: held its hold), which a
    live position shares with its paper entry (agent/paper_trading.take_profit_x)."""
    rows = conn.execute(
        """SELECT p.*, COALESCE(t.decimals, CASE WHEN p.chain = 'rh' THEN 18 END) AS decimals, t.graduated_at,
                  COALESCE(wp.program_label, CASE WHEN p.chain = 'rh' THEN 'Pons v4' END) AS program_label,
                  COALESCE(p.hold_s, (d.detail->>'hold_s')::float) AS hold_s, COALESCE(p.strategy, d.detail->>'strategy') AS strategy,
                  d.ts AS decision_ts, (d.detail->>'take_profit_x')::float AS take_profit_x
           FROM positions p LEFT JOIN tokens t ON t.mint = p.mint
           LEFT JOIN watch_pools wp ON wp.pool = p.pool
           LEFT JOIN decisions d ON d.id = p.entry_decision_id
           WHERE p.book = %s AND p.status = 'open' ORDER BY p.opened_at""",
        (book,),
    ).fetchall()
    return [dict(r) for r in rows]


def paper_cash(conn, book: str) -> float:
    r = conn.execute(
        """SELECT COALESCE(sum(realized_sol), 0) AS realized,
                  COALESCE(sum(cost_sol) FILTER (WHERE status = 'open'), 0) AS open_cost
           FROM positions WHERE book = %s""",
        (book,),
    ).fetchone()
    from ..markets import for_book
    return for_book(book).capital() + float(r["realized"]) - float(r["open_cost"])        # the book's own market's capital (SOL / ETH)


def record_fill(conn, *, order_id: int | None, book: str, mint: str, side: str, token_delta: int,
                sol_delta_lamports: int, price_sol: float, fee_lamports: int, verified_by: str,
                signature: str | None = None, slot: int | None = None, ts: datetime | None = None,
                pre: dict | None = None, post: dict | None = None) -> int:
    row = conn.execute(
        """INSERT INTO fills (order_id, book, ts, signature, slot, mint, side, token_delta, sol_delta_lamports,
             price_sol, fee_lamports, verified_by, pre_snapshot, post_snapshot)
           VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) RETURNING id""",
        (order_id, book, ts or datetime.now(timezone.utc), signature, slot, mint, side, token_delta, sol_delta_lamports,
         price_sol, fee_lamports, verified_by, json.dumps(pre) if pre else None, json.dumps(post) if post else None),
    ).fetchone()
    return int(row["id"])


def _chain_of(book: str) -> str:
    from ..markets import for_book
    return for_book(book).chain


def open_position(conn, *, book: str, mint: str, pool: str | None, qty_raw: int, cost_sol: float,
                  entry_price: float, decision_id: int | None, fees_sol: float, ts: datetime | None = None,
                  hold_s: float | None = None, strategy: str | None = None, deposit_sol: float = 0.0) -> int:
    """``deposit_sol``: what the buy put into the mint's token account (its rent, a first live buy), kept apart from
    ``cost_sol``: the account's close returns it (``refund_deposits``)."""
    ts = ts or datetime.now(timezone.utc)
    existing = conn.execute("SELECT id, qty, cost_sol, entry_price FROM positions WHERE book=%s AND mint=%s AND status='open'",
                            (book, mint)).fetchone()
    if existing:  # add to an open position: average the entry
        q0, c0 = int(existing["qty"]), float(existing["cost_sol"])
        q1, c1 = q0 + qty_raw, c0 + cost_sol
        p1 = (float(existing["entry_price"] or entry_price) * q0 + entry_price * qty_raw) / max(q1, 1)
        conn.execute("UPDATE positions SET qty=%s, cost_sol=%s, entry_price=%s, fees_sol=fees_sol+%s, last_mark_price=%s, last_mark_ts=%s, "
                     "deposit_sol = NULLIF(COALESCE(deposit_sol, 0) + %s, 0) WHERE id=%s",
                     (q1, c1, p1, fees_sol, entry_price, ts, deposit_sol, existing["id"]))
        return int(existing["id"])
    row = conn.execute(
        """INSERT INTO positions (book, mint, pool, opened_at, entry_decision_id, qty, cost_sol, entry_price, peak_price,
             last_mark_price, last_mark_ts, fees_sol, status, last_swap_ts, hold_s, strategy, chain, deposit_sol)
           VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'open',%s,%s,%s,%s,%s) RETURNING id""",
        (book, mint, pool, ts, decision_id, qty_raw, cost_sol, entry_price, entry_price, entry_price, ts, fees_sol, ts, hold_s, strategy, _chain_of(book),
         deposit_sol or None),
    ).fetchone()
    return int(row["id"])


def refund_deposits(conn, book: str, mint: str) -> float:
    """The mint's token-account deposits not yet returned (paid by the buy that opened the account, whichever position
    of the mint closes it), marked returned; their total in SOL. 0 when none was booked apart (a position bought before
    deposits were: its cost holds the rent, so the whole refund is its own)."""
    rows = conn.execute("UPDATE positions SET deposit_refunded = true WHERE book = %s AND mint = %s AND COALESCE(deposit_sol, 0) > 0 AND NOT deposit_refunded "
                        "RETURNING deposit_sol", (book, mint)).fetchall()
    return sum(float(r["deposit_sol"]) for r in rows)


def deposits_held(conn, book: str) -> float:
    """SOL the book's token accounts hold as deposits (their rent) until their close returns it: part of its wealth."""
    r = conn.execute("SELECT COALESCE(sum(deposit_sol), 0) AS s FROM positions WHERE book = %s AND COALESCE(deposit_sol, 0) > 0 AND NOT deposit_refunded",
                     (book,)).fetchone()
    return float(r["s"])


def book_account_close(conn, book: str, mint: str, lamports: int, closed: bool = True) -> tuple[float, int | None]:
    """A close of the mint's token account moved ``lamports`` into the wallet (its rent less its share of the fee; only
    the fee, < 0, when it failed and ``closed`` is False): the deposit the buy that opened it booked apart comes back
    (``refund_deposits``) and the rest -- the fee, or the whole rent of an account opened before deposits were booked
    apart -- is the realized P&L of the mint's latest closed position, whose sale emptied it. Returns (SOL credited, that
    position); no position when the book never held the mint (an account opened for the wallet by someone else)."""
    rest = lamports / config.LAMPORTS_PER_SOL - (refund_deposits(conn, book, mint) if closed else 0.0)
    r = conn.execute("SELECT id FROM positions WHERE book = %s AND mint = %s AND status = 'closed' ORDER BY closed_at DESC NULLS LAST, id DESC LIMIT 1",
                     (book, mint)).fetchone()
    if r is None:
        return rest, None
    credit(conn, int(r["id"]), rest)
    return rest, int(r["id"])


def realize_partial(conn, *, position_id: int, qty_raw: int, cost_part: float, proceeds_sol: float, fees_sol: float,
                    price: float, ts: datetime | None = None) -> float | None:
    """Shrink an open position by qty_raw / cost_part and book proceeds − cost_part into realized_sol.
    Returns this sale's realized P&L, or None when the position is no longer open (nothing booked)."""
    ts = ts or datetime.now(timezone.utc)
    realized = proceeds_sol - cost_part
    r = conn.execute(
        """UPDATE positions SET qty = qty - %s, cost_sol = cost_sol - %s, realized_sol = COALESCE(realized_sol, 0) + %s,
             fees_sol = fees_sol + %s, last_mark_price=%s, last_mark_ts=%s WHERE id=%s AND status='open' RETURNING id""",
        (qty_raw, cost_part, realized, fees_sol, price, ts, position_id)).fetchone()
    return realized if r else None


def close_position(conn, *, position_id: int, exit_price: float, proceeds_sol: float, fees_sol: float,
                   decision_id: int | None, forced_kind: str | None, ts: datetime | None = None) -> float | None:
    """Close an open position; realized_sol accumulates (earlier partial sales + proceeds − remaining cost).
    Returns this sale's realized P&L, or None when the position was not open (already closed: nothing booked)."""
    ts = ts or datetime.now(timezone.utc)
    r = conn.execute(
        """UPDATE positions SET status='closed', closed_at=%s, exit_decision_id=%s, exit_price=%s,
             realized_sol=COALESCE(realized_sol, 0) + %s - cost_sol, fees_sol=fees_sol+%s, forced_exit_kind=%s,
             last_mark_price=%s, last_mark_ts=%s WHERE id=%s AND status='open' RETURNING %s - cost_sol AS realized""",
        (ts, decision_id, exit_price, proceeds_sol, fees_sol, forced_kind, exit_price, ts, position_id, proceeds_sol),
    ).fetchone()
    if r is None:
        log.warning("close_position: position %s is not open; nothing booked", position_id)
        return None
    return float(r["realized"])


def credit(conn, position_id: int, sol: float) -> None:
    """Book ``sol`` that reached the wallet after a position closed (its token account's rent refunded, a dead-bag's tokens
    swept) onto that position's realized P&L."""
    conn.execute("UPDATE positions SET realized_sol = COALESCE(realized_sol, 0) + %s WHERE id = %s", (sol, position_id))


def last_dead_bag(conn, book: str, mint: str) -> int | None:
    """The most recent position of ``mint`` written off as a dead-bag in ``book`` (its later sweeps' proceeds are its)."""
    r = conn.execute("SELECT id FROM positions WHERE book = %s AND mint = %s AND forced_exit_kind = 'dead_bag' ORDER BY closed_at DESC NULLS LAST, id DESC LIMIT 1",
                     (book, mint)).fetchone()
    return int(r["id"]) if r else None


def mark_positions(conn, book: str, prices: dict[str, float], last_swaps: dict[str, float], ts: datetime,
                   dt_s: float = 0.0) -> None:
    """Mark to the last price; track the peak; accrue satiety = Σ max(unrealized return, 0) × dt while in profit."""
    rows = conn.execute("SELECT id, mint, peak_price, entry_price, satiety FROM positions WHERE book=%s AND status='open'", (book,)).fetchall()
    for r in rows:
        p = prices.get(r["mint"])
        if p is None:
            continue
        peak = max(float(r["peak_price"] or 0.0), p)
        entry = float(r["entry_price"] or 0.0)
        u = (p / entry - 1.0) if entry > 0 else 0.0
        sat = float(r["satiety"] or 0.0) + max(u, 0.0) * dt_s
        ls = last_swaps.get(r["mint"])
        conn.execute("UPDATE positions SET last_mark_price=%s, last_mark_ts=%s, peak_price=%s, satiety=%s, "
                     "last_swap_ts=COALESCE(%s, last_swap_ts) WHERE id=%s",
                     (p, ts, peak, sat, datetime.fromtimestamp(ls, tz=timezone.utc) if ls else None, r["id"]))


def verify_fills() -> None:
    """Reconcile live positions against on-chain token balances; prints mismatches, writes wallet_events."""
    from ..chain.rpc import HttpSolanaRpc
    from ..db.connection import transaction
    from ..signer import client
    rpc = HttpSolanaRpc()
    pub = client.get().call("pubkeys")["trading"]
    accounts = rpc.get_token_accounts_by_owner(pub)
    onchain = {a["mint"]: int(a["amount"]) for a in accounts}
    with transaction() as conn:
        pos = open_positions(conn, "live")
        mismatches = []
        for p in pos:
            have = onchain.get(p["mint"], 0)
            if abs(have - int(p["qty"])) > max(1, int(p["qty"]) // 1000):
                mismatches.append({"mint": p["mint"], "db_qty": int(p["qty"]), "onchain": have})
        stray = [m for m, a in onchain.items() if a > 0 and m not in {p["mint"] for p in pos} and m != config.WSOL_MINT]
        conn.execute("INSERT INTO wallet_events (kind, pubkey, detail) VALUES ('verify_fills', %s, %s)",
                     (pub, json.dumps({"mismatches": mismatches, "stray_mints": stray, "n_positions": len(pos)})))
    print(json.dumps({"positions": len(pos), "mismatches": mismatches, "stray_mints": stray}, indent=1))