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

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 (94) 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 +287 -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 +124 -0
  9. package/dist/composer/AUTHORING.md +233 -0
  10. package/dist/composer/__init__.py +29 -0
  11. package/dist/composer/authoring/__init__.py +49 -0
  12. package/dist/composer/authoring/answers.py +226 -0
  13. package/dist/composer/authoring/archetypes/__init__.py +25 -0
  14. package/dist/composer/authoring/archetypes/_common.py +218 -0
  15. package/dist/composer/authoring/archetypes/classifier.py +109 -0
  16. package/dist/composer/authoring/archetypes/skeleton.py +54 -0
  17. package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
  18. package/dist/composer/authoring/archetypes/trend_momentum.py +104 -0
  19. package/dist/composer/authoring/builder.py +191 -0
  20. package/dist/composer/authoring/emit.py +264 -0
  21. package/dist/composer/authoring/gaps.py +115 -0
  22. package/dist/composer/authoring/generate.py +131 -0
  23. package/dist/composer/authoring/presets.py +168 -0
  24. package/dist/composer/authoring/runtime_emit.py +266 -0
  25. package/dist/composer/authoring/strategy_manifest.py +94 -0
  26. package/dist/composer/cli.py +355 -0
  27. package/dist/composer/compiler/__init__.py +22 -0
  28. package/dist/composer/compiler/compile.py +550 -0
  29. package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
  30. package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
  31. package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
  32. package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
  33. package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
  34. package/dist/composer/generated/README.md +22 -0
  35. package/dist/composer/generated/catalog.md +84 -0
  36. package/dist/composer/generated/describe.md +703 -0
  37. package/dist/composer/generated/graph.schema.json +1203 -0
  38. package/dist/composer/primitives/__init__.py +239 -0
  39. package/dist/composer/primitives/_candles.py +76 -0
  40. package/dist/composer/primitives/adapter.py +120 -0
  41. package/dist/composer/primitives/emitters.py +142 -0
  42. package/dist/composer/primitives/filters.py +71 -0
  43. package/dist/composer/primitives/indicators.py +203 -0
  44. package/dist/composer/primitives/rankers.py +40 -0
  45. package/dist/composer/primitives/scorers.py +100 -0
  46. package/dist/composer/primitives/sources.py +405 -0
  47. package/dist/composer/primitives/stateful.py +52 -0
  48. package/dist/composer/pure_fn/__init__.py +34 -0
  49. package/dist/composer/pure_fn/checks.py +39 -0
  50. package/dist/composer/pure_fn/models.py +177 -0
  51. package/dist/composer/pure_fn/purity.py +311 -0
  52. package/dist/composer/pure_fn/runtests.py +155 -0
  53. package/dist/composer/registry/__init__.py +40 -0
  54. package/dist/composer/registry/generate.py +422 -0
  55. package/dist/composer/registry/models.py +331 -0
  56. package/dist/composer/registry/nodes.py +673 -0
  57. package/dist/composer/requirements.txt +21 -0
  58. package/dist/composer/templates/__init__.py +13 -0
  59. package/dist/composer/templates/scan.py.j2 +31 -0
  60. package/dist/composer/validator/__init__.py +29 -0
  61. package/dist/composer/validator/desugar.py +133 -0
  62. package/dist/composer/validator/errors.py +191 -0
  63. package/dist/composer/validator/graph_checks.py +348 -0
  64. package/dist/composer/validator/loader.py +110 -0
  65. package/dist/composer/validator/schema_checks.py +147 -0
  66. package/dist/composer/validator/validate.py +86 -0
  67. package/dist/composer/verbs/__init__.py +33 -0
  68. package/dist/composer/verbs/check.py +193 -0
  69. package/dist/composer/verbs/deploy.py +223 -0
  70. package/dist/composer/verbs/docs.py +30 -0
  71. package/dist/composer/verbs/runtime_validate.py +262 -0
  72. package/dist/composer/verbs/smoke.py +115 -0
  73. package/dist/composer/verbs/status.py +205 -0
  74. package/dist/index.d.ts.map +1 -1
  75. package/dist/index.js +2 -9
  76. package/dist/index.js.map +1 -1
  77. package/dist/runtime-api/config.d.ts +2 -2
  78. package/dist/runtime-api/config.js +4 -4
  79. package/dist/runtime-api/config.js.map +1 -1
  80. package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
  81. package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
  82. package/dist/runtime-api/handlers/ingest-signals.js +5 -14
  83. package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
  84. package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
  85. package/dist/runtime-api/intake/intake-mount.js +2 -8
  86. package/dist/runtime-api/intake/intake-mount.js.map +1 -1
  87. package/dist/skills-manager/manifest.js +1 -1
  88. package/dist/skills-manager/manifest.js.map +1 -1
  89. package/dist/skills-manager/skills-manifest.json +1 -1
  90. package/package.json +4 -2
  91. package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
  92. package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
  93. package/dist/runtime-api/intake/ingest-mutex.js +0 -18
  94. package/dist/runtime-api/intake/ingest-mutex.js.map +0 -1
@@ -0,0 +1,233 @@
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
+ ## 3. Verb reference
52
+
53
+ Every verb takes `--json` for machine-readable output; omit it for a human-readable
54
+ report.
55
+
56
+ | Verb | What it does | Example |
57
+ |---|---|---|
58
+ | `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` |
59
+ | `validate` | Schema + graph-semantic teaching-error report (exit 0 iff clean). | `... python -m composer.cli validate drafts/my_strat.graph.yaml` |
60
+ | `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` |
61
+ | `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` |
62
+ | `deploy` | Stage the COMPLETE standalone unit as a package tree (`strategy.yaml`, `main/runtime.yaml`, `main/scanners/scan.py`, `main/<id>.graph.yaml`, `manifest.json`, `deploy.json`); HARD-gated on a GREEN `check`. Does not install. | `... python -m composer.cli deploy drafts/my_strat.graph.yaml -o drafts/dist` |
63
+ | `status` | Derives your lifecycle position (`absent` / `invalid` / `valid_undeployed` / `deployed_stale` / `deployed_current`) from the file + check + staged manifest, and names the next verb. No journal — the file is the state. | `... python -m composer.cli status drafts/my_strat.graph.yaml -o drafts/dist` |
64
+ | `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` |
65
+ | `describe` | Full ports/params/example/cost for ONE node — level 2, on-demand deep-dive. | `... python -m composer.cli describe candles_fetch` |
66
+
67
+ When in doubt about what a node actually does or which ports it exposes, run `describe`
68
+ rather than guessing from the catalog one-liner.
69
+
70
+ ## 4. The answers-file format
71
+
72
+ The answers file is the interview output: it captures the 7 decisions that are the
73
+ type-signature of `scan()` — archetype, universe, intervals, cardinality, memory
74
+ (dedup TTL), risk knobs, and the EDGE (free text, carried verbatim; the machine never
75
+ interprets it) — plus any `requires:` capabilities. Full field definitions live in
76
+ `composer/authoring/answers.py`; the shape:
77
+
78
+ ```yaml
79
+ archetype: trend_momentum # trend_momentum | thesis_fund | classifier
80
+ strategy: my_strat # short id -> <id>.graph.yaml
81
+ universe:
82
+ whitelist: ["BTC", "ETH", "SOL"] # fixed asset list (trend_momentum)
83
+ long_basket: [] # thesis_fund LONG leg (use instead of whitelist)
84
+ short_basket: [] # thesis_fund SHORT leg
85
+ discover: false # classifier: derive the universe from the live board
86
+ dex: "" # classifier discovery: venue filter
87
+ name_prefix: "" # classifier discovery: instrument name prefix
88
+ max_leverage_cap: 5.0 # classifier discovery: leverage ceiling
89
+ min_day_volume: 0.0 # classifier discovery: daily-volume floor
90
+ intervals: ["4h"] # candle intervals, e.g. ["1h", "4h"]
91
+ max_positions: 1 # 1 = single best; >1 = rank a pool, cap to open slots
92
+ ttl_seconds: 240 # signal-dedup memory window
93
+ exit_preset: null # LIVE: protected_standard | protected_tight | protected_wide.
94
+ # null -> `composer new` BINDS protected_standard (PROTECTION BY DEFAULT).
95
+ unmanaged: false # explicit opt-out of exit protection; without it, an exit-less unit is RED (CMP120)
96
+ risk:
97
+ margin_pct: 5.0 # PERCENT of withdrawable (0,100], never a fraction/USD
98
+ 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
99
+ drawdown_halt_pct: null # guard rail: halt the strategy at this equity drawdown % (else preset default)
100
+ max_consecutive_losses: null # guard rail: pause after N losses
101
+ cooldown_seconds: null # guard rail: post-loss cooldown seconds (>= 60)
102
+ max_entries_per_day: null # guard rail: per-day entry cap (>= 1)
103
+ edge: >
104
+ Your edge in plain words — the actual thesis (e.g. "ride assets breaking to fresh highs
105
+ with accelerating momentum, volume confirmation, and smart money leaning the same way").
106
+ This is carried verbatim into the graph; nothing parses it, so be as specific as you want.
107
+ requires: [] # [{name, kind, need}] — a capability that may not exist yet;
108
+ # a name the registry lacks becomes a gap report, nothing written
109
+ ```
110
+
111
+ **Universe modes**: `whitelist` is a fixed list you already know (trend_momentum,
112
+ trend_conviction shapes). `long_basket`/`short_basket` are the two legs of a fixed macro
113
+ basket (thesis_fund — a relative-value long/short). `discover: true` (+ `dex`,
114
+ `name_prefix`, `max_leverage_cap`, `min_day_volume`) derives the tradeable set each tick
115
+ from the live instrument board by a signature instead of a fixed list (classifier).
116
+
117
+ An `archetype` the composer doesn't recognize (i.e. not `trend_momentum` /
118
+ `thesis_fund` / `classifier`) falls back to the **off-map skeleton** — still a valid,
119
+ generated starting graph, just minimal; expect to do more hand-editing from there.
120
+
121
+ Get a blank skeleton for any archetype with `composer describe`-adjacent tooling, or just
122
+ copy the block above and strip fields you don't need — every field has a sane default via
123
+ `Answers()`, so a short file (just `archetype`, `strategy`, `universe`, `edge`) is enough
124
+ to get started.
125
+
126
+ ## 5. Editing the generated graph
127
+
128
+ `composer new` writes `# TODO(edit): ...` comments at every slot that needs a real
129
+ decision (already filled with a working default so the graph validates unedited) — these
130
+ are the minimum you should look at, not the limit of what you may change. You have full
131
+ edit rights over the whole graph: add/remove nodes, rewire edges, retune any param.
132
+
133
+ Two ways to express bespoke logic:
134
+
135
+ - **Registry nodes** (`node: <name>`, see `catalog`) — parameterized, pre-built pipeline
136
+ steps: sources, indicators, filters, scorers, rankers, the emitter, and the
137
+ `ttl_dedup_state` stateful node. Wire per-asset features into a scorer's `features`
138
+ input to MERGE them (keyed by producing node id).
139
+ - **`kind: pure_fn` nodes** — how you write bespoke scoring/classification math the
140
+ registry vocabulary can't express (e.g. the cross-feature conviction waterfall in
141
+ `composer/examples/conviction_scorer.graph.yaml`, corpus_origin: bison). Rules, statically
142
+ enforced before anything runs:
143
+ - declare typed `inputs:` and `outputs:` (and optional `params:`) — no merge record, each
144
+ input is a single wired writer;
145
+ - the `fn:` block is **exactly one** synchronous top-level `def`, whose signature is
146
+ exactly the declared inputs then params, in order;
147
+ - only `math` and `statistics` may be imported; only a small safe-builtin set (numeric/
148
+ collection work) is allowed — no I/O, no `eval`/`exec`/`open`, no `ctx`/MCP access, no
149
+ dunder/reflection escapes, no `global`/`nonlocal`;
150
+ - it must `return` exactly its declared outputs;
151
+ - include `tests:` (inline fixture cases: `inputs:` → `expect:`) — these run as part of
152
+ `composer check`'s `pure_fn_tests` stage.
153
+
154
+ **Teaching errors are actionable, not decorative.** Every error from `validate`/`check`
155
+ carries a stable code (`CMPxxx`), a location (node id / port / line), what's wrong in one
156
+ sentence, and a concrete fix hint — a name to use, a bound to respect, or a command to
157
+ run. When a hint names a `describe <node>` command, run it before guessing; when it names
158
+ the nearest valid node/param, use that name rather than inventing one. Fix the errors it
159
+ names, re-run `check`, repeat until GREEN — don't rewrite unrelated parts of the graph.
160
+
161
+ ## 6. Worked micro-example
162
+
163
+ Idea: *"Long BTC and ETH when 4h price is above its 200-period average and 1h RSI is not
164
+ overbought; take the single best signal, moderate risk."*
165
+
166
+ `drafts/momo_micro.answers.yaml`:
167
+
168
+ ```yaml
169
+ archetype: trend_momentum
170
+ strategy: momo_micro
171
+ universe:
172
+ whitelist: ["BTC", "ETH"]
173
+ intervals: ["1h", "4h"]
174
+ max_positions: 1
175
+ ttl_seconds: 240
176
+ risk:
177
+ margin_pct: 5.0
178
+ leverage: 3.0
179
+ edge: >
180
+ Go long BTC/ETH when 4h price is above its 200-period SMA and 1h RSI is not overbought
181
+ (< 70) — trend confirmation with a momentum-exhaustion guard.
182
+ ```
183
+
184
+ Three commands:
185
+
186
+ ```bash
187
+ uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli \
188
+ new momo_micro --archetype trend_momentum --answers drafts/momo_micro.answers.yaml -o drafts
189
+
190
+ uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli \
191
+ check drafts/momo_micro.graph.yaml
192
+
193
+ uvx --with pydantic --with jinja2 --with pyyaml --with jsonschema python -m composer.cli \
194
+ deploy drafts/momo_micro.graph.yaml -o drafts/dist
195
+ ```
196
+
197
+ After `new`, open `drafts/momo_micro.graph.yaml` and adjust the `TODO(edit)` gate/scorer
198
+ slot to actually express "above SMA-200 AND RSI < 70" (the generated default is a working
199
+ placeholder, not necessarily your exact edge) — the `sma` and `rsi` indicator nodes and a
200
+ `hard_gates_all` scorer (see `composer/examples/trend_momentum_majors.graph.yaml` for the
201
+ pattern) are
202
+ what you'd wire in. Re-run `check` after each edit until GREEN.
203
+
204
+ ## 7. Honest boundaries
205
+
206
+ - **Gap reports, not silent failures.** If the answers (or an edit) reference a data
207
+ source or capability the registry doesn't have — named under `requires:`, or a
208
+ hand-authored node type that doesn't exist — `composer new`/`check` will not fabricate
209
+ it. `new` prints a structured gap report (what was asked for, what kind, why, and the
210
+ nearest registry alternatives) and writes nothing. That's the correct outcome for
211
+ something not yet buildable; the fix is adding the capability to the registry, not
212
+ working around it in the graph.
213
+ - **Ambiguous ideas need answers first, not invented specifics.** If a brief is missing
214
+ information you can't responsibly guess (which assets, long/short/both, how much risk),
215
+ ask the user rather than pick something plausible-sounding. Pin down the answers file,
216
+ then generate.
217
+ - **Protection by default.** Every strategy the composer emits is a COMPLETE standalone
218
+ runtime with a DSL `exit:` section (the two-phase trailing stop / time-cuts) — protection
219
+ is ON unless you deliberately pass `unmanaged: true`. The runtime schema legally allows an
220
+ exit-less config, so the composer enforces protection itself: an exit-less unit with no
221
+ `unmanaged` opt-out is RED at `check` (CMP120), and opting out prints a loud warning. Pick a
222
+ preset by shape — `protected_standard` (balanced, default), `protected_tight` (cut failures
223
+ fast), `protected_wide` (let a runner breathe). Tune only the `# TODO(edit)` slots
224
+ (phase-2 tier ladder, `max_loss_pct`) in the emitted `runtime.yaml`.
225
+ - **`deploy` stages a COMPLETE unit; it does not install.** `composer deploy` is hard-gated on
226
+ a GREEN `check` (which now includes the `runtime_validate` stage — the real Zod + DSL oracle)
227
+ and stages a standalone strategy PACKAGE TREE, idempotent and side-effect-free:
228
+ `strategy.yaml` (catalog + funding manifest), `main/runtime.yaml` (the complete runtime:
229
+ strategy/scanners/actions/**exit**/risk), `main/scanners/scan.py`, `main/<id>.graph.yaml`, and
230
+ `manifest.json` (hashing all three, with the runtime's Zod verdict). There is no host
231
+ `runtime.yaml` to borrow — the staged unit is complete on its own. Install is `senpi-strategy-ops`:
232
+ it reads `strategy.yaml.instances[]`, mints one funded wallet per instance, and renders each
233
+ `runtime.yaml` onto its wallet. The composer never creates wallets, moves money, or installs.
@@ -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,49 @@
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 Answers, Risk, Universe, load_answers, load_answers_file
20
+ from .archetypes import ARCHETYPES, SKELETON
21
+ from .gaps import Gap, GapReport, detect_gaps
22
+ from .generate import NewResult, compose_new, render_graph
23
+ from .presets import DEFAULT_PRESET, EXIT_PRESETS, ExitPreset, preset_names, resolve_guard_rails
24
+ from .runtime_emit import answers_from_metadata, render_runtime_yaml
25
+ from .strategy_manifest import render_strategy_yaml
26
+
27
+ __all__ = [
28
+ "Answers",
29
+ "Risk",
30
+ "Universe",
31
+ "load_answers",
32
+ "load_answers_file",
33
+ "ARCHETYPES",
34
+ "SKELETON",
35
+ "Gap",
36
+ "GapReport",
37
+ "detect_gaps",
38
+ "NewResult",
39
+ "compose_new",
40
+ "render_graph",
41
+ "EXIT_PRESETS",
42
+ "ExitPreset",
43
+ "DEFAULT_PRESET",
44
+ "preset_names",
45
+ "resolve_guard_rails",
46
+ "render_runtime_yaml",
47
+ "answers_from_metadata",
48
+ "render_strategy_yaml",
49
+ ]
@@ -0,0 +1,226 @@
1
+ """answers — the typed interview-answers object (D7 step 1; the elicitation contract).
2
+
3
+ The 7 decisions are the *type signature of* ``scan(...)`` (HLD "Elicitation"): archetype,
4
+ universe, intervals, cardinality, memory, exit preset, and risk knobs, plus the user's
5
+ EDGE carried verbatim as free text the machine never interprets. This module is the typed
6
+ form of that contract. It is deliberately entry-mode agnostic — a structured authoring
7
+ session and an ambient "make a strategy from this" both land here; ``composer new`` does
8
+ not care where the answers came from.
9
+
10
+ Two acceptance paths, one contract (D7):
11
+ * an answers-file (YAML or JSON) parsed into a dict, and/or
12
+ * individual CLI flags that OVERRIDE the file field-by-field.
13
+ Both feed :func:`load_answers`, which deep-merges (flags win) and constructs :class:`Answers`.
14
+
15
+ Answer domains are registry-derived where possible (D4): a strategy that names a source or
16
+ capability the registry does not have is not a crash — it is turned into a structured GAP
17
+ REPORT before anything is written (see ``composer.authoring.gaps``). The
18
+ :attr:`Answers.requires` field is how an answer references registry capability names: each
19
+ entry is checked against the registry and, if absent, named in the gap report.
20
+
21
+ Exit / protection (slice 9 — PROTECTION BY DEFAULT). ``exit_preset`` is now LIVE (default
22
+ ``protected_standard``): the composer emits a complete standalone ``runtime.yaml`` whose
23
+ ``exit:`` section is the resolved preset (``composer.authoring.presets``). ``unmanaged=True``
24
+ is the explicit, deliberate opt-out (no exit section); leaving it False without an exit is a
25
+ RED check (CMP120). The ``Risk`` model also carries the guard-rail / pause knobs the composer
26
+ owns (drawdown halt, post-loss cooldown, entry cap) — declarative ``runtime.yaml`` fields the
27
+ runtime enforces (design §4).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ from pathlib import Path
34
+ from typing import Any, Optional
35
+
36
+ import yaml
37
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
38
+
39
+
40
+ class Risk(BaseModel):
41
+ """Sizing knobs + the guard-rail / pause knobs the composer owns (D7 step 3; design §4).
42
+
43
+ ``margin_pct``/``leverage`` size the emitted signal. The four pause knobs are optional
44
+ overrides onto the preset's ``risk.guard_rails`` default — ``None`` means "use the
45
+ preset default" (``composer.authoring.presets.resolve_guard_rails``). These are
46
+ declarative ``runtime.yaml`` fields the runtime enforces (auto-halt / post-loss cooldown /
47
+ per-day entry cap); a LIVE operational pause (stopping the box) is an ops action, not the
48
+ composer's (design §4)."""
49
+
50
+ model_config = ConfigDict(extra="forbid")
51
+
52
+ margin_pct: float = 5.0 # PERCENT of withdrawable, (0,100] (never a fraction/USD)
53
+ leverage: float = 3.0
54
+ # guard_rails overrides (None -> preset default); see presets.resolve_guard_rails.
55
+ drawdown_halt_pct: Optional[float] = None # equity-drawdown circuit breaker (halt %)
56
+ max_consecutive_losses: Optional[int] = None # stop after N losses
57
+ cooldown_seconds: Optional[int] = None # post-loss cooldown (>= 60)
58
+ max_entries_per_day: Optional[int] = None # per-day entry cap (>= 1)
59
+
60
+
61
+ class Universe(BaseModel):
62
+ """How the tradeable set is chosen. A superset of the corpus shapes; each archetype
63
+ reads the fields it needs (D4 — the answer stays declarative, the template binds it):
64
+
65
+ * ``whitelist`` — a fixed asset list (stag/hawk/bison shape).
66
+ * ``long_basket``/``short_basket`` — the two legs of a fixed macro basket (thesis-fund).
67
+ * ``discover`` (+ params) — derive the universe each tick from the live instrument
68
+ board by a funding/leverage/volume signature (magpie).
69
+ """
70
+
71
+ model_config = ConfigDict(extra="forbid")
72
+
73
+ whitelist: tuple[str, ...] = ()
74
+ long_basket: tuple[str, ...] = ()
75
+ short_basket: tuple[str, ...] = ()
76
+ discover: bool = False
77
+ dex: str = ""
78
+ name_prefix: str = ""
79
+ max_leverage_cap: float = 5.0
80
+ min_day_volume: float = 0.0
81
+
82
+
83
+ class RequiredCapability(BaseModel):
84
+ """A registry capability an answer says the strategy needs (D4 feasibility surface).
85
+
86
+ ``name`` is matched against the registry; a name the registry lacks becomes a gap-report
87
+ entry. ``kind`` and ``need`` are carried into the report so the boundary reads honestly
88
+ ("``top_traders_discovery`` (source): rank traders by realized PnL") rather than as a
89
+ bare missing symbol.
90
+ """
91
+
92
+ model_config = ConfigDict(extra="forbid")
93
+
94
+ name: str
95
+ kind: str = "node"
96
+ need: str = ""
97
+
98
+
99
+ class Answers(BaseModel):
100
+ """The typed interview answers — a COMPLETE, VALID graph's input (D7 step 1)."""
101
+
102
+ model_config = ConfigDict(extra="forbid")
103
+
104
+ archetype: str
105
+ strategy: str = "unnamed"
106
+ universe: Universe = Field(default_factory=Universe)
107
+ intervals: tuple[str, ...] = ("4h",)
108
+ max_positions: int = 1 # cardinality -> ranker choice (single-best vs pool-cap)
109
+ ttl_seconds: int = 240 # memory -> ttl_dedup_state dedup window
110
+ # LIVE: emitted as the runtime.yaml exit: section. Default None = "unset"; `composer new`
111
+ # BINDS protected_standard unless overridden (protection-by-default). A graph reaching
112
+ # `check` with exit_preset unset AND unmanaged False is the RED CMP120 contradiction.
113
+ exit_preset: Optional[str] = None
114
+ unmanaged: bool = False # explicit opt-out: no exit section (else CMP120 RED at check)
115
+ risk: Risk = Field(default_factory=Risk)
116
+ edge: str = "" # the user's EDGE, verbatim; the machine never interprets it
117
+ requires: tuple[RequiredCapability, ...] = ()
118
+
119
+ @field_validator("exit_preset")
120
+ @classmethod
121
+ def _known_preset(cls, value: Optional[str]) -> Optional[str]:
122
+ """Reject an unknown exit preset early (a typo, not a crash). ``None`` is allowed (unset
123
+ -> `composer new` binds the default, or `check` flags CMP120). Names live in
124
+ ``composer.authoring.presets.EXIT_PRESETS`` (protection-by-default policy)."""
125
+ if value is None:
126
+ return None
127
+ from .presets import EXIT_PRESETS # local import: presets is answers-independent
128
+ if value not in EXIT_PRESETS:
129
+ raise ValueError(
130
+ f"unknown exit_preset {value!r}; choose one of {sorted(EXIT_PRESETS)} "
131
+ f"(or pass unmanaged=true to deliberately run with NO exit protection)."
132
+ )
133
+ return value
134
+
135
+ @field_validator("requires", mode="before")
136
+ @classmethod
137
+ def _coerce_requires(cls, value: Any) -> Any:
138
+ """Accept `requires:` entries as bare strings or full mappings."""
139
+ if value is None:
140
+ return ()
141
+ out = []
142
+ for item in value:
143
+ if isinstance(item, str):
144
+ out.append({"name": item})
145
+ else:
146
+ out.append(item)
147
+ return out
148
+
149
+ def metadata_block(self) -> dict:
150
+ """The answers as an ordered plain-dict for the emitted graph's metadata (D7:
151
+ the spec file IS the state; interview answers live in the metadata block)."""
152
+ return {
153
+ "archetype": self.archetype,
154
+ "strategy": self.strategy,
155
+ "universe": {
156
+ "whitelist": list(self.universe.whitelist),
157
+ "long_basket": list(self.universe.long_basket),
158
+ "short_basket": list(self.universe.short_basket),
159
+ "discover": self.universe.discover,
160
+ "dex": self.universe.dex,
161
+ "name_prefix": self.universe.name_prefix,
162
+ "max_leverage_cap": self.universe.max_leverage_cap,
163
+ "min_day_volume": self.universe.min_day_volume,
164
+ },
165
+ "intervals": list(self.intervals),
166
+ "max_positions": self.max_positions,
167
+ "ttl_seconds": self.ttl_seconds,
168
+ "exit_preset": self.exit_preset, # LIVE: the emitted runtime.yaml exit preset
169
+ "unmanaged": self.unmanaged, # explicit protection opt-out
170
+ "risk": {
171
+ "margin_pct": self.risk.margin_pct,
172
+ "leverage": self.risk.leverage,
173
+ "drawdown_halt_pct": self.risk.drawdown_halt_pct,
174
+ "max_consecutive_losses": self.risk.max_consecutive_losses,
175
+ "cooldown_seconds": self.risk.cooldown_seconds,
176
+ "max_entries_per_day": self.risk.max_entries_per_day,
177
+ },
178
+ "edge": self.edge,
179
+ "requires": [
180
+ {"name": c.name, "kind": c.kind, "need": c.need} for c in self.requires
181
+ ],
182
+ }
183
+
184
+
185
+ def _deep_merge(base: dict, over: dict) -> dict:
186
+ """Field-by-field merge (``over`` wins); nested dicts merge, scalars/lists replace."""
187
+ out = dict(base)
188
+ for key, val in over.items():
189
+ if isinstance(val, dict) and isinstance(out.get(key), dict):
190
+ out[key] = _deep_merge(out[key], val)
191
+ else:
192
+ out[key] = val
193
+ return out
194
+
195
+
196
+ def load_answers_file(path: str | Path) -> dict:
197
+ """Parse a YAML or JSON answers-file into a plain dict (raises on non-mapping)."""
198
+ text = Path(path).read_text(encoding="utf-8")
199
+ data = yaml.safe_load(text) # YAML is a JSON superset, so this reads both (D6)
200
+ if data is None:
201
+ return {}
202
+ if not isinstance(data, dict):
203
+ raise ValueError(f"answers file {path} must be a mapping, got {type(data).__name__}")
204
+ return data
205
+
206
+
207
+ def load_answers(
208
+ file_data: Optional[dict] = None,
209
+ overrides: Optional[dict] = None,
210
+ ) -> Answers:
211
+ """Build :class:`Answers` from an optional answers-file dict + CLI-flag overrides.
212
+
213
+ Overrides win field-by-field (D7: "individual flag overrides"). Unknown keys raise a
214
+ pydantic ``ValidationError`` (``extra="forbid"``) so a typo is caught, not silently
215
+ dropped.
216
+ """
217
+ merged = _deep_merge(file_data or {}, overrides or {})
218
+ return Answers(**merged)
219
+
220
+
221
+ def dumps_answers_template(archetype: str) -> str:
222
+ """A blank answers-file skeleton for an archetype (used by `composer new --help`-style
223
+ scaffolding and the docs); deterministic."""
224
+ return yaml.safe_dump(
225
+ Answers(archetype=archetype).metadata_block(), sort_keys=False
226
+ )
@@ -0,0 +1,25 @@
1
+ """archetypes — the CI-checked archetype templates (D7 step 2; HLD "templates are derived").
2
+
3
+ Each archetype is CODE that builds a graph from the same registry types (see
4
+ ``composer.authoring.builder``), so a template that names a nonexistent node/port fails at
5
+ construction — the CI guarantee that templates cannot drift from the vocabulary. Only the
6
+ archetypes the POC graphs need are here (trend_momentum / classifier / thesis_fund); an
7
+ unrecognised archetype falls to the minimal ``skeleton`` (D7 step 5), which is NOT in
8
+ ``ARCHETYPES`` because "no matching archetype" is its trigger.
9
+
10
+ Each module exposes ``build(answers) -> GraphBuilder`` and a ``BANNER`` header.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from . import classifier, skeleton, thesis_fund, trend_momentum
16
+
17
+ ARCHETYPES = {
18
+ "trend_momentum": trend_momentum,
19
+ "classifier": classifier,
20
+ "thesis_fund": thesis_fund,
21
+ }
22
+
23
+ SKELETON = skeleton
24
+
25
+ __all__ = ["ARCHETYPES", "SKELETON"]