@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.
Files changed (46) hide show
  1. package/dist/cli/composer-commands.d.ts.map +1 -1
  2. package/dist/cli/composer-commands.js +193 -41
  3. package/dist/cli/composer-commands.js.map +1 -1
  4. package/dist/cli/composer-live-read.d.ts +68 -0
  5. package/dist/cli/composer-live-read.d.ts.map +1 -0
  6. package/dist/cli/composer-live-read.js +126 -0
  7. package/dist/cli/composer-live-read.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts.map +1 -1
  9. package/dist/cli/senpi-commands.js +8 -10
  10. package/dist/cli/senpi-commands.js.map +1 -1
  11. package/dist/cli/senpi-strategy-tool.d.ts +56 -0
  12. package/dist/cli/senpi-strategy-tool.d.ts.map +1 -0
  13. package/dist/cli/senpi-strategy-tool.js +158 -0
  14. package/dist/cli/senpi-strategy-tool.js.map +1 -0
  15. package/dist/composer/authoring/__init__.py +2 -0
  16. package/dist/composer/authoring/answers.py +52 -0
  17. package/dist/composer/authoring/archetypes/__init__.py +2 -0
  18. package/dist/composer/authoring/archetypes/contrarian_fade.py +493 -0
  19. package/dist/composer/authoring/archetypes/relative_value.py +280 -23
  20. package/dist/composer/cli.py +57 -6
  21. package/dist/composer/examples/contrarian_fade_crowd.graph.yaml +356 -0
  22. package/dist/composer/examples/rotation_cross_sectional.graph.yaml +389 -0
  23. package/dist/composer/generated/catalog.md +2 -1
  24. package/dist/composer/registry/generate.py +2 -1
  25. package/dist/composer/validator/errors.py +2 -0
  26. package/dist/composer/verbs/__init__.py +6 -0
  27. package/dist/composer/verbs/_box.py +96 -5
  28. package/dist/composer/verbs/_layout.py +37 -0
  29. package/dist/composer/verbs/_live.py +50 -6
  30. package/dist/composer/verbs/close.py +51 -6
  31. package/dist/composer/verbs/fund.py +144 -27
  32. package/dist/composer/verbs/install.py +25 -6
  33. package/dist/composer/verbs/portfolio.py +650 -0
  34. package/dist/composer/verbs/review.py +724 -0
  35. package/dist/composer/verbs/status.py +129 -2
  36. package/dist/composer/verbs/update.py +9 -3
  37. package/dist/index.d.ts +3 -1
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +22 -0
  40. package/dist/index.js.map +1 -1
  41. package/dist/runtime/composer-managed-write.d.ts +133 -0
  42. package/dist/runtime/composer-managed-write.d.ts.map +1 -0
  43. package/dist/runtime/composer-managed-write.js +369 -0
  44. package/dist/runtime/composer-managed-write.js.map +1 -0
  45. package/openclaw.plugin.json +5 -0
  46. 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 builder builds the PAIRWISE member first (design-models §11: `relative_value.pairwise`,
12
- with `cross_sectional` — long the strongest vs short the weakest of N — the designed
13
- follow-up member). One pure_fn produces BOTH tails from the same signed net score:
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: `pairwise` (this builder — the fixed two-asset pair) is the first; `cross_sectional`",
76
- "(long the strongest vs short the weakest across N names) is the designed follow-up.",
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 — no discover),",
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 (that would prune the benchmark
241
- # from a live-board narrowing) and EXACTLY two assets. Reject anything else LOUDLY rather than
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
@@ -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
- print("status needs a target (a graph file / staging dir / strategy name) or --group <name>.",
584
- file=sys.stderr)
585
- return 1
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 "