@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,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 }
@@ -0,0 +1,204 @@
1
+ # pre_listing_classifier — new-listing/IPOP strategy (tier 2) whose direction CLASSIFIER
2
+ # is a per-graph pure_fn node (D2 kind 2).
3
+ #
4
+ # Why a pure_fn here (the slice-4 point): this strategy's pre-listing thesis is a CLASSIFIER
5
+ # PREDICATE — the 4h structure classifies tradeable direction (NEUTRAL is dropped), the 1h
6
+ # structure and smart money CONFIRM, and because SM data is sparse pre-listing its ABSENCE
7
+ # falls back to a trend-only assumed-aligned bonus. That "SM present -> must agree; SM absent
8
+ # -> assume aligned" branch is cross-feature conditional logic no registry scorer expresses.
9
+ # The fn body re-expresses the corpus package magpie's `build_thesis_pre_listing`
10
+ # (corpus: /Users/duncan/Senpi/skills/strategies/magpie/pre_listing/scanners/scoring.py) —
11
+ # cited in the body — statically purity-checked, with inline fixture tests.
12
+ #
13
+ # DYNAMIC UNIVERSE (slice 5, restored): pre_listing_classifier's real universe is discovered each tick off
14
+ # the LIVE market_list_instruments read by its IPOP funding signature (corpus
15
+ # magpie/pre_listing/scanners/scan.py _fetch_ipop_universe + scoring.ipop_passes_universe).
16
+ # The slice-4 whitelist stand-in is replaced by the discovery trio:
17
+ # instrument_list (live board records) -> classifier_predicate (funding + leverage +
18
+ # not-delisted, the ipop_passes_universe funding portion) -> universe_discover (xyz: prefix
19
+ # + leverage cap + min daily volume) -> the tradeable IPOP names.
20
+ # Chaining the two predicates reproduces ipop_passes_universe's full conjunction while using
21
+ # each registry node as declared (no registry change). emit_single_best is used because the
22
+ # discovered pre-listing universe is naturally tiny (recon: "magpie-implicit-via-small-N").
23
+
24
+ version: 1
25
+
26
+ metadata:
27
+ archetype: event_new_listing
28
+ strategy: pre_listing_classifier
29
+ corpus_origin: magpie # provenance only, for fidelity replay — not user-facing
30
+ exit_preset: protected_standard # slice 9: emitted as runtime.yaml exit: (magpie = balanced discovery)
31
+ # EDGE (verbatim, machine never interprets): "catch trade.xyz pre-IPO perps as they start
32
+ # trending into the listing — 4h sets the side, 1h and smart money confirm, and when the
33
+ # book is too new for smart-money data, trust the trend."
34
+
35
+ nodes:
36
+ # --- sources ---
37
+ - id: account
38
+ node: clearinghouse_state
39
+
40
+ - id: dedup_state
41
+ node: ttl_dedup_state
42
+ with:
43
+ ttl_seconds: 720 # pre_listing_classifier's 720s per-coin dedup window
44
+
45
+ # --- dynamic universe discovery (the IPOP funding signature; see header) ---
46
+ - id: board
47
+ node: instrument_list # live market_list_instruments records for the xyz venue
48
+ with:
49
+ dex: "xyz"
50
+
51
+ - id: ipop_funding
52
+ node: classifier_predicate # ipop_passes_universe: |funding| tiny, lev<=5, live
53
+ with:
54
+ max_funding_abs: 0.0000001 # magpie ipopFundingMaxAbs 1e-7
55
+ max_leverage: 5 # magpie ipopMaxLeverageCap
56
+ exclude_delisted: true
57
+
58
+ - id: ipop_universe
59
+ node: universe_discover # xyz: prefix + leverage cap + min daily volume floor
60
+ with:
61
+ name_prefix: "xyz:"
62
+ max_leverage_cap: 5
63
+ min_day_volume: 100000 # magpie ipopMinDailyVolUsd
64
+
65
+ # --- universe narrowing over the discovered IPOP set ---
66
+ - id: exclude_held
67
+ node: held_asset_exclusion
68
+
69
+ - id: exclude_recent
70
+ node: recent_signal_ttl_dedup
71
+
72
+ # --- candle + leaderboard reads over the discovered, narrowed universe ---
73
+ - id: candles
74
+ node: candles_fetch
75
+ with:
76
+ intervals: ["1h", "4h"] # universe is wired in (dynamic), not a static whitelist
77
+
78
+ - id: lean
79
+ node: sm_lean
80
+ with:
81
+ agree_threshold: 0.55 # magpie smTiltMinPct 55%
82
+
83
+ # --- trend structure at both intervals (the classifier's inputs) ---
84
+ - id: trend4h
85
+ node: trend_structure
86
+ with:
87
+ lookback: 6
88
+ interval: "4h"
89
+
90
+ - id: trend1h
91
+ node: trend_structure
92
+ with:
93
+ lookback: 6
94
+ interval: "1h"
95
+
96
+ # --- pure_fn classifier predicate (the bespoke, cross-feature edge; D2 kind 2) ---
97
+ - id: classify
98
+ kind: pure_fn
99
+ inputs:
100
+ - { name: trend4h, type: feature_map, doc: "4h trend structure (BULLISH/BEARISH/NEUTRAL)." }
101
+ - { name: trend1h, type: feature_map, doc: "1h trend structure (BULLISH/BEARISH/NEUTRAL)." }
102
+ - { name: sm, type: feature_map, doc: "Smart-money long lean percent [0,100]; may be absent." }
103
+ outputs:
104
+ - { name: scores, type: score_map, doc: "Per-asset {score, direction, reasons[]} for tradeable IPOPs." }
105
+ params:
106
+ - { name: min_score, type: float, default: 3, doc: "Minimum pre-listing score to emit." }
107
+ fn: |
108
+ def classify_pre_listing(trend4h, trend1h, sm, min_score):
109
+ # Magpie PRE-LISTING (tier 2): a CLASSIFIER PREDICATE, not a weighted scorer. The
110
+ # 4h structure classifies the side (NEUTRAL -> not tradeable, dropped); 1h + SM
111
+ # confirm; SM is SPARSE pre-listing, so its ABSENCE falls back to trend-only
112
+ # (assumed aligned) while a PRESENT-but-opposing SM read drops the candidate.
113
+ # Ported from magpie/pre_listing/scanners/scoring.py build_thesis_pre_listing.
114
+ scores = {}
115
+ assets = sorted(set(trend4h) | set(trend1h) | set(sm))
116
+ for asset in assets:
117
+ t4 = trend4h.get(asset, "NEUTRAL")
118
+ if t4 == "NEUTRAL":
119
+ continue
120
+ direction = "LONG" if t4 == "BULLISH" else "SHORT"
121
+ score = 3.0
122
+ reasons = ["4h_" + t4.lower()]
123
+ t1 = trend1h.get(asset, "NEUTRAL")
124
+ if (direction == "LONG" and t1 == "BULLISH") or (direction == "SHORT" and t1 == "BEARISH"):
125
+ score = score + 2.0
126
+ reasons.append("1h_confirms")
127
+ lean = sm.get(asset)
128
+ if lean is None:
129
+ score = score + 2.0
130
+ reasons.append("sm_sparse_assumed_aligned")
131
+ else:
132
+ tilt = lean if direction == "LONG" else 100.0 - lean
133
+ if tilt < 55.0:
134
+ continue
135
+ score = score + 2.0
136
+ reasons.append("sm_aligned")
137
+ if tilt >= 70.0:
138
+ score = score + 1.0
139
+ reasons.append("sm_strong")
140
+ if score < min_score:
141
+ continue
142
+ scores[asset] = {"score": round(score, 4), "direction": direction, "reasons": reasons}
143
+ return scores
144
+ tests:
145
+ - name: bullish_confirmed_long
146
+ inputs:
147
+ trend4h: { ACME: BULLISH }
148
+ trend1h: { ACME: BULLISH }
149
+ sm: { ACME: 60.0 }
150
+ expect:
151
+ scores:
152
+ ACME: { score: 7.0, direction: LONG, reasons: [4h_bullish, 1h_confirms, sm_aligned] }
153
+ - name: neutral_4h_not_tradeable
154
+ inputs:
155
+ trend4h: { ACME: NEUTRAL }
156
+ trend1h: { ACME: BULLISH }
157
+ sm: { ACME: 60.0 }
158
+ expect:
159
+ scores: {}
160
+ - name: sparse_sm_trend_only
161
+ inputs:
162
+ trend4h: { ACME: BULLISH }
163
+ trend1h: { ACME: NEUTRAL }
164
+ sm: {}
165
+ expect:
166
+ scores:
167
+ ACME: { score: 5.0, direction: LONG, reasons: [4h_bullish, sm_sparse_assumed_aligned] }
168
+
169
+ # --- ranker + emitter ---
170
+ - id: best
171
+ node: emit_single_best
172
+ with:
173
+ min_score: 0 # the pure_fn already applies pre_listing_classifier's score floor
174
+
175
+ - id: emit
176
+ node: signal_envelope
177
+ with:
178
+ margin_pct: 10 # PERCENT of withdrawable
179
+ leverage: 5 # pre_listing_classifier's IPOP venue max is <=5
180
+
181
+ edges:
182
+ # dynamic universe discovery: board -> funding signature -> prefix/leverage/volume
183
+ - { from: board.instruments, to: ipop_funding.candidates }
184
+ - { from: ipop_funding.kept, to: ipop_universe.instruments }
185
+ # universe narrowing chain over the discovered IPOP names
186
+ - { from: ipop_universe.universe, to: exclude_held.candidates }
187
+ - { from: account.held, to: exclude_held.held }
188
+ - { from: exclude_held.kept, to: exclude_recent.candidates }
189
+ - { from: dedup_state.recent, to: exclude_recent.recent }
190
+ - { from: exclude_recent.kept, to: candles.assets }
191
+ - { from: exclude_recent.kept, to: lean.assets }
192
+ # candles fan out to trend structure at both intervals
193
+ - { from: candles.candles, to: trend4h.candles }
194
+ - { from: candles.candles, to: trend1h.candles }
195
+ # each feature wires into ONE declared pure_fn input port (single writer, no merge)
196
+ - { from: trend4h.direction, to: classify.trend4h }
197
+ - { from: trend1h.direction, to: classify.trend1h }
198
+ - { from: lean.lean, to: classify.sm }
199
+ # classify -> rank -> emit
200
+ - { from: classify.scores, to: best.scores }
201
+ - { from: best.selected, to: emit.selected }
202
+ - { from: account.equity, to: emit.equity }
203
+ # close the dedup loop
204
+ - { from: emit.signals, to: dedup_state.emitted }