@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,297 @@
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 Scoring(BaseModel):
84
+ """OPTIONAL re-selection of the scorer's hard gates / weighted contributors / floor (F1).
85
+
86
+ The scoring surface lets the answers move the hard-vs-weighted split AMONG the graph's
87
+ feature nodes without hand-editing the emitted graph. Every field is ``None`` = "keep the
88
+ archetype's working default"; a set field OVERRIDES it (D7 "answers override else current
89
+ defaults"). Names are SOURCE NODE IDS — the same keys the scorer merges its ``features`` by
90
+ (e.g. ``breakout_detect`` wired as ``brk`` is referenced as ``"brk"``). A name matching no
91
+ feature node in the graph is a generation error (``archetypes._common.resolve_scoring``),
92
+ never a silent drop. Absent entirely (the default), the emitted graph is byte-identical to
93
+ the pre-F1 output."""
94
+
95
+ model_config = ConfigDict(extra="forbid")
96
+
97
+ required_gates: Optional[tuple[str, ...]] = None # feature ids that must ALL be truthy
98
+ weights: Optional[dict[str, float]] = None # feature id -> weighted-sum weight
99
+ min_score: Optional[float] = None # minimum score to emit
100
+
101
+
102
+ class Indicator(BaseModel):
103
+ """An ADDITIONAL registry indicator node the answers ask the archetype to add and wire (F1).
104
+
105
+ Each entry adds a real registry node (``node``) under ``id``, feeds it the graph's candles,
106
+ and merges its feature output into the scorer's ``features`` — so the id becomes addressable
107
+ from :class:`Scoring` (as a gate or a weight). ``with`` carries the node's params; ``port``
108
+ names which output feeds the scorer (defaults to the node's sole output). A ``node`` the
109
+ registry lacks — or a ``port`` the node does not declare — is a generation error, never a
110
+ silent drop. This is how an answer requests, e.g., ``breakout_detect`` with ``window: 168``
111
+ on an archetype whose fixed indicator set does not already carry it.
112
+
113
+ ``source`` (optional, C1b) CHAINS this indicator's input off another feature node instead
114
+ of the graph's candles: when set, the node's data input wires from ``source``'s output
115
+ (``source`` names an EARLIER ``indicators`` entry's id or a built-in feature node of the
116
+ archetype), with the port types checked against the registry — a mismatch or an unknown
117
+ ``source`` is a generation error. This is what lets a ``threshold_gate`` read a raw
118
+ ``recent_high_bars_ago`` output (``fresh = threshold_gate(op: lte, value: 5, source:
119
+ fresh_raw)``) — a thresholded gate, not the inverted truthiness gate the raw feature had."""
120
+
121
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
122
+
123
+ id: str
124
+ node: str
125
+ with_: dict[str, Any] = Field(default_factory=dict, alias="with")
126
+ port: Optional[str] = None
127
+ source: Optional[str] = None
128
+
129
+
130
+ class RequiredCapability(BaseModel):
131
+ """A registry capability an answer says the strategy needs (D4 feasibility surface).
132
+
133
+ ``name`` is matched against the registry; a name the registry lacks becomes a gap-report
134
+ entry. ``kind`` and ``need`` are carried into the report so the boundary reads honestly
135
+ ("``top_traders_discovery`` (source): rank traders by realized PnL") rather than as a
136
+ bare missing symbol.
137
+ """
138
+
139
+ model_config = ConfigDict(extra="forbid")
140
+
141
+ name: str
142
+ kind: str = "node"
143
+ need: str = ""
144
+
145
+
146
+ class Answers(BaseModel):
147
+ """The typed interview answers — a COMPLETE, VALID graph's input (D7 step 1)."""
148
+
149
+ model_config = ConfigDict(extra="forbid")
150
+
151
+ archetype: str
152
+ strategy: str = "unnamed"
153
+ universe: Universe = Field(default_factory=Universe)
154
+ intervals: tuple[str, ...] = ("4h",)
155
+ max_positions: int = 1 # cardinality -> ranker choice (single-best vs pool-cap)
156
+ ttl_seconds: int = 240 # memory -> ttl_dedup_state dedup window
157
+ # LIVE: emitted as the runtime.yaml exit: section. Default None = "unset"; `composer new`
158
+ # BINDS protected_standard unless overridden (protection-by-default). A graph reaching
159
+ # `check` with exit_preset unset AND unmanaged False is the RED CMP120 contradiction.
160
+ exit_preset: Optional[str] = None
161
+ unmanaged: bool = False # explicit opt-out: no exit section (else CMP120 RED at check)
162
+ risk: Risk = Field(default_factory=Risk)
163
+ edge: str = "" # the user's EDGE, verbatim; the machine never interprets it
164
+ requires: tuple[RequiredCapability, ...] = ()
165
+ # F1 — OPTIONAL expressiveness surface. Absent (the defaults below) => byte-identical to
166
+ # the pre-F1 output; present => the archetype re-selects gates/weights (scoring) and adds
167
+ # registry indicator nodes (indicators) mechanically, no hand edit. See the two submodels.
168
+ scoring: Optional[Scoring] = None
169
+ indicators: tuple[Indicator, ...] = ()
170
+
171
+ @field_validator("exit_preset")
172
+ @classmethod
173
+ def _known_preset(cls, value: Optional[str]) -> Optional[str]:
174
+ """Reject an unknown exit preset early (a typo, not a crash). ``None`` is allowed (unset
175
+ -> `composer new` binds the default, or `check` flags CMP120). Names live in
176
+ ``composer.authoring.presets.EXIT_PRESETS`` (protection-by-default policy)."""
177
+ if value is None:
178
+ return None
179
+ from .presets import EXIT_PRESETS # local import: presets is answers-independent
180
+ if value not in EXIT_PRESETS:
181
+ raise ValueError(
182
+ f"unknown exit_preset {value!r}; choose one of {sorted(EXIT_PRESETS)} "
183
+ f"(or pass unmanaged=true to deliberately run with NO exit protection)."
184
+ )
185
+ return value
186
+
187
+ @field_validator("requires", mode="before")
188
+ @classmethod
189
+ def _coerce_requires(cls, value: Any) -> Any:
190
+ """Accept `requires:` entries as bare strings or full mappings."""
191
+ if value is None:
192
+ return ()
193
+ out = []
194
+ for item in value:
195
+ if isinstance(item, str):
196
+ out.append({"name": item})
197
+ else:
198
+ out.append(item)
199
+ return out
200
+
201
+ def metadata_block(self) -> dict:
202
+ """The answers as an ordered plain-dict for the emitted graph's metadata (D7:
203
+ the spec file IS the state; interview answers live in the metadata block)."""
204
+ block: dict = {
205
+ "archetype": self.archetype,
206
+ "strategy": self.strategy,
207
+ "universe": {
208
+ "whitelist": list(self.universe.whitelist),
209
+ "long_basket": list(self.universe.long_basket),
210
+ "short_basket": list(self.universe.short_basket),
211
+ "discover": self.universe.discover,
212
+ "dex": self.universe.dex,
213
+ "name_prefix": self.universe.name_prefix,
214
+ "max_leverage_cap": self.universe.max_leverage_cap,
215
+ "min_day_volume": self.universe.min_day_volume,
216
+ },
217
+ "intervals": list(self.intervals),
218
+ "max_positions": self.max_positions,
219
+ "ttl_seconds": self.ttl_seconds,
220
+ "exit_preset": self.exit_preset, # LIVE: the emitted runtime.yaml exit preset
221
+ "unmanaged": self.unmanaged, # explicit protection opt-out
222
+ "risk": {
223
+ "margin_pct": self.risk.margin_pct,
224
+ "leverage": self.risk.leverage,
225
+ "drawdown_halt_pct": self.risk.drawdown_halt_pct,
226
+ "max_consecutive_losses": self.risk.max_consecutive_losses,
227
+ "cooldown_seconds": self.risk.cooldown_seconds,
228
+ "max_entries_per_day": self.risk.max_entries_per_day,
229
+ },
230
+ "edge": self.edge,
231
+ "requires": [
232
+ {"name": c.name, "kind": c.kind, "need": c.need} for c in self.requires
233
+ ],
234
+ }
235
+ # F1: emit the optional surface ONLY when set, so answers without it round-trip
236
+ # byte-identically to the pre-F1 metadata block (the HARD regression constraint).
237
+ # Both survive `Answers(**block)` deterministically (the deploy metadata re-derive path).
238
+ if self.scoring is not None:
239
+ scoring: dict = {}
240
+ if self.scoring.required_gates is not None:
241
+ scoring["required_gates"] = list(self.scoring.required_gates)
242
+ if self.scoring.weights is not None:
243
+ scoring["weights"] = dict(self.scoring.weights)
244
+ if self.scoring.min_score is not None:
245
+ scoring["min_score"] = self.scoring.min_score
246
+ block["scoring"] = scoring
247
+ if self.indicators:
248
+ block["indicators"] = [
249
+ {"id": i.id, "node": i.node, "with": dict(i.with_), "port": i.port,
250
+ "source": i.source}
251
+ for i in self.indicators
252
+ ]
253
+ return block
254
+
255
+
256
+ def _deep_merge(base: dict, over: dict) -> dict:
257
+ """Field-by-field merge (``over`` wins); nested dicts merge, scalars/lists replace."""
258
+ out = dict(base)
259
+ for key, val in over.items():
260
+ if isinstance(val, dict) and isinstance(out.get(key), dict):
261
+ out[key] = _deep_merge(out[key], val)
262
+ else:
263
+ out[key] = val
264
+ return out
265
+
266
+
267
+ def load_answers_file(path: str | Path) -> dict:
268
+ """Parse a YAML or JSON answers-file into a plain dict (raises on non-mapping)."""
269
+ text = Path(path).read_text(encoding="utf-8")
270
+ data = yaml.safe_load(text) # YAML is a JSON superset, so this reads both (D6)
271
+ if data is None:
272
+ return {}
273
+ if not isinstance(data, dict):
274
+ raise ValueError(f"answers file {path} must be a mapping, got {type(data).__name__}")
275
+ return data
276
+
277
+
278
+ def load_answers(
279
+ file_data: Optional[dict] = None,
280
+ overrides: Optional[dict] = None,
281
+ ) -> Answers:
282
+ """Build :class:`Answers` from an optional answers-file dict + CLI-flag overrides.
283
+
284
+ Overrides win field-by-field (D7: "individual flag overrides"). Unknown keys raise a
285
+ pydantic ``ValidationError`` (``extra="forbid"``) so a typo is caught, not silently
286
+ dropped.
287
+ """
288
+ merged = _deep_merge(file_data or {}, overrides or {})
289
+ return Answers(**merged)
290
+
291
+
292
+ def dumps_answers_template(archetype: str) -> str:
293
+ """A blank answers-file skeleton for an archetype (used by `composer new --help`-style
294
+ scaffolding and the docs); deterministic."""
295
+ return yaml.safe_dump(
296
+ Answers(archetype=archetype).metadata_block(), sort_keys=False
297
+ )
@@ -0,0 +1,26 @@
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. The
6
+ archetypes the POC graphs need are here (trend_momentum / breakout / classifier /
7
+ thesis_fund); an unrecognised archetype falls to the minimal ``skeleton`` (D7 step 5), which
8
+ is NOT in ``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 breakout, classifier, skeleton, thesis_fund, trend_momentum
16
+
17
+ ARCHETYPES = {
18
+ "trend_momentum": trend_momentum,
19
+ "breakout": breakout,
20
+ "classifier": classifier,
21
+ "thesis_fund": thesis_fund,
22
+ }
23
+
24
+ SKELETON = skeleton
25
+
26
+ __all__ = ["ARCHETYPES", "SKELETON"]