# IGUANA (v2 supervised demo)

Iguana is a **single-wallet, single-recipe** XYZ **macro index-trend** strategy:
the closest thing to an index fund, but 24/7 on Hyperliquid. It trend-follows the
two broad XYZ indices and trades the stronger move — **two assets, one decision
per tick**. No stock-picking, no commodities, no pre-IPO. Onboarding tier.

Ported from `senpi-skills/iguana` (producer v1.0.1, config v1.0.0) to the runtime
v2 supervised external-scanner contract. The standalone producer daemon is gone;
the runtime supervises `scanners/scan.py` and calls `scan()` every
`interval_seconds` (300s).

## The strategy (`scanners/scan.py`)

Index-trend pipeline, run once per tick:

1. **Whitelist** — the two broad XYZ indices `{xyz:SP500, xyz:XYZ100}`.
2. **Trend strength** — for each non-held index, fetch 4h candles
   (`market_get_asset_data`, `candle_intervals ["4h"]`) and compute the **4-day
   trend strength**: the % change of the latest 4h close vs the close
   `trendLookbackBars` (**24** = 4 days) bars ago.
3. **Pick the strongest** — the index with the highest `|strength|` above
   `minTrendPct` (**1.5%**).
4. **Score** — base **3**, **+2** if `|strength| ≥ strongTrendPct` (**4.0%**),
   **+1** if volume is rising (> **15%** over the last 6 bars). Emit only if
   `score ≥ minScore` (**4**).
5. **Direction** = sign of the trend (LONG if up, SHORT if down).
6. **Emit the single strongest** index trend — at most one signal per tick.

The pure trend/scoring functions (`trend_strength`, `trend_direction`,
`pick_strongest_trend`, `volume_trend`, `build_thesis`) were already unit-tested
in the source repo and are ported **verbatim** into `scoring.py`.

Sizing rides on each signal's `data{}` so the `OPEN_POSITION` rule action sizes
identically to the source:

- **marginPct** = percent of withdrawable (0–100): **20.0**. Dual-DEX equity is
  collapsed via **`max()` not `sum()`** — one cross-margined wallet, two sub-DEX
  views (summing double-counts the shared free balance → 2x sizing).
- **leverage** = `min(config leverage 3, MAX_LEVERAGE 5)`.

MCP tools used (all **read-only**): `market_get_asset_data` (4h candles),
`strategy_get_clearinghouse_state`.

State (the per-coin **240s recent-signal** dedup map) lives in `ctx.state` (the
source kept it in `recent-signals.json`).

## Why iguana emits at most one signal per tick

Turbine/spider emit **all** gated candidates and let the runtime apply the slot
ceiling. **Iguana is deliberately different: it emits ≤ 1 signal per tick** — the
single strongest index trend. This is the **strategy**, not an oversight:
iguana's thesis is literally "two assets, **one decision per tick**," and the
source producer picked the single strongest trend and pushed exactly one signal.
`slots: 1` matches the single-position thesis.

## Layout

```
iguana/
├── README.md
├── recipe.yaml            # the single recipe (path: ./scanners, entrypoint: scan.py)
└── scanners/
    ├── scan.py            # scan(inputs, ctx) entrypoint (index-trend pipeline)
    └── scoring.py         # pure trend/scoring functions (unit-tested, ported verbatim)
```

## Environment variables

| Variable           | Required | Purpose |
|--------------------|----------|---------|
| `IGUANA_WALLET`    | yes      | Iguana's strategy wallet |
| `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 |

## Install / run

```bash
IGUANA_WALLET=0x... \
SENPI_API_KEY=... SENPI_MCP_URL=https://mcp.prod.senpi.ai/mcp \
  openclaw senpi run examples/strategies/iguana/recipe.yaml
```

## Fidelity notes

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

### Dropped: the daemon loop + ingest POST

The source ran a `producer_daemon(interval_seconds=300)` that POSTed signals to
the ingest endpoint via `push_signal`. Under v2 the runtime **supervises** this
module and calls `scan()` once per `interval_seconds`; `scan()` is **single-pass
and synchronous** and **returns a `list[dict]`**. The scaffold owns delivery, the
wire envelope, and `signal_id` dedup. The source's normalized [0,1] wire score
(`min(score / 6, 1.0)`) is **not** recomputed in `scan()` — the raw additive
score rides on `data{}` and the runtime owns wire scoring. (The `6.0` divisor is
preserved in `scoring.py` for parity.)

### Moved to `ctx.state`: the 240s recent-signal dedup

The per-coin **240s** recent-signal dedup that the source kept in
`recent-signals.json` now lives in `ctx.state`: each tick reads the latest
`{coin: ts}` map, **prunes** it by the source window (`4 × TTL`), **skips** coins
signaled within the TTL, **stamps** the chosen candidate, and appends the updated
map for the next tick.

### Action gate: `decision_mode: llm` → `rule`

The source `OPEN_POSITION` action used `decision_mode: llm` with a pass-through
prompt that copied the signal's `asset`/`direction`/`leverage`/`marginPct`
verbatim and pinned confidence — a rule wearing an LLM costume. This recipe uses
`decision_mode: rule` directly.

### DSL preset maps cleanly

The source DSL preset maps 1:1 to the v2 schema (no dropped tiers): hard_timeout
2880m (48h), weak_peak 480m@3, dead_weight **disabled**, phase1 max_loss 12 /
retrace 8, and a 5-tier phase2 (5/0 · 10/40 · 18/60 · 30/75 · 50/85), with the T0
`lock_hw_pct 0` in the allowed `[0, 100]` range.
`dead_weight_cut` is disabled (`enabled: false`) with an inert
`interval_in_minutes` placeholder so the parser is satisfied.

### Guard rails: minutes/hours → seconds

The v2 schema uses integer seconds everywhere. `cooldown_minutes 90` →
`cooldown_seconds 5400`; `per_asset_cooldown_minutes 360` →
`per_asset_cooldown_seconds 21600` (clears the schema's 300s floor);
`data_retention_hours 96` → `data_retention_seconds 345600`.

### Only read-only MCP calls

`scan.py` never opens, closes, or cancels — it produces signals. A test asserts
no `create_position` / `close_position` / `cancel_order` tool is ever called.
