@oracle-agent/oracle 0.24.10 → 0.25.1

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 (37) hide show
  1. package/README.md +3 -2
  2. package/SETUP.md +22 -0
  3. package/dist/assets/graphics/assets/arbitrum-lockup.png +0 -0
  4. package/dist/assets/graphics/assets/avalanche-lockup.png +0 -0
  5. package/dist/assets/graphics/assets/base-lockup.png +0 -0
  6. package/dist/assets/graphics/assets/bitcoin-lockup.png +0 -0
  7. package/dist/assets/graphics/assets/bnb-lockup.png +0 -0
  8. package/dist/assets/graphics/assets/ethereum-lockup.png +0 -0
  9. package/dist/assets/graphics/assets/hyperliquid-lockup.png +0 -0
  10. package/dist/assets/graphics/assets/optimism-lockup.png +0 -0
  11. package/dist/assets/graphics/assets/oracle-globe.png +0 -0
  12. package/dist/assets/graphics/assets/polygon-lockup.png +0 -0
  13. package/dist/assets/graphics/assets/polymarket-lockup.png +0 -0
  14. package/dist/assets/graphics/assets/polymarket.png +0 -0
  15. package/dist/assets/graphics/assets/robinhood-lockup.png +0 -0
  16. package/dist/assets/graphics/assets/solana-lockup.png +0 -0
  17. package/dist/assets/graphics/assets/stable-lockup.png +0 -0
  18. package/dist/assets/graphics/oracle_card.py +962 -0
  19. package/dist/assets/skills/oracle-desk-product/SKILL.md +1 -1
  20. package/dist/bin/desk-server.mjs +45 -44
  21. package/dist/bin/oracle-data-mcp.mjs +74 -33
  22. package/dist/bin/oracle.mjs +1 -1
  23. package/dist/cli/commands/chat.mjs +111 -111
  24. package/dist/cli/commands/doctor.mjs +3 -3
  25. package/dist/cli/commands/eval.mjs +180 -42
  26. package/dist/cli/commands/farm.mjs +73 -60
  27. package/dist/cli/commands/model.mjs +118 -118
  28. package/dist/cli/commands/receipt.mjs +3 -3
  29. package/dist/cli/commands/sign.mjs +11 -11
  30. package/dist/cli/commands/signer.mjs +13 -13
  31. package/dist/cli/commands/venues.mjs +2 -2
  32. package/dist/data/desk-data.mjs +47 -46
  33. package/dist/index.mjs +48 -47
  34. package/dist/local-signer/index.mjs +3 -3
  35. package/package.json +2 -2
  36. package/public/oracle-splash/downloads/index.html +4 -4
  37. package/public/oracle-splash/index.html +1 -1
@@ -0,0 +1,962 @@
1
+ #!/usr/bin/env python3
2
+ """Oracle-branded alert cards.
3
+
4
+ RH AGENT cards stay RH AGENT. 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
+ ]
70
+ for root in roots:
71
+ if not root.exists():
72
+ continue
73
+ for p in root.rglob("*.ttf"):
74
+ try:
75
+ fm.fontManager.addfont(str(p))
76
+ except Exception:
77
+ pass
78
+ names = {f.name for f in fm.fontManager.ttflist}
79
+ for pref in ("Cormorant Garamond", "CormorantGaramond", "DejaVu Serif"):
80
+ if pref in names:
81
+ _FONTS["display"] = pref
82
+ break
83
+ for pref in ("Outfit", "IBM Plex Sans", "DejaVu Sans"):
84
+ if pref in names:
85
+ _FONTS["sans"] = pref
86
+ break
87
+ for pref in ("IBM Plex Mono", "DM Mono", "JetBrains Mono", "DejaVu Sans Mono"):
88
+ if pref in names:
89
+ _FONTS["mono"] = pref
90
+ break
91
+ except Exception:
92
+ pass
93
+ return _FONTS
94
+
95
+
96
+ def _esc(s: str) -> str:
97
+ return str(s or "").replace("$", r"\$")
98
+
99
+
100
+ def _fmt_px(n: float) -> str:
101
+ a = abs(n)
102
+ if a >= 1000:
103
+ return f"{n:,.0f}"
104
+ if a >= 1:
105
+ return f"{n:,.2f}"
106
+ if a >= 0.01:
107
+ return f"{n:.4f}"
108
+ return f"{n:.6f}"
109
+
110
+
111
+ def _rsi(closes: list[float], period: int = 14) -> float | None:
112
+ if len(closes) < period + 1:
113
+ return None
114
+ gains = []
115
+ losses = []
116
+ for i in range(-period, 0):
117
+ d = closes[i] - closes[i - 1]
118
+ gains.append(max(d, 0.0))
119
+ losses.append(max(-d, 0.0))
120
+ ag = sum(gains) / period
121
+ al = sum(losses) / period
122
+ if al == 0:
123
+ return 100.0
124
+ rs = ag / al
125
+ return 100.0 - (100.0 / (1.0 + rs))
126
+
127
+
128
+ def _ema(values: list[float], period: int) -> float | None:
129
+ if len(values) < period:
130
+ return None
131
+ k = 2 / (period + 1)
132
+ e = sum(values[:period]) / period
133
+ for v in values[period:]:
134
+ e = v * k + e * (1 - k)
135
+ return e
136
+
137
+
138
+ def spot_ta(rows: list[dict]) -> list[str]:
139
+ """Return only rules that fire on this series. Empty = print nothing."""
140
+ closes = [float(r["c"]) for r in rows if r.get("c") is not None]
141
+ highs = [float(r["h"]) for r in rows if r.get("h") is not None]
142
+ lows = [float(r["l"]) for r in rows if r.get("l") is not None]
143
+ opens = [float(r["o"]) for r in rows if r.get("o") is not None]
144
+ if len(closes) < 8:
145
+ return []
146
+ spotted: list[str] = []
147
+ rsi = _rsi(closes)
148
+ if rsi is not None and rsi >= 70:
149
+ spotted.append(f"RSI {rsi:.0f} overbought")
150
+ elif rsi is not None and rsi <= 30:
151
+ spotted.append(f"RSI {rsi:.0f} oversold")
152
+ e9, e21 = _ema(closes, 9), _ema(closes, 21)
153
+ e9p = _ema(closes[:-1], 9)
154
+ e21p = _ema(closes[:-1], 21)
155
+ if e9 and e21 and e9p and e21p:
156
+ if e9p <= e21p and e9 > e21:
157
+ spotted.append("EMA 9/21 bull cross")
158
+ elif e9p >= e21p and e9 < e21:
159
+ spotted.append("EMA 9/21 bear cross")
160
+ if len(closes) >= 3 and closes[-1] > max(closes[-6:-1]):
161
+ spotted.append("new local high")
162
+ if len(closes) >= 3 and closes[-1] < min(closes[-6:-1]):
163
+ spotted.append("new local low")
164
+ if opens and closes and highs and lows:
165
+ o, c, h, l = opens[-1], closes[-1], highs[-1], lows[-1]
166
+ body = abs(c - o)
167
+ rng = h - l
168
+ if rng > 0 and body / rng >= 0.7 and c > o and o <= closes[-2]:
169
+ spotted.append("bullish engulf")
170
+ if rng > 0 and body / rng >= 0.7 and c < o and o >= closes[-2]:
171
+ spotted.append("bearish engulf")
172
+ return spotted[:4]
173
+
174
+
175
+ def chain_meta(venue: str) -> dict:
176
+ key = str(venue or "").lower().replace(" ", "-")
177
+ if key in ("hl", "hypercore", "hype"):
178
+ key = "hyperliquid"
179
+ if key in ("poly", "pm"):
180
+ key = "polymarket"
181
+ if key in ("rh", "rh-chain", "4663", "robinhood-chain"):
182
+ key = "robinhood"
183
+ return CHAINS.get(key, {"label": key.upper() or "UNKNOWN", "accent": ORACLE["blue"], "logo": None, "kind": "ohlc"})
184
+
185
+
186
+ def _load_logo(path: Path | None):
187
+ if not path or not Path(path).exists():
188
+ return None
189
+ p = Path(path)
190
+ try:
191
+ if p.suffix.lower() == ".svg":
192
+ import subprocess
193
+ import tempfile
194
+ from PIL import Image
195
+
196
+ tmp = Path(tempfile.gettempdir()) / f"{p.stem}-raster.png"
197
+ subprocess.run(["rsvg-convert", "-h", "200", str(p)], check=True, stdout=open(tmp, "wb"))
198
+ return Image.open(tmp).convert("RGBA")
199
+ from PIL import Image
200
+
201
+ return Image.open(p).convert("RGBA")
202
+ except Exception:
203
+ try:
204
+ import matplotlib.image as mpimg
205
+
206
+ return mpimg.imread(str(p))
207
+ except Exception:
208
+ return None
209
+
210
+
211
+ def _chrome(fig, *, venue: str, eyebrow: str, hero: str, hero_color: str, chip: str, chip_bg: str, footer: str):
212
+ from matplotlib import patheffects as pe
213
+ from matplotlib.lines import Line2D
214
+
215
+ fonts = _init_fonts()
216
+ B = ORACLE
217
+ meta = chain_meta(venue)
218
+ fig.add_artist(Line2D([0.045, 0.955], [0.845, 0.845], transform=fig.transFigure, color=B["line"], linewidth=1.0))
219
+ text_x = 0.048
220
+ globe = _load_logo(ORACLE_MARK)
221
+ if globe is not None:
222
+ try:
223
+ gax = fig.add_axes([0.040, 0.872, 0.078, 0.100], zorder=8)
224
+ gax.imshow(globe)
225
+ gax.axis("off")
226
+ text_x = 0.108
227
+ except Exception:
228
+ text_x = 0.048
229
+ wm = fig.text(text_x, 0.922, "ORACLE", color=B["blue2"], fontsize=16, fontfamily=fonts["display"], fontweight="medium", va="center")
230
+ wm.set_path_effects([pe.withStroke(linewidth=3.0, foreground=B["glow"], alpha=0.65)])
231
+ # Chain tag: "CHAIN:" + the venue's official lockup (its real logo + font).
232
+ tag_x = text_x + 0.118
233
+ fig.text(tag_x, 0.922, "CHAIN:", color=B["mute"], fontsize=8.0, fontfamily=fonts["mono"], va="center")
234
+ lockup = _load_logo(meta.get("logo"))
235
+ drew_lockup = False
236
+ if lockup is not None:
237
+ try:
238
+ w, h = getattr(lockup, "size", (180, 28))
239
+ aspect = (w / max(h, 1)) if w and h else 6.0
240
+ lh = 0.048
241
+ lw = min(lh * aspect * (6.3 / 11.2), 0.24)
242
+ lax = fig.add_axes([tag_x + 0.048, 0.922 - lh / 2, lw, lh], zorder=8)
243
+ lax.imshow(lockup)
244
+ lax.axis("off")
245
+ drew_lockup = True
246
+ except Exception:
247
+ drew_lockup = False
248
+ if not drew_lockup:
249
+ fig.text(tag_x + 0.052, 0.922, meta["label"], color=meta["accent"], fontsize=8.0, fontfamily=fonts["mono"], va="center")
250
+ fig.text(0.048, 0.815, eyebrow, color=B["blue"], fontsize=7.2, fontfamily=fonts["mono"], va="top")
251
+ if hero:
252
+ fig.text(0.952, 0.932, _esc(hero), color=hero_color or B["blue2"], fontsize=22, fontfamily=fonts["display"], ha="right", va="center")
253
+ if chip:
254
+ fig.text(0.952, 0.868, chip, color=B["blue2"], fontsize=8.2, fontfamily=fonts["mono"], ha="right", va="center")
255
+ fig.add_artist(Line2D([0.045, 0.955], [0.072, 0.072], transform=fig.transFigure, color=B["line"], linewidth=1.0))
256
+ fig.text(0.048, 0.040, os.environ.get("ORACLE_CARD_FOOTER", "oracle-agent"), color=B["blue2"], fontsize=6.8, fontfamily=fonts["mono"], fontweight="bold", va="center")
257
+ 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")
258
+ tag.set_path_effects([pe.withStroke(linewidth=2.2, foreground=B["glow"], alpha=0.6)])
259
+ right = f"{_esc(footer)} · " if footer else ""
260
+ 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")
261
+
262
+
263
+ def _bg(fig):
264
+ try:
265
+ import numpy as np
266
+
267
+ h, w = 90, 160
268
+ y, x = np.mgrid[0:h, 0:w].astype(float)
269
+ y /= h - 1
270
+ x /= w - 1
271
+ base = np.zeros((h, w, 3))
272
+ base[..., 0], base[..., 1], base[..., 2] = 0x0B / 255, 0x10 / 255, 0x18 / 255
273
+ d = ((x - 0.78) ** 2 * 1.3 + (y + 0.1) ** 2) ** 0.5
274
+ g = np.clip(1 - d / 0.9, 0, 1) ** 2 * 0.16
275
+ base[..., 0] += g * (124 / 255)
276
+ base[..., 1] += g * (196 / 255)
277
+ base[..., 2] += g * (255 / 255)
278
+ ax = fig.add_axes([0, 0, 1, 1], zorder=-10)
279
+ ax.imshow(np.clip(base, 0, 1), aspect="auto", interpolation="bilinear")
280
+ ax.set_xticks([])
281
+ ax.set_yticks([])
282
+ for sp in ax.spines.values():
283
+ sp.set_visible(False)
284
+ except Exception:
285
+ pass
286
+
287
+
288
+ def render_ohlc_card(
289
+ rows: list[dict],
290
+ *,
291
+ venue: str,
292
+ symbol: str,
293
+ out_path: str | None = None,
294
+ source: str = "LIVE",
295
+ trades: list[dict] | None = None,
296
+ avg_entry: float | None = None,
297
+ targets: list[float] | None = None,
298
+ stop: float | None = None,
299
+ setup: str | None = None,
300
+ pair: str | None = None,
301
+ market: str | None = None,
302
+ hero: str | None = None,
303
+ chip: str | None = None,
304
+ chip_bg: str | None = None,
305
+ ) -> str | None:
306
+ """OHLC card with optional position overlays.
307
+
308
+ trades: [{"t": ms|s, "px": float, "side": "buy"|"sell"}] — entry/exit markers.
309
+ avg_entry: dashed line at position average.
310
+ targets: sell-point / take-profit lines (TP1, TP2, ...).
311
+ stop: stop-loss line.
312
+ setup: one-line trade setup note, rendered with the TA chips.
313
+ pair: full trading pair, e.g. "BTC-USD" or "HYPE/USDC".
314
+ market: market type — "PERP", "SPOT", "HIP-3", ... shown next to the pair.
315
+ hero: override the top-right hero text (e.g. "PNL +$132") — price when None.
316
+ chip: override the chip text (e.g. "MC $866.8K · -55.8%") — 24h % when None.
317
+ chip_bg: override chip color; defaults to up/down by 24h change.
318
+ """
319
+ if not rows or len(rows) < 3:
320
+ return None
321
+ import matplotlib
322
+
323
+ matplotlib.use("Agg")
324
+ import matplotlib.dates as mdates
325
+ import matplotlib.pyplot as plt
326
+ from matplotlib.patches import Rectangle
327
+
328
+ fonts = _init_fonts()
329
+ B = ORACLE
330
+ xs = [datetime.fromtimestamp(int(r["t"]) / (1000 if int(r["t"]) > 1e12 else 1), tz=timezone.utc) for r in rows]
331
+ opens = [float(r["o"]) for r in rows]
332
+ highs = [float(r["h"]) for r in rows]
333
+ lows = [float(r["l"]) for r in rows]
334
+ closes = [float(r["c"]) for r in rows]
335
+ vols = [float(r.get("v") or 0) for r in rows]
336
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
337
+ _bg(fig)
338
+ ax = fig.add_axes([0.075, 0.195, 0.875, 0.575])
339
+ axv = fig.add_axes([0.075, 0.115, 0.875, 0.078], sharex=ax)
340
+ for a in (ax, axv):
341
+ a.set_facecolor(B["panel"])
342
+ a.tick_params(colors=B["mute"], labelsize=7.0)
343
+ for lbl in a.get_xticklabels() + a.get_yticklabels():
344
+ lbl.set_fontfamily(fonts["mono"])
345
+ for sp in a.spines.values():
346
+ sp.set_visible(False)
347
+ a.grid(color="#152031", linewidth=0.6)
348
+ a.set_axisbelow(True)
349
+ plt.setp(ax.get_xticklabels(), visible=False)
350
+ xnum = mdates.date2num(xs)
351
+ wide = (xnum[-1] - xnum[0]) / max(1, len(xnum)) * 0.66
352
+ for i in range(len(rows)):
353
+ up = closes[i] >= opens[i]
354
+ body_c = B["up"] if up else B["down"]
355
+ wick_c = B["up_wick"] if up else B["down_wick"]
356
+ ax.plot([xnum[i], xnum[i]], [lows[i], highs[i]], color=wick_c, linewidth=0.9, zorder=2, solid_capstyle="round")
357
+ body_lo = min(opens[i], closes[i])
358
+ body_h = abs(closes[i] - opens[i]) or (highs[i] - lows[i]) * 0.02 or 1e-12
359
+ ax.add_patch(Rectangle((xnum[i] - wide / 2, body_lo), wide, body_h, facecolor=body_c, edgecolor=body_c, linewidth=0.5, zorder=3))
360
+ axv.bar(xnum[i], vols[i], width=wide, color=body_c, alpha=0.42, zorder=2)
361
+ last = closes[-1]
362
+ ax.axhline(last, color=B["blue"], linewidth=0.8, alpha=0.35, linestyle=(0, (4, 3)), zorder=4)
363
+
364
+ edge_pins = {"top": 0, "bottom": 0}
365
+
366
+ def _pxline(y, color, label):
367
+ # Levels inside (or near) the price window draw as lines; far-away
368
+ # levels pin to the panel edge so they never squash the candles.
369
+ lo_v, hi_v = ax.get_ylim()
370
+ if lo_v <= y <= hi_v:
371
+ ax.axhline(y, color=color, linewidth=0.9, alpha=0.75, linestyle=(0, (5, 3)), zorder=4)
372
+ ax.annotate(
373
+ f"{label} {_fmt_px(y)}", xy=(1.0, y), xycoords=("axes fraction", "data"),
374
+ xytext=(-4, 3), textcoords="offset points", va="bottom", ha="right",
375
+ fontsize=6.4, fontfamily=fonts["mono"], fontweight="bold", color=color, zorder=6,
376
+ )
377
+ else:
378
+ above = y > hi_v
379
+ slot = edge_pins["top" if above else "bottom"]
380
+ edge_pins["top" if above else "bottom"] += 1
381
+ off = 4 + slot * 11 # stack multiple out-of-range labels
382
+ ax.annotate(
383
+ f"{label} {_fmt_px(y)} {'↑' if above else '↓'}",
384
+ xy=(1.0, 1.0 if above else 0.0), xycoords="axes fraction",
385
+ xytext=(-4, -off if above else off), textcoords="offset points",
386
+ va="top" if above else "bottom", ha="right",
387
+ fontsize=6.4, fontfamily=fonts["mono"], fontweight="bold", color=color, zorder=6,
388
+ )
389
+
390
+ # Zoom to the candles: the 24h range owns the panel. Overlay levels never
391
+ # stretch the scale (that's what made candles tiny); they clamp instead.
392
+ lo_c, hi_c = min(lows), max(highs)
393
+ pad_c = (hi_c - lo_c) * 0.08 or max(abs(hi_c) * 1e-4, 1e-9)
394
+ ax.set_ylim(lo_c - pad_c, hi_c + pad_c)
395
+ if avg_entry is not None:
396
+ _pxline(avg_entry, "#FFD166", "AVG")
397
+ for i_t, tp in enumerate(targets or [], start=1):
398
+ _pxline(tp, "#7DF9B9", f"TP{i_t}" if len(targets or []) > 1 else "SELL")
399
+ if stop is not None:
400
+ _pxline(stop, B["down"], "STOP")
401
+ # Entry/exit markers on the candles.
402
+ for tr in trades or []:
403
+ try:
404
+ tt = int(tr.get("t"))
405
+ if tt > 1e12:
406
+ tt /= 1000
407
+ tx = mdates.date2num(datetime.fromtimestamp(tt, tz=timezone.utc))
408
+ tpx = float(tr.get("px"))
409
+ buy = str(tr.get("side", "buy")).lower().startswith("b")
410
+ ax.scatter(
411
+ [tx], [tpx], marker="^" if buy else "v", s=64,
412
+ color="#7DF9B9" if buy else B["down"], edgecolors=B["bg"],
413
+ linewidths=0.8, zorder=7,
414
+ )
415
+ ax.annotate(
416
+ "ENTRY" if buy else "EXIT", xy=(tx, tpx),
417
+ xytext=(0, 10 if buy else -14), textcoords="offset points",
418
+ ha="center", fontsize=5.6, fontfamily=fonts["mono"], fontweight="bold",
419
+ color="#7DF9B9" if buy else B["down"], zorder=7,
420
+ )
421
+ except Exception:
422
+ continue
423
+ ax.annotate(
424
+ _esc(_fmt_px(last)), xy=(1.0, last), xycoords=("axes fraction", "data"),
425
+ xytext=(4, 0), textcoords="offset points", va="center", ha="left",
426
+ fontsize=7.0, fontfamily=fonts["mono"], fontweight="bold", color=B["chip_ink"],
427
+ bbox={"boxstyle": "round,pad=0.30", "facecolor": B["blue2"], "edgecolor": "none"}, zorder=6, annotation_clip=False,
428
+ )
429
+ ta = spot_ta(rows)
430
+ chips = ([f"SETUP: {setup}"] if setup else []) + ta
431
+ if chips:
432
+ ax.text(
433
+ 0.01, 0.97, " · ".join(chips), transform=ax.transAxes, ha="left", va="top",
434
+ fontsize=7.2, fontfamily=fonts["mono"], color=B["blue2"],
435
+ bbox={"boxstyle": "round,pad=0.32", "facecolor": "#0B1018", "edgecolor": B["blue"], "linewidth": 0.8, "alpha": 0.9},
436
+ zorder=10,
437
+ )
438
+ axv.xaxis_date()
439
+ axv.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d %H:%M"))
440
+ axv.set_yticks([])
441
+ # 24h % gain: change vs the close nearest 24h before the last bar.
442
+ last_ts = xs[-1].timestamp()
443
+ base = None
444
+ for i in range(len(xs)):
445
+ if xs[i].timestamp() <= last_ts - 24 * 3600:
446
+ base = closes[i]
447
+ if base is None:
448
+ base = closes[0]
449
+ chg = (closes[-1] / base - 1) * 100 if base > 0 else 0
450
+ _chip_bg = chip_bg or (B["up"] if chg >= 0 else B["down"])
451
+ eyebrow_bits = [str(pair or symbol).upper()]
452
+ if market:
453
+ eyebrow_bits.append(str(market).upper())
454
+ _chrome(
455
+ fig, venue=venue, eyebrow=" · ".join(eyebrow_bits),
456
+ hero=hero if hero is not None else f"{_fmt_px(last)}", hero_color=B["blue2"],
457
+ chip=chip if chip is not None else f"{chg:+.2f}% 24H", chip_bg=_chip_bg,
458
+ footer=f"{source} · CANDLES · {chain_meta(venue)['label']}",
459
+ )
460
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-card-{venue}-{symbol}.png")
461
+ fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
462
+ plt.close(fig)
463
+ return out if os.path.exists(out) and os.path.getsize(out) > 0 else None
464
+
465
+
466
+ def render_yesno_card(
467
+ yes_series: list[dict],
468
+ no_series: list[dict] | None = None,
469
+ *,
470
+ venue: str,
471
+ title: str,
472
+ yes: float | None = None,
473
+ no: float | None = None,
474
+ volume: float | None = None,
475
+ out_path: str | None = None,
476
+ source: str = "LIVE",
477
+ ) -> str | None:
478
+ """Polymarket / HIP-4: two-line YES/NO price graph. Not candles."""
479
+ import matplotlib
480
+
481
+ matplotlib.use("Agg")
482
+ import matplotlib.dates as mdates
483
+ import matplotlib.pyplot as plt
484
+
485
+ fonts = _init_fonts()
486
+ B = ORACLE
487
+ def series(rows):
488
+ xs, ys = [], []
489
+ for r in rows or []:
490
+ t = r.get("t") or r.get("time")
491
+ p = r.get("p") if r.get("p") is not None else r.get("c")
492
+ if t is None or p is None:
493
+ continue
494
+ t = int(t)
495
+ if t > 1e12:
496
+ t = t / 1000
497
+ xs.append(datetime.fromtimestamp(t, tz=timezone.utc))
498
+ ys.append(float(p) * (100.0 if float(p) <= 1.5 else 1.0))
499
+ return xs, ys
500
+
501
+ yx, yy = series(yes_series)
502
+ nx, ny = series(no_series)
503
+ if not yy and yes is not None:
504
+ now = datetime.now(timezone.utc)
505
+ yx, yy = [now], [(yes * 100 if yes <= 1.5 else yes)]
506
+ if not ny:
507
+ if no is not None:
508
+ nx, ny = yx[:], [(no * 100 if no <= 1.5 else no)] * len(yx or [0])
509
+ elif yy:
510
+ nx, ny = yx, [100.0 - v for v in yy]
511
+ if not yy:
512
+ return None
513
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
514
+ _bg(fig)
515
+ ax = fig.add_axes([0.075, 0.125, 0.875, 0.655])
516
+ ax.set_facecolor(B["panel"])
517
+ ax.tick_params(colors=B["mute"], labelsize=7.0)
518
+ for lbl in ax.get_xticklabels() + ax.get_yticklabels():
519
+ lbl.set_fontfamily(fonts["mono"])
520
+ for sp in ax.spines.values():
521
+ sp.set_visible(False)
522
+ ax.grid(color="#152031", linewidth=0.5, alpha=0.7)
523
+ ax.set_axisbelow(True)
524
+ ax.plot(yx, yy, color=B["yes"], linewidth=2.0, label="YES", zorder=3, solid_capstyle="round")
525
+ if ny and nx:
526
+ ax.plot(nx, ny, color=B["no"], linewidth=2.0, label="NO", zorder=3, solid_capstyle="round")
527
+ last = yy[-1]
528
+ ax.set_ylim(0, 100)
529
+ ax.yaxis.set_major_formatter(plt.FuncFormatter(lambda v, _p: f"{v:.0f}¢"))
530
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
531
+ ax.legend(loc="upper left", frameon=False, labelcolor=B["ink"], fontsize=8, prop={"family": fonts["mono"]})
532
+ last = yy[-1]
533
+ chg_24 = None
534
+ if yx and len(yx) >= 2:
535
+ cutoff = yx[-1].timestamp() - 24 * 3600
536
+ older = [yy[i] for i, t in enumerate(yx) if t.timestamp() <= cutoff]
537
+ if older and older[-1] > 0:
538
+ chg_24 = (last / older[-1] - 1.0) * 100.0
539
+ chip = f"{chg_24:+.1f}% 24H" if chg_24 is not None else ""
540
+ _chrome(
541
+ fig, venue=venue, eyebrow=title[:52],
542
+ hero=f"{last:.1f}¢", hero_color=B["blue2"],
543
+ chip=chip, chip_bg=B["blue"],
544
+ footer=f"{source} · YES / NO",
545
+ )
546
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-yesno-{venue}.png")
547
+ fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
548
+ plt.close(fig)
549
+ return out if os.path.exists(out) and os.path.getsize(out) > 0 else None
550
+
551
+
552
+ def render_stat_card(
553
+ *,
554
+ venue: str,
555
+ title: str,
556
+ hero: str,
557
+ hero_color: str | None = None,
558
+ chip: str = "",
559
+ stats: list[tuple[str, str]] | None = None,
560
+ rows: list[list[str]] | None = None,
561
+ row_header: list[str] | None = None,
562
+ note: str | None = None,
563
+ out_path: str | None = None,
564
+ source: str = "LIVE",
565
+ kind: str = "stat",
566
+ ) -> str | None:
567
+ """Generic Oracle stat card: hero + stat tiles + optional table.
568
+
569
+ stats: [(label, value)] tiles across the top of the panel (max 8).
570
+ rows/row_header: optional table (e.g. vault list, trait floors, top offers).
571
+ Backs the DeFi yield and NFT surfaces — anything card-worthy without candles.
572
+ """
573
+ import matplotlib
574
+
575
+ matplotlib.use("Agg")
576
+ import matplotlib.pyplot as plt
577
+
578
+ fonts = _init_fonts()
579
+ B = ORACLE
580
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
581
+ _bg(fig)
582
+ ax = fig.add_axes([0.048, 0.105, 0.904, 0.685])
583
+ ax.set_facecolor(B["panel"])
584
+ ax.set_xticks([])
585
+ ax.set_yticks([])
586
+ ax.set_xlim(0, 1)
587
+ ax.set_ylim(0, 1)
588
+ for sp in ax.spines.values():
589
+ sp.set_color(B["line"])
590
+ sp.set_linewidth(1.0)
591
+ tiles = (stats or [])[:8]
592
+ if tiles:
593
+ per_row = 4 if len(tiles) > 3 else max(len(tiles), 1)
594
+ tile_w = 1.0 / per_row
595
+ n_tile_rows = (len(tiles) + per_row - 1) // per_row
596
+ tile_h = 0.30 if n_tile_rows > 1 else 0.34
597
+ for i, (lab, val) in enumerate(tiles):
598
+ cx = (i % per_row) * tile_w + tile_w / 2
599
+ cy = 0.985 - (i // per_row) * tile_h
600
+ ax.text(cx, cy - 0.055, str(lab).upper(), ha="center", va="top", fontsize=7.0, fontfamily=fonts["mono"], color=B["mute"])
601
+ ax.text(cx, cy - 0.115, _esc(str(val)), ha="center", va="top", fontsize=15, fontfamily=fonts["display"], color=B["ink"])
602
+ table_top = 0.985 - n_tile_rows * tile_h - 0.03
603
+ else:
604
+ table_top = 0.95
605
+ if rows:
606
+ cols = max(len(r) for r in rows) if rows else 0
607
+ col_x = [0.03 + c * (0.94 / max(cols - 1, 1)) for c in range(cols)] if cols > 1 else [0.03]
608
+ y = table_top
609
+ if row_header:
610
+ for c, htxt in enumerate(row_header[:cols]):
611
+ 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"])
612
+ y -= 0.075
613
+ for r in rows[:7]:
614
+ for c, cell in enumerate(r[:cols]):
615
+ 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"])
616
+ y -= 0.082
617
+ if note:
618
+ ax.text(0.03, 0.035, _esc(note), ha="left", va="bottom", fontsize=6.6, fontfamily=fonts["sans"], color=B["mute"])
619
+ _chrome(
620
+ fig, venue=venue, eyebrow=title[:60],
621
+ hero=hero, hero_color=hero_color or B["blue2"],
622
+ chip=chip, chip_bg=B["blue"],
623
+ footer=source,
624
+ )
625
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-{kind}-{venue}.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_yield_card(
632
+ *,
633
+ venue: str,
634
+ title: str,
635
+ vaults: list[dict],
636
+ out_path: str | None = None,
637
+ source: str = "LIVE",
638
+ ) -> str | None:
639
+ """DeFi yield card. vaults: [{name, apy, tvl, chain?, note?}] sorted best-first."""
640
+ if not vaults:
641
+ return None
642
+ best = vaults[0]
643
+ rows = []
644
+ for v in vaults[:7]:
645
+ rows.append([
646
+ str(v.get("name", "?"))[:28],
647
+ str(v.get("chain", "") or "").upper(),
648
+ f"{float(v['apy']):.2f}%" if v.get("apy") is not None else "UNKNOWN",
649
+ f"${float(v['tvl']):,.0f}" if v.get("tvl") is not None else "UNKNOWN",
650
+ ])
651
+ return render_stat_card(
652
+ venue=venue, title=title,
653
+ hero=f"{float(best['apy']):.2f}% APY" if best.get("apy") is not None else "UNKNOWN",
654
+ hero_color=ORACLE["up"],
655
+ chip=f"TOP: {str(best.get('name', ''))[:24]}",
656
+ rows=rows, row_header=["VAULT", "CHAIN", "APY", "TVL"],
657
+ note="APY is variable. Live read, not a promise.",
658
+ out_path=out_path, source=source, kind="yield",
659
+ )
660
+
661
+
662
+ def render_nft_card(
663
+ *,
664
+ venue: str,
665
+ collection: str,
666
+ floor: float | None = None,
667
+ currency: str = "ETH",
668
+ floor_chg_24h: float | None = None,
669
+ volume_24h: float | None = None,
670
+ sales_24h: int | None = None,
671
+ listed: int | None = None,
672
+ supply: int | None = None,
673
+ owners: int | None = None,
674
+ top_offer: float | None = None,
675
+ traits: list[list[str]] | None = None,
676
+ floor_series: list[dict] | None = None,
677
+ image: str | None = None,
678
+ note: str | None = None,
679
+ out_path: str | None = None,
680
+ source: str = "LIVE",
681
+ ) -> str | None:
682
+ """Premium NFT card: NFT image panel + floor-price chart + stat strip.
683
+
684
+ floor_series: [{"t": ms|s, "p"|"c": float}] floor history → line chart.
685
+ image: local path or http(s) URL of the NFT / collection image.
686
+ Falls back to the stat-tile layout when neither image nor series exists.
687
+ """
688
+ if not floor_series and not image:
689
+ fmt = lambda v: f"{_fmt_px(v)} {currency}" if v is not None else "UNKNOWN"
690
+ stats = [
691
+ ("floor", fmt(floor)),
692
+ ("top offer", fmt(top_offer)),
693
+ ("24h vol", fmt(volume_24h)),
694
+ ("24h sales", f"{sales_24h:,}" if sales_24h is not None else "UNKNOWN"),
695
+ ("listed", f"{listed:,}" if listed is not None else "UNKNOWN"),
696
+ ("supply", f"{supply:,}" if supply is not None else "UNKNOWN"),
697
+ ("owners", f"{owners:,}" if owners is not None else "UNKNOWN"),
698
+ ("listed %", f"{listed / supply * 100:.1f}%" if listed and supply else "UNKNOWN"),
699
+ ]
700
+ chip = f"{floor_chg_24h:+.1f}% FLOOR 24H" if floor_chg_24h is not None else ""
701
+ hero_color = ORACLE["up"] if (floor_chg_24h or 0) >= 0 else ORACLE["down"]
702
+ return render_stat_card(
703
+ venue=venue, title=collection,
704
+ hero=fmt(floor), hero_color=hero_color, chip=chip,
705
+ stats=stats,
706
+ rows=traits, row_header=["TRAIT", "FLOOR", "LISTED"] if traits else None,
707
+ note=note, out_path=out_path, source=source, kind="nft",
708
+ )
709
+
710
+ import matplotlib
711
+
712
+ matplotlib.use("Agg")
713
+ import matplotlib.dates as mdates
714
+ import matplotlib.pyplot as plt
715
+
716
+ fonts = _init_fonts()
717
+ B = ORACLE
718
+ fmt = lambda v: f"{_fmt_px(v)} {currency}" if v is not None else "UNKNOWN"
719
+ fig = plt.figure(figsize=(11.2, 6.3), dpi=160, facecolor=B["bg"])
720
+ _bg(fig)
721
+
722
+ img_obj = None
723
+ if image:
724
+ try:
725
+ src = image
726
+ if str(image).startswith(("http://", "https://")):
727
+ import urllib.request
728
+
729
+ tmp_img = Path(tempfile.gettempdir()) / f"oracle-nft-img-{abs(hash(image))}.bin"
730
+ if not tmp_img.exists():
731
+ req = urllib.request.Request(str(image), headers={
732
+ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
733
+ "accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
734
+ })
735
+ tmp_img.write_bytes(urllib.request.urlopen(req, timeout=15).read())
736
+ src = str(tmp_img)
737
+ from PIL import Image
738
+
739
+ img_obj = Image.open(src).convert("RGB")
740
+ except Exception:
741
+ img_obj = None
742
+
743
+ # Left: NFT image panel. Right: floor chart. Bottom: stat strip.
744
+ chart_left = 0.075
745
+ if img_obj is not None:
746
+ iax = fig.add_axes([0.048, 0.245, 0.235, 0.520], zorder=5)
747
+ iax.imshow(img_obj)
748
+ iax.set_xticks([])
749
+ iax.set_yticks([])
750
+ for sp in iax.spines.values():
751
+ sp.set_color(B["blue"])
752
+ sp.set_linewidth(1.2)
753
+ sp.set_alpha(0.55)
754
+ chart_left = 0.318
755
+ ax = fig.add_axes([chart_left, 0.245, 0.952 - chart_left, 0.520])
756
+ ax.set_facecolor(B["panel"])
757
+ ax.tick_params(colors=B["mute"], labelsize=6.8)
758
+ for lbl in ax.get_xticklabels() + ax.get_yticklabels():
759
+ lbl.set_fontfamily(fonts["mono"])
760
+ for sp in ax.spines.values():
761
+ sp.set_visible(False)
762
+ ax.grid(color="#152031", linewidth=0.5, alpha=0.7)
763
+ ax.set_axisbelow(True)
764
+ fx, fy = [], []
765
+ for r in floor_series or []:
766
+ t = r.get("t") or r.get("time")
767
+ p = r.get("p") if r.get("p") is not None else r.get("c")
768
+ if t is None or p is None:
769
+ continue
770
+ t = int(t)
771
+ if t > 1e12:
772
+ t /= 1000
773
+ fx.append(datetime.fromtimestamp(t, tz=timezone.utc))
774
+ fy.append(float(p))
775
+ if fx:
776
+ # Clamp the y-range to the recent floor action. A single ancient spike
777
+ # (mint-day 1.9 ETH vs 0.0125 today) must not flatten the whole line —
778
+ # same law as the candle window. Focus on the last 60% of samples plus
779
+ # padding; the full series still draws, the axis just doesn't chase
780
+ # outliers.
781
+ tail = fy[max(0, int(len(fy) * 0.4)):]
782
+ lo, hi = min(tail), max(tail)
783
+ span = (hi - lo) or (hi * 0.1) or 1e-9
784
+ pad = span * 0.18
785
+ ax.plot(fx, fy, color=B["blue"], linewidth=2.0, zorder=3, solid_capstyle="round")
786
+ ax.fill_between(fx, fy, lo - pad, alpha=0.10, color=B["blue"], zorder=2)
787
+ ax.set_ylim(lo - pad, hi + pad)
788
+ if max(fy) > hi + pad:
789
+ ax.annotate(
790
+ _esc(f"ATH {fmt(max(fy))}"), xy=(0.01, 0.99), xycoords="axes fraction",
791
+ va="top", ha="left", fontsize=6.0, fontfamily=fonts["mono"], color=B["mute"],
792
+ )
793
+ ax.axhline(fy[-1], color=B["blue"], linewidth=0.7, alpha=0.35, linestyle=(0, (4, 3)))
794
+ ax.annotate(
795
+ _esc(fmt(fy[-1])), xy=(1.0, fy[-1]), xycoords=("axes fraction", "data"),
796
+ xytext=(4, 0), textcoords="offset points", va="center", ha="left",
797
+ fontsize=6.6, fontfamily=fonts["mono"], fontweight="bold", color=B["chip_ink"],
798
+ bbox={"boxstyle": "round,pad=0.30", "facecolor": B["blue2"], "edgecolor": "none"}, zorder=6, annotation_clip=False,
799
+ )
800
+ ax.xaxis.set_major_formatter(mdates.DateFormatter("%m-%d"))
801
+ ax.text(0.01, 0.97, "FLOOR", transform=ax.transAxes, ha="left", va="top", fontsize=6.6, fontfamily=fonts["mono"], color=B["mute"])
802
+ else:
803
+ ax.set_xticks([])
804
+ ax.set_yticks([])
805
+ ax.text(0.5, 0.5, "NO FLOOR HISTORY", ha="center", va="center", fontsize=9, fontfamily=fonts["mono"], color=B["mute"], transform=ax.transAxes)
806
+ # Bottom stat strip.
807
+ chg_txt = f"{floor_chg_24h:+.1f}%" if floor_chg_24h is not None else "UNKNOWN"
808
+ strip = [
809
+ ("FLOOR", fmt(floor if floor is not None else (fy[-1] if fy else None))),
810
+ ("24H %", chg_txt),
811
+ ("TOP OFFER", fmt(top_offer)),
812
+ ("24H VOL", fmt(volume_24h)),
813
+ ("24H SALES", f"{sales_24h:,}" if sales_24h is not None else "UNKNOWN"),
814
+ ("LISTED", f"{listed:,}/{supply:,}" if listed is not None and supply else (f"{listed:,}" if listed is not None else "UNKNOWN")),
815
+ ("HOLDERS", f"{owners:,}" if owners is not None else "UNKNOWN"),
816
+ ]
817
+ n = len(strip)
818
+ for i, (lab, val) in enumerate(strip):
819
+ cx = 0.048 + (0.904 / n) * (i + 0.5)
820
+ col = B["ink"]
821
+ if lab == "24H %" and floor_chg_24h is not None:
822
+ col = B["up"] if floor_chg_24h >= 0 else B["down"]
823
+ fig.text(cx, 0.185, lab, ha="center", va="center", fontsize=6.2, fontfamily=fonts["mono"], color=B["mute"])
824
+ fig.text(cx, 0.135, _esc(str(val)), ha="center", va="center", fontsize=9.8, fontfamily=fonts["display"], color=col)
825
+ chip_bits = []
826
+ if floor_chg_24h is not None:
827
+ chip_bits.append(f"{floor_chg_24h:+.1f}% 24H")
828
+ if sales_24h is not None:
829
+ chip_bits.append(f"{sales_24h:,} SOLD")
830
+ if owners is not None:
831
+ chip_bits.append(f"{owners:,} HOLDERS")
832
+ chip = " · ".join(chip_bits)
833
+ chip_color = B["blue"]
834
+ if floor_chg_24h is not None:
835
+ chip_color = B["up"] if floor_chg_24h >= 0 else B["down"]
836
+ hero_color = ORACLE["blue2"]
837
+ _chrome(
838
+ fig, venue=venue, eyebrow="" if img_obj is not None else collection[:60],
839
+ hero=fmt(floor if floor is not None else (fy[-1] if fy else None)),
840
+ hero_color=hero_color, chip=chip, chip_bg=chip_color,
841
+ footer=source,
842
+ )
843
+ # Collection PFP thumb + name beside the eyebrow (like the venue lockup row).
844
+ if img_obj is not None:
845
+ try:
846
+ pax = fig.add_axes([0.048, 0.786, 0.030, 0.053], zorder=9)
847
+ pax.imshow(img_obj)
848
+ pax.set_xticks([])
849
+ pax.set_yticks([])
850
+ for sp in pax.spines.values():
851
+ sp.set_color(B["blue"])
852
+ sp.set_linewidth(0.9)
853
+ fig.text(0.086, 0.812, collection[:60], color=B["blue"], fontsize=8.4, fontfamily=fonts["mono"], fontweight="bold", va="center")
854
+ except Exception:
855
+ fig.text(0.048, 0.812, collection[:60], color=B["blue"], fontsize=7.2, fontfamily=fonts["mono"], va="center")
856
+ out = out_path or str(Path(tempfile.gettempdir()) / f"oracle-nft-{venue}.png")
857
+ fig.savefig(out, facecolor=fig.get_facecolor(), bbox_inches="tight", pad_inches=0.08)
858
+ plt.close(fig)
859
+ return out if os.path.exists(out) and os.path.getsize(out) > 0 else None
860
+
861
+
862
+ def render_price_card(*args, **kwargs):
863
+ """Back-compat alias — prediction venues are yes/no lines."""
864
+ yes = kwargs.get("yes")
865
+ no = kwargs.get("no")
866
+ return render_yesno_card(
867
+ [], None,
868
+ venue=kwargs.get("venue") or "polymarket",
869
+ title=kwargs.get("title") or "MARKET",
870
+ yes=yes, no=no, volume=kwargs.get("volume"),
871
+ out_path=kwargs.get("out_path"),
872
+ )
873
+
874
+
875
+ def format_card_markdown(*, venue: str, title: str, rows: list[tuple[str, str]], ta: list[str] | None = None) -> str:
876
+ from telegram_rich import md_kv_table
877
+
878
+ meta = chain_meta(venue)
879
+ lines = [f"ORACLE · {meta['label']}", title]
880
+ body = list(rows)
881
+ if ta:
882
+ body.append(("TA", " · ".join(ta)))
883
+ lines.append(md_kv_table(body))
884
+ return "\n".join(lines)
885
+
886
+
887
+ def card_buttons(*, chart_url: str | None = None, trade_url: str | None = None, explorer_url: str | None = None) -> dict:
888
+ """URL buttons work for every recipient. No execute callback here."""
889
+ row = []
890
+ if chart_url:
891
+ row.append({"text": "Chart", "url": chart_url})
892
+ if trade_url:
893
+ row.append({"text": "Trade", "url": trade_url})
894
+ if explorer_url:
895
+ row.append({"text": "Explorer", "url": explorer_url})
896
+ return {"inline_keyboard": [row]} if row else {"inline_keyboard": []}
897
+
898
+
899
+ def fetch_hl_candles(coin: str = "BTC", interval: str = "1h", n: int = 25) -> list[dict]:
900
+ """Default window: 24h of hourly candles (25 bars so the 24h-ago baseline exists)."""
901
+ import urllib.request
902
+
903
+ end = int(datetime.now(timezone.utc).timestamp() * 1000)
904
+ start = end - n * 3600 * 1000
905
+ body = json.dumps({"type": "candleSnapshot", "req": {"coin": coin, "interval": interval, "startTime": start, "endTime": end}}).encode()
906
+ req = urllib.request.Request(
907
+ "https://api.hyperliquid.xyz/info",
908
+ data=body,
909
+ headers={"Content-Type": "application/json", "User-Agent": "oracle-card/1"},
910
+ method="POST",
911
+ )
912
+ with urllib.request.urlopen(req, timeout=20) as r:
913
+ raw = json.loads(r.read().decode())
914
+ out = []
915
+ for c in raw or []:
916
+ 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)})
917
+ return out
918
+
919
+
920
+ if __name__ == "__main__":
921
+ dest = Path.home() / ".cache/oracle-test-tmp"
922
+ dest.mkdir(parents=True, exist_ok=True)
923
+ candles = fetch_hl_candles("BTC")
924
+ last = candles[-1]["c"]
925
+ demo_trades = [
926
+ {"t": candles[len(candles) // 3]["t"], "px": candles[len(candles) // 3]["c"], "side": "buy"},
927
+ {"t": candles[2 * len(candles) // 3]["t"], "px": candles[2 * len(candles) // 3]["c"], "side": "sell"},
928
+ ]
929
+ p1 = render_ohlc_card(
930
+ candles, venue="hyperliquid", symbol="BTC",
931
+ out_path=str(dest / "oracle-hl-btc.png"), source="HL /info candleSnapshot",
932
+ trades=demo_trades, avg_entry=last * 0.997,
933
+ targets=[last * 1.006, last * 1.012], stop=last * 0.991,
934
+ setup="long pullback", pair="BTC-USD", market="PERP",
935
+ )
936
+ yes = json.loads(Path("/tmp/poly-yes.json").read_text()) if Path("/tmp/poly-yes.json").exists() else []
937
+ no = json.loads(Path("/tmp/poly-no.json").read_text()) if Path("/tmp/poly-no.json").exists() else []
938
+ 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")
939
+ p3 = render_yield_card(
940
+ venue="ethereum", title="STABLE YIELD SCAN",
941
+ vaults=[
942
+ {"name": "DEMO USDC Vault", "chain": "base", "apy": 12.4, "tvl": 41_200_000},
943
+ {"name": "DEMO sUSDe Loop", "chain": "ethereum", "apy": 9.8, "tvl": 220_000_000},
944
+ {"name": "DEMO GHO Stk", "chain": "ethereum", "apy": 7.1, "tvl": 96_000_000},
945
+ ],
946
+ out_path=str(dest / "oracle-yield.png"), source="DEMO DATA",
947
+ )
948
+ import random
949
+ random.seed(7)
950
+ now_ms = int(datetime.now(timezone.utc).timestamp() * 1000)
951
+ fs, v = [], 42.5
952
+ for i in range(60):
953
+ v = max(30.0, v + random.uniform(-0.8, 0.85))
954
+ fs.append({"t": now_ms - (60 - i) * 6 * 3600 * 1000, "p": v})
955
+ p4 = render_nft_card(
956
+ venue="ethereum", collection="DEMO PUNKS #4156",
957
+ floor=fs[-1]["p"], currency="ETH", floor_chg_24h=3.2, volume_24h=812.0,
958
+ sales_24h=19, listed=412, supply=10000, owners=3801, top_offer=41.9,
959
+ floor_series=fs, image=str(Path.home() / ".local/share/oracle-assets/oracle-globe.png"),
960
+ out_path=str(dest / "oracle-nft.png"), source="DEMO DATA",
961
+ )
962
+ print(json.dumps({"hl": p1, "poly": p2, "yield": p3, "nft": p4, "ta": spot_ta(candles)}, indent=2))