@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,121 @@
1
+ # breakout_swing — breakout swing-trading strategy (tier 1) over a 3-asset basket, as a
2
+ # typed dataflow DAG.
3
+ #
4
+ # Hand-authored from the corpus-recon inventory (hawk section): a BTC/ETH/SOL basket
5
+ # scanned on 1h + 4h candles. Two HARD gates in the corpus (a directional breakout of the
6
+ # prior-7d closes, and smart-money agreement); the 4h trend-structure and volume ratio are
7
+ # score contributors. Single-best emission, universal TTL signal-dedup on ctx.state.
8
+ # Re-expression of corpus package hawk; semantics cited to skills/strategies/hawk/...
9
+ #
10
+ # Re-expression notes (correctness = the slice-3 goldens, not the original's bit output):
11
+ # - breakout_detect.direction is the sole hard gate (required_gates: [brk]); it is also
12
+ # the emitted trade direction (the scorer's direction detector reads the LONG/SHORT
13
+ # feature). Smart-money is a weighted contributor here rather than a second hard,
14
+ # direction-matched gate — the generic scorer gates on truthiness, not cross-feature
15
+ # agreement (documented deviation; corpus fidelity is the slice-8 oracle).
16
+ # - trend_structure emits BULLISH/BEARISH/NEUTRAL, so it can be wired into the same scorer
17
+ # as breakout_detect (both would otherwise be a "direction" port) without colliding.
18
+ # - feature/gate/weight keys are SOURCE NODE IDS (the compiler merges keyed by node id).
19
+
20
+ version: 1
21
+
22
+ metadata:
23
+ archetype: breakout
24
+ strategy: breakout_swing
25
+ corpus_origin: hawk # provenance only, for fidelity replay — not user-facing
26
+ exit_preset: protected_tight # slice 9: emitted as runtime.yaml exit: (hawk = cut failed breakouts fast)
27
+ # EDGE (verbatim, machine never interprets): "buy the liquid majors the moment price
28
+ # breaks a week of range with smart money leaning the same way; ride it with the DSL."
29
+
30
+ nodes:
31
+ # --- sources ---
32
+ - id: account
33
+ node: clearinghouse_state # dual-DEX max() equity + held-position set
34
+
35
+ - id: dedup_state
36
+ node: ttl_dedup_state # ctx.state TTL dedup; `recent` = assets within TTL
37
+ with:
38
+ ttl_seconds: 240
39
+
40
+ # --- universe narrowing (held-exclusion then recent-signal dedup) ---
41
+ - id: exclude_held
42
+ node: held_asset_exclusion # candidates default to the basket (candles whitelist)
43
+
44
+ - id: exclude_recent
45
+ node: recent_signal_ttl_dedup
46
+
47
+ # --- candle + leaderboard reads over the narrowed basket ---
48
+ - id: candles
49
+ node: candles_fetch
50
+ with:
51
+ whitelist: ["BTC", "ETH", "SOL"]
52
+ intervals: ["1h", "4h"]
53
+
54
+ - id: lean
55
+ node: sm_lean
56
+ with:
57
+ agree_threshold: 0.55 # breakout_swing's own 55% band (not conviction_scorer's 58/42)
58
+
59
+ # --- indicators ---
60
+ - id: brk
61
+ node: breakout_detect # HARD gate: prior-168h close high/low break
62
+ with:
63
+ window: 168
64
+ interval: "1h"
65
+
66
+ - id: trend4h
67
+ node: trend_structure # 4h higher-lows/lower-highs (score contributor)
68
+ with:
69
+ lookback: 6
70
+ interval: "4h"
71
+
72
+ - id: vol
73
+ node: volume_surge_ratio # 1h recent/baseline volume (score contributor)
74
+ with:
75
+ recent_window: 1
76
+ baseline_window: 10
77
+ interval: "1h"
78
+
79
+ # --- scorer: breakout is the only hard gate; trend/vol/SM contribute ---
80
+ - id: gates
81
+ node: hard_gates_all
82
+ with:
83
+ required_gates: ["brk"] # a decisive breakout (LONG/SHORT, not NEUTRAL)
84
+ # TODO(edge): tune these weights — the strategy's real edge lives here.
85
+ weights: { brk: 2.0, trend4h: 2.0, vol: 1.0, lean: 0.05 }
86
+ min_score: 5
87
+
88
+ # --- ranker + emitter ---
89
+ - id: best
90
+ node: emit_single_best
91
+ with:
92
+ min_score: 5
93
+
94
+ - id: emit
95
+ node: signal_envelope
96
+ with:
97
+ margin_pct: 20 # PERCENT of withdrawable (breakout_swing's flat 20%)
98
+ leverage: 5
99
+
100
+ edges:
101
+ # universe narrowing chain
102
+ - { from: account.held, to: exclude_held.held }
103
+ - { from: exclude_held.kept, to: exclude_recent.candidates }
104
+ - { from: dedup_state.recent, to: exclude_recent.recent }
105
+ - { from: exclude_recent.kept, to: candles.assets }
106
+ - { from: exclude_recent.kept, to: lean.assets }
107
+ # candles fan out to indicators
108
+ - { from: candles.candles, to: brk.candles }
109
+ - { from: candles.candles, to: trend4h.candles }
110
+ - { from: candles.candles, to: vol.candles }
111
+ # per-asset features MERGE into the scorer (keyed by source node id)
112
+ - { from: brk.direction, to: gates.features }
113
+ - { from: trend4h.direction, to: gates.features }
114
+ - { from: vol.ratio, to: gates.features }
115
+ - { from: lean.lean, to: gates.features }
116
+ # score -> rank -> emit
117
+ - { from: gates.scores, to: best.scores }
118
+ - { from: best.selected, to: emit.selected }
119
+ - { from: account.equity, to: emit.equity }
120
+ # close the dedup loop: this tick's signals are recorded in ctx.state
121
+ - { from: emit.signals, to: dedup_state.emitted }
@@ -0,0 +1,216 @@
1
+ # conviction_scorer — trend/conviction strategy (tier 2) over a BTC/ETH/SOL whitelist, as a
2
+ # typed dataflow DAG whose conviction SCORER is a per-graph pure_fn node (D2 kind 2).
3
+ #
4
+ # Why a pure_fn here (the slice-4 point): this strategy's scoring is not a registry scorer
5
+ # pattern. Its direction is chosen by a WATERFALL (4h trend -> SM lean -> 1h momentum) and
6
+ # then EVERY other signal is a weighted SCORE CONTRIBUTOR, not a hard gate — a CROSS-FEATURE
7
+ # computation the generic weighted-sum scorer cannot express (this is exactly the
8
+ # vocabulary-limit the slice-3 outcome flagged; pure_fn is the pressure valve, D2). The fn
9
+ # body re-expresses the corpus package bison's `build_thesis` conviction math
10
+ # (corpus: /Users/duncan/Senpi/skills/strategies/bison/main/scanners/scoring.py) — cited in
11
+ # the body — and is statically purity-checked (no ctx/MCP/I/O) with inline fixture tests.
12
+ #
13
+ # Correctness = the slice-4 composed goldens (python-tests/composer), not the original's bit
14
+ # output. Feature keys flowing into the pure_fn are its DECLARED input ports (each a single
15
+ # writer), not a merge record — a pure_fn declares its typed inputs explicitly.
16
+
17
+ version: 1
18
+
19
+ metadata:
20
+ archetype: trend_conviction
21
+ strategy: conviction_scorer
22
+ corpus_origin: bison # provenance only, for fidelity replay — not user-facing
23
+ exit_preset: protected_wide # slice 9: emitted as runtime.yaml exit: (bison = conviction hold)
24
+ # EDGE (verbatim, machine never interprets): "stack conviction when the 4h structure,
25
+ # smart money, 1h momentum and volume all line up behind one direction; size up on the
26
+ # strongest read and ride it."
27
+
28
+ nodes:
29
+ # --- sources ---
30
+ - id: account
31
+ node: clearinghouse_state # held-asset set + dual-DEX max() equity
32
+
33
+ - id: dedup_state
34
+ node: ttl_dedup_state # ctx.state TTL dedup; `recent` = assets within TTL
35
+ with:
36
+ ttl_seconds: 180 # conviction_scorer's 180s dedup window
37
+
38
+ # --- universe narrowing (held-exclusion then recent-signal dedup) ---
39
+ - id: exclude_held
40
+ node: held_asset_exclusion # candidates default to the whitelist universe
41
+
42
+ - id: exclude_recent
43
+ node: recent_signal_ttl_dedup
44
+
45
+ # --- candle + leaderboard reads over the narrowed universe ---
46
+ - id: candles
47
+ node: candles_fetch
48
+ with:
49
+ whitelist: ["BTC", "ETH", "SOL"]
50
+ intervals: ["1h", "4h"]
51
+
52
+ - id: lean
53
+ node: sm_lean
54
+ with:
55
+ agree_threshold: 0.58 # conviction_scorer's own 58/42 band
56
+
57
+ # --- indicators feeding the conviction scorer (cross-feature) ---
58
+ - id: trend4h
59
+ node: trend_structure # 4h higher-lows/lower-highs -> BULLISH/BEARISH/NEUTRAL
60
+ with:
61
+ lookback: 6
62
+ interval: "4h"
63
+
64
+ - id: mom1h
65
+ node: pct_change # bison price_momentum: 1h percent change
66
+ with:
67
+ lookback: 1
68
+ interval: "1h"
69
+
70
+ - id: vol
71
+ node: volume_surge_ratio # bison volume_trend, re-expressed as recent/baseline
72
+ with:
73
+ recent_window: 1
74
+ baseline_window: 3
75
+ interval: "1h"
76
+
77
+ - id: rsi1h
78
+ node: rsi # bison calc_rsi (trailing-window RSI over 1h closes)
79
+ with:
80
+ period: 4 # short period so the conviction rsi-room test is cheap
81
+ interval: "1h"
82
+
83
+ # --- pure_fn conviction scorer (the bespoke, cross-feature edge; D2 kind 2) ---
84
+ - id: score
85
+ kind: pure_fn
86
+ inputs:
87
+ - { name: trend4h, type: feature_map, doc: "4h trend structure (BULLISH/BEARISH/NEUTRAL)." }
88
+ - { name: momentum, type: feature_map, doc: "1h percent momentum." }
89
+ - { name: sm, type: feature_map, doc: "Smart-money long lean percent [0,100]." }
90
+ - { name: vol, type: feature_map, doc: "Recent/baseline volume ratio." }
91
+ - { name: rsi, type: feature_map, doc: "Trailing-window RSI [0,100] over 1h closes." }
92
+ outputs:
93
+ - { name: scores, type: score_map, doc: "Per-asset {score, direction, reasons[]}." }
94
+ params:
95
+ - { name: min_score, type: float, default: 5, doc: "Minimum conviction to keep a candidate." }
96
+ fn: |
97
+ def score_conviction(trend4h, momentum, sm, vol, rsi, min_score):
98
+ # Bison (tier 2): direction is the ONLY gate — a 4h-trend -> SM-lean -> 1h-momentum
99
+ # WATERFALL — after which every signal is a weighted SCORE CONTRIBUTOR, not a hard
100
+ # gate. Ported from bison/main/scanners/scoring.py build_thesis (9-component
101
+ # conviction); if no direction resolves the asset is skipped (v2 returns None). The
102
+ # RSI component reproduces build_thesis's rsi rule verbatim: overbought/oversold
103
+ # penalises, room rewards (>72/<55 for LONG; <28/>45 for SHORT).
104
+ scores = {}
105
+ assets = sorted(set(trend4h) | set(momentum) | set(sm) | set(vol) | set(rsi))
106
+ for asset in assets:
107
+ t4 = trend4h.get(asset, "NEUTRAL")
108
+ lean = sm.get(asset)
109
+ mom = momentum.get(asset, 0.0)
110
+ if t4 == "BULLISH":
111
+ direction = "LONG"
112
+ elif t4 == "BEARISH":
113
+ direction = "SHORT"
114
+ elif lean is not None and lean >= 55.0:
115
+ direction = "LONG"
116
+ elif lean is not None and lean <= 45.0:
117
+ direction = "SHORT"
118
+ elif mom > 0.0:
119
+ direction = "LONG"
120
+ elif mom < 0.0:
121
+ direction = "SHORT"
122
+ else:
123
+ continue
124
+ score = 0.0
125
+ reasons = []
126
+ if (direction == "LONG" and t4 == "BULLISH") or (direction == "SHORT" and t4 == "BEARISH"):
127
+ score = score + 4.0
128
+ reasons.append("trend4h_aligned")
129
+ if (direction == "LONG" and mom > 0.0) or (direction == "SHORT" and mom < 0.0):
130
+ score = score + 3.0
131
+ reasons.append("momentum_aligned")
132
+ if lean is not None:
133
+ aligned = lean >= 55.0 if direction == "LONG" else (100.0 - lean) >= 55.0
134
+ if aligned:
135
+ score = score + 2.0
136
+ reasons.append("sm_aligned")
137
+ if vol.get(asset, 0.0) >= 1.0:
138
+ score = score + 1.0
139
+ reasons.append("volume_ok")
140
+ r = rsi.get(asset)
141
+ if r is not None:
142
+ if direction == "LONG":
143
+ if r > 72.0:
144
+ score = score - 1.0
145
+ reasons.append("rsi_overbought")
146
+ elif r < 55.0:
147
+ score = score + 1.0
148
+ reasons.append("rsi_room")
149
+ else:
150
+ if r < 28.0:
151
+ score = score - 1.0
152
+ reasons.append("rsi_oversold")
153
+ elif r > 45.0:
154
+ score = score + 1.0
155
+ reasons.append("rsi_room")
156
+ if score < min_score:
157
+ continue
158
+ scores[asset] = {"score": round(score, 4), "direction": direction, "reasons": reasons}
159
+ return scores
160
+ tests:
161
+ - name: full_conviction_long
162
+ inputs:
163
+ trend4h: { BTC: BULLISH }
164
+ momentum: { BTC: 10.0 }
165
+ sm: { BTC: 60.0 }
166
+ vol: { BTC: 1.0 }
167
+ rsi: { BTC: 50.0 }
168
+ expect:
169
+ scores:
170
+ BTC: { score: 11.0, direction: LONG, reasons: [trend4h_aligned, momentum_aligned, sm_aligned, volume_ok, rsi_room] }
171
+ - name: no_direction_skips
172
+ inputs:
173
+ trend4h: { BTC: NEUTRAL }
174
+ momentum: { BTC: 0.0 }
175
+ sm: { BTC: 50.0 }
176
+ vol: { BTC: 1.0 }
177
+ rsi: { BTC: 50.0 }
178
+ expect:
179
+ scores: {}
180
+
181
+ # --- ranker + emitter ---
182
+ - id: best
183
+ node: emit_single_best
184
+ with:
185
+ min_score: 0 # the pure_fn already applies conviction_scorer's conviction floor
186
+
187
+ - id: emit
188
+ node: signal_envelope
189
+ with:
190
+ margin_pct: 5 # PERCENT of withdrawable (conviction_scorer base margin)
191
+ leverage: 7 # conviction_scorer conviction leverage clamp floor [7,10]
192
+
193
+ edges:
194
+ # universe narrowing chain
195
+ - { from: account.held, to: exclude_held.held }
196
+ - { from: exclude_held.kept, to: exclude_recent.candidates }
197
+ - { from: dedup_state.recent, to: exclude_recent.recent }
198
+ - { from: exclude_recent.kept, to: candles.assets }
199
+ - { from: exclude_recent.kept, to: lean.assets }
200
+ # candles fan out to the indicators
201
+ - { from: candles.candles, to: trend4h.candles }
202
+ - { from: candles.candles, to: mom1h.candles }
203
+ - { from: candles.candles, to: vol.candles }
204
+ - { from: candles.candles, to: rsi1h.candles }
205
+ # each indicator wires into ONE declared pure_fn input port (single writer, no merge)
206
+ - { from: trend4h.direction, to: score.trend4h }
207
+ - { from: mom1h.pct, to: score.momentum }
208
+ - { from: lean.lean, to: score.sm }
209
+ - { from: vol.ratio, to: score.vol }
210
+ - { from: rsi1h.rsi, to: score.rsi }
211
+ # score -> rank -> emit
212
+ - { from: score.scores, to: best.scores }
213
+ - { from: best.selected, to: emit.selected }
214
+ - { from: account.equity, to: emit.equity }
215
+ # close the dedup loop: this tick's signals are recorded in ctx.state
216
+ - { from: emit.signals, to: dedup_state.emitted }
@@ -0,0 +1,247 @@
1
+ # macro_thesis_basket — macro long/short basket strategy (tier 1), as a typed dataflow DAG.
2
+ # This is the one corpus strategy that RANKS A POOL against OPEN SLOTS rather than emitting
3
+ # a single best (recon §2/§3): it scores every basket name's CONFIRMATION of a preset-fixed
4
+ # direction and emits the top (maxSlots - held) confirming names. Re-expression of corpus
5
+ # package thesis-fund; semantics cited to skills/strategies/thesis-fund/...
6
+ #
7
+ # Slice-5 deliverables exercised here:
8
+ # - the discovery/validation source `instrument_list` + filter `live_instrument_validation`
9
+ # (macro_thesis_basket only scores names live on the venue board; corpus scan.py _get_universe_meta
10
+ # + the "not on live instrument board, skipping" guard).
11
+ # - the source `own_return_24h` (24h markPx/prevDayPx return, corpus ret_24h), consumed as
12
+ # a distinct momentum feature.
13
+ # - the indicator `rsi` (corpus scoring.calc_rsi, the RSI-room confirmation component).
14
+ # - the ranker `rank_pool_cap_to_open_slots` (corpus rank-then-cap-to-open-slots) — the
15
+ # NEW second ranker the recon named.
16
+ # - the universal `ttl_dedup_state` on ctx.state (corpus 180s recent-signal dedup).
17
+ #
18
+ # Why a pure_fn scorer (D2 kind 2): macro_thesis_basket's direction is FIXED PER LEG (a long-basket
19
+ # name is LONG, a short-basket name is SHORT — NOT derived), and a name whose 4h structure
20
+ # OPPOSES its leg is dropped ("don't fight the tape"). This per-leg, per-asset confirmation is
21
+ # bespoke scoring, which by the settled stance (2026-07-15) is ALWAYS a pure_fn node, never a
22
+ # scorer primitive — so the cross-feature score_thesis math is a purity-checked pure_fn
23
+ # (mirrors the original's scoring.score_thesis; cited in the body), exactly as the plan permits.
24
+
25
+ version: 1
26
+
27
+ metadata:
28
+ archetype: thesis_fund
29
+ strategy: macro_thesis_basket
30
+ corpus_origin: thesis-fund # provenance only, for fidelity replay — not user-facing
31
+ exit_preset: protected_standard # slice 9: emitted as runtime.yaml exit: (thesis-fund = balanced let-it-work)
32
+ # EDGE (verbatim, machine never interprets): "express a macro view as a fixed long/short
33
+ # basket; only add a name when the tape is confirming its side, and never hold more than
34
+ # my slot budget — rank the confirming names and fill the open slots with the strongest."
35
+
36
+ nodes:
37
+ # --- sources ---
38
+ - id: account
39
+ node: clearinghouse_state # held-asset set (open slots) + dual-DEX max() equity
40
+
41
+ - id: dedup_state
42
+ node: ttl_dedup_state # ctx.state TTL dedup; `recent` = assets within TTL
43
+ with:
44
+ ttl_seconds: 180 # macro_thesis_basket's 180s recent-signal window
45
+
46
+ - id: board
47
+ node: instrument_list # live venue board for live-instrument validation
48
+
49
+ # --- universe narrowing (held-exclusion, recent dedup, live-board validation) ---
50
+ - id: exclude_held
51
+ node: held_asset_exclusion # candidates default to the basket (candles whitelist)
52
+
53
+ - id: exclude_recent
54
+ node: recent_signal_ttl_dedup
55
+
56
+ - id: validate_live
57
+ node: live_instrument_validation # only score names still live on the venue board
58
+
59
+ # --- candle reads over the validated basket (asset_context for the 24h return) ---
60
+ - id: candles
61
+ node: candles_fetch
62
+ with:
63
+ whitelist: ["BTC", "ETH"] # the basket universe (long BTC / short ETH below)
64
+ intervals: ["1h", "4h"]
65
+ include_asset_context: true # markPx/prevDayPx for own_return_24h
66
+
67
+ # --- features feeding the confirmation scorer ---
68
+ - id: ret24
69
+ node: own_return_24h # 24h markPx/prevDayPx return (corpus ret_24h)
70
+
71
+ - id: trend4h
72
+ node: trend_structure
73
+ with:
74
+ lookback: 6
75
+ interval: "4h"
76
+
77
+ - id: trend1h
78
+ node: trend_structure
79
+ with:
80
+ lookback: 6
81
+ interval: "1h"
82
+
83
+ - id: rsi1h
84
+ node: rsi # corpus calc_rsi over 1h closes (RSI-room component)
85
+ with:
86
+ period: 4
87
+ interval: "1h"
88
+
89
+ # --- pure_fn confirmation scorer (fixed per-leg direction; D2 kind 2) ---
90
+ - id: score
91
+ kind: pure_fn
92
+ inputs:
93
+ - { name: trend4h, type: feature_map, doc: "4h trend structure (BULLISH/BEARISH/NEUTRAL)." }
94
+ - { name: trend1h, type: feature_map, doc: "1h trend structure (BULLISH/BEARISH/NEUTRAL)." }
95
+ - { name: rsi, type: feature_map, doc: "Trailing-window RSI [0,100] over 1h closes." }
96
+ - { name: own24h, type: feature_map, doc: "24h percent return (markPx vs prevDayPx)." }
97
+ outputs:
98
+ - { name: scores, type: score_map, doc: "Per-asset {score, direction, reasons[]} for confirming names." }
99
+ params:
100
+ - { name: long_basket, type: "list[str]", default: ["BTC"], doc: "Names whose fixed thesis direction is LONG." }
101
+ - { name: short_basket, type: "list[str]", default: ["ETH"], doc: "Names whose fixed thesis direction is SHORT." }
102
+ - { name: min_score, type: float, default: 4, doc: "Minimum confirmation score to keep a name." }
103
+ fn: |
104
+ def score_thesis_fund(trend4h, trend1h, rsi, own24h, long_basket, short_basket, min_score):
105
+ # MACRO THESIS BASKET (tier 1): direction is FIXED by the preset leg (long_basket -> LONG,
106
+ # short_basket -> SHORT), NOT derived. Ported from thesis-fund/main/scanners/
107
+ # scoring.py score_thesis: a name whose 4h structure OPPOSES its thesis leg is
108
+ # dropped ("don't fight the tape"); otherwise a weighted CONFIRMATION score
109
+ # (4h alignment, 1h alignment, 24h momentum, RSI room). The score only measures how
110
+ # strongly the market confirms the fixed direction.
111
+ scores = {}
112
+ longs = {str(a).upper() for a in long_basket}
113
+ shorts = {str(a).upper() for a in short_basket}
114
+ assets = sorted(set(trend4h) | set(trend1h) | set(rsi) | set(own24h))
115
+ for asset in assets:
116
+ au = str(asset).upper()
117
+ if au in longs:
118
+ direction = "LONG"
119
+ elif au in shorts:
120
+ direction = "SHORT"
121
+ else:
122
+ continue
123
+ t4 = trend4h.get(asset, "NEUTRAL")
124
+ t1 = trend1h.get(asset, "NEUTRAL")
125
+ r = rsi.get(asset, 50.0)
126
+ own = own24h.get(asset, 0.0)
127
+ score = 0.0
128
+ reasons = []
129
+ if direction == "LONG":
130
+ if t4 == "BEARISH":
131
+ continue
132
+ if t4 == "BULLISH":
133
+ score = score + 3.0
134
+ reasons.append("4h_bullish")
135
+ else:
136
+ score = score + 1.0
137
+ reasons.append("4h_neutral")
138
+ if t1 == "BULLISH":
139
+ score = score + 1.0
140
+ reasons.append("1h_bullish")
141
+ elif t1 == "BEARISH":
142
+ score = score - 1.0
143
+ reasons.append("1h_bearish")
144
+ if own >= 1.0:
145
+ score = score + 2.0
146
+ reasons.append("mom_strong")
147
+ elif own >= 0.0:
148
+ score = score + 1.0
149
+ reasons.append("mom")
150
+ if r < 78.0:
151
+ score = score + 1.0
152
+ reasons.append("rsi_room")
153
+ else:
154
+ if t4 == "BULLISH":
155
+ continue
156
+ if t4 == "BEARISH":
157
+ score = score + 3.0
158
+ reasons.append("4h_bearish")
159
+ else:
160
+ score = score + 1.0
161
+ reasons.append("4h_neutral")
162
+ if t1 == "BEARISH":
163
+ score = score + 1.0
164
+ reasons.append("1h_bearish")
165
+ elif t1 == "BULLISH":
166
+ score = score - 1.0
167
+ reasons.append("1h_bullish")
168
+ if own <= -1.0:
169
+ score = score + 2.0
170
+ reasons.append("mom_strong")
171
+ elif own <= 0.0:
172
+ score = score + 1.0
173
+ reasons.append("mom")
174
+ if r > 22.0:
175
+ score = score + 1.0
176
+ reasons.append("rsi_room")
177
+ if score < min_score:
178
+ continue
179
+ scores[asset] = {"score": round(score, 4), "direction": direction, "reasons": reasons}
180
+ return scores
181
+ tests:
182
+ - name: long_leg_confirmed
183
+ inputs:
184
+ trend4h: { BTC: BULLISH }
185
+ trend1h: { BTC: BULLISH }
186
+ rsi: { BTC: 50.0 }
187
+ own24h: { BTC: 10.0 }
188
+ expect:
189
+ scores:
190
+ BTC: { score: 7.0, direction: LONG, reasons: [4h_bullish, 1h_bullish, mom_strong, rsi_room] }
191
+ - name: dont_fight_the_tape
192
+ inputs:
193
+ trend4h: { BTC: BEARISH }
194
+ trend1h: { BTC: BULLISH }
195
+ rsi: { BTC: 50.0 }
196
+ own24h: { BTC: 10.0 }
197
+ expect:
198
+ scores: {}
199
+ - name: short_leg_confirmed
200
+ inputs:
201
+ trend4h: { ETH: BEARISH }
202
+ trend1h: { ETH: BEARISH }
203
+ rsi: { ETH: 50.0 }
204
+ own24h: { ETH: -10.0 }
205
+ expect:
206
+ scores:
207
+ ETH: { score: 7.0, direction: SHORT, reasons: [4h_bearish, 1h_bearish, mom_strong, rsi_room] }
208
+
209
+ # --- ranker: rank the confirming pool, cap to open slots (maxSlots - held) ---
210
+ - id: rank
211
+ node: rank_pool_cap_to_open_slots
212
+ with:
213
+ max_slots: 2 # emit the top (2 - held) confirming names
214
+ min_score: 0 # the pure_fn already applies macro_thesis_basket's floor
215
+
216
+ # --- emitter ---
217
+ - id: emit
218
+ node: signal_envelope
219
+ with:
220
+ margin_pct: 12 # PERCENT of withdrawable (macro_thesis_basket flat 12%)
221
+ leverage: 5 # flat 5x (venue clamp is a no-op without a venue feature)
222
+
223
+ edges:
224
+ # universe narrowing chain (basket -> held-excl -> recent-dedup -> live-validate)
225
+ - { from: account.held, to: exclude_held.held }
226
+ - { from: exclude_held.kept, to: exclude_recent.candidates }
227
+ - { from: dedup_state.recent, to: exclude_recent.recent }
228
+ - { from: exclude_recent.kept, to: validate_live.candidates }
229
+ - { from: board.instruments, to: validate_live.instruments }
230
+ - { from: validate_live.kept, to: candles.assets }
231
+ # candles fan out to the confirmation features
232
+ - { from: candles.candles, to: ret24.candles }
233
+ - { from: candles.candles, to: trend4h.candles }
234
+ - { from: candles.candles, to: trend1h.candles }
235
+ - { from: candles.candles, to: rsi1h.candles }
236
+ # each feature wires into ONE declared pure_fn input port (single writer, no merge)
237
+ - { from: trend4h.direction, to: score.trend4h }
238
+ - { from: trend1h.direction, to: score.trend1h }
239
+ - { from: rsi1h.rsi, to: score.rsi }
240
+ - { from: ret24.return_24h, to: score.own24h }
241
+ # score -> rank-pool-cap-to-slots -> emit
242
+ - { from: score.scores, to: rank.scores }
243
+ - { from: account.held, to: rank.held }
244
+ - { from: rank.selected, to: emit.selected }
245
+ - { from: account.equity, to: emit.equity }
246
+ # close the dedup loop: this tick's signals are recorded in ctx.state
247
+ - { from: emit.signals, to: dedup_state.emitted }