@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.
- 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 +73 -16
- 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,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"]
|