@senpi/runtime 3.0.148 → 3.0.150
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/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +55 -0
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/quant-command.d.ts +128 -0
- package/dist/cli/quant-command.d.ts.map +1 -0
- package/dist/cli/quant-command.js +345 -0
- package/dist/cli/quant-command.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts +11 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +49 -4
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/quant/engine/quantdesk/__init__.py +19 -0
- package/dist/quant/engine/quantdesk/__main__.py +281 -0
- package/dist/quant/engine/quantdesk/_version.py +24 -0
- package/dist/quant/engine/quantdesk/benchmark.py +90 -0
- package/dist/quant/engine/quantdesk/book.py +197 -0
- package/dist/quant/engine/quantdesk/deep.py +197 -0
- package/dist/quant/engine/quantdesk/desk.py +878 -0
- package/dist/quant/engine/quantdesk/dsl.py +224 -0
- package/dist/quant/engine/quantdesk/followups.py +117 -0
- package/dist/quant/engine/quantdesk/hl_api.py +473 -0
- package/dist/quant/engine/quantdesk/market.py +290 -0
- package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
- package/dist/quant/engine/quantdesk/metrics.py +409 -0
- package/dist/quant/engine/quantdesk/opportunities.py +88 -0
- package/dist/quant/engine/quantdesk/render.py +787 -0
- package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
- package/dist/quant/engine/quantdesk/score.py +817 -0
- package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
- package/dist/quant/engine/quantdesk/smart_money.py +354 -0
- package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
- package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
- package/dist/quant/engine/quantdesk/timing.py +164 -0
- package/dist/quant/engine/quantdesk/voice.py +42 -0
- package/dist/quant/engine-runner.d.ts +133 -0
- package/dist/quant/engine-runner.d.ts.map +1 -0
- package/dist/quant/engine-runner.js +306 -0
- package/dist/quant/engine-runner.js.map +1 -0
- package/dist/quant/job.d.ts +125 -0
- package/dist/quant/job.d.ts.map +1 -0
- package/dist/quant/job.js +184 -0
- package/dist/quant/job.js.map +1 -0
- package/dist/quant/register.d.ts +53 -0
- package/dist/quant/register.d.ts.map +1 -0
- package/dist/quant/register.js +253 -0
- package/dist/quant/register.js.map +1 -0
- package/dist/quant/registry.d.ts +105 -0
- package/dist/quant/registry.d.ts.map +1 -0
- package/dist/quant/registry.js +248 -0
- package/dist/quant/registry.js.map +1 -0
- package/dist/quant/types.d.ts +216 -0
- package/dist/quant/types.d.ts.map +1 -0
- package/dist/quant/types.js +121 -0
- package/dist/quant/types.js.map +1 -0
- package/dist/skills-manager/manifest.js +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- package/dist/widgets/index.d.ts +8 -1
- package/dist/widgets/index.d.ts.map +1 -1
- package/dist/widgets/index.js +1 -0
- package/dist/widgets/index.js.map +1 -1
- package/dist/widgets/registry.d.ts +8 -0
- package/dist/widgets/registry.d.ts.map +1 -1
- package/dist/widgets/registry.js +26 -1
- package/dist/widgets/registry.js.map +1 -1
- package/dist/widgets/tool.d.ts +14 -4
- package/dist/widgets/tool.d.ts.map +1 -1
- package/dist/widgets/tool.js +36 -23
- package/dist/widgets/tool.js.map +1 -1
- package/dist/widgets/turn-cap.d.ts +33 -0
- package/dist/widgets/turn-cap.d.ts.map +1 -0
- package/dist/widgets/turn-cap.js +119 -0
- package/dist/widgets/turn-cap.js.map +1 -0
- package/dist/widgets/types.d.ts +33 -5
- package/dist/widgets/types.d.ts.map +1 -1
- package/dist/widgets/types.js +4 -3
- package/dist/widgets/types.js.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.js +1 -0
- package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
- package/openclaw.plugin.json +12 -2
- 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]
|