|
| 1 | +"""The console's Holdings view, as a service -- open tranches with what they cost and how they |
| 2 | +were entered. |
| 3 | +
|
| 4 | +A VIEW over `gather_positions` and the orders book. The one thing it adds is the join the ledger |
| 5 | +does not store: a tranche's ENTRY ORDER (`positions` carries no order id; no backfill exists |
| 6 | +yet). Nothing here reaches a venue; `keel/web/__init__.py`'s posture is unchanged. |
| 7 | +
|
| 8 | +**The join is the fill, not the clock.** `agent._open_tranche` opens a tranche with |
| 9 | +`opened_at=now_ts` (the cycle's clock) and copies two things verbatim from the entry order: |
| 10 | +`entry_fill = order["actual_fill"]` and `rule_id = order.get("rule_id")`. The timestamps do NOT |
| 11 | +reliably agree: live shares the cycle's `now_ts` (`executor._order_row`), but `PaperTrader` writes |
| 12 | +the paper order with `created_at=signal.ts` -- the BAR's ts, possibly days before the cycle that |
| 13 | +acted on it. So a candidate for a tranche is a BUY on the same product, created no later than the |
| 14 | +tranche opened, whose `actual_fill` equals the tranche's `entry_fill` EXACTLY (`Decimal` |
| 15 | +equality), and whose `rule_id` does not contradict the tranche's (a `None` on either side is |
| 16 | +"unknown", not a mismatch -- pre-v21 tranches carry none). |
| 17 | +
|
| 18 | +**Closest before, claimed once.** Several tranches of one product can share a fill (a DCA buying |
| 19 | +on consecutive days); the house reasoning is `sleeve.unverified_fill_orders`' -- the candidate |
| 20 | +nearest BEFORE the lot's `opened_at` (ties: the higher order id) owns it, and each order is |
| 21 | +claimed by at most one tranche, tranches walked oldest-first. "Oldest order first" would hand |
| 22 | +yesterday's order to today's tranche. |
| 23 | +
|
| 24 | +**Unknown, never wrong.** A fill later rewritten by reconciliation of a partial fill no longer |
| 25 | +equals what the tranche copied, so the tranche reads "entry order not identified". That is the |
| 26 | +honest outcome: this module names an order only when the tranche's own copied figures vouch for |
| 27 | +it, and says so when none does. The match is a strong inference, not a stored foreign key. |
| 28 | +
|
| 29 | +**Pure, no clock** (the split `pulse.py` documents): `now_ts` is the caller's one read. The orders |
| 30 | +book is read ONCE PER PRODUCT, not per tranche, and rows are built with `orders._row_from_dict` |
| 31 | +WITHOUT a `repo`, so no cancel classification ever runs for a join. |
| 32 | +""" |
| 33 | + |
| 34 | +from __future__ import annotations |
| 35 | + |
| 36 | +from dataclasses import dataclass |
| 37 | +from decimal import Decimal |
| 38 | +from typing import Any |
| 39 | + |
| 40 | +from keel.commands.orders import OrderRow |
| 41 | +from keel.commands.orders import _row_from_dict as _order_row_from_dict |
| 42 | +from keel.commands.positions import PositionRow, gather_positions |
| 43 | +from keel.config import Config |
| 44 | +from keel.data.repository import Repository |
| 45 | +from keel.types import Side |
| 46 | + |
| 47 | +#: Said when no order vouches for a tranche. Worded for what was checked, not for a cause: the |
| 48 | +#: order may be missing, or its fill may have been rewritten after the tranche copied it. |
| 49 | +ENTRY_ORDER_NOT_IDENTIFIED = ( |
| 50 | + "entry order not identified -- no buy order on this product matches this tranche's fill " |
| 51 | + "price before it opened" |
| 52 | +) |
| 53 | + |
| 54 | + |
| 55 | +@dataclass(frozen=True) |
| 56 | +class HoldingRow: |
| 57 | + position: PositionRow |
| 58 | + #: `positions.bracket_order_id is not None`. |
| 59 | + has_bracket: bool |
| 60 | + #: What the tranche paid to OPEN: `(qty + realized_qty) * entry_fill + entry_fee`. `qty` is |
| 61 | + #: what is still held after scale-outs while `entry_fee` is the whole entry's fee, so |
| 62 | + #: `qty * fill + fee` would mix the two once a partial exit has happened. |
| 63 | + cost: Decimal |
| 64 | + #: The matched entry order, or `None`. Its `cancel` is the inert default, never classified. |
| 65 | + entry_order: OrderRow | None |
| 66 | + #: `""` when matched, else `ENTRY_ORDER_NOT_IDENTIFIED`. |
| 67 | + entry_order_note: str |
| 68 | + |
| 69 | + |
| 70 | +@dataclass(frozen=True) |
| 71 | +class HoldingsReport: |
| 72 | + now_ts: int |
| 73 | + #: The deployment's `auto_trade.mode`, which the bracket judgement needs. |
| 74 | + mode: str |
| 75 | + #: Oldest tranche first. |
| 76 | + rows: tuple[HoldingRow, ...] |
| 77 | + |
| 78 | + @property |
| 79 | + def open_count(self) -> int: |
| 80 | + """Derived, not stored: `payload.py` may not call `len()` (Rule 6e).""" |
| 81 | + return len(self.rows) |
| 82 | + |
| 83 | + @property |
| 84 | + def rules(self) -> tuple[str, ...]: |
| 85 | + """Distinct rule names in first-seen (oldest-first) order, the unattributed `""` last -- |
| 86 | + what a grouped view keys on, so no renderer builds its own list.""" |
| 87 | + seen: list[str] = [] |
| 88 | + for row in self.rows: |
| 89 | + if row.position.rule_name not in seen: |
| 90 | + seen.append(row.position.rule_name) |
| 91 | + return tuple(sorted(seen, key=lambda name: name == "")) |
| 92 | + |
| 93 | + @property |
| 94 | + def stop_row_ids(self) -> tuple[int, ...]: |
| 95 | + """Tranches that carry a stop, so the view can draw the stop column only when one does.""" |
| 96 | + return tuple(r.position.id for r in self.rows if r.position.initial_stop is not None) |
| 97 | + |
| 98 | + |
| 99 | +def _rule_consistent(tranche_rule_id: Any, order: dict[str, Any]) -> bool: |
| 100 | + order_rule_id = order.get("rule_id") |
| 101 | + if tranche_rule_id is None or order_rule_id is None: |
| 102 | + return True |
| 103 | + return int(tranche_rule_id) == int(order_rule_id) |
| 104 | + |
| 105 | + |
| 106 | +def _entry_order( |
| 107 | + raw: dict[str, Any], candidates: list[dict[str, Any]], claimed: set[int] |
| 108 | +) -> dict[str, Any] | None: |
| 109 | + opened_at = int(raw["opened_at"]) |
| 110 | + matches = [ |
| 111 | + o |
| 112 | + for o in candidates |
| 113 | + if int(o["id"]) not in claimed |
| 114 | + and o["side"] == Side.BUY.value |
| 115 | + and o.get("created_at") is not None |
| 116 | + and int(o["created_at"]) <= opened_at |
| 117 | + and o.get("actual_fill") is not None |
| 118 | + and o["actual_fill"] == raw["entry_fill"] |
| 119 | + and _rule_consistent(raw.get("rule_id"), o) |
| 120 | + ] |
| 121 | + if not matches: |
| 122 | + return None |
| 123 | + return max(matches, key=lambda o: (int(o["created_at"]), int(o["id"]))) |
| 124 | + |
| 125 | + |
| 126 | +def gather_holdings(repo: Repository, config: Config, *, now_ts: int) -> HoldingsReport: |
| 127 | + """Every open tranche with its cost and, where one vouches for it, its entry order.""" |
| 128 | + positions = gather_positions(repo, config, now_ts=now_ts) |
| 129 | + raw_by_id = {int(r["id"]): r for r in repo.get_open_positions()} |
| 130 | + rule_names = {int(r["id"]): str(r["kind"]) for r in repo.get_rules(None)} |
| 131 | + book: dict[str, list[dict[str, Any]]] = {} |
| 132 | + claimed: set[int] = set() |
| 133 | + rows: list[HoldingRow] = [] |
| 134 | + for position in positions.rows: |
| 135 | + raw = raw_by_id.get(position.id) |
| 136 | + if raw is None: |
| 137 | + # Closed between the two reads of the open book (the agent writes from its own |
| 138 | + # process). It is no longer held, so it is left out rather than shown with a bracket |
| 139 | + # and rule id nobody read. |
| 140 | + continue |
| 141 | + if position.product_id not in book: |
| 142 | + book[position.product_id] = repo.get_orders(product_id=position.product_id) |
| 143 | + match = _entry_order(raw, book[position.product_id], claimed) |
| 144 | + if match is not None: |
| 145 | + claimed.add(int(match["id"])) |
| 146 | + rows.append( |
| 147 | + HoldingRow( |
| 148 | + position=position, |
| 149 | + has_bracket=raw.get("bracket_order_id") is not None, |
| 150 | + cost=(position.qty + position.realized_qty) * position.entry_fill |
| 151 | + + position.entry_fee, |
| 152 | + entry_order=None if match is None else _order_row_from_dict(match, rule_names), |
| 153 | + entry_order_note="" if match is not None else ENTRY_ORDER_NOT_IDENTIFIED, |
| 154 | + ) |
| 155 | + ) |
| 156 | + return HoldingsReport(now_ts=now_ts, mode=str(config.auto_trade.mode), rows=tuple(rows)) |
0 commit comments