@senpi/runtime 3.0.9-dev.20260710204324 → 3.0.9-dev.feat-strategy-compos.20260716233548

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.
Files changed (108) hide show
  1. package/dist/cli/composer-commands.d.ts +37 -0
  2. package/dist/cli/composer-commands.d.ts.map +1 -0
  3. package/dist/cli/composer-commands.js +396 -0
  4. package/dist/cli/composer-commands.js.map +1 -0
  5. package/dist/cli/senpi-commands.d.ts.map +1 -1
  6. package/dist/cli/senpi-commands.js +2 -0
  7. package/dist/cli/senpi-commands.js.map +1 -1
  8. package/dist/cli/validate-runtime-config.mjs +190 -0
  9. package/dist/composer/AUTHORING.md +435 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +59 -0
  12. package/dist/composer/authoring/answers.py +297 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +26 -0
  14. package/dist/composer/authoring/archetypes/_common.py +410 -0
  15. package/dist/composer/authoring/archetypes/breakout.py +134 -0
  16. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  17. package/dist/composer/authoring/archetypes/skeleton.py +76 -0
  18. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  19. package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
  20. package/dist/composer/authoring/builder.py +191 -0
  21. package/dist/composer/authoring/emit.py +264 -0
  22. package/dist/composer/authoring/gaps.py +115 -0
  23. package/dist/composer/authoring/generate.py +131 -0
  24. package/dist/composer/authoring/presets.py +168 -0
  25. package/dist/composer/authoring/runtime_emit.py +266 -0
  26. package/dist/composer/authoring/strategy_manifest.py +94 -0
  27. package/dist/composer/cli.py +487 -0
  28. package/dist/composer/compiler/__init__.py +22 -0
  29. package/dist/composer/compiler/compile.py +739 -0
  30. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  31. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  32. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  33. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  34. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  35. package/dist/composer/generated/README.md +22 -0
  36. package/dist/composer/generated/catalog.md +118 -0
  37. package/dist/composer/generated/describe.md +1168 -0
  38. package/dist/composer/generated/graph.schema.json +1946 -0
  39. package/dist/composer/primitives/__init__.py +320 -0
  40. package/dist/composer/primitives/_candles.py +76 -0
  41. package/dist/composer/primitives/adapter.py +363 -0
  42. package/dist/composer/primitives/emitters.py +142 -0
  43. package/dist/composer/primitives/filters.py +71 -0
  44. package/dist/composer/primitives/indicators.py +326 -0
  45. package/dist/composer/primitives/rankers.py +40 -0
  46. package/dist/composer/primitives/scorers.py +100 -0
  47. package/dist/composer/primitives/sources.py +979 -0
  48. package/dist/composer/primitives/stateful.py +254 -0
  49. package/dist/composer/pure_fn/__init__.py +34 -0
  50. package/dist/composer/pure_fn/checks.py +39 -0
  51. package/dist/composer/pure_fn/models.py +177 -0
  52. package/dist/composer/pure_fn/purity.py +311 -0
  53. package/dist/composer/pure_fn/runtests.py +155 -0
  54. package/dist/composer/registry/__init__.py +40 -0
  55. package/dist/composer/registry/generate.py +425 -0
  56. package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
  57. package/dist/composer/registry/mcp_closure.py +311 -0
  58. package/dist/composer/registry/models.py +417 -0
  59. package/dist/composer/registry/nodes.py +1297 -0
  60. package/dist/composer/requirements.txt +21 -0
  61. package/dist/composer/scaffold_path.py +118 -0
  62. package/dist/composer/templates/__init__.py +13 -0
  63. package/dist/composer/templates/scan.py.j2 +34 -0
  64. package/dist/composer/validator/__init__.py +29 -0
  65. package/dist/composer/validator/desugar.py +133 -0
  66. package/dist/composer/validator/errors.py +227 -0
  67. package/dist/composer/validator/graph_checks.py +443 -0
  68. package/dist/composer/validator/loader.py +110 -0
  69. package/dist/composer/validator/schema_checks.py +147 -0
  70. package/dist/composer/validator/validate.py +86 -0
  71. package/dist/composer/vendoring.py +103 -0
  72. package/dist/composer/verbs/__init__.py +49 -0
  73. package/dist/composer/verbs/_box.py +308 -0
  74. package/dist/composer/verbs/check.py +254 -0
  75. package/dist/composer/verbs/close.py +494 -0
  76. package/dist/composer/verbs/deploy.py +279 -0
  77. package/dist/composer/verbs/docs.py +30 -0
  78. package/dist/composer/verbs/fund.py +498 -0
  79. package/dist/composer/verbs/install.py +586 -0
  80. package/dist/composer/verbs/runtime_validate.py +422 -0
  81. package/dist/composer/verbs/smoke.py +134 -0
  82. package/dist/composer/verbs/status.py +383 -0
  83. package/dist/index.d.ts.map +1 -1
  84. package/dist/index.js +2 -9
  85. package/dist/index.js.map +1 -1
  86. package/dist/runtime/load-runtime-config.d.ts +17 -0
  87. package/dist/runtime/load-runtime-config.d.ts.map +1 -1
  88. package/dist/runtime/load-runtime-config.js +2 -2
  89. package/dist/runtime/load-runtime-config.js.map +1 -1
  90. package/dist/runtime-api/config.d.ts +2 -2
  91. package/dist/runtime-api/config.js +4 -4
  92. package/dist/runtime-api/config.js.map +1 -1
  93. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  94. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  95. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  96. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  97. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  98. package/dist/runtime-api/intake/intake-mount.js +2 -8
  99. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  100. package/dist/skills-manager/manifest.d.ts.map +1 -1
  101. package/dist/skills-manager/manifest.js +1 -3
  102. package/dist/skills-manager/manifest.js.map +1 -1
  103. package/dist/skills-manager/skills-manifest.json +1 -3
  104. package/package.json +4 -2
  105. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  106. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  107. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  108. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,435 @@
1
+ # Authoring a strategy with the Strategy Composer
2
+
3
+ Quickstart for a fresh Claude Code session (or a human) turning a strategy idea into a
4
+ deployable trading-strategy graph. This is the same teaching surface the eval harness
5
+ gives Qwen (`composer/eval/prompt.py`), adapted for an interactive session instead of a
6
+ scripted transcript.
7
+
8
+ ## 1. Mental model
9
+
10
+ An idea becomes a strategy in four moves: write an **answers file** (a typed interview —
11
+ archetype, universe, intervals, cardinality, risk, and your EDGE in plain words) → run
12
+ `composer new` to generate a **COMPLETE, VALID graph** for that archetype, with the
13
+ mechanically-bindable values already filled in and the bespoke slots marked
14
+ `TODO(edit)` with a working default → **edit only what needs editing** (the TODO slots,
15
+ and nothing else unless you have a reason) → run `composer check` for **one GREEN/RED
16
+ verdict** with located, actionable teaching errors → iterate until GREEN → `composer
17
+ compile` (materialize `scan.py`) or `composer deploy --out <dir>` (stage the COMPLETE
18
+ standalone unit — runtime.yaml + scanners/ + strategy.yaml + manifest). The spec file (the graph YAML) **is** the state — there is no separate
19
+ journal — so at any point `composer status <file>` tells you exactly where you are in
20
+ `new → check → deploy` and names the next verb to run.
21
+
22
+ If the idea doesn't fit a known archetype, `composer new` falls back to a minimal valid
23
+ skeleton (still GREEN, still a real starting point). If the idea needs a capability the
24
+ registry doesn't have, `composer new` refuses to write anything and instead prints a
25
+ **gap report** — that is the correct, honest outcome for something not yet buildable, not
26
+ a bug to work around.
27
+
28
+ ## 2. Setup
29
+
30
+ Run everything from the worktree root:
31
+
32
+ ```bash
33
+ cd /Users/duncan/Senpi/worktrees/trading_runtime/strategy-composer-poc
34
+ ```
35
+
36
+ The composer has no installed console script; invoke it as a module through `uvx` with
37
+ its runtime deps pinned inline (no venv setup needed):
38
+
39
+ ```bash
40
+ uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli <verb> ...
41
+ ```
42
+
43
+ Write your working answers files and generated graphs to `drafts/` (untracked, gitignored
44
+ scratch space) — never into `composer/examples/` (those are the committed corpus
45
+ fixtures) or `composer/generated/` (registry-derived, do-not-edit).
46
+
47
+ ```bash
48
+ mkdir -p drafts
49
+ ```
50
+
51
+ **`scaffold` resolves itself — never set a manual `PYTHONPATH` for it.** `check`/`deploy` run
52
+ the emitted `scan.py` under the runtime's `scaffold` harness. The composer finds `scaffold`
53
+ automatically in BOTH layouts — a dev checkout (`src/scanners/external`) and a claw dist install
54
+ (`dist/scanners/external`, no `src/` segment) — so you do not export any path. If it genuinely
55
+ cannot be found, the `smoke` stage fails RED with a teaching error naming the fix: on a claw run
56
+ `npm run build` (its `copy:external-scanners` step stages the harness), or set
57
+ `COMPOSER_SCAFFOLD_DIR` to the dir that CONTAINS `scaffold/`. Do **not** `pip install scaffold` —
58
+ that is an unrelated PyPI package and will not satisfy the import (the harness ships with the
59
+ runtime, never from PyPI).
60
+
61
+ ## 3. Verb reference
62
+
63
+ Every verb takes `--json` for machine-readable output; omit it for a human-readable
64
+ report.
65
+
66
+ | Verb | What it does | Example |
67
+ |---|---|---|
68
+ | `new` | Interview answers in, a COMPLETE VALID graph out (or a gap report). Refuses to overwrite a hand-edited graph without `--force`. | `uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli new my_strat --archetype trend_momentum --answers drafts/my_strat.answers.yaml -o drafts` |
69
+ | `validate` | Schema + graph-semantic teaching-error report (exit 0 iff clean). | `... python -m composer.cli validate drafts/my_strat.graph.yaml` |
70
+ | `check` | ONE verdict: validate + pure_fn inline tests + compile + one-tick smoke, staged so a failing stage skips the rest. | `... python -m composer.cli check drafts/my_strat.graph.yaml` |
71
+ | `compile` | Materialize the graph into `scan.py` on the `external_scanner` contract. | `... python -m composer.cli compile drafts/my_strat.graph.yaml -o drafts/scan.py` |
72
+ | `deploy` | Stage the COMPLETE standalone unit as a package tree (`strategy.yaml`, `main/runtime.yaml`, `main/scanners/scan.py`, `main/scanners/strategy_primitives/` — the vendored primitives snapshot — `main/<id>.graph.yaml`, `manifest.json`, `deploy.json`); HARD-gated on a GREEN `check`. Does not install. The verdict always reports the ABSOLUTE staged dir (a relative `-o` resolves under your current working dir), and a re-deploy of unchanged bytes is `UNCHANGED` — "already staged at `<abs>`, nothing rewritten". | `... python -m composer.cli deploy drafts/my_strat.graph.yaml -o drafts/dist` |
73
+ | `fund` | Create + fund the strategy's ONE wallet via MCP `strategy_create_custom_strategy`. `--budget <usd>` is REQUIRED and EXPLICIT (≥ $100) — agree it with the user; it is NEVER derived or split. Preflights the visible balance (free Hyperliquid + EVM USDC), creates, records `wallet.json` BEFORE polling, then polls briefly to ACTIVE. Idempotent + safe to re-run while funds settle (re-run IS the poll): existing ACTIVE wallet → `FUNDED` no-op; still initializing → `PENDING`; stale → CMP231. Short balance → CMP232 (states the shortfall + the bridged-funds visibility lag). Teaching errors CMP230-233 (+ CMP223 for missing creds). | `... python -m composer.cli fund drafts/dist/my_strat --budget 250` |
74
+ | `install` | Bind a staged unit onto THIS box: preflight (manifest verify + GREEN oracle verdict) → copy to a content-addressed immutable dir `<senpi-state>/strategies/<id>/<hash8>/` (temp+verify+atomic-rename) → bind the wallet into the COPIED `runtime.yaml` (staged source stays pristine) → invoke the frozen `openclaw senpi runtime create -p …` consumer. Pass `--wallet 0x…` explicitly, OR omit it to AUTO-BIND the funded `wallet.json` from `composer fund` (only if MCP confirms the strategy is ACTIVE; explicit `--wallet` always wins). Failures map to teaching errors (CMP200-219, + CMP206 when there is no ACTIVE wallet to auto-bind). Resumable-by-artifact. | `... python -m composer.cli fund drafts/dist/my_strat --budget 250` then `... install drafts/dist/my_strat` |
75
+ | `close` | SAFE TEARDOWN of a running strategy: STOP the runtime first (`openclaw senpi runtime delete`), CONFIRM it is gone, and only THEN trigger MCP `strategy_close` (flattens all positions, returns funds). Idempotent — re-running IS the poll to `CLOSED`. Target is a strategy name / canonical hash dir / runtime id / `0x…` wallet / strategyId; on-box artifacts resolve first, else legacy `strategy_list` attribution (skillName / strategyId). Failures map to teaching errors (CMP220-225). | `... python -m composer.cli close my_strat --state-dir ~/.openclaw/senpi-state` |
76
+ | `status` | Derives your lifecycle position (`absent` / `invalid` / `valid_undeployed` / `deployed_stale` / `deployed_current`) from the file + check + staged manifest, and names the next verb. Pass `--state-dir` to EXTEND the chain onto a box: `installable` → `installed_unregistered` → `installed_stopped` → `installed_running`, distinguishing which hash is installed/running vs merely a prior landed version. Functional liveness (scan() has actually ticked) is reported honestly as NOT-checked — `runtime list` only proves the process is up. No journal — the file is the state. | `... python -m composer.cli status drafts/my_strat.graph.yaml -o drafts/dist --state-dir ~/.openclaw/senpi-state` |
77
+ | `catalog` | The progressive-disclosure node index (names + one-liners + param summary) — level 1. Reference it; don't inline the whole thing into a prompt. | `... python -m composer.cli catalog` |
78
+ | `describe` | Full ports/params/example/cost for ONE node — level 2, on-demand deep-dive. | `... python -m composer.cli describe candles_fetch` |
79
+
80
+ When in doubt about what a node actually does or which ports it exposes, run `describe`
81
+ rather than guessing from the catalog one-liner.
82
+
83
+ ## 4. The answers-file format
84
+
85
+ The answers file is the interview output: it captures the 7 decisions that are the
86
+ type-signature of `scan()` — archetype, universe, intervals, cardinality, memory
87
+ (dedup TTL), risk knobs, and the EDGE (free text, carried verbatim; the machine never
88
+ interprets it) — plus any `requires:` capabilities. Full field definitions live in
89
+ `composer/authoring/answers.py`; the shape:
90
+
91
+ ```yaml
92
+ archetype: trend_momentum # trend_momentum | breakout | thesis_fund | classifier
93
+ strategy: my_strat # short id -> <id>.graph.yaml
94
+ universe:
95
+ whitelist: ["BTC", "ETH", "SOL"] # fixed asset list (trend_momentum / breakout)
96
+ long_basket: [] # thesis_fund LONG leg (use instead of whitelist)
97
+ short_basket: [] # thesis_fund SHORT leg
98
+ discover: false # classifier / breakout: derive the universe from the live board
99
+ dex: "" # discovery: venue filter
100
+ name_prefix: "" # discovery: instrument name prefix
101
+ max_leverage_cap: 5.0 # discovery: leverage ceiling
102
+ min_day_volume: 0.0 # discovery: daily-volume floor
103
+ intervals: ["4h"] # candle intervals, e.g. ["1h", "4h"]
104
+ max_positions: 1 # 1 = single best; >1 = rank a pool, cap to open slots
105
+ ttl_seconds: 240 # signal-dedup memory window
106
+ exit_preset: null # LIVE: protected_standard | protected_tight | protected_wide.
107
+ # null -> `composer new` BINDS protected_standard (PROTECTION BY DEFAULT).
108
+ unmanaged: false # explicit opt-out of exit protection; without it, an exit-less unit is RED (CMP120)
109
+ risk:
110
+ margin_pct: 5.0 # PERCENT of withdrawable (0,100], never a fraction/USD
111
+ leverage: 3.0 # signal_envelope also exposes clamp_leverage_to_venue (real venue-max clamp when the board is wired), valid_for_seconds, signal_type
112
+ drawdown_halt_pct: null # guard rail: halt the strategy at this equity drawdown % (else preset default)
113
+ max_consecutive_losses: null # guard rail: pause after N losses
114
+ cooldown_seconds: null # guard rail: post-loss cooldown seconds (>= 60)
115
+ max_entries_per_day: null # guard rail: per-day entry cap (>= 1)
116
+ edge: >
117
+ Your edge in plain words — the actual thesis (e.g. "ride assets breaking to fresh highs
118
+ with accelerating momentum, volume confirmation, and smart money leaning the same way").
119
+ This is carried verbatim into the graph; nothing parses it, so be as specific as you want.
120
+ requires: [] # [{name, kind, need}] — a capability that may not exist yet;
121
+ # a name the registry lacks becomes a gap report, nothing written
122
+ # --- OPTIONAL expressiveness (omit both for the archetype's working defaults) ---
123
+ indicators: # ADD registry indicator nodes and wire them into the scorer,
124
+ # so they become addressable by id from `scoring` below.
125
+ - id: fresh_raw # the node's graph id (a feature key = SOURCE NODE ID)
126
+ node: recent_high_bars_ago # any registry indicator (see `composer catalog`)
127
+ with: { window: 24, interval: "1h" } # the node's params
128
+ # port: bars_ago # optional: which output feeds the scorer (default: the node's sole output)
129
+ - id: fresh # a THRESHOLDED gate over the raw feature above (C1b)
130
+ node: threshold_gate # compare a per-asset feature vs a threshold -> 1.0/0.0
131
+ with: { op: lte, value: 5 } # op is lt|lte|gt|gte; here: bars_ago <= 5 means "fresh"
132
+ source: fresh_raw # CHAIN the input off fresh_raw's output (not candles)
133
+ scoring: # RE-SELECT the scorer's hard-vs-weighted split over the feature
134
+ # nodes (built-in + any `indicators` above). Each field omitted
135
+ # -> keep the archetype default; set -> override it.
136
+ required_gates: ["brk", "fresh", "vol", "lean"] # feature ids that must ALL pass (hard gates)
137
+ weights: { mom: 2.0, vol: 1.0, lean: 0.05 } # feature id -> weighted-sum weight
138
+ min_score: 0 # minimum score to emit
139
+ ```
140
+
141
+ **Universe modes**: `whitelist` is a fixed list you already know (trend_momentum,
142
+ breakout, trend_conviction shapes). `long_basket`/`short_basket` are the two legs of a
143
+ fixed macro basket (thesis_fund — a relative-value long/short). `discover: true` (+ `dex`,
144
+ `name_prefix`, `max_leverage_cap`, `min_day_volume`) derives the tradeable set each tick
145
+ from the live instrument board by a signature instead of a fixed list (classifier and
146
+ breakout). `trend_momentum` is fixed-universe only — a `discover: true` answer is rejected
147
+ with a teaching error pointing at `breakout` / `classifier` (it never silently emits an
148
+ empty universe).
149
+
150
+ **Archetypes**: `trend_momentum` (fixed universe → indicators → hard-gates + weighted-sum
151
+ scorer), `breakout` (a directional range-break as the hard gate, fixed basket OR discovered
152
+ universe — the hawk shape), `classifier` (a pure_fn direction classifier on a discovered or
153
+ fixed universe), `thesis_fund` (fixed long/short basket, direction fixed per leg). An
154
+ `archetype` the composer doesn't recognize falls back to the **off-map skeleton** — still a
155
+ valid, generated starting graph, just minimal; expect to do more hand-editing from there.
156
+
157
+ **Expressiveness (`scoring` / `indicators`)**: both are OPTIONAL and default to the
158
+ archetype's built-in scorer. `scoring` re-selects which feature nodes are HARD gates
159
+ (`required_gates`) versus weighted contributors (`weights`) and the score floor
160
+ (`min_score`), addressing feature nodes by their SOURCE NODE ID. `indicators` adds
161
+ registry indicator nodes (e.g. `breakout_detect` with a `window`) and wires them into the
162
+ scorer so they become addressable from `scoring`. A `scoring` name that matches no feature
163
+ node, or an `indicators` node the registry lacks, is a loud generation error — never a
164
+ silent drop. This is how, e.g., the `breakout` archetype expresses a four-hard-gate
165
+ discovery variant without any hand editing.
166
+
167
+ **Thresholded gates (`threshold_gate` + `source`)**: a hard gate must be TRUTHY, so a raw
168
+ numeric feature (e.g. `recent_high_bars_ago`, whose output is bars-since-the-high) can't be
169
+ gated directly — 0 (a high set this bar) reads falsy, a stale 40 reads truthy, exactly
170
+ inverted. Add a `threshold_gate` indicator (`op` one of `lt|lte|gt|gte`, `value` the
171
+ threshold) and CHAIN it off the raw feature with `source:` — its input then reads that node's
172
+ output instead of candles, emitting a real `1.0`/`0.0` gate. So a "fresh entry within 5 bars"
173
+ gate is `fresh = threshold_gate(op: lte, value: 5, source: fresh_raw)` over `fresh_raw =
174
+ recent_high_bars_ago(...)`; a "don't chase a runaway" filter is `threshold_gate(op: lt, value:
175
+ 15, source: own_ret)` over an `own_return_24h` feature. `source` names an EARLIER `indicators`
176
+ entry or a built-in feature node of the archetype; an unknown source, or one whose output
177
+ type doesn't match the consumer's input, is a loud generation error.
178
+
179
+ Get a blank skeleton for any archetype with `composer describe`-adjacent tooling, or just
180
+ copy the block above and strip fields you don't need — every field has a sane default via
181
+ `Answers()`, so a short file (just `archetype`, `strategy`, `universe`, `edge`) is enough
182
+ to get started.
183
+
184
+ ## 5. Editing the generated graph
185
+
186
+ `composer new` writes `# TODO(edit): ...` comments at every slot that needs a real
187
+ decision (already filled with a working default so the graph validates unedited) — these
188
+ are the minimum you should look at, not the limit of what you may change. You have full
189
+ edit rights over the whole graph: add/remove nodes, rewire edges, retune any param.
190
+
191
+ Two ways to express bespoke logic:
192
+
193
+ - **Registry nodes** (`node: <name>`, see `catalog`) — parameterized, pre-built pipeline
194
+ steps: sources, indicators, filters, scorers, rankers, the emitter, and three stateful
195
+ nodes (`ttl_dedup_state` for signal dedup; `snapshot_diff_state` for a bounded rolling
196
+ per-key growth ledger; `cohort_cache_state` to pin an expensive fetch across ticks). Wire
197
+ per-asset features into a scorer's `features` input to MERGE them (keyed by producing node
198
+ id). The cohort/cross-trader vocabulary (the whalehunter thesis) is now expressible end to
199
+ end: `top_traders_fetch` → `cohort_cache_state` → `trader_states_fetch` → `aggregate_bias`
200
+ (the one sanctioned fn→primitive promotion: cross-trader net/gross bias per coin) +
201
+ `snapshot_diff_state` → a bespoke cohort-scoring `pure_fn` → emit. The **world-coverage sources** span the
202
+ whole scanner-usable Senpi MCP read surface (proven fully-mapped by the closure check, see
203
+ below): market signals (funding regime/history, cross-asset flows, prices, momentum events),
204
+ trader intelligence (top-traders + batch trader-state, the fast 4h smart-money leaderboard
205
+ `leaderboard_top_fetch` and its single-trader companion `leaderboard_trader_fetch`, leaderboard
206
+ trader positions, copy-strategy ranking `top_strategies_fetch`, the Arena leaderboard), and
207
+ own-account reads (order/PnL/clearinghouse/history, banked realized-PnL on open positions).
208
+ They emit `record_list` outputs — ordered lists of typed dict records — and, for
209
+ `funding_regime_fetch`, a `label` output (a market-wide categorical string like `LONG_CROWDED`).
210
+ Neither type is asset-keyed: their intended consumer is a `kind: pure_fn` node that declares a
211
+ `record_list`/`label` input and reads the fields itself (e.g. project addresses out of
212
+ `top_traders_fetch.traders` and wire that record_list straight into `trader_states_fetch.traders`
213
+ to pull the cohort's live books; or project `{wallet_address, coin}` pairs into
214
+ `open_positions_realized_pnl_fetch.positions`). Prefer `leaderboard_top_fetch` for CURRENT-
215
+ momentum cohorts (transient 4h delta-PnL) and `top_traders_fetch` for CONSISTENT-performer
216
+ cohorts (multi-window track record) — the two are deliberately different reads.
217
+ - **`kind: pure_fn` nodes** — how you write bespoke scoring/classification math the
218
+ registry vocabulary can't express (e.g. the cross-feature conviction waterfall in
219
+ `composer/examples/conviction_scorer.graph.yaml`, corpus_origin: bison). Rules, statically
220
+ enforced before anything runs:
221
+ - declare typed `inputs:` and `outputs:` (and optional `params:`) — no merge record, each
222
+ input is a single wired writer;
223
+ - the `fn:` block is **exactly one** synchronous top-level `def`, whose signature is
224
+ exactly the declared inputs then params, in order;
225
+ - only `math` and `statistics` may be imported; only a small safe-builtin set (numeric/
226
+ collection work) is allowed — no I/O, no `eval`/`exec`/`open`, no `ctx`/MCP access, no
227
+ dunder/reflection escapes, no `global`/`nonlocal`;
228
+ - it must `return` exactly its declared outputs;
229
+ - include `tests:` (inline fixture cases: `inputs:` → `expect:`) — these run as part of
230
+ `composer check`'s `pure_fn_tests` stage.
231
+ - **The escape hatch (`mcp_read` + the `raw` port)** — the LAST RESORT for a read the curated
232
+ source vocabulary doesn't wrap (or doesn't expose the fields you need). `mcp_read` calls a
233
+ named MCP tool with `args` passed verbatim and emits the unwrapped payload as the `raw` type;
234
+ you MUST wire that payload into a `kind: pure_fn` node — the NORMALIZER — which reads it and
235
+ returns TYPED outputs (with inline tests). What it's for: closing a genuine gap without
236
+ waiting for a registry add. What it is NOT: a way to bypass typing (a `raw` wire may feed
237
+ nothing but a pure_fn — CMP039), a general-purpose tool caller (READ-ONLY: the `tool` must be
238
+ in the committed snapshot and must not be a mutation — CMP060/CMP061), or a substitute for a
239
+ first-class node. **Reach for the registry/answers path FIRST** (`composer catalog` /
240
+ `composer describe`); use the hatch only when nothing fits, and be explicit in the `edge:`
241
+ about WHAT you're fetching and WHY the curated path can't express it. Never invent a tool —
242
+ if the read doesn't exist, it can't be faked here. `composer check` ANNOTATES every hatch use
243
+ (and flags when the tool already has a first-class node) so the usage stays visible; recurring
244
+ hatch use of the same shape is the maintainer's signal to wrap it as a real source node.
245
+
246
+ **Teaching errors are actionable, not decorative.** Every error from `validate`/`check`
247
+ carries a stable code (`CMPxxx`), a location (node id / port / line), what's wrong in one
248
+ sentence, and a concrete fix hint — a name to use, a bound to respect, or a command to
249
+ run. When a hint names a `describe <node>` command, run it before guessing; when it names
250
+ the nearest valid node/param, use that name rather than inventing one. Fix the errors it
251
+ names, re-run `check`, repeat until GREEN — don't rewrite unrelated parts of the graph.
252
+
253
+ ## 6. Worked micro-example
254
+
255
+ Idea: *"Long BTC and ETH when 4h price is above its 200-period average and 1h RSI is not
256
+ overbought; take the single best signal, moderate risk."*
257
+
258
+ `drafts/momo_micro.answers.yaml`:
259
+
260
+ ```yaml
261
+ archetype: trend_momentum
262
+ strategy: momo_micro
263
+ universe:
264
+ whitelist: ["BTC", "ETH"]
265
+ intervals: ["1h", "4h"]
266
+ max_positions: 1
267
+ ttl_seconds: 240
268
+ risk:
269
+ margin_pct: 5.0
270
+ leverage: 3.0
271
+ edge: >
272
+ Go long BTC/ETH when 4h price is above its 200-period SMA and 1h RSI is not overbought
273
+ (< 70) — trend confirmation with a momentum-exhaustion guard.
274
+ ```
275
+
276
+ Three commands:
277
+
278
+ ```bash
279
+ uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli \
280
+ new momo_micro --archetype trend_momentum --answers drafts/momo_micro.answers.yaml -o drafts
281
+
282
+ uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli \
283
+ check drafts/momo_micro.graph.yaml
284
+
285
+ uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli \
286
+ deploy drafts/momo_micro.graph.yaml -o drafts/dist
287
+ ```
288
+
289
+ After `new`, open `drafts/momo_micro.graph.yaml` and adjust the `TODO(edit)` gate/scorer
290
+ slot to actually express "above SMA-200 AND RSI < 70" (the generated default is a working
291
+ placeholder, not necessarily your exact edge) — the `sma` and `rsi` indicator nodes and a
292
+ `hard_gates_all` scorer (see `composer/examples/trend_momentum_majors.graph.yaml` for the
293
+ pattern) are
294
+ what you'd wire in. Re-run `check` after each edit until GREEN.
295
+
296
+ ## 7. Honest boundaries
297
+
298
+ - **Gap reports, not silent failures.** If the answers (or an edit) reference a data
299
+ source or capability the registry doesn't have — named under `requires:`, or a
300
+ hand-authored node type that doesn't exist — `composer new`/`check` will not fabricate
301
+ it. `new` prints a structured gap report (what was asked for, what kind, why, and the
302
+ nearest registry alternatives) and writes nothing. That's the correct outcome for
303
+ something not yet buildable; the fix is adding the capability to the registry, not
304
+ working around it in the graph.
305
+ - **Stateful nodes carry PRIOR-tick data, and the cohort cache does NOT skip the fetch.**
306
+ Every stateful node is split READ (prologue) / WRITE (epilogue): its outputs are read
307
+ BEFORE the pipeline runs and reflect the LAST persisted tick, while its late input is
308
+ recorded AFTER. Two consequences to author around: (1) `snapshot_diff_state.growth` is the
309
+ delta between the latest STORED snapshot and the oldest in the window — one tick behind this
310
+ tick's fresh input, and empty until ≥2 buckets have accumulated (a short warmup, exactly like
311
+ whalehunter's `requireGrowing` blocking until ~day 2). (2) `cohort_cache_state` does NOT
312
+ rate-limit — sources fire every tick and the compiler emits the upstream fetch
313
+ unconditionally (there is no conditional-fetch machinery). What it buys is COHORT STABILITY:
314
+ the read half serves the last-known-good cohort (pinned for `ttl_hours`, smoothing a transient
315
+ fetch failure, surviving process restarts via `ctx.state`), and the write half refreshes only
316
+ when the TTL lapses, the `version` changes, or the cache was empty. Cold start serves an empty
317
+ cohort for one warmup tick. State is BOUNDED by design (dedup prune, `max_snapshots` cap, one
318
+ cached cohort) — an unbounded fleet-box state series is a real ops hazard.
319
+ - **Ambiguous ideas need answers first, not invented specifics.** If a brief is missing
320
+ information you can't responsibly guess (which assets, long/short/both, how much risk),
321
+ ask the user rather than pick something plausible-sounding. Pin down the answers file,
322
+ then generate.
323
+ - **Protection by default.** Every strategy the composer emits is a COMPLETE standalone
324
+ runtime with a DSL `exit:` section (the two-phase trailing stop / time-cuts) — protection
325
+ is ON unless you deliberately pass `unmanaged: true`. The runtime schema legally allows an
326
+ exit-less config, so the composer enforces protection itself: an exit-less unit with no
327
+ `unmanaged` opt-out is RED at `check` (CMP120), and opting out prints a loud warning. Pick a
328
+ preset by shape — `protected_standard` (balanced, default), `protected_tight` (cut failures
329
+ fast), `protected_wide` (let a runner breathe). Tune only the `# TODO(edit)` slots
330
+ (phase-2 tier ladder, `max_loss_pct`) in the emitted `runtime.yaml`.
331
+ - **`deploy` stages a COMPLETE unit; it does not install.** `composer deploy` is hard-gated on
332
+ a GREEN `check` (which now includes the `runtime_validate` stage — the real Zod + DSL oracle)
333
+ and stages a standalone strategy PACKAGE TREE, idempotent and side-effect-free:
334
+ `strategy.yaml` (catalog + funding manifest), `main/runtime.yaml` (the complete runtime:
335
+ strategy/scanners/actions/**exit**/risk), `main/scanners/scan.py`,
336
+ `main/scanners/strategy_primitives/` (a vendored SNAPSHOT of `composer/primitives/`, a sibling
337
+ package of `scan.py`), `main/<id>.graph.yaml`, and `manifest.json` (hashing every artifact
338
+ including each vendored primitives file, with the runtime's Zod verdict). The emitted `scan.py`
339
+ imports `strategy_primitives`, NEVER `composer.*`, so the unit is self-contained: it runs on a
340
+ box that has none of the composer checkout — the scaffold puts the scanner dir on `sys.path` and
341
+ the vendored package resolves as a sibling of `scan.py`. The snapshot freezes the primitives at
342
+ deploy time (later `composer/primitives/` changes never mutate an already-staged strategy). There
343
+ is no host `runtime.yaml` to borrow — the staged unit is complete on its own. Install is `senpi-strategy-ops`:
344
+ it reads `strategy.yaml.instances[]`, mints one funded wallet per instance, and renders each
345
+ `runtime.yaml` onto its wallet. The composer never creates wallets, moves money, or installs.
346
+ - **`install` puts ONE instance of a staged unit onto a box — without touching the frozen runtime.**
347
+ `composer install <staged-dir> --wallet 0x…` (or a graph file plus `-o <staging-dir>`) is the box-side
348
+ step deploy stops short of. It (a) PREFLIGHTS — resolves the staged tree, verifies every artifact's
349
+ bytes against `manifest.json` (CMP201, tamper/hand-edit detection), and requires the recorded oracle
350
+ verdict to be GREEN (`VALID`; a `SKIPPED` verdict on an oracle-less box is allowed, `INVALID` is CMP202);
351
+ (b) COPIES the unit to a CONTENT-ADDRESSED, IMMUTABLE dir `<senpi-state>/strategies/<strategy>/<hash8>/`
352
+ — written to a temp sibling, every file re-verified against the manifest (CMP203), then atomic-renamed
353
+ into place, and NEVER edited after (so the frozen inline-YAML in the registry can't drift from the
354
+ scan.py re-read off disk on a scaffold respawn). `hash8` = the first 8 hex of a sha256 over the manifest
355
+ `artifacts` map, so identical staged content always maps to the same dir and a re-install is an idempotent
356
+ no-op; (c) BINDS the real `--wallet` for the `${..._WALLET}` placeholder in the COPIED `runtime.yaml` only
357
+ (the staged source stays pristine), records the binding in `install.json`, and re-runs the placeholder
358
+ policy — any remaining bare `${VAR}` after binding is CMP115; (d) INVOKES the FROZEN consumer,
359
+ `openclaw senpi runtime create -p <hashdir>/main/runtime.yaml` (PATH mode — never `--content`), behind an
360
+ injectable seam; (e) maps the known installRuntime failure classes (duplicate id CMP210,
361
+ one-runtime-per-wallet CMP211, wallet not ACTIVE CMP212, invalid YAML CMP213, entrypoint missing CMP214)
362
+ to teaching errors that name the exact next command. The runtime id is `<strategy>-<hash8>` (content-keyed,
363
+ so a duplicate is meaningful). Every step's completion is derivable from disk (staged tree / hash dir +
364
+ `install.json` / the box registry entry) — the verb is resumable-by-artifact, with no hidden session state.
365
+ The wallet is supplied by `--wallet`, or auto-bound from the `composer fund` `wallet.json` when `--wallet`
366
+ is omitted (see `fund`, below); the update/reinstall (delete+create) flow is OUT of scope (the sanctioned
367
+ teardown path is `composer close`, below).
368
+ - **`fund` creates + funds the wallet — inside the flow, with an EXPLICIT budget.** `composer fund
369
+ <staged-dir> --budget <usd>` is the funding step the composer used to hand off conversationally. `--budget`
370
+ is REQUIRED and EXPLICIT (≥ $100): agree it with the user and pass the exact amount. It is NEVER derived
371
+ from a `funding_share` nor split across wallets — that legacy ops derivation misfired on a real box
372
+ (it bridged in pieces and told a user with sufficient balance to top up), so this verb funds ONE wallet
373
+ with the ONE number you pass. Steps, resumable-by-artifact: (a) if `<senpi-state>/strategies/<strategy>/wallet.json`
374
+ exists (STRATEGY-level, ABOVE the `<hash8>` dirs — the wallet is the strategy's instance identity across
375
+ content versions), verify it via MCP `strategy_list` — ACTIVE → `FUNDED` no-op, still initializing →
376
+ `PENDING`, closed/gone → CMP231 (stale); (b) otherwise PREFLIGHT the visible balance via
377
+ `account_get_portfolio` (free Hyperliquid + free EVM USDC, summed as the backend funds a create) — short
378
+ of budget → CMP232 stating the current visible total, the exact shortfall, the bridged-funds VISIBILITY
379
+ LAG (funds moved HL→EVM can lag ~a minute; wait + re-run), and the top-up options (the composer NEVER
380
+ bridges); (c) sufficient → call MCP `strategy_create_custom_strategy` (`skillName` = the strategy name,
381
+ matching what `composer close`'s legacy discovery reads), record `wallet.json` BEFORE polling, then poll
382
+ briefly to ACTIVE. `strategy_create_custom_strategy` is a MONEY MUTATION: there is no `--yes` (the CLI is
383
+ agent-driven — the user's confirmation is conversational per the skill), but the verb prints an
384
+ unmistakable pre-action line (creating wallet, budget, funding source) in its result. Missing MCP creds
385
+ is a loud CMP223. Then `composer install` (omit `--wallet`) auto-binds the funded wallet.
386
+ - **`close` is the ONLY sanctioned teardown — never a raw `strategy_close`.** The invariant is
387
+ non-negotiable: STOP the runtime first (`openclaw senpi runtime delete <id>`), CONFIRM it is gone,
388
+ and ONLY THEN submit MCP `strategy_close(<strategyId>)` (which flattens ALL positions and returns
389
+ funds). A raw `strategy_close` with a live runtime STRANDS the runtime — it keeps trading a wallet the
390
+ server is tearing down. `composer close <target>` enforces this behind injectable seams: if the runtime
391
+ delete fails (CMP220) or the runtime is still present afterward (CMP221), `strategy_close` is WITHHELD
392
+ and the verb fails loud rather than strand or double-fire. It is IDEMPOTENT and SUBMIT-ONLY (no poll):
393
+ `strategy_close` is async, so the verb reports `CLOSING` and re-running it IS the poll — a re-run skips
394
+ the delete (runtime already gone) and skips the re-submit (strategy left the ACTIVE set), reporting
395
+ `CLOSED` once the strategy is terminal. It resolves the target from the composer's own on-box artifacts
396
+ first (a canonical hash dir's `install.json`, or a strategy name / runtime id under `--state-dir`), and
397
+ falls back to `strategy_list` attribution (skillName / strategyId + the wallet read from the strategy
398
+ record) so it also closes legacy ops-deployed strategies and orphan runtimes. Missing MCP creds
399
+ (`SENPI_API_KEY`) is a loud CMP223, not a silent skip.
400
+ - **`status --state-dir` is the resumable-by-artifact install chain.** With a box named, `status` walks
401
+ the current staged content DOWN onto the box entirely from artifacts + one read-only `runtime list`:
402
+ landed (canonical hash dir + `install.json`) → registered (`installed_runtimes.json`) → running. Multiple
403
+ hash dirs for one strategy are distinguished — which hash is installed/running vs a merely-landed prior
404
+ version. Liveness is reported HONESTLY: `runtime list` proves only that the process is up, so a running
405
+ install is annotated that functional liveness (scan() `runCount>0`, a fresh `lastRunFinishedAt`,
406
+ `consecutiveErrorCount==0` — see `senpi-strategy-ops/references/liveness-verification.md`) is NOT checked
407
+ here; run `openclaw senpi state -r <id> --json` for that. "running" ≠ "operating".
408
+
409
+ ## 8. Debugging generation deterministically
410
+
411
+ Generation (`new`/`check`/`deploy`) is fully offline-reproducible: no creds, no network, no
412
+ wall-clock — the same answers always produce the same bytes. So any "the composer generated the
413
+ wrong thing" report replays exactly. The procedure (proven on the first real dev-test incident):
414
+
415
+ 1. Reconstruct the user's answers file from the conversation (the graph's `metadata` block IS the
416
+ answers when you have the generated graph — spec-is-state).
417
+ 2. Drive the CLI into a scratch dir: `python -m composer.cli new --archetype <a> --answers <f> -o /tmp/repro`
418
+ (deps: pyyaml, pydantic, jinja2, jsonschema — a throwaway venv or `uvx` is enough).
419
+ 3. Diff the emitted graph against what the user agreed to — the delta names the lost intent.
420
+ 4. Read the archetype's `build()` in `composer/authoring/archetypes/` for where that intent was
421
+ dropped (a hardcoded literal, an unread answer field, a missing archetype route).
422
+
423
+ Classify before fixing: COVERAGE (no archetype+params expresses it) · EXPRESSIVENESS (the answers
424
+ surface can't say it) · ROUTING (wrong archetype chosen) · INSTRUCTION (the skill never told the
425
+ agent to pass it). Each has a different smallest fix — and only genuinely-missing SOURCE
426
+ capability (no data feed in the world) is a registry gap; everything else is composer-side.
427
+
428
+ **Maintainer note (source coverage).** The source vocabulary is kept EXHAUSTIVE over the scanner-
429
+ usable Senpi MCP read surface, and that claim is enforced continuously by the CI closure check
430
+ (`composer/registry/mcp_closure.py`, offline by default). If a new server-side read tool appears,
431
+ CI goes red until it is either mapped by a new source node (declaring `mcp_tool="..."`) or added to
432
+ the allowlist with a reason. To pick up a catalog change, run
433
+ `MCP_SNAPSHOT_REFRESH=1 pytest python-tests/composer/test_mcp_closure.py` (fetches the live
434
+ unauthenticated `tools/list`, rewrites `mcp-tools-snapshot.json`, and surfaces input-schema drift),
435
+ then commit the refreshed snapshot. Full design in `docs/composer/design-models.md` §9.
@@ -0,0 +1,29 @@
1
+ """composer — the agent-facing whole for building trading strategies from typed graphs.
2
+
3
+ The composer turns a chat-stated idea into a running, verified strategy with no dead
4
+ ends: the LLM decides (interview, edge logic) and the machine composes, validates,
5
+ compiles, deploys, verifies, and issues every success claim. It is the union of a
6
+ registry-generated catalog, a teaching validator, a deterministic compiler, a primitive
7
+ library, archetype templates, and a status CLI — never a DSL (the exit-DSL legibility
8
+ lesson stands). Authored artifacts are typed dataflow DAGs (YAML) of nodes; the compiler
9
+ emits straight-line Python onto the existing external_scanner contract. This package is
10
+ importable to tests and to emitted scanners via the scaffold ``sys.path`` (no packaging
11
+ in the POC); the ``senpi_ta`` packaging/versioning question stays open, post-POC.
12
+
13
+ Subpackages:
14
+ registry — pydantic node registry; the single source of truth (D4/D8).
15
+ validator — schema + graph typecheck with located, fix-hinted errors.
16
+ compiler — deterministic graph -> scan.py materializer (D5/D6/D8).
17
+ primitives — runtime-owned Python implementations of primitive nodes.
18
+ templates — per-node emission templates + archetype templates-as-code (D7).
19
+ cli — the ``composer`` verbs (new/check/deploy/status/catalog/describe).
20
+ """
21
+
22
+ __all__ = [
23
+ "registry",
24
+ "validator",
25
+ "compiler",
26
+ "primitives",
27
+ "templates",
28
+ "cli",
29
+ ]
@@ -0,0 +1,59 @@
1
+ """authoring — `composer new`, the machine-generated anchor (D7).
2
+
3
+ Typed interview answers in, a COMPLETE, VALID, COMPILING strategy graph out, with
4
+ mechanically-bindable values bound and bespoke slots TODO-marked WITH WORKING DEFAULTS so
5
+ the emitted graph validates and compiles before any edit (the anchoring principle: small
6
+ models edit a valid example far more reliably than they generate from a schema, and here the
7
+ anchor is machine-generated per-strategy rather than a copied file). The edge free-text is
8
+ carried verbatim as a comment above the scoring slot; the machine never interprets it.
9
+
10
+ Public API:
11
+ Answers / load_answers — the typed elicitation contract (file + flag overrides).
12
+ compose_new / NewResult — plan a `composer new` (gap check, build, no-clobber); no write.
13
+ detect_gaps / GapReport — feasibility at `new` time (D4 made mechanical).
14
+ ARCHETYPES — the CI-checked archetype templates (build(answers)->graph).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .answers import (
20
+ Answers,
21
+ Indicator,
22
+ Risk,
23
+ Scoring,
24
+ Universe,
25
+ load_answers,
26
+ load_answers_file,
27
+ )
28
+ from .archetypes import ARCHETYPES, SKELETON
29
+ from .gaps import Gap, GapReport, detect_gaps
30
+ from .generate import NewResult, compose_new, render_graph
31
+ from .presets import DEFAULT_PRESET, EXIT_PRESETS, ExitPreset, preset_names, resolve_guard_rails
32
+ from .runtime_emit import answers_from_metadata, render_runtime_yaml
33
+ from .strategy_manifest import render_strategy_yaml
34
+
35
+ __all__ = [
36
+ "Answers",
37
+ "Indicator",
38
+ "Risk",
39
+ "Scoring",
40
+ "Universe",
41
+ "load_answers",
42
+ "load_answers_file",
43
+ "ARCHETYPES",
44
+ "SKELETON",
45
+ "Gap",
46
+ "GapReport",
47
+ "detect_gaps",
48
+ "NewResult",
49
+ "compose_new",
50
+ "render_graph",
51
+ "EXIT_PRESETS",
52
+ "ExitPreset",
53
+ "DEFAULT_PRESET",
54
+ "preset_names",
55
+ "resolve_guard_rails",
56
+ "render_runtime_yaml",
57
+ "answers_from_metadata",
58
+ "render_strategy_yaml",
59
+ ]