File size: 13,003 Bytes
0f038be
 
 
 
2b7ab0e
 
 
4afe541
 
 
 
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b7ab0e
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b7ab0e
 
 
 
0f038be
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2b7ab0e
 
0f038be
 
 
 
 
 
 
2b7ab0e
0f038be
2b7ab0e
0f038be
 
4afe541
 
0f038be
 
 
2b7ab0e
 
0f038be
2b7ab0e
 
0f038be
2b7ab0e
0f038be
 
 
 
 
 
 
2b7ab0e
 
 
 
 
0f038be
 
 
 
 
 
 
 
 
2b7ab0e
 
 
 
 
 
 
 
 
 
 
0f038be
2b7ab0e
 
 
 
 
 
 
 
 
0f038be
 
 
 
 
 
 
2b7ab0e
 
 
 
 
 
4afe541
2b7ab0e
0f038be
4afe541
 
 
 
 
0f038be
 
6570567
0f038be
6570567
2b7ab0e
 
0f038be
 
2b7ab0e
 
0f038be
2b7ab0e
 
 
0f038be
 
 
 
 
 
 
5c8ea79
 
0f038be
 
 
5c8ea79
 
0f038be
 
2b7ab0e
 
 
 
 
 
 
0f038be
2b7ab0e
0f038be
 
 
 
 
 
 
 
 
 
2b7ab0e
0f038be
2b7ab0e
0f038be
 
 
2b7ab0e
 
 
0f038be
 
 
2b7ab0e
 
 
 
 
 
 
4afe541
 
 
 
2b7ab0e
4afe541
 
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
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
"""Execution queue for the live book: the caller never blocks on Jupiter.

The caller enqueues ExecRequest objects; one background thread executes them sequentially through
LiveBroker (one in-flight order at a time, balance-verified); results are drained by the caller, which
opens/closes positions accordingly. A mint with a request queued or in flight takes no other, except a buy queued
behind a lone sale of it (its paper book sold and bought the token again in one minute): the sale runs first, and the
buy is refused as held if the sale left the position open. ``busy`` is held from a swap to its booking, so whoever reads
the wallet and the books under it sees them agree. A sale that leaves the live book without its mint marks the mint's
token account ``emptied``: the mirror closes it mid-minute with nothing queued (agent/fly_live.close_accounts), never in
front of an order, and books the rent it returns. ``stop`` lets the request in flight finish (bounded) and drops the
queued ones: exits are retried next minute, entries by the mirror (agent/fly_live.py).
"""
from __future__ import annotations

import logging
import queue
import threading
import traceback
from dataclasses import dataclass, field
from datetime import datetime, timezone

from .. import config
from ..db.connection import transaction
from ..agent import rails
from . import ledger

log = logging.getLogger(__name__)
STOP_JOIN_S = 120.0           # the longest a stop waits for the request in flight (a swap polls until its blockhash expires)


@dataclass
class ExecRequest:
    decision_id: int | None
    mint: str
    pool: str | None
    side: str                     # buy | sell
    amount_in: int                # lamports (buy) or raw token units (sell)
    slippage_bps: int
    max_slippage_bps: int
    decimals: int
    forced_kind: str | None = None
    position_id: int | None = None
    size_sol: float = 0.0
    hold_s: float | None = None   # buy: the entry's own hold and strategy, booked on its position
    strategy: str | None = None
    ref_price: float | None = None   # buy: its decision's paper fill price (SOL per token): a quote above it by more than MAX_ENTRY_CHASE is refused
    retry: int = 0                # a buy sent again after a failure (agent/fly_live.py): its failure was counted on the circuit the first time
    enqueued_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


@dataclass
class ExecResult:
    request: ExecRequest
    ok: bool
    fill_id: int | None = None
    signature: str | None = None
    token_delta: int = 0
    lamports_delta: int = 0
    price_sol: float | None = None
    error: str | None = None
    position_id: int | None = None
    realized_sol: float | None = None
    retryable: bool = False       # a failed buy that moved nothing (no fill row, no unknown outcome): it may be sent again
    closed: bool = False          # a sale that closed its position (or swept a written-off one)


class ExecutionWorker:
    def __init__(self, broker):
        self.broker = broker
        self.q: queue.Queue[ExecRequest] = queue.Queue()
        self.results: queue.Queue[ExecResult] = queue.Queue()
        self.inflight: dict[str, list[str]] = {}       # mint -> sides queued or in flight, in order
        self.lock = threading.Lock()
        self.busy = threading.Lock()                    # held from a request's swap to its booking
        self.thread = threading.Thread(target=self._loop, name="runner-exec", daemon=True)   # routed to the runner's log
        self.stop_flag = False
        self.on_done = None                            # called with each result once booked (the mirror's live stats: agent/fly_live.py)
        self.emptied: set[str] = set()                 # mints whose sale left their token account empty: closed by the mirror, mid-minute
        self.thread.start()

    def submit(self, req: ExecRequest) -> bool:
        """Queue ``req``; False when its mint already has a request queued or in flight (a buy may queue behind a lone
        sale of it) or the worker is stopping."""
        with self.lock:
            sides = self.inflight.get(req.mint)
            if self.stop_flag or (sides and not (req.side == "buy" and sides == ["sell"])):
                return False
            self.inflight.setdefault(req.mint, []).append(req.side)
        self.q.put(req)
        return True

    def pending(self) -> set[str]:
        with self.lock:
            return set(self.inflight)

    def selling(self) -> set[str]:
        """Mints whose only request queued or in flight is a sale: a buy of one may queue behind it."""
        with self.lock:
            return {m for m, sides in self.inflight.items() if sides == ["sell"]}

    def drain(self) -> list[ExecResult]:
        out = []
        while True:
            try:
                out.append(self.results.get_nowait())
            except queue.Empty:
                break
        return out

    def _release(self, req: ExecRequest) -> None:
        with self.lock:
            sides = self.inflight.get(req.mint)
            if sides and req.side in sides:
                sides.remove(req.side)
            if not sides:
                self.inflight.pop(req.mint, None)

    def stop(self, timeout: float = STOP_JOIN_S) -> None:
        """Let the request in flight finish — a process killed mid-swap can leave a landed swap unbooked — drop the
        queued ones, and wait for the thread (at most ``timeout``)."""
        self.stop_flag = True
        while True:
            try:
                self._release(self.q.get_nowait())
            except queue.Empty:
                break
        if self.thread.is_alive() and self.thread is not threading.current_thread():
            self.thread.join(timeout)
            if self.thread.is_alive():
                log.error("execution worker: the request in flight did not finish within %.0f s of the stop", timeout)

    def _loop(self) -> None:
        while not self.stop_flag:
            try:
                req = self.q.get(timeout=1.0)
            except queue.Empty:
                continue
            if self.stop_flag:                          # stopping: a queued request is dropped, never started
                self._release(req)
                break
            with self.busy:
                res = self._execute(req)
                if res.ok and res.closed:
                    self._emptied(req)
            self._release(req)
            self.results.put(res)
            if self.on_done is not None:
                try:
                    self.on_done(res)
                except Exception:
                    log.exception("execution worker: the after-order hook failed (the order stands)")

    def _execute(self, req: ExecRequest) -> ExecResult:
        from ..vault import walletlock                 # the vault fly: swap + ledger commit are one step for its snapshots
        try:
            with walletlock.maybe_exclusive(config.VAULT_ENABLED), transaction() as conn:
                if req.side == "buy" and conn.execute("SELECT 1 FROM positions WHERE book = 'live' AND mint = %s AND status = 'open'", (req.mint,)).fetchone():
                    return ExecResult(req, False, error="held: the live book still holds this token (the sale before this buy did not close it)", retryable=True)
                fr = self.broker.swap(conn, decision_id=req.decision_id, book="live", side=req.side, mint=req.mint,
                                      amount_in=req.amount_in, slippage_bps=req.slippage_bps,
                                      max_slippage_bps=req.max_slippage_bps, step_bps=config.SLIPPAGE_STEP_BPS,
                                      ref_price=req.ref_price if req.side == "buy" else None, decimals=req.decimals)
                if not fr.ok:
                    if self._counts(req) and fr.status != "price_ran":     # the market ran away from the decision: no execution failed
                        rails.record_failure(conn, fr.error or fr.status or "swap failed")
                    return ExecResult(req, False, error=fr.error or fr.status, retryable=req.side == "buy" and fr.fill_id is None)
                rails.record_success(conn)
                ts = datetime.now(timezone.utc)
                if req.side == "buy":
                    cost = -fr.lamports_delta / config.LAMPORTS_PER_SOL
                    price = fr.price_sol or (cost / max(fr.token_delta / (10 ** req.decimals), 1e-12))
                    pid = ledger.open_position(conn, book="live", mint=req.mint, pool=req.pool, qty_raw=fr.token_delta,
                                               cost_sol=cost, entry_price=price, decision_id=req.decision_id,
                                               fees_sol=0.0, ts=ts, hold_s=req.hold_s, strategy=req.strategy,
                                               deposit_sol=max(fr.deposit_lamports, 0) / config.LAMPORTS_PER_SOL)
                    rails.record_notional(conn, cost, "entry")
                    return ExecResult(req, True, fr.fill_id, fr.signature, fr.token_delta, fr.lamports_delta, price, position_id=pid)
                else:
                    if fr.deposit_lamports < 0:                # the swap closed the token account itself: its deposit is back
                        ledger.refund_deposits(conn, "live", req.mint)
                    proceeds = fr.lamports_delta / config.LAMPORTS_PER_SOL
                    price = fr.price_sol if fr.price_sol is not None else (proceeds / max(abs(fr.token_delta) / (10 ** req.decimals), 1e-12))
                    if req.forced_kind == "dead_bag_sweep":    # a written-off position's tokens: what they fetch is that position's
                        pid = ledger.last_dead_bag(conn, "live", req.mint)
                        if pid is not None:
                            ledger.credit(conn, pid, proceeds)
                        return ExecResult(req, True, fr.fill_id, fr.signature, fr.token_delta, fr.lamports_delta, price,
                                          position_id=pid, realized_sol=proceeds if pid is not None else None, closed=pid is not None)
                    pos = conn.execute("SELECT * FROM positions WHERE id=%s FOR UPDATE", (req.position_id,)).fetchone()
                    remaining = int(pos["qty"]) + fr.token_delta if pos else 0  # token_delta negative
                    closed = False
                    if pos is None or pos["status"] != "open":   # closed elsewhere: the fill row stands, nothing is booked twice
                        log.warning("sell of %s filled but position %s is not open; not booked", req.mint, req.position_id)
                        realized = None
                    elif remaining > max(1, int(pos["qty"]) // 200):  # partial fill: keep the remainder open
                        realized = ledger.realize_partial(conn, position_id=req.position_id, qty_raw=-fr.token_delta,
                                                          cost_part=float(pos["cost_sol"]) * (1 - remaining / max(int(pos["qty"]), 1)),
                                                          proceeds_sol=proceeds, fees_sol=0.0, price=price, ts=ts)
                    else:
                        realized = ledger.close_position(conn, position_id=req.position_id, exit_price=price, proceeds_sol=proceeds,
                                                         fees_sol=0.0, decision_id=req.decision_id, forced_kind=req.forced_kind, ts=ts)
                        closed = realized is not None
                    return ExecResult(req, True, fr.fill_id, fr.signature, fr.token_delta, fr.lamports_delta, price,
                                      position_id=req.position_id, realized_sol=realized, closed=closed)
        except Exception as e:
            log.error("execution error for %s: %s\n%s", req.mint, e, traceback.format_exc())
            try:
                if self._counts(req):
                    with transaction() as conn:
                        rails.record_failure(conn, f"{type(e).__name__}: {e}")
            except Exception:
                pass
            return ExecResult(req, False, error=f"{type(e).__name__}: {e}")

    @staticmethod
    def _counts(req: ExecRequest) -> bool:
        """Whether a failure of ``req`` counts on the circuit: not a retried buy (its first attempt counted: three tries
        at one token that cannot be bought must not trip every entry) nor a dead-bag sweep (it says nothing of trading)."""
        return not req.retry and req.forced_kind != "dead_bag_sweep"

    def _emptied(self, req: ExecRequest) -> None:
        """The sale left the live book without this mint: its empty token account is the mirror's to close mid-minute
        (agent/fly_live.close_accounts), not this thread's in front of the next order. Not while a buy is queued behind
        the sale: it needs the account."""
        with self.lock:
            if self.inflight.get(req.mint) == ["sell"]:
                self.emptied.add(req.mint)