@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,131 @@
1
+ """generate — the `composer new` orchestration (D7 mechanics, all five steps in order).
2
+
3
+ 1. answers in (typed; the interview output) — see ``answers``.
4
+ 2. archetype templates are CODE — see ``archetypes`` (build the graph object).
5
+ 3. mechanically-bindable values bound / bespoke slots TODO-marked with working defaults,
6
+ edge verbatim above the scoring slot — see ``builder`` + ``emit``.
7
+ 4. feasibility gap report BEFORE anything is written — see ``gaps``; enforced here first.
8
+ 5. no-clobber (refuse to overwrite an EDITED graph without --force) + off-map skeleton —
9
+ enforced here.
10
+
11
+ ``compose_new`` is a pure planning function: it produces a :class:`NewResult` describing
12
+ what SHOULD happen (the rendered text, a gap report, or a clobber refusal) and never touches
13
+ the filesystem except the single read needed to detect an edited target. The CLI does the
14
+ write, so the behaviour is unit-testable without disk.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from . import emit
24
+ from .answers import Answers
25
+ from .archetypes import ARCHETYPES, SKELETON
26
+ from .gaps import GapReport, detect_gaps
27
+
28
+
29
+ @dataclass
30
+ class NewResult:
31
+ """The outcome of planning a `composer new` invocation."""
32
+
33
+ ok: bool # true == a graph is ready to write (yaml_text set)
34
+ yaml_text: Optional[str] = None
35
+ output_path: Optional[str] = None # where it would be written (may already exist)
36
+ gap_report: Optional[GapReport] = None # set (and ok=False) when feasibility fails
37
+ clobber_refused: bool = False # set (and ok=False) when the target is edited
38
+ used_skeleton: bool = False # true when the archetype was off-map
39
+ message: str = ""
40
+
41
+ def to_json(self) -> dict:
42
+ out: dict = {
43
+ "ok": self.ok,
44
+ "output_path": self.output_path,
45
+ "used_skeleton": self.used_skeleton,
46
+ "clobber_refused": self.clobber_refused,
47
+ "message": self.message,
48
+ }
49
+ if self.gap_report is not None:
50
+ out["gap_report"] = self.gap_report.to_json()
51
+ return out
52
+
53
+
54
+ def _bind_protection_default(answers: Answers) -> Answers:
55
+ """PROTECTION BY DEFAULT (slice 9 policy): `composer new` BINDS ``protected_standard`` when
56
+ the author left ``exit_preset`` unset and did not opt out via ``unmanaged``. Overriding
57
+ (a chosen preset) or opting out (``unmanaged=true``) are both honoured untouched. Returns a
58
+ (possibly) updated copy so the emitted graph metadata carries the bound value."""
59
+ from .presets import DEFAULT_PRESET
60
+ if not answers.unmanaged and answers.exit_preset is None:
61
+ return answers.model_copy(update={"exit_preset": DEFAULT_PRESET})
62
+ return answers
63
+
64
+
65
+ def render_graph(answers: Answers) -> tuple[str, bool]:
66
+ """Build + render the graph for these answers; returns (yaml_text, used_skeleton)."""
67
+ answers = _bind_protection_default(answers)
68
+ module = ARCHETYPES.get(answers.archetype)
69
+ used_skeleton = module is None
70
+ if used_skeleton:
71
+ module = SKELETON
72
+ builder = module.build(answers)
73
+ return emit.render(builder, answers, module.BANNER), used_skeleton
74
+
75
+
76
+ def compose_new(
77
+ answers: Answers,
78
+ output_path: str | Path,
79
+ *,
80
+ force: bool = False,
81
+ ) -> NewResult:
82
+ """Plan a `composer new` (does not write). See the module docstring for step order."""
83
+ path = Path(output_path)
84
+
85
+ # Step 4 — feasibility FIRST: a strategy referencing capabilities the registry lacks is
86
+ # not built and nothing is written (D4/D7). This wins over everything below.
87
+ gap_report = detect_gaps(answers)
88
+ if not gap_report.ok:
89
+ return NewResult(
90
+ ok=False,
91
+ output_path=str(path),
92
+ gap_report=gap_report,
93
+ message=(
94
+ f"{len(gap_report.gaps)} capability gap(s); nothing written. "
95
+ f"Add the missing runtime-owned nodes to the registry first."
96
+ ),
97
+ )
98
+
99
+ # Steps 2+3+5(off-map) — build (archetype or skeleton) and render.
100
+ yaml_text, used_skeleton = render_graph(answers)
101
+
102
+ # Step 5 — no-clobber: refuse to overwrite a HAND-EDITED graph without --force. An
103
+ # unedited generated file (its stored content hash still matches) is safe to regenerate.
104
+ if path.exists() and not force:
105
+ existing = path.read_text(encoding="utf-8")
106
+ state = emit.is_unedited_generated(existing)
107
+ if state is not True:
108
+ reason = (
109
+ "the existing file was hand-edited since it was generated"
110
+ if state is False
111
+ else "the existing file was not produced by `composer new` (no content marker)"
112
+ )
113
+ return NewResult(
114
+ ok=False,
115
+ output_path=str(path),
116
+ clobber_refused=True,
117
+ used_skeleton=used_skeleton,
118
+ message=(
119
+ f"refusing to overwrite {path.name}: {reason}. "
120
+ f"Re-run with --force to overwrite (you will lose those edits)."
121
+ ),
122
+ )
123
+
124
+ verb = "skeleton (off-map archetype)" if used_skeleton else f"archetype {answers.archetype!r}"
125
+ return NewResult(
126
+ ok=True,
127
+ yaml_text=yaml_text,
128
+ output_path=str(path),
129
+ used_skeleton=used_skeleton,
130
+ message=f"generated a valid graph from {verb}.",
131
+ )
@@ -0,0 +1,168 @@
1
+ """presets — the exit/risk presets as CI-checked CODE (slice 9 step 2; the PROTECTION policy).
2
+
3
+ Exits live entirely in `runtime.yaml` (never on the scanner's entry-only signal, see
4
+ `exits-dsl-recon.md`), and the runtime schema legally allows an exit-LESS config — positions
5
+ would then run completely unmanaged (no stop-loss, no trailing, no time-cut). That is the
6
+ disease the composer cures: **PROTECTION BY DEFAULT.** `composer new` binds
7
+ ``exit_preset=protected_standard`` unless overridden; a unit with no exit and no explicit
8
+ ``unmanaged: true`` opt-out is RED at check (CMP120).
9
+
10
+ Same "as-code, CI-checked" discipline as the archetype templates
11
+ (``composer.authoring.archetypes``): each preset is a frozen object with a `dsl_preset` block
12
+ (re-validated LIVE by the real Zod+DSL oracle in the slice-9 test suite — a preset that ever
13
+ stopped parsing fails CI, not a live deploy) and a `guard_rails` default. Derived from the
14
+ whole-corpus medians + the archetype exemplars (`runtime-unit-design.md` §2; the recon table
15
+ in `exits-dsl-recon.md`):
16
+
17
+ * protected_standard (DEFAULT) — corpus-median, thesis-shaped: balanced let-it-work + recycle.
18
+ * protected_tight — hawk-shaped: cut failed entries fast (tight P1).
19
+ * protected_wide — stag/bison-shaped: let runners breathe (wide P1, long timeout).
20
+
21
+ LIVE vs TODO(edit): the preset SELECTION and its phase1 / time-cut scalars are bound live
22
+ (they validate immediately). The phase2 tier ladder and phase1.max_loss_pct carry
23
+ ``# TODO(edit)`` slot comments in the emitted runtime.yaml (the ratchet is the most
24
+ thesis-specific knob) while holding the preset default — a small model can tune them without
25
+ ever producing an invalid preset. See ``composer.authoring.runtime_emit``.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from typing import Optional
31
+
32
+ from pydantic import BaseModel, ConfigDict, Field
33
+
34
+
35
+ class ExitPreset(BaseModel):
36
+ """One named exit+risk preset (protection profile). Frozen, constructed at import — a
37
+ malformed preset fails at construction (CI), never at a live deploy."""
38
+
39
+ model_config = ConfigDict(extra="forbid", frozen=True)
40
+
41
+ name: str
42
+ intent: str
43
+ interval_seconds: int = 60
44
+ # The DSL exit preset block (shape validated LIVE by the Zod+DSL oracle in the tests).
45
+ dsl_preset: dict = Field(default_factory=dict)
46
+ # risk.guard_rails defaults for this preset (answers knobs override per-field, §4).
47
+ guard_rails: dict = Field(default_factory=dict)
48
+
49
+
50
+ def _tiers(*pairs: tuple[int, int]) -> list[dict]:
51
+ return [{"trigger_pct": t, "lock_hw_pct": l} for t, l in pairs]
52
+
53
+
54
+ # --------------------------------------------------------------------------- the three presets
55
+
56
+ EXIT_PRESETS: dict[str, ExitPreset] = {
57
+ "protected_standard": ExitPreset(
58
+ name="protected_standard",
59
+ intent="corpus-median balanced protection (thesis-shaped): let it work + recycle.",
60
+ interval_seconds=60,
61
+ dsl_preset={
62
+ "hard_timeout": {"enabled": True, "interval_in_minutes": 4320}, # 3d
63
+ "weak_peak_cut": {"enabled": True, "interval_in_minutes": 240, "min_value": 2.0}, # 4h
64
+ "dead_weight_cut": {"enabled": False, "interval_in_minutes": 480},
65
+ "phase1": {
66
+ "enabled": True,
67
+ "max_loss_pct": 15.0,
68
+ "retrace_threshold": 8,
69
+ "consecutive_breaches_required": 1,
70
+ },
71
+ "phase2": {
72
+ "enabled": True,
73
+ "tiers": _tiers((10, 0), (22, 40), (40, 60), (75, 75), (120, 90)),
74
+ },
75
+ },
76
+ guard_rails={
77
+ "daily_loss_limit_pct": 12,
78
+ "max_consecutive_losses": 3,
79
+ "cooldown_seconds": 3600,
80
+ "drawdown_halt_pct": 20,
81
+ "drawdown_reset_on_day_rollover": False,
82
+ },
83
+ ),
84
+ "protected_tight": ExitPreset(
85
+ name="protected_tight",
86
+ intent="hawk-shaped: TIGHT phase 1 cuts failed entries fast; wide phase-2 runner ladder.",
87
+ interval_seconds=60,
88
+ dsl_preset={
89
+ "hard_timeout": {"enabled": True, "interval_in_minutes": 1440}, # 24h
90
+ "weak_peak_cut": {"enabled": True, "interval_in_minutes": 60, "min_value": 3.0}, # 60m
91
+ "dead_weight_cut": {"enabled": False, "interval_in_minutes": 60},
92
+ "phase1": {
93
+ "enabled": True,
94
+ "max_loss_pct": 8.0,
95
+ "retrace_threshold": 5,
96
+ "consecutive_breaches_required": 1,
97
+ },
98
+ "phase2": {
99
+ "enabled": True,
100
+ "tiers": _tiers((10, 0), (20, 25), (30, 40), (50, 60), (75, 75), (100, 85)),
101
+ },
102
+ },
103
+ guard_rails={
104
+ "daily_loss_limit_pct": 15,
105
+ "max_consecutive_losses": 3,
106
+ "cooldown_seconds": 3600,
107
+ "drawdown_halt_pct": 18,
108
+ "drawdown_reset_on_day_rollover": False,
109
+ },
110
+ ),
111
+ "protected_wide": ExitPreset(
112
+ name="protected_wide",
113
+ intent="stag/bison-shaped: WIDE phase 1 + long timeout — let a runner breathe.",
114
+ interval_seconds=60,
115
+ dsl_preset={
116
+ "hard_timeout": {"enabled": True, "interval_in_minutes": 20160}, # 14d
117
+ "weak_peak_cut": {"enabled": False, "interval_in_minutes": 240},
118
+ "dead_weight_cut": {"enabled": False, "interval_in_minutes": 480},
119
+ "phase1": {
120
+ "enabled": True,
121
+ "max_loss_pct": 25.0,
122
+ "retrace_threshold": 15,
123
+ "consecutive_breaches_required": 2,
124
+ },
125
+ "phase2": {
126
+ "enabled": True,
127
+ "tiers": _tiers((15, 0), (30, 30), (60, 55), (120, 72), (250, 85)),
128
+ },
129
+ },
130
+ guard_rails={
131
+ "daily_loss_limit_pct": 15,
132
+ "max_consecutive_losses": 4,
133
+ "cooldown_seconds": 3600,
134
+ "drawdown_halt_pct": 25,
135
+ "drawdown_reset_on_day_rollover": False,
136
+ },
137
+ ),
138
+ }
139
+
140
+ DEFAULT_PRESET = "protected_standard"
141
+
142
+ # guard_rails keys an answers `risk.*` knob overrides (design §4): each is a declarative
143
+ # runtime.yaml field the runtime enforces. answer-field -> guard_rails key.
144
+ RISK_OVERRIDE_KEYS = {
145
+ "drawdown_halt_pct": "drawdown_halt_pct",
146
+ "max_consecutive_losses": "max_consecutive_losses",
147
+ "cooldown_seconds": "cooldown_seconds",
148
+ "max_entries_per_day": "max_entries_per_day",
149
+ }
150
+
151
+
152
+ def preset_names() -> list[str]:
153
+ """Sorted preset names — used by the catalog surface and error hints. Byte-stable."""
154
+ return sorted(EXIT_PRESETS)
155
+
156
+
157
+ def resolve_guard_rails(preset: ExitPreset, risk_overrides: Optional[dict] = None) -> dict:
158
+ """The preset's guard_rails with any answer-supplied risk knobs layered on top (§4).
159
+
160
+ ``risk_overrides`` is a plain dict of the answers ``Risk`` pause knobs; a ``None`` value
161
+ means "not set — use the preset default". Returns an ordered dict for deterministic emit.
162
+ """
163
+ out = dict(preset.guard_rails)
164
+ for ans_key, gr_key in RISK_OVERRIDE_KEYS.items():
165
+ val = (risk_overrides or {}).get(ans_key)
166
+ if val is not None:
167
+ out[gr_key] = val
168
+ return out
@@ -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"