# SoriTrading P2P — WebSocket API (v1)

Your own program (a bot, a script, another terminal) connects to the **SoriTrading P2P daemon** on your server, asks it to watch conditions, and receives **confirmed signals** from the WATCH TOGETHER network.

- **Read-only.** There is no order, key, balance or strategy operation. Your program places its own orders with its own keys.
- **Confirmed signals only.** The agent behind the daemon re-checks every hit with its own exchange data before it reaches you. No prices are sent — only which symbol and which bar.
- **Contribution required.** The API answers only while the agent behind the daemon is contributing to the P2P network. Programs that only receive and never contribute are not served.

Clients in this folder (no dependencies):

| Language | File | Runtime |
|---|---|---|
| JavaScript (ESM) | [`soritrading-p2p.mjs`](./soritrading-p2p.mjs) | Node.js 22+, Deno, Bun |
| Python | [`soritrading_p2p.py`](./soritrading_p2p.py) | Python 3.8+, standard library only |

Any other language works too: the protocol below is plain JSON over a WebSocket.

---

## 1. Requirements

1. A SoriTrading agent **1.350.0 or newer**, connected to a P2P daemon **1.4.0 or newer** (on the same server, or a remote daemon).
2. The agent is **contributing**: P2P on, third-party sharing not turned off, and at least one direct peer connection (`participation: joined`).
3. The daemon's WebSocket listener is on — it is **on by default** from daemon 1.16.0 (next section).

## 2. The listener

From daemon 1.16.0 the listener is **on by default** at `127.0.0.1:27720` (loopback only). The install script prints the WebSocket address and the command that shows the token. Older daemons: update with

```sh
curl -fsSL <server>/dl/install.sh | bash -s -- --update
```

`<server>` is the same address you installed the agent from. Move it with `--p2p-ws=PORT`, turn it off with `--no-p2p-ws`; the choice stays across later `--update` runs.

Without the install script, set the daemon's environment:

| Variable | Meaning |
|---|---|
| (unset) | `127.0.0.1:27720` |
| `SORITRADING_P2P_WS=0` | Off |
| `SORITRADING_P2P_WS=host:port` | Listen address, e.g. `127.0.0.1:31000` |
| `SORITRADING_P2P_WS_PUBLIC=1` | Required for any non-loopback address (see §6) |

On the agent side the API is on by default. `SORITRADING_P2P_API=0` turns it off for that agent.

## 3. Token

The daemon creates a 64-hex-character token on first start, in its state folder as `ws-api.token` (mode 0600). Print it with:

```sh
sudo -u soritrading-p2p env STATE_DIRECTORY=/var/lib/soritrading-p2p node /opt/soritrading-p2p/p2pd.mjs --ws-token
```

(Paths are the install script's defaults; the script prints the exact command for your server.) Keep it secret: anyone with the token can add watches and read your signals. To rotate it, stop the daemon, delete `ws-api.token`, start the daemon, and read the new one.

## 4. Quick start

JavaScript:

```js
import { connect } from './soritrading-p2p.mjs';

const api = await connect({ url: 'ws://127.0.0.1:27720/v1', token: process.env.SORITRADING_P2P_WS_TOKEN });
api.on('gate', (g) => console.log('gate', g));                     // contribution gate changed
api.on('signal', (s) => console.log(s.seq, s.slot, s.signal.sym));  // confirmed signal
const w = await api.condsAdd({ v: 1, all: [{ p: 'rsi_below', iv: '1h', n: 14, x: 30 }] }, 'binanceusdm');
console.log('watching', w.slot, w.mode);
```

Python:

```python
import asyncio, os
from soritrading_p2p import connect, ApiError

async def main():
    api = await connect("ws://127.0.0.1:27720/v1", os.environ["SORITRADING_P2P_WS_TOKEN"])
    api.on("signal", lambda s: print(s["seq"], s["slot"], s["signal"]["sym"]))
    try:
        w = await api.conds_add({"v": 1, "all": [{"p": "rsi_below", "iv": "1h", "n": 14, "x": 30}]}, "binanceusdm")
    except ApiError as e:
        print("refused:", e.code, e.reason)   # e.g. not-contributing / no-peers
        return
    await api.wait_closed()

asyncio.run(main())
```

Conditions use the same declarative format as SoriTrading scan presets (`{v:1, all:[{p, iv, ...}]}`). Each condition is watched on one exchange (`ex`).

## 5. Protocol

Endpoint: `ws://HOST:PORT/v1`. Text frames, one JSON object per message. Binary frames and compression extensions are not supported.

### 5.1 Authenticate (first message, within 5 seconds)

```json
→ {"id": 1, "op": "auth", "token": "<64 hex>"}
← {"id": 1, "ok": true, "status": 200, "body": {"v": 1, "daemon": "1.4.0", "gate": {"ok": true, "reason": null}}}
← {"ev": "hello", "v": 1, "daemon": "1.4.0", "ops": [...], "gate": {...}}
```

A wrong token gets `{"ok": false, "status": 401, "error": "unauthorized"}` and the socket closes with code **4401**. No auth within 5 s also closes with 4401. The token is never accepted in the URL or a header.

### 5.2 Requests

```json
→ {"id": <int or string>, "op": "<op>", "args": {...}}
← {"id": <same>, "ok": <bool>, "status": <http-like>, "body": {...}}
```

| op | args | body (200/201) |
|---|---|---|
| `status` | — | `{v, agentVersion, boot, lastSeq, gate, p2p, wt, grid, conds, limits}` — always answered, even when the gate is closed. With no agent connected the daemon answers `{v, gate, agent: null, daemon}` |
| `conds.list` | — | `{items: [{slot, condKey, ex, cond, mode, gridIvs, last, addedAt}]}` |
| `conds.add` | `{cond, ex}` | `{slot, condKey, ex, mode, gridIvs, pending, existed}` — 201 new, 200 already watched |
| `conds.del` | `{slot}` | `{slot, released}` |
| `signals.since` | `{since?, limit?}` (limit ≤ 200) | `{boot, items, next, gap}` |
| `peers` | — | `{known, trusted, partners, banned}` |
| `usage` | — | `{v, at, windowSec: 60, classes, exchanges}` — how much of each exchange's API limit went to the P2P network vs your own trading and data (§5.7). Always answered, even when the gate is closed. With no agent connected: `503 no-agent` |
| `ping` | — | `{pong: true}` (answered by the daemon itself) |

`mode` is `grid` (narrowed by the shared grid) or `local-only` (checked by the agent within its own budget). `pending: true` means the agent has no daemon connection yet; the watch is applied when it connects.

### 5.3 Events (server → you)

```json
{"ev": "signal", "item": {"seq": 12, "at": <ms epoch>, "slot": "wt:0123456789abcdef", "signal": {"sym": "BTC/USDT:USDT", ...}}}
{"ev": "gate", "ok": false, "reason": "no-peers"}
{"ev": "control", "item": {"v": 1, "boot": "…", "epoch": 17, "yield": {...}, "frozen": {...}, "notice": null}}
```

`control` (§5.8) is pushed whether or not the gate is open.

Signals are pushed only while the gate is open. If your connection drops, reconnect and call `signals.since` with the last `seq` you saw. `gap: true` means the buffer (last 1000 signals) no longer holds everything after your `since`. A different `boot` value means the agent restarted and `seq` started again from 1.

### 5.4 Errors

| status | `error` / `body.error` | Meaning |
|---|---|---|
| 400 | `bad-args`, `bad-cond`, `ex-required`, `bad-shape`, `exchange-unsupported`, `no-source` | Your input |
| 401 | `unauthorized` | Token |
| 403 | `not-contributing` + `reason` | Gate closed (§5.5) |
| 404 | `unknown-op`, `not-found` | Unknown op / unknown slot |
| 409 | `too-many-conds` | 32 watches at most |
| 413 | `reply-too-big` | The agent's reply exceeded 192 KB — use a smaller `limit` |
| 429 | `rate-limited` | More than 40 requests in 10 s |
| 502 | `agent-gone` | The agent disconnected while answering |
| 503 | `api-off` | The agent has `SORITRADING_P2P_API=0` |
| 504 | `agent-timeout` | The agent did not answer within 15 s |

Read the codes, not the message text.

### 5.5 Gate reasons

| reason | What to do |
|---|---|
| `no-agent` | No agent is connected to this daemon (or none has the API on) |
| `p2p-off`, `p2p-<state>` | The agent's P2P is off or not connected to the daemon (e.g. `p2p-no-daemon`, `p2p-remote-closed`) |
| `share-off` | Third-party sharing is off — the agent only exchanges with your own devices, which is not contributing |
| `not-joined` | No participation result yet (the first cycle has not run) |
| `no-peers` | No peers to connect to |
| `unreachable` | Peers exist but none could be reached directly |
| `unknown` | The gate could not be determined — treated as closed |

Focus mode (the agent briefly giving its network budget to an imminent trade) does **not** close the gate.

### 5.7 Usage — your exchange API limit, split by who used it

Every request the agent sends to an exchange goes through one rate limiter per exchange key. `usage` reports what that limiter actually sent (not what it planned), per exchange and per limit bucket:

```json
{"v": 1, "at": 1760000000000, "windowSec": 60, "classes": ["network", "trading", "ownData", "other"],
 "exchanges": {"binance": {"frozen": null, "buckets": {
   "rest": {"perSec": 20, "capacityLastMin": 1200,
            "lastMin": {"network": 12, "trading": 3, "ownData": 240, "other": 0},
            "total":   {"network": 9120, "trading": 410, "ownData": 88000, "other": 0}}}}}}
```

| class | what it counts |
|---|---|
| `network` | checks the agent ran for the WATCH TOGETHER network (other people's conditions) |
| `trading` | orders, cancels, order status — your own trading |
| `ownData` | your candle collection and public market data for your own runs |
| `other` | anything not classified yet — shown, not hidden |

- Units are the exchange's own **weight** units, not request counts. `capacityLastMin` = `perSec × 60`, so `lastMin` / `capacityLastMin` is the share of the last minute. `total` counts since the agent started.
- `observed` (when present) is what the exchange itself reported in its response headers — it can differ from our count when another program uses the same key.
- `frozen` is the limiter's back-off state after the exchange refused requests (`null` = not frozen).
- `exchanges: null` means the agent could not report usage (older agent) — it is **not** "nothing used".
- The network share is capped: the agent gives the P2P network only the spare part of each limit, and none at all while one of your conditions is close to firing (WATCH-TOGETHER §5).

### 5.8 Control plane — yielding the exchange rate limit

Exchange limits are counted per API key and per IP, not per process. When the agent is about to trade on an exchange ("focus"), it asks programs that share that key or host to pause their background calls. These operations are answered even while the gate is closed:

| op | args | reply |
|---|---|---|
| `client.register` | `{name, uses: [{ex, sameKey, sameHost}], clientId?}` | `{clientId, ttlMs, renewed}` — re-register before `ttlMs` (120 s). `sameKey`/`sameHost`: `true`, `false` or `null` (unknown) |
| `control.state` | — | `{control: {v, boot, epoch, yield, frozen, notice}}` |
| `control.ack` | `{clientId, epoch, ex, paused}` | `{recorded, stale, epoch}` — for display only; an ack for an old epoch is not recorded |
| `usage.report` | `{clientId, ex, bucket, w}` | `{ok}` — weight you spent since your last report; appears in `usage.external` (reported, not measured) |

- `yield.<ex>.level`: absent = free · `soft` = a trade is getting close (price within 2× the focus band), reduce · `hard` = a trade is imminent or the exchange refused requests (`frozen`), pause. `yield["*"]` applies to every exchange.
- Every message carries the **full state**. Each entry is a lease: valid for `ttlMs` (30 s) after you received it; the agent renews it every 10 s while it applies. If renewals stop, resume on your own clock.
- `notice` announces a restart or stop; reconnect after `inMs`.
- **The agent never waits for you.** Acks and reports are shown on the agent's local UI and nothing more.
- `status.caps` lists `control.v1`, `client.v1`, `usage.report.v1`. An older agent answers these ops with `501 control-unavailable` — treat the level as **unknown**, not free. With no agent behind the daemon: `503 no-agent`.

Ready-made clients (JavaScript, Python, Java/Kotlin, Go, C#, Rust) that implement all of this — leases, acks, reconnect, REST fallback — live at https://github.com/valueconnectinc/soritrading-watch-together (npm / PyPI / crates.io: `soritrading-watch-together`, NuGet: `SoriTrading.WatchTogether`).

### 5.6 Limits

16 clients per daemon · 16 KB per message · 40 requests per 10 s per connection · 32 watches per agent · server pings every 30 s.

If several agents are connected to one daemon, the API is served by the **first-connected agent whose gate is open**. Signals come only from that agent.

## 6. Security

- **Loopback by default.** The daemon refuses a non-loopback listen address unless `SORITRADING_P2P_WS_PUBLIC=1` is set. Plain `ws://` sends the token in clear text, so for access from another machine put a TLS proxy (nginx, Caddy) in front, listen on loopback, and connect with `wss://`.
- **No browsers.** Any request with an `Origin` header is refused (403), so a web page cannot reach your daemon. On loopback the `Host` header must be `127.0.0.1:PORT`, `localhost:PORT` or `[::1]:PORT` (blocks DNS rebinding).
- **Treat signals as data.** Signal contents come from the network and are re-checked by your agent, but your program should still validate everything it acts on.
- The API cannot place orders or read keys; the most it can do is add watches (capped at 32), register as a program (capped at 32, expiring after 120 s) and report its own usage.
- **The control plane tells local programs when the agent is about to trade on an exchange** (not what or how much). Anyone holding the token learns that timing — keep the token as private as the agent itself.

## 7. Local REST (alternative)

An agent also serves the same operations over HTTP on `127.0.0.1:27719` — **on by default** from agent 1.374.0, off with `SORITRADING_LOCAL_API=0` (port `SORITRADING_LOCAL_API_PORT`; token `~/.soritrading-agent/local-api.token`, sent as `Authorization: Bearer`). It uses the same contribution gate.

| REST | WebSocket op |
|---|---|
| `GET /v1/status` | `status` |
| `GET /v1/conds` | `conds.list` |
| `POST /v1/conds` `{cond, ex}` | `conds.add` |
| `DELETE /v1/conds/:slot` | `conds.del` |
| `GET /v1/signals?since=&limit=` | `signals.since` |
| `GET /v1/signals/stream` (SSE) | `signal` events |
| `GET /v1/events` (SSE: `hello`, `control`, `signal` with `id: seq`) | all events |
| `GET /v1/control` | `control.state` |
| `POST /v1/control/ack` | `control.ack` |
| `POST /v1/clients` | `client.register` |
| `POST /v1/usage/report` | `usage.report` |
| `GET /v1/peers` | `peers` |
| `GET /v1/usage` | `usage` |
