@senpi/runtime 3.0.148 → 3.0.149

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/dist/cli/guide-commands.d.ts.map +1 -1
  2. package/dist/cli/guide-commands.js +55 -0
  3. package/dist/cli/guide-commands.js.map +1 -1
  4. package/dist/cli/quant-command.d.ts +128 -0
  5. package/dist/cli/quant-command.d.ts.map +1 -0
  6. package/dist/cli/quant-command.js +345 -0
  7. package/dist/cli/quant-command.js.map +1 -0
  8. package/dist/cli/senpi-commands.d.ts +11 -0
  9. package/dist/cli/senpi-commands.d.ts.map +1 -1
  10. package/dist/cli/senpi-commands.js +49 -4
  11. package/dist/cli/senpi-commands.js.map +1 -1
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +40 -0
  14. package/dist/index.js.map +1 -1
  15. package/dist/quant/engine/quantdesk/__init__.py +19 -0
  16. package/dist/quant/engine/quantdesk/__main__.py +281 -0
  17. package/dist/quant/engine/quantdesk/_version.py +24 -0
  18. package/dist/quant/engine/quantdesk/benchmark.py +90 -0
  19. package/dist/quant/engine/quantdesk/book.py +197 -0
  20. package/dist/quant/engine/quantdesk/deep.py +197 -0
  21. package/dist/quant/engine/quantdesk/desk.py +878 -0
  22. package/dist/quant/engine/quantdesk/dsl.py +224 -0
  23. package/dist/quant/engine/quantdesk/followups.py +117 -0
  24. package/dist/quant/engine/quantdesk/hl_api.py +473 -0
  25. package/dist/quant/engine/quantdesk/market.py +290 -0
  26. package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
  27. package/dist/quant/engine/quantdesk/metrics.py +409 -0
  28. package/dist/quant/engine/quantdesk/opportunities.py +88 -0
  29. package/dist/quant/engine/quantdesk/render.py +787 -0
  30. package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
  31. package/dist/quant/engine/quantdesk/score.py +817 -0
  32. package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
  33. package/dist/quant/engine/quantdesk/smart_money.py +354 -0
  34. package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
  35. package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
  36. package/dist/quant/engine/quantdesk/timing.py +164 -0
  37. package/dist/quant/engine/quantdesk/voice.py +42 -0
  38. package/dist/quant/engine-runner.d.ts +133 -0
  39. package/dist/quant/engine-runner.d.ts.map +1 -0
  40. package/dist/quant/engine-runner.js +306 -0
  41. package/dist/quant/engine-runner.js.map +1 -0
  42. package/dist/quant/job.d.ts +125 -0
  43. package/dist/quant/job.d.ts.map +1 -0
  44. package/dist/quant/job.js +184 -0
  45. package/dist/quant/job.js.map +1 -0
  46. package/dist/quant/register.d.ts +53 -0
  47. package/dist/quant/register.d.ts.map +1 -0
  48. package/dist/quant/register.js +253 -0
  49. package/dist/quant/register.js.map +1 -0
  50. package/dist/quant/registry.d.ts +105 -0
  51. package/dist/quant/registry.d.ts.map +1 -0
  52. package/dist/quant/registry.js +248 -0
  53. package/dist/quant/registry.js.map +1 -0
  54. package/dist/quant/types.d.ts +216 -0
  55. package/dist/quant/types.d.ts.map +1 -0
  56. package/dist/quant/types.js +121 -0
  57. package/dist/quant/types.js.map +1 -0
  58. package/dist/skills-manager/manifest.js +1 -1
  59. package/dist/skills-manager/skills-manifest.json +1 -1
  60. package/dist/widgets/index.d.ts +8 -1
  61. package/dist/widgets/index.d.ts.map +1 -1
  62. package/dist/widgets/index.js +1 -0
  63. package/dist/widgets/index.js.map +1 -1
  64. package/dist/widgets/registry.d.ts +8 -0
  65. package/dist/widgets/registry.d.ts.map +1 -1
  66. package/dist/widgets/registry.js +26 -1
  67. package/dist/widgets/registry.js.map +1 -1
  68. package/dist/widgets/tool.d.ts +14 -4
  69. package/dist/widgets/tool.d.ts.map +1 -1
  70. package/dist/widgets/tool.js +36 -23
  71. package/dist/widgets/tool.js.map +1 -1
  72. package/dist/widgets/turn-cap.d.ts +33 -0
  73. package/dist/widgets/turn-cap.d.ts.map +1 -0
  74. package/dist/widgets/turn-cap.js +119 -0
  75. package/dist/widgets/turn-cap.js.map +1 -0
  76. package/dist/widgets/types.d.ts +33 -5
  77. package/dist/widgets/types.d.ts.map +1 -1
  78. package/dist/widgets/types.js +4 -3
  79. package/dist/widgets/types.js.map +1 -1
  80. package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
  81. package/dist/widgets/widgets/fund-user-wallet.js +1 -0
  82. package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
  83. package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
  84. package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
  85. package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
  86. package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
  87. package/openclaw.plugin.json +12 -2
  88. package/package.json +4 -3
@@ -0,0 +1,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