@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,410 @@
|
|
|
1
|
+
"""_common — shared archetype pieces (rankers, the reusable pure_fn bodies + test-gen).
|
|
2
|
+
|
|
3
|
+
Two concerns live here so the three archetype templates stay small and consistent:
|
|
4
|
+
|
|
5
|
+
* cardinality -> ranker: max_positions == 1 emits the single-best ranker; >1 emits the
|
|
6
|
+
rank-pool-cap-to-open-slots ranker (thesis-fund's shape) wired to the held set. This is
|
|
7
|
+
the mechanical binding of the "cardinality" answer (D7 step 3).
|
|
8
|
+
* bespoke scoring bodies WITH WORKING DEFAULTS: the classifier and thesis archetypes need
|
|
9
|
+
a cross-feature scorer no registry scorer expresses (the slice-3/4 vocabulary limit), so
|
|
10
|
+
they ship a pure_fn (D2 kind 2) pre-filled with a proven default body — magpie's
|
|
11
|
+
classifier predicate / thesis-fund's confirmation scorer. The body is the TODO slot the
|
|
12
|
+
model edits toward its edge; it is emitted verbatim AND executed here to synthesize
|
|
13
|
+
inline fixture tests that pass BY CONSTRUCTION, so the generated graph validates (the
|
|
14
|
+
pure_fn test stage runs) with zero edits regardless of the bound universe.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import difflib
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Optional
|
|
22
|
+
|
|
23
|
+
from ..answers import Answers
|
|
24
|
+
from ..builder import BuiltNode, GraphBuilder
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class GenerationError(ValueError):
|
|
28
|
+
"""A teaching-shaped error raised when the ANSWERS reference something an archetype cannot
|
|
29
|
+
bind — an ``indicators`` entry naming a node the registry lacks (or a port it does not
|
|
30
|
+
declare), or a ``scoring`` name that matches no feature node wired into the scorer.
|
|
31
|
+
|
|
32
|
+
Surfaced like the builder's ``archetype template error:`` ValueErrors (a located,
|
|
33
|
+
fix-pointing message), but attributed to the answers rather than template drift: it is how
|
|
34
|
+
the F1 expressiveness surface refuses an impossible request LOUDLY, so a requested gate or
|
|
35
|
+
indicator is NEVER silently dropped from the emitted graph."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- F1 (scoring + indicators)
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ResolvedIndicator:
|
|
42
|
+
"""One resolved ``answers.indicators`` entry, with its input-wiring decision (C1b).
|
|
43
|
+
|
|
44
|
+
``in_port`` is the node's data input port (``candles`` for the candle-fed indicators,
|
|
45
|
+
``values`` for ``threshold_gate``). When ``source_id`` is set the input is CHAINED off
|
|
46
|
+
that node's ``source_port`` output instead of the graph's candles; ``out_port`` is the
|
|
47
|
+
feature output merged into the scorer."""
|
|
48
|
+
|
|
49
|
+
id: str
|
|
50
|
+
out_port: str
|
|
51
|
+
in_port: Optional[str]
|
|
52
|
+
source_id: Optional[str] = None
|
|
53
|
+
source_port: Optional[str] = None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _node_outputs(bn: BuiltNode) -> list[tuple[str, str]]:
|
|
57
|
+
"""[(port_name, port_type_str)] for a BuiltNode's outputs (primitive or pure_fn)."""
|
|
58
|
+
from composer.registry import NODES_BY_NAME
|
|
59
|
+
|
|
60
|
+
if bn.kind == "pure_fn":
|
|
61
|
+
return [(p["name"], p["type"]) for p in bn.outputs]
|
|
62
|
+
return [(p.name, p.type.value) for p in NODES_BY_NAME[bn.node].outputs]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _resolve_source_output(
|
|
66
|
+
b: GraphBuilder, source_id: str, resolved_by_id: dict, i: int, ind_id: str
|
|
67
|
+
) -> tuple[str, str]:
|
|
68
|
+
"""The (port_name, port_type) a ``source`` reference reads. Prefers an earlier
|
|
69
|
+
``indicators`` entry's resolved feature port; else a single-output built-in node.
|
|
70
|
+
Teaches loudly when the source is unknown or ambiguous (never a silent drop)."""
|
|
71
|
+
bn = next((n for n in b.nodes if n.id == source_id), None)
|
|
72
|
+
if bn is None:
|
|
73
|
+
raise GenerationError(
|
|
74
|
+
f"indicators[{i}] (id {ind_id!r}) sets source {source_id!r}, which is not a node in "
|
|
75
|
+
f"this graph. `source` must name an EARLIER indicators entry, or a built-in feature "
|
|
76
|
+
f"node of the archetype."
|
|
77
|
+
)
|
|
78
|
+
outs = _node_outputs(bn)
|
|
79
|
+
if source_id in resolved_by_id:
|
|
80
|
+
want = resolved_by_id[source_id].out_port
|
|
81
|
+
typ = next((t for name, t in outs if name == want), None)
|
|
82
|
+
return want, typ
|
|
83
|
+
if len(outs) == 1:
|
|
84
|
+
return outs[0]
|
|
85
|
+
raise GenerationError(
|
|
86
|
+
f"indicators[{i}] (id {ind_id!r}) sets source {source_id!r}, which has "
|
|
87
|
+
f"{len(outs)} output ports {sorted(name for name, _ in outs)}; source chaining needs a "
|
|
88
|
+
f"single-output feature node (or reference an earlier indicators entry)."
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def add_indicator_nodes(b: GraphBuilder, indicators) -> list[ResolvedIndicator]:
|
|
93
|
+
"""Add each ``answers.indicators`` entry as an ADDITIONAL registry node (F1/C1b).
|
|
94
|
+
|
|
95
|
+
Returns a :class:`ResolvedIndicator` per entry for the caller to wire (input in, feature
|
|
96
|
+
out). Validates the node exists, its chosen output port is real, and — when ``source`` is
|
|
97
|
+
set — that the source exists and its output port type matches this node's input port type;
|
|
98
|
+
a :class:`GenerationError` otherwise (never a silent drop). Empty ``indicators`` is a no-op,
|
|
99
|
+
so an archetype threaded through this stays byte-identical to its pre-F1 output."""
|
|
100
|
+
from composer.registry import NODES_BY_NAME
|
|
101
|
+
|
|
102
|
+
resolved: list[ResolvedIndicator] = []
|
|
103
|
+
resolved_by_id: dict[str, ResolvedIndicator] = {}
|
|
104
|
+
for i, ind in enumerate(indicators):
|
|
105
|
+
nd = NODES_BY_NAME.get(ind.node)
|
|
106
|
+
if nd is None:
|
|
107
|
+
near = difflib.get_close_matches(ind.node, sorted(NODES_BY_NAME), n=3, cutoff=0.3)
|
|
108
|
+
hint = f" Did you mean: {', '.join(near)}?" if near else ""
|
|
109
|
+
raise GenerationError(
|
|
110
|
+
f"indicators[{i}] (id {ind.id!r}) requests node {ind.node!r}, which is not in "
|
|
111
|
+
f"the registry.{hint} Run `composer catalog` to see the available nodes."
|
|
112
|
+
)
|
|
113
|
+
out_ports = [p.name for p in nd.outputs]
|
|
114
|
+
port = ind.port or (out_ports[0] if out_ports else "")
|
|
115
|
+
if port not in out_ports:
|
|
116
|
+
raise GenerationError(
|
|
117
|
+
f"indicators[{i}] (id {ind.id!r}, node {ind.node!r}) names output port "
|
|
118
|
+
f"{ind.port!r}, which it does not have (has: {sorted(out_ports)})."
|
|
119
|
+
)
|
|
120
|
+
# the node's data input port (the single non-nothing input every feature node declares).
|
|
121
|
+
in_ports = list(nd.inputs)
|
|
122
|
+
in_port = in_ports[0].name if in_ports else None
|
|
123
|
+
in_port_type = in_ports[0].type.value if in_ports else None
|
|
124
|
+
|
|
125
|
+
source_id = source_port = None
|
|
126
|
+
if ind.source is not None:
|
|
127
|
+
if in_port is None:
|
|
128
|
+
raise GenerationError(
|
|
129
|
+
f"indicators[{i}] (id {ind.id!r}, node {ind.node!r}) sets a source but "
|
|
130
|
+
f"declares no input port to receive it."
|
|
131
|
+
)
|
|
132
|
+
src_port, src_type = _resolve_source_output(b, ind.source, resolved_by_id, i, ind.id)
|
|
133
|
+
if src_type != in_port_type:
|
|
134
|
+
raise GenerationError(
|
|
135
|
+
f"indicators[{i}] (id {ind.id!r}) sets source {ind.source!r} whose output "
|
|
136
|
+
f"{src_port!r} is type {src_type!r}, but {ind.node!r}'s input {in_port!r} is "
|
|
137
|
+
f"type {in_port_type!r} — a chained source must feed a port of the same type."
|
|
138
|
+
)
|
|
139
|
+
source_id, source_port = ind.source, src_port
|
|
140
|
+
elif in_port != "candles":
|
|
141
|
+
raise GenerationError(
|
|
142
|
+
f"indicators[{i}] (id {ind.id!r}, node {ind.node!r}) is not candle-fed (its input "
|
|
143
|
+
f"is {in_port!r}); it needs a `source:` naming the feature node whose output feeds "
|
|
144
|
+
f"it (e.g. a threshold_gate reading a recent_high_bars_ago output)."
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
header = ("additional indicators requested via answers.indicators.",) if i == 0 else ()
|
|
148
|
+
b.add(ind.id, ind.node, dict(ind.with_), header=header)
|
|
149
|
+
r = ResolvedIndicator(id=ind.id, out_port=port, in_port=in_port,
|
|
150
|
+
source_id=source_id, source_port=source_port)
|
|
151
|
+
resolved.append(r)
|
|
152
|
+
resolved_by_id[ind.id] = r
|
|
153
|
+
return resolved
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def wire_indicator_inputs(b: GraphBuilder, resolved: list[ResolvedIndicator], *, candles_id: str) -> None:
|
|
157
|
+
"""Wire every extra indicator's data input: the graph's candles by default, or a CHAINED
|
|
158
|
+
feature node's output when the entry set ``source`` (F1/C1b)."""
|
|
159
|
+
for j, r in enumerate(resolved):
|
|
160
|
+
if r.source_id is not None:
|
|
161
|
+
b.connect(r.source_id, r.source_port, r.id, r.in_port,
|
|
162
|
+
section="a chained indicator reads another node's feature" if j == 0 else None)
|
|
163
|
+
else:
|
|
164
|
+
b.connect(candles_id, "candles", r.id, r.in_port,
|
|
165
|
+
section="extra indicators read the same candles" if j == 0 else None)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def wire_indicator_features(b: GraphBuilder, resolved: list[ResolvedIndicator], *, scorer_id: str) -> None:
|
|
169
|
+
"""Merge every extra indicator's feature output into the scorer, keyed by its node id (F1)."""
|
|
170
|
+
for j, r in enumerate(resolved):
|
|
171
|
+
b.connect(r.id, r.out_port, scorer_id, "features",
|
|
172
|
+
section="extra indicator features merge into the scorer" if j == 0 else None)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def resolve_scoring(
|
|
176
|
+
answers: Answers,
|
|
177
|
+
*,
|
|
178
|
+
default_required,
|
|
179
|
+
default_weights: dict,
|
|
180
|
+
default_min,
|
|
181
|
+
feature_ids,
|
|
182
|
+
) -> tuple[list[str], dict, object]:
|
|
183
|
+
"""Resolve ``hard_gates_all`` params from the archetype default + optional answers.scoring.
|
|
184
|
+
|
|
185
|
+
"Answers override else current defaults" (D7): each ``scoring`` field left ``None`` keeps
|
|
186
|
+
the default; a set field replaces it. Every referenced name (``required_gates`` entries +
|
|
187
|
+
``weights`` keys) MUST be a feature node id wired into the scorer, else a
|
|
188
|
+
:class:`GenerationError` names the unknown reference and the real feature ids — never a
|
|
189
|
+
silent drop. With no ``scoring`` the returned values are the defaults unchanged, so the
|
|
190
|
+
emitted graph is byte-identical to the pre-F1 output."""
|
|
191
|
+
req = list(default_required)
|
|
192
|
+
weights = dict(default_weights)
|
|
193
|
+
min_score = default_min
|
|
194
|
+
sc = answers.scoring
|
|
195
|
+
if sc is not None:
|
|
196
|
+
if sc.required_gates is not None:
|
|
197
|
+
req = list(sc.required_gates)
|
|
198
|
+
if sc.weights is not None:
|
|
199
|
+
weights = dict(sc.weights)
|
|
200
|
+
if sc.min_score is not None:
|
|
201
|
+
min_score = sc.min_score
|
|
202
|
+
valid = set(feature_ids)
|
|
203
|
+
unknown = [g for g in req if g not in valid]
|
|
204
|
+
unknown += [w for w in weights if w not in valid]
|
|
205
|
+
if unknown:
|
|
206
|
+
raise GenerationError(
|
|
207
|
+
f"scoring references {sorted(set(unknown))}, which are not feature nodes in this "
|
|
208
|
+
f"graph. The scorable features are {sorted(valid)} (SOURCE NODE IDS wired into the "
|
|
209
|
+
f"scorer). Add the missing feature via `indicators:`, or fix the name."
|
|
210
|
+
)
|
|
211
|
+
return req, weights, min_score
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def add_ranker(
|
|
215
|
+
b: GraphBuilder,
|
|
216
|
+
*,
|
|
217
|
+
ranker_id: str,
|
|
218
|
+
scorer_id: str,
|
|
219
|
+
scorer_port: str,
|
|
220
|
+
account_id: str | None,
|
|
221
|
+
max_positions: int,
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Add the ranker dictated by cardinality and wire the scorer into it.
|
|
224
|
+
|
|
225
|
+
Returns nothing; the caller wires ``ranker_id.selected`` onward to the emitter.
|
|
226
|
+
"""
|
|
227
|
+
if max_positions <= 1:
|
|
228
|
+
b.add(
|
|
229
|
+
ranker_id,
|
|
230
|
+
"emit_single_best",
|
|
231
|
+
{"min_score": 0},
|
|
232
|
+
header=("ranker: single best candidate (cardinality = 1).",),
|
|
233
|
+
)
|
|
234
|
+
b.connect(scorer_id, scorer_port, ranker_id, "scores", section="score -> rank -> emit")
|
|
235
|
+
else:
|
|
236
|
+
b.add(
|
|
237
|
+
ranker_id,
|
|
238
|
+
"rank_pool_cap_to_open_slots",
|
|
239
|
+
{"max_slots": max_positions, "min_score": 0},
|
|
240
|
+
header=(
|
|
241
|
+
f"ranker: rank the pool, fill up to {max_positions} open slots "
|
|
242
|
+
f"(cardinality = {max_positions}).",
|
|
243
|
+
),
|
|
244
|
+
)
|
|
245
|
+
b.connect(scorer_id, scorer_port, ranker_id, "scores", section="score -> rank -> emit")
|
|
246
|
+
if account_id is not None:
|
|
247
|
+
b.connect(account_id, "held", ranker_id, "held")
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
# --------------------------------------------------------------------------- pure_fn bodies
|
|
251
|
+
|
|
252
|
+
# magpie/pre_listing build_thesis_pre_listing (corpus), the classifier default body.
|
|
253
|
+
CLASSIFIER_FN = '''\
|
|
254
|
+
def classify_candidate(trend4h, trend1h, sm, min_score):
|
|
255
|
+
# TODO(edit): this is a WORKING DEFAULT classifier (magpie pre-listing shape). The 4h
|
|
256
|
+
# structure classifies the side (NEUTRAL -> not tradeable); 1h + smart money confirm;
|
|
257
|
+
# sparse SM falls back to trend-only. Encode YOUR edge by editing this body — it is
|
|
258
|
+
# purity-checked (no ctx/MCP/I/O) and its inline tests run at validate time.
|
|
259
|
+
scores = {}
|
|
260
|
+
assets = sorted(set(trend4h) | set(trend1h) | set(sm))
|
|
261
|
+
for asset in assets:
|
|
262
|
+
t4 = trend4h.get(asset, "NEUTRAL")
|
|
263
|
+
if t4 == "NEUTRAL":
|
|
264
|
+
continue
|
|
265
|
+
direction = "LONG" if t4 == "BULLISH" else "SHORT"
|
|
266
|
+
score = 3.0
|
|
267
|
+
reasons = ["4h_" + t4.lower()]
|
|
268
|
+
t1 = trend1h.get(asset, "NEUTRAL")
|
|
269
|
+
if (direction == "LONG" and t1 == "BULLISH") or (direction == "SHORT" and t1 == "BEARISH"):
|
|
270
|
+
score = score + 2.0
|
|
271
|
+
reasons.append("1h_confirms")
|
|
272
|
+
lean = sm.get(asset)
|
|
273
|
+
if lean is None:
|
|
274
|
+
score = score + 2.0
|
|
275
|
+
reasons.append("sm_sparse_assumed_aligned")
|
|
276
|
+
else:
|
|
277
|
+
tilt = lean if direction == "LONG" else 100.0 - lean
|
|
278
|
+
if tilt < 55.0:
|
|
279
|
+
continue
|
|
280
|
+
score = score + 2.0
|
|
281
|
+
reasons.append("sm_aligned")
|
|
282
|
+
if tilt >= 70.0:
|
|
283
|
+
score = score + 1.0
|
|
284
|
+
reasons.append("sm_strong")
|
|
285
|
+
if score < min_score:
|
|
286
|
+
continue
|
|
287
|
+
scores[asset] = {"score": round(score, 4), "direction": direction, "reasons": reasons}
|
|
288
|
+
return scores
|
|
289
|
+
'''
|
|
290
|
+
|
|
291
|
+
# thesis-fund score_thesis (corpus), the fixed-per-leg confirmation default body.
|
|
292
|
+
THESIS_FN = '''\
|
|
293
|
+
def score_thesis(trend4h, trend1h, rsi, own24h, long_basket, short_basket, min_score):
|
|
294
|
+
# TODO(edit): this is a WORKING DEFAULT confirmation scorer (thesis-fund shape). A name's
|
|
295
|
+
# direction is FIXED by its leg (long_basket -> LONG, short_basket -> SHORT), NOT derived;
|
|
296
|
+
# a name whose 4h structure OPPOSES its leg is dropped ("don't fight the tape"), else a
|
|
297
|
+
# weighted CONFIRMATION score. Encode YOUR edge by editing this body — it is purity-checked.
|
|
298
|
+
scores = {}
|
|
299
|
+
longs = {str(a).upper() for a in long_basket}
|
|
300
|
+
shorts = {str(a).upper() for a in short_basket}
|
|
301
|
+
assets = sorted(set(trend4h) | set(trend1h) | set(rsi) | set(own24h))
|
|
302
|
+
for asset in assets:
|
|
303
|
+
au = str(asset).upper()
|
|
304
|
+
if au in longs:
|
|
305
|
+
direction = "LONG"
|
|
306
|
+
elif au in shorts:
|
|
307
|
+
direction = "SHORT"
|
|
308
|
+
else:
|
|
309
|
+
continue
|
|
310
|
+
t4 = trend4h.get(asset, "NEUTRAL")
|
|
311
|
+
t1 = trend1h.get(asset, "NEUTRAL")
|
|
312
|
+
r = rsi.get(asset, 50.0)
|
|
313
|
+
own = own24h.get(asset, 0.0)
|
|
314
|
+
score = 0.0
|
|
315
|
+
reasons = []
|
|
316
|
+
if direction == "LONG":
|
|
317
|
+
if t4 == "BEARISH":
|
|
318
|
+
continue
|
|
319
|
+
if t4 == "BULLISH":
|
|
320
|
+
score = score + 3.0
|
|
321
|
+
reasons.append("4h_bullish")
|
|
322
|
+
else:
|
|
323
|
+
score = score + 1.0
|
|
324
|
+
reasons.append("4h_neutral")
|
|
325
|
+
if t1 == "BULLISH":
|
|
326
|
+
score = score + 1.0
|
|
327
|
+
reasons.append("1h_bullish")
|
|
328
|
+
elif t1 == "BEARISH":
|
|
329
|
+
score = score - 1.0
|
|
330
|
+
reasons.append("1h_bearish")
|
|
331
|
+
if own >= 1.0:
|
|
332
|
+
score = score + 2.0
|
|
333
|
+
reasons.append("mom_strong")
|
|
334
|
+
elif own >= 0.0:
|
|
335
|
+
score = score + 1.0
|
|
336
|
+
reasons.append("mom")
|
|
337
|
+
if r < 78.0:
|
|
338
|
+
score = score + 1.0
|
|
339
|
+
reasons.append("rsi_room")
|
|
340
|
+
else:
|
|
341
|
+
if t4 == "BULLISH":
|
|
342
|
+
continue
|
|
343
|
+
if t4 == "BEARISH":
|
|
344
|
+
score = score + 3.0
|
|
345
|
+
reasons.append("4h_bearish")
|
|
346
|
+
else:
|
|
347
|
+
score = score + 1.0
|
|
348
|
+
reasons.append("4h_neutral")
|
|
349
|
+
if t1 == "BEARISH":
|
|
350
|
+
score = score + 1.0
|
|
351
|
+
reasons.append("1h_bearish")
|
|
352
|
+
elif t1 == "BULLISH":
|
|
353
|
+
score = score - 1.0
|
|
354
|
+
reasons.append("1h_bullish")
|
|
355
|
+
if own <= -1.0:
|
|
356
|
+
score = score + 2.0
|
|
357
|
+
reasons.append("mom_strong")
|
|
358
|
+
elif own <= 0.0:
|
|
359
|
+
score = score + 1.0
|
|
360
|
+
reasons.append("mom")
|
|
361
|
+
if r > 22.0:
|
|
362
|
+
score = score + 1.0
|
|
363
|
+
reasons.append("rsi_room")
|
|
364
|
+
if score < min_score:
|
|
365
|
+
continue
|
|
366
|
+
scores[asset] = {"score": round(score, 4), "direction": direction, "reasons": reasons}
|
|
367
|
+
return scores
|
|
368
|
+
'''
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _exec_fn(fn_src: str):
|
|
372
|
+
ns: dict = {}
|
|
373
|
+
exec(fn_src, ns) # noqa: S102 - trusted, in-repo reference body used only for test-gen
|
|
374
|
+
for name, obj in ns.items():
|
|
375
|
+
if callable(obj) and name != "__builtins__":
|
|
376
|
+
return obj
|
|
377
|
+
raise RuntimeError("no function found in reference body")
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def classifier_tests(min_score: float) -> tuple[dict, ...]:
|
|
381
|
+
"""Two inline tests whose expected values are computed from the default body itself, so
|
|
382
|
+
they pass by construction (the emitted body == the executed body)."""
|
|
383
|
+
fn = _exec_fn(CLASSIFIER_FN)
|
|
384
|
+
confirmed_in = {"trend4h": {"ACME": "BULLISH"}, "trend1h": {"ACME": "BULLISH"}, "sm": {"ACME": 60.0}}
|
|
385
|
+
neutral_in = {"trend4h": {"ACME": "NEUTRAL"}, "trend1h": {"ACME": "BULLISH"}, "sm": {"ACME": 60.0}}
|
|
386
|
+
confirmed = fn(confirmed_in["trend4h"], confirmed_in["trend1h"], confirmed_in["sm"], min_score)
|
|
387
|
+
neutral = fn(neutral_in["trend4h"], neutral_in["trend1h"], neutral_in["sm"], min_score)
|
|
388
|
+
return (
|
|
389
|
+
{"name": "confirmed_side_scored", "inputs": confirmed_in, "expect": {"scores": confirmed}},
|
|
390
|
+
{"name": "neutral_4h_dropped", "inputs": neutral_in, "expect": {"scores": neutral}},
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def thesis_tests(
|
|
395
|
+
long_basket: list[str], short_basket: list[str], min_score: float
|
|
396
|
+
) -> tuple[dict, ...]:
|
|
397
|
+
"""Two inline tests (one per leg) computed from the default body with the BOUND baskets,
|
|
398
|
+
so they pass whatever universe was chosen."""
|
|
399
|
+
fn = _exec_fn(THESIS_FN)
|
|
400
|
+
longs = list(long_basket) or ["BTC"]
|
|
401
|
+
shorts = list(short_basket) or ["ETH"]
|
|
402
|
+
la, sa = str(longs[0]).upper(), str(shorts[0]).upper()
|
|
403
|
+
long_in = {"trend4h": {la: "BULLISH"}, "trend1h": {la: "BULLISH"}, "rsi": {la: 50.0}, "own24h": {la: 10.0}}
|
|
404
|
+
short_in = {"trend4h": {sa: "BEARISH"}, "trend1h": {sa: "BEARISH"}, "rsi": {sa: 50.0}, "own24h": {sa: -10.0}}
|
|
405
|
+
lo = fn(long_in["trend4h"], long_in["trend1h"], long_in["rsi"], long_in["own24h"], longs, shorts, min_score)
|
|
406
|
+
so = fn(short_in["trend4h"], short_in["trend1h"], short_in["rsi"], short_in["own24h"], longs, shorts, min_score)
|
|
407
|
+
return (
|
|
408
|
+
{"name": "long_leg_confirmed", "inputs": long_in, "expect": {"scores": lo}},
|
|
409
|
+
{"name": "short_leg_confirmed", "inputs": short_in, "expect": {"scores": so}},
|
|
410
|
+
)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""breakout — the hawk shape (D7 step 2): a directional range-break as the hard gate, over a
|
|
2
|
+
FIXED basket or a DISCOVERED universe, with trend / volume / smart-money as weighted
|
|
3
|
+
contributors. Ported from the hand-authored ``examples/breakout_swing.graph.yaml`` (the spec)
|
|
4
|
+
and made an archetype so `composer new --archetype breakout` reproduces it — and, composed
|
|
5
|
+
with the F1 ``scoring`` / ``indicators`` surface, the 4-hard-gate discovery variant the first
|
|
6
|
+
real dev test agreed but generation could not produce.
|
|
7
|
+
|
|
8
|
+
Mechanically bound from the answers (D7 step 3): universe -> candles whitelist OR the live
|
|
9
|
+
instrument board (discover); intervals -> candles + indicators; ttl -> ttl_dedup_state;
|
|
10
|
+
cardinality -> ranker; risk -> emitter. The scorer default hard-gates ONLY the breakout
|
|
11
|
+
(``required_gates: ["brk"]``) with trend/vol/SM as weights — the example's shape. Answers
|
|
12
|
+
that pass ``scoring`` re-select the hard gates (e.g. all four of breakout / freshness /
|
|
13
|
+
volume / smart-money), and ``indicators`` add registry nodes the fixed set does not carry
|
|
14
|
+
(e.g. ``recent_high_bars_ago`` for the "don't-chase" freshness gate) — no hand edit.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from ..answers import Answers
|
|
20
|
+
from ..builder import GraphBuilder
|
|
21
|
+
from . import _common
|
|
22
|
+
|
|
23
|
+
BANNER = [
|
|
24
|
+
"breakout strategy — machine-generated anchor (composer new).",
|
|
25
|
+
"",
|
|
26
|
+
"A directional range-break (breakout_detect) is the hard gate; 4h trend structure, a",
|
|
27
|
+
"volume surge, and smart-money lean contribute to a weighted score, then single-best (or",
|
|
28
|
+
"pool-cap) emission with the universal TTL signal-dedup. The universe is a fixed basket or",
|
|
29
|
+
"discovered each tick from the live instrument board. Edit ONLY the TODO(edit) slots on the",
|
|
30
|
+
"scorer to move this toward your edge; every intermediate state stays valid (`composer check`).",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def build(answers: Answers) -> GraphBuilder:
|
|
35
|
+
b = GraphBuilder()
|
|
36
|
+
iv = answers.intervals[0] if answers.intervals else "1h"
|
|
37
|
+
u = answers.universe
|
|
38
|
+
|
|
39
|
+
b.add("account", "clearinghouse_state", header=("sources.",))
|
|
40
|
+
b.add("dedup_state", "ttl_dedup_state", {"ttl_seconds": answers.ttl_seconds})
|
|
41
|
+
|
|
42
|
+
if u.discover:
|
|
43
|
+
b.add("board", "instrument_list", {"dex": u.dex},
|
|
44
|
+
header=("dynamic universe discovery: live board -> leverage/volume/prefix signature.",))
|
|
45
|
+
b.add("universe", "universe_discover",
|
|
46
|
+
{"name_prefix": u.name_prefix, "max_leverage_cap": u.max_leverage_cap,
|
|
47
|
+
"min_day_volume": u.min_day_volume})
|
|
48
|
+
b.add("exclude_held", "held_asset_exclusion",
|
|
49
|
+
header=("universe narrowing over the discovered set.",))
|
|
50
|
+
else:
|
|
51
|
+
b.add("exclude_held", "held_asset_exclusion",
|
|
52
|
+
header=("universe narrowing over the fixed basket (candles supplies it).",))
|
|
53
|
+
b.add("exclude_recent", "recent_signal_ttl_dedup")
|
|
54
|
+
|
|
55
|
+
candles_params = {"intervals": list(answers.intervals)}
|
|
56
|
+
if not u.discover:
|
|
57
|
+
candles_params = {"whitelist": list(u.whitelist), "intervals": list(answers.intervals)}
|
|
58
|
+
b.add("candles", "candles_fetch", candles_params,
|
|
59
|
+
header=("candle + leaderboard reads over the narrowed universe.",))
|
|
60
|
+
b.add("lean", "sm_lean", {"agree_threshold": 0.55},
|
|
61
|
+
param_comments={"agree_threshold": "TODO(edit): smart-money agreement band (hawk 0.55)."},
|
|
62
|
+
slots=("agree_threshold",))
|
|
63
|
+
|
|
64
|
+
b.add("brk", "breakout_detect", {"window": 168, "interval": iv},
|
|
65
|
+
header=("indicators: candles -> per-asset features.",))
|
|
66
|
+
b.add("trend4h", "trend_structure", {"lookback": 6, "interval": "4h"})
|
|
67
|
+
b.add("vol", "volume_surge_ratio", {"recent_window": 1, "baseline_window": 10, "interval": iv})
|
|
68
|
+
|
|
69
|
+
# F1 — ADDITIONAL registry indicators requested by the answers (empty => no-op).
|
|
70
|
+
extra = _common.add_indicator_nodes(b, answers.indicators)
|
|
71
|
+
|
|
72
|
+
# F1 — the scorer's hard-vs-weighted split. DEFAULT = the example's shape: breakout is the
|
|
73
|
+
# sole hard gate, trend/vol/SM are weighted contributors. answers.scoring overrides it (e.g.
|
|
74
|
+
# the incident's four hard gates: brk + a freshness indicator + vol + lean).
|
|
75
|
+
feature_ids = ["brk", "trend4h", "vol", "lean"] + [r.id for r in extra]
|
|
76
|
+
required_gates, weights, min_score = _common.resolve_scoring(
|
|
77
|
+
answers,
|
|
78
|
+
default_required=("brk",),
|
|
79
|
+
default_weights={"brk": 2.0, "trend4h": 2.0, "vol": 1.0, "lean": 0.05},
|
|
80
|
+
default_min=5,
|
|
81
|
+
feature_ids=feature_ids,
|
|
82
|
+
)
|
|
83
|
+
b.add(
|
|
84
|
+
"gates", "hard_gates_all",
|
|
85
|
+
{
|
|
86
|
+
"required_gates": required_gates,
|
|
87
|
+
"weights": weights,
|
|
88
|
+
"min_score": min_score,
|
|
89
|
+
},
|
|
90
|
+
header=("scorer: hard gates + weighted sum — THE EDGE SLOT (see the EDGE block above).",),
|
|
91
|
+
param_comments={
|
|
92
|
+
"required_gates": "TODO(edit): feature keys (SOURCE NODE IDS) that must ALL pass.",
|
|
93
|
+
"weights": "TODO(edit): source-node-id -> weight; this weighted sum IS your edge.",
|
|
94
|
+
"min_score": "TODO(edit): minimum score to emit.",
|
|
95
|
+
},
|
|
96
|
+
slots=("required_gates", "weights", "min_score"),
|
|
97
|
+
)
|
|
98
|
+
b.set_edge_anchor("gates")
|
|
99
|
+
|
|
100
|
+
_common.add_ranker(
|
|
101
|
+
b, ranker_id="best", scorer_id="gates", scorer_port="scores",
|
|
102
|
+
account_id="account", max_positions=answers.max_positions,
|
|
103
|
+
)
|
|
104
|
+
b.add("emit", "signal_envelope",
|
|
105
|
+
{"margin_pct": answers.risk.margin_pct, "leverage": answers.risk.leverage},
|
|
106
|
+
header=("emitter: build the scan() signal list.",))
|
|
107
|
+
|
|
108
|
+
# universe wiring
|
|
109
|
+
if u.discover:
|
|
110
|
+
b.connect("board", "instruments", "universe", "instruments", section="dynamic universe discovery")
|
|
111
|
+
b.connect("universe", "universe", "exclude_held", "candidates", section="universe narrowing chain")
|
|
112
|
+
b.connect("account", "held", "exclude_held", "held",
|
|
113
|
+
section=None if u.discover else "universe narrowing chain")
|
|
114
|
+
b.connect("exclude_held", "kept", "exclude_recent", "candidates")
|
|
115
|
+
b.connect("dedup_state", "recent", "exclude_recent", "recent")
|
|
116
|
+
b.connect("exclude_recent", "kept", "candles", "assets")
|
|
117
|
+
b.connect("exclude_recent", "kept", "lean", "assets")
|
|
118
|
+
# candles fan out to indicators
|
|
119
|
+
b.connect("candles", "candles", "brk", "candles", section="candles fan out to indicators")
|
|
120
|
+
b.connect("candles", "candles", "trend4h", "candles")
|
|
121
|
+
b.connect("candles", "candles", "vol", "candles")
|
|
122
|
+
_common.wire_indicator_inputs(b, extra, candles_id="candles")
|
|
123
|
+
# per-asset features MERGE into the scorer (keyed by source node id)
|
|
124
|
+
b.connect("brk", "direction", "gates", "features", section="per-asset features merge into the scorer (keyed by source node id)")
|
|
125
|
+
b.connect("trend4h", "direction", "gates", "features")
|
|
126
|
+
b.connect("vol", "ratio", "gates", "features")
|
|
127
|
+
b.connect("lean", "lean", "gates", "features")
|
|
128
|
+
_common.wire_indicator_features(b, extra, scorer_id="gates")
|
|
129
|
+
# score -> rank -> emit (rank wiring added by add_ranker)
|
|
130
|
+
b.connect("best", "selected", "emit", "selected")
|
|
131
|
+
b.connect("account", "equity", "emit", "equity")
|
|
132
|
+
# close the dedup loop
|
|
133
|
+
b.connect("emit", "signals", "dedup_state", "emitted", section="close the dedup loop: this tick's signals are recorded in ctx.state")
|
|
134
|
+
return b
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""classifier — the magpie shape (D7 step 2): a per-graph pure_fn CLASSIFIER predicate over
|
|
2
|
+
trend structure + smart money, on either a discovered or a fixed universe.
|
|
3
|
+
|
|
4
|
+
The direction classifier is cross-feature logic no registry scorer expresses (the slice-4
|
|
5
|
+
finding), so it is a pure_fn (D2 kind 2) carrying a WORKING DEFAULT body (magpie's
|
|
6
|
+
pre-listing predicate) as the TODO slot the model edits. Mechanically bound: discovery
|
|
7
|
+
params (dex / prefix / leverage cap / volume floor) -> the instrument_list + classifier_
|
|
8
|
+
predicate + universe_discover trio, or a fixed whitelist -> candles; intervals, ttl, risk,
|
|
9
|
+
cardinality bind as usual.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from ..answers import Answers
|
|
15
|
+
from ..builder import GraphBuilder
|
|
16
|
+
from . import _common
|
|
17
|
+
|
|
18
|
+
BANNER = [
|
|
19
|
+
"classifier strategy — machine-generated anchor (composer new).",
|
|
20
|
+
"",
|
|
21
|
+
"Trend structure at 4h + 1h feeds a per-graph pure_fn CLASSIFIER that fixes the side and",
|
|
22
|
+
"confirms with smart money (magpie pre-listing shape). Universe is either discovered each",
|
|
23
|
+
"tick from the live instrument board (funding/leverage/volume signature) or a fixed",
|
|
24
|
+
"whitelist. Edit ONLY the pure_fn body (the EDGE slot) to encode your classification edge.",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
_MIN_SCORE = 3.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build(answers: Answers) -> GraphBuilder:
|
|
31
|
+
b = GraphBuilder()
|
|
32
|
+
u = answers.universe
|
|
33
|
+
|
|
34
|
+
b.add("account", "clearinghouse_state", header=("sources.",))
|
|
35
|
+
b.add("dedup_state", "ttl_dedup_state", {"ttl_seconds": answers.ttl_seconds})
|
|
36
|
+
|
|
37
|
+
if u.discover:
|
|
38
|
+
b.add("board", "instrument_list", {"dex": u.dex},
|
|
39
|
+
header=("dynamic universe discovery: live board -> funding signature -> prefix/leverage/volume.",))
|
|
40
|
+
b.add("ipop_funding", "classifier_predicate",
|
|
41
|
+
{"max_funding_abs": 0.0000001, "max_leverage": u.max_leverage_cap, "exclude_delisted": True})
|
|
42
|
+
b.add("ipop_universe", "universe_discover",
|
|
43
|
+
{"name_prefix": u.name_prefix, "max_leverage_cap": u.max_leverage_cap, "min_day_volume": u.min_day_volume})
|
|
44
|
+
b.add("exclude_held", "held_asset_exclusion",
|
|
45
|
+
header=("universe narrowing over the discovered set.",))
|
|
46
|
+
else:
|
|
47
|
+
b.add("exclude_held", "held_asset_exclusion",
|
|
48
|
+
header=("universe narrowing over the fixed whitelist (candles supplies it).",))
|
|
49
|
+
b.add("exclude_recent", "recent_signal_ttl_dedup")
|
|
50
|
+
|
|
51
|
+
candles_params = {"intervals": list(answers.intervals)}
|
|
52
|
+
if not u.discover:
|
|
53
|
+
candles_params = {"whitelist": list(u.whitelist), "intervals": list(answers.intervals)}
|
|
54
|
+
b.add("candles", "candles_fetch", candles_params,
|
|
55
|
+
header=("candle + leaderboard reads over the narrowed universe.",))
|
|
56
|
+
b.add("lean", "sm_lean", {"agree_threshold": 0.55},
|
|
57
|
+
param_comments={"agree_threshold": "TODO(edit): smart-money agreement band."},
|
|
58
|
+
slots=("agree_threshold",))
|
|
59
|
+
|
|
60
|
+
b.add("trend4h", "trend_structure", {"lookback": 6, "interval": "4h"},
|
|
61
|
+
header=("trend structure at both intervals (the classifier's inputs).",))
|
|
62
|
+
b.add("trend1h", "trend_structure", {"lookback": 6, "interval": "1h"})
|
|
63
|
+
|
|
64
|
+
b.add_pure_fn(
|
|
65
|
+
"classify",
|
|
66
|
+
inputs=(
|
|
67
|
+
{"name": "trend4h", "type": "feature_map", "doc": "4h trend structure (BULLISH/BEARISH/NEUTRAL)."},
|
|
68
|
+
{"name": "trend1h", "type": "feature_map", "doc": "1h trend structure (BULLISH/BEARISH/NEUTRAL)."},
|
|
69
|
+
{"name": "sm", "type": "feature_map", "doc": "Smart-money long lean percent [0,100]; may be absent."},
|
|
70
|
+
),
|
|
71
|
+
outputs=({"name": "scores", "type": "score_map", "doc": "Per-asset {score, direction, reasons[]}."},),
|
|
72
|
+
params=({"name": "min_score", "type": "float", "default": _MIN_SCORE, "doc": "Minimum score to emit."},),
|
|
73
|
+
fn=_common.CLASSIFIER_FN,
|
|
74
|
+
tests=_common.classifier_tests(_MIN_SCORE),
|
|
75
|
+
header=("pure_fn classifier — THE EDGE SLOT (edit the body; see the EDGE block above).",),
|
|
76
|
+
)
|
|
77
|
+
b.set_edge_anchor("classify")
|
|
78
|
+
|
|
79
|
+
_common.add_ranker(
|
|
80
|
+
b, ranker_id="best", scorer_id="classify", scorer_port="scores",
|
|
81
|
+
account_id="account", max_positions=answers.max_positions,
|
|
82
|
+
)
|
|
83
|
+
b.add("emit", "signal_envelope",
|
|
84
|
+
{"margin_pct": answers.risk.margin_pct, "leverage": answers.risk.leverage},
|
|
85
|
+
header=("emitter.",))
|
|
86
|
+
|
|
87
|
+
# universe wiring
|
|
88
|
+
if u.discover:
|
|
89
|
+
b.connect("board", "instruments", "ipop_funding", "candidates", section="dynamic universe discovery")
|
|
90
|
+
b.connect("ipop_funding", "kept", "ipop_universe", "instruments")
|
|
91
|
+
b.connect("ipop_universe", "universe", "exclude_held", "candidates", section="universe narrowing chain")
|
|
92
|
+
b.connect("account", "held", "exclude_held", "held",
|
|
93
|
+
section=None if u.discover else "universe narrowing chain")
|
|
94
|
+
b.connect("exclude_held", "kept", "exclude_recent", "candidates")
|
|
95
|
+
b.connect("dedup_state", "recent", "exclude_recent", "recent")
|
|
96
|
+
b.connect("exclude_recent", "kept", "candles", "assets")
|
|
97
|
+
b.connect("exclude_recent", "kept", "lean", "assets")
|
|
98
|
+
# candles -> trend structure
|
|
99
|
+
b.connect("candles", "candles", "trend4h", "candles", section="candles fan out to trend structure")
|
|
100
|
+
b.connect("candles", "candles", "trend1h", "candles")
|
|
101
|
+
# features into the pure_fn (single writer per declared port)
|
|
102
|
+
b.connect("trend4h", "direction", "classify", "trend4h", section="features into the classifier pure_fn")
|
|
103
|
+
b.connect("trend1h", "direction", "classify", "trend1h")
|
|
104
|
+
b.connect("lean", "lean", "classify", "sm")
|
|
105
|
+
# classify -> rank -> emit
|
|
106
|
+
b.connect("best", "selected", "emit", "selected")
|
|
107
|
+
b.connect("account", "equity", "emit", "equity")
|
|
108
|
+
b.connect("emit", "signals", "dedup_state", "emitted", section="close the dedup loop")
|
|
109
|
+
return b
|