@senpi/runtime 3.0.146 → 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 +73 -16
  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,817 @@
1
+ #!/usr/bin/env python3
2
+ """The desk's judgement layer: six 0–100 dimensions with one-line explanations, the quant score, the
3
+ archetype, flag chips, the verdict, the leaks ranked by counterfactual dollars, and where the edge is.
4
+ Every number here is a transparent function of the metrics — the formulas are in references/methodology.md."""
5
+ # Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
6
+ import collections
7
+ import statistics
8
+
9
+ import timing
10
+
11
+ WEIGHTS = {"risk": 0.25, "consistency": 0.20, "timing": 0.15, "cost": 0.15, "market_fit": 0.15, "sizing": 0.10}
12
+
13
+
14
+ def clamp(x, lo=0, hi=100):
15
+ return max(lo, min(hi, x))
16
+
17
+
18
+ def _pct(x, d=0):
19
+ return f"{100 * x:.{d}f}%"
20
+
21
+
22
+ def _bp(rate):
23
+ """A fee rate in basis points. `-0.0 bp` is what `f"{x*1e4:.1f}"` prints for a rate that is zero
24
+ or a hair negative, and a book on a zero-maker-fee tier hits that every time — it read as a typo
25
+ in a sentence whose whole job is to reproduce the dollars beside it. A real rebate says so."""
26
+ bp = (rate or 0.0) * 1e4
27
+ if bp <= -0.05:
28
+ return f"{abs(bp):.1f} bp rebate"
29
+ return f"{max(0.0, bp):.1f} bp"
30
+
31
+
32
+ def _lower_first(s):
33
+ """Lower the first letter so a sentence reads on after "and …" — unless the first word is a
34
+ TICKER. `s[0].lower()` turned the market-fit line "ETH, AVAX, TAO, xyz:MU — short into an
35
+ up-trend" into "eTH, AVAX, …" on the headline, which is the most-read sentence on the page.
36
+ Second character lower-case means an ordinary capitalised word; upper means an acronym.
37
+ (Seen on a live 1.25.1 run.)"""
38
+ return (s[0].lower() + s[1:]) if (s and s[1:2].islower()) else s
39
+
40
+
41
+ def _usd(x):
42
+ return f"-${abs(x):,.0f}" if x < 0 else f"${x:,.0f}"
43
+
44
+
45
+ def _pct_cost(x):
46
+ """Cost shares under 1% keep a decimal — "0%" is a claim, "0.4%" is a measurement."""
47
+ if 0 < x < 0.01:
48
+ return f"{100 * x:.1f}%" if x >= 0.0005 else "<0.1%"
49
+ return _pct(x)
50
+
51
+
52
+ # ---------------------------------------------------------------- six dimensions
53
+ def _n(d, key, default=0.0):
54
+ """`dict.get(k, default)` only defaults when the key is ABSENT. Several timing and market fields
55
+ are present-and-NULL — a median of nothing, a share with no sample, a funding rate with no book —
56
+ so `tm.get("chased_share", 0) >= 0.5` raised TypeError on a real wallet (0x767a…0ace, one closed
57
+ trade, no entry had a 24h prior). This defaults for missing AND null."""
58
+ v = (d or {}).get(key)
59
+ return default if v is None else v
60
+
61
+
62
+ def dim_timing(tm, sm, cov=None):
63
+ s, lines = 70.0, []
64
+ if not tm or (tm.get("n") or 0) < 5:
65
+ # abstain, do not score: a dimension that measured nothing must not vote. Scoring 60 here
66
+ # propped a book with 0 closed trades to 56/100 while it sat $1.27M underwater on a naked
67
+ # position. dimensions() re-normalises over the dimensions that could measure.
68
+ return None, "Not enough fully observed trades to judge timing."
69
+ if tm and tm.get("n"):
70
+ cs = tm.get("chased_share") or 0.0
71
+ s -= cs * 40
72
+ cpf, kpf = tm.get("chased_pf"), tm.get("calm_pf")
73
+ if _n(tm, "chased_n") >= 3 and cpf is not None and kpf is not None and cpf < 1.0 < kpf:
74
+ s -= 15
75
+ lines.append((3, f"{_pct(cs)} of your entries come after a ≥3% move — those run a {cpf:.1f} profit factor vs {min(kpf, 9.9):.1f} when you enter before it."))
76
+ elif cs >= 0.5:
77
+ lines.append((2, f"{_pct(cs)} of your entries come after a ≥3% move in their direction — you buy the move, not the turn."))
78
+ gb = tm.get("give_back_median")
79
+ if gb is not None:
80
+ s -= gb * 20
81
+ if gb >= 0.4:
82
+ lines.append((2, f"You give back a median {_pct(gb)} of a winner's peak gain before you exit."))
83
+ lag = (sm or {}).get("entry_lag_h")
84
+ if lag is not None:
85
+ if lag > 0:
86
+ s -= min(20, lag * 2); lines.append((3, f"You enter ~{lag:.1f}h after the whale cohort's flow turns."))
87
+ else:
88
+ s += 10; lines.append((1, f"You enter {abs(lag):.1f}h ahead of the whale cohort on the coins you share."))
89
+ if not lines:
90
+ lines.append((0, "Entries are not systematically late or chased over this window." if tm and tm.get("n") else "Not enough complete trades to judge timing."))
91
+ return clamp(s), max(lines, key=lambda x: x[0])[1]
92
+
93
+
94
+ def dim_risk(tr, book, dd):
95
+ s, lines = 85.0, []
96
+ hr = tr.get("hold_ratio")
97
+ if hr and hr > 1.2:
98
+ s -= min(30, (hr - 1) * 15); lines.append((3 if hr >= 2 else 2, f"You hold losers {hr:.1f}× longer than winners (median {tr['hold_losers_h']:.1f}h vs {tr['hold_winners_h']:.1f}h)."))
99
+ n = len(book["positions"])
100
+ if n:
101
+ naked = len(book["naked"]); s -= 25 * naked / n
102
+ if naked:
103
+ lines.append((4, f"{naked} of {n} open positions {'has' if naked == 1 else 'have'} no stop at all — {', '.join(book['naked'])}."))
104
+ near = [p for p in book["positions"] if p["liq_distance_pct"] is not None and p["liq_distance_pct"] < 5]
105
+ if near:
106
+ s -= 15; p = min(near, key=lambda p: p["liq_distance_pct"])
107
+ lines.append((5, f"{p['coin']} {p['side'].lower()} {p['leverage']}× sits {p['liq_distance_pct']:.1f}% from liquidation."))
108
+ if tr.get("liquidations"):
109
+ s -= min(30, 10 * tr["liquidations"]); lines.append((4, f"{tr['liquidations']} liquidation(s) in 90 days cost {_usd(tr['liquidation_loss'])}."))
110
+ mu = book.get("margin_utilization")
111
+ if mu and mu > 0.6:
112
+ s -= min(20, (mu - 0.6) * 50); lines.append((2, f"Margin used is {_pct(mu)} of account value — little cushion for a bad hour."))
113
+ if dd and dd.get("dd_pct"):
114
+ # The old cap was min(20, dd_pct * 60), which flattened at 33%: a book that gave back a
115
+ # third and a book that went to ZERO were penalised identically, and a wiped-out account
116
+ # scored 65/100 on "Risk management". Drawdown here is built from cumulative P&L and is
117
+ # transfer-immune, so dd_pct = 1.0 really does mean the equity at risk was lost — the one
118
+ # outcome the dimension exists to catch.
119
+ s -= min(75, dd["dd_pct"] * 75)
120
+ if dd["dd_pct"] >= 0.9:
121
+ # dd_pct is scale-relative: giving back $5k of a $5.4k account is 93%. Saying "the
122
+ # account went to zero — a full loss of the equity at risk" about a book that ENDED the
123
+ # window up $25,008 and holds $28,420 is false, and 1.16.1 promoted this sentence into
124
+ # the headline, so it sat directly beside "Net $25,008 on the ledger".
125
+ # The penalty stands either way — they really did nearly lose it — but a drawdown that
126
+ # was recovered is a different sentence from one that was not.
127
+ if (tr.get("ledger_net") or 0) > 0:
128
+ lines.append((4, f"A {_pct(dd['dd_pct'])} drawdown inside the window — nearly the whole "
129
+ f"book at the trough — though it ended up {_usd(tr['ledger_net'])}."))
130
+ else:
131
+ lines.append((6, "The account went to zero inside the window — a full loss of the equity at risk."))
132
+ elif dd["dd_pct"] >= 0.25:
133
+ lines.append((2, f"Max drawdown {_pct(dd['dd_pct'])} of equity over the window."))
134
+ if not lines:
135
+ # "Stops in place, losers cut faster than winners" asserts three findings. On a book with no
136
+ # open positions and one closed trade there is nothing to have found — and it scored 76.
137
+ if not n and (tr.get("trades") or 0) < MIN_PATTERN_TRADES:
138
+ return None, "No open positions and too few closed trades to judge risk."
139
+ lines.append((0, "Stops in place, losers cut faster than winners, no liquidations."))
140
+ return clamp(s), max(lines, key=lambda x: x[0])[1]
141
+
142
+
143
+ def dim_cost(tr):
144
+ cr, ts = tr.get("cost_ratio"), tr.get("taker_share")
145
+ if cr is not None:
146
+ s = 100 - min(70, cr * 150)
147
+ if cr > 1:
148
+ costs = abs(tr.get("fees") or 0) + max(0.0, -(tr.get("funding") or 0))
149
+ line = f"Costs exceeded what you made: {_usd(costs)} against {_usd(tr.get('gross_income') or tr['gross_realized'])} of trade P&L{' and funding' if (tr.get('funding') or 0) > 0 else ''}."
150
+ elif (tr.get("funding") or 0) > 0:
151
+ line = f"Fees took {_pct_cost(cr)} of what you made ({_usd(tr['fees'])} on {_usd(tr['gross_realized'])} of trade P&L plus {_usd(tr['funding'])} of funding collected)."
152
+ else:
153
+ line = f"Fees + funding ate {_pct_cost(cr)} of gross P&L ({_usd(tr['fees'])} fees, {_usd(-tr['funding'])} funding on {_usd(tr['gross_realized'])} gross)."
154
+ else:
155
+ # cost_ratio is None because gross P&L was not positive. That does NOT make costs
156
+ # unmeasurable — it makes them the worst case: a book that lost money and paid to do it.
157
+ # "—" hid the clearest cost problem on the desk (a book paying $7,068 of fees while losing
158
+ # $16,969 read as a blank), and an abstaining dimension let the other five re-normalise the
159
+ # headline UP.
160
+ #
161
+ # The base is |net| — the money actually lost — NOT |gross|. Against |gross| a book that
162
+ # lost $180,996 on $7,143 of fees scores 94/100, which is not cost efficiency, just a large
163
+ # loss. Against |net| the question is the one that matters: how much of what you lost went
164
+ # to cost rather than to bad trades.
165
+ # The ledger is what the user lost, so it is the denominator they would use. Realized-only
166
+ # net puts a book whose result lives in unrealized P&L against the wrong base (#718 Q2);
167
+ # with B1 fixed, ledger_net is finally a 90-day number and safe to prefer.
168
+ net = float(tr.get("ledger_net") if tr.get("ledger_net") is not None else (tr.get("net") or 0.0))
169
+ # NOT abs(): a maker REBATE is negative fees, and abs() turned money earned into an
170
+ # equal-sized cost. 0.51% of books with 5+ trades run a net rebate, and they are exactly the
171
+ # sophisticated books worth reading correctly.
172
+ costs = max(0.0, float(tr.get("fees") or 0)) + max(0.0, -(tr.get("funding") or 0))
173
+ if abs(net) < 1 and costs < 1:
174
+ return None, "Not measurable this window: no trading result, and no costs to price against it."
175
+ drag = costs / max(abs(net), 1.0)
176
+ s = 100 - min(70, drag * 150)
177
+ # a rebate is money EARNED — say so, rather than printing it as a bill
178
+ _fee = float(tr.get("fees") or 0)
179
+ _fee_txt = f"{_usd(-_fee)} EARNED in maker rebates" if _fee < 0 else f"{_usd(_fee)} of fees"
180
+ # Above 100% the share framing stops parsing: "513% of what you lost was cost, not bad
181
+ # trades" is arithmetically true and reads as a broken number. What it actually means is
182
+ # stronger and simpler — costs are bigger than the entire loss, so the trading was ahead
183
+ # before them. The `cost_ratio > 1` branch above already says it that way; say it here too.
184
+ # (Live 1.24.1 run, 0xea66…61ee: $46,786 fees + $125,909 funding on a -$33,647 result.)
185
+ _costs_txt = _fee_txt + (f" and {_usd(-tr['funding'])} of funding" if (tr.get("funding") or 0) < 0 else "")
186
+ if drag > 1.0 and net < 0:
187
+ line = (f"Costs are bigger than the loss itself: {_costs_txt} against a {_usd(net)} net "
188
+ f"result — the trading was {_usd(costs + net)} ahead before costs.")
189
+ else:
190
+ line = (f"{_pct_cost(drag)} of what you lost was cost, not bad trades: {_fee_txt}"
191
+ + (f" and {_usd(-tr['funding'])} of funding" if (tr.get("funding") or 0) < 0 else "")
192
+ + f" against a {_usd(net)} net result"
193
+ + (f" — funding paid you {_usd(tr['funding'])}" if (tr.get("funding") or 0) > 0 else "") + ".")
194
+ if ts is not None and ts > 0.6:
195
+ s -= 10
196
+ line += f" {_pct(ts)} of your volume crossed the spread as a taker."
197
+ return clamp(s), line
198
+
199
+
200
+ def dim_sizing(tr, book, closed):
201
+ s, lines = 85.0, []
202
+ cv = tr.get("size_cv")
203
+ if cv:
204
+ s -= min(30, cv * 20)
205
+ if cv >= 0.6:
206
+ lines.append((2, f"Position size varies {tr['size_max_over_median']:.1f}× trade-to-trade (largest vs median)."))
207
+ comp = [e for e in closed if not e.get("truncated") and e["peak_notional"] > 0]
208
+ lw = [e["peak_notional"] for e in comp if e["win"]]; ll = [e["peak_notional"] for e in comp if not e["win"]]
209
+ if len(lw) >= 3 and len(ll) >= 3:
210
+ r = statistics.median(ll) / statistics.median(lw)
211
+ if r > 1.3:
212
+ s -= 15; lines.append((3, f"Your losers are {r:.1f}× the size of your winners — conviction lands on the wrong trades."))
213
+ elif r < 0.8:
214
+ lines.append((1, f"Your winners are {1 / r:.1f}× the size of your losers — sizing is doing real work."))
215
+ if comp:
216
+ med = statistics.median(e["peak_notional"] for e in comp)
217
+ big = [e for e in comp if e["peak_notional"] > 2 * med]
218
+ if len(big) >= 3 and all(e["win"] for e in big): # conviction sizing that pays is the point of this dimension
219
+ s += 10; lines.append((1, f"Your biggest positions are your best — {len(big)} of {len(big)} above 2× the median size were winners."))
220
+ ex = book.get("exposure_over_equity")
221
+ if ex and ex > 5:
222
+ s -= min(20, (ex - 5) * 3); lines.append((2, f"Gross exposure is {ex:.1f}× account value right now."))
223
+ if book.get("largest_share") and book["largest_share"] > 0.6 and len(book["positions"]) > 1:
224
+ s -= 10; lines.append((1, f"One position is {_pct(book['largest_share'])} of the book."))
225
+ if not lines:
226
+ if len(comp) < MIN_PATTERN_TRADES:
227
+ # too few closed trades and nothing unusual in the live book: there is no sizing
228
+ # BEHAVIOUR to read. Claiming "sizes are consistent and exposure is proportionate"
229
+ # asserts the thing that was not measured — it scored 85 off a single trade.
230
+ return None, ("No closed trades to judge sizing on, and the open book shows nothing unusual."
231
+ if not comp else
232
+ f"Only {len(comp)} closed trade{'s' if len(comp) > 1 else ''} — too few to read sizing from.")
233
+ lines.append((0, "Sizes are consistent and exposure is proportionate."))
234
+ return clamp(s), max(lines, key=lambda x: x[0])[1]
235
+
236
+
237
+ def dim_consistency(tr, pnl_curve):
238
+ n = tr.get("trades") or 0
239
+ wr, pf = tr.get("win_rate"), tr.get("profit_factor")
240
+ if pf is None and wr is None:
241
+ return None, "No closed trades in the window — nothing to judge consistency on."
242
+ pf_ = 3.0 if pf in (None, float("inf")) else min(pf, 3.0)
243
+ s = 50 + (pf_ - 1) * 20 + ((wr - 0.5) * 40 if wr is not None else 0)
244
+ s = 50 + (s - 50) * (n / (n + 10.0)) if n else 50.0
245
+ pfs = "∞" if pf == float("inf") else (f"{pf:.1f}" if pf is not None else "n/a")
246
+ # The profit factor already had an n/a path and the win rate did not, so the one input shape the
247
+ # guard above lets through — a measurable pf with no win rate — reached `_pct(None)` and raised
248
+ # a TypeError, which is not an HLError, so desk.py prints a traceback and no desk. Same failure
249
+ # mode as the all-losing book in #733; found while fixing item 11.
250
+ line = f"Win rate {_pct(wr) if wr is not None else 'n/a'}, profit factor {pfs} across {n} trade{'s' if n != 1 else ''}"
251
+ if tr.get("payoff_ratio"):
252
+ line += f" — average winner {tr['payoff_ratio']:.1f}× the average loser"
253
+ line += "."
254
+ if n and n < 15:
255
+ line += f" Only {n} trade{'s' if n != 1 else ''}: read this loosely."
256
+ return clamp(s), line
257
+
258
+
259
+ def dim_market(book, mf):
260
+ s, lines = 65.0, []
261
+ if not mf or not book["positions"]:
262
+ # nothing is held, so there is no fit to score. Returning 60 let a flat book carry a
263
+ # measured-looking sixth of the headline on a dimension with no input at all.
264
+ return None, "No open positions to fit against the market."
265
+ s += min(25, 10 * mf["with_market"]) - min(45, 15 * mf["against"])
266
+ ag = [r for r in mf["rows"] if r["fit"].startswith("AGAINST")]
267
+ if ag:
268
+ lines.append((3, f"{', '.join(r['coin'] for r in ag)} — {ag[0]['side'].lower()} into {'an' if str(ag[0]['trend']).upper() == 'UP' else 'a'} {str(ag[0]['trend']).lower()}-trend."))
269
+ av = book.get("account_value") or 0
270
+ fpd = mf.get("funding_per_day") or 0.0
271
+ if fpd < 0 and av:
272
+ yr = -fpd * 365 / av
273
+ # capped at 20 this saturated at 40%/yr: a book paying 40% of equity a year in funding and
274
+ # one paying 240% scored identically. Same shape as the drawdown cap fixed in 1.9.1.
275
+ s -= min(45, yr * 50)
276
+ lines.append((2, f"{mf['stance'].capitalize()} into {'positive' if book['net_exposure'] > 0 else 'negative'} funding — paying ~{_usd(-fpd)}/day to hold ({_pct(yr)} of your EQUITY a year — leverage makes this bigger than the headline rate on notional)."))
277
+ elif fpd > 0:
278
+ lines.append((1, f"Your book collects ~{_usd(fpd)}/day in funding at today's rates."))
279
+ if not lines:
280
+ lines.append((0, f"{mf['with_market']} of {len(mf['rows'])} positions sit with the trend; funding is near flat."))
281
+ return clamp(s), max(lines, key=lambda x: x[0])[1]
282
+
283
+
284
+ def dimensions(tr, book, dd, tm, mf, sm, closed, pnl_curve):
285
+ d = {}
286
+ for key, (score, line) in {
287
+ "timing": dim_timing(tm, sm, tr.get("coverage")), "risk": dim_risk(tr, book, dd), "cost": dim_cost(tr), "sizing": dim_sizing(tr, book, closed),
288
+ "consistency": dim_consistency(tr, pnl_curve), "market_fit": dim_market(book, mf)}.items():
289
+ d[key] = dict(score=None if score is None else round(score), line=line)
290
+ # A dimension that could not be measured must not vote. Re-normalise over the ones that could,
291
+ # rather than scoring an unmeasured dimension and letting its default move the headline.
292
+ live = {k: w for k, w in WEIGHTS.items() if d[k]["score"] is not None}
293
+ tot = sum(live.values())
294
+ # Below half the dimensions there is no book to score. 0x31a7…7549 — one trade, net -$91 — had
295
+ # four of six abstain and still printed 66/100, re-normalised onto cost (90, off $0 of fees) and
296
+ # consistency (48, off that single trade). A confident headline from two noisy inputs is the
297
+ # same failure as a confident dimension from no input.
298
+ if len(live) < MIN_DIMENSIONS:
299
+ return d, None
300
+ quant = (sum(live[k] * d[k]["score"] for k in live) / tot) if tot > 0 else 0
301
+ return d, round(quant)
302
+
303
+
304
+ # ---------------------------------------------------------------- archetype, flags, verdict
305
+ def archetype(tr, book, tm, act, opened=None):
306
+ ex = book.get("exposure_over_equity") or 0; mu = book.get("margin_utilization") or 0
307
+ levs = [p["leverage"] for p in book["positions"] if p.get("leverage")]
308
+ adj = "Aggressive" if (ex > 5 or mu > 0.6 or (levs and max(levs) >= 10)) else ("Careful" if (ex < 2 and mu < 0.3) else "Balanced")
309
+ hold = tr.get("hold_winners_h") or 0
310
+ if tr.get("complete_trades"):
311
+ hold = statistics.median([e for e in [tr.get("hold_winners_h"), tr.get("hold_losers_h")] if e]) if (tr.get("hold_winners_h") or tr.get("hold_losers_h")) else 0
312
+ tpd = (tr.get("trades") or 0) / max(1, act.get("active_days") or 1)
313
+ cs = (tm or {}).get("chased_share") or 0; pre = (tm or {}).get("pre24_median")
314
+ if cs >= 0.5:
315
+ noun = "momentum chaser"
316
+ elif hold and hold < 2 and tpd >= 3:
317
+ noun = "scalper"
318
+ elif pre is not None and pre < -0.02:
319
+ noun = "dip buyer" if (tr.get("long_share") or 0) >= 0.7 else "fader"
320
+ elif hold and hold > 120:
321
+ noun = "position trader"
322
+ elif hold and hold > 24:
323
+ noun = "swing trader"
324
+ elif pre is not None and pre > 0 and _n(tm, "give_back_median", 1) < 0.45:
325
+ noun = "trend rider"
326
+ else:
327
+ noun = "opportunist"
328
+ if (tr.get("trades") or 0) < 5:
329
+ n_t = tr.get("trades") or 0; fills = act.get("fills") or 0
330
+ noun = (f"thin record ({n_t} closed trade{'s' if n_t != 1 else ''}, {fills:,} fills)" if fills >= 500 # a whale with one round trip is not "early days"
331
+ else f"early days ({n_t} closed trade{'s' if n_t != 1 else ''})")
332
+ bias = ""
333
+ n_closed = tr.get("trades") or 0; n_open = len(book["positions"])
334
+ longs = (tr.get("long_share") or 0) * n_closed + sum(1 for p in book["positions"] if p["side"] == "LONG")
335
+ if n_closed + n_open >= 5:
336
+ ls = longs / (n_closed + n_open)
337
+ bias = "long-only " if ls >= 0.9 else ("short-only " if ls <= 0.1 else "")
338
+ if noun.startswith(("thin record", "early days")):
339
+ return f"{adj} book · {bias}{noun}"
340
+ return f"{adj} {bias}{noun}"
341
+
342
+
343
+ def flags(tr, book, dd, tm, mf, labels):
344
+ out = []
345
+ n = len(book["positions"])
346
+ if n and book["naked"]:
347
+ out.append(f"NO STOPS ({len(book['naked'])}/{n})")
348
+ elif n and book["partial"]:
349
+ out.append(f"PARTIAL STOPS ({len(book['partial'])}/{n})")
350
+ near = [p for p in book["positions"] if p["liq_distance_pct"] is not None and p["liq_distance_pct"] < 5]
351
+ if near:
352
+ out.append(f"NEAR LIQUIDATION {min(p['liq_distance_pct'] for p in near):.1f}%")
353
+ mu = book.get("margin_utilization")
354
+ if mu and mu >= 0.6:
355
+ out.append(f"HIGH MARGIN {_pct(mu)}")
356
+ if dd and dd.get("in_drawdown"):
357
+ out.append("IN DRAWDOWN")
358
+ if tr.get("liquidations"):
359
+ out.append(f"LIQUIDATED ×{tr['liquidations']}")
360
+ if _n(tm, "chased_share") >= 0.5:
361
+ out.append("CHASING")
362
+ if mf and _n(mf, "funding_per_day") < 0 and book.get("account_value") and -mf["funding_per_day"] * 365 / book["account_value"] > 0.1:
363
+ out.append(f"PAYING FUNDING {_usd(-mf['funding_per_day'])}/DAY")
364
+ for k in ("consistency", "risk", "activity"):
365
+ v = (labels or {}).get(k)
366
+ if v and v.upper() in ("CHOPPY", "STREAKY", "SNIPER", "DEGEN"):
367
+ out.append(v.upper())
368
+ return out
369
+
370
+
371
+ MIN_VERDICT_TRADES = 5 # below this, cost / timing / consistency cannot carry the headline
372
+ MIN_DIMENSIONS = 3 # fewer measurable dimensions than this and there is no book to score
373
+ NOISE_SHARE = 0.15 # a lever keeping less than this share of what it saves is a coin flip
374
+ MIN_PATTERN_TRADES = 5 # a hold-time or give-back leak is a pattern claim: it needs a sample
375
+
376
+
377
+ def verdict(tr, book, dims, leaks):
378
+ pf, pr = tr.get("profit_factor"), tr.get("payoff_ratio")
379
+ n = tr.get("trades") or 0
380
+ negative = False # a negative strength joins its weakness with "and", not "but"
381
+ if pf and pf != float("inf") and pf >= 1.5 and n >= 10:
382
+ strength = f"Real edge — profit factor {pf:.1f} on {n} trades"
383
+ elif pr and pr >= 2 and n >= 10:
384
+ strength = f"You let winners run ({pr:.1f}× payoff)"
385
+ elif tr.get("win_rate") and tr["win_rate"] >= 0.55 and n >= 10:
386
+ strength = f"You pick well — {_pct(tr['win_rate'])} win rate"
387
+ elif (tr.get("ledger_net") is not None and tr["ledger_net"] > 0 and abs(tr["ledger_net"]) > 2 * abs(tr.get("net") or 0)):
388
+ strength = f"Net {_usd(tr['ledger_net'])} on the ledger over the window (open book and funding included)"
389
+ elif tr.get("ledger_net") is not None and tr["ledger_net"] < 0:
390
+ strength = f"Down {_usd(-tr['ledger_net'])} on the ledger over the window (open book and funding included)"; negative = True
391
+ elif (tr.get("net") or 0) > 0:
392
+ strength = f"Net positive ({_usd(tr['net'])} realized over the window)"
393
+ else:
394
+ strength = "No edge shows up in this window"; negative = True
395
+ # the weakest dimension carries the headline only when it is material: enough trades behind it, and costs
396
+ # that are a real share of the result — $2 of fees on a $222 window is not a leak
397
+ costs = abs(tr.get("fees") or 0) + max(0.0, -(tr.get("funding") or 0))
398
+ base = abs(tr["ledger_net"] if tr.get("ledger_net") is not None else (tr.get("net") or 0))
399
+
400
+ def material(k):
401
+ # A dimension that could not be measured has no score to rank and cannot be "the weakest" —
402
+ # nothing measured it. This guard has to come FIRST: sorted() runs before the filter below,
403
+ # so a None reaching the key function raises TypeError and takes the whole desk down before
404
+ # it renders a line. That shipped in 1.4.3 and crashed every run.
405
+ if dims[k]["score"] is None:
406
+ return False
407
+ if k in ("cost", "timing", "consistency") and n < MIN_VERDICT_TRADES:
408
+ return False
409
+ if k == "cost" and base and costs < 0.05 * base:
410
+ return False
411
+ return True
412
+ ranked = sorted([k for k in dims if material(k)], key=lambda k: dims[k]["score"])
413
+ weakest = ranked[0] if ranked else None
414
+ weak_line = imperative = None
415
+ if weakest is not None and dims[weakest]["score"] < 60:
416
+ # Use the dimension's OWN sentence. Keying a fixed phrase off the dimension NAME asserted a
417
+ # reason the dimension had just denied: "your entries are late or chased" printed directly
418
+ # above "Entries are not systematically late or chased over this window." Timing scores low
419
+ # for give-back too, and the headline was naming the wrong half of it.
420
+ _own = (dims[weakest].get("line") or "").strip().rstrip(".")
421
+ weak_line = _lower_first(_own) if _own else {
422
+ "risk": "you're carrying unprotected risk" if book["naked"] else "the risk side is where it leaks",
423
+ "cost": "execution and funding are eating the gains" if (tr.get("funding") or 0) < 0 else "execution is eating the gains",
424
+ "timing": "the timing side is where it leaks", "sizing": "sizing is working against you",
425
+ "consistency": "the results are not repeatable yet",
426
+ "market_fit": "the book is fighting the market it sits in"}[weakest]
427
+ imperative = {"risk": "Fix the risk first.", "cost": "Cut the costs first.", "timing": "Fix the timing first.", "sizing": "Fix the sizing first.",
428
+ "consistency": "Build the sample before scaling.", "market_fit": "Get on the right side of the regime first."}[weakest]
429
+ near = [p for p in book["positions"] if p["liq_distance_pct"] is not None and p["liq_distance_pct"] < 5]
430
+ if near:
431
+ p = min(near, key=lambda p: p["liq_distance_pct"])
432
+ weak_line = f"{p['coin']} sits {p['liq_distance_pct']:.1f}% from liquidation with {'no' if p['stop_covered_share'] == 0 else 'a partial'} stop"
433
+ imperative = "Protect that position today."
434
+ av = book.get("account_value") or 0
435
+ if av and (book.get("unrealized") or 0) < -0.2 * av:
436
+ weak_line = f"the open book is {_usd(-book['unrealized'])} under water ({_pct(-book['unrealized'] / av)} of equity) with {len(book['naked'])} of {len(book['positions'])} positions unprotected"
437
+ imperative = "Decide the exits before the market does."
438
+ if weak_line:
439
+ return f"{strength} — {'and' if negative else 'but'} {weak_line}. {imperative}"
440
+ if n < MIN_VERDICT_TRADES:
441
+ # "the live book is where the desk earns its keep" only holds if there IS a live book. On
442
+ # 0x31a7…7549 it sat above a risk line reading "No open positions".
443
+ tail = ("the live book is where the desk earns its keep today."
444
+ if (book or {}).get("positions") else
445
+ "and with nothing open, there is nothing for the desk to protect right now.")
446
+ return f"{strength} — only {n} closed trade{'s' if n != 1 else ''} in the window, so the record is too thin to grade; {tail}"
447
+ return f"{strength} — nothing in the record is leaking badly; the gains are in the details below."
448
+
449
+
450
+ # ---------------------------------------------------------------- leaks
451
+ def levers(rows, closed, tm=None, funding_late=0.0):
452
+ """Every process fix the desk can price, each as a book-wide total that NETS its own costs.
453
+
454
+ This is the single source both `leaks()` and `recoverable()` read. They used to compute the same
455
+ fixes separately and disagreed on the page: a trailing lock was listed at $6,083 (the most
456
+ conservative setting) while the quotable headline credited the same rule with $9,099 (the best
457
+ setting, charged), and the sizing leak was listed at $53,792 against a charged value of $8,182 —
458
+ so the desk named a "biggest leak" that was not the biggest lever once both were measured the
459
+ same way. Reported by @shnoodles on #718.
460
+
461
+ Each entry: kind, label (a finished sentence fragment), total (charged), gross (before the trades
462
+ it costs), vals (per-trade contributions, for concentration).
463
+ """
464
+ tm, rows, out = tm or {}, rows or [], []
465
+
466
+ grid = [("lock", f"{a:.2f}/{sh:.1f}", f"a trailing stop that arms at +{a:.0%} and keeps {sh:.0%} of the peak")
467
+ for a, sh in timing.LOCK_GRID]
468
+ grid += [("cut", f"{h:.0f}", f"closing anything still open after {h:.0f}h") for h in timing.CUT_GRID_H]
469
+ for grp, k, label in grid:
470
+ st = (((tm.get(grp) or {}).get("settings")) or {}).get(k) or {}
471
+ # a lever is a pattern claim and needs the same sample the leaks require. Without this the
472
+ # quotable headline was built from whatever setting scored highest, however few trades it
473
+ # engaged on: on 0xb699…392e the winner was "closing anything still open after 48h" with
474
+ # n=2, and it put $1.87M in front of a reader who lost $230,596.
475
+ if (st.get("n") or 0) < MIN_PATTERN_TRADES:
476
+ continue
477
+ vals = [float(v) for v in ((t.get(f"{grp}_cf") or {}).get(k) for t in rows) if v is not None]
478
+ total, gross = float(st.get("total") or 0.0), sum(v for v in vals if v > 0)
479
+ # a rule that hands back almost everything it saves is a coin flip, not an edge. On
480
+ # 0xccd2…c8a3 a time-cut netted $165 out of $5,701 saved — 3% — and was listed as a fix.
481
+ # It used to `continue` here, which removed the setting from the family ENTIRELY — so
482
+ # best_lever's median ran over a set the noise gate had already filtered BY SCORE, and a
483
+ # family with one survivor quoted that survivor as its "median". Selection has to happen
484
+ # over every adequately-sampled setting; the noise gate then applies to whatever selection
485
+ # returns. (@danielmbirochi, #718, item 8.)
486
+ out.append(dict(kind=grp, key=k, label=label, total=total, gross=gross,
487
+ vals=vals, n=int(st["n"]), noisy=bool(gross > 0 and total < NOISE_SHARE * gross)))
488
+
489
+ m = None
490
+ winners = [e["peak_size"] * e["entry_vwap"] for e in (closed or [])
491
+ if e.get("win") and not e.get("truncated") and (e.get("peak_size") or 0) > 0
492
+ and (e.get("entry_vwap") or 0) > 0]
493
+ if len(winners) >= 3:
494
+ m = statistics.median(winners)
495
+ if m:
496
+ over = [t for t in rows if (t.get("notional") or 0) > 1.5 * m]
497
+ vals = [-t["realized"] * (1 - m / t["notional"]) for t in over]
498
+ if len(vals) >= MIN_PATTERN_TRADES:
499
+ _tot, _gr = sum(vals), sum(v for v in vals if v > 0)
500
+ out.append(dict(kind="size", key="1.5x", label="capping size at your median winner",
501
+ total=_tot, gross=_gr, vals=vals,
502
+ n=len(over), median_winner=m, noisy=bool(_gr > 0 and _tot < NOISE_SHARE * _gr),
503
+ losers=len([t for t in over if not t.get("win")])))
504
+
505
+ # Funding belongs here, competing with the exits rather than sitting outside them. It was left
506
+ # out because capping a funding-paying hold IS the time-cut — but the union only ever credits
507
+ # ONE lever, so there was never a double-count to prevent, and leaving it out made the headline
508
+ # SMALLER than a leak listed under it: 0x767a…0ace quoted $99,228 above a $114,566 funding leak.
509
+ # No sample gate: funding paid is a measured cost, like fees, not a pattern estimated from a
510
+ # handful of trades.
511
+ _cut24_s = (((tm.get("cut") or {}).get("settings")) or {}).get("24") or {}
512
+ if funding_late > 50 and (_cut24_s.get("n") or 0) >= MIN_PATTERN_TRADES:
513
+ # A 24h cap does not only stop the funding bill — it CLOSES the position, and the P&L
514
+ # consequence of closing at 24h is exactly the 24h time-cut. Crediting the funding saved and
515
+ # charging nothing for the exits it forces was the fourth survivorship bug (@0xsarvesh #718);
516
+ # the first three were fixed in #712.
517
+ # CHARGE only, never credit. A 24h cap forces exits, and if those exits cost money the
518
+ # funding saving has to carry it — that was the survivorship gap. But when they GAIN, that
519
+ # gain is the time-cut lever's, and adding it here summed two levers, which is the exact
520
+ # double-count the union exists to prevent. It made a $122,440 funding bill read as a
521
+ # $4,083,959 saving.
522
+ # The gate above guarantees a sample; without one the credit used to ship uncharged, which
523
+ # is the fourth survivorship bug wearing a different hat. (@danielmbirochi, #718, item 9.)
524
+ _charge = min(0.0, float(_cut24_s.get("total") or 0.0))
525
+ # The lever is legitimately worth funding + the P&L of the exits it forces. The LEAK beside
526
+ # it is titled "you paid $X in funding", so quoting the combined figure there read as saving
527
+ # 6x the bill ($502,094 against $80,256). Carry the two parts separately and let the leak
528
+ # say which is which.
529
+ out.append(dict(kind="funding", key="24h", label="capping holds that pay funding at 24h",
530
+ total=float(funding_late) + _charge, gross=float(funding_late),
531
+ funding_saved=float(funding_late), exit_effect=_charge, vals=[],
532
+ n=len(rows)))
533
+
534
+ # n>=3 and no noise gate: every other pattern lever needs MIN_PATTERN_TRADES and has to keep
535
+ # NOISE_SHARE of what it saves, and this one could win best_lever on three trades.
536
+ # (@danielmbirochi, #718, item 7.)
537
+ if (tm.get("chased_n") or 0) >= MIN_PATTERN_TRADES and (tm.get("chased_realized") or 0) < -50 \
538
+ and (tm.get("calm_pf") or 0) > (tm.get("chased_pf") or 0):
539
+ vals = [-float(t["realized"]) for t in rows if t.get("chased") and (t.get("realized") or 0) < 0]
540
+ _tot, _gr = -float(tm["chased_realized"]), sum(vals)
541
+ out.append(dict(kind="chase", key="3pct", label="skipping entries after a >=3% move",
542
+ total=_tot, gross=_gr, vals=vals, noisy=bool(_gr > 0 and _tot < NOISE_SHARE * _gr),
543
+ n=int(tm["chased_n"])))
544
+ return out
545
+
546
+
547
+ def best_lever(lv, kind=None):
548
+ """The lever to quote. MEDIAN within a family, max across families.
549
+
550
+ Taking the max over all of them was a grid search reported as a finding: 3 lock settings and 3
551
+ cut settings are six in-sample estimates of the same underlying thing — exit discipline — and
552
+ the largest of six is biased high by construction. Charging each setting fixed the per-trade
553
+ asymmetry (#712); the SELECTION step was still optimistic and nothing measured it (@0xsarvesh,
554
+ #718). methodology.md has described the median-of-robust figure since 1.0.
555
+
556
+ Across families the max is right: a size cap, a funding cap and an exit rule are different
557
+ fixes, not six readings of one.
558
+ """
559
+ c = [x for x in (lv or []) if kind is None or x["kind"] == kind]
560
+ if not c:
561
+ return None
562
+ best = None
563
+ for k in {x["kind"] for x in c}:
564
+ fam = sorted((x for x in c if x["kind"] == k), key=lambda x: x["total"])
565
+ rep = fam[(len(fam) - 1) // 2] # lower median: ties go to the more conservative
566
+ if rep.get("noisy"): # the family's typical setting is a coin flip
567
+ continue
568
+ if best is None or rep["total"] > best["total"]:
569
+ best = rep
570
+ return best if best and best["total"] > 0 else None
571
+
572
+
573
+ def recoverable(rows, closed, tr, tm=None, lv=None):
574
+ """One honest number: what a senpi runtime would have kept over this window.
575
+
576
+ The leaks each price a different fix over the SAME trades, so they must never be added — one bad
577
+ trade that was oversized, chased, held too long AND gave back its peak appears in several of
578
+ them. On a real book the four printed leaks summed to $68k against $65k of actual losses.
579
+
580
+ This is the most conservative honest answer: **the single best change, applied to every trade.**
581
+ Not a sum of fixes, and not the best fix per trade either — letting each trade pick its own lever
582
+ is hindsight fitting at a finer grain, and a user runs one rule, not a different one per
583
+ position. Each lever is a book-wide total that nets its own costs:
584
+
585
+ * **exits** — every trailing-lock and time-cut setting, charged on the trades it would have hurt
586
+ as well as the ones it saved.
587
+ * **sizing** — a cap at the median winner's size across every oversized trade, winners included,
588
+ where the term is negative because the cap gives up that upside.
589
+ * **entries** — skipping chased entries, available only when this book's chased entries really
590
+ did do worse than its calm ones, and worth only what the chase leak itself claims.
591
+
592
+ Fees are added on top as the one genuinely independent fix. Funding is excluded: capping a
593
+ funding-paying hold is the same action as the time-cut.
594
+
595
+ Returns `concentration` alongside the total, because the shape matters as much as the size. On
596
+ that same book three trades were 83% of it and one was 50% — "you leak $46k across your book"
597
+ would have been true arithmetic and a false picture. `rule` is the finished sentence, not the
598
+ grid key: the phrasing has one home, here, next to every other string the desk shows a user.
599
+ """
600
+ lv = levers(rows, closed, tm) if lv is None else lv
601
+ best = best_lever(lv)
602
+ rule = best["label"] if best else None
603
+ vals = best["vals"] if best else []
604
+ total = best["total"] if best else 0.0
605
+
606
+ rows = rows or []
607
+ losses = -sum(t["realized"] for t in rows if (t.get("realized") or 0) < 0)
608
+ fees = max(0.0, float(tr.get("fee_recoverable") or 0.0)) if (tr.get("taker_share") or 0) >= 0.25 else 0.0
609
+
610
+ # Concentration answers "is this driven by a few trades or many", so the denominator is the sum
611
+ # of everything that CONTRIBUTED — the positive per-trade terms plus fees, the most diffuse
612
+ # contributor there is. It must NOT be the lever total, which is net of the trades the rule cost
613
+ # money on: a single trade could then be reported as 111% of the number (#718).
614
+ pos = sorted((v for v in vals if v > 0), reverse=True)
615
+ denom = sum(pos) + fees
616
+ conc = dict(top1=pos[0] / denom, top3=sum(pos[:3]) / denom, n_positive=len(pos)) if (denom > 0 and pos) else None
617
+
618
+ return dict(usd=total + fees, fees=fees, n_trades=len(rows), rule=rule, concentration=conc,
619
+ share_of_losses=((total + fees) / losses if losses > 0 else None))
620
+
621
+
622
+ def _cf_charged(l, days):
623
+ """Charged value first, gross second. The gross figure is what the fix saves on the trades it
624
+ helps; the charged one nets the trades it costs, and is the only one that can be quoted next to
625
+ the headline without contradicting it."""
626
+ g = l.get("gross") or 0.0
627
+ s = f"Running {l['label']} over the whole book would have kept ~{_usd(l['total'])} over {days} days"
628
+ return s + (f" — it saves {_usd(g)} on the trades it helps and gives some of that back on the ones it costs." if g > l["total"] + 1 else ".")
629
+
630
+
631
+ def leaks(tr, book, tm, funding_rows, closed, window_start, days, lv=None):
632
+ out = []
633
+ yr = 365.0 / days
634
+ # charged lever totals, so a leak is listed at the value recoverable() would credit it with and
635
+ # the ranking is by the same accounting as the headline (#718)
636
+ ch = {x["kind"]: x for x in (lv or []) if not x.get("noisy")}
637
+ # 1. costs — resting instead of crossing the spread
638
+ if _n(tr, "fee_recoverable") >= 50 and (tr.get("taker_share") or 0) >= 0.25:
639
+ out.append(dict(agent="Leak finder", title=f"{_pct(tr['taker_share'])} of your volume crossed the spread as a taker",
640
+ # On the indexed path `tr["fees"]` is discovery's CLOSED-trade fee and this is the
641
+ # fills-derived total over closed AND open positions, so the two differ — $5,021
642
+ # against $5,069 on 0xb699…392e. Both are right for what they measure; the page
643
+ # just has to say which, or a reader sees two numbers for "fees".
644
+ evidence=f"{_usd(_n(tr, 'fee_total_exec') or tr['fees'])} in fees across every fill in the window, on {_usd(tr['volume'])} of volume at {_bp(tr['fee_rate_taker'])} taker / {_bp(tr['fee_rate_maker'])} maker.",
645
+ counterfactual=f"Resting maker orders for the same fills would have kept ~{_usd(tr['fee_recoverable'])} over {days} days (≈{_usd(tr['fee_recoverable'] * yr)}/yr).",
646
+ usd=tr["fee_recoverable"], window=f"{days}d", cta=f"Execute through senpi and I'll rest your entries maker-first with a taker fallback — "
647
+ f"that's ~{_usd(tr['fee_recoverable'])} over {days} days (~{_usd(tr['fee_recoverable'] * yr)}/yr) "
648
+ f"you keep, on the same fills."))
649
+ # 2. funding — hold time on funding-paying legs
650
+ # _funding_after sums funding PAID; tr["funding"] is NET of funding collected elsewhere. Left
651
+ # uncapped the leak read "You paid $123,764 in funding … would have kept ~$164,499" — a saving
652
+ # larger than the cost in the same sentence.
653
+ paid_late = ch["funding"]["total"] if "funding" in ch else \
654
+ min(_funding_after(funding_rows, closed, window_start, 24.0), -float(tr.get("funding") or 0.0))
655
+ if _n(tr, "funding") < -100 and paid_late > 50:
656
+ worst = min(tr["coins"].items(), key=lambda kv: kv[1]["funding"])
657
+ out.append(dict(agent="Market regime", title=f"You paid {_usd(-tr['funding'])} in funding over {days} days",
658
+ evidence=(f"{worst[0]} alone cost {_usd(-worst[1]['funding'])}"
659
+ # the worst coin can exceed the NET total, because other coins collected —
660
+ # printed bare that reads as an arithmetic error
661
+ + (f" — more than the {_usd(-tr['funding'])} net, because other coins collected funding back"
662
+ if -worst[1]["funding"] > -_n(tr, "funding") else "")
663
+ + (f"; the book pays {_usd(-book['funding_per_day'])}/day at today's rates."
664
+ if _n(book, "funding_per_day") < 0 else ".")),
665
+ counterfactual=(
666
+ f"A 24h cap on those holds would have kept ~{_usd(_fl['total'])} over {days} days — "
667
+ f"{_usd(_fl['funding_saved'])} of funding, and {_usd(_fl['exit_effect'])} from closing "
668
+ f"the positions that much earlier."
669
+ if (_fl := ch.get("funding")) and abs(_fl.get("exit_effect") or 0) > 50 else
670
+ f"A 24h cap on holds that pay funding would have kept ~{_usd(paid_late)} over {days} days."
671
+ if ch.get("funding") else
672
+ "Capping those holds at 24h also CLOSES them, and this window has too few holds past "
673
+ "24h to price what those exits would have done — so the desk does not put a number on "
674
+ "the fix. The funding above is what it actually cost."),
675
+ usd=paid_late if ch.get("funding") else 0.0, unpriced=not ch.get("funding"),
676
+ window=f"{days}d", cta="A funding-aware hold rule caps the cost without changing the thesis."))
677
+ # 3. losers held too long — only when the time cut is robust
678
+ cut_l = best_lever(lv, "cut")
679
+ # `robust` is the pre-levers path: the median of every setting's total, sample gate or not. It
680
+ # let the LEAK walk around every bar `levers()` applies. On 0xb699…392e the 24h setting had n=3
681
+ # and 48h n=2, so the family was correctly declined — and the leak printed $341,779 from a
682
+ # three-trade counterfactual while the headline beside it said $2,709. That is the same wallet
683
+ # whose n=2 lever MIN_PATTERN_TRADES was written for, and the same "the list and the headline
684
+ # disagree" shape @shnoodles reported. One table, one bar: if the lever is declined the claim is
685
+ # unpriced, and only a leak with measured evidence of its own still has anything to say.
686
+ cut = cut_l["total"] if cut_l else None
687
+ _unpriced_cf = ("The desk does not put a number on this one: too few trades clear the sample bar for "
688
+ "a counterfactual to mean anything on this window. The pattern above is measured.")
689
+ # The hold-ratio framing is the EVIDENCE for a time cut, not a precondition for one. Gating the
690
+ # leak on it while the LEVER had no such gate meant a book that cuts losers 2.6x FASTER than
691
+ # winners saw "~$180,892 recoverable — closing anything still open after 24h" as its headline
692
+ # with no matching leak anywhere on the page. A winning lever must always be visible.
693
+ _hold_evidence = (tr.get("hold_ratio") or 0) > 1.2 and tr.get("hold_losers_h") is not None
694
+ if cut_l and cut > 50 and (tr.get("trades") or 0) >= MIN_PATTERN_TRADES and not _hold_evidence:
695
+ out.append(dict(agent="Leak finder", title=f"Positions still open at 24h cost more than closing them",
696
+ evidence=f"Across {tr.get('complete_trades') or tr.get('trades')} complete trades, the ones still "
697
+ f"open at the 24-hour mark gave back more than they added from there on.",
698
+ counterfactual=_cf_charged(cut_l, days) if cut_l else
699
+ f"A time-cut at 24h would have kept roughly ~{_usd(cut)} over {days} days.",
700
+ usd=cut, window=f"{days}d", cta="A time-cut is a rule your quant can run for you."))
701
+ if _hold_evidence and (tr.get("trades") or 0) >= MIN_PATTERN_TRADES and (cut_l is None or cut > 50):
702
+ out.append(dict(agent="Leak finder", title=f"You hold losers {tr['hold_ratio']:.1f}× longer than winners",
703
+ evidence=f"Median loser {tr['hold_losers_h']:.1f}h vs winner {tr['hold_winners_h']:.1f}h across {tr['complete_trades']} complete trades.",
704
+ counterfactual=(_cf_charged(cut_l, days) if cut_l else _unpriced_cf),
705
+ usd=cut if cut_l else 0.0, unpriced=not cut_l,
706
+ window=f"{days}d", cta="A time-cut is a rule your quant can run for you."))
707
+ # 4. giving back winners — only when the lock is robust
708
+ lock_l = best_lever(lv, "lock")
709
+ lock = lock_l["total"] if lock_l else None # same as the cut: no walking around the bar
710
+ # `give_back_median` and `mfe_median_winners` are taken over WINNERS only, so a book with no
711
+ # winning complete trade has both as None — and `lock` still fires, because losers that armed
712
+ # and retraced do produce a lever. _pct(None) then raised TypeError, which is not an HLError, so
713
+ # desk.py printed a traceback and no desk at all. That is exactly the book
714
+ # `--find --find-losers` sends a reader to. (@danielmbirochi, #718.)
715
+ _has_winner_stats = tm and tm.get("give_back_median") is not None
716
+ if (tr.get("trades") or 0) >= MIN_PATTERN_TRADES and not _has_winner_stats and (lock_l is None or lock > 50) and tm and tm.get("n"):
717
+ out.append(dict(agent="Leak finder", title="Your positions give back their peak before you exit",
718
+ evidence=f"No complete trade closed green in this window, but {tm['n']} of them were in profit "
719
+ f"at some point first.",
720
+ counterfactual=(_cf_charged(lock_l, days) if lock_l else _unpriced_cf),
721
+ usd=lock if lock_l else 0.0, unpriced=not lock_l,
722
+ window=f"{days}d", cta="A ratcheting stop locks the peak without capping the run."))
723
+ if (tr.get("trades") or 0) >= MIN_PATTERN_TRADES and _has_winner_stats and (lock_l is None or lock > 50):
724
+ out.append(dict(agent="Leak finder", title=f"You give back a median {_pct(tm['give_back_median'])} of a winner's peak",
725
+ evidence=f"Winners reach a median +{_pct(tm['mfe_median_winners'], 1)} before exit; {_pct(tm['losers_that_were_green'])} of losers were green first." if tm.get("losers_that_were_green") is not None else "",
726
+ counterfactual=(_cf_charged(lock_l, days) if lock_l else _unpriced_cf),
727
+ usd=lock if lock_l else 0.0, unpriced=not lock_l,
728
+ window=f"{days}d", cta="A ratcheting stop locks the peak without capping the run."))
729
+ # 5. chasing
730
+ if tm and _n(tm, "chased_n") >= MIN_PATTERN_TRADES and _n(tm, "chased_realized") < -50 and (tm.get("calm_pf") or 0) > (tm.get("chased_pf") or 0):
731
+ out.append(dict(agent="Smart money", title=f"Entries after a ≥3% move lose money — {tm['chased_n']} of {tm['n']} trades",
732
+ evidence=f"Those trades realized {_usd(tm['chased_realized'])} (profit factor {tm['chased_pf']:.1f}) vs {min(tm['calm_pf'], 9.9):.1f} when you entered before the move.",
733
+ counterfactual=f"Skipping entries that had already run ≥3% would have kept ~{_usd(-tm['chased_realized'])} over {days} days.",
734
+ usd=-tm["chased_realized"], window=f"{days}d", cta="Enter with the flow, not after it — a signal-driven entry does this."))
735
+ # 6. liquidations
736
+ if tr.get("liquidations"):
737
+ # "a stop halfway to liquidation would have kept half" was a GUESS, not a counterfactual: it
738
+ # charges nothing for the positions that stop would have cut early and which then recovered,
739
+ # and we do not know where the trader would have put it. Priced at $0 it states the fact and
740
+ # stops topping a ranking it was never measured for — on one book it led the page at
741
+ # $169,425 and next_steps sent the reader at it. (@0xsarvesh, #718.)
742
+ out.append(dict(agent="Risk guard", title=f"{tr['liquidations']} liquidation(s) cost {_usd(-tr['liquidation_loss'])}",
743
+ evidence="A liquidation surrenders the whole margin plus the liquidation fee.",
744
+ counterfactual="How much a stop would have saved depends where you put it, so the desk does not price this one — but the cost above is what it actually took.",
745
+ usd=0.0, unpriced=True, window=f"{days}d", cta="A hard stop is the cheapest insurance there is."))
746
+ # 7. sizing — oversized losers
747
+ size_l = ch.get("size")
748
+ if size_l and size_l["total"] > 50 and size_l.get("losers", 0) >= 2:
749
+ out.append(dict(agent="Leak finder", title=f"{size_l['n']} positions were sized >1.5× your median winner",
750
+ evidence=f"Median winner {_usd(size_l['median_winner'])} notional; {size_l['losers']} of those {size_l['n']} lost money.",
751
+ counterfactual=_cf_charged(size_l, days),
752
+ usd=size_l["total"], window=f"{days}d", cta="Size by conviction, not by frustration — a fixed-fraction rule does this."))
753
+ out.sort(key=lambda l: -l["usd"])
754
+ return out
755
+
756
+
757
+ def _funding_after(rows, closed, window_start, hours):
758
+ """Funding paid (negative usdc) on payments that landed more than `hours` after the episode holding
759
+ that coin opened — the part of the funding bill that hold time alone would have avoided."""
760
+ opens = collections.defaultdict(list)
761
+ for e in closed:
762
+ opens[e["coin"]].append((e["open_time"], e["close_time"] or 4e12))
763
+ paid = 0.0
764
+ for x in rows or []:
765
+ if x["time"] < window_start:
766
+ continue
767
+ d = x["delta"]; u = float(d["usdc"])
768
+ if u >= 0:
769
+ continue
770
+ for o, c in opens.get(d["coin"], []):
771
+ if o + hours * 3.6e6 <= x["time"] <= c:
772
+ paid += -u; break
773
+ return paid
774
+
775
+
776
+ # ---------------------------------------------------------------- where the edge is
777
+ HOLD_BUCKETS = (("< 4h", 0, 4), ("4–24h", 4, 24), ("1–3d", 24, 72), ("> 3d", 72, 1e9))
778
+
779
+
780
+ def best_setups(closed, tm_rows):
781
+ """Coin × side on every closed trade (realized is realized); hold buckets and entry style only on
782
+ fully observed ones (they need the true open and close)."""
783
+ comp = [e for e in closed if e.get("complete")]
784
+ groups = collections.defaultdict(list)
785
+ for e in closed:
786
+ groups[("coin", f"{e['coin']} {e['direction'].lower()}s")].append(e)
787
+ for e in comp:
788
+ for label, lo, hi in HOLD_BUCKETS:
789
+ if lo <= e["hold_h"] < hi:
790
+ groups[("hold", f"{e['direction'].lower()}s held {label}")].append(e)
791
+ chased = {(r["coin"], r["open_time"]) for r in (tm_rows or []) if r["chased"]}
792
+ for e in comp:
793
+ groups[("entry", "entries after a ≥3% move" if (e["coin"], e["open_time"]) in chased else "entries before the move")].append(e)
794
+ rows = []
795
+ for (kind, label), eps in groups.items():
796
+ if len(eps) < 3:
797
+ continue
798
+ w = sum(e["realized"] for e in eps if e["win"]); l = -sum(e["realized"] for e in eps if not e["win"])
799
+ pf = (w / l) if l else (float("inf") if w else None)
800
+ rows.append(dict(kind=kind, label=label, n=len(eps), wins=sum(1 for e in eps if e["win"]), realized=sum(e["realized"] for e in eps), profit_factor=pf))
801
+ best = sorted([r for r in rows if r["profit_factor"] and r["profit_factor"] >= 1.5 and r["realized"] > 0], key=lambda r: -r["realized"])
802
+ worst = sorted([r for r in rows if r["profit_factor"] is not None and r["profit_factor"] < 1.0], key=lambda r: r["realized"])
803
+ return dict(best=best[:3], worst=worst[:2], all=rows)
804
+
805
+
806
+ def families(closed, tm, tr):
807
+ """The catalog vocabulary the edge maps to, for the discover handoff."""
808
+ out = []
809
+ pre = (tm or {}).get("pre24_median")
810
+ if pre is not None and pre > 0.01:
811
+ out.append("trend_following" if (tm.get("give_back_median") or 1) < 0.5 else "breakout_momentum")
812
+ if pre is not None and pre < -0.01:
813
+ out.append("contrarian_fade")
814
+ top = next(iter(tr.get("coins") or {}), None)
815
+ if top and tr["coins"][top]["volume_share"] >= 0.5:
816
+ out.append("single_market")
817
+ return out or ["trend_following"]