@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,326 @@
1
+ """indicators — pure per-asset indicator primitives (candles -> feature_map).
2
+
3
+ Each function takes the ``candles`` port value (``{asset: {interval: [candle]}}``)
4
+ plus the node's registry params, and returns one feature_map per declared output
5
+ port (``{asset: value}``). Pure compute, no I/O. Two exceptions take a non-candle input
6
+ (each with its own compiler emission branch): ``threshold_gate`` takes a feature_map (a raw
7
+ indicator output to threshold), and ``aggregate_bias`` takes a record_list of trader-state
8
+ records (the cross-trader cohort aggregation) — see their docstrings.
9
+
10
+ Convention (compiler + goldens rely on it): an asset with INSUFFICIENT data for the
11
+ indicator is OMITTED from the returned map(s). A downstream required gate that names
12
+ this feature then fails for that asset (missing key -> not truthy), which reproduces
13
+ the corpus originals' "return None -> no signal" behaviour without special-casing.
14
+
15
+ Semantics are ported from the corpus originals under
16
+ ``/Users/duncan/Senpi/skills/strategies/{stag,hawk}/main/scanners/scoring.py``
17
+ (cited per function). The per-primitive golden vectors WE author define correctness.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from ._candles import _closes, _high, _low, _pct_change, _vol
23
+
24
+
25
+ def sma(candles: dict, *, period: int, interval: str) -> tuple[dict, dict]:
26
+ """SMA of close + a close>SMA flag. Ports: value (feature_map), above (feature_map).
27
+
28
+ Ported from stag ``scoring.sma`` / ``is_above_sma``: mean of the last ``period``
29
+ closes; ``above`` is latest_close > SMA. Assets with < ``period`` closes are omitted.
30
+ """
31
+ value: dict = {}
32
+ above: dict = {}
33
+ for asset in sorted(candles):
34
+ closes = _closes(candles[asset].get(interval, []))
35
+ if len(closes) < period:
36
+ continue
37
+ window = closes[-period:]
38
+ s = sum(window) / period
39
+ value[asset] = s
40
+ above[asset] = closes[-1] > s
41
+ return value, above
42
+
43
+
44
+ def pct_change(candles: dict, *, lookback: int, interval: str) -> dict:
45
+ """Percent change of close over ``lookback`` bars. Port: pct (feature_map).
46
+
47
+ Ported from stag ``scoring.pct_change`` (7d trend %). Assets with <= ``lookback``
48
+ closes, or a non-positive reference price, are omitted.
49
+ """
50
+ out: dict = {}
51
+ for asset in sorted(candles):
52
+ closes = _closes(candles[asset].get(interval, []))
53
+ pct = _pct_change(closes, lookback)
54
+ if pct is not None:
55
+ out[asset] = pct
56
+ return out
57
+
58
+
59
+ def recent_high_bars_ago(candles: dict, *, window: int, interval: str) -> dict:
60
+ """Bars since the highest close in the window. Port: bars_ago (feature_map).
61
+
62
+ Ported from stag ``scoring.recent_high_bars_ago`` (fresh-high freshness check;
63
+ ``window`` maps to the original ``lookback``): 0 = the latest bar is the high.
64
+ Assets with <= ``window`` closes are omitted.
65
+ """
66
+ out: dict = {}
67
+ for asset in sorted(candles):
68
+ closes = _closes(candles[asset].get(interval, []))
69
+ if len(closes) <= window:
70
+ continue
71
+ win = closes[-(window + 1):]
72
+ high_idx = max(range(len(win)), key=lambda i: win[i])
73
+ out[asset] = len(win) - 1 - high_idx
74
+ return out
75
+
76
+
77
+ def volume_surge_ratio(candles: dict, *, recent_window: int, baseline_window: int, interval: str) -> dict:
78
+ """mean(recent volumes) / mean(baseline volumes). Port: ratio (feature_map).
79
+
80
+ Unifies stag ``scoring.volume_surge`` (ratio part), hawk ``volume_ratio`` and
81
+ bison ``volume_trend`` into one parameterized primitive. Assets with < baseline
82
+ volumes, or a non-positive baseline mean, are omitted.
83
+ """
84
+ out: dict = {}
85
+ for asset in sorted(candles):
86
+ vols = [_vol(c) for c in candles[asset].get(interval, [])]
87
+ if len(vols) < baseline_window:
88
+ continue
89
+ recent = vols[-recent_window:]
90
+ baseline = vols[-baseline_window:]
91
+ rmean = sum(recent) / len(recent) if recent else 0.0
92
+ bmean = sum(baseline) / len(baseline) if baseline else 0.0
93
+ if bmean <= 0:
94
+ continue
95
+ out[asset] = rmean / bmean
96
+ return out
97
+
98
+
99
+ def acceleration(candles: dict, *, short_window: int, long_window: int, interval: str) -> dict:
100
+ """Whether the short-window move is >= half the long-window move. Port: accelerating.
101
+
102
+ Ported from stag ``scoring.is_accelerating`` composed with ``pct_change``: the
103
+ recent half is keeping pace or faster, and the long move is positive (LONG-bias).
104
+ Assets where either move is uncomputable are omitted.
105
+ """
106
+ out: dict = {}
107
+ for asset in sorted(candles):
108
+ closes = _closes(candles[asset].get(interval, []))
109
+ short_move = _pct_change(closes, short_window)
110
+ long_move = _pct_change(closes, long_window)
111
+ if short_move is None or long_move is None:
112
+ continue
113
+ out[asset] = bool(long_move > 0 and short_move >= (long_move / 2.0))
114
+ return out
115
+
116
+
117
+ def trend_structure(candles: dict, *, lookback: int, interval: str) -> dict:
118
+ """Higher-lows / lower-highs structure. Port: direction (feature_map).
119
+
120
+ Ported VERBATIM from hawk ``scoring.trend_4h`` (the corpus' single most-duplicated
121
+ indicator): strict higher-low / lower-high counting, gated at ``>= (lookback-1)*0.6``.
122
+ Emits the corpus' BULLISH / BEARISH / NEUTRAL vocabulary (a deliberate deviation
123
+ from the registry prose's "LONG/SHORT/NEUTRAL": keeping this distinct from a trade
124
+ DIRECTION lets a graph wire both breakout_detect.direction AND trend_structure into
125
+ one scorer without the direction detector confusing the two). Assets with < lookback
126
+ candles are omitted.
127
+ """
128
+ out: dict = {}
129
+ for asset in sorted(candles):
130
+ series = candles[asset].get(interval, [])
131
+ if len(series) < lookback:
132
+ continue
133
+ lows = [_low(c) for c in series[-lookback:]]
134
+ highs = [_high(c) for c in series[-lookback:]]
135
+ higher_lows = sum(1 for i in range(1, len(lows)) if lows[i] > lows[i - 1])
136
+ lower_highs = sum(1 for i in range(1, len(highs)) if highs[i] < highs[i - 1])
137
+ total = lookback - 1
138
+ if higher_lows >= total * 0.6:
139
+ out[asset] = "BULLISH"
140
+ elif lower_highs >= total * 0.6:
141
+ out[asset] = "BEARISH"
142
+ else:
143
+ out[asset] = "NEUTRAL"
144
+ return out
145
+
146
+
147
+ def rsi(candles: dict, *, period: int, interval: str) -> dict:
148
+ """Relative Strength Index over the last ``period`` bars. Port: rsi (feature_map).
149
+
150
+ Ported VERBATIM from bison ``scoring.calc_rsi`` / thesis-fund ``scoring.calc_rsi``
151
+ (both use the trailing-window variant: the LAST ``period`` gains/losses). ``avg_l == 0``
152
+ -> 100.0. Assets with < ``period + 1`` closes are omitted (the corpus returns a neutral
153
+ 50 there; omission lets a downstream consumer supply its own default, per this module's
154
+ insufficient-data convention).
155
+ """
156
+ out: dict = {}
157
+ for asset in sorted(candles):
158
+ closes = _closes(candles[asset].get(interval, []))
159
+ if len(closes) < period + 1:
160
+ continue
161
+ gains: list = []
162
+ losses: list = []
163
+ for i in range(1, len(closes)):
164
+ d = closes[i] - closes[i - 1]
165
+ gains.append(max(0.0, d))
166
+ losses.append(max(0.0, -d))
167
+ g = gains[-period:]
168
+ l = losses[-period:]
169
+ avg_g = sum(g) / period
170
+ avg_l = sum(l) / period
171
+ if avg_l == 0:
172
+ out[asset] = 100.0
173
+ else:
174
+ out[asset] = 100.0 - (100.0 / (1.0 + avg_g / avg_l))
175
+ return out
176
+
177
+
178
+ _THRESHOLD_OPS = {
179
+ "lt": lambda a, b: a < b,
180
+ "lte": lambda a, b: a <= b,
181
+ "gt": lambda a, b: a > b,
182
+ "gte": lambda a, b: a >= b,
183
+ }
184
+
185
+
186
+ def threshold_gate(values: dict, *, op: str, value: float) -> dict:
187
+ """Compare a per-asset numeric feature against a threshold. Port: passed (feature_map).
188
+
189
+ Takes a feature_map ``{asset: value}`` (a RAW indicator output — e.g.
190
+ recent_high_bars_ago's ``bars_ago``) and emits ``1.0`` where ``value <op> threshold``
191
+ holds, ``0.0`` otherwise, so the result reads as a truthy/falsy gate the scorer's
192
+ ``required_gates`` can consume. ``op`` is one of ``lt|lte|gt|gte``. A missing key is
193
+ simply absent from the output (the downstream gate then fails for that asset, the
194
+ module's insufficient-data convention); a present ``None`` (or a non-numeric value)
195
+ maps to ``0.0`` — never a crash. This closes the C1 inversion where a raw bars-ago
196
+ feature was hard-gated on TRUTHINESS (a fresh 0-bars-ago high read as falsy and FAILED,
197
+ a stale 40-bar-old high read as truthy and PASSED); a real threshold makes ``bars_ago
198
+ <= 5`` mean "fresh".
199
+ """
200
+ cmp = _THRESHOLD_OPS.get(op)
201
+ if cmp is None:
202
+ raise ValueError(
203
+ f"threshold_gate: unknown op {op!r}; choose one of {sorted(_THRESHOLD_OPS)}"
204
+ )
205
+ out: dict = {}
206
+ for asset in sorted(values or {}):
207
+ raw = values[asset]
208
+ if raw is None:
209
+ out[asset] = 0.0
210
+ continue
211
+ try:
212
+ fv = float(raw)
213
+ except (TypeError, ValueError):
214
+ out[asset] = 0.0
215
+ continue
216
+ out[asset] = 1.0 if cmp(fv, value) else 0.0
217
+ return out
218
+
219
+
220
+ def breakout_detect(candles: dict, *, window: int, interval: str) -> dict:
221
+ """Directional breakout of the prior-window close high/low. Port: direction.
222
+
223
+ Ported from hawk ``scoring.detect_breakout``: LONG if the latest close breaks the
224
+ max of the PRIOR ``window`` closes (the latest bar excluded from its own range),
225
+ SHORT if it breaks the min, else NEUTRAL. Emits LONG / SHORT / NEUTRAL (a trade
226
+ direction; the scorer's direction detector reads it). Assets with < window+1
227
+ closes, or a non-positive latest close, are omitted.
228
+ """
229
+ out: dict = {}
230
+ for asset in sorted(candles):
231
+ series = candles[asset].get(interval, [])
232
+ if len(series) < window + 1:
233
+ continue
234
+ win = series[-window:]
235
+ closes = _closes(win)
236
+ latest_close = closes[-1]
237
+ prior = closes[:-1]
238
+ if not prior or latest_close <= 0:
239
+ continue
240
+ high = max(prior)
241
+ low = min(prior)
242
+ if latest_close > high:
243
+ out[asset] = "LONG"
244
+ elif latest_close < low:
245
+ out[asset] = "SHORT"
246
+ else:
247
+ out[asset] = "NEUTRAL"
248
+ return out
249
+
250
+
251
+ # --------------------------------------------------------------------- aggregate_bias
252
+ # The ONE sanctioned promote-loop primitive (registry-mapping-audit.md:49-51): the cross-
253
+ # trader net/gross bias per coin, shared NEAR-VERBATIM by whalehunter (scoring.aggregate_bias
254
+ # fused) and chimp/gibbon/gorilla/orangutan (the split cohort_positions_bias + finalize_bias).
255
+ # Ported from whalehunter scoring.py:40-70. Takes a record_list of TraderState records (from
256
+ # trader_states_fetch, whose normalized rows carry open_positions[] with coin/szi/position_value/
257
+ # entry_px) and emits a per-coin feature_map — the indicator output contract, hence kind=indicator
258
+ # (a scorer must emit a score_map+reasons, which this does not). The concentration/consensus
259
+ # cohort packages (oxpecker/remora) DIVERGE — they score per-candidate top positions, not net/
260
+ # gross cohort bias — so only this common core is promoted; those stay pure_fn.
261
+
262
+
263
+ def _bias_f(x, *keys, default=0.0):
264
+ """Float from a dict trying each key in order (whalehunter scoring._f), tolerating the camel-
265
+ AND snake-case field names trader_states_fetch may surface."""
266
+ if not isinstance(x, dict):
267
+ return default
268
+ for k in keys:
269
+ if x.get(k) is not None:
270
+ try:
271
+ return float(x[k])
272
+ except (TypeError, ValueError):
273
+ continue
274
+ return default
275
+
276
+
277
+ def _signed_notional(p) -> float:
278
+ """Signed notional of one position: sign(szi) * |value|, value falling back to |szi|*entry
279
+ when positionValue is missing (whalehunter scoring._signed_notional, verbatim contract)."""
280
+ szi = _bias_f(p, "szi", "size")
281
+ val = _bias_f(p, "positionValue", "position_value", "notional")
282
+ if val <= 0:
283
+ val = abs(szi) * _bias_f(p, "entryPx", "entry_px", "markPx", "entry_price")
284
+ return (1.0 if szi > 0 else (-1.0 if szi < 0 else 0.0)) * abs(val)
285
+
286
+
287
+ def aggregate_bias(traders: list) -> dict:
288
+ """Aggregate a cohort's NET positioning per coin. Port: bias (feature_map).
289
+
290
+ Given a record_list of trader-state records, sums each coin's signed notional across every
291
+ trader's open positions and emits a per-coin record:
292
+ ``{coin: {net, gross, bias, n_long, n_short, direction}}`` where ``bias = net/gross`` in
293
+ [-1,+1] (+1 all long, -1 all short) and ``direction`` = LONG / SHORT / NEUTRAL from the sign
294
+ of net. n_long/n_short are the per-side member counts (whalehunter reads them as the confirm
295
+ count; the split cohort packages surface members = n_long+n_short, recoverable by summing).
296
+ Pure compute — no clock, no I/O. Both openPositions/open_positions and coin/asset field
297
+ spellings are tolerated so the node consumes trader_states_fetch's normalized rows directly.
298
+ """
299
+ per: dict = {}
300
+ for t in traders or []:
301
+ if not isinstance(t, dict):
302
+ continue
303
+ positions = t.get("open_positions") or t.get("openPositions") or []
304
+ for p in positions:
305
+ if not isinstance(p, dict):
306
+ continue
307
+ coin = p.get("coin") or p.get("asset")
308
+ if not coin:
309
+ continue
310
+ sn = _signed_notional(p)
311
+ if sn == 0:
312
+ continue
313
+ d = per.setdefault(coin, {"net": 0.0, "gross": 0.0, "n_long": 0, "n_short": 0})
314
+ d["net"] += sn
315
+ d["gross"] += abs(sn)
316
+ d["n_long" if sn > 0 else "n_short"] += 1
317
+ out: dict = {}
318
+ for coin in sorted(per):
319
+ d = per[coin]
320
+ bias = round(d["net"] / d["gross"], 3) if d["gross"] > 0 else 0.0
321
+ direction = "LONG" if d["net"] > 0 else ("SHORT" if d["net"] < 0 else "NEUTRAL")
322
+ out[coin] = {
323
+ "net": round(d["net"], 6), "gross": round(d["gross"], 6), "bias": bias,
324
+ "n_long": d["n_long"], "n_short": d["n_short"], "direction": direction,
325
+ }
326
+ return out
@@ -0,0 +1,40 @@
1
+ """rankers — selection primitives over a score_map.
2
+
3
+ ``emit_single_best`` is the stag/hawk/bison selector: sort descending, keep the top
4
+ one at/above the floor. Ported from stag/hawk ``candidates.sort(..., reverse=True)[0]``.
5
+ ``rank_pool_cap_to_open_slots`` is thesis-fund's rank-the-pool-then-cap selector: sort the
6
+ whole confirming pool descending and take the top ``max_slots - held`` — the one corpus
7
+ strategy that is not single-best.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ def emit_single_best(scores: dict, *, min_score: float) -> dict:
14
+ """Select the single highest-scoring candidate at/above ``min_score``. Port: selected.
15
+
16
+ Deterministic tie-break: highest ``score``, then asset name ascending. Returns a
17
+ score_map with at most one entry (``{}`` when nothing clears the floor).
18
+ """
19
+ eligible = [(a, e) for a, e in scores.items() if e.get("score", 0) >= min_score]
20
+ if not eligible:
21
+ return {}
22
+ # highest score first; ascending asset name as the deterministic tie-break.
23
+ eligible.sort(key=lambda item: (-item[1].get("score", 0), item[0]))
24
+ asset, entry = eligible[0]
25
+ return {asset: entry}
26
+
27
+
28
+ def rank_pool_cap_to_open_slots(scores: dict, held: list, *, max_slots: int, min_score: float) -> dict:
29
+ """Rank the confirming pool and cap emissions to the open slots. Port: selected.
30
+
31
+ Ported from thesis-fund ``scan.scan``: ``open_slots = max(0, max_slots - len(held))``;
32
+ sort all candidates at/above ``min_score`` by score desc (asset name asc tie-break) and
33
+ keep the top ``open_slots``. When every slot is already taken (open_slots == 0) the pool
34
+ is ranked but NOTHING is selected — the recon's "pool ranked but all open slots taken"
35
+ case. Returns a score_map with 0..open_slots entries.
36
+ """
37
+ eligible = [(a, e) for a, e in scores.items() if e.get("score", 0) >= min_score]
38
+ eligible.sort(key=lambda item: (-item[1].get("score", 0), item[0]))
39
+ open_slots = max(0, int(max_slots) - len(held or []))
40
+ return {a: e for a, e in eligible[:open_slots]}
@@ -0,0 +1,100 @@
1
+ """scorers — the weighted-sum-with-gates scorer primitive + the merge helper.
2
+
3
+ The corpus scorers are all "weighted sum + reasons[]" at the core, differing only in
4
+ the gate wrapper. ``hard_gates_all`` is the stag/hawk pattern (a set of hard gates,
5
+ then a weighted score with a min-score floor). Correctness is defined by the goldens
6
+ WE author (the graph re-expresses the strategy via registry primitives; it is not a
7
+ bit-transcription of stag's ``parabolic_score`` — see the POC plan slice-3 note).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+
13
+ def merge_features(feature_maps: dict) -> dict:
14
+ """Merge several feature_maps into one per-asset record keyed by SOURCE NODE ID.
15
+
16
+ ``feature_maps`` = ``{node_id: {asset: value}}`` (the compiler passes the wired
17
+ producers). Returns ``{asset: {node_id: value}}`` over the union of assets. Keying
18
+ by node id (not port name) is collision-free even when two producers share a port
19
+ name (e.g. breakout_detect.direction and trend_structure.direction), and it is what
20
+ a graph's ``required_gates`` / ``weights`` reference.
21
+ """
22
+ assets: set = set()
23
+ for fm in feature_maps.values():
24
+ assets.update(fm.keys())
25
+ merged: dict = {}
26
+ for asset in sorted(assets):
27
+ rec: dict = {}
28
+ for node_id in sorted(feature_maps):
29
+ fm = feature_maps[node_id]
30
+ if asset in fm:
31
+ rec[node_id] = fm[asset]
32
+ merged[asset] = rec
33
+ return merged
34
+
35
+
36
+ def _truthy(value) -> bool:
37
+ """Gate truthiness: a decisive, present feature. bool -> itself; number -> > 0;
38
+ str -> a decisive direction (not '' / 'NEUTRAL'); missing/None -> False."""
39
+ if isinstance(value, bool):
40
+ return value
41
+ if isinstance(value, (int, float)):
42
+ return value > 0
43
+ if isinstance(value, str):
44
+ return value.strip().upper() not in ("", "NEUTRAL")
45
+ return False
46
+
47
+
48
+ def _num(value) -> float:
49
+ """Scoring coercion: bool -> 1/0; number -> itself; decisive str -> 1.0 else 0.0;
50
+ missing/None -> 0.0."""
51
+ if isinstance(value, bool):
52
+ return 1.0 if value else 0.0
53
+ if isinstance(value, (int, float)):
54
+ return float(value)
55
+ if isinstance(value, str):
56
+ return 1.0 if _truthy(value) else 0.0
57
+ return 0.0
58
+
59
+
60
+ def _detect_direction(rec: dict) -> str:
61
+ """A candidate's trade direction = the unique feature whose value is a trade
62
+ direction ('LONG'/'SHORT'). None or ambiguous -> 'LONG' (LONG-only default)."""
63
+ dirs = {
64
+ v.strip().upper()
65
+ for v in rec.values()
66
+ if isinstance(v, str) and v.strip().upper() in ("LONG", "SHORT")
67
+ }
68
+ return dirs.pop() if len(dirs) == 1 else "LONG"
69
+
70
+
71
+ def hard_gates_all(features: dict, *, required_gates: list, weights: dict, min_score: float) -> dict:
72
+ """Require every named gate, then score survivors by a weighted sum. Port: scores.
73
+
74
+ ``features`` = merged ``{asset: {node_id: value}}``. For each asset: every key in
75
+ ``required_gates`` must be truthy; the score is ``sum(weights[k] * num(features[k]))``
76
+ over the (sorted) weight keys; keep the asset iff score >= ``min_score``. Emits a
77
+ ``score_map`` = ``{asset: {score, direction, reasons[], features}}``.
78
+ """
79
+ out: dict = {}
80
+ for asset in sorted(features):
81
+ rec = features[asset]
82
+ if not all(_truthy(rec.get(g)) for g in required_gates):
83
+ continue
84
+ score = 0.0
85
+ reasons: list = []
86
+ for gate in required_gates:
87
+ reasons.append(f"gate:{gate}")
88
+ for key in sorted(weights):
89
+ contribution = weights[key] * _num(rec.get(key))
90
+ score += contribution
91
+ reasons.append(f"{key}={round(contribution, 4)}")
92
+ if score < min_score:
93
+ continue
94
+ out[asset] = {
95
+ "score": round(score, 6),
96
+ "direction": _detect_direction(rec),
97
+ "reasons": reasons,
98
+ "features": dict(rec),
99
+ }
100
+ return out