diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index e29c87a..55f2775 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -1,6 +1,9 @@ # Sidepit distribution record -Status: **v0.1.0 is live on PyPI** as `sidepit`. +Status: **v0.1.0 is live on PyPI** as `sidepit`. **v0.1.1 is the release +candidate**: the cockpit TUI no longer triggers whole-order-book snapshot +broadcasts (12129) to refresh its open-orders view; open orders come from the +account's POSITIONS reply. No SDK API changes. This file records the distribution decisions made for the public client. It is the durable boundary between what works today and what is deliberately next. diff --git a/pyproject.toml b/pyproject.toml index a6cc6bc..ee72d1e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sidepit" -version = "0.1.0" +version = "0.1.1" description = "The official Python SDK and trading terminal for Sidepit." readme = "README.md" requires-python = ">=3.10" diff --git a/python-client/pyproject.toml b/python-client/pyproject.toml index d310b9c..a939a9d 100644 --- a/python-client/pyproject.toml +++ b/python-client/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "sidepit" -version = "0.1.0" +version = "0.1.1" description = "The official Python SDK for Sidepit — Bitcoin-margined forwards, DLOB one-second deterministic auctions." readme = "sidepit_trader/README.md" license = { text = "MIT" } diff --git a/python-client/tests/test_tui_bridge_polling.py b/python-client/tests/test_tui_bridge_polling.py new file mode 100644 index 0000000..80a0d4f --- /dev/null +++ b/python-client/tests/test_tui_bridge_polling.py @@ -0,0 +1,64 @@ +"""The TUI bridge must never trigger 12129 whole-book snapshots. + +Regression for the 2026-09-09 production finding: the bridge refreshed its +open-orders panel with snapshot_sync every 10s, making the engine broadcast +the ENTIRE order book on a timer. Open orders are the remaining>0 subset of +the POSITIONS reply's orderfills — the per-account door the bridge already +polls. +""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve() + .parents[2] / "users-cli")) + +from sidepit_trader._proto import pb # noqa: E402 +from sidepit_tui import bridge as bridge_mod # noqa: E402 +from sidepit_tui.bridge import Bridge # noqa: E402 + + +def _tpo_with_orders(): + tp = pb.TraderPositionOrders() + resting = tp.orderfills["bc1qme:100"].order + resting.ticker = "USDBTCU26" + resting.side = 1 + resting.price = 1500 + resting.open_qty = 3 + resting.filled_qty = 1 + resting.remaining_qty = 2 + done = tp.orderfills["bc1qme:50"].order + done.ticker = "USDBTCU26" + done.side = -1 + done.price = 1600 + done.open_qty = 1 + done.filled_qty = 1 + done.remaining_qty = 0 + return tp + + +def test_bridge_never_imports_snapshot_sync(): + source = pathlib.Path(bridge_mod.__file__).read_text() + assert "snapshot_sync" not in source + assert "12129" not in source.replace( + "never touches the 12129 snapshot stream", "").replace( + "a 12129 whole-book snapshot", "") + + +def test_open_orders_come_from_orderfills(): + b = Bridge("example.invalid", on_snap=lambda s: None, + on_event=lambda k, t: None) + b.snap.address = "bc1qme" + b._rq = lambda fn: fn(_FakeReq()) + b._poll_account() + assert b.snap.open_orders == [{ + "orderid": "bc1qme:100", "ticker": "USDBTCU26", "side": "buy", + "price": 1500, "remaining": 2, "filled": 1}] + # the closed order is still visible in history, just not "open" + assert {o["orderid"]: o["status"] for o in b.snap.orders} == { + "bc1qme:100": "open", "bc1qme:50": "closed"} + + +class _FakeReq: + def positions(self, address): + assert address == "bc1qme" + return _tpo_with_orders() diff --git a/python-client/tests/test_tui_doggie_meter.py b/python-client/tests/test_tui_doggie_meter.py new file mode 100644 index 0000000..12fb7de --- /dev/null +++ b/python-client/tests/test_tui_doggie_meter.py @@ -0,0 +1,65 @@ +"""Doggie meter = margin capacity, from the exchange's numbers. + +Fixture = Jay's live screenshots (2026-09-10): wealth 0.00319 BTC = 319,000 sats, +USDBTCU26 maint_margin 100,000 / initial_margin 200,000 sats per contract, +$500 contracts. Intraday the engine charges maintenance margin on the net +position, so 319,000 // 100,000 = 3 contracts either way — he maxed at +3 and -3. +""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "users-cli")) + +from sidepit_tui.bridge import Snap # noqa: E402 +from sidepit_tui.doggie import meter # noqa: E402 + +MAINT, INITIAL, EQUITY = 100_000, 200_000, 319_000 + + +def snap(pos: int, margin_reject_at: float = 0.0) -> Snap: + held = abs(pos) * MAINT + s = Snap(maint_margin_sats=MAINT, initial_margin_sats=INITIAL, + available_margin=EQUITY - held, available_balance=EQUITY, + contract_usd=500, last=1293, is_open=True, state="EXCHANGE_OPEN", + margin_reject_at=margin_reject_at) + if pos: + s.positions = [{"ticker": "USDBTCU26", "contracts": pos, "margin_required": held, + "entry_price": 1293, "side": "long" if pos > 0 else "short", + "realized_pnl": 0, "reduce_only": False, "open_bids": 0, "open_asks": 0}] + return s + + +def test_flat_account_sits_in_the_middle_with_three_taps_each_way(): + m = meter(snap(0), now=1000.0) + assert (m["n_max"], m["points"], m["index"]) == (3, 7, 3) + assert m["room"] == 3 and not m["at_max"] + + +def test_max_short_is_the_left_end_and_max_levered_the_right_end(): + short = meter(snap(+3), now=1000.0) + assert (short["index"], short["at_max"], short["taps_left"]) == (0, True, 0) + levered = meter(snap(-3), now=1000.0) + assert (levered["index"], levered["at_max"], levered["taps_left"]) == (6, True, 0) + + +def test_jays_rule_long_one_with_room_two(): + # position -1 (levered one), available margin = 2 contracts of maintenance: + # 2 more the same way, 2+1+1 = 4 steps the other way. + m = meter(snap(-1), now=1000.0) + assert m["room"] == 2 and m["n_max"] == 3 and m["points"] == 7 + assert m["index"] == 4 # dot one right of centre + assert m["n_max"] - abs(m["pos"]) == 2 # same direction + assert abs(m["pos"]) + m["n_max"] == 4 # opposite direction + assert not m["at_max"] + + +def test_exchange_margin_rejection_pins_max_briefly_then_clears(): + s = snap(-2, margin_reject_at=1000.0) + assert meter(s, now=1003.0)["at_max"] + assert not meter(s, now=1020.0)["at_max"] + + +def test_unknown_margin_spec_degrades_honestly(): + s = snap(0); s.maint_margin_sats = 0 + m = meter(s, now=1000.0) + assert not m["known"] and m["n_max"] == 0 diff --git a/python-client/tests/test_tui_market_intent.py b/python-client/tests/test_tui_market_intent.py index 75f7853..0dea9ca 100644 --- a/python-client/tests/test_tui_market_intent.py +++ b/python-client/tests/test_tui_market_intent.py @@ -1,4 +1,4 @@ -"""Doggy/cockpit market intents stay native IOC and price-less.""" +"""Doggie/cockpit market intents stay price-less market orders (fill what they can, rest cancels).""" import sys from pathlib import Path @@ -9,17 +9,17 @@ from sidepit_tui.intents import parse # noqa: E402 -def test_market_intent_is_native_ioc_without_a_price(): +def test_market_intent_is_a_market_order_without_a_price(): intent = parse("buy 2 at market", last_sats=1548, contract_usd=500) assert intent.kind == "MKT" assert intent.side == 1 assert intent.size == 2 assert intent.price == 0 - assert "native IOC" in intent.summary - assert "cancel every remainder" in intent.summary + assert "market order" in intent.summary + assert "anything unfilled cancels" in intent.summary -def test_flatten_intent_uses_ioc_market_closes(): +def test_flatten_intent_uses_market_closes(): intent = parse("go flat") assert intent.kind == "FLATTEN_ALL" - assert "IOC market closes" in intent.summary + assert "market orders to close" in intent.summary diff --git a/python-client/tests/test_tui_positions_dump.py b/python-client/tests/test_tui_positions_dump.py new file mode 100644 index 0000000..d4c18fb --- /dev/null +++ b/python-client/tests/test_tui_positions_dump.py @@ -0,0 +1,32 @@ +"""`sidepit positions [address]` dumps the raw POSITIONS reply — keyless, unprojected.""" +import pathlib +import sys + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parents[2] / "users-cli")) + +from sidepit_trader._proto import pb # noqa: E402 +from sidepit_tui import __main__ as cli # noqa: E402 + + +class _FakeClient: + def __init__(self, host): self.host = host + def positions(self, address): + tpo = pb.TraderPositionOrders(traderid=address) + tpo.accountstate.available_margin = 307_694 + return tpo + def close(self): pass + + +def test_positions_dump_prints_raw_protobuf_text(monkeypatch, capsys): + import sidepit_trader.reqrep as reqrep + monkeypatch.setattr(reqrep, "RequestClient", _FakeClient) + assert cli._cli(["positions", "bc1qexample"]) == 0 + out = capsys.readouterr().out + assert "# POSITIONS bc1qexample via" in out + assert 'traderid: "bc1qexample"' in out and "available_margin: 307694" in out + + +def test_positions_dump_needs_an_address_when_no_active_wallet(monkeypatch, capsys): + from sidepit_trader import keystore + monkeypatch.setattr(keystore, "active_identity", lambda: None) + assert cli._cli(["positions"]) == 2 diff --git a/sidepit b/sidepit index a7a1895..bc0fc5e 100755 --- a/sidepit +++ b/sidepit @@ -6,6 +6,7 @@ # sidepit import add an existing key (12 words or WIF) # sidepit list list your wallets # sidepit use switch the active wallet +# sidepit positions dump the raw POSITIONS reply (debugging, keyless) # # Resolves its own location, so it works from any directory and through a # symlink on your PATH. @@ -26,7 +27,7 @@ if [ ! -x "$PY" ]; then fi if [ "${1:-}" = "-h" ] || [ "${1:-}" = "--help" ]; then - sed -n '3,8p' "$src" | sed 's/^# \?//' + sed -n '3,9p' "$src" | sed 's/^# \?//' exit 0 fi diff --git a/users-cli/README.md b/users-cli/README.md index 37a14ef..cdb3fdc 100644 --- a/users-cli/README.md +++ b/users-cli/README.md @@ -22,8 +22,44 @@ sidepit # cockpit: book, positions, plain-english prompt sidepit doggie # wallet view: your wealth as one number, two buttons sidepit list # your wallets sidepit use switch wallet sidepit import # add a key (12 words or WIF, hidden input) +sidepit positions [addr] # debugging: dump the raw POSITIONS reply, keyless ``` +### Running the TUI from a local checkout (lower level) + +`sidepit` is a launcher: it runs `python -m sidepit_tui` from `users-cli/` with the +repo's own environment at `python-client/.venv`. If you want the pieces: + +- **Python 3.10+.** +- **One venv for SDK + TUI** — `install_sidepit` creates `python-client/.venv` and + installs three things into it. To do it by hand (or into a venv you made yourself): + + ```sh + python3 -m venv python-client/.venv + source python-client/.venv/bin/activate + pip install -r python-client/requirements.txt # the SDK: protobuf, pynng, secp256k1, … + pip install -r users-cli/requirements.txt # the TUI: textual, qrcode + pip install -e . # from the repo root: makes `sidepit_tui` + # and `sidepit_trader` importable anywhere + ``` + +- **Run it** with that venv active, from any directory: + + ```sh + source python-client/.venv/bin/activate + python -m sidepit_tui # the cockpit (same as `sidepit`) + python -m sidepit_tui list # wallet subcommands work the same way + ``` + + Without the `pip install -e .` step, run from `users-cli/` so the package is on the path. + +- **If you see `ModuleNotFoundError: No module named 'textual'`**, the wrong Python is + running — the system interpreter, or a venv without `users-cli/requirements.txt`. + Activate `python-client/.venv` (or install the TUI requirements into your venv). + +The published `pip install sidepit` package is a separate, versioned copy of this code; +a local checkout does not update it and it does not update the checkout. + ## First run — your identity Pick one of three doors: diff --git a/users-cli/sidepit_tui/__main__.py b/users-cli/sidepit_tui/__main__.py index 1814a74..87e0343 100644 --- a/users-cli/sidepit_tui/__main__.py +++ b/users-cli/sidepit_tui/__main__.py @@ -12,6 +12,11 @@ sidepit list # your wallets (no secrets printed) sidepit use # switch the active wallet +Debugging, keyless (reads the public 12125 door, prints the raw reply): + + sidepit positions [bc1q…] # dump the POSITIONS reply for an account + # (default: the active wallet) + Keys are never deleted by this app, by design. """ import sys @@ -91,6 +96,27 @@ def _cli(argv: list[str]) -> int: kind = "key" if i["has_key"] else "watch-only" print(f"{mark} {i['name']:<16} {i['sidepit_id']} ({kind})") return 0 + if cmd == "positions": + # Raw dump of the account's POSITIONS reply — the exchange's own words, + # nothing projected. Keyless: the 12125 door serves any address. + from google.protobuf import text_format + from sidepit_trader import config + from sidepit_trader.reqrep import RequestClient + address = argv[1] if len(argv) > 1 else None + if address is None: + ident = keystore.active_identity() or {} + address = ident.get("sidepit_id") or ident.get("SIDEPIT_ID") + if not address or not address.startswith("bc1"): + print("usage: sidepit positions [bc1q…] (no active wallet to default to)") + return 2 + rc = RequestClient(config.HOST) + try: + tpo = rc.positions(address) + finally: + rc.close() + print(f"# POSITIONS {address} via {config.HOST}:12125 — raw reply, protobuf text format") + print(text_format.MessageToString(tpo)) + return 0 if cmd == "use": if len(argv) < 2: print("usage: python -m sidepit_tui use ") diff --git a/users-cli/sidepit_tui/app.py b/users-cli/sidepit_tui/app.py index 9bbe9a0..58c8a61 100644 --- a/users-cli/sidepit_tui/app.py +++ b/users-cli/sidepit_tui/app.py @@ -589,7 +589,7 @@ def apply_snap(self, s: Snap) -> None: if not s.delegates: dlines.append(f"[{DIM}]no delegates — mint one on the delegates tab[/]") self._q("#session", Static).update("\n".join(dlines)) - # working orders (right panel, authoritative sync; right-click cancels) + # working orders (right panel, engine orderfills; right-click cancels) wt = self._q("#working", WorkingOrders) rows = [(o["orderid"], o["side"], str(o["remaining"]), str(o["price"]), "…" + o["orderid"][-13:]) for o in s.open_orders[:12]] diff --git a/users-cli/sidepit_tui/bridge.py b/users-cli/sidepit_tui/bridge.py index ffee496..1445193 100644 --- a/users-cli/sidepit_tui/bridge.py +++ b/users-cli/sidepit_tui/bridge.py @@ -7,11 +7,16 @@ Cadences (human terminal, not a bot): - exchange state + quote every ~1.5s (12125 reqrep) - - account (positions/margin) every ~3s when an address is set - - open-order snapshot sync every ~10s while OPEN (12129, authoritative) + - account (positions/margin/ every ~3s when an address is set — + open orders via orderfills) the per-account door is the ONLY poll - rejections feed drained every loop (12128, ours only) - on-chain balance (esplora) every ~60s and on demand +The TUI never touches the 12129 snapshot stream: triggering SNAPSHOT makes the +engine broadcast the ENTIRE order book, which is a market-data reconstruction +tool, not a per-account refresh. Open orders are the POSITIONS reply's +orderfills (engine-authoritative remaining/filled per order). + Connections stay healthy on their own: the REQ socket is reopened on Timeout and retried once; the Submitter keeps its push connection fresh across idle periods. @@ -32,11 +37,9 @@ project_unlock_records) from sidepit_trader.signer import Signer from sidepit_trader.submit import Submitter -from sidepit_trader.sync import snapshot_sync QUOTE_SECS = 1.5 ACCOUNT_SECS = 3.0 -SYNC_SECS = 10.0 CHAIN_SECS = 60.0 @@ -61,6 +64,9 @@ class Snap: contract_usd: int = 500 # Contract.unit_size ($ per contract) tick_size_sats: int = 1 # Contract.tic_min tick_value_sats: int = 0 # Contract.tic_value + maint_margin_sats: int = 0 # Contract.maint_margin — intraday margin per contract + initial_margin_sats: int = 0 # Contract.initial_margin — charged on NEW positions at the close + margin_reject_at: float = 0.0 # last RC_MARGIN rejection of ours (wall clock) depth_bids: list = field(default_factory=list) # [(price, size)] best-first depth_asks: list = field(default_factory=list) # identity @@ -79,7 +85,7 @@ class Snap: realized_fees: int = 0 positions: list = field(default_factory=list) # dicts, gateway-shaped orders: list = field(default_factory=list) # today's orders (orderfills) - open_orders: list = field(default_factory=list) # authoritative (snapshot sync) + open_orders: list = field(default_factory=list) # remaining>0 subset of orders delegates: list = field(default_factory=list) # settled truth (delegate_data) account_requests: list = field(default_factory=list) # receipts, keyed by oid unlock_records: list = field(default_factory=list) # unlock lifecycle rows @@ -115,7 +121,7 @@ def inner(*a): self._sub: Submitter | None = None self._rejects: RejectionFeed | None = None self._halt = threading.Event() - self._last = {"quote": 0.0, "account": 0.0, "sync": 0.0, "chain": 0.0} + self._last = {"quote": 0.0, "account": 0.0, "chain": 0.0} # --- identity (called from UI thread BEFORE start(), or via command) ---- def set_identity(self, address: str, wif: str | None) -> None: @@ -203,11 +209,6 @@ def _tick(self) -> None: self._last["account"] = now self._poll_account() dirty = True - if (self.snap.address and self.snap.is_open - and now - self._last["sync"] >= SYNC_SECS): - self._last["sync"] = now - self._poll_open_orders() - dirty = True if self.snap.address and now - self._last["chain"] >= CHAIN_SECS: self._last["chain"] = now self._poll_chain() @@ -234,6 +235,8 @@ def _poll_exchange(self) -> None: s.contract_usd = c.unit_size s.tick_size_sats = c.tic_min or 1 s.tick_value_sats = c.tic_value + s.maint_margin_sats = int(c.maint_margin) + s.initial_margin_sats = int(c.initial_margin) if prev != "?" and prev != s.state: self.on_event("info", f"exchange {prev} → {s.state}") try: @@ -311,6 +314,15 @@ def _poll_account(self) -> None: "ns": int(oid.rsplit(":", 1)[-1] or 0)}) orders.sort(key=lambda o: -o["ns"]) s.orders = orders + # Open orders are the remaining>0 subset of the same engine-served + # orderfills — never a 12129 whole-book snapshot (that broadcast is a + # market-data tool; a per-account view must not trigger it). + s.open_orders = [{ + "orderid": o["orderid"], "ticker": o["ticker"], "side": o["side"], + "price": o["price"], "remaining": o["remaining"], + "filled": o["filled"]} + for o in sorted(orders, key=lambda o: o["orderid"]) + if o["remaining"] > 0] # Contract projection (no client-side state machine): receipts are # history (is_pending=True = received/displayed, NOT active; terminal # lands on the SAME oid — reject_code decides applied vs rejected). @@ -336,19 +348,6 @@ def _poll_account(self) -> None: "is_active": False, "updatetime": 0, "pending": True}) s.delegates = delegs - def _poll_open_orders(self) -> None: - try: - orders, _epoch = snapshot_sync(self.host, self.snap.address, - timeout_ms=5000) - except pynng.Timeout: - return # closed / no worker — keep the last view - self.snap.open_orders = [{ - "orderid": oid, "ticker": bo.ticker, - "side": "buy" if bo.side > 0 else "sell", - "price": bo.price, "remaining": bo.remaining_qty, - "filled": bo.filled_qty} - for oid, bo in sorted(orders.items())] - def _poll_chain(self) -> None: try: c, m = wallet.balance(self.snap.address) @@ -368,6 +367,8 @@ def _drain_rejections(self) -> None: continue code = RejectionFeed.code_name(rj) kind = "error" if RejectionFeed.is_error(rj) else "info" + if code == "RC_MARGIN": + self.snap.margin_reject_at = time.time() self.on_event(kind, f"rejected {code}: " f"{rj.transaction.sidepit_id}:{rj.transaction.timestamp}") except Exception: @@ -384,27 +385,27 @@ def _exec(self, parts) -> None: f"order submitted {('BUY' if side > 0 else 'SELL')} " f"{size} @ {price} → {oid[-18:]} " f"(resolves next epoch — watch Open Orders)") - self._last["sync"] = 0.0 # re-sync soon + self._last["account"] = 0.0 # refresh orders soon elif verb == "cancel": _, oid = parts self._submitter().cancel(oid) self.on_event("success", f"cancel submitted for …{oid[-18:]}") - self._last["sync"] = 0.0 + self._last["account"] = 0.0 elif verb == "market": _, side, size = parts s = self.snap oid = self._submitter().market_order(side, size, s.ticker) self.on_event("success", - f"IOC MKT {('BUY' if side > 0 else 'SELL')} {size} → " - f"{oid[-18:]} · unfilled remainder cancels next DLOB auction") - self._last["sync"] = 0.0 + f"MARKET {('BUY' if side > 0 else 'SELL')} {size} → " + f"{oid[-18:]} · fills what it can in the next auction, the rest cancels") + self._last["account"] = 0.0 elif verb == "cancel_all": n = 0 for o in list(self.snap.open_orders): self._submitter().cancel(o["orderid"]) n += 1 self.on_event("success", f"cancel submitted for {n} open order(s)") - self._last["sync"] = 0.0 + self._last["account"] = 0.0 elif verb == "flatten": sub = self._submitter() s = self.snap @@ -420,7 +421,6 @@ def _exec(self, parts) -> None: self.on_event("success", f"FLATTEN_ALL submitted · {len(s.open_orders)} cancel(s), " f"{closed} closing order(s) · verify next epoch") - self._last["sync"] = 0.0 self._last["account"] = 0.0 elif verb == "unlock": _, amount = parts @@ -502,7 +502,6 @@ def _exec(self, parts) -> None: elif verb == "chain_refresh": self._last["chain"] = 0.0 elif verb == "sync_now": - self._last["sync"] = 0.0 self._last["account"] = 0.0 except Exception as e: self.on_event("error", f"{verb}: {e}") diff --git a/users-cli/sidepit_tui/doggie.py b/users-cli/sidepit_tui/doggie.py index ce72906..cea2172 100644 --- a/users-cli/sidepit_tui/doggie.py +++ b/users-cli/sidepit_tui/doggie.py @@ -8,13 +8,14 @@ buttons); do not blend with the cockpit's hacker-green. It is a SKIN over the same client core: same Bridge, same Snap, same -native IOC `market` command the cockpit prompt uses — proof the architecture +native `market` command the cockpit prompt uses — proof the architecture supports "cockpit view" and "doggie view" as two faces of one client (the handoff's stated test of doing it right). Sizing honesty (the concept note's $10k unit is not decided product): here one tap = ONE contract (= $`Contract.unit_size`, currently $500) via a native -immediate-or-cancel market order in the next DLOB deterministic auction. Mapping +market order (price 0 on the wire: fills what it can in the next DLOB +deterministic auction, the rest cancels). Mapping on the inverse forward: account equity starts 100% BTC (it IS bitcoin margin); being LONG p contracts of USDBTC = holding $p×size synthetically = hedged; p past your whole equity = net SHORT bitcoin; p negative = LEVERAGED long. Fully @@ -37,6 +38,33 @@ MUT = "#7d93ab" +def meter(s, now: float | None = None) -> dict: + """The doggie meter, as numbers (Jay's rule, 2026-09-10). + + Intraday the engine charges MAINTENANCE margin per contract of net position, + so `available_margin // maint_margin` is how many more contracts you can add + in the direction you already lean. Going the other way you first unwind, so + the reachable extreme on either side is n_max = room + |position|: + long 1 with room 2 -> 2 more the same way, 2+1+1 the other way. + The line has 2*n_max+1 points, dot = position, 0 in the middle. Wealth is + available_balance + realized (+ a local unrealized while open, which does + not enter margin). Labels describe NET bitcoin exposure, which includes the + bitcoin you hold, so the two ends are not mirror images in dollars. + """ + import time as _time + now = _time.time() if now is None else now + pos = sum(p["contracts"] for p in s.positions) + maint = s.maint_margin_sats + room = max(0, s.available_margin) // maint if maint > 0 else 0 + n_max = room + abs(pos) + points = 2 * n_max + 1 + index = n_max - pos # +n_max (short bitcoin) sits at the LEFT end + at_max = maint > 0 and room == 0 and pos != 0 + exchange_said_no = (now - s.margin_reject_at) < 8.0 if s.margin_reject_at else False + return {"pos": pos, "n_max": n_max, "room": room, "points": points, "index": index, + "taps_left": room, "at_max": at_max or exchange_said_no, + "exchange_said_no": exchange_said_no, "known": maint > 0} + class DoggieScreen(Screen): """ctrl+d toggles back to the cockpit. Reads app.snap on a timer; taps go through the same bridge `market` command as the cockpit prompt.""" @@ -50,9 +78,9 @@ class DoggieScreen(Screen): border: round {MUT}; padding: 1 4; }} #dog-title {{ text-align: center; color: {MUT}; height: 1; }} #dog-value {{ width: 1fr; color: {INK}; }} - #dog-sub {{ text-align: center; color: {MUT}; height: 1; }} - #dog-stance {{ text-align: center; height: 2; text-style: bold; }} - #dog-meter {{ text-align: center; height: 2; }} + #dog-sub {{ text-align: center; color: {MUT}; height: 1; margin-top: 1; }} + #dog-stance {{ text-align: center; height: 1; margin-top: 1; text-style: bold; }} + #dog-meter {{ text-align: center; height: 1; margin: 1 0; }} #dog-buttons {{ height: 7; align: center middle; }} #tap-btc {{ width: 1fr; height: 7; background: {ORANGE}; color: {NAVY}; text-style: bold; border: round {ORANGE}; margin: 0 1; }} @@ -64,7 +92,7 @@ class DoggieScreen(Screen): def compose(self) -> ComposeResult: with Vertical(id="dog-frame"): - yield Static("doggie // wallet · native IOC · ctrl+d = cockpit", + yield Static("doggie // wallet · ctrl+d = cockpit", id="dog-title") yield Digits("0.00", id="dog-value") yield Static("", id="dog-sub") @@ -109,28 +137,38 @@ def _update_view(self) -> None: # p=0 still reads LONG — you're long the bitcoin you hold. net_usd = eq_usd - hedged_usd net_btc = net_usd * s.last / 1e8 if s.last else 0.0 + m = meter(s) if pos != 0 and net_usd > eq_usd + 0.5: side, color = "LEVERAGED LONG", ORANGE # exposure beyond your equity + elif pos == 0: + side, color = "LONG", INK # just the bitcoin you hold — no position elif net_usd > 0.5: side, color = "LONG", ORANGE elif net_usd < -0.5: side, color = "SHORT", DGREEN else: side, color = "HEDGED", DGREEN + if m["at_max"] and side != "HEDGED": + side = "MAX " + side btc_str = f"{abs(net_btc):.3f}".lstrip("0") or "0" stance = (f"{side} {btc_str} BITCOIN · ${abs(net_usd):,.0f}" - if side != "HEDGED" else "HEDGED · value frozen in USD") + if not side.endswith("HEDGED") else "HEDGED · value frozen in USD") self.query_one("#dog-stance", Static).update( f"[{MUT}]position({pos})[/] [{color}]{stance}[/]") - frac = 1.0 - (hedged_usd / eq_usd) if eq_usd else 1.0 - width = 40 - marker = max(0, min(width - 1, int((frac + 0.5) / 2.0 * width))) - bar = "".join("●" if i == marker else "─" for i in range(width)) - self.query_one("#dog-meter", Static).update( - f"[{MUT}]short[/] [{color}]{bar}[/] [{MUT}]levered[/]") + # The line: one point per contract you could hold, dot = where you are. + if m["known"]: + points = m["points"] + index = max(0, min(points - 1, m["index"])) + step = 5 if points <= 9 else 2 if points <= 21 else 1 + bar = "".join("●" if i == index else "─" * step for i in range(points)) + self.query_one("#dog-meter", Static).update( + f"[{MUT}]dollars[/] [{color}]{bar}[/] [{MUT}]bitcoin[/]") + else: + self.query_one("#dog-meter", Static).update( + f"[{MUT}]dollars ─── margin unknown ─── bitcoin[/]") self.query_one("#dog-foot", Static).update( - f"[{MUT}]one tap = ${s.contract_usd} (1 contract) · IOC in the next " - f"DLOB auction[/]") + f"[{MUT}]one tap = 1 contract = ${s.contract_usd} more bitcoin or " + f"${s.contract_usd} more USD[/]") def on_button_pressed(self, event: Button.Pressed) -> None: b = self.app.bridge @@ -143,8 +181,8 @@ def on_button_pressed(self, event: Button.Pressed) -> None: if event.button.id == "tap-usd": # toward dollars: hedge $unit more = BUY one USDBTC contract b.cmd("market", 1, 1) - self.app.add_event("ok", f"doggie IOC requested → USD (+${s.contract_usd})") + self.app.add_event("ok", f"${s.contract_usd} more USD at market price · fills in the next auction") elif event.button.id == "tap-btc": # toward bitcoin: unwind $unit of hedge = SELL one contract b.cmd("market", -1, 1) - self.app.add_event("ok", f"doggie IOC requested → BTC (−${s.contract_usd})") + self.app.add_event("ok", f"${s.contract_usd} more bitcoin at market price · fills in the next auction") diff --git a/users-cli/sidepit_tui/intents.py b/users-cli/sidepit_tui/intents.py index be4bc10..2fa7429 100644 --- a/users-cli/sidepit_tui/intents.py +++ b/users-cli/sidepit_tui/intents.py @@ -63,7 +63,7 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: if t in ("go flat", "go flat and exit", "flatten", "flatten all", "exit all"): return Intent("FLATTEN_ALL", "parsed → FLATTEN_ALL · cancel all working orders, then send " - "IOC market closes for every position") + "market orders to close every position") if t in ("cancel all", "cancel all working orders", "cancel everything"): return Intent("CANCEL_ALL", "parsed → CANCEL_ALL · cancel all working orders") if t in ("risk", "what's my risk", "whats my risk", "what's my risk in btc terms", @@ -90,7 +90,7 @@ def parse(text: str, *, last_sats: int = 0, contract_usd: int = 500) -> Intent: raise ValueError("size must be positive") return Intent("MKT", f"parsed → MKT {'BUY' if side > 0 else 'SELL'} {size} · " - f"native IOC: fill available liquidity, cancel every remainder · " + f"market order: fills whatever is available in the next auction; anything unfilled cancels · " f"routing to the next DLOB auction", side=side, size=size) m = _CXL.match(t)