@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.
- package/dist/assets/graphics/assets/arbitrum-lockup.png +0 -0
- package/dist/assets/graphics/assets/avalanche-lockup.png +0 -0
- package/dist/assets/graphics/assets/base-lockup.png +0 -0
- package/dist/assets/graphics/assets/bitcoin-lockup.png +0 -0
- package/dist/assets/graphics/assets/bnb-lockup.png +0 -0
- package/dist/assets/graphics/assets/ethereum-lockup.png +0 -0
- package/dist/assets/graphics/assets/hyperliquid-lockup.png +0 -0
- package/dist/assets/graphics/assets/optimism-lockup.png +0 -0
- package/dist/assets/graphics/assets/oracle-globe.png +0 -0
- package/dist/assets/graphics/assets/polygon-lockup.png +0 -0
- package/dist/assets/graphics/assets/polymarket-lockup.png +0 -0
- package/dist/assets/graphics/assets/polymarket.png +0 -0
- package/dist/assets/graphics/assets/robinhood-lockup.png +0 -0
- package/dist/assets/graphics/assets/solana-lockup.png +0 -0
- package/dist/assets/graphics/assets/stable-lockup.png +0 -0
- package/dist/assets/graphics/fonts/CormorantGaramond-Bold.ttf +0 -0
- package/dist/assets/graphics/fonts/RobotoMono-Bold.ttf +0 -0
- package/dist/assets/graphics/fonts/dmmono-Medium.ttf +0 -0
- package/dist/assets/graphics/oracle_card.py +1165 -0
- package/dist/assets/skills/oracle-desk-product/SKILL.md +1 -1
- package/dist/bin/desk-server.mjs +44 -44
- package/dist/bin/oracle-data-mcp.mjs +68 -27
- package/dist/bin/oracle-public-server.mjs +15 -15
- package/dist/bin/oracle-route.mjs +1 -1
- package/dist/cli/commands/chat.mjs +94 -94
- package/dist/cli/commands/doctor.mjs +3 -3
- package/dist/cli/commands/eval.mjs +27 -27
- package/dist/cli/commands/farm.mjs +57 -57
- package/dist/cli/commands/model.mjs +98 -98
- package/dist/cli/commands/receipt.mjs +2 -2
- package/dist/cli/commands/sign.mjs +13 -13
- package/dist/cli/commands/signer.mjs +13 -13
- package/dist/cli/commands/strategy.mjs +1 -1
- package/dist/cli/commands/swap.mjs +1 -1
- package/dist/cli/commands/venues.mjs +2 -2
- package/dist/data/desk-data.mjs +42 -42
- package/dist/index.mjs +51 -51
- package/dist/local-signer/index.mjs +3 -3
- package/dist/nft/index.mjs +1 -1
- package/dist/prepare-envelope.mjs +1 -1
- package/dist/public-api/agent-grants.mjs +3 -3
- package/dist/router/index.mjs +10 -10
- package/dist/router/prepare-bridge.mjs +1 -1
- package/dist/strategy/index.mjs +1 -1
- 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))
|