From f9413f81d4f4dbfd5e4e91b94f36c10aa3841453 Mon Sep 17 00:00:00 2001 From: zlei9 Date: Sun, 29 Mar 2026 09:42:39 +0800 Subject: [PATCH] Initial commit with translated description --- ARCHITECTURE.md | 437 ++++++++++++++++ README.md | 194 ++++++++ SKILL.md | 50 ++ _meta.json | 6 + requirements.txt | 1 + scripts/get_price_chart.py | 988 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1676 insertions(+) create mode 100644 ARCHITECTURE.md create mode 100644 README.md create mode 100644 SKILL.md create mode 100644 _meta.json create mode 100644 requirements.txt create mode 100644 scripts/get_price_chart.py diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..79b9fac --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,437 @@ +# Architecture & Design Decisions + +**Version:** 1.0 +**Date:** 2026-01-27 +**Author:** Research & audit by Clawd 🦊 + +--- + +## Why This Document Exists + +This document explains why `crypto-price` does **not** use template generation for wrapper skills, and why the current architecture is optimal. + +**TL;DR:** No refactoring needed. System already DRY. + +--- + +## System Overview + +### What We Have + +**1 Core Script:** +- `scripts/get_price_chart.py` (~800 lines) +- All logic: API calls, caching, chart generation, error handling + +**30 Wrapper Skills:** +- `../btc/SKILL.md`, `../eth/SKILL.md`, `../hype/SKILL.md`, ... (28 token wrappers) +- `../cryptochart/SKILL.md` (universal fallback) +- `../token/SKILL.md` (universal `/token ANY` command) +- Each wrapper = 35 lines: metadata + symbol + "call core script" + +**Total duplication:** Zero. All wrappers call same script. + +--- + +## Why No Template Generation? + +### The Proposal (Rejected) + +**Idea:** Generate wrappers from template to reduce duplication. + +**Reality:** No duplication exists. + +**Current wrapper:** +```yaml +--- +name: BTC +description: Slash command for Bitcoin token price + chart. +metadata: {"clawdbot":{"emoji":"📈","requires":{"bins":["python3"]}}} +--- + +# Bitcoin Price + +## Usage +/BTC +/BTC 12h + +## Execution +python3 {baseDir}/../crypto-price/scripts/get_price_chart.py BTC + +Return JSON output. Attach chart_path PNG. +Return text_plain with no markdown. +``` + +**Lines:** 35 +**Logic:** 0 (just metadata + symbol) + +### Comparison: Manual vs Template + +**Current (no template):** +```bash +# Add new token +cp btc/SKILL.md pepe/SKILL.md +vim pepe/SKILL.md # Edit 3 places: name, description, symbol +# Done (2 minutes) +``` + +**With template:** +```bash +# Maintain template file +vim crypto-price/templates/token_wrapper.md +# Maintain generator script +vim crypto-price/scripts/generate_wrapper.sh +# Generate wrapper +./generate_wrapper.sh PEPE "Pepe Coin" pepe +# Debug if generator fails +# Done (5 minutes + maintenance overhead) +``` + +**Winner:** Current approach (simpler, fewer files, easier to understand) + +--- + +## Maintenance Model + +### Bug Fix Scenario + +**Bug:** Chart generation broken + +**Fix location:** `scripts/get_price_chart.py` (1 file) + +**Impact:** All 30 wrapper skills fixed instantly + +**Wrapper changes:** 0 files + +**Time:** 10 minutes (edit core script only) + +### Adding New Token + +**Process:** +1. `cp ../btc/SKILL.md ../pepe/SKILL.md` +2. Edit 3 lines: `name: PEPE`, description, symbol +3. Done + +**Time:** 2 minutes + +**No template needed.** + +--- + +## Architecture Principles + +### 1. Single Source of Truth + +**Core script** = all logic +**Wrappers** = metadata only + +**Result:** Bug fix in 1 place affects all 30 skills. + +### 2. Minimal Wrappers + +**Current:** 35 lines per wrapper +**Can't reduce further** without losing readability + +**Template would add complexity** (template file + generator script + docs) without reducing wrapper size. + +### 3. Clear Separation of Concerns + +**Core script:** +- API calls (Hyperliquid, CoinGecko) +- Caching (300s TTL) +- Chart generation (matplotlib) +- Error handling (retries, fallbacks) + +**Wrappers:** +- Clawdbot metadata (name, description, emoji) +- Trigger conditions (slash command, keywords) +- Symbol mapping (e.g., `gld` → `GOLD-USDC`) + +**Clawdbot:** +- Skill matching (user input → skill) +- Execution (run core script) +- Reply formatting (text + image) + +### 4. Easy to Extend + +**New token:** Copy wrapper + edit 3 lines + +**No script maintenance needed.** + +--- + +## Data Flow + +``` +User: /hype 12h + ↓ +Clawdbot: Match skill hype/SKILL.md + ↓ +Execute: python3 .../get_price_chart.py HYPE 12h + ↓ +Core Script: + 1. Parse duration + 2. Try Hyperliquid API + 3. Fallback to CoinGecko + 4. Check cache (5 min TTL) + 5. Generate chart (matplotlib) + 6. Return JSON + ↓ +Clawdbot: Send text + attach image + ↓ +User sees: Text + Chart +``` + +**Single entry point:** Core script +**Single maintenance location:** Core script + +--- + +## Testing Results + +**Test 1: HYPE (Hyperliquid)** +```bash +$ python3 get_price_chart.py HYPE +{"symbol": "HYPE", "price": 27.31, "text_plain": "HYPE: $27.31 USD ⬆️ +22.60% over 24h"} +``` +✅ Works + +**Test 2: BTC 12h** +```bash +$ python3 get_price_chart.py BTC 12h +{"symbol": "BTC", "price": 88263.00, "text_plain": "BTC: $88263.00 USD ⬆️ +0.53% over 12h"} +``` +✅ Works + +**Test 3: Rate Limit** +```bash +$ python3 get_price_chart.py PEPE +{"error": "price lookup failed", "details": "HTTP Error 429: Too Many Requests"} +``` +✅ Graceful error + +**Test 4: Wrapper Consistency** +```bash +$ diff -u btc/SKILL.md eth/SKILL.md +-name: BTC ++name: ETH +-# Bitcoin Price ++# Ethereum Price +-get_price_chart.py BTC ++get_price_chart.py ETH +``` +✅ Identical structure (only symbol differs) + +--- + +## Risks Assessment + +### Risk 1: Core Script Failure +**Scenario:** Bug in `get_price_chart.py` → all 30 skills break + +**Likelihood:** LOW (stable for months) + +**Mitigation:** +- Error handling in script +- Caching reduces API dependency +- Graceful degradation (price without chart if matplotlib fails) + +**Impact:** HIGH (all token commands broken) + +**Note:** Same risk exists regardless of wrapper structure. Not a refactoring issue. + +### Risk 2: API Rate Limits +**Scenario:** CoinGecko 50 calls/min exceeded + +**Likelihood:** MEDIUM (confirmed during testing) + +**Mitigation:** +- 5-minute cache (TTL 300s) +- Hyperliquid fallback +- Retry logic with backoff + +**Impact:** LOW (temporary failure, user retries in 5 min) + +### Risk 3: Template Maintenance +**Scenario:** Template generator breaks + +**Likelihood:** ZERO (no template exists) + +**Current approach:** Manual copy-paste (simpler, no generator to maintain) + +--- + +## Comparison: Current vs Template System + +| Aspect | Current | With Template | +|--------|---------|---------------| +| Core logic files | 1 | 1 | +| Template files | 0 | 1 | +| Generator scripts | 0 | 1 | +| Wrapper files | 30 | 30 | +| Lines per wrapper | 35 | 35 (same) | +| Add new token | 2 min (copy + edit) | 5 min (run generator) | +| Maintenance overhead | Low (1 file) | Medium (3 files) | +| Debugging complexity | Low | Medium (script + template) | +| Learning curve | Low | Medium | + +**Winner:** Current approach + +--- + +## Alternatives Considered + +### Alternative 1: Full Template Generation +**Idea:** Template file + generator script + +**Pros:** +- Slightly more "automated" + +**Cons:** +- More files to maintain (template + script + docs) +- More complexity (scripting, error handling) +- No reduction in wrapper size (still 35 lines) +- Slower than copy-paste + +**Decision:** Rejected (adds complexity without benefit) + +### Alternative 2: Single Universal Skill +**Idea:** Remove all wrappers, use only `/token ANY` + +**Pros:** +- Fewer files + +**Cons:** +- Worse UX (users must remember symbols) +- No autocomplete for popular tokens +- Loses slash command convenience (`/btc` vs `/token BTC`) + +**Decision:** Rejected (UX downgrade) + +### Alternative 3: Current System (Winner) +**Idea:** 1 core script + minimal wrappers (copy-paste to add tokens) + +**Pros:** +- Simple (no template complexity) +- Fast (copy + edit 3 lines = 2 min) +- Easy to understand (no hidden generation logic) +- Zero duplication (all use same script) + +**Cons:** +- None + +**Decision:** Keep current system + +--- + +## Common Misconceptions + +### Misconception 1: "25 duplicate skills" +**Reality:** 28 wrappers + 2 utility skills, **zero logic duplication** + +All wrappers call same script. No duplicated code. + +### Misconception 2: "Maintenance nightmare" +**Reality:** Bug fix in 1 place (core script) = instant fix for all 30 skills + +Wrappers never need maintenance (only core script). + +### Misconception 3: "Template would reduce duplication" +**Reality:** No duplication exists to reduce + +Wrappers are metadata-only (35 lines). Template wouldn't make them smaller. + +### Misconception 4: "Hard to add new tokens" +**Reality:** 2 minutes to copy + edit 3 lines + +Simpler than maintaining template + generator. + +--- + +## Optional Improvements + +**None required, but if desired:** + +### 1. Add Unit Tests +**File:** `tests/test_get_price_chart.py` + +**Why:** +- Catch bugs before deployment +- Validate API changes + +**Effort:** 2-3 hours + +### 2. Add Helper Script +**File:** `scripts/add_token.sh` + +**Usage:** +```bash +./add_token.sh PEPE "Pepe Coin" pepe +# Creates ../pepe/SKILL.md from btc template +``` + +**Why:** +- Slightly faster than manual copy-paste +- Reduces typos + +**Effort:** 30 minutes + +**Note:** Still simpler than full template system. + +### 3. Documentation +**File:** This document (`ARCHITECTURE.md`) + +**Why:** +- Explain design decisions +- Prevent future "refactoring" attempts + +**Effort:** Done + +--- + +## Conclusion + +### Summary + +**System is optimal as-is:** + +✅ 1 core script (all logic) +✅ 30 minimal wrappers (metadata only) +✅ Zero duplication +✅ Easy maintenance (bug fix in 1 place) +✅ Easy to extend (copy + edit 3 lines) + +**Template generation would:** + +❌ Add complexity (more files to maintain) +❌ Slow down token addition (scripting overhead) +❌ Not reduce wrapper size (still 35 lines) +❌ Not improve maintenance (already 1 bug fix location) + +### Recommendation + +**Action:** Keep current system. No refactoring needed. + +**Rationale:** Current approach is simpler, faster, and easier to understand than template generation. + +--- + +## References + +**Core Script:** +- `scripts/get_price_chart.py` (~800 lines) +- Git repo: `git@github.com:evgyur/crypto-price.git` + +**Wrapper Example:** +- `../btc/SKILL.md` (35 lines) + +**Documentation:** +- `README.md` (user guide) +- `ARCHITECTURE.md` (this document) + +**Research:** +- `/home/eyurc/clawd/memory/2026-01-27-crypto-refactor-research.md` (full analysis) + +--- + +**Last Updated:** 2026-01-27 +**Status:** Stable, no changes planned diff --git a/README.md b/README.md new file mode 100644 index 0000000..3c20b32 --- /dev/null +++ b/README.md @@ -0,0 +1,194 @@ +# 📈 Crypto Price & Chart + +A Clawdbot skill for fetching cryptocurrency token prices and generating beautiful candlestick charts. + +## Features + +- 🚀 **Fast price lookup** via CoinGecko and Hyperliquid APIs +- 📊 **Candlestick charts** with dark theme (8x8 square format) +- ⚡ **Smart caching** (5-minute TTL for price data) +- 🎯 **Multiple data sources** (Hyperliquid preferred for supported tokens, CoinGecko fallback) +- 📱 **Flexible timeframes** (30m, 3h, 12h, 24h, 2d) + +## Installation + +### Via ClawdHub + +```bash +clawdhub install evgyur/crypto-price +``` + +### Manual Installation + +1. Clone or copy this skill to your Clawdbot workspace: + ```bash + cd ~/.clawdbot/workspace/skills + git clone https://github.com/evgyur/crypto-price.git + ``` + +2. Ensure Python 3 is installed: + ```bash + python3 --version + ``` + +3. Install required Python packages: + ```bash + pip install matplotlib + ``` + +4. Verify installation: + ```bash + clawdbot skills info crypto-price + ``` + +## Usage + +### As a Skill + +The skill is automatically triggered when users ask for: +- Token prices +- Crypto charts +- Cryptocurrency market data + +### Direct Script Usage + +```bash +python3 scripts/get_price_chart.py [duration] +``` + +**Examples:** +```bash +# Get HYPE price and 24h chart +python3 scripts/get_price_chart.py HYPE + +# Get Bitcoin price and 12h chart +python3 scripts/get_price_chart.py BTC 12h + +# Get Ethereum price and 3h chart +python3 scripts/get_price_chart.py ETH 3h + +# Get Solana price and 30m chart +python3 scripts/get_price_chart.py SOL 30m + +# Get Cardano price and 2d chart +python3 scripts/get_price_chart.py ADA 2d +``` + +### Duration Format + +- `30m` - 30 minutes +- `3h` - 3 hours +- `12h` - 12 hours +- `24h` - 24 hours (default) +- `2d` - 2 days + +## Output Format + +The script returns JSON with the following structure: + +```json +{ + "symbol": "BTC", + "token_id": "bitcoin", + "source": "coingecko", + "currency": "USD", + "hours": 24.0, + "duration_label": "24h", + "candle_minutes": 15, + "price": 89946.00, + "price_usdt": 89946.00, + "change_period": -54.00, + "change_period_percent": -0.06, + "chart_path": "/tmp/crypto_chart_BTC_1769142011.png", + "text": "BTC: $89946.00 USD (-0.06% over 24h)", + "text_plain": "BTC: $89946.00 USD (-0.06% over 24h)" +} +``` + +## Chart Generation + +- **Type**: Candlestick (OHLC) +- **Size**: 8x8 inches (square format) +- **Theme**: Dark (#121212 background) +- **Colors** (default mode): + - Grey (#B0B0B0 / #606060) normal candles + - Cyan (#00FFFF) bullish swing reversals (3 candles after swing low) + - Magenta (#FF00FF) bearish swing reversals (3 candles after swing high) + - Gold (#FFD54F) / Light Blue (#90CAF9) absolute high/low markers +- **Colors** (gradient mode, add `gradient` flag): + - Green gradient (#84dc58 → #336d16) bullish candles + - Blue-purple gradient (#6c7ce4 → #544996) bearish candles +- **Features**: + - Fractal swing high/low detection (true pivots, configurable window) + - Volume bars (when available from API) + - Last price highlighted on Y-axis + - Tomorrow font for crisp rendering +- **Output**: PNG files saved to `/tmp/crypto_chart_{SYMBOL}_{timestamp}.png` + +## Data Sources + +1. **Hyperliquid API** (`https://api.hyperliquid.xyz/info`) + - Preferred for HYPE and other Hyperliquid tokens + - Provides real-time price data and candlestick data + +2. **CoinGecko API** (`https://api.coingecko.com/api/v3/`) + - Fallback for all other tokens + - Supports price lookup, market charts, and OHLC data + +## Caching + +Price data is cached for 300 seconds (5 minutes) to reduce API calls: +- Cache files: `/tmp/crypto_price_*.json` +- Automatic cache invalidation after TTL + +## Supported Tokens + +Works with any token supported by CoinGecko or Hyperliquid: +- **Popular tokens**: BTC, ETH, SOL, ADA, DOT, LINK, MATIC, AVAX, ATOM, ALGO, XLM, XRP, LTC, BCH, ETC, TRX, XMR, DASH, ZEC, EOS, BNB, DOGE, SHIB, UNI, AAVE +- **Hyperliquid tokens**: HYPE, and other tokens listed on Hyperliquid + +## Requirements + +- Python 3.6+ +- `matplotlib` library +- Internet connection for API calls + +## Dependencies + +```bash +pip install matplotlib +``` + +## License + +MIT + +## Author + +Created for Clawdbot community. Originally part of Clawdbot bundled skills, restored and enhanced. + +## Contributing + +Contributions welcome! Please feel free to submit a Pull Request. + +## Related Skills + +This skill works with slash command skills: +- `/hype` - HYPE token price and chart +- `/token ` - Any token price and chart +- `/btc`, `/eth`, `/sol`, etc. - Popular tokens + +## Links + +- [GitHub Repository](https://github.com/evgyur/crypto-price) +- [ClawdHub](https://clawdhub.com/evgyur/crypto-price) +- [Clawdbot Documentation](https://docs.clawd.bot) + +## Changelog + +### v1.0.0 +- Initial release +- Support for CoinGecko and Hyperliquid APIs +- Candlestick chart generation +- Smart caching system +- Multiple timeframe support diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..d9f3c3e --- /dev/null +++ b/SKILL.md @@ -0,0 +1,50 @@ +--- +name: crypto-price +description: "通过CoinGecko API或Hyperliquid API获取加密货币代币价格并生成K线图。当用户询问代币价格、加密货币价格、价格图表或加密货币市场数据时使用。" +metadata: {"clawdbot":{"emoji":"📈","requires":{"bins":["python3"]}}} +--- + +# Crypto Price & Chart + +Get cryptocurrency token price and generate candlestick charts. + +## Usage + +Execute the script with token symbol and optional duration: + +```bash +python3 {baseDir}/scripts/get_price_chart.py [duration] +``` + +**Examples:** +- `python3 {baseDir}/scripts/get_price_chart.py HYPE` +- `python3 {baseDir}/scripts/get_price_chart.py HYPE 12h` +- `python3 {baseDir}/scripts/get_price_chart.py BTC 3h` +- `python3 {baseDir}/scripts/get_price_chart.py ETH 30m` +- `python3 {baseDir}/scripts/get_price_chart.py SOL 2d` + +**Duration format:** `30m`, `3h`, `12h`, `24h` (default), `2d` + +## Output + +Returns JSON with: +- `price` - Current price in USD/USDT +- `change_period_percent` - Price change percentage for the period +- `chart_path` - Path to generated PNG chart (if available) +- `text_plain` - Formatted text description + +**Chart as image (always when chart_path is present):** +You must send the chart as a **photo**, not as text. In your reply, output `text_plain` and on a new line: `MEDIA: ` followed by the exact `chart_path` value (e.g. `MEDIA: /tmp/crypto_chart_HYPE_1769204734.png`). Clawdbot will attach that file as an image. Do **not** write `[chart: path]` or any other text placeholder — only the `MEDIA: ` line makes the image appear. + +## Chart Details + +- Format: Candlestick chart (8x8 square) +- Theme: Dark (#0f141c background) +- Output: `/tmp/crypto_chart_{SYMBOL}_{timestamp}.png` + +## Data Sources + +1. **Hyperliquid API** - For HYPE and other Hyperliquid tokens (preferred) +2. **CoinGecko API** - Fallback for other tokens + +Price data cached for 300 seconds (5 minutes) in `/tmp/crypto_price_*.json`. diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..2d2dfb3 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn7dehtnm6pbrnszan740htxtn7zq8wy", + "slug": "crypto-price", + "version": "0.2.2", + "publishedAt": 1769498220031 +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..974b126 --- /dev/null +++ b/requirements.txt @@ -0,0 +1 @@ +matplotlib>=3.5.0 diff --git a/scripts/get_price_chart.py b/scripts/get_price_chart.py new file mode 100644 index 0000000..510e051 --- /dev/null +++ b/scripts/get_price_chart.py @@ -0,0 +1,988 @@ +#!/usr/bin/env python3 +import json +import math +import re +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from datetime import datetime, timezone + +DEFAULT_HOURS = 24 +CANDLE_MINUTES = 15 +CACHE_TTL_SEC = 300 +COINGECKO_PRICE_URL = "https://api.coingecko.com/api/v3/simple/price?ids={id}&vs_currencies={currency}" +COINGECKO_OHLC_URL = "https://api.coingecko.com/api/v3/coins/{id}/ohlc?vs_currency={currency}&days=1" +COINGECKO_SEARCH_URL = "https://api.coingecko.com/api/v3/search?query={query}" +COINGECKO_MARKET_CHART_URL = "https://api.coingecko.com/api/v3/coins/{id}/market_chart?vs_currency={currency}&days=1" +COINGECKO_MARKET_CHART_DAYS_URL = "https://api.coingecko.com/api/v3/coins/{id}/market_chart?vs_currency={currency}&days={days}" +HYPERLIQUID_INFO_URL = "https://api.hyperliquid.xyz/info" + +TOKEN_ID_MAP = { + "HYPE": "hyperliquid", + "HYPERLIQUID": "hyperliquid", +} + + +def _json_error(message, details=None): + payload = {"error": message} + if details: + payload["details"] = details + print(json.dumps(payload)) + return 0 + + +def _cache_path(prefix, token_id): + safe = token_id.replace("/", "-") + return f"/tmp/crypto_price_{prefix}_{safe}.json" + + +def _read_cache(path, max_age_sec): + try: + stat = os.stat(path) + except FileNotFoundError: + return None + age = time.time() - stat.st_mtime + if age > max_age_sec: + return None + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except (OSError, json.JSONDecodeError): + return None + + +def _write_cache(path, payload): + try: + with open(path, "w", encoding="utf-8") as handle: + json.dump(payload, handle) + except OSError: + return + + +def _fetch_json(url): + req = urllib.request.Request( + url, + headers={"User-Agent": "clawdbot-crypto-price/1.0"}, + ) + retry_codes = {429, 502, 503, 504} + last_error = None + for attempt in range(3): + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8") + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("invalid JSON") from exc + except urllib.error.HTTPError as exc: + last_error = exc + if exc.code in retry_codes and attempt < 2: + time.sleep(2 * (attempt + 1)) + continue + raise RuntimeError(str(exc)) from exc + except urllib.error.URLError as exc: + last_error = exc + if attempt < 2: + time.sleep(2 * (attempt + 1)) + continue + raise RuntimeError(str(exc)) from exc + raise RuntimeError(str(last_error)) + + +def _post_json(url, payload): + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json", "User-Agent": "clawdbot-crypto-price/1.0"}, + ) + retry_codes = {429, 502, 503, 504} + last_error = None + for attempt in range(3): + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8") + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("invalid JSON") from exc + except urllib.error.HTTPError as exc: + last_error = exc + if exc.code in retry_codes and attempt < 2: + time.sleep(2 * (attempt + 1)) + continue + raise RuntimeError(str(exc)) from exc + except urllib.error.URLError as exc: + last_error = exc + if attempt < 2: + time.sleep(2 * (attempt + 1)) + continue + raise RuntimeError(str(exc)) from exc + raise RuntimeError(str(last_error)) + + +def _post_json(url, payload): + req = urllib.request.Request( + url, + data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json", "User-Agent": "clawdbot-crypto-price/1.0"}, + ) + retry_codes = {429, 502, 503, 504} + last_error = None + for attempt in range(3): + try: + with urllib.request.urlopen(req, timeout=15) as resp: + raw = resp.read().decode("utf-8") + try: + return json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError("invalid JSON") from exc + except urllib.error.HTTPError as exc: + last_error = exc + if exc.code in retry_codes and attempt < 2: + time.sleep(2 * (attempt + 1)) + continue + raise RuntimeError(str(exc)) from exc + except urllib.error.URLError as exc: + last_error = exc + if attempt < 2: + time.sleep(2 * (attempt + 1)) + continue + raise RuntimeError(str(exc)) from exc + raise RuntimeError(str(last_error)) + + +def _get_price(token_id, currency): + cache_path = _cache_path(f"price_{currency}", token_id) + cached = _read_cache(cache_path, CACHE_TTL_SEC) + if cached is not None: + return cached + data = _fetch_json(COINGECKO_PRICE_URL.format(id=token_id, currency=currency)) + _write_cache(cache_path, data) + return data + + +def _get_ohlc(token_id, currency): + cache_path = _cache_path(f"ohlc_{currency}", token_id) + cached = _read_cache(cache_path, CACHE_TTL_SEC) + if cached is not None: + return cached + data = _fetch_json(COINGECKO_OHLC_URL.format(id=token_id, currency=currency)) + _write_cache(cache_path, data) + return data + + +def _get_market_chart(token_id, currency, days): + cache_path = _cache_path(f"market_{currency}_{days}", token_id) + cached = _read_cache(cache_path, CACHE_TTL_SEC) + if cached is not None: + return cached + if days == 1: + url = COINGECKO_MARKET_CHART_URL.format(id=token_id, currency=currency) + else: + url = COINGECKO_MARKET_CHART_DAYS_URL.format(id=token_id, currency=currency, days=days) + data = _fetch_json(url) + _write_cache(cache_path, data) + return data + + +def _get_hyperliquid_meta(): + cache_path = _cache_path("hyperliquid_meta", "meta") + cached = _read_cache(cache_path, CACHE_TTL_SEC) + if cached is not None: + return cached + data = _post_json(HYPERLIQUID_INFO_URL, {"type": "metaAndAssetCtxs"}) + _write_cache(cache_path, data) + return data + + +def _hyperliquid_lookup(symbol): + try: + meta, ctxs = _get_hyperliquid_meta() + except RuntimeError: + return None, None + universe = meta.get("universe", []) + mapping = {} + for idx, entry in enumerate(universe): + name = str(entry.get("name", "")).upper() + if name: + mapping[name] = idx + idx = mapping.get(symbol.upper()) + if idx is None or idx >= len(ctxs): + return None, None + return universe[idx], ctxs[idx] + + +def _pick_hyperliquid_interval_minutes(total_minutes): + if total_minutes <= 180: + return 1 + if total_minutes <= 360: + return 3 + if total_minutes <= 720: + return 5 + if total_minutes <= 1440: + return 15 + if total_minutes <= 4320: + return 30 + if total_minutes <= 10080: + return 60 + if total_minutes <= 20160: + return 120 + if total_minutes <= 40320: + return 240 + if total_minutes <= 80640: + return 480 + return 1440 + + +def _interval_minutes_to_str(minutes): + if minutes < 60: + return f"{int(minutes)}m" + hours = int(minutes / 60) + if hours < 24: + return f"{hours}h" + days = int(hours / 24) + return f"{days}d" + + +def _get_hyperliquid_candles(symbol, total_minutes, interval_minutes): + now_ms = int(time.time() * 1000) + start_ms = now_ms - int(total_minutes * 60 * 1000) + payload = { + "type": "candleSnapshot", + "req": { + "coin": symbol.upper(), + "interval": _interval_minutes_to_str(interval_minutes), + "startTime": start_ms, + "endTime": now_ms, + }, + } + data = _post_json(HYPERLIQUID_INFO_URL, payload) + candles = [] + for row in data: + try: + ts_ms = int(row["t"]) + open_price = float(row["o"]) + high_price = float(row["h"]) + low_price = float(row["l"]) + close_price = float(row["c"]) + except (KeyError, TypeError, ValueError): + continue + candles.append((ts_ms, open_price, high_price, low_price, close_price)) + return candles + + +def _get_hyperliquid_meta(): + cache_path = _cache_path("hyperliquid_meta", "meta") + cached = _read_cache(cache_path, CACHE_TTL_SEC) + if cached is not None: + return cached + data = _post_json(HYPERLIQUID_INFO_URL, {"type": "metaAndAssetCtxs"}) + _write_cache(cache_path, data) + return data + + +def _hyperliquid_lookup(symbol): + try: + meta, ctxs = _get_hyperliquid_meta() + except RuntimeError: + return None, None + universe = meta.get("universe", []) + mapping = {} + for idx, entry in enumerate(universe): + name = str(entry.get("name", "")).upper() + if name: + mapping[name] = idx + idx = mapping.get(symbol.upper()) + if idx is None or idx >= len(ctxs): + return None, None + return universe[idx], ctxs[idx] + + +def _pick_hyperliquid_interval_minutes(total_minutes): + if total_minutes <= 180: + return 1 + if total_minutes <= 360: + return 3 + if total_minutes <= 720: + return 5 + if total_minutes <= 1440: + return 15 + if total_minutes <= 4320: + return 30 + if total_minutes <= 10080: + return 60 + if total_minutes <= 20160: + return 120 + if total_minutes <= 40320: + return 240 + if total_minutes <= 80640: + return 480 + return 1440 + + +def _interval_minutes_to_str(minutes): + if minutes < 60: + return f"{int(minutes)}m" + hours = int(minutes / 60) + if hours < 24: + return f"{hours}h" + days = int(hours / 24) + return f"{days}d" + + +def _get_hyperliquid_candles(symbol, total_minutes, interval_minutes): + now_ms = int(time.time() * 1000) + start_ms = now_ms - int(total_minutes * 60 * 1000) + payload = { + "type": "candleSnapshot", + "req": { + "coin": symbol.upper(), + "interval": _interval_minutes_to_str(interval_minutes), + "startTime": start_ms, + "endTime": now_ms, + }, + } + data = _post_json(HYPERLIQUID_INFO_URL, payload) + candles = [] + for row in data: + try: + ts_ms = int(row["t"]) + open_price = float(row["o"]) + high_price = float(row["h"]) + low_price = float(row["l"]) + close_price = float(row["c"]) + volume = float(row.get("v", 0)) + except (KeyError, TypeError, ValueError): + continue + candles.append((ts_ms, open_price, high_price, low_price, close_price, volume)) + return candles + + +def _find_fractals(ohlc_rows, window=10, max_fractals=3): + """Find true swing highs and lows. + Swing high: highest high within window candles on both sides. + Swing low: lowest low within window candles on both sides. + Returns list of (index, type, price) where type is 'up' or 'down'. + """ + if len(ohlc_rows) < window * 2 + 1: + return [] + + swing_highs = [] + swing_lows = [] + + for i in range(window, len(ohlc_rows) - window): + current_high = ohlc_rows[i][2] + current_low = ohlc_rows[i][3] + + # Check if this is a swing high (highest high in the window) + is_swing_high = True + for j in range(i - window, i + window + 1): + if j != i and ohlc_rows[j][2] >= current_high: + is_swing_high = False + break + + if is_swing_high: + swing_highs.append((i, current_high)) + + # Check if this is a swing low (lowest low in the window) + is_swing_low = True + for j in range(i - window, i + window + 1): + if j != i and ohlc_rows[j][3] <= current_low: + is_swing_low = False + break + + if is_swing_low: + swing_lows.append((i, current_low)) + + # Sort by price extremity and take top N + # For highs: sort by price descending (highest first) + swing_highs.sort(key=lambda x: -x[1]) + # For lows: sort by price ascending (lowest first) + swing_lows.sort(key=lambda x: x[1]) + + result = [] + for idx, price in swing_highs[:max_fractals]: + result.append((idx, 'down', price)) # down arrow for resistance/high + for idx, price in swing_lows[:max_fractals]: + result.append((idx, 'up', price)) # up arrow for support/low + + return sorted(result, key=lambda x: x[0]) + + +def _search_token_id(symbol): + cache_path = _cache_path("search", symbol.upper()) + cached = _read_cache(cache_path, CACHE_TTL_SEC) + if cached is None: + data = _fetch_json(COINGECKO_SEARCH_URL.format(query=urllib.parse.quote(symbol))) + _write_cache(cache_path, data) + else: + data = cached + + coins = data.get("coins", []) + symbol_upper = symbol.upper() + matches = [coin for coin in coins if coin.get("symbol", "").upper() == symbol_upper] + if not matches: + return None + + def _rank_key(coin): + rank = coin.get("market_cap_rank") + return rank if isinstance(rank, int) else 10**9 + + matches.sort(key=_rank_key) + return matches[0].get("id") + + +def _format_price(value): + if value is None: + return "n/a" + if value >= 1: + return f"{value:.2f}" + return f"{value:.6f}" + + +def _build_candles_from_prices(price_points, hours, candle_minutes): + if not price_points: + return [] + price_points.sort(key=lambda row: row[0]) + last_ts = price_points[-1][0] + start_ts = last_ts - (hours * 3600 * 1000) + bucket_ms = candle_minutes * 60 * 1000 + candles = [] + bucket = None + for ts, price in price_points: + if ts < start_ts: + continue + bucket_start = (int(ts) // bucket_ms) * bucket_ms + if bucket is None or bucket["bucket_start"] != bucket_start: + if bucket is not None: + candles.append(( + bucket["bucket_start"], + bucket["open"], + bucket["high"], + bucket["low"], + bucket["close"], + )) + bucket = { + "bucket_start": bucket_start, + "open": price, + "high": price, + "low": price, + "close": price, + } + else: + bucket["high"] = max(bucket["high"], price) + bucket["low"] = min(bucket["low"], price) + bucket["close"] = price + if bucket is not None: + candles.append(( + bucket["bucket_start"], + bucket["open"], + bucket["high"], + bucket["low"], + bucket["close"], + )) + return candles + + +def _parse_duration(args): + for arg in args: + cleaned = arg.strip().lower() + match = re.match(r"^(\d+(?:\.\d+)?)([mhd])?$", cleaned) + if not match: + continue + value = float(match.group(1)) + unit = match.group(2) or "h" + if unit == "m": + total_minutes = max(1.0, value) + label = f"{int(value)}m" if value.is_integer() else f"{value}m" + elif unit == "d": + total_minutes = max(1.0, value * 24 * 60) + label = f"{int(value)}d" if value.is_integer() else f"{value}d" + else: + total_minutes = max(1.0, value * 60) + label = f"{int(value)}h" if value.is_integer() else f"{value}h" + return total_minutes, label + return float(DEFAULT_HOURS * 60), f"{DEFAULT_HOURS}h" + + +def _pick_candle_minutes(total_minutes): + if total_minutes <= 360: + return 5 + if total_minutes <= 1440: + return 15 + if total_minutes <= 4320: + return 30 + return 60 + + +def _timestamp_to_datetime(ts_value): + ts = float(ts_value) + if ts >= 1e12: + ts = ts / 1000.0 + return datetime.fromtimestamp(ts, tz=timezone.utc) + + +def _build_chart(symbol, ohlc_rows, currency, label, use_gradient=False): + try: + import matplotlib + matplotlib.use("Agg") + import matplotlib.pyplot as plt + import matplotlib.dates as mdates + from matplotlib.lines import Line2D + from matplotlib.patches import Rectangle + import matplotlib.font_manager as fm + + # Load custom font + font_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'fonts', 'Tomorrow.ttf') + if os.path.exists(font_path): + fm.fontManager.addfont(font_path) + custom_font = fm.FontProperties(fname=font_path).get_name() + plt.rcParams['font.family'] = custom_font + except Exception: + return None + + if not ohlc_rows: + return None + + # Check if we have volume data (6-element tuples) + has_volume = len(ohlc_rows[0]) >= 6 if ohlc_rows else False + + if has_volume: + fig, (ax, ax_vol) = plt.subplots(2, 1, figsize=(8, 9), facecolor="#121212", + gridspec_kw={'height_ratios': [3, 1], 'hspace': 0.05}) + ax_vol.set_facecolor("#121212") + else: + fig, ax = plt.subplots(figsize=(8, 8), facecolor="#121212") + ax_vol = None + + ax.set_facecolor("#121212") + + times = [_timestamp_to_datetime(row[0]) for row in ohlc_rows] + x_vals = mdates.date2num(times) + widths = [] + if len(x_vals) > 1: + delta = min(x_vals[i + 1] - x_vals[i] for i in range(len(x_vals) - 1)) + widths = [delta * 0.7] * len(x_vals) + else: + widths = [0.02] * len(x_vals) + delta = 0.02 + + lows = [] + highs = [] + volumes = [] + colors = [] + + # Pre-calculate fractals + fractals = _find_fractals(ohlc_rows) + + # Build sets of indices for swing coloring (only used in default mode) + bullish_reversal_indices = set() + bearish_reversal_indices = set() + + # Always include absolute high/low candles in coloring + abs_high_idx = None + abs_low_idx = None + if ohlc_rows: + abs_high_idx = max(range(len(ohlc_rows)), key=lambda i: ohlc_rows[i][2]) + abs_low_idx = min(range(len(ohlc_rows)), key=lambda i: ohlc_rows[i][3]) + + if not use_gradient: + for frac_idx, frac_type, frac_price in fractals: + if frac_type == 'up': # swing low = bullish reversal + for off in range(0, 3): # swing candle + 2 after = 3 total + if frac_idx + off < len(ohlc_rows): + bullish_reversal_indices.add(frac_idx + off) + else: # swing high = bearish reversal + for off in range(0, 3): # swing candle + 2 after = 3 total + if frac_idx + off < len(ohlc_rows): + bearish_reversal_indices.add(frac_idx + off) + + if abs_low_idx is not None: + for off in range(0, 3): + if abs_low_idx + off < len(ohlc_rows): + bullish_reversal_indices.add(abs_low_idx + off) + if abs_high_idx is not None: + for off in range(0, 3): + if abs_high_idx + off < len(ohlc_rows): + bearish_reversal_indices.add(abs_high_idx + off) + + for idx, row in enumerate(ohlc_rows): + if has_volume: + _ts, open_price, high_price, low_price, close_price, volume = row + volumes.append(volume) + else: + _ts, open_price, high_price, low_price, close_price = row[:5] + + is_bullish = close_price >= open_price + x = x_vals[idx] + width = widths[idx] + lower = min(open_price, close_price) + height = max(abs(close_price - open_price), 1e-9) + + if use_gradient: + # Gradient mode: green gradient up, blue-purple gradient down + wick_color = "#888888" + border_color = "#000000" + if is_bullish: + color_top = "#84dc58" # Bright green + color_bottom = "#336d16" # Dark green + else: + color_top = "#6c7ce4" # Blue + color_bottom = "#544996" # Purple + + colors.append(color_top) + wick = Line2D([x, x], [low_price, high_price], color=wick_color, linewidth=1.0, zorder=3) + ax.add_line(wick) + + # Draw gradient candle body + n_segments = 10 + segment_height = height / n_segments + for seg in range(n_segments): + t = seg / (n_segments - 1) if n_segments > 1 else 0 + r1, g1, b1 = int(color_bottom[1:3], 16), int(color_bottom[3:5], 16), int(color_bottom[5:7], 16) + r2, g2, b2 = int(color_top[1:3], 16), int(color_top[3:5], 16), int(color_top[5:7], 16) + r = int(r1 + (r2 - r1) * t) + g = int(g1 + (g2 - g1) * t) + b = int(b1 + (b2 - b1) * t) + seg_color = f'#{r:02x}{g:02x}{b:02x}' + seg_y = lower + seg * segment_height + rect = Rectangle((x - width / 2, seg_y), width, segment_height, + facecolor=seg_color, edgecolor='none', zorder=4) + ax.add_patch(rect) + border_rect = Rectangle((x - width / 2, lower), width, height, + facecolor='none', edgecolor=border_color, linewidth=0.5, zorder=5) + ax.add_patch(border_rect) + else: + # Default mode: Grey + Cyan/Magenta for swings + wick_color = "#808080" + border_color = "#000000" + up_normal = "#B0B0B0" + down_normal = "#606060" + up_reversal = "#00FFFF" + down_reversal = "#FF00FF" + + if idx in bullish_reversal_indices: + color = up_reversal + elif idx in bearish_reversal_indices: + color = down_reversal + else: + color = up_normal if is_bullish else down_normal + + colors.append(color) + wick = Line2D([x, x], [low_price, high_price], color=wick_color, linewidth=1.0, zorder=3) + ax.add_line(wick) + rect = Rectangle((x - width / 2, lower), width, height, facecolor=color, edgecolor=border_color, linewidth=0.5, zorder=4) + ax.add_patch(rect) + + lows.append(low_price) + highs.append(high_price) + + # Draw fractals (already calculated above) + price_range = max(highs) - min(lows) if highs and lows else 1 + offset = price_range * 0.02 + + # Fractal colors based on mode + frac_up_color = "#84dc58" if use_gradient else "#00FFFF" + frac_down_color = "#6c7ce4" if use_gradient else "#FF00FF" + + for frac_idx, frac_type, frac_price in fractals: + x = x_vals[frac_idx] + if frac_type == 'down': # bearish fractal - arrow down above high + ax.plot(x, frac_price + offset * 0.5, marker='v', color=frac_down_color, markersize=6, zorder=5) + ax.annotate(f'{frac_price:.2f}', xy=(x, frac_price + offset * 1.5), + fontsize=8, color='white', ha='center', va='bottom', zorder=6) + else: # bullish fractal - arrow up below low + ax.plot(x, frac_price - offset * 0.5, marker='^', color=frac_up_color, markersize=6, zorder=5) + ax.annotate(f'{frac_price:.2f}', xy=(x, frac_price - offset * 1.5), + fontsize=8, color='white', ha='center', va='top', zorder=6) + + # Always mark absolute high/low so at least one swing high/low is visible + if highs and lows: + abs_high_price = max(highs) + abs_low_price = min(lows) + abs_high_idx = highs.index(abs_high_price) + abs_low_idx = lows.index(abs_low_price) + + abs_high_color = "#FFD54F" # gold + abs_low_color = "#90CAF9" # light blue + + xh = x_vals[abs_high_idx] + xl = x_vals[abs_low_idx] + + ax.plot(xh, abs_high_price + offset * 0.5, marker='v', color=abs_high_color, markersize=7, zorder=6) + ax.annotate(f'{abs_high_price:.2f}', xy=(xh, abs_high_price + offset * 1.7), + fontsize=8, color='white', ha='center', va='bottom', zorder=7) + + ax.plot(xl, abs_low_price - offset * 0.5, marker='^', color=abs_low_color, markersize=7, zorder=6) + ax.annotate(f'{abs_low_price:.2f}', xy=(xl, abs_low_price - offset * 1.7), + fontsize=8, color='white', ha='center', va='top', zorder=7) + + # Draw volume bars + if has_volume and ax_vol and volumes: + for idx, vol in enumerate(volumes): + x = x_vals[idx] + width = widths[idx] + rect = Rectangle((x - width / 2, 0), width, vol, + facecolor=colors[idx], edgecolor=colors[idx], alpha=0.7, zorder=3) + ax_vol.add_patch(rect) + + ax_vol.set_xlim(min(x_vals) - delta, max(x_vals) + delta) + ax_vol.set_ylim(0, max(volumes) * 1.1 if volumes else 1) + ax_vol.set_ylabel("Volume", color="#8b949e", fontsize=9) + ax_vol.tick_params(axis="x", colors="#8b949e") + ax_vol.tick_params(axis="y", colors="#8b949e") + for spine in ax_vol.spines.values(): + spine.set_color("#2a2f38") + ax_vol.grid(True, linestyle="-", linewidth=0.6, color="#1f2630", alpha=0.8, zorder=1) + + # Format volume axis + locator = mdates.AutoDateLocator(minticks=4, maxticks=8) + formatter = mdates.ConciseDateFormatter(locator) + ax_vol.xaxis.set_major_locator(locator) + ax_vol.xaxis.set_major_formatter(formatter) + ax.set_xticklabels([]) # Hide x labels on price chart + + ax.set_title(f"{symbol} last {label}", loc="center", fontsize=14, color="white", fontweight="bold", pad=12) + if not has_volume: + ax.set_xlabel("Time (UTC)", color="#8b949e") + ax.set_ylabel(currency.upper(), color="#8b949e") + + ax.tick_params(axis="x", colors="#8b949e") + ax.tick_params(axis="y", colors="#8b949e") + for spine in ax.spines.values(): + spine.set_color("#2a2f38") + + ax.grid(True, linestyle="-", linewidth=0.6, color="#1f2630", alpha=0.8, zorder=1) + + if len(x_vals) > 1: + ax.set_xlim(min(x_vals) - delta, max(x_vals) + delta) + if lows and highs: + min_y = min(lows) + max_y = max(highs) + pad = (max_y - min_y) * 0.08 if max_y > min_y else max_y * 0.01 # More padding for fractals + ax.set_ylim(min_y - pad, max_y + pad) + + if not has_volume: + locator = mdates.AutoDateLocator(minticks=4, maxticks=8) + formatter = mdates.ConciseDateFormatter(locator) + ax.xaxis.set_major_locator(locator) + ax.xaxis.set_major_formatter(formatter) + ax.tick_params(axis="x", labelrotation=0) + + # Highlight last price on Y-axis + if ohlc_rows: + last_close = ohlc_rows[-1][4] + # Draw horizontal dashed line at last price + ax.axhline(y=last_close, color='white', linestyle='--', linewidth=0.8, alpha=0.6) + # Add price label on left side + ax.annotate(f'{last_close:.2f}', xy=(ax.get_xlim()[0], last_close), + fontsize=9, color='white', fontweight='bold', + ha='right', va='center', + bbox=dict(boxstyle='round,pad=0.3', facecolor='#0f141c', edgecolor='white', linewidth=1)) + + ts = int(time.time()) + chart_path = f"/tmp/crypto_chart_{symbol}_{ts}.png" + fig.tight_layout() + fig.savefig(chart_path, dpi=150) + plt.close(fig) + return chart_path + + +def _normalize_hl_symbol(symbol): + sym = str(symbol or "").upper() + # Strip common separators (e.g., BTC-USD, BTC/USDC) + for sep in ("-", "/", "_"): + if sep in sym: + sym = sym.split(sep)[0] + break + # Strip common stablecoin suffixes (e.g., BTCUSDC) + stable_suffixes = ("USDC", "USDH", "USDE", "USD", "USDT") + for suf in stable_suffixes: + if sym.endswith(suf) and len(sym) > len(suf): + sym = sym[: -len(suf)] + break + return sym + + +def _hyperliquid_lookup(symbol): + try: + meta, ctxs = _get_hyperliquid_meta() + except RuntimeError: + return None, None + universe = meta.get("universe", []) + mapping = {} + for idx, entry in enumerate(universe): + name = str(entry.get("name", "")).upper() + if name: + mapping[name] = idx + norm = _normalize_hl_symbol(symbol) + idx = mapping.get(norm) + if idx is None or idx >= len(ctxs): + return None, None + return universe[idx], ctxs[idx] + + +def main(): + if len(sys.argv) < 2: + return _json_error("missing symbol", "Usage: get_price_chart.py ") + + raw_symbol = sys.argv[1].strip() + if not raw_symbol: + return _json_error("missing symbol", "Usage: get_price_chart.py ") + + symbol_upper = raw_symbol.upper() + token_id = TOKEN_ID_MAP.get(symbol_upper) + if token_id is None: + token_id = raw_symbol.lower() + + total_minutes, label = _parse_duration(sys.argv[2:]) + # Check for gradient mode flag + use_gradient = any(arg.lower() in ('gradient', 'grad', '-g', '--gradient') for arg in sys.argv[2:]) + hours = total_minutes / 60.0 + source = "coingecko" + currency = "usdt" + price_usdt = None + hl_symbol = _normalize_hl_symbol(symbol_upper) + hl_meta, hl_ctx = _hyperliquid_lookup(hl_symbol) + if hl_ctx: + source = "hyperliquid" + currency = "usd" + try: + price_usdt = float(hl_ctx.get("markPx") or hl_ctx.get("midPx")) + except (TypeError, ValueError): + price_usdt = None + if price_usdt is None: + try: + price_payload = _get_price(token_id, currency) + except RuntimeError as exc: + return _json_error("price lookup failed", str(exc)) + + price_entry = price_payload.get(token_id, {}) + price_usdt = price_entry.get(currency) + if price_usdt is None: + currency = "usd" + try: + price_payload = _get_price(token_id, currency) + except RuntimeError as exc: + return _json_error("price lookup failed", str(exc)) + price_entry = price_payload.get(token_id, {}) + price_usdt = price_entry.get(currency) + + if price_usdt is None and token_id == raw_symbol.lower(): + try: + searched_id = _search_token_id(symbol_upper) + except RuntimeError as exc: + return _json_error("token search failed", str(exc)) + if searched_id: + token_id = searched_id + currency = "usdt" + try: + price_payload = _get_price(token_id, currency) + except RuntimeError as exc: + return _json_error("price lookup failed", str(exc)) + price_entry = price_payload.get(token_id, {}) + price_usdt = price_entry.get(currency) + if price_usdt is None: + currency = "usd" + try: + price_payload = _get_price(token_id, currency) + except RuntimeError as exc: + return _json_error("price lookup failed", str(exc)) + price_entry = price_payload.get(token_id, {}) + price_usdt = price_entry.get(currency) + + if price_usdt is None: + return _json_error("token not found", f"CoinGecko id: {token_id}") + + candles = [] + candle_minutes = _pick_candle_minutes(total_minutes) + if source == "hyperliquid": + interval_minutes = _pick_hyperliquid_interval_minutes(total_minutes) + candle_minutes = interval_minutes + try: + candles = _get_hyperliquid_candles(hl_symbol, total_minutes, interval_minutes) + except RuntimeError: + candles = [] + + if not candles: + try: + days = max(1, int(math.ceil(total_minutes / 1440.0))) + if days > 365: + days = 365 + chart_payload = _get_market_chart(token_id, currency, days) + price_points = chart_payload.get("prices", []) + candles = _build_candles_from_prices(price_points, hours, candle_minutes) + except RuntimeError: + candles = [] + + if not candles: + candle_minutes = 30 + try: + ohlc_payload = _get_ohlc(token_id, currency) + except RuntimeError: + ohlc_payload = [] + for row in ohlc_payload: + if len(row) < 5: + continue + ts_ms, open_price, high_price, low_price, close_price = row + candles.append((ts_ms, open_price, high_price, low_price, close_price)) + + candles.sort(key=lambda item: item[0]) + if candles: + target = max(2, int((hours * 60) / candle_minutes)) + target = int(target * 0.8) # 20% fewer candles for breathing room + last_points = candles[-target:] + else: + last_points = [] + + change_period = None + change_period_percent = None + price_period_ago = None + + if len(last_points) >= 2: + price_period_ago = last_points[0][4] + if price_period_ago: + change_period = price_usdt - price_period_ago + change_period_percent = (change_period / price_period_ago) * 100 + chart_path = _build_chart(symbol_upper, last_points, currency, label, use_gradient) + + if change_period_percent is None: + change_text = f"{label} n/a" + else: + if change_period_percent >= 0: + emoji = "⬆️" + sign = "+" + else: + emoji = "🔻" + sign = "" + change_text = f"{emoji} {sign}{change_period_percent:.2f}% over {label}" + + text = f"{symbol_upper}: ${_format_price(price_usdt)} {currency.upper()} {change_text}" + text = text.replace("*", "") + result = { + "symbol": symbol_upper, + "token_id": token_id, + "source": source, + "currency": currency.upper(), + "hours": hours, + "duration_label": label, + "candle_minutes": candle_minutes, + "price": price_usdt, + "price_usdt": price_usdt, + "change_12h": change_period, + "change_12h_percent": change_period_percent, + "change_period": change_period, + "change_period_percent": change_period_percent, + "chart_path": chart_path, + "text": text, + "text_plain": text, + } + print(json.dumps(result, ensure_ascii=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main())