# TURBINE (v2 supervised demo)

Turbine is a **two-leg** strategy where both legs run the **same signal** and
differ **only** in sizing + DSL exit + risk. So unlike spider (two distinct
theses), turbine is authored as **one shared scanner** and **two recipes**.

Ported from `senpi-skills/turbine` (producer v3.2.2, config v3.3) to the runtime
v2 supervised external-scanner contract. The standalone producer daemon is gone;
the runtime supervises the shared `scanners/scan.py` and calls `scan()` on each
recipe's `interval_seconds`.

## The two legs

| Leg       | Thesis (identical signal) | Slots | Margin/slot | Lev | DSL exit |
|-----------|---------------------------|-------|-------------|-----|----------|
| `volume`  | Funding-fade rotation     | 7     | 20% withdrawable | 5x  | force-close: 10-min hard timeout, phase1 max_loss 50 / retrace 30, **no phase2**, maker exit |
| `runners` | Funding-fade rotation     | 2     | 20% withdrawable | 5x  | patient: 4h hard timeout, weak_peak 90m@3 + dead_weight 120m, phase1 max_loss 30 / retrace 8, 5-tier phase2 (5/0·10/35·20/55·35/75·50/85), taker exit |

Both legs run the **identical** rotation alpha (same universe, same direction
logic, same gates). The wallet boundary selects DSL behavior: `volume` prints
high notional and force-closes on the clock; `runners` takes the same entries but
lets the ~5% that catch a real directional move ride the phase2 ratchet.

Both recipes set `group: turbine` — the agent-facing handle that ties the legs
together.

## The signal (shared `scanners/scan.py`)

Funding-fade volume rotation:

- **Universe** — `MAIN = {BTC, ETH, SOL, HYPE}`, `XYZ = {xyz:BRENTOIL, xyz:GOLD,
  xyz:SPX}`.
- **Pool pick** — probabilistic: `random() < xyzWeight (0.80)` → XYZ pool, else
  MAIN pool. A **deterministic rotation index** then walks the chosen pool;
  exhaustion falls through to the other pool.
- **Direction = funding fade** — `LONG_CROWDED`/`LONG_HEAVY` → SHORT,
  `SHORT_CROWDED`/`SHORT_HEAVY` → LONG, flat/unknown → a random coin flip.
- **Spread gate** — `main ≤ 3 bps`, `xyz ≤ 10 bps`, off the top-of-book mid.
- **Skip held + 90s post-close cooldown** — never re-enter an asset already held
  or closed in the last 90 seconds.
- **One signal per free slot** — the rotation index advances once per emitted
  candidate, so a tick emits at most `maxSlots` distinct assets.

MCP tools used (all read-only): `strategy_get_clearinghouse_state`,
`strategy_get_open_orders`, `market_list_instruments`, `market_get_asset_data`.

State (rotation index, held set, last-closed map, signal dedup) lives in
`ctx.state` (the source kept it in per-wallet JSON files).

## Layout

```
turbine/
├── README.md
├── recipe-volume.yaml      # volume leg recipe   (path: ./scanners, entrypoint: scan.py)
├── recipe-runners.yaml     # runners leg recipe  (path: ./scanners, entrypoint: scan.py)
└── scanners/               # SHARED by both recipes
    ├── scan.py             # scan(inputs, ctx) entrypoint (one signal stream)
    └── scoring.py          # pure rotation/direction/spread functions (unit-tested)
```

The per-leg sizing (`marginPct`, `leverage`, `maxSlots`) rides on each recipe's
`inputs:` block, so the one `scan.py` emits correctly-sized signals for whichever
leg supervises it.

## Environment variables

| Variable                 | Required | Purpose |
|--------------------------|----------|---------|
| `TURBINE_VOLUME_WALLET`  | volume   | Volume-leg strategy wallet |
| `TURBINE_RUNNERS_WALLET` | runners  | Runners-leg strategy wallet (**optional** — see below) |
| `SENPI_API_KEY`          | yes      | MCP auth for `ctx.senpi_mcp` (injected into the scaffold child) |
| `SENPI_MCP_URL`          | yes      | MCP server URL |
| `TELEGRAM_CHAT_ID`       | optional | Notifications |

### Runners is optional

Leave `TURBINE_RUNNERS_WALLET` unset and simply **don't launch**
`recipe-runners.yaml` to run a **pure volume engine**. The two legs are
independent runtimes with no cross-leg coupling, so volume runs standalone with
no degradation. (The source's per-wallet rotation desync came from each wallet
owning its own rotation index — here that is just each leg owning its own
`ctx.state`.)

## Install / run

Run each leg as its own runtime instance, pointing at its recipe:

```bash
TURBINE_VOLUME_WALLET=0x... \
SENPI_API_KEY=... SENPI_MCP_URL=https://mcp.prod.senpi.ai/mcp \
  openclaw senpi run examples/strategies/turbine/recipe-volume.yaml

# optional second leg:
TURBINE_RUNNERS_WALLET=0x... \
SENPI_API_KEY=... SENPI_MCP_URL=https://mcp.prod.senpi.ai/mcp \
  openclaw senpi run examples/strategies/turbine/recipe-runners.yaml
```

## Fidelity notes

These are the source behaviors that changed (or did not map 1:1) under the v2
contract. Nothing load-bearing was silently dropped.

### Dropped: the `cancel_order` stale-order sweep

The source producer cancelled orphaned resting maker orders each tick
(`sweep_stale_resting_orders`). Those orphans were an artifact of the **old
daemon / helpers-migration era**: a runtime swap left resting ALO orders that the
*new* runtime instance did not own, so they starved slots indefinitely.

Under v2 the **supervised runtime owns its own order lifecycle** end to end:

- `FEE_OPTIMIZED_LIMIT`'s `execution_timeout_seconds` cancels the entry maker
  order the runtime itself placed if it doesn't fill in time, and
- the DSL `hard_timeout` force-closes any position that does fill.

There is no second uncoordinated runtime to leave orphans, so the sweep is
**redundant** and was not ported — a scanner produces signals, it does not manage
orders. (If orphaned ALOs ever reappear in operation, the correct fix is a
runtime-side reconcile, not a scanner side-effect.) `scan.py` therefore makes
**only read-only MCP calls** — a test asserts `cancel_order` is never called.

### Moved to the runtime: `effective_slots` auto-downsize / per-tick cap

The source computed `effective_slots = min(maxSlots, account_value /
margin_per_slot)` and emitted at most that many signals per tick. In v2 the
runtime owns slot count (`strategy.slots`) and affordability (`risk.guard_rails`),
so `scan()` emits all candidates clearing the gates (held + cooldown + spread),
carrying `marginPct` (percent of withdrawable) + `leverage` on each signal's top
level. It still caps a tick at `maxSlots` candidates (the rotation only advances
that many times) so the scanner doesn't flood the queue; the runtime makes the
final open/skip decision.

### Kept: non-deterministic `random` picks

The probabilistic pool pick and the neutral-regime coin flip are the source's
design (rotation desync + unbiased neutral direction). They are kept verbatim. A
non-deterministic scan is fine — the tests assert structure (valid, gated
signals) and seed/inject the rng for the deterministic paths.

### Threshold that did not map: volume `phase2: tiers: []`

The source volume preset disabled phase2 with `tiers: []`. The v2 DSL parser
requires a **non-empty** `tiers` array even when phase2 is disabled. The volume
recipe keeps `phase2.enabled: false` (so phase2 never advances) but supplies one
**inert placeholder tier** to satisfy the parser. Every other threshold in both
legs is carried verbatim. The runners 5-tier phase2 maps cleanly.

### `trading_risk: balanced` → `moderate`

The runners source used `trading_risk: balanced`, which is not a v2 enum value
(`conservative | moderate | aggressive`). It is mapped to the nearest tier,
`moderate`. The volume source's `aggressive` is carried verbatim.

### Action gate

The source `OPEN_POSITION` action used `decision_mode: llm` with a copy-verbatim
pass-through prompt and a pinned confidence — a rule wearing an LLM costume. Both
v2 recipes use `decision_mode: rule` directly.

### Sizing source of truth

The source had margin/slot drift across files (700/1300 in config v3.3 vs
500/950 producer defaults vs YAML). This port uses **config v3.3** proportions:
volume 7 slots, runners 2 slots, both 5x, `xyzWeight 0.80`, spread main 3 bps /
xyz 10 bps. Sizing is now `marginPct` (percent of withdrawable) set in each
recipe's `inputs:` block (default 20); the old fixed-USD amounts can't be
preserved as a percent without knowing account size.
