Initial commit with translated description
This commit is contained in:
437
ARCHITECTURE.md
Normal file
437
ARCHITECTURE.md
Normal file
@@ -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
|
||||||
194
README.md
Normal file
194
README.md
Normal file
@@ -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 <SYMBOL> [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 <SYMBOL>` - 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
|
||||||
50
SKILL.md
Normal file
50
SKILL.md
Normal file
@@ -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 <SYMBOL> [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: <chart_path>` 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`.
|
||||||
6
_meta.json
Normal file
6
_meta.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"ownerId": "kn7dehtnm6pbrnszan740htxtn7zq8wy",
|
||||||
|
"slug": "crypto-price",
|
||||||
|
"version": "0.2.2",
|
||||||
|
"publishedAt": 1769498220031
|
||||||
|
}
|
||||||
1
requirements.txt
Normal file
1
requirements.txt
Normal file
@@ -0,0 +1 @@
|
|||||||
|
matplotlib>=3.5.0
|
||||||
988
scripts/get_price_chart.py
Normal file
988
scripts/get_price_chart.py
Normal file
@@ -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 <symbol>")
|
||||||
|
|
||||||
|
raw_symbol = sys.argv[1].strip()
|
||||||
|
if not raw_symbol:
|
||||||
|
return _json_error("missing symbol", "Usage: get_price_chart.py <symbol>")
|
||||||
|
|
||||||
|
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())
|
||||||
Reference in New Issue
Block a user