@senpi/runtime 3.0.0-dev.runtime-phase-2.20260623210537 → 3.0.1-dev.openclaw-upgrade-mai.20260625140818
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -9
- package/dist/actions/action-runtime-module.d.ts.map +1 -1
- package/dist/actions/action-runtime-module.js +1 -1
- package/dist/actions/action-runtime-module.js.map +1 -1
- package/dist/actions/decision-engine.d.ts.map +1 -1
- package/dist/actions/decision-engine.js +3 -0
- package/dist/actions/decision-engine.js.map +1 -1
- package/dist/actions/open-position/open-position.action.d.ts.map +1 -1
- package/dist/actions/open-position/open-position.action.js +54 -14
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/actions/types.d.ts +10 -2
- package/dist/actions/types.d.ts.map +1 -1
- package/dist/actions/types.js +8 -0
- package/dist/actions/types.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +80 -74
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +0 -108
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/constants/dsl/index.d.ts +2 -0
- package/dist/constants/dsl/index.d.ts.map +1 -1
- package/dist/constants/dsl/index.js +2 -0
- package/dist/constants/dsl/index.js.map +1 -1
- package/dist/dsl/config/parse-tiers.d.ts.map +1 -1
- package/dist/dsl/config/parse-tiers.js +6 -2
- package/dist/dsl/config/parse-tiers.js.map +1 -1
- package/dist/dsl/engine-client/config-mapping.d.ts +7 -0
- package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
- package/dist/dsl/engine-client/config-mapping.js +16 -2
- package/dist/dsl/engine-client/config-mapping.js.map +1 -1
- package/dist/dsl/events/handlers.d.ts +18 -2
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +302 -213
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/create-monitor.d.ts +15 -0
- package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
- package/dist/dsl/monitor/create-monitor.js +46 -9
- package/dist/dsl/monitor/create-monitor.js.map +1 -1
- package/dist/dsl/monitor/process-one-position.d.ts.map +1 -1
- package/dist/dsl/monitor/process-one-position.js +138 -77
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/dsl/plugin/index.d.ts +16 -1
- package/dist/dsl/plugin/index.d.ts.map +1 -1
- package/dist/dsl/plugin/index.js +35 -4
- package/dist/dsl/plugin/index.js.map +1 -1
- package/dist/dsl/plugin/options.d.ts +18 -0
- package/dist/dsl/plugin/options.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/create-scanner-module.d.ts +4 -3
- package/dist/runtime/create-scanner-module.d.ts.map +1 -1
- package/dist/runtime/create-scanner-module.js +14 -5
- package/dist/runtime/create-scanner-module.js.map +1 -1
- package/dist/runtime/create-scanner-providers-for-run.d.ts.map +1 -1
- package/dist/runtime/create-scanner-providers-for-run.js +12 -1
- package/dist/runtime/create-scanner-providers-for-run.js.map +1 -1
- package/dist/runtime/load-runtime-config.js +1 -1
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime/map-strategies-registration.js +7 -8
- package/dist/runtime/map-strategies-registration.js.map +1 -1
- package/dist/runtime/map-strategies.d.ts.map +1 -1
- package/dist/runtime/map-strategies.js +12 -3
- package/dist/runtime/map-strategies.js.map +1 -1
- package/dist/runtime/reconcile-decide.d.ts +73 -0
- package/dist/runtime/reconcile-decide.d.ts.map +1 -0
- package/dist/runtime/reconcile-decide.js +186 -0
- package/dist/runtime/reconcile-decide.js.map +1 -0
- package/dist/runtime/reconcile-run.d.ts +62 -0
- package/dist/runtime/reconcile-run.d.ts.map +1 -0
- package/dist/runtime/reconcile-run.js +173 -0
- package/dist/runtime/reconcile-run.js.map +1 -0
- package/dist/runtime/reconcile-state.d.ts +44 -0
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +80 -44
- package/dist/runtime/reconcile-state.js.map +1 -1
- package/dist/runtime/risk-guard.d.ts.map +1 -1
- package/dist/runtime/risk-guard.js +17 -12
- package/dist/runtime/risk-guard.js.map +1 -1
- package/dist/runtime/runtime-schema.d.ts +2912 -1052
- package/dist/runtime/runtime-schema.d.ts.map +1 -1
- package/dist/runtime/runtime-schema.js +136 -42
- package/dist/runtime/runtime-schema.js.map +1 -1
- package/dist/runtime/runtime.d.ts +26 -0
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +254 -8
- package/dist/runtime/runtime.js.map +1 -1
- package/dist/runtime/scanner-registry.d.ts +1 -1
- package/dist/runtime/scanner-registry.d.ts.map +1 -1
- package/dist/runtime/scanner-registry.js +3 -12
- package/dist/runtime/scanner-registry.js.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js +1 -1
- package/dist/runtime/supervisor/long-running-supervisor.js.map +1 -1
- package/dist/runtime/supervisor/orphan-sweep.js +1 -1
- package/dist/runtime/supervisor/orphan-sweep.js.map +1 -1
- package/dist/runtime/supervisor/supervisor-utils.d.ts +1 -1
- package/dist/runtime/supervisor/supervisor-utils.d.ts.map +1 -1
- package/dist/runtime/supervisor/supervisor-utils.js +1 -1
- package/dist/runtime/supervisor/supervisor-utils.js.map +1 -1
- package/dist/runtime-api/intake/acceptance-record.d.ts +10 -0
- package/dist/runtime-api/intake/acceptance-record.d.ts.map +1 -1
- package/dist/runtime-api/intake/acceptance-record.js +28 -0
- package/dist/runtime-api/intake/acceptance-record.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +28 -36
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts +20 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.js +57 -6
- package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.d.ts +18 -4
- package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.js +24 -3
- package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
- package/dist/runtime-api/intake/types.d.ts +16 -0
- package/dist/runtime-api/intake/types.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +4 -0
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +12 -0
- package/dist/runtime-api/routes/signals.schema.js.map +1 -1
- package/dist/scanners/external/scaffold/__main__.py +65 -24
- package/dist/scanners/external/scaffold/ctx.py +78 -0
- package/dist/scanners/external/scaffold/delivery.py +56 -22
- package/dist/scanners/external/scaffold/entrypoint_loader.py +42 -48
- package/dist/scanners/external/scaffold/journal.py +13 -2
- package/dist/scanners/external/scaffold/requirements.txt +11 -0
- package/dist/scanners/external/scaffold/scaffold.py +159 -128
- package/dist/scanners/external/scaffold/senpi_mcp.py +250 -0
- package/dist/scanners/external/scaffold/signal_envelope.py +177 -34
- package/dist/scanners/external/scaffold/state_manager.py +175 -0
- package/dist/scanners/external-scanner-receiver.d.ts +6 -0
- package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
- package/dist/scanners/external-scanner-receiver.js +14 -1
- package/dist/scanners/external-scanner-receiver.js.map +1 -1
- package/dist/scripts/run-dsl-standalone-live.d.ts +1 -1
- package/dist/scripts/run-dsl-standalone-live.js +5 -3
- package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
- package/dist/senpi/client.d.ts +37 -4
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +101 -24
- package/dist/senpi/client.js.map +1 -1
- package/dist/state/state-manager.d.ts +17 -0
- package/dist/state/state-manager.d.ts.map +1 -1
- package/dist/state/state-manager.js +50 -4
- package/dist/state/state-manager.js.map +1 -1
- package/dist/strategy/strategy-state.d.ts +9 -1
- package/dist/strategy/strategy-state.d.ts.map +1 -1
- package/dist/strategy/strategy-state.js +62 -9
- package/dist/strategy/strategy-state.js.map +1 -1
- package/dist/tracking/constants.d.ts.map +1 -1
- package/dist/tracking/constants.js +2 -0
- package/dist/tracking/constants.js.map +1 -1
- package/dist/types/dsl/index.d.ts +1 -1
- package/dist/types/dsl/index.d.ts.map +1 -1
- package/dist/types/scanner.d.ts +7 -0
- package/dist/types/scanner.d.ts.map +1 -1
- package/dist/types/strategy.d.ts +3 -1
- package/dist/types/strategy.d.ts.map +1 -1
- package/dist/utils/keyed-mutex.d.ts +37 -0
- package/dist/utils/keyed-mutex.d.ts.map +1 -0
- package/dist/utils/keyed-mutex.js +77 -0
- package/dist/utils/keyed-mutex.js.map +1 -0
- package/examples/strategies/README.md +17 -12
- package/examples/strategies/iguana/README.md +135 -0
- package/examples/strategies/iguana/recipe.yaml +137 -0
- package/examples/strategies/iguana/scanners/scan.py +243 -0
- package/examples/strategies/iguana/scanners/scoring.py +152 -0
- package/examples/strategies/raptor/README.md +156 -0
- package/examples/strategies/raptor/recipe.yaml +154 -0
- package/examples/strategies/raptor/scanners/scan.py +349 -0
- package/examples/strategies/raptor/scanners/scoring.py +452 -0
- package/examples/strategies/spider/README.md +89 -0
- package/examples/strategies/spider/scalp/recipe.yaml +129 -0
- package/examples/strategies/spider/scalp/scanners/scan.py +219 -0
- package/examples/strategies/spider/scalp/scanners/scoring.py +179 -0
- package/examples/strategies/spider/swing/recipe.yaml +137 -0
- package/examples/strategies/spider/swing/scanners/scan.py +334 -0
- package/examples/strategies/spider/swing/scanners/scoring.py +184 -0
- package/examples/strategies/turbine/README.md +166 -0
- package/examples/strategies/turbine/recipe-runners.yaml +123 -0
- package/examples/strategies/turbine/recipe-volume.yaml +129 -0
- package/examples/strategies/turbine/scanners/scan.py +283 -0
- package/examples/strategies/turbine/scanners/scoring.py +190 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/dist/scanners/external/liquidation-watchdog/README.md +0 -107
- package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +0 -48
- package/dist/scanners/external/liquidation-watchdog/producer.mjs +0 -336
- package/dist/scanners/external/momentum/README.md +0 -148
- package/dist/scanners/external/momentum/momentum.yaml +0 -116
- package/dist/scanners/external/momentum/producer.mjs +0 -251
- package/dist/scanners/external/shared/ingest.mjs +0 -112
- package/examples/README.md +0 -89
- package/examples/scanners-consumer-quickstart.mjs +0 -134
- package/examples/scanners-emerging-movers-v4.mjs +0 -287
- package/examples/scanners-liquidation-watchdog.mjs +0 -341
- package/examples/scanners-live-mcp.mjs +0 -208
- package/examples/scanners-multi-strategy.mjs +0 -165
- package/examples/scanners-position-tracker-e2e.mjs +0 -225
- package/examples/scanners-scheduled-events.mjs +0 -96
- package/examples/scanners-senpi-provider-client.mjs +0 -182
- package/examples/scanners-single-strategy.mjs +0 -198
- package/examples/strategies/dsl-showcase.yaml +0 -68
- package/examples/strategies/dsl.yaml +0 -51
- package/examples/strategies/fox-minimal-loosened.yaml +0 -240
- package/examples/strategies/fox-minimal.yaml +0 -242
- package/examples/strategies/minimal.yaml +0 -46
- package/examples/strategies/momentum/how-it-works.md +0 -48
- package/examples/strategies/momentum/momentum-guarded.yaml +0 -161
- package/examples/strategies/risk-showcase.yaml +0 -93
- package/examples/strategies/viper.yaml +0 -67
- package/examples/strategies/wolf.yaml +0 -72
- package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +0 -123
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""IGUANA — supervised external scanner entrypoint (XYZ macro index trend).
|
|
2
|
+
|
|
3
|
+
Port of senpi-skills/iguana (producer v1.0.1, config v1.0.0) to the v2
|
|
4
|
+
`scan(inputs, ctx)` contract. The daemon loop is gone — the runtime supervisor
|
|
5
|
+
calls scan() every interval_seconds (300s).
|
|
6
|
+
|
|
7
|
+
THE STRATEGY — the simplest possible XYZ equity exposure (ported VERBATIM; see
|
|
8
|
+
scoring.py for the pure trend/scoring functions):
|
|
9
|
+
|
|
10
|
+
1. Whitelist — the two broad XYZ indices {xyz:SP500, xyz:XYZ100}. No
|
|
11
|
+
stock-picking, no commodities, no pre-IPO.
|
|
12
|
+
2. Per non-held index, fetch 4h candles (market_get_asset_data,
|
|
13
|
+
candle_intervals ["4h"]) and compute a 4-day trend strength: the % change
|
|
14
|
+
of the latest 4h close vs the close `trendLookbackBars` (24) bars ago.
|
|
15
|
+
3. Pick the index with the highest |strength| above `minTrendPct` (1.5%).
|
|
16
|
+
4. Score it: base 3, +2 if |strength| >= `strongTrendPct` (4.0%), +1 if volume
|
|
17
|
+
is rising (> 15% over 6 bars). Emit only if score >= `minScore` (4).
|
|
18
|
+
5. Direction = sign of the trend (LONG if up, SHORT if down).
|
|
19
|
+
6. Emit AT MOST ONE signal per tick (one decision, two assets).
|
|
20
|
+
|
|
21
|
+
DESIGN CHOICE — emit best-only vs runtime-owns-slots. Turbine/spider emit ALL
|
|
22
|
+
gated candidates and let the runtime apply the slot ceiling. IGUANA is
|
|
23
|
+
deliberately different: it emits AT MOST ONE signal per tick (the single
|
|
24
|
+
strongest index trend). This is the strategy, not an oversight — iguana's whole
|
|
25
|
+
thesis is "two assets, ONE decision per tick," and the source producer picked the
|
|
26
|
+
single strongest trend and pushed exactly one signal. Keeping `slots: 1` in the
|
|
27
|
+
recipe matches the single-position thesis. We preserve the per-tick <=1 emission
|
|
28
|
+
ceiling on purpose.
|
|
29
|
+
|
|
30
|
+
Sizing — carried on each signal's data{} so the OPEN_POSITION rule action sizes
|
|
31
|
+
identically to the source:
|
|
32
|
+
- marginUsd = round(account_value * marginPct, 2), marginPct 0.20. Dual-DEX
|
|
33
|
+
equity is collapsed via max() (NOT sum()) — one cross-margined wallet, two
|
|
34
|
+
sub-DEX views; summing double-counts free balance -> 2x sizing.
|
|
35
|
+
- leverage = min(config leverage 3, MAX_LEVERAGE 5).
|
|
36
|
+
|
|
37
|
+
FLAGS — behaviors that changed under the v2 contract (NOT silent drops):
|
|
38
|
+
|
|
39
|
+
1. Daemon loop / producer_daemon — DROPPED. The runtime supervises this module
|
|
40
|
+
and calls scan() each interval_seconds. scan() is single-pass and SYNC.
|
|
41
|
+
|
|
42
|
+
2. push_signal / ingest POST — DROPPED. scan() returns a plain list[dict]; the
|
|
43
|
+
scaffold owns delivery, dedup (signal_id), and the wire envelope. The
|
|
44
|
+
source's [0,1] normalized wire score (min(score/6, 1.0)) is NOT recomputed
|
|
45
|
+
here — the raw additive score rides on data{} and the scaffold/runtime own
|
|
46
|
+
wire scoring. (SCORE_NORMALIZATION_DIVISOR is kept in scoring.py for parity.)
|
|
47
|
+
|
|
48
|
+
3. recent-signals.json 240s dedup — MOVED to ctx.state. The per-coin 240s
|
|
49
|
+
recent-signal dedup that the source kept in a JSON file lives in ctx.state
|
|
50
|
+
now: each tick reads the latest {coin: ts} map, prunes by the source's
|
|
51
|
+
window (4 × TTL), skips coins signaled within TTL, stamps the chosen
|
|
52
|
+
candidate, and appends the updated map for next tick.
|
|
53
|
+
|
|
54
|
+
4. ONLY read-only MCP calls. scan() never opens/closes/cancels — it produces
|
|
55
|
+
signals. (A test asserts no create/close/cancel tool is ever called.)
|
|
56
|
+
|
|
57
|
+
MCP tools used (all read-only): market_get_asset_data (4h candles),
|
|
58
|
+
strategy_get_clearinghouse_state.
|
|
59
|
+
|
|
60
|
+
State (in ctx.state, newest record wins): {"recent": {COIN: epoch_seconds}}.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
import sys
|
|
64
|
+
import time
|
|
65
|
+
|
|
66
|
+
import scoring
|
|
67
|
+
|
|
68
|
+
RECENT_SIGNAL_TTL_SEC = 240 # source iguana_config.RECENT_SIGNAL_TTL_SEC
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
# ── MCP data fetchers (route the producer's calls through ctx.senpi_mcp) ──
|
|
72
|
+
|
|
73
|
+
def _fetch_candles(ctx, asset):
|
|
74
|
+
"""market_get_asset_data(4h) -> list of 4h candle dicts (or []).
|
|
75
|
+
|
|
76
|
+
Verbatim parse from iguana-producer.fetch_candles: honors the `success`
|
|
77
|
+
flag and digs into data.candles["4h"].
|
|
78
|
+
"""
|
|
79
|
+
try:
|
|
80
|
+
data = ctx.senpi_mcp.call_tool("market_get_asset_data", {
|
|
81
|
+
"asset": asset,
|
|
82
|
+
"candle_intervals": ["4h"],
|
|
83
|
+
"include_funding": False,
|
|
84
|
+
"include_order_book": False,
|
|
85
|
+
})
|
|
86
|
+
except Exception:
|
|
87
|
+
return []
|
|
88
|
+
if not data or (isinstance(data, dict) and not data.get("success", True)):
|
|
89
|
+
return []
|
|
90
|
+
d = data.get("data", data) if isinstance(data, dict) else data
|
|
91
|
+
if not isinstance(d, dict):
|
|
92
|
+
return []
|
|
93
|
+
candles = d.get("candles", {}) or {}
|
|
94
|
+
if not isinstance(candles, dict):
|
|
95
|
+
return []
|
|
96
|
+
return candles.get("4h", []) or []
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_account(ctx):
|
|
100
|
+
"""(account_value, held_assets) from strategy_get_clearinghouse_state.
|
|
101
|
+
|
|
102
|
+
Dual-DEX equity collapse: account_value via max() across main/xyz sections
|
|
103
|
+
(two views of ONE cross-margined wallet — summing double-counts free
|
|
104
|
+
balance -> 2x sizing). Verbatim from iguana_config.get_positions.
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
ch = ctx.senpi_mcp.call_tool("strategy_get_clearinghouse_state",
|
|
108
|
+
{"strategy_wallet": ctx.wallet})
|
|
109
|
+
except Exception:
|
|
110
|
+
return 0.0, []
|
|
111
|
+
if not ch:
|
|
112
|
+
return 0.0, []
|
|
113
|
+
data = ch.get("data", ch) if isinstance(ch, dict) else ch
|
|
114
|
+
if not isinstance(data, dict):
|
|
115
|
+
return 0.0, []
|
|
116
|
+
account_value = 0.0
|
|
117
|
+
held = []
|
|
118
|
+
for section in ("main", "xyz"):
|
|
119
|
+
s = data.get(section, {})
|
|
120
|
+
if not isinstance(s, dict):
|
|
121
|
+
continue
|
|
122
|
+
ms = s.get("marginSummary", {})
|
|
123
|
+
account_value = max(account_value, scoring._f(ms, "accountValue"))
|
|
124
|
+
for ap in s.get("assetPositions", []) or []:
|
|
125
|
+
pos = ap.get("position", ap)
|
|
126
|
+
if scoring._f(pos, "szi") == 0:
|
|
127
|
+
continue
|
|
128
|
+
coin = pos.get("coin", "")
|
|
129
|
+
if coin:
|
|
130
|
+
held.append(coin)
|
|
131
|
+
return account_value, held
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ── ctx.state I/O (per-coin 240s recent-signal dedup) ──
|
|
135
|
+
|
|
136
|
+
def _load_recent(ctx):
|
|
137
|
+
if ctx.state is None or len(ctx.state) == 0:
|
|
138
|
+
return {}
|
|
139
|
+
last = ctx.state.last() or {}
|
|
140
|
+
recent = last.get("recent", {})
|
|
141
|
+
return dict(recent) if isinstance(recent, dict) else {}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _prune_recent(recent, now):
|
|
145
|
+
"""Drop entries older than the source window (4 × TTL). Verbatim from
|
|
146
|
+
iguana_config._prune_recent_signals."""
|
|
147
|
+
cutoff = now - (RECENT_SIGNAL_TTL_SEC * 4)
|
|
148
|
+
return {k: v for k, v in recent.items() if v >= cutoff}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _was_recently_signaled(recent, coin, now, ttl_sec=RECENT_SIGNAL_TTL_SEC):
|
|
152
|
+
"""True if `coin` was signaled within `ttl_sec`. Verbatim window logic from
|
|
153
|
+
iguana_config.was_recently_signaled."""
|
|
154
|
+
if not coin:
|
|
155
|
+
return False
|
|
156
|
+
last = recent.get(coin.upper())
|
|
157
|
+
if last is None:
|
|
158
|
+
return False
|
|
159
|
+
return (now - last) < ttl_sec
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def scan(inputs, ctx):
|
|
163
|
+
now = time.time()
|
|
164
|
+
whitelist = inputs.get("whitelist", scoring.DEFAULT_WHITELIST)
|
|
165
|
+
lookback = int(inputs.get("trendLookbackBars", scoring.DEFAULT_TREND_LOOKBACK))
|
|
166
|
+
min_pct = float(inputs.get("minTrendPct", scoring.DEFAULT_MIN_TREND_PCT))
|
|
167
|
+
min_score = int(inputs.get("minScore", scoring.DEFAULT_MIN_SCORE))
|
|
168
|
+
margin_pct = float(inputs.get("marginPct", scoring.DEFAULT_MARGIN_PCT))
|
|
169
|
+
leverage = min(int(inputs.get("leverage", scoring.DEFAULT_LEVERAGE)),
|
|
170
|
+
scoring.MAX_LEVERAGE)
|
|
171
|
+
|
|
172
|
+
account_value, held_assets = _get_account(ctx)
|
|
173
|
+
if account_value <= 0:
|
|
174
|
+
return []
|
|
175
|
+
held_set = {h.upper() for h in held_assets}
|
|
176
|
+
|
|
177
|
+
recent = _prune_recent(_load_recent(ctx), now)
|
|
178
|
+
|
|
179
|
+
# config-shaped knobs for build_thesis (reads minTrendPct / strongTrendPct).
|
|
180
|
+
thesis_config = {
|
|
181
|
+
"minTrendPct": min_pct,
|
|
182
|
+
"strongTrendPct": float(inputs.get("strongTrendPct",
|
|
183
|
+
scoring.DEFAULT_STRONG_TREND_PCT)),
|
|
184
|
+
"minScore": min_score,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
# Compute 4d trend strength for each non-held, non-recently-signaled index.
|
|
188
|
+
candles_by_asset = {}
|
|
189
|
+
strength_by_asset = {}
|
|
190
|
+
for asset in whitelist:
|
|
191
|
+
if asset.upper() in held_set or _was_recently_signaled(recent, asset, now):
|
|
192
|
+
continue
|
|
193
|
+
candles = _fetch_candles(ctx, asset)
|
|
194
|
+
if len(candles) <= lookback:
|
|
195
|
+
continue
|
|
196
|
+
closes = [scoring._f(c, "close", "c") for c in candles]
|
|
197
|
+
candles_by_asset[asset] = candles
|
|
198
|
+
strength_by_asset[asset] = scoring.trend_strength(closes, lookback)
|
|
199
|
+
|
|
200
|
+
out = []
|
|
201
|
+
picked = scoring.pick_strongest_trend(strength_by_asset, min_pct)
|
|
202
|
+
if picked is not None:
|
|
203
|
+
asset, strength = picked
|
|
204
|
+
thesis = scoring.build_thesis(asset, strength, candles_by_asset[asset],
|
|
205
|
+
thesis_config)
|
|
206
|
+
if thesis is not None and thesis["score"] >= min_score:
|
|
207
|
+
coin = thesis["coin"]
|
|
208
|
+
if coin.upper() not in held_set:
|
|
209
|
+
margin_usd = round(account_value * margin_pct, 2)
|
|
210
|
+
# Stamp the 240s recent-signal dedup so we don't re-emit.
|
|
211
|
+
recent[coin.upper()] = now
|
|
212
|
+
out.append({
|
|
213
|
+
"asset": coin,
|
|
214
|
+
"direction": thesis["direction"],
|
|
215
|
+
"marginUsd": float(margin_usd),
|
|
216
|
+
"leverage": float(leverage),
|
|
217
|
+
"data": {
|
|
218
|
+
"score": thesis["score"],
|
|
219
|
+
"direction": thesis["direction"],
|
|
220
|
+
"reasons": thesis["reasons"],
|
|
221
|
+
"trendPct": thesis.get("trend_pct") or 0.0,
|
|
222
|
+
"volumeTrendPct": thesis.get("volume_trend_pct") or 0.0,
|
|
223
|
+
"heldAssets": held_assets,
|
|
224
|
+
},
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
# Persist the pruned + updated dedup map for next tick.
|
|
228
|
+
# If this append fails (e.g. state_history_max_count is 0/unset so append
|
|
229
|
+
# raises, or a transient persist error), the next tick reloads stale dedup
|
|
230
|
+
# state and may re-emit already-suppressed signals. Log-and-continue: don't
|
|
231
|
+
# crash the tick, but make the failure visible on stderr (the runtime
|
|
232
|
+
# supervisor captures the scaffold child's stderr) instead of swallowing it.
|
|
233
|
+
if ctx.state is not None:
|
|
234
|
+
try:
|
|
235
|
+
ctx.state.append({"recent": recent})
|
|
236
|
+
except Exception as exc:
|
|
237
|
+
print(
|
|
238
|
+
f"[iguana.scan] WARNING: dedup-state append failed; next tick "
|
|
239
|
+
f"may re-emit suppressed signals: {exc!r}",
|
|
240
|
+
file=sys.stderr,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return out
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""IGUANA — pure index-trend functions (port of iguana-producer.py v1.0.1).
|
|
2
|
+
|
|
3
|
+
Ported VERBATIM from senpi-skills/iguana/scripts/iguana-producer.py (the pure
|
|
4
|
+
trend / scoring helpers, already unit-tested in that repo's tests/test_signal.py)
|
|
5
|
+
and iguana_config.py. No I/O, no MCP, no daemon — pure and unit-testable.
|
|
6
|
+
`scan.py` fetches 4h candles via ctx.senpi_mcp and hands them to these helpers.
|
|
7
|
+
|
|
8
|
+
THE STRATEGY — XYZ macro index trend (the simplest XYZ equity exposure):
|
|
9
|
+
trend-follow the broad indices `xyz:SP500` + `xyz:XYZ100`. No stock-picking, no
|
|
10
|
+
commodities, no pre-IPO. Two assets, ONE decision per tick. Each tick computes a
|
|
11
|
+
4-day trend strength (% change of the latest 4h close vs the close
|
|
12
|
+
`trendLookbackBars` (24 = 4 days) ago), picks the stronger move past
|
|
13
|
+
`minTrendPct` (1.5%), and trades in its direction. Score = base 3, +2 if the move
|
|
14
|
+
is strong (>= `strongTrendPct` 4.0%), +1 if volume is rising (> 15% over 6 bars);
|
|
15
|
+
floor `minScore` 4. Closest thing to an index fund, but 24/7 on Hyperliquid.
|
|
16
|
+
|
|
17
|
+
Constants below are the source defaults (iguana-producer.py + config v1.0.0).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
# ── Constants (verbatim from producer + config v1.0.0) ──
|
|
21
|
+
|
|
22
|
+
MAX_LEVERAGE = 5
|
|
23
|
+
DEFAULT_LEVERAGE = 3
|
|
24
|
+
DEFAULT_MIN_SCORE = 4
|
|
25
|
+
|
|
26
|
+
DEFAULT_WHITELIST = ["xyz:SP500", "xyz:XYZ100"]
|
|
27
|
+
DEFAULT_TREND_LOOKBACK = 24 # 24 × 4h bars = 4 days
|
|
28
|
+
DEFAULT_MIN_TREND_PCT = 1.5 # minimum |4-day move| to call it a trend
|
|
29
|
+
DEFAULT_STRONG_TREND_PCT = 4.0
|
|
30
|
+
DEFAULT_MARGIN_PCT = 0.20
|
|
31
|
+
|
|
32
|
+
# Score normalization divisor used by the source when posting the [0,1] wire
|
|
33
|
+
# score (min(score / 6, 1.0)). Kept for parity — the scaffold/runtime own wire
|
|
34
|
+
# scoring now; the raw additive score rides on data{}.
|
|
35
|
+
SCORE_NORMALIZATION_DIVISOR = 6.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _f(c, primary, alt=None, default=0.0):
|
|
39
|
+
"""Pull a numeric field from a candle dict, trying `primary` then `alt`.
|
|
40
|
+
|
|
41
|
+
Verbatim from iguana-producer._f — candle responses use either long keys
|
|
42
|
+
(close/volume) or short keys (c/v) depending on the source shape.
|
|
43
|
+
"""
|
|
44
|
+
val = c.get(primary)
|
|
45
|
+
if val is None and alt:
|
|
46
|
+
val = c.get(alt)
|
|
47
|
+
try:
|
|
48
|
+
return float(val if val is not None else default)
|
|
49
|
+
except (TypeError, ValueError):
|
|
50
|
+
return default
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
# ═══════════════════════════════════════════════════════════════
|
|
54
|
+
# Pure index-trend logic (unit-tested in the source tests/test_signal.py)
|
|
55
|
+
# ═══════════════════════════════════════════════════════════════
|
|
56
|
+
|
|
57
|
+
def trend_strength(closes, lookback):
|
|
58
|
+
"""% change of the latest close vs the close `lookback` bars ago.
|
|
59
|
+
None if insufficient data or the reference price is non-positive.
|
|
60
|
+
|
|
61
|
+
Verbatim from iguana-producer.trend_strength.
|
|
62
|
+
"""
|
|
63
|
+
if not closes or len(closes) <= lookback:
|
|
64
|
+
return None
|
|
65
|
+
ref = closes[-(lookback + 1)]
|
|
66
|
+
latest = closes[-1]
|
|
67
|
+
if ref is None or ref <= 0:
|
|
68
|
+
return None
|
|
69
|
+
return ((latest - ref) / ref) * 100.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def trend_direction(strength, min_pct):
|
|
73
|
+
"""Direction implied by trend strength. None if magnitude below threshold.
|
|
74
|
+
|
|
75
|
+
Verbatim from iguana-producer.trend_direction.
|
|
76
|
+
"""
|
|
77
|
+
if strength is None or abs(strength) < min_pct:
|
|
78
|
+
return None
|
|
79
|
+
return "LONG" if strength > 0 else "SHORT"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def pick_strongest_trend(per_asset_strength, min_pct):
|
|
83
|
+
"""Among {asset: strength}, return the asset with the highest |strength|
|
|
84
|
+
above min_pct. Returns (asset, strength) or None.
|
|
85
|
+
|
|
86
|
+
Verbatim from iguana-producer.pick_strongest_trend.
|
|
87
|
+
"""
|
|
88
|
+
best, best_mag = None, -1.0
|
|
89
|
+
for asset, strength in per_asset_strength.items():
|
|
90
|
+
if strength is None:
|
|
91
|
+
continue
|
|
92
|
+
mag = abs(strength)
|
|
93
|
+
if mag < min_pct:
|
|
94
|
+
continue
|
|
95
|
+
if mag > best_mag:
|
|
96
|
+
best_mag, best = mag, (asset, strength)
|
|
97
|
+
return best
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def volume_trend(candles, lookback=6):
|
|
101
|
+
"""% change of the recent half vs the earlier half of the last `lookback`
|
|
102
|
+
candle volumes. 0.0 when there is insufficient data.
|
|
103
|
+
|
|
104
|
+
Verbatim from iguana-producer.volume_trend.
|
|
105
|
+
"""
|
|
106
|
+
if len(candles) < lookback:
|
|
107
|
+
return 0.0
|
|
108
|
+
vols = [_f(c, "volume", "v") for c in candles[-lookback:]]
|
|
109
|
+
half = lookback // 2
|
|
110
|
+
if half <= 0:
|
|
111
|
+
return 0.0
|
|
112
|
+
recent = sum(vols[-half:]) / half
|
|
113
|
+
earlier = sum(vols[:half]) / half
|
|
114
|
+
if earlier <= 0:
|
|
115
|
+
return 0.0
|
|
116
|
+
return ((recent - earlier) / earlier) * 100
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def build_thesis(asset, strength, candles, config):
|
|
120
|
+
"""Build the index-trend thesis (direction + additive score + reasons).
|
|
121
|
+
|
|
122
|
+
Score: base 3 (trend above min), +2 if |strength| >= strongTrendPct, +1 if
|
|
123
|
+
volume_trend > 15%. Returns the thesis dict or None when the direction gate
|
|
124
|
+
(|strength| >= minTrendPct) is not cleared. Verbatim from
|
|
125
|
+
iguana-producer.build_thesis.
|
|
126
|
+
"""
|
|
127
|
+
min_pct = float(config.get("minTrendPct", DEFAULT_MIN_TREND_PCT))
|
|
128
|
+
strong_pct = float(config.get("strongTrendPct", DEFAULT_STRONG_TREND_PCT))
|
|
129
|
+
|
|
130
|
+
direction = trend_direction(strength, min_pct)
|
|
131
|
+
if direction is None:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
vol = volume_trend(candles)
|
|
135
|
+
|
|
136
|
+
score = 3 # base — trend strength above min
|
|
137
|
+
reasons = [f"{asset}_4d_trend_{strength:+.1f}%"]
|
|
138
|
+
if abs(strength) >= strong_pct:
|
|
139
|
+
score += 2
|
|
140
|
+
reasons.append(f"trend_strong_{strength:+.1f}%")
|
|
141
|
+
if vol > 15:
|
|
142
|
+
score += 1
|
|
143
|
+
reasons.append(f"vol_rising_{vol:+.0f}%")
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
"coin": asset,
|
|
147
|
+
"direction": direction,
|
|
148
|
+
"score": score,
|
|
149
|
+
"reasons": reasons,
|
|
150
|
+
"trend_pct": round(strength, 2),
|
|
151
|
+
"volume_trend_pct": round(vol, 2),
|
|
152
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# RAPTOR (v2 supervised demo)
|
|
2
|
+
|
|
3
|
+
Raptor is a **single-wallet, single-recipe** whale **copy-follow** strategy: it
|
|
4
|
+
finds currently-winning quality traders, takes their strongest-conviction
|
|
5
|
+
position when smart money agrees, and rides it with a patient-but-bounded DSL
|
|
6
|
+
exit.
|
|
7
|
+
|
|
8
|
+
Ported from `senpi-skills/raptor` (producer v4.0.1, config v3.1) to the runtime
|
|
9
|
+
v2 supervised external-scanner contract. The standalone producer daemon is gone;
|
|
10
|
+
the runtime supervises `scanners/scan.py` and calls `scan()` every
|
|
11
|
+
`interval_seconds` (180s).
|
|
12
|
+
|
|
13
|
+
> **DOC-vs-CODE warning.** The `senpi-skills/raptor` SKILL/README docs are
|
|
14
|
+
> **stale** — they describe a $2M trader gate and 0.40 concentration. The
|
|
15
|
+
> **code** (config v3.1 + producer constants) is authoritative and is what this
|
|
16
|
+
> port uses: trader delta gate **$500k**, position gate **$100k**, concentration
|
|
17
|
+
> **0.35**, SM **pct ≥ 2 / traders ≥ 10**, whale entry-discipline **5%**,
|
|
18
|
+
> `minScore` **6**. Every threshold below was resolved to the code.
|
|
19
|
+
|
|
20
|
+
## The strategy (`scanners/scan.py`)
|
|
21
|
+
|
|
22
|
+
Quality-first copy-follow pipeline, run once per tick:
|
|
23
|
+
|
|
24
|
+
1. **Quality hot traders** — `discovery_get_top_traders(WEEKLY,
|
|
25
|
+
sort PROFIT_AND_LOSS_UNREALIZED, consistency ELITE/RELIABLE,
|
|
26
|
+
open_position_filter, limit 20)`; keep traders whose **unrealized delta-pnl
|
|
27
|
+
≥ $500k**, sorted desc. Scan the top **10** for positions.
|
|
28
|
+
2. **Strongest position** — per trader, `leaderboard_get_trader_positions`; pick
|
|
29
|
+
the position with the largest **|delta_pnl| (≥ $100k)**.
|
|
30
|
+
3. **Concentration** — `best |delta_pnl| / total |delta_pnl| ≥ 0.35` (one
|
|
31
|
+
conviction bet, not a diversified book).
|
|
32
|
+
4. **Smart-money alignment** — `leaderboard_get_markets`; the asset must show
|
|
33
|
+
**SM pct ≥ 2.0**, **traders ≥ 10**, and direction matching the whale's.
|
|
34
|
+
5. **Whale entry-discipline** — `market_get_prices`; **skip if the price already
|
|
35
|
+
ran > 5%** from the whale's entry in the whale's direction (don't buy the top).
|
|
36
|
+
6. **Additive score (max ~16)** — TCS + trader-$ tier + ROI + concentration +
|
|
37
|
+
SM strength + 4h/1h price confirm + 15m contribution + a better-than-whale
|
|
38
|
+
entry-edge bonus. (All tiers ported verbatim — see `scoring.py`.)
|
|
39
|
+
7. **Emit the single best candidate**, and only if `score ≥ 6`.
|
|
40
|
+
|
|
41
|
+
Sizing rides on each signal's `data{}` so the `OPEN_POSITION` rule action sizes
|
|
42
|
+
identically to the source:
|
|
43
|
+
|
|
44
|
+
- **marginUsd** = `account_value × margin_pct`, `margin_pct` **0.25** (**0.35**
|
|
45
|
+
if `score ≥ 10`). Dual-DEX equity is collapsed via **`max()` not `sum()`** —
|
|
46
|
+
one cross-margined wallet, two sub-DEX views.
|
|
47
|
+
- **leverage** = conviction tier (**≥10 → 10x, ≥8 → 8x, ≥6 → 7x**), clamped to
|
|
48
|
+
the per-asset Hyperliquid venue max via `strategy_get_asset_trading_limits`.
|
|
49
|
+
|
|
50
|
+
**XYZ is banned** (config `smAlignment.xyzBanned`): xyz-dex SM markets and `xyz:`
|
|
51
|
+
positions are dropped.
|
|
52
|
+
|
|
53
|
+
MCP tools used (all **read-only**): `discovery_get_top_traders`,
|
|
54
|
+
`leaderboard_get_trader_positions`, `leaderboard_get_markets`,
|
|
55
|
+
`market_get_prices`, `strategy_get_asset_trading_limits`,
|
|
56
|
+
`strategy_get_clearinghouse_state`.
|
|
57
|
+
|
|
58
|
+
State (the per-`(trader, asset)` **4h event dedup** map) lives in `ctx.state`
|
|
59
|
+
(the source kept it in `seen-events.json`).
|
|
60
|
+
|
|
61
|
+
## Why raptor emits at most one signal per tick
|
|
62
|
+
|
|
63
|
+
Turbine/spider emit **all** gated candidates and let the runtime apply the slot
|
|
64
|
+
ceiling. **Raptor is deliberately different: it emits ≤ 1 signal per tick** — the
|
|
65
|
+
single highest-score whale-aligned setup. This is the **strategy**, not an
|
|
66
|
+
oversight: raptor's thesis is "follow the single strongest conviction signal
|
|
67
|
+
right now," and the source producer pushed exactly one signal per tick after
|
|
68
|
+
sorting candidates by score. `slots: 2` still lets a prior position stay open
|
|
69
|
+
while a later tick emits the next-best; the runtime owns whether the second slot
|
|
70
|
+
fills across ticks.
|
|
71
|
+
|
|
72
|
+
## Layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
raptor/
|
|
76
|
+
├── README.md
|
|
77
|
+
├── recipe.yaml # the single recipe (path: ./scanners, entrypoint: scan.py)
|
|
78
|
+
└── scanners/
|
|
79
|
+
├── scan.py # scan(inputs, ctx) entrypoint (whale copy-follow pipeline)
|
|
80
|
+
└── scoring.py # pure parsing/gating/scoring functions (unit-tested)
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Environment variables
|
|
84
|
+
|
|
85
|
+
| Variable | Required | Purpose |
|
|
86
|
+
|--------------------|----------|---------|
|
|
87
|
+
| `RAPTOR_WALLET` | yes | Raptor's strategy wallet |
|
|
88
|
+
| `SENPI_API_KEY` | yes | MCP auth for `ctx.senpi_mcp` (injected into the scaffold child) |
|
|
89
|
+
| `SENPI_MCP_URL` | yes | MCP server URL |
|
|
90
|
+
| `TELEGRAM_CHAT_ID` | optional | Notifications |
|
|
91
|
+
|
|
92
|
+
## Install / run
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
RAPTOR_WALLET=0x... \
|
|
96
|
+
SENPI_API_KEY=... SENPI_MCP_URL=https://mcp.prod.senpi.ai/mcp \
|
|
97
|
+
openclaw senpi run examples/strategies/raptor/recipe.yaml
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Fidelity notes
|
|
101
|
+
|
|
102
|
+
Source behaviors that changed (or did not map 1:1) under the v2 contract.
|
|
103
|
+
Nothing load-bearing was silently dropped.
|
|
104
|
+
|
|
105
|
+
### Dropped: the daemon loop + ingest POST
|
|
106
|
+
|
|
107
|
+
The source ran a `producer_daemon(interval_seconds=180)` that POSTed signals to
|
|
108
|
+
the ingest endpoint via `push_signal`. Under v2 the runtime **supervises** this
|
|
109
|
+
module and calls `scan()` once per `interval_seconds`; `scan()` is **single-pass
|
|
110
|
+
and synchronous** and **returns a `list[dict]`**. The scaffold owns delivery,
|
|
111
|
+
the wire envelope, and `signal_id` dedup. The source's normalized [0,1] wire
|
|
112
|
+
score (`min(score / 16, 1.0)`) is **not** recomputed in `scan()` — the raw
|
|
113
|
+
additive score rides on `data{}` and the runtime owns wire scoring. (The
|
|
114
|
+
`16.0` divisor is preserved in `scoring.py` for parity.)
|
|
115
|
+
|
|
116
|
+
### Moved to `ctx.state`: the 4h event dedup
|
|
117
|
+
|
|
118
|
+
The per-`(trader, asset)` **4h** event dedup that the source kept in
|
|
119
|
+
`seen-events.json` now lives in `ctx.state`: each tick reads the latest
|
|
120
|
+
`{key: ts}` map, **prunes** it by the 4h window, applies it, **stamps** the
|
|
121
|
+
chosen candidate's `(trader, asset)`, and appends the updated map for the next
|
|
122
|
+
tick. The dedup key is verbatim — first 10 chars of the lowercased trader
|
|
123
|
+
address + the asset symbol.
|
|
124
|
+
|
|
125
|
+
### Action gate: `decision_mode: llm` → `rule`
|
|
126
|
+
|
|
127
|
+
The source `OPEN_POSITION` action used `decision_mode: llm` with a copy-verbatim
|
|
128
|
+
pass-through prompt and a pinned confidence — a rule wearing an LLM costume. This
|
|
129
|
+
recipe uses `decision_mode: rule` directly.
|
|
130
|
+
|
|
131
|
+
### Thresholds resolved to CODE (not stale docs)
|
|
132
|
+
|
|
133
|
+
The `senpi-skills/raptor` SKILL/README claimed a **$2M** trader gate and **0.40**
|
|
134
|
+
concentration. The **code** (config v3.1 + producer constants) uses **$500k** and
|
|
135
|
+
**0.35** — those are the values ported. Every other gate/scoring tier
|
|
136
|
+
(`minPositionPnl` $100k, SM pct 2 / traders 10, entry-discipline 5%, `minScore`
|
|
137
|
+
6, trader tiers $1.5M/$3M, leverage 7/8/10x, margin 0.25/0.35) maps cleanly and
|
|
138
|
+
is carried verbatim.
|
|
139
|
+
|
|
140
|
+
### DSL preset maps cleanly
|
|
141
|
+
|
|
142
|
+
The source DSL preset maps 1:1 to the v2 schema (no dropped tiers): hard_timeout
|
|
143
|
+
360m, weak_peak 60m@2.5, dead_weight 40m, phase1 max_loss 25 / retrace 10, and a
|
|
144
|
+
5-tier phase2 (8/25 · 15/50 · 25/65 · 40/80 · 60/88).
|
|
145
|
+
|
|
146
|
+
### Guard rails: minutes/hours → seconds
|
|
147
|
+
|
|
148
|
+
The v2 schema uses integer seconds everywhere. `cooldown_minutes 30` →
|
|
149
|
+
`cooldown_seconds 1800`; `per_asset_cooldown_minutes 120` →
|
|
150
|
+
`per_asset_cooldown_seconds 7200`; `data_retention_hours 72` →
|
|
151
|
+
`data_retention_seconds 259200`.
|
|
152
|
+
|
|
153
|
+
### Only read-only MCP calls
|
|
154
|
+
|
|
155
|
+
`scan.py` never opens, closes, or cancels — it produces signals. A test asserts
|
|
156
|
+
no `create_position` / `close_position` / `cancel_order` tool is ever called.
|