@oracle-agent/oracle 0.25.0 → 0.25.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/dist/assets/graphics/assets/arbitrum-lockup.png +0 -0
  2. package/dist/assets/graphics/assets/avalanche-lockup.png +0 -0
  3. package/dist/assets/graphics/assets/base-lockup.png +0 -0
  4. package/dist/assets/graphics/assets/bitcoin-lockup.png +0 -0
  5. package/dist/assets/graphics/assets/bnb-lockup.png +0 -0
  6. package/dist/assets/graphics/assets/ethereum-lockup.png +0 -0
  7. package/dist/assets/graphics/assets/hyperliquid-lockup.png +0 -0
  8. package/dist/assets/graphics/assets/optimism-lockup.png +0 -0
  9. package/dist/assets/graphics/assets/oracle-globe.png +0 -0
  10. package/dist/assets/graphics/assets/polygon-lockup.png +0 -0
  11. package/dist/assets/graphics/assets/polymarket-lockup.png +0 -0
  12. package/dist/assets/graphics/assets/polymarket.png +0 -0
  13. package/dist/assets/graphics/assets/robinhood-lockup.png +0 -0
  14. package/dist/assets/graphics/assets/solana-lockup.png +0 -0
  15. package/dist/assets/graphics/assets/stable-lockup.png +0 -0
  16. package/dist/assets/graphics/fonts/CormorantGaramond-Bold.ttf +0 -0
  17. package/dist/assets/graphics/fonts/RobotoMono-Bold.ttf +0 -0
  18. package/dist/assets/graphics/fonts/dmmono-Medium.ttf +0 -0
  19. package/dist/assets/graphics/oracle_card.py +1165 -0
  20. package/dist/assets/skills/oracle-desk-product/SKILL.md +1 -1
  21. package/dist/bin/desk-server.mjs +44 -44
  22. package/dist/bin/oracle-data-mcp.mjs +68 -27
  23. package/dist/bin/oracle-public-server.mjs +15 -15
  24. package/dist/bin/oracle-route.mjs +1 -1
  25. package/dist/cli/commands/chat.mjs +94 -94
  26. package/dist/cli/commands/doctor.mjs +3 -3
  27. package/dist/cli/commands/eval.mjs +27 -27
  28. package/dist/cli/commands/farm.mjs +57 -57
  29. package/dist/cli/commands/model.mjs +98 -98
  30. package/dist/cli/commands/receipt.mjs +2 -2
  31. package/dist/cli/commands/sign.mjs +13 -13
  32. package/dist/cli/commands/signer.mjs +13 -13
  33. package/dist/cli/commands/strategy.mjs +1 -1
  34. package/dist/cli/commands/swap.mjs +1 -1
  35. package/dist/cli/commands/venues.mjs +2 -2
  36. package/dist/data/desk-data.mjs +42 -42
  37. package/dist/index.mjs +51 -51
  38. package/dist/local-signer/index.mjs +3 -3
  39. package/dist/nft/index.mjs +1 -1
  40. package/dist/prepare-envelope.mjs +1 -1
  41. package/dist/public-api/agent-grants.mjs +3 -3
  42. package/dist/router/index.mjs +10 -10
  43. package/dist/router/prepare-bridge.mjs +1 -1
  44. package/dist/strategy/index.mjs +1 -1
  45. package/package.json +1 -1
@@ -0,0 +1,1165 @@
1
+ #!/usr/bin/env python3
2
+ """Oracle-branded alert cards.
3
+
4
+ RH AGENT brand is dead. This module is the Oracle desk surface:
5
+ same lime/red candles, Oracle splash kit, official chain lockup (logo, not
6
+ a typeset fake), and price-only chrome for Polymarket / HIP-4.
7
+
8
+ TA is printed only when the live series actually trips a rule.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ import tempfile
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+
18
+ # Splash tokens (oracle.demi.la :root)
19
+ ORACLE = {
20
+ "bg": "#0B1018",
21
+ "panel": "#111925",
22
+ "ink": "#F4F9FE",
23
+ "mute": "#9FB8D2",
24
+ "blue": "#7CC4FF",
25
+ "blue2": "#A7D8FF",
26
+ "line": (124 / 255, 196 / 255, 255 / 255, 0.18),
27
+ "glow": "#1a3a55",
28
+ "up": "#7CC4FF",
29
+ "up_wick": "#A7D8FF",
30
+ "down": "#FF5A6A",
31
+ "down_wick": "#C94A56",
32
+ "yes": "#7CC4FF",
33
+ "no": "#FF5A6A",
34
+ "chip_ink": "#061018",
35
+ }
36
+
37
+ # Assets ship with the package, next to this module. ORACLE_CARD_ASSETS overrides.
38
+ _ASSETS = Path(os.environ.get("ORACLE_CARD_ASSETS") or (Path(__file__).resolve().parent / "assets"))
39
+ _WM = _ASSETS
40
+ ORACLE_MARK = _ASSETS / "oracle-globe.png"
41
+ CHAINS = {
42
+ "hyperliquid": {"label": "HYPERLIQUID", "accent": "#50D2C1", "logo": _ASSETS / "hyperliquid-lockup.png", "kind": "ohlc"},
43
+ "hip-3": {"label": "HYPERLIQUID HIP-3", "accent": "#50D2C1", "logo": _ASSETS / "hyperliquid-lockup.png", "kind": "ohlc"},
44
+ "hip-4": {"label": "HYPERLIQUID HIP-4", "accent": "#50D2C1", "logo": _ASSETS / "hyperliquid-lockup.png", "kind": "yesno"},
45
+ "polymarket": {"label": "POLYMARKET", "accent": "#3B82F6", "logo": _ASSETS / "polymarket-lockup.png", "kind": "yesno"},
46
+ "ethereum": {"label": "ETHEREUM", "accent": "#7CC4FF", "logo": _ASSETS / "ethereum-lockup.png", "kind": "ohlc"},
47
+ "base": {"label": "BASE", "accent": "#0052FF", "logo": _ASSETS / "base-lockup.png", "kind": "ohlc"},
48
+ "solana": {"label": "SOLANA", "accent": "#9945FF", "logo": _ASSETS / "solana-lockup.png", "kind": "ohlc"},
49
+ "bitcoin": {"label": "BITCOIN", "accent": "#F7931A", "logo": _ASSETS / "bitcoin-lockup.png", "kind": "ohlc"},
50
+ "arbitrum": {"label": "ARBITRUM", "accent": "#12AAFF", "logo": _ASSETS / "arbitrum-lockup.png", "kind": "ohlc"},
51
+ "optimism": {"label": "OPTIMISM", "accent": "#FF0420", "logo": _ASSETS / "optimism-lockup.png", "kind": "ohlc"},
52
+ "polygon": {"label": "POLYGON", "accent": "#8247E5", "logo": _ASSETS / "polygon-lockup.png", "kind": "ohlc"},
53
+ "bnb": {"label": "BNB CHAIN", "accent": "#F0B90B", "logo": _ASSETS / "bnb-lockup.png", "kind": "ohlc"},
54
+ "avalanche": {"label": "AVALANCHE", "accent": "#E84142", "logo": _ASSETS / "avalanche-lockup.png", "kind": "ohlc"},
55
+ "stable": {"label": "STABLE", "accent": "#26A17B", "logo": _ASSETS / "stable-lockup.png", "kind": "ohlc"},
56
+ "robinhood": {"label": "ROBINHOOD CHAIN", "accent": "#d7ff4d", "logo": _ASSETS / "robinhood-lockup.png", "kind": "ohlc"},
57
+ }
58
+
59
+ _FONTS = {"display": "DejaVu Serif", "sans": "DejaVu Sans", "mono": "DejaVu Sans Mono"}
60
+
61
+
62
+ def _init_fonts():
63
+ try:
64
+ import matplotlib.font_manager as fm
65
+
66
+ roots = [
67
+ Path.home() / ".local/share/fonts",
68
+ Path.home() / ".local/share/fonts/dmmono",
69
+ Path(__file__).resolve().parent / "fonts",
70
+ ]
71
+ for root in roots:
72
+ if not root.exists():
73
+ continue
74
+ for p in root.rglob("*.ttf"):
75
+ try:
76
+ fm.fontManager.addfont(str(p))
77
+ except Exception:
78
+ pass
79
+ names = {f.name for f in fm.fontManager.ttflist}
80
+ for pref in ("Cormorant Garamond", "CormorantGaramond", "DejaVu Serif"):
81
+ if pref in names:
82
+ _FONTS["display"] = pref
83
+ break
84
+ for pref in ("Outfit", "IBM Plex Sans", "DejaVu Sans"):
85
+ if pref in names:
86
+ _FONTS["sans"] = pref
87
+ break
88
+ for pref in ("IBM Plex Mono", "DM Mono", "JetBrains Mono", "DejaVu Sans Mono"):
89
+ if pref in names:
90
+ _FONTS["mono"] = pref
91
+ break
92
+ except Exception:
93
+ pass
94
+ return _FONTS
95
+
96
+
97
+ def _oracle_display_fp(size: float):
98
+ """Oracle display face at a real weight. Variable Cormorant has no bold
99
+ instance in matplotlib — we ship a 700 static cut next to this file."""
100
+ from matplotlib import font_manager as fm
101
+
102
+ here = Path(__file__).resolve().parent / "fonts"
103
+ for name in ("CormorantGaramond-Bold.ttf", "CormorantGaramond-SemiBold.ttf"):
104
+ p = here / name
105
+ if p.exists():
106
+ try:
107
+ fm.fontManager.addfont(str(p))
108
+ except Exception:
109
+ pass
110
+ return fm.FontProperties(fname=str(p), size=size)
111
+ return fm.FontProperties(family=_FONTS.get("display") or "DejaVu Serif", size=size, weight="bold")
112
+
113
+
114
+ def _oracle_num_fp(size: float):
115
+ """Oracle data face for figures. Cormorant is for names; numbers use
116
+ DM Mono / Roboto Mono from a real file so they stay bold and tabular."""
117
+ from matplotlib import font_manager as fm
118
+
119
+ cands = [
120
+ Path(__file__).resolve().parent / "fonts" / "RobotoMono-Bold.ttf",
121
+ Path.home() / ".local/share/fonts/keyitdev/RobotoMono-Bold.ttf",
122
+ Path(__file__).resolve().parent / "fonts" / "dmmono-Medium.ttf",
123
+ Path.home() / ".local/share/fonts/dmmono/dmmono-Medium.ttf",
124
+ Path.home() / ".local/share/fonts/MapleMono/MapleMono-Bold.ttf",
125
+ ]
126
+ for p in cands:
127
+ if not p.exists():
128
+ continue
129
+ try:
130
+ fm.fontManager.addfont(str(p))
131
+ except Exception:
132
+ pass
133
+ return fm.FontProperties(fname=str(p), size=size)
134
+ return fm.FontProperties(family=_FONTS.get("mono") or "DejaVu Sans Mono", size=size, weight="bold")
135
+
136
+
137
+ def _esc(s: str) -> str:
138
+ return str(s or "")
139
+
140
+
141
+ def _fmt_px(n: float) -> str:
142
+ a = abs(n)
143
+ if a >= 1000:
144
+ return f"{n:,.0f}"
145
+ if a >= 1:
146
+ return f"{n:,.2f}"
147
+ if a >= 0.01:
148
+ return f"{n:.4f}"
149
+ return f"{n:.6f}"
150
+
151
+
152
+ def _rsi(closes: list[float], period: int = 14) -> float | None:
153
+ if len(closes) < period + 1:
154
+ return None
155
+ gains = []
156
+ losses = []
157
+ for i in range(-period, 0):
158
+ d = closes[i] - closes[i - 1]
159
+ gains.append(max(d, 0.0))
160
+ losses.append(max(-d, 0.0))
161
+ ag = sum(gains) / period
162
+ al = sum(losses) / period
163
+ if al == 0:
164
+ return 100.0
165
+ rs = ag / al
166
+ return 100.0 - (100.0 / (1.0 + rs))
167
+
168
+
169
+ def _ema(values: list[float], period: int) -> float | None:
170
+ if len(values) < period:
171
+ return None
172
+ k = 2 / (period + 1)
173
+ e = sum(values[:period]) / period
174
+ for v in values[period:]:
175
+ e = v * k + e * (1 - k)
176
+ return e
177
+
178
+
179
+ def spot_ta(rows: list[dict]) -> list[str]:
180
+ """Return only rules that fire on this series. Empty = print nothing."""
181
+ closes = [float(r["c"]) for r in rows if r.get("c") is not None]
182
+ highs = [float(r["h"]) for r in rows if r.get("h") is not None]
183
+ lows = [float(r["l"]) for r in rows if r.get("l") is not None]
184
+ opens = [float(r["o"]) for r in rows if r.get("o") is not None]
185
+ if len(closes) < 8:
186
+ return []
187
+ spotted: list[str] = []
188
+ rsi = _rsi(closes)
189
+ if rsi is not None and rsi >= 70:
190
+ spotted.append(f"RSI {rsi:.0f} overbought")
191
+ elif rsi is not None and rsi <= 30:
192
+ spotted.append(f"RSI {rsi:.0f} oversold")
193
+ e9, e21 = _ema(closes, 9), _ema(closes, 21)
194
+ e9p = _ema(closes[:-1], 9)
195
+ e21p = _ema(closes[:-1], 21)
196
+ if e9 and e21 and e9p and e21p:
197
+ if e9p <= e21p and e9 > e21:
198
+ spotted.append("EMA 9/21 bull cross")
199
+ elif e9p >= e21p and e9 < e21:
200
+ spotted.append("EMA 9/21 bear cross")
201
+ if len(closes) >= 3 and closes[-1] > max(closes[-6:-1]):
202
+ spotted.append("new local high")
203
+ if len(closes) >= 3 and closes[-1] < min(closes[-6:-1]):
204
+ spotted.append("new local low")
205
+ if opens and closes and highs and lows:
206
+ o, c, h, l = opens[-1], closes[-1], highs[-1], lows[-1]
207
+ body = abs(c - o)
208
+ rng = h - l
209
+ if rng > 0 and body / rng >= 0.7 and c > o and o <= closes[-2]:
210
+ spotted.append("bullish engulf")
211
+ if rng > 0 and body / rng >= 0.7 and c < o and o >= closes[-2]:
212
+ spotted.append("bearish engulf")
213
+ return spotted[:4]
214
+
215
+
216
+ def chain_meta(venue: str) -> dict:
217
+ key = str(venue or "").lower().replace(" ", "-")
218
+ if key in ("hl", "hypercore", "hype"):
219
+ key = "hyperliquid"
220
+ if key in ("poly", "pm"):
221
+ key = "polymarket"
222
+ if key in ("rh", "rh-chain", "4663", "robinhood-chain"):
223
+ key = "robinhood"
224
+ return CHAINS.get(key, {"label": key.upper() or "UNKNOWN", "accent": ORACLE["blue"], "logo": None, "kind": "ohlc"})
225
+
226
+
227
+ def _load_logo(path: Path | None):
228
+ if not path or not Path(path).exists():
229
+ return None
230
+ p = Path(path)
231
+ try:
232
+ if p.suffix.lower() == ".svg":
233
+ import subprocess
234
+ import tempfile
235
+ from PIL import Image
236
+
237
+ tmp = Path(tempfile.gettempdir()) / f"{p.stem}-raster.png"
238
+ subprocess.run(["rsvg-convert", "-h", "200", str(p)], check=True, stdout=open(tmp, "wb"))
239
+ return Image.open(tmp).convert("RGBA")
240
+ from PIL import Image
241
+
242
+ return Image.open(p).convert("RGBA")
243
+ except Exception:
244
+ try:
245
+ import matplotlib.image as mpimg
246
+
247
+ return mpimg.imread(str(p))
248
+ except Exception:
249
+ return None
250
+
251
+
252
+ def _chrome(fig, *, venue: str, eyebrow: str, hero: str, hero_color: str, chip: str, chip_bg: str, footer: str, pair_label: str = ""):
253
+ from matplotlib import patheffects as pe
254
+ from matplotlib.lines import Line2D
255
+
256
+ fonts = _init_fonts()
257
+ B = ORACLE
258
+ meta = chain_meta(venue)
259
+ fig.add_artist(Line2D([0.045, 0.955], [0.845, 0.845], transform=fig.transFigure, color=B["line"], linewidth=1.0))
260
+ text_x = 0.048
261
+ globe = _load_logo(ORACLE_MARK)
262
+ if globe is not None:
263
+ try:
264
+ gax = fig.add_axes([0.040, 0.872, 0.078, 0.100], zorder=8)
265
+ gax.imshow(globe)
266
+ gax.axis("off")
267
+ text_x = 0.108
268
+ except Exception:
269
+ text_x = 0.048
270
+ wm = fig.text(text_x, 0.922, "ORACLE", color=B["blue2"], fontsize=16, fontfamily=fonts["display"], fontweight="medium", va="center")
271
+ wm.set_path_effects([pe.withStroke(linewidth=3.0, foreground=B["glow"], alpha=0.65)])
272
+ tag_x = text_x + 0.118
273
+ fig.text(tag_x, 0.922, "CHAIN:", color=B["mute"], fontsize=8.0, fontfamily=fonts["mono"], va="center")
274
+ lockup = _load_logo(meta.get("logo"))
275
+ drew_lockup = False
276
+ if lockup is not None:
277
+ try:
278
+ w, h = getattr(lockup, "size", (180, 28))
279
+ aspect = (w / max(h, 1)) if w and h else 6.0
280
+ lh = 0.048
281
+ lw = min(lh * aspect * (6.3 / 11.2), 0.24)
282
+ lax = fig.add_axes([tag_x + 0.048, 0.922 - lh / 2, lw, lh], zorder=8)
283
+ lax.imshow(lockup)
284
+ lax.axis("off")
285
+ drew_lockup = True
286
+ except Exception:
287
+ drew_lockup = False
288
+ if not drew_lockup:
289
+ fig.text(tag_x + 0.052, 0.922, meta["label"], color=meta["accent"], fontsize=8.0, fontfamily=fonts["mono"], va="center")
290
+ fig.text(0.048, 0.815, eyebrow, color=B["blue"], fontsize=7.2, fontfamily=fonts["mono"], va="top")
291
+ if hero:
292
+ hp = None
293
+ try:
294
+ hp = _oracle_num_fp(26)
295
+ except Exception:
296
+ hp = None
297
+ ht = fig.text(
298
+ 0.952, 0.932, _esc(hero), color=hero_color or B["blue2"],
299
+ fontsize=26, fontfamily=fonts["mono"], fontweight="bold",
300
+ fontproperties=hp, ha="right", va="center", zorder=9,
301
+ parse_math=False,
302
+ )
303
+ if pair_label:
304
+ tp = None
305
+ try:
306
+ tp = _oracle_display_fp(20)
307
+ except Exception:
308
+ tp = None
309
+ # Sit flush left of the MC using the real glyph box.
310
+ gap = 0.014
311
+ x = 0.74
312
+ try:
313
+ fig.canvas.draw()
314
+ bb = ht.get_window_extent(renderer=fig.canvas.get_renderer())
315
+ x0, _ = fig.transFigure.inverted().transform((bb.x0, bb.y0))
316
+ x = x0 - gap
317
+ except Exception:
318
+ x = 0.952 - max(0.18, len(_esc(hero)) * 0.014)
319
+ fig.text(
320
+ x, 0.932, _esc(pair_label),
321
+ color=B["ink"], fontsize=20, fontfamily=fonts["display"],
322
+ fontweight="bold", fontproperties=tp, ha="right", va="center",
323
+ zorder=9,
324
+ )
325
+ if chip:
326
+ fig.text(0.952, 0.868, chip, color=B["blue2"], fontsize=8.2, fontfamily=fonts["mono"], ha="right", va="center")
327
+ fig.add_artist(Line2D([0.045, 0.955], [0.072, 0.072], transform=fig.transFigure, color=B["line"], linewidth=1.0))
328
+ fig.text(0.048, 0.040, os.environ.get("ORACLE_CARD_FOOTER", "oracle.demi.la"), color=B["blue2"], fontsize=6.8, fontfamily=fonts["mono"], fontweight="bold", va="center")
329
+ tag = fig.text(0.500, 0.040, "T H E F U T U R E I S A G E N T I C", color=B["blue2"], fontsize=6.8, fontfamily=fonts["display"], ha="center", va="center")
330
+ tag.set_path_effects([pe.withStroke(linewidth=2.2, foreground=B["glow"], alpha=0.6)])
331
+ right = f"{_esc(footer)} · " if footer else ""
332
+ fig.text(0.952, 0.040, right + datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"), color=B["mute"], fontsize=5.6, fontfamily=fonts["mono"], ha="right", va="center")
333
+
334
+
335
+ def _bg(fig):
336
+ try:
337
+ import numpy as np
338
+
339
+ h, w = 90, 160
340
+ y, x = np.mgrid[0:h, 0:w].astype(float)
341
+ y /= h - 1
342
+ x /= w - 1
343
+ base = np.zeros((h, w, 3))
344
+ base[..., 0], base[..., 1], base[..., 2] = 0x0B / 255, 0x10 / 255, 0x18 / 255
345
+ d = ((x - 0.78) ** 2 * 1.3 + (y + 0.1) ** 2) ** 0.5
346
+ g = np.clip(1 - d / 0.9, 0, 1) ** 2 * 0.16
347
+ base[..., 0] += g * (124 / 255)
348
+ base[..., 1] += g * (196 / 255)
349
+ base[..., 2] += g * (255 / 255)
350
+ ax = fig.add_axes([0, 0, 1, 1], zorder=-10)
351
+ ax.imshow(np.clip(base, 0, 1), aspect="auto", interpolation="bilinear")
352
+ ax.set_xticks([])
353
+ ax.set_yticks([])
354
+ for sp in ax.spines.values():
355
+ sp.set_visible(False)
356
+ except Exception:
357
+ pass
358
+
359
+
360
+ def render_spot_card(
361
+ *,
362
+ symbol: str,
363
+ venue: str = "robinhood",
364
+ hero: str | None = None,
365
+ chip: str | None = None,
366
+ mid: str | None = None,
367
+ sub: str | None = None,
368
+ reason: str = "OHLCV UNAVAILABLE",
369
+ out_path: str | None = None,
370
+ ) -> str | None:
371
+ """Oracle chrome with live stats but no fake candles.
372
+
373
+ Used when Gecko OHLCV 429s. Honest: no data, no fake lines.
374
+ """
375
+ import matplotlib
376
+
377
+ matplotlib.use("Agg")
378
+ import matplotlib.pyplot as plt
379
+
380
+ fonts = _init_fonts()
381
+ B = ORACLE
382
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
383
+ _bg(fig)
384
+ ax = fig.add_axes([0.075, 0.135, 0.875, 0.68])
385
+ ax.set_facecolor(B["panel"])
386
+ ax.set_xticks([])
387
+ ax.set_yticks([])
388
+ for sp in ax.spines.values():
389
+ sp.set_visible(False)
390
+ sym = str(symbol or "TOKEN").lstrip("$").upper()
391
+ ax.text(0.06, 0.72, f"${sym}", color=B["ink"], fontsize=28, fontfamily=fonts["display"], fontweight="medium", transform=ax.transAxes)
392
+ if mid:
393
+ ax.text(0.06, 0.48, mid, color=B["blue2"], fontsize=18, fontfamily=fonts["display"], transform=ax.transAxes)
394
+ if sub:
395
+ ax.text(0.06, 0.30, sub, color=B["mute"], fontsize=12, fontfamily=fonts["mono"], transform=ax.transAxes)
396
+ ax.text(0.06, 0.12, reason, color=B["mute"], fontsize=8.5, fontfamily=fonts["mono"], transform=ax.transAxes)
397
+ _chrome(
398
+ fig,
399
+ venue=venue,
400
+ eyebrow=f"{sym} · SPOT",
401
+ hero=hero or "",
402
+ hero_color=B["blue2"],
403
+ chip=chip or "",
404
+ chip_bg=B["blue"],
405
+ footer=reason,
406
+ )
407
+ dest = out_path or str(Path(tempfile.gettempdir()) / f"oracle-spot-{sym[:12]}.png")
408
+ fig.savefig(dest, facecolor=fig.get_facecolor())
409
+ plt.close(fig)
410
+ return dest if Path(dest).exists() else None
411
+
412
+
413
+ def render_ohlc_card(
414
+ rows: list[dict],
415
+ *,
416
+ venue: str,
417
+ symbol: str,
418
+ out_path: str | None = None,
419
+ source: str = "LIVE",
420
+ trades: list[dict] | None = None,
421
+ avg_entry: float | None = None,
422
+ targets: list[float] | None = None,
423
+ stop: float | None = None,
424
+ setup: str | None = None,
425
+ pair: str | None = None,
426
+ market: str | None = None,
427
+ hero: str | None = None,
428
+ chip: str | None = None,
429
+ chip_bg: str | None = None,
430
+ edge_px: float | None = None,
431
+ holders_delta_24h: int | None = None,
432
+ holders_now: int | None = None,
433
+ ) -> str | None:
434
+ """OHLC card with optional position overlays.
435
+
436
+ trades: [{"t": ms|s, "px": float, "side": "buy"|"sell"}] — entry/exit markers.
437
+ avg_entry: dashed line at position average.
438
+ targets: sell-point / take-profit lines (TP1, TP2, ...).
439
+ stop: stop-loss line.
440
+ setup: one-line trade setup note, rendered with the TA chips.
441
+ pair: full trading pair, e.g. "BTC-USD" or "HYPE/USDC".
442
+ market: market type — "PERP", "SPOT", "HIP-3", ... shown next to the pair.
443
+ hero: override the top-right hero text (e.g. "PNL +$132") — price when None.
444
+ chip: override the chip text (e.g. "MC $866.8K · -55.8%") — 24h % when None.
445
+ chip_bg: override chip color; defaults to up/down by 24h change.
446
+ """
447
+ if not rows or len(rows) < 3:
448
+ return None
449
+ import matplotlib
450
+
451
+ matplotlib.use("Agg")
452
+ import matplotlib.dates as mdates
453
+ import matplotlib.pyplot as plt
454
+ from matplotlib.patches import Rectangle
455
+
456
+ fonts = _init_fonts()
457
+ B = ORACLE
458
+ xs = [datetime.fromtimestamp(int(r["t"]) / (1000 if int(r["t"]) > 1e12 else 1), tz=timezone.utc) for r in rows]
459
+ opens = [float(r["o"]) for r in rows]
460
+ highs = [float(r["h"]) for r in rows]
461
+ lows = [float(r["l"]) for r in rows]
462
+ closes = [float(r["c"]) for r in rows]
463
+ vols = [float(r.get("v") or 0) for r in rows]
464
+ last_c = closes[-1] if closes else 0.0
465
+ if last_c > 0:
466
+ keep = [i for i in range(len(closes)) if 0.4 * last_c <= closes[i] <= 2.5 * last_c]
467
+ if 8 <= len(keep) < len(closes):
468
+ xs = [xs[i] for i in keep]
469
+ opens = [opens[i] for i in keep]
470
+ highs = [highs[i] for i in keep]
471
+ lows = [lows[i] for i in keep]
472
+ closes = [closes[i] for i in keep]
473
+ vols = [vols[i] for i in keep]
474
+ rows = [rows[i] for i in keep]
475
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
476
+ _bg(fig)
477
+ ax = fig.add_axes([0.075, 0.195, 0.875, 0.575])
478
+ axv = fig.add_axes([0.075, 0.115, 0.875, 0.078], sharex=ax)
479
+ for a in (ax, axv):
480
+ a.set_facecolor(B["panel"])
481
+ a.tick_params(colors=B["mute"], labelsize=7.0)
482
+ for lbl in a.get_xticklabels() + a.get_yticklabels():
483
+ lbl.set_fontfamily(fonts["mono"])
484
+ for sp in a.spines.values():
485
+ sp.set_visible(False)
486
+ a.grid(color="#152031", linewidth=0.6)
487
+ a.set_axisbelow(True)
488
+ plt.setp(ax.get_xticklabels(), visible=False)
489
+ xnum = mdates.date2num(xs)
490
+ deltas = [xnum[i] - xnum[i - 1] for i in range(1, len(xnum)) if xnum[i] > xnum[i - 1]]
491
+ med = sorted(deltas)[len(deltas) // 2] if deltas else (15.0 / 1440.0)
492
+ wide = min(max(med * 0.55, 4.0 / 1440.0), 18.0 / 1440.0)
493
+ last_px = closes[-1] if closes else 0.0
494
+ clip_wicks = last_px >= 20
495
+ for i in range(len(rows)):
496
+ o, h, l, c = opens[i], highs[i], lows[i], closes[i]
497
+ if clip_wicks:
498
+ body_hi, body_lo = max(o, c), min(o, c)
499
+ cap = max(abs(c) * 0.012, (body_hi - body_lo) * 2.5, 0.15)
500
+ h = min(h, body_hi + cap)
501
+ l = max(l, body_lo - cap)
502
+ up = c >= o
503
+ body_c = B["up"] if up else B["down"]
504
+ wick_c = B["up_wick"] if up else B["down_wick"]
505
+ ax.plot([xnum[i], xnum[i]], [l, h], color=wick_c, linewidth=0.85, zorder=2, solid_capstyle="round")
506
+ body_lo = min(o, c)
507
+ body_h = abs(c - o) or (h - l) * 0.04 or abs(c) * 1e-4
508
+ ax.add_patch(Rectangle((xnum[i] - wide / 2, body_lo), wide, body_h, facecolor=body_c, edgecolor=body_c, linewidth=0.4, zorder=3))
509
+ axv.bar(xnum[i], vols[i], width=wide, color=body_c, alpha=0.42, zorder=2)
510
+ last = closes[-1]
511
+ ax.axhline(last, color=B["blue"], linewidth=0.8, alpha=0.35, linestyle=(0, (4, 3)), zorder=4)
512
+
513
+ edge_pins = {"top": 0, "bottom": 0}
514
+
515
+ def _pxline(y, color, label):
516
+ # Levels inside (or near) the price window draw as lines; far-away
517
+ # levels pin to the panel edge so they never squash the candles.
518
+ lo_v, hi_v = ax.get_ylim()
519
+ if lo_v <= y <= hi_v:
520
+ ax.axhline(y, color=color, linewidth=0.9, alpha=0.75, linestyle=(0, (5, 3)), zorder=4)
521
+ ax.annotate(
522
+ f"{label} {_fmt_px(y)}", xy=(1.0, y), xycoords=("axes fraction", "data"),
523
+ xytext=(-4, 3), textcoords="offset points", va="bottom", ha="right",
524
+ fontsize=6.4, fontfamily=fonts["mono"], fontweight="bold", color=color, zorder=6,
525
+ )
526
+ else:
527
+ above = y > hi_v
528
+ slot = edge_pins["top" if above else "bottom"]
529
+ edge_pins["top" if above else "bottom"] += 1
530
+ off = 4 + slot * 11 # stack multiple out-of-range labels
531
+ ax.annotate(
532
+ f"{label} {_fmt_px(y)} {'↑' if above else '↓'}",
533
+ xy=(1.0, 1.0 if above else 0.0), xycoords="axes fraction",
534
+ xytext=(-4, -off if above else off), textcoords="offset points",
535
+ va="top" if above else "bottom", ha="right",
536
+ fontsize=6.4, fontfamily=fonts["mono"], fontweight="bold", color=color, zorder=6,
537
+ )
538
+
539
+ # Zoom to the candles: the 24h range owns the panel. Overlay levels never
540
+ # stretch the scale (that's what made candles tiny); they clamp instead.
541
+ lo_c, hi_c = min(lows), max(highs)
542
+ pad_c = (hi_c - lo_c) * 0.08 or max(abs(hi_c) * 1e-4, 1e-9)
543
+ ax.set_ylim(lo_c - pad_c, hi_c + pad_c)
544
+ if avg_entry is not None:
545
+ _pxline(avg_entry, "#FFD166", "AVG")
546
+ for i_t, tp in enumerate(targets or [], start=1):
547
+ _pxline(tp, "#7DF9B9", f"TP{i_t}" if len(targets or []) > 1 else "SELL")
548
+ if stop is not None:
549
+ _pxline(stop, B["down"], "STOP")
550
+ # Entry/exit markers on the candles.
551
+ for tr in trades or []:
552
+ try:
553
+ tt = int(tr.get("t"))
554
+ if tt > 1e12:
555
+ tt /= 1000
556
+ tx = mdates.date2num(datetime.fromtimestamp(tt, tz=timezone.utc))
557
+ tpx = float(tr.get("px"))
558
+ buy = str(tr.get("side", "buy")).lower().startswith("b")
559
+ ax.scatter(
560
+ [tx], [tpx], marker="^" if buy else "v", s=64,
561
+ color="#7DF9B9" if buy else B["down"], edgecolors=B["bg"],
562
+ linewidths=0.8, zorder=7,
563
+ )
564
+ ax.annotate(
565
+ "ENTRY" if buy else "EXIT", xy=(tx, tpx),
566
+ xytext=(0, 10 if buy else -14), textcoords="offset points",
567
+ ha="center", fontsize=5.6, fontfamily=fonts["mono"], fontweight="bold",
568
+ color="#7DF9B9" if buy else B["down"], zorder=7,
569
+ )
570
+ except Exception:
571
+ continue
572
+ ax.annotate(
573
+ _esc(_fmt_px(edge_px if edge_px is not None else last)), xy=(1.0, last), xycoords=("axes fraction", "data"),
574
+ xytext=(4, 0), textcoords="offset points", va="center", ha="left",
575
+ fontsize=7.0, fontfamily=fonts["mono"], fontweight="bold", color=B["chip_ink"],
576
+ bbox={"boxstyle": "round,pad=0.30", "facecolor": B["blue2"], "edgecolor": "none"}, zorder=6, annotation_clip=False,
577
+ )
578
+ ta = spot_ta(rows)
579
+ chips = ([f"SETUP: {setup}"] if setup else []) + ta
580
+ if chips:
581
+ ax.text(
582
+ 0.01, 0.97, " · ".join(chips), transform=ax.transAxes, ha="left", va="top",
583
+ fontsize=7.2, fontfamily=fonts["mono"], color=B["blue2"],
584
+ bbox={"boxstyle": "round,pad=0.32", "facecolor": "#0B1018", "edgecolor": B["blue"], "linewidth": 0.8, "alpha": 0.9},
585
+ zorder=10,
586
+ )
587
+ axv.xaxis_date()
588
+ axv.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
589
+ axv.set_yticks([])
590
+ if last >= 1:
591
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _p: f"{v:,.2f}"))
592
+ # 24h % gain: change vs the close nearest 24h before the last bar.
593
+ last_ts = xs[-1].timestamp()
594
+ base = None
595
+ for i in range(len(xs)):
596
+ if xs[i].timestamp() <= last_ts - 24 * 3600:
597
+ base = closes[i]
598
+ if base is None:
599
+ base = closes[0]
600
+ chg = (closes[-1] / base - 1) * 100 if base > 0 else 0
601
+ if last >= 20 and abs(chg) > 80:
602
+ chg = 0.0
603
+ if chip is None:
604
+ bits = []
605
+ if not (last >= 20 and abs((closes[-1] / base - 1) * 100 if base > 0 else 0) > 80):
606
+ bits.append(f"{chg:+.2f}% 24H")
607
+ elif chg != 0:
608
+ bits.append(f"{chg:+.2f}% 24H")
609
+ else:
610
+ bits.append("24H n/a")
611
+ if holders_delta_24h is not None:
612
+ bits.append(f"HLD {int(holders_delta_24h):+d}/24H")
613
+ elif holders_now is not None:
614
+ bits.append(f"HLD {int(holders_now)}")
615
+ chip = " · ".join(bits)
616
+ _chip_bg = chip_bg or (B["up"] if chg >= 0 else B["down"])
617
+ _chrome(
618
+ fig, venue=venue, eyebrow="",
619
+ hero=hero if hero is not None else f"{_fmt_px(last)}", hero_color=B["blue2"],
620
+ chip=chip,
621
+ chip_bg=_chip_bg,
622
+ footer=f"{source} · CANDLES · {chain_meta(venue)['label']}",
623
+ pair_label=str(pair or "").upper(),
624
+ )
625
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-card-{venue}-{symbol}.png")
626
+ fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
627
+ plt.close(fig)
628
+ return out if os.path.exists(out) and os.path.getsize(out) > 0 else None
629
+
630
+
631
+ def render_yesno_card(
632
+ yes_series: list[dict],
633
+ no_series: list[dict] | None = None,
634
+ *,
635
+ venue: str,
636
+ title: str,
637
+ yes: float | None = None,
638
+ no: float | None = None,
639
+ volume: float | None = None,
640
+ out_path: str | None = None,
641
+ source: str = "LIVE",
642
+ ) -> str | None:
643
+ """Polymarket / HIP-4: two-line YES/NO price graph. Not candles."""
644
+ import matplotlib
645
+
646
+ matplotlib.use("Agg")
647
+ import matplotlib.dates as mdates
648
+ import matplotlib.pyplot as plt
649
+
650
+ fonts = _init_fonts()
651
+ B = ORACLE
652
+ def series(rows):
653
+ xs, ys = [], []
654
+ for r in rows or []:
655
+ t = r.get("t") or r.get("time")
656
+ p = r.get("p") if r.get("p") is not None else r.get("c")
657
+ if t is None or p is None:
658
+ continue
659
+ t = int(t)
660
+ if t > 1e12:
661
+ t = t / 1000
662
+ xs.append(datetime.fromtimestamp(t, tz=timezone.utc))
663
+ ys.append(float(p) * (100.0 if float(p) <= 1.5 else 1.0))
664
+ return xs, ys
665
+
666
+ yx, yy = series(yes_series)
667
+ nx, ny = series(no_series)
668
+ if not yy and yes is not None:
669
+ now = datetime.now(timezone.utc)
670
+ yx, yy = [now], [(yes * 100 if yes <= 1.5 else yes)]
671
+ if not ny:
672
+ if no is not None:
673
+ nx, ny = yx[:], [(no * 100 if no <= 1.5 else no)] * len(yx or [0])
674
+ elif yy:
675
+ nx, ny = yx, [100.0 - v for v in yy]
676
+ if not yy:
677
+ return None
678
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
679
+ _bg(fig)
680
+ ax = fig.add_axes([0.075, 0.125, 0.875, 0.655])
681
+ ax.set_facecolor(B["panel"])
682
+ ax.tick_params(colors=B["mute"], labelsize=7.0)
683
+ for lbl in ax.get_xticklabels() + ax.get_yticklabels():
684
+ lbl.set_fontfamily(fonts["mono"])
685
+ for sp in ax.spines.values():
686
+ sp.set_visible(False)
687
+ ax.grid(color="#152031", linewidth=0.5, alpha=0.7)
688
+ ax.set_axisbelow(True)
689
+ ax.plot(yx, yy, color=B["yes"], linewidth=2.0, label="YES", zorder=3, solid_capstyle="round")
690
+ if ny and nx:
691
+ ax.plot(nx, ny, color=B["no"], linewidth=2.0, label="NO", zorder=3, solid_capstyle="round")
692
+ last = yy[-1]
693
+ ax.set_ylim(0, 100)
694
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _p: f"{v:.0f}¢"))
695
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
696
+ ax.legend(loc="upper left", frameon=False, labelcolor=B["ink"], fontsize=8, prop={"family": fonts["mono"]})
697
+ last = yy[-1]
698
+ chg_24 = None
699
+ if yx and len(yx) >= 2:
700
+ cutoff = yx[-1].timestamp() - 24 * 3600
701
+ older = [yy[i] for i, t in enumerate(yx) if t.timestamp() <= cutoff]
702
+ if older and older[-1] > 0:
703
+ chg_24 = (last / older[-1] - 1.0) * 100.0
704
+ chip = f"{chg_24:+.1f}% 24H" if chg_24 is not None else ""
705
+ _chrome(
706
+ fig, venue=venue, eyebrow=title[:52],
707
+ hero=f"{last:.1f}¢", hero_color=B["blue2"],
708
+ chip=chip, chip_bg=B["blue"],
709
+ footer=f"{source} · YES / NO",
710
+ )
711
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-yesno-{venue}.png")
712
+ fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
713
+ plt.close(fig)
714
+ return out if os.path.exists(out) and os.path.getsize(out) > 0 else None
715
+
716
+
717
+ def render_stat_card(
718
+ *,
719
+ venue: str,
720
+ title: str,
721
+ hero: str,
722
+ hero_color: str | None = None,
723
+ chip: str = "",
724
+ stats: list[tuple[str, str]] | None = None,
725
+ rows: list[list[str]] | None = None,
726
+ row_header: list[str] | None = None,
727
+ note: str | None = None,
728
+ out_path: str | None = None,
729
+ source: str = "LIVE",
730
+ kind: str = "stat",
731
+ ) -> str | None:
732
+ """Generic Oracle stat card: hero + stat tiles + optional table.
733
+
734
+ stats: [(label, value)] tiles across the top of the panel (max 8).
735
+ rows/row_header: optional table (e.g. vault list, trait floors, top offers).
736
+ Backs the DeFi yield and NFT surfaces — anything card-worthy without candles.
737
+ """
738
+ import matplotlib
739
+
740
+ matplotlib.use("Agg")
741
+ import matplotlib.pyplot as plt
742
+
743
+ fonts = _init_fonts()
744
+ B = ORACLE
745
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
746
+ _bg(fig)
747
+ ax = fig.add_axes([0.048, 0.105, 0.904, 0.685])
748
+ ax.set_facecolor(B["panel"])
749
+ ax.set_xticks([])
750
+ ax.set_yticks([])
751
+ ax.set_xlim(0, 1)
752
+ ax.set_ylim(0, 1)
753
+ for sp in ax.spines.values():
754
+ sp.set_color(B["line"])
755
+ sp.set_linewidth(1.0)
756
+ tiles = (stats or [])[:8]
757
+ if tiles:
758
+ per_row = 4 if len(tiles) > 3 else max(len(tiles), 1)
759
+ tile_w = 1.0 / per_row
760
+ n_tile_rows = (len(tiles) + per_row - 1) // per_row
761
+ tile_h = 0.30 if n_tile_rows > 1 else 0.34
762
+ for i, (lab, val) in enumerate(tiles):
763
+ cx = (i % per_row) * tile_w + tile_w / 2
764
+ cy = 0.985 - (i // per_row) * tile_h
765
+ ax.text(cx, cy - 0.055, str(lab).upper(), ha="center", va="top", fontsize=7.0, fontfamily=fonts["mono"], color=B["mute"])
766
+ np_ = None
767
+ try:
768
+ np_ = _oracle_num_fp(15)
769
+ except Exception:
770
+ np_ = None
771
+ ax.text(
772
+ cx, cy - 0.115, _esc(str(val)), ha="center", va="top",
773
+ fontsize=15, fontfamily=fonts["mono"], fontweight="bold",
774
+ fontproperties=np_, color=B["ink"], parse_math=False,
775
+ )
776
+ table_top = 0.985 - n_tile_rows * tile_h - 0.03
777
+ else:
778
+ table_top = 0.95
779
+ if rows:
780
+ cols = max(len(r) for r in rows) if rows else 0
781
+ col_x = [0.03 + c * (0.94 / max(cols - 1, 1)) for c in range(cols)] if cols > 1 else [0.03]
782
+ y = table_top
783
+ if row_header:
784
+ for c, htxt in enumerate(row_header[:cols]):
785
+ ax.text(col_x[c], y, str(htxt).upper(), ha="left" if c == 0 else "right", va="top", fontsize=6.6, fontfamily=fonts["mono"], color=B["blue"])
786
+ y -= 0.075
787
+ for r in rows[:7]:
788
+ for c, cell in enumerate(r[:cols]):
789
+ ax.text(col_x[c], y, _esc(str(cell)), ha="left" if c == 0 else "right", va="top", fontsize=8.2, fontfamily=fonts["mono"], color=B["ink"])
790
+ y -= 0.082
791
+ if note:
792
+ ax.text(0.03, 0.035, _esc(note), ha="left", va="bottom", fontsize=6.6, fontfamily=fonts["sans"], color=B["mute"])
793
+ _chrome(
794
+ fig, venue=venue, eyebrow=title[:60],
795
+ hero=hero, hero_color=hero_color or B["blue2"],
796
+ chip=chip, chip_bg=B["blue"],
797
+ footer=source,
798
+ )
799
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-{kind}-{venue}.png")
800
+ fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
801
+ plt.close(fig)
802
+ return out if os.path.exists(out) and os.path.getsize(out) > 0 else None
803
+
804
+
805
+ def render_yield_card(
806
+ *,
807
+ venue: str,
808
+ title: str,
809
+ vaults: list[dict],
810
+ out_path: str | None = None,
811
+ source: str = "LIVE",
812
+ ) -> str | None:
813
+ """DeFi yield card. vaults: [{name, apy, tvl, chain?, note?}] sorted best-first."""
814
+ if not vaults:
815
+ return None
816
+ best = vaults[0]
817
+ rows = []
818
+ for v in vaults[:7]:
819
+ rows.append([
820
+ str(v.get("name", "?"))[:28],
821
+ str(v.get("chain", "") or "").upper(),
822
+ f"{float(v['apy']):.2f}%" if v.get("apy") is not None else "UNKNOWN",
823
+ f"${float(v['tvl']):,.0f}" if v.get("tvl") is not None else "UNKNOWN",
824
+ ])
825
+ return render_stat_card(
826
+ venue=venue, title=title,
827
+ hero=f"{float(best['apy']):.2f}% APY" if best.get("apy") is not None else "UNKNOWN",
828
+ hero_color=ORACLE["up"],
829
+ chip=f"TOP: {str(best.get('name', ''))[:24]}",
830
+ rows=rows, row_header=["VAULT", "CHAIN", "APY", "TVL"],
831
+ note="APY is variable. Live read, not a promise.",
832
+ out_path=out_path, source=source, kind="yield",
833
+ )
834
+
835
+
836
+ def render_nft_card(
837
+ *,
838
+ venue: str,
839
+ collection: str,
840
+ floor: float | None = None,
841
+ currency: str = "ETH",
842
+ floor_chg_24h: float | None = None,
843
+ volume_24h: float | None = None,
844
+ sales_24h: int | None = None,
845
+ listed: int | None = None,
846
+ listed_plus: bool = False,
847
+ supply: int | None = None,
848
+ owners: int | None = None,
849
+ top_offer: float | None = None,
850
+ traits: list[list[str]] | None = None,
851
+ floor_series: list[dict] | None = None,
852
+ image: str | None = None,
853
+ note: str | None = None,
854
+ out_path: str | None = None,
855
+ source: str = "LIVE",
856
+ ) -> str | None:
857
+ """Premium NFT card: NFT image panel + floor-price chart + stat strip.
858
+
859
+ floor_series: [{"t": ms|s, "p"|"c": float}] floor history → line chart.
860
+ image: local path or http(s) URL of the NFT / collection image.
861
+ Falls back to the stat-tile layout when neither image nor series exists.
862
+ """
863
+ if not floor_series and not image:
864
+ fmt = lambda v: f"{_fmt_px(v)} {currency}" if v is not None else "UNKNOWN"
865
+ stats = [
866
+ ("floor", fmt(floor)),
867
+ ("top offer", fmt(top_offer)),
868
+ ("24h vol", fmt(volume_24h)),
869
+ ("24h sales", f"{sales_24h:,}" if sales_24h is not None else "UNKNOWN"),
870
+ ("listed", f"{listed:,}" if listed is not None else "UNKNOWN"),
871
+ ("supply", f"{supply:,}" if supply is not None else "UNKNOWN"),
872
+ ("owners", f"{owners:,}" if owners is not None else "UNKNOWN"),
873
+ ("listed %", f"{listed / supply * 100:.1f}%" if listed and supply else "UNKNOWN"),
874
+ ]
875
+ chip = f"{floor_chg_24h:+.1f}% FLOOR 24H" if floor_chg_24h is not None else ""
876
+ hero_color = ORACLE["up"] if (floor_chg_24h or 0) >= 0 else ORACLE["down"]
877
+ return render_stat_card(
878
+ venue=venue, title=collection,
879
+ hero=fmt(floor), hero_color=hero_color, chip=chip,
880
+ stats=stats,
881
+ rows=traits, row_header=["TRAIT", "FLOOR", "LISTED"] if traits else None,
882
+ note=note, out_path=out_path, source=source, kind="nft",
883
+ )
884
+
885
+ import matplotlib
886
+
887
+ matplotlib.use("Agg")
888
+ import matplotlib.dates as mdates
889
+ import matplotlib.pyplot as plt
890
+
891
+ fonts = _init_fonts()
892
+ B = ORACLE
893
+ fmt = lambda v: f"{_fmt_px(v)} {currency}" if v is not None else "UNKNOWN"
894
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
895
+ _bg(fig)
896
+
897
+ img_obj = None
898
+ if image:
899
+ try:
900
+ src = image
901
+ if str(image).startswith(("http://", "https://")):
902
+ import urllib.request
903
+
904
+ tmp_img = Path(tempfile.gettempdir()) / f"oracle-nft-img-{abs(hash(image))}.bin"
905
+ if not tmp_img.exists():
906
+ req = urllib.request.Request(str(image), headers={
907
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
908
+ "accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
909
+ })
910
+ tmp_img.write_bytes(urllib.request.urlopen(req, timeout=15).read())
911
+ src = str(tmp_img)
912
+ from PIL import Image
913
+
914
+ raw = Path(src).read_bytes()[:200]
915
+ if src.lower().endswith(".svg") or raw.lstrip().startswith(b"<svg") or b"<svg" in raw[:80]:
916
+ png = Path(src).with_suffix(".png")
917
+ import subprocess
918
+ subprocess.run(
919
+ ["rsvg-convert", "-w", "720", "-h", "720", "-o", str(png), src],
920
+ check=True, timeout=12, capture_output=True,
921
+ )
922
+ src = str(png)
923
+ img_obj = Image.open(src).convert("RGB")
924
+ except Exception:
925
+ img_obj = None
926
+
927
+ # Left: NFT image, flush under the collection name. Right: floor chart.
928
+ chart_left = 0.075
929
+ panel_y, panel_h = 0.228, 0.538
930
+ if img_obj is not None:
931
+ iax = fig.add_axes([0.048, panel_y, 0.248, panel_h], zorder=5)
932
+ iax.imshow(img_obj)
933
+ iax.set_xticks([])
934
+ iax.set_yticks([])
935
+ for sp in iax.spines.values():
936
+ sp.set_color(B["blue"])
937
+ sp.set_linewidth(1.15)
938
+ sp.set_alpha(0.55)
939
+ chart_left = 0.318
940
+ ax = fig.add_axes([chart_left, panel_y, 0.952 - chart_left, panel_h])
941
+ ax.set_facecolor(B["panel"])
942
+ ax.tick_params(colors=B["mute"], labelsize=6.8)
943
+ for lbl in ax.get_xticklabels() + ax.get_yticklabels():
944
+ lbl.set_fontfamily(fonts["mono"])
945
+ for sp in ax.spines.values():
946
+ sp.set_visible(False)
947
+ ax.grid(color="#152031", linewidth=0.5, alpha=0.7)
948
+ ax.set_axisbelow(True)
949
+ fx, fy = [], []
950
+ for r in floor_series or []:
951
+ t = r.get("t") or r.get("time")
952
+ p = r.get("p") if r.get("p") is not None else r.get("c")
953
+ if t is None or p is None:
954
+ continue
955
+ t = int(t)
956
+ if t > 1e12:
957
+ t /= 1000
958
+ fx.append(datetime.fromtimestamp(t, tz=timezone.utc))
959
+ fy.append(float(p))
960
+ if fx:
961
+ # Clamp the y-range to the recent floor action. A single ancient spike
962
+ # (mint-day 1.9 ETH vs 0.0125 today) must not flatten the whole line —
963
+ # same law as the candle window. Focus on the last 60% of samples plus
964
+ # padding; the full series still draws, the axis just doesn't chase
965
+ # outliers.
966
+ tail = fy[max(0, int(len(fy) * 0.4)):]
967
+ lo, hi = min(tail), max(tail)
968
+ span = (hi - lo) or (hi * 0.1) or 1e-9
969
+ pad = span * 0.18
970
+ ax.plot(fx, fy, color=B["blue"], linewidth=2.0, zorder=3, solid_capstyle="round")
971
+ ax.fill_between(fx, fy, lo - pad, alpha=0.10, color=B["blue"], zorder=2)
972
+ ax.set_ylim(lo - pad, hi + pad)
973
+ if max(fy) > hi + pad:
974
+ ax.annotate(
975
+ _esc(f"ATH {fmt(max(fy))}"), xy=(0.01, 0.99), xycoords="axes fraction",
976
+ va="top", ha="left", fontsize=6.0, fontfamily=fonts["mono"], color=B["mute"],
977
+ )
978
+ ax.axhline(fy[-1], color=B["blue"], linewidth=0.7, alpha=0.35, linestyle=(0, (4, 3)))
979
+ ax.annotate(
980
+ _esc(fmt(fy[-1])), xy=(1.0, fy[-1]), xycoords=("axes fraction", "data"),
981
+ xytext=(4, 0), textcoords="offset points", va="center", ha="left",
982
+ fontsize=6.6, fontfamily=fonts["mono"], fontweight="bold", color=B["chip_ink"],
983
+ bbox={"boxstyle": "round,pad=0.30", "facecolor": B["blue2"], "edgecolor": "none"}, zorder=6, annotation_clip=False,
984
+ )
985
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
986
+ ax.text(0.01, 0.97, "FLOOR", transform=ax.transAxes, ha="left", va="top", fontsize=6.6, fontfamily=fonts["mono"], color=B["mute"])
987
+ else:
988
+ ax.set_xticks([])
989
+ ax.set_yticks([])
990
+ ax.text(0.5, 0.5, "NO FLOOR HISTORY", ha="center", va="center", fontsize=9, fontfamily=fonts["mono"], color=B["mute"], transform=ax.transAxes)
991
+ # Bottom stat strip.
992
+ chg_txt = f"{floor_chg_24h:+.1f}%" if floor_chg_24h is not None else "UNKNOWN"
993
+ if listed is None:
994
+ listed_txt = "UNKNOWN"
995
+ elif listed_plus and supply:
996
+ listed_txt = f"{listed:,}+/{supply:,}"
997
+ elif listed_plus:
998
+ listed_txt = f"{listed:,}+"
999
+ elif supply:
1000
+ listed_txt = f"{listed:,}/{supply:,}"
1001
+ else:
1002
+ listed_txt = f"{listed:,}"
1003
+ strip = [
1004
+ ("FLOOR", fmt(floor if floor is not None else (fy[-1] if fy else None))),
1005
+ ("24H %", chg_txt),
1006
+ ("TOP OFFER", fmt(top_offer)),
1007
+ ("24H VOL", fmt(volume_24h)),
1008
+ ("24H SALES", f"{sales_24h:,}" if sales_24h is not None else "UNKNOWN"),
1009
+ ("LISTED", listed_txt),
1010
+ ("HOLDERS", f"{owners:,}" if owners is not None else "UNKNOWN"),
1011
+ ]
1012
+ n = len(strip)
1013
+ for i, (lab, val) in enumerate(strip):
1014
+ cx = 0.048 + (0.904 / n) * (i + 0.5)
1015
+ col = B["ink"]
1016
+ if lab == "24H %" and floor_chg_24h is not None:
1017
+ col = B["up"] if floor_chg_24h >= 0 else B["down"]
1018
+ fig.text(cx, 0.185, lab, ha="center", va="center", fontsize=6.2, fontfamily=fonts["mono"], color=B["mute"])
1019
+ np_ = None
1020
+ try:
1021
+ np_ = _oracle_num_fp(12.5)
1022
+ except Exception:
1023
+ np_ = None
1024
+ fig.text(
1025
+ cx, 0.135, _esc(str(val)), ha="center", va="center",
1026
+ fontsize=12.5, fontfamily=fonts["mono"], fontweight="bold",
1027
+ fontproperties=np_, color=col, parse_math=False,
1028
+ )
1029
+ chip_bits = []
1030
+ if floor_chg_24h is not None:
1031
+ chip_bits.append(f"{floor_chg_24h:+.1f}% 24H")
1032
+ if sales_24h is not None:
1033
+ chip_bits.append(f"{sales_24h:,} SOLD")
1034
+ if owners is not None:
1035
+ chip_bits.append(f"{owners:,} HOLDERS")
1036
+ chip = " · ".join(chip_bits)
1037
+ chip_color = B["blue"]
1038
+ if floor_chg_24h is not None:
1039
+ chip_color = B["up"] if floor_chg_24h >= 0 else B["down"]
1040
+ hero_color = ORACLE["blue2"]
1041
+ _chrome(
1042
+ fig, venue=venue, eyebrow="",
1043
+ hero=fmt(floor if floor is not None else (fy[-1] if fy else None)),
1044
+ hero_color=hero_color, chip=chip, chip_bg=chip_color,
1045
+ footer=source,
1046
+ )
1047
+ # Collection name: Oracle display (Cormorant 700), ice-blue like IF/USD.
1048
+ name = collection[:44].upper()
1049
+ t = fig.text(
1050
+ 0.048, 0.800, name, color=B["blue2"],
1051
+ fontproperties=_oracle_display_fp(22.5),
1052
+ va="center", ha="left", zorder=9,
1053
+ )
1054
+ try:
1055
+ from matplotlib import patheffects as pe
1056
+ t.set_path_effects([pe.withStroke(linewidth=2.4, foreground=B["glow"], alpha=0.55)])
1057
+ except Exception:
1058
+ pass
1059
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-nft-{venue}.png")
1060
+ fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
1061
+ plt.close(fig)
1062
+ return out if os.path.exists(out) and os.path.getsize(out) > 0 else None
1063
+
1064
+
1065
+ def render_price_card(*args, **kwargs):
1066
+ """Back-compat alias — prediction venues are yes/no lines."""
1067
+ yes = kwargs.get("yes")
1068
+ no = kwargs.get("no")
1069
+ return render_yesno_card(
1070
+ [], None,
1071
+ venue=kwargs.get("venue") or "polymarket",
1072
+ title=kwargs.get("title") or "MARKET",
1073
+ yes=yes, no=no, volume=kwargs.get("volume"),
1074
+ out_path=kwargs.get("out_path"),
1075
+ )
1076
+
1077
+
1078
+ def format_card_markdown(*, venue: str, title: str, rows: list[tuple[str, str]], ta: list[str] | None = None) -> str:
1079
+ from telegram_rich import md_kv_table
1080
+
1081
+ meta = chain_meta(venue)
1082
+ lines = [f"ORACLE · {meta['label']}", title]
1083
+ body = list(rows)
1084
+ if ta:
1085
+ body.append(("TA", " · ".join(ta)))
1086
+ lines.append(md_kv_table(body))
1087
+ return "\n".join(lines)
1088
+
1089
+
1090
+ def card_buttons(*, chart_url: str | None = None, trade_url: str | None = None, explorer_url: str | None = None) -> dict:
1091
+ """URL buttons work for every recipient. No execute callback here."""
1092
+ row = []
1093
+ if chart_url:
1094
+ row.append({"text": "Chart", "url": chart_url})
1095
+ if trade_url:
1096
+ row.append({"text": "Trade", "url": trade_url})
1097
+ if explorer_url:
1098
+ row.append({"text": "Explorer", "url": explorer_url})
1099
+ return {"inline_keyboard": [row]} if row else {"inline_keyboard": []}
1100
+
1101
+
1102
+ def fetch_hl_candles(coin: str = "BTC", interval: str = "1h", n: int = 25) -> list[dict]:
1103
+ """Default window: 24h of hourly candles (25 bars so the 24h-ago baseline exists)."""
1104
+ import urllib.request
1105
+
1106
+ end = int(datetime.now(timezone.utc).timestamp() * 1000)
1107
+ start = end - n * 3600 * 1000
1108
+ body = json.dumps({"type": "candleSnapshot", "req": {"coin": coin, "interval": interval, "startTime": start, "endTime": end}}).encode()
1109
+ req = urllib.request.Request(
1110
+ "https://api.hyperliquid.xyz/info",
1111
+ data=body,
1112
+ headers={"Content-Type": "application/json", "User-Agent": "oracle-card/1"},
1113
+ method="POST",
1114
+ )
1115
+ with urllib.request.urlopen(req, timeout=20) as r:
1116
+ raw = json.loads(r.read().decode())
1117
+ out = []
1118
+ for c in raw or []:
1119
+ out.append({"t": int(c["t"]), "o": float(c["o"]), "h": float(c["h"]), "l": float(c["l"]), "c": float(c["c"]), "v": float(c.get("v") or 0)})
1120
+ return out
1121
+
1122
+
1123
+ if __name__ == "__main__":
1124
+ dest = Path.home() / ".cache/oracle-test-tmp"
1125
+ dest.mkdir(parents=True, exist_ok=True)
1126
+ candles = fetch_hl_candles("BTC")
1127
+ last = candles[-1]["c"]
1128
+ demo_trades = [
1129
+ {"t": candles[len(candles) // 3]["t"], "px": candles[len(candles) // 3]["c"], "side": "buy"},
1130
+ {"t": candles[2 * len(candles) // 3]["t"], "px": candles[2 * len(candles) // 3]["c"], "side": "sell"},
1131
+ ]
1132
+ p1 = render_ohlc_card(
1133
+ candles, venue="hyperliquid", symbol="BTC",
1134
+ out_path=str(dest / "oracle-hl-btc.png"), source="HL /info candleSnapshot",
1135
+ trades=demo_trades, avg_entry=last * 0.997,
1136
+ targets=[last * 1.006, last * 1.012], stop=last * 0.991,
1137
+ setup="long pullback", pair="BTC-USD", market="PERP",
1138
+ )
1139
+ yes = json.loads(Path("/tmp/poly-yes.json").read_text()) if Path("/tmp/poly-yes.json").exists() else []
1140
+ no = json.loads(Path("/tmp/poly-no.json").read_text()) if Path("/tmp/poly-no.json").exists() else []
1141
+ p2 = render_yesno_card(yes, no, venue="polymarket", title="JD VANCE 2028", volume=None, out_path=str(dest / "oracle-poly-yesno.png"), source="CLOB prices-history")
1142
+ p3 = render_yield_card(
1143
+ venue="ethereum", title="STABLE YIELD SCAN",
1144
+ vaults=[
1145
+ {"name": "DEMO USDC Vault", "chain": "base", "apy": 12.4, "tvl": 41_200_000},
1146
+ {"name": "DEMO sUSDe Loop", "chain": "ethereum", "apy": 9.8, "tvl": 220_000_000},
1147
+ {"name": "DEMO GHO Stk", "chain": "ethereum", "apy": 7.1, "tvl": 96_000_000},
1148
+ ],
1149
+ out_path=str(dest / "oracle-yield.png"), source="DEMO DATA",
1150
+ )
1151
+ import random
1152
+ random.seed(7)
1153
+ now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
1154
+ fs, v = [], 42.5
1155
+ for i in range(60):
1156
+ v = max(30.0, v + random.uniform(-0.8, 0.85))
1157
+ fs.append({"t": now_ms - (60 - i) * 6 * 3600 * 1000, "p": v})
1158
+ p4 = render_nft_card(
1159
+ venue="ethereum", collection="DEMO PUNKS #4156",
1160
+ floor=fs[-1]["p"], currency="ETH", floor_chg_24h=3.2, volume_24h=812.0,
1161
+ sales_24h=19, listed=412, supply=10000, owners=3801, top_offer=41.9,
1162
+ floor_series=fs, image=str(Path.home() / ".local/share/oracle-assets/oracle-globe.png"),
1163
+ out_path=str(dest / "oracle-nft.png"), source="DEMO DATA",
1164
+ )
1165
+ print(json.dumps({"hl": p1, "poly": p2, "yield": p3, "nft": p4, "ta": spot_ta(candles)}, indent=2))