@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,409 @@
1
+ #!/usr/bin/env python3
2
+ """The desk's core numbers from public Hyperliquid data: track record, costs, holds, sizing, per-coin
3
+ table, the open book with its protection audit, transfer-adjusted equity and drawdown, activity."""
4
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
5
+ import collections
6
+ import statistics
7
+
8
+ from hl_api import is_perp
9
+
10
+
11
+ MIN_HOLD_N = 5 # complete winners AND losers needed before a hold-time claim is made
12
+
13
+
14
+ def med(xs):
15
+ xs = [x for x in xs if x is not None]
16
+ return statistics.median(xs) if xs else None
17
+
18
+
19
+ def _f(x, default=0.0):
20
+ try:
21
+ return float(x)
22
+ except (TypeError, ValueError):
23
+ return default
24
+
25
+
26
+ def in_window(ep, start_ms):
27
+ return (ep["close_time"] or ep["last_time"]) >= start_ms
28
+
29
+
30
+ def track_record(closed, opened, funding_rows, fee_sched, window_start, fee_sched_xyz=None):
31
+ closed = [e for e in closed if in_window(e, window_start)]
32
+ complete = [e for e in closed if e["complete"]]
33
+ wins = [e for e in closed if e["win"]]
34
+ losses = [e for e in closed if not e["win"]]
35
+ cw = [e for e in complete if e["win"]]
36
+ cl = [e for e in complete if not e["win"]]
37
+ gross = sum(e["realized"] for e in closed)
38
+ fees = sum(e["fees"] for e in closed) + sum(e["fees"] for e in opened)
39
+ fund_rows = [x for x in funding_rows if x["time"] >= window_start]
40
+ funding = sum(_f(x["delta"]["usdc"]) for x in fund_rows)
41
+ fund_by_coin = collections.defaultdict(float)
42
+ for x in fund_rows:
43
+ fund_by_coin[x["delta"]["coin"]] += _f(x["delta"]["usdc"])
44
+ win_sum = sum(e["realized"] for e in wins)
45
+ loss_sum = -sum(e["realized"] for e in losses)
46
+ vol = sum(e["volume"] for e in closed + opened)
47
+ taker_vol = sum(e["taker_volume"] for e in closed + opened)
48
+ # Fees are billed per dex. `userFees` returns the MAIN-dex schedule; applying it to HIP-3
49
+ # (`xyz:`) volume overstated one book's fee leak 4.01x, and on that wallet 98.9% of taker volume
50
+ # was xyz:. Split the volume and price each side on its own schedule (B2, @0xsarvesh #718).
51
+ _rate = lambda sch, k: _f((sch or {}).get(k))
52
+ cross, add = _rate(fee_sched, "userCrossRate"), _rate(fee_sched, "userAddRate")
53
+ x_cross = _rate(fee_sched_xyz, "userCrossRate") or cross
54
+ x_add = _rate(fee_sched_xyz, "userAddRate") or add
55
+ _xyz = lambda e: str(e.get("coin", "")).startswith("xyz:")
56
+ tv_main = sum(e["taker_volume"] for e in closed + opened if not _xyz(e))
57
+ tv_xyz = taker_vol - tv_main
58
+ # Anchor the saving on the fees ACTUALLY PAID (summed from fills), not on volume x schedule.
59
+ # `userFees` returns the main-dex schedule and HIP-3 volume does not bill at it: on a book with
60
+ # $91M of xyz: taker volume, volume x schedule implied $49,434 against $12,863 really paid —
61
+ # 3.84x, and the fee leak inherited all of it. The rates are still used, but only to APPORTION
62
+ # real fees between taker and maker fills, which is schedule-independent as long as the ratio
63
+ # holds. (B2, @0xsarvesh #718.)
64
+ _t_est = tv_main * cross + tv_xyz * x_cross
65
+ # The taker side was split by dex and the maker side was not: every resting fill, xyz: included,
66
+ # was priced at the main-dex maker rate. On the book where 98.9% of taker volume is xyz: that
67
+ # skews the very ratio this exists to compute. Split both sides. (@danielmbirochi, #718, item 6.)
68
+ v_main = sum(e["volume"] for e in closed + opened if not _xyz(e))
69
+ mv_main, mv_xyz = max(0.0, v_main - tv_main), max(0.0, (vol - v_main) - tv_xyz)
70
+ _m_est = mv_main * add + mv_xyz * x_add
71
+ _taker_fee_share = (_t_est / (_t_est + _m_est)) if (_t_est + _m_est) > 0 else 0.0
72
+ # Same for the saving: resting instead of crossing saves the spread on the dex the volume is
73
+ # ACTUALLY on, at that dex's own schedule. Weight by where the taker volume sits.
74
+ _sv = lambda a, c: max(0.0, 1.0 - (a / c)) if c else 0.0
75
+ _save_rate = ((tv_main * _sv(add, cross) + tv_xyz * _sv(x_add, x_cross)) / taker_vol) if taker_vol else 0.0
76
+ # The rates the reader actually faces, measured from the fills. Deriving them from the schedule
77
+ # did not survive contact: `userFees` with dex:"xyz" returns the IDENTICAL schedule to main
78
+ # (0.000405 / 0.000135 both ways), so the per-dex split always fell back and the evidence line
79
+ # still read "4.0 bp taker / 1.4 bp maker" beside a fee figure those rates multiply out to 4.03x.
80
+ # Same principle as the dollars: take what was actually paid. (item 12, @0xsarvesh #718.)
81
+ _tk_fees = sum(e.get("taker_fees") or 0.0 for e in closed + opened)
82
+ _mk_vol = max(0.0, vol - taker_vol)
83
+ eff_taker = (_tk_fees / taker_vol) if (taker_vol and _tk_fees) else (_t_est / taker_vol if taker_vol else cross)
84
+ eff_maker = ((fees - _tk_fees) / _mk_vol) if (_mk_vol > 0 and _tk_fees) else (
85
+ (_m_est / (mv_main + mv_xyz)) if (mv_main + mv_xyz) > 0 else add)
86
+ # NOT abs(): a maker REBATE is negative fees, money EARNED. dim_cost was fixed for exactly this
87
+ # in #733 and this site was missed — it turned a rebate into recoverable dollars, which
88
+ # recoverable() then added on top of the lever. (@danielmbirochi, #718.)
89
+ fee_recoverable = max(0.0, fees) * _taker_fee_share * _save_rate if vol else 0.0
90
+ sizes = [e["peak_notional"] for e in closed if not e["truncated"] and e["peak_notional"] > 0]
91
+ liq = [e for e in closed if e["liquidated"]]
92
+ # The per-coin shares divided a CLOSED-trade numerator by a closed+opened denominator, so on a
93
+ # book carrying large open positions every coin's share read low and none of them summed to 1.
94
+ # (@danielmbirochi, #718, round 2.)
95
+ vol_closed = sum(e["volume"] for e in closed)
96
+ by_coin = collections.defaultdict(lambda: dict(trades=0, wins=0, realized=0.0, fees=0.0, volume=0.0, long=0, short=0, hold_h=[], sizes=[]))
97
+ for e in closed:
98
+ c = by_coin[e["coin"]]
99
+ c["trades"] += 1; c["wins"] += e["win"]; c["realized"] += e["realized"]; c["fees"] += e["fees"]
100
+ c["volume"] += e["volume"]; c[e["direction"].lower()] += 1
101
+ if e["complete"]:
102
+ c["hold_h"].append(e["hold_h"]); c["sizes"].append(e["peak_notional"])
103
+ coins = {}
104
+ for k, v in sorted(by_coin.items(), key=lambda kv: -kv[1]["volume"]):
105
+ coins[k] = dict(trades=v["trades"], win_rate=v["wins"] / v["trades"], realized=v["realized"], fees=v["fees"],
106
+ funding=fund_by_coin.get(k, 0.0), volume_share=v["volume"] / vol_closed if vol_closed else 0.0, long=v["long"], short=v["short"],
107
+ hold_median_h=med(v["hold_h"]), size_median=med(v["sizes"]))
108
+ longs = [e for e in closed if e["direction"] == "LONG"]
109
+ shorts = [e for e in closed if e["direction"] == "SHORT"]
110
+ pf = (win_sum / loss_sum) if loss_sum else (None if not win_sum else float("inf"))
111
+ return dict(
112
+ trades=len(closed), complete_trades=len(complete), truncated_trades=sum(1 for e in closed if e["truncated"]),
113
+ wins=len(wins), losses=len(losses), win_rate=len(wins) / len(closed) if closed else None, profit_factor=pf,
114
+ gross_realized=gross, fees=fees, funding=funding, net=gross - fees + funding,
115
+ gross_income=gross + max(0.0, funding),
116
+ cost_ratio=((fees + max(0.0, -funding)) / (gross + max(0.0, funding))) if (gross + max(0.0, funding)) > 0 else None, # costs over what was made: trade P&L plus funding collected
117
+ avg_win=win_sum / len(wins) if wins else None, avg_loss=-loss_sum / len(losses) if losses else None,
118
+ payoff_ratio=((win_sum / len(wins)) / (loss_sum / len(losses))) if (wins and losses and loss_sum) else None,
119
+ largest_win=max((e["realized"] for e in closed), default=None), largest_loss=min((e["realized"] for e in closed), default=None),
120
+ hold_winners_h=med([e["hold_h"] for e in cw]) if len(cw) >= MIN_HOLD_N else None,
121
+ hold_losers_h=med([e["hold_h"] for e in cl]) if len(cl) >= MIN_HOLD_N else None,
122
+ hold_ratio=(med([e["hold_h"] for e in cl]) / med([e["hold_h"] for e in cw])) if (len(cw) >= MIN_HOLD_N and len(cl) >= MIN_HOLD_N and med([e["hold_h"] for e in cw])) else None,
123
+ hold_n=dict(winners=len(cw), losers=len(cl)),
124
+ taker_share=taker_vol / vol if vol else None, volume=vol, fee_rate_taker=eff_taker, fee_rate_maker=eff_maker,
125
+ fee_rate_taker_main=cross, fee_rate_maker_main=add,
126
+ fee_recoverable=fee_recoverable, fee_rate_taker_xyz=x_cross, fee_rate_maker_xyz=x_add,
127
+ taker_volume_xyz=tv_xyz,
128
+ liquidations=len(liq), liquidation_loss=sum(e["realized"] for e in liq),
129
+ size_cv=(statistics.pstdev(sizes) / statistics.mean(sizes)) if len(sizes) > 1 and statistics.mean(sizes) else None,
130
+ size_max_over_median=(max(sizes) / med(sizes)) if sizes and med(sizes) else None, size_median=med(sizes),
131
+ long_share=len(longs) / len(closed) if closed else None,
132
+ long=dict(trades=len(longs), wins=sum(1 for e in longs if e["win"]), realized=sum(e["realized"] for e in longs)),
133
+ short=dict(trades=len(shorts), wins=sum(1 for e in shorts if e["win"]), realized=sum(e["realized"] for e in shorts)),
134
+ adds_per_trade=statistics.mean([e["adds"] for e in complete if e.get("adds") is not None]) if any(e.get("adds") is not None for e in complete) else None,
135
+ size_buckets=_size_buckets([e for e in closed if not e["truncated"]]), coins=coins)
136
+
137
+
138
+ def _size_buckets(complete):
139
+ """Winner and loser counts by peak notional, in bands scaled to the trader's own median size."""
140
+ sizes = [e["peak_notional"] for e in complete if e["peak_notional"] > 0]
141
+ m = med(sizes) or 1.0
142
+ bands = [("< ½× median", 0.0, 0.5), ("½–1× median", 0.5, 1.0), ("1–2× median", 1.0, 2.0), ("> 2× median", 2.0, float("inf"))]
143
+ out = []
144
+ for label, lo, hi in bands:
145
+ eps = [e for e in complete if lo * m <= e["peak_notional"] < hi * m]
146
+ out.append(dict(band=label, winners=sum(1 for e in eps if e["win"]), losers=sum(1 for e in eps if not e["win"]),
147
+ realized=sum(e["realized"] for e in eps)))
148
+ return dict(median_notional=m, bands=out)
149
+
150
+
151
+ DUST_USD = 10.0
152
+
153
+
154
+ def spot_free_usdc(spot):
155
+ """USDC in spot not on hold as perps margin — withdrawable on a unified account."""
156
+ for b in (spot or {}).get("balances") or []:
157
+ if b.get("coin") == "USDC":
158
+ return max(0.0, _f(b.get("total")) - _f(b.get("hold")))
159
+ return 0.0
160
+
161
+
162
+ def open_book(cs, open_orders, ctxs, ages=None, cs_xyz=None, open_orders_xyz=None, ctxs_xyz=None, total_account_value=None, spot_free=0.0):
163
+ """Every open position with liquidation distance, funding per day at the current rate, and its stop
164
+ coverage from resting trigger orders: a stop for a long is a sell trigger below the mark, for a short
165
+ a buy trigger above it. Coverage is the stop-covered fraction of the size. The xyz dex is its own
166
+ collateral pool on the public API: its positions, orders, margin and account value are added in."""
167
+ marks = {u["name"]: _f(c["markPx"]) for u, c in zip(ctxs[0]["universe"], ctxs[1])}
168
+ rates = {u["name"]: _f(c["funding"]) for u, c in zip(ctxs[0]["universe"], ctxs[1])}
169
+ if ctxs_xyz:
170
+ marks.update({u["name"]: _f(c["markPx"]) for u, c in zip(ctxs_xyz[0]["universe"], ctxs_xyz[1])})
171
+ rates.update({u["name"]: _f(c["funding"]) for u, c in zip(ctxs_xyz[0]["universe"], ctxs_xyz[1])})
172
+ out = []
173
+ open_orders = list(open_orders or []) + list(open_orders_xyz or [])
174
+ for ap in (cs.get("assetPositions") or []) + ((cs_xyz or {}).get("assetPositions") or []):
175
+ p = ap["position"]; szi = _f(p["szi"]); coin = p["coin"]; side = "LONG" if szi > 0 else "SHORT"
176
+ # On a merged book (--book) two strategy wallets can hold the SAME coin. Matching a resting
177
+ # trigger to a position by coin alone then credits one wallet's stop to the other's naked
178
+ # position — the protection audit's one job, answered wrong in the dangerous direction.
179
+ # Untagged reads (every single-wallet run) match as before.
180
+ wal = p.get("wallet")
181
+ size = abs(szi); mark = marks.get(coin) or _f(p["entryPx"])
182
+ if size * mark < DUST_USD:
183
+ continue # dust left behind by a partial close: not a position
184
+ liq_px = _f(p["liquidationPx"]) if p.get("liquidationPx") else None
185
+ exit_side = "A" if side == "LONG" else "B"
186
+ stops, tps = [], []
187
+ for o in open_orders or []:
188
+ if o.get("coin") != coin or not o.get("isTrigger") or o.get("side") != exit_side:
189
+ continue
190
+ if wal and o.get("wallet") and o["wallet"] != wal:
191
+ continue # a sibling wallet's stop is not this position's
192
+ tp_ = _f(o.get("triggerPx"))
193
+ (stops if ((side == "LONG" and tp_ < mark) or (side == "SHORT" and tp_ > mark)) else tps).append(o)
194
+ covered = min(size, sum(_f(o["sz"]) for o in stops))
195
+ nearest = None
196
+ if stops:
197
+ nearest = max(_f(o["triggerPx"]) for o in stops) if side == "LONG" else min(_f(o["triggerPx"]) for o in stops)
198
+ notional = size * mark; rate = rates.get(coin, 0.0)
199
+ lev = p.get("leverage") or {}
200
+ out.append(dict(coin=coin, side=side, size=size, entry=_f(p["entryPx"]), mark=mark, leverage=lev.get("value"), margin_mode=lev.get("type"),
201
+ notional=notional, margin_used=_f(p.get("marginUsed")), unrealized=_f(p.get("unrealizedPnl")), roe=_f(p.get("returnOnEquity")),
202
+ liq_px=liq_px, liq_distance_pct=(abs(mark - liq_px) / mark * 100) if (liq_px and mark) else None,
203
+ stop_covered_share=(covered / size) if size else 0.0, stop_px=nearest,
204
+ # the ids of the orders actually resting. A backend ratchet row names the
205
+ # order it believes it owns; being able to check that against the book is
206
+ # what separates a live row from a stale one. (@0xsarvesh, #753.)
207
+ stop_oids=[o.get("oid") for o in stops if o.get("oid") is not None],
208
+ stop_distance_pct=(abs(mark - nearest) / mark * 100) if (nearest and mark) else None, take_profit=bool(tps),
209
+ funding_rate_hourly=rate, funding_per_day=-(rate * notional * 24) * (1 if side == "LONG" else -1),
210
+ funding_since_open=_f((p.get("cumFunding") or {}).get("sinceOpen")),
211
+ opened_ms=(ages or {}).get(coin), wallet=wal))
212
+ ms = cs.get("marginSummary") or {}; mx = (cs_xyz or {}).get("marginSummary") or {}
213
+ perps_av = _f(ms.get("accountValue")) + _f(mx.get("accountValue")); mu = _f(ms.get("totalMarginUsed")) + _f(mx.get("totalMarginUsed"))
214
+ av = total_account_value if (total_account_value and total_account_value > 0) else perps_av
215
+ gross_exp = sum(p["notional"] for p in out)
216
+ net_exp = sum(p["notional"] * (1 if p["side"] == "LONG" else -1) for p in out)
217
+ return dict(positions=out, account_value=av, margin_used=mu, margin_utilization=(mu / av) if av else None,
218
+ withdrawable=_f(cs.get("withdrawable")) + _f((cs_xyz or {}).get("withdrawable")) + (spot_free or 0.0),
219
+ account_value_main=_f(ms.get("accountValue")), account_value_xyz=_f(mx.get("accountValue")), account_value_perps=perps_av,
220
+ unrealized=sum(p["unrealized"] for p in out), naked=[p["coin"] for p in out if p["stop_covered_share"] == 0],
221
+ partial=[p["coin"] for p in out if 0 < p["stop_covered_share"] < 0.9], gross_exposure=gross_exp, net_exposure=net_exp,
222
+ exposure_over_equity=(gross_exp / av) if av else None, funding_per_day=sum(p["funding_per_day"] for p in out),
223
+ largest_share=(max(p["notional"] for p in out) / gross_exp) if gross_exp else None)
224
+
225
+
226
+ def whole_account_value(portfolio, spot):
227
+ """Hyperliquid's own account value: the last point of the portfolio series (spot balances plus the perps
228
+ equity — on a unified account the perps margin is USDC on hold in spot). Falls back to the spot USDC
229
+ total when the series is missing. None when neither is readable, so the caller uses the perps view."""
230
+ for name in ("day", "week", "month", "allTime"):
231
+ w = dict(portfolio or []).get(name) or {}
232
+ pts = w.get("accountValueHistory") or []
233
+ if pts:
234
+ v = _f(pts[-1][1])
235
+ if v > 0:
236
+ return v
237
+ for b in (spot or {}).get("balances") or []:
238
+ if b.get("coin") == "USDC":
239
+ v = _f(b.get("total"))
240
+ return v if v > 0 else None
241
+ return None
242
+
243
+
244
+ def flows(ledger, addr):
245
+ """Signed transfers in the window: + into this account, − out. Sends carry `user` (sender) and
246
+ `destination`; deposits and withdrawals carry their own types. Other ledger types are ignored.
247
+
248
+ `addr` may be ONE address or every wallet of a merged book. A book's inbound transfer is one
249
+ that landed on any of its wallets from outside it — internal moves are already dropped upstream
250
+ (book._internal), but matching on a single address would have signed a sibling's deposit as an
251
+ outflow."""
252
+ a = {addr.lower()} if isinstance(addr, str) else {x.lower() for x in addr}
253
+ out = []
254
+ for x in ledger or []:
255
+ d = x.get("delta") or {}; t = d.get("type")
256
+ amt = _f(d.get("amount") if d.get("amount") is not None else d.get("usdc"))
257
+ if t == "send":
258
+ sign = 1 if (d.get("destination") or "").lower() in a else -1
259
+ elif t == "deposit":
260
+ sign = 1
261
+ elif t == "withdraw":
262
+ sign = -1
263
+ else:
264
+ continue
265
+ out.append((x["time"], sign * amt))
266
+ return sorted(out)
267
+
268
+
269
+ # The portfolio series to read. Ranked by the SPAN a series covers, never by how many POINTS it
270
+ # has: HL samples `month` far more densely than `allTime`, so `len(pts) > len(best)` picked a 31-day
271
+ # series and every caller labelled it 90 days. The ledger verdict, the drawdown search and
272
+ # return-on-avg-equity all inherited it — one book read "Down $4,614 over the window" to a trader up
273
+ # $29,003 over the 90 days it claimed to cover. `methodology.md` has documented the span rule since
274
+ # 1.0; the code did not implement it.
275
+ #
276
+ # Ties go to the order below, which puts the perps-only series first: every other number on this
277
+ # desk is perps-only, so a whole-account P&L must not sit beside a perps-only anything.
278
+ _SERIES_PREF = ("perpAllTime", "allTime", "perpMonth", "month")
279
+
280
+
281
+ def _longest(portfolio, key, window_start):
282
+ windows = dict(portfolio or [])
283
+ best, best_span = [], -1
284
+ for name in _SERIES_PREF:
285
+ pts = [(t, _f(v)) for t, v in ((windows.get(name) or {}).get(key) or []) if t >= window_start]
286
+ if len(pts) >= 2 and (pts[-1][0] - pts[0][0]) > best_span:
287
+ best, best_span = pts, pts[-1][0] - pts[0][0]
288
+ return best
289
+
290
+
291
+ def equity_curve(portfolio, flow_list, window_start):
292
+ """Transfer-adjusted equity over the window (account value + cumulative net outflows), from the longest
293
+ portfolio series that covers it. A withdrawal must not read as a loss."""
294
+ series = _longest(portfolio, "accountValueHistory", window_start)
295
+ adj, j, cum_out = [], 0, 0.0
296
+ for t, v in series:
297
+ while j < len(flow_list) and flow_list[j][0] <= t:
298
+ cum_out -= flow_list[j][1]; j += 1
299
+ adj.append((t, v + cum_out))
300
+ return adj
301
+
302
+
303
+ def _dd_basis(av_at, peak_t, trough_t, fall):
304
+ """The equity a fall came out of, or None when the series cannot say.
305
+
306
+ Equity AT THE PEAK is the direct answer: that is the capital the drawdown ate into. The older
307
+ form, `equity at the trough + the fall`, only equals it when nothing was deposited or withdrawn
308
+ in between — and that is precisely the assumption a senpi strategy wallet breaks. Such a wallet
309
+ is funded, traded, then SWEPT back to the funding wallet when the strategy closes, so its
310
+ account-value history reads 0.0 long after real money passed through it.
311
+
312
+ When the chosen series carries 0.0 at every point (a swept wallet's sparse perpAllTime history),
313
+ the old form degenerated to `0 + fall = fall` and every closed wallet scored a 100% drawdown —
314
+ "the account went to zero, a full loss of the equity at risk" printed over books that fell 12%
315
+ and one that ended the window UP $57. There is no basis for a percentage there, so say None and
316
+ let callers render "—" rather than invent the most alarming number in the range.
317
+ """
318
+ at_peak = av_at(peak_t) or 0.0
319
+ if at_peak > 0:
320
+ return at_peak
321
+ at_trough = av_at(trough_t) or 0.0
322
+ if at_trough > 0:
323
+ return at_trough + fall
324
+ return None
325
+
326
+
327
+ def drawdown(pnl_pts, av_pts):
328
+ """Max drawdown from Hyperliquid's own P&L series (transfer-immune by construction): the deepest
329
+ peak-to-trough fall in cumulative P&L, as a share of the equity that fall came out of. Capped at
330
+ 100%. `dd` (the dollar fall) is always meaningful; `dd_pct` is None when the account-value series
331
+ has no positive reading to divide by — see _dd_basis."""
332
+ if not pnl_pts:
333
+ return dict(dd=0.0, dd_pct=0.0, span=None, in_drawdown=False, current_dd_pct=None)
334
+ av = dict(av_pts or [])
335
+ def av_at(t):
336
+ ks = [k for k in av if k <= t]
337
+ return av[max(ks)] if ks else (av[min(av)] if av else 0.0)
338
+ peak, peak_t, dd, dd_pct, span = -1e18, None, 0.0, None, None
339
+ for t, v in pnl_pts:
340
+ if v > peak:
341
+ peak, peak_t = v, t
342
+ fall = peak - v
343
+ if fall > dd:
344
+ base = _dd_basis(av_at, peak_t, t, fall)
345
+ dd, span = fall, (peak_t, t)
346
+ dd_pct = min(1.0, fall / base) if base and base > 0 else None
347
+ last_t, last = pnl_pts[-1]
348
+ base_now = _dd_basis(av_at, peak_t, last_t, peak - last) if peak > last else None
349
+ cur = min(1.0, (peak - last) / base_now) if base_now and base_now > 0 and peak > last else None
350
+ if dd == 0.0 and dd_pct is None:
351
+ dd_pct = 0.0 # no fall at all is a real 0%, not an unknown
352
+ return dict(dd=dd, dd_pct=dd_pct, span=span, in_drawdown=bool(cur and cur > 0.05), current_dd_pct=cur)
353
+
354
+
355
+ def pnl_series(portfolio, window_start):
356
+ best = _longest(portfolio, "pnlHistory", window_start)
357
+ if not best:
358
+ return []
359
+ base = best[0][1]
360
+ return [(t, v - base) for t, v in best]
361
+
362
+
363
+ def coverage(closed, opened, fills=None, fee_sched=None):
364
+ """Observed volume ÷ (observed + the volume implied by `startPosition` jumps), over every episode —
365
+ 1.0 when the public API returned every execution. Below 0.9 the desk says so: the usual cause is TWAP
366
+ slices older than the retained window. `volume_ratio` (observed ÷ the wallet's `dailyUserVlm`) rides
367
+ along as a diagnostic only — that figure is not a reliable denominator on every wallet."""
368
+ from roundtrips import coverage as _cov
369
+ eps = list(closed) + list(opened)
370
+ out = dict(overall=_cov(eps), unobserved_volume=sum(e["unobserved_notional"] for e in eps), observed_volume=sum(e["volume"] for e in eps),
371
+ episodes_incomplete=sum(1 for e in eps if e["unobserved_qty"] or not e["close_observed"]), episodes=len(eps))
372
+ out["volume_ratio"] = _daily_ratio(fills or [], fee_sched or {})
373
+ return out
374
+
375
+
376
+ def _daily_ratio(fills, fee_sched):
377
+ import datetime
378
+ days = fee_sched.get("dailyUserVlm") or []
379
+ if not days:
380
+ return None
381
+ seen = collections.defaultdict(lambda: [0.0, 0.0])
382
+ for f in fills:
383
+ # main-dex only: `dailyUserVlm` is the main-dex figure, so counting xyz: fills in the
384
+ # numerator made the diagnostic read 2.26x — "we saw 226% of the volume" (B2, #718)
385
+ if not is_perp(f["coin"]) or str(f["coin"]).startswith("xyz:"):
386
+ continue
387
+ d = datetime.datetime.fromtimestamp(f["time"] / 1000, datetime.timezone.utc).strftime("%Y-%m-%d")
388
+ seen[d][1 if f.get("crossed") else 0] += _f(f["sz"]) * _f(f["px"])
389
+ hl_t = sum(_f(d.get("userCross")) for d in days); hl_m = sum(_f(d.get("userAdd")) for d in days)
390
+ ob_t = sum(seen[d["date"]][1] for d in days); ob_m = sum(seen[d["date"]][0] for d in days)
391
+ tot = hl_t + hl_m
392
+ return dict(days=len(days), overall=((ob_t + ob_m) / tot) if tot else None, taker=(ob_t / hl_t) if hl_t else None, maker=(ob_m / hl_m) if hl_m else None)
393
+
394
+
395
+ def ledger_pnl(portfolio, window_start):
396
+ """Net P&L over the window from Hyperliquid's own portfolio series (fees, funding and unrealized
397
+ included) — the account-level truth, independent of which fills the API returned."""
398
+ pts = pnl_series(portfolio, window_start)
399
+ return pts[-1][1] if pts else None
400
+
401
+
402
+ def activity(fills, window_start):
403
+ perp = [f for f in fills if f["time"] >= window_start and is_perp(f["coin"])]
404
+ days = {f["time"] // 86_400_000 for f in perp}
405
+ hours = collections.Counter((f["time"] // 3_600_000) % 24 for f in perp)
406
+ return dict(fills=len(perp), coins=len({f["coin"] for f in perp}), active_days=len(days),
407
+ twap_share=(sum(1 for f in perp if f.get("twapId")) / len(perp)) if perp else 0.0,
408
+ busiest_utc_hours=[h for h, _ in hours.most_common(3)], first_fill=min((f["time"] for f in perp), default=None),
409
+ last_fill=max((f["time"] for f in perp), default=None))
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env python3
2
+ """Live matches: where today's market, the cohorts and the trader's own best pattern point the same way.
3
+ Process framing only — a match is a setup that fits how this trader wins, never a call to buy a coin."""
4
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
5
+ import taxonomy
6
+
7
+ CHASE_PCT_24H = 5.0
8
+
9
+
10
+ def _pattern_keys(setups, closed, majors, large):
11
+ """The trader's winning patterns as (coin, side) and (class, side) keys with their evidence."""
12
+ keys = {}
13
+ for b in (setups or {}).get("best") or []:
14
+ lab = b["label"]
15
+ for e in closed:
16
+ cs = f"{e['coin']} {e['direction'].lower()}s"
17
+ if lab == cs:
18
+ keys[("coin", e["coin"], e["direction"])] = b
19
+ if lab.endswith("s held < 4h") or "held" in lab or "entries" in lab:
20
+ continue
21
+ # class-level: best class×side by profit factor with ≥ 4 trades
22
+ import collections
23
+ g = collections.defaultdict(list)
24
+ for e in closed:
25
+ g[(taxonomy.classify(e["coin"], majors, large), e["direction"])].append(e)
26
+ for (cls, side), eps in g.items():
27
+ if len(eps) >= 4:
28
+ w = sum(x["realized"] for x in eps if x["realized"] > 0); l = -sum(x["realized"] for x in eps if x["realized"] <= 0)
29
+ pf = (w / l) if l else (float("inf") if w else None)
30
+ if pf and pf >= 1.5:
31
+ keys[("class", cls, side)] = dict(label=f"{taxonomy.label(cls)} {side.lower()}s", n=len(eps), wins=sum(1 for x in eps if x["win"]), profit_factor=pf)
32
+ return keys
33
+
34
+
35
+ def scout(closed, setups, book, breadth, regimes, cohorts, attention, majors, large, limit=6):
36
+ """Rank (coin, side) candidates the cohorts lean on or the trader already wins on, by fit."""
37
+ patterns = _pattern_keys(setups, closed, majors, large)
38
+ mine = {p["coin"]: p["side"] for p in book["positions"]}
39
+ cands = {}
40
+ def add(coin, side, why, pts):
41
+ d = cands.setdefault((coin, side), dict(coin=coin, side=side, score=0.0, why=[]))
42
+ d["score"] += pts; d["why"].append(why)
43
+ for cv in cohorts or []:
44
+ for h in cv.get("they_hold") or []:
45
+ if h["members"] >= 5:
46
+ add(h["coin"], h["side"], f"{cv['name']} cohort: {h['members']} wallets {h['side'].lower()} (bias {h['bias']:+.2f})", 2.0 if cv["name"] == "proven" else 1.0)
47
+ for r in cv.get("rows") or []:
48
+ if r["read"].startswith("WITH") and r["members"] >= 5:
49
+ add(r["coin"], mine[r["coin"]], f"{cv['name']} cohort is with your {mine[r['coin']].lower()} ({r['members']} wallets)", 1.0)
50
+ for m in (attention or {}).get("markets") or []:
51
+ if m["coin"] and m["traders"] >= 5:
52
+ add(m["coin"], m["direction"], f"top traders' gains: {m['share_of_gains']:.0f}% from {m['direction'].lower()}s ({m['traders']} traders)", 1.0)
53
+ for m in (attention or {}).get("momentum") or []:
54
+ add(m["coin"], m["direction"], f"{m['events']} momentum event(s) {m['direction'].lower()} in the last 4h", 0.5)
55
+ for (kind, key, side), ev in patterns.items():
56
+ if kind == "coin":
57
+ add(key, side, f"your own pattern: {ev['wins']} of {ev['n']} wins on {key} {side.lower()}s", 3.0)
58
+ out = []
59
+ for (coin, side), d in cands.items():
60
+ cls = taxonomy.classify(coin, majors, large)
61
+ pat = patterns.get(("class", cls, side))
62
+ if pat:
63
+ pf = "∞" if pat["profit_factor"] == float("inf") else f"{pat['profit_factor']:.1f}×"
64
+ d["score"] += 2.0; d["why"].append(f"fits how you win: {pat['label']} {pat['wins']} of {pat['n']}, profit factor {pf}")
65
+ r = (regimes or {}).get(coin)
66
+ if r and r["trend"] != "UNKNOWN": # under 20 days of tape: no trend credit either way
67
+ if r["trend"] == "RANGING":
68
+ d["why"].append("trend: ranging")
69
+ elif (side == "LONG") == (r["trend"] == "UP"):
70
+ d["score"] += 2.0; d["why"].append(f"trend: {r['trend'].lower()} — with you")
71
+ else:
72
+ d["score"] -= 2.5; d["why"].append(f"trend: {r['trend'].lower()} — against")
73
+ a = (breadth or {}).get("assets", {}).get(coin)
74
+ if a:
75
+ fb = a["funding_bp_8h"]
76
+ pays = (side == "LONG" and fb > 10) or (side == "SHORT" and fb < -10)
77
+ if pays:
78
+ d["score"] -= 1.0; d["why"].append(f"funding {fb:+.0f} bp/8h — you would pay to hold")
79
+ elif (side == "LONG" and fb < -3) or (side == "SHORT" and fb > 3):
80
+ d["score"] += 0.5; d["why"].append(f"funding {fb:+.0f} bp/8h — the side that collects")
81
+ ch = a["change_pct"]
82
+ if (side == "LONG" and ch > CHASE_PCT_24H) or (side == "SHORT" and ch < -CHASE_PCT_24H):
83
+ d["score"] -= 1.0; d["why"].append(f"already moved {ch:+.1f}% today — entering here is a chase")
84
+ d["held"] = mine.get(coin)
85
+ d["cls"] = taxonomy.label(cls)
86
+ out.append(d)
87
+ out.sort(key=lambda d: -d["score"])
88
+ return [d for d in out if d["score"] > 0][:limit]