@senpi/runtime 3.0.0-dev.runtime-phase-2.20260623210537 → 3.0.0-dev.runtime-phase-2.20260625112547
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/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,123 @@
|
|
|
1
|
+
# ── TURBINE · RUNNERS leg (v2 supervised) ───────────────────────────
|
|
2
|
+
# SAME funding-fade rotation as VOLUME, but a patient DSL that lets winners run.
|
|
3
|
+
#
|
|
4
|
+
# Ported from senpi-skills/turbine (producer v3.2.2, config v3.3) to the v2
|
|
5
|
+
# supervised external-scanner contract. The producer daemon is gone: the runtime
|
|
6
|
+
# supervises ../scanners/scan.py (SHARED with the volume leg) and calls
|
|
7
|
+
# scan(inputs, ctx) every `interval_seconds`. Both turbine legs run the SAME
|
|
8
|
+
# rotation alpha — only the sizing (inputs) and DSL exit differ. See scan.py.
|
|
9
|
+
#
|
|
10
|
+
# RUNNERS is OPTIONAL: leave TURBINE_RUNNERS_WALLET unset to run pure volume
|
|
11
|
+
# (don't launch this recipe). The two legs are independent runtimes — no coupling.
|
|
12
|
+
name: turbine-runners
|
|
13
|
+
version: 2.0.0
|
|
14
|
+
group: turbine # agent-facing handle (BOTH legs share it)
|
|
15
|
+
description: >
|
|
16
|
+
TURBINE RUNNERS — Identical funding-fade rotation to the VOLUME leg (same
|
|
17
|
+
universe, same direction logic), but a patient DSL exit: most positions still
|
|
18
|
+
exit early via Phase 1 / weak_peak, while the ~5% that catch a real directional
|
|
19
|
+
move ratchet through a 5-tier Phase 2 (T0..T4) up to +50% margin ROE before
|
|
20
|
+
retrace. 2 slots @ $1300 margin, 5x, 4h hard timeout. Paired with the VOLUME
|
|
21
|
+
leg on a separate wallet.
|
|
22
|
+
|
|
23
|
+
strategy:
|
|
24
|
+
wallet: "${TURBINE_RUNNERS_WALLET}"
|
|
25
|
+
slots: 2 # source slots.runners 2
|
|
26
|
+
margin_per_slot: 100 # FALLBACK sizing — used only if a signal omits marginUsd. scan.py emits per-signal marginUsd (from inputs below), which WINS. TEST 100; prod source margin.runners 1300 (config v3.3)
|
|
27
|
+
default_leverage: 5 # source leverage.runners 5 (clamped per-asset in scan.py)
|
|
28
|
+
trading_risk: moderate # source trading_risk balanced → nearest v2 tier moderate
|
|
29
|
+
enabled: true
|
|
30
|
+
|
|
31
|
+
scanners:
|
|
32
|
+
- name: position_tracker # built-in: feeds DSL exit engine
|
|
33
|
+
type: position_tracker
|
|
34
|
+
interval_seconds: 10 # source interval 10s
|
|
35
|
+
|
|
36
|
+
- name: turbine_runners_signals # supervised external scanner (SHARED scan.py)
|
|
37
|
+
type: external_scanner
|
|
38
|
+
path: ./scanners # shared rotation scanner (one dir, two recipes)
|
|
39
|
+
entrypoint: scan.py
|
|
40
|
+
interval_seconds: 60 # source daemon interval_seconds 60
|
|
41
|
+
timeout_seconds: 45 # source tick_timeout 45
|
|
42
|
+
default_signal_validity_seconds: 120 # 2x tick; pass-through gate re-scores next tick
|
|
43
|
+
state_history_max_count: 200 # holds rotation index + held/last-closed/dedup records
|
|
44
|
+
inputs:
|
|
45
|
+
# ── rotation knobs (config v3.3; identical to volume) ──
|
|
46
|
+
xyzWeight: 0.80 # source xyzWeight 0.80
|
|
47
|
+
spreadMainBps: 3 # source spread.mainBps 3
|
|
48
|
+
spreadXyzBps: 10 # source spread.xyzBps 10
|
|
49
|
+
# ── per-leg sizing (runners) ──
|
|
50
|
+
leverage: 5 # source leverage.runners 5
|
|
51
|
+
marginUsd: 100 # per-signal USD margin; scan.py emits it TOP-LEVEL → runtime honors it as the EFFECTIVE sizing (overrides margin_per_slot). TEST 100; prod source 1300
|
|
52
|
+
maxSlots: 2 # source slots.runners 2 (per-tick emission ceiling)
|
|
53
|
+
signal_data_schema:
|
|
54
|
+
thesis: { type: string }
|
|
55
|
+
fundingRegime: { type: string, required: false }
|
|
56
|
+
fundingAnnualizedPct: { type: number, required: false }
|
|
57
|
+
spreadBps: { type: number, required: false }
|
|
58
|
+
isXyz: { type: boolean, required: false }
|
|
59
|
+
|
|
60
|
+
actions:
|
|
61
|
+
- name: position_tracker_action # rule-based lifecycle → DSL
|
|
62
|
+
action_type: POSITION_TRACKER
|
|
63
|
+
decision_mode: rule
|
|
64
|
+
scanners: [position_tracker]
|
|
65
|
+
|
|
66
|
+
- name: turbine_runners_entry # OPEN_POSITION, rule mode (source LLM gate was pure pass-through)
|
|
67
|
+
action_type: OPEN_POSITION
|
|
68
|
+
decision_mode: rule
|
|
69
|
+
scanners: [turbine_runners_signals]
|
|
70
|
+
params:
|
|
71
|
+
order_type: FEE_OPTIMIZED_LIMIT
|
|
72
|
+
fee_optimized_limit_options:
|
|
73
|
+
ensure_execution_as_taker: true # source runners exit taker true (don't let a winner slip)
|
|
74
|
+
execution_timeout_seconds: 60
|
|
75
|
+
context:
|
|
76
|
+
- type: signal
|
|
77
|
+
scanner: turbine_runners_signals
|
|
78
|
+
|
|
79
|
+
# ── EXIT (DSL — patient; lets the ~5% real movers ride) ──
|
|
80
|
+
exit:
|
|
81
|
+
engine: dsl
|
|
82
|
+
interval_seconds: 30 # source exit interval_seconds 30
|
|
83
|
+
order_type: FEE_OPTIMIZED_LIMIT
|
|
84
|
+
fee_optimized_limit_options:
|
|
85
|
+
ensure_execution_as_taker: true # source runners exit taker true
|
|
86
|
+
execution_timeout_seconds: 60
|
|
87
|
+
dsl_preset:
|
|
88
|
+
hard_timeout:
|
|
89
|
+
enabled: true
|
|
90
|
+
interval_in_minutes: 240 # source hard_timeout 240min (4h absolute cap)
|
|
91
|
+
weak_peak_cut:
|
|
92
|
+
enabled: true
|
|
93
|
+
interval_in_minutes: 90 # source weak_peak 90min
|
|
94
|
+
min_value: 3.0 # source weak_peak min 3%
|
|
95
|
+
dead_weight_cut:
|
|
96
|
+
enabled: true
|
|
97
|
+
interval_in_minutes: 120 # source dead_weight 120min (flat 2h → cut)
|
|
98
|
+
phase1:
|
|
99
|
+
enabled: true
|
|
100
|
+
max_loss_pct: 30.0 # source phase1 max_loss 30
|
|
101
|
+
retrace_threshold: 8 # source phase1 retrace 8
|
|
102
|
+
consecutive_breaches_required: 1
|
|
103
|
+
phase2:
|
|
104
|
+
enabled: true
|
|
105
|
+
tiers: # source T0..T4 — carried verbatim
|
|
106
|
+
- { trigger_pct: 5, lock_hw_pct: 0 }
|
|
107
|
+
- { trigger_pct: 10, lock_hw_pct: 35 }
|
|
108
|
+
- { trigger_pct: 20, lock_hw_pct: 55 }
|
|
109
|
+
- { trigger_pct: 35, lock_hw_pct: 75 }
|
|
110
|
+
- { trigger_pct: 50, lock_hw_pct: 85 }
|
|
111
|
+
|
|
112
|
+
# ── RISK guard rails (from runtime-runners.yaml; minutes → seconds) ──
|
|
113
|
+
risk:
|
|
114
|
+
data_retention_seconds: 259200 # 72h (was data_retention_hours 72)
|
|
115
|
+
guard_rails:
|
|
116
|
+
daily_loss_limit_pct: 25
|
|
117
|
+
max_entries_per_day: 50
|
|
118
|
+
bypass_max_entries_per_day_on_profit: true
|
|
119
|
+
max_consecutive_losses: 6
|
|
120
|
+
cooldown_seconds: 300 # was cooldown_minutes 5
|
|
121
|
+
drawdown_halt_pct: 25
|
|
122
|
+
drawdown_reset_on_day_rollover: false
|
|
123
|
+
per_asset_cooldown_seconds: 600 # was per_asset_cooldown_minutes 10
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# ── TURBINE · VOLUME leg (v2 supervised) ────────────────────────────
|
|
2
|
+
# Funding-fade volume rotation, fast force-close. 7 slots, 10-min hard timeout.
|
|
3
|
+
#
|
|
4
|
+
# Ported from senpi-skills/turbine (producer v3.2.2, config v3.3) to the v2
|
|
5
|
+
# supervised external-scanner contract. The producer daemon is gone: the runtime
|
|
6
|
+
# supervises ../scanners/scan.py (SHARED with the runners leg) and calls
|
|
7
|
+
# scan(inputs, ctx) every `interval_seconds`. Both turbine legs run the SAME
|
|
8
|
+
# rotation alpha — only the sizing (inputs) and DSL exit differ. See scan.py.
|
|
9
|
+
name: turbine-volume
|
|
10
|
+
version: 2.0.0
|
|
11
|
+
group: turbine # agent-facing handle (BOTH legs share it)
|
|
12
|
+
description: >
|
|
13
|
+
TURBINE VOLUME — Funding-fade rotation across BTC/ETH/SOL/HYPE + xyz
|
|
14
|
+
(BRENTOIL/GOLD/SPX), probabilistic xyz-weighted pool pick. Pure high-notional
|
|
15
|
+
volume printing: 7 slots @ $700 margin, 5x, force-closed by a 10-min DSL hard
|
|
16
|
+
timeout with no Phase 2. Shares the rotation scanner with the RUNNERS leg on a
|
|
17
|
+
separate wallet; the wallet boundary selects the DSL exit.
|
|
18
|
+
|
|
19
|
+
strategy:
|
|
20
|
+
wallet: "${TURBINE_VOLUME_WALLET}"
|
|
21
|
+
slots: 7 # source slots.volume 7
|
|
22
|
+
margin_per_slot: 100 # FALLBACK sizing — used only if a signal omits marginUsd. scan.py emits per-signal marginUsd (from inputs below), which WINS. TEST 100; prod source margin.volume 700 (config v3.3)
|
|
23
|
+
default_leverage: 5 # source leverage.volume 5 (clamped per-asset in scan.py)
|
|
24
|
+
trading_risk: aggressive # source trading_risk aggressive
|
|
25
|
+
enabled: true
|
|
26
|
+
|
|
27
|
+
scanners:
|
|
28
|
+
- name: position_tracker # built-in: feeds DSL exit engine
|
|
29
|
+
type: position_tracker
|
|
30
|
+
interval_seconds: 10 # source interval 10s
|
|
31
|
+
|
|
32
|
+
- name: turbine_volume_signals # supervised external scanner (SHARED scan.py)
|
|
33
|
+
type: external_scanner
|
|
34
|
+
path: ./scanners # shared rotation scanner (one dir, two recipes)
|
|
35
|
+
entrypoint: scan.py
|
|
36
|
+
interval_seconds: 60 # source daemon interval_seconds 60
|
|
37
|
+
timeout_seconds: 45 # source tick_timeout 45
|
|
38
|
+
default_signal_validity_seconds: 120 # 2x tick; pass-through gate re-scores next tick
|
|
39
|
+
state_history_max_count: 200 # holds rotation index + held/last-closed/dedup records
|
|
40
|
+
inputs:
|
|
41
|
+
# ── rotation knobs (config v3.3) ──
|
|
42
|
+
xyzWeight: 0.80 # source xyzWeight 0.80
|
|
43
|
+
spreadMainBps: 3 # source spread.mainBps 3
|
|
44
|
+
spreadXyzBps: 10 # source spread.xyzBps 10
|
|
45
|
+
# ── per-leg sizing (volume) ──
|
|
46
|
+
leverage: 5 # source leverage.volume 5
|
|
47
|
+
marginUsd: 100 # per-signal USD margin; scan.py emits it TOP-LEVEL → runtime honors it as the EFFECTIVE sizing (overrides margin_per_slot). TEST 100; prod source 700
|
|
48
|
+
maxSlots: 7 # source slots.volume 7 (per-tick emission ceiling)
|
|
49
|
+
signal_data_schema:
|
|
50
|
+
thesis: { type: string }
|
|
51
|
+
fundingRegime: { type: string, required: false }
|
|
52
|
+
fundingAnnualizedPct: { type: number, required: false }
|
|
53
|
+
spreadBps: { type: number, required: false }
|
|
54
|
+
isXyz: { type: boolean, required: false }
|
|
55
|
+
|
|
56
|
+
actions:
|
|
57
|
+
- name: position_tracker_action # rule-based lifecycle → DSL
|
|
58
|
+
action_type: POSITION_TRACKER
|
|
59
|
+
decision_mode: rule
|
|
60
|
+
scanners: [position_tracker]
|
|
61
|
+
|
|
62
|
+
- name: turbine_volume_entry # OPEN_POSITION, rule mode (source LLM gate was pure pass-through)
|
|
63
|
+
action_type: OPEN_POSITION
|
|
64
|
+
decision_mode: rule
|
|
65
|
+
scanners: [turbine_volume_signals]
|
|
66
|
+
params:
|
|
67
|
+
order_type: FEE_OPTIMIZED_LIMIT
|
|
68
|
+
fee_optimized_limit_options:
|
|
69
|
+
ensure_execution_as_taker: false # source volume: maker-only (free volume)
|
|
70
|
+
execution_timeout_seconds: 60 # source exit timeout 60 (entry was 180; v2 owns order lifecycle)
|
|
71
|
+
context:
|
|
72
|
+
- type: signal
|
|
73
|
+
scanner: turbine_volume_signals
|
|
74
|
+
|
|
75
|
+
# ── EXIT (DSL — pure rotation, force-close on the clock; NO Phase 2) ──
|
|
76
|
+
exit:
|
|
77
|
+
engine: dsl
|
|
78
|
+
interval_seconds: 30 # source exit interval_seconds 30
|
|
79
|
+
order_type: FEE_OPTIMIZED_LIMIT
|
|
80
|
+
fee_optimized_limit_options:
|
|
81
|
+
ensure_execution_as_taker: false # source volume exit taker false (free volume)
|
|
82
|
+
execution_timeout_seconds: 60
|
|
83
|
+
dsl_preset:
|
|
84
|
+
hard_timeout:
|
|
85
|
+
enabled: true
|
|
86
|
+
interval_in_minutes: 10 # source hard_timeout 10min — force every position off the clock
|
|
87
|
+
weak_peak_cut:
|
|
88
|
+
enabled: false # source disabled on volume
|
|
89
|
+
interval_in_minutes: 60
|
|
90
|
+
min_value: 1.0
|
|
91
|
+
dead_weight_cut:
|
|
92
|
+
enabled: false # source disabled on volume
|
|
93
|
+
interval_in_minutes: 60
|
|
94
|
+
phase1:
|
|
95
|
+
enabled: true
|
|
96
|
+
max_loss_pct: 50.0 # source phase1 max_loss 50
|
|
97
|
+
retrace_threshold: 30 # source phase1 retrace 30
|
|
98
|
+
consecutive_breaches_required: 1
|
|
99
|
+
phase2:
|
|
100
|
+
enabled: false # source: NO phase2 on volume — disabled, never advances
|
|
101
|
+
# The v2 DSL parser requires a non-empty tiers array even when phase2 is
|
|
102
|
+
# disabled (parsePreset: "must have tiers or phase2.tiers"). The source used
|
|
103
|
+
# `tiers: []`, which v2 rejects. One inert placeholder tier satisfies the
|
|
104
|
+
# parser; `enabled: false` keeps phase2 off (config.test.ts confirms a
|
|
105
|
+
# disabled phase2 with tiers present yields phase2Enabled=false). This is the
|
|
106
|
+
# only threshold that does not map 1:1 to the v2 schema for the volume leg.
|
|
107
|
+
tiers:
|
|
108
|
+
- { trigger_pct: 100, lock_hw_pct: 0 }
|
|
109
|
+
|
|
110
|
+
# ── RISK guard rails (from runtime-volume.yaml; minutes → seconds) ──
|
|
111
|
+
risk:
|
|
112
|
+
data_retention_seconds: 259200 # 72h (was data_retention_hours 72)
|
|
113
|
+
guard_rails:
|
|
114
|
+
daily_loss_limit_pct: 50
|
|
115
|
+
max_entries_per_day: 1500
|
|
116
|
+
bypass_max_entries_per_day_on_profit: true
|
|
117
|
+
max_consecutive_losses: 30
|
|
118
|
+
cooldown_seconds: 60 # was cooldown_minutes 1 (schema min 60s)
|
|
119
|
+
drawdown_halt_pct: 50
|
|
120
|
+
drawdown_reset_on_day_rollover: true
|
|
121
|
+
# per_asset_cooldown OMITTED — source set per_asset_cooldown_minutes: 0 (disabled).
|
|
122
|
+
# v2 per_asset_cooldown_seconds has a min of 300; 0 is not expressible, and omitting
|
|
123
|
+
# the key == disabled, which is the faithful representation of the source's intent.
|
|
124
|
+
|
|
125
|
+
notifications:
|
|
126
|
+
telegram_chat_id: "${TELEGRAM_CHAT_ID}"
|
|
127
|
+
dsl_lifecycle: false
|
|
128
|
+
dsl_notify_sl_updates: false
|
|
129
|
+
action_lifecycle: false
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""TURBINE — shared supervised external scanner entrypoint (volume + runners).
|
|
2
|
+
|
|
3
|
+
Port of senpi-skills/turbine (producer v3.2.2) to the v2 `scan(inputs, ctx)` contract. The
|
|
4
|
+
daemon loop is gone — the runtime supervisor calls scan() every interval_seconds.
|
|
5
|
+
|
|
6
|
+
ONE scanner, TWO recipes. Turbine's two legs (volume + runners) ran the IDENTICAL volume-
|
|
7
|
+
rotation alpha on the source; only the DSL exit preset and the per-slot sizing differed, and
|
|
8
|
+
those live entirely in the recipe YAMLs. So both recipe-volume.yaml and recipe-runners.yaml
|
|
9
|
+
point `path: ./scanners` + `entrypoint: scan.py` at THIS one module. The `inputs` block carries
|
|
10
|
+
the per-leg sizing (marginUsd, leverage, slots) so the same code emits correctly-sized signals
|
|
11
|
+
for whichever leg supervises it.
|
|
12
|
+
|
|
13
|
+
What stayed faithful to the source (see scoring.py — ported verbatim):
|
|
14
|
+
- Universe MAIN={BTC,ETH,SOL,HYPE} / XYZ={xyz:BRENTOIL,xyz:GOLD,xyz:SPX}.
|
|
15
|
+
- Probabilistic pool pick (xyzWeight 0.80) + deterministic rotation index.
|
|
16
|
+
- Direction = funding fade (crowded-long -> SHORT, crowded-short -> LONG, flat -> random).
|
|
17
|
+
- Spread gate: main <= 3bps, xyz <= 10bps (from the top-of-book mid).
|
|
18
|
+
- Skip held assets + 90s post-close cooldown.
|
|
19
|
+
- Emit one signal per free slot (filled by rotating the index).
|
|
20
|
+
- Per-wallet rotation index, prev-held set, and last-closed map: source kept these in JSON
|
|
21
|
+
files; here they live in ctx.state.
|
|
22
|
+
|
|
23
|
+
FLAGS — behaviors that changed under the v2 contract (do NOT read as silent drops):
|
|
24
|
+
|
|
25
|
+
1. cancel_order stale-order sweep — DROPPED (was producer.sweep_stale_resting_orders).
|
|
26
|
+
The source cancelled orphaned resting maker orders each tick. Those orphans were an
|
|
27
|
+
artifact of the OLD daemon/helpers-migration era: a runtime swap left resting ALOs that
|
|
28
|
+
the NEW runtime instance did not own, so they starved slots forever. Under v2 the
|
|
29
|
+
SUPERVISED runtime owns its own order lifecycle end-to-end:
|
|
30
|
+
- FEE_OPTIMIZED_LIMIT `execution_timeout_seconds` cancels the entry maker order the
|
|
31
|
+
runtime itself placed if it doesn't fill in time, and
|
|
32
|
+
- the DSL `hard_timeout` force-closes any position that does fill.
|
|
33
|
+
There is no second uncoordinated runtime to leave orphans, so the sweep is redundant.
|
|
34
|
+
A scanner's job is to PRODUCE SIGNALS, not to manage orders — so the cancel side-effect
|
|
35
|
+
is intentionally not ported. (If, in operation, orphaned ALOs ever reappear, the right
|
|
36
|
+
fix is a runtime-side reconcile, not a scanner side-effect.)
|
|
37
|
+
|
|
38
|
+
2. effective_slots auto-downsize / per-tick emission cap — MOVED to the runtime. The source
|
|
39
|
+
computed effective_slots = min(maxSlots, account_value / margin_per_slot) and emitted at
|
|
40
|
+
most that many signals per tick. In v2 the runtime owns slot count (strategy.slots) and
|
|
41
|
+
affordability (risk gates), so scan() emits ALL candidates clearing the gates (held +
|
|
42
|
+
cooldown + spread), carrying marginUsd + leverage at each signal's TOP LEVEL. We still emit
|
|
43
|
+
at most `maxSlots` candidates per tick (the rotation only advances that many times) so the
|
|
44
|
+
scanner doesn't flood the queue, but the runtime makes the final open/skip decision.
|
|
45
|
+
|
|
46
|
+
3. random pool/direction picks are non-deterministic — kept verbatim (this is the source's
|
|
47
|
+
design: rotation desync + neutral-regime coin flip). Tests assert STRUCTURE (valid gated
|
|
48
|
+
signals) and seed/inject rng for the deterministic paths.
|
|
49
|
+
|
|
50
|
+
4. runners optional — an empty TURBINE_RUNNERS_WALLET means runners-only is simply not
|
|
51
|
+
launched; the volume leg runs standalone. There is no cross-leg coupling in this module
|
|
52
|
+
(the source's per-wallet desync came from independent rotation indices, which here is just
|
|
53
|
+
each leg owning its own ctx.state).
|
|
54
|
+
|
|
55
|
+
State (in ctx.state, newest record wins): {"signaled": {coin_key: ts}, "last_closed":
|
|
56
|
+
{coin_key: {"ts": ts}}, "rotation_index": int, "held": [coin_key, ...]}.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
import sys
|
|
60
|
+
import time
|
|
61
|
+
|
|
62
|
+
import scoring
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _dex_for(asset):
|
|
66
|
+
return "xyz" if asset.lower().startswith("xyz:") else ""
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _get_universe_meta(ctx):
|
|
70
|
+
"""{name: {"max_leverage": int|None}} for live instruments (one call). Used only to clamp
|
|
71
|
+
leverage to the venue max; turbine's source used a fixed 5x and let the runtime clamp."""
|
|
72
|
+
try:
|
|
73
|
+
data = ctx.senpi_mcp.call_tool("market_list_instruments", {})
|
|
74
|
+
except Exception:
|
|
75
|
+
return {}
|
|
76
|
+
out = {}
|
|
77
|
+
if not data:
|
|
78
|
+
return out
|
|
79
|
+
insts = data.get("data", data) if isinstance(data, dict) else data
|
|
80
|
+
if isinstance(insts, dict):
|
|
81
|
+
insts = insts.get("instruments", [])
|
|
82
|
+
for inst in insts or []:
|
|
83
|
+
if not isinstance(inst, dict):
|
|
84
|
+
continue
|
|
85
|
+
if inst.get("is_delisted"):
|
|
86
|
+
continue
|
|
87
|
+
name = inst.get("name") or (inst.get("context", {}) or {}).get("coin")
|
|
88
|
+
if not name:
|
|
89
|
+
continue
|
|
90
|
+
entry = {"max_leverage": inst.get("max_leverage", inst.get("maxLeverage"))}
|
|
91
|
+
out[name] = entry
|
|
92
|
+
out[name.upper()] = entry
|
|
93
|
+
return out
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _fetch_asset_data(ctx, asset):
|
|
97
|
+
"""market_get_asset_data with order book + funding for one asset (spread + regime)."""
|
|
98
|
+
try:
|
|
99
|
+
return ctx.senpi_mcp.call_tool("market_get_asset_data", {
|
|
100
|
+
"asset": asset,
|
|
101
|
+
"candle_intervals": [],
|
|
102
|
+
"include_funding": True,
|
|
103
|
+
"include_order_book": True,
|
|
104
|
+
"dex": _dex_for(asset),
|
|
105
|
+
})
|
|
106
|
+
except Exception:
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get_held(ctx):
|
|
111
|
+
"""held_keys set from open positions + non-reduceOnly resting orders, across both dexes.
|
|
112
|
+
|
|
113
|
+
Mirrors the source's held accounting (positions via clearinghouse_state, resting maker
|
|
114
|
+
orders via open_orders), so a slot the runtime is already working is not double-filled.
|
|
115
|
+
Reduce-only / trigger orders are DSL exit legs — never slot occupiers."""
|
|
116
|
+
held = set()
|
|
117
|
+
try:
|
|
118
|
+
ch = ctx.senpi_mcp.call_tool("strategy_get_clearinghouse_state",
|
|
119
|
+
{"strategy_wallet": ctx.wallet})
|
|
120
|
+
except Exception:
|
|
121
|
+
ch = None
|
|
122
|
+
account_value = 0.0
|
|
123
|
+
if ch:
|
|
124
|
+
data = ch.get("data", ch) if isinstance(ch, dict) else ch
|
|
125
|
+
if isinstance(data, dict):
|
|
126
|
+
for section in ("main", "xyz"):
|
|
127
|
+
s = data.get(section, {})
|
|
128
|
+
if not isinstance(s, dict):
|
|
129
|
+
continue
|
|
130
|
+
ms = s.get("marginSummary", {})
|
|
131
|
+
account_value += scoring.safe_float(ms.get("accountValue", 0))
|
|
132
|
+
for ap in s.get("assetPositions", []) or []:
|
|
133
|
+
pos = ap.get("position", ap)
|
|
134
|
+
if scoring.safe_float(pos.get("szi", 0)) == 0:
|
|
135
|
+
continue
|
|
136
|
+
coin = pos.get("coin", "")
|
|
137
|
+
if coin:
|
|
138
|
+
held.add(scoring.normalize_coin_key(coin))
|
|
139
|
+
|
|
140
|
+
try:
|
|
141
|
+
od = ctx.senpi_mcp.call_tool("strategy_get_open_orders",
|
|
142
|
+
{"strategy_wallet": ctx.wallet})
|
|
143
|
+
except Exception:
|
|
144
|
+
od = None
|
|
145
|
+
if od:
|
|
146
|
+
orders = od.get("data", od) if isinstance(od, dict) else od
|
|
147
|
+
if isinstance(orders, dict):
|
|
148
|
+
orders = orders.get("orders", orders.get("openOrders", []))
|
|
149
|
+
if isinstance(orders, list):
|
|
150
|
+
for o in orders:
|
|
151
|
+
if not isinstance(o, dict):
|
|
152
|
+
continue
|
|
153
|
+
if o.get("reduceOnly") or o.get("isTrigger"):
|
|
154
|
+
continue
|
|
155
|
+
coin = o.get("coin") or o.get("asset", "")
|
|
156
|
+
if coin:
|
|
157
|
+
held.add(scoring.normalize_coin_key(coin))
|
|
158
|
+
return held, account_value
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ── ctx.state I/O ──
|
|
162
|
+
|
|
163
|
+
def _load_state(ctx):
|
|
164
|
+
if ctx.state is None or len(ctx.state) == 0:
|
|
165
|
+
return {}, {}, 0
|
|
166
|
+
last = ctx.state.last() or {}
|
|
167
|
+
signaled = last.get("signaled", {})
|
|
168
|
+
last_closed = last.get("last_closed", {})
|
|
169
|
+
rot_idx = last.get("rotation_index", 0)
|
|
170
|
+
return (dict(signaled) if isinstance(signaled, dict) else {},
|
|
171
|
+
dict(last_closed) if isinstance(last_closed, dict) else {},
|
|
172
|
+
int(rot_idx) if isinstance(rot_idx, int) else 0)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _load_prev_held(ctx):
|
|
176
|
+
if ctx.state is None or len(ctx.state) == 0:
|
|
177
|
+
return set()
|
|
178
|
+
last = ctx.state.last() or {}
|
|
179
|
+
held = last.get("held", [])
|
|
180
|
+
return set(held) if isinstance(held, list) else set()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def scan(inputs, ctx):
|
|
184
|
+
now = time.time()
|
|
185
|
+
xyz_weight = float(inputs.get("xyzWeight", 0.80))
|
|
186
|
+
spread_main_bps = float(inputs.get("spreadMainBps", 3))
|
|
187
|
+
spread_xyz_bps = float(inputs.get("spreadXyzBps", 10))
|
|
188
|
+
leg_leverage = float(inputs.get("leverage", 5))
|
|
189
|
+
margin_usd = float(inputs.get("marginUsd", 700))
|
|
190
|
+
max_slots = int(inputs.get("maxSlots", 7))
|
|
191
|
+
|
|
192
|
+
held_set, _account_value = _get_held(ctx)
|
|
193
|
+
|
|
194
|
+
# Detect closes since last tick -> stamp last_closed (drives the 90s post-close cooldown).
|
|
195
|
+
prev_signaled, last_closed, rot_idx = _load_state(ctx)
|
|
196
|
+
prev_held = _load_prev_held(ctx)
|
|
197
|
+
closed_this_tick = prev_held - held_set
|
|
198
|
+
for k in closed_this_tick:
|
|
199
|
+
last_closed[k] = {"ts": now}
|
|
200
|
+
# Prune cooldown entries well past the window so last_closed doesn't grow unbounded.
|
|
201
|
+
cutoff = now - (scoring.POST_CLOSE_COOLDOWN_SECONDS * 4)
|
|
202
|
+
last_closed = {k: v for k, v in last_closed.items()
|
|
203
|
+
if scoring.safe_float(v.get("ts", 0)) >= cutoff}
|
|
204
|
+
|
|
205
|
+
free_slots = max(0, max_slots - len(held_set))
|
|
206
|
+
if free_slots <= 0:
|
|
207
|
+
if ctx.state is not None:
|
|
208
|
+
try:
|
|
209
|
+
ctx.state.append({"signaled": prev_signaled, "last_closed": last_closed,
|
|
210
|
+
"rotation_index": rot_idx, "held": sorted(held_set)})
|
|
211
|
+
except Exception as exc:
|
|
212
|
+
# See note at the end-of-tick append below: a swallowed failure
|
|
213
|
+
# here lets the next tick reload stale dedup state and re-emit
|
|
214
|
+
# suppressed signals. Log-and-continue, don't crash the tick.
|
|
215
|
+
print(
|
|
216
|
+
f"[turbine.scan] WARNING: dedup-state append failed (no-slot "
|
|
217
|
+
f"branch); next tick may re-emit suppressed signals: {exc!r}",
|
|
218
|
+
file=sys.stderr,
|
|
219
|
+
)
|
|
220
|
+
return []
|
|
221
|
+
|
|
222
|
+
meta_map = _get_universe_meta(ctx)
|
|
223
|
+
|
|
224
|
+
# working_held: assets already held PLUS those we emit this tick, so the rotation index
|
|
225
|
+
# advances past them (mirrors fill_wallet_slots adding to held_keys after each emit).
|
|
226
|
+
working_held = set(held_set)
|
|
227
|
+
out = []
|
|
228
|
+
for _ in range(free_slots):
|
|
229
|
+
asset, rot_idx = scoring.pick_rotation_asset(
|
|
230
|
+
rot_idx, xyz_weight, working_held, last_closed, now=now)
|
|
231
|
+
if asset is None:
|
|
232
|
+
break
|
|
233
|
+
resp = _fetch_asset_data(ctx, asset)
|
|
234
|
+
ad = scoring.parse_asset_data(resp)
|
|
235
|
+
if ad is None:
|
|
236
|
+
continue
|
|
237
|
+
if not scoring.spread_ok(asset, ad["spread_bps"], spread_main_bps, spread_xyz_bps):
|
|
238
|
+
continue
|
|
239
|
+
direction, thesis = scoring.choose_direction(ad["funding_regime"])
|
|
240
|
+
|
|
241
|
+
meta = meta_map.get(asset) or meta_map.get(asset.upper()) or {}
|
|
242
|
+
leverage = scoring.clamp_leverage(leg_leverage, meta.get("max_leverage"))
|
|
243
|
+
if leverage <= 0:
|
|
244
|
+
continue
|
|
245
|
+
|
|
246
|
+
out.append({
|
|
247
|
+
"asset": asset,
|
|
248
|
+
"direction": direction,
|
|
249
|
+
"marginUsd": float(margin_usd),
|
|
250
|
+
"leverage": float(leverage),
|
|
251
|
+
"data": {
|
|
252
|
+
"thesis": thesis,
|
|
253
|
+
"fundingRegime": ad["funding_regime"],
|
|
254
|
+
"fundingAnnualizedPct": float(ad["funding_annualized_pct"]),
|
|
255
|
+
"spreadBps": float(ad["spread_bps"]),
|
|
256
|
+
"isXyz": scoring.is_xyz(asset),
|
|
257
|
+
},
|
|
258
|
+
})
|
|
259
|
+
coin_key = scoring.normalize_coin_key(asset)
|
|
260
|
+
working_held.add(coin_key)
|
|
261
|
+
prev_signaled[coin_key] = now
|
|
262
|
+
|
|
263
|
+
# If this append fails (e.g. state_history_max_count is 0/unset so append
|
|
264
|
+
# raises, or a transient persist error), the next tick reloads stale dedup
|
|
265
|
+
# state and may re-emit already-suppressed signals. Log-and-continue: don't
|
|
266
|
+
# crash the tick, but make the failure visible on stderr (the runtime
|
|
267
|
+
# supervisor captures the scaffold child's stderr) instead of swallowing it.
|
|
268
|
+
if ctx.state is not None:
|
|
269
|
+
try:
|
|
270
|
+
ctx.state.append({
|
|
271
|
+
"signaled": prev_signaled,
|
|
272
|
+
"last_closed": last_closed,
|
|
273
|
+
"rotation_index": rot_idx,
|
|
274
|
+
"held": sorted(working_held),
|
|
275
|
+
})
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
print(
|
|
278
|
+
f"[turbine.scan] WARNING: dedup-state append failed; next tick "
|
|
279
|
+
f"may re-emit suppressed signals: {exc!r}",
|
|
280
|
+
file=sys.stderr,
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
return out
|