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))
|