@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,290 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Market fit from public data: per-coin trend, realized volatility, funding and open interest, and whether
|
|
3
|
+
each open position sits with or against them. Funding is quoted per 8h in basis points (Hyperliquid pays
|
|
4
|
+
hourly; hourly rate × 8 × 10,000)."""
|
|
5
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
6
|
+
import math
|
|
7
|
+
import statistics
|
|
8
|
+
|
|
9
|
+
H = 3_600_000.0
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _f(x, d=0.0):
|
|
13
|
+
try:
|
|
14
|
+
return float(x)
|
|
15
|
+
except (TypeError, ValueError):
|
|
16
|
+
return d
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def coin_regime(coin, candles, ctx):
|
|
20
|
+
"""Trend from hourly candles: 7-day and 30-day change and the close vs its 20-day mean; volatility as the
|
|
21
|
+
24h realized vol against the 30-day. Returns None when the candle series is missing."""
|
|
22
|
+
c = candles.get(coin)
|
|
23
|
+
if not c:
|
|
24
|
+
return None
|
|
25
|
+
rows = c[1]
|
|
26
|
+
if len(rows) < 48:
|
|
27
|
+
return None
|
|
28
|
+
close = [r[4] for r in rows]
|
|
29
|
+
now = close[-1]
|
|
30
|
+
def chg(hours):
|
|
31
|
+
return (now / close[-hours - 1] - 1) if len(close) > hours else None
|
|
32
|
+
# 480 hourly candles IS the 20-day mean. Below that it took whatever it had — as few as 48
|
|
33
|
+
# candles, two days — and called it the 20-day mean, then fed it to a +/-2% trend threshold that
|
|
34
|
+
# a 2-day mean can barely clear. That is a new listing: the case where a trend call is least
|
|
35
|
+
# reliable and most likely to be read as one. Abstain. (@danielmbirochi, #718, round 2.)
|
|
36
|
+
sma20 = statistics.mean(close[-480:]) if len(close) >= 480 else None
|
|
37
|
+
rets = [math.log(close[i] / close[i - 1]) for i in range(1, len(close)) if close[i - 1] > 0]
|
|
38
|
+
vol24 = statistics.pstdev(rets[-24:]) * math.sqrt(24) if len(rets) >= 24 else None
|
|
39
|
+
vol30 = statistics.pstdev(rets[-720:]) * math.sqrt(24) if len(rets) >= 720 else None
|
|
40
|
+
c7, c30 = chg(168), chg(720)
|
|
41
|
+
vs_sma = (now / sma20 - 1) if sma20 else None
|
|
42
|
+
if vs_sma is None:
|
|
43
|
+
trend = "UNKNOWN" # too little tape to confirm a direction; not the same as ranging
|
|
44
|
+
elif c7 is not None and c7 > 0.05 and vs_sma > 0.02:
|
|
45
|
+
trend = "UP"
|
|
46
|
+
elif c7 is not None and c7 < -0.05 and vs_sma < -0.02:
|
|
47
|
+
trend = "DOWN"
|
|
48
|
+
else:
|
|
49
|
+
trend = "RANGING"
|
|
50
|
+
fr = _f((ctx or {}).get("funding"))
|
|
51
|
+
return dict(coin=coin, trend=trend, change_7d=c7, change_30d=c30, vs_20d_mean=vs_sma, vol_24h=vol24, vol_30d=vol30,
|
|
52
|
+
vol_ratio=(vol24 / vol30) if (vol24 and vol30) else None, funding_bp_8h=fr * 8 * 1e4,
|
|
53
|
+
open_interest_usd=_f((ctx or {}).get("openInterest")) * _f((ctx or {}).get("markPx")), day_volume=_f((ctx or {}).get("dayNtlVlm")))
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def fit(position, regime):
|
|
57
|
+
"""WITH / AGAINST / NEUTRAL: a long fits an up-trend, a short a down-trend; ranging is neutral. Funding
|
|
58
|
+
is reported beside it (a long paying positive funding is a cost, not a misfit)."""
|
|
59
|
+
if not regime or regime["trend"] == "UNKNOWN":
|
|
60
|
+
return "UNKNOWN"
|
|
61
|
+
if regime["trend"] == "RANGING":
|
|
62
|
+
return "NEUTRAL — ranging"
|
|
63
|
+
return "WITH THE MARKET" if (position["side"] == "LONG") == (regime["trend"] == "UP") else "AGAINST THE MARKET"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def book_fit(book, candles, ctxs):
|
|
67
|
+
ctx_by = {u["name"]: c for u, c in zip(ctxs[0]["universe"], ctxs[1])}
|
|
68
|
+
rows, regimes = [], {}
|
|
69
|
+
for p in book["positions"]:
|
|
70
|
+
r = coin_regime(p["coin"], candles, ctx_by.get(p["coin"]))
|
|
71
|
+
regimes[p["coin"]] = r
|
|
72
|
+
rows.append(dict(coin=p["coin"], side=p["side"], leverage=p["leverage"], trend=r["trend"] if r else None,
|
|
73
|
+
funding_bp_8h=r["funding_bp_8h"] if r else None, open_interest_usd=r["open_interest_usd"] if r else None,
|
|
74
|
+
notional=abs(_f(p.get("notional"))), funding_per_day=p["funding_per_day"], fit=fit(p, r)))
|
|
75
|
+
btc = coin_regime("BTC", candles, ctx_by.get("BTC"))
|
|
76
|
+
# Weight the headline by NOTIONAL, not by coin. A plain median counts a $19 dust position and a
|
|
77
|
+
# $20M one equally, so a book with sixteen xyz names at 0 bp and its real size in HYPE (+10) and
|
|
78
|
+
# XMR (+9) read "FUNDING NEAR FLAT" while the same desk said it collects $20,629/day two lines
|
|
79
|
+
# below. Size is what decides whether funding is a real part of the return.
|
|
80
|
+
pairs = [(r["funding_bp_8h"], r["notional"]) for r in rows
|
|
81
|
+
if r["funding_bp_8h"] is not None and r["notional"] > 0]
|
|
82
|
+
if pairs:
|
|
83
|
+
tot = sum(w for _, w in pairs)
|
|
84
|
+
med_f = sum(f * w for f, w in pairs) / tot if tot > 0 else None
|
|
85
|
+
else:
|
|
86
|
+
fundings = [r["funding_bp_8h"] for r in rows if r["funding_bp_8h"] is not None]
|
|
87
|
+
med_f = statistics.median(fundings) if fundings else None
|
|
88
|
+
# Say the annualised rate, and never let the word contradict it. bp/8h is three periods a day, so
|
|
89
|
+
# 3 bp/8h — the old floor for "POSITIVE" — is already 33%/yr, and everything below it read
|
|
90
|
+
# "NEAR FLAT". On the real book that printed FUNDING NEAR FLAT over a weighted 2.70 bp/8h =
|
|
91
|
+
# 29.6%/yr, beside the same desk's own "$20,629/day" (25.8%/yr of account value). The two agree;
|
|
92
|
+
# only the label was wrong.
|
|
93
|
+
ann = (med_f * 3 * 365 / 100.0) if med_f is not None else None
|
|
94
|
+
if med_f is None:
|
|
95
|
+
fhead = "FUNDING UNKNOWN"
|
|
96
|
+
elif med_f >= 10:
|
|
97
|
+
fhead = "HIGH POSITIVE FUNDING"
|
|
98
|
+
elif med_f >= 3:
|
|
99
|
+
fhead = "POSITIVE FUNDING"
|
|
100
|
+
elif med_f >= 1:
|
|
101
|
+
fhead = "MILDLY POSITIVE FUNDING"
|
|
102
|
+
elif med_f <= -10:
|
|
103
|
+
fhead = "DEEPLY NEGATIVE FUNDING"
|
|
104
|
+
elif med_f <= -3:
|
|
105
|
+
fhead = "NEGATIVE FUNDING"
|
|
106
|
+
elif med_f <= -1:
|
|
107
|
+
fhead = "MILDLY NEGATIVE FUNDING"
|
|
108
|
+
else:
|
|
109
|
+
fhead = "FUNDING NEAR FLAT"
|
|
110
|
+
if ann is not None and abs(ann) >= 5:
|
|
111
|
+
# the RATE on notional, not a share of equity. The risk dimension quotes the same funding as
|
|
112
|
+
# a share of EQUITY, which leverage makes a different number: on 0xccd2…c8a3 this read
|
|
113
|
+
# "+49%/yr on the book you hold" beside "186% of equity a year" — both true, 4x apart.
|
|
114
|
+
fhead += f" ({ann:+.0f}%/yr on notional at today's rates)"
|
|
115
|
+
headline = f"{fhead} · BTC {btc['trend'] if btc else 'UNKNOWN'}"
|
|
116
|
+
net = book["net_exposure"]
|
|
117
|
+
stance = "net long" if net > 0 else ("net short" if net < 0 else "flat")
|
|
118
|
+
fpd = book["funding_per_day"]
|
|
119
|
+
return dict(headline=headline, median_funding_bp_8h=med_f, btc=btc, stance=stance, funding_per_day=fpd, rows=rows, regimes=regimes,
|
|
120
|
+
with_market=sum(1 for r in rows if r["fit"].startswith("WITH")), against=sum(1 for r in rows if r["fit"].startswith("AGAINST")))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# ================================================================ v2: the market the trader is in
|
|
124
|
+
import collections # noqa: E402
|
|
125
|
+
|
|
126
|
+
import taxonomy # noqa: E402
|
|
127
|
+
|
|
128
|
+
DAY_MS = 86_400_000
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _field(d, *names, default=None):
|
|
132
|
+
if isinstance(d, dict):
|
|
133
|
+
for n in names:
|
|
134
|
+
if n in d and d[n] is not None:
|
|
135
|
+
return d[n]
|
|
136
|
+
return default
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _ok(resp):
|
|
140
|
+
if isinstance(resp, dict):
|
|
141
|
+
if resp.get("success") is False:
|
|
142
|
+
return None
|
|
143
|
+
return resp.get("data", resp)
|
|
144
|
+
return resp
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def breadth(ctx_main, ctx_xyz=None):
|
|
148
|
+
"""Today's tape from the live contexts: 24h change per asset (mark vs previous day), grouped by asset
|
|
149
|
+
class; risk_on / risk_off / mixed by senpi-market-pulse's rule (groups moving ≥ 0.5% either way)."""
|
|
150
|
+
majors, large = taxonomy.crypto_tiers(ctx_main)
|
|
151
|
+
rows = []
|
|
152
|
+
for ctxs in (ctx_main, ctx_xyz):
|
|
153
|
+
if not ctxs:
|
|
154
|
+
continue
|
|
155
|
+
for u, c in zip(ctxs[0]["universe"], ctxs[1]):
|
|
156
|
+
try:
|
|
157
|
+
mark, prev = float(c["markPx"]), float(c["prevDayPx"])
|
|
158
|
+
if not mark or not prev:
|
|
159
|
+
continue
|
|
160
|
+
rows.append(dict(coin=u["name"], cls=taxonomy.classify(u["name"], majors, large), change_pct=100 * (mark / prev - 1),
|
|
161
|
+
funding_bp_8h=_f(c.get("funding")) * 8 * 1e4, oi_usd=_f(c.get("openInterest")) * mark, volume=_f(c.get("dayNtlVlm"))))
|
|
162
|
+
except (TypeError, ValueError, KeyError):
|
|
163
|
+
continue
|
|
164
|
+
groups = {}
|
|
165
|
+
for cls in sorted({r["cls"] for r in rows}):
|
|
166
|
+
rs = [r for r in rows if r["cls"] == cls and r["volume"] > 0]
|
|
167
|
+
if not rs:
|
|
168
|
+
continue
|
|
169
|
+
chg = [r["change_pct"] for r in rs]
|
|
170
|
+
groups[cls] = dict(label=taxonomy.label(cls), n=len(rs), avg_change_pct=statistics.mean(chg), median_change_pct=statistics.median(chg),
|
|
171
|
+
up=sum(1 for x in chg if x > 0), down=sum(1 for x in chg if x < 0), median_funding_bp_8h=statistics.median(r["funding_bp_8h"] for r in rs),
|
|
172
|
+
oi_usd=sum(r["oi_usd"] for r in rs))
|
|
173
|
+
avgs = [g["avg_change_pct"] for g in groups.values()]
|
|
174
|
+
down = sum(1 for x in avgs if x < -0.5); up = sum(1 for x in avgs if x > 0.5)
|
|
175
|
+
day = "risk_off" if (down >= up * 2 and down >= 3) else ("risk_on" if (up >= down * 2 and up >= 3) else "mixed")
|
|
176
|
+
main_rows = [r for r in rows if not r["coin"].startswith("xyz:") and r["volume"] > 0]
|
|
177
|
+
share_up = (sum(1 for r in main_rows if r["change_pct"] > 0) / len(main_rows)) if main_rows else None
|
|
178
|
+
by = {r["coin"]: r for r in rows}
|
|
179
|
+
return dict(day=day, share_up=share_up, groups=groups, btc_change_pct=by.get("BTC", {}).get("change_pct"), eth_change_pct=by.get("ETH", {}).get("change_pct"),
|
|
180
|
+
memes_vs_majors=(groups.get("memes", {}).get("avg_change_pct", 0) - groups.get("majors", {}).get("avg_change_pct", 0)) if ("memes" in groups and "majors" in groups) else None,
|
|
181
|
+
assets=by, majors=sorted(majors), large=sorted(large))
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def daily_regimes(daily, basket):
|
|
185
|
+
"""One label per UTC day over the window from daily candles of a basket: risk_on when BTC is up more
|
|
186
|
+
than 1% or ≥ 65% of the basket closed up; risk_off symmetric; else mixed."""
|
|
187
|
+
closes = {c: {r[0]: r[4] for r in rows} for c, rows in (daily or {}).items() if rows and c in basket}
|
|
188
|
+
btc = closes.get("BTC") or {}
|
|
189
|
+
days = sorted(set().union(*(set(v) for v in closes.values())) if closes else set())
|
|
190
|
+
out = {}
|
|
191
|
+
prev = {}
|
|
192
|
+
for d in days:
|
|
193
|
+
ups = downs = 0
|
|
194
|
+
for c, s in closes.items():
|
|
195
|
+
if d in s and c in prev and prev[c]:
|
|
196
|
+
ups += s[d] > prev[c]; downs += s[d] < prev[c]
|
|
197
|
+
if d in s:
|
|
198
|
+
prev[c] = s[d]
|
|
199
|
+
n = ups + downs
|
|
200
|
+
bchg = (btc[d] / btc[prev_d] - 1) if (d in btc and (prev_d := _prev_key(btc, d)) is not None) else None
|
|
201
|
+
share = (ups / n) if n else None
|
|
202
|
+
if bchg is not None and (bchg > 0.01 or (share is not None and share >= 0.65)):
|
|
203
|
+
lab = "risk_on"
|
|
204
|
+
elif bchg is not None and (bchg < -0.01 or (share is not None and share <= 0.35)):
|
|
205
|
+
lab = "risk_off"
|
|
206
|
+
else:
|
|
207
|
+
lab = "mixed"
|
|
208
|
+
out[d] = dict(label=lab, btc_change=bchg, share_up=share)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _prev_key(series, d):
|
|
213
|
+
ks = [k for k in series if k < d]
|
|
214
|
+
return max(ks) if ks else None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def regime_performance(closed, regimes):
|
|
218
|
+
"""The trader's own record split by the regime of the day each trade was opened."""
|
|
219
|
+
if not regimes:
|
|
220
|
+
return None
|
|
221
|
+
days = sorted(regimes)
|
|
222
|
+
import bisect
|
|
223
|
+
def label_for(t):
|
|
224
|
+
i = bisect.bisect_right(days, t) - 1
|
|
225
|
+
return regimes[days[i]]["label"] if i >= 0 and t - days[i] < 2 * DAY_MS else None
|
|
226
|
+
g = collections.defaultdict(list)
|
|
227
|
+
for e in closed:
|
|
228
|
+
lab = label_for(e["open_time"])
|
|
229
|
+
if lab:
|
|
230
|
+
g[(lab, "ALL")].append(e); g[(lab, e["direction"])].append(e)
|
|
231
|
+
out = {}
|
|
232
|
+
for (lab, side), eps in g.items():
|
|
233
|
+
w = sum(e["realized"] for e in eps if e["realized"] > 0); l = -sum(e["realized"] for e in eps if e["realized"] <= 0)
|
|
234
|
+
out[f"{lab}/{side}"] = dict(regime=lab, side=side, trades=len(eps), wins=sum(1 for e in eps if e["win"]), realized=sum(e["realized"] for e in eps),
|
|
235
|
+
pf=(w / l) if l else (float("inf") if w else None))
|
|
236
|
+
counts = collections.Counter(v["label"] for v in regimes.values())
|
|
237
|
+
return dict(cells=out, days=dict(counts))
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def funding_regime(resp):
|
|
241
|
+
d = _ok(resp)
|
|
242
|
+
if isinstance(d, str):
|
|
243
|
+
return dict(regime=d)
|
|
244
|
+
if not isinstance(d, dict):
|
|
245
|
+
return None
|
|
246
|
+
return dict(regime=_field(d, "regime", "funding_regime", "label", "state"), avg_annualized_pct=_field(d, "avg_annualized_funding", "average_annualized_funding_pct", "avg_annualized_pct"),
|
|
247
|
+
extreme_count=_field(d, "extreme_count", "extreme_funding_count", "num_extreme"), duration_hours=_field(d, "regime_duration_hours", "duration_hours", "duration"),
|
|
248
|
+
long_funding_count=_field(d, "long_funding_count", "longs_pay_count"), short_funding_count=_field(d, "short_funding_count", "shorts_pay_count"))
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def attention(markets_resp, momentum_resp, book):
|
|
252
|
+
"""Where the top traders' gains sit right now (leaderboard_get_markets) and who is entering momentum
|
|
253
|
+
in what (momentum events, last 4h) — against the trader's own book."""
|
|
254
|
+
out = dict(markets=[], overlap=[], momentum=[], with_momentum=[], against_momentum=[])
|
|
255
|
+
m = _ok(markets_resp) or {}
|
|
256
|
+
for _ in range(3):
|
|
257
|
+
if isinstance(m, dict):
|
|
258
|
+
m = _field(m, "markets", "data", "results", default=None) or next((v for v in m.values() if isinstance(v, list)), [])
|
|
259
|
+
rows = m if isinstance(m, list) else []
|
|
260
|
+
dom = [r for r in rows or [] if isinstance(r, dict) and r.get("is_dominant_direction")]
|
|
261
|
+
dom.sort(key=lambda r: -_f(r.get("pct_of_top_traders_gain")))
|
|
262
|
+
mine = {p["coin"]: p["side"] for p in book["positions"]}
|
|
263
|
+
for r in dom[:8]:
|
|
264
|
+
coin = (("xyz:" + r["token"]) if r.get("dex") == "xyz" else r.get("token")) if r.get("token") else None
|
|
265
|
+
row = dict(coin=coin, direction=(r.get("direction") or "").upper(), share_of_gains=_f(r.get("pct_of_top_traders_gain")), traders=int(_f(r.get("trader_count"))),
|
|
266
|
+
change_4h=_field(r, "token_price_change_pct_4h"), contribution_change_1h=_field(r, "contribution_pct_change_1h"))
|
|
267
|
+
out["markets"].append(row)
|
|
268
|
+
if coin in mine:
|
|
269
|
+
out["overlap"].append(dict(coin=coin, you=mine[coin], top=row["direction"], read="WITH" if mine[coin] == row["direction"] else "AGAINST"))
|
|
270
|
+
ev = _ok(momentum_resp) or []
|
|
271
|
+
for _ in range(3): # unwrap {data:{events:[...]}} however deep it comes
|
|
272
|
+
if isinstance(ev, dict):
|
|
273
|
+
ev = _field(ev, "events", "data", "results", "items", default=None) or next((v for v in ev.values() if isinstance(v, list)), [])
|
|
274
|
+
if not isinstance(ev, list):
|
|
275
|
+
ev = []
|
|
276
|
+
seen = collections.Counter()
|
|
277
|
+
for e in ev[:50]:
|
|
278
|
+
if not isinstance(e, dict):
|
|
279
|
+
continue
|
|
280
|
+
for p in e.get("top_positions") or []:
|
|
281
|
+
if not isinstance(p, dict):
|
|
282
|
+
continue
|
|
283
|
+
coin = _field(p, "token", "coin", "asset", "market"); side = str(_field(p, "direction", "side", default="")).upper()
|
|
284
|
+
if coin and side:
|
|
285
|
+
seen[(coin, side)] += 1
|
|
286
|
+
for (coin, side), n in seen.most_common(8):
|
|
287
|
+
out["momentum"].append(dict(coin=coin, direction=side, events=n))
|
|
288
|
+
if coin in mine:
|
|
289
|
+
(out["with_momentum"] if mine[coin] == side else out["against_momentum"]).append(coin)
|
|
290
|
+
return out
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Self-contained streamable-HTTP MCP client (stdlib only).
|
|
3
|
+
|
|
4
|
+
Ported from senpi_runtime_helpers.SenpiClient so the discovery skill has ZERO cross-skill
|
|
5
|
+
dependency — it ships its own market-data transport. Read-only tool calls only.
|
|
6
|
+
|
|
7
|
+
client = MCPClient() # reads SENPI_AUTH_TOKEN / SENPI_MCP_URL from env
|
|
8
|
+
data = client.mcp_call("market_get_asset_data", asset="BTC") # -> unwrapped {success,data,...}
|
|
9
|
+
|
|
10
|
+
Returns the same unwrapped JSON `mcporter`/SenpiClient returns. JSON-RPC errors and tool-level
|
|
11
|
+
failures (result.isError) raise MCPError so callers log a warning instead of silently reading None.
|
|
12
|
+
"""
|
|
13
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
14
|
+
import http.client
|
|
15
|
+
import itertools
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from urllib.parse import urlsplit
|
|
19
|
+
|
|
20
|
+
MCP_URL = os.environ.get("SENPI_MCP_URL", "https://mcp.prod.senpi.ai/mcp")
|
|
21
|
+
AUTH = os.environ.get("SENPI_AUTH_TOKEN", "")
|
|
22
|
+
_PROTOCOL = "2025-03-26"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class MCPError(Exception):
|
|
26
|
+
pass
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _post(url, body, headers, timeout):
|
|
30
|
+
"""POST a JSON body on a fresh connection; return (status, raw_bytes, content_type, session_id)."""
|
|
31
|
+
parts = urlsplit(url)
|
|
32
|
+
scheme = parts.scheme
|
|
33
|
+
host = parts.hostname or ""
|
|
34
|
+
port = parts.port or (443 if scheme == "https" else 80)
|
|
35
|
+
path = (parts.path or "/") + (("?" + parts.query) if parts.query else "")
|
|
36
|
+
data = json.dumps(body).encode("utf-8")
|
|
37
|
+
hdrs = {
|
|
38
|
+
"Host": parts.netloc,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
"Accept": "application/json, text/event-stream",
|
|
41
|
+
"Content-Length": str(len(data)),
|
|
42
|
+
}
|
|
43
|
+
hdrs.update(headers)
|
|
44
|
+
cls = http.client.HTTPSConnection if scheme == "https" else http.client.HTTPConnection
|
|
45
|
+
conn = cls(host, port, timeout=timeout)
|
|
46
|
+
try:
|
|
47
|
+
conn.request("POST", path, body=data, headers=hdrs)
|
|
48
|
+
resp = conn.getresponse()
|
|
49
|
+
raw = resp.read()
|
|
50
|
+
return resp.status, raw, (resp.getheader("Content-Type") or ""), resp.getheader("Mcp-Session-Id")
|
|
51
|
+
finally:
|
|
52
|
+
try:
|
|
53
|
+
conn.close()
|
|
54
|
+
except Exception: # noqa
|
|
55
|
+
pass
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _parse(raw, content_type):
|
|
59
|
+
"""Streamable-HTTP returns a single JSON doc OR an SSE stream; return the first JSON-RPC payload."""
|
|
60
|
+
text = raw.decode("utf-8", errors="replace")
|
|
61
|
+
if "text/event-stream" in (content_type or "").lower():
|
|
62
|
+
for line in text.splitlines():
|
|
63
|
+
line = line.strip()
|
|
64
|
+
if line.startswith("data:"):
|
|
65
|
+
payload = line[5:].strip()
|
|
66
|
+
if payload and payload != "[DONE]":
|
|
67
|
+
try:
|
|
68
|
+
obj = json.loads(payload)
|
|
69
|
+
if isinstance(obj, dict):
|
|
70
|
+
return obj
|
|
71
|
+
except json.JSONDecodeError:
|
|
72
|
+
continue
|
|
73
|
+
return {}
|
|
74
|
+
if not text:
|
|
75
|
+
return {}
|
|
76
|
+
try:
|
|
77
|
+
obj = json.loads(text)
|
|
78
|
+
return obj if isinstance(obj, dict) else {"result": obj}
|
|
79
|
+
except json.JSONDecodeError:
|
|
80
|
+
return {}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _unwrap(rpc):
|
|
84
|
+
"""tools/call JSON-RPC -> the inner JSON document (content[0].text), like mcporter.
|
|
85
|
+
|
|
86
|
+
Raises MCPError on a JSON-RPC error or a tool-level failure (result.isError) — silence
|
|
87
|
+
here is how a missing/renamed tool masquerades as 'no data'."""
|
|
88
|
+
if not isinstance(rpc, dict):
|
|
89
|
+
return None
|
|
90
|
+
if rpc.get("error"):
|
|
91
|
+
err = rpc["error"]
|
|
92
|
+
raise MCPError(f"JSON-RPC error {err.get('code')}: {err.get('message')}"
|
|
93
|
+
if isinstance(err, dict) else str(err))
|
|
94
|
+
result = rpc.get("result")
|
|
95
|
+
if not isinstance(result, dict):
|
|
96
|
+
return result
|
|
97
|
+
content = result.get("content")
|
|
98
|
+
text = None
|
|
99
|
+
if isinstance(content, list) and content:
|
|
100
|
+
first = content[0]
|
|
101
|
+
if isinstance(first, dict) and "text" in first:
|
|
102
|
+
text = first["text"]
|
|
103
|
+
if result.get("isError"):
|
|
104
|
+
raise MCPError(f"tool error: {str(text)[:300]}")
|
|
105
|
+
if text is not None:
|
|
106
|
+
try:
|
|
107
|
+
doc = json.loads(text)
|
|
108
|
+
except (json.JSONDecodeError, TypeError):
|
|
109
|
+
return result
|
|
110
|
+
# the senpi server reports tool failures app-level: HTTP 200 + {"success": false, "error": {...}}
|
|
111
|
+
if isinstance(doc, dict) and doc.get("success") is False:
|
|
112
|
+
err = doc.get("error") or {}
|
|
113
|
+
raise MCPError(f"tool failed: {err.get('code')}: {err.get('message')}"
|
|
114
|
+
if isinstance(err, dict) else f"tool failed: {err}")
|
|
115
|
+
return doc
|
|
116
|
+
return result
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class MCPClient:
|
|
120
|
+
"""Lazy `initialize` handshake (streamable-http), then `tools/call`. One per process."""
|
|
121
|
+
|
|
122
|
+
def __init__(self, url=None, token=None):
|
|
123
|
+
self.url = url or MCP_URL
|
|
124
|
+
self.token = token if token is not None else AUTH
|
|
125
|
+
self._sid = None
|
|
126
|
+
self._initialized = False
|
|
127
|
+
self._id = itertools.count(1)
|
|
128
|
+
|
|
129
|
+
def _headers(self, sid=None):
|
|
130
|
+
h = {"Authorization": f"Bearer {self.token}"} if self.token else {}
|
|
131
|
+
sid = sid or self._sid
|
|
132
|
+
if sid:
|
|
133
|
+
h["Mcp-Session-Id"] = sid
|
|
134
|
+
return h
|
|
135
|
+
|
|
136
|
+
def _initialize(self, timeout):
|
|
137
|
+
if self._initialized:
|
|
138
|
+
return
|
|
139
|
+
body = {"jsonrpc": "2.0", "id": next(self._id), "method": "initialize",
|
|
140
|
+
"params": {"protocolVersion": _PROTOCOL, "capabilities": {},
|
|
141
|
+
"clientInfo": {"name": "quant-desk", "version": "2.0.0"}}}
|
|
142
|
+
status, _raw, _ct, sid = _post(self.url, body, self._headers(), timeout)
|
|
143
|
+
if status >= 400:
|
|
144
|
+
raise MCPError(f"initialize HTTP {status}")
|
|
145
|
+
self._sid = sid
|
|
146
|
+
# streamable-http requires notifications/initialized after init
|
|
147
|
+
note = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}
|
|
148
|
+
_post(self.url, note, self._headers(sid), timeout)
|
|
149
|
+
self._initialized = True
|
|
150
|
+
|
|
151
|
+
def mcp_call(self, tool, timeout=12, **arguments):
|
|
152
|
+
self._initialize(timeout)
|
|
153
|
+
body = {"jsonrpc": "2.0", "id": next(self._id), "method": "tools/call",
|
|
154
|
+
"params": {"name": tool, "arguments": arguments}}
|
|
155
|
+
status, raw, ct, _sid = _post(self.url, body, self._headers(), timeout)
|
|
156
|
+
if status >= 400:
|
|
157
|
+
raise MCPError(f"{tool} HTTP {status}")
|
|
158
|
+
return _unwrap(_parse(raw, ct))
|