@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,266 @@
1
+ """runtime_emit — render the COMPLETE standalone `runtime.yaml` (slice 9 step 3).
2
+
3
+ The composer no longer stages a bare scanner (entry-less, exit-less — the incomplete unit
4
+ `exits-dsl-recon.md` flagged). It emits a full, Zod-valid, PROTECTED-by-default runtime unit:
5
+ ``strategy`` / ``scanners`` / ``actions`` / ``exit`` / ``risk`` as SIBLING top-level sections
6
+ (NOT nodes in the DAG — they are consumed by the DslPlugin / ActionRegistry at runtime boot,
7
+ a different lifecycle than the ``nodes``/``edges`` compiled into ``scan.py``; design §6).
8
+
9
+ Everything derives from the graph's ``metadata.answers`` (the single source of truth both
10
+ artifacts share, D7), so recompiling regenerates ``scan.py`` AND ``runtime.yaml``
11
+ byte-identically. LIVE bindings: strategy sizing, the scanner cadence, the exit preset
12
+ SELECTION and its phase1 / time-cut scalars, the guard_rails. TODO(edit) slots (working
13
+ default in place, still valid): ``exit.dsl_preset.phase1.max_loss_pct`` and each
14
+ ``exit.dsl_preset.phase2.tiers`` entry (the ratchet is the most thesis-specific knob).
15
+
16
+ ``unmanaged=True`` emits NO ``exit`` section plus a loud warning banner — the deliberate,
17
+ explicit opt-out of protection (a graph that reaches here with ``unmanaged=False`` and no
18
+ exit is impossible: the preset always resolves; the CMP120 gate lives at ``check`` time).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+
25
+ from .answers import Answers
26
+ from .presets import DEFAULT_PRESET, EXIT_PRESETS, resolve_guard_rails
27
+
28
+ # housekeeping window (corpus median 72h); a fixed, sensible default (design §4).
29
+ _DATA_RETENTION_SECONDS = 259200
30
+ # built-in position_tracker cadence (corpus ships ~10s); the DSL twin that mirrors the book.
31
+ _POSITION_TRACKER_INTERVAL = 10
32
+ # external_scanner cadence + signal TTL (corpus 300s / 600s; §0 minimal unit).
33
+ _SCAN_INTERVAL_SECONDS = 300
34
+ _SIGNAL_VALIDITY_SECONDS = 600
35
+ _ENTRY_EXECUTION_TIMEOUT = 45 # design §3: corpus 30–60; use 45.
36
+
37
+ _UNMANAGED_BANNER = [
38
+ "!! UNMANAGED — NO EXIT PROTECTION. You passed unmanaged=true, so this runtime ships",
39
+ "!! WITHOUT a DSL exit: positions run with NO stop-loss, NO trailing floor, NO time-cut.",
40
+ "!! A loss can run to liquidation. Remove `unmanaged` (or set exit_preset) to restore",
41
+ "!! protection-by-default (protected_standard). This is a deliberate, explicit opt-out.",
42
+ ]
43
+
44
+
45
+ def _env_wallet(strategy: str) -> str:
46
+ slug = re.sub(r"[^0-9a-zA-Z]+", "_", strategy).strip("_").upper() or "STRATEGY"
47
+ return "${" + f"{slug}_WALLET" + "}"
48
+
49
+
50
+ def _dq(s: str) -> str:
51
+ return '"' + str(s).replace("\\", "\\\\").replace('"', '\\"') + '"'
52
+
53
+
54
+ def _num(v) -> str:
55
+ if isinstance(v, bool):
56
+ return "true" if v else "false"
57
+ if isinstance(v, float) and v.is_integer():
58
+ return str(int(v))
59
+ return str(v)
60
+
61
+
62
+ def _tier_inline(tier: dict) -> str:
63
+ return "{ " + f"trigger_pct: {_num(tier['trigger_pct'])}, lock_hw_pct: {_num(tier['lock_hw_pct'])}" + " }"
64
+
65
+
66
+ def _strategy_name(answers: Answers) -> str:
67
+ return str(answers.strategy or "unnamed")
68
+
69
+
70
+ def _description(answers: Answers, has_exit: bool) -> str:
71
+ """A substantive top-level description (the runtime registers it; senpi-portfolio reads it
72
+ back as the strategy's mandate — must be meaningful, >= 40 chars). The verbatim EDGE leads
73
+ when present; the exit posture is always stated so the mandate names how it protects."""
74
+ strategy = _strategy_name(answers)
75
+ if answers.unmanaged:
76
+ exit_clause = "Runs UNMANAGED (no DSL exit) by explicit opt-out — positions are unprotected."
77
+ elif has_exit:
78
+ exit_clause = f"Protected by the '{answers.exit_preset}' DSL exit ({EXIT_PRESETS[answers.exit_preset].intent})"
79
+ else:
80
+ exit_clause = "No exit preset set (INCOMPLETE — composer check is RED, CMP120)."
81
+ edge = (answers.edge or "").strip().replace("\n", " ")
82
+ lead = edge if edge else (
83
+ f"A composer-authored {answers.archetype} strategy ({strategy}) that scans its universe "
84
+ f"each tick and opens signal-gated positions."
85
+ )
86
+ return f"{lead} {exit_clause}"
87
+
88
+
89
+ def scanner_name(answers: Answers) -> str:
90
+ return f"{_strategy_name(answers)}_signals"
91
+
92
+
93
+ def runtime_name(answers: Answers) -> str:
94
+ return f"{_strategy_name(answers)}-main"
95
+
96
+
97
+ def render_runtime_yaml(answers: Answers) -> str:
98
+ """Render the complete standalone runtime.yaml text for these answers. Deterministic."""
99
+ strategy = _strategy_name(answers)
100
+ sig = scanner_name(answers)
101
+ has_exit = (not answers.unmanaged) and (answers.exit_preset is not None)
102
+ lines: list[str] = []
103
+
104
+ # --- banner ---
105
+ if answers.unmanaged:
106
+ for b in _UNMANAGED_BANNER:
107
+ lines.append(f"# {b}")
108
+ elif has_exit:
109
+ preset = EXIT_PRESETS[answers.exit_preset]
110
+ lines.append(f"# {strategy} — complete standalone strategy runtime (composer deploy).")
111
+ lines.append("#")
112
+ lines.append("# PROTECTED BY DEFAULT: the exit: section below is the resolved preset")
113
+ lines.append(f"# '{answers.exit_preset}' — {preset.intent}")
114
+ lines.append("# A standalone runtime in its own funded wallet; ops mints the wallet + installs")
115
+ lines.append("# (one wallet per instance). Edit ONLY the TODO(edit) slots to tune the exit.")
116
+ else:
117
+ # exit_preset unset AND not opted out — the CMP120 contradiction (check flags it RED).
118
+ lines.append(f"# {strategy} — runtime with NO exit preset set and NO unmanaged opt-out.")
119
+ lines.append("# This unit is INCOMPLETE by composer policy: `composer check` will be RED (CMP120).")
120
+ lines.append("# Set an exit_preset, or pass unmanaged=true to deliberately opt out.")
121
+ lines.append("")
122
+
123
+ # --- identity ---
124
+ lines.append(f"name: {runtime_name(answers)}")
125
+ lines.append(f"group: {strategy}")
126
+ lines.append(f"description: {_dq(_description(answers, has_exit))}")
127
+ lines.append("")
128
+
129
+ # --- strategy (only wallet is required; the rest are LIVE from answers.risk) ---
130
+ lines.append("strategy:")
131
+ lines.append(f" wallet: {_dq(_env_wallet(strategy))}")
132
+ lines.append(" slots: 1")
133
+ lines.append(f" margin_pct: {_num(answers.risk.margin_pct)}")
134
+ lines.append(f" default_leverage: {_num(answers.risk.leverage)}")
135
+ lines.append(" trading_risk: moderate")
136
+ lines.append(" enabled: true")
137
+ lines.append("")
138
+
139
+ # --- scanners: the DSL position twin + the compiled external scanner ---
140
+ lines.append("scanners:")
141
+ lines.append(" - name: position_tracker")
142
+ lines.append(" type: position_tracker")
143
+ lines.append(f" interval_seconds: {_POSITION_TRACKER_INTERVAL}")
144
+ lines.append(f" - name: {sig}")
145
+ lines.append(" type: external_scanner")
146
+ lines.append(" path: ./scanners")
147
+ lines.append(" entrypoint: scan.py")
148
+ lines.append(f" interval_seconds: {_SCAN_INTERVAL_SECONDS}")
149
+ lines.append(f" default_signal_validity_seconds: {_SIGNAL_VALIDITY_SECONDS}")
150
+ lines.append(" signal_data_schema:")
151
+ lines.append(" score: { type: number }")
152
+ lines.append(" reasons: { type: array, required: false }")
153
+ lines.append("")
154
+
155
+ # --- actions: 100% mechanical (design §3) — POSITION_TRACKER + OPEN_POSITION ---
156
+ lines.append("actions:")
157
+ lines.append(" - name: position_tracker_action")
158
+ lines.append(" action_type: POSITION_TRACKER")
159
+ lines.append(" decision_mode: rule")
160
+ lines.append(" scanners: [position_tracker]")
161
+ lines.append(f" - name: {strategy}_entry")
162
+ lines.append(" action_type: OPEN_POSITION")
163
+ lines.append(" decision_mode: rule") # the scan already applied every gate
164
+ lines.append(f" scanners: [{sig}]")
165
+ lines.append(" params:")
166
+ lines.append(" order_type: FEE_OPTIMIZED_LIMIT")
167
+ lines.append(" fee_optimized_limit_options:")
168
+ lines.append(" ensure_execution_as_taker: true")
169
+ lines.append(f" execution_timeout_seconds: {_ENTRY_EXECUTION_TIMEOUT}")
170
+ lines.append(" context:")
171
+ lines.append(" - type: signal")
172
+ lines.append(f" scanner: {sig}")
173
+ lines.append("")
174
+
175
+ # --- exit: the resolved DSL preset (LIVE scalars; tiers + max_loss are TODO slots) ---
176
+ if has_exit:
177
+ preset = EXIT_PRESETS[answers.exit_preset]
178
+ dp = preset.dsl_preset
179
+ lines.append(f"# EXIT — DSL preset '{preset.name}': {preset.intent}")
180
+ lines.append("exit:")
181
+ lines.append(" engine: dsl")
182
+ lines.append(f" interval_seconds: {preset.interval_seconds}")
183
+ lines.append(" order_type: FEE_OPTIMIZED_LIMIT")
184
+ lines.append(" fee_optimized_limit_options:")
185
+ lines.append(" ensure_execution_as_taker: true") # exit closes MUST happen
186
+ lines.append(f" execution_timeout_seconds: {_ENTRY_EXECUTION_TIMEOUT}")
187
+ lines.append(" dsl_preset:")
188
+ for cut in ("hard_timeout", "weak_peak_cut", "dead_weight_cut"):
189
+ block = dp[cut]
190
+ lines.append(f" {cut}:")
191
+ lines.append(f" enabled: {_num(block['enabled'])}")
192
+ lines.append(f" interval_in_minutes: {_num(block['interval_in_minutes'])}")
193
+ if "min_value" in block:
194
+ lines.append(f" min_value: {_num(block['min_value'])}")
195
+ p1 = dp["phase1"]
196
+ lines.append(" phase1:")
197
+ lines.append(f" enabled: {_num(p1['enabled'])}")
198
+ lines.append(
199
+ f" max_loss_pct: {_num(p1['max_loss_pct'])}"
200
+ " # TODO(edit): phase-1 hard-floor loss cap (%). Preset default; tighten/loosen for your edge."
201
+ )
202
+ lines.append(f" retrace_threshold: {_num(p1['retrace_threshold'])}")
203
+ lines.append(f" consecutive_breaches_required: {_num(p1['consecutive_breaches_required'])}")
204
+ p2 = dp["phase2"]
205
+ lines.append(" phase2:")
206
+ lines.append(f" enabled: {_num(p2['enabled'])}")
207
+ lines.append(" tiers: # TODO(edit): the phase-2 ratchet — trigger_pct -> lock_hw_pct.")
208
+ for i, tier in enumerate(p2["tiers"]):
209
+ note = " # TODO(edit): tier — lock-in floor at this ROE" if i == 0 else ""
210
+ lines.append(f" - {_tier_inline(tier)}{note}")
211
+ lines.append("")
212
+
213
+ # --- risk.guard_rails: the automated pause / circuit breaker (design §4) ---
214
+ # Present even when unmanaged: the drawdown-halt circuit breaker is independent of the
215
+ # per-position DSL exit, so an unmanaged unit still ships guard rails from the default preset.
216
+ guard_rails = resolve_guard_rails(
217
+ EXIT_PRESETS[answers.exit_preset or DEFAULT_PRESET],
218
+ {
219
+ "drawdown_halt_pct": answers.risk.drawdown_halt_pct,
220
+ "max_consecutive_losses": answers.risk.max_consecutive_losses,
221
+ "cooldown_seconds": answers.risk.cooldown_seconds,
222
+ "max_entries_per_day": answers.risk.max_entries_per_day,
223
+ },
224
+ )
225
+ lines.append("# RISK guard rails — the automated pause / circuit breaker (composer owns policy; ops owns manual stop).")
226
+ lines.append("risk:")
227
+ lines.append(f" data_retention_seconds: {_DATA_RETENTION_SECONDS}")
228
+ lines.append(" guard_rails:")
229
+ # deterministic key order
230
+ for key in (
231
+ "daily_loss_limit_pct",
232
+ "max_entries_per_day",
233
+ "max_consecutive_losses",
234
+ "cooldown_seconds",
235
+ "drawdown_halt_pct",
236
+ "drawdown_reset_on_day_rollover",
237
+ ):
238
+ if key in guard_rails:
239
+ lines.append(f" {key}: {_num(guard_rails[key])}")
240
+
241
+ return "\n".join(ln.rstrip() for ln in lines).rstrip("\n") + "\n"
242
+
243
+
244
+ def answers_from_metadata(metadata: dict) -> Answers:
245
+ """Reconstruct :class:`Answers` from a graph's ``metadata`` (the SoT both artifacts derive
246
+ from). Used by ``check``/``deploy`` to emit runtime.yaml from the graph.
247
+
248
+ Two shapes are accepted:
249
+ * ``composer new`` output carries a full ``metadata.answers`` block -> construct from it.
250
+ * A hand-authored example carries only top-level ``metadata`` keys (archetype, strategy,
251
+ and optionally ``exit_preset`` / ``unmanaged``) -> construct a minimal Answers, letting
252
+ the exit_preset default to protected_standard (PROTECTION BY DEFAULT).
253
+ """
254
+ metadata = metadata or {}
255
+ block = metadata.get("answers")
256
+ if isinstance(block, dict) and block:
257
+ return Answers(**block)
258
+ kwargs: dict = {
259
+ "archetype": str(metadata.get("archetype") or "skeleton"),
260
+ "strategy": str(metadata.get("strategy") or "unnamed"),
261
+ }
262
+ if metadata.get("exit_preset") is not None:
263
+ kwargs["exit_preset"] = metadata["exit_preset"]
264
+ if metadata.get("unmanaged") is not None:
265
+ kwargs["unmanaged"] = bool(metadata["unmanaged"])
266
+ return Answers(**kwargs)
@@ -0,0 +1,94 @@
1
+ """strategy_manifest — emit the `strategy.yaml` catalog + funding manifest (slice 9 step 8).
2
+
3
+ The THIN package manifest (design §7; shape = `skills/strategies/<id>/strategy.yaml`): the
4
+ single source of truth for ``id`` / ``version`` / catalog facets / ``instances[]``. It carries
5
+ NO tunables (those live in the instance's runtime.yaml) — only what the ops handoff reads:
6
+
7
+ * ``catalog:`` facets — name / thesis / tier / assets / tags. ``thesis`` carries the verbatim
8
+ EDGE (the machine never interprets it; it is the user's own words).
9
+ * ``requires.runtime: ">=3.0.0"`` — the runtime-major compatibility boundary.
10
+ * ``instances:`` — a SINGLE instance for the POC (``funding_share: 1.0``); multi-instance
11
+ packages (magpie's two books) are out of scope. ``wallet_env`` matches the ``${...}``
12
+ placeholder the runtime.yaml binds, so ops can mint one wallet per instance and fund by share.
13
+
14
+ The composer's scope ENDS at emitting this manifest + the staged unit; it never creates
15
+ wallets, moves money, or installs — that is ``senpi-strategy-ops`` (design §7 scope boundary).
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import re
21
+
22
+ from .answers import Answers
23
+ from .runtime_emit import _dq # reuse the shared double-quote helper
24
+
25
+
26
+ def _slug_env(strategy: str) -> str:
27
+ return (re.sub(r"[^0-9a-zA-Z]+", "_", strategy).strip("_").upper() or "STRATEGY") + "_WALLET"
28
+
29
+
30
+ def _assets(answers: Answers) -> list[str]:
31
+ u = answers.universe
32
+ if u.whitelist:
33
+ return list(u.whitelist)
34
+ combined = list(u.long_basket) + list(u.short_basket)
35
+ if combined:
36
+ return combined
37
+ if u.discover:
38
+ return [] # discovered each tick — no fixed asset list
39
+ return []
40
+
41
+
42
+ def _tags(answers: Answers) -> list[str]:
43
+ tags = [answers.archetype]
44
+ if answers.universe.discover:
45
+ tags.append("discovery")
46
+ tags.append("composer-authored")
47
+ if answers.unmanaged:
48
+ tags.append("unmanaged")
49
+ else:
50
+ tags.append("protected")
51
+ # dedupe preserving order
52
+ seen: set[str] = set()
53
+ return [t for t in tags if not (t in seen or seen.add(t))]
54
+
55
+
56
+ def render_strategy_yaml(answers: Answers, *, version: str = "0.1.0") -> str:
57
+ """Render the strategy.yaml manifest text for these answers. Deterministic."""
58
+ strategy = str(answers.strategy or "unnamed")
59
+ thesis = (answers.edge or "").strip().replace("\n", " ") or (
60
+ f"A composer-authored {answers.archetype} strategy."
61
+ )
62
+ assets = _assets(answers)
63
+ tags = _tags(answers)
64
+
65
+ lines: list[str] = []
66
+ lines.append(f"# {strategy} — strategy package manifest (composer deploy). THIN: id/version/")
67
+ lines.append("# catalog facets + instances[] only. Ops mints one wallet per instance + installs.")
68
+ lines.append("schema_version: 1")
69
+ lines.append("")
70
+ lines.append(f"id: {strategy}")
71
+ lines.append(f"version: {_dq(version)}")
72
+ lines.append("")
73
+ lines.append("catalog:")
74
+ lines.append(f" name: {_dq(strategy)}")
75
+ lines.append(f" archetype: {_dq(answers.archetype)}")
76
+ lines.append(f" thesis: {_dq(thesis)}")
77
+ lines.append(" tier: starter")
78
+ lines.append(" risk_level: moderate")
79
+ if assets:
80
+ lines.append(f" assets: [{', '.join(_dq(a) for a in assets)}]")
81
+ else:
82
+ lines.append(" assets: [] # discovered each tick (no fixed universe)")
83
+ lines.append(f" tags: [{', '.join(tags)}]")
84
+ lines.append("")
85
+ lines.append("requires:")
86
+ lines.append(" runtime: \">=3.0.0\"")
87
+ lines.append("")
88
+ lines.append("instances:")
89
+ lines.append(" - name: main")
90
+ lines.append(" runtime: main/runtime.yaml")
91
+ lines.append(f" wallet_env: {_slug_env(strategy)}")
92
+ lines.append(" funding_share: 1.0")
93
+
94
+ return "\n".join(ln.rstrip() for ln in lines).rstrip("\n") + "\n"