@senpi/runtime 3.0.148 → 3.0.149

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 (88) hide show
  1. package/dist/cli/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +55 -0
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/quant-command.d.ts +128 -0
  5. package/dist/cli/quant-command.d.ts.map +1 -0
  6. package/dist/cli/quant-command.js +345 -0
  7. package/dist/cli/quant-command.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts +11 -0
  9. package/dist/cli/senpi-commands.d.ts.map +1 -1
  10. package/dist/cli/senpi-commands.js +49 -4
  11. package/dist/cli/senpi-commands.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +40 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/quant/engine/quantdesk/__init__.py +19 -0
  16. package/dist/quant/engine/quantdesk/__main__.py +281 -0
  17. package/dist/quant/engine/quantdesk/_version.py +24 -0
  18. package/dist/quant/engine/quantdesk/benchmark.py +90 -0
  19. package/dist/quant/engine/quantdesk/book.py +197 -0
  20. package/dist/quant/engine/quantdesk/deep.py +197 -0
  21. package/dist/quant/engine/quantdesk/desk.py +878 -0
  22. package/dist/quant/engine/quantdesk/dsl.py +224 -0
  23. package/dist/quant/engine/quantdesk/followups.py +117 -0
  24. package/dist/quant/engine/quantdesk/hl_api.py +473 -0
  25. package/dist/quant/engine/quantdesk/market.py +290 -0
  26. package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
  27. package/dist/quant/engine/quantdesk/metrics.py +409 -0
  28. package/dist/quant/engine/quantdesk/opportunities.py +88 -0
  29. package/dist/quant/engine/quantdesk/render.py +787 -0
  30. package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
  31. package/dist/quant/engine/quantdesk/score.py +817 -0
  32. package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
  33. package/dist/quant/engine/quantdesk/smart_money.py +354 -0
  34. package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
  35. package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
  36. package/dist/quant/engine/quantdesk/timing.py +164 -0
  37. package/dist/quant/engine/quantdesk/voice.py +42 -0
  38. package/dist/quant/engine-runner.d.ts +133 -0
  39. package/dist/quant/engine-runner.d.ts.map +1 -0
  40. package/dist/quant/engine-runner.js +306 -0
  41. package/dist/quant/engine-runner.js.map +1 -0
  42. package/dist/quant/job.d.ts +125 -0
  43. package/dist/quant/job.d.ts.map +1 -0
  44. package/dist/quant/job.js +184 -0
  45. package/dist/quant/job.js.map +1 -0
  46. package/dist/quant/register.d.ts +53 -0
  47. package/dist/quant/register.d.ts.map +1 -0
  48. package/dist/quant/register.js +253 -0
  49. package/dist/quant/register.js.map +1 -0
  50. package/dist/quant/registry.d.ts +105 -0
  51. package/dist/quant/registry.d.ts.map +1 -0
  52. package/dist/quant/registry.js +248 -0
  53. package/dist/quant/registry.js.map +1 -0
  54. package/dist/quant/types.d.ts +216 -0
  55. package/dist/quant/types.d.ts.map +1 -0
  56. package/dist/quant/types.js +121 -0
  57. package/dist/quant/types.js.map +1 -0
  58. package/dist/skills-manager/manifest.js +1 -1
  59. package/dist/skills-manager/skills-manifest.json +1 -1
  60. package/dist/widgets/index.d.ts +8 -1
  61. package/dist/widgets/index.d.ts.map +1 -1
  62. package/dist/widgets/index.js +1 -0
  63. package/dist/widgets/index.js.map +1 -1
  64. package/dist/widgets/registry.d.ts +8 -0
  65. package/dist/widgets/registry.d.ts.map +1 -1
  66. package/dist/widgets/registry.js +26 -1
  67. package/dist/widgets/registry.js.map +1 -1
  68. package/dist/widgets/tool.d.ts +14 -4
  69. package/dist/widgets/tool.d.ts.map +1 -1
  70. package/dist/widgets/tool.js +36 -23
  71. package/dist/widgets/tool.js.map +1 -1
  72. package/dist/widgets/turn-cap.d.ts +33 -0
  73. package/dist/widgets/turn-cap.d.ts.map +1 -0
  74. package/dist/widgets/turn-cap.js +119 -0
  75. package/dist/widgets/turn-cap.js.map +1 -0
  76. package/dist/widgets/types.d.ts +33 -5
  77. package/dist/widgets/types.d.ts.map +1 -1
  78. package/dist/widgets/types.js +4 -3
  79. package/dist/widgets/types.js.map +1 -1
  80. package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
  81. package/dist/widgets/widgets/fund-user-wallet.js +1 -0
  82. package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
  83. package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
  84. package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
  85. package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
  86. package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
  87. package/openclaw.plugin.json +12 -2
  88. package/package.json +4 -3
@@ -0,0 +1,197 @@
1
+ #!/usr/bin/env python3
2
+ """The deep modes behind the follow-ups. Each takes the cached analysis (plus candles where needed) and
3
+ returns a dict the renderer knows. Same rules as the desk: computed, counterfactual, process only."""
4
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
5
+ import bisect
6
+ import collections
7
+ import math
8
+ import statistics
9
+
10
+ import timing as timing_mod
11
+
12
+ H = 3_600_000.0
13
+ DAY_MS = 86_400_000
14
+ FALLBACK_RANGE = 0.04 # a stand-in daily high-low, as a share of mark, when a coin has no candles
15
+
16
+
17
+ def _atr(candles, coin, days=14):
18
+ """Average DAILY range, built from hourly candles.
19
+
20
+ This used to be `mean(high - low)` over the last 24 HOURLY candles — a mean hourly range, while
21
+ the column called it "24h range" and the copy said "one and a half days of normal range". On BTC
22
+ that shipped a 1.27% stop where a real daily range gives ~4.1%, so the desk was telling a reader
23
+ to place a stop roughly three times too tight, on the one screen that asks them to act.
24
+ (B7, @0xsarvesh / @danielmbirochi, #718.)
25
+
26
+ A day's range is the high-to-low of the whole day, not the average of its hours' ranges. Blocks
27
+ are rolling 24h windows ending at the newest candle, so the most recent hours always count; the
28
+ oldest partial block is what gets dropped.
29
+ """
30
+ c = candles.get(coin)
31
+ if not c:
32
+ return None
33
+ rows = c[1][-(days * 24):]
34
+ rows = rows[len(rows) % 24:]
35
+ if not rows:
36
+ return None
37
+ ranges = [max(r[2] for r in rows[i:i + 24]) - min(r[3] for r in rows[i:i + 24])
38
+ for i in range(0, len(rows), 24)]
39
+ return statistics.mean(ranges) if ranges else None
40
+
41
+
42
+ def _hard_floor(mark, sgn, atr, liq):
43
+ """The hard stop: one and a half daily ranges from the mark, pulled in to 40% of the way to
44
+ liquidation when liquidation is nearer, because the stop has to trigger first.
45
+
46
+ ONE formula for two readers — `protect()`'s *Hard stop* column and the protect card's Set stop loss
47
+ button (`card_stop_losses`) — so the stop loss a reader signs is the hard stop the ladder prints.
48
+
49
+ `sgn` is +1 for a long, -1 for a short; `atr` is a daily range in price units; `liq` may be None.
50
+ """
51
+ floor_dist = 1.5 * atr
52
+ hard = mark - sgn * floor_dist
53
+ if liq:
54
+ buffer = 0.4 * abs(mark - liq)
55
+ hard = max(hard, liq + buffer) if sgn > 0 else min(hard, liq - buffer)
56
+ return hard
57
+
58
+
59
+ def card_stop_losses(positions, candles, default_wallet):
60
+ """The protect card's executable stop losses: one per open position whose hard stop can be placed.
61
+
62
+ The same number as `protect()`'s *Hard stop* column (`_hard_floor`, the same range fallback),
63
+ computed DURING the run from the candles the desk already read: the runtime refuses `--deep`,
64
+ and the candles are gone once the run ends, so the card cannot ask the ladder for it later.
65
+
66
+ Each stop loss is shaped as the runtime's `QuantStopLoss` (src/quant/types.ts): `side` lowercase, `dex`
67
+ from the coin's prefix ("" on the main dex, "xyz" for `xyz:TSLA` — the coin keeps its prefix),
68
+ `wallet` the position's own (a --book run tags every position) or `default_wallet` (a
69
+ single-wallet run leaves positions untagged).
70
+
71
+ A position gets no stop loss — never a made-up one — when it has no mark, or when it would not
72
+ be a positive finite price on the losing side of the mark and short of liquidation.
73
+ """
74
+ out = []
75
+ for p in positions:
76
+ mark = p.get("mark"); side = p.get("side"); liq = p.get("liq_px")
77
+ if not mark or mark <= 0 or side not in ("LONG", "SHORT"):
78
+ continue
79
+ sgn = 1 if side == "LONG" else -1
80
+ atr = _atr(candles, p["coin"]) or FALLBACK_RANGE * mark
81
+ hard = _hard_floor(mark, sgn, atr, liq)
82
+ if not math.isfinite(hard) or hard <= 0 or sgn * (mark - hard) <= 0:
83
+ continue
84
+ if liq and sgn * (hard - liq) <= 0:
85
+ continue
86
+ coin = p["coin"]
87
+ out.append(dict(coin=coin, dex=coin.split(":", 1)[0] if ":" in coin else "", side=side.lower(),
88
+ size=p["size"], entry=p["entry"], mark=mark, liq_px=liq,
89
+ trigger_px=hard, trigger_pct=100 * abs(mark - hard) / mark,
90
+ wallet=p.get("wallet") or default_wallet))
91
+ return out
92
+
93
+
94
+ def protect(r, candles):
95
+ """A stop ladder per open position: hard floor above liquidation and beyond one day's range, a
96
+ trailing lock once the trade has paid, and the dollars at risk before vs after."""
97
+ out = []
98
+ for p in r["book"]["positions"]:
99
+ atr = _atr(candles, p["coin"]); mark = p["mark"]; sgn = 1 if p["side"] == "LONG" else -1
100
+ if not mark:
101
+ continue
102
+ measured = atr
103
+ atr = atr or FALLBACK_RANGE * mark # no candles: stand in a typical daily range, not an hourly one
104
+ liq = p["liq_px"]
105
+ hard = _hard_floor(mark, sgn, atr, liq)
106
+ risk_now = p["margin_used"] if p["liq_px"] else p["notional"] * 0.5
107
+ risk_after = abs(mark - hard) * p["size"]
108
+ lock_arm = mark + sgn * (2 * atr)
109
+ out.append(dict(coin=p["coin"], side=p["side"], mark=mark, hard_stop=hard, hard_stop_pct=100 * abs(mark - hard) / mark, atr_pct=(100 * measured / mark) if measured else None,
110
+ liq_px=liq, lock_arms_at=lock_arm, lock_share=0.5, risk_now=risk_now, risk_after=risk_after,
111
+ covered_now=p["stop_covered_share"], note=("already covered" if p["stop_covered_share"] >= 0.9 else ("extend to full size" if p["stop_covered_share"] > 0 else "no stop today"))))
112
+ return dict(rows=out, total_risk_now=sum(x["risk_now"] for x in out), total_risk_after=sum(x["risk_after"] for x in out))
113
+
114
+
115
+ def replay(r, candles):
116
+ """The worst 7-day window by realized P&L, its trades, and the two counterfactuals on exactly those."""
117
+ eps = [e for e in r["episodes"] if e.get("close_time")]
118
+ if not eps:
119
+ return None
120
+ eps.sort(key=lambda e: e["close_time"])
121
+ worst, best_t = 0.0, None
122
+ for e in eps:
123
+ t0 = e["close_time"]; s = sum(x["realized"] for x in eps if t0 <= x["close_time"] < t0 + 7 * DAY_MS)
124
+ if s < worst:
125
+ worst, best_t = s, t0
126
+ if best_t is None:
127
+ return dict(empty=True)
128
+ week = [e for e in eps if best_t <= e["close_time"] < best_t + 7 * DAY_MS]
129
+ rows = timing_mod.per_trade([e for e in week if e.get("complete")], candles) if candles else []
130
+ s = timing_mod.summarize(rows) if rows else None
131
+ return dict(start=best_t, realized=worst, trades=len(week), losers=sum(1 for e in week if not e["win"]),
132
+ biggest=sorted(week, key=lambda e: e["realized"])[:5], cut=(s or {}).get("cut"), lock=(s or {}).get("lock"),
133
+ green_first=(s or {}).get("losers_that_were_green"))
134
+
135
+
136
+ def funding_forecast(r):
137
+ rows = []
138
+ for p in r["book"]["positions"]:
139
+ rows.append(dict(coin=p["coin"], side=p["side"], notional=p["notional"], per_day=p["funding_per_day"], thirty_days=30 * p["funding_per_day"],
140
+ rate_bp_8h=p["funding_rate_hourly"] * 8 * 1e4, since_open=-p["funding_since_open"]))
141
+ tot = sum(x["thirty_days"] for x in rows)
142
+ av = r["book"].get("account_value") or 0
143
+ return dict(rows=rows, thirty_days=tot, share_of_equity=(tot / av) if av else None, payers=[x["coin"] for x in rows if x["per_day"] < 0])
144
+
145
+
146
+ def compare_windows(r):
147
+ """Last 30 days vs the 60 before, on the things that cost money."""
148
+ now = r["now_ms"]; cut = now - 30 * DAY_MS
149
+ recent = [e for e in r["episodes"] if (e["close_time"] or 0) >= cut]; prior = [e for e in r["episodes"] if 0 < (e["close_time"] or 0) < cut]
150
+ def stats(eps):
151
+ if not eps:
152
+ return None
153
+ w = [e for e in eps if e["win"]]; l = [e for e in eps if not e["win"]]
154
+ ws = sum(e["realized"] for e in w); ls = -sum(e["realized"] for e in l)
155
+ comp_w = [e["hold_h"] for e in w if e.get("complete")]; comp_l = [e["hold_h"] for e in l if e.get("complete")]
156
+ return dict(trades=len(eps), win_rate=len(w) / len(eps), pf=(ws / ls) if ls else (float("inf") if ws else None), realized=sum(e["realized"] for e in eps),
157
+ fees=sum(e["fees"] for e in eps), avg_size=statistics.mean(e["peak_notional"] for e in eps if e["peak_notional"] > 0) if any(e["peak_notional"] > 0 for e in eps) else None,
158
+ hold_w=statistics.median(comp_w) if len(comp_w) >= 3 else None, hold_l=statistics.median(comp_l) if len(comp_l) >= 3 else None,
159
+ taker=(sum(e["taker_volume"] for e in eps) / sum(e["volume"] for e in eps)) if sum(e["volume"] for e in eps) else None)
160
+ return dict(recent=stats(recent), prior=stats(prior))
161
+
162
+
163
+ def rules(r):
164
+ """The trader's strategy as a rule set — what the record says they do when it works — for the coder."""
165
+ fp = (r.get("strategy") or {}).get("fingerprint") or {}
166
+ setups = (r.get("setups") or {}).get("best") or []
167
+ tr = r["track"]
168
+ ent = []
169
+ for b in setups[:2]:
170
+ ent.append(f"{b['label']} — {b['wins']} of {b['n']} wins, profit factor {b['profit_factor'] if b['profit_factor'] != float('inf') else '∞'}")
171
+ tm = r.get("timing") or {}
172
+ entry_rule = ("enter before the move: skip anything already up ≥ 3% on the day" if (tm.get("chased_pf") or 9) < (tm.get("calm_pf") or 0) else "entries after strength are fine for you — keep buying breakouts")
173
+ hold = f"winners run a median {tr['hold_winners_h']:.0f}h; losers should not outlive {min(24, max(4, tr['hold_winners_h'] or 12)):.0f}h" if tr.get("hold_winners_h") else "hold time: not enough complete trades to set"
174
+ size = f"size at your median ({fp.get('trades_per_active_day', 0):.1f} trades/day); never above 1.5× median on a loser's thesis" if fp else "fixed-fraction sizing"
175
+ risk = "hard stop on every position; a trailing lock at 50% of the peak once +2 ATR; daily entry cap at your median day"
176
+ fams = r.get("families") or ["trend_following"]
177
+ return dict(entries=ent, entry_rule=entry_rule, hold_rule=hold, size_rule=size, risk_rule=risk, families=fams,
178
+ handoff=f"senpi-strategy-discover: closest templates in the {' / '.join(f.replace('_', ' ') for f in fams)} families; senpi-strategy-author to write these rules from scratch, deployed as your strategy")
179
+
180
+
181
+ def regime(r):
182
+ ctx = r.get("context") or {}
183
+ return dict(today=ctx.get("breadth", {}).get("day"), performance=ctx.get("regime_performance"))
184
+
185
+
186
+ def watch(r):
187
+ book = r["book"]
188
+ items = []
189
+ if book["naked"] or book["partial"]:
190
+ items.append(f"Risk guard: a stop missing or a position within 5% of liquidation ({', '.join(book['naked'] + book['partial'])} today)")
191
+ items.append("Smart money: the proven cohort flipping against a coin you hold, or building a coin you don't")
192
+ items.append("Market regime: the day turning risk-off while your book is net long — or funding turning against you")
193
+ items.append("Leak finder: weekly — fees, funding and hold time with the dollar figure")
194
+ return dict(items=items)
195
+
196
+
197
+ MODES = {"protect": "protect", "smart": "smart", "scout": "scout", "replay": "replay", "funding": "funding", "regime": "regime", "compare": "compare", "rules": "rules", "strategy": "strategy", "watch": "watch"}