Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion DISTRIBUTION.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion python-client/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
64 changes: 64 additions & 0 deletions python-client/tests/test_tui_bridge_polling.py
Original file line number Diff line number Diff line change
@@ -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()
65 changes: 65 additions & 0 deletions python-client/tests/test_tui_doggie_meter.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 6 additions & 6 deletions python-client/tests/test_tui_market_intent.py
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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
32 changes: 32 additions & 0 deletions python-client/tests/test_tui_positions_dump.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion sidepit
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
# sidepit import add an existing key (12 words or WIF)
# sidepit list list your wallets
# sidepit use <name> 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.
Expand All @@ -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

Expand Down
36 changes: 36 additions & 0 deletions users-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> 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:
Expand Down
26 changes: 26 additions & 0 deletions users-cli/sidepit_tui/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@
sidepit list # your wallets (no secrets printed)
sidepit use <name> # 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
Expand Down Expand Up @@ -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 <name>")
Expand Down
2 changes: 1 addition & 1 deletion users-cli/sidepit_tui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
Loading