@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.
- 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 +287 -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 +124 -0
- package/dist/composer/AUTHORING.md +233 -0
- package/dist/composer/__init__.py +29 -0
- package/dist/composer/authoring/__init__.py +49 -0
- package/dist/composer/authoring/answers.py +226 -0
- package/dist/composer/authoring/archetypes/__init__.py +25 -0
- package/dist/composer/authoring/archetypes/_common.py +218 -0
- package/dist/composer/authoring/archetypes/classifier.py +109 -0
- package/dist/composer/authoring/archetypes/skeleton.py +54 -0
- package/dist/composer/authoring/archetypes/thesis_fund.py +103 -0
- package/dist/composer/authoring/archetypes/trend_momentum.py +104 -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 +355 -0
- package/dist/composer/compiler/__init__.py +22 -0
- package/dist/composer/compiler/compile.py +550 -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 +84 -0
- package/dist/composer/generated/describe.md +703 -0
- package/dist/composer/generated/graph.schema.json +1203 -0
- package/dist/composer/primitives/__init__.py +239 -0
- package/dist/composer/primitives/_candles.py +76 -0
- package/dist/composer/primitives/adapter.py +120 -0
- package/dist/composer/primitives/emitters.py +142 -0
- package/dist/composer/primitives/filters.py +71 -0
- package/dist/composer/primitives/indicators.py +203 -0
- package/dist/composer/primitives/rankers.py +40 -0
- package/dist/composer/primitives/scorers.py +100 -0
- package/dist/composer/primitives/sources.py +405 -0
- package/dist/composer/primitives/stateful.py +52 -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 +422 -0
- package/dist/composer/registry/models.py +331 -0
- package/dist/composer/registry/nodes.py +673 -0
- package/dist/composer/requirements.txt +21 -0
- package/dist/composer/templates/__init__.py +13 -0
- package/dist/composer/templates/scan.py.j2 +31 -0
- package/dist/composer/validator/__init__.py +29 -0
- package/dist/composer/validator/desugar.py +133 -0
- package/dist/composer/validator/errors.py +191 -0
- package/dist/composer/validator/graph_checks.py +348 -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/verbs/__init__.py +33 -0
- package/dist/composer/verbs/check.py +193 -0
- package/dist/composer/verbs/deploy.py +223 -0
- package/dist/composer/verbs/docs.py +30 -0
- package/dist/composer/verbs/runtime_validate.py +262 -0
- package/dist/composer/verbs/smoke.py +115 -0
- package/dist/composer/verbs/status.py +205 -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-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.js +1 -1
- package/dist/skills-manager/manifest.js.map +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- 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,239 @@
|
|
|
1
|
+
"""primitives — the runtime-owned pure Python implementing the registry nodes (D2 §1).
|
|
2
|
+
|
|
3
|
+
Primitive nodes are the parameterized building blocks where all I/O and effects live
|
|
4
|
+
(sources, indicators, filters, rankers, emitters) plus the stateful TTL-dedup variant
|
|
5
|
+
whose state is a typed in/out port pair compiled onto ``ctx.state`` (D2). Their
|
|
6
|
+
implementations live here so both the compiled ``scan.py`` and, later, hand-written
|
|
7
|
+
tier-3 scanners can import them; the emitted scanner reaches this package through the
|
|
8
|
+
scaffold ``sys.path`` (recorded POC decision: no packaging yet, ``senpi_ta`` versioning
|
|
9
|
+
stays open post-POC).
|
|
10
|
+
|
|
11
|
+
Slice-3 scope: real implementations for every node the stag AND hawk graphs use —
|
|
12
|
+
3 sources, 7 indicators, 2 filters, 1 scorer, 1 ranker, 1 emitter, 1 stateful = 16.
|
|
13
|
+
Each is a pure function (or, for the sources, a pure normalizer paired with the thin
|
|
14
|
+
``adapter`` that does the I/O) over plain data structures. Semantics are ported from
|
|
15
|
+
the corpus originals (cited in each module); per-primitive golden vectors under
|
|
16
|
+
python-tests/composer define correctness.
|
|
17
|
+
|
|
18
|
+
``IMPLEMENTATIONS`` is the machine-checkable bridge between the registry declarations
|
|
19
|
+
and these functions: the registry<->implementation signature-agreement test asserts,
|
|
20
|
+
for every implemented node, that the callable's signature and the params/ports it
|
|
21
|
+
consumes match the registry NodeDef exactly.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from dataclasses import dataclass
|
|
27
|
+
from typing import Callable, Optional
|
|
28
|
+
|
|
29
|
+
from . import emitters, filters, indicators, rankers, scorers, sources, stateful
|
|
30
|
+
from .adapter import McpSourceAdapter, make_adapter
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class ImplSpec:
|
|
35
|
+
"""How one registry node maps onto its Python implementation.
|
|
36
|
+
|
|
37
|
+
fn_args — the callable's exact ordered parameter names (pins the signature).
|
|
38
|
+
param_args — registry param names the callable consumes.
|
|
39
|
+
adapter_params — registry param names consumed by the adapter / emitted code, not fn.
|
|
40
|
+
port_args — registry (non-state) input-port names the callable consumes.
|
|
41
|
+
out_ports — registry (non-state) output-port names the callable returns, in order.
|
|
42
|
+
write_fn/args — the WRITE half of a split stateful node (epilogue), if any.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
node: str
|
|
46
|
+
fn: Callable
|
|
47
|
+
fn_args: tuple = ()
|
|
48
|
+
param_args: tuple = ()
|
|
49
|
+
adapter_params: tuple = ()
|
|
50
|
+
port_args: tuple = ()
|
|
51
|
+
out_ports: tuple = ()
|
|
52
|
+
write_fn: Optional[Callable] = None
|
|
53
|
+
write_args: tuple = ()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
IMPLEMENTATIONS: dict[str, ImplSpec] = {
|
|
57
|
+
# ---- sources (pure normalizer + adapter-fetched raw) --------------------
|
|
58
|
+
"candles_fetch": ImplSpec(
|
|
59
|
+
node="candles_fetch", fn=sources.candles_fetch,
|
|
60
|
+
fn_args=("raw_by_asset", "intervals"),
|
|
61
|
+
param_args=("intervals",),
|
|
62
|
+
adapter_params=("whitelist", "include_funding", "include_asset_context"),
|
|
63
|
+
port_args=(), out_ports=("candles",),
|
|
64
|
+
),
|
|
65
|
+
"clearinghouse_state": ImplSpec(
|
|
66
|
+
node="clearinghouse_state", fn=sources.clearinghouse_state,
|
|
67
|
+
fn_args=("raw",), out_ports=("held", "equity"),
|
|
68
|
+
),
|
|
69
|
+
"sm_lean": ImplSpec(
|
|
70
|
+
node="sm_lean", fn=sources.sm_lean,
|
|
71
|
+
fn_args=("raw", "assets", "agree_threshold"),
|
|
72
|
+
param_args=("agree_threshold",), port_args=("assets",), out_ports=("lean",),
|
|
73
|
+
),
|
|
74
|
+
"instrument_list": ImplSpec(
|
|
75
|
+
node="instrument_list", fn=sources.instrument_list,
|
|
76
|
+
fn_args=("raw",), adapter_params=("dex",), out_ports=("instruments",),
|
|
77
|
+
),
|
|
78
|
+
"universe_discover": ImplSpec(
|
|
79
|
+
node="universe_discover", fn=sources.universe_discover,
|
|
80
|
+
fn_args=("instruments", "min_day_volume", "max_leverage_cap", "name_prefix"),
|
|
81
|
+
param_args=("min_day_volume", "max_leverage_cap", "name_prefix"),
|
|
82
|
+
port_args=("instruments",), out_ports=("universe",),
|
|
83
|
+
),
|
|
84
|
+
"own_return_24h": ImplSpec(
|
|
85
|
+
node="own_return_24h", fn=sources.own_return_24h,
|
|
86
|
+
fn_args=("candles",), port_args=("candles",), out_ports=("return_24h",),
|
|
87
|
+
),
|
|
88
|
+
# ---- batch C: world-coverage sources ------------------------------------
|
|
89
|
+
"market_prices_fetch": ImplSpec(
|
|
90
|
+
node="market_prices_fetch", fn=sources.market_prices,
|
|
91
|
+
fn_args=("raw", "assets"),
|
|
92
|
+
adapter_params=("dex",), port_args=("assets",), out_ports=("prices",),
|
|
93
|
+
),
|
|
94
|
+
"strategy_list_fetch": ImplSpec(
|
|
95
|
+
node="strategy_list_fetch", fn=sources.strategy_list,
|
|
96
|
+
fn_args=("raw",), adapter_params=("status", "strategy_type"),
|
|
97
|
+
out_ports=("strategies",),
|
|
98
|
+
),
|
|
99
|
+
"open_orders_fetch": ImplSpec(
|
|
100
|
+
node="open_orders_fetch", fn=sources.open_orders,
|
|
101
|
+
fn_args=("raw",), out_ports=("orders",),
|
|
102
|
+
),
|
|
103
|
+
"trader_history_fetch": ImplSpec(
|
|
104
|
+
node="trader_history_fetch", fn=sources.trader_history,
|
|
105
|
+
fn_args=("raw",), adapter_params=("trader_address", "limit"),
|
|
106
|
+
out_ports=("closed_positions",),
|
|
107
|
+
),
|
|
108
|
+
"asset_trading_limits_fetch": ImplSpec(
|
|
109
|
+
node="asset_trading_limits_fetch", fn=sources.asset_trading_limits,
|
|
110
|
+
fn_args=("raw_by_asset",), adapter_params=("dex",),
|
|
111
|
+
out_ports=("max_leverage",),
|
|
112
|
+
),
|
|
113
|
+
"strategy_pnl_snapshot": ImplSpec(
|
|
114
|
+
node="strategy_pnl_snapshot", fn=sources.strategy_pnl_snapshot,
|
|
115
|
+
fn_args=("raw",), adapter_params=("period", "drawdown_reset_on_day_rollover"),
|
|
116
|
+
out_ports=("today_pnl", "today_account_value", "today_drawdown"),
|
|
117
|
+
),
|
|
118
|
+
"trader_state_fetch": ImplSpec(
|
|
119
|
+
node="trader_state_fetch", fn=sources.trader_state,
|
|
120
|
+
fn_args=("raw",), adapter_params=("trader_address",),
|
|
121
|
+
out_ports=("held", "equity"),
|
|
122
|
+
),
|
|
123
|
+
# ---- indicators ---------------------------------------------------------
|
|
124
|
+
"sma": ImplSpec(
|
|
125
|
+
node="sma", fn=indicators.sma,
|
|
126
|
+
fn_args=("candles", "period", "interval"),
|
|
127
|
+
param_args=("period", "interval"), port_args=("candles",),
|
|
128
|
+
out_ports=("value", "above"),
|
|
129
|
+
),
|
|
130
|
+
"pct_change": ImplSpec(
|
|
131
|
+
node="pct_change", fn=indicators.pct_change,
|
|
132
|
+
fn_args=("candles", "lookback", "interval"),
|
|
133
|
+
param_args=("lookback", "interval"), port_args=("candles",), out_ports=("pct",),
|
|
134
|
+
),
|
|
135
|
+
"recent_high_bars_ago": ImplSpec(
|
|
136
|
+
node="recent_high_bars_ago", fn=indicators.recent_high_bars_ago,
|
|
137
|
+
fn_args=("candles", "window", "interval"),
|
|
138
|
+
param_args=("window", "interval"), port_args=("candles",), out_ports=("bars_ago",),
|
|
139
|
+
),
|
|
140
|
+
"volume_surge_ratio": ImplSpec(
|
|
141
|
+
node="volume_surge_ratio", fn=indicators.volume_surge_ratio,
|
|
142
|
+
fn_args=("candles", "recent_window", "baseline_window", "interval"),
|
|
143
|
+
param_args=("recent_window", "baseline_window", "interval"),
|
|
144
|
+
port_args=("candles",), out_ports=("ratio",),
|
|
145
|
+
),
|
|
146
|
+
"acceleration": ImplSpec(
|
|
147
|
+
node="acceleration", fn=indicators.acceleration,
|
|
148
|
+
fn_args=("candles", "short_window", "long_window", "interval"),
|
|
149
|
+
param_args=("short_window", "long_window", "interval"),
|
|
150
|
+
port_args=("candles",), out_ports=("accelerating",),
|
|
151
|
+
),
|
|
152
|
+
"trend_structure": ImplSpec(
|
|
153
|
+
node="trend_structure", fn=indicators.trend_structure,
|
|
154
|
+
fn_args=("candles", "lookback", "interval"),
|
|
155
|
+
param_args=("lookback", "interval"), port_args=("candles",), out_ports=("direction",),
|
|
156
|
+
),
|
|
157
|
+
"breakout_detect": ImplSpec(
|
|
158
|
+
node="breakout_detect", fn=indicators.breakout_detect,
|
|
159
|
+
fn_args=("candles", "window", "interval"),
|
|
160
|
+
param_args=("window", "interval"), port_args=("candles",), out_ports=("direction",),
|
|
161
|
+
),
|
|
162
|
+
"rsi": ImplSpec(
|
|
163
|
+
node="rsi", fn=indicators.rsi,
|
|
164
|
+
fn_args=("candles", "period", "interval"),
|
|
165
|
+
param_args=("period", "interval"), port_args=("candles",), out_ports=("rsi",),
|
|
166
|
+
),
|
|
167
|
+
# ---- filters ------------------------------------------------------------
|
|
168
|
+
"held_asset_exclusion": ImplSpec(
|
|
169
|
+
node="held_asset_exclusion", fn=filters.held_asset_exclusion,
|
|
170
|
+
fn_args=("candidates", "held"), port_args=("candidates", "held"), out_ports=("kept",),
|
|
171
|
+
),
|
|
172
|
+
"recent_signal_ttl_dedup": ImplSpec(
|
|
173
|
+
node="recent_signal_ttl_dedup", fn=filters.recent_signal_ttl_dedup,
|
|
174
|
+
fn_args=("candidates", "recent"), port_args=("candidates", "recent"), out_ports=("kept",),
|
|
175
|
+
),
|
|
176
|
+
"classifier_predicate": ImplSpec(
|
|
177
|
+
node="classifier_predicate", fn=filters.classifier_predicate,
|
|
178
|
+
fn_args=("candidates", "max_funding_abs", "max_leverage", "exclude_delisted"),
|
|
179
|
+
param_args=("max_funding_abs", "max_leverage", "exclude_delisted"),
|
|
180
|
+
port_args=("candidates",), out_ports=("kept",),
|
|
181
|
+
),
|
|
182
|
+
"live_instrument_validation": ImplSpec(
|
|
183
|
+
node="live_instrument_validation", fn=filters.live_instrument_validation,
|
|
184
|
+
fn_args=("candidates", "instruments"),
|
|
185
|
+
port_args=("candidates", "instruments"), out_ports=("kept",),
|
|
186
|
+
),
|
|
187
|
+
# ---- scorer -------------------------------------------------------------
|
|
188
|
+
"hard_gates_all": ImplSpec(
|
|
189
|
+
node="hard_gates_all", fn=scorers.hard_gates_all,
|
|
190
|
+
fn_args=("features", "required_gates", "weights", "min_score"),
|
|
191
|
+
param_args=("required_gates", "weights", "min_score"),
|
|
192
|
+
port_args=("features",), out_ports=("scores",),
|
|
193
|
+
),
|
|
194
|
+
# ---- ranker -------------------------------------------------------------
|
|
195
|
+
"emit_single_best": ImplSpec(
|
|
196
|
+
node="emit_single_best", fn=rankers.emit_single_best,
|
|
197
|
+
fn_args=("scores", "min_score"),
|
|
198
|
+
param_args=("min_score",), port_args=("scores",), out_ports=("selected",),
|
|
199
|
+
),
|
|
200
|
+
"rank_pool_cap_to_open_slots": ImplSpec(
|
|
201
|
+
node="rank_pool_cap_to_open_slots", fn=rankers.rank_pool_cap_to_open_slots,
|
|
202
|
+
fn_args=("scores", "held", "max_slots", "min_score"),
|
|
203
|
+
param_args=("max_slots", "min_score"), port_args=("scores", "held"),
|
|
204
|
+
out_ports=("selected",),
|
|
205
|
+
),
|
|
206
|
+
# ---- emitter ------------------------------------------------------------
|
|
207
|
+
"signal_envelope": ImplSpec(
|
|
208
|
+
node="signal_envelope", fn=emitters.signal_envelope,
|
|
209
|
+
fn_args=("selected", "equity", "margin_pct", "leverage", "clamp_leverage_to_venue",
|
|
210
|
+
"instruments", "valid_for_seconds", "signal_type"),
|
|
211
|
+
param_args=("margin_pct", "leverage", "clamp_leverage_to_venue",
|
|
212
|
+
"valid_for_seconds", "signal_type"),
|
|
213
|
+
port_args=("selected", "equity", "instruments"), out_ports=("signals",),
|
|
214
|
+
),
|
|
215
|
+
# ---- stateful (split read/write halves) ---------------------------------
|
|
216
|
+
"ttl_dedup_state": ImplSpec(
|
|
217
|
+
node="ttl_dedup_state", fn=stateful.ttl_dedup_state_read,
|
|
218
|
+
fn_args=("state", "ttl_seconds", "prune_factor", "now"),
|
|
219
|
+
param_args=("ttl_seconds", "prune_factor"),
|
|
220
|
+
port_args=(), out_ports=("recent",),
|
|
221
|
+
write_fn=stateful.ttl_dedup_state_write,
|
|
222
|
+
write_args=("state", "working_map", "emitted", "now"),
|
|
223
|
+
),
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
__all__ = [
|
|
227
|
+
"sources",
|
|
228
|
+
"indicators",
|
|
229
|
+
"filters",
|
|
230
|
+
"scorers",
|
|
231
|
+
"rankers",
|
|
232
|
+
"emitters",
|
|
233
|
+
"stateful",
|
|
234
|
+
"adapter",
|
|
235
|
+
"McpSourceAdapter",
|
|
236
|
+
"make_adapter",
|
|
237
|
+
"ImplSpec",
|
|
238
|
+
"IMPLEMENTATIONS",
|
|
239
|
+
]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""_candles — shared candle-field coercion helpers for the indicator primitives.
|
|
2
|
+
|
|
3
|
+
These reproduce the dual-key float coercion used verbatim across the corpus
|
|
4
|
+
originals (``_f`` / ``_close`` / ``_high`` / ``_low`` / ``_vol``); see
|
|
5
|
+
``/Users/duncan/Senpi/skills/strategies/hawk/main/scanners/scoring.py`` and
|
|
6
|
+
``/Users/duncan/Senpi/skills/strategies/stag/main/scanners/scoring.py``. A candle
|
|
7
|
+
is a dict carrying either the long key (``close``) or the short key (``c``); the
|
|
8
|
+
list branch ``[t, o, h, l, c, v]`` is defensive and never fires on dict candles.
|
|
9
|
+
|
|
10
|
+
The goldens WE author (python-tests/composer) define correctness — these helpers
|
|
11
|
+
follow the originals' coercion so the compiled graphs behave the same way on the
|
|
12
|
+
same candle series, but bit-level parity with the originals is not the oracle.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _f(v: Any, d: float = 0.0) -> float:
|
|
21
|
+
"""Float coercion (corpus ``scoring._f``): non-numeric / None -> default."""
|
|
22
|
+
try:
|
|
23
|
+
return float(v if v is not None else d)
|
|
24
|
+
except (TypeError, ValueError):
|
|
25
|
+
return d
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _close(c: Any) -> float:
|
|
29
|
+
if isinstance(c, dict):
|
|
30
|
+
return _f(c.get("close", c.get("c", 0)))
|
|
31
|
+
if isinstance(c, (list, tuple)) and len(c) >= 5:
|
|
32
|
+
return _f(c[4])
|
|
33
|
+
return 0.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _high(c: Any) -> float:
|
|
37
|
+
if isinstance(c, dict):
|
|
38
|
+
return _f(c.get("high", c.get("h", 0)))
|
|
39
|
+
if isinstance(c, (list, tuple)) and len(c) >= 5:
|
|
40
|
+
return _f(c[2])
|
|
41
|
+
return 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _low(c: Any) -> float:
|
|
45
|
+
if isinstance(c, dict):
|
|
46
|
+
return _f(c.get("low", c.get("l", 0)))
|
|
47
|
+
if isinstance(c, (list, tuple)) and len(c) >= 5:
|
|
48
|
+
return _f(c[3])
|
|
49
|
+
return 0.0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _vol(c: Any) -> float:
|
|
53
|
+
if isinstance(c, dict):
|
|
54
|
+
return _f(c.get("volume", c.get("v", c.get("vlm", 0))))
|
|
55
|
+
if isinstance(c, (list, tuple)) and len(c) >= 6:
|
|
56
|
+
return _f(c[5])
|
|
57
|
+
return 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _closes(series: list) -> list[float]:
|
|
61
|
+
return [_close(c) for c in series]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _pct_change(closes: list[float], lookback: int):
|
|
65
|
+
"""% change of the latest close vs the close ``lookback`` bars ago.
|
|
66
|
+
|
|
67
|
+
Verbatim shape from stag ``scoring.pct_change`` (cited file above): None on
|
|
68
|
+
insufficient data or a non-positive reference price.
|
|
69
|
+
"""
|
|
70
|
+
if not closes or len(closes) <= lookback:
|
|
71
|
+
return None
|
|
72
|
+
ref = closes[-(lookback + 1)]
|
|
73
|
+
latest = closes[-1]
|
|
74
|
+
if ref is None or ref <= 0:
|
|
75
|
+
return None
|
|
76
|
+
return ((latest - ref) / ref) * 100.0
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""adapter — the thin, stubbable data-source layer over the scaffold ctx.
|
|
2
|
+
|
|
3
|
+
This is the ONLY place a compiled scan.py performs I/O: each method makes exactly one
|
|
4
|
+
``ctx.senpi_mcp.call_tool(name, args)`` read and returns the raw response, which the
|
|
5
|
+
pure ``sources`` primitives then normalize. Reads are guarded (a transient/permission
|
|
6
|
+
error on ONE read must not roll back the whole tick — corpus ``_read`` behaviour):
|
|
7
|
+
a failed read returns None and the source primitive drops that asset.
|
|
8
|
+
|
|
9
|
+
Golden tests stub source data at THIS boundary by handing scan() a ctx whose
|
|
10
|
+
``senpi_mcp`` returns fixture responses — so the compiled graph runs end-to-end over
|
|
11
|
+
fixed data while the real ctx / StateManager frozen semantics are honored.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class McpSourceAdapter:
|
|
21
|
+
"""Wraps a scaffold ``ctx`` and exposes one read method per corpus MCP tool."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, ctx: Any) -> None:
|
|
24
|
+
self._ctx = ctx
|
|
25
|
+
|
|
26
|
+
def _read(self, name: str, args: dict):
|
|
27
|
+
mcp = getattr(self._ctx, "senpi_mcp", None)
|
|
28
|
+
if mcp is None:
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return mcp.call_tool(name, args)
|
|
32
|
+
except Exception as exc: # noqa: BLE001 — a read error must not roll back the tick
|
|
33
|
+
print(f"[composer.adapter] {name} read failed: {exc!r}", file=sys.stderr)
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
def asset_data(self, asset: str, intervals: list, *, include_funding: bool = False,
|
|
37
|
+
include_asset_context: bool = False, dex: str = "") -> Any:
|
|
38
|
+
args = {
|
|
39
|
+
"asset": asset,
|
|
40
|
+
"candle_intervals": list(intervals),
|
|
41
|
+
"include_funding": include_funding,
|
|
42
|
+
"include_order_book": False,
|
|
43
|
+
}
|
|
44
|
+
if include_asset_context:
|
|
45
|
+
args["include_asset_context"] = True
|
|
46
|
+
if dex:
|
|
47
|
+
args["dex"] = dex
|
|
48
|
+
return self._read("market_get_asset_data", args)
|
|
49
|
+
|
|
50
|
+
def clearinghouse(self) -> Any:
|
|
51
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
52
|
+
return self._read("strategy_get_clearinghouse_state", {"strategy_wallet": wallet})
|
|
53
|
+
|
|
54
|
+
def leaderboard_markets(self) -> Any:
|
|
55
|
+
return self._read("leaderboard_get_markets", {})
|
|
56
|
+
|
|
57
|
+
def instruments(self, *, dex: str = "") -> Any:
|
|
58
|
+
return self._read("market_list_instruments", {"dex": dex})
|
|
59
|
+
|
|
60
|
+
# -------------------------------------------------- world-coverage reads (batch C)
|
|
61
|
+
|
|
62
|
+
def market_prices(self, *, dex: str = "") -> Any:
|
|
63
|
+
"""market_get_prices — all latest prices for a venue (omitting `assets` returns
|
|
64
|
+
the full map; the normalizer filters to the wired universe)."""
|
|
65
|
+
return self._read("market_get_prices", {"dex": dex})
|
|
66
|
+
|
|
67
|
+
def strategy_list(self, *, status: str = "", strategy_type: str = "") -> Any:
|
|
68
|
+
"""strategy_list — strategy-metadata rows, optionally filtered by status / type."""
|
|
69
|
+
args: dict = {}
|
|
70
|
+
if status:
|
|
71
|
+
args["status"] = status
|
|
72
|
+
if strategy_type:
|
|
73
|
+
args["strategy_type"] = strategy_type
|
|
74
|
+
return self._read("strategy_list", args)
|
|
75
|
+
|
|
76
|
+
def open_orders(self) -> Any:
|
|
77
|
+
"""strategy_get_open_orders for the strategy's own wallet (like clearinghouse)."""
|
|
78
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
79
|
+
return self._read("strategy_get_open_orders", {"strategy_wallet": wallet})
|
|
80
|
+
|
|
81
|
+
def trader_history(self, *, trader_address: str, limit: int = 100) -> Any:
|
|
82
|
+
"""discovery_get_trader_history — closed-trade history for an arbitrary trader."""
|
|
83
|
+
return self._read(
|
|
84
|
+
"discovery_get_trader_history",
|
|
85
|
+
{"trader_address": trader_address, "latest": True, "limit": limit},
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
def asset_trading_limits(self, coin: str, *, dex: str = "") -> Any:
|
|
89
|
+
"""strategy_get_asset_trading_limits — single-asset venue limit for the own wallet
|
|
90
|
+
(fanned out per-asset by the emitted comprehension, like asset_data)."""
|
|
91
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
92
|
+
args = {"strategy_wallet": wallet, "coin": coin}
|
|
93
|
+
if dex:
|
|
94
|
+
args["dex"] = dex
|
|
95
|
+
return self._read("strategy_get_asset_trading_limits", args)
|
|
96
|
+
|
|
97
|
+
def pnl_snapshot(self, *, period: str = "", drawdown_reset_on_day_rollover: bool = False) -> Any:
|
|
98
|
+
"""strategy_get_pnl_and_account_value_history for the own wallet."""
|
|
99
|
+
wallet = getattr(self._ctx, "wallet", "") or ""
|
|
100
|
+
args: dict = {"strategy_wallet": wallet}
|
|
101
|
+
if period:
|
|
102
|
+
args["period"] = period
|
|
103
|
+
if drawdown_reset_on_day_rollover:
|
|
104
|
+
args["drawdown_reset_on_day_rollover"] = True
|
|
105
|
+
return self._read("strategy_get_pnl_and_account_value_history", args)
|
|
106
|
+
|
|
107
|
+
def trader_state(self, *, trader_address: str) -> Any:
|
|
108
|
+
"""discovery_get_trader_state — an arbitrary wallet's current book (clearinghouse
|
|
109
|
+
twin). The MCP arg is a BATCH (`trader_addresses`); we read the single wired wallet."""
|
|
110
|
+
return self._read(
|
|
111
|
+
"discovery_get_trader_state",
|
|
112
|
+
{"trader_addresses": [trader_address], "latest": True},
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def make_adapter(ctx: Any) -> McpSourceAdapter:
|
|
117
|
+
"""Factory the compiled scan.py calls. A test may monkeypatch this module attribute
|
|
118
|
+
to inject a fully synthetic adapter, but the default (a real ctx + fake senpi_mcp)
|
|
119
|
+
is the primary stubbing seam."""
|
|
120
|
+
return McpSourceAdapter(ctx)
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""emitters — the signal_envelope primitive: selected -> the scan() return list.
|
|
2
|
+
|
|
3
|
+
Produces the exact top-level shape the scaffold contract expects (see
|
|
4
|
+
``src/scanners/external/scaffold/signal_envelope.py``):
|
|
5
|
+
``{asset, direction, marginPct, leverage, data:{score, reasons, ...}}`` plus the optional
|
|
6
|
+
``valid_for_seconds`` / ``signal_type`` the scaffold also recognizes on a candidate.
|
|
7
|
+
|
|
8
|
+
Supervisor decision (slice 3): FINAL sizing lives here. ``marginPct`` is emitted as a
|
|
9
|
+
PERCENT of withdrawable in (0, 100] (never a fraction, never USD) — the scaffold
|
|
10
|
+
LOUD-rejects anything outside that band. Conviction/tier scorers only produce inputs
|
|
11
|
+
to a score; the margin decision is the emitter's.
|
|
12
|
+
|
|
13
|
+
================================================================================
|
|
14
|
+
ENVELOPE SURFACE AUDIT (batch D) — every field a scanner may set on a signal.
|
|
15
|
+
Source of truth: src/scanners/external/scaffold/signal_envelope.py::candidate_to_signal
|
|
16
|
+
(_RECOGNIZED_CANDIDATE_KEYS + its per-field validation). "Covered" = expressible by this
|
|
17
|
+
emitter after batch D.
|
|
18
|
+
|
|
19
|
+
field req/opt constraint (scaffold reject rule) covered by
|
|
20
|
+
----------------- -------- --------------------------------------------- -------------------------
|
|
21
|
+
asset REQUIRED non-empty string selected keys (dynamic)
|
|
22
|
+
direction REQUIRED long/short (case-normalized -> LONG/SHORT) entry["direction"] (dynamic)
|
|
23
|
+
marginPct opt number in (0,100] margin_pct param OR entry["marginPct"]
|
|
24
|
+
leverage opt positive number leverage param, venue-clamped, OR entry["leverage"]
|
|
25
|
+
valid_for_seconds opt positive int (TTL override) valid_for_seconds param OR entry["valid_for_seconds"]
|
|
26
|
+
signal_type opt non-empty string signal_type param OR entry["signal_type"]
|
|
27
|
+
data opt dict, validated vs signal_data_schema {score, reasons} + entry["data"] merge (dynamic)
|
|
28
|
+
signal_id IGNORED scaffold owns the dedup key (author cannot set) n/a (never emitted)
|
|
29
|
+
|
|
30
|
+
Static values come from params; DYNAMIC per-asset values (the corpus emits a per-signal,
|
|
31
|
+
venue-clamped leverage and a per-thesis data block) come from the score_map `selected`
|
|
32
|
+
entry a pure_fn scorer produces, and from the live venue-max carried on the optional
|
|
33
|
+
`instruments` input port. Every field above is now expressible — full parity with a
|
|
34
|
+
hand-written scanner's signal envelope.
|
|
35
|
+
================================================================================
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
from typing import Any, Optional
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _clamp_leverage(desired: Any, venue_max: Any):
|
|
44
|
+
"""Clamp desired leverage to an instrument's venue max — ported VERBATIM from the
|
|
45
|
+
corpus ``clamp_leverage`` helper (byte-identical in thesis-fund/main/scanners/scoring.py
|
|
46
|
+
and magpie/pre_listing/scanners/scoring.py).
|
|
47
|
+
|
|
48
|
+
Fallback semantics (must match the corpus): a ``venue_max`` that is None / non-numeric /
|
|
49
|
+
<= 0 (i.e. no usable venue metadata) falls back to the *desired* leverage — the signal is
|
|
50
|
+
still emitted at the requested leverage, never dropped and never clamped to 1. The result
|
|
51
|
+
is a whole-number leverage (the corpus does ``int(...)`` on both sides), floored at 1.
|
|
52
|
+
"""
|
|
53
|
+
try:
|
|
54
|
+
venue = int(venue_max)
|
|
55
|
+
except (TypeError, ValueError):
|
|
56
|
+
venue = int(desired)
|
|
57
|
+
if venue <= 0:
|
|
58
|
+
venue = int(desired)
|
|
59
|
+
return max(1, min(int(desired), venue))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _venue_max_map(instruments: Any) -> dict:
|
|
63
|
+
"""Project instrument_list records ([{name, max_leverage, ...}]) into an upper-cased
|
|
64
|
+
``{name: max_leverage}`` lookup — the composer analogue of thesis-fund's
|
|
65
|
+
``_get_universe_meta`` (name -> max_leverage from market_list_instruments)."""
|
|
66
|
+
out: dict = {}
|
|
67
|
+
for rec in instruments or []:
|
|
68
|
+
if isinstance(rec, dict) and rec.get("name"):
|
|
69
|
+
out[str(rec["name"]).upper()] = rec.get("max_leverage")
|
|
70
|
+
return out
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def signal_envelope(
|
|
74
|
+
selected: dict,
|
|
75
|
+
equity=None,
|
|
76
|
+
*,
|
|
77
|
+
margin_pct: float,
|
|
78
|
+
leverage: float,
|
|
79
|
+
clamp_leverage_to_venue: bool,
|
|
80
|
+
instruments: Optional[list] = None,
|
|
81
|
+
valid_for_seconds: int = 0,
|
|
82
|
+
signal_type: str = "",
|
|
83
|
+
) -> list:
|
|
84
|
+
"""Map selected candidates to the scan() signal list. Port: signals.
|
|
85
|
+
|
|
86
|
+
``selected`` = ``{asset: {score, direction, reasons, ...}}``. A pure_fn scorer may attach
|
|
87
|
+
per-asset OVERRIDES on each entry (``marginPct``, ``leverage``, ``valid_for_seconds``,
|
|
88
|
+
``signal_type``, ``data``); when absent the corresponding param supplies the static value —
|
|
89
|
+
this is what gives an emitted strategy full parity with a hand-written scanner.
|
|
90
|
+
|
|
91
|
+
``equity`` (optional account scalar) is accepted for wiring parity; flat-margin strategies
|
|
92
|
+
(stag/hawk) do not consume it. ``instruments`` (optional) carries the live venue board
|
|
93
|
+
(instrument_list records); when ``clamp_leverage_to_venue`` is True AND ``instruments`` is
|
|
94
|
+
wired, each signal's leverage is clamped to that instrument's venue max exactly as the
|
|
95
|
+
corpus ``clamp_leverage`` does. Without an ``instruments`` wire the clamp is a no-op (the
|
|
96
|
+
requested leverage passes through unchanged — stag/hawk/thesis_fund/magpie today), so
|
|
97
|
+
existing graphs are byte-unchanged.
|
|
98
|
+
"""
|
|
99
|
+
venue_max = _venue_max_map(instruments) if instruments is not None else None
|
|
100
|
+
signals: list = []
|
|
101
|
+
for asset in sorted(selected):
|
|
102
|
+
entry = selected[asset]
|
|
103
|
+
|
|
104
|
+
sig_margin = entry.get("marginPct", margin_pct)
|
|
105
|
+
sig_leverage = entry.get("leverage", leverage)
|
|
106
|
+
# Real venue clamp: only bites when the caller wired the live board (venue_max is not
|
|
107
|
+
# None). Corpus clamp_leverage semantics + fallback (see _clamp_leverage docstring).
|
|
108
|
+
if clamp_leverage_to_venue and venue_max is not None:
|
|
109
|
+
sig_leverage = _clamp_leverage(sig_leverage, venue_max.get(str(asset).upper()))
|
|
110
|
+
|
|
111
|
+
data: dict = {
|
|
112
|
+
"score": entry.get("score", 0),
|
|
113
|
+
"reasons": entry.get("reasons", []),
|
|
114
|
+
}
|
|
115
|
+
# Per-asset data enrichment: a pure_fn scorer can carry a `data` block on the entry
|
|
116
|
+
# (the corpus packs trend4h/rsi/own24h/... into signal.data). Merged over the base
|
|
117
|
+
# {score, reasons}; absent for stag/hawk/thesis_fund/magpie -> byte-unchanged.
|
|
118
|
+
extra = entry.get("data")
|
|
119
|
+
if isinstance(extra, dict):
|
|
120
|
+
for k in sorted(extra):
|
|
121
|
+
data[k] = extra[k]
|
|
122
|
+
|
|
123
|
+
sig: dict = {
|
|
124
|
+
"asset": asset,
|
|
125
|
+
"direction": entry.get("direction", "LONG"),
|
|
126
|
+
"marginPct": sig_margin,
|
|
127
|
+
"leverage": sig_leverage,
|
|
128
|
+
"data": data,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# valid_for_seconds (TTL override) and signal_type are OPTIONAL scaffold-recognized
|
|
132
|
+
# keys: emitted only when set (per-asset override wins over the static param) so an
|
|
133
|
+
# unset field never appears on the wire (matches the scaffold's "absent -> use default").
|
|
134
|
+
vfs = entry.get("valid_for_seconds", valid_for_seconds)
|
|
135
|
+
if isinstance(vfs, int) and not isinstance(vfs, bool) and vfs > 0:
|
|
136
|
+
sig["valid_for_seconds"] = vfs
|
|
137
|
+
st = entry.get("signal_type", signal_type)
|
|
138
|
+
if isinstance(st, str) and st:
|
|
139
|
+
sig["signal_type"] = st
|
|
140
|
+
|
|
141
|
+
signals.append(sig)
|
|
142
|
+
return signals
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""filters — pure asset_list -> asset_list narrowing primitives.
|
|
2
|
+
|
|
3
|
+
Both preserve input order and compare case-insensitively (assets are upper-cased in
|
|
4
|
+
the corpus). Ported from the held / recently-signaled pre-fetch filters present in
|
|
5
|
+
all five corpus packages (e.g. stag ``scan.scan`` held + recent skip).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def held_asset_exclusion(*, candidates: list, held: list) -> list:
|
|
12
|
+
"""Drop candidates already held in the account. Port: kept (asset_list)."""
|
|
13
|
+
held_set = {str(h).upper() for h in held}
|
|
14
|
+
return [a for a in candidates if str(a).upper() not in held_set]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def recent_signal_ttl_dedup(*, candidates: list, recent: list) -> list:
|
|
18
|
+
"""Drop candidates signaled within the dedup TTL. Port: kept (asset_list).
|
|
19
|
+
|
|
20
|
+
``recent`` is the within-TTL exclusion set produced by ttl_dedup_state.
|
|
21
|
+
"""
|
|
22
|
+
recent_set = {str(a).upper() for a in recent}
|
|
23
|
+
return [a for a in candidates if str(a).upper() not in recent_set]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _f(v, d: float = 0.0) -> float:
|
|
27
|
+
try:
|
|
28
|
+
return float(v if v is not None else d)
|
|
29
|
+
except (TypeError, ValueError):
|
|
30
|
+
return d
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def classifier_predicate(*, candidates: list, max_funding_abs: float,
|
|
34
|
+
max_leverage: float, exclude_delisted: bool) -> list:
|
|
35
|
+
"""Gate instrument-record membership on venue-field predicates. Port: kept (asset_list).
|
|
36
|
+
|
|
37
|
+
Ported from magpie ``scoring.is_ipop`` / ``ipop_passes_universe`` (the funding-signature
|
|
38
|
+
portion): keep a candidate iff (not delisted, when excluded) AND ``|funding| <=
|
|
39
|
+
max_funding_abs`` AND ``max_leverage <= max_leverage``. Operates on the instrument
|
|
40
|
+
RECORDS from instrument_list and PRESERVES them (so it can chain into universe_discover);
|
|
41
|
+
a bare-name candidate carrying no venue fields passes the (0-valued) numeric gates, i.e.
|
|
42
|
+
the generalized identity classifier. Input order is preserved.
|
|
43
|
+
"""
|
|
44
|
+
out: list = []
|
|
45
|
+
for inst in candidates or []:
|
|
46
|
+
rec = inst if isinstance(inst, dict) else {"name": inst}
|
|
47
|
+
if exclude_delisted and rec.get("is_delisted"):
|
|
48
|
+
continue
|
|
49
|
+
if abs(_f(rec.get("funding", 0))) > max_funding_abs:
|
|
50
|
+
continue
|
|
51
|
+
lev = _f(rec.get("max_leverage", 0))
|
|
52
|
+
if lev and lev > max_leverage:
|
|
53
|
+
continue
|
|
54
|
+
out.append(inst)
|
|
55
|
+
return out
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def live_instrument_validation(*, candidates: list, instruments: list) -> list:
|
|
59
|
+
"""Keep only candidates present on the live instrument board. Port: kept (asset_list).
|
|
60
|
+
|
|
61
|
+
Ported from thesis-fund's "only score names live on the instrument board" check
|
|
62
|
+
(``meta.get(asset) is None -> skip``): intersect candidate names with the live board
|
|
63
|
+
from instrument_list (case-insensitive), preserving candidate order/case.
|
|
64
|
+
"""
|
|
65
|
+
names: set = set()
|
|
66
|
+
for inst in instruments or []:
|
|
67
|
+
rec = inst if isinstance(inst, dict) else {"name": inst}
|
|
68
|
+
nm = rec.get("name")
|
|
69
|
+
if isinstance(nm, str):
|
|
70
|
+
names.add(nm.upper())
|
|
71
|
+
return [c for c in (candidates or []) if str(c).upper() in names]
|