@senpi/runtime 3.0.148 → 3.0.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/guide-commands.d.ts.map +1 -1
- package/dist/cli/guide-commands.js +55 -0
- package/dist/cli/guide-commands.js.map +1 -1
- package/dist/cli/quant-command.d.ts +128 -0
- package/dist/cli/quant-command.d.ts.map +1 -0
- package/dist/cli/quant-command.js +345 -0
- package/dist/cli/quant-command.js.map +1 -0
- package/dist/cli/senpi-commands.d.ts +11 -0
- package/dist/cli/senpi-commands.d.ts.map +1 -1
- package/dist/cli/senpi-commands.js +49 -4
- package/dist/cli/senpi-commands.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +40 -0
- package/dist/index.js.map +1 -1
- package/dist/quant/engine/quantdesk/__init__.py +19 -0
- package/dist/quant/engine/quantdesk/__main__.py +281 -0
- package/dist/quant/engine/quantdesk/_version.py +24 -0
- package/dist/quant/engine/quantdesk/benchmark.py +90 -0
- package/dist/quant/engine/quantdesk/book.py +197 -0
- package/dist/quant/engine/quantdesk/deep.py +197 -0
- package/dist/quant/engine/quantdesk/desk.py +878 -0
- package/dist/quant/engine/quantdesk/dsl.py +224 -0
- package/dist/quant/engine/quantdesk/followups.py +117 -0
- package/dist/quant/engine/quantdesk/hl_api.py +473 -0
- package/dist/quant/engine/quantdesk/market.py +290 -0
- package/dist/quant/engine/quantdesk/mcp_client.py +158 -0
- package/dist/quant/engine/quantdesk/metrics.py +409 -0
- package/dist/quant/engine/quantdesk/opportunities.py +88 -0
- package/dist/quant/engine/quantdesk/render.py +787 -0
- package/dist/quant/engine/quantdesk/roundtrips.py +124 -0
- package/dist/quant/engine/quantdesk/score.py +817 -0
- package/dist/quant/engine/quantdesk/senpi_history.py +160 -0
- package/dist/quant/engine/quantdesk/smart_money.py +354 -0
- package/dist/quant/engine/quantdesk/strategy_read.py +292 -0
- package/dist/quant/engine/quantdesk/taxonomy.py +55 -0
- package/dist/quant/engine/quantdesk/timing.py +164 -0
- package/dist/quant/engine/quantdesk/voice.py +42 -0
- package/dist/quant/engine-runner.d.ts +133 -0
- package/dist/quant/engine-runner.d.ts.map +1 -0
- package/dist/quant/engine-runner.js +306 -0
- package/dist/quant/engine-runner.js.map +1 -0
- package/dist/quant/job.d.ts +125 -0
- package/dist/quant/job.d.ts.map +1 -0
- package/dist/quant/job.js +184 -0
- package/dist/quant/job.js.map +1 -0
- package/dist/quant/register.d.ts +53 -0
- package/dist/quant/register.d.ts.map +1 -0
- package/dist/quant/register.js +253 -0
- package/dist/quant/register.js.map +1 -0
- package/dist/quant/registry.d.ts +105 -0
- package/dist/quant/registry.d.ts.map +1 -0
- package/dist/quant/registry.js +248 -0
- package/dist/quant/registry.js.map +1 -0
- package/dist/quant/types.d.ts +216 -0
- package/dist/quant/types.d.ts.map +1 -0
- package/dist/quant/types.js +121 -0
- package/dist/quant/types.js.map +1 -0
- package/dist/skills-manager/manifest.js +1 -1
- package/dist/skills-manager/skills-manifest.json +1 -1
- package/dist/widgets/index.d.ts +8 -1
- package/dist/widgets/index.d.ts.map +1 -1
- package/dist/widgets/index.js +1 -0
- package/dist/widgets/index.js.map +1 -1
- package/dist/widgets/registry.d.ts +8 -0
- package/dist/widgets/registry.d.ts.map +1 -1
- package/dist/widgets/registry.js +26 -1
- package/dist/widgets/registry.js.map +1 -1
- package/dist/widgets/tool.d.ts +14 -4
- package/dist/widgets/tool.d.ts.map +1 -1
- package/dist/widgets/tool.js +36 -23
- package/dist/widgets/tool.js.map +1 -1
- package/dist/widgets/turn-cap.d.ts +33 -0
- package/dist/widgets/turn-cap.d.ts.map +1 -0
- package/dist/widgets/turn-cap.js +119 -0
- package/dist/widgets/turn-cap.js.map +1 -0
- package/dist/widgets/types.d.ts +33 -5
- package/dist/widgets/types.d.ts.map +1 -1
- package/dist/widgets/types.js +4 -3
- package/dist/widgets/types.js.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.d.ts.map +1 -1
- package/dist/widgets/widgets/fund-user-wallet.js +1 -0
- package/dist/widgets/widgets/fund-user-wallet.js.map +1 -1
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts +52 -0
- package/dist/widgets/widgets/quant-desk-recommendations.d.ts.map +1 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js +278 -0
- package/dist/widgets/widgets/quant-desk-recommendations.js.map +1 -0
- package/openclaw.plugin.json +12 -2
- package/package.json +4 -3
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""What the trader has actually been doing — a strategy fingerprint with receipts, plus a rule-based
|
|
3
|
+
critique. Everything here is computed; the agent turns it into the two-paragraph read."""
|
|
4
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
5
|
+
import collections
|
|
6
|
+
import math
|
|
7
|
+
import statistics
|
|
8
|
+
|
|
9
|
+
import taxonomy
|
|
10
|
+
|
|
11
|
+
H = 3_600_000.0
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _pf(eps):
|
|
15
|
+
w = sum(e["realized"] for e in eps if e["realized"] > 0); l = -sum(e["realized"] for e in eps if e["realized"] <= 0)
|
|
16
|
+
return (w / l) if l else (float("inf") if w else None)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def by_class_side(closed, majors, large):
|
|
20
|
+
g = collections.defaultdict(list)
|
|
21
|
+
for e in closed:
|
|
22
|
+
g[(taxonomy.classify(e["coin"], majors, large), e["direction"])].append(e)
|
|
23
|
+
tot = sum(len(v) for v in g.values()) or 1
|
|
24
|
+
total_real = sum(e["realized"] for v in g.values() for e in v)
|
|
25
|
+
rows = []
|
|
26
|
+
for (cls, side), eps in g.items():
|
|
27
|
+
real = sum(e["realized"] for e in eps)
|
|
28
|
+
# Ties on realized P&L (two break-even coins, say) break by name. Without it the order fell
|
|
29
|
+
# through to set iteration, which follows PYTHONHASHSEED: the same book printed its coins in a
|
|
30
|
+
# different order run to run, and the recorded oracle could not be reproduced reliably.
|
|
31
|
+
rows.append(dict(cls=cls, label=taxonomy.label(cls), side=side, trades=len(eps), share=len(eps) / tot, realized=real,
|
|
32
|
+
pnl_share=(real / total_real) if total_real else None, wins=sum(1 for e in eps if e["win"]), pf=_pf(eps),
|
|
33
|
+
coins=sorted({e["coin"] for e in eps}, key=lambda c: (-sum(x["realized"] for x in eps if x["coin"] == c), c))[:4]))
|
|
34
|
+
rows.sort(key=lambda r: -r["trades"])
|
|
35
|
+
return rows
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def simultaneity(closed, opened, window_start, now):
|
|
39
|
+
"""Share of the window with a long AND a short open at once, and the class pairs that overlapped."""
|
|
40
|
+
intervals = [(e["open_time"], e["close_time"] or now, e["direction"], e["coin"]) for e in closed + opened]
|
|
41
|
+
if not intervals:
|
|
42
|
+
return dict(both_share=0.0, pairs=[])
|
|
43
|
+
pts = sorted({max(window_start, o) for o, _, _, _ in intervals} | {min(now, c) for _, c, _, _ in intervals} | {window_start, now})
|
|
44
|
+
both = 0.0; pairs = collections.Counter()
|
|
45
|
+
for a, b in zip(pts, pts[1:]):
|
|
46
|
+
mid = (a + b) / 2
|
|
47
|
+
live = [(d, c) for o, cl, d, c in intervals if o <= mid < cl]
|
|
48
|
+
longs = {c for d, c in live if d == "LONG"}; shorts = {c for d, c in live if d == "SHORT"}
|
|
49
|
+
if longs and shorts:
|
|
50
|
+
both += b - a
|
|
51
|
+
pairs[(tuple(sorted(longs))[:3], tuple(sorted(shorts))[:3])] += b - a
|
|
52
|
+
span = max(1.0, now - window_start)
|
|
53
|
+
top = [dict(longs=list(l), shorts=list(s), share=v / span) for (l, s), v in pairs.most_common(2)]
|
|
54
|
+
return dict(both_share=both / span, pairs=top)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def leg_correlation(candles, long_coins, short_coins):
|
|
58
|
+
"""Correlation of hourly returns between the long-leg basket and the short-leg basket the trader
|
|
59
|
+
actually used. A hedge that moves with its own book is not a hedge."""
|
|
60
|
+
def basket(coins):
|
|
61
|
+
series = []
|
|
62
|
+
for c in coins:
|
|
63
|
+
cd = candles.get(c)
|
|
64
|
+
if cd and len(cd[1]) > 200:
|
|
65
|
+
rows = cd[1]
|
|
66
|
+
series.append({r[0]: r[4] for r in rows})
|
|
67
|
+
if not series:
|
|
68
|
+
return None
|
|
69
|
+
times = sorted(set.intersection(*(set(s) for s in series)))
|
|
70
|
+
if len(times) < 100:
|
|
71
|
+
return None
|
|
72
|
+
rets = []
|
|
73
|
+
for a, b in zip(times, times[1:]):
|
|
74
|
+
rets.append(statistics.mean(math.log(s[b] / s[a]) for s in series if s[a] > 0 and s[b] > 0))
|
|
75
|
+
return dict(zip(times[1:], rets))
|
|
76
|
+
L, S = basket(long_coins), basket(short_coins)
|
|
77
|
+
if not L or not S:
|
|
78
|
+
return None
|
|
79
|
+
common = sorted(set(L) & set(S))
|
|
80
|
+
if len(common) < 100:
|
|
81
|
+
return None
|
|
82
|
+
x = [L[t] for t in common]; y = [S[t] for t in common]
|
|
83
|
+
mx, my = statistics.mean(x), statistics.mean(y)
|
|
84
|
+
sx, sy = statistics.pstdev(x), statistics.pstdev(y)
|
|
85
|
+
if not sx or not sy:
|
|
86
|
+
return None
|
|
87
|
+
return sum((a - mx) * (b - my) for a, b in zip(x, y)) / (len(x) * sx * sy)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def pnl_beta(pnl_curve, btc_candles, account_value, equity=None):
|
|
91
|
+
"""How much of the P&L is just BTC: correlation and beta of P&L changes (as % of equity) to BTC returns
|
|
92
|
+
over matching intervals."""
|
|
93
|
+
if not pnl_curve or not btc_candles or len(pnl_curve) < 12 or not account_value:
|
|
94
|
+
return None
|
|
95
|
+
times, rows = btc_candles
|
|
96
|
+
import bisect
|
|
97
|
+
def px(t):
|
|
98
|
+
i = bisect.bisect_right(times, t) - 1
|
|
99
|
+
return rows[i][4] if i >= 0 else None
|
|
100
|
+
# Every step's P&L used to be divided by TODAY's account value, so a book that doubled over the
|
|
101
|
+
# window had its early returns scaled by a denominator that did not exist yet — correlation
|
|
102
|
+
# survives a constant rescale, beta does not. Divide each step by the equity AT THAT STEP, from
|
|
103
|
+
# the transfer-adjusted curve, so a deposit does not read as a return either.
|
|
104
|
+
# (@danielmbirochi, #718, round 2.)
|
|
105
|
+
eq = dict(equity or [])
|
|
106
|
+
eq_t = sorted(eq)
|
|
107
|
+
# A step measured against a near-zero equity is a division by the tail, not a return: it reported
|
|
108
|
+
# "a 1% BTC move swings your equity by about 35%" on a book whose equity collapsed mid-window.
|
|
109
|
+
# Floor the denominator at a tenth of the window's median equity and drop the steps below it.
|
|
110
|
+
_pos = [v for v in eq.values() if v and v > 0]
|
|
111
|
+
_floor = 0.1 * statistics.median(_pos) if _pos else 0.0
|
|
112
|
+
def eq_at(t):
|
|
113
|
+
i = bisect.bisect_right(eq_t, t) - 1
|
|
114
|
+
v = eq[eq_t[i]] if i >= 0 else None
|
|
115
|
+
return v if v and v > _floor else None
|
|
116
|
+
xs, ys = [], []
|
|
117
|
+
for (t0, v0), (t1, v1) in zip(pnl_curve, pnl_curve[1:]):
|
|
118
|
+
p0, p1 = px(t0), px(t1)
|
|
119
|
+
base = eq_at(t0) if eq else account_value
|
|
120
|
+
if p0 and p1 and t1 > t0 and base:
|
|
121
|
+
xs.append(p1 / p0 - 1); ys.append((v1 - v0) / base)
|
|
122
|
+
if len(xs) < 10:
|
|
123
|
+
return None
|
|
124
|
+
mx, my = statistics.mean(xs), statistics.mean(ys); sx, sy = statistics.pstdev(xs), statistics.pstdev(ys)
|
|
125
|
+
if not sx or not sy:
|
|
126
|
+
return None
|
|
127
|
+
cov = sum((a - mx) * (b - my) for a, b in zip(xs, ys)) / len(xs)
|
|
128
|
+
return dict(corr=cov / (sx * sy), beta=cov / (sx * sx), n=len(xs))
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def fingerprint(closed, opened, book, tr, act, tm, candles, ctxs, pnl_curve, window_start, now, equity=None):
|
|
132
|
+
majors, large = taxonomy.crypto_tiers(ctxs)
|
|
133
|
+
rows = by_class_side(closed, majors, large)
|
|
134
|
+
live = [dict(coin=p["coin"], side=p["side"], cls=taxonomy.classify(p["coin"], majors, large), notional=p["notional"]) for p in book["positions"]]
|
|
135
|
+
# where the MONEY went, closed and open together, by notional — the count-based table can hide a book of held shorts
|
|
136
|
+
money = collections.defaultdict(float)
|
|
137
|
+
for e in closed:
|
|
138
|
+
money[(taxonomy.label(taxonomy.classify(e["coin"], majors, large)), e["direction"], "closed")] += e["peak_notional"]
|
|
139
|
+
for p in live:
|
|
140
|
+
money[(taxonomy.label(p["cls"]), p["side"], "open")] += p["notional"]
|
|
141
|
+
tot_money = sum(money.values()) or 1.0
|
|
142
|
+
money_rows = sorted((dict(cls=k[0], side=k[1], state=k[2], share=v / tot_money) for k, v in money.items()), key=lambda r: -r["share"])
|
|
143
|
+
gross = sum(p["notional"] for p in live) or 1.0
|
|
144
|
+
live_cls = collections.defaultdict(float)
|
|
145
|
+
for p in live:
|
|
146
|
+
live_cls[(taxonomy.label(p["cls"]), p["side"])] += p["notional"] / gross
|
|
147
|
+
net_ratio = abs(book["net_exposure"]) / gross if book["positions"] else None
|
|
148
|
+
sim = simultaneity(closed, opened, window_start, now)
|
|
149
|
+
long_coins = {e["coin"] for e in closed if e["direction"] == "LONG"}; short_coins = {e["coin"] for e in closed if e["direction"] == "SHORT"}
|
|
150
|
+
corr = leg_correlation(candles, long_coins, short_coins) if (long_coins and short_coins) else None
|
|
151
|
+
beta = pnl_beta(pnl_curve, candles.get("BTC"), book.get("account_value"), equity)
|
|
152
|
+
# outcome concentration
|
|
153
|
+
reals = sorted((e["realized"] for e in closed), reverse=True); total = sum(reals)
|
|
154
|
+
top3 = sum(reals[:3]) if reals else 0.0
|
|
155
|
+
conc = (top3 / total) if total > 0 else None
|
|
156
|
+
# sides that never worked
|
|
157
|
+
dead = [r for r in rows if r["trades"] >= 3 and r["wins"] == 0]
|
|
158
|
+
coins_traded = len({e["coin"] for e in closed})
|
|
159
|
+
top_coins = sorted(collections.Counter(e["coin"] for e in closed).items(), key=lambda kv: -kv[1])[:3]
|
|
160
|
+
hold = tr.get("hold_winners_h") if tr.get("hold_winners_h") is not None else None
|
|
161
|
+
style = []
|
|
162
|
+
es = entry_style(tm, tr)
|
|
163
|
+
if es:
|
|
164
|
+
style.append(es)
|
|
165
|
+
if tr.get("adds_per_trade") is not None and tr["adds_per_trade"] >= 1:
|
|
166
|
+
style.append(f"pyramids — {tr['adds_per_trade']:.1f} added orders per trade on average")
|
|
167
|
+
if (act.get("twap_share") or 0) > 0.2: # present-and-None on a book with no TWAPs
|
|
168
|
+
style.append(f"works orders — {100 * act['twap_share']:.0f}% of fills are TWAP slices")
|
|
169
|
+
tpd = (tr.get("trades") or 0) / max(1, act.get("active_days") or 1)
|
|
170
|
+
return dict(class_side=rows, live=live, money=money_rows[:4], open_shorts=sum(1 for p in live if p["side"] == "SHORT"), open_longs=sum(1 for p in live if p["side"] == "LONG"),
|
|
171
|
+
live_mix=[dict(cls=k[0], side=k[1], share=v) for k, v in sorted(live_cls.items(), key=lambda kv: -kv[1])],
|
|
172
|
+
net_over_gross=net_ratio, simultaneity=sim, leg_correlation=corr, pnl_beta=beta, outcome_concentration=conc,
|
|
173
|
+
dead_sides=dead, coins_traded=coins_traded, top_coins=top_coins, trades_per_active_day=tpd, style=style,
|
|
174
|
+
long_share=tr.get("long_share"))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def entry_style(tm, tr):
|
|
178
|
+
"""Chaser or fader, in the words of the side the trader actually trades: a short seller does not "buy weakness"."""
|
|
179
|
+
if not tm:
|
|
180
|
+
return None
|
|
181
|
+
ls = tr.get("long_share")
|
|
182
|
+
lean = "long" if ls is None or ls >= 0.7 else ("short" if ls <= 0.3 else "mixed")
|
|
183
|
+
if (tm.get("chased_share") or 0) >= 0.5:
|
|
184
|
+
return {"long": "buys strength — half or more of your entries come after a ≥3% move up, a chaser",
|
|
185
|
+
"short": "sells weakness — half or more of your shorts come after a ≥3% drop, a chaser",
|
|
186
|
+
"mixed": "chases the move — half or more of your entries come after a ≥3% move in their direction"}[lean]
|
|
187
|
+
if tm.get("pre24_median") is not None and tm["pre24_median"] < -0.02:
|
|
188
|
+
return {"long": "buys weakness — you enter after the move against you, a fader",
|
|
189
|
+
"short": "sells strength — you short after the move against you, a fader",
|
|
190
|
+
"mixed": "fades the move — you enter after the move against you, on both sides"}[lean]
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def statements(fp, tr, book):
|
|
195
|
+
"""The receipts, as sentences the agent can quote. Ordered: what you do → where the money is → what's off."""
|
|
196
|
+
s = []
|
|
197
|
+
rows = fp["class_side"]
|
|
198
|
+
money = fp.get("money") or []
|
|
199
|
+
if money and money[0]["share"] >= 0.5:
|
|
200
|
+
m = money[0]
|
|
201
|
+
s.append(f"by notional, {100 * m['share']:.0f}% of your activity is {m['cls']} {m['side'].lower()}s" + (" you are still holding" if m["state"] == "open" else " you have closed"))
|
|
202
|
+
if rows:
|
|
203
|
+
head = rows[0]
|
|
204
|
+
n_short = sum(r["trades"] for r in rows if r["side"] == "SHORT"); n_all = sum(r["trades"] for r in rows)
|
|
205
|
+
if n_short == 0 and not fp.get("open_shorts"):
|
|
206
|
+
tail = "you never short"
|
|
207
|
+
elif n_short == 0 and fp.get("open_shorts"):
|
|
208
|
+
tail = f"every closed trade was a long, but the open book is {fp['open_shorts']} short{'s' if fp['open_shorts'] != 1 else ''}"
|
|
209
|
+
elif n_short / n_all < 0.1:
|
|
210
|
+
tail = f"you almost never short ({n_short} of {n_all})"
|
|
211
|
+
else:
|
|
212
|
+
tail = "your shorts are " + ", ".join(dict.fromkeys(r["label"] for r in rows if r["side"] == "SHORT"))
|
|
213
|
+
s.append(f"{100 * head['share']:.0f}% of your closed trades are {head['label']} {head['side'].lower()}s ({', '.join(head['coins'][:3])}); {tail}")
|
|
214
|
+
sim = fp["simultaneity"]
|
|
215
|
+
if sim["both_share"] >= 0.3 and sim["pairs"]:
|
|
216
|
+
p = sim["pairs"][0]
|
|
217
|
+
s.append(f"{100 * sim['both_share']:.0f}% of the window you held longs and shorts at once — typically long {', '.join(p['longs'][:3])} against short {', '.join(p['shorts'][:3])}")
|
|
218
|
+
if fp["net_over_gross"] is not None and book["positions"]:
|
|
219
|
+
s.append("the book right now is " + ("directional" if fp["net_over_gross"] > 0.7 else ("hedged" if fp["net_over_gross"] < 0.3 else "tilted"))
|
|
220
|
+
+ f" — net {100 * fp['net_over_gross']:.0f}% of gross; " + ", ".join(f"{100 * m['share']:.0f}% {m['side'].lower()} {m['cls']}" for m in fp["live_mix"][:3]))
|
|
221
|
+
if fp["leg_correlation"] is not None and sim["both_share"] >= 0.2:
|
|
222
|
+
c = fp["leg_correlation"]
|
|
223
|
+
# Past tense, always. This measures the WINDOW, and it used to sit in the present tense
|
|
224
|
+
# directly beneath "the book right now is directional — net 100% of gross", so a reader was
|
|
225
|
+
# told about a long leg the live book does not have. Say when the legs are gone.
|
|
226
|
+
sides = {q["side"] for q in (book["positions"] or [])}
|
|
227
|
+
gone = " — though the book you hold now is " + ("short only" if sides == {"SHORT"} else "long only") \
|
|
228
|
+
if len(sides) == 1 else ""
|
|
229
|
+
s.append(f"over the window your long leg and your short leg moved together (ρ = {c:+.2f}) — "
|
|
230
|
+
+ ("the hedge was mostly a fee" if c > 0.6
|
|
231
|
+
else ("the legs were genuinely different bets" if c < 0.3 else "partly a hedge"))
|
|
232
|
+
+ gone)
|
|
233
|
+
if fp["pnl_beta"]:
|
|
234
|
+
b = fp["pnl_beta"]
|
|
235
|
+
if abs(b["corr"]) >= 0.5:
|
|
236
|
+
rel = "tracks BTC" if b["corr"] > 0 else "is inverse BTC"
|
|
237
|
+
s.append(f"your P&L {rel} (correlation {b['corr']:+.2f}): a 1% BTC move swings your equity by about {abs(b['beta']):.1f}% — a lot of the result is the market and the leverage, not the picks")
|
|
238
|
+
else:
|
|
239
|
+
s.append(f"your P&L is largely independent of BTC (correlation {b['corr']:+.2f}) — the picks, not the tape, drive it")
|
|
240
|
+
if fp["outcome_concentration"] is not None and fp["outcome_concentration"] >= 0.6 and (tr.get("trades") or 0) >= 10:
|
|
241
|
+
c = fp["outcome_concentration"]
|
|
242
|
+
s.append((f"your three best trades are more than all of your realized P&L ({100 * c:.0f}%) — everything else nets negative" if c > 1
|
|
243
|
+
else f"your three best trades are {100 * c:.0f}% of your realized P&L") + " — the edge is concentrated, and fragile")
|
|
244
|
+
for d in fp["dead_sides"][:2]:
|
|
245
|
+
s.append(f"{d['label']} {d['side'].lower()}s have never paid: 0 of {d['trades']} ({'-' if d['realized'] < 0 else ''}${abs(d['realized']):,.0f})")
|
|
246
|
+
if fp["coins_traded"] >= 20 and rows:
|
|
247
|
+
top = fp["top_coins"]
|
|
248
|
+
s.append(f"you touched {fp['coins_traded']} coins, but " + ", ".join(f"{c} ({n})" for c, n in top) + " are most of the activity")
|
|
249
|
+
s += fp["style"]
|
|
250
|
+
return s
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def critique(fp, tr, book, mf, sm, cohorts=None):
|
|
254
|
+
"""Rule-based: what this way of trading needs to work, and where the data says it isn't getting it."""
|
|
255
|
+
out = []
|
|
256
|
+
by = {c["name"]: c for c in (cohorts or [])}
|
|
257
|
+
pv, ht = by.get("proven"), by.get("hot")
|
|
258
|
+
if pv and ht and pv.get("agreement") is not None and ht.get("agreement") is not None and pv["agreement"] >= 0.5 and ht["agreement"] <= -0.5:
|
|
259
|
+
ag, n_rows = list(ht["against"]), len(ht["rows"])
|
|
260
|
+
cover = {p["coin"]: p.get("stop_covered_share", 1.0) for p in book["positions"]}
|
|
261
|
+
thin = [x for x in ag if (cover.get(x) or 0.0) < 1.0] # the squeeze line only says "without stops" when that is true
|
|
262
|
+
tail = (f"without a full stop on {', '.join(thin)} the day it doesn't is the whole book." if thin else "the stops are what make the day it doesn't survivable.")
|
|
263
|
+
if len(ag) * 2 >= max(1, n_rows):
|
|
264
|
+
out.append(f"You are positioned with the record and against the momentum: the proven cohort sits with you, the last 30 days' winners are on the other side on {len(ag)} of {n_rows} coins. That is the shape of a squeeze — it pays until it doesn't, and {tail}")
|
|
265
|
+
elif ag:
|
|
266
|
+
out.append(f"On {', '.join(ag)} you sit with the proven cohort and against the last 30 days' winners. That is the shape of a squeeze on {'that coin' if len(ag) == 1 else 'those coins'} — it pays until it doesn't, and {tail}")
|
|
267
|
+
elif pv and ht and pv.get("agreement") is not None and ht.get("agreement") is not None and pv["agreement"] <= -0.5 and ht["agreement"] >= 0.5:
|
|
268
|
+
out.append(f"You are riding the momentum against the record: the hot cohort is with you, the proven cohort is on the other side on {len(pv['against'])} of {len(pv['rows'])} coins. Momentum books need the exit decided in advance.")
|
|
269
|
+
sim = fp["simultaneity"]; corr = fp["leg_correlation"]
|
|
270
|
+
if sim["both_share"] >= 0.3 and corr is not None and corr > 0.6:
|
|
271
|
+
out.append("A long/short book only earns its funding and fees if the legs diverge. Yours are correlated — you are paying two spreads for one bet. Either pick the side or hedge with something that actually moves differently.")
|
|
272
|
+
if fp["net_over_gross"] is not None and fp["net_over_gross"] > 0.7 and mf and mf.get("against", 0):
|
|
273
|
+
ag_coins = [x["coin"] for x in mf.get("rows") or [] if str(x.get("fit", "")).startswith("AGAINST")]
|
|
274
|
+
gross = sum(p.get("notional", 0.0) for p in book["positions"]) or 1.0
|
|
275
|
+
share = sum(p.get("notional", 0.0) for p in book["positions"] if p["coin"] in ag_coins) / gross
|
|
276
|
+
if share >= 0.5:
|
|
277
|
+
out.append(f"A directional book lives or dies with the tape, and most of it ({100 * share:.0f}% by notional) is against the trend right now. Directional needs a regime filter; you are running it without one.")
|
|
278
|
+
elif share >= 0.2:
|
|
279
|
+
out.append(f"A directional book lives or dies with the tape, and {100 * share:.0f}% of it by notional ({', '.join(ag_coins)}) is against the trend right now. Directional needs a regime filter; you are running it without one.")
|
|
280
|
+
if fp["pnl_beta"] and fp["pnl_beta"]["corr"] >= 0.6:
|
|
281
|
+
out.append("If the P&L is mostly BTC, the leverage and the fees are the only things you are adding. Either the picks need to diverge from BTC, or the same exposure is cheaper with one position and a stop.")
|
|
282
|
+
if fp["outcome_concentration"] is not None and fp["outcome_concentration"] >= 0.6 and (tr.get("trades") or 0) >= 10:
|
|
283
|
+
out.append("An edge that lives in three trades is a story, not a system. The question your record asks is whether the process that found those three is repeatable — the size-vs-outcome and hold-time tables say where.")
|
|
284
|
+
for d in fp["dead_sides"][:1]:
|
|
285
|
+
out.append(f"Stop trading {d['label']} {d['side'].lower()}s until you can say what would make one work; the sample says nothing has.")
|
|
286
|
+
if tr.get("hold_ratio") and tr["hold_ratio"] > 2:
|
|
287
|
+
out.append("The strategy's exits are asymmetric the wrong way: losers get time, winners don't. That is the single most expensive habit in the record.")
|
|
288
|
+
if sm and sm.get("against") and not (pv and ht):
|
|
289
|
+
out.append(f"On {', '.join(sm['against'])} you are on the other side of the proven cohort. Being contrarian is a strategy only if it is deliberate — is it?")
|
|
290
|
+
if not out:
|
|
291
|
+
out.append("The structure is coherent: the book does what the record says it does. The improvements are in execution and risk, not in the thesis.")
|
|
292
|
+
return out
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Asset classes for the strategy read. Crypto tiers come from LIVE open interest (majors = top 3 by OI,
|
|
3
|
+
large caps = the next 12, alts = the rest); memecoins are the 1000×-denominated `k…` names plus a short,
|
|
4
|
+
documented list; xyz groups are senpi-market-pulse's own (verbatim), so the two skills agree."""
|
|
5
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
6
|
+
XYZ_GROUPS = {
|
|
7
|
+
"semis_memory": ["MU", "SNDK", "SKHX", "DRAM", "WDC"],
|
|
8
|
+
"semis_equipment": ["ASML", "TSM", "QCOM", "ARM", "MRVL"],
|
|
9
|
+
"semis_logic": ["NVDA", "AMD", "AVGO", "SMH"],
|
|
10
|
+
"software_megacap": ["AMZN", "MSFT", "META", "GOOGL", "AAPL", "ORCL", "PLTR"],
|
|
11
|
+
"crypto_proxy": ["MSTR", "COIN", "HOOD", "CRWV"],
|
|
12
|
+
"other_equity": ["TSLA", "SPCX", "ZHIPU"],
|
|
13
|
+
"indices": ["SP500", "XYZ100", "JP225", "KR200", "NIFTY", "VIX"],
|
|
14
|
+
"commodities": ["GOLD", "SILVER", "COPPER", "BRENTOIL", "NATGAS", "PLATINUM"],
|
|
15
|
+
"macro_fx": ["DXY", "JPY", "EUR", "GBP"],
|
|
16
|
+
}
|
|
17
|
+
XYZ_CLASS = {}
|
|
18
|
+
for _g, _names in XYZ_GROUPS.items():
|
|
19
|
+
for _n in _names:
|
|
20
|
+
XYZ_CLASS[_n] = ("xyz_" + ("equities" if _g in ("semis_memory", "semis_equipment", "semis_logic", "software_megacap", "crypto_proxy", "other_equity")
|
|
21
|
+
else _g))
|
|
22
|
+
# memecoins beyond the k-prefix rule — a taxonomy list, never a trading whitelist
|
|
23
|
+
MEMES = {"DOGE", "WIF", "FARTCOIN", "PUMP", "TRUMP", "MOODENG", "POPCAT", "PNUT", "PEPE", "SHIB", "BONK", "FLOKI", "GOAT", "CHILLGUY",
|
|
24
|
+
"MEW", "BRETT", "TURBO", "NEIRO", "SPX", "CASHCAT", "PONS", "MELANIA", "AI16Z", "VVV", "GRIFFAIN"}
|
|
25
|
+
LABELS = {"majors": "majors", "large_caps": "large caps", "alts": "alts", "memes": "memecoins", "xyz_equities": "equities", "xyz_indices": "indices",
|
|
26
|
+
"xyz_commodities": "commodities", "xyz_macro_fx": "FX / macro", "xyz_other": "other xyz"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def crypto_tiers(ctxs):
|
|
30
|
+
"""(majors, large_caps) from the main-dex contexts, by open interest in USD."""
|
|
31
|
+
rows = []
|
|
32
|
+
for u, c in zip(ctxs[0]["universe"], ctxs[1]):
|
|
33
|
+
try:
|
|
34
|
+
rows.append((float(c["openInterest"]) * float(c["markPx"]), u["name"]))
|
|
35
|
+
except (TypeError, ValueError, KeyError):
|
|
36
|
+
continue
|
|
37
|
+
rows.sort(reverse=True)
|
|
38
|
+
names = [n for _, n in rows]
|
|
39
|
+
return set(names[:3]), set(names[3:15])
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def classify(coin, majors, large):
|
|
43
|
+
if coin.startswith("xyz:"):
|
|
44
|
+
return XYZ_CLASS.get(coin[4:], "xyz_other")
|
|
45
|
+
if coin in majors:
|
|
46
|
+
return "majors"
|
|
47
|
+
if coin.startswith("k") and coin[1:2].isupper() or coin in MEMES:
|
|
48
|
+
return "memes"
|
|
49
|
+
if coin in large:
|
|
50
|
+
return "large_caps"
|
|
51
|
+
return "alts"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def label(cls):
|
|
55
|
+
return LABELS.get(cls, cls)
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Candle-based timing analyses per complete round trip: the move before entry (chasing), favourable and
|
|
3
|
+
adverse excursions, give-back of peak gains, and counterfactuals that are only ever surfaced when they
|
|
4
|
+
are positive across a small fixed grid (never a fitted parameter). Dollar figures use peak size × price
|
|
5
|
+
move — an approximation that ignores adds and partials, and is labelled as such."""
|
|
6
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
7
|
+
import bisect
|
|
8
|
+
import statistics
|
|
9
|
+
|
|
10
|
+
H = 3_600_000.0
|
|
11
|
+
CUT_GRID_H = (12.0, 24.0, 48.0) # time-cut on losers
|
|
12
|
+
LOCK_GRID = ((0.03, 0.5), (0.05, 0.5), (0.05, 0.3)) # (arm at +peak%, lock this share of the peak)
|
|
13
|
+
CHASE_PCT = 0.03 # an entry after a ≥3% move in its direction over 24h
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_candles(raw):
|
|
17
|
+
out = {}
|
|
18
|
+
for coin, rows in (raw or {}).items():
|
|
19
|
+
if rows:
|
|
20
|
+
out[coin] = ([r[0] for r in rows], rows)
|
|
21
|
+
return out
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def px_at(c, t):
|
|
25
|
+
times, rows = c
|
|
26
|
+
i = bisect.bisect_right(times, t) - 1
|
|
27
|
+
return rows[i][4] if i >= 0 else None
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def path(c, t0, t1):
|
|
31
|
+
times, rows = c
|
|
32
|
+
return rows[bisect.bisect_left(times, t0):bisect.bisect_right(times, t1)]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fav(r, sgn, ent):
|
|
36
|
+
return sgn * ((r[2] if sgn > 0 else r[3]) / ent - 1)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _adv(r, sgn, ent):
|
|
40
|
+
return sgn * ((r[3] if sgn > 0 else r[2]) / ent - 1)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _at(path, times, t, p):
|
|
44
|
+
"""What the trade would show if the WHOLE position were closed at time `t`, price `p`: the P&L
|
|
45
|
+
already booked by then, plus mark-to-market on the size actually open at that moment against its
|
|
46
|
+
average entry.
|
|
47
|
+
|
|
48
|
+
The old form was `return x peak_size x entry_vwap` — it priced the PEAK size as if it had been
|
|
49
|
+
held from entry through to `t`. On a position scaled in and out those are not the same trade and
|
|
50
|
+
the error scales with the notional: xyz:SKHX ran 507 fills with $16.3M entered against a $7.1M
|
|
51
|
+
peak and its 48h cut came back at +$96,858 against a $44,302 loss, 2.2x the thing it was
|
|
52
|
+
"fixing". Three of four losing books were offered a recovery larger than their entire loss.
|
|
53
|
+
Pricing on the exposure that existed removes the assumption rather than bounding its damage.
|
|
54
|
+
(B6, @0xsarvesh #718.)"""
|
|
55
|
+
i = bisect.bisect_right(times, t) - 1
|
|
56
|
+
if i < 0:
|
|
57
|
+
return None
|
|
58
|
+
_, size, avg, booked = path[i]
|
|
59
|
+
if avg is None:
|
|
60
|
+
return None
|
|
61
|
+
return booked + size * (p - avg)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def per_trade(episodes, candles):
|
|
65
|
+
out = []
|
|
66
|
+
for e in episodes:
|
|
67
|
+
c = candles.get(e["coin"]); ent = e["entry_vwap"]
|
|
68
|
+
if not c or not ent or not e.get("complete"):
|
|
69
|
+
continue
|
|
70
|
+
sgn = 1 if e["direction"] == "LONG" else -1
|
|
71
|
+
p24 = px_at(c, e["open_time"] - 24 * H)
|
|
72
|
+
pre24 = sgn * (ent / p24 - 1) if p24 else None
|
|
73
|
+
rows = [r for r in path(c, e["open_time"] - H, e["close_time"]) if r[0] >= e["open_time"] - H]
|
|
74
|
+
ntl = e["peak_size"] * ent
|
|
75
|
+
# There IS per-moment exposure in this data — `size_path` carries it — so the exit grid no
|
|
76
|
+
# longer has to assume the peak size was held throughout, and no longer has to decline the
|
|
77
|
+
# positions where that assumption fails worst. The `cycled` skip this replaces was the right
|
|
78
|
+
# call while the counterfactual was constant-size; it dropped 10 of 11 rows on one of the
|
|
79
|
+
# books in the report, which is its own kind of wrong. (B6, @0xsarvesh #718.)
|
|
80
|
+
# senpi's indexed rows are aggregate positions, not fills: one size, one entry price, no
|
|
81
|
+
# intra-trade path — and for those the constant-size form was always exactly right
|
|
82
|
+
# (`entry_val == peak_notional`, so they never tripped the old `cycled` skip either). The
|
|
83
|
+
# degenerate one-step path below reproduces the old arithmetic for them to the cent, so this
|
|
84
|
+
# change lands only where there IS a path to be wrong about.
|
|
85
|
+
spath = e.get("size_path") or [(e["open_time"], sgn * e["peak_size"], ent, 0.0)]
|
|
86
|
+
stimes = [x[0] for x in spath]
|
|
87
|
+
mfe = mae = 0.0
|
|
88
|
+
cut_px = {h: None for h in CUT_GRID_H}
|
|
89
|
+
for r in rows:
|
|
90
|
+
mfe = max(mfe, _fav(r, sgn, ent)); mae = min(mae, _adv(r, sgn, ent))
|
|
91
|
+
for h in CUT_GRID_H:
|
|
92
|
+
if cut_px[h] is None and r[0] >= e["open_time"] + h * H:
|
|
93
|
+
cut_px[h] = (r[0], r[4])
|
|
94
|
+
realized_pct = e["realized"] / ntl if ntl else 0.0
|
|
95
|
+
give_back = ((mfe - realized_pct) / mfe) if (mfe > 0 and realized_pct < mfe) else 0.0
|
|
96
|
+
locks = {}
|
|
97
|
+
for arm, share in LOCK_GRID:
|
|
98
|
+
locks[f"{arm:.2f}/{share:.1f}"] = _lock_cf(rows, e, sgn, ent, spath, stimes, mfe, arm, share)
|
|
99
|
+
cuts = {}
|
|
100
|
+
for h in CUT_GRID_H:
|
|
101
|
+
hit = cut_px[h]
|
|
102
|
+
# every trade still open at hour h, winner or loser. Charging a time-cut only on the
|
|
103
|
+
# losers is survivorship bias — at hour h you do not yet know which is which — and it
|
|
104
|
+
# inflated the cut counterfactual to more than the book's whole equity.
|
|
105
|
+
v = _at(spath, stimes, hit[0], hit[1]) if (hit and e["hold_h"] > h) else None
|
|
106
|
+
cuts[f"{h:.0f}"] = (v - e["realized"]) if v is not None else None
|
|
107
|
+
out.append(dict(coin=e["coin"], direction=e["direction"], realized=e["realized"], hold_h=e["hold_h"], win=e["win"], pre24=pre24,
|
|
108
|
+
chased=(pre24 is not None and pre24 >= CHASE_PCT), mfe=mfe, mae=mae, realized_pct=realized_pct, give_back=give_back,
|
|
109
|
+
notional=ntl, lock_cf=locks, cut_cf=cuts, open_time=e["open_time"]))
|
|
110
|
+
return out
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _lock_cf(rows, e, sgn, ent, spath, stimes, mfe, arm, share):
|
|
114
|
+
"""Exit when price gives back (1-share) of the peak gain, once the peak reached `arm`. Returns the
|
|
115
|
+
dollar difference vs what was realized, or None when the rule never engaged.
|
|
116
|
+
|
|
117
|
+
The RULE is unchanged — a fixed grid measured against the entry VWAP, never fitted. What changed
|
|
118
|
+
is the pricing of the exit it produces: the stop fills the position that exists at that moment,
|
|
119
|
+
not the peak size assumed held since entry."""
|
|
120
|
+
if mfe < arm:
|
|
121
|
+
return None
|
|
122
|
+
armed, lvl = False, None
|
|
123
|
+
for r in rows:
|
|
124
|
+
if r[0] < e["open_time"]:
|
|
125
|
+
continue
|
|
126
|
+
fav = _fav(r, sgn, ent)
|
|
127
|
+
if fav >= arm:
|
|
128
|
+
armed = True
|
|
129
|
+
if armed:
|
|
130
|
+
lvl = max(lvl or 0.0, fav * share)
|
|
131
|
+
if _adv(r, sgn, ent) <= lvl:
|
|
132
|
+
v = _at(spath, stimes, r[0], ent * (1 + sgn * lvl))
|
|
133
|
+
return None if v is None else v - e["realized"]
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def summarize(rows):
|
|
138
|
+
med = lambda xs: statistics.median(xs) if xs else None # noqa: E731
|
|
139
|
+
wins = [r for r in rows if r["win"]]; losses = [r for r in rows if not r["win"]]
|
|
140
|
+
chased = [r for r in rows if r["chased"]]; calm = [r for r in rows if r["pre24"] is not None and not r["chased"]]
|
|
141
|
+
def pf(rs):
|
|
142
|
+
w = sum(r["realized"] for r in rs if r["realized"] > 0); l = -sum(r["realized"] for r in rs if r["realized"] <= 0)
|
|
143
|
+
return (w / l) if l else (None if not w else float("inf"))
|
|
144
|
+
cuts = {h: [r["cut_cf"][h] for r in rows if r["cut_cf"][h] is not None] for h in (f"{x:.0f}" for x in CUT_GRID_H)}
|
|
145
|
+
locks = {k: [r["lock_cf"][k] for r in rows if r["lock_cf"][k] is not None] for k in rows[0]["lock_cf"]} if rows else {}
|
|
146
|
+
return dict(n=len(rows),
|
|
147
|
+
pre24_median=med([r["pre24"] for r in rows if r["pre24"] is not None]),
|
|
148
|
+
chased_share=(len(chased) / len([r for r in rows if r["pre24"] is not None])) if rows and any(r["pre24"] is not None for r in rows) else None,
|
|
149
|
+
chased_n=len(chased), chased_realized=sum(r["realized"] for r in chased), chased_pf=pf(chased), calm_pf=pf(calm),
|
|
150
|
+
mfe_median_winners=med([r["mfe"] for r in wins]), give_back_median=med([r["give_back"] for r in wins if r["mfe"] > 0]),
|
|
151
|
+
mae_median_losers=med([r["mae"] for r in losses]),
|
|
152
|
+
losers_that_were_green=(sum(1 for r in losses if r["mfe"] >= 0.01) / len(losses)) if losses else None,
|
|
153
|
+
cut=_grid(cuts), lock=_grid(locks))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _grid(d):
|
|
157
|
+
"""Per setting: total $ and count; plus `robust` = the median setting's total when ≥ 2 of the settings are
|
|
158
|
+
positive — the only number the desk is allowed to call a leak."""
|
|
159
|
+
per = {k: dict(total=sum(v), n=len(v)) for k, v in d.items()}
|
|
160
|
+
totals = sorted(x["total"] for x in per.values() if x["n"])
|
|
161
|
+
robust = None
|
|
162
|
+
if len(totals) >= 2 and sum(1 for t in totals if t > 0) >= 2:
|
|
163
|
+
robust = statistics.median(totals)
|
|
164
|
+
return dict(settings=per, robust=robust)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Second person is for the trader's own book. For someone else's wallet the desk speaks in the third
|
|
3
|
+
person — English second person and third person plural share every verb form, so the switch is a
|
|
4
|
+
pronoun rewrite. The product persona ("your quant") stays addressed to the reader, who hired it."""
|
|
5
|
+
# Copyright 2026 Senpi (https://senpi.ai) — Apache-2.0
|
|
6
|
+
import re
|
|
7
|
+
|
|
8
|
+
KEEP = ("YOUR QUANT — LIVE", "Your quant", "your quant", "hire my quant", "your own book", "under **your** name", "under your name", "your name",
|
|
9
|
+
"you could run", "you can run", "you run under", "by the time you see them", "you see them", "Say *hire", "you hired", "you want", "Want me", "Want the",
|
|
10
|
+
"Want their", "Want to", "your say-so", "on your side", "run for you", "run this for you", "tell you", "your agents")
|
|
11
|
+
# object case first: "paid you", "sits with you", "against you" → them. English second person hides the case; third person does not.
|
|
12
|
+
OBJECT = re.compile(r"\b(against|with|behind|ahead of|beside|from|of|by|to|at|on|for|between|around|near|over|under|toward|towards|without|into|onto|off|past|"
|
|
13
|
+
r"paid|pays|paying|pay|cost|costs|costing|tells|tell|telling|told|shows|show|showing|showed|lets|let|letting|gives|give|gave|giving|"
|
|
14
|
+
r"hurt|hurts|hurting|keeps|keep|kept|keeping|puts|put|putting|leaves|leave|left|leaving|makes|make|made|making|gets|get|got|getting|"
|
|
15
|
+
r"beat|beats|beating|saves|save|saved|saving|earns|earn|earned|earning|owes|owe|owed|protects|protect|protected|protecting|"
|
|
16
|
+
r"stops|stop|stopped|stopping|warns|warn|warned|reminds|remind|reminded) you\b")
|
|
17
|
+
PAIRS = (
|
|
18
|
+
(r"\bYou're\b", "They're"), (r"\byou're\b", "they're"), (r"\bYou've\b", "They've"), (r"\byou've\b", "they've"),
|
|
19
|
+
(r"\bYou'd\b", "They'd"), (r"\byou'd\b", "they'd"), (r"\bYou'll\b", "They'll"), (r"\byou'll\b", "they'll"),
|
|
20
|
+
(r"\bYourself\b", "Themselves"), (r"\byourself\b", "themselves"), (r"\bYours\b", "Theirs"), (r"\byours\b", "theirs"),
|
|
21
|
+
(r"\bYour\b", "Their"), (r"\byour\b", "their"), (r"\bYou\b", "They"), (r"\byou\b", "they"),
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def third_person(md, short_addr=None):
|
|
26
|
+
keep = {}
|
|
27
|
+
for i, k in enumerate(KEEP):
|
|
28
|
+
tok = f"\x00{i}\x00"
|
|
29
|
+
keep[tok] = k
|
|
30
|
+
md = md.replace(k, tok)
|
|
31
|
+
md = OBJECT.sub(r"\1 them", md)
|
|
32
|
+
for pat, rep in PAIRS:
|
|
33
|
+
md = re.sub(pat, rep, md)
|
|
34
|
+
for tok, k in keep.items():
|
|
35
|
+
md = md.replace(tok, k)
|
|
36
|
+
if short_addr:
|
|
37
|
+
md = md.replace(f"# Their desk — `{short_addr}`", f"# The desk for `{short_addr}`")
|
|
38
|
+
md = md.replace("| Coin | They | Cohort | Read |", "| Coin | Trader | Cohort | Read |").replace("| Coin | They | Smart money | Read |", "| Coin | Trader | Smart money | Read |")
|
|
39
|
+
md = re.sub(r"(?m)^They: ", "This trader: ", md)
|
|
40
|
+
md = md.replace("They vs smart money", f"`{short_addr}` vs smart money" if short_addr else "This trader vs smart money")
|
|
41
|
+
md = md.replace("What they've been doing", "What they've been doing").replace("The market they're trading in", "The market they're trading in")
|
|
42
|
+
return md
|