@senpi/runtime 3.0.9-dev.feat-strategy-compos.20260721003344 → 3.0.9-dev.feat-strategy-compos.20260722213012
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.map +1 -1
- package/dist/cli/composer-commands.js +193 -41
- package/dist/cli/composer-commands.js.map +1 -1
- package/dist/cli/composer-live-read.d.ts +68 -0
- package/dist/cli/composer-live-read.d.ts.map +1 -0
- package/dist/cli/composer-live-read.js +126 -0
- package/dist/cli/composer-live-read.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +8 -10
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/cli/senpi-strategy-tool.d.ts +56 -0
- package/dist/cli/senpi-strategy-tool.d.ts.map +1 -0
- package/dist/cli/senpi-strategy-tool.js +158 -0
- package/dist/cli/senpi-strategy-tool.js.map +1 -0
- package/dist/composer/authoring/__init__.py +2 -0
- package/dist/composer/authoring/answers.py +52 -0
- package/dist/composer/authoring/archetypes/__init__.py +2 -0
- package/dist/composer/authoring/archetypes/contrarian_fade.py +493 -0
- package/dist/composer/authoring/archetypes/relative_value.py +280 -23
- package/dist/composer/cli.py +57 -6
- package/dist/composer/examples/contrarian_fade_crowd.graph.yaml +356 -0
- package/dist/composer/examples/rotation_cross_sectional.graph.yaml +389 -0
- package/dist/composer/generated/catalog.md +2 -1
- package/dist/composer/registry/generate.py +2 -1
- package/dist/composer/validator/errors.py +2 -0
- package/dist/composer/verbs/__init__.py +6 -0
- package/dist/composer/verbs/_box.py +96 -5
- package/dist/composer/verbs/_layout.py +37 -0
- package/dist/composer/verbs/_live.py +50 -6
- package/dist/composer/verbs/close.py +51 -6
- package/dist/composer/verbs/fund.py +144 -27
- package/dist/composer/verbs/install.py +25 -6
- package/dist/composer/verbs/portfolio.py +650 -0
- package/dist/composer/verbs/review.py +724 -0
- package/dist/composer/verbs/status.py +129 -2
- package/dist/composer/verbs/update.py +9 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -1
- package/dist/runtime/composer-managed-write.d.ts +133 -0
- package/dist/runtime/composer-managed-write.d.ts.map +1 -0
- package/dist/runtime/composer-managed-write.js +369 -0
- package/dist/runtime/composer-managed-write.js.map +1 -0
- package/openclaw.plugin.json +5 -0
- package/package.json +2 -1
|
@@ -8,9 +8,12 @@ whose benchmark leg is pinned in the universe (never discovered, never held/dedu
|
|
|
8
8
|
narrowing could prune the benchmark and silently break the comparison) and whose cross-asset
|
|
9
9
|
comparison lives in ONE two-output signed-net-score pure_fn (D2 kind 2).
|
|
10
10
|
|
|
11
|
-
This
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
This module builds BOTH members of the family (design-models §11), dispatched by `build()` from
|
|
12
|
+
the universe ingredients (no member field): `relative_value.pairwise` (this file's first half — the
|
|
13
|
+
fixed [target, benchmark] two-asset compare, when the whitelist is EXACTLY two) and
|
|
14
|
+
`relative_value.cross_sectional` (the second half, below — long the strongest vs fade the weakest of
|
|
15
|
+
N, when the whitelist has >= 3 names or the board is discovered). The pairwise builder's one pure_fn
|
|
16
|
+
produces BOTH tails from the same signed net score:
|
|
14
17
|
|
|
15
18
|
* entry_scores — LONG the target when the benchmark has out-returned it (target is
|
|
16
19
|
relatively cheap) by net >= enter_threshold AND the target is NOT already held.
|
|
@@ -45,6 +48,7 @@ from __future__ import annotations
|
|
|
45
48
|
|
|
46
49
|
from ..answers import Answers
|
|
47
50
|
from ..builder import GraphBuilder
|
|
51
|
+
from . import _common
|
|
48
52
|
from ._common import GenerationError
|
|
49
53
|
|
|
50
54
|
DESCRIBE = [
|
|
@@ -72,8 +76,11 @@ DESCRIBE = [
|
|
|
72
76
|
"the incident that motivated the family). If the signal is one asset's own trend/momentum in",
|
|
73
77
|
"isolation, it is trend_momentum.",
|
|
74
78
|
"",
|
|
75
|
-
"Members
|
|
76
|
-
"
|
|
79
|
+
"Members (inferred from the universe — no member field): `pairwise` = EXACTLY two whitelisted",
|
|
80
|
+
"assets [target, benchmark] (the fixed two-asset compare); `cross_sectional` = a whitelist of",
|
|
81
|
+
">= 3 names OR a discovered board — rank the universe by relative strength, LONG the strongest",
|
|
82
|
+
"and SHORT (fade) the weakest within slot caps (the `rotation` cluster; enter_threshold/",
|
|
83
|
+
"exit_threshold become the relative-strength ENTER band and the CONVERGENCE exit band).",
|
|
77
84
|
"",
|
|
78
85
|
"Emits: clearinghouse_state (equity for sizing — NO held-exclusion on the entry universe),",
|
|
79
86
|
"open_positions (held + direction, for in-fn held-exclusion and the exit direction stamp),",
|
|
@@ -81,7 +88,7 @@ DESCRIBE = [
|
|
|
81
88
|
"TWO outputs), emit_single_best + signal_envelope (entry tail), and close_signal_envelope",
|
|
82
89
|
"(exit tail — only when exit_signals is set).",
|
|
83
90
|
"",
|
|
84
|
-
"Answers that matter: universe.whitelist = [target, benchmark] (EXACTLY two
|
|
91
|
+
"Answers that matter (pairwise): universe.whitelist = [target, benchmark] (EXACTLY two);",
|
|
85
92
|
"intervals (the candle interval + window bar counts), exit_signals.{enter_threshold,",
|
|
86
93
|
"exit_threshold, interval_seconds, reentry_cooldown_seconds, min_divergence} (present => the",
|
|
87
94
|
"exit tail; min_divergence is the per-window vote NOISE GATE threaded into the scoring pure_fn —",
|
|
@@ -233,28 +240,38 @@ def _relative_value_tests(
|
|
|
233
240
|
|
|
234
241
|
|
|
235
242
|
def build(answers: Answers) -> GraphBuilder:
|
|
243
|
+
"""Dispatch the relative_value FAMILY to its member (taxonomy: family = thesis shape, members =
|
|
244
|
+
ingredient combos). The MEMBER is inferred from the universe INGREDIENTS (the least-new-surface
|
|
245
|
+
convention — no member field):
|
|
246
|
+
|
|
247
|
+
* ``pairwise`` — EXACTLY two whitelisted assets, no discovery: the fixed [target, benchmark]
|
|
248
|
+
cross-asset compare (the original member).
|
|
249
|
+
* ``cross_sectional`` — a DISCOVERED board OR a whitelist of >= 3 names: rank the universe by
|
|
250
|
+
relative strength, LONG the strongest / SHORT (fade) the weakest within slot caps.
|
|
251
|
+
|
|
252
|
+
A whitelist of 0 or 1 (with no discovery) fits neither and is rejected LOUDLY."""
|
|
253
|
+
u = answers.universe
|
|
254
|
+
if u.discover or len(u.whitelist) >= 3:
|
|
255
|
+
return _build_cross_sectional(answers)
|
|
256
|
+
if len(u.whitelist) != 2:
|
|
257
|
+
raise GenerationError(
|
|
258
|
+
"archetype 'relative_value' needs EITHER exactly two whitelisted assets as [target, "
|
|
259
|
+
f"benchmark] (the pairwise member) OR a discovered board / a whitelist of >= 3 names "
|
|
260
|
+
f"(the cross_sectional member); got universe.whitelist={list(u.whitelist)} with "
|
|
261
|
+
f"discover={u.discover}. The pairwise FIRST asset is the traded target, the SECOND is "
|
|
262
|
+
"the reference benchmark (never traded)."
|
|
263
|
+
)
|
|
264
|
+
return _build_pairwise(answers)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _build_pairwise(answers: Answers) -> GraphBuilder:
|
|
236
268
|
b = GraphBuilder()
|
|
237
269
|
u = answers.universe
|
|
238
270
|
iv = answers.intervals[0] if answers.intervals else "4h"
|
|
239
271
|
|
|
240
|
-
# relative_value.pairwise is a FIXED-PAIR shape: no discovery (
|
|
241
|
-
#
|
|
242
|
-
# silently emit a broken universe (mirrors trend_momentum's discover guard).
|
|
243
|
-
if u.discover:
|
|
244
|
-
raise GenerationError(
|
|
245
|
-
"archetype 'relative_value' (pairwise member) is a FIXED-PAIR shape and cannot "
|
|
246
|
-
"discover a universe from the live board — discovery could prune the benchmark leg. "
|
|
247
|
-
"Set universe.whitelist to exactly [target, benchmark]. (A discovered cross-sectional "
|
|
248
|
-
"long-strongest/short-weakest variant is the designed cross_sectional follow-up member, "
|
|
249
|
-
"not yet built.)"
|
|
250
|
-
)
|
|
272
|
+
# relative_value.pairwise is a FIXED-PAIR shape: EXACTLY two assets, no discovery (the dispatcher
|
|
273
|
+
# guarantees this — a discovered/>=3 universe routes to cross_sectional).
|
|
251
274
|
pair = list(u.whitelist)
|
|
252
|
-
if len(pair) != 2:
|
|
253
|
-
raise GenerationError(
|
|
254
|
-
"archetype 'relative_value' (pairwise member) needs EXACTLY two assets in "
|
|
255
|
-
f"universe.whitelist as [target, benchmark]; got {pair}. The FIRST is the traded "
|
|
256
|
-
"target, the SECOND is the reference benchmark (never traded)."
|
|
257
|
-
)
|
|
258
275
|
target, benchmark = pair[0], pair[1]
|
|
259
276
|
|
|
260
277
|
bars = _BARS_PER_DAY.get(iv)
|
|
@@ -374,3 +391,243 @@ def build(answers: Answers) -> GraphBuilder:
|
|
|
374
391
|
section="exit tail: exit_scores -> close emitter (+ positions for direction)")
|
|
375
392
|
b.connect("positions", "positions", "close_emit", "positions")
|
|
376
393
|
return b
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
# --------------------------------------------------------------------------- cross_sectional member
|
|
397
|
+
#
|
|
398
|
+
# The `cross_sectional` member (design-models §11; capability-regression audit §E4) — the
|
|
399
|
+
# designed-but-unbuilt follow-up: rank a UNIVERSE (a fixed basket of >= 3 OR a discovered board) by
|
|
400
|
+
# RELATIVE STRENGTH and go LONG the strongest / SHORT (fade) the weakest, capped to the open slots.
|
|
401
|
+
# It covers the `rotation` corpus cluster (sailfish long-only single-best; lion / gibbon / orangutan
|
|
402
|
+
# / wolf long/short rank-and-ride). It reuses the relative_value family's conventions verbatim: the
|
|
403
|
+
# same 3d/7d/30d lookback windows + weights (WINDOWS), the same days->bars mapping (_BARS_PER_DAY),
|
|
404
|
+
# the same two-output pure_fn shape with in-fn held-exclusion, and the SAME exit_signals surface
|
|
405
|
+
# (enter_threshold/exit_threshold are the relative-strength ENTER band and the CONVERGENCE exit band).
|
|
406
|
+
#
|
|
407
|
+
# The RANKER CONSTRAINT (handover 2026-07-19 tripwire: 2 fixed rankers, no pure_fn-shaped ranking
|
|
408
|
+
# hatch): a cross-sectional long/short is expressed WITHIN the existing rankers by having the pure_fn
|
|
409
|
+
# score BOTH tails by |relative strength| and stamp the direction per score_map entry — the ranker
|
|
410
|
+
# then caps to the top-N by magnitude (a mix of strongest-longs + weakest-shorts), no new ranker
|
|
411
|
+
# type. max_positions drives single-best (the single strongest, sailfish) vs pool-cap (lion/gibbon).
|
|
412
|
+
|
|
413
|
+
# The cross-sectional relative-strength scorer (D2 kind 2). Generic over the bound windows/weights/
|
|
414
|
+
# thresholds (all passed as params); emitted verbatim AND executed in _cross_sectional_tests.
|
|
415
|
+
CROSS_SECTIONAL_FN = '''\
|
|
416
|
+
def score_cross_sectional(ret_3d, ret_7d, ret_30d, positions, enter_threshold, exit_threshold, w_3d, w_7d, w_30d, signal_strength):
|
|
417
|
+
# TODO(edit): the CROSS-SECTIONAL relative-strength edge (a WORKING DEFAULT; the `rotation`
|
|
418
|
+
# cluster — sailfish/lion/gibbon). For each asset the COMPOSITE momentum is the WEIGHTED AVERAGE
|
|
419
|
+
# of its 3d/7d/30d returns (a longer window is a more durable read, so it carries more weight).
|
|
420
|
+
# RELATIVE STRENGTH is composite minus the CROSS-SECTIONAL MEAN composite across the universe.
|
|
421
|
+
# ENTER when FLAT: LONG the strongest (RS >= enter_threshold), SHORT (fade) the weakest
|
|
422
|
+
# (RS <= -enter_threshold); between the bands is the dead band (emit nothing). EXIT a HELD name
|
|
423
|
+
# when its relative value has CONVERGED (|RS| <= exit_threshold — the spread the trade was betting
|
|
424
|
+
# on has closed) OR its RS has FLIPPED sign (it rotated to the other tail). Both tails score by
|
|
425
|
+
# |RS| so the fixed ranker caps to the top-N by magnitude. Held-exclusion + the real held direction
|
|
426
|
+
# live here (via positions) so entry and exit never both fire. A window missing a leg abstains; an
|
|
427
|
+
# empty universe returns nothing, never raises. Encode YOUR edge by editing this body — it is
|
|
428
|
+
# purity-checked (no ctx/MCP/I/O, math only) and its inline tests run at validate time.
|
|
429
|
+
composite = {}
|
|
430
|
+
for asset in sorted(set(ret_3d) | set(ret_7d) | set(ret_30d)):
|
|
431
|
+
acc = 0.0
|
|
432
|
+
wsum = 0.0
|
|
433
|
+
for series, weight in ((ret_3d, w_3d), (ret_7d, w_7d), (ret_30d, w_30d)):
|
|
434
|
+
value = series.get(asset)
|
|
435
|
+
if value is None:
|
|
436
|
+
continue
|
|
437
|
+
acc = acc + value * weight
|
|
438
|
+
wsum = wsum + weight
|
|
439
|
+
if wsum <= 0.0:
|
|
440
|
+
continue
|
|
441
|
+
composite[asset] = acc / wsum
|
|
442
|
+
if not composite:
|
|
443
|
+
return {}, {}
|
|
444
|
+
mean = sum(composite.values()) / len(composite)
|
|
445
|
+
held = {}
|
|
446
|
+
for record in positions or []:
|
|
447
|
+
coin = str(record.get("coin", "")).upper()
|
|
448
|
+
if coin:
|
|
449
|
+
held[coin] = record.get("direction") or "LONG"
|
|
450
|
+
entry_scores = {}
|
|
451
|
+
exit_scores = {}
|
|
452
|
+
for asset in sorted(composite):
|
|
453
|
+
rs = composite[asset] - mean
|
|
454
|
+
held_direction = held.get(str(asset).upper())
|
|
455
|
+
if held_direction is None:
|
|
456
|
+
if rs >= enter_threshold:
|
|
457
|
+
entry_scores[asset] = {"score": round(signal_strength * rs, 4), "direction": "LONG",
|
|
458
|
+
"reasons": ["relative_strength"]}
|
|
459
|
+
elif rs <= -enter_threshold:
|
|
460
|
+
entry_scores[asset] = {"score": round(signal_strength * abs(rs), 4), "direction": "SHORT",
|
|
461
|
+
"reasons": ["relative_weakness"]}
|
|
462
|
+
else:
|
|
463
|
+
converged = abs(rs) <= exit_threshold
|
|
464
|
+
flipped = (held_direction == "LONG" and rs < 0.0) or (held_direction == "SHORT" and rs > 0.0)
|
|
465
|
+
if converged or flipped:
|
|
466
|
+
exit_scores[asset] = {"score": round(abs(rs), 4), "direction": held_direction,
|
|
467
|
+
"reasons": ["converged" if converged else "rank_flip"]}
|
|
468
|
+
return entry_scores, exit_scores
|
|
469
|
+
'''
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _cross_sectional_tests(
|
|
473
|
+
weights: tuple[float, ...], enter: float, exit_threshold: float,
|
|
474
|
+
) -> tuple[dict, ...]:
|
|
475
|
+
"""Six inline fixtures whose expected values are computed from the default body itself with the
|
|
476
|
+
BOUND weights/thresholds, so they pass by construction. Semantic cases: long-strongest entry,
|
|
477
|
+
short-weakest entry, dead band, converged exit (held), rank-flip exit (held), flat-no-exit. Each
|
|
478
|
+
window carries the same per-asset value so composite == that value (keeps the arithmetic legible)."""
|
|
479
|
+
fn = _exec_fn(CROSS_SECTIONAL_FN)
|
|
480
|
+
w3, w7, w30 = weights
|
|
481
|
+
|
|
482
|
+
def call(series, positions):
|
|
483
|
+
e, x = fn(series, series, series, positions, enter, exit_threshold, w3, w7, w30, _SIGNAL_STRENGTH)
|
|
484
|
+
return {"entry_scores": e, "exit_scores": x}
|
|
485
|
+
|
|
486
|
+
# values chosen so the cross-sectional mean is 0 and RS = the raw value:
|
|
487
|
+
strong = enter + 5.0 # clearly the strongest (LONG)
|
|
488
|
+
weak = -(enter + 5.0) # clearly the weakest (SHORT)
|
|
489
|
+
spread = {"AAA": strong, "BBB": 0.0, "CCC": weak} # mean 0; AAA long, CCC short, BBB dead
|
|
490
|
+
tight = {"AAA": exit_threshold / 2.0, "BBB": 0.0, "CCC": -exit_threshold / 2.0} # all within bands
|
|
491
|
+
held_long_aaa = [{"coin": "AAA", "direction": "LONG", "szi": 1.0,
|
|
492
|
+
"entry_px": 100.0, "unrealized_pnl_pct": 2.0, "margin_used": 10.0}]
|
|
493
|
+
# a held LONG on the now-WEAKEST name (RS flipped negative) -> rank_flip exit.
|
|
494
|
+
flip = {"AAA": weak, "BBB": 0.0, "CCC": strong}
|
|
495
|
+
|
|
496
|
+
cases = (
|
|
497
|
+
("long_strongest_entry", spread, []),
|
|
498
|
+
("short_weakest_entry", spread, []), # same input; asserts CCC SHORT is present
|
|
499
|
+
("dead_band_emits_nothing", tight, []),
|
|
500
|
+
("converged_exit_when_held", tight, held_long_aaa),
|
|
501
|
+
("rank_flip_exit_when_held", flip, held_long_aaa),
|
|
502
|
+
("flat_no_exit", tight, []),
|
|
503
|
+
)
|
|
504
|
+
return tuple(
|
|
505
|
+
{"name": name, "inputs": {"ret_3d": s, "ret_7d": s, "ret_30d": s, "positions": pos},
|
|
506
|
+
"expect": call(s, pos)}
|
|
507
|
+
for name, s, pos in cases
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _build_cross_sectional(answers: Answers) -> GraphBuilder:
|
|
512
|
+
b = GraphBuilder()
|
|
513
|
+
u = answers.universe
|
|
514
|
+
iv = answers.intervals[0] if answers.intervals else "4h"
|
|
515
|
+
|
|
516
|
+
bars = _BARS_PER_DAY.get(iv)
|
|
517
|
+
if bars is None:
|
|
518
|
+
raise GenerationError(
|
|
519
|
+
f"archetype 'relative_value' (cross_sectional member) cannot map interval {iv!r} to a "
|
|
520
|
+
f"bars-per-day count (supported: {sorted(_BARS_PER_DAY)}). Choose a standard candle "
|
|
521
|
+
f"interval so the lookback windows (3d/7d/30d) resolve to bar counts."
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
es = answers.exit_signals
|
|
525
|
+
enter_threshold = es.enter_threshold if es is not None else _DEFAULT_ENTER
|
|
526
|
+
exit_threshold = es.exit_threshold if es is not None else _DEFAULT_EXIT
|
|
527
|
+
weights = tuple(w for _d, w in WINDOWS)
|
|
528
|
+
|
|
529
|
+
# --- sources ---
|
|
530
|
+
b.add("account", "clearinghouse_state",
|
|
531
|
+
header=("sources: account equity for sizing. NO held-exclusion on the ranked universe —",
|
|
532
|
+
"held-exclusion lives in the pure_fn (enter only when flat, exit only when held).",))
|
|
533
|
+
b.add("positions", "open_positions",
|
|
534
|
+
header=("open positions (held + direction): in-fn held-exclusion + the exit tail's REAL",
|
|
535
|
+
"held-direction stamp. Kept even in the entry-only variant (no-pyramiding).",))
|
|
536
|
+
|
|
537
|
+
# --- universe: a fixed basket (>= 3) OR a discovered board ---
|
|
538
|
+
if u.discover:
|
|
539
|
+
b.add("board", "instrument_list", {"dex": u.dex},
|
|
540
|
+
header=("dynamic universe discovery: live board -> leverage/volume/prefix signature.",))
|
|
541
|
+
b.add("universe", "universe_discover",
|
|
542
|
+
{"name_prefix": u.name_prefix, "max_leverage_cap": u.max_leverage_cap,
|
|
543
|
+
"min_day_volume": u.min_day_volume})
|
|
544
|
+
b.add("candles", "candles_fetch", {"intervals": [iv]},
|
|
545
|
+
header=("candle reads over the discovered universe (ranked cross-sectionally).",))
|
|
546
|
+
else:
|
|
547
|
+
b.add("candles", "candles_fetch",
|
|
548
|
+
{"whitelist": list(u.whitelist), "intervals": [iv]},
|
|
549
|
+
header=("candle reads over the fixed basket (ranked cross-sectionally).",))
|
|
550
|
+
|
|
551
|
+
# --- N lookback returns (days -> bars at the chosen interval), each a per-asset feature_map ---
|
|
552
|
+
window_ids: list[str] = []
|
|
553
|
+
for i, (days, _weight) in enumerate(WINDOWS):
|
|
554
|
+
wid = f"ret_{days}d"
|
|
555
|
+
b.add(wid, "pct_change", {"lookback": days * bars, "interval": iv},
|
|
556
|
+
header=(f"lookback returns at {iv} ({days}d = {days * bars} x {iv}).",) if i == 0 else ())
|
|
557
|
+
window_ids.append(wid)
|
|
558
|
+
|
|
559
|
+
# --- the two-output cross-sectional relative-strength scorer (THE EDGE slot; D2 kind 2) ---
|
|
560
|
+
b.add_pure_fn(
|
|
561
|
+
"score",
|
|
562
|
+
inputs=tuple(
|
|
563
|
+
{"name": wid, "type": "feature_map",
|
|
564
|
+
"doc": f"Per-asset {days}d ({days * bars} x {iv}) percent return."}
|
|
565
|
+
for wid, (days, _w) in zip(window_ids, WINDOWS)
|
|
566
|
+
) + (
|
|
567
|
+
{"name": "positions", "type": "record_list",
|
|
568
|
+
"doc": "Open positions (from open_positions) — held-exclusion + exit direction."},
|
|
569
|
+
),
|
|
570
|
+
outputs=(
|
|
571
|
+
{"name": "entry_scores", "type": "score_map",
|
|
572
|
+
"doc": "LONG the strongest (RS >= enter_threshold) / SHORT the weakest (RS <= -enter_threshold), FLAT."},
|
|
573
|
+
{"name": "exit_scores", "type": "score_map",
|
|
574
|
+
"doc": "CLOSE a held name whose |RS| converged below exit_threshold OR whose RS flipped sign."},
|
|
575
|
+
),
|
|
576
|
+
params=(
|
|
577
|
+
{"name": "enter_threshold", "type": "float", "default": enter_threshold,
|
|
578
|
+
"doc": "Relative strength to ENTER (top of the dead band); |RS| in return-percent units."},
|
|
579
|
+
{"name": "exit_threshold", "type": "float", "default": exit_threshold,
|
|
580
|
+
"doc": "Convergence band; a held name whose |RS| falls below it CLOSES (relative value realized)."},
|
|
581
|
+
{"name": "w_3d", "type": "float", "default": weights[0], "doc": "Weight of the 3d window."},
|
|
582
|
+
{"name": "w_7d", "type": "float", "default": weights[1],
|
|
583
|
+
"doc": "Weight of the 7d window (longer = stronger)."},
|
|
584
|
+
{"name": "w_30d", "type": "float", "default": weights[2],
|
|
585
|
+
"doc": "Weight of the 30d window (longest = strongest)."},
|
|
586
|
+
{"name": "signal_strength", "type": "float", "default": _SIGNAL_STRENGTH,
|
|
587
|
+
"doc": "Per-signal score unit multiplier."},
|
|
588
|
+
),
|
|
589
|
+
fn=CROSS_SECTIONAL_FN,
|
|
590
|
+
tests=_cross_sectional_tests(weights, enter_threshold, exit_threshold),
|
|
591
|
+
header=("pure_fn cross-sectional relative-strength scorer — THE EDGE SLOT (edit the body; see",
|
|
592
|
+
"the EDGE block above). TWO outputs: entry_scores (long strong / short weak) + exit_scores.",),
|
|
593
|
+
title="cross-sectional relative strength: long the strongest, fade the weakest",
|
|
594
|
+
)
|
|
595
|
+
b.set_edge_anchor("score")
|
|
596
|
+
|
|
597
|
+
# --- entry tail: ranker (single-best = the single strongest, or pool-cap) -> emitter ---
|
|
598
|
+
_common.add_ranker(
|
|
599
|
+
b, ranker_id="best", scorer_id="score", scorer_port="entry_scores",
|
|
600
|
+
account_id="account", max_positions=answers.max_positions,
|
|
601
|
+
)
|
|
602
|
+
b.add("emit", "signal_envelope",
|
|
603
|
+
{"margin_pct": answers.risk.margin_pct, "leverage": answers.risk.leverage},
|
|
604
|
+
header=("entry emitter: build the scan() OPEN signal list.",))
|
|
605
|
+
|
|
606
|
+
# --- exit tail: only when a thesis (convergence) exit was requested (answers.exit_signals) ---
|
|
607
|
+
if es is not None:
|
|
608
|
+
b.add("close_emit", "close_signal_envelope", {"signal_type": "thesis_exit"},
|
|
609
|
+
header=("exit emitter: reduce-only CLOSE list (no sizing/equity). The compiler splits",
|
|
610
|
+
"this into a second, exit-only scanner; the DSL preset stays as the safety net.",))
|
|
611
|
+
|
|
612
|
+
# --- edges ---
|
|
613
|
+
if u.discover:
|
|
614
|
+
b.connect("board", "instruments", "universe", "instruments", section="dynamic universe discovery")
|
|
615
|
+
b.connect("universe", "universe", "candles", "assets", section="discovered universe -> candle reads")
|
|
616
|
+
# candles fan out to the lookback returns
|
|
617
|
+
for i, wid in enumerate(window_ids):
|
|
618
|
+
b.connect("candles", "candles", wid, "candles",
|
|
619
|
+
section="candles fan out to the lookback returns" if i == 0 else None)
|
|
620
|
+
# each return + the positions record wire into ONE declared pure_fn input (single writer)
|
|
621
|
+
for i, wid in enumerate(window_ids):
|
|
622
|
+
b.connect(wid, "pct", "score", wid,
|
|
623
|
+
section="each lookback return -> its pure_fn input port" if i == 0 else None)
|
|
624
|
+
b.connect("positions", "positions", "score", "positions")
|
|
625
|
+
# entry tail: entry_scores -> rank (wired inside add_ranker) -> emit (+ equity for sizing)
|
|
626
|
+
b.connect("best", "selected", "emit", "selected", section="entry tail: entry_scores -> rank -> emit")
|
|
627
|
+
b.connect("account", "equity", "emit", "equity")
|
|
628
|
+
# exit tail: exit_scores -> close emitter (+ positions for the real held-direction stamp)
|
|
629
|
+
if es is not None:
|
|
630
|
+
b.connect("score", "exit_scores", "close_emit", "selected",
|
|
631
|
+
section="exit tail: exit_scores -> close emitter (+ positions for direction)")
|
|
632
|
+
b.connect("positions", "positions", "close_emit", "positions")
|
|
633
|
+
return b
|
package/dist/composer/cli.py
CHANGED
|
@@ -65,10 +65,13 @@ from composer.verbs import (
|
|
|
65
65
|
run_group_close,
|
|
66
66
|
run_group_status,
|
|
67
67
|
run_install,
|
|
68
|
+
run_portfolio_status,
|
|
69
|
+
run_review,
|
|
68
70
|
run_simulate,
|
|
69
71
|
run_status,
|
|
70
72
|
run_update,
|
|
71
73
|
)
|
|
74
|
+
from composer.verbs._box import default_read_positions
|
|
72
75
|
from composer.verbs._layout import (
|
|
73
76
|
canonical_graph_path,
|
|
74
77
|
canonical_staged_dir,
|
|
@@ -506,6 +509,7 @@ def _cmd_fund(args: argparse.Namespace) -> int:
|
|
|
506
509
|
_resolve_state_dir(args.state_dir),
|
|
507
510
|
out_dir=args.out,
|
|
508
511
|
created_at=args.created_at,
|
|
512
|
+
manual=args.manual,
|
|
509
513
|
)
|
|
510
514
|
if args.json:
|
|
511
515
|
print(json.dumps(result.to_json(), indent=2, sort_keys=True))
|
|
@@ -580,13 +584,37 @@ def _cmd_status(args: argparse.Namespace) -> int:
|
|
|
580
584
|
return 0
|
|
581
585
|
|
|
582
586
|
if args.target is None:
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
587
|
+
# J2: bare `status` (no target, no --group) is the CROSS-STRATEGY PORTFOLIO view — every
|
|
588
|
+
# accountable strategy + stranded runtimes + manual positions. It is inherently on-box, so it
|
|
589
|
+
# RESOLVES the state root (CMP248 if unresolvable — the rim always threads it). The targeted
|
|
590
|
+
# per-strategy path below is byte-unchanged.
|
|
591
|
+
result = run_portfolio_status(_resolve_state_dir(args.state_dir))
|
|
592
|
+
if args.json:
|
|
593
|
+
print(json.dumps(result.to_json(), indent=2, sort_keys=True))
|
|
594
|
+
else:
|
|
595
|
+
print(result.render(), end="")
|
|
596
|
+
return 0
|
|
586
597
|
# --state-dir opts INTO the on-box install-chain derivation; without it status stops at the
|
|
587
|
-
# deploy layer (the file-only lifecycle), exactly as before.
|
|
598
|
+
# deploy layer (the file-only lifecycle), exactly as before. J3: wire the clearinghouse seam so a
|
|
599
|
+
# MANUAL strategy's wallet positions render (managed status never invokes it — manual-branch only).
|
|
588
600
|
state_dir = _resolve_state_dir(args.state_dir) if args.state_dir is not None else None
|
|
589
|
-
result = run_status(args.target, out_dir=args.out, state_dir=state_dir
|
|
601
|
+
result = run_status(args.target, out_dir=args.out, state_dir=state_dir,
|
|
602
|
+
positions_fn=default_read_positions)
|
|
603
|
+
if args.json:
|
|
604
|
+
print(json.dumps(result.to_json(), indent=2, sort_keys=True))
|
|
605
|
+
else:
|
|
606
|
+
print(result.render(), end="")
|
|
607
|
+
return 0
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _cmd_review(args: argparse.Namespace) -> int:
|
|
611
|
+
# J8: `review [<strategy>]` — retrospective trade-quality review. Bare = every accountable
|
|
612
|
+
# strategy + the cross-strategy realized aggregate; a named target reviews ONE. It is inherently
|
|
613
|
+
# on-box (enumerates <state>/composer/*, derives each runtime id from the install chain), so it
|
|
614
|
+
# RESOLVES the state root (CMP248 if unresolvable — the rim always threads it). Closed history is
|
|
615
|
+
# the in-process MCP seam; exit/leak enrichment is the live document's `events` section (served by
|
|
616
|
+
# the rim). NO events subprocess fallback: run offline, exits render "unavailable (no live channel)".
|
|
617
|
+
result = run_review(_resolve_state_dir(args.state_dir), args.target)
|
|
590
618
|
if args.json:
|
|
591
619
|
print(json.dumps(result.to_json(), indent=2, sort_keys=True))
|
|
592
620
|
else:
|
|
@@ -791,10 +819,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
791
819
|
help="Create + fund the strategy's wallet (MCP strategy_create_custom_strategy): preflights "
|
|
792
820
|
"the balance, waits for ACTIVE, safe to re-run while funding settles.",
|
|
793
821
|
)
|
|
794
|
-
p_fund.add_argument("target", help="A `composer deploy` staging dir, or a graph file (with -o)
|
|
822
|
+
p_fund.add_argument("target", help="A `composer deploy` staging dir, or a graph file (with -o); "
|
|
823
|
+
"with --manual, a bare strategy NAME.")
|
|
795
824
|
p_fund.add_argument("--budget", type=float, required=True,
|
|
796
825
|
help="Explicit USD to fund THIS strategy's wallet with (≥ $100). Never "
|
|
797
826
|
"derived or split — agree it with the user first.")
|
|
827
|
+
p_fund.add_argument("--manual", action="store_true",
|
|
828
|
+
help="Fund a MANUAL strategy: a first-class wallet with NO graph/runtime, "
|
|
829
|
+
"managed by the composer record system. Direct MCP position edits are "
|
|
830
|
+
"the sanctioned path for this kind. Refuses if the name is already a "
|
|
831
|
+
"managed (graph) strategy (CMP262).")
|
|
798
832
|
p_fund.add_argument("-o", "--out", help="Staging dir (when target is a graph file, not a staged dir).")
|
|
799
833
|
p_fund.add_argument("--state-dir", dest="state_dir",
|
|
800
834
|
help="Base senpi-state dir (rim passes it; else $SENPI_STATE_DIR; else refuse — never guessed from HOME).")
|
|
@@ -869,6 +903,23 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
869
903
|
p_status.add_argument("--json", action="store_true", help="Emit the status as JSON.")
|
|
870
904
|
p_status.set_defaults(func=_cmd_status)
|
|
871
905
|
|
|
906
|
+
p_review = sub.add_parser(
|
|
907
|
+
"review",
|
|
908
|
+
help="Retrospective trade-quality review: per-strategy closed-trade summary (count / win "
|
|
909
|
+
"rate / realized PnL), exit-reason distribution + leak findings from the box event log, "
|
|
910
|
+
"and recommendations judged against the graph's thesis. Bare = all strategies + the "
|
|
911
|
+
"cross-strategy realized aggregate.",
|
|
912
|
+
)
|
|
913
|
+
p_review.add_argument("target", nargs="?",
|
|
914
|
+
help="A strategy NAME to review just one. Omit to sweep every accountable "
|
|
915
|
+
"strategy (canonical + landed-legacy) and add the cross-strategy "
|
|
916
|
+
"realized aggregate.")
|
|
917
|
+
p_review.add_argument("--state-dir", dest="state_dir",
|
|
918
|
+
help="Base senpi-state dir (rim passes it; else $SENPI_STATE_DIR; else "
|
|
919
|
+
"refuse — never guessed from HOME).")
|
|
920
|
+
p_review.add_argument("--json", action="store_true", help="Emit the review as JSON.")
|
|
921
|
+
p_review.set_defaults(func=_cmd_review)
|
|
922
|
+
|
|
872
923
|
p_simulate = sub.add_parser(
|
|
873
924
|
"simulate",
|
|
874
925
|
help="Phase-A signal-timeline replay: drive the compiled scan(s) over historical candles "
|