@senpi/runtime 3.0.0-dev.openclaw-upgrade-mai.20260623210452 → 3.0.0-dev.runtime-phase-2.20260623210537
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 +9 -10
- package/dist/actions/decision-engine.d.ts.map +1 -1
- package/dist/actions/decision-engine.js +0 -3
- 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 +2 -6
- package/dist/actions/open-position/open-position.action.js.map +1 -1
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +56 -43
- 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 +108 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/constants/dsl/index.d.ts +0 -2
- package/dist/constants/dsl/index.d.ts.map +1 -1
- package/dist/constants/dsl/index.js +0 -2
- package/dist/constants/dsl/index.js.map +1 -1
- package/dist/dsl/engine-client/config-mapping.d.ts +0 -7
- package/dist/dsl/engine-client/config-mapping.d.ts.map +1 -1
- package/dist/dsl/engine-client/config-mapping.js +2 -16
- package/dist/dsl/engine-client/config-mapping.js.map +1 -1
- package/dist/dsl/events/handlers.d.ts +2 -18
- package/dist/dsl/events/handlers.d.ts.map +1 -1
- package/dist/dsl/events/handlers.js +213 -302
- package/dist/dsl/events/handlers.js.map +1 -1
- package/dist/dsl/monitor/create-monitor.d.ts +0 -15
- package/dist/dsl/monitor/create-monitor.d.ts.map +1 -1
- package/dist/dsl/monitor/create-monitor.js +9 -44
- 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 +1 -24
- package/dist/dsl/monitor/process-one-position.js.map +1 -1
- package/dist/dsl/plugin/index.d.ts +1 -16
- package/dist/dsl/plugin/index.d.ts.map +1 -1
- package/dist/dsl/plugin/index.js +4 -35
- package/dist/dsl/plugin/index.js.map +1 -1
- package/dist/dsl/plugin/options.d.ts +0 -18
- package/dist/dsl/plugin/options.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -4
- package/dist/index.js.map +1 -1
- package/dist/runtime/create-scanner-module.d.ts +3 -4
- package/dist/runtime/create-scanner-module.d.ts.map +1 -1
- package/dist/runtime/create-scanner-module.js +5 -14
- 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 +1 -12
- 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 +8 -7
- 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 +3 -12
- package/dist/runtime/map-strategies.js.map +1 -1
- package/dist/runtime/reconcile-state.d.ts +0 -44
- package/dist/runtime/reconcile-state.d.ts.map +1 -1
- package/dist/runtime/reconcile-state.js +44 -80
- 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 +12 -17
- package/dist/runtime/risk-guard.js.map +1 -1
- package/dist/runtime/runtime-schema.d.ts +995 -2855
- package/dist/runtime/runtime-schema.d.ts.map +1 -1
- package/dist/runtime/runtime-schema.js +40 -133
- package/dist/runtime/runtime-schema.js.map +1 -1
- package/dist/runtime/runtime.d.ts +0 -26
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +5 -233
- 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 +12 -3
- 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/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +36 -28
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.d.ts.map +1 -1
- package/dist/runtime-api/intake/process-signal-post.js +0 -0
- package/dist/runtime-api/intake/process-signal-post.js.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.d.ts +4 -18
- package/dist/runtime-api/intake/scanner-launcher.d.ts.map +1 -1
- package/dist/runtime-api/intake/scanner-launcher.js +3 -24
- package/dist/runtime-api/intake/scanner-launcher.js.map +1 -1
- package/dist/runtime-api/intake/types.d.ts +0 -6
- package/dist/runtime-api/intake/types.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.d.ts +0 -1
- package/dist/runtime-api/routes/signals.schema.d.ts.map +1 -1
- package/dist/runtime-api/routes/signals.schema.js +0 -5
- package/dist/runtime-api/routes/signals.schema.js.map +1 -1
- package/dist/scanners/external/liquidation-watchdog/README.md +107 -0
- package/dist/scanners/external/liquidation-watchdog/liquidation-watchdog.yaml +48 -0
- package/dist/scanners/external/liquidation-watchdog/producer.mjs +336 -0
- package/dist/scanners/external/momentum/README.md +148 -0
- package/dist/scanners/external/momentum/momentum.yaml +116 -0
- package/dist/scanners/external/momentum/producer.mjs +251 -0
- package/dist/scanners/external/scaffold/__main__.py +24 -65
- package/dist/scanners/external/scaffold/delivery.py +22 -56
- package/dist/scanners/external/scaffold/entrypoint_loader.py +48 -42
- package/dist/scanners/external/scaffold/journal.py +2 -13
- package/dist/scanners/external/scaffold/scaffold.py +128 -159
- package/dist/scanners/external/scaffold/signal_envelope.py +34 -177
- package/dist/scanners/external/shared/ingest.mjs +112 -0
- package/dist/scanners/external-scanner-receiver.d.ts +0 -6
- package/dist/scanners/external-scanner-receiver.d.ts.map +1 -1
- package/dist/scanners/external-scanner-receiver.js +1 -14
- 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 +3 -5
- package/dist/scripts/run-dsl-standalone-live.js.map +1 -1
- package/dist/senpi/client.d.ts +0 -12
- package/dist/senpi/client.d.ts.map +1 -1
- package/dist/senpi/client.js +2 -31
- package/dist/senpi/client.js.map +1 -1
- package/dist/state/state-manager.d.ts +0 -17
- package/dist/state/state-manager.d.ts.map +1 -1
- package/dist/state/state-manager.js +0 -28
- package/dist/state/state-manager.js.map +1 -1
- package/dist/strategy/strategy-state.d.ts.map +1 -1
- package/dist/strategy/strategy-state.js +1 -7
- package/dist/strategy/strategy-state.js.map +1 -1
- package/dist/tracking/constants.d.ts.map +1 -1
- package/dist/tracking/constants.js +0 -2
- 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 +0 -7
- package/dist/types/scanner.d.ts.map +1 -1
- package/examples/README.md +89 -0
- package/examples/scanners-consumer-quickstart.mjs +134 -0
- package/examples/scanners-emerging-movers-v4.mjs +287 -0
- package/examples/scanners-liquidation-watchdog.mjs +341 -0
- package/examples/scanners-live-mcp.mjs +208 -0
- package/examples/scanners-multi-strategy.mjs +165 -0
- package/examples/scanners-position-tracker-e2e.mjs +225 -0
- package/examples/scanners-scheduled-events.mjs +96 -0
- package/examples/scanners-senpi-provider-client.mjs +182 -0
- package/examples/scanners-single-strategy.mjs +198 -0
- package/examples/strategies/README.md +12 -17
- package/examples/strategies/dsl-showcase.yaml +68 -0
- package/examples/strategies/dsl.yaml +51 -0
- package/examples/strategies/fox-minimal-loosened.yaml +240 -0
- package/examples/strategies/fox-minimal.yaml +242 -0
- package/examples/strategies/minimal.yaml +46 -0
- package/examples/strategies/momentum/how-it-works.md +48 -0
- package/examples/strategies/momentum/momentum-guarded.yaml +161 -0
- package/examples/strategies/risk-showcase.yaml +93 -0
- package/examples/strategies/viper.yaml +67 -0
- package/examples/strategies/wolf.yaml +72 -0
- package/examples/yamls/sample-with-dsl-and-emerging-movers.yaml +123 -0
- package/package.json +1 -1
- package/dist/runtime/reconcile-decide.d.ts +0 -73
- package/dist/runtime/reconcile-decide.d.ts.map +0 -1
- package/dist/runtime/reconcile-decide.js +0 -166
- package/dist/runtime/reconcile-decide.js.map +0 -1
- package/dist/runtime/reconcile-run.d.ts +0 -62
- package/dist/runtime/reconcile-run.d.ts.map +0 -1
- package/dist/runtime/reconcile-run.js +0 -173
- package/dist/runtime/reconcile-run.js.map +0 -1
- package/dist/scanners/external/scaffold/ctx.py +0 -78
- package/dist/scanners/external/scaffold/requirements.txt +0 -11
- package/dist/scanners/external/scaffold/senpi_mcp.py +0 -250
- package/dist/scanners/external/scaffold/state_manager.py +0 -175
- package/dist/utils/keyed-mutex.d.ts +0 -37
- package/dist/utils/keyed-mutex.d.ts.map +0 -1
- package/dist/utils/keyed-mutex.js +0 -77
- package/dist/utils/keyed-mutex.js.map +0 -1
- package/examples/strategies/iguana/README.md +0 -135
- package/examples/strategies/iguana/recipe.yaml +0 -137
- package/examples/strategies/iguana/scanners/scan.py +0 -243
- package/examples/strategies/iguana/scanners/scoring.py +0 -152
- package/examples/strategies/raptor/README.md +0 -156
- package/examples/strategies/raptor/recipe.yaml +0 -154
- package/examples/strategies/raptor/scanners/scan.py +0 -349
- package/examples/strategies/raptor/scanners/scoring.py +0 -452
- package/examples/strategies/spider/README.md +0 -97
- package/examples/strategies/spider/scalp/recipe.yaml +0 -129
- package/examples/strategies/spider/scalp/scanners/scan.py +0 -219
- package/examples/strategies/spider/scalp/scanners/scoring.py +0 -179
- package/examples/strategies/spider/swing/recipe.yaml +0 -141
- package/examples/strategies/spider/swing/scanners/scan.py +0 -334
- package/examples/strategies/spider/swing/scanners/scoring.py +0 -184
- package/examples/strategies/turbine/README.md +0 -167
- package/examples/strategies/turbine/recipe-runners.yaml +0 -123
- package/examples/strategies/turbine/recipe-volume.yaml +0 -129
- package/examples/strategies/turbine/scanners/scan.py +0 -283
- package/examples/strategies/turbine/scanners/scoring.py +0 -190
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
name: external-momentum
|
|
2
|
+
version: 1.0.0
|
|
3
|
+
description: >
|
|
4
|
+
External producer variant of the momentum scanner. Latest-candle breakout
|
|
5
|
+
detection runs out-of-process (see producer.mjs) and pushes signals in via
|
|
6
|
+
senpi.ingestExternalScannerData. An LLM gauntlet filters breakouts into
|
|
7
|
+
OPEN_POSITION decisions; the DSL exit engine manages trailing exits.
|
|
8
|
+
|
|
9
|
+
strategy:
|
|
10
|
+
wallet: "${WALLET_ADDRESS}"
|
|
11
|
+
budget: 500
|
|
12
|
+
slots: 2
|
|
13
|
+
margin_per_slot: 200
|
|
14
|
+
trading_risk: moderate
|
|
15
|
+
enabled: true
|
|
16
|
+
|
|
17
|
+
scanners:
|
|
18
|
+
# Onchain position tracker — emits position lifecycle events into the DSL
|
|
19
|
+
# exit engine for trailing stop-loss management.
|
|
20
|
+
- name: position_tracker
|
|
21
|
+
type: position_tracker
|
|
22
|
+
interval: 10s
|
|
23
|
+
|
|
24
|
+
# External scanner: signals are pushed by producer.mjs on its own cadence.
|
|
25
|
+
# `fields` documents the payload shape the gateway will accept.
|
|
26
|
+
- name: external_momentum
|
|
27
|
+
type: external_scanner
|
|
28
|
+
outputs:
|
|
29
|
+
signals: true
|
|
30
|
+
context: false
|
|
31
|
+
config:
|
|
32
|
+
fields:
|
|
33
|
+
sourceScannerId: { type: string, required: true }
|
|
34
|
+
sourceSignalType: { type: string, required: true }
|
|
35
|
+
sourceTimestamp: { type: number, required: true }
|
|
36
|
+
sourceFactors: { type: object, required: true }
|
|
37
|
+
sourceMeta: { type: object, required: true }
|
|
38
|
+
|
|
39
|
+
actions:
|
|
40
|
+
# Position lifecycle tracking — feeds DSL exit engine.
|
|
41
|
+
- name: position_tracker_action
|
|
42
|
+
action_type: POSITION_TRACKER
|
|
43
|
+
decision_mode: rule
|
|
44
|
+
scanners: [position_tracker]
|
|
45
|
+
|
|
46
|
+
# Entry: LLM gauntlet on momentum breakout signals.
|
|
47
|
+
- name: momentum_entry
|
|
48
|
+
action_type: OPEN_POSITION
|
|
49
|
+
decision_mode: llm
|
|
50
|
+
decision_model: gpt-5.1-codex
|
|
51
|
+
scanners: [external_momentum]
|
|
52
|
+
min_confidence: 6
|
|
53
|
+
context:
|
|
54
|
+
- type: signal
|
|
55
|
+
scanner: external_momentum
|
|
56
|
+
decision_prompt: |
|
|
57
|
+
You are a trade-decisioning evaluator. Given a MOMENTUM_BREAKOUT
|
|
58
|
+
signal, decide whether to open a position on it or skip.
|
|
59
|
+
|
|
60
|
+
A MOMENTUM_BREAKOUT fires when an asset's latest closed candle moves
|
|
61
|
+
by at least a configured threshold vs. the previous close on the
|
|
62
|
+
signal's timeframe — i.e. a short-term directional breakout in price.
|
|
63
|
+
`meta.movePct` carries the signed % change (positive = LONG bias,
|
|
64
|
+
negative = SHORT bias); `score` (0..1) scales with the move's
|
|
65
|
+
magnitude; `meta.dayNtlVlm` and `meta.maxLeverage` describe the
|
|
66
|
+
instrument's liquidity and leverage headroom.
|
|
67
|
+
|
|
68
|
+
SIGNAL DATA:
|
|
69
|
+
{{signal_external_momentum}}
|
|
70
|
+
|
|
71
|
+
═══ GAUNTLET ═══
|
|
72
|
+
1. movePct magnitude — require |movePct| >= 1.5; reject sub-threshold noise.
|
|
73
|
+
2. Direction sanity — LONG only if movePct > 0, SHORT only if movePct < 0.
|
|
74
|
+
3. Liquidity — meta.dayNtlVlm must be >= 1_000_000.
|
|
75
|
+
4. Leverage — meta.maxLeverage must be >= 5; skip otherwise.
|
|
76
|
+
5. Score — use the producer score (0..1) as a proxy for strength; require >= 0.4.
|
|
77
|
+
6. One signal at a time — pick the highest-score eligible signal; skip the rest.
|
|
78
|
+
|
|
79
|
+
Respond with a single JSON object:
|
|
80
|
+
{
|
|
81
|
+
"execute": true|false,
|
|
82
|
+
"actionType": "OPEN_POSITION",
|
|
83
|
+
"confidence": 1-10,
|
|
84
|
+
"reasoning": "one sentence",
|
|
85
|
+
"payload": {
|
|
86
|
+
"signals": [
|
|
87
|
+
{ "asset": "<ticker>", "direction": "LONG"|"SHORT", "reason": "one sentence" }
|
|
88
|
+
]
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# ── EXIT (DSL) ──
|
|
93
|
+
# Trailing stop-loss tuned for short-horizon momentum entries: a slightly
|
|
94
|
+
# tighter Phase-1 fixed floor than Fox (momentum reversals are fast), the same
|
|
95
|
+
# 7% retrace trigger so winners get room to breathe, and a four-tier Phase-2
|
|
96
|
+
# ladder that locks in progressively more of the high-water as gains extend.
|
|
97
|
+
exit:
|
|
98
|
+
engine: dsl
|
|
99
|
+
interval_seconds: 30
|
|
100
|
+
dsl_preset:
|
|
101
|
+
phase1:
|
|
102
|
+
enabled: true
|
|
103
|
+
max_loss_pct: 2.5
|
|
104
|
+
retrace_threshold: 7
|
|
105
|
+
consecutive_breaches_required: 3
|
|
106
|
+
phase2:
|
|
107
|
+
enabled: true
|
|
108
|
+
tiers:
|
|
109
|
+
- { trigger_pct: 6, lock_hw_pct: 35 }
|
|
110
|
+
- { trigger_pct: 10, lock_hw_pct: 55 }
|
|
111
|
+
- { trigger_pct: 15, lock_hw_pct: 70 }
|
|
112
|
+
- { trigger_pct: 20, lock_hw_pct: 85 }
|
|
113
|
+
|
|
114
|
+
# ── NOTIFICATIONS ──
|
|
115
|
+
notifications:
|
|
116
|
+
telegram_chat_id: "${TELEGRAM_CHAT_ID}"
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External momentum producer.
|
|
3
|
+
*
|
|
4
|
+
* A standalone momentum scanner that:
|
|
5
|
+
* 1. Pulls instruments + latest candles from the live Senpi MCP server.
|
|
6
|
+
* 2. Computes last-vs-prev close % move against configurable thresholds.
|
|
7
|
+
* 3. Pushes MOMENTUM_BREAKOUT signals into a running runtime via the
|
|
8
|
+
* `openclaw senpi external-scanner ingest` CLI command.
|
|
9
|
+
*
|
|
10
|
+
* Prereqs:
|
|
11
|
+
* - `npm run build` so dist/ is current (used only for the MCP provider bridge).
|
|
12
|
+
* - A runtime installed from ./momentum.yaml is running for STRATEGY_ADDRESS.
|
|
13
|
+
* - `openclaw` CLI on PATH and authenticated against that runtime.
|
|
14
|
+
* - SENPI_API_KEY and SENPI_MCP_URL env vars set.
|
|
15
|
+
*
|
|
16
|
+
* Run: node src/scanners/external/momentum/producer.mjs
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
20
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
21
|
+
import { createSenpiScannerProviders } from "../../../../dist/scanners/index.js";
|
|
22
|
+
import { pushScannerSignals } from "../shared/ingest.mjs";
|
|
23
|
+
|
|
24
|
+
// ─── Config ─────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const SENPI_API_KEY = process.env.SENPI_API_KEY;
|
|
27
|
+
const SENPI_MCP_URL = process.env.SENPI_MCP_URL;
|
|
28
|
+
const STRATEGY_ADDRESS = process.env.STRATEGY_ADDRESS;
|
|
29
|
+
const SCANNER_NAME =
|
|
30
|
+
process.env.EXTERNAL_SCANNER_NAME ?? "external_momentum";
|
|
31
|
+
const OPENCLAW_BIN = process.env.OPENCLAW_BIN ?? "openclaw";
|
|
32
|
+
const SCANNER_ID = "external-momentum-producer";
|
|
33
|
+
|
|
34
|
+
const config = {
|
|
35
|
+
timeframe: process.env.TIMEFRAME ?? "1h",
|
|
36
|
+
minMovePct: numEnv("MIN_MOVE_PCT", 1.5),
|
|
37
|
+
minDayVolume: numEnv("MIN_DAY_VOLUME", 1_000_000),
|
|
38
|
+
minLeverage: numEnv("MIN_LEVERAGE", 5),
|
|
39
|
+
maxCandidates: numEnv("MAX_CANDIDATES", 20),
|
|
40
|
+
minSignalScore: numEnv("MIN_SIGNAL_SCORE", 0.2),
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!SENPI_API_KEY || !SENPI_MCP_URL || !STRATEGY_ADDRESS) {
|
|
44
|
+
console.error(
|
|
45
|
+
"Missing required env vars. Set SENPI_API_KEY, SENPI_MCP_URL, and STRATEGY_ADDRESS.\n" +
|
|
46
|
+
" export SENPI_API_KEY='...'\n" +
|
|
47
|
+
" export SENPI_MCP_URL='https://mcp.prod.senpi.ai/mcp'\n" +
|
|
48
|
+
" export STRATEGY_ADDRESS='0x...'",
|
|
49
|
+
);
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function numEnv(name, fallback) {
|
|
54
|
+
const raw = process.env[name];
|
|
55
|
+
if (raw === undefined || raw === "") return fallback;
|
|
56
|
+
const parsed = Number(raw);
|
|
57
|
+
if (!Number.isFinite(parsed)) {
|
|
58
|
+
throw new Error(`Env ${name} must be a finite number, got "${raw}"`);
|
|
59
|
+
}
|
|
60
|
+
return parsed;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── MCP → SenpiToolClient bridge ───────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
function createSenpiToolClient(mcpClient) {
|
|
66
|
+
return {
|
|
67
|
+
async callTool(name, args) {
|
|
68
|
+
const result = await mcpClient.callTool({ name, arguments: args });
|
|
69
|
+
|
|
70
|
+
if (result.isError) {
|
|
71
|
+
const errorText =
|
|
72
|
+
Array.isArray(result.content) &&
|
|
73
|
+
result.content.length > 0 &&
|
|
74
|
+
result.content[0].type === "text"
|
|
75
|
+
? result.content[0].text
|
|
76
|
+
: JSON.stringify(result.content);
|
|
77
|
+
throw new Error(`MCP tool "${name}" returned error: ${errorText}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (result.structuredContent !== undefined) {
|
|
81
|
+
return result.structuredContent;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (Array.isArray(result.content) && result.content.length > 0) {
|
|
85
|
+
const first = result.content[0];
|
|
86
|
+
if (first.type === "text") {
|
|
87
|
+
try {
|
|
88
|
+
return JSON.parse(first.text);
|
|
89
|
+
} catch {
|
|
90
|
+
return first.text;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return result.content;
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ─── Momentum logic ─────────────────────────────────────────────────────────
|
|
101
|
+
// Self-contained port of src/scanners/implementations/momentum.ts. Kept free
|
|
102
|
+
// of any runtime-internal imports.
|
|
103
|
+
|
|
104
|
+
function clamp01(value) {
|
|
105
|
+
if (!Number.isFinite(value)) return 0;
|
|
106
|
+
if (value < 0) return 0;
|
|
107
|
+
if (value > 1) return 1;
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function selectCandidates(instruments, cfg) {
|
|
112
|
+
const eligible = instruments.filter((inst) => {
|
|
113
|
+
if (inst.isDelisted) return false;
|
|
114
|
+
if ((inst.maxLeverage ?? 0) < cfg.minLeverage) return false;
|
|
115
|
+
const vol = inst.context?.dayNtlVlm ?? 0;
|
|
116
|
+
return vol >= cfg.minDayVolume;
|
|
117
|
+
});
|
|
118
|
+
eligible.sort(
|
|
119
|
+
(a, b) => (b.context?.dayNtlVlm ?? 0) - (a.context?.dayNtlVlm ?? 0),
|
|
120
|
+
);
|
|
121
|
+
return eligible.slice(0, cfg.maxCandidates);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function computeMovePct(series) {
|
|
125
|
+
if (!series || series.length < 2) return null;
|
|
126
|
+
const prev = series[series.length - 2]?.close;
|
|
127
|
+
const last = series[series.length - 1]?.close;
|
|
128
|
+
if (!Number.isFinite(prev) || !Number.isFinite(last) || prev <= 0) {
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
return { prev, last, movePct: ((last - prev) / prev) * 100 };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function buildSignal({ instrument, move, timeframe, now }) {
|
|
135
|
+
const movePct = move.movePct;
|
|
136
|
+
const absMove = Math.abs(movePct);
|
|
137
|
+
const direction = movePct > 0 ? "LONG" : "SHORT";
|
|
138
|
+
// Score scales from 0 at minMovePct to 1 at 5% move, clamped.
|
|
139
|
+
const score = clamp01((absMove - config.minMovePct) / (5 - config.minMovePct));
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
scannerId: SCANNER_ID,
|
|
143
|
+
signalType: "MOMENTUM_BREAKOUT",
|
|
144
|
+
asset: instrument.name,
|
|
145
|
+
direction,
|
|
146
|
+
score,
|
|
147
|
+
timestamp: now,
|
|
148
|
+
factors: {
|
|
149
|
+
breakout: true,
|
|
150
|
+
large_move: absMove >= 3,
|
|
151
|
+
},
|
|
152
|
+
meta: {
|
|
153
|
+
timeframe,
|
|
154
|
+
movePct,
|
|
155
|
+
prev: move.prev,
|
|
156
|
+
last: move.last,
|
|
157
|
+
dayNtlVlm: instrument.context?.dayNtlVlm ?? 0,
|
|
158
|
+
maxLeverage: instrument.maxLeverage,
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ─── Main ───────────────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
async function main() {
|
|
166
|
+
console.log(`External momentum producer`);
|
|
167
|
+
console.log(` MCP: ${SENPI_MCP_URL}`);
|
|
168
|
+
console.log(` address: ${STRATEGY_ADDRESS}`);
|
|
169
|
+
console.log(` scanner: ${SCANNER_NAME}`);
|
|
170
|
+
console.log(` config: ${JSON.stringify(config)}`);
|
|
171
|
+
|
|
172
|
+
const transport = new StreamableHTTPClientTransport(new URL(SENPI_MCP_URL), {
|
|
173
|
+
requestInit: { headers: { Authorization: `Bearer ${SENPI_API_KEY}` } },
|
|
174
|
+
});
|
|
175
|
+
const mcpClient = new Client({
|
|
176
|
+
name: "external-momentum",
|
|
177
|
+
version: "0.0.1",
|
|
178
|
+
});
|
|
179
|
+
await mcpClient.connect(transport);
|
|
180
|
+
|
|
181
|
+
const providers = createSenpiScannerProviders({
|
|
182
|
+
client: createSenpiToolClient(mcpClient),
|
|
183
|
+
logLevel: "warn",
|
|
184
|
+
cacheTtlMs: { instruments: 0, smData: 0, candles: 0 },
|
|
185
|
+
candleIntervals: [config.timeframe],
|
|
186
|
+
maxCandleAssets: config.maxCandidates,
|
|
187
|
+
retries: 2,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const instruments = await providers.getInstruments(STRATEGY_ADDRESS);
|
|
192
|
+
const candidates = selectCandidates(instruments, config);
|
|
193
|
+
console.log(
|
|
194
|
+
`\nInstruments: ${instruments.length}, candidates: ${candidates.length}`,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
if (candidates.length === 0) {
|
|
198
|
+
console.log("No candidates after filtering — skipping ingest.");
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const candles = await providers.getCandles(STRATEGY_ADDRESS, candidates, {
|
|
203
|
+
scannerId: SCANNER_ID,
|
|
204
|
+
preferredAssets: candidates.map((c) => c.name),
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const signals = [];
|
|
209
|
+
for (const inst of candidates) {
|
|
210
|
+
const series = candles[inst.name]?.candles?.[config.timeframe];
|
|
211
|
+
const move = computeMovePct(series);
|
|
212
|
+
if (!move) continue;
|
|
213
|
+
if (Math.abs(move.movePct) < config.minMovePct) continue;
|
|
214
|
+
const signal = buildSignal({
|
|
215
|
+
instrument: inst,
|
|
216
|
+
move,
|
|
217
|
+
timeframe: config.timeframe,
|
|
218
|
+
now,
|
|
219
|
+
});
|
|
220
|
+
if (signal.score < config.minSignalScore) continue;
|
|
221
|
+
signals.push(signal);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log(`\nSignals: ${signals.length}`);
|
|
225
|
+
for (const s of signals) {
|
|
226
|
+
console.log(
|
|
227
|
+
` ${s.asset} ${s.direction} move=${s.meta.movePct.toFixed(2)}% score=${s.score.toFixed(2)}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (signals.length === 0) {
|
|
232
|
+
console.log("\nNothing to ingest — skipping gateway call.");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const response = await pushScannerSignals({
|
|
237
|
+
address: STRATEGY_ADDRESS,
|
|
238
|
+
scanner: SCANNER_NAME,
|
|
239
|
+
signals,
|
|
240
|
+
openclawBin: OPENCLAW_BIN,
|
|
241
|
+
});
|
|
242
|
+
console.log("\nIngest response:", JSON.stringify(response));
|
|
243
|
+
} finally {
|
|
244
|
+
await transport.close();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
main().catch((error) => {
|
|
249
|
+
console.error("\nProducer failed:", error.message ?? error);
|
|
250
|
+
process.exit(1);
|
|
251
|
+
});
|
|
@@ -1,25 +1,17 @@
|
|
|
1
|
-
"""Process entry for the scaffold core
|
|
1
|
+
"""Process entry for the scaffold core.
|
|
2
2
|
|
|
3
3
|
Usage: python3 -m scaffold <launch-config.json>
|
|
4
4
|
|
|
5
|
-
The launch config JSON
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
wallet — strategy wallet (-> ctx.wallet).
|
|
16
|
-
scanner_name — this scanner's recipe name (-> ctx.scanner_name).
|
|
17
|
-
|
|
18
|
-
Optional keys:
|
|
19
|
-
timeout_seconds — per-tick wall-clock budget (default: interval_seconds).
|
|
20
|
-
state_path — JSON state file path (None => no persistence).
|
|
21
|
-
inputs — tunables dict for scan() (was `params`); default {}.
|
|
22
|
-
state_history_max_count — state series bound; default 0 = history disabled.
|
|
5
|
+
The launch config JSON must contain:
|
|
6
|
+
path — directory added to sys.path (the scanner's working directory).
|
|
7
|
+
entrypoint — module name to import from `path`.
|
|
8
|
+
interval — seconds between tick starts.
|
|
9
|
+
|
|
10
|
+
Optional fields:
|
|
11
|
+
timeout_seconds — per-tick wall-clock budget (default: interval).
|
|
12
|
+
params — tunables dict passed to scan() as the first argument.
|
|
13
|
+
state_path — path to the JSON state file.
|
|
14
|
+
max_ticks — cap on tick count (for tests).
|
|
23
15
|
|
|
24
16
|
Delivery sink is wired when EXTERNAL_SCANNER_ID and SIGNAL_INGESTION_URL are set in
|
|
25
17
|
the environment. If either is absent the scaffold runs with the default no-op sink
|
|
@@ -31,18 +23,15 @@ import os
|
|
|
31
23
|
import sys
|
|
32
24
|
|
|
33
25
|
|
|
34
|
-
def _main(
|
|
35
|
-
if argv
|
|
36
|
-
argv = sys.argv
|
|
37
|
-
|
|
38
|
-
if len(argv) < 2:
|
|
26
|
+
def _main() -> int:
|
|
27
|
+
if len(sys.argv) < 2:
|
|
39
28
|
print(
|
|
40
29
|
"Usage: python3 -m scaffold <launch-config.json>",
|
|
41
30
|
file=sys.stderr,
|
|
42
31
|
)
|
|
43
32
|
return 1
|
|
44
33
|
|
|
45
|
-
config_path = argv[1]
|
|
34
|
+
config_path = sys.argv[1]
|
|
46
35
|
try:
|
|
47
36
|
with open(config_path, "r", encoding="utf-8") as fh:
|
|
48
37
|
config = json.load(fh)
|
|
@@ -50,43 +39,22 @@ def _main(argv=None) -> int:
|
|
|
50
39
|
print(f"[scaffold] launch config error: {exc}", file=sys.stderr)
|
|
51
40
|
return 1
|
|
52
41
|
|
|
53
|
-
# ---- Required §4a keys: missing => fail loud (no magic default) ----
|
|
54
42
|
try:
|
|
55
43
|
path = config["path"]
|
|
56
44
|
entrypoint = config["entrypoint"]
|
|
57
|
-
interval = float(config["
|
|
58
|
-
default_signal_validity_seconds = int(config["default_signal_validity_seconds"])
|
|
59
|
-
wallet = config["wallet"]
|
|
60
|
-
scanner_name = config["scanner_name"]
|
|
45
|
+
interval = float(config["interval"])
|
|
61
46
|
except (KeyError, TypeError, ValueError) as exc:
|
|
62
|
-
print(
|
|
63
|
-
f"[scaffold] launch config missing/invalid required field: {exc}",
|
|
64
|
-
file=sys.stderr,
|
|
65
|
-
)
|
|
47
|
+
print(f"[scaffold] launch config missing required field: {exc}", file=sys.stderr)
|
|
66
48
|
return 1
|
|
67
49
|
|
|
68
|
-
# ---- Optional §4a keys ----
|
|
69
50
|
timeout_seconds = config.get("timeout_seconds")
|
|
70
|
-
|
|
71
|
-
inputs = config.get("inputs") or {}
|
|
51
|
+
params = config.get("params") or {}
|
|
72
52
|
state_path = config.get("state_path")
|
|
73
|
-
state_history_max_count = config.get("state_history_max_count") or 0
|
|
74
|
-
# Per-signal data{} schema (§4a). None / absent => no scaffold-side validation
|
|
75
|
-
# (intake still validates authoritatively). A dict turns on early, loud
|
|
76
|
-
# scaffold-side validation that POSTs a violation to /errors.
|
|
77
|
-
signal_data_schema = config.get("signal_data_schema")
|
|
78
|
-
if signal_data_schema is not None and not isinstance(signal_data_schema, dict):
|
|
79
|
-
signal_data_schema = None
|
|
80
53
|
max_ticks = config.get("max_ticks")
|
|
81
54
|
|
|
82
|
-
|
|
83
|
-
from scaffold import ScanEntrypointError, ScanSignatureError
|
|
55
|
+
from scaffold import ScanEntrypointError, ScanSignatureError, run_scaffold
|
|
84
56
|
from scaffold.loop_primitives import log_event
|
|
85
57
|
|
|
86
|
-
# Resolve run_scaffold via the package symbol so a test stub patched onto the
|
|
87
|
-
# package (or this module) is picked up.
|
|
88
|
-
run_scaffold = globals().get("run_scaffold") or scaffold_pkg.run_scaffold
|
|
89
|
-
|
|
90
58
|
# Build the delivery sink when scanner_id and intake URL are available.
|
|
91
59
|
sink = None
|
|
92
60
|
scanner_id = os.environ.get("EXTERNAL_SCANNER_ID", "").strip()
|
|
@@ -99,24 +67,19 @@ def _main(argv=None) -> int:
|
|
|
99
67
|
post_timeout = float(os.environ.get("SIGNAL_POST_TIMEOUT_SECONDS", "5"))
|
|
100
68
|
|
|
101
69
|
# Derive journal path from the state file directory when available.
|
|
102
|
-
journal_path = None
|
|
70
|
+
journal_path: str | None = None
|
|
103
71
|
if state_path:
|
|
104
|
-
|
|
105
|
-
|
|
72
|
+
import os as _os
|
|
73
|
+
journal_path = _os.path.join(
|
|
74
|
+
_os.path.dirname(_os.path.abspath(state_path)),
|
|
106
75
|
"delivery-journal.jsonl",
|
|
107
76
|
)
|
|
108
77
|
|
|
109
|
-
# §4a threads the per-data-key schema (`signal_data_schema`) to the
|
|
110
|
-
# scaffold so the envelope validates each signal's `data` scaffold-side
|
|
111
|
-
# and POSTs a violation to /errors (early, loud feedback). None ⇒ no
|
|
112
|
-
# scaffold-side validation. The intake still performs authoritative
|
|
113
|
-
# server-side schema validation regardless (this is additive).
|
|
114
78
|
sink = build_delivery_sink(
|
|
115
79
|
scanner_id=scanner_id,
|
|
116
80
|
intake_url=intake_url,
|
|
81
|
+
interval=interval,
|
|
117
82
|
journal_path=journal_path,
|
|
118
|
-
signal_data_schema=signal_data_schema,
|
|
119
|
-
default_signal_validity_seconds=default_signal_validity_seconds,
|
|
120
83
|
timeout=post_timeout,
|
|
121
84
|
)
|
|
122
85
|
log_event("delivery_sink_enabled", scanner_id=scanner_id, intake_url=intake_url)
|
|
@@ -135,12 +98,8 @@ def _main(argv=None) -> int:
|
|
|
135
98
|
entrypoint,
|
|
136
99
|
interval=interval,
|
|
137
100
|
timeout_seconds=timeout_seconds,
|
|
138
|
-
|
|
101
|
+
params=params,
|
|
139
102
|
state_path=state_path,
|
|
140
|
-
scanner_name=scanner_name,
|
|
141
|
-
wallet=wallet,
|
|
142
|
-
default_signal_validity_seconds=default_signal_validity_seconds,
|
|
143
|
-
state_history_max_count=state_history_max_count,
|
|
144
103
|
sink=sink,
|
|
145
104
|
max_ticks=max_ticks,
|
|
146
105
|
)
|
|
@@ -4,9 +4,8 @@ build_delivery_sink(
|
|
|
4
4
|
*,
|
|
5
5
|
scanner_id: str,
|
|
6
6
|
intake_url: str,
|
|
7
|
+
interval: float, # seconds; valid_until default = produced_at + 2*interval
|
|
7
8
|
journal_path: str | None,
|
|
8
|
-
signal_data_schema: dict | None, # per-data-key {type, required?}; None = no validation
|
|
9
|
-
default_signal_validity_seconds: int, # fallback signal TTL (seconds)
|
|
10
9
|
timeout: float = 5.0,
|
|
11
10
|
clock: Callable[[], float] = time.time,
|
|
12
11
|
) -> sink
|
|
@@ -33,7 +32,6 @@ from __future__ import annotations
|
|
|
33
32
|
|
|
34
33
|
import sys
|
|
35
34
|
import time
|
|
36
|
-
import uuid
|
|
37
35
|
from typing import Any, Callable
|
|
38
36
|
|
|
39
37
|
from .http_post import ERRORS_PATH, SIGNALS_PATH, TransportError, post_json
|
|
@@ -49,14 +47,14 @@ def build_delivery_sink(
|
|
|
49
47
|
*,
|
|
50
48
|
scanner_id: str,
|
|
51
49
|
intake_url: str,
|
|
50
|
+
interval: float,
|
|
52
51
|
journal_path: str | None,
|
|
53
|
-
signal_data_schema: dict | None,
|
|
54
|
-
default_signal_validity_seconds: int,
|
|
55
52
|
timeout: float = 5.0,
|
|
56
53
|
clock: Callable[[], float] = time.time,
|
|
57
54
|
):
|
|
58
55
|
"""Factory: build and return the delivery sink callable."""
|
|
59
56
|
|
|
57
|
+
interval_ms = int(interval * 1000)
|
|
60
58
|
journal = Journal(journal_path)
|
|
61
59
|
replayed = [False] # mutable cell; replay runs once on first invocation
|
|
62
60
|
|
|
@@ -89,35 +87,16 @@ def build_delivery_sink(
|
|
|
89
87
|
log_event("delivery_dropped_expired", entry_id=entry.id, asset=entry.asset)
|
|
90
88
|
return to_send
|
|
91
89
|
|
|
92
|
-
def _on_reject(reason: dict) -> None:
|
|
93
|
-
"""Loud rejection: log AND POST to /errors (design §4 — never a silent drop)."""
|
|
94
|
-
log_event("delivery_candidate_invalid", **reason)
|
|
95
|
-
_post_error({"type": "candidate_rejected", **reason})
|
|
96
|
-
|
|
97
90
|
def _wrap_candidates(candidates, produced_at_ms: int) -> list[tuple]:
|
|
98
|
-
"""Wrap candidates into (signal, entry_id) pairs, skipping invalid ones.
|
|
99
|
-
|
|
100
|
-
Schema-rejected candidates are NOT silently dropped: candidate_to_signal
|
|
101
|
-
calls _on_reject, which logs AND POSTs to /errors.
|
|
102
|
-
"""
|
|
91
|
+
"""Wrap candidates into (signal, entry_id) pairs, skipping invalid ones."""
|
|
103
92
|
result = []
|
|
104
93
|
for c in candidates:
|
|
105
|
-
|
|
106
|
-
# through the envelope so the POSTed signal carries it. record_produced
|
|
107
|
-
# re-uses this same id as the entry id, so wire signal_id == journal eid.
|
|
108
|
-
eid = uuid.uuid4().hex
|
|
109
|
-
sig = candidate_to_signal(
|
|
110
|
-
c,
|
|
111
|
-
produced_at_ms,
|
|
112
|
-
signal_id=eid,
|
|
113
|
-
signal_data_schema=signal_data_schema,
|
|
114
|
-
default_signal_validity_seconds=default_signal_validity_seconds,
|
|
115
|
-
on_reject=_on_reject,
|
|
116
|
-
)
|
|
94
|
+
sig = candidate_to_signal(c, produced_at_ms, interval_ms)
|
|
117
95
|
if sig is None:
|
|
96
|
+
log_event("delivery_candidate_invalid", candidate=repr(c))
|
|
118
97
|
continue
|
|
119
|
-
|
|
120
|
-
result.append((sig,
|
|
98
|
+
eid = journal.record_produced(sig, now_ms=produced_at_ms)
|
|
99
|
+
result.append((sig, eid))
|
|
121
100
|
return result
|
|
122
101
|
|
|
123
102
|
def _post_signals(signals: list[dict]) -> tuple[int, Any]:
|
|
@@ -146,33 +125,20 @@ def build_delivery_sink(
|
|
|
146
125
|
if not isinstance(per_signal, list):
|
|
147
126
|
per_signal = []
|
|
148
127
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
log_event("delivery_status_unmatched", signal_id=eid)
|
|
164
|
-
continue
|
|
165
|
-
st = sig_status.get("status")
|
|
166
|
-
if st == "accepted":
|
|
167
|
-
journal.record_outcome(eid, "delivered", correlationId=sig_status.get("correlationId", ""))
|
|
168
|
-
elif st == "rejected":
|
|
169
|
-
journal.record_outcome(eid, "rejected", reason=sig_status.get("reason", ""))
|
|
170
|
-
elif st in ("expired", "superseded"):
|
|
171
|
-
journal.record_outcome(eid, st)
|
|
172
|
-
else:
|
|
173
|
-
# Unknown status — treat as undelivered (no outcome written)
|
|
174
|
-
log_event("delivery_unknown_status", entry_id=eid, status=st)
|
|
175
|
-
# Entries with no matching status returned stay pending (retry next tick).
|
|
128
|
+
for i, eid in enumerate(pending_eids):
|
|
129
|
+
if i < len(per_signal):
|
|
130
|
+
sig_status = per_signal[i]
|
|
131
|
+
st = sig_status.get("status") if isinstance(sig_status, dict) else None
|
|
132
|
+
if st == "accepted":
|
|
133
|
+
journal.record_outcome(eid, "delivered", correlationId=sig_status.get("correlationId", ""))
|
|
134
|
+
elif st == "rejected":
|
|
135
|
+
journal.record_outcome(eid, "rejected", reason=sig_status.get("reason", ""))
|
|
136
|
+
elif st in ("expired", "superseded"):
|
|
137
|
+
journal.record_outcome(eid, st)
|
|
138
|
+
else:
|
|
139
|
+
# Unknown status — treat as undelivered (no outcome written)
|
|
140
|
+
log_event("delivery_unknown_status", entry_id=eid, status=st)
|
|
141
|
+
# If no per-signal status returned for this entry, leave it pending (retry)
|
|
176
142
|
|
|
177
143
|
return True
|
|
178
144
|
|