@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,224 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""What senpi's own runtime is doing to a position — read-only, and never an overclaim.
|
|
3
|
+
|
|
4
|
+
A DSL-managed position already shows as PROTECTED because the desk reads the resting order off the
|
|
5
|
+
exchange and finds a real one. What the desk could not say is WHAT that order is: a price with no
|
|
6
|
+
context reads as a static stop when it is a floor that ratchets. This module supplies that — and
|
|
7
|
+
the two things it must never do are present the ladder as protection already in force, or trust a
|
|
8
|
+
backend row it cannot corroborate.
|
|
9
|
+
|
|
10
|
+
**How the runtime actually works** (verified in senpi-trading-runtime@main, @0xsarvesh #753):
|
|
11
|
+
|
|
12
|
+
* **Phase 1 is runtime-local.** The runtime posts its own exchange stop with
|
|
13
|
+
`editPosition({stopLoss})` (`monitor/process-one-position.ts:877`), priced at
|
|
14
|
+
`phase1.absoluteFloor` — the strategy's `max_loss_pct` (`engine/exchange-stop.ts:18`). No backend
|
|
15
|
+
ratchet is registered.
|
|
16
|
+
* **The backend row appears at the phase-2 handoff.** `addRatchetStop` is gated on
|
|
17
|
+
`next.phase === 2` (`:807`/`:824`). **A position in phase 1 normally has no `ratchet_stop_list`
|
|
18
|
+
row at all.**
|
|
19
|
+
* So `currentTierIndex: -1` does **NOT** mean "phase 1". An ACTIVE row with no tier armed may be
|
|
20
|
+
stale — `backendHasLiveSl()` (`:1052`) exists precisely because a row can name an exchange SL
|
|
21
|
+
that is no longer resting, and a ratchet can stay ACTIVE after its stop filled and the strategy
|
|
22
|
+
re-entered. It can also be a direct `ratchet_stop_add` on a raw position, which is profit-lock
|
|
23
|
+
only. **"Stale" is an inference from the runtime source; neither of us has confirmed it in
|
|
24
|
+
telemetry** — which is the point, and why the desk corroborates instead of deciding.
|
|
25
|
+
|
|
26
|
+
An earlier draft of this module read 7,778 telemetry rows of `ACTIVE / currentTierIndex: -1 /
|
|
27
|
+
activeSLOrderId: null` as "phase 1, stop posted at entry". The conclusion it drew (the positions
|
|
28
|
+
are protected) was right — the public order book showed full-size reduce-only triggers on all
|
|
29
|
+
three spot-checked wallets — but the mechanism was wrong, and a wrong mechanism on this screen is
|
|
30
|
+
how you render "tier 2 armed, floor X" for a position the row no longer belongs to.
|
|
31
|
+
|
|
32
|
+
**So this module renders a tier only when the exchange agrees with the row.** Corroboration is
|
|
33
|
+
either the row's `activeSLOrderId` matching an order genuinely resting in the book the desk already
|
|
34
|
+
read, or its `tierFloorPrice` sitting where a resting stop actually sits. Uncorroborated, it says
|
|
35
|
+
nothing at all — which leaves the protection audit reading exactly as it does without this module.
|
|
36
|
+
|
|
37
|
+
tiers: [{triggerRoe: 8, lockRoe: 35}, ...] currentTierIndex: 0 tierFloorPrice: 3.1134
|
|
38
|
+
|
|
39
|
+
`lockRoe` is a share of the HIGH-WATER GAIN, not an absolute:
|
|
40
|
+
`floor = entry + lock% x (high_water - entry)`. Saying "protected in all tiers" would claim tier
|
|
41
|
+
3's 88% floor when 35% is locked — on the worked example, 1.45% of entry at 5x of protection that
|
|
42
|
+
does not exist. An agent made exactly that mistake in production once: it reported "Tier 3 locked
|
|
43
|
+
17.7%" when only breakeven was locked.
|
|
44
|
+
|
|
45
|
+
Every read is `.get()`, and any failure leaves the book exactly as it was.
|
|
46
|
+
"""
|
|
47
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
48
|
+
|
|
49
|
+
# How close a row's claimed floor must sit to a stop actually resting on the venue before the desk
|
|
50
|
+
# will believe the row describes that order. Rounding is 5 significant figures (~0.01-0.02%), so
|
|
51
|
+
# 0.5% was two orders of magnitude looser than the thing it was correcting for: @0xsarvesh
|
|
52
|
+
# reproduced a stale row sitting 0.45% from an unrelated stop, passing, and having its floor
|
|
53
|
+
# rendered — 0.5% of price at 5x is 2.5% of ROE. Tolerance is for rounding, nothing else.
|
|
54
|
+
FLOOR_TOL = 0.0005
|
|
55
|
+
# Entry is what actually distinguishes a row written for THIS position from one left behind by an
|
|
56
|
+
# earlier entry on the same coin — two stops can sit a tick apart for unrelated reasons, two entries
|
|
57
|
+
# rarely do. Required only on the weak path: an `activeSLOrderId` that names an order resting right
|
|
58
|
+
# now is conclusive on its own, and demanding entry as well would drop the annotation every time a
|
|
59
|
+
# reader adds to a position and moves their average.
|
|
60
|
+
ENTRY_TOL = 0.001
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _rows(resp, key):
|
|
64
|
+
d = (resp or {}).get("data") or {}
|
|
65
|
+
v = d.get(key)
|
|
66
|
+
return v if isinstance(v, list) else []
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _f(x):
|
|
70
|
+
try:
|
|
71
|
+
return float(x)
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def strategies_for(mcp, addr):
|
|
77
|
+
"""Every senpi strategy whose wallet IS this address. Empty for an external wallet."""
|
|
78
|
+
if mcp is None or not addr:
|
|
79
|
+
return []
|
|
80
|
+
try:
|
|
81
|
+
# filtered server-side rather than fetching every strategy the user has ever had
|
|
82
|
+
resp = mcp.mcp_call("strategy_list", strategyAddresses=[addr], timeout=20)
|
|
83
|
+
except Exception: # noqa: BLE001
|
|
84
|
+
return []
|
|
85
|
+
a = addr.lower()
|
|
86
|
+
return [s for s in _rows(resp, "strategies")
|
|
87
|
+
if str(s.get("strategyWalletAddress") or "").lower() == a]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _near(a, b, tol):
|
|
91
|
+
return bool(a and b and abs(a - b) <= abs(b) * tol)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def corroborated(row, p):
|
|
95
|
+
"""Does the exchange agree with this backend row, for THIS position?
|
|
96
|
+
|
|
97
|
+
Returns False when there is no resting stop at all, which is the important one: a position the
|
|
98
|
+
table calls UNPROTECTED must never carry a sentence claiming a floor.
|
|
99
|
+
|
|
100
|
+
Two paths, and they are not equally strong:
|
|
101
|
+
|
|
102
|
+
* **The row names an order resting right now.** Conclusive — nothing else is needed. Note the
|
|
103
|
+
ids are compared AS STRINGS: `activeSLOrderId` comes off the wire as a string
|
|
104
|
+
(`"413893122747"`) while Hyperliquid's `oid` is an int, so a direct `in` test is always False
|
|
105
|
+
and every row silently fell through to the weak path below. It passed its test only because
|
|
106
|
+
the fixture I wrote used an int on both sides. (@0xsarvesh, #753, second pass — reproduced
|
|
107
|
+
against the wire data rather than the source.)
|
|
108
|
+
* **The floor sits on a resting stop.** Weak: two stops can land a tick apart for unrelated
|
|
109
|
+
reasons. So this path also requires the row's ENTRY to match the live position's, which is
|
|
110
|
+
what actually rules out a row left behind by an earlier entry on the same coin.
|
|
111
|
+
|
|
112
|
+
Either way the side must match when the row states one — a row from the opposite direction
|
|
113
|
+
renders a floor on the wrong side of the mark.
|
|
114
|
+
"""
|
|
115
|
+
if not p.get("stop_covered_share"):
|
|
116
|
+
return False
|
|
117
|
+
side = str(row.get("direction") or "").upper()
|
|
118
|
+
if side in ("LONG", "SHORT") and side != p["side"]:
|
|
119
|
+
return False
|
|
120
|
+
oid = row.get("activeSLOrderId")
|
|
121
|
+
if oid is not None and str(oid) in {str(o) for o in (p.get("stop_oids") or [])}:
|
|
122
|
+
return True
|
|
123
|
+
if not _near(_f(row.get("entryPrice")), _f(p.get("entry")), ENTRY_TOL):
|
|
124
|
+
return False
|
|
125
|
+
return _near(_f(row.get("tierFloorPrice")), _f(p.get("stop_px")), FLOOR_TOL)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def attach(mcp, addr, book, meta=None, strats=None):
|
|
129
|
+
"""Annotate each open position with the DSL state senpi holds for it, where the exchange agrees.
|
|
130
|
+
|
|
131
|
+
Adds `position["dsl"]` only where there is a corroborated, armed tier. Silent no-op for an
|
|
132
|
+
external wallet, a missing token, or any failure — the protection audit must read exactly as it
|
|
133
|
+
does today rather than lose a section because a side read went down.
|
|
134
|
+
|
|
135
|
+
`strats` is `strategies_for(mcp, addr)` when the caller already has it (desk.analyze reads it
|
|
136
|
+
once and reuses it for the Set stop loss gate), so the lookup is not made twice.
|
|
137
|
+
"""
|
|
138
|
+
if strats is None:
|
|
139
|
+
strats = strategies_for(mcp, addr)
|
|
140
|
+
if not strats:
|
|
141
|
+
return 0
|
|
142
|
+
if meta is not None:
|
|
143
|
+
meta.setdefault("sources", {})["dsl"] = (
|
|
144
|
+
f"senpi runtime — {len(strats)} strateg{'y' if len(strats) == 1 else 'ies'} on this wallet")
|
|
145
|
+
# ONE call per strategy: `asset` is optional on ratchet_stop_list, so asking per position made
|
|
146
|
+
# it N calls for one answer. (@0xsarvesh, #753.)
|
|
147
|
+
by_coin, skipped = {}, 0
|
|
148
|
+
for s in strats:
|
|
149
|
+
sid = s.get("id")
|
|
150
|
+
if not sid:
|
|
151
|
+
continue
|
|
152
|
+
try:
|
|
153
|
+
resp = mcp.mcp_call("ratchet_stop_list", strategyId=sid,
|
|
154
|
+
strategy_wallet_address=addr, status="ACTIVE", limit=500, timeout=20)
|
|
155
|
+
except Exception: # noqa: BLE001
|
|
156
|
+
continue
|
|
157
|
+
for r in _rows(resp, "positions"):
|
|
158
|
+
if r.get("status") != "ACTIVE" or not r.get("asset"):
|
|
159
|
+
continue
|
|
160
|
+
# A HIP-3 row may carry `asset: "GOLD"` with `dex: "xyz"` where the book's coin is
|
|
161
|
+
# always "xyz:GOLD". Index both spellings rather than assume which one arrives.
|
|
162
|
+
# (@0xsarvesh, #753 — unconfirmed from the captured data, so handle both.)
|
|
163
|
+
by_coin.setdefault(r["asset"], (r, s))
|
|
164
|
+
dex = r.get("dex")
|
|
165
|
+
if dex and ":" not in str(r["asset"]):
|
|
166
|
+
by_coin.setdefault(f"{dex}:{r['asset']}", (r, s))
|
|
167
|
+
found = 0
|
|
168
|
+
for p in book.get("positions") or []:
|
|
169
|
+
hit = by_coin.get(p["coin"])
|
|
170
|
+
if not hit:
|
|
171
|
+
continue
|
|
172
|
+
r, s = hit
|
|
173
|
+
idx = r.get("currentTierIndex")
|
|
174
|
+
idx = -1 if idx is None else int(idx)
|
|
175
|
+
tiers = (((r.get("dslConfig") or {}).get("tiered") or {}).get("tiers")) or []
|
|
176
|
+
# An unarmed row describes no protection in force, and we cannot tell a genuinely unarmed
|
|
177
|
+
# ratchet from a stale one. Either way there is nothing true to say, so say nothing.
|
|
178
|
+
if idx < 0 or idx >= len(tiers):
|
|
179
|
+
continue
|
|
180
|
+
if not corroborated(r, p):
|
|
181
|
+
skipped += 1
|
|
182
|
+
continue
|
|
183
|
+
p["dsl"] = dict(
|
|
184
|
+
strategy=s.get("strategyName"), tiers=tiers, tier_index=idx, n_tiers=len(tiers),
|
|
185
|
+
floor_px=r.get("tierFloorPrice"), high_water_px=r.get("highWaterPrice"),
|
|
186
|
+
high_water_roe=r.get("highWaterRoe"), armed=tiers[idx],
|
|
187
|
+
stop_px=p.get("stop_px"),
|
|
188
|
+
next_tier=tiers[idx + 1] if idx + 1 < len(tiers) else None,
|
|
189
|
+
)
|
|
190
|
+
found += 1
|
|
191
|
+
if skipped and meta is not None:
|
|
192
|
+
meta.setdefault("warnings", []).append(
|
|
193
|
+
f"{skipped} senpi ratchet row(s) did not match an order resting on the venue — not shown")
|
|
194
|
+
return found
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def line(p):
|
|
198
|
+
"""One sentence for a DSL-managed position. States what is LOCKED, then what is merely ahead.
|
|
199
|
+
|
|
200
|
+
The distinction is the whole point: an armed tier is protection, an unarmed one is a rule that
|
|
201
|
+
has not fired. Never collapse them into "protected in all tiers"."""
|
|
202
|
+
d = p.get("dsl")
|
|
203
|
+
if not d:
|
|
204
|
+
return None
|
|
205
|
+
a, nxt = d.get("armed") or {}, d.get("next_tier")
|
|
206
|
+
# The price quoted is the one RESTING ON THE VENUE, not the row's `tierFloorPrice`. I claimed
|
|
207
|
+
# the sentence was self-verifying — "we only ever quote a price an order really rests at" — and
|
|
208
|
+
# it was not: it quoted the row. Corroboration proves the two agree; printing the venue's number
|
|
209
|
+
# makes that true by construction rather than by argument. (@0xsarvesh, #753, second pass.)
|
|
210
|
+
floor = d.get("stop_px") if d.get("stop_px") is not None else d.get("floor_px")
|
|
211
|
+
# .4g turns 3.1134 into 3.113. This is a price the reader may place by hand —
|
|
212
|
+
# keep the venue's own precision.
|
|
213
|
+
floor_s = f"{float(floor):,.6g}" if floor is not None else "its floor"
|
|
214
|
+
if nxt:
|
|
215
|
+
ahead = (f" Next: +{float(nxt.get('triggerRoe') or 0):.0f}% ROE locks "
|
|
216
|
+
f"{float(nxt.get('lockRoe') or 0):.0f}%.")
|
|
217
|
+
else:
|
|
218
|
+
ahead = " That is the last tier."
|
|
219
|
+
# "rises" is only true of a long. A short's floor ratchets DOWNWARD toward the entry — same
|
|
220
|
+
# mechanism, opposite direction, and a short reader told their floor "only ever rises" is being
|
|
221
|
+
# told their stop moves away from them. (@0xsarvesh, #753.)
|
|
222
|
+
return (f"**DSL tier {d['tier_index'] + 1} of {d['n_tiers']}** armed — floor {floor_s}, "
|
|
223
|
+
f"locking {float(a.get('lockRoe') or 0):.0f}% of the gain from entry.{ahead}"
|
|
224
|
+
" The floor only ever tightens.")
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""The bank of ten follow-ups the quant is prepared to go into, and which three to five to offer after a
|
|
3
|
+
desk. Each maps to a `--deep <mode>`; the desk holds these back on purpose — the more the trader asks, the
|
|
4
|
+
more of their own book they see."""
|
|
5
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
6
|
+
# Two of these answer from what is ALREADY on screen and carry no `--deep` mode: the desk output
|
|
7
|
+
# holds the numbers, so the quant expands them in conversation rather than re-running anything.
|
|
8
|
+
# `eli5` is named ELI5 on purpose: traders know the term, and it signals "ask me anything" better
|
|
9
|
+
# than "plain English" does. It leads because the reader most likely to bounce is the one who did
|
|
10
|
+
# not follow the vocabulary,
|
|
11
|
+
# and it is the cheapest possible next step — no wallet, no signup, no wait.
|
|
12
|
+
MODELESS = {"eli5", "leak"}
|
|
13
|
+
|
|
14
|
+
BANK = {
|
|
15
|
+
"eli5": "Want the ELI5 — what this means and what to do about it, without the trading vocabulary?",
|
|
16
|
+
"leak": "Want me to walk through the biggest one — {leak} — and what it would take to stop it?",
|
|
17
|
+
"protect": "Want me to draft the stop ladder for each open position — hard floor, trailing lock, and what each one changes about your worst case?",
|
|
18
|
+
"smart": "Want the full smart-money picture — every coin you trade against the proven cohort and the hot 30-day cohort, and when they moved?",
|
|
19
|
+
"scout": "Want me to scout today's market for setups that match how you actually win?",
|
|
20
|
+
"replay": "Want me to replay your worst week and show what a time-cut and a trailing lock would have done, trade by trade?",
|
|
21
|
+
"funding": "Want your funding bill for the next 30 days at today's rates, position by position?",
|
|
22
|
+
"regime": "Want to see how you trade in risk-off tape versus risk-on — and which one today is?",
|
|
23
|
+
"compare": "Want your last 30 days against the 60 before — are you getting better or worse at the things that cost you?",
|
|
24
|
+
"rules": "Want your strategy written up as a rule set your quant can run for you, under your name?",
|
|
25
|
+
"strategy": "Want the long version of what you've been doing — position by position, and where the thesis holds and breaks?",
|
|
26
|
+
"watch": "Want me to keep watching this book — a missing stop, a whale flipping against you, a regime change — and tell you the moment it happens?",
|
|
27
|
+
}
|
|
28
|
+
ORDER = ["eli5", "leak", "protect", "smart", "scout", "replay", "regime", "funding", "compare", "rules", "strategy", "watch"]
|
|
29
|
+
|
|
30
|
+
# the same ten modes, asked about SOMEONE ELSE's book — learning from a trader, not fixing your own
|
|
31
|
+
BANK_OTHER = {
|
|
32
|
+
"eli5": "Want the ELI5 — what this trader does well, what they do badly, without the vocabulary?",
|
|
33
|
+
"leak": "Want me to walk through their biggest one — {leak} — and what it would take to stop it?",
|
|
34
|
+
"rules": "Want their playbook written up as a rule set — the setups, the holds, the sizing — that your quant could run under your name?",
|
|
35
|
+
"smart": "Want the full smart-money picture on their coins — where the proven cohort and the hot 30-day cohort agree and disagree with them, and when they moved?",
|
|
36
|
+
"strategy": "Want the long version of what they've been doing — position by position, and where the thesis holds and breaks?",
|
|
37
|
+
"scout": "Want me to scout today's market for setups that match how they win?",
|
|
38
|
+
"regime": "Want to see how they trade risk-off tape versus risk-on — and whether today is their kind of day?",
|
|
39
|
+
"replay": "Want me to replay their worst week and show what a time-cut and a trailing lock would have done to it?",
|
|
40
|
+
"compare": "Want their last 30 days against the 60 before — are they getting better or worse?",
|
|
41
|
+
"protect": "Want the stop ladder their book is missing — what each open position would look like protected?",
|
|
42
|
+
"funding": "Want their funding bill for the next 30 days at today's rates — is the carry paying them or costing them?",
|
|
43
|
+
"watch": "Want me to keep watching this wallet — a new position, a flip, a size change — and tell you when they move?",
|
|
44
|
+
}
|
|
45
|
+
ORDER_OTHER = ["eli5", "leak", "rules", "smart", "strategy", "scout", "regime", "replay", "compare", "protect", "funding", "watch"]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def offer(r, n=4, whose="mine"):
|
|
49
|
+
"""Pick the follow-ups this desk earned, most relevant first."""
|
|
50
|
+
if whose == "other":
|
|
51
|
+
return _offer_other(r, n)
|
|
52
|
+
book, tr, tm = r["book"], r["track"], r.get("timing") or {}
|
|
53
|
+
score = {k: 0.0 for k in BANK}
|
|
54
|
+
naked = len(book["naked"]) + len(book["partial"])
|
|
55
|
+
near = any(p["liq_distance_pct"] is not None and p["liq_distance_pct"] < 5 for p in book["positions"])
|
|
56
|
+
score["protect"] += 3 * bool(naked) + 3 * near
|
|
57
|
+
cohorts = r.get("cohorts") or []
|
|
58
|
+
against = sum(len(c.get("against") or []) for c in cohorts)
|
|
59
|
+
score["smart"] += 2 + against + (1 if any(c.get("they_hold") for c in cohorts) else 0)
|
|
60
|
+
score["scout"] += 2 + (1 if r.get("opportunities") else 0) + (1 if (r.get("setups") or {}).get("best") else 0)
|
|
61
|
+
worst = [l for l in r["leaks"] if "losers" in l["title"] or "give back" in l["title"]]
|
|
62
|
+
score["replay"] += 1 + 2 * bool(worst) + (1 if (tr.get("largest_loss") or 0) < -0.05 * max(1, book.get("account_value") or 1) else 0)
|
|
63
|
+
rp = (r.get("context") or {}).get("regime_performance") or {}
|
|
64
|
+
score["regime"] += 1 + (2 if rp.get("cells") else 0)
|
|
65
|
+
score["funding"] += 1 + (2 if (book.get("funding_per_day") or 0) < 0 else 0) + (1 if any("funding" in l["title"] for l in r["leaks"]) else 0)
|
|
66
|
+
score["compare"] += 1 + (1 if (tr.get("trades") or 0) >= 30 else 0)
|
|
67
|
+
score["rules"] += 1 + (2 if (r.get("setups") or {}).get("best") else 0)
|
|
68
|
+
score["strategy"] += 1 + (1 if (r.get("strategy") or {}).get("critique") else 0)
|
|
69
|
+
score["watch"] += 1 + (1 if naked else 0)
|
|
70
|
+
# Plain English leads for the reader who did not follow the vocabulary — UNLESS the book is in
|
|
71
|
+
# trouble. A position that is unprotected AND near liquidation outranks everything; the desk's own
|
|
72
|
+
# recommendation is "Protect first", and a follow-up list that opens with "want this explained?"
|
|
73
|
+
# over a book 38% from liquidation is the desk disagreeing with itself.
|
|
74
|
+
urgent = bool(naked) and near
|
|
75
|
+
score["eli5"] += 40 if urgent else 99
|
|
76
|
+
score["protect"] += 100 if urgent else 0
|
|
77
|
+
score["leak"] += 50 if r.get("leaks") else -99
|
|
78
|
+
ranked = sorted(BANK, key=lambda k: (-score[k], ORDER.index(k)))
|
|
79
|
+
return _fill(ranked[:n], BANK, r)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _offer_other(r, n):
|
|
83
|
+
score = {k: 0.0 for k in BANK_OTHER}
|
|
84
|
+
setups = (r.get("setups") or {}).get("best") or []
|
|
85
|
+
cohorts = r.get("cohorts") or []
|
|
86
|
+
score["rules"] += 3 + (1 if setups else 0)
|
|
87
|
+
score["smart"] += 2 + sum(len(c.get("against") or []) for c in cohorts)
|
|
88
|
+
score["strategy"] += 2 + (1 if (r.get("strategy") or {}).get("critique") else 0)
|
|
89
|
+
score["scout"] += 1 + (1 if r.get("opportunities") else 0)
|
|
90
|
+
score["regime"] += 1 + (1 if ((r.get("context") or {}).get("regime_performance") or {}).get("cells") else 0)
|
|
91
|
+
score["replay"] += 1 + (1 if any("losers" in l["title"] or "give back" in l["title"] for l in r["leaks"]) else 0)
|
|
92
|
+
score["compare"] += 1 + (1 if (r["track"].get("trades") or 0) >= 30 else 0)
|
|
93
|
+
score["protect"] += 1 + (1 if r["book"]["naked"] else 0)
|
|
94
|
+
score["funding"] += 1 + (1 if abs(r["book"].get("funding_per_day") or 0) > 0.001 * max(1, r["book"].get("account_value") or 1) else 0)
|
|
95
|
+
score["watch"] += 2
|
|
96
|
+
score["eli5"] += 99
|
|
97
|
+
score["leak"] += 50 if r.get("leaks") else -99
|
|
98
|
+
ranked = sorted(BANK_OTHER, key=lambda k: (-score[k], ORDER_OTHER.index(k)))
|
|
99
|
+
return _fill(ranked[:n], BANK_OTHER, r)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _fill(keys, bank, r):
|
|
103
|
+
"""Name the finding in the prompt. "Walk through the biggest one" is a menu item; "walk through the
|
|
104
|
+
71% of every winner's peak you give back" is a question about THEM, and that is the whole difference
|
|
105
|
+
between a reader who asks a second question and one who closes the tab."""
|
|
106
|
+
leaks = r.get("leaks") or []
|
|
107
|
+
top = (leaks[0].get("title") or "").rstrip(".") if leaks else ""
|
|
108
|
+
out = []
|
|
109
|
+
for k in keys:
|
|
110
|
+
prompt = bank[k]
|
|
111
|
+
if "{leak}" in prompt:
|
|
112
|
+
if not top:
|
|
113
|
+
continue
|
|
114
|
+
# same shape as score._lower_first: a leak title can begin with a ticker
|
|
115
|
+
prompt = prompt.replace("{leak}", (top[0].lower() + top[1:]) if (top and top[1:2].islower()) else top)
|
|
116
|
+
out.append(dict(mode=None if k in MODELESS else k, prompt=prompt))
|
|
117
|
+
return out
|