@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.
- package/dist/cli/composer-commands.d.ts +37 -0
- package/dist/cli/composer-commands.d.ts.map +1 -0
- package/dist/cli/composer-commands.js +396 -0
- package/dist/cli/composer-commands.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +2 -0
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/validate-runtime-config.mjs +190 -0
- package/dist/composer/AUTHORING.md +435 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +59 -0
- package/dist/composer/authoring/answers.py +297 -0
- package/dist/composer/authoring/archetypes/__init__.py +26 -0
- package/dist/composer/authoring/archetypes/_common.py +410 -0
- package/dist/composer/authoring/archetypes/breakout.py +134 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +76 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +132 -0
- package/dist/composer/authoring/builder.py +191 -0
- package/dist/composer/authoring/emit.py +264 -0
- package/dist/composer/authoring/gaps.py +115 -0
- package/dist/composer/authoring/generate.py +131 -0
- package/dist/composer/authoring/presets.py +168 -0
- package/dist/composer/authoring/runtime_emit.py +266 -0
- package/dist/composer/authoring/strategy_manifest.py +94 -0
- package/dist/composer/cli.py +487 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +739 -0
- package/dist/composer/examples/breakout_swing.graph.yaml +121 -0
- package/dist/composer/examples/conviction_scorer.graph.yaml +216 -0
- package/dist/composer/examples/macro_thesis_basket.graph.yaml +247 -0
- package/dist/composer/examples/pre_listing_classifier.graph.yaml +204 -0
- package/dist/composer/examples/trend_momentum_majors.graph.yaml +133 -0
- package/dist/composer/generated/README.md +22 -0
- package/dist/composer/generated/catalog.md +118 -0
- package/dist/composer/generated/describe.md +1168 -0
- package/dist/composer/generated/graph.schema.json +1946 -0
- package/dist/composer/primitives/__init__.py +320 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +363 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +326 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +979 -0
- package/dist/composer/primitives/stateful.py +254 -0
- package/dist/composer/pure_fn/__init__.py +34 -0
- package/dist/composer/pure_fn/checks.py +39 -0
- package/dist/composer/pure_fn/models.py +177 -0
- package/dist/composer/pure_fn/purity.py +311 -0
- package/dist/composer/pure_fn/runtests.py +155 -0
- package/dist/composer/registry/__init__.py +40 -0
- package/dist/composer/registry/generate.py +425 -0
- package/dist/composer/registry/mcp-tools-snapshot.json +193 -0
- package/dist/composer/registry/mcp_closure.py +311 -0
- package/dist/composer/registry/models.py +417 -0
- package/dist/composer/registry/nodes.py +1297 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/scaffold_path.py +118 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +34 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +227 -0
- package/dist/composer/validator/graph_checks.py +443 -0
- package/dist/composer/validator/loader.py +110 -0
- package/dist/composer/validator/schema_checks.py +147 -0
- package/dist/composer/validator/validate.py +86 -0
- package/dist/composer/vendoring.py +103 -0
- package/dist/composer/verbs/__init__.py +49 -0
- package/dist/composer/verbs/_box.py +308 -0
- package/dist/composer/verbs/check.py +254 -0
- package/dist/composer/verbs/close.py +494 -0
- package/dist/composer/verbs/deploy.py +279 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/fund.py +498 -0
- package/dist/composer/verbs/install.py +586 -0
- package/dist/composer/verbs/runtime_validate.py +422 -0
- package/dist/composer/verbs/smoke.py +134 -0
- package/dist/composer/verbs/status.py +383 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -9
- package/dist/index.js.map +1 -1
- package/dist/runtime/load-runtime-config.d.ts +17 -0
- package/dist/runtime/load-runtime-config.d.ts.map +1 -1
- package/dist/runtime/load-runtime-config.js +2 -2
- package/dist/runtime/load-runtime-config.js.map +1 -1
- package/dist/runtime-api/config.d.ts +2 -2
- package/dist/runtime-api/config.js +4 -4
- package/dist/runtime-api/config.js.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.d.ts +3 -5
- package/dist/runtime-api/handlers/ingest-signals.d.ts.map +1 -1
- package/dist/runtime-api/handlers/ingest-signals.js +5 -14
- package/dist/runtime-api/handlers/ingest-signals.js.map +1 -1
- package/dist/runtime-api/intake/intake-mount.d.ts.map +1 -1
- package/dist/runtime-api/intake/intake-mount.js +2 -8
- package/dist/runtime-api/intake/intake-mount.js.map +1 -1
- package/dist/skills-manager/manifest.d.ts.map +1 -1
- package/dist/skills-manager/manifest.js +1 -3
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -3
- package/package.json +4 -2
- package/dist/runtime-api/intake/ingest-mutex.d.ts +0 -16
- package/dist/runtime-api/intake/ingest-mutex.d.ts.map +0 -1
- package/dist/runtime-api/intake/ingest-mutex.js +0 -18
- 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
|