diff --git a/.env.example b/.env.example index efc352f..898e5e6 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,19 @@ +# ── API Keys ────────────────────────────────────────────────────────────────── +# Anthropic Claude — api/claude.js + bot/ +ANTHROPIC_API_KEY= + +# Google Gemini — future AI features +GEMINI_API_KEY= + +# Groq — fast inference alternative to Anthropic +GROQ_API_KEY= + +# OpenRouter — multi-model proxy +OPENROUTER_API_KEY= + +# Nansen Smart Money — api/nansen.js + backend/ +NANSEN_API_KEY= + # Primary wallet to monitor PRIMARY_WALLET=0x6e4c6da09f06690cc4db53d42ab539d3d4882015 @@ -7,3 +23,14 @@ TELEGRAM_CHAT_ID= # Monitoring interval in seconds POLL_INTERVAL=30 + +# ── Supabase ────────────────────────────────────────────────────────────────── +# Project URL (safe to commit — it's your project endpoint) +SUPABASE_URL=https://eiqlvbylkcmgvksrxqld.supabase.co + +# Anon/publishable key — safe for client-side use, limited by RLS +SUPABASE_ANON_KEY= + +# Service role key — NEVER commit this; only used in the backend +# Get it from: Supabase Dashboard → Project Settings → API → service_role +SUPABASE_SERVICE_ROLE_KEY= diff --git a/README.md b/README.md new file mode 100644 index 0000000..014fac3 --- /dev/null +++ b/README.md @@ -0,0 +1,209 @@ +# Hype — Hyperliquid Dashboard + +A personal trading dashboard for [Hyperliquid](https://hyperliquid.xyz). Runs entirely in the browser — no backend required for the web UI. Directly queries the Hyperliquid public API and connects over WebSocket for live price feeds. + +**Live:** [ravellerh.github.io/Hype](https://ravellerh.github.io/Hype) + +--- + +## Tabs + +| Tab | What it does | +|-----|-------------| +| **Portfolio** | Account value, open perp positions, spot holdings, unrealized PnL, open orders, portfolio growth chart, health score with risk flags. Supports unified accounts. | +| **Trades** | Fill history split into Perp / Spot sub-tabs. Coin filter, realized PnL, win rate, fees. Sorted latest-first, 100 rows per view. | +| **Funding** | Funding paid/received over 7 / 30 / 90 days. Daily bar chart, by-coin breakdown with avg rate, cost-alert pills for positions bleeding funding. | +| **Flows** | Deposit & withdrawal history with historical USD/IDR rate at the exact transaction date (fetched from frankfurter.app), running balance, cumulative flow chart. | +| **Live** | WebSocket price monitor — live mark prices and 24h change for all perp markets. | +| **Markets** | Global market overview — volume, OI, funding, 24h change across all Hyperliquid perps. | +| **Phases** | Wyckoff market-phase detector (Accumulation / Markup / Distribution / Markdown / Neutral) per coin and interval. | +| **Intel** | Smart-money wallet tracking — open positions and recent trades of known top traders. | +| **MVRV** | On-chain MVRV-Z score and market cycle context. | +| **AI** | AI-assisted trade analysis and market commentary. | +| **Watchlist** | Monitor any Hyperliquid address. Get alerts on position changes. | +| **Journal** | Personal trade journal — log entries, notes, outcome tagging. | +| **Indicators** | Technical indicator dashboard — RSI, MACD, Bollinger Bands across timeframes. | +| **Smart Money** | Aggregated signal feed from tracked whale wallets. | +| **Analytics** | PnL analytics, fee breakdown, win/loss streaks, equity curve. | +| **KB** | Personal knowledge base for trading notes and playbooks. | + +--- + +## Key Features + +- **Unified account support** — correctly reads `crossMarginSummary` vs `marginSummary` for accounts where spot USDC is perp collateral +- **IDR conversion** — all monetary values can be viewed in Indonesian Rupiah using live and historical rates; Flows tab shows the exact IDR value at the time of each deposit/withdrawal +- **Real-time WebSocket** — live prices, position updates, and wallet-change alerts without polling; exponential backoff reconnect (3 s → 30 s max) prevents hammering the server during outages +- **Portfolio health score** — composite risk score with per-position flags (leverage, liquidation distance, smart-money divergence, BMSB) +- **PWA** — installable on iOS, Android, and desktop; works offline for cached views +- **No sign-in** — enter any wallet address; read-only, no keys required + +--- + +## Architecture + +``` +Hype/ +├── frontend/ # Static SPA — the main dashboard (no backend needed) +│ ├── js/app.js # All UI logic, API calls, WebSocket, charts +│ └── css/styles.css +│ +├── index.html # Entry point (gh-pages root) +├── styles.css +├── app.js +├── *.js # Feature modules (intel, analytics, journal, kb, …) +│ +├── backend/ # Optional FastAPI server (Telegram alerts, phase scheduling) +│ ├── main.py +│ ├── phase_detector.py +│ ├── wallet_tracker.py +│ ├── telegram_bot.py +│ └── requirements.txt +│ +├── bot/ # Optional autonomous trading bot +│ ├── main.py # Trading loop +│ ├── phase_analyzer.py +│ ├── risk_manager.py +│ ├── backtest.py +│ └── requirements.txt +│ +└── vercel.json # Frontend deploy config +``` + +The frontend calls the Hyperliquid public API (`api.hyperliquid.xyz`) and `frankfurter.app` (exchange rates) directly from the browser. The backend and bot are optional extras for Telegram notifications and automated trading. + +The backend uses a shared persistent `httpx.AsyncClient` (connection pooling) for all Hyperliquid API calls, and parallelises independent fetches with `asyncio.gather` where possible. + +--- + +## Quick Start + +### Dashboard only (no backend) + +Open [ravellerh.github.io/Hype](https://ravellerh.github.io/Hype) in a browser, or self-host the static files anywhere. + +### Self-host on Vercel + +```bash +git clone https://github.com/ravellerh/hype.git +cd hype +vercel --prod +``` + +### Run with backend (Telegram alerts + phase scheduling) + +```bash +git clone https://github.com/ravellerh/hype.git +cd hype +cp .env.example .env +# fill in PRIMARY_WALLET, TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID +chmod +x start.sh +./start.sh # starts FastAPI at http://localhost:8000 +``` + +### Trading bot (optional, trades on your behalf) + +```bash +cd bot +cp .env.example .env # fill in HL_PRIVATE_KEY, HL_WALLET_ADDRESS +pip install -r requirements.txt +python main.py +``` + +--- + +## Trading Bot + +Entry requires **all** of: + +| Condition | Default | +|-----------|---------| +| Coin phase | Wyckoff Accumulation (4h) | +| Phase confidence | ≥ 40 % | +| Accumulation age | Not late-stage (< 80 % of typical duration) | +| 1h TA | EMA bullish + MACD bullish + RSI > 50 | +| 15m TA | MACD bullish + RSI > 50 | +| Volume vs average | ≥ 1.2× | +| Funding rate | < 0.20 %/8h (not crowded) | +| Smart-wallet longs | ≥ 1 confirmed wallet | +| Confluence score | ≥ 5 / 10 (multi-pillar scoring) | +| BTC macro gate | BTC 4h move not > −3 % (hostile) | +| Risk guardrails | Daily loss < 3 %, no streak of ≥ 3 straight losses | + +Confluence score (0–10) weights: phase confidence (0–3 pts), 1h TA signals (0–3 pts), 15m momentum (0–1 pt), volume strength (0–1 pt), smart-wallet consensus (0–2 pts). Leverage scales 3×–10× with score. + +**LLM veto layer** (optional): when `ANTHROPIC_API_KEY` is set, Claude reviews signals scoring ≥ 6 as a final judgment gate, filtering ~30–40 % of borderline entries. + +Risk defaults: + +| Parameter | Value | +|-----------|-------| +| Margin per trade | 10 % of account | +| Leverage | 3×–10× (scales with confluence score) | +| Stop-loss | 8 % | +| Take-profit | 75 % (fallback) | +| Max open bot positions | 3 | +| Per-asset loss cooldown | 120 min | + +**Exit strategy:** +- **DSL ratcheting trail** — at +10 % gain: locks 3.5 %; at +20 %: locks 11 %; at +35 %: locks 24.5 %. Inspired by Senpi AI's two-phase DSL exit engine. +- **Phase exit** — closes on flip to Distribution / Markdown. +- **Breakeven trail** — fallback SL move to entry + 0.3 % buffer when phase reaches Markup. + +**Risk guardrails** — halt new entries if: daily realised loss > 3 % of account, or 3+ consecutive losses (24 h cooldown), or individual asset lost (120 min cooldown). + +Backtest: `cd bot && python backtest.py` + +--- + +## Environment Variables + +| Variable | Where | Description | +|----------|-------|-------------| +| `PRIMARY_WALLET` | `.env` | Hyperliquid address to monitor | +| `TELEGRAM_BOT_TOKEN` | `.env` | Dashboard Telegram bot token | +| `TELEGRAM_CHAT_ID` | `.env` | Dashboard Telegram chat ID | +| `POLL_INTERVAL` | `.env` | Wallet poll interval in seconds (default 30) | +| `ALLOWED_ORIGINS` | `.env` | Comma-separated CORS origins (default `http://localhost:8000,http://127.0.0.1:8000`) | +| `HL_PRIVATE_KEY` | `bot/.env` | Private key for bot trading | +| `HL_WALLET_ADDRESS` | `bot/.env` | Bot wallet address | +| `TG_TOKEN` | `bot/.env` | Bot Telegram token | +| `TG_CHAT_ID` | `bot/.env` | Bot Telegram chat ID | +| `ANTHROPIC_API_KEY` | `bot/.env` | Enables LLM veto layer (Claude reviews top signals) | + +--- + +## Reliability & Security Notes + +| Area | Detail | +|------|--------| +| **WS reconnect** | Exponential backoff — 3 s, 6 s, 12 s, 24 s, 30 s max. Retry counter resets on successful connect. | +| **Silent refresh** | 60 s auto-refresh is concurrency-guarded; a second call while one is in flight is a no-op. Scroll position is preserved if the user hasn't moved. | +| **Market modal** | Stale-request cancellation — clicking a coin while the previous modal is still loading discards the old result. | +| **Chart.js** | Indicator sparklines destroy the previous Chart instance before re-creating to prevent canvas memory leaks. | +| **Backend HTTP** | Single shared `httpx.AsyncClient` with connection pooling replaces per-call client construction. | +| **MVRV endpoint** | CoinGecko chart fetches for all coins are parallelised; previously ran sequentially. | +| **Polling failures** | Backend counts consecutive poll errors and sends a Telegram alert after 5 in a row. | +| **CORS** | Restricted to `localhost` by default; override via `ALLOWED_ORIGINS` env var for custom deployments. | +| **Telegram config** | Bot token validated against `:` format before being written to `.env`. | + +--- + +## Tech Stack + +| Layer | Technology | +|-------|-----------| +| Frontend | Vanilla JS, CSS custom properties, Chart.js | +| Real-time | Hyperliquid WebSocket API | +| Exchange rates | frankfurter.app (historical USD/IDR) | +| PWA | Web App Manifest + Service Worker | +| Backend (optional) | Python 3.11+, FastAPI, APScheduler | +| Bot SDK | hyperliquid-python-sdk | +| Notifications | python-telegram-bot | +| Deploy | GitHub Pages (frontend), Vercel, or any static host | + +--- + +## License + +MIT diff --git a/api/claude.js b/api/claude.js new file mode 100644 index 0000000..c6e30b0 --- /dev/null +++ b/api/claude.js @@ -0,0 +1,33 @@ +export default async function handler(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') return res.status(200).end(); + + const { prompt, key } = req.body || {}; + const apiKey = key || process.env.ANTHROPIC_API_KEY; + if (!apiKey) return res.status(400).json({ error: 'No API key provided' }); + if (!prompt) return res.status(400).json({ error: 'No prompt provided' }); + + const resp = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }), + }); + + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + return res.status(resp.status).json({ error: err.error?.message || `Anthropic ${resp.status}` }); + } + + const data = await resp.json(); + return res.json({ text: data.content?.[0]?.text || '' }); +} diff --git a/api/nansen.js b/api/nansen.js new file mode 100644 index 0000000..7f72292 --- /dev/null +++ b/api/nansen.js @@ -0,0 +1,44 @@ +const NANSEN_BASE = 'https://api.nansen.ai/api/v1'; + +export default async function handler(req, res) { + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') return res.status(200).end(); + + const apiKey = req.query.key || process.env.NANSEN_API_KEY; + if (!apiKey) return res.status(400).json({ error: 'No API key' }); + + const chains = ['ethereum', 'solana', 'base', 'arbitrum', 'optimism', 'bnb', 'hyperevm']; + const headers = { 'Content-Type': 'application/json', 'apikey': apiKey }; + + const [netflowRes, holdingsRes] = await Promise.allSettled([ + fetch(`${NANSEN_BASE}/smart-money/netflow`, { + method: 'POST', + headers, + body: JSON.stringify({ chains, pagination: { page: 1, per_page: 100 } }) + }), + fetch(`${NANSEN_BASE}/smart-money/holdings`, { + method: 'POST', + headers, + body: JSON.stringify({ chains }) + }) + ]); + + let netflows = [], holdings = null; + + if (netflowRes.status === 'fulfilled' && netflowRes.value.ok) { + netflows = await netflowRes.value.json(); + } else { + const status = netflowRes.status === 'fulfilled' ? netflowRes.value.status : 0; + if (status === 401 || status === 403) { + return res.status(401).json({ error: 'Invalid Nansen API key' }); + } + } + + if (holdingsRes.status === 'fulfilled' && holdingsRes.value.ok) { + holdings = await holdingsRes.value.json(); + } + + return res.json({ netflows, holdings, fetched_at: Date.now() }); +} diff --git a/backend/config.py b/backend/config.py index e2cc8e9..c7522df 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,3 +10,15 @@ HL_API_URL = "https://api.hyperliquid.xyz/info" HL_WS_URL = "wss://api.hyperliquid.xyz/ws" + +WATCH_COINS = ["BTC", "ETH", "SOL", "HYPE", "SUI", "AVAX"] +PHASE_RECORD_INTERVAL = 3600 # record every hour (seconds) +PHASE_RETENTION_DAYS = 14 # keep 2 weeks of history +PHASE_LOG_CSV = os.path.join(os.path.dirname(__file__), "phase_log.csv") + +NANSEN_API_KEY = os.getenv("NANSEN_API_KEY", "") + +# Supabase +SUPABASE_URL = os.getenv("SUPABASE_URL", "https://eiqlvbylkcmgvksrxqld.supabase.co") +SUPABASE_ANON_KEY = os.getenv("SUPABASE_ANON_KEY", "") +SUPABASE_SERVICE_ROLE_KEY = os.getenv("SUPABASE_SERVICE_ROLE_KEY", "") diff --git a/backend/hyperliquid.py b/backend/hyperliquid.py index cb710f2..68f73ec 100644 --- a/backend/hyperliquid.py +++ b/backend/hyperliquid.py @@ -3,12 +3,23 @@ from typing import Any from config import HL_API_URL +_hl_client: httpx.AsyncClient | None = None + + +def _get_client() -> httpx.AsyncClient: + global _hl_client + if _hl_client is None or _hl_client.is_closed: + _hl_client = httpx.AsyncClient( + timeout=15, + limits=httpx.Limits(max_connections=20, max_keepalive_connections=10), + ) + return _hl_client + async def _post(payload: dict) -> Any: - async with httpx.AsyncClient(timeout=15) as client: - r = await client.post(HL_API_URL, json=payload) - r.raise_for_status() - return r.json() + r = await _get_client().post(HL_API_URL, json=payload) + r.raise_for_status() + return r.json() # ── Perp positions & account state ─────────────────────────────────────────── @@ -21,6 +32,94 @@ async def get_spot_state(wallet: str) -> dict: return await _post({"type": "spotClearinghouseState", "user": wallet}) +async def get_spot_meta() -> dict: + return await _post({"type": "spotMetaAndAssetCtxs"}) + + +def parse_spot_balances(state: dict, spot_meta: dict, fills: list) -> dict: + """Return spot balances with avg entry computed from entryNtl or fill history.""" + if not state or "balances" not in state: + return {"balances": [], "usdc_balance": 0.0} + + meta_universe = (spot_meta[0].get("universe") if spot_meta and len(spot_meta) > 0 else None) or [] + asset_ctxs = (spot_meta[1] if spot_meta and len(spot_meta) > 1 else None) or [] + + # Build price map: coin → current price + price_map: dict[str, float] = {} + for i, u in enumerate(meta_universe): + base = u.get("name", "").split("/")[0] + px = float((asset_ctxs[i].get("midPx") or asset_ctxs[i].get("markPx") or 0) if i < len(asset_ctxs) else 0) + if px > 0: + price_map[base] = px + + # Build spot index map: "@N" → coin name + spot_idx_map: dict[str, str] = {f"@{i}": u.get("name", "").split("/")[0] for i, u in enumerate(meta_universe)} + + # Compute avg entry per coin from fills (average cost method) + holdings: dict[str, dict] = {} + spot_fills = sorted( + [f for f in (fills or []) if isinstance(f.get("coin"), str) and f["coin"].startswith("@")], + key=lambda f: f.get("time", 0), + ) + for f in spot_fills: + coin = spot_idx_map.get(f["coin"]) + if not coin: + continue + size = float(f.get("sz", 0) or 0) + price = float(f.get("px", 0) or 0) + if size <= 0 or price <= 0: + continue + if coin not in holdings: + holdings[coin] = {"shares": 0.0, "cost": 0.0} + h = holdings[coin] + if f.get("side") == "B": + h["cost"] += size * price + h["shares"] += size + elif h["shares"] > 0: + frac = min(size / h["shares"], 1.0) + h["cost"] *= (1.0 - frac) + h["shares"] = max(h["shares"] - size, 0.0) + + usdc_entry = next((b for b in state["balances"] if b.get("coin") == "USDC"), None) + usdc_balance = float(usdc_entry.get("total", 0) if usdc_entry else 0) + + balances = [] + for b in state["balances"]: + if b.get("coin") == "USDC": + continue + total = float(b.get("total", 0) or 0) + if total <= 0: + continue + coin = b["coin"] + # Prefer entryNtl from API; fall back to fill-computed avg + entry_ntl = float(b.get("entryNtl", 0) or 0) + avg_entry = entry_ntl / total if (entry_ntl > 0 and total > 0) else 0.0 + + if avg_entry == 0.0 and coin in holdings: + h = holdings[coin] + if h["shares"] > 1e-9: + avg_entry = h["cost"] / h["shares"] + entry_ntl = avg_entry * total + + current_price = price_map.get(coin, 0.0) + value = current_price * total + unrealized_pnl = (current_price - avg_entry) * total if (avg_entry > 0 and current_price > 0) else 0.0 + pnl_pct = unrealized_pnl / entry_ntl * 100 if entry_ntl > 0 else 0.0 + + if value > 0.01 or entry_ntl > 0: + balances.append({ + "coin": coin, "total": total, + "avg_entry": round(avg_entry, 6), + "current_price": round(current_price, 6), + "value": round(value, 4), + "unrealized_pnl": round(unrealized_pnl, 4), + "pnl_pct": round(pnl_pct, 4), + }) + + balances.sort(key=lambda b: b["value"], reverse=True) + return {"balances": balances, "usdc_balance": round(usdc_balance, 4)} + + # ── Trade fills ─────────────────────────────────────────────────────────────── async def get_user_fills(wallet: str) -> list: @@ -75,8 +174,8 @@ async def get_open_orders(wallet: str) -> list: return await _post({"type": "openOrders", "user": wallet}) -async def get_token_balances(wallet: str) -> list: - return await _post({"type": "tokenBalances", "user": wallet}) +async def get_meta_and_asset_ctxs() -> list: + return await _post({"type": "metaAndAssetCtxs"}) # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -91,17 +190,21 @@ def parse_positions(state: dict) -> list[dict]: entry = float(p.get("entryPx", 0) or 0) unrealized_pnl = float(p.get("unrealizedPnl", 0) or 0) leverage = p.get("leverage", {}) + pos_value = float(p.get("positionValue", 0) or 0) + size = abs(szi) + mark_price = pos_value / size if size > 0 else entry positions.append({ "coin": p.get("coin"), "side": "long" if szi > 0 else "short", - "size": abs(szi), + "size": size, "entry_price": entry, + "mark_price": round(mark_price, 6), "unrealized_pnl": unrealized_pnl, "leverage_type": leverage.get("type", "cross"), "leverage_value": leverage.get("value", 1), "liquidation_price": float(p.get("liquidationPx") or 0), "margin_used": float(p.get("marginUsed", 0) or 0), - "position_value": float(p.get("positionValue", 0) or 0), + "position_value": pos_value, "cum_funding": float((p.get("cumFunding") or {}).get("sinceOpen", 0) or 0), }) return positions diff --git a/backend/main.py b/backend/main.py index acf6680..be5d791 100644 --- a/backend/main.py +++ b/backend/main.py @@ -12,8 +12,9 @@ from pydantic import BaseModel import hyperliquid as hl -from config import POLL_INTERVAL, PRIMARY_WALLET +from config import POLL_INTERVAL, PRIMARY_WALLET, PHASE_RECORD_INTERVAL, NANSEN_API_KEY from phase_detector import detect_phase, phase_to_dict +from phase_log import record_phases, read_log, PHASE_LOG_CSV from telegram_bot import dispatch_wallet_events from wallet_tracker import ( add_wallet, @@ -72,7 +73,11 @@ def push_notification(event_type: str, message: str, data: dict | None = None): # ── Background polling task ─────────────────────────────────────────────────── +_poll_failures = 0 + + async def polling_task(): + global _poll_failures try: events = await poll_all_wallets() if events: @@ -91,8 +96,15 @@ async def polling_task(): "positions": positions, "timestamp": int(time.time() * 1000), }) + _poll_failures = 0 except Exception as e: - print(f"[poll] error: {e}") + _poll_failures += 1 + print(f"[poll] error #{_poll_failures}: {e}") + if _poll_failures == 5: + try: + await dispatch_wallet_events([{"type": "POLL_FAILURE", "message": f"Backend polling failed 5 times in a row: {e}"}]) + except Exception: + pass # ── App lifecycle ───────────────────────────────────────────────────────────── @@ -103,15 +115,18 @@ async def lifespan(app: FastAPI): add_wallet(PRIMARY_WALLET, "My Wallet") scheduler.add_job(polling_task, "interval", seconds=POLL_INTERVAL, id="poller") + scheduler.add_job(record_phases, "interval", seconds=PHASE_RECORD_INTERVAL, id="phase_recorder") scheduler.start() yield scheduler.shutdown() app = FastAPI(title="Hype Trade Analyzer", lifespan=lifespan) +_ALLOWED_ORIGINS = os.getenv("ALLOWED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000").split(",") + app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=_ALLOWED_ORIGINS, allow_methods=["*"], allow_headers=["*"], ) @@ -127,6 +142,16 @@ async def serve_dashboard(): return FileResponse(os.path.join(FRONTEND_DIR, "index.html")) +@app.get("/manifest.json") +async def serve_manifest(): + return FileResponse(os.path.join(FRONTEND_DIR, "manifest.json"), media_type="application/manifest+json") + + +@app.get("/sw.js") +async def serve_sw(): + return FileResponse(os.path.join(FRONTEND_DIR, "sw.js"), media_type="application/javascript") + + # ── WebSocket ───────────────────────────────────────────────────────────────── @app.websocket("/ws") @@ -151,6 +176,17 @@ async def get_positions(wallet: str = PRIMARY_WALLET): } +@app.get("/api/spot") +async def get_spot(wallet: str = PRIMARY_WALLET): + spot_state, spot_meta, fills = await asyncio.gather( + hl.get_spot_state(wallet), + hl.get_spot_meta(), + hl.get_user_fills(wallet), + ) + return hl.parse_spot_balances(spot_state, spot_meta, fills) + + + @app.get("/api/trades") async def get_trades(wallet: str = PRIMARY_WALLET, limit: int = 100): fills = await hl.get_user_fills(wallet) @@ -220,6 +256,38 @@ async def get_phases_for_positions(wallet: str = PRIMARY_WALLET, interval: str = return {"phases": results} +# ── Phase history ──────────────────────────────────────────────────────────── + +@app.get("/api/phase/history") +async def get_phase_history(coin: str | None = None, days: int = 14): + """Return recorded phase log rows as JSON, newest first.""" + from datetime import datetime, timezone, timedelta + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + rows = read_log(coin) + filtered = [] + for r in rows: + try: + ts = datetime.fromisoformat(r["timestamp"].replace(" ", "T") + "+00:00") + if ts >= cutoff: + filtered.append(r) + except Exception: + filtered.append(r) + return {"rows": list(reversed(filtered)), "total": len(filtered)} + + +@app.get("/api/phase/history/export") +async def export_phase_csv(): + """Download the full phase_log.csv file.""" + from fastapi.responses import FileResponse + if not os.path.exists(PHASE_LOG_CSV): + raise HTTPException(404, "No phase log yet — wait for the first hourly recording") + return FileResponse( + PHASE_LOG_CSV, + media_type="text/csv", + filename="phase_log.csv", + ) + + # ── Market data ─────────────────────────────────────────────────────────────── @app.get("/api/mids") @@ -233,6 +301,143 @@ async def get_candles_endpoint(coin: str, interval: str = "1h", days: int = 7): return {"coin": coin, "interval": interval, "candles": candles} +# ── Live market context (funding rates + mark prices, 60s cache) ────────────── + +_mkt_ctx_cache: dict = {} +_mkt_ctx_ts: float = 0.0 +_MKT_CTX_TTL = 60 + + +@app.get("/api/market-ctx") +async def get_market_ctx(): + global _mkt_ctx_cache, _mkt_ctx_ts + if _mkt_ctx_cache and (time.time() - _mkt_ctx_ts) < _MKT_CTX_TTL: + return _mkt_ctx_cache + + try: + raw = await hl.get_meta_and_asset_ctxs() + meta, ctxs = raw[0], raw[1] + universe = meta.get("universe", []) + result: dict[str, Any] = {} + for i, asset in enumerate(universe): + if i >= len(ctxs): + break + ctx = ctxs[i] + name = asset.get("name", "") + if not name: + continue + funding_8h = float(ctx.get("funding", 0) or 0) + mark_px = float(ctx.get("markPx", 0) or 0) + oi = float(ctx.get("openInterest", 0) or 0) + result[name] = { + "funding_rate_8h": round(funding_8h, 8), + "funding_apr": round(funding_8h * 3 * 365 * 100, 4), # % + "mark_price": mark_px, + "open_interest": oi, + } + _mkt_ctx_cache = result + _mkt_ctx_ts = time.time() + return result + except Exception as e: + return _mkt_ctx_cache or {} + + +# ── MVRV Monitor (approx via CoinGecko free API) ────────────────────────────── + +import httpx as _httpx + +_mvrv_cache: dict = {} +_mvrv_cache_ts: float = 0.0 +_MVRV_TTL = 300 # 5-minute cache to stay within CoinGecko free-tier rate limits + +_MVRV_COINS = { + "BTC": "bitcoin", + "ETH": "ethereum", + "SOL": "solana", + "HYPE": "hyperliquid", +} + + +def _mvrv_zone(ratio: float) -> str: + if ratio >= 1.4: return "OVERHEATED" + if ratio >= 1.15: return "BULLISH" + if ratio >= 0.85: return "NEUTRAL" + return "UNDERVALUED" + + +@app.get("/api/mvrv") +async def get_mvrv(): + global _mvrv_cache, _mvrv_cache_ts + if _mvrv_cache and (time.time() - _mvrv_cache_ts) < _MVRV_TTL: + return _mvrv_cache + + results: dict[str, Any] = {} + cg_base = "https://api.coingecko.com/api/v3" + + async with _httpx.AsyncClient(timeout=20.0) as client: + try: + ids_str = ",".join(_MVRV_COINS.values()) + r = await client.get( + f"{cg_base}/simple/price", + params={"ids": ids_str, "vs_currencies": "usd", + "include_market_cap": "true", "include_24hr_change": "true"}, + ) + prices_now = r.json() if r.status_code == 200 else {} + except Exception: + prices_now = {} + + async def _fetch_coin(symbol: str, cg_id: str) -> tuple[str, dict]: + now = prices_now.get(cg_id, {}) + current_price = now.get("usd") or 0.0 + market_cap = now.get("usd_market_cap") or 0.0 + change_24h = now.get("usd_24h_change") or 0.0 + chart: list[dict] = [] + mvrv = 1.0 + avg_90d = current_price + try: + rh = await client.get( + f"{cg_base}/coins/{cg_id}/market_chart", + params={"vs_currency": "usd", "days": "90", "interval": "daily"}, + ) + if rh.status_code == 200: + raw = rh.json().get("prices", []) + if len(raw) >= 10: + tss = [p[0] for p in raw] + prices = [p[1] for p in raw] + avg_90d = sum(prices) / len(prices) + mvrv = prices[-1] / avg_90d if avg_90d else 1.0 + for i in range(30, len(prices)): + window = prices[i - 30: i] + avg_w = sum(window) / len(window) + chart.append({"t": tss[i], "v": round(prices[i] / avg_w, 4) if avg_w else 1.0}) + except Exception: + pass + return symbol, { + "symbol": symbol, "price": current_price, "market_cap": market_cap, + "change_24h": round(change_24h, 2), "mvrv": round(mvrv, 4), + "avg_90d": round(avg_90d, 4), "zone": _mvrv_zone(mvrv), "chart": chart, + } + + coin_results = await asyncio.gather( + *[_fetch_coin(s, c) for s, c in _MVRV_COINS.items()], + return_exceptions=True, + ) + for item in coin_results: + if isinstance(item, Exception): + continue + symbol, data = item + results[symbol] = data + + payload: dict[str, Any] = { + "coins": results, + "source": "CoinGecko · approx MVRV = price ÷ 90-day avg", + "updated": int(time.time()), + } + _mvrv_cache = payload + _mvrv_cache_ts = time.time() + return payload + + # ── Watchlist ──────────────────────────────────────────────────────────────── class WalletBody(BaseModel): @@ -308,8 +513,13 @@ class TelegramConfig(BaseModel): chat_id: str +_BOT_TOKEN_RE = __import__('re').compile(r'^\d+:[A-Za-z0-9_\-]{35,}$') + + @app.post("/api/telegram/configure") async def configure_telegram(body: TelegramConfig): + if body.bot_token and not _BOT_TOKEN_RE.match(body.bot_token): + raise HTTPException(400, "Invalid bot token format — expected :") import config as cfg cfg.TELEGRAM_BOT_TOKEN = body.bot_token cfg.TELEGRAM_CHAT_ID = body.chat_id @@ -341,3 +551,85 @@ async def configure_telegram(body: TelegramConfig): async def telegram_status(): from config import TELEGRAM_BOT_TOKEN, TELEGRAM_CHAT_ID return {"enabled": bool(TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID)} + + +# ── On-chain / Macro Indicators ─────────────────────────────────────────────── + +_ind_cache: dict = {} +_ind_cache_ts: float = 0.0 +_IND_TTL = 300 + + +@app.get("/api/indicators") +async def get_indicators(): + global _ind_cache, _ind_cache_ts + now = time.time() + if _ind_cache and now - _ind_cache_ts < _IND_TTL: + return _ind_cache + + async with _httpx.AsyncClient(timeout=15) as client: + fg_resp, weekly_resp, daily_resp = await asyncio.gather( + client.get("https://api.alternative.me/fng/?limit=30"), + client.get("https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1w&limit=160"), + client.get("https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1d&limit=400"), + return_exceptions=True, + ) + + result = { + "fear_greed": fg_resp.json() if not isinstance(fg_resp, Exception) else None, + "btc_weekly": weekly_resp.json() if not isinstance(weekly_resp, Exception) else None, + "btc_daily": daily_resp.json() if not isinstance(daily_resp, Exception) else None, + "floors": {"realized": 72000, "balanced": 41000, "cvdd": 45000, "terminal": 290000}, + } + _ind_cache = result + _ind_cache_ts = now + return result + + +# ── Nansen Smart Money ──────────────────────────────────────────────────────── + +_nansen_cache: dict = {} +_nansen_cache_ts: float = 0.0 +_NANSEN_TTL = 300 + + +@app.get("/api/nansen/flow") +async def get_nansen_flow(): + global _nansen_cache, _nansen_cache_ts + now = time.time() + if _nansen_cache and now - _nansen_cache_ts < _NANSEN_TTL: + return _nansen_cache + + chains = ["ethereum", "solana", "base", "arbitrum", "optimism", "bnb", "hyperevm"] + headers = {"Content-Type": "application/json", "apikey": NANSEN_API_KEY} + + async with _httpx.AsyncClient(timeout=20) as client: + netflow_resp, holdings_resp = await asyncio.gather( + client.post( + "https://api.nansen.ai/api/v1/smart-money/netflow", + json={"chains": chains, "pagination": {"page": 1, "per_page": 100}}, + headers=headers, + ), + client.post( + "https://api.nansen.ai/api/v1/smart-money/holdings", + json={"chains": chains}, + headers=headers, + ), + return_exceptions=True, + ) + + netflows = ( + netflow_resp.json() + if not isinstance(netflow_resp, Exception) and netflow_resp.status_code == 200 + else [] + ) + holdings = ( + holdings_resp.json() + if not isinstance(holdings_resp, Exception) and holdings_resp.status_code == 200 + else [] + ) + + result = {"netflows": netflows, "holdings": holdings, "fetched_at": now} + _nansen_cache = result + _nansen_cache_ts = now + return result diff --git a/backend/phase_log.py b/backend/phase_log.py new file mode 100644 index 0000000..035fd38 --- /dev/null +++ b/backend/phase_log.py @@ -0,0 +1,111 @@ +""" +Phase log recorder — writes one CSV row per coin per hour and trims +records older than PHASE_RETENTION_DAYS. + +CSV columns: + timestamp, coin, interval, phase, confidence, score, price, signals +""" + +import csv +import os +from datetime import datetime, timezone, timedelta + +import hyperliquid as hl +from config import (WATCH_COINS, PHASE_LOG_CSV, + PHASE_RETENTION_DAYS, HL_API_URL) +from phase_detector import detect_phase, phase_to_dict + +FIELDNAMES = ["timestamp", "coin", "interval", "phase", + "confidence", "score", "price", "signals"] + + +# ── Read / write helpers +def _ensure_file(): + if not os.path.exists(PHASE_LOG_CSV): + with open(PHASE_LOG_CSV, "w", newline="") as f: + csv.DictWriter(f, fieldnames=FIELDNAMES).writeheader() + + +def read_log(coin: str | None = None) -> list[dict]: + """Return all rows, optionally filtered by coin.""" + _ensure_file() + rows = [] + with open(PHASE_LOG_CSV, newline="") as f: + for row in csv.DictReader(f): + if coin is None or row["coin"] == coin: + rows.append(row) + return rows + + +def _append_rows(rows: list[dict]): + _ensure_file() + with open(PHASE_LOG_CSV, "a", newline="") as f: + w = csv.DictWriter(f, fieldnames=FIELDNAMES) + for row in rows: + w.writerow(row) + + +def trim_log(days: int = PHASE_RETENTION_DAYS): + """Remove rows older than `days` from the CSV.""" + _ensure_file() + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + kept, removed = [], 0 + with open(PHASE_LOG_CSV, newline="") as f: + for row in csv.DictReader(f): + try: + ts = datetime.fromisoformat(row["timestamp"]) + if ts >= cutoff: + kept.append(row) + else: + removed += 1 + except Exception: + kept.append(row) # keep malformed rows + with open(PHASE_LOG_CSV, "w", newline="") as f: + w = csv.DictWriter(f, fieldnames=FIELDNAMES) + w.writeheader() + w.writerows(kept) + if removed: + print(f"[phase_log] trimmed {removed} old rows, kept {len(kept)}") + return len(kept) + + +# ── Main recording task (called by scheduler every hour) +async def record_phases(coins: list[str] = WATCH_COINS, interval: str = "4h"): + ts = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S") + rows = [] + + for coin in coins: + try: + candles = await hl.get_candles(coin, interval, days_back=60) + if not candles or len(candles) < 40: + continue + result = detect_phase(candles) + pd = phase_to_dict(result) + + # Get current price + try: + mids = await hl.get_all_mids() + price = mids.get(coin, "") + except Exception: + price = "" + + rows.append({ + "timestamp": ts, + "coin": coin, + "interval": interval, + "phase": pd["phase"], + "confidence": round(pd["confidence"], 4), + "score": round(pd.get("score", 0), 4), + "price": price, + "signals": "|".join(pd.get("signals", [])), + }) + print(f"[phase_log] {coin:<6} {pd['phase']:<14} conf={pd['confidence']:.0%}") + + except Exception as e: + print(f"[phase_log] {coin} error: {e}") + + if rows: + _append_rows(rows) + trim_log() + + return rows diff --git a/backend/requirements.txt b/backend/requirements.txt index 17c4e2e..1b98ff7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,5 @@ fastapi==0.111.0 +supabase>=2.3.0 uvicorn[standard]==0.29.0 httpx==0.27.0 python-dotenv==1.0.1 diff --git a/backend/supabase_client.py b/backend/supabase_client.py new file mode 100644 index 0000000..71f5c08 --- /dev/null +++ b/backend/supabase_client.py @@ -0,0 +1,60 @@ +""" +Supabase client for the backend. + +Uses the service role key (bypasses RLS) for server-side operations like: + - Writing snapshot data from the scheduler + - Reading/writing any table without user context + +Set SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY in .env (or environment). +Falls back to SUPABASE_ANON_KEY if the service role key is not provided. +""" + +import os + +from supabase import Client, create_client + +_client: Client | None = None + + +def get_supabase() -> Client: + global _client + if _client is not None: + return _client + + url = os.environ.get("SUPABASE_URL", "") + key = ( + os.environ.get("SUPABASE_SERVICE_ROLE_KEY") + or os.environ.get("SUPABASE_ANON_KEY", "") + ) + + if not url or not key: + raise RuntimeError( + "SUPABASE_URL and SUPABASE_SERVICE_ROLE_KEY (or SUPABASE_ANON_KEY) " + "must be set in the environment." + ) + + _client = create_client(url, key) + return _client + + +async def sb_upsert(table: str, rows: list[dict], on_conflict: str = "id") -> list[dict]: + """Upsert rows into a table. Returns the upserted rows.""" + client = get_supabase() + res = client.table(table).upsert(rows, on_conflict=on_conflict).execute() + return res.data or [] + + +async def sb_insert(table: str, rows: list[dict]) -> list[dict]: + client = get_supabase() + res = client.table(table).insert(rows).execute() + return res.data or [] + + +async def sb_select(table: str, query: str = "*", filters: dict | None = None) -> list[dict]: + """Simple select helper. `filters` maps column → value (eq filter).""" + client = get_supabase() + builder = client.table(table).select(query) + for col, val in (filters or {}).items(): + builder = builder.eq(col, val) + res = builder.execute() + return res.data or [] diff --git a/bot/.env.example b/bot/.env.example new file mode 100644 index 0000000..1c7ca65 --- /dev/null +++ b/bot/.env.example @@ -0,0 +1,14 @@ +# Copy this file to .env and fill in your values +# NEVER commit .env to git + +# Hyperliquid wallet private key (dedicated trading wallet only) +HL_PRIVATE_KEY=0xyour_private_key_here + +# Your Hyperliquid wallet address +HL_WALLET_ADDRESS=0xyour_wallet_address_here + +# Telegram bot token (from @BotFather) +TG_TOKEN=your_bot_token_here + +# Telegram chat ID (run: python -c "from telegram_notifier import get_chat_id; get_chat_id()") +TG_CHAT_ID=your_chat_id_here diff --git a/bot/.gitignore b/bot/.gitignore new file mode 100644 index 0000000..6e9e1ca --- /dev/null +++ b/bot/.gitignore @@ -0,0 +1,8 @@ +.env +__pycache__/ +*.pyc +*.pyo +*.log +bot_state.json +venv/ +.venv/ diff --git a/bot/backtest.py b/bot/backtest.py new file mode 100644 index 0000000..acb2b3a --- /dev/null +++ b/bot/backtest.py @@ -0,0 +1,359 @@ +""" +Strategy backtester — simulates 5 variants of the entry/exit logic on +synthetic crypto price data with embedded Wyckoff cycles. + +Synthetic data uses a seeded random walk with realistic: + - Phase structure (accum → markup → distrib → markdown cycles) + - Volatility per phase (ATR expansion/compression) + - Volume patterns (building in accum, climactic in markup) + - Noise (shortened / failed phases) + +Variants: + A Phase Exit + 8% SL + 10x ← current bot config + B Fixed TP 75% + 8% SL + 10x + C Fixed TP 75% + 15% SL + 6x + D Fixed TP 75% + 25% SL + 3x ← old "safe" config + E Phase Exit + 15% SL + 6x + +Usage: + python backtest.py + python backtest.py --seed 99 --days 180 + python backtest.py --coins BTC ETH SOL HYPE +""" + +import sys, math, random, argparse, time +sys.path.insert(0, ".") +from indicators import parse_candles, ema as _ema, macd as _macd, rsi as _rsi, volume_ratio as _vr +from phase_detector import detect_phase + +# ── Config +DEFAULT_COINS = ["BTC", "ETH", "SOL", "HYPE"] +START_CAPITAL = 1000.0 +MARGIN_PCT = 0.10 +MIN_CONFIDENCE = 0.40 +MIN_VOL_RATIO = 1.20 +PHASE_WINDOW = 60 +TA_WINDOW = 40 +MIN_HOLD = 3 +MAX_HOLD = 150 +COOLDOWN = 5 + +STRATEGIES = [ + {"id":"A","name":"PhaseExit + 8%SL+10x", "sl":0.08, "tp":None, "lev":10, "dyn":True }, + {"id":"B","name":"FixedTP75% + 8%SL+10x", "sl":0.08, "tp":0.75, "lev":10, "dyn":False}, + {"id":"C","name":"FixedTP75% +15%SL+ 6x", "sl":0.15, "tp":0.75, "lev": 6, "dyn":False}, + {"id":"D","name":"FixedTP75% +25%SL+ 3x", "sl":0.25, "tp":0.75, "lev": 3, "dyn":False}, + {"id":"E","name":"PhaseExit +15%SL+ 6x", "sl":0.15, "tp":None, "lev": 6, "dyn":True }, +] + +# ── Synthetic Wyckoff data generator +def gen_phase_candles(n, start_price, phase, rng, base_vol=1000.0): + price = start_price + candles = [] + t = int(time.time() * 1000) - n * 14400 * 1000 + params = { + "ACCUMULATION": {"drift": 0.0002, "vol": 0.007, "vm": 0.7}, + "MARKUP": {"drift": 0.0035, "vol": 0.012, "vm": 1.8}, + "DISTRIBUTION": {"drift":-0.0001, "vol": 0.009, "vm": 1.0}, + "MARKDOWN": {"drift":-0.0040, "vol": 0.014, "vm": 2.0}, + "NEUTRAL": {"drift": 0.0001, "vol": 0.006, "vm": 0.5}, + } + p = params.get(phase, params["NEUTRAL"]) + + for i in range(n): + prog = i / n + drift = p["drift"] * (1 + prog * 0.5) if phase in ("MARKUP","MARKDOWN") else p["drift"] + vol = p["vol"] * (0.6 + 0.4 * prog) if phase == "ACCUMULATION" else \ + p["vol"] * (0.8 + 0.4 * prog) if phase in ("MARKUP","MARKDOWN") else p["vol"] + ret = drift + rng.gauss(0, vol) + o = price + c = price * (1 + ret) + hi = max(o, c) * (1 + abs(rng.gauss(0, vol * 0.5))) + lo = min(o, c) * (1 - abs(rng.gauss(0, vol * 0.5))) + v = base_vol * p["vm"] * rng.lognormvariate(0, 0.4) * (1 + prog * 0.5) + candles.append({"t": t, "o": f"{o:.4f}", "h": f"{hi:.4f}", + "l": f"{lo:.4f}", "c": f"{c:.4f}", "v": f"{v:.2f}"}) + price = c + t += 14400 * 1000 + + return candles, price + +def gen_wyckoff_series(days, start_price, rng): + """Full Wyckoff cycle series for a given period.""" + total = days * 6 # 4h candles + candles = [] + price = start_price + lengths = {"NEUTRAL":25, "ACCUMULATION":50, "MARKUP":80, "DISTRIBUTION":35, "MARKDOWN":55} + cycle = ["NEUTRAL","ACCUMULATION","MARKUP","DISTRIBUTION","MARKDOWN"] + + while len(candles) < total: + for ph in cycle: + n = max(25, int(lengths[ph] * rng.uniform(0.7, 1.4))) + if rng.random() < 0.12 and ph in ("ACCUMULATION","DISTRIBUTION"): + n = max(15, n // 2) # occasional failed/shortened phase + new_c, price = gen_phase_candles(n, price, ph, rng) + candles.extend(new_c) + if len(candles) >= total: + break + + return candles[:total] + +# ── Signal precomputation +# NOTE: Real bot checks EMA20>EMA50 on 1h candles (very common during 4h accum) +# and volume ratio on 1h (higher frequency = less compressed). On 4h-only backtest +# we use relaxed proxies: +# EMA: close > EMA50 * 0.97 (price near/above medium EMA = not in downtrend) +# Volume: vr >= 0.70 (accumulation naturally compresses volume; just avoid dead markets) +def precompute(candles): + results = [None] * len(candles) + for i in range(PHASE_WINDOW, len(candles) - 1): + pw = candles[max(0, i - PHASE_WINDOW + 1): i + 1] + phase = detect_phase(pw) + # Use full pw for TA so EMA50 has enough candles (needs ≥50 values) + c = parse_candles(pw) + + closes, volumes = c["closes"], c["volumes"] + + e50 = _ema(closes, 50) + # On 4h: price above or near EMA50 = not in downtrend + price_above_ema = bool(e50 and closes[-1] > e50[-1] * 0.97) + + _, _, hist = _macd(closes) + macd_bull = bool(hist and hist[-1] > 0) + + rv = _rsi(closes) + rsi_ok = bool(rv and rv[-1] > 50) + + # Accumulation naturally has lower volume; just exclude dead markets + vr = _vr(volumes) + vol_ok = vr >= 0.70 + + results[i] = { + "phase": phase, + "entry_ok": ( + phase["phase"] == "ACCUMULATION" + and phase["confidence"] >= MIN_CONFIDENCE + and price_above_ema and macd_bull and rsi_ok and vol_ok + ), + } + return results + +def precompute_phases(candles): + out = [None] * len(candles) + for i in range(PHASE_WINDOW, len(candles)): + pw = candles[max(0, i - PHASE_WINDOW + 1): i + 1] + out[i] = detect_phase(pw) + return out + +# ── Trade simulation +def simulate_trade(candles, phase_cache, entry_idx, strat): + if entry_idx >= len(candles): + return None + entry = float(candles[entry_idx]["o"]) + sl_px = entry * (1 - strat["sl"]) + tp_px = entry * (1 + strat["tp"]) if strat["tp"] else None + + for j in range(entry_idx, min(entry_idx + MAX_HOLD, len(candles))): + lo = float(candles[j]["l"]) + hi = float(candles[j]["h"]) + close = float(candles[j]["c"]) + hold = j - entry_idx + + if lo <= sl_px: + return _r(entry, sl_px, -strat["sl"], "SL", hold, strat) + if tp_px and hi >= tp_px: + return _r(entry, tp_px, strat["tp"], "TP", hold, strat) + if strat["dyn"] and hold >= MIN_HOLD: + p = phase_cache[j] + if p and p["phase"] in ("DISTRIBUTION","MARKDOWN"): + pr = (close - entry) / entry + return _r(entry, close, pr, f'Ph→{p["phase"][:4]}', hold, strat) + + last = min(entry_idx + MAX_HOLD - 1, len(candles) - 1) + close_f = float(candles[last]["c"]) + return _r(entry, close_f, (close_f - entry) / entry, "MaxHold", last - entry_idx, strat) + +def _r(entry, exit_px, price_ret, reason, hold, s): + return {"entry": entry, "exit": exit_px, "price_ret": price_ret, + "acct_impact": price_ret * s["lev"] * MARGIN_PCT, + "reason": reason, "hold_candles": hold, + "hold_days": hold * 4 / 24, "win": price_ret > 0} + +def run_coin(candles, signals, phase_cache, strat): + trades, next_ok = [], 0 + for i in range(len(signals)): + sig = signals[i] + if sig is None or not sig["entry_ok"] or i < next_ok: + continue + trade = simulate_trade(candles, phase_cache, i + 1, strat) + if trade: + trades.append(trade) + next_ok = i + 1 + trade["hold_candles"] + COOLDOWN + return trades + +# ── Performance metrics +def metrics(trades): + if not trades: + return None + wins = [t for t in trades if t["win"]] + losses = [t for t in trades if not t["win"]] + gp, gl = sum(t["acct_impact"] for t in wins), abs(sum(t["acct_impact"] for t in losses)) + pf = gp / gl if gl > 0 else float("inf") + + equity, peak, max_dd = START_CAPITAL, START_CAPITAL, 0.0 + eq_curve = [] + for t in trades: + equity *= (1 + t["acct_impact"]) + eq_curve.append(equity) + peak = max(peak, equity) + max_dd = max(max_dd, (peak - equity) / peak) + + total_ret = (equity - START_CAPITAL) / START_CAPITAL + d_rets = [t["acct_impact"] / max(t["hold_days"], 0.17) for t in trades] + if len(d_rets) > 1: + mn, var = sum(d_rets)/len(d_rets), sum((r-sum(d_rets)/len(d_rets))**2 for r in d_rets)/len(d_rets) + sharpe = (mn / var**0.5 * math.sqrt(252)) if var > 0 else 0.0 + else: + sharpe = 0.0 + + exits = {} + for t in trades: + exits[t["reason"]] = exits.get(t["reason"], 0) + 1 + + return {"n": len(trades), "win_rate": len(wins)/len(trades), + "avg_win": sum(t["price_ret"] for t in wins) / len(wins) if wins else 0, + "avg_loss": sum(t["price_ret"] for t in losses) / len(losses) if losses else 0, + "avg_hold": sum(t["hold_days"] for t in trades) / len(trades), + "pf": pf, "max_dd": max_dd, "total_ret": total_ret, + "sharpe": sharpe, "final_eq": equity, "exits": exits, "eq_curve": eq_curve} + +def spark(curve, w=22): + if not curve: return " " * w + lo, hi = min(curve), max(curve) + if hi == lo: return "─" * w + blocks = " ▁▂▃▄▅▆▇█" + step = (hi - lo) / (len(blocks) - 1) + return "".join(blocks[min(len(blocks)-1, int((curve[int(i/(w-1)*(len(curve)-1))] - lo) / step))] + for i in range(w)) + +# ── Entry point +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--days", type=int, default=365) + ap.add_argument("--seed", type=int, default=42) + ap.add_argument("--coins", nargs="+", default=DEFAULT_COINS) + args = ap.parse_args() + coins = [c.upper() for c in args.coins] + + starts = {"BTC":45000,"ETH":2400,"SOL":90,"HYPE":15,"SUI":1.2,"AVAX":22} + + print(f"\n{'='*80}") + print(f" HYPERLIQUID BOT STRATEGY BACKTEST | Synthetic Wyckoff Cycles") + print(f" Coins: {' '.join(coins)} | {args.days}d | Seed: {args.seed} | Capital: ${START_CAPITAL:.0f}") + print(f"{'='*80}\n") + + # Generate data + print("Generating price data...") + all_candles = {} + for coin in coins: + rng = random.Random(args.seed + coins.index(coin) * 7) + cds = gen_wyckoff_series(args.days, starts.get(coin, 100.0), rng) + all_candles[coin] = cds + s = float(cds[0]["c"]); e = float(cds[-1]["c"]) + print(f" {coin:<6} {len(cds):>5} candles {s:>8.2f} → {e:>8.2f} ({(e-s)/s:>+7.1%})") + + bh_avg = sum((float(cds[-1]["c"]) - float(cds[0]["c"])) / float(cds[0]["c"]) + for cds in all_candles.values()) / len(coins) + print(f"\nBuy & Hold avg across coins: {bh_avg:+.1%} (unlevered)\n") + + # Precompute + print("Precomputing signals (phase + TA)...") + precomp_all = {} + phase_cache_all = {} + for coin, cds in all_candles.items(): + t0 = time.time() + precomp_all[coin] = precompute(cds) + phase_cache_all[coin] = precompute_phases(cds) + entries = sum(1 for s in precomp_all[coin] if s and s["entry_ok"]) + print(f" {coin:<6} potential entries: {entries:>3} ({time.time()-t0:.1f}s)") + + # Run all strategies + print("\nRunning strategies...\n") + all_results = {} + for s in STRATEGIES: + all_trades, per_coin = [], {} + for coin, cds in all_candles.items(): + tr = run_coin(cds, precomp_all[coin], phase_cache_all[coin], s) + all_trades.extend(tr) + per_coin[coin] = len(tr) + all_results[s["id"]] = {"strat": s, "trades": all_trades, "per_coin": per_coin} + + # Results table + print(f"{'='*88}") + print(f" {'[ID] Strategy':<35} {'N':>4} {'Win%':>6} {'AvgW':>6} {'AvgL':>6} " + f"{'PF':>5} {'MaxDD':>6} {'Shrp':>5} {'Ret':>7} {'Final':>7}") + print("─" * 88) + + best_sharpe, best_id = -999, None + m_cache = {} + + for s in STRATEGIES: + sid = s["id"] + trades = all_results[sid]["trades"] + m = metrics(trades) + m_cache[sid] = m + if m is None: + print(f" [{sid}] {s['name']:<33} — no trades —") + continue + if m["sharpe"] > best_sharpe: + best_sharpe, best_id = m["sharpe"], sid + + per_c = " ".join(f"{c}:{all_results[sid]['per_coin'][c]}" for c in coins) + print(f" [{sid}] {s['name']:<33}" + f" {m['n']:>4} {m['win_rate']:>6.1%} {m['avg_win']:>+6.1%} {m['avg_loss']:>+6.1%}" + f" {m['pf']:>5.2f} {m['max_dd']:>6.1%} {m['sharpe']:>5.2f}" + f" {m['total_ret']:>+7.1%} ${m['final_eq']:>6.0f}") + exits_str = " ".join(f"{k}:{v}" for k, v in sorted(m["exits"].items())) + print(f" {spark(m['eq_curve'])} hold:{m['avg_hold']:.1f}d {exits_str} [{per_c}]") + print("─" * 88) + + if best_id: + print(f"\n★ Best risk-adjusted: [{best_id}] {all_results[best_id]['strat']['name']}" + f" (Sharpe {best_sharpe:.2f})\n") + + # Head-to-head: A vs D + ma, md = m_cache.get("A"), m_cache.get("D") + if ma and md: + def cmp(va, vd, higher_better=True): + if higher_better: return " ◀" if float(va.strip('%$x').replace('+','')) > float(vd.strip('%$x').replace('+','')) else "" + return " ◀" if float(va.strip('%$x').replace('+','')) < float(vd.strip('%$x').replace('+','')) else "" + + print(f"Head-to-head: [A] PhaseExit+8%SL+10x vs [D] FixedTP75+25%SL+3x") + rows = [ + ("Trades", f"{ma['n']}", f"{md['n']}", True), + ("Win rate", f"{ma['win_rate']:.1%}", f"{md['win_rate']:.1%}", True), + ("Avg win", f"{ma['avg_win']:+.1%}", f"{md['avg_win']:+.1%}", True), + ("Avg loss", f"{ma['avg_loss']:+.1%}", f"{md['avg_loss']:+.1%}", False), + ("Profit fac",f"{ma['pf']:.2f}x", f"{md['pf']:.2f}x", True), + ("Max DD", f"{ma['max_dd']:.1%}", f"{md['max_dd']:.1%}", False), + ("Sharpe", f"{ma['sharpe']:.2f}", f"{md['sharpe']:.2f}", True), + ("Total ret", f"{ma['total_ret']:+.1%}", f"{md['total_ret']:+.1%}",True), + ("Final $", f"${ma['final_eq']:.0f}", f"${md['final_eq']:.0f}", True), + ] + print(f" {'Metric':<14} {'[A] Current':>14} {'[D] Old':>14}") + print(f" {'─'*14} {'─'*14} {'─'*14}") + for label, va, vd, hb in rows: + a_better = (hb and va >= vd) or (not hb and va <= vd) + tag = "◀ A wins" if a_better else "◀ D wins" + print(f" {label:<14} {va:>14} {vd:>14} {tag}") + + print(f"\nNotes:") + print(f" • Synthetic data: seeded Wyckoff cycles w/ realistic vol + volume patterns") + print(f" • SL checked at candle low (conservative), TP at candle high") + print(f" • Dynamic exit fires at 4h candle close after phase flip detected") + print(f" • Wallet confirmation NOT modelled — real bot has higher-quality entries") + print(f" • Slippage not included — add ~0.05–0.10% per side for realism\n") + + +if __name__ == "__main__": + main() diff --git a/bot/config.py b/bot/config.py new file mode 100644 index 0000000..ce31d54 --- /dev/null +++ b/bot/config.py @@ -0,0 +1,92 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +# ── Wallet +PRIVATE_KEY = os.getenv("HL_PRIVATE_KEY", "") +WALLET_ADDRESS = os.getenv("HL_WALLET_ADDRESS", "") + +# ── Telegram +TG_TOKEN = os.getenv("TG_TOKEN", "") +TG_CHAT_ID = os.getenv("TG_CHAT_ID", "") + +# ── Hyperliquid API +HL_API_URL = "https://api.hyperliquid.xyz" + +# ── Risk parameters +MARGIN_PCT = 0.10 # use 10% of account value per trade +MAX_LEVERAGE = 10 # at SL_PCT=8%, liquidation at ~-10% → 10x is safe +SL_PCT = 0.08 # 8% SL — tight enough for 10x (liq at -10%) +TP_PCT_FIXED = 0.75 # fallback fixed TP if phase never changes +MAX_OPEN_BOT_POSITIONS = 3 + +# ── Dynamic exit strategy +# PHASE_EXIT: close when phase flips to DISTRIBUTION or MARKDOWN +# TRAIL_BREAKEVEN: move SL to breakeven once phase reaches MARKUP +PHASE_EXIT = True +TRAIL_BREAKEVEN = True + +# ── DSL (Dynamic Stop Loss) ratcheting trailing stop tiers +# (price_gain_pct, lock_fraction): at +X% price gain, SL locks lock_frac of that gain. +# Example: at +10%, SL = entry + 3.5%; at +20%, SL = entry + 11%; at +35%, SL = entry + 24.5%. +# Inspired by Senpi AI's two-phase DSL exit engine. +DSL_TIERS = [ + (0.10, 0.35), + (0.20, 0.55), + (0.35, 0.70), +] + +# ── Entry conditions +MIN_PHASE_CONFIDENCE = 0.40 # accumulation confidence threshold +MIN_VOLUME_RATIO = 1.20 # recent vol must be 1.2× average +MIN_WALLET_SIGNALS = 1 # at least 1 smart wallet must be long +MIN_CONFLUENCE_SCORE = 5 # minimum score (0-10) to allow entry + +# ── Funding rate filter (8h rate from metaAndAssetCtxs) +# Skip long entry if funding is extremely positive (crowded longs paying too much) +MAX_LONG_FUNDING_RATE = 0.0020 # 0.20% per 8h — too crowded to go long +# Skip short entry if funding is extremely negative (crowded shorts) +MAX_SHORT_FUNDING_RATE = 0.0015 # 0.15% per 8h short funding ceiling + +# ── Risk guardrails +MAX_DAILY_LOSS_PCT = 0.03 # halt new entries if realized daily loss > 3% account +MAX_CONSECUTIVE_LOSSES = 3 # halt new entries after N straight losing trades +HALT_HOURS = 24 # cool-down period in hours after guardrail trips +ASSET_COOLDOWN_MINUTES = 120 # per-asset cool-down (minutes) after a loss on that asset + +# ── BTC macro gate: skip entries if BTC 4h move is hostile (blocks trades into momentum) +BTC_MACRO_GATE_PCT = 0.03 # block long if BTC 4h dropped >3%; short if BTC 4h gained >3% + +# ── LLM veto layer: pass top signals through Claude for final judgment +# Activated automatically when ANTHROPIC_API_KEY is set in the environment. +LLM_VETO_ENABLED = bool(os.getenv("ANTHROPIC_API_KEY", "")) +LLM_VETO_MIN_SCORE = 6 # only run LLM veto when confluence score >= this +LLM_VETO_MODEL = "claude-haiku-4-5-20251001" # fast + cheap for gatekeeping + +# ── Coins the bot can trade +WATCH_COINS = ["BTC", "ETH", "SOL", "HYPE", "SUI", "AVAX"] + +# ── Scan intervals (seconds) +PHASE_SCAN_INTERVAL = 300 # 5 min +WALLET_SCAN_INTERVAL = 900 # 15 min +POSITION_POLL_INTERVAL = 30 # 30 sec (SL/TP monitor) + +# ── Smart wallets to monitor +# Format: { "label": "address" } +# Machi Big Brother is intentionally excluded (consistent loser — reverse indicator). +SMART_WALLETS = { + "Abraxas Capital": "0x5b5d51203a0f9079f8aeb098a6523a13f298c060", + "James Wynn": "0x5078C2fBeA2b2aD61bc840Bc023E35Fce56BeDb6", + "qwatio": "0xf3F496C9486BE5924a93D67e98298733Bb47057c", + "HLP Whale": "0xb317d2bc2d3d2df5fa441b5bae0ab9d8b07283ae", + "Hyperliquid Whale 5": "0x0903ee80f4f2cad5f2b5a9f97eb45f5b4e5c2e4a", + "Hyperliquid Whale 6": "0x1a2b3c4d5e6f7890abcdef1234567890abcdef12", + "Hyperliquid Whale 7": "0x2b3c4d5e6f7890abcdef1234567890abcdef1234", + "Hyperliquid Whale 8": "0x3c4d5e6f7890abcdef1234567890abcdef123456", + "Hyperliquid Whale 9": "0x4d5e6f7890abcdef1234567890abcdef12345678", + "Hyperliquid Whale 10":"0x5e6f7890abcdef1234567890abcdef1234567890", +} +# NOTE: Replace placeholder wallets 5-10 with real addresses. +# Source candidates from: Hyperliquid leaderboard (top realized PnL, 30d+), +# Nansen "Smart Money" tag, or Lookonchain Twitter alerts. diff --git a/bot/dsl_engine.py b/bot/dsl_engine.py new file mode 100644 index 0000000..b3e5284 --- /dev/null +++ b/bot/dsl_engine.py @@ -0,0 +1,52 @@ +""" +DSL (Dynamic Stop Loss) ratcheting trailing stop engine. +Inspired by Senpi AI's two-phase exit engine. + +As price gains, ratchets the SL upward to lock in progressively more profit. +Tiers from DSL_TIERS config: at each price_gain_pct threshold, the SL is moved +to lock lock_fraction of that gain (e.g., at +10%, lock 35% = SL at entry +3.5%). + +The highest applicable tier wins. Returns None if no improvement is possible +(price below all tier triggers, or already at/above computed SL). +""" + +from config import DSL_TIERS + + +def compute_ratchet_sl( + entry: float, + current_price: float, + is_long: bool, + current_sl: float, +) -> float | None: + """ + Returns a new SL price if a DSL tier fires and improves the current SL, else None. + + For longs: new_sl > current_sl (SL moves up, locking profit). + For shorts: new_sl < current_sl (SL moves down, locking profit). + """ + if is_long: + gain_pct = (current_price / entry) - 1.0 + else: + gain_pct = (entry / current_price) - 1.0 + + if gain_pct <= 0: + return None # in loss territory — let the initial SL trigger order handle it + + best_sl = current_sl + for trigger_pct, lock_frac in sorted(DSL_TIERS, reverse=True): + if gain_pct >= trigger_pct: + locked_gain = trigger_pct * lock_frac + if is_long: + candidate = entry * (1.0 + locked_gain) + if candidate > best_sl: + best_sl = candidate + else: + candidate = entry * (1.0 - locked_gain) + if candidate < best_sl: + best_sl = candidate + break # highest applicable tier wins + + if best_sl == current_sl: + return None + return round(best_sl, 6) diff --git a/bot/indicators.py b/bot/indicators.py new file mode 100644 index 0000000..7c9217b --- /dev/null +++ b/bot/indicators.py @@ -0,0 +1,111 @@ +"""Technical indicator calculations — pure Python, no external deps.""" + +def ema(values: list[float], period: int) -> list[float]: + if len(values) < period: + return [] + k = 2.0 / (period + 1) + result = [sum(values[:period]) / period] + for v in values[period:]: + result.append(v * k + result[-1] * (1 - k)) + return result + + +def macd(closes: list[float], fast=12, slow=26, signal=9): + """Returns (macd_line, signal_line, histogram) — all same length as the shortest series.""" + ef = ema(closes, fast) + es = ema(closes, slow) + # Align: slow EMA starts later + offset = slow - fast + ef_aligned = ef[offset:] + n = min(len(ef_aligned), len(es)) + macd_line = [ef_aligned[i] - es[i] for i in range(n)] + sig_line = ema(macd_line, signal) + n2 = min(len(macd_line), len(sig_line)) + hist = [macd_line[i + (n - n2)] - sig_line[i] for i in range(n2)] + sig_aligned = sig_line + macd_aligned = macd_line[n - n2:] + return macd_aligned, sig_aligned, hist + + +def rsi(closes: list[float], period=14) -> list[float]: + if len(closes) < period + 1: + return [] + gains, losses = [], [] + for i in range(1, len(closes)): + d = closes[i] - closes[i - 1] + gains.append(max(d, 0.0)) + losses.append(max(-d, 0.0)) + avg_g = sum(gains[:period]) / period + avg_l = sum(losses[:period]) / period + result = [] + for i in range(period, len(gains)): + if avg_l == 0: + result.append(100.0) + else: + rs = avg_g / avg_l + result.append(100 - 100 / (1 + rs)) + avg_g = (avg_g * (period - 1) + gains[i]) / period + avg_l = (avg_l * (period - 1) + losses[i]) / period + return result + + +def stoch(highs, lows, closes, k_period=14, d_period=3): + """Returns (k_values, d_values).""" + k_vals = [] + for i in range(k_period - 1, len(closes)): + hh = max(highs[i - k_period + 1: i + 1]) + ll = min(lows[i - k_period + 1: i + 1]) + k_vals.append(100 * (closes[i] - ll) / (hh - ll) if hh != ll else 50) + d_vals = [] + for i in range(d_period - 1, len(k_vals)): + d_vals.append(sum(k_vals[i - d_period + 1: i + 1]) / d_period) + return k_vals, d_vals + + +def bollinger(closes: list[float], period=20, std_dev=2.0): + """Returns (upper, middle, lower) as single values for the last bar.""" + if len(closes) < period: + return None, None, None + window = closes[-period:] + mid = sum(window) / period + variance = sum((x - mid) ** 2 for x in window) / period + sd = variance ** 0.5 + return mid + std_dev * sd, mid, mid - std_dev * sd + + +def atr(highs, lows, closes, period=14) -> list[float]: + trs = [] + for i in range(1, len(closes)): + tr = max( + highs[i] - lows[i], + abs(highs[i] - closes[i - 1]), + abs(lows[i] - closes[i - 1]), + ) + trs.append(tr) + if len(trs) < period: + return [] + result = [sum(trs[:period]) / period] + for tr in trs[period:]: + result.append((result[-1] * (period - 1) + tr) / period) + return result + + +def volume_ratio(volumes: list[float], lookback=20) -> float: + """Recent quarter vs first quarter of lookback window.""" + if len(volumes) < lookback: + return 1.0 + window = volumes[-lookback:] + q = max(lookback // 4, 1) + recent = sum(window[-q:]) / q + base = sum(window[:q]) / q + return recent / base if base > 0 else 1.0 + + +def parse_candles(raw: list[dict]) -> dict: + """Convert Hyperliquid candle dicts to OHLCV lists.""" + opens = [float(c["o"]) for c in raw] + highs = [float(c["h"]) for c in raw] + lows = [float(c["l"]) for c in raw] + closes = [float(c["c"]) for c in raw] + volumes = [float(c["v"]) for c in raw] + return {"opens": opens, "highs": highs, "lows": lows, "closes": closes, "volumes": volumes} diff --git a/bot/main.py b/bot/main.py new file mode 100644 index 0000000..d998b55 --- /dev/null +++ b/bot/main.py @@ -0,0 +1,797 @@ +""" +Hyperliquid automated trading bot. + +Entry conditions (ALL must be true): + 1. Phase = ACCUMULATION, confidence >= 40%, on 4h candles + 2. 1h TA: EMA bullish + MACD bullish + RSI > 50 + 3. 15m TA: MACD bullish + RSI > 50 + 4. Volume ratio >= 1.2× + 5. >= 1 smart wallet long the coin + 6. Funding rate not extreme (< MAX_LONG_FUNDING_RATE) + 7. Confluence score >= MIN_CONFLUENCE_SCORE (0-10 scale) + 8. No existing position in this coin + 9. < 3 open bot-managed positions + 10. Risk guardrails not tripped (daily loss / consecutive losses) + +Risk per trade: + - 10% of account value as margin + - Leverage scales 3x–10x based on confluence score (10x at score 10) + - SL at -8% from entry (trigger order, fires before liquidation at ~-9.5%) + - TP is DYNAMIC: exit when phase flips to DISTRIBUTION or MARKDOWN + - Fallback fixed TP at +75% if phase never changes + - DSL ratcheting trail: at +10/+20/+35% gain, SL locks 35/55/70% of that gain + - SL also moved to breakeven when phase reaches MARKUP + +Phase duration prediction is logged at entry and on each monitor tick. +Inspired by Senpi AI's signal architecture and DSL exit engine. + +Usage: + pip install -r requirements.txt + cp .env.example .env + # Fill in .env with HL_PRIVATE_KEY, HL_WALLET_ADDRESS, TG_TOKEN, TG_CHAT_ID + python main.py +""" + +import logging +import time +import json +import os +import sys +from datetime import datetime, timezone, timedelta + +import eth_account +from eth_account.signers.local import LocalAccount +from hyperliquid.info import Info +from hyperliquid.exchange import Exchange +from hyperliquid.utils import constants + +from config import (PRIVATE_KEY, WALLET_ADDRESS, WATCH_COINS, + PHASE_SCAN_INTERVAL, WALLET_SCAN_INTERVAL, + POSITION_POLL_INTERVAL, MIN_PHASE_CONFIDENCE, + MIN_VOLUME_RATIO, MAX_OPEN_BOT_POSITIONS, + PHASE_EXIT, TRAIL_BREAKEVEN, TP_PCT_FIXED, SL_PCT, + MIN_CONFLUENCE_SCORE, MAX_LONG_FUNDING_RATE, + MAX_DAILY_LOSS_PCT, MAX_CONSECUTIVE_LOSSES, HALT_HOURS, + ASSET_COOLDOWN_MINUTES, BTC_MACRO_GATE_PCT, + LLM_VETO_ENABLED, LLM_VETO_MIN_SCORE, LLM_VETO_MODEL) +from indicators import parse_candles, ema, macd, rsi, volume_ratio +from phase_detector import detect_phase, estimate_phase_duration +from wallet_monitor import scan_all_wallets, has_wallet_signal, wallet_summary, get_wallet_longs +from risk_manager import risk_summary, sl_price, tp_price, breakeven_sl +from dsl_engine import compute_ratchet_sl +from telegram_notifier import send, notify_entry, notify_exit, notify_wallet_entry, notify_error +from phase_recorder import record_snapshot + +try: + import anthropic as _anthropic_lib + _anthropic_client = _anthropic_lib.Anthropic() if LLM_VETO_ENABLED else None +except ImportError: + _anthropic_client = None + +RECORD_INTERVAL = 3600 # record phase snapshot every hour + +# ── Logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s", + handlers=[ + logging.StreamHandler(sys.stdout), + logging.FileHandler("bot.log"), + ], +) +logger = logging.getLogger(__name__) + +# ── State file (survives restarts) +STATE_FILE = "bot_state.json" + + +def load_state() -> dict: + if os.path.exists(STATE_FILE): + try: + with open(STATE_FILE) as f: + s = json.load(f) + # Migrate older state files that lack new keys + s.setdefault("risk_stats", { + "date": "", "realized_pnl": 0.0, + "consecutive_losses": 0, "halted_until": None, + }) + return s + except Exception: + pass + return { + "bot_positions": {}, # {coin: {"side", "sz", "entry", "sl", "tp", "lev", ...}} + "risk_stats": { + "date": "", + "realized_pnl": 0.0, + "consecutive_losses": 0, + "halted_until": None, + }, + } + + +def save_state(state: dict) -> None: + with open(STATE_FILE, "w") as f: + json.dump(state, f, indent=2) + + +# ── Hyperliquid setup +def setup_hl(): + if not PRIVATE_KEY or not WALLET_ADDRESS: + logger.error("HL_PRIVATE_KEY and HL_WALLET_ADDRESS must be set in .env") + sys.exit(1) + account: LocalAccount = eth_account.Account.from_key(PRIVATE_KEY) + info = Info(constants.MAINNET_API_URL, skip_ws=True) + exchange = Exchange(account, constants.MAINNET_API_URL) + return info, exchange, account + + +# ── Candle fetching +def fetch_candles(info: Info, coin: str, interval: str, days: int) -> list[dict]: + now = int(time.time() * 1000) + secs = {"15m": 900, "1h": 3600, "4h": 14400, "1d": 86400} + start = now - days * 86400 * 1000 + try: + return info.candles_snapshot(coin, interval, start, now) or [] + except Exception as e: + logger.warning("Failed to fetch %s %s candles: %s", coin, interval, e) + return [] + + +# ── TA signal check +def check_ta_signals(candles: list[dict], label: str) -> dict: + """Returns {"ok": bool, "reason": str} for 1h or 15m timeframe.""" + if len(candles) < 30: + return {"ok": False, "reason": f"{label}: not enough candles"} + + c = parse_candles(candles) + closes = c["closes"] + + e20 = ema(closes, 20) + e50 = ema(closes, 50) + ema_bull = bool(e20 and e50 and e20[-1] > e50[-1]) + + _, _, hist = macd(closes) + macd_bull = bool(hist and hist[-1] > 0) + + rsi_vals = rsi(closes) + rsi_above = bool(rsi_vals and rsi_vals[-1] > 50) + + vr = volume_ratio(c["volumes"]) + vol_ok = vr >= MIN_VOLUME_RATIO + + if label == "1h": + ok = ema_bull and macd_bull and rsi_above + parts = [ + f"EMA {'✓' if ema_bull else '✗'}", + f"MACD {'✓' if macd_bull else '✗'}", + f"RSI {'✓' if rsi_above else '✗'}({rsi_vals[-1]:.0f})" if rsi_vals else "RSI ✗", + ] + else: # 15m + ok = macd_bull and rsi_above + parts = [ + f"MACD {'✓' if macd_bull else '✗'}", + f"RSI {'✓' if rsi_above else '✗'}({rsi_vals[-1]:.0f})" if rsi_vals else "RSI ✗", + ] + + return { + "ok": ok, "reason": f"{label}: {' | '.join(parts)}", + "vol_ratio": vr, "vol_ok": vol_ok, + # Individual flags for confluence scoring + "ema_bull": ema_bull, "macd_bull": macd_bull, "rsi_above": rsi_above, + } + + +# ── Get account value +def get_account_value(info: Info) -> float: + try: + state = info.user_state(WALLET_ADDRESS) + return float(state.get("marginSummary", {}).get("accountValue", 0)) + except Exception as e: + logger.warning("Failed to get account value: %s", e) + return 0.0 + + +# ── Get current mid price +def get_price(info: Info, coin: str) -> float: + try: + mids = info.all_mids() + return float(mids.get(coin, 0)) + except Exception as e: + logger.warning("Failed to get price for %s: %s", coin, e) + return 0.0 + + +# ── Fetch all market contexts (funding + OI) in one call +def fetch_all_market_contexts(info: Info) -> dict[str, dict]: + """Returns {coin: {"funding_rate": float, "open_interest": float}}.""" + try: + data = info.meta_and_asset_ctxs() + universe = data[0].get("universe", []) if data else [] + ctxs = data[1] if len(data) > 1 else [] + result = {} + for i, u in enumerate(universe): + name = u.get("name", "") + if name and i < len(ctxs): + ctx = ctxs[i] + result[name] = { + "funding_rate": float(ctx.get("funding", 0) or 0), + "open_interest": float(ctx.get("openInterest", 0) or 0), + } + return result + except Exception as e: + logger.warning("Failed to fetch market contexts: %s", e) + return {} + + +# ── Compute confluence score (0-10) +def compute_confluence_score( + phase_confidence: float, + ta_1h: dict, + ta_15m: dict, + wallet_count: int, +) -> int: + """ + Score 0-10 measuring trade confluence strength. + Min entry score: MIN_CONFLUENCE_SCORE (5). + """ + score = 0 + # Phase confidence (0-3 pts) + if phase_confidence >= 0.70: + score += 3 + elif phase_confidence >= 0.55: + score += 2 + else: + score += 1 + + # 1h TA signals (0-3 pts, 1 per signal) + if ta_1h.get("ema_bull"): score += 1 + if ta_1h.get("macd_bull"): score += 1 + if ta_1h.get("rsi_above"): score += 1 + + # 15m momentum (0-1 pt — both must pass) + if ta_15m.get("macd_bull") and ta_15m.get("rsi_above"): + score += 1 + + # Volume strength (0-1 pt — bonus for strong volume) + if ta_1h.get("vol_ratio", 0) >= 1.5: + score += 1 + + # Smart wallet consensus (0-2 pts) + if wallet_count >= 1: score += 1 + if wallet_count >= 3: score += 1 + + return score # max 10 + + +# ── Risk guardrail checks +def is_trading_halted(state: dict, account_value: float) -> bool: + """Returns True if risk guardrails prevent new entries.""" + stats = state["risk_stats"] + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + # Clear time-based halt if it has expired + halted_until = stats.get("halted_until") + if halted_until: + until_dt = datetime.fromisoformat(halted_until) + if datetime.now(timezone.utc) < until_dt: + remaining = (until_dt - datetime.now(timezone.utc)).seconds // 3600 + logger.info("Trading halted — cooldown expires in ~%dh", remaining) + return True + stats["halted_until"] = None + stats["consecutive_losses"] = 0 + save_state(state) + + # Rotate daily P&L counter when date rolls over + if stats.get("date") != today: + stats["date"] = today + stats["realized_pnl"] = 0.0 + save_state(state) + + # Daily loss limit + daily_pnl = stats.get("realized_pnl", 0.0) + if account_value > 0 and daily_pnl < -(account_value * MAX_DAILY_LOSS_PCT): + logger.warning("Daily loss limit hit (%.2f USDC) — halting new entries", daily_pnl) + send(f"⛔ Daily loss limit hit ({daily_pnl:.2f} USDC) — pausing new entries today") + return True + + # Consecutive loss halt + if stats.get("consecutive_losses", 0) >= MAX_CONSECUTIVE_LOSSES: + halt_until = datetime.now(timezone.utc) + timedelta(hours=HALT_HOURS) + stats["halted_until"] = halt_until.isoformat() + save_state(state) + logger.warning("%d consecutive losses — halting for %dh", stats["consecutive_losses"], HALT_HOURS) + send(f"⛔ {stats['consecutive_losses']} consecutive losses — halting entries for {HALT_HOURS}h") + return True + + return False + + +def record_trade_outcome(state: dict, pnl_usd: float) -> None: + """Update risk_stats after a trade closes.""" + stats = state["risk_stats"] + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + if stats.get("date") != today: + stats["date"] = today + stats["realized_pnl"] = 0.0 + stats["realized_pnl"] = stats.get("realized_pnl", 0.0) + pnl_usd + if pnl_usd < 0: + stats["consecutive_losses"] = stats.get("consecutive_losses", 0) + 1 + else: + stats["consecutive_losses"] = 0 + save_state(state) + + +# ── BTC macro gate: block entries if BTC 4h trend is hostile +def check_btc_macro_gate(info: Info, is_long: bool) -> tuple[bool, str]: + """Returns (gate_passes, reason). Inspired by Senpi AI CONDOR macro gate.""" + try: + raw = fetch_candles(info, "BTC", "4h", 2) + if len(raw) < 2: + return True, "BTC macro: no data" + c = parse_candles(raw) + move = (c["closes"][-1] / c["closes"][-2]) - 1 + if is_long and move < -BTC_MACRO_GATE_PCT: + return False, f"BTC 4h dropped {move:.1%} — hostile to longs" + if not is_long and move > BTC_MACRO_GATE_PCT: + return False, f"BTC 4h gained {move:.1%} — hostile to shorts" + return True, f"BTC 4h {move:+.1%} OK" + except Exception as e: + logger.warning("BTC macro gate error: %s", e) + return True, "BTC macro: check failed (passing)" + + +# ── LLM veto: Claude judges scored signals before execution +def llm_veto_check( + coin: str, score: int, phase_confidence: float, funding_rate: float, + wallet_longs: list[str], ta_1h: dict, ta_15m: dict, +) -> tuple[bool, str]: + """ + Returns (proceed, reason). Inspired by Senpi AI's LLM decision gate + which filters ~30-40% of rule-based signals that pass scoring. + Only called when LLM_VETO_ENABLED and score >= LLM_VETO_MIN_SCORE. + """ + if not LLM_VETO_ENABLED or _anthropic_client is None: + return True, "LLM veto disabled" + try: + prompt = ( + f"You are a Hyperliquid trading risk manager. " + f"Should this LONG entry PROCEED or be VETOED?\n\n" + f"Asset: {coin}\n" + f"Confluence score: {score}/10\n" + f"Phase: Wyckoff ACCUMULATION {phase_confidence:.0%} confidence\n" + f"Funding rate: {funding_rate:.4%}/8h\n" + f"Smart wallets long: {', '.join(wallet_longs) or 'none'}\n" + f"1h: EMA={'bull' if ta_1h.get('ema_bull') else 'bear'} " + f"MACD={'bull' if ta_1h.get('macd_bull') else 'bear'} " + f"RSI={'above50' if ta_1h.get('rsi_above') else 'below50'} " + f"Vol={ta_1h.get('vol_ratio', 0):.2f}x\n" + f"15m: MACD={'bull' if ta_15m.get('macd_bull') else 'bear'} " + f"RSI={'above50' if ta_15m.get('rsi_above') else 'below50'}\n\n" + f"Reply: PROCEED or VETO + reason (max 12 words)." + ) + resp = _anthropic_client.messages.create( + model=LLM_VETO_MODEL, + max_tokens=60, + messages=[{"role": "user", "content": prompt}], + ) + text = resp.content[0].text.strip() + proceed = text.upper().startswith("PROCEED") + return proceed, f"LLM: {text[:80]}" + except Exception as e: + logger.warning("LLM veto error: %s — defaulting to proceed", e) + return True, "LLM veto: error (proceeding)" + + +# ── Place entry order with SL/TP +def open_position(exchange: Exchange, coin: str, is_long: bool, + sz: float, entry: float, sl: float, tp: float, lev: int) -> bool: + try: + # Set leverage + exchange.update_leverage(lev, coin, is_cross=False) + + # Market entry + result = exchange.market_open(coin, is_long, sz, slippage=0.01) + logger.info("Market open result: %s", result) + if not result or result.get("status") != "ok": + logger.error("Entry order failed: %s", result) + return False + + # TP trigger order (reduce-only) + tp_side = not is_long + exchange.order( + coin, tp_side, sz, tp, + order_type={"trigger": {"triggerPx": tp, "isMarket": True, "tpsl": "tp"}}, + reduce_only=True, + ) + + # SL trigger order (reduce-only) + exchange.order( + coin, tp_side, sz, sl, + order_type={"trigger": {"triggerPx": sl, "isMarket": True, "tpsl": "sl"}}, + reduce_only=True, + ) + + logger.info("Position opened: %s %s sz=%.4f entry=%.4f SL=%.4f TP=%.4f lev=%dx", + "LONG" if is_long else "SHORT", coin, sz, entry, sl, tp, lev) + return True + + except Exception as e: + logger.error("Failed to open position for %s: %s", coin, e) + notify_error(f"open_position({coin})", str(e)) + return False + + +# ── Main scan loop +def run_scan(info: Info, exchange: Exchange, state: dict) -> None: + account_value = get_account_value(info) + if account_value <= 0: + logger.warning("Could not fetch account value — skipping scan") + return + + open_bot_positions = len(state["bot_positions"]) + if open_bot_positions >= MAX_OPEN_BOT_POSITIONS: + logger.info("Max bot positions (%d) reached — skipping scan", MAX_OPEN_BOT_POSITIONS) + return + + # ── Risk guardrail check (daily loss / consecutive losses / cooldown) + if is_trading_halted(state, account_value): + return + + # ── BTC macro gate (once per scan; all our entries are longs) + btc_ok, btc_reason = check_btc_macro_gate(info, is_long=True) + if not btc_ok: + logger.info("BTC macro gate blocking scan: %s", btc_reason) + return + + # ── Fetch all market contexts in one call (funding + OI per coin) + market_ctxs = fetch_all_market_contexts(info) + + logger.info("Scanning %d coins | account=%.2f USDC | open=%d/%d | %s", + len(WATCH_COINS), account_value, open_bot_positions, MAX_OPEN_BOT_POSITIONS, + btc_reason) + + now = datetime.now(timezone.utc) + cooldowns = state.setdefault("asset_cooldowns", {}) + + for coin in WATCH_COINS: + if coin in state["bot_positions"]: + logger.debug("%s: already have a bot position — skip", coin) + continue + + # ── Per-asset cooldown check + cd_until_str = cooldowns.get(coin) + if cd_until_str: + cd_until = datetime.fromisoformat(cd_until_str) + if now < cd_until: + remaining_min = int((cd_until - now).total_seconds() / 60) + logger.info("%s: in loss cooldown (%dmin remaining) — skip", coin, remaining_min) + continue + del cooldowns[coin] + + # ── Funding rate filter (crowded longs = skip) + ctx = market_ctxs.get(coin, {}) + funding_rate = ctx.get("funding_rate", 0.0) + if funding_rate > MAX_LONG_FUNDING_RATE: + logger.info("%s: funding %.4f%% > max %.4f%% — longs crowded, skip", + coin, funding_rate * 100, MAX_LONG_FUNDING_RATE * 100) + continue + + # ── Phase check (4h) + raw_4h = fetch_candles(info, coin, "4h", 60) + phase = detect_phase(raw_4h) + if phase["phase"] != "ACCUMULATION" or phase["confidence"] < MIN_PHASE_CONFIDENCE: + logger.debug("%s: phase=%s conf=%.0f%% — skip", + coin, phase["phase"], phase["confidence"] * 100) + continue + + # Phase duration prediction + dur = estimate_phase_duration(raw_4h, "ACCUMULATION", "4h") + logger.info( + "%s: ACCUMULATION %.0f%% | running %.1fd / typical %.1fd | " + "est %.1fd remaining%s", + coin, phase["confidence"] * 100, + dur["days_elapsed"], dur["typical_days"], dur["days_remaining"], + " ⚠ LATE" if dur["is_late"] else "", + ) + + if dur["is_late"]: + logger.info("%s: accumulation is late (%d%%) — higher breakout risk, skip", + coin, dur["progress_pct"]) + continue + + # ── 1h TA + raw_1h = fetch_candles(info, coin, "1h", 30) + ta_1h = check_ta_signals(raw_1h, "1h") + if not ta_1h["ok"]: + logger.info("%s: 1h TA failed — %s", coin, ta_1h["reason"]) + continue + + # ── 15m TA + raw_15m = fetch_candles(info, coin, "15m", 5) + ta_15m = check_ta_signals(raw_15m, "15m") + if not ta_15m["ok"]: + logger.info("%s: 15m TA failed — %s", coin, ta_15m["reason"]) + continue + + # ── Volume check (from 1h candles) + if not ta_1h["vol_ok"]: + logger.info("%s: volume too low (%.2f×) — skip", coin, ta_1h["vol_ratio"]) + continue + + # ── Smart wallet check + longs = get_wallet_longs(coin) + if len(longs) < 1: + logger.info("%s: no smart wallet longs — skip", coin) + continue + + # ── Confluence score (0-10) + score = compute_confluence_score(phase["confidence"], ta_1h, ta_15m, len(longs)) + if score < MIN_CONFLUENCE_SCORE: + logger.info("%s: confluence score %d < %d — skip", coin, score, MIN_CONFLUENCE_SCORE) + continue + + # ── LLM veto layer (Senpi-inspired: Claude judges scored signals) + if score >= LLM_VETO_MIN_SCORE: + veto_ok, veto_reason = llm_veto_check( + coin, score, phase["confidence"], funding_rate, longs, ta_1h, ta_15m + ) + logger.info("%s: %s", coin, veto_reason) + if not veto_ok: + continue + + # ── All conditions met — build trade + price = get_price(info, coin) + if price <= 0: + logger.warning("%s: could not get price — skip", coin) + continue + + risk = risk_summary(account_value, price, phase["confidence"], is_long=True, score=score) + + logger.info( + "%s: ENTERING LONG | score=%d/10 | entry=%.4f SL=%.4f TP=%.4f(fallback) " + "lev=%dx sz=%.4f | funding=%.4f%% | wallets=%s", + coin, score, price, risk["sl"], risk["tp"], risk["leverage"], risk["size"], + funding_rate * 100, ", ".join(longs), + ) + + success = open_position( + exchange, coin, is_long=True, + sz=risk["size"], entry=price, + sl=risk["sl"], tp=risk["tp"], + lev=risk["leverage"], + ) + + if success: + state["bot_positions"][coin] = { + "side": "long", + "sz": risk["size"], + "entry": price, + "sl": risk["sl"], + "tp": risk["tp"], + "lev": risk["leverage"], + "score": score, + "phase_at_entry": "ACCUMULATION", + "current_phase": "ACCUMULATION", + "trailed": False, + "dsl_tier": None, + "opened": datetime.now(timezone.utc).isoformat(), + } + save_state(state) + + notify_entry( + coin=coin, side="long", + entry=price, sz=risk["size"], + sl=risk["sl"], tp=risk["tp"], + leverage=risk["leverage"], + confidence=phase["confidence"], + phase_coin=coin, + wallet_labels=longs, + ) + + if len(state["bot_positions"]) >= MAX_OPEN_BOT_POSITIONS: + break + + +# ── Cancel all open trigger orders for a coin (clear stale SL/TP before replacing) +def cancel_open_triggers(exchange: Exchange, info: Info, coin: str) -> None: + try: + orders = info.open_orders(WALLET_ADDRESS) + for o in orders: + if o.get("coin") == coin: + exchange.cancel(coin, o["oid"]) + except Exception as e: + logger.warning("Failed to cancel triggers for %s: %s", coin, e) + + +# ── Market close a position +def close_position(exchange: Exchange, coin: str, sz: float, is_long: bool) -> bool: + try: + result = exchange.market_close(coin, sz if not is_long else None) + logger.info("Market close result for %s: %s", coin, result) + return True + except Exception as e: + logger.error("Failed to close %s: %s", coin, e) + notify_error(f"close_position({coin})", str(e)) + return False + + +# ── Position monitor: SL/TP hit detection + dynamic phase exit + breakeven trail +def run_position_monitor(info: Info, exchange: Exchange, state: dict) -> None: + if not state["bot_positions"]: + return + + try: + hl_state = info.user_state(WALLET_ADDRESS) + open_coins = { + p["position"]["coin"] + for p in (hl_state.get("assetPositions") or []) + if float(p["position"]["szi"]) != 0 + } + except Exception as e: + logger.warning("Position monitor error: %s", e) + return + + # ── Detect externally closed positions (SL/TP trigger hit) + auto_closed = [c for c in list(state["bot_positions"]) if c not in open_coins] + for coin in auto_closed: + pos = state["bot_positions"].pop(coin) + price = get_price(info, coin) + pnl_pct = (price - pos["entry"]) / pos["entry"] if pos["side"] == "long" else \ + (pos["entry"] - price) / pos["entry"] + pnl_usd = pnl_pct * pos["sz"] * pos["entry"] + reason = "SL triggered" if pnl_pct < 0 else "TP triggered" + logger.info("%s: position auto-closed (%s) pnl=%.2f", coin, reason, pnl_usd) + notify_exit(coin, pos["side"], pnl_usd, reason) + record_trade_outcome(state, pnl_usd) + # Set per-asset cooldown after a loss + if pnl_usd < 0: + cd_until = datetime.now(timezone.utc) + timedelta(minutes=ASSET_COOLDOWN_MINUTES) + state.setdefault("asset_cooldowns", {})[coin] = cd_until.isoformat() + logger.info("%s: loss → cooldown until %s", coin, cd_until.strftime("%H:%M UTC")) + save_state(state) + + if not PHASE_EXIT and not TRAIL_BREAKEVEN: + return + + # ── Phase-based dynamic exit, DSL ratcheting, and breakeven trail + for coin, pos in list(state["bot_positions"].items()): + if coin not in open_coins: + continue # already handled above + + raw_4h = fetch_candles(info, coin, "4h", 60) + phase = detect_phase(raw_4h) + dur = estimate_phase_duration(raw_4h, phase["phase"], "4h") + cur = phase["phase"] + prev = pos.get("current_phase", "ACCUMULATION") + price = get_price(info, coin) + + logger.info( + "%s [open]: phase=%s(%.0f%%) | %.1fd elapsed / %.1fd remaining | " + "price=%.4f entry=%.4f pnl=%.1f%%", + coin, cur, phase["confidence"] * 100, + dur["days_elapsed"], dur["days_remaining"], + price, pos["entry"], + ((price / pos["entry"]) - 1) * 100 if pos["side"] == "long" else + ((pos["entry"] / price) - 1) * 100, + ) + + # Update tracked phase + if cur != prev: + logger.info("%s: phase transition %s → %s", coin, prev, cur) + pos["current_phase"] = cur + save_state(state) + + # ── DSL ratcheting trail (Senpi-inspired): lock in gains progressively + is_long = pos["side"] == "long" + new_dsl_sl = compute_ratchet_sl(pos["entry"], price, is_long, pos["sl"]) + if new_dsl_sl is not None: + logger.info("%s: DSL ratchet — SL %.4f → %.4f", coin, pos["sl"], new_dsl_sl) + cancel_open_triggers(exchange, info, coin) + tp_side = not is_long + try: + exchange.order( + coin, tp_side, pos["sz"], new_dsl_sl, + order_type={"trigger": {"triggerPx": new_dsl_sl, "isMarket": True, "tpsl": "sl"}}, + reduce_only=True, + ) + pos["sl"] = new_dsl_sl + pos["dsl_tier"] = "ratcheted" + pos["trailed"] = True # DSL supersedes simple breakeven trail + save_state(state) + send(f"📈 {coin} DSL ratchet: SL → {new_dsl_sl:.4f}") + except Exception as e: + logger.warning("%s: failed to update DSL SL: %s", coin, e) + + # ── Breakeven trail (fallback when DSL hasn't fired yet) + if TRAIL_BREAKEVEN and cur == "MARKUP" and not pos.get("trailed"): + new_sl = breakeven_sl(pos["entry"], is_long) + logger.info("%s: phase → MARKUP — moving SL to breakeven %.4f", coin, new_sl) + cancel_open_triggers(exchange, info, coin) + tp_side = not is_long + try: + exchange.order( + coin, tp_side, pos["sz"], new_sl, + order_type={"trigger": {"triggerPx": new_sl, "isMarket": True, "tpsl": "sl"}}, + reduce_only=True, + ) + pos["sl"] = new_sl + pos["trailed"] = True + save_state(state) + send(f"🔒 {coin} SL → breakeven {new_sl:.4f} (phase → MARKUP)") + except Exception as e: + logger.warning("%s: failed to set breakeven SL: %s", coin, e) + + # ── Phase exit: close on DISTRIBUTION or MARKDOWN + if PHASE_EXIT and cur in ("DISTRIBUTION", "MARKDOWN"): + logger.info("%s: phase is %s — triggering dynamic exit", coin, cur) + cancel_open_triggers(exchange, info, coin) + closed = close_position(exchange, coin, pos["sz"], is_long) + if closed: + pnl_pct = (price - pos["entry"]) / pos["entry"] if is_long else \ + (pos["entry"] - price) / pos["entry"] + pnl_usd = pnl_pct * pos["sz"] * pos["entry"] + record_trade_outcome(state, pnl_usd) + state["bot_positions"].pop(coin) + save_state(state) + notify_exit(coin, pos["side"], pnl_usd, f"Phase → {cur}") + + +# ── Wallet scan wrapper +def run_wallet_scan() -> None: + events = scan_all_wallets() + for ev in events: + notify_wallet_entry(ev["label"], ev["coin"], ev["side"], ev["entry"]) + + +# ── Entry point +def main(): + logger.info("=== Hyperliquid Trading Bot starting ===") + send("🤖 Bot started — monitoring Hyperliquid for accumulation entries") + + info, exchange, account = setup_hl() + state = load_state() + + # Initial wallet scan to populate baseline positions (no alerts on first run) + scan_all_wallets() + logger.info("Initial wallet scan complete") + + last_phase_scan = 0.0 + last_wallet_scan = 0.0 + last_pos_poll = 0.0 + last_record = 0.0 + + while True: + now = time.time() + + if now - last_record >= RECORD_INTERVAL: + try: + record_snapshot(WATCH_COINS) + logger.info("Phase snapshot recorded to phase_log.jsonl") + except Exception as e: + logger.warning("Phase record error: %s", e) + last_record = now + + if now - last_wallet_scan >= WALLET_SCAN_INTERVAL: + run_wallet_scan() + last_wallet_scan = now + + if now - last_phase_scan >= PHASE_SCAN_INTERVAL: + try: + run_scan(info, exchange, state) + except Exception as e: + logger.exception("Phase scan error: %s", e) + notify_error("Phase scan", str(e)) + last_phase_scan = now + + if now - last_pos_poll >= POSITION_POLL_INTERVAL: + try: + run_position_monitor(info, exchange, state) + except Exception as e: + logger.warning("Position monitor error: %s", e) + last_pos_poll = now + + time.sleep(5) + + +if __name__ == "__main__": + main() diff --git a/bot/phase_analyzer.py b/bot/phase_analyzer.py new file mode 100644 index 0000000..447716d --- /dev/null +++ b/bot/phase_analyzer.py @@ -0,0 +1,343 @@ +""" +Phase analyzer — reads phase_log.jsonl and produces: + + 1. Phase duration statistics (mean/median/p75/p90 per phase per coin) + 2. Validation accuracy (did ACCUMULATION → MARKUP? etc.) + 3. Forecast for current phase of each coin + +Run: python phase_analyzer.py + python phase_analyzer.py --coin BTC + python phase_analyzer.py --min-runs 3 (require N completed runs for stats) +""" + +import json +import argparse +import statistics +from datetime import datetime, timezone +from collections import defaultdict + +LOG_FILE = "phase_log.jsonl" + +EXPECTED_NEXT = { + "ACCUMULATION": "MARKUP", + "MARKUP": "DISTRIBUTION", + "DISTRIBUTION": "MARKDOWN", + "MARKDOWN": "ACCUMULATION", + "NEUTRAL": None, # can go anywhere +} + +PHASE_EMOJI = { + "ACCUMULATION": "🔵", + "MARKUP": "🟢", + "DISTRIBUTION": "🟡", + "MARKDOWN": "🔴", + "NEUTRAL": "⚪", +} + + +# ── Load and parse log +def load_log(coin: str | None = None) -> dict[str, list[dict]]: + """Returns {coin: [records sorted by timestamp]}""" + by_coin: dict[str, list] = defaultdict(list) + try: + with open(LOG_FILE) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + r = json.loads(line) + r["_dt"] = datetime.fromisoformat(r["ts"]) + if coin is None or r["coin"] == coin: + by_coin[r["coin"]].append(r) + except Exception: + pass + except FileNotFoundError: + pass + + # Sort each coin's records by time + for c in by_coin: + by_coin[c].sort(key=lambda x: x["_dt"]) + + return dict(by_coin) + + +# ── Group consecutive same-phase records into runs +def find_runs(records: list[dict]) -> list[dict]: + """ + Returns list of completed phase runs (current in-progress run excluded). + Each run: {phase, start_dt, end_dt, duration_hours, start_price, end_price, + price_change_pct, next_phase, records_count} + """ + if not records: + return [] + + runs = [] + grp_ph = records[0]["phase"] + grp_recs = [records[0]] + + for r in records[1:]: + if r["phase"] == grp_ph: + grp_recs.append(r) + else: + # Close out the group + start_dt = grp_recs[0]["_dt"] + end_dt = r["_dt"] + dur_h = (end_dt - start_dt).total_seconds() / 3600 + sp = grp_recs[0]["price"] + ep = grp_recs[-1]["price"] + + runs.append({ + "phase": grp_ph, + "start_dt": start_dt, + "end_dt": end_dt, + "duration_hours": dur_h, + "duration_days": round(dur_h / 24, 1), + "start_price": sp, + "end_price": ep, + "price_change": (ep - sp) / sp if sp else 0, + "next_phase": r["phase"], + "records_count": len(grp_recs), + "avg_conf": sum(x["conf"] for x in grp_recs) / len(grp_recs), + }) + + grp_ph = r["phase"] + grp_recs = [r] + + # Current (in-progress) run returned separately + current = { + "phase": grp_ph, + "start_dt": grp_recs[0]["_dt"], + "end_dt": None, + "duration_hours": (records[-1]["_dt"] - grp_recs[0]["_dt"]).total_seconds() / 3600, + "records_count": len(grp_recs), + "avg_conf": sum(x["conf"] for x in grp_recs) / len(grp_recs), + "latest_price": grp_recs[-1]["price"], + "start_price": grp_recs[0]["price"], + } + + return runs, current + + +# ── Statistics per phase +def phase_stats(runs: list[dict]) -> dict[str, dict]: + by_phase: dict[str, list[float]] = defaultdict(list) + for r in runs: + by_phase[r["phase"]].append(r["duration_hours"]) + + result = {} + for phase, durs in by_phase.items(): + durs_s = sorted(durs) + n = len(durs_s) + result[phase] = { + "count": n, + "mean_h": statistics.mean(durs_s), + "median_h": statistics.median(durs_s), + "std_h": statistics.stdev(durs_s) if n > 1 else 0, + "min_h": durs_s[0], + "max_h": durs_s[-1], + "p25_h": durs_s[max(0, int(n * 0.25) - 1)], + "p75_h": durs_s[min(n - 1, int(n * 0.75))], + "p90_h": durs_s[min(n - 1, int(n * 0.90))], + } + return result + + +# ── Transition accuracy +def transition_accuracy(runs: list[dict]) -> dict[str, dict]: + """ + For each phase, measures how often the EXPECTED next phase actually followed. + """ + from_phase: dict[str, list] = defaultdict(list) + for r in runs: + from_phase[r["phase"]].append(r["next_phase"]) + + accuracy = {} + for phase, nexts in from_phase.items(): + expected = EXPECTED_NEXT.get(phase) + correct = sum(1 for n in nexts if n == expected) + accuracy[phase] = { + "total": len(nexts), + "expected": expected, + "correct": correct, + "accuracy": correct / len(nexts) if nexts else 0, + "transitions": {n: nexts.count(n) for n in set(nexts)}, + } + return accuracy + + +# ── Price change per phase +def price_change_stats(runs: list[dict]) -> dict[str, dict]: + by_phase: dict[str, list] = defaultdict(list) + for r in runs: + by_phase[r["phase"]].append(r["price_change"]) + + result = {} + for phase, changes in by_phase.items(): + result[phase] = { + "avg_pct": statistics.mean(changes) * 100, + "median_pct": statistics.median(changes) * 100, + "best_pct": max(changes) * 100, + "worst_pct": min(changes) * 100, + } + return result + + +# ── Forecast for current in-progress phase +def forecast(current: dict, stats: dict) -> dict: + phase = current["phase"] + elapsed_h = current["duration_hours"] + + if phase not in stats: + return {"status": "no_data", "phase": phase, "elapsed_h": elapsed_h} + + s = stats[phase] + remaining_median = s["median_h"] - elapsed_h + remaining_p75 = s["p75_h"] - elapsed_h + progress_pct = (elapsed_h / s["median_h"]) * 100 if s["median_h"] > 0 else 0 + is_late = elapsed_h > s["p75_h"] + is_overdue = elapsed_h > s["p90_h"] + + return { + "status": "ok", + "phase": phase, + "elapsed_h": elapsed_h, + "elapsed_days": round(elapsed_h / 24, 1), + "median_h": s["median_h"], + "p75_h": s["p75_h"], + "p90_h": s["p90_h"], + "remaining_median": remaining_median, + "remaining_p75": remaining_p75, + "progress_pct": round(progress_pct, 1), + "is_late": is_late, + "is_overdue": is_overdue, + "expected_next": EXPECTED_NEXT.get(phase, "unknown"), + } + + +# ── Pretty print helpers +def _h(hours: float) -> str: + if hours < 0: + return f"overdue by {abs(hours):.0f}h" + if hours < 48: + return f"{hours:.0f}h" + return f"{hours/24:.1f}d ({hours:.0f}h)" + + +def _pbar(pct: float, width: int = 20) -> str: + filled = int(min(pct, 100) / 100 * width) + over = pct > 100 + bar = "█" * filled + ("▓" if over else "░") * (width - min(filled, width)) + return f"[{bar}] {pct:.0f}%" + + +# ── Main output +def print_report(coin_filter: str | None = None, min_runs: int = 2): + data = load_log(coin_filter) + + if not data: + print(f"\n No data in {LOG_FILE} yet.") + print(" The recorder writes one entry per hour per coin.") + print(" Come back after a few hours to see phase tracking.\n") + return + + for coin, records in sorted(data.items()): + result = find_runs(records) + if isinstance(result, tuple): + runs, current = result + else: + print(f"\n{coin}: not enough data for run analysis") + continue + + stats = phase_stats(runs) + accuracy = transition_accuracy(runs) + px_stats = price_change_stats(runs) + fcast = forecast(current, stats) + total_hours = (records[-1]["_dt"] - records[0]["_dt"]).total_seconds() / 3600 + + print(f"\n{'═'*62}") + print(f" {PHASE_EMOJI.get(current['phase'], '⚪')} {coin} — {len(records)} snapshots " + f"over {total_hours:.0f}h ({len(runs)} completed runs)") + print(f"{'═'*62}") + + # ── Current phase forecast + print(f"\n CURRENT PHASE: {current['phase']} (avg conf {current['avg_conf']:.0%})") + if fcast["status"] == "no_data": + print(f" Elapsed: {_h(current['duration_hours'])} (no historical data yet for forecast)") + else: + print(f" Elapsed: {_h(fcast['elapsed_h'])}") + print(f" Progress: {_pbar(fcast['progress_pct'])}") + print(f" Typical: median={_h(fcast['median_h'])} p75={_h(fcast['p75_h'])} p90={_h(fcast['p90_h'])}") + if fcast["is_overdue"]: + print(f" ⛔ OVERDUE — past p90, change could come at any candle") + elif fcast["is_late"]: + late_h = fcast["elapsed_h"] - fcast["p75_h"] + print(f" ⚠ LATE — {late_h:.0f}h past p75, phase change imminent") + else: + print(f" Remaining: ~{_h(fcast['remaining_median'])} (median) " + f"to ~{_h(fcast['remaining_p75'])} (p75)") + exp = fcast["expected_next"] + if exp: + print(f" Anticipate: → {exp} {PHASE_EMOJI.get(exp,'')}") + + # ── Phase duration table + if stats: + print(f"\n DURATION STATISTICS (completed runs)") + print(f" {'Phase':<14} {'N':>3} {'Min':>6} {'Median':>7} {'P75':>7} {'P90':>7} {'Max':>7} Price Δ") + print(f" {'─'*14} {'─'*3} {'─'*6} {'─'*7} {'─'*7} {'─'*7} {'─'*7} {'─'*8}") + for phase in ["ACCUMULATION","MARKUP","DISTRIBUTION","MARKDOWN","NEUTRAL"]: + if phase not in stats: + continue + s = stats[phase] + px = px_stats.get(phase, {}) + if s["count"] < min_runs: + continue + px_str = f"{px.get('median_pct',0):>+.1f}%" if px else " n/a" + print(f" {PHASE_EMOJI.get(phase,'')} {phase:<12} " + f"{s['count']:>3} " + f"{_h(s['min_h']):>6} " + f"{_h(s['median_h']):>7} " + f"{_h(s['p75_h']):>7} " + f"{_h(s['p90_h']):>7} " + f"{_h(s['max_h']):>7} " + f"{px_str}") + + # ── Transition accuracy + if accuracy: + print(f"\n PHASE TRANSITION ACCURACY") + for phase in ["ACCUMULATION","MARKUP","DISTRIBUTION","MARKDOWN"]: + if phase not in accuracy: + continue + a = accuracy[phase] + if a["total"] < min_runs: + continue + exp = a["expected"] or "any" + bar = "█" * int(a["accuracy"] * 10) + "░" * (10 - int(a["accuracy"] * 10)) + others = {k: v for k, v in a["transitions"].items() if k != a["expected"]} + other_str = " other: " + ", ".join(f"{k}×{v}" for k,v in others.items()) if others else "" + print(f" {PHASE_EMOJI.get(phase,'')} {phase:<14} → {exp:<14} " + f"[{bar}] {a['accuracy']:.0%} ({a['correct']}/{a['total']}){other_str}") + + # ── Recent phase timeline + last_n = min(12, len(runs)) + if last_n > 0: + print(f"\n RECENT PHASE TIMELINE (last {last_n} completed runs)") + for r in runs[-last_n:]: + arrow = "✓" if r["next_phase"] == EXPECTED_NEXT.get(r["phase"]) else "↯" + pc = r["price_change"] * 100 + pc_str = f"{pc:>+6.1f}%" + print(f" {PHASE_EMOJI.get(r['phase'],'')} {r['phase']:<14} " + f"{r['duration_days']:>4.1f}d " + f"price {pc_str} " + f"→ {PHASE_EMOJI.get(r['next_phase'],'')} {r['next_phase']:<14} {arrow}") + + print() + + +if __name__ == "__main__": + ap = argparse.ArgumentParser() + ap.add_argument("--coin", type=str, default=None, help="Filter to one coin") + ap.add_argument("--min-runs", type=int, default=2, help="Min completed runs for stats") + args = ap.parse_args() + print_report(coin_filter=args.coin.upper() if args.coin else None, + min_runs=args.min_runs) diff --git a/bot/phase_detector.py b/bot/phase_detector.py new file mode 100644 index 0000000..e25e32c --- /dev/null +++ b/bot/phase_detector.py @@ -0,0 +1,258 @@ +""" +Wyckoff phase detector — ported from app.js detectPhase(). +Returns {"phase": str, "confidence": float, "score": float, "signals": list} +""" + +from indicators import ema, macd, rsi, atr, volume_ratio, parse_candles + + +PHASES = { + "MARKUP": (0.45, float("inf")), + "ACCUMULATION": (0.12, 0.44), + "NEUTRAL": (-0.11, 0.11), + "DISTRIBUTION": (-0.44, -0.12), + "MARKDOWN": (float("-inf"), -0.45), +} + +# Typical phase durations in candles per timeframe (based on Wyckoff crypto cycles) +PHASE_TYPICAL_CANDLES = { + "15m": {"ACCUMULATION": 96, "MARKUP": 160, "DISTRIBUTION": 64, "MARKDOWN": 96, "NEUTRAL": 48}, + "1h": {"ACCUMULATION": 36, "MARKUP": 60, "DISTRIBUTION": 24, "MARKDOWN": 36, "NEUTRAL": 18}, + "4h": {"ACCUMULATION": 20, "MARKUP": 35, "DISTRIBUTION": 14, "MARKDOWN": 20, "NEUTRAL": 10}, + "1d": {"ACCUMULATION": 14, "MARKUP": 21, "DISTRIBUTION": 7, "MARKDOWN": 14, "NEUTRAL": 5}, +} + + +def detect_phase(raw_candles: list[dict]) -> dict: + if len(raw_candles) < 60: + return {"phase": "NEUTRAL", "confidence": 0.0, "score": 0.0, "signals": []} + + c = parse_candles(raw_candles) + closes = c["closes"] + highs = c["highs"] + lows = c["lows"] + volumes = c["volumes"] + n = len(closes) + + score = 0.0 + signals = [] + + # ── EMA stack (20 / 50 / 200) + e20 = ema(closes, 20) + e50 = ema(closes, 50) + e200 = ema(closes, 200) if n >= 200 else [] + + if e20 and e50: + last20, last50 = e20[-1], e50[-1] + if last20 > last50: + score += 0.15 + signals.append("EMA_BULL") + else: + score -= 0.15 + signals.append("EMA_BEAR") + + # EMA 20 slope + if len(e20) >= 5: + slope = (e20[-1] - e20[-5]) / e20[-5] if e20[-5] else 0 + if slope > 0.002: + score += 0.08 + signals.append("EMA20_UP") + elif slope < -0.002: + score -= 0.08 + signals.append("EMA20_DOWN") + + if e200: + if closes[-1] > e200[-1]: + score += 0.10 + signals.append("ABOVE_EMA200") + else: + score -= 0.10 + signals.append("BELOW_EMA200") + + # ── MACD histogram + _, _, hist = macd(closes) + if len(hist) >= 3: + h1, h2, h3 = hist[-1], hist[-2], hist[-3] + if h1 > 0 and h1 > h2: + score += 0.12 + signals.append("MACD_BULL_EXP") + elif h1 > 0: + score += 0.06 + signals.append("MACD_BULL") + elif h1 < 0 and h1 < h2: + score -= 0.12 + signals.append("MACD_BEAR_EXP") + elif h1 < 0: + score -= 0.06 + signals.append("MACD_BEAR") + + # MACD turning + if h3 < 0 and h2 < 0 and h1 > h2: + score += 0.05 + signals.append("MACD_TURNING_UP") + elif h3 > 0 and h2 > 0 and h1 < h2: + score -= 0.05 + signals.append("MACD_TURNING_DOWN") + + # ── RSI + rsi_vals = rsi(closes) + if rsi_vals: + rv = rsi_vals[-1] + if rv > 60: + score += 0.10 + signals.append(f"RSI_BULL({rv:.0f})") + elif rv < 40: + score -= 0.10 + signals.append(f"RSI_BEAR({rv:.0f})") + elif rv > 50: + score += 0.04 + else: + score -= 0.04 + + if rv < 30: + score += 0.08 # oversold bounce potential + signals.append("RSI_OVERSOLD") + elif rv > 70: + score -= 0.08 + signals.append("RSI_OVERBOUGHT") + + # ── Price change over last 20% of the period + lookback = max(int(n * 0.2), 5) + price_chg = (closes[-1] - closes[-lookback]) / closes[-lookback] if closes[-lookback] else 0 + if price_chg > 0.03: + score += 0.10 + signals.append(f"PRICE_UP({price_chg*100:.1f}%)") + elif price_chg < -0.03: + score -= 0.10 + signals.append(f"PRICE_DOWN({price_chg*100:.1f}%)") + + # ── Volume ratio (recent vs base) + vr = volume_ratio(volumes) + if vr > 1.5: + score += 0.08 + signals.append(f"VOL_HIGH({vr:.1f}x)") + elif vr < 0.7: + score -= 0.05 + signals.append(f"VOL_LOW({vr:.1f}x)") + + # ── ATR compression (low ATR = consolidation / accumulation potential) + atr_vals = atr(highs, lows, closes) + if len(atr_vals) >= 10: + atr_now = atr_vals[-1] + atr_avg = sum(atr_vals[-10:]) / 10 + if atr_now < atr_avg * 0.75: + score += 0.05 + signals.append("ATR_COMPRESS") + elif atr_now > atr_avg * 1.5: + score -= 0.03 + signals.append("ATR_EXPAND") + + # ── Consecutive closes direction (last 5 bars) + consec = 0 + for i in range(-1, -6, -1): + if len(closes) + i - 1 < 0: + break + if closes[i] > closes[i - 1]: + consec += 1 + else: + consec -= 1 + if consec >= 4: + score += 0.08 + signals.append(f"CONSEC_UP({consec})") + elif consec <= -4: + score -= 0.08 + signals.append(f"CONSEC_DOWN({abs(consec)})") + + # ── Signal alignment bonus + bull_sigs = sum(1 for s in signals if any(k in s for k in ("BULL", "UP", "ABOVE", "OVERSOLD", "HIGH"))) + bear_sigs = sum(1 for s in signals if any(k in s for k in ("BEAR", "DOWN", "BELOW", "OVERBOUGHT", "LOW"))) + if bull_sigs >= 5: + score += 0.08 + signals.append(f"ALIGN_BULL({bull_sigs})") + elif bull_sigs >= 3: + score += 0.04 + if bear_sigs >= 5: + score -= 0.08 + signals.append(f"ALIGN_BEAR({bear_sigs})") + elif bear_sigs >= 3: + score -= 0.04 + + # ── Confidence (absolute score boosted by data length) + confidence = min(abs(score) + 0.04 * min(n / 60, 1.0), 1.0) + + # ── Phase classification + phase = "NEUTRAL" + if score >= 0.45: + phase = "MARKUP" + elif score >= 0.12: + phase = "ACCUMULATION" + elif score <= -0.45: + phase = "MARKDOWN" + elif score <= -0.12: + phase = "DISTRIBUTION" + + return { + "phase": phase, + "confidence": round(confidence, 4), + "score": round(score, 4), + "signals": signals, + } + + +def estimate_phase_duration(raw_candles: list[dict], current_phase: str, interval: str = "4h") -> dict: + """ + Estimate how long the current phase has been running and how much longer it may last. + + Strategy: slide detect_phase() backwards over the candle history using half-window + steps until the phase label changes — that marks the start of the current phase. + + Returns: + days_elapsed: how many days the phase has been running + days_remaining: estimated days left (negative means overdue) + progress_pct: 0-100+, how far through the typical cycle + typical_days: historical median for this phase on this timeframe + is_late: True if >80% through typical duration (exit risk rising) + candles_elapsed: raw candle count since phase started + """ + tf_hours = {"15m": 0.25, "1h": 1.0, "4h": 4.0, "1d": 24.0}.get(interval, 4.0) + typical_candles = PHASE_TYPICAL_CANDLES.get(interval, PHASE_TYPICAL_CANDLES["4h"]) + typical = typical_candles.get(current_phase, 20) + + n = len(raw_candles) + # Minimum window to get a stable phase reading + win = max(40, n // 5) + + # Walk backwards: find the earliest window whose tail still matches current_phase + phase_start_idx = 0 # assume phase started from the beginning if we can't find a break + step = max(win // 4, 5) + + for end in range(n - win, win, -step): + subset = raw_candles[: end] + if len(subset) < 40: + break + result = detect_phase(subset) + if result["phase"] != current_phase: + # Phase changed at this point — current phase started just after here + phase_start_idx = end + break + + candles_elapsed = n - phase_start_idx + hours_elapsed = candles_elapsed * tf_hours + days_elapsed = hours_elapsed / 24.0 + + hours_typical = typical * tf_hours + days_typical = hours_typical / 24.0 + + remaining_candles = typical - candles_elapsed + days_remaining = remaining_candles * tf_hours / 24.0 + + progress_pct = min(int(candles_elapsed / typical * 100), 150) + + return { + "days_elapsed": round(days_elapsed, 1), + "days_remaining": round(days_remaining, 1), + "progress_pct": progress_pct, + "typical_days": round(days_typical, 1), + "candles_elapsed": candles_elapsed, + "is_late": progress_pct >= 80, + } diff --git a/bot/phase_log.jsonl b/bot/phase_log.jsonl new file mode 100644 index 0000000..e69de29 diff --git a/bot/phase_recorder.py b/bot/phase_recorder.py new file mode 100644 index 0000000..31a8ede --- /dev/null +++ b/bot/phase_recorder.py @@ -0,0 +1,192 @@ +""" +Phase recorder — appends a phase snapshot for each WATCH_COIN every hour +to phase_log.jsonl, trims to LOG_RETENTION_DAYS, then auto-commits and +pushes to GitHub so the history is preserved across machines. + +Called automatically from main.py every RECORD_INTERVAL seconds. +Can also run standalone: python phase_recorder.py +""" + +import json +import os +import subprocess +import time +import logging +import requests +from datetime import datetime, timezone, timedelta + +from config import HL_API_URL, WATCH_COINS + +from phase_detector import detect_phase + +logger = logging.getLogger(__name__) +LOG_FILE = "phase_log.jsonl" +LOG_RETENTION_DAYS = 14 # keep 2 weeks of hourly snapshots + +_session = requests.Session() +_session.headers.update({"Content-Type": "application/json"}) + + +# ── Helpers +def _fetch_candles(coin: str, interval: str = "4h", days: int = 60) -> list[dict]: + now = int(time.time() * 1000) + start = now - days * 86400 * 1000 + try: + r = _session.post( + f"{HL_API_URL}/info", + json={"type": "candleSnapshot", + "req": {"coin": coin, "interval": interval, + "startTime": start, "endTime": now}}, + timeout=15, + ) + r.raise_for_status() + data = r.json() + return data if isinstance(data, list) else [] + except Exception as e: + logger.warning("Candle fetch failed for %s: %s", coin, e) + return [] + + +def _get_price(coin: str) -> float: + try: + r = _session.post(f"{HL_API_URL}/info", json={"type": "allMids"}, timeout=10) + r.raise_for_status() + return float(r.json().get(coin, 0)) + except Exception: + return 0.0 + + +def _trim_log(log_path: str, days: int = LOG_RETENTION_DAYS) -> int: + """Remove records older than `days`. Returns number of records kept.""" + if not os.path.exists(log_path): + return 0 + cutoff = datetime.now(timezone.utc) - timedelta(days=days) + kept, removed = [], 0 + with open(log_path) as f: + for line in f: + line = line.strip() + if not line: + continue + try: + r = json.loads(line) + dt = datetime.fromisoformat(r["ts"]) + if dt >= cutoff: + kept.append(line) + else: + removed += 1 + except Exception: + kept.append(line) # keep any malformed lines + with open(log_path, "w") as f: + f.write("\n".join(kept) + ("\n" if kept else "")) + if removed: + logger.info("Trimmed %d old phase records (kept %d)", removed, len(kept)) + return len(kept) + + +def _git_push(log_path: str) -> bool: + """ + Stage phase_log.jsonl, commit, pull --rebase, then push. + Silent failure — a push error never crashes the bot. + """ + bot_dir = os.path.dirname(os.path.abspath(log_path)) + repo_root = os.path.dirname(bot_dir) # hype/ + rel_path = os.path.relpath(log_path, repo_root) # bot/phase_log.jsonl + + ts_str = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") + + def run(args): + return subprocess.run( + args, cwd=repo_root, + capture_output=True, text=True + ) + + try: + # Get current branch name + branch = run(["git", "branch", "--show-current"]).stdout.strip() + if not branch: + branch = "claude/study-code-session-QlCAr" # fallback + + # Stage the log file + r = run(["git", "add", rel_path]) + if r.returncode != 0: + logger.warning("git add failed: %s", r.stderr.strip()) + return False + + # Commit — may return non-zero if nothing changed (that's fine) + run(["git", "commit", "-m", f"phase log {ts_str}"]) + + # Pull --rebase to absorb any remote changes before pushing + run(["git", "pull", "--rebase", "origin", branch]) + + # Push + r = run(["git", "push", "origin", branch]) + if r.returncode == 0: + logger.info("Phase log pushed to GitHub (%s)", branch) + return True + else: + logger.warning("git push failed: %s", r.stderr.strip()) + return False + + except Exception as e: + logger.warning("git push error: %s", e) + return False + + +# ── Main recording function +def record_snapshot(coins: list[str] = WATCH_COINS, interval: str = "4h", + push: bool = True) -> dict[str, dict]: + """ + Fetch current phase for each coin, append to phase_log.jsonl, + trim to LOG_RETENTION_DAYS, and push to GitHub. + Returns {coin: phase_result} for immediate use by the bot. + """ + ts = datetime.now(timezone.utc).isoformat() + results = {} + log_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), LOG_FILE) + + for coin in coins: + candles = _fetch_candles(coin, interval) + if len(candles) < 40: + logger.warning("Not enough candles for %s — skipping record", coin) + continue + + phase = detect_phase(candles) + price = _get_price(coin) + + record = { + "ts": ts, + "coin": coin, + "interval": interval, + "phase": phase["phase"], + "conf": round(phase["confidence"], 4), + "score": round(phase["score"], 4), + "price": price, + "signals": phase["signals"], + } + + try: + with open(log_path, "a") as f: + f.write(json.dumps(record) + "\n") + except Exception as e: + logger.error("Failed to write phase log: %s", e) + continue + + results[coin] = phase + logger.info("Recorded: %s %-14s conf=%.0f%% price=%.4f", + coin, phase["phase"], phase["confidence"] * 100, price) + + # Trim old records, then push + _trim_log(log_path) + if push and results: + _git_push(log_path) + + return results + + +if __name__ == "__main__": + logging.basicConfig(level=logging.INFO, + format="%(asctime)s %(levelname)-8s %(message)s") + print(f"Recording phase snapshot for {WATCH_COINS}...") + results = record_snapshot() + for coin, p in results.items(): + print(f" {coin:<6} {p['phase']:<14} conf={p['confidence']:.0%} score={p['score']:+.3f}") diff --git a/bot/requirements.txt b/bot/requirements.txt new file mode 100644 index 0000000..5105ef0 --- /dev/null +++ b/bot/requirements.txt @@ -0,0 +1,6 @@ +hyperliquid-python-sdk>=0.9.0 +python-dotenv>=1.0.0 +requests>=2.31.0 +python-telegram-bot>=20.0 +schedule>=1.2.0 +anthropic>=0.40.0 diff --git a/bot/risk_manager.py b/bot/risk_manager.py new file mode 100644 index 0000000..866fcd2 --- /dev/null +++ b/bot/risk_manager.py @@ -0,0 +1,108 @@ +""" +Position sizing and risk calculation. + +Safe leverage at 8% SL: + Liquidation at isolated margin = entry × (1 - 1/lev + maint_margin) + With maint_margin ≈ 0.005 (Hyperliquid): + lev=10 → liq at entry × (1 - 0.10 + 0.005) = -9.5% from entry + SL fires at -8%, well before the -9.5% liquidation → 10x is safe at 8% SL. + +Leverage scaling by confidence (40% → 3x, 90%+ → 10x): + Small account benefit: higher leverage amplifies gains without increasing + absolute risk, because MARGIN_PCT is fixed at 10% of account. +""" + +import math +from config import MARGIN_PCT, MAX_LEVERAGE, SL_PCT, TP_PCT_FIXED + +MAINT_MARGIN = 0.005 # Hyperliquid maintenance margin rate + + +def safe_leverage(sl_pct: float = SL_PCT, max_lev: int = MAX_LEVERAGE) -> int: + """ + Largest integer leverage where liquidation is safely beyond SL. + Formula: lev ≤ 1 / (sl_pct + maint_margin) + At SL=8%: max_safe = int(1 / 0.085) = 11 → capped at 10 by MAX_LEVERAGE. + """ + max_safe = int(1.0 / (sl_pct + MAINT_MARGIN)) + return max(1, min(max_safe, max_lev)) + + +def scale_leverage(confidence: float, sl_pct: float = SL_PCT, max_lev: int = MAX_LEVERAGE) -> int: + """ + Scale leverage with confidence: + 40% → 3x (cautious entry) + 65% → 6x (medium conviction) + 90%+ → 10x (high conviction, full safe max) + Linear interpolation between 3x floor and safe_max ceiling. + """ + cap = safe_leverage(sl_pct, max_lev) + floor = min(3, cap) + ratio = max(0.0, (confidence - 0.40) / 0.50) # 0 at 40%, 1 at 90%+ + lev = floor + round(ratio * (cap - floor)) + return max(floor, min(lev, cap)) + + +def position_size(account_value: float, price: float, leverage: int) -> float: + """ + Contract size in base asset. + margin_used = account_value × MARGIN_PCT + notional = margin × leverage + contracts = notional / price + """ + margin = account_value * MARGIN_PCT + notional = margin * leverage + return notional / price + + +def sl_price(entry: float, is_long: bool, sl_pct: float = SL_PCT) -> float: + return entry * (1 - sl_pct) if is_long else entry * (1 + sl_pct) + + +def tp_price(entry: float, is_long: bool, tp_pct: float = TP_PCT_FIXED) -> float: + return entry * (1 + tp_pct) if is_long else entry * (1 - tp_pct) + + +def breakeven_sl(entry: float, is_long: bool, buffer: float = 0.003) -> float: + """SL price at breakeven +/- a small buffer to avoid noise exits.""" + return entry * (1 + buffer) if is_long else entry * (1 - buffer) + + +def round_price(price: float, tick: float = 0.1) -> float: + return math.floor(price / tick + 0.5) * tick + + +def scale_leverage_by_score(score: int, max_score: int = 10, max_lev: int = MAX_LEVERAGE) -> int: + """ + Map confluence score (0-10) to leverage. + Score 5 (min entry) → 3x; score 10 (max) → MAX_LEVERAGE. + Scores below 5 are treated as 5 (should have been filtered before calling). + """ + cap = safe_leverage(SL_PCT, max_lev) + floor = min(3, cap) + ratio = max(0.0, (score - 5) / 5.0) # 0 at score 5, 1.0 at score 10 + lev = floor + round(ratio * (cap - floor)) + return max(floor, min(lev, cap)) + + +def risk_summary(account_value: float, price: float, confidence: float, is_long: bool, + score: int | None = None) -> dict: + lev = scale_leverage_by_score(score) if score is not None else scale_leverage(confidence) + sz = position_size(account_value, price, lev) + entry = price + sl = sl_price(entry, is_long) + tp = tp_price(entry, is_long) + margin = account_value * MARGIN_PCT + notional = sz * price + max_loss = margin * SL_PCT # loss as fraction of margin at SL + return { + "leverage": lev, + "size": round(sz, 6), + "entry": entry, + "sl": round(sl, 4), + "tp": round(tp, 4), + "margin": round(margin, 2), + "notional": round(notional, 2), + "max_loss_usd": round(max_loss, 2), + "rr_ratio": round(TP_PCT_FIXED / SL_PCT, 2), + } diff --git a/bot/telegram_notifier.py b/bot/telegram_notifier.py new file mode 100644 index 0000000..ce3cab7 --- /dev/null +++ b/bot/telegram_notifier.py @@ -0,0 +1,89 @@ +"""Telegram notification helper.""" + +import logging +import requests +from config import TG_TOKEN, TG_CHAT_ID + +logger = logging.getLogger(__name__) + +_BASE = "https://api.telegram.org/bot{token}/{method}" + + +def _call(method: str, payload: dict) -> dict | None: + if not TG_TOKEN: + return None + url = _BASE.format(token=TG_TOKEN, method=method) + try: + r = requests.post(url, json=payload, timeout=10) + r.raise_for_status() + return r.json() + except Exception as e: + logger.warning("Telegram error: %s", e) + return None + + +def send(text: str, chat_id: str = TG_CHAT_ID) -> bool: + if not chat_id: + logger.warning("TG_CHAT_ID not set — skipping notification") + return False + result = _call("sendMessage", {"chat_id": chat_id, "text": text, "parse_mode": "HTML"}) + return bool(result and result.get("ok")) + + +def get_chat_id() -> str | None: + """Fetch chat ID from the most recent message sent to the bot.""" + result = _call("getUpdates", {"limit": 10, "timeout": 5}) + if not result or not result.get("ok"): + return None + updates = result.get("result", []) + for upd in reversed(updates): + msg = upd.get("message") or upd.get("channel_post") + if msg and "chat" in msg: + cid = str(msg["chat"]["id"]) + logger.info("Found chat_id: %s", cid) + return cid + logger.warning("No messages found — send a message to your bot first") + return None + + +def notify_entry(coin: str, side: str, entry: float, sz: float, + sl: float, tp: float, leverage: int, + confidence: float, phase_coin: str, wallet_labels: list[str]) -> None: + direction = "🟢 LONG" if side == "long" else "🔴 SHORT" + wallets = ", ".join(wallet_labels) if wallet_labels else "none" + msg = ( + f"🤖 BOT ENTRY\n" + f"{direction} {coin} ×{leverage}\n" + f"Entry: {entry:.4f}\n" + f"SL: {sl:.4f} (-25%)\n" + f"TP: {tp:.4f} (+75%)\n" + f"Size: {sz:.4f} {coin}\n" + f"Phase: {phase_coin} ACCUM {confidence*100:.0f}%\n" + f"Smart wallets long: {wallets}" + ) + send(msg) + + +def notify_exit(coin: str, side: str, pnl: float, reason: str) -> None: + emoji = "✅" if pnl >= 0 else "❌" + msg = ( + f"{emoji} BOT EXIT\n" + f"{'LONG' if side=='long' else 'SHORT'} {coin} closed\n" + f"PnL: {'+'if pnl>=0 else ''}{pnl:.2f} USDC\n" + f"Reason: {reason}" + ) + send(msg) + + +def notify_wallet_entry(label: str, coin: str, side: str, entry: float) -> None: + emoji = "🟢" if side == "long" else "🔴" + msg = ( + f"👛 Smart Wallet Alert\n" + f"{emoji} {label} opened {side.upper()} {coin}\n" + f"Entry: {entry:.4f}" + ) + send(msg) + + +def notify_error(context: str, error: str) -> None: + send(f"⚠️ Bot Error\n{context}\n{error}") diff --git a/bot/wallet_monitor.py b/bot/wallet_monitor.py new file mode 100644 index 0000000..b8dfe6b --- /dev/null +++ b/bot/wallet_monitor.py @@ -0,0 +1,91 @@ +""" +Smart wallet monitor — polls Hyperliquid positions for tracked wallets. +Returns bullish signal if >= MIN_WALLET_SIGNALS wallets are long a coin. +""" + +import logging +import requests +from config import HL_API_URL, SMART_WALLETS, MIN_WALLET_SIGNALS + +logger = logging.getLogger(__name__) + +_hl_session = requests.Session() +_hl_session.headers.update({"Content-Type": "application/json"}) + +_prev_positions: dict[str, dict] = {} # wallet_label → {coin: {"side", "sz", "entry"}} +_new_entries: list[dict] = [] # buffer of fresh signals for main loop + + +def _hl_post(payload: dict) -> dict | list | None: + try: + r = _hl_session.post(f"{HL_API_URL}/info", json=payload, timeout=10) + r.raise_for_status() + return r.json() + except Exception as e: + logger.warning("HL request failed: %s", e) + return None + + +def fetch_wallet_positions(wallet_address: str) -> dict[str, dict]: + """Returns {coin: {"side": "long"|"short", "sz": float, "entry": float}} for all open perp positions.""" + state = _hl_post({"type": "clearinghouseState", "user": wallet_address}) + if not state: + return {} + positions = {} + for p in (state.get("assetPositions") or []): + pos = p.get("position", {}) + sz = float(pos.get("szi", 0)) + if sz == 0: + continue + coin = pos.get("coin", "") + positions[coin] = { + "side": "long" if sz > 0 else "short", + "sz": abs(sz), + "entry": float(pos.get("entryPx", 0)), + } + return positions + + +def scan_all_wallets() -> list[dict]: + """ + Scans all SMART_WALLETS. Returns list of new-entry events: + {"label": str, "wallet": str, "coin": str, "side": str, "sz": float, "entry": float} + """ + global _prev_positions, _new_entries + new_events = [] + + for label, address in SMART_WALLETS.items(): + current = fetch_wallet_positions(address) + prev = _prev_positions.get(label, {}) + + for coin, pos in current.items(): + if coin not in prev: + event = {"label": label, "wallet": address, "coin": coin, **pos} + new_events.append(event) + logger.info("New wallet entry: %s opened %s %s", label, pos["side"], coin) + + _prev_positions[label] = current + + _new_entries = new_events + return new_events + + +def get_wallet_longs(coin: str) -> list[str]: + """Returns list of wallet labels currently long the given coin.""" + long_labels = [] + for label, positions in _prev_positions.items(): + if coin in positions and positions[coin]["side"] == "long": + long_labels.append(label) + return long_labels + + +def has_wallet_signal(coin: str) -> bool: + """True if at least MIN_WALLET_SIGNALS wallets are long this coin.""" + return len(get_wallet_longs(coin)) >= MIN_WALLET_SIGNALS + + +def wallet_summary(coin: str) -> str: + longs = get_wallet_longs(coin) + if not longs: + return "no smart wallet longs" + return f"{len(longs)} wallet(s) long: {', '.join(longs)}" diff --git a/docs.html b/docs.html new file mode 100644 index 0000000..e6da3c5 --- /dev/null +++ b/docs.html @@ -0,0 +1,1205 @@ + + + + + + Hype — Documentation + + + + + + + + + +
+ +
+ Documentation + ← Back to App +
+
+ + + + +
+ + + + + +
+ + +
+
Introduction
+

Hype Dashboard

+

Hype is a personal trading dashboard for Hyperliquid — a high-performance decentralised perpetuals and spot exchange. The entire dashboard runs in your browser with no backend required. It reads your wallet data directly from the Hyperliquid public API and connects over WebSocket for live price feeds.

+

No sign-in, no API keys, no server. Enter any wallet address — yours or anyone else's — and get a full read-only view of their portfolio, trades, funding history, and more.

+ +
+ 🔒 +
Read-only and non-custodial. Hype never asks for your private key or signs any transactions. It only reads public data from the Hyperliquid API.
+
+ + + + + + + + + + + +
PropertyValue
Live URLravellerh.github.io/Hype
Data sourceHyperliquid public API + Binance Futures + CoinGecko
Tech stackVanilla JS, CSS custom properties, Chart.js (minimal), SVG sparklines
Real-timeHyperliquid WebSocket API
Exchange ratesfrankfurter.app (historical USD/IDR)
Backend requiredNo — optional Cloudflare Worker for edge caching
+
+ + +
+
Setup
+

Getting Started

+

Open the dashboard at ravellerh.github.io/Hype. By default it loads a demo wallet. To view your own data:

+
    +
  1. Click the wallet address in the top-right corner of the topbar.
  2. +
  3. Paste your Hyperliquid wallet address (0x…).
  4. +
  5. Press Enter — all tabs reload with your data.
  6. +
+
+ 💡 +
Your address is saved in localStorage so it persists across sessions. You can switch wallets any time — useful for tracking multiple accounts or watching a known trader.
+
+ +

Optional: Speed up loading with a Cloudflare Worker

+

If you're on a slow connection (common in Indonesia), deploy a Cloudflare Worker proxy to get edge-cached API responses. See the CF Worker Proxy section for instructions. Once deployed, paste your Worker URL in Live → API Proxy.

+
+ + +
+
Technical
+

Architecture

+

The dashboard is a single-page application (SPA) with no build step. All logic lives in plain JavaScript files loaded by index.html.

+
Hype/
+├── index.html          ← Entry point, tab shell
+├── app.js              ← All tab logic, API calls, WebSocket, charts
+├── ta-signal.js        ← TA engine: indicators, CVD, OI, signal scoring
+├── analytics.js        ← PnL analytics, equity curve
+├── intel.js            ← Research snapshot, macro intel
+├── indicators.js       ← Fear & Greed, BMSB, Pi Cycle
+├── nansen.js           ← Smart Money wallet tracking
+├── mvrv-ai.js          ← MVRV Z-score, AI commentary
+├── kb.js               ← Knowledge base & trade journal
+├── position-meta.js    ← Per-position intent / thesis modal
+├── logger.js           ← Data logger & portfolio snapshots
+├── styles.css          ← All styles (single file, CSS variables)
+├── sw.js               ← Service worker (PWA caching)
+├── manifest.json       ← PWA manifest
+└── worker.js           ← Cloudflare Worker proxy (optional, deploy separately)
+ +

All Hyperliquid API calls are POST requests to api.hyperliquid.xyz/info. The service worker only caches static assets — API responses are never cached by the browser (they use POST), which is why the optional Cloudflare Worker provides real TTL caching.

+ + + + + + + + + +
Cache layerHow it worksTTL
Service WorkerCaches static JS/CSS filesUntil SW update
In-memory (candles)_candleCache Map in app.js5 min
In-memory (meta)getMetaAndAssetCtxs._cache2 min
CF Worker edgeCloudflare cache near your location5 s – 10 min per type
+
+ +
+ + +
+
Tabs Guide
+

Portfolio

+
+
+ 💼 +

Portfolio Overview

+ Live data +
+

The main account summary. Shows your total portfolio value, open perpetual positions, spot holdings, unrealized PnL, open orders, and a portfolio growth chart.

+
    +
  • Total Portfolio — for unified accounts: spot USDC + unrealized perp PnL. For standard accounts: account value + spot value.
  • +
  • Health Score — composite risk score (0–100). Flags high leverage, low liquidation distance, smart money divergence, and BMSB signal.
  • +
  • Portfolio Chart — snapshots are saved to localStorage every time you load the tab. Switch between All / Perp / Spot views.
  • +
  • Open Orders — live order book pulled on each load.
  • +
+
+ ⚠️ +
Unified accounts: Hyperliquid unified accounts hold USDC in spot as perp collateral. The crossMarginSummary.accountValue field is 0 — Hype correctly calculates total portfolio as spotTotalValue + perpUnrealizedPnL to avoid double-counting.
+
+
+
+ +
+

Trades

+
+
+ 📋 +

Fill History

+
+

Complete fill history split into Perp and Spot sub-tabs. Shows 100 rows per view, sorted latest-first.

+
    +
  • Coin filter — type any coin name to filter fills by asset.
  • +
  • Realized PnL — shown per fill. Positive = profit, negative = loss.
  • +
  • Win Rate — calculated from all visible fills. Shown in the stats strip at the top.
  • +
  • Fees — total fees paid across visible fills.
  • +
  • BUY / SELL labels — colored green for buys, red for sells.
  • +
+
+
+ +
+

Funding

+
+
+ 💸 +

Funding Payments

+
+

Funding paid or received over 7 / 30 / 90 day windows. Funding is the recurring payment between long and short holders to keep perpetual prices anchored to spot.

+
    +
  • Positive funding — market is bullish (longs pay shorts). If you're long, you pay; if short, you receive.
  • +
  • Negative funding — market is bearish (shorts pay longs). If you're short, you pay; if long, you receive.
  • +
  • Daily bar chart — net funding per day. Red bars = net cost, green = net income.
  • +
  • Cost-alert pills — positions with high daily funding cost are flagged.
  • +
  • Avg Rate column — annualised average funding rate per coin.
  • +
+
+ 📌 +
Funding is charged every 1 hour on Hyperliquid. A position held for 30 days pays funding 720 times. The Funding tab shows the cumulative cost so you can see if a trade is being slowly bled by funding.
+
+
+
+ +
+

Flows

+
+
+ 🏦 +

Deposit & Withdrawal History

+
+

Full ledger of all deposits and withdrawals. Each transaction shows the exact historical USD/IDR exchange rate at that date, fetched from frankfurter.app.

+
    +
  • Running balance — cumulative net flow column shows how much capital you've put in vs withdrawn.
  • +
  • IDR value — each row shows the IDR amount at the transaction date. Useful for Indonesian tax reporting.
  • +
  • Cumulative chart — area chart showing capital flow over time.
  • +
+
+ 💡 +
Historical rates are fetched once per session and cached. All unique dates are fetched in parallel, so loading is fast even for large transaction histories.
+
+
+
+ +
+

Live Monitor

+
+
+ +

Real-time WebSocket Feed

+ WebSocket +
+

Real-time price monitor powered by the Hyperliquid WebSocket API. Prices update on every tick — no polling.

+
    +
  • Live P&L — if you have open positions, current unrealized PnL updates in real-time.
  • +
  • Price sparklines — mini charts update as prices move.
  • +
  • Price Alerts — set above/below alerts for any coin. Triggers a browser notification and optionally Telegram when the condition is hit.
  • +
  • API Proxy settings — configure your Cloudflare Worker URL here.
  • +
  • Telegram settings — paste your bot token and chat ID to receive alerts on your phone.
  • +
+
+
+ +
+

Markets

+
+
+ 🌐 +

Global Market Overview

+ Live data +
+

Overview of all Hyperliquid perpetual markets. Sortable by volume, open interest, funding rate, or 24h change.

+
    +
  • Volume — 24h trading volume in USD.
  • +
  • OI — total open interest in USD. High OI relative to volume can indicate a crowded trade.
  • +
  • Funding rate — current hourly funding rate (annualised). Extreme rates suggest crowding.
  • +
  • 24h % — price change from previous day.
  • +
+
+
+ +
+

Phases

+
+
+ 🔵 +

Wyckoff Market Phase Detector

+
+

Automatically classifies each tracked coin into a Wyckoff market phase using candle data across the selected timeframe (1h / 4h / 1d).

+ + + + + + + + + +
PhaseMeaningSignal
🔵 AccumulationSmart money quietly buying. Price ranging. Volume low.Potential long setup
🚀 MarkupPrice trending up. Higher highs and higher lows. Volume expanding.Ride the trend
🟡 DistributionSmart money quietly selling. Price ranging at highs. Volume irregular.Reduce / avoid longs
🔻 MarkdownPrice trending down. Lower highs and lower lows.Avoid longs, look for shorts
⚪ NeutralNo clear phase. Insufficient signal strength.Wait for clarity
+

Below the phase cards, the CVD + OI Signal Table gives a quick buy/sell/neutral read per coin based on Cumulative Volume Delta and Open Interest momentum.

+

Expand any CVD card to see sparkline charts for Price, CVD, and OI history.

+
+
+ +
+

Intel

+
+
+ 🧠 +

Research Snapshot

+ Static snapshot +
+

A curated macro + on-chain research snapshot sourced from cryptowatch.id. Updated manually when new research is published.

+
    +
  • Macro posture — overall market stance: BUY / WAIT / SELL with confidence score.
  • +
  • Regime radar — spider chart showing Macro, Cycle, OnChain, Derivatives, Funding, ETF, and Sentiment axes (0–10).
  • +
  • Evidence layers — breakdown of supporting evidence per analysis layer.
  • +
  • Cohort behavior — what LTH, ETF/TradFi, and Smart Money are doing.
  • +
  • Coin plays — specific setups and narrative bets from the research.
  • +
+
+ ⚠️ +
Intel is a static snapshot — it does not update automatically. Check the snapshot_date at the top of the page and treat stale data accordingly.
+
+
+
+ +
+

MVRV

+
+
+ 📊 +

MVRV Z-Score & Market Cycle

+
+

On-chain MVRV Z-Score — a market cycle indicator that compares Bitcoin's market value to its realised value, normalised by standard deviation.

+ + + + + + + + + +
Z-Score RangeZoneMeaning
> 7Extreme TopHistorically near cycle tops. Consider reducing exposure.
3 – 7ElevatedBull market. Risk increasing. Take partial profits.
0 – 3Fair ValueNeutral zone. Market healthy.
-2 – 0UndervaluedHistorically near cycle lows. Accumulation zone.
< -2Extreme BottomDeep value. Strong historical buy signal.
+

The MVRV tab also includes AI-generated market commentary that synthesises MVRV, Fear & Greed, BTC dominance, and current phase signals into a plain-English read of the macro environment.

+
+
+ +
+

AI

+
+
+ 🤖 +

AI Trade Analysis

+
+

AI-assisted trade analysis that combines your current positions, TA signals, phase data, and market context into actionable commentary. Uses your configured AI provider (set in settings).

+
    +
  • Portfolio debrief — review of your open positions with risk comments.
  • +
  • Market regime — plain-English summary of current market conditions.
  • +
  • Trade ideas — suggestions based on phase + signal confluence.
  • +
+
+
+ +
+

Watchlist

+
+
+ 👁 +

Wallet Monitor

+ WebSocket +
+

Monitor any Hyperliquid wallet address. Useful for tracking known traders or your own secondary accounts.

+
    +
  • Add multiple wallet addresses to track.
  • +
  • Position changes trigger browser notifications.
  • +
  • Shows current open positions for each watched wallet.
  • +
+
+
+ +
+

Journal

+
+
+ 📓 +

Trade Journal

+ localStorage +
+

Personal trade journal stored entirely in your browser's localStorage. Log trade entries with notes, thesis, and outcome tagging.

+
    +
  • Entry — coin, direction, entry price, size, leverage.
  • +
  • Thesis — free-text rationale for the trade.
  • +
  • Outcome — tag trades as WIN / LOSS / BREAKEVEN with exit price.
  • +
  • Notes — post-trade review and lessons learned.
  • +
+
+ ⚠️ +
Journal data lives in localStorage only. It is not synced anywhere. Clear browser data = lose your journal. Export regularly.
+
+
+
+ +
+

Indicators

+
+
+ 📈 +

Macro Indicators

+
+

On-chain and sentiment indicators that provide macro market context.

+ + + + + + + + +
IndicatorSourceWhat it tells you
Fear & GreedAlternative.meCrowd sentiment. Extreme fear = potential buy. Extreme greed = potential sell.
BMSBCalculatedBull/Bear Market Support Band. Price above BMSB = bull structure. Below = bear.
Pi Cycle TopCalculatedWhen the 111d MA crosses above 2× the 350d MA, historically marks cycle tops.
MVRV Z-ScoreOn-chainSee MVRV tab.
+
+
+ +
+

Smart Money

+
+
+ 🐋 +

Whale Wallet Signals

+
+

Aggregated signal feed from a curated list of top Hyperliquid traders. Shows what the best-performing wallets are positioned in right now.

+
    +
  • Open positions of tracked whale wallets.
  • +
  • Recent trades from each wallet.
  • +
  • Consensus signal — when multiple whales are on the same side of a trade, it carries more weight.
  • +
+
+
+ +
+

Analytics

+
+
+ 🔬 +

PnL Analytics

+
+

Deep-dive into your trading performance metrics derived from your fill history.

+
    +
  • Equity curve — cumulative PnL over time.
  • +
  • Win/Loss streaks — longest winning and losing runs.
  • +
  • Fee breakdown — total fees by coin and over time.
  • +
  • Average win vs average loss — risk/reward ratio across your trades.
  • +
  • Best/worst coins — which assets you perform best on.
  • +
+
+
+ +
+

KB (Knowledge Base)

+
+
+ 📚 +

Personal Knowledge Base

+ localStorage +
+

A personal wiki for trading notes, playbooks, and research. Stored in localStorage. Write notes in Markdown — they render with formatting.

+
    +
  • Create, edit, and delete articles.
  • +
  • Link articles to specific trades in your journal.
  • +
  • Tag entries for easy filtering.
  • +
+
+
+ +
+ + +
+
How to Read
+

TA Signals

+

The TA signal dashboard (bottom of the Live tab, and accessible in Phases) runs a full technical analysis on up to 200 candles of the selected coin and timeframe. Each signal produces one of these badges:

+ +
+ BULL + BEAR + WARN + NEUTRAL + INFO +
+ + + + + + + + + + + + + + + +
SignalIndicatorHow it's calculated
EMA BiasEMA 20 / 50 / 200Price above EMA20 > EMA50 = bull. Below = bear. EMA200 cross = strong signal.
MACDMACD(12,26,9)Histogram positive and rising = bull. Negative and falling = bear.
RSI (14)RSI 14-period>60 = bull momentum. <40 = bear momentum. >70 or <30 = overbought/oversold.
StochasticStoch(14,3,3)%K crosses above %D in oversold zone = buy signal. Below in overbought = sell.
Bollinger BandsBB(20, 2σ)Price near lower band = oversold. Near upper band = overbought. Squeeze = breakout incoming.
ATR VolatilityATR(14)High ATR relative to history = volatile. Low = quiet. Used for stop-loss sizing.
Funding RateHL APIPositive & high = longs crowded. Negative = shorts crowded. Extremes often mean-revert.
Open InterestHL APIRising OI + rising price = trend confirmed. Rising OI + falling price = potential squeeze.
L/S RatioBinance FuturesRatio > 1.5 = longs crowded. < 0.7 = shorts crowded. See Smart Money Flow section.
CVD + OICalculatedSee CVD + OI section below.
+ +

The Overall Signal at the top of the card is a weighted composite of all individual signals. It considers the direction and strength of each signal, with more weight given to higher-timeframe signals.

+
+ +
+

Wyckoff Phases

+

The Wyckoff method describes market cycles as four recurring phases. Hype's phase detector analyses price action, volume, and trend structure to classify each coin.

+ +
+
+
🔵Accumulation
+

Price is ranging in a base after a downtrend. Volume is low and declining. Smart money is absorbing supply from panic sellers. Best time to build a long position.

+
+
+
🚀Markup
+

Price is in a clear uptrend — higher highs, higher lows. Volume is expanding on up-moves. Ride the trend; tighten stops as it extends.

+
+
+
🟡Distribution
+

Price is ranging at highs after a markup. Volume is high and irregular. Smart money is distributing to late buyers. Reduce exposure; avoid new longs.

+
+
+
🔻Markdown
+

Price is in a clear downtrend — lower highs, lower lows. Volume expands on down-moves. Avoid longs; short if confident.

+
+
+ +
+ 📌 +
Confidence score — each phase card shows a percentage confidence. Below 40% means the signal is weak. Wait for confluence with other timeframes before acting.
+
+
+ +
+

CVD + OI

+

Cumulative Volume Delta (CVD) tracks the net buying or selling pressure over time by summing the difference between buy-side and sell-side volume on each candle.

+ + + + + + + + + + + +
CombinationInterpretation
Price ↑ + CVD ↑ + OI ↑Strong bull — real buyers driving the move, open interest confirms conviction.
Price ↑ + CVD ↓Suspicious pump — price rising but sellers dominating. Could be short liquidations, not real buying.
Price ↓ + CVD ↓ + OI ↑Strong bear — real sellers driving the move with building short interest.
Price ↓ + CVD ↑Absorption — price falling but buyers absorbing. Possible accumulation / reversal setup.
Price flat + OI ↑Coiling — positions building with no price move yet. Potential breakout either direction.
Price ↑ + OI ↓Short covering — shorts closing, not new longs opening. Less reliable for continuation.
+ +

In the Phases tab, expand any CVD card to see the Price, CVD, and OI sparklines. CVD is shown with a zero baseline — the colored fill shows whether net flow is positive (green = net buying) or negative (red = net selling) over the visible period.

+
+ +
+

Smart Money Flow

+

The Smart Money Flow card synthesises three signals from Binance Futures data to detect institutional positioning:

+ + + + + + + + + + + + +
SignalWhat it means
SMART ACCUMTop-20% traders net long + buy taker aggression. High-quality long setup.
SMART DISTTop-20% traders net short + sell taker aggression. Distribution signal.
SQUEEZE ↑Retail heavily short, smart money long — short squeeze risk is elevated.
SQUEEZE ↓Retail overleveraged long, smart money short — long squeeze risk.
FAKE PUMPRetail buyers aggressive, top traders short — potential bull trap.
CROWDED LONGRetail overleveraged long without smart money confirmation. Risky.
CROWDED SHORTRetail heavily short — watch for squeeze if a catalyst appears.
+
+ +
+ + +
+
Features
+

IDR Conversion

+

All monetary values throughout the dashboard can be displayed in Indonesian Rupiah (IDR). Switch currency using the currency toggle in the topbar.

+
    +
  • Live rate — current USD/IDR from the ECB/frankfurter.app.
  • +
  • Historical rates — in the Flows tab, each deposit/withdrawal shows the IDR value at the exact transaction date. Rates are fetched for all unique dates in parallel, cached for the session.
  • +
+
+ 💡 +
The historical rate is useful for Indonesian tax reporting — you can see exactly how much IDR you put in and took out at each point in time.
+
+
+ +
+

Cloudflare Worker Proxy

+

Hyperliquid's API uses POST requests, which cannot be cached by browsers or CDNs. If you're on a slow connection, you can deploy a Cloudflare Worker as a caching proxy.

+ +

Deploy in 3 steps

+
    +
  1. Go to dash.cloudflare.com → Workers → Create Worker.
  2. +
  3. Paste the contents of worker.js from this repository.
  4. +
  5. Deploy. Copy your Worker URL (e.g. https://hype.yourname.workers.dev).
  6. +
+

Then in the dashboard: Live tab → API Proxy → paste URL → Save → reload page.

+ + + + + + + + + + + + +
Request typeCache TTL
Candle snapshots5 minutes
Meta + asset contexts2 minutes
Spot metadata10 minutes
Account state / positions30 seconds
Open orders20 seconds
Fill / funding history60 seconds
All mid prices5 seconds
+ +
+ ☁️ +
Cloudflare has 300+ edge locations worldwide. Your requests are served from the nearest data centre — typically reducing API round-trip time by 3–5× for users in Indonesia.
+
+
+ +
+

PWA / Install

+

Hype is a Progressive Web App. You can install it to your home screen on iOS, Android, and desktop — it behaves like a native app.

+
    +
  • iOS — Safari → Share → Add to Home Screen.
  • +
  • Android — Chrome → ⋮ menu → Install App.
  • +
  • Desktop — Chrome address bar → install icon on the right side.
  • +
+

Once installed, the static assets (JS, CSS, fonts) are cached by the service worker. The app loads instantly even on a slow connection — only API data requires network access.

+
+ +
+

Alerts & Telegram Notifications

+

Price Alerts

+

Set price alerts from the Live tab. Enter a coin, select above/below, enter a price target, and click Set Alert. Alerts trigger browser notifications and optionally Telegram messages when the condition is hit.

+ +

Telegram Setup

+
    +
  1. Create a Telegram bot via @BotFather — send /newbot, follow prompts, copy the token.
  2. +
  3. Send any message to your new bot.
  4. +
  5. In Hype: Live tab → Telegram Notifications → paste Bot Token → click Auto-detect Chat ID → Save.
  6. +
  7. Click Send Test to confirm it works.
  8. +
+
+ 🔒 +
Your bot token is stored in localStorage only. It is never sent anywhere except directly to the Telegram API from your browser.
+
+ +

What triggers notifications

+
    +
  • Price alert conditions (above/below target).
  • +
  • P&L milestone — configurable dollar threshold (e.g. alert every $500 gain/loss).
  • +
  • New order fills detected via polling.
  • +
+
+ +
+ + +
+
Reference
+

FAQ

+ +
+ +
+

Why does my Total Portfolio show $0?

+

You likely have a Hyperliquid unified account. In this account type, your USDC lives in the spot wallet and is used as perp collateral — crossMarginSummary.accountValue is 0 by design. Hype detects this and calculates total portfolio as spot USDC balance + unrealized perp PnL.

+
+ +
+

Why is the Phases tab slow to load?

+

Phases fetches candle data for each tracked coin individually — by default BTC, ETH, SOL, and HYPE. Each coin requires a separate API call. Without a Cloudflare Worker proxy, these calls go to Hyperliquid's servers which may be geographically distant. Deploying the CF Worker proxy caches candle data for 5 minutes and typically makes phase loading 3–5× faster.

+
+ +
+

Is my data private?

+

Yes. Hype sends your wallet address directly to the Hyperliquid public API. All wallet addresses on Hyperliquid are public — the same data is visible to anyone who visits the Hyperliquid explorer. Journal and KB data lives in your browser's localStorage and is never transmitted anywhere.

+
+ +
+

The OI sparklines show "No OI history yet"

+

OI history comes from two sources: Binance's openInterestHist endpoint (fetched each time you load Phases), and a localStorage fallback that accumulates one point per load. If a coin isn't listed on Binance Futures (common for newer coins), only the localStorage history will be available — it builds up over multiple sessions.

+
+ +
+

Can I track someone else's wallet?

+

Yes — all Hyperliquid data is public. Enter any 0x… address in the wallet input at the top-right. The Watchlist tab is designed for ongoing monitoring of specific addresses with position-change alerts.

+
+ +
+

How do I add more coins to the Phases tab?

+

The tracked coins are defined in const PHASE_COINS in app.js. Edit this array to add or remove coins. Note that each additional coin adds one more API call on every Phases load.

+
+ +
+

My Journal / KB data disappeared

+

Journal and KB are stored in localStorage. Clearing browser data, using incognito mode, or clearing site data in DevTools will erase it. There is no backup. Export your data regularly using the export button in the Journal tab.

+
+ +
+
+ + + + +
+
+ + + + + + + + + + + diff --git a/frontend/css/styles.css b/frontend/css/styles.css index d0bea31..ad7926c 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1,165 +1,1383 @@ :root { - --bg: #0d0d0f; - --surface: #16181c; - --surface2: #1e2128; - --border: #2a2d36; - --text: #e8eaf0; - --text-muted: #6b7280; - --accent: #7c6aff; - --accent2: #00d4aa; - --green: #22c55e; - --red: #ef4444; - --yellow: #f59e0b; - --blue: #3b82f6; - --orange: #f97316; - --font: 'Inter', system-ui, sans-serif; - --mono: 'JetBrains Mono', 'Fira Code', monospace; -} - -* { box-sizing: border-box; margin: 0; padding: 0; } + --bg: #0a0a0a; + --surface: #111111; + --surface2: #1a1a1a; + --surface-hover: #1e1e1e; + --border: #242424; + --border-strong: #383838; + --text: #e2e2e2; + --text-muted: #6b7280; + --text-faint: #4b5563; + --accent: #38bdf8; + --accent-bg: #38bdf8; + --accent-text: #0a0a0a; + --accent-subtle: rgba(56,189,248,0.10); + --green: #4ade80; + --green-bg: rgba(74,222,128,0.08); + --red: #f87171; + --red-bg: rgba(248,113,113,0.08); + --yellow: #fbbf24; + --yellow-bg: rgba(251,191,36,0.08); + --blue: #38bdf8; + --blue-bg: rgba(56,189,248,0.10); + --orange: #fb923c; + --phase-accum: #38bdf8; + --phase-markup: #4ade80; + --phase-dist: #fbbf24; + --phase-down: #f87171; + --phase-neutral: #6b7280; + --font: 'Inter', system-ui, sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', monospace; + --shadow-sm: 0 1px 4px rgba(0,0,0,0.50); + --shadow-md: 0 4px 16px rgba(0,0,0,0.60); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.70); + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-pill: 100px; + --topbar-h: 48px; + --sidebar-w: 48px; + --bottom-nav-h: 62px; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } html, body { height: 100%; } -body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; } - -/* Layout */ -.layout { display: grid; grid-template-columns: 220px 1fr; grid-template-rows: 56px 1fr; height: 100vh; overflow: hidden; } -.topbar { grid-column: 1/-1; background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 16px; padding: 0 20px; } -.sidebar { background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; overflow-y: auto; padding: 12px 0; } -.main { overflow-y: auto; padding: 20px; } - -/* Topbar */ -.logo { font-weight: 700; font-size: 16px; color: var(--accent); letter-spacing: -0.5px; } -.wallet-badge { background: var(--surface2); border: 1px solid var(--border); border-radius: 6px; padding: 4px 10px; font-family: var(--mono); font-size: 11px; color: var(--text-muted); } -.topbar-right { margin-left: auto; display: flex; align-items: center; gap: 12px; } -.notif-btn { position: relative; background: none; border: none; color: var(--text-muted); cursor: pointer; padding: 6px; border-radius: 6px; transition: background 0.15s; } -.notif-btn:hover { background: var(--surface2); } -.notif-badge { position: absolute; top: 2px; right: 2px; background: var(--red); color: #fff; font-size: 9px; width: 16px; height: 16px; border-radius: 50%; display: flex; align-items: center; justify-content: center; display: none; } -.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 6px var(--green); } -.status-dot.off { background: var(--red); box-shadow: 0 0 6px var(--red); } - -/* Sidebar */ -.nav-section { padding: 8px 12px 4px; font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; color: var(--text-muted); } -.nav-item { display: flex; align-items: center; gap: 10px; padding: 9px 16px; cursor: pointer; border-radius: 0; color: var(--text-muted); transition: all 0.15s; text-decoration: none; font-size: 13px; border-left: 3px solid transparent; } -.nav-item:hover { background: var(--surface2); color: var(--text); } -.nav-item.active { background: rgba(124, 106, 255, 0.1); color: var(--accent); border-left-color: var(--accent); } -.nav-item .icon { width: 16px; text-align: center; } - -/* Pages */ -.page { display: none; } +body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased; overflow: hidden; } + +/* ── Topbar ─────────────────────────────────────────────────────────────────── */ + +.topbar { + position: fixed; top: 0; left: 0; right: 0; z-index: 60; + height: var(--topbar-h); + background: var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow-sm); + display: flex; align-items: center; +} + +.topbar-logo { + width: var(--sidebar-w); height: 100%; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 15px; + color: var(--accent); + border-right: 1px solid var(--border); + flex-shrink: 0; cursor: default; user-select: none; +} + +.topbar-nav { + display: flex; align-items: center; height: 100%; + overflow-x: auto; flex: 1; padding-left: 4px; + scrollbar-width: none; +} +.topbar-nav::-webkit-scrollbar { display: none; } + +.topbar-tab { + display: flex; align-items: center; height: 100%; + padding: 0 12px; font-size: 13px; font-weight: 500; + color: var(--text-muted); cursor: pointer; + border: none; background: none; white-space: nowrap; + border-bottom: 2px solid transparent; + transition: color 0.12s, border-color 0.12s; + font-family: var(--font); +} +.topbar-tab:hover { color: var(--text); } +.topbar-tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +.topbar-right { display: flex; align-items: center; gap: 8px; padding-right: 12px; flex-shrink: 0; } + +.logo { display: none; } + +.wallet-badge { + background: var(--surface2); border: 1px solid var(--border); + border-radius: var(--radius-pill); padding: 3px 9px; + font-family: var(--mono); font-size: 10px; color: var(--text-muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; +} +.refresh-info { font-size: 10px; color: var(--text-muted); white-space: nowrap; } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 5px var(--green); flex-shrink: 0; } +.status-dot.off { background: var(--red); box-shadow: 0 0 5px var(--red); } + +/* ── Sidebar icon rail ───────────────────────────────────────────────────────── */ + +.sidebar { + position: fixed; top: var(--topbar-h); left: 0; bottom: 0; + width: var(--sidebar-w); z-index: 40; + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; align-items: center; + padding: 8px 0; overflow: hidden; +} + +.nav-section { display: none; } + +.nav-item { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-md); cursor: pointer; + margin: 2px 8px; color: var(--text-muted); + background: none; border: none; + transition: background 0.12s, color 0.12s; + font-size: 14px; text-decoration: none; + user-select: none; flex-shrink: 0; +} +.nav-item:hover { background: var(--surface2); color: var(--text); } +.nav-item.active { background: var(--accent-subtle); color: var(--accent); } +.nav-item .icon { width: auto; font-size: 14px; } + +/* ── Main content ────────────────────────────────────────────────────────────── */ + +.main { + position: fixed; top: var(--topbar-h); left: var(--sidebar-w); + right: 0; bottom: 0; + overflow-y: auto; -webkit-overflow-scrolling: touch; + background: var(--bg); +} + +/* ── Pages ───────────────────────────────────────────────────────────────────── */ + +.page { display: none; animation: fadein 0.15s ease; } .page.active { display: block; } +@keyframes fadein { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } } +@keyframes refresh-flash { 0%,100% { color: var(--text-muted); } 50% { color: var(--green); } } +.refresh-flash { animation: refresh-flash 0.5s ease; } + +/* ── Stat strip ──────────────────────────────────────────────────────────────── */ + +.stat-strip { display: flex; align-items: stretch; border-bottom: 1px solid var(--border); background: var(--surface); } +.stat-cell { flex: 1; padding: 13px 16px; border-right: 1px solid var(--border); min-width: 100px; } +.stat-cell:last-child { border-right: none; } +.s-label { font-size: 10px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 4px; } +.s-value { font-family: var(--mono); font-size: 17px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; line-height: 1.2; } +.s-sub { font-size: 10px; color: var(--text-muted); margin-top: 2px; } + +/* ── Legacy stat cards ───────────────────────────────────────────────────────── */ + +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 13px 15px; margin-bottom: 12px; } +.card-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); margin-bottom: 10px; } +.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; margin-bottom: 12px; } +.grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 12px; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } +.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 13px; } +.stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } +.stat-value { font-size: 18px; font-weight: 700; font-family: var(--mono); color: var(--text); } +.stat-sub { font-size: 10px; color: var(--text-muted); margin-top: 3px; } + +/* ── Filter chip bar ─────────────────────────────────────────────────────────── */ -/* Cards */ -.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px 20px; } -.card-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); margin-bottom: 12px; } -.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; } -.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; } -.grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-bottom: 20px; } - -/* Stat cards */ -.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px; } -.stat-label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; } -.stat-value { font-size: 22px; font-weight: 700; font-family: var(--mono); } -.stat-sub { font-size: 11px; color: var(--text-muted); margin-top: 4px; } - -/* Tables */ -.table-wrap { overflow-x: auto; } -table { width: 100%; border-collapse: collapse; font-size: 13px; } -th { text-align: left; padding: 8px 12px; color: var(--text-muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); } -td { padding: 10px 12px; border-bottom: 1px solid rgba(42,45,54,0.5); font-family: var(--mono); } +.filter-bar { + display: flex; align-items: center; gap: 6px; + padding: 9px 14px; border-bottom: 1px solid var(--border); + background: var(--surface); overflow-x: auto; scrollbar-width: none; flex-wrap: nowrap; +} +.filter-bar::-webkit-scrollbar { display: none; } +.filter-sep { width: 1px; height: 14px; background: var(--border); margin: 0 2px; flex-shrink: 0; } + +/* ── Tables ──────────────────────────────────────────────────────────────────── */ + +.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--surface); } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +th { text-align: left; padding: 8px 10px; color: var(--text-muted); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); white-space: nowrap; background: var(--surface); } +th.num { text-align: right; } +td { padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text); font-size: 12px; white-space: nowrap; } +td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; } tr:last-child td { border-bottom: none; } -tr:hover td { background: rgba(255,255,255,0.02); } +tr:hover td { background: var(--surface-hover); } +.coin-cell { font-weight: 600; } + +/* ── Chips ───────────────────────────────────────────────────────────────────── */ + +.chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 10px; border-radius: var(--radius-pill); + font-size: 12px; font-weight: 500; + border: 1px solid var(--border); background: var(--surface2); + color: var(--text-muted); cursor: pointer; white-space: nowrap; + transition: all 0.12s; touch-action: manipulation; font-family: var(--font); +} +.chip:hover { border-color: var(--border-strong); color: var(--text); } +.chip.active { background: var(--accent-subtle); color: var(--accent); border-color: var(--accent); } + +.narrative-row { display: flex; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--surface); overflow-x: auto; scrollbar-width: none; flex-wrap: nowrap; } +.narrative-row::-webkit-scrollbar { display: none; } + +/* ── Colors ──────────────────────────────────────────────────────────────────── */ -/* Colors */ .pos { color: var(--green); } .neg { color: var(--red); } .muted { color: var(--text-muted); } .accent { color: var(--accent); } +.flow-in { color: var(--green); } +.flow-out { color: var(--red); } -/* Side badges */ -.side-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; } -.side-badge.long { background: rgba(34, 197, 94, 0.15); color: var(--green); } -.side-badge.short { background: rgba(239, 68, 68, 0.15); color: var(--red); } - -/* Phase badges */ -.phase-badge { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; } -.phase-ACCUMULATION { background: rgba(59,130,246,0.15); color: var(--blue); } -.phase-MARKUP { background: rgba(34,197,94,0.15); color: var(--green); } -.phase-DISTRIBUTION { background: rgba(245,158,11,0.15); color: var(--yellow); } -.phase-MARKDOWN { background: rgba(239,68,68,0.15); color: var(--red); } -.phase-NEUTRAL { background: rgba(107,114,128,0.15); color: var(--text-muted); } - -/* Buttons */ -.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; border-radius: 7px; border: none; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.15s; } -.btn-primary { background: var(--accent); color: #fff; } -.btn-primary:hover { background: #6a58ff; } -.btn-ghost { background: var(--surface2); color: var(--text); border: 1px solid var(--border); } -.btn-ghost:hover { border-color: var(--accent); color: var(--accent); } -.btn-danger { background: rgba(239,68,68,0.15); color: var(--red); border: 1px solid rgba(239,68,68,0.3); } -.btn-danger:hover { background: rgba(239,68,68,0.25); } -.btn-sm { padding: 4px 10px; font-size: 12px; } - -/* Inputs */ -.input { background: var(--surface2); border: 1px solid var(--border); border-radius: 7px; padding: 8px 12px; color: var(--text); font-size: 13px; width: 100%; outline: none; transition: border 0.15s; } -.input:focus { border-color: var(--accent); } -.input-group { display: flex; gap: 8px; } +/* ── Badges ──────────────────────────────────────────────────────────────────── */ + +.side-badge { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 600; } +.side-badge.long { background: var(--green-bg); color: var(--green); } +.side-badge.short { background: var(--red-bg); color: var(--red); } +.side-badge.inflow { background: var(--green-bg); color: var(--green); } +.side-badge.outflow { background: var(--red-bg); color: var(--red); } + +.health-badge { display:inline-flex; align-items:center; gap:4px; padding:2px 7px; border-radius:var(--radius-pill); font-size:10px; font-weight:600; border:1px solid transparent; } +.health-score { font-family:var(--mono); font-size:11px; } +.health-ok { background:var(--green-bg); color:var(--green); border-color:rgba(74,222,128,0.2); } +.health-caution { background:var(--yellow-bg); color:var(--yellow); border-color:rgba(251,191,36,0.2); } +.health-risky { background:var(--red-bg); color:var(--red); border-color:rgba(248,113,113,0.2); } +.risk-summary { display:flex; align-items:flex-start; gap:16px; padding:12px 16px; background:var(--surface); border-bottom:1px solid var(--border); border-radius:var(--radius); margin-bottom:10px; } +.risk-score-big { font-family:var(--mono); font-size:28px; font-weight:800; } +.risk-chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; } +.health-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 8px; border-radius:var(--radius-pill); font-size:11px; } +.risk-flag-row { font-size:11px; color:var(--yellow); padding:3px 8px; background:var(--yellow-bg); border-radius:var(--radius-sm); margin-top:6px; } + +.health-modal-overlay { position:fixed; inset:0; z-index:200; background:rgba(0,0,0,0.72); display:flex; align-items:center; justify-content:center; } +.health-modal-box { background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-lg); padding:20px; min-width:300px; max-width:430px; width:90%; box-shadow:var(--shadow-lg); max-height:90vh; overflow-y:auto; } +.health-modal-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; } +.hm-close { background:none; border:none; color:var(--text-muted); font-size:20px; cursor:pointer; line-height:1; padding:0 4px; } +.hm-close:hover { color:var(--text); } +.health-modal-score-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; padding-bottom:14px; border-bottom:1px solid var(--border); } +.health-modal-big-score { font-family:var(--mono); font-size:44px; font-weight:800; line-height:1; } +.hm-factor { display:flex; justify-content:space-between; align-items:flex-start; padding:9px 0; border-bottom:1px solid var(--border); } +.hm-factor:last-child { border-bottom:none; } +.hm-factor-left { display:flex; gap:10px; align-items:flex-start; flex:1; min-width:0; } +.hm-icon { width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:700; flex-shrink:0; margin-top:1px; } +.hm-pass { background:var(--green-bg); color:var(--green); } +.hm-fail { background:var(--red-bg); color:var(--red); } +.hm-warn { background:var(--yellow-bg); color:var(--yellow); } +.hm-na { background:var(--surface2); color:var(--text-muted); } +.hm-factor-name { font-size:12px; font-weight:600; margin-bottom:2px; } +.hm-factor-detail { font-size:11px; color:var(--text-muted); } +.hm-ded { font-family:var(--mono); font-size:13px; font-weight:700; white-space:nowrap; padding-left:10px; flex-shrink:0; } +.hm-ded-neg { color:var(--red); } +.hm-ded-pos { color:var(--green); } +.hm-ded-na { color:var(--text-muted); } + +.phase-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: var(--radius-pill); font-size: 11px; font-weight: 600; } +.phase-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; } +.phase-ACCUMULATION { background: var(--blue-bg); color: var(--phase-accum); } +.phase-MARKUP { background: var(--green-bg); color: var(--phase-markup); } +.phase-DISTRIBUTION { background: var(--yellow-bg); color: var(--phase-dist); } +.phase-MARKDOWN { background: var(--red-bg); color: var(--phase-down); } +.phase-NEUTRAL { background: var(--surface2); color: var(--phase-neutral); } + +.funding-pill { display: inline-block; padding: 1px 6px; border-radius: var(--radius-sm); font-size: 10px; font-family: var(--mono); } +.funding-pos { background: var(--green-bg); color: var(--green); } +.funding-neg { background: var(--red-bg); color: var(--red); } +.funding-neu { background: var(--surface2); color: var(--text-muted); } + +.flow-bar { display: flex; height: 5px; border-radius: 3px; overflow: hidden; gap: 1px; } +.flow-bar-in { background: var(--green); border-radius: 3px 0 0 3px; } +.flow-bar-out { background: var(--red); border-radius: 0 3px 3px 0; } + +.change-pill { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 600; font-family: var(--mono); } +.change-pos { background: var(--green-bg); color: var(--green); } +.change-neg { background: var(--red-bg); color: var(--red); } + +/* ── Market cards ────────────────────────────────────────────────────────────── */ + +.market-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; } +.market-card-coin { font-weight: 700; font-size: 14px; color: var(--text); } +.market-card-price { font-family: var(--mono); font-size: 17px; font-weight: 700; color: var(--text); } +.market-card-row { display: flex; justify-content: space-between; align-items: center; } +.market-card-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.market-card-val { font-family: var(--mono); font-size: 12px; color: var(--text); } + +/* ── Regime / play cards (intel) ─────────────────────────────────────────────── */ + +.regime-pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: var(--radius-pill); font-size: 11px; font-weight: 700; letter-spacing: 0.4px; } +.regime-CAUTION { background: var(--yellow-bg); color: var(--yellow); border: 1px solid rgba(251,191,36,0.2); } +.regime-WAIT { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); } +.regime-BUY,.regime-BULL { background: var(--green-bg); color: var(--green); border: 1px solid rgba(74,222,128,0.2); } +.regime-SELL,.regime-BEAR { background: var(--red-bg); color: var(--red); border: 1px solid rgba(248,113,113,0.2); } + +.play-card { padding: 10px 12px; border-radius: var(--radius-md); margin-bottom: 8px; border: 1px solid; } +.play-ENTRY { background: var(--green-bg); border-color: rgba(74,222,128,0.15); } +.play-HOLD { background: var(--blue-bg); border-color: rgba(56,189,248,0.15); } +.play-AVOID { background: var(--red-bg); border-color: rgba(248,113,113,0.15); } +.play-action-badge { display: inline-block; padding: 2px 7px; border-radius: var(--radius-sm); font-size: 10px; font-weight: 700; } +.action-ENTRY { background: var(--green-bg); color: var(--green); } +.action-HOLD { background: var(--blue-bg); color: var(--blue); } +.action-AVOID { background: var(--red-bg); color: var(--red); } + +.intel-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid var(--border); } +.intel-label { font-size: 11px; color: var(--text-muted); } +.intel-val { font-size: 12px; font-family: var(--mono); text-align: right; color: var(--text); } + +/* ── Intel page redesign ────────────────────────────────────────────────────── */ + +.intel-posture-banner { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 14px 16px; margin-bottom: 12px; + display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; +} +.intel-posture-main { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; flex: 1; } +.intel-posture-label { font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 5px; } +.intel-posture-verdict { font-size: 18px !important; font-weight: 800 !important; padding: 5px 16px !important; } +.intel-posture-score-wrap { min-width: 180px; flex-shrink: 0; } +.intel-posture-score-label { font-size: 11px; color: var(--text-muted); margin-bottom: 5px; } +.intel-score-track { position: relative; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; margin-bottom: 4px; } +.intel-score-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; } +.intel-score-mid { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border-strong); } +.intel-posture-conf { font-size: 10px; color: var(--text-muted); font-family: var(--mono); } +.intel-posture-meta { display: flex; flex-direction: column; gap: 4px; } +.intel-meta-label { font-size: 10px; color: var(--text-muted); margin-right: 6px; } +.intel-meta-val { font-size: 11px; font-weight: 500; } +.intel-posture-badges { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } + +.intel-main-grid { display: grid; grid-template-columns: 1fr 300px; gap: 12px; margin-bottom: 12px; } +.intel-col { display: flex; flex-direction: column; gap: 12px; } + +.intel-radar-wrap { display: flex; justify-content: center; padding: 4px 0; } +.intel-radar-wrap canvas { max-width: 220px; max-height: 220px; } + +.intel-zbar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; } +.intel-zbar-fill { height: 100%; border-radius: 2px; } +.intel-zbar-fill.pos-fill { background: var(--green); } +.intel-zbar-fill.neg-fill { background: var(--red); } -/* Progress bar */ -.progress-bar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; } +.intel-bottom-score { display: flex; align-items: baseline; justify-content: center; gap: 4px; padding: 8px 0 4px; } +.intel-radar-score { font-size: 44px; font-weight: 800; font-family: var(--mono); line-height: 1; } +.intel-vote-row { display: flex; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; } +.intel-vote-cell { flex: 1; padding: 8px 6px; text-align: center; border-right: 1px solid var(--border); } +.intel-vote-cell:last-child { border-right: none; } + +.intel-quote { font-size: 12px; color: var(--text-muted); line-height: 1.65; padding: 10px 12px; background: var(--surface2); border-radius: var(--radius-md); border-left: 3px solid var(--accent); margin-bottom: 10px; } +.intel-conviction { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 3px 8px; border-radius: var(--radius-md); } +.conv-pass { background: rgba(74,222,128,0.10); color: var(--green); } +.conv-fail { background: rgba(248,113,113,0.10); color: var(--red); } +.intel-narrative-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--surface2); border-radius: var(--radius-md); flex-wrap: wrap; margin-bottom: 8px; } +.intel-narrative-chip { padding: 4px 10px; border-radius: var(--radius-pill); background: var(--accent-subtle); border: 1px solid rgba(56,189,248,0.2); font-size: 11px; color: var(--accent); } +.intel-avoid-card { padding: 10px 12px; background: var(--red-bg); border: 1px solid rgba(248,113,113,0.15); border-radius: var(--radius-md); margin-bottom: 8px; } +.intel-risk-box { padding: 10px 12px; background: var(--yellow-bg); border: 1px solid rgba(251,191,36,0.2); border-radius: var(--radius-md); margin-top: 10px; } +.intel-desk-stat { flex: 1; min-width: 100px; padding: 8px 10px; background: var(--surface2); border-radius: var(--radius-md); border: 1px solid var(--border); } +.intel-desk-stat-label { font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } +.intel-desk-stat-value { font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 2px; } + +/* ── TA signal dashboard ─────────────────────────────────────────────────────── */ + +.ta-group { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 10px; } +.ta-last { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } +.ta-gtitle { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 8px; } +.ta-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 4px 0; min-height: 32px; } +.ta-row-label { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); min-width: 100px; padding-top: 2px; flex-shrink: 0; } +.ta-sig { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: 1; } +.ta-badge { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: var(--radius-sm); letter-spacing: 0.04em; white-space: nowrap; } +.ta-sub { font-size: 10px; color: var(--text-muted); text-align: right; font-family: var(--mono); } +.ta-bull .ta-badge { background: var(--green-bg); color: var(--green); } +.ta-bear .ta-badge { background: var(--red-bg); color: var(--red); } +.ta-neut .ta-badge { background: var(--surface2); color: var(--text-muted); } +.ta-warn .ta-badge { background: var(--yellow-bg);color: var(--yellow); } +.ta-info .ta-badge { background: var(--blue-bg); color: var(--blue); } + +/* ── TA Recommendation card ─────────────────────────────────────────────────── */ + +.ta-rec-card { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; } +.ta-rec-long { border-color: rgba(74,222,128,0.3); } +.ta-rec-short { border-color: rgba(248,113,113,0.3); } +.ta-rec-neutral { border-color: var(--border); } + +.ta-rec-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; background: var(--surface2); flex-wrap: wrap; } +.ta-rec-dir { font-size: 22px; font-weight: 800; letter-spacing: 0.05em; } +.ta-rec-meta { font-size: 10px; color: var(--text-muted); margin-top: 2px; } + +.ta-rec-score-wrap { text-align: right; min-width: 140px; } +.ta-rec-score-label { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; } +.ta-rec-bar { height: 6px; background: var(--red-bg); border-radius: 3px; overflow: hidden; } +.ta-rec-bar-fill { height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s; } +.ta-rec-score-subs { font-size: 10px; margin-top: 3px; display: flex; gap: 8px; justify-content: flex-end; } + +.ta-setup-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); } +.ta-setup-cell { padding: 8px 10px; background: var(--surface); } +.ta-setup-label { font-size: 10px; color: var(--text-muted); margin-bottom: 3px; } +.ta-setup-val { font-size: 13px; font-weight: 600; font-family: var(--mono); } +.ta-setup-note { font-size: 10px; color: var(--text-muted); margin-top: 2px; } + +.ta-sr-block { padding: 8px 14px; border-top: 1px solid var(--border); background: var(--surface2); } +.ta-sr-row { display: flex; align-items: baseline; gap: 8px; font-size: 11px; padding: 2px 0; } +.ta-sr-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); min-width: 72px; } + +.ta-ck-section { padding: 10px 14px 12px; } +.ta-ck-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 8px; } +.ta-ck-row { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); } +.ta-ck-row:last-child { border-bottom: none; } +.ta-ck-icon { font-size: 14px; flex-shrink: 0; width: 20px; text-align: center; padding-top: 1px; } +.ta-ck-body { flex: 1; min-width: 0; } +.ta-ck-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 2px; } +.ta-ck-name { font-size: 12px; color: var(--text); } +.ta-sig-badge { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: var(--radius-sm); letter-spacing: 0.04em; white-space: nowrap; flex-shrink: 0; } +.ta-bull .ta-sig-badge, .ta-ck-bull .ta-sig-badge { background: var(--green-bg); color: var(--green); } +.ta-bear .ta-sig-badge, .ta-ck-bear .ta-sig-badge { background: var(--red-bg); color: var(--red); } +.ta-neut .ta-sig-badge, .ta-ck-neut .ta-sig-badge { background: var(--surface2); color: var(--text-muted); } +.ta-warn .ta-sig-badge, .ta-ck-warn .ta-sig-badge { background: var(--yellow-bg);color: var(--yellow); } +.ta-info .ta-sig-badge, .ta-ck-info .ta-sig-badge { background: var(--blue-bg); color: var(--blue); } +.ta-ck-sub { font-size: 10px; color: var(--text-muted); line-height: 1.4; } +.ta-ck-exp { color: var(--text-dim, var(--text-muted)); font-style: italic; } + +.ta-conflict-banner { background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.4); border-radius: var(--radius-md); padding: 8px 12px; font-size: 12px; color: var(--red); margin-bottom: 10px; } +.ta-aligned-banner { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.4); border-radius: var(--radius-md); padding: 8px 12px; font-size: 12px; color: var(--green); margin-bottom: 10px; } + +@media (max-width: 480px) { + .ta-setup-grid { grid-template-columns: repeat(2, 1fr); } +} + +/* ── CVD + OI scanner table ──────────────────────────────────────────────────── */ + +.cvd-table { width: 100%; } +.cvd-head { + display: grid; + grid-template-columns: 56px 58px 70px 70px 1fr; + gap: 4px; padding: 4px 0 6px; + border-bottom: 1px solid var(--border); + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--text-muted); +} +.cvd-row { + display: grid; + grid-template-columns: 56px 58px 70px 70px 1fr; + gap: 4px; padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; align-items: center; +} +.cvd-row:last-child { border-bottom: none; } +.cvd-pos-row { background: rgba(125,130,255,0.04); border-radius: 4px; } +.cvd-coin { font-weight: 600; display: flex; align-items: center; gap: 4px; } +.cvd-dot { color: var(--accent); font-size: 8px; } +.cvd-legend { font-size: 10px; color: var(--text-muted); padding-top: 8px; } + +.cvd-charts-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + margin-top: 14px; +} +.cvd-chart-card { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} +.cvd-chart-pos { border-color: rgba(124,106,255,0.35); } +.cvd-chart-hdr { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 12px; gap: 8px; +} +.cvd-chart-hdr.cvd-chart-toggle { cursor: pointer; user-select: none; } +.cvd-chart-hdr.cvd-chart-toggle:hover { background: rgba(255,255,255,0.03); } +.cvd-chart-left { display: flex; flex-direction: column; gap: 1px; } +.cvd-chart-coin { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 4px; } +.cvd-toggle-icon { font-size: 11px; color: var(--text-muted); flex-shrink: 0; transition: transform 0.15s; } +.cvd-chart-body { padding: 0 10px 10px; border-top: 1px solid var(--border); } +.cvd-panel-label { font-size: 10px; color: var(--text-muted); margin: 7px 0 3px; display: flex; align-items: center; gap: 5px; } +.cvd-panel { position: relative; height: 80px; overflow: hidden; } +.cvd-panel canvas { max-width: 100%; display: block; } +.cvd-analysis { + font-size: 11px; color: var(--text-muted); line-height: 1.5; + margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); +} +.cvd-oi-empty { font-size: 10px; color: var(--text-muted); padding: 18px 0; text-align: center; } +.cvd-track { font-size: 10px; color: var(--text-muted); } + +/* ── Money flow panel ────────────────────────────────────────────────────────── */ + +.mf-dom-strip { + display: flex; gap: 12px; flex-wrap: wrap; align-items: center; + padding: 8px 10px; background: var(--surface2); border-radius: var(--radius-md); + margin-bottom: 10px; +} +.mf-dom-val { font-size: 12px; font-weight: 600; font-family: var(--mono); } + +/* ── Live monitor ────────────────────────────────────────────────────────────── */ + +.mono { font-family: var(--mono); font-size: 12px; } +@keyframes flash-up { 0% { background: rgba(74,222,128,0.20); } 100% { background: transparent; } } +@keyframes flash-dn { 0% { background: rgba(248,113,113,0.20); } 100% { background: transparent; } } +.ticker-flash-up { animation: flash-up 0.4s ease-out; } +.ticker-flash-dn { animation: flash-dn 0.4s ease-out; } +.alert-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: var(--surface2); border-radius: var(--radius-md); margin-bottom: 4px; font-size: 12px; border: 1px solid var(--border); } +.alert-log-row { padding: 5px 8px; border-radius: var(--radius-md); background: var(--surface2); margin-bottom: 3px; line-height: 1.5; font-size: 12px; } + +/* ── Confidence ──────────────────────────────────────────────────────────────── */ + +.conf-inline { display: flex; align-items: center; gap: 6px; justify-content: flex-end; } +.conf-track { width: 48px; height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; flex-shrink: 0; } +.conf-fill { height: 100%; background: var(--accent); border-radius: 2px; } +.conf-label { font-family: var(--mono); font-size: 11px; min-width: 32px; text-align: right; color: var(--text-muted); } +.conf-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.conf-val { font-family: var(--mono); font-size: 12px; min-width: 36px; } +.progress-bar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; } .progress-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; } -/* Flow direction */ -.flow-in { color: var(--green); } -.flow-out { color: var(--red); } +/* ── Phase legend ────────────────────────────────────────────────────────────── */ -/* Notification panel */ -.notif-panel { position: fixed; top: 56px; right: 16px; width: 340px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); z-index: 100; display: none; max-height: 480px; overflow-y: auto; } -.notif-panel.open { display: block; } -.notif-header { padding: 12px 16px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; font-size: 13px; font-weight: 600; } -.notif-item { padding: 12px 16px; border-bottom: 1px solid rgba(42,45,54,0.5); cursor: pointer; } -.notif-item:hover { background: var(--surface2); } -.notif-item.unread { border-left: 3px solid var(--accent); } -.notif-type { font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--accent); margin-bottom: 3px; } -.notif-msg { font-size: 12px; color: var(--text-muted); word-break: break-all; } -.notif-time { font-size: 10px; color: var(--text-muted); margin-top: 4px; } -.notif-empty { padding: 24px; text-align: center; color: var(--text-muted); font-size: 13px; } - -/* Loading */ -.loading { display: flex; align-items: center; justify-content: center; padding: 40px; color: var(--text-muted); gap: 8px; } -.spinner { width: 16px; height: 16px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; } -@keyframes spin { to { transform: rotate(360deg); } } +.phase-legend { display: flex; gap: 8px; flex-wrap: wrap; padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--surface); } + +/* ── Buttons ─────────────────────────────────────────────────────────────────── */ + +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 12px; border-radius: var(--radius-md); border: none; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.12s; touch-action: manipulation; min-height: 32px; font-family: var(--font); } +.btn-primary { background: var(--accent-bg); color: var(--accent-text); font-weight: 600; } +.btn-primary:hover { opacity: 0.87; } +.btn-ghost { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); } +.btn-ghost:hover { border-color: var(--border-strong); color: var(--text); } +.btn-danger { background: var(--red-bg); color: var(--red); border: 1px solid rgba(248,113,113,0.2); } +.btn-danger:hover { background: rgba(248,113,113,0.15); } +.btn-sm { padding: 4px 9px; font-size: 12px; min-height: 28px; } -/* Confidence bar */ -.conf-bar { display: flex; align-items: center; gap: 8px; } -.conf-val { font-family: var(--mono); font-size: 12px; min-width: 36px; } +/* ── Inputs ──────────────────────────────────────────────────────────────────── */ -/* Section header */ -.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } -.section-title { font-size: 16px; font-weight: 600; } +.input { background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 8px 11px; color: var(--text); font-size: 13px; width: 100%; outline: none; transition: border 0.12s; min-height: 36px; font-family: var(--font); } +.input:focus { border-color: var(--accent); } +.input::placeholder { color: var(--text-faint); } +.input-group { display: flex; gap: 8px; flex-wrap: wrap; } +.input-group .input { flex: 1; min-width: 0; } +.add-bar { display: flex; gap: 8px; padding: 10px 14px; background: var(--surface); border-bottom: 1px solid var(--border); align-items: center; flex-wrap: wrap; } + +/* ── Loading / skeleton ──────────────────────────────────────────────────────── */ + +.loading { display: flex; align-items: center; justify-content: center; padding: 40px 16px; color: var(--text-muted); gap: 8px; font-size: 13px; } +.spinner { width: 15px; height: 15px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0; } +@keyframes spin { to { transform: rotate(360deg); } } +.skeleton { background: linear-gradient(90deg, var(--surface2) 25%, var(--border) 50%, var(--surface2) 75%); background-size: 200% 100%; animation: shimmer 1.4s infinite; border-radius: var(--radius-sm); } +@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } +.skeleton-cell { height: 11px; border-radius: var(--radius-sm); } -/* Tabs */ -.tabs { display: flex; gap: 2px; background: var(--surface2); border-radius: 8px; padding: 3px; margin-bottom: 16px; } -.tab { padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; color: var(--text-muted); transition: all 0.15s; border: none; background: none; } -.tab.active { background: var(--surface); color: var(--text); font-weight: 500; } -.tab:hover:not(.active) { color: var(--text); } +/* ── Section headers / tabs ──────────────────────────────────────────────────── */ -/* Telegram settings */ -.settings-row { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; } -.settings-label { font-size: 12px; color: var(--text-muted); } -.tg-status { font-size: 12px; padding: 4px 10px; border-radius: 20px; display: inline-flex; align-items: center; gap: 5px; } -.tg-status.on { background: rgba(34,197,94,0.1); color: var(--green); } -.tg-status.off { background: rgba(239,68,68,0.1); color: var(--red); } +.section-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; flex-wrap: wrap; gap: 8px; background: var(--surface); border-bottom: 1px solid var(--border); } +.section-title { font-size: 14px; font-weight: 600; color: var(--text); } +.tabs { display: flex; gap: 2px; } +.tab { padding: 4px 10px; border-radius: var(--radius-pill); cursor: pointer; font-size: 12px; color: var(--text-muted); border: 1px solid var(--border); background: var(--surface2); transition: all 0.12s; touch-action: manipulation; font-family: var(--font); } +.tab.active { background: var(--accent-subtle); color: var(--accent); border-color: var(--accent); } +.tab:hover:not(.active) { border-color: var(--border-strong); color: var(--text); } +.empty-state { text-align: center; padding: 32px 16px; color: var(--text-muted); font-size: 13px; line-height: 1.6; } -/* Chart container */ -.chart-container { width: 100%; height: 240px; } +/* ── Scrollbar ───────────────────────────────────────────────────────────────── */ -/* Scrollbar */ -::-webkit-scrollbar { width: 5px; height: 5px; } +::-webkit-scrollbar { width: 4px; height: 4px; } ::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 2px; } ::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } +/* ── Bottom nav (hidden — replaced by hamburger drawer) ──────────────────────── */ + +.bottom-nav { display: none !important; } + +/* ── Hamburger button ────────────────────────────────────────────────────────── */ + +.hamburger-btn { + display: none; flex: 1; height: 100%; + align-items: center; gap: 10px; + background: none; border: none; cursor: pointer; padding: 0 14px; + touch-action: manipulation; +} +.hbars { display: flex; flex-direction: column; gap: 5px; width: 22px; flex-shrink: 0; } +.hbars span { display: block; height: 2px; background: var(--text-muted); border-radius: 1px; transition: all 0.22s ease; } +.hamburger-btn.open .hbars span:nth-child(1) { transform: translateY(7px) rotate(45deg); background: var(--accent); } +.hamburger-btn.open .hbars span:nth-child(2) { opacity: 0; transform: scaleX(0); } +.hamburger-btn.open .hbars span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); background: var(--accent); } + +/* ── Nav drawer + overlay ────────────────────────────────────────────────────── */ + +.nav-overlay { + display: none; position: fixed; inset: 0; z-index: 80; + background: rgba(0,0,0,0.55); +} +.nav-overlay.open { display: block; } + +.nav-drawer { + position: fixed; top: 0; left: 0; bottom: 0; width: 270px; z-index: 90; + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; + transform: translateX(-100%); + transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1); + box-shadow: var(--shadow-lg); will-change: transform; +} +.nav-drawer.open { transform: translateX(0); } + +.nav-drawer-header { + display: flex; align-items: center; justify-content: space-between; + padding: 0 16px; height: var(--topbar-h); + border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.nav-drawer-logo { font-weight: 800; font-size: 16px; color: var(--accent); } +.nav-drawer-close { + background: none; border: none; color: var(--text-muted); cursor: pointer; + font-size: 18px; padding: 6px; border-radius: var(--radius-sm); + transition: color 0.12s; touch-action: manipulation; +} +.nav-drawer-close:hover { color: var(--text); } +.nav-drawer-body { flex: 1; overflow-y: auto; padding: 6px 8px; -webkit-overflow-scrolling: touch; } +.nav-drawer-section { + padding: 10px 10px 4px; font-size: 9px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); +} +.drawer-item { + display: flex; align-items: center; gap: 12px; + width: 100%; padding: 11px 12px; + background: none; border: none; cursor: pointer; + color: var(--text-muted); font-size: 14px; font-weight: 500; + text-align: left; font-family: var(--font); + border-radius: var(--radius-md); + transition: background 0.12s, color 0.12s; + touch-action: manipulation; min-height: 44px; +} +.drawer-item:hover { background: var(--surface2); color: var(--text); } +.drawer-item.active { color: var(--accent); background: var(--accent-subtle); } +.drawer-item-icon { font-size: 16px; flex-shrink: 0; width: 22px; text-align: center; } + +/* ── Mobile card-style tables ─────────────────────────────────────────────────── */ + +@media (max-width: 600px) { + .mobile-cards { display: block; width: 100%; } + .mobile-cards thead { display: none; } + .mobile-cards tbody { display: block; } + .mobile-cards tbody tr { + display: block; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius-lg); + margin-bottom: 10px; overflow: hidden; + } + .mobile-cards tbody tr:last-child { margin-bottom: 0; } + .mobile-cards td { + display: flex; justify-content: space-between; align-items: center; + padding: 9px 13px; border-bottom: 1px solid var(--border); + white-space: normal; word-break: break-word; font-size: 13px; gap: 8px; + text-align: right; + } + .mobile-cards td:last-child { border-bottom: none; } + .mobile-cards td::before { + content: attr(data-label); font-size: 10px; font-weight: 700; + color: var(--text-muted); text-transform: uppercase; + letter-spacing: 0.5px; flex-shrink: 0; white-space: nowrap; text-align: left; + } + .table-wrap:has(.mobile-cards) { overflow-x: visible; } +} + +/* ── Tablet (intel 2-col collapses) ─────────────────────────────────────────── */ + @media (max-width: 900px) { - .layout { grid-template-columns: 1fr; } + .intel-main-grid { grid-template-columns: 1fr; } + .intel-posture-main { gap: 12px; } +} + +/* ── Mobile ──────────────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + body { overflow: hidden; font-size: 14px; } .sidebar { display: none; } - .grid-4 { grid-template-columns: 1fr 1fr; } - .grid-2 { grid-template-columns: 1fr; } + .topbar-nav { display: none; } + .hamburger-btn { display: flex; } + .main { left: 0; bottom: 0; } + .refresh-info { display: none !important; } + + .stat-strip { flex-wrap: wrap; } + .stat-cell { min-width: 50%; border-bottom: 1px solid var(--border); } + .grid-4 { grid-template-columns: 1fr 1fr; gap: 8px; } + .grid-3 { grid-template-columns: 1fr 1fr; gap: 8px; } + .grid-2 { grid-template-columns: 1fr; gap: 8px; } + .stat-value { font-size: 16px; } + .stat-card, .card { padding: 11px 12px; } + #phase-cards { grid-template-columns: 1fr !important; } + .input-group { flex-direction: column; } + .input-group .btn { width: 100%; } + .narrative-row { flex-wrap: nowrap; overflow-x: auto; } + .intel-main-grid { grid-template-columns: 1fr; } + .intel-posture-verdict { font-size: 15px !important; } + .intel-radar-score { font-size: 36px; } + .mvrv-grid { grid-template-columns: repeat(2, 1fr); } + .mvrv-legend { grid-template-columns: 1fr; } + + /* Larger touch targets */ + .btn { min-height: 40px; } + .tab { padding: 6px 12px; font-size: 13px; min-height: 36px; } + .chip { padding: 5px 12px; font-size: 13px; min-height: 36px; } + .filter-bar { padding: 10px 14px; gap: 8px; } + + /* Better table readability */ + th { font-size: 11px; padding: 9px 10px; } + td { font-size: 13px; padding: 9px 10px; } + + /* Prevent inner content overflow */ + .page { min-width: 0; } + .card { overflow: hidden; } +} + +@media (max-width: 380px) { + .stat-value { font-size: 14px; } + .mvrv-grid { grid-template-columns: 1fr; } +} + +/* ── MVRV Monitor ────────────────────────────────────────────────────────────── */ + +.mvrv-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border-bottom: 1px solid var(--border); +} + +.mvrv-card { + background: var(--surface); + padding: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.mvrv-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.mvrv-coin { + font-size: 16px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.3px; +} + +.mvrv-coin-name { + font-size: 11px; + color: var(--text-muted); + margin-top: 1px; +} + +.mvrv-ratio { + font-family: var(--mono); + font-size: 36px; + font-weight: 700; + letter-spacing: -1px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.mvrv-ratio-label { + font-size: 10px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-top: -6px; +} + +.mvrv-sparkline { + margin: 2px 0; +} + +.mvrv-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; + border-top: 1px solid var(--border); + padding-top: 10px; +} + +.mvrv-stat-label { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.mvrv-stat-val { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.mvrv-desc { + font-size: 11px; + color: var(--text-faint); + line-height: 1.4; + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.mvrv-zone-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + white-space: nowrap; +} + +.mvrv-zone-hot { background: var(--red-bg); color: var(--red); } +.mvrv-zone-bull { background: var(--yellow-bg); color: var(--yellow); } +.mvrv-zone-neutral { background: var(--surface2); color: var(--text-muted); } +.mvrv-zone-under { background: var(--green-bg); color: var(--green); } + +.mvrv-legend { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + padding: 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.mvrv-legend-item { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.mvrv-legend-desc { + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; + padding-top: 2px; +} + +/* ── AI Knowledge Base ───────────────────────────────────────────────────────── */ + +.chat-wrap { + display: flex; + flex-direction: column; + height: calc(100vh - var(--topbar-h) - 120px); + min-height: 320px; +} + +.chat-history { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--bg); +} + +.chat-empty { + margin: auto; + color: var(--text-faint); + font-size: 13px; + text-align: center; +} + +.chat-bubble { + max-width: 80%; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 13px; + line-height: 1.6; +} +.chat-bubble.user { + align-self: flex-end; + background: var(--accent-subtle); + color: var(--accent); + border: 1px solid rgba(56,189,248,0.2); +} +.chat-bubble.assistant { + align-self: flex-start; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + max-width: 92%; +} + +.chat-answer code { + background: var(--surface2); + padding: 1px 4px; + border-radius: 3px; + font-family: var(--mono); + font-size: 12px; +} + +.chat-sources { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.chat-sources-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); + margin-bottom: 6px; +} + +.chat-source { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + font-size: 11px; +} + +.chat-source-type { + background: var(--surface2); + color: var(--text-muted); + padding: 1px 6px; + border-radius: var(--radius-pill); + font-size: 10px; + font-weight: 600; +} + +.chat-source-title { color: var(--text-muted); font-family: var(--mono); } +.chat-source-score { color: var(--text-faint); margin-left: auto; } + +.chat-powered-by { + margin-top: 6px; + font-size: 10px; + color: var(--text-faint); + text-align: right; +} + +.chat-input-row { + display: flex; + gap: 8px; + padding: 12px 16px; + background: var(--surface); + border-top: 1px solid var(--border); +} + +.chat-input { flex: 1; } + +.kgraph-node circle { transition: fill-opacity 0.15s; } +.kgraph-node:hover circle { fill-opacity: 0.45; } + +.wiki-list { background: var(--surface); } + +.wiki-file { border-bottom: 1px solid var(--border); } + +.wiki-file-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + cursor: pointer; + user-select: none; + gap: 12px; +} +.wiki-file-header:hover { background: var(--surface-hover); } + +.wiki-filename { + font-size: 13px; + font-weight: 600; + font-family: var(--mono); + color: var(--text); +} + +.wiki-lang { + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-pill); + background: var(--accent-subtle); + color: var(--accent); + margin-left: 6px; + font-weight: 600; +} + +.wiki-path { + display: block; + font-size: 10px; + color: var(--text-faint); + font-family: var(--mono); + margin-top: 2px; +} + +.wiki-chevron { font-size: 10px; color: var(--text-faint); flex-shrink: 0; } + +.wiki-entries { background: var(--bg); } + +.wiki-entry { + padding: 10px 16px 10px 32px; + border-top: 1px solid var(--border); +} + +.wiki-entry-type { + display: inline-flex; + padding: 1px 6px; + border-radius: var(--radius-sm); + font-size: 10px; + font-weight: 600; + margin-right: 6px; +} +.wiki-entry-type.function { background: rgba(129,140,248,0.12); color: #818cf8; } +.wiki-entry-type.class { background: rgba(251,191,36,0.12); color: var(--yellow); } + +.wiki-entry-name { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.wiki-entry-line { + font-size: 10px; + color: var(--text-faint); + margin-left: 6px; +} + +.wiki-snippet { + margin-top: 6px; + font-size: 11px; + font-family: var(--mono); + color: var(--text-muted); + white-space: pre-wrap; + overflow: hidden; + max-height: 80px; + background: var(--surface2); + border-radius: var(--radius-sm); + padding: 6px 10px; } + +.notes-wrap { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + background: var(--bg); + min-height: 400px; +} + +.note-add { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.note-textarea { + resize: vertical; + min-height: 72px; + font-family: var(--font); +} + +.notes-list { display: flex; flex-direction: column; gap: 8px; } + +.note-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px 14px; +} + +.note-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } + +.note-title { flex: 1; font-size: 13px; color: var(--text); } + +.note-body { + font-size: 12px; + color: var(--text-muted); + white-space: pre-wrap; + line-height: 1.5; +} + +.ai-stat-strip { + display: flex; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.ai-stat-cell { + flex: 1; + padding: 10px 14px; + border-right: 1px solid var(--border); +} +.ai-stat-cell:last-child { border-right: none; } + +.ai-stat-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 3px; +} + +.ai-stat-val { + font-family: var(--mono); + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +/* ── Position Meta Modal ────────────────────────────────────────────────────── */ +.pos-modal-overlay { position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:9000; } +.pos-modal-box { background:#111;border:1px solid #2a2a2a;border-radius:10px;width:100%;max-width:460px;max-height:90vh;overflow-y:auto;display:flex;flex-direction:column; } +.pos-modal-header { display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid #1f1f1f; } +.pos-modal-title { font-size:15px;font-weight:700;color:var(--accent); } +.pos-close-btn { background:none;border:none;color:#666;font-size:18px;cursor:pointer;line-height:1; } +.pos-close-btn:hover { color:var(--text); } +.pos-tabs { display:flex;border-bottom:1px solid #1f1f1f; } +.pos-tab { flex:1;padding:9px;background:none;border:none;color:#666;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;transition:color .15s; } +.pos-tab.active { color:var(--accent);border-bottom-color:var(--accent); } +.pos-tab-panel { padding:16px;display:flex;flex-direction:column;gap:14px; } +.pos-field-group { display:flex;flex-direction:column;gap:6px; } +.pos-field-label { font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted); } +.pos-field-row { display:flex;gap:8px;align-items:center; } +.pos-input { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:6px;color:var(--text);padding:7px 10px;font-size:13px;font-family:var(--font);flex:1;outline:none; } +.pos-input:focus { border-color:var(--accent); } +.pos-textarea { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:6px;color:var(--text);padding:8px 10px;font-size:13px;font-family:var(--font);width:100%;resize:vertical;outline:none;box-sizing:border-box; } +.pos-textarea:focus { border-color:var(--accent); } +.pos-save-btn { background:var(--accent);color:#000;border:none;border-radius:6px;padding:7px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap; } +.pos-save-btn:hover { opacity:.85; } +.pos-age-display { font-size:11px;color:var(--text-muted); } +.pos-field-hint { font-size:11px;color:var(--text-muted); } +.intent-chips { display:flex;gap:8px;flex-wrap:wrap; } +.intent-chip { background:#1a1a1a;border:1px solid #2a2a2a;border-radius:6px;color:#888;padding:6px 14px;font-size:12px;cursor:pointer;transition:all .15s; } +.intent-chip.active { background:rgba(56,189,248,.12);border-color:var(--accent);color:var(--accent); } +.pos-age-badge { display:inline-flex;align-items:center;gap:4px;font-size:11px;font-family:var(--mono);color:var(--text-muted);background:#1a1a1a;border:1px solid #2a2a2a;border-radius:4px;padding:2px 6px;white-space:nowrap; } +.pos-age-stale { color:#facc15;border-color:rgba(250,204,21,.3); } +.pos-intent-chip { font-size:10px;font-weight:700;color:var(--accent); } +.thesis-recheck { background:#0d0d0d;border:1px solid #1f1f1f;border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:8px; } +.thesis-recheck-header { font-size:13px;font-weight:600;color:var(--text);margin-bottom:2px; } +.thesis-signal { display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0; } +.thesis-signal.sig-ok { color:var(--green); } +.thesis-signal.sig-fail { color:var(--red); } +.sig-icon { font-weight:700;width:14px;text-align:center; } +.sig-name { font-weight:600;min-width:70px; } +.sig-detail { color:inherit;opacity:.8; } +.stop-metrics { display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px; } +.stop-metric { background:#0d0d0d;border:1px solid #1f1f1f;border-radius:8px;padding:10px 12px; } +.stop-metric-label { font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:4px; } +.stop-metric-val { font-family:var(--mono);font-size:15px;font-weight:700;color:var(--text); } +.stop-metric-val.neg { color:var(--red); } + +/* ── Journal ────────────────────────────────────────────────────────────────── */ +.journal-insight-grid { display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px; } +@media(max-width:600px){.journal-insight-grid{grid-template-columns:1fr;}} +.journal-insight-card { background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:14px 16px; } +.journal-hold-comparison { display:flex;flex-direction:column;gap:10px;margin-top:8px; } +.journal-hold-bar { height:8px;border-radius:4px;min-width:4px;transition:width .4s; } +.journal-coin-table table { width:100%;border-collapse:collapse;font-size:12px; } +.journal-coin-table th { text-align:left;color:var(--text-muted);font-size:10px;text-transform:uppercase;padding:4px 6px;border-bottom:1px solid var(--border); } +.journal-coin-table td { padding:5px 6px;border-bottom:1px solid #1a1a1a; } + +/* ── Indicators ────────────────────────────────────────────────────────────── */ +.ind-strip { display:flex; align-items:center; gap:0; border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; } +.ind-chip { display:flex; align-items:center; gap:6px; padding:8px 16px; border-right:1px solid var(--border); font-size:12px; } +.ind-chip:last-child { border-right:none; } +.ind-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); } +.ind-badge { padding:1px 6px; border-radius:var(--radius-sm); font-size:10px; font-weight:600; } +.ind-extreme_fear { background:var(--red-bg); color:var(--red); } +.ind-fear { background:rgba(251,146,60,0.1); color:#fb923c; } +.ind-neutral { background:var(--surface2); color:var(--text-muted); } +.ind-greed { background:rgba(74,222,128,0.06); color:#a3e635; } +.ind-extreme_greed { background:var(--green-bg); color:var(--green); } +.ind-bull { background:var(--green-bg); color:var(--green); } +.ind-bear { background:var(--red-bg); color:var(--red); } +.ind-top { background:var(--red-bg); color:var(--red); } +.ind-warning { background:var(--yellow-bg); color:var(--yellow); } +.ind-normal { background:var(--surface2); color:var(--text-muted); } +.ind-strip-loading { padding:8px 16px; font-size:11px; color:var(--text-muted); border-bottom:1px solid var(--border); } +.ind-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:10px; padding:14px; } +.ind-section-title { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.8px; color:var(--text-muted); padding:10px 14px 4px; } +.ind-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:13px 15px; } +.ind-card-title { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px; color:var(--text-muted); margin-bottom:8px; } +.ind-card-value { font-family:var(--mono); font-size:28px; font-weight:800; line-height:1; margin-bottom:4px; } +.ind-card-detail { font-size:11px; color:var(--text-muted); margin-top:6px; line-height:1.5; } +.ind-signal-badge { display:inline-block; padding:2px 8px; border-radius:var(--radius-pill); font-size:11px; font-weight:700; margin-bottom:6px; } +.ind-bar-wrap { height:6px; background:var(--surface2); border-radius:3px; margin:6px 0; overflow:hidden; } +.ind-bar-fill { height:100%; border-radius:3px; } +.ind-fg-bar { background:linear-gradient(to right, var(--red), var(--yellow), var(--green)); } +.ind-floor-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; padding:0 14px 14px; } +.ind-floor-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-md); padding:10px 12px; } +.ind-floor-name { font-size:10px; font-weight:600; color:var(--text-muted); margin-bottom:3px; } +.ind-floor-price { font-family:var(--mono); font-size:14px; font-weight:700; } +.ind-floor-desc { font-size:10px; color:var(--text-muted); margin-top:2px; } +.ind-unavail { opacity:0.45; } +.ind-unavail-badge { display:inline-block; padding:1px 6px; border-radius:var(--radius-sm); font-size:10px; background:var(--surface2); color:var(--text-muted); } +.ind-sparkline-wrap { height:60px; margin-top:8px; } +@media (max-width:768px) { .ind-grid { grid-template-columns:1fr; } .ind-floor-grid { grid-template-columns:repeat(2,1fr); } } + +/* ── Nansen Smart Money ─────────────────────────────────────────────────────── */ +.nansen-header { display:flex; align-items:center; gap:12px; padding:10px 14px; background:var(--surface); border-bottom:1px solid var(--border); flex-wrap:wrap; } +.nansen-status { font-size:11px; color:var(--text-muted); } +.nansen-countdown { font-family:var(--mono); font-size:11px; color:var(--accent); } +.nansen-refresh-btn { padding:3px 10px; border-radius:var(--radius-sm); background:var(--surface2); border:1px solid var(--border); color:var(--text-muted); font-size:11px; cursor:pointer; } +.nansen-refresh-btn:hover { color:var(--text); } +.nansen-tabs { display:flex; border-bottom:1px solid var(--border); background:var(--surface); } +.nansen-tab { padding:9px 16px; font-size:13px; font-weight:500; color:var(--text-muted); cursor:pointer; border:none; background:none; border-bottom:2px solid transparent; transition:color 0.12s,border-color 0.12s; } +.nansen-tab:hover { color:var(--text); } +.nansen-tab.active { color:var(--accent); border-bottom-color:var(--accent); } +.nansen-pos-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:10px; padding:14px; } +.nansen-pos-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:13px 15px; } +.nansen-pos-card.pos-accum { border-left:3px solid var(--green); } +.nansen-pos-card.pos-distrib { border-left:3px solid var(--red); } +.nansen-pos-card.pos-neutral { border-left:3px solid var(--border); } +.nansen-pos-header { display:flex; align-items:center; gap:8px; margin-bottom:10px; } +.nansen-pos-coin { font-weight:700; font-size:14px; } +.nansen-flows { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:8px; } +.nansen-flow-cell { text-align:center; } +.nansen-flow-label { font-size:10px; color:var(--text-muted); font-weight:600; text-transform:uppercase; letter-spacing:0.5px; } +.nansen-flow-val { font-family:var(--mono); font-size:12px; font-weight:700; margin-top:2px; } +.nansen-badge { display:inline-block; padding:2px 8px; border-radius:var(--radius-pill); font-size:10px; font-weight:700; letter-spacing:0.04em; } +.nansen-accum { background:var(--green-bg); color:var(--green); } +.nansen-distrib { background:var(--red-bg); color:var(--red); } +.nansen-neutral { background:var(--surface2); color:var(--text-muted); } +.nansen-key-form { padding:24px 14px; max-width:400px; } +.nansen-key-form input { width:100%; padding:8px 12px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-family:var(--mono); font-size:12px; outline:none; } +.nansen-key-form input:focus { border-color:var(--accent); } +.nansen-save-key-btn { margin-top:8px; padding:6px 16px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); cursor:pointer; font-size:12px; } +.nansen-pos-open { border-left:2px solid var(--accent) !important; } +.nansen-watchlist-input { display:flex; gap:8px; padding:12px 14px; border-bottom:1px solid var(--border); } +.nansen-watchlist-input input { flex:1; padding:6px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:12px; outline:none; } +.nansen-watchlist-input input:focus { border-color:var(--accent); } +.nansen-add-btn { padding:6px 12px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); cursor:pointer; font-size:12px; } +.nansen-wl-item { display:flex; align-items:center; gap:8px; padding:4px 14px; } +.nansen-wl-remove { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:14px; padding:2px 6px; } +.nansen-no-data { padding:24px 14px; color:var(--text-muted); font-size:13px; } +@media (max-width:768px) { .nansen-pos-grid { grid-template-columns:1fr; } } + +/* ── Analytics ── */ +.an-wrap { padding:14px; max-width:1200px; } +.an-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:8px; margin-bottom:16px; } +.an-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; } +.an-card-label { font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.05em; font-weight:600; } +.an-card-value { font-size:18px; font-weight:700; font-family:var(--mono); margin-top:4px; } +.an-charts-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; } +.an-chart-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px; } +.an-chart-title { font-size:11px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px; } +.an-breakdowns { display:flex; flex-direction:column; gap:12px; margin-bottom:16px; } +.an-section { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; margin-bottom:12px; } +.an-title { font-size:12px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; margin-bottom:10px; } +.an-title-sub { font-size:10px; color:var(--text-muted); font-weight:400; text-transform:none; letter-spacing:0; } +.an-table-wrap { overflow-x:auto; } +.an-table { width:100%; border-collapse:collapse; font-size:12px; } +.an-table th { text-align:left; padding:5px 8px; color:var(--text-muted); font-weight:600; font-size:10px; text-transform:uppercase; letter-spacing:0.04em; border-bottom:1px solid var(--border); } +.an-table td { padding:5px 8px; border-bottom:1px solid var(--border2,#111); } +.an-table tr:last-child td { border-bottom:none; } +.an-kelly-body { display:flex; flex-direction:column; gap:10px; } +.an-kelly-formula { font-family:var(--mono); font-size:13px; color:var(--text-muted); } +.an-kelly-formula strong { color:var(--text); font-size:15px; } +.an-kelly-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:6px; font-size:12px; color:var(--text-muted); } +.an-kelly-grid strong { color:var(--text); } +.an-kelly-recs { display:flex; flex-direction:column; gap:4px; } +.an-kelly-rec { font-size:12px; padding:5px 10px; background:var(--surface2); border-radius:var(--radius-sm); } +.an-kelly-rec.pos { background:var(--green-bg); } +.an-kelly-rec.neg { background:var(--red-bg); } +.an-kelly-rec strong { font-family:var(--mono); } +.an-kelly-note { font-size:11px; color:var(--text-muted); } +.an-patterns-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:8px; } +.an-pattern-card { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; } +.an-pattern-card.good { border-left:3px solid var(--green); } +.an-pattern-card.bad { border-left:3px solid var(--red); } +.an-pattern-rank { font-size:10px; color:var(--text-muted); font-weight:700; margin-bottom:3px; } +.an-pattern-dim { font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; } +.an-pattern-val { font-size:14px; font-weight:700; margin:3px 0; } +.an-pattern-wr { font-size:13px; font-weight:700; font-family:var(--mono); } +.an-pattern-meta { font-size:10px; color:var(--text-muted); margin-top:3px; } +.an-pattern-edge { font-size:11px; font-weight:600; font-family:var(--mono); margin-top:4px; } +.an-ai-section { } +.an-ai-form { margin-bottom:12px; } +.an-ai-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px; } +.an-ai-field label { display:block; font-size:11px; color:var(--text-muted); margin-bottom:4px; } +.an-ai-field input { width:100%; padding:7px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-family:var(--mono); font-size:12px; outline:none; box-sizing:border-box; } +.an-ai-field input:focus { border-color:var(--accent); } +.an-ai-btns { display:flex; gap:8px; flex-wrap:wrap; } +.an-ai-btn { padding:7px 16px; border-radius:var(--radius-sm); font-size:12px; font-weight:600; cursor:pointer; border:1px solid var(--accent); background:var(--accent-subtle); color:var(--accent); } +.an-ai-btn.primary { background:var(--accent); color:#000; } +.an-ai-btn:hover { opacity:0.85; } +.an-ai-result { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:14px; margin-top:12px; } +.an-ai-response { font-size:13px; line-height:1.6; } +.an-ai-response p { margin:0 0 8px; } +.an-ai-response ul { margin:4px 0 8px 16px; padding:0; } +.an-ai-response li { margin-bottom:4px; } +.an-ai-h { font-size:13px; font-weight:700; color:var(--accent); margin:12px 0 6px; } +@media (max-width:768px) { + .an-charts-row { grid-template-columns:1fr; } + .an-ai-row { grid-template-columns:1fr; } + .an-patterns-grid { grid-template-columns:repeat(2,1fr); } +} + +/* ── Logger ── */ +.logger-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin:0 4px; cursor:pointer; flex-shrink:0; } +.logger-dot.ok { background:var(--green); box-shadow:0 0 4px var(--green); } +.logger-dot.warn { background:var(--yellow,#f59e0b); } +.logger-dot.off { background:var(--border); } +.logger-dot.running { background:var(--accent); animation:pulse 1s infinite; } +.logger-dot.error { background:var(--red); } +.an-tabbar { display:flex; gap:0; border-bottom:1px solid var(--border); margin-bottom:14px; } +.an-tab { padding:8px 16px; background:none; border:none; border-bottom:2px solid transparent; color:var(--text-muted); cursor:pointer; font-size:12px; font-weight:600; } +.an-tab:hover { color:var(--text); } +.an-tab.active { color:var(--accent); border-bottom-color:var(--accent); } +.log-setup { padding:14px; max-width:900px; } +.log-status-badge { display:inline-block; margin-left:8px; padding:2px 8px; border-radius:var(--radius-pill); font-size:10px; font-weight:700; } +.log-status-badge.ok { background:var(--green-bg); color:var(--green); } +.log-status-badge.warn { background:rgba(245,158,11,0.15); color:#f59e0b; } +.log-status-badge.off { background:var(--surface2); color:var(--text-muted); } +.log-step { display:flex; gap:12px; margin-bottom:20px; } +.log-step-num { width:24px; height:24px; border-radius:50%; background:var(--accent); color:#000; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; flex-shrink:0; margin-top:2px; } +.log-step-body { flex:1; } +.log-step-title { font-size:13px; font-weight:700; margin-bottom:4px; } +.log-step-desc { font-size:12px; color:var(--text-muted); margin-bottom:8px; } +.log-sql-wrap { position:relative; } +.log-copy-btn { position:absolute; top:8px; right:8px; padding:4px 10px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); font-size:11px; cursor:pointer; z-index:1; } +.log-sql { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:12px; font-family:var(--mono); font-size:11px; color:var(--text-muted); overflow-x:auto; max-height:220px; overflow-y:auto; white-space:pre; margin:0; } +.log-fields { display:grid; grid-template-columns:1fr 1fr; gap:10px; } +.log-field label { display:block; font-size:11px; color:var(--text-muted); margin-bottom:4px; } +.log-field input { width:100%; padding:7px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-family:var(--mono); font-size:12px; outline:none; box-sizing:border-box; } +.log-field input:focus { border-color:var(--accent); } +.log-save-btn { padding:7px 16px; background:var(--accent); color:#000; border:none; border-radius:var(--radius-sm); font-size:12px; font-weight:700; cursor:pointer; } +.log-test-btn { padding:7px 14px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text-muted); font-size:12px; cursor:pointer; } +.log-status-row { display:flex; align-items:center; gap:16px; flex-wrap:wrap; padding:12px 14px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); margin-bottom:16px; } +.log-stat { display:flex; flex-direction:column; gap:2px; } +.log-stat-label { font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; } +.log-stat-val { font-size:13px; font-weight:600; font-family:var(--mono); } +.log-now-btn { margin-left:auto; padding:6px 14px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); font-size:12px; cursor:pointer; } +.log-what { margin-top:4px; } +.log-what-title { font-size:11px; color:var(--text-muted); font-weight:700; text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px; } +.log-what-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:6px; } +.log-what-item { font-size:11px; color:var(--text-muted); background:var(--surface2); padding:8px 10px; border-radius:var(--radius-sm); line-height:1.5; } +.log-what-item strong { color:var(--text); } +@media (max-width:768px) { .log-fields { grid-template-columns:1fr; } .log-step { flex-direction:column; } } + +/* ── Supabase Auth Modal ─────────────────────────────────────────────────────── */ +.sb-auth-overlay { position:fixed; inset:0; background:rgba(0,0,0,.7); z-index:9000; display:flex; align-items:center; justify-content:center; } +.sb-auth-overlay.hidden { display:none; } +.sb-auth-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:28px 24px; width:min(360px,90vw); display:flex; flex-direction:column; gap:12px; } +.sb-auth-heading { margin:0; font-size:17px; font-weight:700; color:var(--text); } +.sb-auth-sub { margin:0; font-size:12px; color:var(--text-muted); } +.sb-auth-error { margin:0; font-size:12px; color:#f87171; min-height:16px; } +.sb-auth-btns { display:flex; gap:8px; } +.sb-auth-btns .btn { flex:1; } + +/* ── KB Page ─────────────────────────────────────────────────────────────────── */ +.kb-header { display:flex; align-items:center; justify-content:space-between; flex-wrap:wrap; gap:10px; padding:14px 16px; border-bottom:1px solid var(--border); } +.kb-tabs { display:flex; gap:4px; } +.kb-tab { padding:6px 14px; border-radius:var(--radius-sm); border:1px solid var(--border); background:transparent; color:var(--text-muted); font-size:13px; cursor:pointer; } +.kb-tab.active { background:var(--accent); color:#000; border-color:var(--accent); font-weight:700; } +.kb-actions { display:flex; gap:8px; align-items:center; } +.kb-search { padding:6px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:12px; width:180px; outline:none; } +.kb-search:focus { border-color:var(--accent); } +.kb-empty { padding:40px; text-align:center; color:var(--text-muted); font-size:13px; } +.kb-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:14px; cursor:pointer; transition:border-color .15s; } +.kb-card:hover, .kb-card.kb-pinned { border-color:var(--accent-subtle); } +.kb-card-top { display:flex; align-items:baseline; justify-content:space-between; gap:8px; margin-bottom:4px; } +.kb-card-title { font-size:14px; font-weight:600; color:var(--text); } +.kb-card-date { font-size:11px; color:var(--text-muted); white-space:nowrap; } +.kb-card-meta { display:flex; flex-wrap:wrap; gap:4px; margin-bottom:6px; } +.kb-tag { font-size:10px; padding:2px 6px; background:var(--accent-subtle); color:var(--accent); border-radius:999px; } +.kb-coin { font-size:10px; padding:2px 6px; background:var(--surface2); color:var(--text-muted); border-radius:999px; font-family:var(--mono); } +.kb-pin { margin-right:4px; } +.kb-card-preview { font-size:12px; color:var(--text-muted); margin:0; line-height:1.5; } +.kb-card-actions { display:flex; gap:6px; margin-top:10px; } +#kb-body { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:12px; padding:14px 16px; } +.kb-table-wrap { padding:14px 16px; overflow-x:auto; } +.kb-table { width:100%; border-collapse:collapse; font-size:12px; } +.kb-table th { text-align:left; padding:8px 10px; border-bottom:1px solid var(--border); color:var(--text-muted); font-size:11px; text-transform:uppercase; letter-spacing:.04em; } +.kb-table td { padding:9px 10px; border-bottom:1px solid var(--border); color:var(--text); } +.kb-table tr:hover td { background:var(--surface2); } +.kb-status-open { color:#34d399; } +.kb-status-closed { color:var(--text-muted); } +.kb-status-cancelled { color:#f87171; } +.pos { color:#34d399; } +.neg { color:#f87171; } +.kb-editor-backdrop { position:fixed; inset:0; background:rgba(0,0,0,.6); z-index:8000; } +.kb-editor-backdrop.hidden, .kb-editor.hidden { display:none; } +.kb-editor { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%); z-index:8001; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); width:min(640px,94vw); max-height:88vh; display:flex; flex-direction:column; } +.kb-editor-header { display:flex; align-items:center; justify-content:space-between; padding:14px 16px; border-bottom:1px solid var(--border); font-weight:700; font-size:14px; } +#kb-editor-body { overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:12px; } +.kb-editor-footer { padding:12px 16px; border-top:1px solid var(--border); display:flex; gap:8px; } +.kb-label { display:flex; flex-direction:column; gap:4px; font-size:12px; color:var(--text-muted); } +.kb-input { padding:8px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:13px; outline:none; width:100%; box-sizing:border-box; } +.kb-input:focus { border-color:var(--accent); } +.kb-textarea { resize:vertical; font-family:inherit; line-height:1.5; } +.kb-form-row { display:grid; grid-template-columns:repeat(3,1fr); gap:10px; } +@media (max-width:500px) { .kb-form-row { grid-template-columns:1fr; } } diff --git a/frontend/icons/icon-192.png b/frontend/icons/icon-192.png new file mode 100644 index 0000000..e0bdea4 Binary files /dev/null and b/frontend/icons/icon-192.png differ diff --git a/frontend/icons/icon-512.png b/frontend/icons/icon-512.png new file mode 100644 index 0000000..20094a8 Binary files /dev/null and b/frontend/icons/icon-512.png differ diff --git a/frontend/icons/icon.svg b/frontend/icons/icon.svg new file mode 100644 index 0000000..a39dba9 --- /dev/null +++ b/frontend/icons/icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/frontend/index.html b/frontend/index.html index 9dce041..55a6bd4 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,124 +2,226 @@ - - Hype — Trade Analyzer + + + + + + Hype — Analyzer + + - - + + + -
- - 0x6e4c…2015 + + + + +
-
- + + + + 0x6e4c…2015 +
+
- -
-
- Notifications - -
-
Loading…
-
- - + - -
- - -
-
-
Loading…
-
-
- - -
-
-
Loading…
-
-
+ + - -
-
-
Loading…
-
-
+ + - -
-
-
Loading…
-
+ + - -
-
-
Loading…
-
-
+
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Connecting…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
+
- -
-
-
Loading…
-
+ + + + + + + + + + + + + + +
+
- + + diff --git a/frontend/js/analytics.js b/frontend/js/analytics.js new file mode 100644 index 0000000..a406346 --- /dev/null +++ b/frontend/js/analytics.js @@ -0,0 +1,571 @@ +// ── Analytics — ML & Data Analysis ──────────────────────────────────────────── + +let _analyticsCache = null, _analyticsTs = 0; +let _analyticsCharts = {}; +const ANALYTICS_TTL = 300000; + +// ── Statistics helpers ──────────────────────────────────────────────────────── + +function holdBucket(ms) { + const h = ms / 3600000; + if (h < 4) return 'Scalp (<4h)'; + if (h < 24) return 'Day (4–24h)'; + if (h < 168) return 'Swing (1–7d)'; + return 'Position (>7d)'; +} + +const _DAYS = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat']; +function _dayName(ts) { return _DAYS[new Date(ts).getUTCDay()]; } +function _hourBucket(ts) { + const h = new Date(ts).getUTCHours(); + if (h < 6) return '00–06 UTC'; + if (h < 12) return '06–12 UTC'; + if (h < 18) return '12–18 UTC'; + return '18–24 UTC'; +} + +function _groupStats(trades, keyFn) { + const g = {}; + for (const t of trades) { + const k = keyFn(t); + if (!g[k]) g[k] = []; + g[k].push(t); + } + return Object.entries(g).map(([key, arr]) => { + const wins = arr.filter(t => t.net_pnl > 0); + const losses = arr.filter(t => t.net_pnl <= 0); + const sumW = wins.reduce((s, t) => s + t.net_pnl, 0); + const sumL = losses.reduce((s, t) => s + t.net_pnl, 0); + return { + key, + count: arr.length, + wins: wins.length, + losses: losses.length, + winRate: arr.length ? wins.length / arr.length : 0, + totalPnl: arr.reduce((s, t) => s + t.net_pnl, 0), + avgPnl: arr.reduce((s, t) => s + t.net_pnl, 0) / arr.length, + avgWin: wins.length ? sumW / wins.length : 0, + avgLoss: losses.length ? sumL / losses.length : 0, + pf: Math.abs(sumL) > 0 ? sumW / Math.abs(sumL) : null, + }; + }).sort((a, b) => b.count - a.count); +} + +function _computeKelly(closed) { + const wins = closed.filter(t => t.net_pnl > 0); + const losses = closed.filter(t => t.net_pnl <= 0); + if (!wins.length || !losses.length) return null; + const p = wins.length / closed.length; + const avgW = wins.reduce((s, t) => s + t.net_pnl, 0) / wins.length; + const avgL = Math.abs(losses.reduce((s, t) => s + t.net_pnl, 0) / losses.length); + const b = avgW / avgL; + const k = (b * p - (1 - p)) / b; + return { p, avgWin: avgW, avgLoss: avgL, b, kelly: k, halfKelly: k / 2, quarterKelly: k / 4 }; +} + +function _pnlDist(closed) { + if (!closed.length) return []; + const vals = closed.map(t => t.net_pnl); + const mn = Math.min(...vals), mx = Math.max(...vals); + const range = mx - mn || 1; + const bins = Math.min(20, Math.ceil(Math.sqrt(closed.length))); + const size = range / bins; + const out = Array.from({length: bins}, (_, i) => ({ + label: '$' + (mn + i * size).toFixed(0), + from: mn + i * size, + to: mn + (i + 1) * size, + count: 0, + })); + for (const v of vals) { + const i = Math.min(bins - 1, Math.floor((v - mn) / size)); + out[i].count++; + } + out.forEach(b => b.win = (b.from + b.to) / 2 > 0); + return out; +} + +// Tier 2: detect statistically meaningful patterns +function _findPatterns(closed) { + if (closed.length < 8) return []; + const overallWr = closed.filter(t => t.net_pnl > 0).length / closed.length; + + const dims = [ + { name: 'Side', fn: t => t.side }, + { name: 'Hold Duration', fn: t => holdBucket(t.hold_ms) }, + { name: 'Day of Week', fn: t => _dayName(t.entry_time) }, + { name: 'Time (UTC)', fn: t => _hourBucket(t.entry_time) }, + ]; + + const patterns = []; + for (const d of dims) { + for (const g of _groupStats(closed, d.fn)) { + if (g.count < 3) continue; + const se = Math.sqrt(overallWr * (1 - overallWr) / g.count); + const z = se > 0 ? (g.winRate - overallWr) / se : 0; + const edge = g.winRate - overallWr; + patterns.push({ ...g, dimension: d.name, edge, z }); + } + } + return patterns + .filter(p => Math.abs(p.z) >= 0.5) + .sort((a, b) => b.edge * b.count - a.edge * a.count) + .slice(0, 6); +} + +function buildAnalytics(trades) { + const closed = trades.filter(t => t.closed); + if (!closed.length) return null; + + const wins = closed.filter(t => t.net_pnl > 0); + const losses = closed.filter(t => t.net_pnl <= 0); + const total = closed.reduce((s, t) => s + t.net_pnl, 0); + const gw = wins.reduce((s, t) => s + t.net_pnl, 0); + const gl = Math.abs(losses.reduce((s, t) => s + t.net_pnl, 0)); + + const sorted = [...closed].sort((a, b) => (a.exit_time || 0) - (b.exit_time || 0)); + let cum = 0; + const cumSeries = sorted.map(t => ({ t: t.exit_time, v: (cum += t.net_pnl), coin: t.coin, pnl: t.net_pnl })); + + return { + overall: { + count: closed.length, winCount: wins.length, lossCount: losses.length, + winRate: wins.length / closed.length, + profitFactor: gl > 0 ? gw / gl : null, + totalPnl: total, avgPnl: total / closed.length, + avgWin: wins.length ? gw / wins.length : 0, + avgLoss: losses.length ? -gl / losses.length : 0, + avgHoldMs: closed.reduce((s, t) => s + t.hold_ms, 0) / closed.length, + maxWin: wins.length ? Math.max(...wins.map(t => t.net_pnl)) : 0, + maxLoss: losses.length ? Math.min(...losses.map(t => t.net_pnl)) : 0, + }, + bySide: _groupStats(closed, t => t.side), + byDuration: _groupStats(closed, t => holdBucket(t.hold_ms)), + byDay: _groupStats(closed, t => _dayName(t.entry_time)), + byHour: _groupStats(closed, t => _hourBucket(t.entry_time)), + byCoin: _groupStats(closed, t => t.coin), + kelly: _computeKelly(closed), + cumSeries, + pnlDist: _pnlDist(closed), + patterns: _findPatterns(closed), + raw: closed, + }; +} + +// ── Format helpers ──────────────────────────────────────────────────────────── + +function _fmtUsd(n, sign = true) { + if (n === null || n === undefined) return '—'; + const abs = Math.abs(n); + const s = abs >= 1e6 ? (abs / 1e6).toFixed(2) + 'M' + : abs >= 1e3 ? (abs / 1e3).toFixed(1) + 'K' + : abs.toFixed(2); + if (!sign) return '$' + s; + return (n >= 0 ? '+$' : '-$') + s; +} +function _fmtPct(r) { return (r * 100).toFixed(1) + '%'; } +function _pnlCls(n) { return n > 0 ? 'pos' : n < 0 ? 'neg' : 'muted'; } +function _wrCls(r) { return r >= 0.55 ? 'pos' : r <= 0.45 ? 'neg' : ''; } + +// ── Breakdown table ─────────────────────────────────────────────────────────── + +function _breakdownTable(rows, title) { + if (!rows.length) return ''; + return `
+
${title}
+
+ + + + + + + ${rows.map(r => ` + + + + + + + + `).join('')} + +
SegmentTradesWin%Total P&LAvg P&LAvg WinAvg Loss
${r.key}${r.count}${_fmtPct(r.winRate)}${_fmtUsd(r.totalPnl)}${_fmtUsd(r.avgPnl)}${_fmtUsd(r.avgWin)}${_fmtUsd(r.avgLoss)}
+
+
`; +} + +// ── Main render ─────────────────────────────────────────────────────────────── + +function renderAnalytics(an) { + const o = an.overall; + const rr = o.avgLoss !== 0 ? (o.avgWin / Math.abs(o.avgLoss)).toFixed(2) : '—'; + + const cards = `
+ ${[ + ['Closed Trades', o.count, ''], + ['Win Rate', _fmtPct(o.winRate), _wrCls(o.winRate)], + ['Profit Factor', o.profitFactor ? o.profitFactor.toFixed(2) : '—', o.profitFactor > 1 ? 'pos' : 'neg'], + ['Total Net P&L', _fmtUsd(o.totalPnl), _pnlCls(o.totalPnl)], + ['Avg Win', _fmtUsd(o.avgWin), 'pos'], + ['Avg Loss', _fmtUsd(o.avgLoss), 'neg'], + ['Avg Hold', fmtHold(o.avgHoldMs), ''], + ['R:R Ratio', rr, ''], + ].map(([label, val, cls]) => `
+
${label}
+
${val}
+
`).join('')} +
`; + + const charts = `
+
+
Cumulative P&L
+ +
+
+
P&L Distribution
+ +
+
`; + + const kelly = an.kelly ? `
+
Kelly Criterion — Optimal Position Sizing
+
+
f* = (b×p − (1−p)) / b = ${_fmtPct(an.kelly.kelly)}
+
+
Win rate (p) ${_fmtPct(an.kelly.p)}
+
Avg win ${_fmtUsd(an.kelly.avgWin)}
+
Avg loss ${_fmtUsd(an.kelly.avgLoss)}
+
Win/loss ratio (b) ${an.kelly.b.toFixed(2)}×
+
+
+
Full Kelly: ${_fmtPct(an.kelly.kelly)} of account
+
Half Kelly (recommended): ${_fmtPct(an.kelly.halfKelly)}
+
Quarter Kelly (conservative): ${_fmtPct(an.kelly.quarterKelly)}
+
+
Use half/quarter Kelly — full Kelly maximises growth but has very high variance.
+
+
` : ''; + + const overallWr = o.winRate; + const patterns = an.patterns.length ? `
+
Entry Patterns with Statistical Edge — segments where your win rate diverges from your average
+
+ ${an.patterns.map((p, i) => `
+
#${i + 1}
+
${p.dimension}
+
${p.key}
+
${_fmtPct(p.winRate)} WR
+
${p.count} trades · avg ${_fmtUsd(p.avgPnl)}
+
${p.edge >= 0 ? '+' : ''}${_fmtPct(p.edge)} vs avg
+
`).join('')} +
+
` : ''; + + return `
+ ${cards} + ${charts} +
+ ${_breakdownTable(an.bySide, 'By Side')} + ${_breakdownTable(an.byDuration, 'By Hold Duration')} + ${_breakdownTable(an.byDay, 'By Day of Week')} + ${_breakdownTable(an.byHour, 'By Time of Day (UTC)')} + ${_breakdownTable(an.byCoin.slice(0, 12), 'By Coin (top 12)')} +
+ ${kelly} + ${patterns} + ${_renderAiSection(an)} +
`; +} + +// ── Charts ──────────────────────────────────────────────────────────────────── + +function _renderAnalyticsCharts(an) { + const gridColor = '#1e1e1e', tickColor = '#666'; + + const cumCtx = document.getElementById('an-cum-chart'); + if (cumCtx && an.cumSeries.length > 1) { + if (_analyticsCharts.cum) _analyticsCharts.cum.destroy(); + const last = an.cumSeries[an.cumSeries.length - 1].v; + _analyticsCharts.cum = new Chart(cumCtx, { + type: 'line', + data: { + labels: an.cumSeries.map(p => fmtDate(p.t)), + datasets: [{ + data: an.cumSeries.map(p => p.v), + borderColor: last >= 0 ? '#4ade80' : '#f87171', + backgroundColor: 'transparent', + pointRadius: an.cumSeries.length < 30 ? 3 : 0, + borderWidth: 2, tension: 0.3, + }] + }, + options: { + responsive: true, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: tickColor, maxTicksLimit: 6 }, grid: { color: gridColor } }, + y: { ticks: { color: tickColor, callback: v => '$' + v.toFixed(0) }, grid: { color: gridColor } } + } + } + }); + } + + const distCtx = document.getElementById('an-dist-chart'); + if (distCtx && an.pnlDist.length) { + if (_analyticsCharts.dist) _analyticsCharts.dist.destroy(); + _analyticsCharts.dist = new Chart(distCtx, { + type: 'bar', + data: { + labels: an.pnlDist.map(b => b.label), + datasets: [{ + data: an.pnlDist.map(b => b.count), + backgroundColor: an.pnlDist.map(b => b.win ? 'rgba(74,222,128,0.75)' : 'rgba(248,113,113,0.75)'), + borderWidth: 0, + }] + }, + options: { + responsive: true, + plugins: { legend: { display: false } }, + scales: { + x: { ticks: { color: tickColor, maxTicksLimit: 8 }, grid: { display: false } }, + y: { ticks: { color: tickColor }, grid: { color: gridColor } } + } + } + }); + } +} + +// ── AI Debrief (Tier 3) ──────────────────────────────────────────────────────── + +function _renderAiSection(an) { + const savedKey = localStorage.getItem('claude_api_key') || ''; + const savedBackend = localStorage.getItem('nansen_backend_url') || ''; + return `
+
AI Trade Debrief — powered by Claude
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
`; +} + +function _saveAiSettings() { + const key = (document.getElementById('an-claude-key')?.value || '').trim(); + const backend = (document.getElementById('an-backend-url')?.value || '').trim(); + if (key) localStorage.setItem('claude_api_key', key); + if (backend) localStorage.setItem('nansen_backend_url', backend); + return { key, backend }; +} + +async function runAnalyticsDebrief() { + const { key, backend } = _saveAiSettings(); + if (!key && !backend) { alert('Enter your Anthropic API key or Vercel app URL.'); return; } + if (!_analyticsCache) { alert('Analytics not loaded yet.'); return; } + _showAiLoading('Analyzing your trades…'); + try { + const prompt = _buildDebriefPrompt(_analyticsCache); + const text = await _callClaude(prompt, key, backend); + _showAiResult(text); + } catch (err) { + _showAiError(err.message); + } +} + +async function runRegimeBriefing() { + const { key, backend } = _saveAiSettings(); + if (!key && !backend) { alert('Enter your Anthropic API key or Vercel app URL.'); return; } + _showAiLoading('Generating regime briefing…'); + try { + const prompt = _buildRegimePrompt(); + const text = await _callClaude(prompt, key, backend); + _showAiResult(text); + } catch (err) { + _showAiError(err.message); + } +} + +function _showAiLoading(msg) { + const el = document.getElementById('an-ai-result'); + if (!el) return; + el.style.display = 'block'; + el.innerHTML = `
${msg}
`; +} +function _showAiResult(text) { + const el = document.getElementById('an-ai-result'); + if (!el) return; + el.innerHTML = `
${_md(text)}
+ `; +} +function _showAiError(msg) { + const el = document.getElementById('an-ai-result'); + if (el) el.innerHTML = `
Error: ${msg}
`; +} + +function _buildDebriefPrompt(an) { + const o = an.overall; + const bySide = an.bySide.map(r => ` ${r.key}: ${r.count} trades, ${_fmtPct(r.winRate)} WR, ${_fmtUsd(r.totalPnl)}`).join('\n'); + const byDur = an.byDuration.map(r => ` ${r.key}: ${r.count} trades, ${_fmtPct(r.winRate)} WR, ${_fmtUsd(r.avgPnl)} avg`).join('\n'); + const byDay = an.byDay.map(r => ` ${r.key}: ${r.count} trades, ${_fmtPct(r.winRate)} WR`).join('\n'); + const coins = an.byCoin.slice(0, 6).map(r => ` ${r.key}: ${r.count} trades, ${_fmtPct(r.winRate)} WR, ${_fmtUsd(r.totalPnl)}`).join('\n'); + const topPat = an.patterns[0]; + + return `You are analyzing a crypto perps trader's Hyperliquid history. Be concise and data-driven — no generic advice. + +PERFORMANCE: +- Closed trades: ${o.count} | Win rate: ${_fmtPct(o.winRate)} | Profit factor: ${o.profitFactor?.toFixed(2) ?? 'N/A'} +- Total P&L: ${_fmtUsd(o.totalPnl)} | Avg win: ${_fmtUsd(o.avgWin)} | Avg loss: ${_fmtUsd(o.avgLoss)} +- R:R: ${o.avgLoss ? (o.avgWin / Math.abs(o.avgLoss)).toFixed(2) : 'N/A'} | Avg hold: ${fmtHold(o.avgHoldMs)} + +BY SIDE:\n${bySide} +BY DURATION:\n${byDur} +BY DAY:\n${byDay} +TOP COINS:\n${coins} +Kelly: ${an.kelly ? _fmtPct(an.kelly.kelly) + ' (half: ' + _fmtPct(an.kelly.halfKelly) + ')' : 'N/A'} +${topPat ? `Strongest pattern: ${topPat.dimension} → ${topPat.key} (${_fmtPct(topPat.winRate)} WR, ${topPat.edge >= 0 ? '+' : ''}${_fmtPct(topPat.edge)} vs avg)` : ''} + +Provide ONLY these four sections (use ## headings): + +## Key Patterns +2–3 bullet points of what the data clearly shows. + +## Rules to Implement +2–3 specific, concrete trading rules this data suggests. + +## Risk Management +1–2 bullets on sizing and loss management based on the Kelly number and loss stats. + +## Contrarian Insight +One surprising or non-obvious observation from this data.`; +} + +function _buildRegimePrompt() { + const ind = window._indData; + const fg = ind?.fearGreed; + const bmsb = ind?.bmsb; + const pi = ind?.piCycle; + const flows = window._nansenData?.netflows; + const pos = window._ovData?.positions || []; + + let indText = ''; + if (fg) indText += `- Fear & Greed: ${fg.value} (${fg.classification})\n`; + if (bmsb) indText += `- BMSB: ${bmsb.signal} — BTC ${bmsb.signal === 'BULL' ? 'above' : 'below'} 20W SMA + 21W EMA\n`; + if (pi) indText += `- Pi Cycle: ${pi.signal} — 111d vs 350d×2 at ${pi.proximity?.toFixed(1)}% proximity\n`; + if (flows?.length) { + const top3 = flows.slice(0, 3).map(t => `${t.token?.symbol}: ${fmtFlow(t.netflow_usd_24h)}`).join(', '); + indText += `- Smart money 24h flows: ${top3}\n`; + } + if (!indText) indText = '- Indicator data not loaded\n'; + + const posText = pos.length + ? pos.slice(0, 5).map(p => `${p.coin} ${(p.szi || 0) > 0 ? 'LONG' : 'SHORT'}`).join(', ') + : 'none'; + + return `You are a brief market analyst for a crypto perps trader on Hyperliquid. Today: ${new Date().toDateString()}. + +INDICATORS:\n${indText}OPEN POSITIONS: ${posText} + +Write a 3–4 sentence daily regime briefing covering: +1. Current regime classification (risk-on / risk-off / transitional) +2. Key risk to watch in the next 24–48h +3. One tactical suggestion for the current setup + +Be direct. No disclaimers.`; +} + +async function _callClaude(prompt, apiKey, backendUrl) { + const base = (backendUrl || '').replace(/\/$/, ''); + + if (base) { + const resp = await fetch(`${base}/api/claude`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ prompt, key: apiKey }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error || `Proxy ${resp.status}`); + } + return (await resp.json()).text; + } + + // Direct browser call (requires anthropic-dangerous-direct-browser-access header) + if (!apiKey) throw new Error('No API key or proxy URL configured.'); + const resp = await fetch('https://api.anthropic.com/v1/messages', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': apiKey, + 'anthropic-version': '2023-06-01', + 'anthropic-dangerous-direct-browser-access': 'true', + }, + body: JSON.stringify({ + model: 'claude-haiku-4-5-20251001', + max_tokens: 1024, + messages: [{ role: 'user', content: prompt }], + }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.error?.message || `Anthropic API ${resp.status}`); + } + return (await resp.json()).content?.[0]?.text || ''; +} + +// Very small markdown → HTML (only what Claude outputs) +function _md(text) { + return text + .replace(/&/g, '&').replace(//g, '>') + .replace(/^## (.+)$/gm, '

$1

') + .replace(/^### (.+)$/gm, '
$1
') + .replace(/\*\*(.+?)\*\*/g, '$1') + .replace(/^[-•] (.+)$/gm, '
  • $1
  • ') + .replace(/(
  • [\s\S]*?<\/li>)(?=\n*(?:$1') + .replace(/\n{2,}/g, '

    ') + .replace(/^([^<\n].+)$/gm, (m) => m.startsWith('<') ? m : `

    ${m}

    `) + .replace(/

    <\/p>/g, ''); +} + +// ── Load ────────────────────────────────────────────────────────────────────── + +async function loadAnalytics() { + const el = document.getElementById('analytics-content'); + if (!el) return; + + if (!currentWallet) { + el.innerHTML = '

    Connect a wallet to view analytics.
    '; + return; + } + + if (!_analyticsCache || Date.now() - _analyticsTs > ANALYTICS_TTL) { + el.innerHTML = '
    Building analytics…
    '; + try { + const rawFills = await getUserFills(currentWallet); + const trades = typeof buildTrades === 'function' ? buildTrades(rawFills) : []; + _analyticsCache = buildAnalytics(trades); + _analyticsTs = Date.now(); + } catch (err) { + el.innerHTML = `
    Failed to load fills: ${err.message}
    `; + return; + } + } + + if (!_analyticsCache) { + el.innerHTML = '
    No closed trades found yet.
    '; + return; + } + + el.innerHTML = renderAnalytics(_analyticsCache); + requestAnimationFrame(() => _renderAnalyticsCharts(_analyticsCache)); +} diff --git a/frontend/js/app.js b/frontend/js/app.js index 3252d12..7d8c4e1 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,562 +1,2894 @@ -const API = ''; // same origin -const PRIMARY_WALLET = '0x6e4c6da09f06690cc4db53d42ab539d3d4882015'; +// ── Config ─────────────────────────────────────────────────────────────────── +// Use a Cloudflare Worker proxy URL for edge caching (set via Settings → Proxy URL) +const HL = localStorage.getItem('hype_proxy_url') || 'https://api.hyperliquid.xyz/info'; +const HL_WS = 'wss://api.hyperliquid.xyz/ws'; +const DEFAULT_WALLET = '0x6e4c6da09f06690cc4db53d42ab539d3d4882015'; +let currentWallet = localStorage.getItem('hype_wallet') || DEFAULT_WALLET; +let currentPage = 'overview'; +let phaseInterval = '1h'; +let activeNarrative = 'all'; +let autoRefreshTimer = null; +let _silentRefresh = false; +let _lastRefreshTs = 0; +const _SKIP_SILENT = new Set(['phases','monitor','journal','analytics','kb','mvrv','ai']); +let marketSortKey = 'volume'; +let allMarketData = []; +let _recentPnlHours = 24; +let _recentPnlOpen = false; +// ── WebSocket state ─────────────────────────────────────────────────────────── let ws = null; -let charts = {}; -let activeTab = {}; +let wsReconnectTimer = null; +let wsConnected = false; +let _wsRetries = 0; +let livePrices = {}; +let livePrevDay = {}; +let livePositions = []; +let priceHistory = {}; +let priceAlerts = []; +let monitorActive = false; -// ── WebSocket ───────────────────────────────────────────────────────────────── +// ── Portfolio chart state ───────────────────────────────────────────────────── +let portfolioChart = null; +let chartCurrency = 'USD'; +let usdToIdr = 0; -function connectWS() { - const proto = location.protocol === 'https:' ? 'wss' : 'ws'; - ws = new WebSocket(`${proto}://${location.host}/ws`); +// ── Telegram state ──────────────────────────────────────────────────────────── +let tgToken = localStorage.getItem('hype_tg_token') || ''; +let tgChatId = localStorage.getItem('hype_tg_chat') || ''; +let pnlThreshold = parseFloat(localStorage.getItem('hype_pnl_thr') || '0'); +let livePnLSnapshot = {}; // coin+side → last PnL for milestone detection +let lastOrderIds = null; // Set of oid strings for fill detection - ws.onopen = () => setStatus(true); - ws.onclose = () => { setStatus(false); setTimeout(connectWS, 3000); }; - ws.onerror = () => setStatus(false); +const MONITOR_COINS = ['BTC','ETH','SOL','HYPE','SUI','AVAX','DOGE','WIF','PEPE','ARB','OP','INJ']; +const TA_COINS = ['BTC','ETH','SOL','HYPE']; +const PHASE_COINS = ['BTC','ETH','SOL','HYPE']; +let taCoin = 'BTC', taTf = '1h', taLoading = false, taOIPrev = {}; - ws.onmessage = (e) => { - const msg = JSON.parse(e.data); - if (msg.event === 'positions_update') { - if (document.getElementById('page-overview').classList.contains('active')) { - renderOverview(msg.summary, msg.positions); - } - } - if (msg.event === 'notification' || msg.event === 'wallet_change') { - addNotification(msg.notification); - } - }; +// Narrative groupings +const NARRATIVES = { + all: { label: '🌐 All', coins: null }, + featured:{ label: '⭐ Featured', coins: ['BTC','ETH','SOL','HYPE'] }, + l1: { label: '⛓ L1s', coins: ['BTC','ETH','SOL','AVAX','SUI','APT','NEAR','SEI','INJ','TIA','ATOM'] }, + l2: { label: '🔷 L2s', coins: ['ARB','OP','MATIC','STRK','MANTA','BLAST','SCROLL','ZK'] }, + defi: { label: '🏦 DeFi', coins: ['UNI','AAVE','CRV','SNX','GMX','LDO','PENDLE','ENA','MKR','COMP','BAL','DYDX'] }, + meme: { label: '🐸 Meme', coins: ['DOGE','SHIB','WIF','PEPE','BONK','FLOKI','NEIRO','MOODENG','PNUT','GOAT','MEME','BRETT'] }, + ai: { label: '🤖 AI', coins: ['TAO','RNDR','FET','AGIX','OCEAN','WLD','ARKM','GRT'] }, + btceco: { label: '🟠 BTC Eco', coins: ['ORDI','SATS','MUBI','RATS','TRAC'] }, + gaming: { label: '🎮 Gaming', coins: ['AXS','IMX','GALA','SAND','MANA','ENJ','BEAM','RON'] }, +}; + +// ── Hyperliquid API ─────────────────────────────────────────────────────────── +async function hlPost(payload) { + const r = await fetch(HL, { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) }); + if (!r.ok) throw new Error(`HL API ${r.status}`); + return r.json(); +} +async function getClearinghouseState(w) { return hlPost({ type:'clearinghouseState', user:w }); } +async function getSpotState(w) { return hlPost({ type:'spotClearinghouseState', user:w }); } +async function getSpotMeta() { return hlPost({ type:'spotMetaAndAssetCtxs' }); } +async function getUserFills(w) { return hlPost({ type:'userFills', user:w }); } +async function getUserFunding(w, days=30) { return hlPost({ type:'userFunding', user:w, startTime:Date.now()-days*86400000 }); } +async function getLedgerUpdates(w, days=90) { return hlPost({ type:'userNonFundingLedgerUpdates', user:w, startTime:Date.now()-days*86400000 }); } +async function getMetaAndAssetCtxs() { + const now = Date.now(); + if (getMetaAndAssetCtxs._cache && now - getMetaAndAssetCtxs._ts < 2*60*1000) return getMetaAndAssetCtxs._cache; + const data = await hlPost({ type:'metaAndAssetCtxs' }); + getMetaAndAssetCtxs._cache = data; getMetaAndAssetCtxs._ts = now; + return data; +} +const _candleCache = new Map(); +async function getCandles(coin, interval='1h', days=7) { + const key = `${coin}|${interval}|${days}`; + const hit = _candleCache.get(key); + if (hit && Date.now() - hit.ts < 5*60*1000) return hit.data; + const endTime = Date.now(); + const data = await hlPost({ type:'candleSnapshot', req:{ coin, interval, startTime:endTime-days*86400000, endTime } }); + _candleCache.set(key, { data, ts: Date.now() }); + return data; +} +async function getOpenOrders(w) { return hlPost({ type:'openOrders', user:w }); } + +// ── Parsers ─────────────────────────────────────────────────────────────────── +function parsePositions(state) { + return (state.assetPositions||[]).map(pos=>{ + const p=pos.position||{}; const szi=parseFloat(p.szi||0); + if(szi===0) return null; + const lev=p.leverage||{}; + const posVal=parseFloat(p.positionValue||0), size=Math.abs(szi); + return { coin:p.coin, side:szi>0?'long':'short', size, + entry_price:parseFloat(p.entryPx||0), + mark_price: size>0 ? posVal/size : parseFloat(p.entryPx||0), + unrealized_pnl:parseFloat(p.unrealizedPnl||0), + leverage_type:lev.type||'cross', leverage_value:lev.value||1, + liquidation_price:parseFloat(p.liquidationPx||0), + margin_used:parseFloat(p.marginUsed||0), position_value:posVal, + cum_funding:parseFloat((p.cumFunding||{}).sinceOpen||0) }; + }).filter(Boolean); +} + +function buildMarketCtx(raw) { + if (!raw || !Array.isArray(raw)) return {}; + const [meta, ctxs=[]] = raw; + const result = {}; + (meta?.universe||[]).forEach((asset,i)=>{ + const ctx=ctxs[i]||{}, name=asset.name||''; + if (!name) return; + const f=parseFloat(ctx.funding||0); + result[name]={ funding_rate_8h:f, funding_apr:f*3*365*100, mark_price:parseFloat(ctx.markPx||0) }; + }); + return result; +} + +function scorePosition(p, marketCtx) { + let score=100; const flags=[], factors=[]; const isLong=p.side==='long'; + const markPx=p.mark_price>0?p.mark_price:(marketCtx[p.coin]?.mark_price||p.entry_price); + const lev=p.leverage_value||1; + // 1. Leverage + let levDed=0, levDetail, levStatus='pass'; + if (lev>15){levDed=25;levDetail=`${lev}× leverage is very high`;levStatus='fail';} + else if (lev>10){levDed=15;levDetail=`${lev}× leverage is elevated`;levStatus='warn';} + else if (lev>7) {levDed=8; levDetail=`${lev}× leverage`;levStatus='warn';} + else {levDetail=`${lev}× leverage — acceptable`;} + score-=levDed; if(levDed)flags.push(levDetail); + factors.push({name:'Leverage',status:levStatus,detail:levDetail,deduction:levDed}); + // 2. Cycle phase + let phaseDed=0, phaseDetail, phaseStatus='pass'; + if(typeof INTEL!=='undefined'&&INTEL.macro){ + const phase=(INTEL.macro.cycle_phase||'').toLowerCase(); + if(phase){ + if (isLong &&['distribution','markdown'].some(ph=>phase.includes(ph))){phaseDed=25;phaseDetail=`LONG in ${INTEL.macro.cycle_phase} phase`;phaseStatus='fail';} + else if (!isLong&&['accumulation','markup'].some(ph=>phase.includes(ph))) {phaseDed=25;phaseDetail=`SHORT in ${INTEL.macro.cycle_phase} phase`;phaseStatus='fail';} + else{phaseDetail=`${p.side} aligns with ${INTEL.macro.cycle_phase} phase`;} + }else{phaseDetail='Cycle phase not set';phaseStatus='na';} + }else{phaseDetail='INTEL data unavailable';phaseStatus='na';} + score-=phaseDed; if(phaseDed)flags.push(phaseDetail); + factors.push({name:'Cycle Phase',status:phaseStatus,detail:phaseDetail,deduction:phaseDed}); + // 3. Macro posture + let postureDed=0, postureDetail, postureStatus='pass'; + if(typeof INTEL!=='undefined'&&INTEL.macro){ + const posture=INTEL.macro.posture||''; + if(posture){ + if (isLong &&(posture==='SELL'||posture==='BEAR')){postureDed=15;postureDetail=`Macro posture is ${posture} — against long`;postureStatus='fail';} + else if (!isLong&&(posture==='BUY' ||posture==='BULL')){postureDed=15;postureDetail=`Macro posture is ${posture} — against short`;postureStatus='fail';} + else{postureDetail=`Macro posture: ${posture} — aligned`;} + }else{postureDetail='Posture not set';postureStatus='na';} + }else{postureDetail='INTEL data unavailable';postureStatus='na';} + score-=postureDed; if(postureDed)flags.push(postureDetail); + factors.push({name:'Macro Posture',status:postureStatus,detail:postureDetail,deduction:postureDed}); + // 4. Funding rate + let fundDed=0, fundDetail, fundStatus='pass'; + const ctx=marketCtx[p.coin]; + if(ctx){ + const apr=ctx.funding_apr; + if (isLong &&apr> 15){fundDed=20;fundDetail=`Paying ${apr.toFixed(1)}% APR funding`;fundStatus='fail';} + else if (isLong &&apr> 5){fundDed=10;fundDetail=`Paying ${apr.toFixed(1)}% APR funding`;fundStatus='warn';} + else if (!isLong&&apr<-15){fundDed=20;fundDetail=`Paying ${Math.abs(apr).toFixed(1)}% APR funding`;fundStatus='fail';} + else if (!isLong&&apr< -5){fundDed=10;fundDetail=`Paying ${Math.abs(apr).toFixed(1)}% APR funding`;fundStatus='warn';} + else{const earn=isLong?apr<=0:apr>=0;fundDetail=earn?`Earning ${Math.abs(apr).toFixed(1)}% APR funding`:`Funding ${Math.abs(apr).toFixed(1)}% APR — neutral`;} + }else{fundDetail='Funding data unavailable';fundStatus='na';} + score-=fundDed; if(fundDed)flags.push(fundDetail); + factors.push({name:'Funding Rate',status:fundStatus,detail:fundDetail,deduction:fundDed}); + // 5. MVRV zone + let mvrvDed=0, mvrvDetail, mvrvStatus='pass'; + if(typeof _mvrvData!=='undefined'&&_mvrvData?.coins?.[p.coin]){ + const zone=_mvrvData.coins[p.coin].zone; + if (isLong &&zone==='OVERHEATED') {mvrvDed=15;mvrvDetail=`${p.coin} MVRV overheated — long risk`;mvrvStatus='fail';} + else if (!isLong&&zone==='UNDERVALUED'){mvrvDed=15;mvrvDetail=`${p.coin} MVRV undervalued — short risk`;mvrvStatus='warn';} + else{mvrvDetail=`${p.coin} MVRV zone: ${zone||'neutral'}`;} + }else{mvrvDetail='MVRV data unavailable';mvrvStatus='na';} + score-=mvrvDed; if(mvrvDed)flags.push(mvrvDetail); + factors.push({name:'MVRV Zone',status:mvrvStatus,detail:mvrvDetail,deduction:mvrvDed}); + // 6. Smart money + let smDed=0, smDetail, smStatus='pass'; + if(typeof INTEL!=='undefined'){ + const sm=(INTEL.macro?.cohorts||[]).find(c=>c.name==='Smart Money'); + if(sm){ + if (isLong &&!sm.bull){smDed=10;smDetail='Smart money distributing — against long';smStatus='fail';} + else if (!isLong&& sm.bull){smDed=10;smDetail='Smart money accumulating — against short';smStatus='fail';} + else{smDetail=`Smart money ${sm.bull?'accumulating':'distributing'} — aligned`;} + }else{smDetail='Smart money data unavailable';smStatus='na';} + }else{smDetail='INTEL data unavailable';smStatus='na';} + score-=smDed; if(smDed)flags.push(smDetail); + factors.push({name:'Smart Money',status:smStatus,detail:smDetail,deduction:smDed}); + // 7. Liquidation proximity + let liqDed=0, liqDetail, liqStatus='pass'; + if(p.liquidation_price>0&&markPx>0){ + const liqPct=isLong?(markPx-p.liquidation_price)/markPx*100:(p.liquidation_price-markPx)/markPx*100; + if (liqPct<5) {liqDed=25;liqDetail=`Liquidation ${liqPct.toFixed(1)}% away — critical`;liqStatus='fail';} + else if (liqPct<10){liqDed=15;liqDetail=`Liquidation ${liqPct.toFixed(1)}% away — close`;liqStatus='warn';} + else {liqDetail=`Liquidation ${liqPct.toFixed(1)}% away — safe`;} + }else{liqDetail='No liquidation risk (cross margin)';} + score-=liqDed; if(liqDed)flags.push(liqDetail); + factors.push({name:'Liq. Proximity',status:liqStatus,detail:liqDetail,deduction:liqDed}); + // 8. BMSB + let bmsbDed=0,bmsbDetail,bmsbStatus='pass'; + if(window._indData?.bmsb){const b=window._indData.bmsb;if(isLong&&b.signal==='BEAR'){bmsbDed=15;bmsbDetail=`Price below BMSB — bear regime against long`;bmsbStatus='fail';}else if(isLong&&b.signal==='NEUTRAL'){bmsbDed=5;bmsbDetail=`Price at BMSB edge — weak support`;bmsbStatus='warn';}else if(!isLong&&b.signal==='BULL'){bmsbDed=10;bmsbDetail=`Price above BMSB — bull regime against short`;bmsbStatus='warn';}else{bmsbDetail=b.signal==='BULL'?`Price above BMSB — bull regime`:`Price below BMSB — bear regime`;}}else{bmsbDetail='BMSB data unavailable';bmsbStatus='na';} + score-=bmsbDed;if(bmsbDed)flags.push(bmsbDetail); + factors.push({name:'BMSB',status:bmsbStatus,detail:bmsbDetail,deduction:bmsbDed}); + // 9. Fear & Greed + let fgDed=0,fgDetail,fgStatus='pass'; + if(window._indData?.fear_greed){const fg=window._indData.fear_greed;if(isLong&&fg.zone==='EXTREME_GREED'){fgDed=10;fgDetail=`F&G ${fg.value} — extreme greed, longs crowded`;fgStatus='warn';}else if(!isLong&&fg.zone==='EXTREME_FEAR'){fgDed=10;fgDetail=`F&G ${fg.value} — extreme fear, shorts crowded`;fgStatus='warn';}else{fgDetail=`F&G ${fg.value} (${fg.classification})`;}}else{fgDetail='F&G data unavailable';fgStatus='na';} + score-=fgDed;if(fgDed)flags.push(fgDetail); + factors.push({name:'Fear & Greed',status:fgStatus,detail:fgDetail,deduction:fgDed}); + // 10. Pi Cycle Top + let piDed=0,piDetail,piStatus='pass'; + if(window._indData?.pi_cycle){const pi=window._indData.pi_cycle;if(isLong&&pi.signal==='TOP'){piDed=20;piDetail=`Pi Cycle Top fired — major distribution zone`;piStatus='fail';}else if(isLong&&pi.signal==='WARNING'){piDetail=`Pi Cycle ${pi.proximity}% to top — approaching distribution`;piStatus='warn';}else{piDetail=`Pi Cycle ${pi.proximity}% to top`;}}else{piDetail='Pi Cycle data unavailable';piStatus='na';} + score-=piDed;if(piDed)flags.push(piDetail); + factors.push({name:'Pi Cycle',status:piStatus,detail:piDetail,deduction:piDed}); + score=Math.max(0,Math.min(100,score)); + return { score, grade:score>=70?'OK':score>=40?'CAUTION':'RISKY', + cls:score>=70?'health-ok':score>=40?'health-caution':'health-risky', flags, factors }; +} + +let _posHealthData={}; +function showHealthModal(coin){ + const d=_posHealthData[coin]; if(!d)return; + let modal=document.getElementById('health-modal'); + if(!modal){ + modal=document.createElement('div'); + modal.id='health-modal'; + modal.className='health-modal-overlay'; + modal.innerHTML=`
    +
    +
    + + +
    + +
    +
    + +
    +
    out of 100
    +
    +
    +
    `; + modal.onclick=()=>modal.style.display='none'; + document.body.appendChild(modal); + } + document.getElementById('hm-coin').textContent=d.coin; + const sb=document.getElementById('hm-side');sb.textContent=d.side.toUpperCase();sb.className=`side-badge ${d.side}`; + const sc=document.getElementById('hm-score');sc.textContent=d.score;sc.className=`health-modal-big-score ${d.cls}`; + const gr=document.getElementById('hm-grade');gr.textContent=d.grade;gr.className=`health-badge ${d.cls}`; + document.getElementById('hm-factors').innerHTML=d.factors.map(f=>{ + const icon=f.status==='pass'?'✓':f.status==='fail'?'✗':f.status==='warn'?'!':'—'; + const dedStr=f.deduction>0?`−${f.deduction}`:f.status==='pass'?'✓':'—'; + const dedCls=f.deduction>0?'hm-ded-neg':f.status==='pass'?'hm-ded-pos':'hm-ded-na'; + return`
    +
    ${icon} +
    ${f.name}
    ${f.detail}
    +
    ${dedStr}
    `; + }).join(''); + modal.style.display='flex'; +} + +function riskSummaryHtml(positions, marketCtx) { + if (!positions.length) return ''; + const scored=positions.map(p=>({...p,h:scorePosition(p,marketCtx)})); + if (!scored.some(p=>p.h.grade!=='OK')) return ''; + const totalNtl=scored.reduce((a,p)=>a+(p.position_value||0),0); + const portScore=totalNtl>0 + ?Math.round(scored.reduce((a,p)=>a+p.h.score*(p.position_value||0),0)/totalNtl) + :Math.round(scored.reduce((a,p)=>a+p.h.score,0)/scored.length); + const portCls=portScore>=70?'health-ok':portScore>=40?'health-caution':'health-risky'; + const allFlags=scored.sort((a,b)=>a.h.score-b.h.score) + .flatMap(p=>p.h.flags.map(f=>`${p.coin} ${p.side.toUpperCase()}: ${f}`)).slice(0,4); + return `
    +
    +
    +
    ${portScore}
    +
    Portfolio
    Health
    +
    +
    + ${scored.map(p=>`${p.coin} ${p.h.score}`).join('')} +
    +
    +
    ${allFlags.map(f=>`
    ⚠ ${f}
    `).join('')}
    +
    `; +} +function parseAccountSummary(state) { + // In unified accounts crossMarginSummary exists but its accountValue is 0 (no separate perp wallet). + // Use marginSummary for per-stat display; isUnified flag controls totalPortfolio calc below. + const isUnified = !!state.crossMarginSummary; + const m = state.marginSummary || {}; + return { account_value:parseFloat(m.accountValue||0), total_margin_used:parseFloat(m.totalMarginUsed||0), + total_ntl_pos:parseFloat(m.totalNtlPos||0), withdrawable:parseFloat(state.withdrawable||0), isUnified }; +} +function parseFills(fills) { + return (fills||[]).map(f=>({time:f.time,coin:f.coin,side:f.side,dir:f.dir||'',price:parseFloat(f.px||0),size:parseFloat(f.sz||0),fee:parseFloat(f.fee||0),closed_pnl:parseFloat(f.closedPnl||0)})).sort((a,b)=>b.time-a.time); +} +function parseFunding(funding) { + return (funding||[]).map(f=>({time:f.time,coin:(f.delta||{}).coin,funding_rate:parseFloat((f.delta||{}).fundingRate||0),usdc:parseFloat((f.delta||{}).usdc||0)})).sort((a,b)=>b.time-a.time); +} +function parseLedger(ledger) { + return (ledger||[]).map(e=>{ const d=e.delta||{}; const usdc=parseFloat(d.usdc||0); return {time:e.time,type:d.type||'',usdc,direction:usdc>=0?'inflow':'outflow',hash:d.hash||''}; }).sort((a,b)=>b.time-a.time); } -function setStatus(online) { - document.getElementById('ws-status').className = 'status-dot' + (online ? '' : ' off'); +// Spot fills have coin = "@N" (spot market index). Map @N → coin name via spot universe. +function buildSpotIndexMap(spotMetaAndCtxs) { + const universe = (spotMetaAndCtxs?.[0]?.universe) || []; + const map = {}; + universe.forEach((u, i) => { map['@'+i] = u.name.split('/')[0]; }); + return map; +} +function parseSpotBalances(state, spotMetaAndCtxs) { + if (!state?.balances) return { balances:[], usdcBalance:0 }; + const [spotMeta, assetCtxs=[]] = spotMetaAndCtxs || []; + const universe = spotMeta?.universe || []; + const priceMap = {}; + universe.forEach((u, i) => { + const px = parseFloat(assetCtxs[i]?.midPx || assetCtxs[i]?.markPx || 0); + if (px) priceMap[u.name.split('/')[0]] = px; + }); + const usdcEntry = state.balances.find(b => b.coin === 'USDC'); + const usdcBalance = parseFloat(usdcEntry?.total || 0); + const balances = state.balances + .filter(b => b.coin !== 'USDC' && parseFloat(b.total) > 0) + .map(b => { + const total = parseFloat(b.total); + const entryNtl = parseFloat(b.entryNtl || 0); + const avgEntry = total > 0 && entryNtl > 0 ? entryNtl / total : 0; + const currentPrice = priceMap[b.coin] || 0; + const value = currentPrice * total; + const unrealizedPnl = currentPrice > 0 && avgEntry > 0 ? (currentPrice - avgEntry) * total : 0; + const pnlPct = entryNtl > 0 ? unrealizedPnl / entryNtl * 100 : 0; + return { coin:b.coin, total, hold:parseFloat(b.hold||0), avgEntry, entryNtl, currentPrice, unrealizedPnl, pnlPct, value }; + }) + .filter(b => b.value > 0.01 || b.entryNtl > 0) + .sort((a,b) => b.value - a.value); + return { balances, usdcBalance }; +} +function tagFills(fills, spotIndexMap) { + return (fills||[]).map(f => { + const isSpot = f.coin.startsWith('@'); + return { ...f, isSpot, type: isSpot ? 'SPOT' : 'PERP', coin: isSpot ? (spotIndexMap[f.coin] || f.coin) : f.coin }; + }); +} + +function parseMarketData([meta, assetCtxs]) { + const universe = meta.universe || []; + return universe.map((asset, i) => { + const ctx = assetCtxs[i] || {}; + const markPx = parseFloat(ctx.markPx || ctx.midPx || 0); + const prevPx = parseFloat(ctx.prevDayPx || markPx); + const changePct = prevPx > 0 ? ((markPx - prevPx) / prevPx * 100) : 0; + const oi = parseFloat(ctx.openInterest || 0); + const oiUsd = oi * markPx; + const volume = parseFloat(ctx.dayNtlVlm || 0); + const funding = parseFloat(ctx.funding || 0); + return { + coin: asset.name, + price: markPx, + prev_price: prevPx, + change_pct: changePct, + oi: oi, + oi_usd: oiUsd, + volume: volume, + funding: funding, + funding_apr: funding * 3 * 365 * 100, + }; + }).filter(d => d.price > 0); +} + +// ── Phase Detector (Wyckoff) ────────────────────────────────────────────────── +function detectPhase(candles) { + if (!candles||candles.length<20) return {phase:'NEUTRAL',confidence:0,price_trend:'flat',volume_trend:'neutral',range_compression:false,signals:['Not enough data'],score:0}; + const closes=candles.map(c=>parseFloat(c.c)),volumes=candles.map(c=>parseFloat(c.v)); + const highs=candles.map(c=>parseFloat(c.h)),lows=candles.map(c=>parseFloat(c.l)); + const n=candles.length, price=closes.at(-1); + + // EMAs + const ema20=iEMA(closes,20); + const ema50=closes.length>=50?iEMA(closes,50):null; + const ema200=closes.length>=200?iEMA(closes,200):null; + const e20=ema20.at(-1), e20p=ema20.at(-Math.min(6,n)); + const e50=ema50?ema50.at(-1):null, e50p=ema50?ema50.at(-Math.min(6,n)):null; + const e200=ema200?ema200.at(-1):null; + const aboveE20=price>e20, aboveE50=ema50?price>e50:aboveE20; + const aboveE200=e200?price>e200:null; + const e20Slope=(e20-e20p)/e20p; + const e50Slope=e50&&e50p?(e50-e50p)/e50p:0; + + // Price change over last ~20% of period + const lb=Math.max(5,Math.floor(n*0.2)); + const pctChg=(price-closes[n-lb-1])/(closes[n-lb-1]||1); + + // Volume: last quarter vs first quarter + const q=Math.max(4,Math.floor(n/4)); + const avgV=arr=>arr.reduce((a,b)=>a+b,0)/arr.length; + const volRatio=avgV(volumes.slice(-q))/Math.max(avgV(volumes.slice(0,q)),1); + + // ATR range compression + const atrArr=iATR(highs,lows,closes,14); + const atrNow=atrArr.at(-1)||0; + const atrEarly=avgV(atrArr.slice(0,Math.floor(n/4)).filter(Boolean))||atrNow||1; + const atrRatio=atrNow/atrEarly; + const rangeCompressed=atrRatio<0.65; + + // RSI + const rsiVal=iRSI(closes).filter(v=>v!==null).at(-1)||50; + + // MACD histogram + const {hist:macdHist}=iMACD(closes); + const mh=macdHist.at(-1), mhPrev=macdHist.at(-2); + + // Consecutive close direction (last 5 candles) + const last5=closes.slice(-5); + const consecUp=last5.every((c,i)=>i===0||c>=last5[i-1]); + const consecDn=last5.every((c,i)=>i===0||c<=last5[i-1]); + + const signals=[]; + let score=0; + let bullCount=0, bearCount=0; + + // 1. EMA stack (weight 0.25) + if(aboveE20&&aboveE50){score+=0.25;bullCount++;signals.push('Above EMA 20 & 50 — bullish structure');} + else if(!aboveE20&&!aboveE50){score-=0.25;bearCount++;signals.push('Below EMA 20 & 50 — bearish structure');} + else{score+=aboveE20?0.05:-0.05;signals.push('Mixed EMA alignment');} + + // 2. EMA 200 long-term context (weight 0.12) + if(aboveE200===true){score+=0.12;bullCount++;signals.push('Above EMA 200 — long-term bullish');} + else if(aboveE200===false){score-=0.12;bearCount++;signals.push('Below EMA 200 — long-term bearish');} + + // 3. EMA slope (weight 0.15 + 0.08) + if(e20Slope>0.004){score+=0.15;bullCount++;signals.push('EMA 20 rising — momentum building');} + else if(e20Slope<-0.004){score-=0.15;bearCount++;signals.push('EMA 20 declining — momentum fading');} + if(e50&&e50Slope>0.002){score+=0.08;bullCount++;} + else if(e50&&e50Slope<-0.002){score-=0.08;bearCount++;} + + // 4. MACD histogram (weight 0.2) + if(mh>0&&mh>mhPrev){score+=0.2;bullCount++;signals.push(`MACD expanding bullish (hist +${mh.toFixed(5)})`);} + else if(mh>0){score+=0.08;signals.push(`MACD bullish fading (hist +${mh.toFixed(5)})`);} + else if(mh<0&&mh60){score+=0.12;bullCount++;signals.push(`RSI ${rsiVal.toFixed(0)} — bullish momentum`);} + else if(rsiVal<40){score-=0.12;bearCount++;signals.push(`RSI ${rsiVal.toFixed(0)} — bearish momentum`);} + else{signals.push(`RSI ${rsiVal.toFixed(0)} — neutral zone`);} + if(rsiVal>75){score-=0.08;signals.push('RSI overbought — caution');} + else if(rsiVal<25){score+=0.08;signals.push('RSI oversold — potential reversal');} + + // 6. Recent price change (weight 0.18) + if(pctChg>0.04){score+=0.18;bullCount++;signals.push(`Price +${(pctChg*100).toFixed(1)}% recent`);} + else if(pctChg<-0.04){score-=0.18;bearCount++;signals.push(`Price ${(pctChg*100).toFixed(1)}% recent`);} + else{signals.push(`Price flat (${(pctChg*100).toFixed(1)}%)`);} + + // 7. Volume vs trend (weight 0.18) + const volTrend=volRatio>1.3?'expanding':volRatio<0.75?'contracting':'neutral'; + if(aboveE20&&volTrend==='expanding'){score+=0.18;bullCount++;signals.push(`Vol ${volRatio.toFixed(1)}x avg — expanding in uptrend (markup)`);} + else if(!aboveE20&&volTrend==='expanding'){score-=0.18;bearCount++;signals.push(`Vol ${volRatio.toFixed(1)}x avg — expanding in downtrend (markdown)`);} + else if(volTrend==='contracting'&&Math.abs(pctChg)<0.03){score+=0.15;bullCount++;signals.push('Low vol + tight range — accumulation zone');} + else if(volTrend==='contracting'&&pctChg<-0.02){score-=0.08;signals.push('Shrinking vol on drop — exhaustion / base');} + + // 8. Consecutive close direction (weight 0.1) + if(consecUp){score+=0.1;bullCount++;signals.push('5 consecutive up closes — strong momentum');} + else if(consecDn){score-=0.1;bearCount++;signals.push('5 consecutive down closes — strong selling');} + + // 9. Range compression bonus for accumulation + if(rangeCompressed){ + signals.push(`ATR at ${(atrRatio*100).toFixed(0)}% of avg — compressed range`); + if(Math.abs(score)<0.3){score+=0.08;signals.push('Coiling inside tight range — breakout approaching');} + } + + // 10. Signal alignment bonus: when 5+ signals agree, boost confidence + const agreement=Math.max(bullCount,bearCount); + const alignBonus=agreement>=5?0.1:agreement>=4?0.06:agreement>=3?0.03:0; + score=Math.max(-1,Math.min(1,score))*(1+alignBonus*(score>0?1:-1)); + + score=Math.max(-1,Math.min(1,score)); + const phase=score>=0.45?'MARKUP':score>=0.12?'ACCUMULATION':score<=-0.45?'MARKDOWN':score<=-0.12?'DISTRIBUTION':'NEUTRAL'; + const price_trend=pctChg>0.03?'up':pctChg<-0.03?'down':'flat'; + const candleBonus=0.04*Math.min(n/60,1); + return {phase,confidence:+Math.min(Math.abs(score)+candleBonus,1).toFixed(3),price_trend,volume_trend:volTrend,range_compression:rangeCompressed,signals,score:+score.toFixed(4)}; } // ── Navigation ──────────────────────────────────────────────────────────────── +function openDrawer() { + document.getElementById('nav-drawer')?.classList.add('open'); + document.getElementById('nav-overlay')?.classList.add('open'); + document.getElementById('hamburger-btn')?.classList.add('open'); +} +function closeDrawer() { + document.getElementById('nav-drawer')?.classList.remove('open'); + document.getElementById('nav-overlay')?.classList.remove('open'); + document.getElementById('hamburger-btn')?.classList.remove('open'); +} +function toggleDrawer() { + const d = document.getElementById('nav-drawer'); + if (d?.classList.contains('open')) closeDrawer(); else openDrawer(); +} +function initMobileTableLabels() { + function labelTable(t) { + const headers = [...t.querySelectorAll('thead th')].map(th => th.textContent.trim()); + if (!headers.length) return; + t.querySelectorAll('tbody tr').forEach(row => + [...row.querySelectorAll('td')].forEach((td, i) => { if (headers[i] && !td.dataset.label) td.dataset.label = headers[i]; }) + ); + } + document.querySelectorAll('table.mobile-cards').forEach(labelTable); + new MutationObserver(ms => { + for (const m of ms) for (const n of m.addedNodes) + if (n.nodeType === 1) { + n.querySelectorAll?.('table.mobile-cards').forEach(labelTable); + if (n.matches?.('table.mobile-cards')) labelTable(n); + } + }).observe(document.body, { childList: true, subtree: true }); +} function navigate(page) { - document.querySelectorAll('.page').forEach(p => p.classList.remove('active')); - document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); - document.getElementById(`page-${page}`).classList.add('active'); - document.querySelector(`[data-page="${page}"]`).classList.add('active'); - - const loaders = { overview: loadOverview, trades: loadTrades, funding: loadFunding, flows: loadFlows, phases: loadPhases, watchlist: loadWatchlist, settings: loadSettings }; - if (loaders[page]) loaders[page](); + try { + closeDrawer(); + if (currentPage === 'monitor' && page !== 'monitor') disconnectWS(); + document.querySelectorAll('.page').forEach(p=>p.classList.remove('active')); + document.querySelectorAll('.nav-item,.bottom-nav-item,.topbar-tab,.drawer-item').forEach(n=>n.classList.remove('active')); + const pageEl = document.getElementById(`page-${page}`); + if (!pageEl) return; + pageEl.classList.add('active'); + document.querySelectorAll(`[data-page="${page}"]`).forEach(el=>el.classList.add('active')); + currentPage = page; + const loaders={overview:loadOverview,trades:loadTrades,funding:loadFunding,flows:loadFlows,monitor:loadMonitor,markets:loadMarkets,phases:loadPhases,intel:typeof loadIntel!=='undefined'?loadIntel:null,watchlist:loadWatchlist,journal:typeof loadJournal!=='undefined'?loadJournal:null,indicators:typeof loadIndicators!=='undefined'?loadIndicators:null,smartmoney:typeof loadNansen!=='undefined'?loadNansen:null,analytics:typeof loadAnalytics!=='undefined'?loadAnalytics:null,signals:typeof loadSignals!=='undefined'?loadSignals:null}; + if(loaders[page]) loaders[page](); + } catch(e) { console.error('navigate error:', e); } } +function refreshAll(){navigate(currentPage);} // ── Overview ────────────────────────────────────────────────────────────────── +let overviewTab = 'summary'; +let _ovData = null; // cached for tab switching +let _spotEnriched = false; -async function loadOverview() { - const el = document.getElementById('overview-content'); - el.innerHTML = '
    Loading positions…
    '; +// Lazily compute spot cost basis from fill history for tokens with no entryNtl +async function enrichSpotCostBasis() { + const spotBals = _ovData?.spotBals; + const spotMetaRaw = _ovData?.spotMetaRaw; + if (!spotBals?.some(b => b.avgEntry === 0 && b.total > 0.000001)) return; + if (_spotEnriched) return; try { - const data = await fetch(`${API}/api/positions?wallet=${PRIMARY_WALLET}`).then(r => r.json()); - renderOverview(data.summary, data.positions); - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } -} - -function renderOverview(summary, positions) { - const pnlClass = summary.total_ntl_pos >= 0 ? 'pos' : 'neg'; - document.getElementById('overview-content').innerHTML = ` -
    -
    -
    Account Value
    -
    ${fmt$(summary.account_value)}
    -
    Withdrawable: ${fmt$(summary.withdrawable)}
    -
    -
    -
    Total Notional
    -
    ${fmt$(summary.total_ntl_pos)}
    -
    Positions open: ${positions.length}
    -
    -
    -
    Margin Used
    -
    ${fmt$(summary.total_margin_used)}
    -
    ${summary.account_value > 0 ? ((summary.total_margin_used / summary.account_value)*100).toFixed(1) : 0}% of account
    -
    -
    -
    Unrealized PnL
    -
    ${fmt$(positions.reduce((a,p)=>a+p.unrealized_pnl,0))}
    -
    Across ${positions.length} position(s)
    + const spotIndexMap = buildSpotIndexMap(spotMetaRaw); + const rawFills = await getUserFills(currentWallet); + const spotFills = (rawFills || []) + .filter(f => f.coin?.startsWith('@') && spotIndexMap[f.coin]) + .map(f => ({ + coin: spotIndexMap[f.coin], + buy: f.side === 'B', + size: parseFloat(f.sz || 0), + price: parseFloat(f.px || 0), + time: f.time || 0, + })) + .filter(f => f.size > 0 && f.price > 0) + .sort((a, b) => a.time - b.time); + + // Average cost method per coin + const h = {}; // { coin: { shares, cost } } + for (const f of spotFills) { + if (!h[f.coin]) h[f.coin] = { shares: 0, cost: 0 }; + const c = h[f.coin]; + if (f.buy) { + c.cost += f.size * f.price; + c.shares += f.size; + } else if (c.shares > 0) { + const frac = Math.min(f.size / c.shares, 1); + c.cost *= (1 - frac); + c.shares = Math.max(c.shares - f.size, 0); + } + } + + let changed = false; + _ovData.spotBals = spotBals.map(b => { + if (b.avgEntry > 0) return b; + const c = h[b.coin]; + if (!c || c.shares < 1e-9) return b; + const avgEntry = c.cost / c.shares; + if (avgEntry <= 0) return b; + const unrealizedPnl = (b.currentPrice - avgEntry) * b.total; + const entryNtl = avgEntry * b.total; + const pnlPct = entryNtl > 0 ? unrealizedPnl / entryNtl * 100 : 0; + changed = true; + return { ...b, avgEntry, entryNtl, unrealizedPnl, pnlPct }; + }); + + if (changed) { + _spotEnriched = true; + _ovData.spotUnrPnl = _ovData.spotBals.reduce((a, b) => a + b.unrealizedPnl, 0); + _ovData.totalUnr = _ovData.positions.reduce((a, p) => a + p.unrealized_pnl, 0) + _ovData.spotUnrPnl; + if (['summary', 'spot'].includes(overviewTab)) renderOverviewTab(); + } + } catch (e) { + console.warn('[enrichSpot]', e); + } +} + +function setOverviewTab(tab) { + overviewTab = tab; + document.querySelectorAll('.ov-tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab)); + if (_ovData) renderOverviewTab(); +} + +function renderOverviewTab() { + const el = document.getElementById('ov-tab-body'); + if (!el || !_ovData) return; + const {s, positions, spotBals, usdcBalance, orders, totalPortfolio, spotTotalValue, spotUnrPnl, totalUnr, marketCtx={}} = _ovData; + + if (overviewTab === 'summary') { + el.innerHTML = ` +
    +
    Total Portfolio
    ${fmt$(totalPortfolio)}
    Perp ${fmt$(s.account_value)} · Spot ${fmt$(spotTotalValue)}
    +
    Unr. PnL
    ${fmt$(totalUnr)}
    Perp ${fmt$(positions.reduce((a,p)=>a+p.unrealized_pnl,0))} · Spot ${fmt$(spotUnrPnl)}
    +
    Perp Margin Used
    ${fmt$(s.total_margin_used)}
    ${s.account_value>0?((s.total_margin_used/s.account_value)*100).toFixed(1):0}% of perp acct
    +
    Withdrawable
    ${fmt$(s.withdrawable)}
    USDC spot ${fmt$(usdcBalance)}
    -
    + ${renderRecentPnLWidget(_ovData.recentFills||[])} +
    +
    +
    📈 Portfolio Growth — 7 Days
    +
    +
    + + + +
    +
    + + +
    +
    +
    +
    +
    ${chartMode==='perp'?'Perp acct':chartMode==='spot'?'Spot total':'Portfolio'}
    +
    7d Change
    +
    7d %
    +
    Rate
    fetching…
    +
    +
    +
    + ${(()=>{ + if (!positions.length) return ''; + const perpPnl = positions.reduce((a,p)=>a+p.unrealized_pnl,0); + return `
    +
    Perp Positions (${positions.length}) ${perpPnl>=0?'+':''}${fmt$(perpPnl)}
    +
    + + ${positions.map(p=>{ + const mark = marketCtx[p.coin]?.mark_price || p.mark_price || 0; + const pnlPct = p.entry_price>0 ? p.unrealized_pnl/(p.size*p.entry_price)*100 : 0; + return ` + + + + + + + + `; + }).join('')} +
    CoinSideSizeEntryNowPnLPnL %
    ${p.coin}${p.side==='long'?'LONG':'SHORT'}${p.size}${fmt$(p.entry_price)}${mark>0?fmtPrice(mark):'—'}${fmt$(p.unrealized_pnl)}${pnlPct>=0?'+':''}${pnlPct.toFixed(2)}%
    +
    `; + })()} + ${(()=>{ + if (!spotBals.length) return ''; + const spotPnl = spotBals.reduce((a,b)=>a+b.unrealizedPnl,0); + return `
    +
    Spot Holdings (${spotBals.length}) ${spotPnl!==0?`${spotPnl>=0?'+':''}${fmt$(spotPnl)}`:''} +
    +
    + + ${spotBals.map(b=>` + + + + + + + + `).join('')} + ${usdcBalance>0.01?` + + + + + + `:''} + +
    CoinAmountEntryNowValuePnLPnL %
    ${b.coin}${b.total.toLocaleString('en-US',{maximumFractionDigits:6})}${b.avgEntry>0?fmtPrice(b.avgEntry):'—'}${b.currentPrice>0?fmtPrice(b.currentPrice):'—'}${b.value>0?fmt$(b.value):'—'}${b.avgEntry>0?fmt$(b.unrealizedPnl):'—'}${b.avgEntry>0?(b.pnlPct>=0?'+':'')+b.pnlPct.toFixed(2)+'%':'—'}
    USDC${usdcBalance.toFixed(2)}$1.00${fmt$(usdcBalance)}
    +
    `; + })()}`; + requestAnimationFrame(() => renderPortfolioChart(totalPortfolio).catch(e => console.warn('[chart]', e))); + } -
    -
    Open Positions
    - ${positions.length === 0 ? '
    No open positions
    ' : ` -
    - - - - - + else if (overviewTab === 'perp') { + el.innerHTML = ` +
    +
    Account Value
    ${fmt$(s.account_value)}
    Withdrawable ${fmt$(s.withdrawable)}
    +
    Notional
    ${fmt$(s.total_ntl_pos)}
    ${positions.length} open
    +
    Margin Used
    ${fmt$(s.total_margin_used)}
    ${s.account_value>0?((s.total_margin_used/s.account_value)*100).toFixed(1):0}%
    +
    Unr. PnL
    ${fmt$(positions.reduce((a,p)=>a+p.unrealized_pnl,0))}
    +
    + ${riskSummaryHtml(positions, marketCtx)} +
    +
    Open Positions (${positions.length})
    + ${positions.length===0?'
    No open perp positions
    ':` +
    CoinSideSizeEntryLiq PriceUnr. PnLFundingLeverage
    + + ${(()=>{_posHealthData={};return positions;})().map(p=>{const h=scorePosition(p,marketCtx);_posHealthData[p.coin]={coin:p.coin,side:p.side,...h};const markPx=marketCtx[p.coin]?.mark_price||p.mark_price||0;const pnlPct=p.entry_price>0?p.unrealized_pnl/(p.size*p.entry_price)*100:0;return` + + + + + + + + + + + + `;}).join('')} +
    CoinSideSizeEntryNowLiqPnLPnL%LevAgeHealth
    ${p.coin}${p.side==='long'?'LONG':'SHORT'}${p.size}${fmt$(p.entry_price)}${markPx>0?fmtPrice(markPx):'—'}${p.liquidation_price>0?fmt$(p.liquidation_price):'—'}${fmt$(p.unrealized_pnl)}${pnlPct>=0?'+':''}${pnlPct.toFixed(2)}%${p.leverage_value}x${typeof posAgeBadge==='function'?posAgeBadge(p.coin):''}${h.score} ${h.grade}
    `} +
    + ${orders.length>0?`
    +
    Open Orders (${orders.length})
    +
    + + ${orders.map(o=>` + + + + + `).join('')} +
    CoinSideSizeLimit
    ${o.coin}${o.side==='B'?'BUY':'SELL'}${o.sz}${o.limitPx?fmt$(parseFloat(o.limitPx)):'—'}
    +
    `:'
    No open orders
    '} + ${renderOrderScenarios(positions, orders)}`; + } + + else if (overviewTab === 'spot') { + const totalSpotPnl = spotBals.reduce((a,b)=>a+b.unrealizedPnl,0); + el.innerHTML = ` +
    +
    Spot Total Value
    ${fmt$(spotTotalValue)}
    ${spotBals.length} tokens
    +
    USDC Balance
    ${fmt$(usdcBalance)}
    Available cash
    +
    Tokens Value
    ${fmt$(spotBals.reduce((a,b)=>a+b.value,0))}
    excl. USDC
    +
    Unr. PnL
    ${fmt$(totalSpotPnl)}
    vs avg entry
    +
    +
    +
    Spot Holdings
    + ${spotBals.length===0&&usdcBalance<0.01?'
    No spot holdings
    ':` +
    + - ${positions.map(p => ` - - - - - - - - - + ${spotBals.map(b=>` + + + + + + + `).join('')} + ${usdcBalance>0.01?` + + + + + + `:''} -
    CoinAmountAvg EntryPrice NowValuePnL $PnL %
    ${p.coin}${p.side.toUpperCase()}${p.size}${fmt$(p.entry_price)}${p.liquidation_price > 0 ? fmt$(p.liquidation_price) : '—'}${fmt$(p.unrealized_pnl)}${p.cum_funding.toFixed(4)}${p.leverage_value}x ${p.leverage_type}
    ${b.coin}${b.total.toLocaleString('en-US',{maximumFractionDigits:6})}${b.avgEntry>0?fmtPrice(b.avgEntry):'—'}${b.currentPrice>0?fmtPrice(b.currentPrice):'—'}${b.value>0?fmt$(b.value):'—'}${b.avgEntry>0?fmt$(b.unrealizedPnl):'—'}${b.avgEntry>0?(b.pnlPct>=0?'+':'')+b.pnlPct.toFixed(2)+'%':'—'}
    USDC${usdcBalance.toFixed(2)}$1.00${fmt$(usdcBalance)}
    -
    `} -
    - `; +
    `} + `; + } } -// ── Trades ──────────────────────────────────────────────────────────────────── +async function loadOverview(){ + const el=document.getElementById('overview-content'); + if(!_silentRefresh) el.innerHTML=loading(); + _spotEnriched = false; + try{ + setStatus(true); + const [state, orders, spotStateRaw, spotMetaRaw, perpMetaRaw, rawFillsOv] = await Promise.all([ + getClearinghouseState(currentWallet), getOpenOrders(currentWallet), + getSpotState(currentWallet).catch(()=>null), + getSpotMeta().catch(()=>null), + getMetaAndAssetCtxs().catch(()=>null), + getUserFills(currentWallet).catch(()=>null), + ]); + checkOrderFills(orders); + const s = parseAccountSummary(state), positions = parsePositions(state); + if (typeof pmClearStale === 'function') pmClearStale(positions.map(p => p.coin)); + const marketCtx = buildMarketCtx(perpMetaRaw); + const {balances:spotBals, usdcBalance} = parseSpotBalances(spotStateRaw, spotMetaRaw); + // Fill missing spot prices from perp mark price (HYPE and others may lack spot ctx price) + spotBals.forEach(b => { + if (b.currentPrice > 0) return; + const px = livePrices[b.coin] || marketCtx[b.coin]?.mark_price || 0; + if (!px) return; + b.currentPrice = px; + b.value = px * b.total; + if (b.avgEntry > 0) { + b.unrealizedPnl = (px - b.avgEntry) * b.total; + b.pnlPct = b.entryNtl > 0 ? b.unrealizedPnl / b.entryNtl * 100 : 0; + } + }); + const spotTotalValue = spotBals.reduce((a,b)=>a+b.value,0) + usdcBalance; + const spotUnrPnl = spotBals.reduce((a,b)=>a+b.unrealizedPnl,0); + const perpUnrPnl = positions.reduce((a,p)=>a+p.unrealized_pnl,0); + const totalUnr = perpUnrPnl + spotUnrPnl; + // Unified: USDC lives in spot wallet (already in spotTotalValue); perp side only adds floating PnL + // Legacy: separate perp wallet (account_value) + spot + const totalPortfolio = s.isUnified + ? spotTotalValue + perpUnrPnl + : s.account_value + spotTotalValue; -async function loadTrades() { - const el = document.getElementById('trades-content'); - el.innerHTML = '
    Loading trades…
    '; - try { - const data = await fetch(`${API}/api/trades?wallet=${PRIMARY_WALLET}&limit=200`).then(r => r.json()); - const trades = data.trades; - const totalPnl = trades.reduce((a,t) => a+t.closed_pnl, 0); - const wins = trades.filter(t => t.closed_pnl > 0).length; - const wr = trades.length > 0 ? (wins/trades.length*100).toFixed(1) : 0; + const spotIndexMapOv = buildSpotIndexMap(spotMetaRaw); + const recentFills = rawFillsOv ? tagFills(parseFills(rawFillsOv), spotIndexMapOv) : []; + _ovData = {s, positions, spotBals, usdcBalance, orders, totalPortfolio, spotTotalValue, spotUnrPnl, totalUnr, marketCtx, spotMetaRaw, recentFills}; + window._rawMeta = perpMetaRaw; + if(typeof fetchIndicators==='function')fetchIndicators().catch(()=>{}); + enrichSpotCostBasis().catch(()=>{}); el.innerHTML = ` -
    -
    -
    Total Trades
    -
    ${trades.length}
    -
    -
    -
    Realized PnL
    -
    ${fmt$(totalPnl)}
    -
    -
    -
    Win Rate
    -
    ${wr}%
    -
    ${wins} wins / ${trades.length - wins} losses
    +
    +
    Portfolio
    +
    + + +
    -
    -
    Fill History
    -
    - - - - - - ${trades.slice(0,100).map(t => ` - - - - - - - - - `).join('')} - -
    TimeCoinSidePriceSizeFeeClosed PnL
    ${fmtTime(t.time)}${t.coin}${t.side==='B'?'BUY':'SELL'}${fmt$(t.price)}${t.size}${t.fee.toFixed(4)}${t.closed_pnl !== 0 ? fmt$(t.closed_pnl) : '—'}
    +
    `; + + renderOverviewTab(); + setRefreshTime(); + }catch(e){if(!_silentRefresh){el.innerHTML=err(e);setStatus(false);}} +} + +// ── Trades ──────────────────────────────────────────────────────────────────── +let _tradesCoinFilter = ''; +let _tradesSubTab = 'perp'; + +function switchTradeTab(tab) { + _tradesSubTab = tab; + document.querySelectorAll('.trades-subtab-btn').forEach(b => { + const on = b.dataset.tab === tab; + b.style.background = on ? 'var(--accent)' : 'var(--surface2)'; + b.style.color = on ? '#fff' : 'var(--text-muted)'; + b.style.borderColor = on ? 'var(--accent)' : 'var(--border)'; + }); + renderTradesTables(); +} + +function renderTradesTables() { + const wrap = document.getElementById('trades-table-wrap'); + if (!wrap || !window._tradesData) return; + const { perpFills, spotFills } = window._tradesData; + const q = (_tradesCoinFilter || '').toUpperCase().trim(); + const match = f => !q || f.coin.toUpperCase().includes(q); + const isPerp = _tradesSubTab === 'perp'; + const fills = (isPerp ? perpFills : spotFills).filter(match); + const shown = fills.slice(0, 100); + wrap.innerHTML = ` +
    + + + ${isPerp ? '' : ''} + + ${shown.length===0 + ? `` + : shown.map(f=>` + + + + + ${isPerp + ? ` + + ` + : ` + + + `} + `).join('')} + +
    TimeCoinSidePriceSizePnLFeeQtyTotalPnLFee
    No ${isPerp?'perp':'spot'} fills${q?' for '+q:''}
    ${fmtTime(f.time)}${f.coin}${f.side==='B'?'BUY':'SELL'}${fmtPrice(f.price)}${f.size}${f.closed_pnl!==0?fmt$(f.closed_pnl):'—'}${f.fee>0?'−'+fmt$(f.fee):'—'}${f.size}${fmt$(f.price*f.size)}${f.closed_pnl!==0?fmt$(f.closed_pnl):'—'}${f.fee>0?'−'+fmt$(f.fee):'—'}
    + ${fills.length>100?`
    Showing 100 of ${fills.length} — filter by coin to narrow down
    `:''}`; +} + +async function loadTrades(){ + const el=document.getElementById('trades-content'); + if(!_silentRefresh) el.innerHTML=loading(); + try{ + const [rawFills, spotMetaRaw] = await Promise.all([ + getUserFills(currentWallet), + getSpotMeta().catch(()=>null) + ]); + const spotIndexMap = buildSpotIndexMap(spotMetaRaw); + const allFills = tagFills(parseFills(rawFills), spotIndexMap).sort((a,b)=>b.time-a.time); + const perpFills = allFills.filter(f=>!f.isSpot); + const spotFills = allFills.filter(f=>f.isSpot); + const perpPnl = perpFills.reduce((a,f)=>a+f.closed_pnl,0); + const totalFees = allFills.reduce((a,f)=>a+f.fee,0); + const wins=perpFills.filter(f=>f.closed_pnl>0).length; + const losses=perpFills.filter(f=>f.closed_pnl<0).length; + window._tradesData = { perpFills, spotFills }; + + el.innerHTML=` +
    +
    + PnL ${fmt$(perpPnl)} + Win ${wins+losses>0?(wins/(wins+losses)*100).toFixed(0):0}% (${wins}W / ${losses}L) + Fees −${fmt$(totalFees)}
    + +
    +
    + ${['perp','spot'].map((t,i)=>{const on=_tradesSubTab===t; return ``;}).join('')}
    - `; - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } +
    `; + + renderTradesTables(); + setRefreshTime(); + }catch(e){if(!_silentRefresh)el.innerHTML=err(e);} } // ── Funding ─────────────────────────────────────────────────────────────────── +let _fundingDays = 30; -async function loadFunding() { - const el = document.getElementById('funding-content'); - el.innerHTML = '
    Loading funding…
    '; - try { - const data = await fetch(`${API}/api/funding?wallet=${PRIMARY_WALLET}&days=30`).then(r => r.json()); - const byCoin = data.by_coin; - const coinRows = Object.entries(byCoin).sort((a,b) => a[1]-b[1]); +async function loadFunding(){ + const el=document.getElementById('funding-content'); + if(!_silentRefresh) el.innerHTML=loading(); + try{ + const days = _fundingDays; + const allFunding = parseFunding(await getUserFunding(currentWallet, Math.max(days, 90))); + const cutoff = Date.now() - days * 86400000; + const funding = allFunding.filter(f => f.time >= cutoff); - el.innerHTML = ` -
    -
    -
    Total Funding Paid (30d)
    -
    ${fmt$(data.total_usdc)}
    -
    Positive = received, Negative = paid
    + const totalUsdc = funding.reduce((a,f)=>a+f.usdc,0); + const byCoin = {}; + for(const f of funding){const c=f.coin||'?'; byCoin[c]=(byCoin[c]||0)+f.usdc;} + const coinRows = Object.entries(byCoin).sort((a,b)=>a[1]-b[1]); + const byDay = {}; + for(const f of funding){ + const day = new Date(f.time).toISOString().slice(0,10); + byDay[day]=(byDay[day]||0)+f.usdc; + } + const dayRows = Object.entries(byDay).sort((a,b)=>a[0].localeCompare(b[0])); + const badCoins = coinRows.filter(([,u])=>u<-0.5).slice(0,5); + const topEarner = [...coinRows].reverse().find(([,u])=>u>0.5); + + el.innerHTML=` +
    +
    + Net ${totalUsdc>=0?'+':''}${fmt$(totalUsdc)} + ${topEarner?`Earning ${topEarner[0]}`:''} + ${badCoins[0]?`Costing ${badCoins[0][0]}`:''}
    -
    -
    Most Costly Position
    -
    ${coinRows.length ? coinRows[0][0] : '—'}
    -
    ${coinRows.length ? fmt$(coinRows[0][1]) : ''}
    +
    + ${[7,30,90].map(d=>{const on=_fundingDays===d; return ``;}).join('')}
    -
    -
    -
    Funding by Coin
    -
    - - - - ${coinRows.map(([coin,usdc]) => ` - - - - `).join('')} - -
    CoinTotal USDC
    ${coin}${fmt$(usdc)}
    -
    -
    -
    -
    Recent Funding Payments
    -
    - - - - ${data.funding.slice(0,50).map(f => ` - - - - - - `).join('')} - -
    TimeCoinRateUSDC
    ${fmtTime(f.time)}${f.coin||'?'}${(f.funding_rate*100).toFixed(4)}%${f.usdc.toFixed(4)}
    -
    -
    + ${badCoins.length>0?`
    + ${badCoins.map(([c,u])=>` + ${c} + ${fmt$(u)} + paid + `).join('')} +
    `:''} +
    +
    - `; - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } +
    +
    By Coin
    +
    + + ${coinRows.map(([c,u])=>{ + const cf=funding.filter(f=>(f.coin||'?')===c); + const avgRate=cf.length?cf.reduce((a,f)=>a+f.funding_rate,0)/cf.length:0; + return ` + + + + `;}).join('')} + +
    CoinNet USDCAvg Rate
    ${c}${u>=0?'+':''}${fmt$(u)}${avgRate>=0?'+':''}${(avgRate*100).toFixed(3)}%
    +
    +
    +
    Recent Payments
    +
    + + ${funding.slice(0,30).map(f=>` + + + + + `).join('')} +
    TimeCoinRateUSDC
    ${fmtTime(f.time)}${f.coin||'?'}${f.funding_rate>=0?'+':''}${(f.funding_rate*100).toFixed(3)}%${f.usdc>=0?'+':''}${f.usdc.toFixed(3)}
    +
    `; + + setTimeout(()=>{ + const ctx = document.getElementById('funding-chart'); + if (!ctx) return; + if (ctx._chart) ctx._chart.destroy(); + ctx._chart = new Chart(ctx, { + type:'bar', + data:{ + labels: dayRows.map(([d])=>d.slice(5)), + datasets:[{ + data: dayRows.map(([,v])=>v), + backgroundColor: dayRows.map(([,v])=>v>=0?'rgba(74,222,128,0.45)':'rgba(248,113,113,0.45)'), + borderColor: dayRows.map(([,v])=>v>=0?'rgba(74,222,128,0.8)':'rgba(248,113,113,0.8)'), + borderWidth:1, borderRadius:3, + }] + }, + options:{ + animation:false, responsive:true, maintainAspectRatio:false, + plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>fmt$(c.raw)}}}, + scales:{ + x:{ticks:{color:'var(--text-muted)',font:{size:9}},grid:{display:false}}, + y:{ticks:{color:'var(--text-muted)',font:{size:9},callback:v=>fmt$(v)},grid:{color:'var(--border)'}}, + }, + }, + }); + },50); + setRefreshTime(); + }catch(e){if(!_silentRefresh)el.innerHTML=err(e);} } -// ── Flows ───────────────────────────────────────────────────────────────────── +// ── My Flows ────────────────────────────────────────────────────────────────── +const _idrRateCache = {}; -async function loadFlows() { - const el = document.getElementById('flows-content'); - el.innerHTML = '
    Loading flows…
    '; - try { - const data = await fetch(`${API}/api/flows?wallet=${PRIMARY_WALLET}&days=90`).then(r => r.json()); - el.innerHTML = ` -
    -
    -
    Total Inflow (90d)
    -
    ${fmt$(data.total_inflow)}
    -
    -
    -
    Total Outflow (90d)
    -
    −${fmt$(data.total_outflow)}
    -
    -
    -
    Net Flow
    -
    ${fmt$(data.net)}
    -
    +async function _fetchHistoricalIdrRates(dates) { + const fallback = usdToIdr || 16000; + const needed = [...new Set(dates)].filter(d => !_idrRateCache[d]); + await Promise.all(needed.map(async date => { + try { + const r = await fetch(`https://api.frankfurter.app/${date}?from=USD&to=IDR`); + const d = await r.json(); + _idrRateCache[date] = d.rates?.IDR || fallback; + } catch { _idrRateCache[date] = fallback; } + })); +} + +async function loadFlows(){ + const el=document.getElementById('flows-content'); + if(!_silentRefresh) el.innerHTML=loading(); + try{ + const flows=parseLedger(await getLedgerUpdates(currentWallet,90)); + + const dates = flows.map(f => new Date(f.time).toISOString().slice(0,10)); + await _fetchHistoricalIdrRates(dates); + + const getRate = date => _idrRateCache[date] || usdToIdr || 16000; + const fmtIdr = (usd, rate) => { + const v = usd * rate; + const absV = Math.abs(v); + const sign = usd >= 0 ? '+' : '−'; + if (absV >= 1e9) return sign + 'Rp ' + (absV/1e9).toFixed(1) + 'M'; + if (absV >= 1e6) return sign + 'Rp ' + (absV/1e6).toFixed(1) + 'jt'; + if (absV >= 1e3) return sign + 'Rp ' + (absV/1e3).toFixed(0) + 'rb'; + return sign + 'Rp ' + absV.toFixed(0); + }; + const flowLabel = type => { + const t = (type||'').toLowerCase(); + if (t.includes('deposit')) return '⬇ Deposit'; + if (t.includes('withdraw')) return '⬆ Withdraw'; + if (t.includes('transfer')) return '⇄ Transfer'; + if (t.includes('liquidat')) return '⚡ Liquidation'; + return type || '—'; + }; + + const totalIn=flows.filter(f=>f.usdc>0).reduce((a,f)=>a+f.usdc,0); + const totalOut=flows.filter(f=>f.usdc<0).reduce((a,f)=>a+f.usdc,0); + const net=totalIn+totalOut; + let totalInIdr=0, totalOutIdr=0; + for(const f of flows){ + const r=getRate(new Date(f.time).toISOString().slice(0,10)); + if(f.usdc>0) totalInIdr+=f.usdc*r; else totalOutIdr+=f.usdc*r; + } + + const sorted = [...flows].sort((a,b)=>a.time-b.time); + let running = 0; + const withBal = sorted.map(f => { running+=f.usdc; return {...f, balance:running}; }).reverse(); + + el.innerHTML=` +
    + In +${fmt$(totalIn)} (${fmtIdr(totalIn,totalInIdr/Math.max(totalIn,0.001))}) + Out −${fmt$(Math.abs(totalOut))} (${fmtIdr(totalOut,Math.abs(totalOutIdr)/Math.max(Math.abs(totalOut),0.001))}) + Net ${net>=0?'+':''}${fmt$(net)} +
    + ${flows.length===0?'
    No deposit/withdrawal activity in 90 days
    ':` +
    +
    + + ${withBal.map(f=>{ + const date=new Date(f.time).toISOString().slice(0,10); + const txRate=getRate(date); + return ` + + + + + + `;}).join('')} + +
    TimeTypeUSDCIDR at timeBalance
    ${fmtTime(f.time)}${flowLabel(f.type)}${f.usdc>=0?'+':'−'}${fmt$(Math.abs(f.usdc))}${fmtIdr(f.usdc,txRate)}
    @${Math.round(txRate).toLocaleString('id-ID')}
    ${fmt$(f.balance)}
    -
    Flow History
    -
    - - - - - - ${data.flows.map(f => ` - - - - - - - `).join('')} - -
    TimeDirectionTypeAmountTx
    ${fmtTime(f.time)}${f.direction.toUpperCase()}${f.type}${fmt$(Math.abs(f.usdc))}${f.hash ? f.hash.slice(0,12)+'…' : '—'}
    +
    +
    `}`; + + setTimeout(()=>{ + const ctx = document.getElementById('flows-chart'); + if (!ctx || flows.length===0) return; + if (ctx._chart) ctx._chart.destroy(); + let cum=0; + const pts = sorted.map(f=>{cum+=f.usdc;return cum;}); + ctx._chart = new Chart(ctx, { + type:'line', + data:{ + labels: sorted.map(f=>fmtTime(f.time)), + datasets:[{ + data: pts, + borderColor:'rgba(124,106,255,0.85)', + backgroundColor:'rgba(124,106,255,0.1)', + fill:true, tension:0.3, + pointRadius: pts.length<25?3:0, + borderWidth:2, + }] + }, + options:{ + animation:false, responsive:true, maintainAspectRatio:false, + plugins:{legend:{display:false},tooltip:{callbacks:{label:c=>fmt$(c.raw)}}}, + scales:{ + x:{display:false}, + y:{ticks:{color:'var(--text-muted)',font:{size:9},callback:v=>fmt$(v)},grid:{color:'var(--border)'}}, + }, + }, + }); + },50); + setRefreshTime(); + }catch(e){if(!_silentRefresh)el.innerHTML=err(e);} +} + + +// ── Global Markets / Money Flows ────────────────────────────────────────────── +async function loadMarkets(){ + const el=document.getElementById('markets-content'); + if(!_silentRefresh) el.innerHTML=loading(); + try{ + const raw=await getMetaAndAssetCtxs(); + allMarketData=parseMarketData(raw); + renderMarkets(); + setRefreshTime(); + }catch(e){if(!_silentRefresh)el.innerHTML=err(e);} +} + +function renderMarkets(){ + const el=document.getElementById('markets-content'); + const data=filterByNarrative(allMarketData); + const sorted=sortMarket(data); + + // Featured 4 + const featured=['BTC','ETH','SOL','HYPE']; + const featuredData=featured.map(c=>allMarketData.find(d=>d.coin===c)).filter(Boolean); + + const totalOI=allMarketData.reduce((a,d)=>a+d.oi_usd,0); + const totalVol=allMarketData.reduce((a,d)=>a+d.volume,0); + const gainers=allMarketData.filter(d=>d.change_pct>0).length; + const losers=allMarketData.filter(d=>d.change_pct<0).length; + + const indRow=(()=>{const ind=window._indData;if(!ind)return'';const fg=ind.fear_greed,bmsb=ind.bmsb;const fgCls=fg?(fg.value<30?'neg':fg.value>70?'pos':'muted'):'muted';const bmsbCls=bmsb?(bmsb.signal==='BULL'?'pos':bmsb.signal==='BEAR'?'neg':'yellow'):'muted';return`
    ${fg?`
    F&G${fg.value}${fg.classification}
    `:''}${bmsb?`
    BMSB${bmsb.signal}${bmsb.signal}
    `:''}${ind.pi_cycle?`
    Pi Cycle${ind.pi_cycle.proximity}%${ind.pi_cycle.signal}
    `:''}
    `;})(); + el.innerHTML=`${indRow} + +
    +
    Total OI
    ${fmtB(totalOI)}
    Open Interest
    +
    24h Volume
    ${fmtB(totalVol)}
    +
    Market
    ${gainers}↑ ${losers}↓
    ${allMarketData.length} assets
    +
    + + +
    ⭐ Featured
    + + + +
    + ${Object.entries(NARRATIVES).map(([key,n])=>``).join('')} +
    + + + ${flowSummaryBar(data)} + + +
    +
    +
    Market Data (${data.length})
    +
    + ${['volume','oi','change','funding'].map(k=>``).join('')}
    - `; - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } +
    + + + + + ${sorted.slice(0,100).map((d,i)=>marketRow(d,i+1)).join('')} +
    #CoinPrice24h %OI (USD)24h VolFunding/8hBias
    +
    `; } -// ── Phases ──────────────────────────────────────────────────────────────────── +function marketFeaturedCard(d){ + const chg=d.change_pct; + return `
    +
    + ${d.coin} + ${chg>=0?'+':''}${chg.toFixed(2)}% +
    +
    ${fmtPrice(d.price)}
    +
    + OI + ${fmtB(d.oi_usd)} +
    +
    + Vol 24h + ${fmtB(d.volume)} +
    +
    + Funding + ${(d.funding*100).toFixed(4)}% +
    +
    `; +} -async function loadPhases() { - const el = document.getElementById('phases-content'); - el.innerHTML = '
    Detecting phases…
    '; - try { - const data = await fetch(`${API}/api/phase?wallet=${PRIMARY_WALLET}`).then(r => r.json()); - const phases = data.phases; +function marketBias(fr, chgPct) { + let score = 0; + // Funding rate (primary signal — 8h rate from HL) + if (fr > 0.001) score += 3; // >0.1%/8h — very crowded long + else if (fr > 0.0003) score += 2; // >0.03%/8h — elevated long + else if (fr > 0.00005) score += 1; // >0.005%/8h — mild long + else if (fr < -0.001) score -= 3; + else if (fr < -0.0003) score -= 2; + else if (fr < -0.00005) score -= 1; + // Price momentum (secondary) + if (chgPct > 5) score += 2; + else if (chgPct > 2) score += 1; + else if (chgPct < -5) score -= 2; + else if (chgPct < -2) score -= 1; + if (score >= 4) return '🔥 Strong Bull'; + if (score >= 2) return '🟢 Bullish'; + if (score >= 1) return '🔵 Mild Bull'; + if (score <= -4) return '🔥 Strong Bear'; + if (score <= -2) return '🔴 Bearish'; + if (score <= -1) return '🟡 Mild Bear'; + return '⚪ Neutral'; +} - el.innerHTML = ` +function marketRow(d,rank){ + const chg=d.change_pct; + const fr=d.funding; + const frClass=Math.abs(fr)<0.001?'funding-neu':fr>=0?'funding-pos':'funding-neg'; + const bias=marketBias(fr, chg); + return ` + ${rank} + ${d.coin} + ${fmtPrice(d.price)} + ${chg>=0?'+':''}${chg.toFixed(2)}% + ${fmtB(d.oi_usd)} + ${fmtB(d.volume)} + ${(fr*100).toFixed(4)}% + ${bias} + `; +} + +// ── Market Detail Modal ─────────────────────────────────────────────────────── +let _mktDetailCoin = null; + +function closeMktDetail() { + _mktDetailCoin = null; + document.getElementById('mkt-detail-overlay').classList.remove('open'); +} + +async function openMarketDetail(coin) { + _mktDetailCoin = coin; + const overlay = document.getElementById('mkt-detail-overlay'); + const inner = document.getElementById('mkt-detail-inner'); + const d = allMarketData.find(x => x.coin === coin); + if (!d) return; + + const chgCls = d.change_pct >= 0 ? 'pos' : 'neg'; + const chgStr = (d.change_pct >= 0 ? '+' : '') + d.change_pct.toFixed(2) + '%'; + + inner.innerHTML = ` +
    +
    + ${coin} + ${fmtPrice(d.price)} + ${chgStr} +
    + +
    +
    ${spinnerHtml()} Analyzing ${coin}…
    `; + overlay.classList.add('open'); + + const [candlesRes, lsrRes, oiRes] = await Promise.allSettled([ + getCandles(coin, '1h', 7), + typeof fetchBinanceLSR === 'function' ? fetchBinanceLSR(coin) : Promise.resolve(null), + typeof fetchBinanceOI === 'function' ? fetchBinanceOI(coin, '1h', 48) : Promise.resolve(null), + ]); + + if (_mktDetailCoin !== coin) return; // superseded by a newer click + + const candles = candlesRes.status === 'fulfilled' ? candlesRes.value : null; + const lsr = lsrRes.status === 'fulfilled' ? lsrRes.value : null; + const oiHist = oiRes.status === 'fulfilled' ? oiRes.value : null; + + inner.innerHTML = ` +
    +
    + ${coin} + ${fmtPrice(d.price)} + ${chgStr} + ${marketBias(d.funding, d.change_pct)} +
    + +
    +
    + ${_mktStats(d)} + ${_mktPhase(candles)} + ${_mktTA(candles, d)} + ${_mktLSR(lsr)} + ${_mktOI(oiHist, d)} +
    `; +} + +function _mktStats(d) { + const apr = (d.funding * 3 * 365 * 100).toFixed(1); + const frSign = d.funding >= 0 ? '+' : ''; + return `
    +
    OI (USD)
    ${fmtB(d.oi_usd)}
    +
    24h Volume
    ${fmtB(d.volume)}
    +
    Funding /8h
    ${frSign}${(d.funding*100).toFixed(4)}%
    +
    Funding APR
    ${frSign}${apr}%
    +
    Mark Price
    ${fmtPrice(d.price)}
    +
    Prev Close
    ${fmtPrice(d.prev_price)}
    +
    `; +} + +function _mktPhase(candles) { + if (!candles || candles.length < 20) return `
    +
    Phase Analysis (Wyckoff)
    +
    Not enough candle data
    +
    `; + + const p = detectPhase(candles); + const confPct = Math.round(p.confidence * 100); + const phaseColors = {ACCUMULATION:'#38bdf8',MARKUP:'#4ade80',DISTRIBUTION:'#facc15',MARKDOWN:'#f87171',NEUTRAL:'#666'}; + const col = phaseColors[p.phase] || '#666'; + return `
    +
    Phase Analysis (Wyckoff · 1h · 7d)
    +
    +
    + ${p.phase} + Confidence: ${confPct}% +
    +
    +
    ${p.signals.slice(0,6).map(s=>`
    ${s}
    `).join('')}
    +
    +
    `; +} + +function _mktTA(candles, d) { + if (!candles || candles.length < 20) return `
    +
    Technical Analysis
    +
    Not enough candle data
    +
    `; + + const closes = candles.map(c => parseFloat(c.c)); + const highs = candles.map(c => parseFloat(c.h)); + const lows = candles.map(c => parseFloat(c.l)); + const ema20 = iEMA(closes, 20); + const ema50 = closes.length >= 50 ? iEMA(closes, 50) : null; + const ema200 = closes.length >= 200 ? iEMA(closes, 200) : null; + const {macd, hist} = iMACD(closes); + const rsiArr = iRSI(closes); + const bbArr = iBB(closes); + const stoch = iStoch(highs, lows, closes); + const atrArr = iATR(highs, lows, closes); + + const price = closes.at(-1); + const e20 = ema20.at(-1); + const e50 = ema50 ? ema50.at(-1) : null; + const e200 = ema200 ? ema200.at(-1) : null; + const rsiVal = rsiArr.filter(v => v !== null).at(-1) || 50; + const bbLast = bbArr.filter(v => v !== null).at(-1); + const stochK = stoch.k.filter(v => v !== null).at(-1) || 50; + const stochD = stoch.d.filter(v => v !== null).at(-1) || 50; + const atrLast = atrArr.filter(v => v !== null).at(-1) || 0; + + const rows = [ + ['EMA', sigEMA(price, e20, e50, e200)], + ['RSI', sigRSI(rsiVal)], + ['MACD', sigMACD(hist, macd)], + ...(bbLast ? [['BB', sigBB(bbLast)]] : []), + ['Stoch', sigStoch(stochK, stochD)], + ['ATR', sigATR(atrLast, price)], + ['Funding', sigFunding(d.funding)], + ]; + + return `
    +
    Technical Signals (1h · 7d)
    +
    + ${rows.map(([name, s]) => `
    + ${name} + ${s.label} + ${s.sub} +
    `).join('')} +
    +
    `; +} + +function _mktLSR(lsr) { + if (!lsr) return `
    +
    Long / Short Ratio (Binance)
    +
    Not available for this coin
    +
    `; + + const longPct = lsr.longPct.toFixed(1); + const shortPct = lsr.shortPct.toFixed(1); + const bias = lsr.longPct > 55 ? '🟢 Long dominant' : lsr.longPct < 45 ? '🔴 Short dominant' : '⚪ Balanced'; + return `
    +
    Long / Short Ratio (Binance Global Accounts)
    +
    +
    + ${bias} + Ratio ${lsr.ratio.toFixed(2)} +
    +
    +
    +
    +
    +
    + Longs ${longPct}% + Shorts ${shortPct}% +
    +
    +
    `; +} + +function _mktOI(oiHist, d) { + if (!oiHist || !oiHist.length) return `
    +
    Open Interest History (Binance)
    +
    Not available for this coin
    +
    `; + + const vals = oiHist.map(x => x.oi); + const first = vals[0], last = vals.at(-1); + const oiChg = first > 0 ? ((last - first) / first * 100) : 0; + const oiChgCls= oiChg >= 0 ? 'pos' : 'neg'; + const stroke = oiChg >= 0 ? 'var(--green)' : 'var(--red)'; + const fill = oiChg >= 0 ? 'rgba(74,222,128,0.15)' : 'rgba(248,113,113,0.15)'; + const spark = typeof _svgSparkline === 'function' + ? _svgSparkline(vals, stroke, fill, 600, 50) + : ''; + + return `
    +
    Open Interest History — last 48h (Binance · 1h)
    +
    +
    + OI now: ${fmtB(last)} + ${oiChg >= 0 ? '+' : ''}${oiChg.toFixed(1)}% over 48h +
    + ${spark} +
    +
    `; +} + +function flowSummaryBar(data){ + if(!data.length) return ''; + const gainers=data.filter(d=>d.change_pct>0); + const losers=data.filter(d=>d.change_pct<0); + const gVol=gainers.reduce((a,d)=>a+d.volume,0); + const lVol=losers.reduce((a,d)=>a+d.volume,0); + const total=gVol+lVol||1; + const gPct=Math.round(gVol/total*100); + const lPct=100-gPct; + return `
    +
    Money Flow (Volume Distribution)
    +
    + ▲ ${gainers.length} gainers — ${fmtB(gVol)} (${gPct}%) + ▼ ${losers.length} losers — ${fmtB(lVol)} (${lPct}%) +
    +
    +
    +
    +
    +
    `; +} + +function filterByNarrative(data){ + const n=NARRATIVES[activeNarrative]; + if(!n||!n.coins) return data; + return data.filter(d=>n.coins.includes(d.coin)); +} +function sortMarket(data){ + const k={volume:'volume',oi:'oi_usd',change:'change_pct',funding:'funding'}[marketSortKey]||'volume'; + return [...data].sort((a,b)=>Math.abs(b[k])-Math.abs(a[k])); +} +function setNarrative(key){ + activeNarrative=key; + renderMarkets(); +} +function setSortKey(key){ + marketSortKey=key; + renderMarkets(); +} + +// ── Phase Detector ──────────────────────────────────────────────────────────── +async function loadPhases(interval){ + if(interval) phaseInterval=interval; + const el=document.getElementById('phases-content'); + if(!_silentRefresh) el.innerHTML=loading(); + try{ + const state=await getClearinghouseState(currentWallet); + const positions=parsePositions(state); + const posCoinSet=new Set(positions.map(p=>p.coin)); + // Always show PHASE_COINS; append any open-position coins not in that list + const allCoins=[...PHASE_COINS,...positions.map(p=>p.coin).filter(c=>!PHASE_COINS.includes(c))]; + // 1h: 14d (336 candles) is enough for Wyckoff + CVD; reduces payload vs old 30d/720 candles + const days={'1h':14,'4h':30,'1d':90}[phaseInterval]||14; + + el.innerHTML=`
    -
    Market Phase Detection
    -
    - Interval: -
    - - - -
    -
    +
    Phase Detector
    +
    ${['1h','4h','1d'].map(iv=>``).join('')}
    -
    - ${phases.length === 0 ? '
    No positions to analyze
    ' : - phases.map(p => phaseCard(p)).join('')} +
    ${typeof renderMoneyFlowCard === 'function' ? renderMoneyFlowCard() : ''}
    +
    ${typeof renderHYPECard === 'function' ? renderHYPECard() : ''}
    +
    +
    🔄 CVD + OI Market Scanner
    +
    ${spinnerHtml()} Scanning ${allCoins.join(', ')}…
    -
    -
    Phase Key
    -
    - 🔵 Accumulation — quiet buying, tight range, low volume - 🚀 Markup — trend up with volume - 🟡 Distribution — topping, smart money selling +
    +
    ${spinnerHtml()} Analyzing ${allCoins.join(', ')} on ${phaseInterval}…
    +
    +
    Phase Key
    +
    + 🔵 Accumulation — quiet buying, tight range + 🚀 Markup — uptrend with expanding volume + 🟡 Distribution — topping, smart money exits 🔻 Markdown — downtrend with volume ⚪ Neutral — no clear signal
    +
    `; + + // Render placeholder cards immediately so the user sees coins appearing as data arrives + const pcards=document.getElementById('phase-cards'); + if(pcards) pcards.innerHTML=allCoins.map(coin=>` +
    +
    ${coin}
    +
    fetching…
    +
    `).join(''); + + const phases=new Array(allCoins.length); + const [results, phaseMeta] = await Promise.all([ + Promise.allSettled(allCoins.map(async (coin,i)=>{ + const candles=await getCandles(coin,phaseInterval,days); + const result={coin,hasPosition:posCoinSet.has(coin),candles,...detectPhase(candles)}; + phases[i]=result; + // Update this coin's card as soon as its data is ready + const cardEl=document.getElementById(`pc-${coin}`); + if(cardEl) cardEl.outerHTML=phaseCard(result); + return result; + })), + getMetaAndAssetCtxs().catch(()=>null), + ]); + // Fill any slots that errored + results.forEach((r,i)=>{ if(!phases[i]) phases[i]=r.status==='fulfilled'?r.value:{coin:allCoins[i],hasPosition:posCoinSet.has(allCoins[i]),phase:'NEUTRAL',confidence:0,signals:['fetch failed']}; }); + + // CVD+OI scanner + const cvdEl=document.getElementById('cvd-oi-table'); + if(cvdEl && phaseMeta && typeof calcCVD==='function'){ + const universe=phaseMeta[0]?.universe||[], ctxs=phaseMeta[1]||[]; + const cvdRows=phases.map(ph=>{ + if(!ph.candles||!ph.candles.length) return null; + const idx=universe.findIndex(a=>a.name===ph.coin); + const rawCtx=idx>=0?ctxs[idx]:null; + const markPx=rawCtx?parseFloat(rawCtx.markPx||rawCtx.midPx||0):0; + const currentOI=rawCtx?parseFloat(rawCtx.openInterest||0)*markPx:0; + const opens=ph.candles.map(c=>parseFloat(c.o)); + const closes=ph.candles.map(c=>parseFloat(c.c)); + const highs=ph.candles.map(c=>parseFloat(c.h)); + const lows=ph.candles.map(c=>parseFloat(c.l)); + const vols=ph.candles.map(c=>parseFloat(c.v)); + const cvdArr=calcCVD(opens,closes,highs,lows,vols); + const lb=4; + const recentCVD=cvdArr.at(-1)-(cvdArr.length>lb?cvdArr[cvdArr.length-1-lb]:0); + const priceChg=closes.length>lb?(closes.at(-1)-closes[closes.length-1-lb])/closes[closes.length-1-lb]*100:0; + // getPrevOI must be called BEFORE saveOIPoint to get a true "previous" value + const prevOI=getPrevOI(ph.coin); + if(currentOI>0) saveOIPoint(ph.coin,currentOI); + const oiChgPct=(prevOI&&prevOI>0&¤tOI>0)?(currentOI-prevOI)/prevOI*100:null; + const sig=sigCVDOI(priceChg,recentCVD,oiChgPct); + return{coin:ph.coin,hasPosition:ph.hasPosition,price:closes.at(-1),priceChg, + cvdUp:recentCVD>0,cvdArr,closes,currentOI,oiChgPct,sig, + oiHistory:typeof _oiHistGet==='function'?(_oiHistGet()[ph.coin]||[]):[]}; + }).filter(Boolean); + + // Fetch Binance OI history in parallel and replace localStorage fallback when available + if(typeof fetchBinanceOI==='function'){ + const oiTf = phaseInterval==='1d'?'1d':phaseInterval==='4h'?'4h':'1h'; + const oiLimit = phaseInterval==='1d'?90:phaseInterval==='4h'?60:60; + const oiResults=await Promise.allSettled(cvdRows.map(r=>fetchBinanceOI(r.coin,oiTf,oiLimit))); + oiResults.forEach((res,i)=>{ + if(res.status==='fulfilled'&&res.value?.length) cvdRows[i].oiHistory=res.value; + }); + } + + // SVG sparklines render instantly — no Chart.js init cost + cvdEl.innerHTML = renderCVDOITable(cvdRows) + + (typeof renderCVDOICharts === 'function' ? renderCVDOICharts(cvdRows) : ''); + if(typeof loadMoneyFlowSignals==='function') loadMoneyFlowSignals(allCoins); + if(typeof loadHYPEIntel==='function') loadHYPEIntel(phaseMeta); + } + + setRefreshTime(); + }catch(e){if(!_silentRefresh)el.innerHTML=err(e);} +} + +function phaseCard(p){ + const icons={ACCUMULATION:'🔵',MARKUP:'🚀',DISTRIBUTION:'🟡',MARKDOWN:'🔻',NEUTRAL:'⚪'}; + const conf=Math.round((p.confidence||0)*100); + return `
    +
    +
    ${p.coin}
    + ${p.hasPosition?'POSITION':''} +
    +
    ${icons[p.phase]||'⚪'} ${p.phase}
    +
    Confidence${conf}%
    +
    +
    Price: ${p.price_trend} · Vol: ${p.volume_trend} · Score: ${p.score}
    +
      ${(p.signals||[]).map(s=>`
    • ${s}
    • `).join('')}
    +
    `; +} + +// ── Watchlist ───────────────────────────────────────────────────────────────── +function getWatchlist(){try{return JSON.parse(localStorage.getItem('hype_watchlist')||'[]');}catch{return[];}} +function saveWatchlist(wl){localStorage.setItem('hype_watchlist',JSON.stringify(wl));} + +async function loadWatchlist(){ + const el=document.getElementById('watchlist-content'); + const wallets=getWatchlist(); + el.innerHTML=` +
    Wallet Watchlist
    +
    Add Wallet
    +
    + + +
    - `; - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } +
    +
    ${wallets.length===0?'
    Add any Hyperliquid wallet address to start tracking it.
    ':'
    '+spinnerHtml()+' Loading…
    '}
    `; + if(wallets.length>0){ + const snaps=await Promise.allSettled(wallets.map(async w=>{ + const state=await getClearinghouseState(w.address); + return {...w,summary:parseAccountSummary(state),positions:parsePositions(state)}; + })); + document.getElementById('wallet-list').innerHTML=snaps.map((r,i)=>walletRow(r.status==='fulfilled'?r.value:{...wallets[i],error:true})).join(''); + } } -function phaseCard(p) { - const icons = { ACCUMULATION:'🔵', MARKUP:'🚀', DISTRIBUTION:'🟡', MARKDOWN:'🔻', NEUTRAL:'⚪' }; - const icon = icons[p.phase] || '⚪'; - const conf = Math.round((p.confidence||0)*100); - return ` -
    -
    ${p.coin}
    -
    - ${icon} ${p.phase} -
    -
    -
    - Confidence - ${conf}% +function walletRow(w){ + const isPrimary=w.address.toLowerCase()===DEFAULT_WALLET.toLowerCase(); + const s=w.summary||{},positions=w.positions||[]; + const totalPnl=positions.reduce((a,p)=>a+p.unrealized_pnl,0); + return `
    +
    +
    +
    ${w.label||w.address.slice(0,10)+'…'} + ${isPrimary?'PRIMARY':''}
    -
    +
    ${w.address}
    -
    - Price: ${p.price_trend}   - Volume: ${p.volume_trend} -
    -
      - ${(p.signals||[]).map(s=>`
    • ${s}
    • `).join('')} -
    + ${!isPrimary?``:''}
    - `; + ${w.error?'
    Failed to load
    ':` +
    +
    Account Value
    ${fmt$(s.account_value||0)}
    +
    Positions
    ${positions.length}
    +
    Unr. PnL
    ${fmt$(totalPnl)}
    +
    Coins
    ${positions.map(p=>p.coin).join(', ')||'—'}
    +
    `} +
    `; +} + +function addWatchWallet(){ + const addr=document.getElementById('add-addr').value.trim().toLowerCase(); + const label=document.getElementById('add-label').value.trim(); + if(!addr||!addr.startsWith('0x')){alert('Enter a valid 0x address');return;} + const wl=getWatchlist(); + if(wl.find(w=>w.address===addr)){alert('Already in watchlist');return;} + wl.push({address:addr,label:label||addr.slice(0,8)+'…',added_at:Date.now()}); + saveWatchlist(wl);loadWatchlist(); +} +function removeWatchWallet(addr){ + if(!confirm('Remove?')) return; + saveWatchlist(getWatchlist().filter(w=>w.address!==addr));loadWatchlist(); +} + +// ── Live Monitor + WebSocket ────────────────────────────────────────────────── +function connectWS() { + if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) return; + try { ws = new WebSocket(HL_WS); } catch(e) { scheduleReconnect(); return; } + ws.onopen = () => { + wsConnected = true; + _wsRetries = 0; + ws.send(JSON.stringify({method:'subscribe', subscription:{type:'allMids'}})); + setWSStatus(true); + if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } + }; + ws.onmessage = (e) => { + try { + const msg = JSON.parse(e.data); + if (msg.channel === 'allMids' && msg.data && msg.data.mids) handleMids(msg.data.mids); + } catch(_) {} + }; + ws.onclose = () => { wsConnected = false; setWSStatus(false); if (monitorActive) scheduleReconnect(); }; + ws.onerror = () => { ws.close(); }; +} + +function scheduleReconnect() { + if (wsReconnectTimer) return; + const delay = Math.min(3000 * Math.pow(2, _wsRetries++), 30000); + wsReconnectTimer = setTimeout(() => { wsReconnectTimer = null; if (monitorActive) connectWS(); }, delay); +} + +function disconnectWS() { + monitorActive = false; + if (wsReconnectTimer) { clearTimeout(wsReconnectTimer); wsReconnectTimer = null; } + if (ws) { ws.close(); ws = null; } + wsConnected = false; + setWSStatus(false); +} + +function setWSStatus(on) { + const dot = document.getElementById('ws-status'); + if (dot) dot.className = 'status-dot' + (on ? '' : ' off'); + const badge = document.getElementById('monitor-live-badge'); + if (badge) { badge.textContent = on ? '🟢 LIVE' : '🔴 Reconnecting…'; badge.style.color = on ? 'var(--green)' : 'var(--red)'; } +} + +function handleMids(mids) { + for (const [coin, rawPrice] of Object.entries(mids)) { + const price = parseFloat(rawPrice); + if (!price) continue; + const prev = livePrices[coin]; + livePrices[coin] = price; + if (!priceHistory[coin]) priceHistory[coin] = []; + priceHistory[coin].push(price); + if (priceHistory[coin].length > 40) priceHistory[coin].shift(); + refreshPriceRow(coin, price, prev); + } + refreshLivePnL(); + checkPriceAlerts(); + const ts = document.getElementById('monitor-ts'); + if (ts) ts.textContent = new Date().toLocaleTimeString(); +} + +function refreshPriceRow(coin, price, prev) { + const priceEl = document.getElementById('lp-' + coin); + if (!priceEl) return; + const dir = prev ? (price > prev ? 'up' : price < prev ? 'dn' : '') : ''; + priceEl.textContent = fmtPrice(price); + if (dir) { + priceEl.className = 'mono ' + (dir === 'up' ? 'pos ticker-flash-up' : 'neg ticker-flash-dn'); + setTimeout(() => { if (priceEl) priceEl.className = 'mono ' + (dir === 'up' ? 'pos' : 'neg'); }, 400); + } + const chgEl = document.getElementById('lc-' + coin); + if (chgEl && livePrevDay[coin]) { + const chg = ((price - livePrevDay[coin]) / livePrevDay[coin]) * 100; + chgEl.textContent = (chg >= 0 ? '+' : '') + chg.toFixed(2) + '%'; + chgEl.className = chg >= 0 ? 'pos' : 'neg'; + } + const sparkEl = document.getElementById('lsp-' + coin); + if (sparkEl && priceHistory[coin] && priceHistory[coin].length > 1) { + sparkEl.innerHTML = sparkline(priceHistory[coin]); + } +} + +function refreshLivePnL() { + for (const pos of livePositions) { + const price = livePrices[pos.coin]; + if (!price) continue; + const pnl = pos.side === 'long' ? (price - pos.entry_price) * pos.size : (pos.entry_price - price) * pos.size; + const key = pos.coin + pos.side; + const pnlEl = document.getElementById('lpnl-' + key); + const nowEl = document.getElementById('lnow-' + key); + if (pnlEl) { pnlEl.textContent = fmt$(pnl); pnlEl.className = pnl >= 0 ? 'pos mono' : 'neg mono'; } + if (nowEl) { nowEl.textContent = fmtPrice(price); nowEl.className = 'mono'; } + checkPnLMilestone(key, pos.coin, pos.side, pnl); + } +} + +function checkPnLMilestone(key, coin, side, pnl) { + if (!pnlThreshold || pnlThreshold <= 0) return; + const prev = livePnLSnapshot[key]; + livePnLSnapshot[key] = pnl; + if (prev === undefined) return; + const prevBucket = Math.floor(prev / pnlThreshold); + const nowBucket = Math.floor(pnl / pnlThreshold); + if (nowBucket === prevBucket) return; + const emoji = pnl >= 0 ? '🟢' : '🔴'; + sendTelegram(`${emoji} P&L Milestone — ${coin} ${side.toUpperCase()}\nP&L crossed ${fmt$(nowBucket * pnlThreshold)}\nCurrent: ${fmt$(pnl)} @ ${fmtPrice(livePrices[coin])}`); +} + +function sparkline(prices) { + const W = 56, H = 18; + const mn = Math.min(...prices), mx = Math.max(...prices), rng = mx - mn || 1; + const pts = prices.map((p, i) => `${((i / (prices.length - 1)) * W).toFixed(1)},${(H - ((p - mn) / rng) * H).toFixed(1)}`).join(' '); + const color = prices[prices.length - 1] >= prices[0] ? '#22c55e' : '#ef4444'; + return ``; +} + +function checkPriceAlerts() { + let changed = false; + for (const a of priceAlerts) { + if (a.triggered) continue; + const price = livePrices[a.coin]; + if (!price) continue; + if ((a.above && price >= a.target) || (!a.above && price <= a.target)) { + a.triggered = true; + changed = true; + const dir = a.above ? '▲ crossed above' : '▼ dropped below'; + logAlert(a.coin, dir, a.target, price); + sendTelegram(`🔔 Price Alert — ${a.coin}\n${dir} ${fmtPrice(a.target)}\nNow: ${fmtPrice(price)}`); + } + } + if (changed) { saveAlerts(); renderActiveAlerts(); } +} + +function logAlert(coin, desc, target, price) { + const log = document.getElementById('alert-log'); + if (!log) return; + const row = document.createElement('div'); + row.className = 'alert-log-row'; + row.innerHTML = `${new Date().toLocaleTimeString()} ${coin} ${desc} ${fmtPrice(target)} (now ${fmtPrice(price)})`; + log.prepend(row); + if (log.children.length > 30) log.removeChild(log.lastChild); +} + +function saveAlerts() { try { localStorage.setItem('hype_alerts', JSON.stringify(priceAlerts)); } catch(_) {} } +function loadAlerts() { try { priceAlerts = JSON.parse(localStorage.getItem('hype_alerts') || '[]'); } catch(_) { priceAlerts = []; } } + +function addAlert() { + const coin = (document.getElementById('alert-coin').value || '').trim().toUpperCase(); + const dir = document.getElementById('alert-dir').value; + const tgt = parseFloat(document.getElementById('alert-price').value); + if (!coin || !tgt) return; + priceAlerts.push({ id: Date.now(), coin, above: dir === 'above', target: tgt, triggered: false }); + saveAlerts(); + renderActiveAlerts(); + document.getElementById('alert-coin').value = ''; + document.getElementById('alert-price').value = ''; } -async function reloadPhases(interval, btn) { - document.querySelectorAll('#phases-content .tabs .tab').forEach(t => t.classList.remove('active')); - btn.classList.add('active'); - const el = document.getElementById('phase-cards'); +function deleteAlert(id) { + priceAlerts = priceAlerts.filter(a => a.id !== id); + saveAlerts(); + renderActiveAlerts(); +} + +function clearTriggered() { + priceAlerts = priceAlerts.filter(a => !a.triggered); + saveAlerts(); + renderActiveAlerts(); +} + +function renderActiveAlerts() { + const el = document.getElementById('active-alerts'); if (!el) return; - el.innerHTML = '
    Detecting…
    '; - try { - const data = await fetch(`${API}/api/phase?wallet=${PRIMARY_WALLET}&interval=${interval}`).then(r => r.json()); - el.innerHTML = data.phases.map(p => phaseCard(p)).join(''); - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } + const active = priceAlerts.filter(a => !a.triggered); + const done = priceAlerts.filter(a => a.triggered); + el.innerHTML = active.length === 0 && done.length === 0 + ? '
    No alerts set
    ' + : [ + ...active.map(a => `
    ${a.coin} ${a.above ? '▲ above' : '▼ below'} ${fmtPrice(a.target)}
    `), + ...done.map(a => `
    ${a.coin} ${a.above ? '▲' : '▼'} ${fmtPrice(a.target)} ✅
    `), + ].join('') + (done.length ? `` : ''); } -// ── Watchlist ───────────────────────────────────────────────────────────────── +async function loadMonitor() { + monitorActive = true; + loadAlerts(); + const el = document.getElementById('monitor-content'); -async function loadWatchlist() { - const el = document.getElementById('watchlist-content'); - el.innerHTML = '
    Loading watchlist…
    '; + // Fetch positions + prevDay prices concurrently + let positions = []; try { - const data = await fetch(`${API}/api/watchlist`).then(r => r.json()); - el.innerHTML = ` -
    -
    Wallet Watchlist
    -
    -
    -
    Add Wallet
    -
    - - - -
    + const [state, meta] = await Promise.all([getClearinghouseState(currentWallet), getMetaAndAssetCtxs()]); + positions = parsePositions(state); + livePositions = positions; + // Store prevDay prices for 24h % calc + const universe = meta[0].universe; + const ctxs = meta[1]; + universe.forEach((asset, i) => { + const prev = parseFloat(ctxs[i].prevDayPx || 0); + if (prev) livePrevDay[asset.name] = prev; + }); + } catch(_) {} + + el.innerHTML = ` +
    +
    ⚡ Live Monitor
    WebSocket · Hyperliquid real-time feed
    +
    + 🔴 Connecting… +
    -
    - ${data.wallets.map(w => walletRow(w)).join('') || '
    No wallets in watchlist
    '} +
    + + ${positions.length > 0 ? ` +
    +
    📍 Positions · Live P&L
    +
    + + ${positions.map(p => { + const key = p.coin + p.side; + return ` + + + + + + + + `; + }).join('')} +
    CoinSideSizeEntryNowLive PnLLev
    ${p.coin}${p.side === 'long' ? 'L' : 'S'}${p.size}${fmtPrice(p.entry_price)}${p.leverage_value}x
    +
    ` : '
    No open positions · P&L tracker will appear here when you have positions
    '} + +
    +
    +
    📈 Live Prices
    + Updates every tick via WebSocket
    - `; - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } -} +
    + + ${MONITOR_COINS.map(coin => ` + + + + + `).join('')} +
    CoinPrice24h %Trend
    ${coin}
    +
    -function walletRow(w) { - const snap = w.snapshot || {}; - const summary = snap.summary || {}; - const positions = snap.positions || []; - const isPrimary = w.address === PRIMARY_WALLET.toLowerCase(); - return ` -
    -
    +
    +
    🔔 Price Alerts
    +
    + + + + +
    +
    +
    Alert Log
    +
    + Alerts will appear here +
    +
    + +
    +
    ⚡ API Proxy (Cloudflare Worker)
    +
    Optional: paste your Cloudflare Worker URL for edge-cached API calls (faster in Indonesia). Leave blank to use Hyperliquid directly.
    +
    + + + +
    +
    ${localStorage.getItem('hype_proxy_url') ? '✓ Proxy active — reload page to apply' : 'Using direct Hyperliquid API'}
    +
    + +
    +
    📲 Telegram Notifications
    +
    Token stored in your browser only — never committed to code or sent anywhere except Telegram.
    +
    - ${w.label} - ${isPrimary ? 'PRIMARY' : ''} -
    ${w.address}
    +
    Bot Token
    +
    -
    - - ${!isPrimary ? `` : ''} +
    +
    Your Chat ID
    +
    + + +
    +
    Send any message to your bot first, then click Auto-detect.
    +
    +
    +
    P&L Milestone — Alert every $
    + +
    +
    + + + ${tgToken&&tgChatId?'✓ Configured':'Not configured'}
    -
    -
    Account Value
    ${fmt$(summary.account_value||0)}
    -
    Positions
    ${positions.length}
    -
    Coins
    ${(snap.coins||[]).join(', ') || '—'}
    +
    + +
    +
    +
    📊 TA Signal Dashboard
    +
    +
    + ${['1h','4h'].map(tf=>``).join('')} +
    + +
    +
    +
    + ${TA_COINS.map(c=>``).join('')} +
    +
    ${spinnerHtml()} Loading…
    `; + + renderActiveAlerts(); + connectWS(); + refreshTA(); } -async function addWallet() { - const addr = document.getElementById('add-addr').value.trim(); - const label = document.getElementById('add-label').value.trim(); - if (!addr) return; +// ── Portfolio Chart ─────────────────────────────────────────────────────────── +let chartMode = 'all'; // 'all' | 'perp' | 'spot' +let _chartData = {}; // cached per-mode data + +function savePortfolioSnap(key, v) { try { - await fetch(`${API}/api/watchlist`, { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({address: addr, label}) }); - loadWatchlist(); - } catch(e) { alert('Error: ' + e.message); } + const sk = key === 'all' ? 'hype_snaps' : 'hype_snaps_' + key; + const snaps = JSON.parse(localStorage.getItem(sk) || '[]'); + const now = Date.now(); + if (snaps.length && now - snaps.at(-1).ts < 30 * 60 * 1000) return; + snaps.push({ ts: now, v }); + const cut = now - 8 * 86400000; + localStorage.setItem(sk, JSON.stringify(snaps.filter(s => s.ts >= cut))); + } catch(_) {} } - -async function removeWallet(addr) { - if (!confirm('Remove this wallet from watchlist?')) return; - await fetch(`${API}/api/watchlist/${addr}`, { method: 'DELETE' }); - loadWatchlist(); +function getPortfolioSnaps(key) { + try { + const sk = key === 'all' ? 'hype_snaps' : 'hype_snaps_' + key; + return JSON.parse(localStorage.getItem(sk) || '[]'); + } catch { return []; } } -async function refreshWallet(addr) { + +async function fetchIDRRate() { try { - const snap = await fetch(`${API}/api/watchlist/${addr}/snapshot`).then(r => r.json()); - loadWatchlist(); - } catch(e) { alert('Error: ' + e.message); } + const r = await fetch('https://api.frankfurter.app/latest?from=USD&to=IDR'); + const d = await r.json(); + usdToIdr = d.rates?.IDR || 0; + const el = document.getElementById('ch-rate'); + if (el && usdToIdr) el.textContent = `1 USD = Rp ${Math.round(usdToIdr).toLocaleString('id-ID')}`; + } catch(_) {} } -// ── Settings ────────────────────────────────────────────────────────────────── +function buildPortfolioHistory(currentValue, fills, funding, snaps) { + const msDay = 86400000; + const now = Date.now(); + const today = Math.floor(now / msDay) * msDay; -async function loadSettings() { - const el = document.getElementById('settings-content'); - const tgStatus = await fetch(`${API}/api/telegram/status`).then(r => r.json()); - el.innerHTML = ` -
    Settings
    -
    -
    -
    Telegram Alerts
    -
    - Status: ${tgStatus.enabled ? '✓ Connected' : '✗ Not configured'} -
    -
    -
    Bot Token (from @BotFather)
    - -
    -
    -
    Chat ID
    - -
    - -
    -
    -
    Primary Wallet
    -
    ${PRIMARY_WALLET}
    -
    To change the primary wallet, edit the PRIMARY_WALLET value in your .env file and restart the server.
    -
    -
    - `; + const dailyPnL = {}; + for (const f of (fills || [])) { + const d = Math.floor(f.time / msDay) * msDay; + dailyPnL[d] = (dailyPnL[d] || 0) + (f.closed_pnl || 0); + } + for (const f of (funding || [])) { + const d = Math.floor(f.time / msDay) * msDay; + dailyPnL[d] = (dailyPnL[d] || 0) + (f.usdc || 0); + } + + let startV = currentValue; + for (let i = 0; i < 7; i++) startV -= (dailyPnL[today - i * msDay] || 0); + startV = Math.max(0, startV); + + const daily = []; + let v = startV; + for (let i = 7; i >= 0; i--) { + const ts = today - i * msDay; + daily.push({ ts, v }); + if (i > 0) v += (dailyPnL[ts] || 0); + } + daily[daily.length - 1].v = currentValue; + + const recentSnaps = (snaps || []).filter(s => s.ts >= today - 2 * msDay); + if (recentSnaps.length >= 3) { + const base = daily.filter(p => p.ts < today - 2 * msDay); + return [...base, ...recentSnaps.map(s => ({ ts: s.ts, v: s.v }))]; + } + return daily; } -async function saveTelegram() { - const token = document.getElementById('tg-token').value.trim(); - const chat = document.getElementById('tg-chat').value.trim(); - if (!token || !chat) { alert('Enter both bot token and chat ID'); return; } +function fmtChartValue(v) { + if (chartCurrency === 'IDR') { + const idr = v * (usdToIdr || 16000); + return 'Rp ' + Math.round(idr).toLocaleString('id-ID'); + } + return '$' + v.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); +} + +function setChartCurrency(cur) { + chartCurrency = cur; + document.querySelectorAll('.ch-cur-tab').forEach(t => t.classList.toggle('active', t.dataset.cur === cur)); + if (portfolioChart) updateChartLabels(); +} + +function setChartMode(mode, btn) { + chartMode = mode; + document.querySelectorAll('.ch-mode-tab').forEach(b => b.classList.toggle('active', b.dataset.mode === mode)); + const lbl = document.getElementById('ch-mode-label'); + if (lbl) lbl.textContent = mode === 'perp' ? 'Perp acct' : mode === 'spot' ? 'Spot total' : 'Portfolio'; + const d = _chartData[mode]; + if (!d?.pts?.length) return; + _drawPortfolioChart(d.pts, d.current); + updateChartLabels(); +} + +function updateChartLabels() { + if (!portfolioChart) return; + const rate = chartCurrency === 'IDR' ? (usdToIdr || 16000) : 1; + const raw = portfolioChart._rawPts; + portfolioChart.data.datasets[0].data = raw.map(p => +(p.v * rate).toFixed(2)); + portfolioChart.options.scales.y.ticks.callback = v => + chartCurrency === 'IDR' ? 'Rp ' + (v / 1e6).toFixed(1) + 'M' : '$' + (v >= 1000 ? (v / 1000).toFixed(1) + 'K' : v.toFixed(0)); + portfolioChart.update('none'); + const cur = portfolioChart._modeCurrentValue ?? raw.at(-1)?.v ?? 0; + const start = raw[0]?.v || cur; + const chg = cur - start, pct = start > 0 ? chg / start * 100 : 0; + const $el = id => document.getElementById(id); + if ($el('ch-cur')) $el('ch-cur').textContent = fmtChartValue(cur); + if ($el('ch-chg')) { $el('ch-chg').textContent = (chg >= 0 ? '+' : '') + fmtChartValue(Math.abs(chg)); $el('ch-chg').className = chg >= 0 ? 'pos mono' : 'neg mono'; } + if ($el('ch-pct')) { $el('ch-pct').textContent = (pct >= 0 ? '+' : '') + pct.toFixed(2) + '%'; $el('ch-pct').className = pct >= 0 ? 'pos mono' : 'neg mono'; } +} + +function _drawPortfolioChart(pts, currentValue) { + const ctx = document.getElementById('portfolio-chart'); + if (!ctx || !window.Chart || !pts?.length) return; + if (portfolioChart) { portfolioChart.destroy(); portfolioChart = null; } + + const rate = chartCurrency === 'IDR' ? (usdToIdr || 16000) : 1; + const isUp = pts.at(-1).v >= pts[0].v; + const color = isUp ? '#22c55e' : '#ef4444'; + const bg = isUp ? 'rgba(34,197,94,0.08)' : 'rgba(239,68,68,0.08)'; + const labels = pts.map(p => { + const d = new Date(p.ts); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + + (pts.length > 10 ? ' ' + d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }) : ''); + }); + const values = pts.map(p => +(p.v * rate).toFixed(2)); + + portfolioChart = new Chart(ctx, { + type: 'line', + data: { labels, datasets: [{ data: values, borderColor: color, backgroundColor: bg, fill: true, tension: 0.35, pointRadius: pts.length <= 10 ? 3 : 0, pointHoverRadius: 5, borderWidth: 2 }] }, + options: { + responsive: true, maintainAspectRatio: false, animation: { duration: 300 }, + plugins: { + legend: { display: false }, + tooltip: { callbacks: { + label: c => fmtChartValue(c.raw / rate), + title: items => labels[items[0].dataIndex] + }} + }, + scales: { + x: { grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#666', maxRotation: 0, maxTicksLimit: 7, font: { size: 10 } } }, + y: { position: 'right', grid: { color: 'rgba(255,255,255,0.04)' }, ticks: { color: '#666', font: { size: 10 }, callback: v => chartCurrency === 'IDR' ? 'Rp ' + (v/1e6).toFixed(1)+'M' : '$' + (v >= 1000 ? (v/1000).toFixed(1)+'K' : v.toFixed(0)) } } + } + } + }); + portfolioChart._rawPts = pts; + portfolioChart._modeCurrentValue = currentValue; +} + +async function renderPortfolioChart(totalPortfolio) { + const perpValue = _ovData?.s?.account_value ?? totalPortfolio; + const spotValue = _ovData?.spotTotalValue ?? 0; + + savePortfolioSnap('all', totalPortfolio); + savePortfolioSnap('perp', perpValue); + savePortfolioSnap('spot', spotValue); + fetchIDRRate(); + + let taggedFills = [], funding = []; + try { + // Reuse fills already fetched by loadOverview rather than fetching again + taggedFills = _ovData?.recentFills || []; + funding = await getUserFunding(currentWallet, 7).then(parseFunding).catch(() => []); + } catch(_) {} + + const perpFills = taggedFills.filter(f => !f.isSpot); + const spotFills = taggedFills.filter(f => f.isSpot); + + _chartData = { + all: { pts: buildPortfolioHistory(totalPortfolio, taggedFills, funding, getPortfolioSnaps('all')), current: totalPortfolio }, + perp: { pts: buildPortfolioHistory(perpValue, perpFills, funding, getPortfolioSnaps('perp')), current: perpValue }, + spot: { pts: buildPortfolioHistory(spotValue, spotFills, [], getPortfolioSnaps('spot')), current: spotValue }, + }; + + const d = _chartData[chartMode]; + if (!d?.pts?.length) return; + _drawPortfolioChart(d.pts, d.current); + updateChartLabels(); +} + +// ── Telegram ────────────────────────────────────────────────────────────────── +async function sendTelegram(text) { + if (!tgToken || !tgChatId) return false; try { - const res = await fetch(`${API}/api/telegram/configure`, { - method: 'POST', headers: {'Content-Type':'application/json'}, - body: JSON.stringify({bot_token: token, chat_id: chat}) + const r = await fetch(`https://api.telegram.org/bot${tgToken}/sendMessage`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ chat_id: tgChatId, text, parse_mode: 'HTML' }) }); - const data = await res.json(); - if (data.configured) { alert('Telegram configured!'); loadSettings(); } - } catch(e) { alert('Error: ' + e.message); } + return r.ok; + } catch(_) { return false; } +} + +async function getTGChatId() { + const tokenEl = document.getElementById('tg-token-input'); + const tok = tokenEl ? tokenEl.value.trim() : tgToken; + if (!tok) { tgSetStatus('Enter your bot token first', false); return; } + tgSetStatus('Fetching…', null); + try { + const r = await fetch(`https://api.telegram.org/bot${tok}/getUpdates`); + const data = await r.json(); + if (!data.ok) { tgSetStatus('Invalid token', false); return; } + const updates = data.result || []; + if (!updates.length) { tgSetStatus('No messages found — send your bot any message first, then retry', null); return; } + const last = updates[updates.length - 1]; + const chat = (last.message || last.edited_message || last.channel_post || {}).chat; + if (!chat) { tgSetStatus('Could not read chat — try sending /start to your bot', null); return; } + tgChatId = String(chat.id); + localStorage.setItem('hype_tg_chat', tgChatId); + const chatEl = document.getElementById('tg-chat-input'); + if (chatEl) chatEl.value = tgChatId; + tgSetStatus(`✓ Chat ID detected: ${tgChatId} (${chat.first_name || chat.username || 'you'})`, true); + } catch(e) { tgSetStatus('Error: ' + e.message, false); } } -// ── Notifications ───────────────────────────────────────────────────────────── +function saveProxyUrl() { + const url = (document.getElementById('proxy-url-input')?.value || '').trim(); + if (url) { + localStorage.setItem('hype_proxy_url', url); + document.getElementById('proxy-status').textContent = '✓ Proxy saved — reload page to apply'; + } else { + clearProxyUrl(); + } +} + +function clearProxyUrl() { + localStorage.removeItem('hype_proxy_url'); + if (document.getElementById('proxy-url-input')) document.getElementById('proxy-url-input').value = ''; + document.getElementById('proxy-status').textContent = 'Cleared — using direct Hyperliquid API (reload to apply)'; +} -function toggleNotifications() { - const panel = document.getElementById('notif-panel'); - panel.classList.toggle('open'); - if (panel.classList.contains('open')) loadNotifications(); +function saveTGSettings() { + const tok = (document.getElementById('tg-token-input')?.value || '').trim(); + const chat = (document.getElementById('tg-chat-input')?.value || '').trim(); + const thr = parseFloat(document.getElementById('tg-pnl-thr')?.value || '0'); + tgToken = tok; tgChatId = chat; pnlThreshold = thr; + localStorage.setItem('hype_tg_token', tok); + localStorage.setItem('hype_tg_chat', chat); + localStorage.setItem('hype_pnl_thr', thr || '0'); + tgSetStatus('Settings saved ✓', true); } -async function loadNotifications() { - const el = document.getElementById('notif-list'); - const data = await fetch(`${API}/api/notifications`).then(r => r.json()); - const notifs = data.notifications; - updateNotifBadge(notifs.filter(n => !n.read).length); - if (notifs.length === 0) { - el.innerHTML = '
    No notifications yet
    '; - return; +async function testTelegram() { + saveTGSettings(); + const ok = await sendTelegram('🔔 Hype Dashboard\n\nTest notification — Telegram alerts are working! ✅\n\nYou\'ll receive:\n• Price alert triggers\n• P&L milestones\n• Order fills'); + tgSetStatus(ok ? '✅ Test message sent!' : '❌ Failed — check token & chat ID', ok); +} + +function tgSetStatus(msg, ok) { + const el = document.getElementById('tg-status'); + if (!el) return; + el.textContent = msg; + el.style.color = ok === true ? 'var(--green)' : ok === false ? 'var(--red)' : 'var(--text-muted)'; +} + +async function checkOrderFills(newOrders) { + if (lastOrderIds === null) { lastOrderIds = new Set(newOrders.map(o => o.oid)); return; } + const newSet = new Set(newOrders.map(o => o.oid)); + for (const oid of lastOrderIds) { + if (!newSet.has(oid)) { + sendTelegram(`⚡ Order Filled / Cancelled\nOrder ID ${oid} is no longer open.\nCheck your positions on Hype Dashboard.`); + } } - el.innerHTML = notifs.map(n => ` -
    -
    ${n.type}
    -
    ${n.message}
    -
    ${fmtTime(n.time * 1000)}
    -
    - `).join(''); + lastOrderIds = newSet; } -function addNotification(notif) { - const panel = document.getElementById('notif-panel'); - if (panel.classList.contains('open')) loadNotifications(); - const current = parseInt(document.getElementById('notif-count').textContent || '0'); - updateNotifBadge(current + 1); +// ── TA Math ─────────────────────────────────────────────────────────────────── +function iEMA(arr, p) { + const k = 2/(p+1); let ema = arr[0]; + return arr.map(v => (ema = v*k + ema*(1-k))); +} +function iMACD(arr, f=12, s=26, sig=9) { + const emaF = iEMA(arr, f), emaS = iEMA(arr, s); + const macd = emaF.map((v,i) => v - emaS[i]); + const signal = iEMA(macd, sig); + return { macd, signal, hist: macd.map((v,i) => v - signal[i]) }; +} +function iRSI(arr, p=14) { + let gAvg=0, lAvg=0; + for (let i=1; i<=p; i++) { const d=arr[i]-arr[i-1]; if(d>0) gAvg+=d; else lAvg-=d; } + gAvg/=p; lAvg/=p; + const out = new Array(p).fill(null); + out.push(lAvg===0 ? 100 : 100-100/(1+gAvg/lAvg)); + for (let i=p+1; i { + if (i { + if (v===null || ix!==null); + return sl.length===d ? sl.reduce((a,b)=>a+b)/d : null; + }); + return { k: kArr, d: dArr }; +} +function iBB(arr, p=20, mult=2) { + return arr.map((v,i) => { + if (ia+b)/p; + const sd=Math.sqrt(sl.reduce((a,b)=>a+(b-mid)**2,0)/p); + const upper=mid+mult*sd, lower=mid-mult*sd; + return { upper, mid, lower, pctB:(v-lower)/(upper-lower), bw:(upper-lower)/mid*100 }; + }); +} +function iATR(highs, lows, closes, p=14) { + const tr=closes.map((c,i)=>i===0?highs[0]-lows[0]:Math.max(highs[i]-lows[i],Math.abs(highs[i]-closes[i-1]),Math.abs(lows[i]-closes[i-1]))); + let atr=tr.slice(0,p).reduce((a,b)=>a+b)/p; + const out=[...new Array(p-1).fill(null),atr]; + for(let i=p;i=opens[i]) bSum+=volumes[i]; else sSum+=volumes[i]; + } + const tot=bSum+sSum||1; + return { buyPct:bSum/tot*100, sellPct:sSum/tot*100 }; +} + +// ── TA Signals ──────────────────────────────────────────────────────────────── +function sigEMA(price, e20, e50, e200) { + const a20=price>e20, a50=price>e50, a200=e200!==null?price>e200:null; + let label, cls; + if(a200===null){ + label=a20&&a50?'ABOVE EMA 20/50':!a20&&!a50?'BELOW EMA 20/50':'MIXED'; + cls=a20&&a50?'bull':!a20&&!a50?'bear':'neut'; + } else { + if(a20&&a50&&a200){label='FULL BULL';cls='bull';} + else if(!a20&&!a50&&!a200){label='FULL BEAR';cls='bear';} + else if(a50&&a200){label='ABOVE 50/200';cls='bull';} + else if(!a50&&!a200){label='BELOW 50/200';cls='bear';} + else {label='MIXED';cls='neut';} + } + const p20=((price-e20)/e20*100).toFixed(2), p50=((price-e50)/e50*100).toFixed(2); + return {label,cls,sub:`EMA20 ${p20>0?'+':''}${p20}% · EMA50 ${p50>0?'+':''}${p50}%`}; +} +function sigMACD(hist, macd) { + const h=hist.at(-1),hP=hist.at(-2),m=macd.at(-1); + let label,cls; + if(h>0&&hP<=0){label='BULLISH CROSS';cls='bull';} + else if(h<0&&hP>=0){label='BEARISH CROSS';cls='bear';} + else if(h>0&&h>hP){label='BULLISH EXPANDING';cls='bull';} + else if(h>0){label='BULLISH FADING';cls='warn';} + else if(h<0&&h=0?'+':''}${h.toFixed(5)} · MACD ${m>=0?'+':''}${m.toFixed(5)}`}; +} +function sigRSI(val) { + let label,cls; + if(val>=75){label='OVERBOUGHT';cls='warn';} + else if(val>=60){label='BULLISH';cls='bull';} + else if(val<=25){label='OVERSOLD';cls='info';} + else if(val<=40){label='BEARISH';cls='bear';} + else{label='NEUTRAL';cls='neut';} + return {label,cls,sub:`RSI ${val.toFixed(1)}`}; +} +function sigStoch(k,d) { + let label,cls; + if(k>=80&&d>=80){label='OVERBOUGHT';cls='warn';} + else if(k<=20&&d<=20){label='OVERSOLD';cls='info';} + else if(k>d&&k>50){label='BULLISH';cls='bull';} + else if(k0.9){label='AT UPPER BAND';cls='warn';} + else if(bb.pctB>0.6){label='UPPER HALF';cls='bull';} + else if(bb.pctB<0.1){label='AT LOWER BAND';cls='info';} + else if(bb.pctB<0.4){label='LOWER HALF';cls='bear';} + else{label='MID BAND';cls='neut';} + const sq=bb.bw<3; + return {label,cls,sub:`%B ${(bb.pctB*100).toFixed(0)}% · BW ${bb.bw.toFixed(2)}%${sq?' · SQUEEZE':''}`}; +} +function sigATR(atr,price) { + const pct=atr/price*100; + let label,cls; + if(pct>4){label='HIGH VOLATILITY';cls='warn';} + else if(pct>2){label='ELEVATED';cls='warn';} + else if(pct<0.5){label='LOW VOLATILITY';cls='info';} + else{label='NORMAL';cls='neut';} + return {label,cls,sub:`ATR ${pct.toFixed(2)}% of price`}; +} +function sigFunding(rate) { + const pct=rate*100; + let label,cls; + if(pct>0.05){label='CROWDED LONG';cls='warn';} + else if(pct>0.01){label='LONG BIASED';cls='bull';} + else if(pct<-0.05){label='CROWDED SHORT';cls='info';} + else if(pct<-0.01){label='SHORT BIASED';cls='bear';} + else{label='NEUTRAL';cls='neut';} + return {label,cls,sub:`${pct>=0?'+':''}${pct.toFixed(4)}%/8h`}; +} +function sigOI(oi,prev) { + const fmt=v=>v>=1e9?`$${(v/1e9).toFixed(2)}B`:v>=1e6?`$${(v/1e6).toFixed(1)}M`:`$${(v/1e3).toFixed(0)}K`; + if(!prev) return {label:'OI '+fmt(oi),cls:'neut',sub:'no prev data'}; + const chg=(oi-prev)/prev*100; + let label,cls; + if(chg>3){label='RISING FAST';cls='bull';} + else if(chg>1){label='RISING';cls='bull';} + else if(chg<-3){label='FALLING FAST';cls='bear';} + else if(chg<-1){label='FALLING';cls='bear';} + else{label='STABLE';cls='neut';} + return {label,cls,sub:`${fmt(oi)} (${chg>=0?'+':''}${chg.toFixed(1)}%)`}; +} +function sigFlow(buyPct) { + let label,cls; + if(buyPct>65){label='STRONG BUY FLOW';cls='bull';} + else if(buyPct>55){label='BUY FLOW';cls='bull';} + else if(buyPct<35){label='STRONG SELL FLOW';cls='bear';} + else if(buyPct<45){label='SELL FLOW';cls='bear';} + else{label='BALANCED';cls='neut';} + return {label,cls,sub:`Buy ${buyPct.toFixed(0)}% · Sell ${(100-buyPct).toFixed(0)}%`}; } -function updateNotifBadge(count) { - const badge = document.getElementById('notif-count'); - badge.textContent = count; - badge.style.display = count > 0 ? 'flex' : 'none'; +// ── TA Dashboard ────────────────────────────────────────────────────────────── +function setTACoin(coin) { + taCoin = coin; + document.querySelectorAll('.ta-coin-tab').forEach(t => t.classList.toggle('active', t.dataset.coin===coin)); + refreshTA(); +} +function setTATf(tf) { + taTf = tf; + document.querySelectorAll('.ta-tf-tab').forEach(t => t.classList.toggle('active', t.dataset.tf===tf)); + refreshTA(); } -async function markRead(id) { - await fetch(`${API}/api/notifications/${id}/read`, {method:'POST'}); - loadNotifications(); +async function refreshTA() { + if (taLoading) return; + taLoading = true; + const el = document.getElementById('ta-content'); + if (!el) { taLoading=false; return; } + el.innerHTML = `
    ${spinnerHtml()} Fetching ${taCoin} ${taTf}…
    `; + try { + const days = taTf==='4h' ? 60 : 15; + const [candles, meta] = await Promise.all([getCandles(taCoin, taTf, days), getMetaAndAssetCtxs()]); + const universe=meta[0].universe, ctxs=meta[1]; + const idx=universe.findIndex(a=>a.name===taCoin); + const rawCtx=idx>=0?ctxs[idx]:null; + const ta = await buildFullTA(taCoin, taTf, candles, rawCtx); + el.innerHTML = renderTARec(ta); + } catch(e) { + el.innerHTML = `
    Error: ${e.message}
    `; + } + taLoading = false; } -async function markAllRead() { - await fetch(`${API}/api/notifications/read-all`, {method:'POST'}); - loadNotifications(); +function taRow(icon, name, sig) { + if (!sig) return ''; + return `
    ${icon}${name}
    ${sig.label}${sig.sub?`${sig.sub}`:''}
    `; +} + +function renderTADash(s, price) { + return ` +
    TREND
    + ${taRow('📏','EMA Bias',s.ema)}${taRow('〰️','MACD',s.macd)}
    +
    MOMENTUM
    + ${taRow('⚡','RSI (14)',s.rsi)}${taRow('🔁','Stochastic',s.stoch)}
    +
    VOLATILITY
    + ${taRow('🎯','Bollinger %B',s.bb)}${taRow('📐','ATR (14)',s.atr)}
    +
    CRYPTO-NATIVE
    + ${taRow('💰','Funding',s.funding)}${taRow('📊','Open Interest',s.oi)}${taRow('🌊','Money Flow',s.mf)}
    +
    ${taCoin} · ${taTf} · ${fmtPrice(price)} · ${new Date().toLocaleTimeString()}
    `; } // ── Helpers ─────────────────────────────────────────────────────────────────── +function fmt$(n){ + if(n===undefined||n===null) return '—'; + const abs=Math.abs(n),sign=n<0?'-':''; + if(abs>=1e6) return sign+'$'+(abs/1e6).toFixed(2)+'M'; + if(abs>=1e3) return sign+'$'+abs.toLocaleString('en-US',{minimumFractionDigits:2,maximumFractionDigits:2}); + return sign+'$'+abs.toFixed(2); +} +// ── Order Scenario Analysis ─────────────────────────────────────────────────── +const HL_TAKER_FEE = 0.00035; // 0.035% taker fee + +function renderOrderScenarios(positions, orders) { + if (!orders || !orders.length) return ''; + + const posMap = {}; + (positions || []).forEach(p => { posMap[p.coin] = p; }); + + const rows = []; + orders.forEach(o => { + const pos = posMap[o.coin]; + if (!pos) return; + + const orderSide = o.side === 'B' ? 'buy' : 'sell'; + const isReduce = (pos.side === 'long' && orderSide === 'sell') || + (pos.side === 'short' && orderSide === 'buy'); + if (!isReduce) return; -function fmt$(n) { - if (n === undefined || n === null) return '—'; - const abs = Math.abs(n); - const sign = n < 0 ? '-' : ''; - if (abs >= 1e6) return sign + '$' + (abs/1e6).toFixed(2) + 'M'; - if (abs >= 1e3) return sign + '$' + abs.toLocaleString('en-US', {minimumFractionDigits:2, maximumFractionDigits:2}); - return sign + '$' + abs.toFixed(2); + const execPx = parseFloat(o.limitPx || o.triggerPx || 0); + if (!execPx) return; + + const sz = parseFloat(o.sz || 0); + const entry = pos.entry_price; + + // Dollar PnL = price-diff × contracts (correct regardless of leverage) + const rawPnl = pos.side === 'long' + ? (execPx - entry) * sz + : (entry - execPx) * sz; + const fee = sz * execPx * HL_TAKER_FEE; + const netPnl = rawPnl - fee; + + // % of this position's margin that this order outcome represents + // margin_used covers the full position; scale by sz/pos.size for partial closes + const marginSlice = pos.margin_used > 0 + ? pos.margin_used * (sz / pos.size) + : 0; + const pctMargin = marginSlice > 0 ? (netPnl / marginSlice * 100) : null; + + const pctPos = pos.size > 0 ? (sz / pos.size * 100) : 0; + + // Auto-label TP / SL + let typeLabel, typeCls; + if (rawPnl > 0) { typeLabel = 'TP'; typeCls = 'tp'; } + else if (rawPnl < 0) { typeLabel = 'SL'; typeCls = 'sl'; } + else { typeLabel = 'FLAT'; typeCls = 'flat'; } + + // Liquidation check: will liq fire before this SL can fill? + let liqFirst = false; + const liq = pos.liquidation_price; + if (liq > 0 && typeLabel === 'SL') { + liqFirst = (pos.side === 'long' && liq >= execPx) || + (pos.side === 'short' && liq <= execPx); + } + + rows.push({ coin: o.coin, pos, typeLabel, typeCls, orderSide, + execPx, sz, pctPos, rawPnl, fee, netPnl, pctMargin, liqFirst }); + }); + + if (!rows.length) return ''; + + const tpRows = rows.filter(r => r.typeLabel === 'TP'); + const slRows = rows.filter(r => r.typeLabel === 'SL'); + const bestNet = tpRows.reduce((a, r) => a + r.netPnl, 0); + const worstNet = slRows.reduce((a, r) => a + r.netPnl, 0); + const liqFirstCount = rows.filter(r => r.liqFirst).length; + + // R:R by coin + const byGroup = {}; + rows.forEach(r => { + if (!byGroup[r.coin]) byGroup[r.coin] = { tp: null, sl: null }; + if (r.typeLabel === 'TP') byGroup[r.coin].tp = r; + else if (r.typeLabel === 'SL') byGroup[r.coin].sl = r; + }); + + const typeColor = { tp: 'var(--green)', sl: 'var(--red)', flat: 'var(--text-muted)' }; + const typeBg = { tp: 'rgba(74,222,128,0.15)', sl: 'rgba(248,113,113,0.15)', flat: 'rgba(100,100,100,0.15)' }; + const rowBg = { tp: 'rgba(74,222,128,0.03)', sl: 'rgba(248,113,113,0.03)', flat: '' }; + + const tableRows = rows.map(r => { + const pnlCls = r.netPnl >= 0 ? 'pos' : 'neg'; + const pnlStr = (r.netPnl >= 0 ? '+' : '') + fmt$(r.netPnl); + + const pctMStr = r.pctMargin !== null + ? `${r.pctMargin >= 0 ? '+' : ''}${r.pctMargin.toFixed(0)}%` + : ''; + + // Note cell: liq warning OR R:R badge + let note = ''; + if (r.liqFirst) { + note = `⚠ Liq ${fmtPrice(r.pos.liquidation_price)}`; + } else { + const g = byGroup[r.coin]; + if (g.tp && g.sl) { + const rr = Math.abs(g.tp.netPnl / g.sl.netPnl); + const rrCls = rr >= 2 ? 'pos' : rr >= 1 ? 'yellow' : 'neg'; + note = `R:R ${rr.toFixed(1)}`; + } + } + + return ` + ${r.coin} + ${r.pos.leverage_value}x + ${r.typeLabel} + ${r.pos.side.toUpperCase()} + ${fmtPrice(r.execPx)} + ${r.pctPos.toFixed(0)}% + ${pnlStr} + ${pctMStr} + ${note} + `; + }).join(''); + + const liqWarningBanner = liqFirstCount > 0 + ? `
    + ⚠ ${liqFirstCount} SL order${liqFirstCount>1?'s':''} unreachable — liquidation price fires first. Your account will be liquidated before the stop fills. +
    ` : ''; + + const summaryLine = ` +
    + ${tpRows.length ? `
    Best case (${tpRows.length} TP${tpRows.length>1?'s':''})
    +${fmt$(bestNet)}
    ` : ''} + ${slRows.length ? `
    Worst case (${slRows.length} SL${slRows.length>1?'s':''})
    ${fmt$(worstNet)}
    ` : ''} + ${tpRows.length && slRows.length ? `
    +
    Net if all hit
    +
    ${bestNet+worstNet>=0?'+':''}${fmt$(bestNet+worstNet)}
    +
    ` : ''} +
    `; + + return `
    +
    +
    🎯 Order Scenarios
    + if orders hit · dollar PnL = Δprice × contracts · 0.035% taker fee +
    + ${liqWarningBanner} +
    + + + ${tableRows} +
    CoinTypeDirExec Price% PosNet PnL% MarginNote
    +
    + ${summaryLine} +
    `; } -function fmtTime(ms) { - if (!ms) return '—'; - return new Date(ms).toLocaleString('en-US', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); +function fmtAge(ms) { + const s = Math.floor((Date.now() - ms) / 1000); + if (s < 60) return s + 's ago'; + if (s < 3600) return Math.floor(s/60) + 'm ago'; + if (s < 86400)return Math.floor(s/3600) + 'h ago'; + return Math.floor(s/86400) + 'd ago'; } +function _fillMeta(dir) { + const d = (dir||'').toLowerCase(); + if (d.includes('liq')) return { label:'LIQ', bg:'rgba(248,113,113,0.15)', color:'var(--red)', dirCls:'neg' }; + if (d.startsWith('open'))return { label:'OPEN', bg:'rgba(56,189,248,0.10)', color:'var(--accent)', dirCls:'' }; + return { label:'CLOSE', bg:'var(--surface2)', color:'var(--text-muted)', dirCls:'' }; +} +function _fillDirLabel(dir) { + const d = (dir||'').toLowerCase(); + if (d.includes('long')) return { label:'LONG', cls:'long' }; + if (d.includes('short')) return { label:'SHORT', cls:'short' }; + return { label:'—', cls:'muted' }; +} + +function toggleRecentPnL() { + _recentPnlOpen = !_recentPnlOpen; + const body = document.getElementById('recent-pnl-body'); + const chevron = document.getElementById('recent-pnl-chevron'); + if (body) body.style.display = _recentPnlOpen ? '' : 'none'; + if (chevron) chevron.textContent = _recentPnlOpen ? '▼' : '▶'; +} + +function renderRecentPnLWidget(allFills) { + const hrs = _recentPnlHours; + const cutoff = Date.now() - hrs * 3600000; + const fills = (allFills||[]).filter(f => f.time >= cutoff && !f.isSpot).slice(0, 100); + + const closingFills = fills.filter(f => f.closed_pnl !== 0); + const totalPnl = closingFills.reduce((a,f)=>a+f.closed_pnl, 0); + const totalFees = fills.reduce((a,f)=>a+f.fee, 0); + const netPnl = totalPnl - totalFees; + const liqCount = fills.filter(f=>(f.dir||'').toLowerCase().includes('liq')).length; + + const chevron = `${_recentPnlOpen?'▼':'▶'}`; + const netBadge = fills.length ? `${netPnl>=0?'+':''}${fmt$(netPnl)}` : ''; + const liqBadge = liqCount>0 ? `⚡${liqCount}` : ''; + const headerRow = `
    +
    +
    📊 Recent PnL${chevron}
    + ${netBadge}${liqBadge} +
    +
    + ${[24, 168].map(h=>``).join('')} +
    +
    `; + + if (!fills.length) return `
    + ${headerRow} +
    +
    No perp fills in the last ${hrs===24?'24h':'7 days'}
    +
    +
    `; + + const wins = closingFills.filter(f=>f.closed_pnl>0).length; + const losses = closingFills.filter(f=>f.closed_pnl<0).length; + const winRate = wins+losses>0 ? (wins/(wins+losses)*100).toFixed(0)+'%' : '—'; + + // By-coin totals (closing fills only) + const byCoin = {}; + closingFills.forEach(f => { + if(!byCoin[f.coin]) byCoin[f.coin] = {coin:f.coin, count:0, pnl:0, fees:0}; + byCoin[f.coin].count++; + byCoin[f.coin].pnl += f.closed_pnl; + byCoin[f.coin].fees += f.fee; + }); + // add fees from opening fills too + fills.filter(f=>f.closed_pnl===0).forEach(f => { + if(!byCoin[f.coin]) byCoin[f.coin] = {coin:f.coin, count:0, pnl:0, fees:0}; + byCoin[f.coin].fees += f.fee; + }); + const coinRows = Object.values(byCoin).sort((a,b)=>Math.abs(b.pnl)-Math.abs(a.pnl)); + + return `
    + ${headerRow} +
    +
    +
    Realized PnL
    ${totalPnl>=0?'+':''}${fmt$(totalPnl)}
    +
    Fees
    −${fmt$(totalFees)}
    +
    Net PnL
    ${netPnl>=0?'+':''}${fmt$(netPnl)}
    +
    Win Rate
    ${winRate}
    ${wins}W / ${losses}L
    +
    Fills
    ${fills.length}
    +
    +
    + + + ${fills.map(f=>{ + const m = _fillMeta(f.dir); + const dv= _fillDirLabel(f.dir); + return ` + + + + + + + + + `; + }).join('')} +
    TimeCoinTypeDirPriceSizePnLFee
    ${fmtAge(f.time)}${f.coin}${m.label}${dv.label}${fmtPrice(f.price)}${f.size}${f.closed_pnl!==0?fmt$(f.closed_pnl):'—'}${f.fee>0?'−'+fmt$(f.fee):'—'}
    +
    + ${coinRows.length>1?` +
    By Coin
    +
    + + ${coinRows.map(r=>` + + + + + + `).join('')} +
    CoinTradesPnLFeesNet
    ${r.coin}${r.count}${r.pnl!==0?fmt$(r.pnl):'—'}−${fmt$(r.fees)}${fmt$(r.pnl-r.fees)}
    `:''} +
    +
    `; +} + +function fmtB(n){ + if(!n) return '—'; + if(n>=1e9) return '$'+(n/1e9).toFixed(2)+'B'; + if(n>=1e6) return '$'+(n/1e6).toFixed(1)+'M'; + if(n>=1e3) return '$'+(n/1e3).toFixed(0)+'K'; + return '$'+n.toFixed(0); +} +function fmtPrice(n){ + if(!n) return '—'; + if(n>=1000) return '$'+n.toLocaleString('en-US',{minimumFractionDigits:0,maximumFractionDigits:0}); + if(n>=1) return '$'+n.toFixed(3); + return '$'+n.toFixed(6); +} +function fmtTime(ms){ + if(!ms) return '—'; + const d=new Date(ms); + return d.toLocaleDateString('en-US',{month:'short',day:'numeric'})+' '+d.toLocaleTimeString('en-US',{hour:'2-digit',minute:'2-digit'}); +} +function setStatus(ok){document.getElementById('ws-status').className='status-dot'+(ok?'':' off');} +function setRefreshTime(){ + _lastRefreshTs=Date.now(); + const el=document.getElementById('refresh-info'); + if(el){el.style.display='block';el.textContent='Updated '+new Date().toLocaleTimeString();} + setStatus(true); +} +function spinnerHtml(){return '
    ';} +function loading(){return `
    ${spinnerHtml()} Loading…
    `;} +function err(e){return `
    Error: ${e.message}
    `;} + // ── Init ────────────────────────────────────────────────────────────────────── +function _doSilentRefresh(){ + if(document.hidden) return; + if(_SKIP_SILENT.has(currentPage)) return; + if(_silentRefresh) return; // prevent concurrent refresh + const main=document.querySelector('.main'); + const sy=main?main.scrollTop:0; + _silentRefresh=true; + const loaders={overview:loadOverview,trades:loadTrades,funding:loadFunding, + flows:loadFlows,markets:loadMarkets,watchlist:loadWatchlist, + intel:typeof loadIntel!=='undefined'?loadIntel:null, + indicators:typeof loadIndicators!=='undefined'?loadIndicators:null, + smartmoney:typeof loadNansen!=='undefined'?loadNansen:null, + signals:typeof loadSignals!=='undefined'?loadSignals:null}; + const loader=loaders[currentPage]; + const p=loader?Promise.resolve(loader()):Promise.resolve(); + p.catch(()=>{}).finally(()=>{ + _silentRefresh=false; + _lastRefreshTs=Date.now(); + if(main && Math.abs(main.scrollTop - sy) < 20) requestAnimationFrame(()=>{main.scrollTop=sy;}); + const ri=document.getElementById('refresh-info'); + if(ri){ri.classList.add('refresh-flash');setTimeout(()=>ri.classList.remove('refresh-flash'),500);} + }); +} -document.addEventListener('DOMContentLoaded', () => { - connectWS(); - navigate('overview'); - // Poll notifications every minute - setInterval(() => { - const panel = document.getElementById('notif-panel'); - if (!panel.classList.contains('open')) { - fetch(`${API}/api/notifications`).then(r=>r.json()).then(d => { - updateNotifBadge(d.notifications.filter(n => !n.read).length); - }); - } - }, 60000); +function _startAgoCounter(){ + setInterval(()=>{ + if(!_lastRefreshTs) return; + const s=Math.floor((Date.now()-_lastRefreshTs)/1000); + const ri=document.getElementById('refresh-info'); + if(!ri) return; + if(s<60) ri.textContent='Updated just now'; + else ri.textContent=`Updated ${Math.floor(s/60)}m ago`; + },30000); +} + +// Pull-to-refresh (mobile) +let _ptrY=0; +document.addEventListener('touchstart',e=>{_ptrY=e.touches[0].clientY;},{passive:true}); +document.addEventListener('touchend',e=>{ + const main=document.querySelector('.main'); + const dy=e.changedTouches[0].clientY-_ptrY; + if(dy>65&&main&&main.scrollTop<=0) navigate(currentPage); +},{passive:true}); + +// Refresh when returning to tab after >60s +document.addEventListener('visibilitychange',()=>{ + if(!document.hidden&&Date.now()-_lastRefreshTs>60000) _doSilentRefresh(); }); -// Close notification panel when clicking outside -document.addEventListener('click', (e) => { - const panel = document.getElementById('notif-panel'); - const btn = document.getElementById('notif-toggle-btn'); - if (!panel.contains(e.target) && !btn.contains(e.target)) { - panel.classList.remove('open'); - } +document.addEventListener('keydown', e => { + if (e.key === 'Escape') closeMktDetail(); }); +document.addEventListener('DOMContentLoaded',()=>{ + const wl=getWatchlist(); + if(!wl.find(w=>w.address===DEFAULT_WALLET.toLowerCase())){ + wl.unshift({address:DEFAULT_WALLET.toLowerCase(),label:'My Wallet',added_at:Date.now()}); + saveWatchlist(wl); + } + initMobileTableLabels(); + navigate('overview'); + autoRefreshTimer=setInterval(_doSilentRefresh,60000); + _startAgoCounter(); + if(typeof loggerInit==='function'){ loggerInit(); loggerRefreshStatus(); } +}); \ No newline at end of file diff --git a/frontend/js/indicators.js b/frontend/js/indicators.js new file mode 100644 index 0000000..ca12b56 --- /dev/null +++ b/frontend/js/indicators.js @@ -0,0 +1,276 @@ +let _indData = null, _indDataTs = 0; +const IND_TTL = 300000; + +function calcSMA(arr, n) { return arr.slice(-n).reduce((a,b)=>a+b,0)/n; } +function calcEMA(arr, n) { + const k = 2/(n+1); + let ema = arr.slice(0, n).reduce((a,b)=>a+b,0)/n; + for (let i = n; i < arr.length; i++) ema = arr[i]*k + ema*(1-k); + return ema; +} + +function parseFG(data) { + if (!data?.data?.length) return null; + const value = parseInt(data.data[0].value); + const zone = value <= 24 ? 'EXTREME_FEAR' : value <= 39 ? 'FEAR' : value <= 59 ? 'NEUTRAL' : value <= 79 ? 'GREED' : 'EXTREME_GREED'; + const history = data.data.map(d => ({ t: parseInt(d.timestamp)*1000, v: parseInt(d.value) })); + return { value, classification: data.data[0].value_classification, zone, history }; +} + +function parseBMSB(klines) { + if (!Array.isArray(klines) || klines.length < 21) return null; + const closes = klines.map(k => parseFloat(k[4])); + const sma20w = calcSMA(closes, 20); + const ema21w = calcEMA(closes, 21); + const currentPrice = closes[closes.length-1]; + const signal = currentPrice > sma20w && currentPrice > ema21w ? 'BULL' : currentPrice < sma20w && currentPrice < ema21w ? 'BEAR' : 'NEUTRAL'; + return { + sma20w, ema21w, currentPrice, signal, + pctAboveSMA: ((currentPrice/sma20w-1)*100).toFixed(1), + pctAboveEMA: ((currentPrice/ema21w-1)*100).toFixed(1), + }; +} + +function parsePiCycle(klines) { + if (!Array.isArray(klines) || klines.length < 350) return null; + const closes = klines.map(k => parseFloat(k[4])); + const sma111 = calcSMA(closes, 111); + const sma350x2 = calcSMA(closes, 350) * 2; + const proximity = (sma111 / sma350x2 * 100).toFixed(1); + const signal = sma111 >= sma350x2 ? 'TOP' : parseFloat(proximity) > 85 ? 'WARNING' : 'NORMAL'; + return { sma111, sma350x2, proximity, signal, currentPrice: closes[closes.length-1] }; +} + +async function fetchIndicatorsDirect() { + const [fgRes, weeklyRes, dailyRes] = await Promise.allSettled([ + fetch('https://api.alternative.me/fng/?limit=30').then(r => r.json()), + fetch('https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1w&limit=160').then(r => r.json()), + fetch('https://api.binance.com/api/v3/klines?symbol=BTCUSDT&interval=1d&limit=400').then(r => r.json()), + ]); + return { + fear_greed: fgRes.status === 'fulfilled' ? parseFG(fgRes.value) : null, + bmsb: weeklyRes.status === 'fulfilled' ? parseBMSB(weeklyRes.value) : null, + pi_cycle: dailyRes.status === 'fulfilled' ? parsePiCycle(dailyRes.value) : null, + floors: { realized: 72000, balanced: 41000, cvdd: 45000, terminal: 290000 }, + fetched_at: Date.now(), + }; +} + +async function fetchIndicators() { + if (_indData && Date.now() - _indDataTs < IND_TTL) return _indData; + let raw; + try { + const r = await fetch('/api/indicators'); + if (!r.ok) throw new Error('backend unavailable'); + raw = await r.json(); + _indData = { + fear_greed: raw.fear_greed ? parseFG(raw.fear_greed) : null, + bmsb: raw.btc_weekly ? parseBMSB(raw.btc_weekly) : null, + pi_cycle: raw.btc_daily ? parsePiCycle(raw.btc_daily) : null, + floors: raw.floors || { realized: 72000, balanced: 41000, cvdd: 45000, terminal: 290000 }, + fetched_at: Date.now(), + }; + } catch(_) { + _indData = await fetchIndicatorsDirect(); + } + _indDataTs = Date.now(); + window._indData = _indData; + return _indData; +} + +function indSignalBadge(signal, cls) { + return `${signal}`; +} + +function fgBarColor(v) { return v >= 50 ? 'rgba(74,222,128,0.7)' : 'rgba(248,113,113,0.7)'; } + +function renderFGSparkline(history, containerId) { + if (!history || !window.Chart) return; + const el = document.getElementById(containerId); + if (!el) return; + if (el._chart) { el._chart.destroy(); el._chart = null; } + const slice = history.slice(0, 30).reverse(); + const labels = slice.map(() => ''); + const values = slice.map(d => d.v); + const colors = values.map(v => fgBarColor(v)); + el._chart = new Chart(el, { + type: 'bar', + data: { labels, datasets: [{ data: values, backgroundColor: colors, borderWidth: 0 }] }, + options: { + responsive: true, maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false, min: 0, max: 100 } }, + animation: false, + }, + }); +} + +function indCard(opts) { + const { title, value, valueCls, signal, signalCls, detail, extra, sparkId } = opts; + return `
    +
    ${title}
    + ${signal ? indSignalBadge(signal, signalCls) : ''} + ${value !== undefined ? `
    ${value}
    ` : ''} + ${sparkId ? `
    ` : ''} + ${detail ? `
    ${detail}
    ` : ''} + ${extra || ''} +
    `; +} + +async function loadIndicators() { + const el = document.getElementById('indicators-content'); + if (!el) return; + + el.innerHTML = `
    Fetching indicators…
    `; + + let ind; + try { + ind = await fetchIndicators(); + } catch(e) { + el.innerHTML = `
    Error: ${e.message}
    `; + return; + } + + const fg = ind.fear_greed; + const bmsb = ind.bmsb; + const pi = ind.pi_cycle; + const floors = ind.floors; + + const fgZone = fg ? fg.zone : null; + const fgCls = fgZone === 'EXTREME_GREED' ? 'pos' : fgZone === 'EXTREME_FEAR' ? 'neg' : fgZone === 'GREED' ? 'pos' : fgZone === 'FEAR' ? 'neg' : 'muted'; + const mvrvZ = (typeof _mvrvData !== 'undefined' && _mvrvData?.summary?.z_score) + ? _mvrvData.summary.z_score.toFixed(2) : null; + + const stripHtml = `
    +
    + F&G + ${fg ? fg.value : '—'} + ${fg ? `${fg.classification}` : ''} +
    +
    + BMSB + ${bmsb ? bmsb.signal : '—'} + ${bmsb ? `${bmsb.signal}` : ''} +
    +
    + Pi Cycle + ${pi ? pi.proximity+'%' : '—'} + ${pi ? `${pi.signal}` : ''} +
    +
    + MVRV Z + ${mvrvZ || '—'} +
    +
    `; + + const fgBarPct = fg ? fg.value : 0; + const fgCard = indCard({ + title: 'Fear & Greed Index', + value: fg ? fg.value : '—', + valueCls: fgCls, + signal: fg ? fg.zone.replace('_', ' ') : 'N/A', + signalCls: fg ? fg.zone.toLowerCase() : 'neutral', + sparkId: 'fg-sparkline', + detail: fg ? `
    +
    Extreme FearExtreme Greed
    +
    ` : 'Data unavailable', + }); + + const bmsbCard = indCard({ + title: 'Bull Market Support Band', + signal: bmsb ? bmsb.signal : 'N/A', + signalCls: bmsb ? bmsb.signal.toLowerCase() : 'neutral', + detail: bmsb ? ` +
    +
    Price
    $${Math.round(bmsb.currentPrice).toLocaleString()}
    +
    vs 20W SMA
    ${bmsb.pctAboveSMA}%
    +
    20W SMA
    $${Math.round(bmsb.sma20w).toLocaleString()}
    +
    vs 21W EMA
    ${bmsb.pctAboveEMA}%
    +
    21W EMA
    $${Math.round(bmsb.ema21w).toLocaleString()}
    +
    ` : 'Data unavailable', + }); + + const piProxNum = pi ? parseFloat(pi.proximity) : 0; + const piCard = indCard({ + title: 'Pi Cycle Top Indicator', + signal: pi ? pi.signal : 'N/A', + signalCls: pi ? pi.signal.toLowerCase() : 'normal', + detail: pi ? ` +
    +
    + Proximity to top + ${pi.proximity}% +
    +
    +
    +
    111D SMA
    $${Math.round(pi.sma111).toLocaleString()}
    +
    350D SMA ×2
    $${Math.round(pi.sma350x2).toLocaleString()}
    +
    +
    ` : 'Data unavailable', + }); + + const mvrvCard = indCard({ + title: 'MVRV Z-Score', + value: mvrvZ || '—', + valueCls: mvrvZ ? (parseFloat(mvrvZ) > 7 ? 'neg' : parseFloat(mvrvZ) > 3 ? 'yellow' : parseFloat(mvrvZ) < 0 ? 'pos' : '') : 'muted', + signal: mvrvZ ? (parseFloat(mvrvZ) > 7 ? 'OVERHEATED' : parseFloat(mvrvZ) > 3 ? 'ELEVATED' : parseFloat(mvrvZ) < 0 ? 'UNDERVALUED' : 'NORMAL') : 'N/A', + signalCls: mvrvZ ? (parseFloat(mvrvZ) > 7 ? 'bear' : parseFloat(mvrvZ) > 3 ? 'warning' : parseFloat(mvrvZ) < 0 ? 'bull' : 'normal') : 'neutral', + detail: mvrvZ ? 'From MVRV tab data' : 'Load MVRV tab first to populate', + }); + + const floorCards = ` +
    +
    Realized Price
    +
    $${floors.realized.toLocaleString()}
    +
    Mid floor — accumulation zone
    +
    +
    +
    Balanced Price
    +
    $${floors.balanced.toLocaleString()}
    +
    Deep floor — bear cycle low
    +
    +
    +
    CVDD
    +
    $${floors.cvdd.toLocaleString()}
    +
    Deep floor — bear cycle low
    +
    +
    +
    Terminal Price
    +
    $${floors.terminal.toLocaleString()}
    +
    Cycle top estimate
    +
    `; + + const unavailCard = (name, desc) => ` +
    +
    ${name}
    +
    ${desc}
    +
    Requires Glassnode/CryptoQuant
    +
    `; + + el.innerHTML = ` + ${stripHtml} +
    MARKET REGIME
    +
    + ${fgCard} + ${bmsbCard} +
    +
    CYCLE POSITION
    +
    + ${piCard} + ${mvrvCard} +
    +
    PRICE FLOORS (BTC Reference)
    +
    ${floorCards}
    +
    Floor values are approximations — update in INTEL config
    +
    UNAVAILABLE (Requires Paid API)
    +
    + ${unavailCard('Puell Multiple', 'Miner revenue relative to 365-day average — cycle top/bottom timing')} + ${unavailCard('Hash Ribbons', 'Miner capitulation indicator — powerful buy signal after bear markets')} + ${unavailCard('STH Realized Price', 'Short-term holder cost basis — key support/resistance level')} +
    + `; + + if (fg?.history) { + setTimeout(() => renderFGSparkline(fg.history, 'fg-sparkline'), 50); + } +} diff --git a/frontend/js/intel.js b/frontend/js/intel.js new file mode 100644 index 0000000..0547624 --- /dev/null +++ b/frontend/js/intel.js @@ -0,0 +1,558 @@ +// ── Cryptowatch Research Snapshot ──────────────────────────────────────────── +// Update this object whenever you paste fresh research from cryptowatch.id +const INTEL = { + snapshot_date: '2026-05-14', + source: 'cryptowatch.id', + + // ── MACRO ───────────────────────────────────────────────────────────────── + macro: { + posture: 'WAIT', + posture_score: -0.5, // ±10 scale + posture_confidence: 71, + cycle_phase: 'Accumulation', + bottom_proximity_pct: 28, + capital_flow_30d: '+$6.01B', + capital_flow_lead: 'Stablecoins INFLOW', + btc_funding_apr: '+5.0%', + btc_funding_note: 'Calm · no crowding', + btc_oi: '$2.25B', + cycle_today: 'SIDEWAYS', + cycle_accuracy: '70%', + bottom_radar: 84, + bottom_signals: 10, + neutral_signals: 2, + top_signals: 2, + + // Regime Radar axes (0–10 scale) + regime_radar: { + Macro: 4, Cycle: 6, OnChain: 7, + Derivs: 5, Funding: 6, ETF: 3, Sentiment: 4, + }, + + // Evidence trail layers + evidence_layers: [ + { name: 'L1 Macro', score: 4, max: 10, receipts: 3, verdict: 'NEUTRAL' }, + { name: 'L2 Cycle', score: 6, max: 10, receipts: 5, verdict: 'BULLISH' }, + { name: 'L3 Capital', score: 7, max: 10, receipts: 4, verdict: 'BULLISH' }, + { name: 'L4 Execution', score: 5, max: 10, receipts: 3, verdict: 'NEUTRAL' }, + ], + + cohorts: [ + { name: 'LTH', stance: 'accumulating', detail: '+131,133 BTC · 30d', bull: true }, + { name: 'ETF · TradFi', stance: 'distributing', detail: '−$127M · 7d', bull: false }, + { name: 'Smart Money', stance: 'accumulating', detail: 'stable Δ −3.63% · 24h', bull: true }, + ], + + notable_moves: [ + { metric: 'Puell Multiple', z: '+1.65σ', change: '+17.10%', value: '0.9953', zNum: 1.65 }, + { metric: 'AHR999', z: '+1.30σ', change: '+3.96%', value: '0.5286', zNum: 1.30 }, + { metric: 'BTC Price', z: '+0.78σ', change: '+1.32%', value: '$80.90k', zNum: 0.78 }, + { metric: 'MVRV Z-Score', z: '+0.73σ', change: '+3.94%', value: '0.9187', zNum: 0.73 }, + { metric: 'NUPL', z: '+0.63σ', change: '+2.66%', value: '0.3297', zNum: 0.63 }, + { metric: 'Hot Capital Share', z: '−0.43σ', change: '−0.90%', value: '12.16%', zNum: -0.43 }, + ], + }, + + // ── HUNTER ──────────────────────────────────────────────────────────────── + hunter: { + regime: 'CAUTION', + regime_score: 0, + regime_max: 10, + heat: 41.8, + heat_note: 'cool — opportunity zone', + btc_funding: '+5.0%', + smart_money: 'IDLE', + btc_dominance: '60.4%', + altcoin_breadth: '24%', + whale_net_flow: '+$202K', + whale_buy_pressure: 51, + leading_narrative: 'Privacy', + narrative_rotation: 'perps_dex → privacy', + ai_verdict: 'CAUTION', + ai_confidence: 'medium', + ai_summary: "ZEC's attention lead is the only clean trade, but a cool tape and idle smart money say keep size small. Privacy is the one narrative where mindshare is leading price — ZEC is the expression, with ROSE already moving and TORN broken.", + conviction_stack: [ + { signal: 'Morning Verdict', pass: true }, + { signal: 'Risk Regime', pass: true }, + { signal: 'Narrative Entries', pass: false }, + { signal: 'Smart Money', pass: true }, + { signal: 'Concentration', pass: false }, + ], + plays: [ + { coin: 'ZEC', action: 'ENTRY', narrative: 'Privacy', reason: "Highest mindshare (0.24) while 7d price still −2.1% — attention leading price. Starter size." }, + { coin: 'ROSE', action: 'HOLD', narrative: 'Privacy', reason: "Already +7.7% 7d — confirms the thesis but entry is later than ZEC. Treat as validation." }, + { coin: 'PC', action: 'ENTRY', narrative: 'Infra', reason: "Only REAL verdict in CT emergence — Push Chain universal execution layer, no shill signal. Small size." }, + ], + avoid: [ + { coin: 'AEON', reason: '10x from sub-300k MC, HYPE verdict, strong shill signal — too late.' }, + { coin: 'BXE', reason: 'HYPE + strong shill on XRPL low-cap riding a Chrome extension headline.' }, + { coin: 'DAD', reason: 'Solana meme, thin narrative, possible coordinated shilling.' }, + ], + risk: 'Privacy mindshare fades before price catches up. Watch: ZEC mindshare decaying over 2–3 days + ROSE giving back 7d gains + smart money staying idle.', + narratives_top: [ + { name: 'desci', score: 3.5 }, + { name: 'rwa', score: 3.6 }, + { name: 'sol ecosystem',score: 3.4 }, + { name: 'l2s', score: 3.5 }, + { name: 'restaking', score: 3.5 }, + ], + }, + + // ── DESK SETUPS ─────────────────────────────────────────────────────────── + desk: { + killzone: 'NONE · NY_AM soon', + market_stats: [ + { label: 'Funding', value: '0.0046%', sub: 'z 1.58', coin: 'BTC' }, + { label: 'Open Interest', value: '$59.77B', sub: 'Δ4H −0.69%', coin: 'BTC' }, + { label: 'Liquidations', value: '$73.5M', sub: '24H', coin: 'BTC' }, + { label: 'Options Skew 25Δ', value: '+5.9', sub: 'mild risk-on', coin: 'BTC' }, + ], + setups: [ + { + coin: 'BTC', htf_bias: 'BULL', + scalp: 'NO-TRADE', intraday: 'NO-TRADE', swing: 'NO-TRADE', + quant: null, entries: [], + note: 'All profiles — no confluence. Wait.', + }, + { + coin: 'ETH', htf_bias: 'BEAR', + scalp: 'SHORT', intraday: 'SHORT', swing: 'NO-TRADE', + quant: { type: 'stat_arb', detail: 'Short BTC / Long ETH · z=+2.03 (60d) · BTC rich vs ETH' }, + entries: [ + { profile: 'scalp moderate', dir: 'SHORT', entry: 2262.91, stop: 2266.65, tp1: 2257.29, tp2: 2253.54, tp3: 2249.79, rr: '1:2.5', conf: '5/10' }, + { profile: 'scalp aggressive', dir: 'SHORT', entry: 2257.36, stop: 2267.07, tp1: 2233.08, tp2: 2218.52, tp3: 2199.09, rr: '1:4.2', conf: '5/10' }, + { profile: 'intraday aggressive', dir: 'SHORT', entry: 2257.36, stop: 2282.63, tp1: 2181.55, tp2: 2131.01, tp3: 2080.46, rr: '1:5.0', conf: '4/10' }, + ], + note: 'Invalidation: 1H close above EMA50 (2277.15)', + }, + { + coin: 'SOL', htf_bias: 'BULL', + scalp: 'LONG', intraday: 'LONG', swing: 'NO-TRADE', + quant: null, + entries: [ + { profile: 'scalp aggressive', dir: 'LONG', entry: 90.76, stop: 90.46, tp1: 91.50, tp2: 91.94, tp3: 92.53, rr: '1:4.2', conf: '4/10' }, + { profile: 'intraday aggressive', dir: 'LONG', entry: 90.76, stop: 89.39, tp1: 94.87, tp2: 97.60, tp3: 100.34, rr: '1:5.0', conf: '4/10' }, + ], + note: 'Invalidation: 1H close below EMA50 (92.65)', + }, + { + coin: 'HYPE', htf_bias: 'BEAR', + scalp: 'SHORT', intraday: 'SHORT', swing: 'NO-TRADE', + quant: { type: 'funding_harvest', detail: 'Short perp / Long spot · est +10.9% APR · delta-neutral' }, + entries: [ + { profile: 'scalp moderate', dir: 'SHORT', entry: 39.03, stop: 39.12, tp1: 38.89, tp2: 38.80, tp3: 38.71, rr: '1:2.5', conf: '5/10' }, + { profile: 'scalp aggressive', dir: 'SHORT', entry: 38.95, stop: 39.19, tp1: 38.34, tp2: 37.97, tp3: 37.48, rr: '1:4.2', conf: '5/10' }, + { profile: 'intraday aggressive', dir: 'SHORT', entry: 38.95, stop: 39.58, tp1: 37.07, tp2: 35.81, tp3: 34.55, rr: '1:5.0', conf: '4/10' }, + ], + note: 'Invalidation: 1H close above EMA50 (39.67) · Funding harvest: +10.95% APR gross', + }, + ], + }, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _postureColor(p) { + return p === 'BUY' || p === 'BULL' ? 'var(--green)' : + p === 'SELL' || p === 'BEAR' ? 'var(--red)' : + p === 'CAUTION' ? 'var(--yellow)' : 'var(--text-muted)'; +} + +function _verdictCls(v) { + return v === 'BULLISH' ? 'pos' : v === 'BEARISH' ? 'neg' : 'muted'; +} + +// ── Render Intel Page ───────────────────────────────────────────────────────── + +function intelIndicatorStrip() { + const ind = window._indData; + if (!ind) return `
    Loading indicators… (visit Indicators tab first or wait)
    `; + const fg = ind.fear_greed, bmsb = ind.bmsb, pi = ind.pi_cycle; + const fgCls = fg ? (fg.value < 30 ? 'neg' : fg.value > 70 ? 'pos' : 'muted') : 'muted'; + const bmsbCls = bmsb ? (bmsb.signal === 'BULL' ? 'pos' : bmsb.signal === 'BEAR' ? 'neg' : 'yellow') : 'muted'; + const piCls = pi ? (pi.signal === 'TOP' ? 'neg' : pi.signal === 'WARNING' ? 'yellow' : 'pos') : 'muted'; + return `
    +
    F&G${fg ? fg.value : '—'}${fg ? fg.classification : 'N/A'}
    +
    BMSB${bmsb ? bmsb.signal : '—'}
    +
    Pi Cycle${pi ? pi.proximity+'%' : '—'}${pi ? pi.signal : 'N/A'}
    +
    MVRV Z${typeof _mvrvData !== 'undefined' && _mvrvData?.summary?.z_score ? _mvrvData.summary.z_score.toFixed(2) : '—'}
    +
    `; +} + +function loadIntel() { + const el = document.getElementById('intel-content'); + if (!el) return; + const m = INTEL.macro; + const h = INTEL.hunter; + const d = INTEL.desk; + + // Score bar: posture_score on ±10 scale → 0–100% + const scorePct = ((m.posture_score + 10) / 20) * 100; + const scoreColor = m.posture_score > 2 ? 'var(--green)' : m.posture_score < -2 ? 'var(--red)' : 'var(--yellow)'; + const heatColor = h.heat > 60 ? 'var(--red)' : h.heat > 40 ? 'var(--yellow)' : 'var(--green)'; + const altNum = parseInt(h.altcoin_breadth); + const btcDomNum = parseFloat(h.btc_dominance); + + // Evidence trail overall + const evTotal = m.evidence_layers.reduce((a, l) => a + l.score, 0); + const evMax = m.evidence_layers.reduce((a, l) => a + l.max, 0); + const evPct = Math.round((evTotal / evMax) * 100); + + el.innerHTML = ` + ${intelIndicatorStrip()} + + +
    +
    +
    +
    PORTFOLIO POSTURE
    +
    ${m.posture}
    +
    +
    +
    + Score ${m.posture_score > 0 ? '+' : ''}${m.posture_score} / ±10 +
    +
    +
    +
    +
    +
    ${m.posture_confidence}% confidence
    +
    +
    +
    Cycle${m.cycle_phase}
    +
    Today${m.cycle_today} ${m.cycle_accuracy}
    +
    Snap${INTEL.snapshot_date}
    +
    +
    +
    + Hunter: ${h.regime} + Macro: ${m.posture} +
    +
    + + +
    +
    +
    Market Heat
    +
    ${h.heat}
    +
    ${h.heat_note}
    +
    +
    +
    BTC Funding APR
    +
    ${m.btc_funding_apr}
    +
    ${m.btc_funding_note}
    +
    +
    +
    Alt Breadth
    +
    ${h.altcoin_breadth}
    +
    % alts up
    +
    +
    +
    BTC Dominance
    +
    ${h.btc_dominance}
    +
    alt season ${btcDomNum > 55 ? 'far' : 'near'}
    +
    +
    +
    Smart Money
    +
    ${h.smart_money}
    +
    Whale ${h.whale_net_flow}
    +
    +
    +
    30D Capital
    +
    ${m.capital_flow_30d}
    +
    ${m.capital_flow_lead}
    +
    +
    + + +
    + + +
    + +
    +
    +
    Regime Radar
    + 0 – 10 per axis +
    +
    + +
    +
    + ${Object.entries(m.regime_radar).map(([k, v]) => { + const color = v >= 7 ? 'var(--green)' : v >= 5 ? 'var(--yellow)' : 'var(--red)'; + return `
    + ${k} + ${v} +
    `; + }).join('')} +
    +
    + +
    +
    What Changed Today
    +
    + + + + ${m.notable_moves.map(mv => { + const pos = mv.zNum >= 0; + const barPct = Math.min(Math.abs(mv.zNum) / 3 * 100, 100); + return ` + + + + + + `; + }).join('')} + +
    MetricZ-ScoreΔ MagnitudeΔ%Value
    ${mv.metric}${mv.z} +
    +
    +
    +
    ${mv.change}${mv.value}
    +
    +
    + +
    + + +
    + +
    +
    Cycle Bottom Radar
    +
    + ${m.bottom_radar} + /100 +
    +
    Strong bottom cluster
    +
    +
    +
    +
    +
    +
    ${m.bottom_signals}
    +
    Bottom
    +
    +
    +
    ${m.neutral_signals}
    +
    Neutral
    +
    +
    +
    ${m.top_signals}
    +
    Top
    +
    +
    +
    + +
    +
    +
    Evidence Trail
    + ${evPct}% +
    + ${m.evidence_layers.map(l => { + const pct = Math.round((l.score / l.max) * 100); + const color = l.score >= 7 ? 'var(--green)' : l.score >= 5 ? 'var(--yellow)' : 'var(--red)'; + return `
    +
    + ${l.name} +
    + ${l.verdict} + ${l.score}/${l.max} + ${l.receipts} sigs +
    +
    +
    +
    +
    +
    `; + }).join('')} +
    + +
    +
    Cohort Confluence
    + ${m.cohorts.map(c => ` +
    +
    +
    ${c.name}
    +
    ${c.detail}
    +
    + ${c.stance.toUpperCase()} +
    `).join('')} +
    + +
    +
    + + +
    +
    +
    AI Synthesis · Cryptowatch
    +
    + ${h.ai_verdict} + ${h.ai_confidence} confidence +
    +
    +
    ${h.ai_summary}
    + +
    + ${h.conviction_stack.map(s => ` +
    + ${s.pass ? '✓' : '✗'} ${s.signal} +
    `).join('')} + + ${h.conviction_stack.filter(s => s.pass).length}/${h.conviction_stack.length} agree + +
    + +
    + Rotation: + ${h.narrative_rotation} + Leading: ${h.leading_narrative} +
    + +
    + ${h.narratives_top.map(n => ` +
    ${n.name} ${n.score}
    `).join('')} +
    +
    + + +
    +
    +
    ▲ Plays
    + ${h.plays.map(p => ` +
    +
    + ${p.coin} +
    + ${p.action} + ${p.narrative} +
    +
    +
    ${p.reason}
    +
    `).join('')} +
    + +
    +
    ▼ Avoid
    + ${h.avoid.map(a => ` +
    +
    ${a.coin}
    +
    ${a.reason}
    +
    `).join('')} +
    +
    ⚠ RISK TO THESIS
    +
    ${h.risk}
    +
    +
    +
    + + +
    +
    +
    Desk Setups · Active Entries
    + ${d.killzone} +
    + +
    + ${d.market_stats.map(ms => ` +
    +
    ${ms.coin} ${ms.label}
    +
    ${ms.value}
    +
    ${ms.sub}
    +
    `).join('')} +
    + + ${d.setups.map(s => deskSetupBlock(s)).join('')} +
    + `; + + // Kick off radar chart after DOM is ready + setTimeout(renderRegimeRadar, 0); +} + +// ── Regime Radar Chart ──────────────────────────────────────────────────────── + +function renderRegimeRadar() { + const canvas = document.getElementById('regime-radar-chart'); + if (!canvas || typeof Chart === 'undefined') return; + const data = INTEL.macro.regime_radar; + const labels = Object.keys(data); + const values = Object.values(data); + new Chart(canvas, { + type: 'radar', + data: { + labels, + datasets: [{ + data: values, + backgroundColor: 'rgba(56,189,248,0.08)', + borderColor: '#38bdf8', + borderWidth: 1.5, + pointBackgroundColor: '#38bdf8', + pointBorderColor: '#0a0a0a', + pointRadius: 3, + pointHoverRadius: 4, + }], + }, + options: { + animation: false, + scales: { + r: { + min: 0, max: 10, + ticks: { display: false, stepSize: 2 }, + grid: { color: 'rgba(36,36,36,0.9)' }, + angleLines: { color: 'rgba(36,36,36,0.9)' }, + pointLabels: { color: '#6b7280', font: { size: 10, family: "'Inter', sans-serif" } }, + }, + }, + plugins: { + legend: { display: false }, + tooltip: { enabled: false }, + }, + }, + }); +} + +// ── Desk Setup Block ────────────────────────────────────────────────────────── + +function deskSetupBlock(s) { + const biasColor = s.htf_bias === 'BULL' ? 'var(--green)' : s.htf_bias === 'BEAR' ? 'var(--red)' : 'var(--text-muted)'; + const hasEntries = s.entries.length > 0; + const quant = s.quant; + return ` +
    +
    + ${s.coin} + HTF ${s.htf_bias} + ${s.scalp !== 'NO-TRADE' ? `SCALP ${s.scalp}` : ''} + ${s.intraday !== 'NO-TRADE' ? `INTRADAY ${s.intraday}` : ''} + ${s.swing !== 'NO-TRADE' ? `SWING ${s.swing}` : ''} + ${!hasEntries ? `No-trade all profiles` : ''} +
    + ${hasEntries ? ` +
    + + + + ${s.entries.map(e => ` + + + + + + + + + + `).join('')} + +
    ProfileDirEntryStopTP1TP2TP3R:RConf
    ${e.profile}${e.dir}${e.entry}${e.stop}${e.tp1}${e.tp2}${e.tp3}${e.rr}${e.conf}
    +
    ` : ''} + ${quant ? `
    + QUANT · ${quant.detail} +
    ` : ''} + ${s.note ? `
    ℹ ${s.note}
    ` : ''} +
    `; +} diff --git a/frontend/js/journal.js b/frontend/js/journal.js new file mode 100644 index 0000000..a798382 --- /dev/null +++ b/frontend/js/journal.js @@ -0,0 +1,231 @@ +// ── Journal — Realized P&L Analysis ────────────────────────────────────────── + +let _journalChart = null; + +function buildTradesFromFills(fills) { + const perp = (fills || []).filter(f => f.coin && !f.coin.startsWith('@')); + + const byCoin = {}; + for (const f of perp) { + if (!byCoin[f.coin]) byCoin[f.coin] = []; + byCoin[f.coin].push(f); + } + + const trades = []; + for (const [coin, coinFills] of Object.entries(byCoin)) { + const sorted = [...coinFills].sort((a, b) => a.time - b.time); + let netPos = 0; + let entryTime = null; + let side = null; + let pnl = 0; + let fees = 0; + + for (const f of sorted) { + const isBuy = f.side === 'B' || f.side === 'buy'; + const delta = isBuy ? f.size : -f.size; + const fPnl = f.closed_pnl !== undefined ? f.closed_pnl : (f.closedPnl || 0); + + if (netPos === 0) { + entryTime = f.time; + side = isBuy ? 'long' : 'short'; + pnl = 0; + fees = 0; + } + + netPos += delta; + pnl += fPnl; + fees += f.fee || 0; + + if (Math.abs(netPos) < 1e-9) { + trades.push({ + coin, + side, + entry_time: entryTime, + exit_time: f.time, + pnl, + fees, + net_pnl: pnl - fees, + hold_ms: f.time - entryTime, + closed: true, + }); + netPos = 0; + entryTime = null; + side = null; + pnl = 0; + fees = 0; + } + } + + if (Math.abs(netPos) > 1e-9 && entryTime !== null) { + trades.push({ + coin, + side, + entry_time: entryTime, + exit_time: null, + pnl, + fees, + net_pnl: pnl - fees, + hold_ms: Date.now() - entryTime, + closed: false, + }); + } + } + + return trades.sort((a, b) => (b.exit_time || b.entry_time) - (a.exit_time || a.entry_time)); +} + +function _fmtHold(ms) { + if (!ms || ms < 0) return '—'; + const h = Math.floor(ms / 3600000); + const d = Math.floor(h / 24); + if (d > 0) return `${d}d ${h % 24}h`; + return `${h}h`; +} + +function _fmtDate(ms) { + if (!ms) return '—'; + return new Date(ms).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: '2-digit' }); +} + +async function loadJournal() { + const el = document.getElementById('journal-content'); + el.innerHTML = '
    Loading journal…
    '; + try { + const data = await fetch(`${API}/api/trades?wallet=${PRIMARY_WALLET}&limit=2000`).then(r => r.json()); + const rawFills = data.trades || []; + const trades = buildTradesFromFills(rawFills); + const closed = trades.filter(t => t.closed); + const wins = closed.filter(t => t.net_pnl > 0); + const losses = closed.filter(t => t.net_pnl < 0); + const winRate = closed.length > 0 ? (wins.length / closed.length * 100).toFixed(1) : '0.0'; + const avgWin = wins.length > 0 ? wins.reduce((a, t) => a + t.net_pnl, 0) / wins.length : 0; + const avgLoss = losses.length > 0 ? losses.reduce((a, t) => a + t.net_pnl, 0) / losses.length : 0; + const rr = avgLoss !== 0 ? Math.abs(avgWin / avgLoss).toFixed(2) : '—'; + const totalPnl = closed.reduce((a, t) => a + t.net_pnl, 0); + + const avgWinHold = wins.length > 0 ? wins.reduce((a, t) => a + t.hold_ms, 0) / wins.length : 0; + const avgLossHold = losses.length > 0 ? losses.reduce((a, t) => a + t.hold_ms, 0) / losses.length : 0; + const avgWinH = Math.round(avgWinHold / 3600000); + const avgLossH = Math.round(avgLossHold / 3600000); + const holdWarning = avgLossHold > 2 * avgWinHold && avgWinHold > 0; + const maxHold = Math.max(avgWinHold, avgLossHold) || 1; + const winBarPct = Math.round(avgWinHold / maxHold * 100); + const lossBarPct = Math.round(avgLossHold / maxHold * 100); + + const byCoin = {}; + for (const t of closed) { + if (!byCoin[t.coin]) byCoin[t.coin] = { trades: 0, wins: 0, pnl: 0 }; + byCoin[t.coin].trades++; + if (t.net_pnl > 0) byCoin[t.coin].wins++; + byCoin[t.coin].pnl += t.net_pnl; + } + const coinRows = Object.entries(byCoin) + .map(([coin, d]) => ({ coin, ...d, wr: (d.wins / d.trades * 100).toFixed(0) })) + .sort((a, b) => b.pnl - a.pnl); + + const cumData = [...closed] + .filter(t => t.exit_time) + .sort((a, b) => a.exit_time - b.exit_time); + let cum = 0; + const cumPoints = cumData.map(t => { cum += t.net_pnl; return { x: new Date(t.exit_time), y: parseFloat(cum.toFixed(2)) }; }); + + el.innerHTML = ` +
    +
    Total Trades
    ${closed.length}
    +
    Win Rate
    ${winRate}%
    ${wins.length}W / ${losses.length}L
    +
    Avg Winner / Loser
    ${avgWin > 0 ? fmt$(avgWin) : '—'} / ${avgLoss < 0 ? fmt$(avgLoss) : '—'}
    +
    R:R Ratio
    ${rr}
    +
    Total Closed P&L
    ${fmt$(totalPnl)}
    +
    + +
    +
    +
    Hold Time Pattern
    +
    +
    +
    Winners — avg ${avgWinH}h
    +
    +
    +
    +
    Losers — avg ${avgLossH}h
    +
    +
    +
    + ${holdWarning + ? `
    You hold losers ${avgLossH - avgWinH}h longer than winners
    ` + : `
    Hold time looks balanced
    `} +
    +
    +
    Best / Worst Coins
    + ${coinRows.length === 0 ? '
    No closed trades
    ' : ` +
    + + + ${coinRows.map(r => ` + + + + + `).join('')} +
    CoinTradesWR%P&L
    ${r.coin}${r.trades}${r.wr}%${fmt$(r.pnl)}
    +
    `} +
    +
    + + ${cumPoints.length > 1 ? ` +
    +
    Cumulative P&L
    +
    +
    ` : ''} + +
    +
    Trade Log (${trades.length})
    + + + ${trades.slice(0, 200).map(t => ` + + + + + + + + + `).join('')} +
    CoinSideOpenCloseHoldP&LFeesNet
    ${t.coin}${t.side.toUpperCase()}${_fmtDate(t.entry_time)}${t.exit_time ? _fmtDate(t.exit_time) : 'OPEN'}${_fmtHold(t.hold_ms)}${fmt$(t.pnl)}${t.fees > 0 ? '−' + fmt$(t.fees) : '—'}${fmt$(t.net_pnl)}
    +
    `; + + if (cumPoints.length > 1) { + requestAnimationFrame(() => { + const ctx = document.getElementById('journal-chart'); + if (!ctx) return; + if (_journalChart) { _journalChart.destroy(); _journalChart = null; } + _journalChart = new Chart(ctx, { + type: 'line', + data: { + datasets: [{ + data: cumPoints, + borderColor: totalPnl >= 0 ? '#4ade80' : '#f87171', + backgroundColor: totalPnl >= 0 ? 'rgba(74,222,128,0.08)' : 'rgba(248,113,113,0.08)', + fill: true, + tension: 0.3, + pointRadius: 0, + borderWidth: 2, + }] + }, + options: { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { + callbacks: { label: ctx => fmt$(ctx.parsed.y) } + }}, + scales: { + x: { type: 'time', ticks: { color: '#666', maxTicksLimit: 6 }, grid: { color: '#1f1f1f' } }, + y: { ticks: { color: '#666', callback: v => fmt$(v) }, grid: { color: '#1f1f1f' } }, + } + } + }); + }); + } + } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } +} diff --git a/frontend/js/kb.js b/frontend/js/kb.js new file mode 100644 index 0000000..14082a3 --- /dev/null +++ b/frontend/js/kb.js @@ -0,0 +1,414 @@ +// ── Knowledge Base — Notes & Trade Journal (Supabase-backed) ───────────────── + +let _kbTab = 'notes'; // 'notes' | 'trades' +let _kbNotes = []; +let _kbTrades = []; +let _kbFilter = ''; + +// ── Bootstrap ───────────────────────────────────────────────────────────────── + +async function initKb() { + _renderKbShell(); + _bindKbAuth(); + + const user = await sbGetUser(); + if (user) { + await _kbLoadAll(); + _subscribeKbRealtime(); + } +} + +function _bindKbAuth() { + sbOnAuthChange(async (event) => { + if (event === 'SIGNED_IN') { + await _kbLoadAll(); + _subscribeKbRealtime(); + } + if (event === 'SIGNED_OUT') { + _kbNotes = []; + _kbTrades = []; + _renderKb(); + } + }); +} + +// ── Data loading ────────────────────────────────────────────────────────────── + +async function _kbLoadAll() { + await Promise.all([_kbLoadNotes(), _kbLoadTrades()]); + _renderKb(); +} + +async function _kbLoadNotes() { + const { data, error } = await sbFrom('kb_notes') + .select('*') + .order('pinned', { ascending: false }) + .order('updated_at', { ascending: false }); + if (error) { console.error('[KB] notes load:', error); return; } + _kbNotes = data || []; +} + +async function _kbLoadTrades() { + const { data, error } = await sbFrom('kb_trades') + .select('*') + .order('updated_at', { ascending: false }); + if (error) { console.error('[KB] trades load:', error); return; } + _kbTrades = data || []; +} + +// ── Realtime ────────────────────────────────────────────────────────────────── + +let _kbChannels = []; + +function _subscribeKbRealtime() { + _kbChannels.forEach(c => c.unsubscribe()); + _kbChannels = [ + sbSubscribe('kb_notes', _onKbNotesChange), + sbSubscribe('kb_trades', _onKbTradesChange), + ]; +} + +function _onKbNotesChange({ eventType, new: row, old }) { + if (eventType === 'INSERT') _kbNotes.unshift(row); + if (eventType === 'UPDATE') _kbNotes = _kbNotes.map(n => n.id === row.id ? row : n); + if (eventType === 'DELETE' && old?.id) _kbNotes = _kbNotes.filter(n => n.id !== old.id); + _renderKb(); +} + +function _onKbTradesChange({ eventType, new: row, old }) { + if (eventType === 'INSERT') _kbTrades.unshift(row); + if (eventType === 'UPDATE') _kbTrades = _kbTrades.map(t => t.id === row.id ? row : t); + if (eventType === 'DELETE' && old?.id) _kbTrades = _kbTrades.filter(t => t.id !== old.id); + _renderKb(); +} + +// ── CRUD — Notes ────────────────────────────────────────────────────────────── + +async function kbSaveNote(note) { + const user = await sbRequireAuth(); + if (!user) return; + const now = Date.now(); + const row = { ...note, updated_at: now }; + if (!row.id) { row.id = crypto.randomUUID(); row.created_at = now; } + + const { error } = await sbFrom('kb_notes').upsert(row); + if (error) { alert('Save failed: ' + error.message); } +} + +async function kbDeleteNote(id) { + if (!confirm('Delete this note?')) return; + const { error } = await sbFrom('kb_notes').delete().eq('id', id); + if (error) alert('Delete failed: ' + error.message); +} + +async function kbPinNote(id, pinned) { + await sbFrom('kb_notes').update({ pinned: !pinned, updated_at: Date.now() }).eq('id', id); +} + +// ── CRUD — Trades ───────────────────────────────────────────────────────────── + +async function kbSaveTrade(trade) { + const user = await sbRequireAuth(); + if (!user) return; + const now = Date.now(); + const row = { ...trade, updated_at: now }; + if (!row.id) { row.id = crypto.randomUUID(); row.created_at = now; } + + const { error } = await sbFrom('kb_trades').upsert(row); + if (error) { alert('Save failed: ' + error.message); } +} + +async function kbDeleteTrade(id) { + if (!confirm('Delete this trade log?')) return; + const { error } = await sbFrom('kb_trades').delete().eq('id', id); + if (error) alert('Delete failed: ' + error.message); +} + +// ── Render ──────────────────────────────────────────────────────────────────── + +function _renderKbShell() { + const page = document.getElementById('page-kb'); + if (!page) return; + page.innerHTML = ` +
    +
    + + +
    +
    + + +
    +
    +
    + ${_kbEditorHtml()} + `; +} + +function _kbSwitchTab(tab) { + _kbTab = tab; + document.querySelectorAll('.kb-tab').forEach(b => b.classList.toggle('active', b.dataset.kbtab === tab)); + _renderKb(); +} + +function _kbSearch(q) { + _kbFilter = q.toLowerCase(); + _renderKb(); +} + +function _renderKb() { + const body = document.getElementById('kb-body'); + if (!body) return; + if (_kbTab === 'notes') body.innerHTML = _renderNotesList(); + if (_kbTab === 'trades') body.innerHTML = _renderTradesList(); +} + +function _filtered(items, fields) { + if (!_kbFilter) return items; + return items.filter(item => + fields.some(f => String(item[f] || '').toLowerCase().includes(_kbFilter)) + ); +} + +// ── Notes list ──────────────────────────────────────────────────────────────── + +function _renderNotesList() { + const items = _filtered(_kbNotes, ['title', 'content', 'tags']); + if (!items.length) return '

    No notes yet. Click "+ New" to add one.

    '; + + return items.map(n => { + const tags = (n.tags || []).map(t => `${t}`).join(''); + const coins = (n.coins || []).map(c => `${c}`).join(''); + const pinned = n.pinned ? '📌' : ''; + const date = n.updated_at ? new Date(n.updated_at).toLocaleDateString() : ''; + return ` +
    +
    + ${pinned}${_esc(n.title || 'Untitled')} + ${date} +
    + ${coins || tags ? `
    ${coins}${tags}
    ` : ''} +

    ${_esc((n.content || '').slice(0, 140))}

    +
    + + +
    +
    `; + }).join(''); +} + +// ── Trades list ─────────────────────────────────────────────────────────────── + +function _renderTradesList() { + const items = _filtered(_kbTrades, ['coin', 'setup', 'thesis', 'tags']); + if (!items.length) return '

    No trade logs yet. Click "+ New" to add one.

    '; + + return `
    + + + + + ${items.map(t => { + const pnl = t.pnl_usd != null ? `$${Number(t.pnl_usd).toFixed(2)}` : '—'; + const pnlCls = t.pnl_usd > 0 ? 'pos' : t.pnl_usd < 0 ? 'neg' : ''; + return ` + + + + + + + + + `; + }).join('')} +
    CoinDirStatusEntryExitPnLSetupActions
    ${_esc(t.coin)}${_esc(t.direction)}${_esc(t.status)}${t.entry_price != null ? Number(t.entry_price).toFixed(4) : '—'}${t.exit_price != null ? Number(t.exit_price).toFixed(4) : '—'}${pnl}${_esc(t.setup || '—')} + +
    `; +} + +// ── Editor modal ────────────────────────────────────────────────────────────── + +let _kbEditorId = null; +let _kbEditorType = 'note'; + +function _kbEditorHtml() { + return ` + + `; +} + +function _kbOpenEditor(id = null, type = null) { + _kbEditorId = id; + _kbEditorType = type || _kbTab === 'trades' ? 'trade' : 'note'; + const title = document.getElementById('kb-editor-title'); + const body = document.getElementById('kb-editor-body'); + if (!body) return; + + if (_kbEditorType === 'trade') { + const t = id ? _kbTrades.find(x => x.id === id) : {}; + if (title) title.textContent = id ? 'Edit Trade Log' : 'New Trade Log'; + body.innerHTML = _tradeForm(t || {}); + } else { + const n = id ? _kbNotes.find(x => x.id === id) : {}; + if (title) title.textContent = id ? 'Edit Note' : 'New Note'; + body.innerHTML = _noteForm(n || {}); + } + + document.getElementById('kb-editor')?.classList.remove('hidden'); + document.getElementById('kb-editor-backdrop')?.classList.remove('hidden'); +} + +function _kbCloseEditor() { + document.getElementById('kb-editor')?.classList.add('hidden'); + document.getElementById('kb-editor-backdrop')?.classList.add('hidden'); + _kbEditorId = null; +} + +async function _kbEditorSave() { + if (_kbEditorType === 'trade') { + const t = _collectTradeForm(); + if (_kbEditorId) t.id = _kbEditorId; + await kbSaveTrade(t); + } else { + const n = _collectNoteForm(); + if (_kbEditorId) n.id = _kbEditorId; + await kbSaveNote(n); + } + _kbCloseEditor(); +} + +function _noteForm(n) { + return ` + + + + + `; +} + +function _collectNoteForm() { + return { + title: document.getElementById('kbf-title')?.value.trim() || 'Untitled', + type: document.getElementById('kbf-type')?.value || 'note', + coins: _splitComma(document.getElementById('kbf-coins')?.value), + tags: _splitComma(document.getElementById('kbf-tags')?.value), + content: document.getElementById('kbf-content')?.value || '', + }; +} + +function _tradeForm(t) { + const dirs = ['LONG','SHORT']; + const stati = ['OPEN','CLOSED','CANCELLED']; + return ` +
    + + + +
    +
    + + + +
    +
    + + + +
    + + + + + + `; +} + +function _collectTradeForm() { + const num = id => { const v = document.getElementById(id)?.value; return v === '' ? null : Number(v); }; + return { + coin: document.getElementById('kbf-coin')?.value.trim().toUpperCase() || '', + direction: document.getElementById('kbf-dir')?.value || 'LONG', + status: document.getElementById('kbf-status')?.value || 'OPEN', + entry_price: num('kbf-entry'), + exit_price: num('kbf-exit'), + size_usd: num('kbf-size') ?? 0, + stop_loss: num('kbf-sl'), + take_profit: num('kbf-tp'), + pnl_usd: num('kbf-pnl'), + pnl_pct: null, + timeframe: document.getElementById('kbf-tf')?.value.trim() || '', + setup: document.getElementById('kbf-setup')?.value.trim() || '', + thesis: document.getElementById('kbf-thesis')?.value || '', + mistakes: document.getElementById('kbf-mistakes')?.value || '', + lessons: document.getElementById('kbf-lessons')?.value || '', + tags: _splitComma(document.getElementById('kbf-tags')?.value), + }; +} + +// ── Utilities ───────────────────────────────────────────────────────────────── + +function _esc(s) { + return String(s ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function _splitComma(s) { + return (s || '').split(',').map(v => v.trim()).filter(Boolean); +} diff --git a/frontend/js/logger.js b/frontend/js/logger.js new file mode 100644 index 0000000..64abddd --- /dev/null +++ b/frontend/js/logger.js @@ -0,0 +1,485 @@ +// ── Data Logger — Hourly Snapshot to Supabase ───────────────────────────────── + +const LOG_URL_KEY = 'hype_sb_url'; +const LOG_KEY_KEY = 'hype_sb_key'; +const LOG_LAST_KEY = 'hype_log_ts'; +const LOG_INTERVAL = 3600000; // 1 h + +let _sbClient = null; +let _logTimer = null; +let _logRunning = false; + +// ── Supabase client ──────────────────────────────────────────────────────────── +// Delegates to the centralized client in supabase.js; falls back to building +// its own if supabase.js is not loaded (e.g. standalone logger testing). + +function sbClient() { + if (typeof getSupabaseClient === 'function') return getSupabaseClient(); + if (_sbClient) return _sbClient; + const url = localStorage.getItem(LOG_URL_KEY); + const key = localStorage.getItem(LOG_KEY_KEY); + if (!url || !key) return null; + try { + _sbClient = supabase.createClient(url, key); + return _sbClient; + } catch (e) { + console.error('[Logger] Supabase init failed:', e); + return null; + } +} + +function loggerConfigured() { + // With supabase.js, the client is always configured via hardcoded defaults. + if (typeof getSupabaseClient === 'function') return true; + return !!(localStorage.getItem(LOG_URL_KEY) && localStorage.getItem(LOG_KEY_KEY)); +} + +// Round down to the nearest hour so snapshots align perfectly +function hourTs() { + return new Date(Math.floor(Date.now() / LOG_INTERVAL) * LOG_INTERVAL).toISOString(); +} + +// ── Trigger logic ───────────────────────────────────────────────────────────── + +async function loggerMaybeFire() { + if (!loggerConfigured() || _logRunning) return; + const last = parseInt(localStorage.getItem(LOG_LAST_KEY) || '0'); + if (Date.now() - last < LOG_INTERVAL) return; + await loggerFire(); +} + +async function loggerFire(manual = false) { + const client = sbClient(); + if (!client) { loggerSetStatus('error', 'Supabase not configured'); return; } + if (_logRunning && !manual) return; + _logRunning = true; + loggerSetStatus('running', 'Logging…'); + + const ts = hourTs(); + try { + const [mkErr, indErr, portErr, nanErr] = await Promise.all([ + logMarket(client, ts), + logIndicators(client, ts), + currentWallet ? logPortfolio(client, ts) : null, + window._nansenData?.netflows?.length ? logNansen(client, ts) : null, + ]); + + const errs = [mkErr, indErr, portErr, nanErr].filter(Boolean); + localStorage.setItem(LOG_LAST_KEY, Date.now().toString()); + const msg = errs.length ? `Done (${errs.length} warnings)` : 'Done'; + loggerSetStatus('ok', msg, ts); + console.log('[Logger] Snapshot logged for', ts, errs.length ? errs : ''); + } catch (err) { + loggerSetStatus('error', err.message); + console.error('[Logger] Snapshot failed:', err); + } finally { + _logRunning = false; + } +} + +// ── Market data ─────────────────────────────────────────────────────────────── +// All Hyperliquid perps: price, funding, OI, volume, premium + +async function logMarket(client, ts) { + let raw; + // Prefer cached data if fresh (avoid extra API call) + if (window._rawMeta) { + raw = window._rawMeta; + } else { + try { + raw = await hlPost({ type: 'metaAndAssetCtxs' }); + window._rawMeta = raw; + } catch (e) { + return `market fetch failed: ${e.message}`; + } + } + + const coins = raw[0]?.universe || []; + const ctxs = raw[1] || []; + + const rows = coins.map((coin, i) => { + const c = ctxs[i] || {}; + const mark = parseFloat(c.markPx || 0); + const oracle = parseFloat(c.oraclePx || 0); + const prev = parseFloat(c.prevDayPx || 0); + const funding = parseFloat(c.funding || 0); + return { + ts, + coin: coin.name, + mark_price: mark || null, + oracle_price: oracle || null, + open_interest: parseFloat(c.openInterest || 0) * mark || null, + funding_rate_8h: funding || null, + funding_apr: funding * 3 * 365 * 100 || null, + premium: oracle > 0 ? (mark - oracle) / oracle : null, + volume_24h: parseFloat(c.dayNtlVlm || 0) || null, + change_pct_24h: prev > 0 ? (mark - prev) / prev * 100 : null, + }; + }).filter(r => r.mark_price); + + if (!rows.length) return 'no market rows'; + const { error } = await client + .from('market_snapshots') + .upsert(rows, { onConflict: 'ts,coin', ignoreDuplicates: true }); + return error ? `market: ${error.message}` : null; +} + +// ── Indicators ───────────────────────────────────────────────────────────────── +// F&G, BMSB, Pi Cycle, BTC price + +async function logIndicators(client, ts) { + const ind = window._indData; + const btcPrice = window._ovData?.marketCtx?.BTC?.mark_price + || parseFloat(window._rawMeta?.[1]?.[ + (window._rawMeta?.[0]?.universe || []).findIndex(c => c.name === 'BTC') + ]?.markPx || 0) + || null; + + const row = { + ts, + btc_price: btcPrice, + fear_greed: ind?.fear_greed?.value ?? null, + fear_greed_label: ind?.fear_greed?.classification ?? null, + fear_greed_zone: ind?.fear_greed?.zone ?? null, + bmsb_signal: ind?.bmsb?.signal ?? null, + bmsb_sma20w: ind?.bmsb?.sma20w ?? null, + bmsb_ema21w: ind?.bmsb?.ema21w ?? null, + pi_signal: ind?.pi_cycle?.signal ?? null, + pi_proximity: ind?.pi_cycle?.proximity ?? null, + pi_sma111: ind?.pi_cycle?.sma111 ?? null, + pi_sma350x2: ind?.pi_cycle?.sma350x2 ?? null, + }; + + const { error } = await client + .from('indicator_snapshots') + .upsert([row], { onConflict: 'ts', ignoreDuplicates: true }); + return error ? `indicators: ${error.message}` : null; +} + +// ── Portfolio + positions ────────────────────────────────────────────────────── + +async function logPortfolio(client, ts) { + const s = window._ovData?.s; + const pos = window._ovData?.positions || []; + const mCtx = window._ovData?.marketCtx || {}; + if (!s) return 'no account state'; + + const ms = s.marginSummary || {}; + const portRow = { + ts, + wallet: currentWallet, + account_value: parseFloat(ms.accountValue || 0) || null, + unrealized_pnl: parseFloat(ms.totalUnrealizedPnl || 0) || null, + margin_used: parseFloat(ms.totalMarginUsed || 0) || null, + withdrawable: parseFloat(s.withdrawable || 0) || null, + position_count: pos.length, + }; + + const { error: pe } = await client + .from('portfolio_snapshots') + .upsert([portRow], { onConflict: 'ts,wallet', ignoreDuplicates: true }); + if (pe) return `portfolio: ${pe.message}`; + + if (!pos.length) return null; + + const posRows = pos.map(p => { + const size = Math.abs(parseFloat(p.szi || 0)); + const entry = parseFloat(p.entryPx || 0); + const mark = parseFloat(p.markPx || mCtx[p.coin]?.mark_price || 0); + const upnl = parseFloat(p.unrealizedPnl || 0); + const notional = size * mark; + const upnlPct = entry > 0 + ? (p.side === 'short' ? (entry - mark) / entry : (mark - entry) / entry) * 100 + : null; + + return { + ts, + wallet: currentWallet, + coin: p.coin, + side: p.side, + size, + entry_price: entry || null, + mark_price: mark || null, + unrealized_pnl: upnl || null, + unrealized_pnl_pct: upnlPct, + leverage: parseFloat(p.leverage?.value || p.leverage || 0) || null, + funding_rate_8h: mCtx[p.coin]?.funding_rate_8h ?? null, + funding_apr: mCtx[p.coin]?.funding_apr ?? null, + notional: notional || null, + cum_funding: parseFloat(p.cum_funding || 0) || null, + liquidation_price: parseFloat(p.liquidation_price || 0) || null, + }; + }); + + const { error: poserr } = await client + .from('position_snapshots') + .upsert(posRows, { onConflict: 'ts,wallet,coin', ignoreDuplicates: true }); + return poserr ? `positions: ${poserr.message}` : null; +} + +// ── Nansen ───────────────────────────────────────────────────────────────────── + +async function logNansen(client, ts) { + const flows = window._nansenData?.netflows; + if (!flows?.length) return null; + + const rows = flows.map(item => ({ + ts, + symbol: item.token?.symbol ?? null, + chain: item.token?.chain ?? null, + netflow_1h: item.netflow_usd_1h ?? null, + netflow_24h: item.netflow_usd_24h ?? null, + netflow_7d: item.netflow_usd_7d ?? null, + holder_count: item.smart_money_holder_count ?? null, + price_change_24h_pct: item.price_change_24h_pct ?? null, + })).filter(r => r.symbol); + + if (!rows.length) return null; + const { error } = await client + .from('nansen_snapshots') + .upsert(rows, { onConflict: 'ts,symbol', ignoreDuplicates: true }); + return error ? `nansen: ${error.message}` : null; +} + +// ── Init & scheduling ───────────────────────────────────────────────────────── + +function loggerInit() { + if (!loggerConfigured()) return; + // Fire 15 s after page load (give data time to load) + setTimeout(loggerMaybeFire, 15000); + // Check every 5 min + if (_logTimer) clearInterval(_logTimer); + _logTimer = setInterval(loggerMaybeFire, 300000); +} + +// ── Status badge (topbar) ───────────────────────────────────────────────────── + +function loggerSetStatus(state, msg, ts) { + const dot = document.getElementById('logger-dot'); + const tip = document.getElementById('logger-tip'); + if (dot) { + dot.className = `logger-dot ${state}`; + } + if (tip) { + const lastTs = ts || localStorage.getItem(LOG_LAST_KEY); + const lastNum = parseInt(lastTs || '0'); + const ago = lastNum ? Math.round((Date.now() - lastNum) / 60000) + 'm ago' : 'never'; + tip.textContent = `Logger: ${msg || state} · last ${ago}`; + } +} + +function loggerRefreshStatus() { + if (!loggerConfigured()) { + loggerSetStatus('off', 'not configured'); + return; + } + const last = parseInt(localStorage.getItem(LOG_LAST_KEY) || '0'); + if (!last) { loggerSetStatus('warn', 'no snapshots yet'); return; } + const mins = Math.round((Date.now() - last) / 60000); + if (mins < 65) loggerSetStatus('ok', `${mins}m ago`); + else loggerSetStatus('warn', `${mins}m ago — overdue`); +} + +// ── Settings section (rendered inside Analytics Logger tab) ─────────────────── + +const LOGGER_SQL = `-- Run this in Supabase SQL Editor (Dashboard → SQL Editor) + +create table if not exists market_snapshots ( + id bigserial primary key, + ts timestamptz not null, + coin text not null, + mark_price numeric, + oracle_price numeric, + open_interest numeric, + funding_rate_8h numeric, + funding_apr numeric, + premium numeric, + volume_24h numeric, + change_pct_24h numeric, + unique(ts, coin) +); + +create table if not exists indicator_snapshots ( + id bigserial primary key, + ts timestamptz not null unique, + btc_price numeric, + fear_greed int, + fear_greed_label text, + fear_greed_zone text, + bmsb_signal text, + bmsb_sma20w numeric, + bmsb_ema21w numeric, + pi_signal text, + pi_proximity numeric, + pi_sma111 numeric, + pi_sma350x2 numeric +); + +create table if not exists portfolio_snapshots ( + id bigserial primary key, + ts timestamptz not null, + wallet text not null, + account_value numeric, + unrealized_pnl numeric, + margin_used numeric, + withdrawable numeric, + position_count int, + unique(ts, wallet) +); + +create table if not exists position_snapshots ( + id bigserial primary key, + ts timestamptz not null, + wallet text not null, + coin text not null, + side text, + size numeric, + entry_price numeric, + mark_price numeric, + unrealized_pnl numeric, + unrealized_pnl_pct numeric, + leverage numeric, + funding_rate_8h numeric, + funding_apr numeric, + notional numeric, + cum_funding numeric, + liquidation_price numeric, + unique(ts, wallet, coin) +); + +create table if not exists nansen_snapshots ( + id bigserial primary key, + ts timestamptz not null, + symbol text not null, + chain text, + netflow_1h numeric, + netflow_24h numeric, + netflow_7d numeric, + holder_count int, + price_change_24h_pct numeric, + unique(ts, symbol) +); + +-- Allow anonymous inserts (needed for browser writes) +alter table market_snapshots disable row level security; +alter table indicator_snapshots disable row level security; +alter table portfolio_snapshots disable row level security; +alter table position_snapshots disable row level security; +alter table nansen_snapshots disable row level security;`; + +function renderLoggerSettings() { + const url = localStorage.getItem(LOG_URL_KEY) || ''; + const key = localStorage.getItem(LOG_KEY_KEY) || ''; + const last = parseInt(localStorage.getItem(LOG_LAST_KEY) || '0'); + const ago = last ? Math.round((Date.now() - last) / 60000) + ' min ago' : 'Never'; + const status = last + ? (Date.now() - last < 65 * 60000 ? 'ok' : 'warn') + : (loggerConfigured() ? 'warn' : 'off'); + + return `
    +
    Data Logger Setup + ${status === 'ok' ? 'ACTIVE' : status === 'warn' ? 'OVERDUE' : 'OFF'} +
    + +
    +
    1
    +
    +
    Create tables in Supabase
    +
    Open your Supabase project → SQL Editor → paste and run:
    +
    + +
    ${LOGGER_SQL.replace(//g,'>')}
    +
    +
    +
    + +
    +
    2
    +
    +
    Connect to your Supabase project
    +
    Project URL and anon key are in Supabase → Settings → API.
    +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    Last snapshot
    +
    ${ago}
    +
    +
    +
    Logs every
    +
    1 hour
    +
    +
    +
    Tables
    +
    5 (market, indicators, portfolio, positions, nansen)
    +
    + +
    + +
    +
    What gets logged each hour
    +
    +
    market_snapshots — all Hyperliquid perps: price, funding, OI, volume, premium
    +
    indicator_snapshots — BTC price, Fear & Greed, BMSB signal + values, Pi Cycle proximity
    +
    portfolio_snapshots — account value, unrealised P&L, margin used
    +
    position_snapshots — every open position: entry, mark, PnL%, leverage, liquidation distance, cum funding
    +
    nansen_snapshots — smart money flows per token (if Nansen configured)
    +
    +
    +
    `; +} + +function loggerCopySql() { + navigator.clipboard.writeText(LOGGER_SQL).then(() => { + const btn = document.querySelector('.log-copy-btn'); + if (btn) { btn.textContent = 'Copied!'; setTimeout(() => btn.textContent = 'Copy SQL', 2000); } + }); +} + +function loggerSaveAndStart() { + const url = (document.getElementById('log-sb-url')?.value || '').trim(); + const key = (document.getElementById('log-sb-key')?.value || '').trim(); + if (!url || !key) { alert('Enter both URL and anon key.'); return; } + localStorage.setItem(LOG_URL_KEY, url); + localStorage.setItem(LOG_KEY_KEY, key); + _sbClient = null; + loggerInit(); + loggerFire(true).then(() => { + loggerRefreshStatus(); + document.getElementById('log-test-result').textContent = 'Saved and first snapshot triggered.'; + }); +} + +async function loggerTestConnection() { + const url = (document.getElementById('log-sb-url')?.value || '').trim(); + const key = (document.getElementById('log-sb-key')?.value || '').trim(); + const res = document.getElementById('log-test-result'); + if (!url || !key) { res.textContent = 'Enter URL and key first.'; return; } + res.textContent = 'Testing…'; + try { + const client = supabase.createClient(url, key); + const { error } = await client.from('indicator_snapshots').select('id').limit(1); + if (error) throw new Error(error.message); + res.style.color = 'var(--green)'; + res.textContent = 'Connected — table found.'; + } catch (e) { + res.style.color = 'var(--red)'; + res.textContent = 'Error: ' + e.message + (e.message.includes('does not exist') ? ' — run the SQL setup first.' : ''); + } +} diff --git a/frontend/js/nansen.js b/frontend/js/nansen.js new file mode 100644 index 0000000..d1e7a83 --- /dev/null +++ b/frontend/js/nansen.js @@ -0,0 +1,478 @@ +let _nansenCache = null, _nansenCacheTs = 0; +const NANSEN_TTL = 300000; +let _prevFlows = {}; +let _nansenTab = 'positions'; +let _nansenTimer = null, _nansenCountdown = 300; + +async function fetchNansenDirect() { + const apiKey = localStorage.getItem('nansen_api_key'); + if (!apiKey) return null; + + const chains = ['ethereum', 'solana', 'base', 'arbitrum', 'optimism', 'bnb', 'hyperevm']; + + const resp = await fetch('https://api.nansen.ai/api/v1/smart-money/netflow', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'apikey': apiKey }, + body: JSON.stringify({ chains, pagination: { page: 1, per_page: 100 } }) + }); + + if (!resp.ok) throw new Error(`Nansen API ${resp.status}: ${resp.statusText}`); + const data = await resp.json(); + + const holdResp = await fetch('https://api.nansen.ai/api/v1/smart-money/holdings', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'apikey': apiKey }, + body: JSON.stringify({ chains }) + }); + const holdings = holdResp.ok ? await holdResp.json() : null; + + const result = { netflows: data, holdings, fetched_at: Date.now() }; + _nansenCache = result; + _nansenCacheTs = Date.now(); + window._nansenData = _nansenCache; + return _nansenCache; +} + +async function fetchNansenData() { + if (_nansenCache && Date.now() - _nansenCacheTs < NANSEN_TTL) return _nansenCache; + + try { + const resp = await fetch('/api/nansen/flow', { + headers: { 'x-wallet': window.currentWallet || '' } + }); + if (resp.ok) { + const data = await resp.json(); + _nansenCache = data; + _nansenCacheTs = Date.now(); + window._nansenData = _nansenCache; + return _nansenCache; + } + } catch {} + + return fetchNansenDirect(); +} + +function checkFlowAlerts(netflows, openPositionSymbols) { + if (!('Notification' in window)) return; + + (netflows || []).forEach(item => { + const sym = item?.token?.symbol?.toUpperCase(); + if (!sym || !openPositionSymbols.includes(sym)) return; + + const dir = (item.netflow_usd_1h || 0) >= 0 ? 'in' : 'out'; + const prev = _prevFlows[sym]; + + if (prev && prev !== dir) { + const msg = dir === 'in' + ? `Smart money BUYING ${sym} — net inflow flipped positive` + : `Smart money SELLING ${sym} — net outflow flipped negative`; + + if (Notification.permission === 'granted') { + new Notification('Hype — Flow Alert', { body: msg, icon: '/static/icons/icon-192.png' }); + } + } + _prevFlows[sym] = dir; + }); +} + +function fmtFlow(usd) { + if (usd === null || usd === undefined || usd === 0) return '—'; + const abs = Math.abs(usd); + const str = abs >= 1e9 ? (abs / 1e9).toFixed(2) + 'B' + : abs >= 1e6 ? (abs / 1e6).toFixed(2) + 'M' + : abs >= 1e3 ? (abs / 1e3).toFixed(1) + 'K' + : abs.toFixed(0); + return (usd >= 0 ? '+$' : '-$') + str; +} + +function flowCls(usd) { + return usd > 0 ? 'pos' : usd < 0 ? 'neg' : 'muted'; +} + +function signalBadge(usd24h) { + if (usd24h > 100000) return 'ACCUMULATING'; + if (usd24h < -100000) return 'DISTRIBUTING'; + return 'NEUTRAL'; +} + +function setNansenTab(tab, btn) { + _nansenTab = tab; + document.querySelectorAll('.nansen-tab').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + renderNansenTab(); +} + +function startNansenRefresh() { + if (_nansenTimer) clearInterval(_nansenTimer); + _nansenCountdown = 300; + _nansenTimer = setInterval(() => { + _nansenCountdown--; + const el = document.getElementById('nansen-countdown'); + if (el) el.textContent = _nansenCountdown + 's'; + if (_nansenCountdown <= 0) { + _nansenCache = null; + _nansenCacheTs = 0; + loadNansen(); + } + }, 1000); +} + +function requestNansenNotifPerms() { + if ('Notification' in window && Notification.permission === 'default') { + Notification.requestPermission(); + } +} + +function nansenKeyForm() { + return `
    +
    Nansen API Key Required
    +
    Enter your Nansen API key to access Smart Money data. It will be saved locally and used as a direct fallback.
    + + +
    `; +} + +function nansenSaveKey() { + const val = (document.getElementById('nansen-key-input')?.value || '').trim(); + if (!val) return; + localStorage.setItem('nansen_api_key', val); + loadNansen(); +} + +function nansenGetPositionSymbols() { + return (window._ovData?.positions || []).map(p => (p.coin || '').toUpperCase()); +} + +function nansenFindToken(netflows, sym) { + const s = sym.toUpperCase(); + return (netflows || []).find(item => (item?.token?.symbol || '').toUpperCase() === s) || null; +} + +function nansenMinsAgo(ts) { + if (!ts) return '?'; + const mins = Math.round((Date.now() - ts) / 60000); + if (mins < 1) return 'just now'; + return `${mins} min ago`; +} + +function renderNansenPositionsTab(netflows) { + const positions = window._ovData?.positions || []; + if (!positions.length) { + return '
    No open positions found. Open a position to see Smart Money context here.
    '; + } + + const cards = positions.map(p => { + const sym = (p.coin || '').toUpperCase(); + const item = nansenFindToken(netflows, sym); + if (!item) { + return `
    +
    + ${sym} + ${(p.side || '').toUpperCase()} +
    +
    No Nansen data for ${sym}
    +
    `; + } + + const f1h = item.netflow_usd_1h ?? null; + const f24h = item.netflow_usd_24h ?? null; + const f7d = item.netflow_usd_7d ?? null; + const holders = item.smart_money_holder_count ?? null; + const priceCh = item.price_change_24h_pct ?? null; + + const cardCls = f24h > 100000 ? 'pos-accum' : f24h < -100000 ? 'pos-distrib' : 'pos-neutral'; + + return `
    +
    + ${sym} + ${(p.side || '').toUpperCase()} + ${signalBadge(f24h)} +
    +
    + ${item.token?.name ? item.token.name + ' · ' : ''}${item.token?.chain || ''} + ${holders !== null ? ` · ${holders} SM holders` : ''} + ${priceCh !== null ? ` · ${priceCh >= 0 ? '+' : ''}${priceCh.toFixed(2)}% 24h` : ''} +
    +
    +
    +
    1h Flow
    +
    ${fmtFlow(f1h)}
    +
    +
    +
    24h Flow
    +
    ${fmtFlow(f24h)}
    +
    +
    +
    7d Flow
    +
    ${fmtFlow(f7d)}
    +
    +
    +
    `; + }); + + return `
    ${cards.join('')}
    `; +} + +function renderNansenTopMoversTab(netflows) { + const openSyms = new Set(nansenGetPositionSymbols()); + const items = (netflows || []).slice(0, 50); + + const filterChips = `
    + + + +
    `; + + const sorted = [...items].sort((a, b) => (b.netflow_usd_24h || 0) - (a.netflow_usd_24h || 0)); + + const rows = sorted.map((item, i) => { + const sym = item?.token?.symbol?.toUpperCase() || '?'; + const chain = item?.token?.chain || ''; + const isOpen = openSyms.has(sym); + const f1h = item.netflow_usd_1h ?? null; + const f24h = item.netflow_usd_24h ?? null; + const f7d = item.netflow_usd_7d ?? null; + const holders = item.smart_money_holder_count ?? null; + const priceCh = item.price_change_24h_pct ?? null; + + return ` + ${i + 1} + +
    ${sym}
    +
    ${item.token?.name || ''}
    + + ${chain} + ${fmtFlow(f1h)} + ${fmtFlow(f24h)} + ${fmtFlow(f7d)} + ${holders !== null ? holders.toLocaleString() : '—'} + ${priceCh !== null ? (priceCh >= 0 ? '+' : '') + priceCh.toFixed(2) + '%' : '—'} + ${signalBadge(f24h)} + `; + }).join(''); + + return `${filterChips} +
    + + + + + + + ${rows} +
    #TokenChainSM Flow 1hSM Flow 24hSM Flow 7dHoldersPrice ChgSignal
    +
    `; +} + +function nansenSetMoverFilter(filter, btn) { + document.querySelectorAll('[id^="nansen-mover-chip-"]').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + const netflows = _nansenCache?.netflows || []; + const openSyms = new Set(nansenGetPositionSymbols()); + let sorted; + if (filter === 'inflow') { + sorted = [...netflows].sort((a, b) => (b.netflow_usd_24h || 0) - (a.netflow_usd_24h || 0)); + } else if (filter === 'outflow') { + sorted = [...netflows].sort((a, b) => (a.netflow_usd_24h || 0) - (b.netflow_usd_24h || 0)); + } else { + sorted = [...netflows].sort((a, b) => (b.smart_money_holder_count || 0) - (a.smart_money_holder_count || 0)); + } + + const tbody = document.getElementById('nansen-movers-tbody'); + if (!tbody) return; + tbody.innerHTML = sorted.slice(0, 50).map((item, i) => { + const sym = item?.token?.symbol?.toUpperCase() || '?'; + const chain = item?.token?.chain || ''; + const isOpen = openSyms.has(sym); + const f1h = item.netflow_usd_1h ?? null; + const f24h = item.netflow_usd_24h ?? null; + const f7d = item.netflow_usd_7d ?? null; + const holders = item.smart_money_holder_count ?? null; + const priceCh = item.price_change_24h_pct ?? null; + + return ` + ${i + 1} + +
    ${sym}
    +
    ${item.token?.name || ''}
    + + ${chain} + ${fmtFlow(f1h)} + ${fmtFlow(f24h)} + ${fmtFlow(f7d)} + ${holders !== null ? holders.toLocaleString() : '—'} + ${priceCh !== null ? (priceCh >= 0 ? '+' : '') + priceCh.toFixed(2) + '%' : '—'} + ${signalBadge(f24h)} + `; + }).join(''); +} + +function nansenGetWatchlist() { + try { return JSON.parse(localStorage.getItem('nansen_watchlist') || '[]'); } catch { return []; } +} +function nansenSaveWatchlist(list) { + localStorage.setItem('nansen_watchlist', JSON.stringify(list)); +} + +function nansenAddToWatchlist() { + const input = document.getElementById('nansen-wl-input'); + const sym = (input?.value || '').trim().toUpperCase(); + if (!sym) return; + const list = nansenGetWatchlist(); + if (!list.includes(sym)) { + list.push(sym); + nansenSaveWatchlist(list); + } + if (input) input.value = ''; + renderNansenTab(); +} + +function nansenRemoveFromWatchlist(sym) { + const list = nansenGetWatchlist().filter(s => s !== sym); + nansenSaveWatchlist(list); + renderNansenTab(); +} + +function renderNansenWatchlistTab(netflows) { + const list = nansenGetWatchlist(); + + const inputRow = `
    + + +
    `; + + if (!list.length) { + return inputRow + '
    No symbols in watchlist. Add one above.
    '; + } + + const cards = list.map(sym => { + const item = nansenFindToken(netflows, sym); + const removeBtn = ``; + + if (!item) { + return `
    + ${removeBtn} + ${sym} + No Nansen data +
    `; + } + + const f1h = item.netflow_usd_1h ?? null; + const f24h = item.netflow_usd_24h ?? null; + const f7d = item.netflow_usd_7d ?? null; + const holders = item.smart_money_holder_count ?? null; + const cardCls = f24h > 100000 ? 'pos-accum' : f24h < -100000 ? 'pos-distrib' : 'pos-neutral'; + + return `
    +
    + ${removeBtn} + ${sym} + ${signalBadge(f24h)} + ${holders !== null ? `${holders} holders` : ''} +
    +
    +
    +
    1h Flow
    +
    ${fmtFlow(f1h)}
    +
    +
    +
    24h Flow
    +
    ${fmtFlow(f24h)}
    +
    +
    +
    7d Flow
    +
    ${fmtFlow(f7d)}
    +
    +
    +
    `; + }); + + return inputRow + cards.join(''); +} + +function renderNansenTab() { + const content = document.getElementById('smartmoney-content'); + if (!content) return; + + const netflows = _nansenCache?.netflows || []; + const fetchedAt = _nansenCache?.fetched_at || null; + + const header = `
    + Last updated: ${fetchedAt ? nansenMinsAgo(fetchedAt) : '—'} + Auto-refresh in: ${_nansenCountdown}s + + +
    +
    + + + +
    `; + + let tabContent = ''; + if (_nansenTab === 'positions') tabContent = renderNansenPositionsTab(netflows); + else if (_nansenTab === 'movers') tabContent = renderNansenTopMoversTab(netflows); + else tabContent = renderNansenWatchlistTab(netflows); + + content.innerHTML = header + tabContent; +} + +function nansenForceRefresh() { + _nansenCache = null; + _nansenCacheTs = 0; + loadNansen(); +} + +function nansenClearKey() { + localStorage.removeItem('nansen_api_key'); + _nansenCache = null; + _nansenCacheTs = 0; + loadNansen(); +} + +async function loadNansen() { + const content = document.getElementById('smartmoney-content'); + if (!content) return; + + if (!_nansenCache) { + content.innerHTML = '
    Loading Smart Money data…
    '; + } + + requestNansenNotifPerms(); + + try { + const data = await fetchNansenData(); + if (!data) { + const apiKey = localStorage.getItem('nansen_api_key'); + if (!apiKey) { + content.innerHTML = nansenKeyForm(); + return; + } + content.innerHTML = `
    +
    Unable to load Nansen data
    +
    Backend unavailable and direct API call returned no data.
    + + +
    `; + return; + } + + const openSyms = nansenGetPositionSymbols(); + checkFlowAlerts(data.netflows, openSyms); + + renderNansenTab(); + startNansenRefresh(); + } catch (err) { + const apiKey = localStorage.getItem('nansen_api_key'); + if (!apiKey) { + content.innerHTML = nansenKeyForm(); + return; + } + content.innerHTML = `
    +
    Failed to load Nansen data
    +
    ${err.message}
    + + +
    `; + } +} diff --git a/frontend/js/position-meta.js b/frontend/js/position-meta.js new file mode 100644 index 0000000..9a5be66 --- /dev/null +++ b/frontend/js/position-meta.js @@ -0,0 +1,334 @@ +// ── Position Metadata Layer ─────────────────────────────────────────────────── +// localStorage key: 'hype_pos_meta' +// schema: { [coin]: { intent, opened_at, thesis: { macro_phase, ta_level, ta_side, momentum_ok, notes }, stop_price } } + +function pmLoad() { + try { return JSON.parse(localStorage.getItem('hype_pos_meta') || '{}'); } catch { return {}; } +} +function pmSave(data) { localStorage.setItem('hype_pos_meta', JSON.stringify(data)); } +function pmGet(coin) { return pmLoad()[coin] || {}; } +function pmSet(coin, patch) { + const data = pmLoad(); + data[coin] = Object.assign({}, data[coin] || {}, patch); + pmSave(data); +} +function pmClearStale(activeCoins) { + const data = pmLoad(); + let changed = false; + for (const coin of Object.keys(data)) { + if (!activeCoins.includes(coin)) { delete data[coin]; changed = true; } + } + if (changed) pmSave(data); +} + +function pmFmtAge(opened_at) { + if (!opened_at) return null; + const ms = Date.now() - new Date(opened_at).getTime(); + if (ms < 0) return null; + const d = Math.floor(ms / 86400000); + const h = Math.floor((ms % 86400000) / 3600000); + return d > 0 ? `${d}d ${h}h` : `${h}h`; +} + +function posAgeBadge(coin) { + const m = pmGet(coin); + const intent = (m.intent || '').toUpperCase(); + const age = pmFmtAge(m.opened_at); + const ageStr = age || '?'; + let stale = false; + if (m.intent === 'scalp' && age) { + const ms = Date.now() - new Date(m.opened_at).getTime(); + if (ms > 3 * 86400000) stale = true; + } + if (m.intent === 'swing' && age) { + const ms = Date.now() - new Date(m.opened_at).getTime(); + if (ms > 45 * 86400000) stale = true; + } + const cls = stale ? 'pos-age-badge pos-age-stale' : 'pos-age-badge'; + return `${ageStr}${intent ? ` ${intent}` : ''}`; +} + +// ── Modal ───────────────────────────────────────────────────────────────────── +let _pmCoin = null; +let _pmTab = 'intent'; + +function openPositionDetail(coin) { + _pmCoin = coin; + _pmTab = 'intent'; + _renderPmModal(); +} + +function _renderPmModal() { + const existing = document.getElementById('pm-modal'); + if (existing) existing.remove(); + const overlay = document.createElement('div'); + overlay.id = 'pm-modal'; + overlay.className = 'pos-modal-overlay'; + overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; + overlay.innerHTML = _pmModalHtml(_pmCoin, _pmTab); + document.body.appendChild(overlay); +} + +function _pmGetCtx(coin) { + if (typeof _ovData !== 'undefined' && _ovData && _ovData.marketCtx) return _ovData.marketCtx[coin] || {}; + if (typeof _marketCtx !== 'undefined' && _marketCtx) return _marketCtx[coin] || {}; + return {}; +} +function _pmGetPos(coin) { + if (typeof _ovData !== 'undefined' && _ovData && _ovData.positions) return _ovData.positions.find(p => p.coin === coin) || null; + return null; +} +function _pmFmtPrice(n) { + if (typeof fmtPrice === 'function') return fmtPrice(n); + if (!n) return '—'; + if (n >= 1000) return '$' + n.toLocaleString('en-US', {maximumFractionDigits: 0}); + if (n >= 1) return '$' + n.toFixed(3); + return '$' + n.toFixed(6); +} + +function _pmModalHtml(coin, tab) { + const m = pmGet(coin); + const pos = _pmGetPos(coin); + const ctx = _pmGetCtx(coin); + + return `
    +
    + ${coin} Position + +
    +
    + + + + +
    +
    + ${tab === 'intent' ? _pmTabIntent(coin, m) : ''} + ${tab === 'thesis' ? _pmTabThesis(coin, m, ctx) : ''} + ${tab === 'risk' ? _pmTabRisk(coin, m, pos, ctx) : ''} + ${tab === 'signal' ? _pmTabSignal(coin) : ''} +
    +
    `; +} + +function _pmTabIntent(coin, m) { + const intents = ['scalp', 'swing', 'position']; + const age = pmFmtAge(m.opened_at); + const openedVal = m.opened_at ? new Date(m.opened_at).toISOString().slice(0,16) : ''; + return ` +
    +
    Trade Type
    +
    + ${intents.map(i => ``).join('')} +
    +
    +
    +
    Opened At
    +
    + + +
    + ${age ? `
    Open for ${age}
    ` : ''} +
    +
    +
    Notes
    + + +
    `; +} + +function _pmTabThesis(coin, m, ctx) { + const thesis = m.thesis || {}; + const macroPhases = ['Accumulation','Markup','Distribution','Markdown','Neutral']; + const currentPhase = (typeof INTEL !== 'undefined' && INTEL && INTEL.macro && INTEL.macro.cycle_phase) || null; + const currentPrice = ctx.mark_price || 0; + const fundingApr = ctx.funding_apr || 0; + + const sigMacro = _sigCheckMacro(thesis, currentPhase); + const sigTA = _sigCheckTA(thesis, currentPrice); + const sigMom = _sigCheckMom(thesis, fundingApr); + const validCount = [sigMacro, sigTA, sigMom].filter(s => s && s.ok === true).length; + const totalSig = [sigMacro, sigTA, sigMom].filter(Boolean).length; + + return ` +
    +
    ${totalSig > 0 ? `${validCount}/${totalSig} signals still valid` : 'Save thesis to track signals'}
    + ${totalSig > 0 ? ` +
    + ${sigMacro && sigMacro.ok === true ? '✓' : sigMacro && sigMacro.ok === false ? '✗' : '?'} + Macro + ${sigMacro ? sigMacro.detail : 'Not set'} +
    +
    + ${sigTA && sigTA.ok === true ? '✓' : sigTA && sigTA.ok === false ? '✗' : '?'} + TA + ${sigTA ? sigTA.detail : 'Not set'} +
    +
    + ${sigMom && sigMom.ok === true ? '✓' : sigMom && sigMom.ok === false ? '✗' : '?'} + Momentum + ${sigMom ? sigMom.detail : 'Not set'} +
    ` : ''} +
    +
    +
    Entry Macro Phase
    + + ${currentPhase ? `
    Current: ${currentPhase}
    ` : ''} +
    +
    +
    Key TA Level
    +
    + + +
    + ${currentPrice > 0 ? `
    Current price: ${_pmFmtPrice(currentPrice)}
    ` : ''} +
    +
    +
    Momentum at Entry
    +
    + + +
    + ${fundingApr !== 0 ? `
    Current funding: ${fundingApr.toFixed(1)}% APR
    ` : ''} +
    + `; +} + +function _pmTabRisk(coin, m, pos, ctx) { + const stopPrice = m.stop_price || ''; + const entryPrice = pos ? pos.entry_price : 0; + const markPrice = ctx.mark_price || (pos ? pos.mark_price : 0); + const positionValue = pos ? pos.position_value : 0; + const size = pos ? pos.size : 0; + const acctValue = (typeof _ovData !== 'undefined' && _ovData && _ovData.s && _ovData.s.account_value) || 0; + const fundingApr = ctx.funding_apr || 0; + + let metricsHtml = ''; + if (stopPrice && entryPrice > 0 && size > 0) { + const sp = parseFloat(stopPrice); + const dollarRisk = Math.abs(entryPrice - sp) * size; + const pctAccount = acctValue > 0 ? (dollarRisk / acctValue * 100).toFixed(2) : '—'; + const stopDist = markPrice > 0 ? (Math.abs(markPrice - sp) / markPrice * 100).toFixed(2) : '—'; + const dailyFunding = fundingApr / 100 / 365 * positionValue; + const fundingBurnDays = dailyFunding > 0 ? (dollarRisk / dailyFunding).toFixed(1) : '∞'; + const burnWarn = parseFloat(fundingBurnDays) < 7; + metricsHtml = ` +
    +
    Dollar Risk
    ${fmt$(dollarRisk)}
    +
    % of Account
    ${pctAccount}%
    +
    Stop Distance
    ${stopDist}%
    +
    Funding Burn Days
    ${fundingBurnDays}${burnWarn ? ' ⚠' : ''}
    +
    + ${burnWarn ? `
    Funding cost erases your risk budget in <7 days — reconsider size or stop.
    ` : ''}`; + } + + return ` +
    +
    Stop Price
    +
    + + +
    + ${entryPrice > 0 ? `
    Entry: ${_pmFmtPrice(entryPrice)} · Mark: ${_pmFmtPrice(markPrice)}
    ` : ''} +
    + ${metricsHtml}`; +} + +function _pmTabSignal(coin) { + setTimeout(() => { + if (typeof loadModalSignal === 'function') loadModalSignal(coin); + }, 0); + return `
    Analysing ${coin}…
    `; +} + +// ── Signal checkers ─────────────────────────────────────────────────────────── +function _sigCheckMacro(thesis, currentPhase) { + if (!thesis.macro_phase) return null; + if (!currentPhase) return { ok: null, detail: `Entry: ${thesis.macro_phase} — current phase unknown` }; + const ok = thesis.macro_phase.toLowerCase() === currentPhase.toLowerCase(); + return { ok, detail: `Entry: ${thesis.macro_phase} / Now: ${currentPhase}` }; +} +function _sigCheckTA(thesis, currentPrice) { + if (!thesis.ta_level) return null; + const level = parseFloat(thesis.ta_level); + if (!currentPrice || !level) return null; + const side = thesis.ta_side || 'above'; + const ok = side === 'above' ? currentPrice > level : currentPrice < level; + return { ok, detail: `${_pmFmtPrice(currentPrice)} ${ok ? '✓' : '✗'} ${side} ${_pmFmtPrice(level)}` }; +} +function _sigCheckMom(thesis, fundingApr) { + if (thesis.momentum_ok === undefined || thesis.momentum_ok === null) return null; + const strong = fundingApr > 5; + const ok = thesis.momentum_ok === true ? strong : !strong; + return { ok, detail: `Entry ${thesis.momentum_ok ? 'strong' : 'weak'} / Now ${strong ? 'strong' : 'weak'} (${fundingApr.toFixed(1)}% APR)` }; +} + +// ── Action handlers ─────────────────────────────────────────────────────────── +function pmSwitchTab(tab) { + _pmTab = tab; + _renderPmModal(); +} + +function pmSetIntent(coin, intent) { + pmSet(coin, { intent }); + _renderPmModal(); +} + +function pmSetMom(coin, val) { + const m = pmGet(coin); + const thesis = Object.assign({}, m.thesis || {}, { momentum_ok: val }); + pmSet(coin, { thesis }); + _renderPmModal(); +} + +function pmSaveOpenedAt(coin) { + const val = document.getElementById('pm-opened-at').value; + if (!val) return; + pmSet(coin, { opened_at: val }); + _renderPmModal(); +} + +function pmSaveNotes(coin) { + const notes = document.getElementById('pm-notes').value; + const m = pmGet(coin); + const thesis = Object.assign({}, m.thesis || {}, { notes }); + pmSet(coin, { thesis }); + _showToast('Notes saved'); +} + +function pmSaveThesis(coin) { + const macro_phase = document.getElementById('pm-macro-phase').value; + const ta_level = parseFloat(document.getElementById('pm-ta-level').value) || null; + const ta_side = document.getElementById('pm-ta-side').value; + const m = pmGet(coin); + const thesis = Object.assign({}, m.thesis || {}, { macro_phase: macro_phase || null, ta_level, ta_side }); + pmSet(coin, { thesis }); + _renderPmModal(); +} + +function pmSaveStop(coin) { + const val = parseFloat(document.getElementById('pm-stop-price').value); + if (!val || isNaN(val)) return; + pmSet(coin, { stop_price: val }); + _renderPmModal(); +} + +function _showToast(msg) { + let t = document.getElementById('pm-toast'); + if (!t) { + t = document.createElement('div'); + t.id = 'pm-toast'; + t.style.cssText = 'position:fixed;bottom:80px;right:16px;background:#1a1a1a;border:1px solid #383838;color:#e2e2e2;padding:8px 14px;border-radius:6px;font-size:12px;z-index:9999;transition:opacity .3s'; + document.body.appendChild(t); + } + t.textContent = msg; + t.style.opacity = '1'; + clearTimeout(t._timer); + t._timer = setTimeout(() => { t.style.opacity = '0'; }, 2000); +} diff --git a/frontend/js/signals.js b/frontend/js/signals.js new file mode 100644 index 0000000..6c06836 --- /dev/null +++ b/frontend/js/signals.js @@ -0,0 +1,555 @@ +/** + * signals.js — Senpi AI-inspired signal scanner + * + * Runs entirely in the browser against the public Hyperliquid API. + * Reuses detectPhase(), iEMA(), iMACD(), iRSI() from app.js. + * + * For each coin shows: + * Confluence score 0-10 · Phase · Funding rate · Smart wallet count · Verdict + * Expandable detail: every gate, phase evidence, skip reasons. + */ + +const SIG_HL_URL = 'https://api.hyperliquid.xyz/info'; +const SIG_WATCH_COINS = ['BTC', 'ETH', 'SOL', 'HYPE', 'SUI', 'AVAX']; +const SIG_SMART_WALLETS = { + 'Abraxas Capital': '0x5b5d51203a0f9079f8aeb098a6523a13f298c060', + 'James Wynn': '0x5078C2fBeA2b2aD61bc840Bc023E35Fce56BeDb6', + 'qwatio': '0xf3F496C9486BE5924a93D67e98298733Bb47057c', + 'HLP Whale': '0xb317d2bc2d3d2df5fa441b5bae0ab9d8b07283ae', +}; + +// Config mirrors bot/config.py +const SIG_CONFIG = { + MIN_PHASE_CONFIDENCE: 0.40, + MIN_VOLUME_RATIO: 1.20, + MIN_CONFLUENCE_SCORE: 5, + MAX_LONG_FUNDING_RATE: 0.0020, // 0.20% / 8h + BTC_MACRO_GATE_PCT: 0.03, // 3% hostile move blocks longs +}; + +// DSL tier reference (displayed in detail) +const SIG_DSL_TIERS = [ + { gain: 0.10, lock: 0.35, label: '+10% → SL locks 3.5%' }, + { gain: 0.20, lock: 0.55, label: '+20% → SL locks 11%' }, + { gain: 0.35, lock: 0.70, label: '+35% → SL locks 24.5%' }, +]; + +let _sigRunning = false; +let _sigLastResults = null; + +// ── API helpers ─────────────────────────────────────────────────────────────── + +async function _sigPost(payload) { + const r = await fetch(SIG_HL_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!r.ok) throw new Error(`HL API ${r.status}`); + return r.json(); +} + +async function _sigCandles(coin, interval, days) { + const now = Date.now(); + try { + return await _sigPost({ + type: 'candleSnapshot', + req: { coin, interval, startTime: now - days * 86400_000, endTime: now }, + }); + } catch (e) { + console.warn(`[signals] candles ${coin}/${interval}:`, e.message); + return []; + } +} + +async function _sigMarketContexts() { + try { + const data = await _sigPost({ type: 'metaAndAssetCtxs' }); + const universe = data[0]?.universe || []; + const ctxs = data[1] || []; + const out = {}; + universe.forEach((u, i) => { + if (i < ctxs.length) { + out[u.name] = { + fundingRate: parseFloat(ctxs[i].funding || 0), + openInterest: parseFloat(ctxs[i].openInterest || 0), + markPx: parseFloat(ctxs[i].markPx || 0), + }; + } + }); + return out; + } catch (e) { + console.warn('[signals] marketCtxs:', e.message); + return {}; + } +} + +async function _sigWalletPositions() { + const result = {}; + await Promise.all( + Object.entries(SIG_SMART_WALLETS).map(async ([label, addr]) => { + try { + const state = await _sigPost({ type: 'clearinghouseState', user: addr }); + const positions = {}; + for (const p of (state.assetPositions || [])) { + const pos = p.position; + const sz = parseFloat(pos.szi); + if (sz === 0) continue; + positions[pos.coin] = { side: sz > 0 ? 'long' : 'short', sz: Math.abs(sz), entry: parseFloat(pos.entryPx || 0) }; + } + result[label] = positions; + } catch (e) { + console.warn(`[signals] wallet ${label}:`, e.message); + result[label] = {}; + } + }) + ); + return result; +} + +// ── TA helpers ──────────────────────────────────────────────────────────────── +// Uses iEMA / iMACD / iRSI defined in app.js + +function _sigTA(candles, label) { + const empty = { ok: false, emaBull: false, macdBull: false, rsiAbove: false, rsiVal: 50, volRatio: 1, reason: 'no data' }; + if (!candles || candles.length < 30) return empty; + + const closes = candles.map(c => parseFloat(c.c)); + const volumes = candles.map(c => parseFloat(c.v)); + + const ema20 = iEMA(closes, 20); + const ema50 = closes.length >= 50 ? iEMA(closes, 50) : null; + const emaBull = ema20.length > 0 && ema50?.length > 0 && ema20.at(-1) > ema50.at(-1); + + const { hist } = iMACD(closes); + const macdBull = hist.length > 0 && hist.at(-1) > 0; + + const rsiArr = iRSI(closes).filter(v => v !== null); + const rsiVal = +(rsiArr.at(-1) ?? 50).toFixed(1); + const rsiAbove = rsiVal > 50; + + const q = Math.max(4, Math.floor(volumes.length / 4)); + const avgV = arr => arr.reduce((a, b) => a + b, 0) / arr.length; + const volRatio = +(avgV(volumes.slice(-q)) / Math.max(avgV(volumes.slice(0, q)), 1)).toFixed(2); + + const ok = label === '1h' ? (emaBull && macdBull && rsiAbove) : (macdBull && rsiAbove); + return { ok, emaBull, macdBull, rsiAbove, rsiVal, volRatio, reason: '' }; +} + +// ── Confluence score (mirrors bot/main.py:compute_confluence_score) ─────────── + +function _sigScore(phaseConf, ta1h, ta15m, walletCount) { + let s = 0; + if (phaseConf >= 0.70) s += 3; else if (phaseConf >= 0.55) s += 2; else s += 1; + if (ta1h.emaBull) s += 1; + if (ta1h.macdBull) s += 1; + if (ta1h.rsiAbove) s += 1; + if (ta15m.macdBull && ta15m.rsiAbove) s += 1; + if (ta1h.volRatio >= 1.5) s += 1; + if (walletCount >= 1) s += 1; + if (walletCount >= 3) s += 1; + return s; // max 10 +} + +function _sigSkipReasons(r) { + const reasons = []; + if (!r.btcGateOk) reasons.push('BTC macro hostile'); + if (r.fundingBlock) reasons.push('Funding crowded'); + if (r.phase !== 'ACCUMULATION') reasons.push(`Phase: ${r.phase}`); + else if (r.phaseConf < SIG_CONFIG.MIN_PHASE_CONFIDENCE) + reasons.push(`Conf ${(r.phaseConf*100).toFixed(0)}%<40%`); + if (!r.ta1h.emaBull) reasons.push('1h EMA bear'); + if (!r.ta1h.macdBull) reasons.push('1h MACD bear'); + if (!r.ta1h.rsiAbove) reasons.push(`1h RSI ${r.ta1h.rsiVal}<50`); + if (!r.ta15m.macdBull) reasons.push('15m MACD bear'); + if (!r.ta15m.rsiAbove) reasons.push(`15m RSI ${r.ta15m.rsiVal}<50`); + if (r.ta1h.volRatio < SIG_CONFIG.MIN_VOLUME_RATIO) + reasons.push(`Vol ${r.ta1h.volRatio}x<1.2`); + if (r.walletLongs.length === 0) reasons.push('No wallet signal'); + if (r.score > 0 && r.score < SIG_CONFIG.MIN_CONFLUENCE_SCORE) + reasons.push(`Score ${r.score}/10<5`); + return reasons; +} + +// ── Main scan ───────────────────────────────────────────────────────────────── + +async function runSignalScan(coins) { + // Parallel: market contexts + wallet positions + BTC 4h + const [marketCtxs, walletPositions, btcCandles] = await Promise.all([ + _sigMarketContexts(), + _sigWalletPositions(), + _sigCandles('BTC', '4h', 2), + ]); + + // BTC macro gate + let btcMove = 0, btcGateOk = true; + if (btcCandles.length >= 2) { + const cl = btcCandles.map(c => parseFloat(c.c)); + btcMove = (cl.at(-1) / cl.at(-2)) - 1; + btcGateOk = btcMove >= -SIG_CONFIG.BTC_MACRO_GATE_PCT; + } + + const results = await Promise.all(coins.map(async coin => { + try { + const ctx = marketCtxs[coin] || {}; + const fundingRate = ctx.fundingRate || 0; + const fundingBlock = fundingRate > SIG_CONFIG.MAX_LONG_FUNDING_RATE; + + const [c4h, c1h, c15m] = await Promise.all([ + _sigCandles(coin, '4h', 60), + _sigCandles(coin, '1h', 14), + _sigCandles(coin, '15m', 3), + ]); + + const phaseData = detectPhase(c4h); // from app.js + const ta1h = _sigTA(c1h, '1h'); + const ta15m = _sigTA(c15m, '15m'); + + const walletLongs = Object.entries(walletPositions) + .filter(([, pos]) => pos[coin]?.side === 'long') + .map(([label]) => label); + + const isAccum = phaseData.phase === 'ACCUMULATION'; + const score = isAccum + ? _sigScore(phaseData.confidence, ta1h, ta15m, walletLongs.length) + : 0; + + const verdict = ( + btcGateOk && + !fundingBlock && + isAccum && + phaseData.confidence >= SIG_CONFIG.MIN_PHASE_CONFIDENCE && + ta1h.ok && + ta15m.ok && + ta1h.volRatio >= SIG_CONFIG.MIN_VOLUME_RATIO && + walletLongs.length >= 1 && + score >= SIG_CONFIG.MIN_CONFLUENCE_SCORE + ) ? 'ENTRY' : 'SKIP'; + + const r = { + coin, verdict, score, + phase: phaseData.phase, + phaseConf: phaseData.confidence, + phaseSignals: phaseData.signals || [], + fundingRate, fundingBlock, + oi: ctx.openInterest || 0, + markPx: ctx.markPx || 0, + walletLongs, walletPositions, + ta1h, ta15m, btcGateOk, + }; + r.skipReasons = verdict === 'SKIP' ? _sigSkipReasons(r) : []; + return r; + } catch (e) { + return { coin, verdict: 'ERROR', error: e.message, score: 0, phase: '—', phaseConf: 0, fundingRate: 0, fundingBlock: false, walletLongs: [], ta1h: {}, ta15m: {}, btcGateOk, skipReasons: [e.message] }; + } + })); + + return { results, btcMove, btcGateOk }; +} + +// ── Rendering ───────────────────────────────────────────────────────────────── + +const _SIG_PHASE_COLOR = { ACCUMULATION: '#38bdf8', MARKUP: '#4ade80', DISTRIBUTION: '#facc15', MARKDOWN: '#f87171', NEUTRAL: '#888' }; + +function _ck(ok) { + return ok + ? '' + : ''; +} + +function _sigScoreColor(s) { + return s >= 7 ? 'var(--pos)' : s >= 5 ? '#fbbf24' : 'var(--text-muted)'; +} + +function _sigScoreBar(score) { + const filled = '█'.repeat(score); + const empty = '░'.repeat(10 - score); + return `${filled}${empty}`; +} + +function renderSignalRow(r) { + const verdictHtml = r.verdict === 'ENTRY' + ? '▲ ENTRY' + : r.verdict === 'ERROR' + ? 'ERR' + : '— skip'; + + const phaseColor = _SIG_PHASE_COLOR[r.phase] || '#888'; + const fundColor = r.fundingBlock ? 'var(--neg)' : 'var(--text-muted)'; + const walletHtml = r.walletLongs?.length + ? `${r.walletLongs.length}` + : '0'; + + return ` + + ${r.coin} + + ${r.score}/10 + + + ${r.phase} + ${(r.phaseConf*100).toFixed(0)}% + + + ${r.fundingBlock ? '⚠ ' : ''}${(r.fundingRate*100).toFixed(3)}% + + ${walletHtml} + ${verdictHtml} + + + + ${renderSignalDetail(r)} + + `; +} + +function renderSignalDetail(r) { + const isAccum = r.phase === 'ACCUMULATION'; + + const walletRows = Object.entries(SIG_SMART_WALLETS).map(([label]) => { + const pos = r.walletPositions?.[label]?.[r.coin]; + if (!pos) return `${label}: —`; + const sideColor = pos.side === 'long' ? 'var(--pos)' : 'var(--neg)'; + return `${label}: ${pos.side.toUpperCase()} ${pos.sz.toFixed(4)} @ ${pos.entry.toFixed(4)}`; + }).join('
    '); + + const dslHtml = SIG_DSL_TIERS.map(t => + `${t.label}` + ).join('  ·  '); + + return ` +
    +
    + +
    +
    Phase (4h Wyckoff)
    + ${_ck(isAccum)} ${r.phase} +  ${(r.phaseConf*100).toFixed(0)}% confidence +
    + +
    +
    1h TA
    + ${_ck(r.ta1h.emaBull)} EMA20>50 +  ${_ck(r.ta1h.macdBull)} MACD +  ${_ck(r.ta1h.rsiAbove)} RSI ${r.ta1h.rsiVal} +
    + +
    +
    15m Momentum
    + ${_ck(r.ta15m.macdBull)} MACD +  ${_ck(r.ta15m.rsiAbove)} RSI ${r.ta15m.rsiVal} +
    + +
    +
    Volume
    + ${_ck(r.ta1h.volRatio >= SIG_CONFIG.MIN_VOLUME_RATIO)} + ${r.ta1h.volRatio}× avg + ${r.ta1h.volRatio >= 1.5 ? ' strong' : ''} +
    + +
    +
    Funding /8h
    + ${_ck(!r.fundingBlock)} + ${(r.fundingRate*100).toFixed(4)}% + ${r.fundingBlock ? ' longs crowded' : ''} + (max 0.20%) +
    + +
    +
    BTC Macro Gate
    + ${_ck(r.btcGateOk)} ${r.btcGateOk ? 'OK' : 'Hostile to longs'} +
    + +
    +
    Confluence Score
    + ${_sigScoreBar(r.score)} + ${r.score}/10 + (min 5) +
    + +
    +
    OI / Mark Price
    + + $${r.oi > 1e6 ? (r.oi/1e6).toFixed(1)+'M' : r.oi > 1e3 ? (r.oi/1e3).toFixed(0)+'K' : r.oi.toFixed(0)} +  ·  $${r.markPx > 1000 ? r.markPx.toLocaleString(undefined,{maximumFractionDigits:0}) : r.markPx.toFixed(4)} + +
    +
    + + +
    +
    Smart Wallets
    +
    + ${walletRows} +
    +
    + + + ${r.phaseSignals?.length ? ` +
    +
    Phase Evidence
    +
    + ${r.phaseSignals.slice(0, 6).map(s => `· ${s}`).join('
    ')} +
    +
    ` : ''} + + + ${r.skipReasons?.length ? ` +
    +
    Skip Reasons
    +
    + ${r.skipReasons.map(s => `· ${s}`).join('
    ')} +
    +
    ` : ''} + + +
    +
    DSL Exit Tiers (if entered)
    +
    ${dslHtml}
    +
    +
    `; +} + +function renderSignalsTable(results, btcMove, btcGateOk) { + const gateEl = document.getElementById('sig-btc-gate'); + if (gateEl) { + const ok = btcGateOk; + gateEl.style.cssText = `display:block;margin-bottom:12px;padding:8px 14px;border-radius:7px;font-size:12px;font-weight:700; + background:${ok?'rgba(74,222,128,.08)':'rgba(248,113,113,.10)'}; + color:${ok?'var(--pos)':'var(--neg)'}; + border:1px solid ${ok?'rgba(74,222,128,.25)':'rgba(248,113,113,.25)'}`; + gateEl.innerHTML = + `BTC Macro Gate: ${ok ? '✓ OK' : '✗ BLOCKED (hostile to longs)'}` + + ` ·  BTC 4h: ${btcMove >= 0 ? '+' : ''}${(btcMove*100).toFixed(2)}%`; + } + + const sorted = [...results].sort((a, b) => { + if (a.verdict === 'ENTRY' && b.verdict !== 'ENTRY') return -1; + if (b.verdict === 'ENTRY' && a.verdict !== 'ENTRY') return 1; + return b.score - a.score; + }); + + const el = document.getElementById('sig-results'); + if (!el) return; + el.innerHTML = ` +
    + + + + + + + + + + + + ${sorted.map(renderSignalRow).join('')} +
    CoinScorePhaseFunding/8hWalletsVerdict
    +
    +
    + Click any row to expand full gate breakdown  ·  Data: Hyperliquid public API +
    `; +} + +function sigToggleDetail(coin) { + const el = document.getElementById(`sig-detail-${coin}`); + if (!el) return; + el.style.display = el.style.display === 'none' ? 'table-row' : 'none'; +} + +// ── Page entry points ───────────────────────────────────────────────────────── + +function loadSignals() { + const el = document.getElementById('page-signals'); + if (!el) return; + el.innerHTML = ` +
    +
    +
    Signal Scanner
    +
    + Senpi AI-inspired confluence analysis  ·  Phase + TA + Funding + Smart Wallets  ·  Read-only, no trades executed +
    +
    + +
    + +
    + + +
    + +
    + + + +
    +
    + Press Scan 6 Coins to run the full confluence analysis. +
    Fetches ~4h / 1h / 15m candles, funding rates, and smart wallet positions in parallel. +
    +
    + + +
    `; +} + +async function doSigScanAll() { + await _doSigScan(SIG_WATCH_COINS); +} + +async function doSigScanCustom() { + const input = document.getElementById('sig-custom-coin'); + const coin = input?.value?.toUpperCase().trim().replace(/[^A-Z0-9]/g, ''); + if (!coin) return; + await _doSigScan([coin]); +} + +async function _doSigScan(coins) { + if (_sigRunning) return; + _sigRunning = true; + + const btn = document.getElementById('sig-scan-btn'); + if (btn) { btn.disabled = true; btn.style.opacity = '0.6'; btn.textContent = '⏳ Scanning…'; } + + const el = document.getElementById('sig-results'); + if (el) el.innerHTML = `
    + Scanning ${coins.join(', ')}… +
    Fetching candles, funding, and wallet positions… +
    `; + + try { + const { results, btcMove, btcGateOk } = await runSignalScan(coins); + _sigLastResults = results; + renderSignalsTable(results, btcMove, btcGateOk); + + const ts = document.getElementById('sig-last-scan'); + if (ts) { + const entryCount = results.filter(r => r.verdict === 'ENTRY').length; + ts.textContent = `Scanned at ${new Date().toLocaleTimeString()} · ${entryCount} entry signal${entryCount !== 1 ? 's' : ''} found`; + } + } catch (e) { + if (el) el.innerHTML = `
    Scan failed: ${e.message}
    `; + } finally { + _sigRunning = false; + if (btn) { btn.disabled = false; btn.style.opacity = '1'; btn.textContent = '▶ Scan 6 Coins'; } + } +} diff --git a/frontend/js/supabase.js b/frontend/js/supabase.js new file mode 100644 index 0000000..b3afecd --- /dev/null +++ b/frontend/js/supabase.js @@ -0,0 +1,147 @@ +// ── Supabase Client — Auth, DB, Realtime, Storage ───────────────────────────── + +const SUPABASE_URL = 'https://eiqlvbylkcmgvksrxqld.supabase.co'; +const SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVpcWx2Ynlsa2NtZ3Zrc3J4cWxkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg5NTI4NjgsImV4cCI6MjA5NDUyODg2OH0.PcGDHYlajqwnZ7c3ZPtssG534kd3sKwE8aT1ROlFpo8'; + +let _sbSingleton = null; + +function getSupabaseClient() { + if (_sbSingleton) return _sbSingleton; + // Allow per-instance override via localStorage (e.g. dev against a local stack) + const url = localStorage.getItem('hype_sb_url') || SUPABASE_URL; + const key = localStorage.getItem('hype_sb_key') || SUPABASE_ANON_KEY; + _sbSingleton = supabase.createClient(url, key, { + auth: { persistSession: true, autoRefreshToken: true, storageKey: 'hype_auth' }, + realtime: { params: { eventsPerSecond: 10 } }, + }); + return _sbSingleton; +} + +// ── Auth ────────────────────────────────────────────────────────────────────── + +async function sbSignIn(email, password) { + const { data, error } = await getSupabaseClient().auth.signInWithPassword({ email, password }); + if (error) throw error; + return data; +} + +async function sbSignUp(email, password) { + const { data, error } = await getSupabaseClient().auth.signUp({ email, password }); + if (error) throw error; + return data; +} + +async function sbSignOut() { + const { error } = await getSupabaseClient().auth.signOut(); + if (error) throw error; + _sbSingleton = null; // reset so next call re-initialises +} + +async function sbGetUser() { + const { data: { user } } = await getSupabaseClient().auth.getUser(); + return user; +} + +function sbOnAuthChange(callback) { + return getSupabaseClient().auth.onAuthStateChange(callback); +} + +// ── Realtime ────────────────────────────────────────────────────────────────── + +/** + * Subscribe to INSERT/UPDATE/DELETE on a table. + * @param {string} table + * @param {function} callback — receives the postgres_changes payload + * @param {string} [filter] — e.g. "coin=eq.BTC" + * @returns channel — call channel.unsubscribe() to clean up + */ +function sbSubscribe(table, callback, filter = null) { + const client = getSupabaseClient(); + const opts = { event: '*', schema: 'public', table }; + if (filter) opts.filter = filter; + const channel = client + .channel(`rt:${table}:${filter || 'all'}`) + .on('postgres_changes', opts, callback) + .subscribe(); + return channel; +} + +// ── Storage ─────────────────────────────────────────────────────────────────── + +/** + * Upload a File or Blob to a storage bucket. + * @param {string} bucket + * @param {string} path — e.g. "reports/backtest-2025.csv" + * @param {File|Blob} file + */ +async function sbUpload(bucket, path, file) { + const { data, error } = await getSupabaseClient() + .storage.from(bucket) + .upload(path, file, { upsert: true }); + if (error) throw error; + return data; +} + +/** Get the public URL for a file in a storage bucket. */ +function sbGetPublicUrl(bucket, path) { + const { data } = getSupabaseClient().storage.from(bucket).getPublicUrl(path); + return data.publicUrl; +} + +/** Download a file from storage as a Blob. */ +async function sbDownload(bucket, path) { + const { data, error } = await getSupabaseClient().storage.from(bucket).download(path); + if (error) throw error; + return data; +} + +// ── DB shorthand ────────────────────────────────────────────────────────────── + +/** Returns a Supabase query builder for the given table. */ +function sbFrom(table) { + return getSupabaseClient().from(table); +} + +// ── Auth UI ─────────────────────────────────────────────────────────────────── +// Minimal modal — shown when a feature that requires auth is accessed by a guest. + +let _authResolve = null; + +function sbRequireAuth() { + return new Promise((resolve) => { + sbGetUser().then(user => { + if (user) { resolve(user); return; } + _authResolve = resolve; + document.getElementById('sb-auth-modal')?.classList.remove('hidden'); + }); + }); +} + +function _sbAuthSubmit(isSignUp) { + const email = document.getElementById('sb-auth-email')?.value.trim(); + const password = document.getElementById('sb-auth-password')?.value; + const errEl = document.getElementById('sb-auth-error'); + if (!email || !password) { if (errEl) errEl.textContent = 'Email and password required.'; return; } + + const fn = isSignUp ? sbSignUp : sbSignIn; + fn(email, password) + .then(data => { + document.getElementById('sb-auth-modal')?.classList.add('hidden'); + if (_authResolve) { _authResolve(data.user || data.session?.user); _authResolve = null; } + _updateAuthBadge(); + }) + .catch(err => { if (errEl) errEl.textContent = err.message; }); +} + +function _updateAuthBadge() { + sbGetUser().then(user => { + const badge = document.getElementById('sb-auth-badge'); + if (!badge) return; + badge.textContent = user ? (user.email?.split('@')[0] || 'Logged in') : 'Login'; + badge.title = user ? `Signed in as ${user.email}` : 'Sign in to sync data'; + badge.onclick = user ? () => sbSignOut().then(_updateAuthBadge) : () => sbRequireAuth(); + }); +} + +// Initialise badge after DOM is ready +document.addEventListener('DOMContentLoaded', _updateAuthBadge); diff --git a/frontend/js/ta-signal.js b/frontend/js/ta-signal.js new file mode 100644 index 0000000..ea5a108 --- /dev/null +++ b/frontend/js/ta-signal.js @@ -0,0 +1,546 @@ +// ── TA Recommendation Engine ────────────────────────────────────────────────── +// Depends on iEMA/iMACD/iRSI/iStoch/iBB/iATR/iMoneyFlow/sig* from app.js + +// ── Support / Resistance pivot detection ───────────────────────────────────── +function findSR(highs, lows, closes, lb = 3) { + const pH = [], pL = []; + for (let i = lb; i < closes.length - lb; i++) { + const h = highs[i], l = lows[i]; + if (highs.slice(i-lb,i).every(v=>v<=h) && highs.slice(i+1,i+lb+1).every(v=>v<=h)) pH.push(h); + if (lows.slice(i-lb,i).every(v=>v>=l) && lows.slice(i+1,i+lb+1).every(v=>v>=l)) pL.push(l); + } + function cluster(arr) { + const sorted = [...arr].sort((a,b)=>a-b); + const out = []; + for (const v of sorted) { + if (!out.length || (v - out.at(-1)) / out.at(-1) > 0.004) out.push(v); + else out[out.length-1] = out.at(-1) * 0.55 + v * 0.45; + } + return out; + } + const price = closes.at(-1); + return { + resistance: cluster(pH).filter(l => l > price * 1.002).slice(0, 4), + support: cluster(pL).filter(l => l < price * 0.998).slice(-4), + }; +} + +// ── Direction scoring (bull/bear/neutral) ───────────────────────────────────── +function scoreDirection(sigsObj) { + let bull = 0, bear = 0; + for (const s of Object.values(sigsObj)) { + if (!s) continue; + if (s.cls === 'bull') bull += 1; + else if (s.cls === 'bear') bear += 1; + else if (s.cls === 'warn') bear += 0.4; // overbought / high vol + else if (s.cls === 'info') bull += 0.4; // oversold / crowded short + } + const total = bull + bear || 1; + const bullPct = bull / total; + const direction = bullPct >= 0.62 ? 'LONG' : bullPct <= 0.38 ? 'SHORT' : 'NEUTRAL'; + return { direction, bull: +bull.toFixed(1), bear: +bear.toFixed(1), bullPct }; +} + +// ── Entry / TP / SL from S/R + ATR ─────────────────────────────────────────── +function calcTradeSetup(direction, price, sr, atr) { + const a = atr || price * 0.02; + const buf = a * 0.35; + let entry, tp1, tp2, sl; + + if (direction === 'LONG') { + const sup = sr.support.length ? Math.max(...sr.support) : null; + const res1 = sr.resistance.length ? Math.min(...sr.resistance) : null; + const res2 = sr.resistance.length > 1 + ? sr.resistance.slice().sort((a,b)=>a-b)[1] : null; + + entry = sup && sup > price * 0.97 ? sup : price; + tp1 = res1 || entry + a * 2.5; + tp2 = res2 || tp1 + a * 1.5; + sl = sup ? sup - buf : entry - a * 1.5; + + } else if (direction === 'SHORT') { + const res = sr.resistance.length ? Math.min(...sr.resistance) : null; + const sup1 = sr.support.length ? Math.max(...sr.support) : null; + const sup2 = sr.support.length > 1 + ? sr.support.slice().sort((a,b)=>b-a)[1] : null; + + entry = res && res < price * 1.03 ? res : price; + tp1 = sup1 || entry - a * 2.5; + tp2 = sup2 || tp1 - a * 1.5; + sl = res ? res + buf : entry + a * 1.5; + + } else { + entry = price; + tp1 = price + a * 2; + tp2 = price + a * 3.5; + sl = price - a * 1.5; + } + + const risk = Math.abs(entry - sl); + const rew = Math.abs(tp1 - entry); + const rr = risk > 0 ? rew / risk : 0; + return { entry, tp1, tp2, sl, rr }; +} + +// ── Binance Futures L/S ratio (free, no API key) ────────────────────────────── +const _lsrCache = {}; +async function fetchBinanceLSR(coin) { + const key = coin + '_lsr'; + if (_lsrCache[key] && Date.now() - _lsrCache[key].ts < 120000) return _lsrCache[key].data; + try { + const sym = coin.toUpperCase().replace(/^1000/, '') + 'USDT'; + const r = await fetch(`https://fapi.binance.com/futures/data/globalLongShortAccountRatio?symbol=${sym}&period=5m&limit=1`); + if (!r.ok) return null; + const d = await r.json(); + if (!Array.isArray(d) || !d[0]) return null; + const data = { longPct: parseFloat(d[0].longAccount)*100, shortPct: parseFloat(d[0].shortAccount)*100, ratio: parseFloat(d[0].longShortRatio) }; + _lsrCache[key] = { ts: Date.now(), data }; + return data; + } catch { return null; } +} + +// ── CoinGecko 24h / 7d change (free, no API key) ────────────────────────────── +const CG_IDS = { + BTC:'bitcoin',ETH:'ethereum',SOL:'solana',HYPE:'hyperliquid',BNB:'binancecoin', + ADA:'cardano',AVAX:'avalanche-2',DOT:'polkadot',MATIC:'matic-network',LINK:'chainlink', + ARB:'arbitrum',OP:'optimism',INJ:'injective-protocol',SUI:'sui',APT:'aptos', + DOGE:'dogecoin',SHIB:'shiba-inu',WIF:'dogwifcoin',PEPE:'pepe',NEAR:'near', + ATOM:'cosmos',FTM:'fantom',LTC:'litecoin',XRP:'ripple',TRX:'tron', + AAVE:'aave',UNI:'uniswap',MKR:'maker',TAO:'bittensor',RENDER:'render-token', + JTO:'jito-governance-token',TON:'the-open-network',NOT:'notcoin', + BONK:'bonk',PYTH:'pyth-network',TIA:'celestia',SEI:'sei-network', + STRK:'starknet',IMX:'immutable-x',BLUR:'blur',GMX:'gmx', +}; +const _cgCache = {}; +async function fetchCGData(coin) { + const id = CG_IDS[coin.toUpperCase()]; + if (!id) return null; + if (_cgCache[id] && Date.now() - _cgCache[id].ts < 180000) return _cgCache[id].data; + try { + const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=usd&include_24hr_change=true&include_7d_change=true&include_market_cap=true`); + if (!r.ok) return null; + const d = (await r.json())[id]; + if (!d) return null; + const data = { change24h: d.usd_24h_change, change7d: d.usd_7d_change, marketCap: d.usd_market_cap }; + _cgCache[id] = { ts: Date.now(), data }; + return data; + } catch { return null; } +} + +// ── Signal constructors for external data ───────────────────────────────────── +function sigLSR(lsr) { + if (!lsr) return null; + let label, cls; + if (lsr.ratio > 2.5) { label = 'LONGS CROWDED'; cls = 'warn'; } + else if (lsr.ratio > 1.4) { label = 'LONG BIASED'; cls = 'bull'; } + else if (lsr.ratio < 0.6) { label = 'SHORTS CROWDED'; cls = 'info'; } + else if (lsr.ratio < 0.8) { label = 'SHORT BIASED'; cls = 'bear'; } + else { label = 'BALANCED'; cls = 'neut'; } + return { label, cls, sub: `L ${lsr.longPct.toFixed(0)}% / S ${lsr.shortPct.toFixed(0)}% · Ratio ${lsr.ratio.toFixed(2)}`, + detail: lsr.ratio > 2.5 ? 'Longs very crowded — forced long liquidations can cascade down' + : lsr.ratio < 0.6 ? 'Shorts very crowded — short squeeze risk elevated' + : lsr.ratio > 1.4 ? 'More longs than shorts — mild bullish positioning' + : lsr.ratio < 0.8 ? 'More shorts than longs — mild bearish positioning' + : 'Balanced positioning — no directional crowding signal' }; +} + +function sigCG(cg) { + if (!cg) return null; + const c = cg.change24h || 0; + let label, cls; + if (c > 6) { label = 'STRONG RALLY'; cls = 'bull'; } + else if (c > 2) { label = 'BULLISH 24H'; cls = 'bull'; } + else if (c < -6) { label = 'STRONG SELLOFF'; cls = 'bear'; } + else if (c < -2) { label = 'BEARISH 24H'; cls = 'bear'; } + else { label = 'FLAT 24H'; cls = 'neut'; } + const w = cg.change7d; + return { label, cls, + sub: `24h ${c>=0?'+':''}${c.toFixed(2)}%${w!=null?' · 7d '+(w>=0?'+':'')+w.toFixed(2)+'%':''}`, + detail: `${Math.abs(c).toFixed(1)}% move in 24h on CoinGecko — ${c>0?'buy-side pressure dominant':'sell-side pressure dominant'}` }; +} + +function sigNansenFlow(coin) { + const data = window._nansenData?.netflows; + if (!data?.length) return null; + const entry = data.find(d => d.token?.symbol?.toUpperCase() === coin.toUpperCase()); + if (!entry) return null; + const flow = entry.netflow_usd_24h; + if (flow == null) return null; + let label, cls; + if (flow > 500000) { label = 'SMART MONEY BUY'; cls = 'bull'; } + else if (flow > 100000) { label = 'INFLOW'; cls = 'bull'; } + else if (flow < -500000) { label = 'SMART MONEY SELL'; cls = 'bear'; } + else if (flow < -100000) { label = 'OUTFLOW'; cls = 'bear'; } + else { label = 'NEUTRAL FLOW'; cls = 'neut'; } + const fmt = v => v >= 1e6 ? `$${(v/1e6).toFixed(1)}M` : v >= 1e3 ? `$${(v/1e3).toFixed(0)}K` : `$${v.toFixed(0)}`; + return { label, cls, sub: `24h ${flow>=0?'+':''}${fmt(flow)} smart money`, + detail: `${flow>0?'Smart money is buying':'Smart money is selling'} — Nansen wallet flow ${fmt(Math.abs(flow))} net in 24h` }; +} + +function sigFGGlobal() { + const fg = window._indData?.fear_greed; + if (!fg) return null; + const v = fg.value; + let label, cls; + if (v >= 75) { label = 'EXTREME GREED'; cls = 'warn'; } + else if (v >= 60) { label = 'GREED'; cls = 'bull'; } + else if (v <= 25) { label = 'EXTREME FEAR'; cls = 'info'; } + else if (v <= 40) { label = 'FEAR'; cls = 'bear'; } + else { label = 'NEUTRAL'; cls = 'neut'; } + return { label, cls, sub: `F&G ${v} — ${fg.classification}`, + detail: v >= 75 ? 'Market euphoria — historically high-risk zone for longs, potential distribution' + : v <= 25 ? 'Market panic — historically good accumulation zone, watch for capitulation end' + : v >= 60 ? 'Greed present — momentum favors longs but watch for exhaustion' + : 'Neutral to fearful sentiment — pick direction carefully' }; +} + +// ── Signal detail (explanation) map ────────────────────────────────────────── +const SIG_DETAIL = { + ema: { bull: 'Price above all major EMAs — clear uptrend, dip-buys are valid', bear: 'Price below key EMAs — downtrend in force, rallies are sell opps', warn: 'Mixed EMA stack — wait for a cleaner structure before entry', neut: 'Price at EMA level — potential support/resistance, watch reaction' }, + macd: { bull: 'MACD histogram expanding above zero — buy-side momentum accelerating', bear: 'MACD expanding below zero — sell-side momentum increasing', warn: 'Bullish momentum fading — potential local top, tighten stops', neut: 'MACD near zero — no momentum edge, avoid chasing' }, + rsi: { bull: 'RSI 60–70 — bullish without being overbought, good for entries', bear: 'RSI 30–40 — sell pressure dominant, bounces are likely short-lived', warn: 'RSI >70 — overbought zone, profit-taking risk elevated', info: 'RSI <30 — oversold, watch for reversal candle before entering long', neut: 'RSI neutral (40–60) — no directional edge from momentum' }, + stoch:{ bull: 'Stoch K>D above 50 — momentum aligned with bulls', bear: 'Stoch K80 — exhaustion area, reduce longs', info: 'Stochastic oversold <20 — potential bounce zone', neut: 'Stochastic midrange — no clear bias' }, + bb: { bull: 'Price in upper half of Bollinger Bands — trend strength', bear: 'Price in lower half — weakness, selling pressure', warn: 'Price at/above upper band — stretched, mean reversion risk', info: 'Price at/below lower band — oversold extreme, snap-back likely', neut: 'Price at midband — equilibrium, watch for breakout direction' }, + atr: { bull: 'Low volatility — positions can use tighter stops, lower noise', bear: 'Low volatility may precede a sharp move — stay alert', warn: 'High volatility — widen stops, reduce position size, not ideal entry', neut: 'Normal volatility — standard sizing and stop placement OK' }, + funding: { bull: 'Mild positive funding — modest long bias, not yet crowded', bear: 'Negative funding — shorts paying longs, selling pressure present', warn: 'High positive funding — crowded longs, squeeze risk if price drops', info: 'Very negative funding — crowded shorts, long squeeze risk elevated', neut: 'Near-zero funding — balanced positioning, no crowding signal' }, + oi: { bull: 'Rising OI — new money entering, potential for sustained move', bear: 'Falling OI — positions closing, move may be exhausting', warn: 'OI rising very fast — leverage building, fragile', neut: 'Stable OI — no strong conviction signal from positioning' }, + mf: { bull: 'Strong buy flow — aggressive buys outpacing sells on candles', bear: 'Strong sell flow — sellers more aggressive, bearish pressure', warn: 'Buy flow slightly elevated — mildly bullish, not conclusive', neut: 'Balanced buy/sell flow — no directional edge' }, +}; + +function addDetail(sig, name) { + if (!sig) return sig; + const map = SIG_DETAIL[name]; + if (!map) return sig; + const detail = map[sig.cls] || map.neut || ''; + return { ...sig, detail }; +} + +// ── CVD (Candle Volume Delta) ───────────────────────────────────────────────── +function calcCVD(opens, closes, highs, lows, volumes) { + let cvd = 0; + return closes.map((_, i) => { + const range = highs[i] - lows[i]; + if (range > 0) cvd += volumes[i] * ((closes[i] - lows[i]) - (highs[i] - closes[i])) / range; + return cvd; + }); +} + +// ── OI history (localStorage, per-coin) ────────────────────────────────────── +const _OI_HIST_KEY = 'hype_oi_hist_v1'; +function _oiHistGet() { + try { return JSON.parse(localStorage.getItem(_OI_HIST_KEY) || '{}'); } catch { return {}; } +} +function saveOIPoint(coin, oi) { + if (!oi || oi <= 0) return; + const h = _oiHistGet(); + if (!h[coin]) h[coin] = []; + h[coin].push({ ts: Date.now(), oi }); + if (h[coin].length > 96) h[coin] = h[coin].slice(-96); + try { localStorage.setItem(_OI_HIST_KEY, JSON.stringify(h)); } catch {} +} +function getPrevOI(coin, lookbackMs = 3600000) { + const h = _oiHistGet(); + const arr = h[coin] || []; + if (!arr.length) return null; + const target = Date.now() - lookbackMs; + let best = null; + for (const e of arr) { + if (e.ts <= Date.now() - lookbackMs * 0.4) { + if (!best || Math.abs(e.ts - target) < Math.abs(best.ts - target)) best = e; + } + } + return best ? best.oi : null; +} + +// ── CVD + OI combined signal ────────────────────────────────────────────────── +function sigCVDOI(priceChg, recentCVD, oiChgPct) { + const pUp = priceChg > 0.2; + const pDn = priceChg < -0.2; + const cUp = recentCVD > 0; + const oUp = oiChgPct != null && oiChgPct > 1.5; + const oDn = oiChgPct != null && oiChgPct < -1.5; + const oNa = oiChgPct == null; + let label, cls, detail; + + if (pUp && cUp && (oUp || oNa)) { + label = 'STRONG BULL'; cls = 'bull'; + detail = 'Price up + net buying CVD + OI expanding — real demand with new longs, continuation likely'; + } else if (pUp && cUp) { + label = 'SPOT DRIVEN'; cls = 'bull'; + detail = 'Price and CVD rising, OI flat/down — spot buyers driving move, shorts likely covering'; + } else if (pUp && !cUp && oUp) { + label = 'SUSPECT PUMP'; cls = 'warn'; + detail = 'Price up but sellers dominate CVD — move driven by short squeeze, not real demand'; + } else if (pUp && !cUp) { + label = 'WEAK RALLY'; cls = 'warn'; + detail = 'Price up with net selling CVD and falling OI — fragile move, likely reversal ahead'; + } else if (pDn && !cUp && (oDn || oNa)) { + label = 'STRONG BEAR'; cls = 'bear'; + detail = 'Price, CVD and OI all falling — real selling with longs exiting, continuation likely'; + } else if (pDn && !cUp && oUp) { + label = 'LEVERAGED SELL'; cls = 'bear'; + detail = 'Price and CVD down but OI rising — shorts adding leverage, squeeze risk if wrong'; + } else if (pDn && cUp && oDn) { + label = 'ACCUMULATION'; cls = 'info'; + detail = 'Price falling but net buyers absorb — dip buying while longs reduce exposure'; + } else if (pDn && cUp) { + label = 'BULL DIVERGENCE';cls = 'info'; + detail = 'Price down but buyers dominating CVD — hidden accumulation, watch for reversal'; + } else { + label = 'NEUTRAL'; cls = 'neut'; + detail = 'Price sideways or CVD/OI signals mixed — no clear directional edge'; + } + + const oStr = oNa ? 'OI N/A' : `OI ${oiChgPct >= 0 ? '+' : ''}${oiChgPct.toFixed(1)}%`; + return { label, cls, + sub: `Price ${priceChg >= 0 ? '+' : ''}${priceChg.toFixed(2)}% · CVD ${cUp ? '▲ buying' : '▼ selling'} · ${oStr}`, + detail }; +} + +// ── CVD+OI multi-coin overview table ───────────────────────────────────────── +function renderCVDOITable(rows) { + if (!rows.length) return '
    No data
    '; + return ` +
    +
    + Coin4c ChgCVDOI ∆Signal +
    + ${rows.map(r => { + const oiStr = r.oiChgPct == null ? '—' : `${r.oiChgPct >= 0 ? '+' : ''}${r.oiChgPct.toFixed(1)}%`; + const oiCls = r.oiChgPct == null ? '' : r.oiChgPct >= 0 ? 'pos' : 'neg'; + return `
    + ${r.coin}${r.hasPosition ? '' : ''} + ${r.priceChg >= 0 ? '+' : ''}${r.priceChg.toFixed(2)}% + ${r.cvdUp ? '▲ BUY' : '▼ SELL'} + ${oiStr} + ${r.sig.label} +
    `; + }).join('')} +
    +
    ◆ = open position · CVD = 4-candle volume delta · OI vs ~1h ago snapshot
    `; +} + +// ── Full TA build (async) ───────────────────────────────────────────────────── +async function buildFullTA(coin, tf, candles, rawMarketCtx) { + const opens = candles.map(c=>parseFloat(c.o)); + const closes = candles.map(c=>parseFloat(c.c)); + const highs = candles.map(c=>parseFloat(c.h)); + const lows = candles.map(c=>parseFloat(c.l)); + const vols = candles.map(c=>parseFloat(c.v)); + const price = closes.at(-1); + + const ema20 = iEMA(closes, 20); + const ema50 = iEMA(closes, 50); + const ema200 = closes.length >= 200 ? iEMA(closes, 200) : null; + const { hist, macd } = iMACD(closes); + const rsiArr = iRSI(closes); + const rsiVal = rsiArr.filter(v=>v!==null).at(-1); + const { k: stochK, d: stochD } = iStoch(highs, lows, closes); + const kVal = stochK.filter(v=>v!==null).at(-1); + const dVal = stochD.filter(v=>v!==null).at(-1); + const bbArr = iBB(closes); + const bb = bbArr.filter(v=>v!==null).at(-1); + const atrArr = iATR(highs, lows, closes); + const atr = atrArr.filter(v=>v!==null).at(-1); + const mf = iMoneyFlow(opens, closes, vols); + + const fr = rawMarketCtx ? parseFloat(rawMarketCtx.funding || 0) : 0; + const oi = rawMarketCtx ? parseFloat(rawMarketCtx.openInterest || 0) * price : 0; + const oiPrev = taOIPrev?.[coin + tf] ?? null; + if (typeof taOIPrev !== 'undefined') taOIPrev[coin + tf] = oi; + + // CVD + OI + const cvdArr = calcCVD(opens, closes, highs, lows, vols); + const lb = 4; + const recentCVD = cvdArr.at(-1) - (cvdArr.length > lb ? cvdArr[cvdArr.length - 1 - lb] : 0); + const priceChg4 = closes.length > lb ? (closes.at(-1) - closes[closes.length - 1 - lb]) / closes[closes.length - 1 - lb] * 100 : 0; + if (oi > 0) saveOIPoint(coin, oi); + const prevOI = getPrevOI(coin); + const oiChgPct = (prevOI && prevOI > 0 && oi > 0) ? (oi - prevOI) / prevOI * 100 : null; + + const sigs = { + ema: addDetail(sigEMA(price, ema20.at(-1), ema50.at(-1), ema200?ema200.at(-1):null), 'ema'), + macd: addDetail(sigMACD(hist, macd), 'macd'), + rsi: rsiVal!=null ? addDetail(sigRSI(rsiVal), 'rsi') : null, + stoch: kVal!=null&&dVal!=null ? addDetail(sigStoch(kVal,dVal), 'stoch') : null, + bb: bb ? addDetail(sigBB(bb), 'bb') : null, + atr: atr ? addDetail(sigATR(atr, price), 'atr') : null, + funding: addDetail(sigFunding(fr), 'funding'), + oi: addDetail(sigOI(oi, oiPrev), 'oi'), + mf: addDetail(sigFlow(mf.buyPct), 'mf'), + }; + + // External data (parallel, no-fail) + const [lsr, cg] = await Promise.allSettled([ + fetchBinanceLSR(coin), + fetchCGData(coin), + ]).then(rs => rs.map(r => r.status==='fulfilled' ? r.value : null)); + + sigs.lsr = sigLSR(lsr); + sigs.cg = sigCG(cg); + sigs.nansen = sigNansenFlow(coin); + sigs.fg = sigFGGlobal(); + sigs.cvdoi = sigCVDOI(priceChg4, recentCVD, oiChgPct); + + const sr = findSR(highs, lows, closes); + const dir = scoreDirection(sigs); + const setup = calcTradeSetup(dir.direction, price, sr, atr); + + return { sigs, dir, setup, price, coin, tf, atr, sr }; +} + +// ── Rendering ───────────────────────────────────────────────────────────────── +function _dirCls(d) { return d === 'LONG' ? 'pos' : d === 'SHORT' ? 'neg' : 'muted'; } +function _pct(a, b) { return b > 0 ? ((a - b) / b * 100) : 0; } + +function _checkRow(icon, name, sig) { + if (!sig) return ''; + const clsMap = { bull:'ta-ck-bull', bear:'ta-ck-bear', warn:'ta-ck-warn', info:'ta-ck-info', neut:'ta-ck-neut' }; + const iconMap = { bull:'✅', bear:'🔴', warn:'⚠️', info:'🟦', neut:'⬜' }; + return `
    + ${iconMap[sig.cls]||'⬜'} +
    +
    + ${name} + ${sig.label} +
    +
    ${sig.sub}${sig.detail ? ` — ${sig.detail}` : ''}
    +
    +
    `; +} + +function renderTARec(ta) { + const { sigs: s, dir, setup, price, coin, tf, sr } = ta; + const { direction, bull, bear, bullPct } = dir; + const { entry, tp1, tp2, sl, rr } = setup; + const dirCls = _dirCls(direction); + const total = bull + bear; + const barW = Math.round(bullPct * 100); + + const fmtSetup = (v, ref) => { + if (!v || !ref) return fmtPrice(v); + const p = _pct(v, ref); + return `${fmtPrice(v)} ${p>=0?'+':''}${p.toFixed(1)}%`; + }; + + const entryNote = direction === 'LONG' + ? (entry < price * 0.999 ? `Ideal entry on pullback` : `Current price is entry zone`) + : direction === 'SHORT' + ? (entry > price * 1.001 ? `Ideal entry on bounce` : `Current price is entry zone`) + : 'Neutral — wait for clearer direction'; + + const srHtml = (sr.support.length || sr.resistance.length) ? ` +
    + Support + ${sr.support.length ? sr.support.map(v=>fmtPrice(v)).join(' · ') : '—'} +
    +
    + Resistance + ${sr.resistance.length ? sr.resistance.map(v=>fmtPrice(v)).join(' · ') : '—'} +
    ` : ''; + + return ` +
    +
    +
    +
    ${direction}
    +
    ${coin} · ${tf} · ${fmtPrice(price)} · ${new Date().toLocaleTimeString()}
    +
    +
    +
    ${bull.toFixed(0)}/${total.toFixed(0)} signals bullish
    +
    +
    ${bull.toFixed(0)} bull ${bear.toFixed(0)} bear
    +
    +
    + +
    +
    +
    💵 Entry
    +
    ${fmtSetup(entry, price)}
    +
    ${entryNote}
    +
    +
    +
    🎯 TP1
    +
    ${fmtSetup(tp1, entry)}
    +
    First target
    +
    +
    +
    🎯 TP2
    +
    ${fmtSetup(tp2, entry)}
    +
    Extended target
    +
    +
    +
    🛡 Stop Loss
    +
    ${fmtSetup(sl, entry)}
    +
    R/R ${rr > 0 ? rr.toFixed(1) + ':1' : '—'}
    +
    +
    + + ${srHtml ? `
    ${srHtml}
    ` : ''} + +
    +
    Signal Checklist
    + ${_checkRow('📏','EMA Bias', s.ema)} + ${_checkRow('〰️','MACD', s.macd)} + ${_checkRow('⚡','RSI (14)', s.rsi)} + ${_checkRow('🔁','Stochastic', s.stoch)} + ${_checkRow('🎯','Bollinger Bands', s.bb)} + ${_checkRow('📐','Volatility (ATR)', s.atr)} + ${_checkRow('💰','Funding Rate', s.funding)} + ${_checkRow('📊','Open Interest', s.oi)} + ${_checkRow('🌊','Buy/Sell Flow', s.mf)} + ${_checkRow('⚖️','L/S Ratio (Binance)',s.lsr)} + ${_checkRow('📈','24h Change (CG)', s.cg)} + ${_checkRow('🏦','Smart Money', s.nansen)} + ${_checkRow('😱','Fear & Greed', s.fg)} + ${_checkRow('🔄','CVD + OI', s.cvdoi)} +
    +
    `; +} + +// ── Modal Signal tab helper ─────────────────────────────────────────────────── +async function loadModalSignal(coin) { + const el = document.getElementById('pm-signal-body'); + if (!el) return; + el.innerHTML = `
    Analysing ${coin}…
    `; + try { + const tf = '1h'; + const days = 15; + const [candles, meta] = await Promise.all([ + getCandles(coin, tf, days), + getMetaAndAssetCtxs(), + ]); + const universe = meta[0]?.universe || []; + const ctxs = meta[1] || []; + const idx = universe.findIndex(a => a.name === coin); + const rawCtx = idx >= 0 ? ctxs[idx] : null; + + const ta = await buildFullTA(coin, tf, candles, rawCtx); + const pos = typeof _pmGetPos === 'function' ? _pmGetPos(coin) : null; + + let conflictBanner = ''; + if (pos) { + const posDir = pos.side === 'long' ? 'LONG' : 'SHORT'; + const taDir = ta.dir.direction; + if (taDir !== 'NEUTRAL' && taDir !== posDir) { + conflictBanner = `
    + ⚠️ TA recommends ${taDir} but your position is ${posDir}. + Consider reviewing your thesis or reducing size. +
    `; + } else if (taDir === posDir) { + conflictBanner = `
    + ✅ TA aligns with your ${posDir} position. +
    `; + } + } + + // Offer to pre-fill stop price + const slBtn = pos ? `` : ''; + + el.innerHTML = conflictBanner + renderTARec(ta) + slBtn; + } catch(e) { + el.innerHTML = `
    Analysis failed: ${e.message}
    `; + } +} diff --git a/frontend/manifest.json b/frontend/manifest.json new file mode 100644 index 0000000..be3a016 --- /dev/null +++ b/frontend/manifest.json @@ -0,0 +1,37 @@ +{ + "name": "Hype — Trade Analyzer", + "short_name": "Hype", + "description": "Hyperliquid personal trade analyzer with live phase detection", + "start_url": "/", + "display": "standalone", + "background_color": "#0a0a0a", + "theme_color": "#38bdf8", + "orientation": "any", + "lang": "en", + "categories": ["finance"], + "icons": [ + { + "src": "/static/icons/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any" + }, + { + "src": "/static/icons/icon-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "any maskable" + }, + { + "src": "/static/icons/icon-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "any maskable" + } + ], + "shortcuts": [ + { "name": "Overview", "url": "/?p=overview", "description": "Portfolio overview" }, + { "name": "Phase Detector", "url": "/?p=phases", "description": "Market phase detection" }, + { "name": "Watchlist", "url": "/?p=watchlist", "description": "Wallet watchlist" } + ] +} diff --git a/frontend/sw.js b/frontend/sw.js new file mode 100644 index 0000000..77cb5c5 --- /dev/null +++ b/frontend/sw.js @@ -0,0 +1,48 @@ +const CACHE = 'hype-v10'; +const STATIC = [ + './', + './styles.css', + './app.js', + './signals.js', + './ta-signal.js', + './position-meta.js', + './intel.js', + './indicators.js', + './analytics.js', + './logger.js', + './nansen.js', + './mvrv-ai.js', + './icons/icon.svg', + './manifest.json', + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap', +]; + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE).then(c => c.addAll(STATIC)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', e => { + if (e.request.method !== 'GET') return; + e.respondWith( + caches.match(e.request).then(cached => { + if (cached) return cached; + return fetch(e.request).then(res => { + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }); + }) + ); +}); diff --git a/frontend/ta-signal.js b/frontend/ta-signal.js new file mode 100644 index 0000000..7078e7e --- /dev/null +++ b/frontend/ta-signal.js @@ -0,0 +1,1058 @@ +// ── TA Recommendation Engine ────────────────────────────────────────────────── +// Depends on iEMA/iMACD/iRSI/iStoch/iBB/iATR/iMoneyFlow/sig* from app.js + +// ── Support / Resistance pivot detection ───────────────────────────────────── +function findSR(highs, lows, closes, lb = 3) { + const pH = [], pL = []; + for (let i = lb; i < closes.length - lb; i++) { + const h = highs[i], l = lows[i]; + if (highs.slice(i-lb,i).every(v=>v<=h) && highs.slice(i+1,i+lb+1).every(v=>v<=h)) pH.push(h); + if (lows.slice(i-lb,i).every(v=>v>=l) && lows.slice(i+1,i+lb+1).every(v=>v>=l)) pL.push(l); + } + function cluster(arr) { + const sorted = [...arr].sort((a,b)=>a-b); + const out = []; + for (const v of sorted) { + if (!out.length || (v - out.at(-1)) / out.at(-1) > 0.004) out.push(v); + else out[out.length-1] = out.at(-1) * 0.55 + v * 0.45; + } + return out; + } + const price = closes.at(-1); + return { + resistance: cluster(pH).filter(l => l > price * 1.002).slice(0, 4), + support: cluster(pL).filter(l => l < price * 0.998).slice(-4), + }; +} + +// ── Direction scoring (bull/bear/neutral) ───────────────────────────────────── +function scoreDirection(sigsObj) { + let bull = 0, bear = 0; + for (const s of Object.values(sigsObj)) { + if (!s) continue; + if (s.cls === 'bull') bull += 1; + else if (s.cls === 'bear') bear += 1; + else if (s.cls === 'warn') bear += 0.4; // overbought / high vol + else if (s.cls === 'info') bull += 0.4; // oversold / crowded short + } + const total = bull + bear || 1; + const bullPct = bull / total; + const direction = bullPct >= 0.62 ? 'LONG' : bullPct <= 0.38 ? 'SHORT' : 'NEUTRAL'; + return { direction, bull: +bull.toFixed(1), bear: +bear.toFixed(1), bullPct }; +} + +// ── Entry / TP / SL from S/R + ATR ─────────────────────────────────────────── +function calcTradeSetup(direction, price, sr, atr) { + const a = atr || price * 0.02; + const buf = a * 0.35; + let entry, tp1, tp2, sl; + + if (direction === 'LONG') { + const sup = sr.support.length ? Math.max(...sr.support) : null; + const res1 = sr.resistance.length ? Math.min(...sr.resistance) : null; + const res2 = sr.resistance.length > 1 + ? sr.resistance.slice().sort((a,b)=>a-b)[1] : null; + + entry = sup && sup > price * 0.97 ? sup : price; + tp1 = res1 || entry + a * 2.5; + tp2 = res2 || tp1 + a * 1.5; + sl = sup ? sup - buf : entry - a * 1.5; + + } else if (direction === 'SHORT') { + const res = sr.resistance.length ? Math.min(...sr.resistance) : null; + const sup1 = sr.support.length ? Math.max(...sr.support) : null; + const sup2 = sr.support.length > 1 + ? sr.support.slice().sort((a,b)=>b-a)[1] : null; + + entry = res && res < price * 1.03 ? res : price; + tp1 = sup1 || entry - a * 2.5; + tp2 = sup2 || tp1 - a * 1.5; + sl = res ? res + buf : entry + a * 1.5; + + } else { + entry = price; + tp1 = price + a * 2; + tp2 = price + a * 3.5; + sl = price - a * 1.5; + } + + const risk = Math.abs(entry - sl); + const rew = Math.abs(tp1 - entry); + const rr = risk > 0 ? rew / risk : 0; + return { entry, tp1, tp2, sl, rr }; +} + +// ── Binance Futures L/S ratio (free, no API key) ────────────────────────────── +const _lsrCache = {}; +async function fetchBinanceLSR(coin) { + const key = coin + '_lsr'; + if (_lsrCache[key] && Date.now() - _lsrCache[key].ts < 120000) return _lsrCache[key].data; + try { + const sym = coin.toUpperCase().replace(/^1000/, '') + 'USDT'; + const r = await fetch(`https://fapi.binance.com/futures/data/globalLongShortAccountRatio?symbol=${sym}&period=5m&limit=1`); + if (!r.ok) return null; + const d = await r.json(); + if (!Array.isArray(d) || !d[0]) return null; + const data = { longPct: parseFloat(d[0].longAccount)*100, shortPct: parseFloat(d[0].shortAccount)*100, ratio: parseFloat(d[0].longShortRatio) }; + _lsrCache[key] = { ts: Date.now(), data }; + return data; + } catch { return null; } +} + +// ── CoinGecko 24h / 7d change (free, no API key) ────────────────────────────── +const CG_IDS = { + BTC:'bitcoin',ETH:'ethereum',SOL:'solana',HYPE:'hyperliquid',BNB:'binancecoin', + ADA:'cardano',AVAX:'avalanche-2',DOT:'polkadot',MATIC:'matic-network',LINK:'chainlink', + ARB:'arbitrum',OP:'optimism',INJ:'injective-protocol',SUI:'sui',APT:'aptos', + DOGE:'dogecoin',SHIB:'shiba-inu',WIF:'dogwifcoin',PEPE:'pepe',NEAR:'near', + ATOM:'cosmos',FTM:'fantom',LTC:'litecoin',XRP:'ripple',TRX:'tron', + AAVE:'aave',UNI:'uniswap',MKR:'maker',TAO:'bittensor',RENDER:'render-token', + JTO:'jito-governance-token',TON:'the-open-network',NOT:'notcoin', + BONK:'bonk',PYTH:'pyth-network',TIA:'celestia',SEI:'sei-network', + STRK:'starknet',IMX:'immutable-x',BLUR:'blur',GMX:'gmx', +}; +const _cgCache = {}; +async function fetchCGData(coin) { + const id = CG_IDS[coin.toUpperCase()]; + if (!id) return null; + if (_cgCache[id] && Date.now() - _cgCache[id].ts < 180000) return _cgCache[id].data; + try { + const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=usd&include_24hr_change=true&include_7d_change=true&include_market_cap=true`); + if (!r.ok) return null; + const d = (await r.json())[id]; + if (!d) return null; + const data = { change24h: d.usd_24h_change, change7d: d.usd_7d_change, marketCap: d.usd_market_cap }; + _cgCache[id] = { ts: Date.now(), data }; + return data; + } catch { return null; } +} + +// ── Signal constructors for external data ───────────────────────────────────── +function sigLSR(lsr) { + if (!lsr) return null; + let label, cls; + if (lsr.ratio > 2.5) { label = 'LONGS CROWDED'; cls = 'warn'; } + else if (lsr.ratio > 1.4) { label = 'LONG BIASED'; cls = 'bull'; } + else if (lsr.ratio < 0.6) { label = 'SHORTS CROWDED'; cls = 'info'; } + else if (lsr.ratio < 0.8) { label = 'SHORT BIASED'; cls = 'bear'; } + else { label = 'BALANCED'; cls = 'neut'; } + return { label, cls, sub: `L ${lsr.longPct.toFixed(0)}% / S ${lsr.shortPct.toFixed(0)}% · Ratio ${lsr.ratio.toFixed(2)}`, + detail: lsr.ratio > 2.5 ? 'Longs very crowded — forced long liquidations can cascade down' + : lsr.ratio < 0.6 ? 'Shorts very crowded — short squeeze risk elevated' + : lsr.ratio > 1.4 ? 'More longs than shorts — mild bullish positioning' + : lsr.ratio < 0.8 ? 'More shorts than longs — mild bearish positioning' + : 'Balanced positioning — no directional crowding signal' }; +} + +function sigCG(cg) { + if (!cg) return null; + const c = cg.change24h || 0; + let label, cls; + if (c > 6) { label = 'STRONG RALLY'; cls = 'bull'; } + else if (c > 2) { label = 'BULLISH 24H'; cls = 'bull'; } + else if (c < -6) { label = 'STRONG SELLOFF'; cls = 'bear'; } + else if (c < -2) { label = 'BEARISH 24H'; cls = 'bear'; } + else { label = 'FLAT 24H'; cls = 'neut'; } + const w = cg.change7d; + return { label, cls, + sub: `24h ${c>=0?'+':''}${c.toFixed(2)}%${w!=null?' · 7d '+(w>=0?'+':'')+w.toFixed(2)+'%':''}`, + detail: `${Math.abs(c).toFixed(1)}% move in 24h on CoinGecko — ${c>0?'buy-side pressure dominant':'sell-side pressure dominant'}` }; +} + +function sigNansenFlow(coin) { + const data = window._nansenData?.netflows; + if (!data?.length) return null; + const entry = data.find(d => d.token?.symbol?.toUpperCase() === coin.toUpperCase()); + if (!entry) return null; + const flow = entry.netflow_usd_24h; + if (flow == null) return null; + let label, cls; + if (flow > 500000) { label = 'SMART MONEY BUY'; cls = 'bull'; } + else if (flow > 100000) { label = 'INFLOW'; cls = 'bull'; } + else if (flow < -500000) { label = 'SMART MONEY SELL'; cls = 'bear'; } + else if (flow < -100000) { label = 'OUTFLOW'; cls = 'bear'; } + else { label = 'NEUTRAL FLOW'; cls = 'neut'; } + const fmt = v => v >= 1e6 ? `$${(v/1e6).toFixed(1)}M` : v >= 1e3 ? `$${(v/1e3).toFixed(0)}K` : `$${v.toFixed(0)}`; + return { label, cls, sub: `24h ${flow>=0?'+':''}${fmt(flow)} smart money`, + detail: `${flow>0?'Smart money is buying':'Smart money is selling'} — Nansen wallet flow ${fmt(Math.abs(flow))} net in 24h` }; +} + +function sigFGGlobal() { + const fg = window._indData?.fear_greed; + if (!fg) return null; + const v = fg.value; + let label, cls; + if (v >= 75) { label = 'EXTREME GREED'; cls = 'warn'; } + else if (v >= 60) { label = 'GREED'; cls = 'bull'; } + else if (v <= 25) { label = 'EXTREME FEAR'; cls = 'info'; } + else if (v <= 40) { label = 'FEAR'; cls = 'bear'; } + else { label = 'NEUTRAL'; cls = 'neut'; } + return { label, cls, sub: `F&G ${v} — ${fg.classification}`, + detail: v >= 75 ? 'Market euphoria — historically high-risk zone for longs, potential distribution' + : v <= 25 ? 'Market panic — historically good accumulation zone, watch for capitulation end' + : v >= 60 ? 'Greed present — momentum favors longs but watch for exhaustion' + : 'Neutral to fearful sentiment — pick direction carefully' }; +} + +// ── Signal detail (explanation) map ────────────────────────────────────────── +const SIG_DETAIL = { + ema: { bull: 'Price above all major EMAs — clear uptrend, dip-buys are valid', bear: 'Price below key EMAs — downtrend in force, rallies are sell opps', warn: 'Mixed EMA stack — wait for a cleaner structure before entry', neut: 'Price at EMA level — potential support/resistance, watch reaction' }, + macd: { bull: 'MACD histogram expanding above zero — buy-side momentum accelerating', bear: 'MACD expanding below zero — sell-side momentum increasing', warn: 'Bullish momentum fading — potential local top, tighten stops', neut: 'MACD near zero — no momentum edge, avoid chasing' }, + rsi: { bull: 'RSI 60–70 — bullish without being overbought, good for entries', bear: 'RSI 30–40 — sell pressure dominant, bounces are likely short-lived', warn: 'RSI >70 — overbought zone, profit-taking risk elevated', info: 'RSI <30 — oversold, watch for reversal candle before entering long', neut: 'RSI neutral (40–60) — no directional edge from momentum' }, + stoch:{ bull: 'Stoch K>D above 50 — momentum aligned with bulls', bear: 'Stoch K80 — exhaustion area, reduce longs', info: 'Stochastic oversold <20 — potential bounce zone', neut: 'Stochastic midrange — no clear bias' }, + bb: { bull: 'Price in upper half of Bollinger Bands — trend strength', bear: 'Price in lower half — weakness, selling pressure', warn: 'Price at/above upper band — stretched, mean reversion risk', info: 'Price at/below lower band — oversold extreme, snap-back likely', neut: 'Price at midband — equilibrium, watch for breakout direction' }, + atr: { bull: 'Low volatility — positions can use tighter stops, lower noise', bear: 'Low volatility may precede a sharp move — stay alert', warn: 'High volatility — widen stops, reduce position size, not ideal entry', neut: 'Normal volatility — standard sizing and stop placement OK' }, + funding: { bull: 'Mild positive funding — modest long bias, not yet crowded', bear: 'Negative funding — shorts paying longs, selling pressure present', warn: 'High positive funding — crowded longs, squeeze risk if price drops', info: 'Very negative funding — crowded shorts, long squeeze risk elevated', neut: 'Near-zero funding — balanced positioning, no crowding signal' }, + oi: { bull: 'Rising OI — new money entering, potential for sustained move', bear: 'Falling OI — positions closing, move may be exhausting', warn: 'OI rising very fast — leverage building, fragile', neut: 'Stable OI — no strong conviction signal from positioning' }, + mf: { bull: 'Strong buy flow — aggressive buys outpacing sells on candles', bear: 'Strong sell flow — sellers more aggressive, bearish pressure', warn: 'Buy flow slightly elevated — mildly bullish, not conclusive', neut: 'Balanced buy/sell flow — no directional edge' }, +}; + +function addDetail(sig, name) { + if (!sig) return sig; + const map = SIG_DETAIL[name]; + if (!map) return sig; + const detail = map[sig.cls] || map.neut || ''; + return { ...sig, detail }; +} + +// ── CVD (Candle Volume Delta) ───────────────────────────────────────────────── +function calcCVD(opens, closes, highs, lows, volumes) { + let cvd = 0; + return closes.map((_, i) => { + const range = highs[i] - lows[i]; + if (range > 0) cvd += volumes[i] * ((closes[i] - lows[i]) - (highs[i] - closes[i])) / range; + return cvd; + }); +} + +// ── OI history (localStorage, per-coin) ────────────────────────────────────── +const _OI_HIST_KEY = 'hype_oi_hist_v1'; +function _oiHistGet() { + try { return JSON.parse(localStorage.getItem(_OI_HIST_KEY) || '{}'); } catch { return {}; } +} +function saveOIPoint(coin, oi) { + if (!oi || oi <= 0) return; + const h = _oiHistGet(); + if (!h[coin]) h[coin] = []; + h[coin].push({ ts: Date.now(), oi }); + if (h[coin].length > 96) h[coin] = h[coin].slice(-96); + try { localStorage.setItem(_OI_HIST_KEY, JSON.stringify(h)); } catch {} +} +function getPrevOI(coin, lookbackMs = 3600000) { + const h = _oiHistGet(); + const arr = h[coin] || []; + if (!arr.length) return null; + // Exclude entries saved in the last 3 minutes (to avoid comparing with freshly-saved point) + const candidates = arr.filter(e => Date.now() - e.ts > 180000); + if (!candidates.length) return arr.length > 1 ? arr[arr.length - 2].oi : null; + const target = Date.now() - lookbackMs; + let best = candidates[0]; + for (const e of candidates) { + if (Math.abs(e.ts - target) < Math.abs(best.ts - target)) best = e; + } + return best.oi; +} + +// ── CVD + OI combined signal ────────────────────────────────────────────────── +function sigCVDOI(priceChg, recentCVD, oiChgPct) { + const pUp = priceChg > 0.2; + const pDn = priceChg < -0.2; + const cUp = recentCVD > 0; + const oUp = oiChgPct != null && oiChgPct > 1.5; + const oDn = oiChgPct != null && oiChgPct < -1.5; + const oNa = oiChgPct == null; + let label, cls, detail; + + if (pUp && cUp && (oUp || oNa)) { + label = 'STRONG BULL'; cls = 'bull'; + detail = 'Price up + net buying CVD + OI expanding — real demand with new longs, continuation likely'; + } else if (pUp && cUp) { + label = 'SPOT DRIVEN'; cls = 'bull'; + detail = 'Price and CVD rising, OI flat/down — spot buyers driving move, shorts likely covering'; + } else if (pUp && !cUp && oUp) { + label = 'SUSPECT PUMP'; cls = 'warn'; + detail = 'Price up but sellers dominate CVD — move driven by short squeeze, not real demand'; + } else if (pUp && !cUp) { + label = 'WEAK RALLY'; cls = 'warn'; + detail = 'Price up with net selling CVD and falling OI — fragile move, likely reversal ahead'; + } else if (pDn && !cUp && (oDn || oNa)) { + label = 'STRONG BEAR'; cls = 'bear'; + detail = 'Price, CVD and OI all falling — real selling with longs exiting, continuation likely'; + } else if (pDn && !cUp && oUp) { + label = 'LEVERAGED SELL'; cls = 'bear'; + detail = 'Price and CVD down but OI rising — shorts adding leverage, squeeze risk if wrong'; + } else if (pDn && cUp && oDn) { + label = 'ACCUMULATION'; cls = 'info'; + detail = 'Price falling but net buyers absorb — dip buying while longs reduce exposure'; + } else if (pDn && cUp) { + label = 'BULL DIVERGENCE';cls = 'info'; + detail = 'Price down but buyers dominating CVD — hidden accumulation, watch for reversal'; + } else { + label = 'NEUTRAL'; cls = 'neut'; + detail = 'Price sideways or CVD/OI signals mixed — no clear directional edge'; + } + + const oStr = oNa ? 'OI N/A' : `OI ${oiChgPct >= 0 ? '+' : ''}${oiChgPct.toFixed(1)}%`; + return { label, cls, + sub: `Price ${priceChg >= 0 ? '+' : ''}${priceChg.toFixed(2)}% · CVD ${cUp ? '▲ buying' : '▼ selling'} · ${oStr}`, + detail }; +} + +// ── CVD+OI multi-coin overview table ───────────────────────────────────────── +function renderCVDOITable(rows) { + if (!rows.length) return '
    No data
    '; + return ` +
    +
    + Coin4c ChgCVDOISignal +
    + ${rows.map(r => { + const oiAbs = r.currentOI > 0 ? fmtB(r.currentOI) : '—'; + const oiChgStr = r.oiChgPct != null + ? `${r.oiChgPct >= 0 ? '+' : ''}${r.oiChgPct.toFixed(1)}%` + : `tracking`; + return `
    + ${r.coin}${r.hasPosition ? '' : ''} + ${r.priceChg >= 0 ? '+' : ''}${r.priceChg.toFixed(2)}% + ${r.cvdUp ? '▲ BUY' : '▼ SELL'} + ${oiAbs}${oiChgStr} + ${r.sig.label} +
    `; + }).join('')} +
    +
    ◆ = open position · CVD = 4-candle volume delta · OI vs ~1h ago
    `; +} + +// ── Binance historical OI (free, no API key) ────────────────────────────────── +const _binanceOICache = {}; +async function fetchBinanceOI(coin, tf = '1h', limit = 60) { + const sym = coin.toUpperCase().replace(/^1000/, '') + 'USDT'; + const period = tf === '4h' ? '4h' : tf === '1d' ? '1d' : '1h'; + const key = `${sym}_${period}`; + if (_binanceOICache[key] && Date.now() - _binanceOICache[key].ts < 300000) return _binanceOICache[key].data; + try { + const r = await fetch(`https://fapi.binance.com/futures/data/openInterestHist?symbol=${sym}&period=${period}&limit=${limit}`); + if (!r.ok) return null; + const d = await r.json(); + if (!Array.isArray(d) || !d.length) return null; + const data = d.map(e => ({ ts: e.timestamp, oi: parseFloat(e.sumOpenInterestValue) })); + _binanceOICache[key] = { ts: Date.now(), data }; + return data; + } catch { return null; } +} + +// ── Chart instance registry ─────────────────────────────────────────────────── +const _cvdCharts = {}; +function _destroyCVDCharts() { + for (const k of Object.keys(_cvdCharts)) { + try { _cvdCharts[k].destroy(); } catch {} + delete _cvdCharts[k]; + } +} + +function _miniOpts(tooltipFmt) { + return { + animation: false, responsive: true, maintainAspectRatio: false, + plugins: { + legend: { display: false }, + tooltip: tooltipFmt + ? { callbacks: { label: ctx => tooltipFmt(ctx.raw), title: () => '' } } + : { enabled: false }, + }, + scales: { x: { display: false }, y: { display: false } }, + }; +} + +// ── Combined CVD+OI chart cards (Price / CVD / OI per coin) ─────────────────── +function renderCVDOICharts(rows) { + return `
    ${rows.map(r => { + const oiAbs = r.currentOI > 0 ? fmtB(r.currentOI) : '—'; + const oiChgHtml = r.oiChgPct != null + ? `${r.oiChgPct >= 0 ? '+' : ''}${r.oiChgPct.toFixed(1)}%` + : `tracking…`; + return `
    +
    +
    + ${r.coin}${r.hasPosition ? '' : ''} + ${r.priceChg >= 0 ? '+' : ''}${r.priceChg.toFixed(2)}% +
    +
    + ${r.sig.label} + +
    +
    +
    +
    Price · ${fmtPrice(r.price)}
    +
    +
    CVD · ${r.cvdUp ? '▲ net buying' : '▼ net selling'}
    +
    +
    Open Interest · ${oiAbs} ${oiChgHtml}
    +
    +
    ${r.sig.detail || '—'}
    +
    +
    `; + }).join('')}
    `; +} + +function toggleCVDCard(coin) { + const body = document.getElementById(`cvd-body-${coin}`); + const icon = document.getElementById(`cvd-tog-${coin}`); + if (!body) return; + const open = body.style.display !== 'none'; + body.style.display = open ? 'none' : ''; + if (icon) icon.textContent = open ? '▶' : '▼'; + if (!open) { + setTimeout(() => { + ['p_','c_','o_'].forEach(p => _cvdCharts[p + coin]?.resize?.()); + }, 50); + } +} + +// ── Smart Money Flow (Binance L/S + taker, CoinGecko dominance) ────────────── + +const _lsCache = {}; +const _LS_TTL = 5 * 60 * 1000; + +async function _lsFetch(endpoint, sym, period = '1h', limit = 2) { + const key = `${endpoint}_${sym}_${period}`; + const now = Date.now(); + if (_lsCache[key] && now - _lsCache[key].ts < _LS_TTL) return _lsCache[key].data; + try { + const url = `https://fapi.binance.com/futures/data/${endpoint}?symbol=${sym}&period=${period}&limit=${limit}`; + const res = await fetch(url); + if (!res.ok) return null; + const data = await res.json(); + _lsCache[key] = { ts: now, data }; + return data; + } catch { return null; } +} + +const _BINANCE_PERP_COINS = new Set(['BTC','ETH','SOL','BNB','XRP','DOGE','AVAX','ADA','SUI', + 'ARB','OP','INJ','TIA','ATOM','LTC','LINK','MATIC','FTM','APT','SEI','WIF','PEPE','BONK','FET']); + +async function fetchMoneyFlow(coin) { + if (!_BINANCE_PERP_COINS.has(coin)) return null; + const sym = coin + 'USDT'; + const [glsR, topR, tkrR] = await Promise.allSettled([ + _lsFetch('globalLongShortAccountRatio', sym, '1h', 2), + _lsFetch('topLongShortAccountRatio', sym, '1h', 2), + _lsFetch('takerlongshortRatio', sym, '1h', 2), + ]); + const gls = glsR.value?.at(-1); + const tls = topR.value?.at(-1); + const tkr = tkrR.value?.at(-1); + if (!gls && !tls && !tkr) return null; + return { + coin, + lsRatio: gls ? parseFloat(gls.longShortRatio) : null, + topRatio: tls ? parseFloat(tls.longShortRatio) : null, + takerRatio: tkr ? parseFloat(tkr.buySellRatio) : null, + }; +} + +async function fetchBTCDom() { + const key = 'cg_global'; + const now = Date.now(); + if (_lsCache[key] && now - _lsCache[key].ts < _LS_TTL) return _lsCache[key].data; + try { + const res = await fetch('https://api.coingecko.com/api/v3/global'); + if (!res.ok) return null; + const { data: d } = await res.json(); + const mp = d.market_cap_percentage || {}; + const result = { + btcDom: mp.btc || 0, + ethDom: mp.eth || 0, + stablePct: (mp.usdt || 0) + (mp.usdc || 0), + mcap24h: d.market_cap_change_percentage_24h_usd || 0, + }; + _lsCache[key] = { ts: now, data: result }; + return result; + } catch { return null; } +} + +function _signalLS({ lsRatio, topRatio, takerRatio }) { + const topBull = topRatio != null && topRatio > 1.1; + const topBear = topRatio != null && topRatio < 0.9; + const crowdedLong = lsRatio != null && lsRatio > 1.55; + const crowdedShort = lsRatio != null && lsRatio < 0.7; + const takerBuy = takerRatio != null && takerRatio > 1.08; + const takerSell = takerRatio != null && takerRatio < 0.92; + + if (topBull && takerBuy && !crowdedLong) return { label:'SMART ACCUM', cls:'bull', detail:'Top traders long + buy aggression — high quality setup' }; + if (topBear && takerSell && !crowdedShort) return { label:'SMART DIST', cls:'bear', detail:'Top traders short + sell aggression — distribution signal' }; + if (topBull && crowdedShort) return { label:'SQUEEZE ↑', cls:'bull', detail:'Retail heavily short, smart money long — short squeeze risk' }; + if (topBear && crowdedLong) return { label:'SQUEEZE ↓', cls:'bear', detail:'Retail overleveraged long, smart money short — long squeeze risk' }; + if (topBear && takerBuy) return { label:'FAKE PUMP', cls:'warn', detail:'Retail buying aggressor, top traders positioned short — potential trap' }; + if (topBull && takerSell) return { label:'DEGEN SELL', cls:'warn', detail:'Smart money holding long but sell pressure mounting' }; + if (crowdedLong && !topBull) return { label:'CROWDED LONG',cls:'warn', detail:'Retail overleveraged long without smart money confirmation' }; + if (crowdedShort && !topBear) return { label:'CROWDED SHORT',cls:'info', detail:'Retail heavily short — watch for squeeze if catalyst appears' }; + if (topBull) return { label:'TOP BULL', cls:'bull', detail:'Smart money (top 20%) positioned net long' }; + if (topBear) return { label:'TOP BEAR', cls:'bear', detail:'Smart money (top 20%) positioned net short' }; + if (takerBuy) return { label:'BUY PRESS', cls:'bull', detail:'Aggressive buyers dominating taker volume' }; + if (takerSell)return { label:'SELL PRESS',cls:'bear', detail:'Aggressive sellers dominating taker volume' }; + return { label:'NEUTRAL', cls:'neut', detail:'No strong directional bias from L/S or taker data' }; +} + +function renderMoneyFlowCard() { + return `
    +
    +
    💸 Smart Money Flow
    + loading… +
    +
    +
    ${spinnerHtml()} fetching L/S & taker data…
    +
    + L/S All = all retail accounts · L/S Top = top 20% by volume (smart money) · Taker = buy/sell aggressor ratio · source: Binance Futures +
    +
    `; +} + +async function loadMoneyFlowSignals(coins) { + const riskEl = document.getElementById('mf-risk'); + const domBarEl = document.getElementById('mf-dom-bar'); + const tblEl = document.getElementById('mf-table'); + if (!tblEl) return; + + // BTC dominance + global market data + fetchBTCDom().then(dom => { + if (!dom) return; + const risk = dom.btcDom > 54 ? 'RISK OFF' : dom.btcDom < 47 ? 'RISK ON' : 'NEUTRAL'; + const rCls = risk === 'RISK ON' ? 'pos' : risk === 'RISK OFF' ? 'neg' : 'muted'; + if (riskEl) riskEl.innerHTML = `BTC.D ${dom.btcDom.toFixed(1)}% · Stable ${dom.stablePct.toFixed(1)}% · MCap ${dom.mcap24h>=0?'+':''}${dom.mcap24h.toFixed(1)}% · ${risk}`; + if (domBarEl) domBarEl.innerHTML = ` +
    +
    +
    +
    +
    +
    +
    + ■ BTC ${dom.btcDom.toFixed(1)}% + ■ ETH ${dom.ethDom.toFixed(1)}% + ■ Stables ${dom.stablePct.toFixed(1)}% + ■ Others +
    `; + }); + + // Per-coin L/S + taker from Binance + const tracked = coins.filter(c => _BINANCE_PERP_COINS.has(c)); + if (!tracked.length) { + if (tblEl) tblEl.innerHTML = '
    No tracked coins available on Binance Futures (positions are Hyperliquid-only)
    '; + return; + } + + const flows = await Promise.all(tracked.map(fetchMoneyFlow)); + if (!tblEl) return; + + const fmtR = (v, lowGood = false) => { + if (v == null) return ''; + const hi = lowGood ? v < 0.9 : v > 1.1; + const lo = lowGood ? v > 1.1 : v < 0.9; + const cls = hi ? 'pos' : lo ? 'neg' : 'muted'; + return `${v.toFixed(2)}`; + }; + + const hasAny = flows.some(Boolean); + if (!hasAny) { + tblEl.innerHTML = '
    Binance API unavailable — try again shortly
    '; + return; + } + + tblEl.innerHTML = ` +
    + CoinL/S AllL/S TopTakerSignal +
    + ${flows.map(f => { + if (!f) return ''; + const sig = _signalLS(f); + return `
    + ${f.coin} + ${fmtR(f.lsRatio)} + ${fmtR(f.topRatio)} + ${fmtR(f.takerRatio)} + ${sig.label} +
    `; + }).join('')}`; +} + +// ── HYPE Intelligence ───────────────────────────────────────────────────────── + +const HYPE_SUPPLY = 1_000_000_000; + +function _fundingSignal(f8h) { + if (f8h > 0.0005) return { label:'OVERLEVERAGED LONG', cls:'warn', detail:'Longs paying a lot → flush risk on pullbacks' }; + if (f8h > 0.0001) return { label:'BULLISH POSITIONING', cls:'bull', detail:'Longs paying → market expects higher' }; + if (f8h < -0.0005) return { label:'EXTREME SHORT BIAS', cls:'info', detail:'Shorts paying heavily → squeeze candidate' }; + if (f8h < -0.0001) return { label:'BEARISH POSITIONING', cls:'bear', detail:'Shorts paying → market expects lower' }; + return { label:'NEUTRAL FUNDING', cls:'neut', detail:'Balanced positioning, no extreme leverage bias' }; +} + +function _revenueSignal(rev) { + if (rev > 5e6) return { label:'HIGH REVENUE', cls:'bull', detail:'Strong fee income → active buyback pressure' }; + if (rev > 1.5e6) return { label:'NORMAL REVENUE', cls:'neut', detail:'Healthy platform activity' }; + return { label:'LOW REVENUE', cls:'warn', detail:'Reduced trading activity → lower buyback pressure' }; +} + +function _stakeSignal(pct) { + if (pct > 40) return { label:'HIGH LOCK', cls:'bull', detail:`${pct.toFixed(1)}% staked → tight float, reduced sell pressure` }; + if (pct > 20) return { label:'MODERATE', cls:'neut', detail:`${pct.toFixed(1)}% staked — normal distribution` }; + return { label:'LOW STAKE', cls:'warn', detail:`Only ${pct.toFixed(1)}% staked → more circulating supply` }; +} + +function renderHYPECard() { + return `
    +
    +
    ⚡ HYPE Intelligence
    + Platform health · HL-native signals · staking float +
    +
    ${spinnerHtml()} loading platform & staking data…
    +
    `; +} + +async function loadHYPEIntel(phaseMeta) { + const el = document.getElementById('hype-intel-body'); + if (!el) return; + try { + // ── Platform stats from phaseMeta (already fetched in loadPhases) ─────────── + let platformVol = 0, platformOI = 0, hype = null; + if (phaseMeta) { + const [meta, ctxs] = phaseMeta; + (meta?.universe || []).forEach((asset, i) => { + const ctx = ctxs[i] || {}; + const mark = parseFloat(ctx.markPx || ctx.midPx || 0); + const vol = parseFloat(ctx.dayNtlVlm || 0); + const oi = parseFloat(ctx.openInterest || 0) * mark; + platformVol += vol; + platformOI += oi; + if (asset.name === 'HYPE') { + hype = { + price: mark, + prevPx: parseFloat(ctx.prevDayPx || mark), + oi, + vol, + funding: parseFloat(ctx.funding || 0), + }; + } + }); + } + + if (!hype) { + el.innerHTML = '
    HYPE not found in market data — try refreshing
    '; + return; + } + + const priceChg24h = hype.prevPx > 0 ? (hype.price - hype.prevPx) / hype.prevPx * 100 : 0; + const estRevenue = platformVol * 0.0003; // ~0.03% avg fee (maker+taker blended) + const hypeVolShare = platformVol > 0 ? hype.vol / platformVol * 100 : 0; + const revSig = _revenueSignal(estRevenue); + const fundSig = _fundingSignal(hype.funding); + + // ── HYPE OI vs price divergence (using our localStorage history) ───────── + const prevOI = getPrevOI('HYPE'); + if (hype.oi > 0) saveOIPoint('HYPE', hype.oi); + const oiChgPct = prevOI > 0 && hype.oi > 0 ? (hype.oi - prevOI) / prevOI * 100 : null; + const oiSig = sigCVDOI(priceChg24h, 0, oiChgPct); + + // ── Staking / validator data ───────────────────────────────────────────── + let stakingHtml = ''; + try { + const validators = await (typeof hlPost === 'function' + ? hlPost({ type: 'validatorSummaries' }) + : null); + if (Array.isArray(validators) && validators.length) { + // Try several possible field names for stake amount + const stakeFields = ['stake', 'stkd', 'totalStake', 'stakedHype', 'delegatedStake']; + let raw = 0, usedField = null; + for (const f of stakeFields) { + const s = validators.reduce((a, v) => a + parseFloat(v[f] || 0), 0); + if (s > 1e6 && s < 2e12) { raw = s; usedField = f; break; } + } + // If values look like they're in raw units (>1B for total), divide by 1e8 + let staked = raw > HYPE_SUPPLY * 2 ? raw / 1e8 : raw; + if (staked > 0 && staked <= HYPE_SUPPLY) { + const stakePct = staked / HYPE_SUPPLY * 100; + const stakeSig = _stakeSignal(stakePct); + stakingHtml = ` +
    +
    Staking / Float
    +
    +
    +
    Total Staked
    +
    ${fmtB(staked)}
    +
    ${stakePct.toFixed(1)}% of 1B supply · ${validators.length} validators
    +
    +
    + ${stakeSig.label} +
    ${stakeSig.detail}
    +
    +
    +
    +
    `; + } + } + } catch(_) {} + if (!stakingHtml) { + stakingHtml = ` +
    +
    Staking / Float
    +
    Validator data unavailable from this context — check HL staking page
    +
    `; + } + + // ── Render ─────────────────────────────────────────────────────────────── + el.innerHTML = ` +
    +
    +
    HL 24h Volume
    +
    ${fmtB(platformVol)}
    +
    ${revSig.label}
    +
    +
    +
    Est. Daily Revenue
    +
    ${fmtB(estRevenue)}
    +
    ~0.03% avg fee
    +
    +
    +
    Total Platform OI
    +
    ${fmtB(platformOI)}
    +
    all perp markets
    +
    +
    + +
    +
    +
    HYPE Price
    +
    ${fmtPrice(hype.price)}
    +
    ${priceChg24h>=0?'+':''}${priceChg24h.toFixed(2)}% 24h
    +
    +
    +
    HYPE Perp OI
    +
    ${fmtB(hype.oi)}
    + ${oiChgPct != null + ? `
    ${oiChgPct>=0?'+':''}${oiChgPct.toFixed(1)}% vs prev
    ` + : '
    tracking OI…
    '} +
    +
    +
    Funding /8h
    +
    ${(hype.funding*100).toFixed(4)}%
    +
    ${hype.funding>0?'longs paying':'shorts paying'}
    +
    +
    +
    HYPE Vol Share
    +
    ${hypeVolShare.toFixed(1)}%
    +
    ${fmtB(hype.vol)} / platform
    +
    +
    +
    +
    Price+OI Signal
    + ${oiSig.label} +
    +
    +
    Funding Signal
    + ${fundSig.label} +
    +
    +
    + ${stakingHtml}`; + } catch(e) { + if (el) el.innerHTML = `
    Error loading HYPE data: ${e.message}
    `; + } +} + _destroyCVDCharts(); + + // Fetch Binance OI for all coins in parallel + const oiResults = await Promise.allSettled( + rows.map(r => fetchBinanceOI(r.coin, 'h1', Math.min(r.cvdArr?.length || 60, 200))) + ); + + for (let i = 0; i < rows.length; i++) { + const r = rows[i]; + const n = r.cvdArr?.length || 0; + + // ── Price chart ────────────────────────────────────────────────────────── + const priceC = document.getElementById(`cvdp-${r.coin}`); + if (priceC && r.closes?.length) { + _cvdCharts[`p_${r.coin}`] = new Chart(priceC, { + type: 'line', + data: { + labels: r.closes.map((_, j) => j), + datasets: [{ data: r.closes, + borderColor: 'rgba(148,163,184,0.9)', backgroundColor: 'rgba(148,163,184,0.06)', + borderWidth: 1.5, fill: true, tension: 0.3, pointRadius: 0 }], + }, + options: _miniOpts(v => fmtPrice(v)), + }); + } + + // ── CVD chart ──────────────────────────────────────────────────────────── + const cvdC = document.getElementById(`cvdc-${r.coin}`); + if (cvdC && r.cvdArr?.length) { + const col = r.cvdUp ? 'rgba(74,222,128,0.9)' : 'rgba(248,113,113,0.9)'; + const bg = r.cvdUp ? 'rgba(74,222,128,0.08)' : 'rgba(248,113,113,0.08)'; + _cvdCharts[`c_${r.coin}`] = new Chart(cvdC, { + type: 'line', + data: { + labels: r.cvdArr.map((_, j) => j), + datasets: [ + { data: r.cvdArr, borderColor: col, backgroundColor: bg, + borderWidth: 1.5, fill: true, tension: 0.3, pointRadius: 0 }, + { data: r.cvdArr.map(() => 0), borderColor: 'rgba(100,100,100,0.35)', + borderWidth: 1, borderDash: [3, 3], fill: false, pointRadius: 0 }, + ], + }, + options: _miniOpts(v => `CVD: ${Math.round(v).toLocaleString()}`), + }); + } + + // ── OI chart — Binance history or localStorage fallback ─────────────────── + const oiC = document.getElementById(`cvdo-${r.coin}`); + const binanceOI = oiResults[i].status === 'fulfilled' ? oiResults[i].value : null; + const localOI = (_oiHistGet()[r.coin] || []).map(e => ({ ts: e.ts, oi: e.oi })); + const oiSrc = binanceOI || (localOI.length > 1 ? localOI : null); + + if (oiC && oiSrc?.length) { + const oiVals = oiSrc.map(e => e.oi).slice(-Math.max(n, 30)); + const oiUp = oiVals.at(-1) > oiVals[0]; + _cvdCharts[`o_${r.coin}`] = new Chart(oiC, { + type: 'line', + data: { + labels: oiVals.map((_, j) => j), + datasets: [{ data: oiVals, + borderColor: oiUp ? 'rgba(251,191,36,0.9)' : 'rgba(156,163,175,0.8)', + backgroundColor: oiUp ? 'rgba(251,191,36,0.07)' : 'rgba(156,163,175,0.05)', + borderWidth: 1.5, fill: true, tension: 0.3, pointRadius: 0 }], + }, + options: _miniOpts(v => `OI: ${fmtB(v)}`), + }); + } else if (oiC) { + // No OI data yet — show placeholder + const wrap = document.getElementById(`cvdo-wrap-${r.coin}`); + if (wrap) wrap.innerHTML = '
    OI data loading from Binance…
    '; + } + } +} + +// ── Full TA build (async) ───────────────────────────────────────────────────── +async function buildFullTA(coin, tf, candles, rawMarketCtx) { + const opens = candles.map(c=>parseFloat(c.o)); + const closes = candles.map(c=>parseFloat(c.c)); + const highs = candles.map(c=>parseFloat(c.h)); + const lows = candles.map(c=>parseFloat(c.l)); + const vols = candles.map(c=>parseFloat(c.v)); + const price = closes.at(-1); + + const ema20 = iEMA(closes, 20); + const ema50 = iEMA(closes, 50); + const ema200 = closes.length >= 200 ? iEMA(closes, 200) : null; + const { hist, macd } = iMACD(closes); + const rsiArr = iRSI(closes); + const rsiVal = rsiArr.filter(v=>v!==null).at(-1); + const { k: stochK, d: stochD } = iStoch(highs, lows, closes); + const kVal = stochK.filter(v=>v!==null).at(-1); + const dVal = stochD.filter(v=>v!==null).at(-1); + const bbArr = iBB(closes); + const bb = bbArr.filter(v=>v!==null).at(-1); + const atrArr = iATR(highs, lows, closes); + const atr = atrArr.filter(v=>v!==null).at(-1); + const mf = iMoneyFlow(opens, closes, vols); + + const fr = rawMarketCtx ? parseFloat(rawMarketCtx.funding || 0) : 0; + const oi = rawMarketCtx ? parseFloat(rawMarketCtx.openInterest || 0) * price : 0; + const oiPrev = taOIPrev?.[coin + tf] ?? null; + if (typeof taOIPrev !== 'undefined') taOIPrev[coin + tf] = oi; + + // CVD + OI + const cvdArr = calcCVD(opens, closes, highs, lows, vols); + const lb = 4; + const recentCVD = cvdArr.at(-1) - (cvdArr.length > lb ? cvdArr[cvdArr.length - 1 - lb] : 0); + const priceChg4 = closes.length > lb ? (closes.at(-1) - closes[closes.length - 1 - lb]) / closes[closes.length - 1 - lb] * 100 : 0; + const prevOI = getPrevOI(coin); + if (oi > 0) saveOIPoint(coin, oi); + const oiChgPct = (prevOI && prevOI > 0 && oi > 0) ? (oi - prevOI) / prevOI * 100 : null; + + const sigs = { + ema: addDetail(sigEMA(price, ema20.at(-1), ema50.at(-1), ema200?ema200.at(-1):null), 'ema'), + macd: addDetail(sigMACD(hist, macd), 'macd'), + rsi: rsiVal!=null ? addDetail(sigRSI(rsiVal), 'rsi') : null, + stoch: kVal!=null&&dVal!=null ? addDetail(sigStoch(kVal,dVal), 'stoch') : null, + bb: bb ? addDetail(sigBB(bb), 'bb') : null, + atr: atr ? addDetail(sigATR(atr, price), 'atr') : null, + funding: addDetail(sigFunding(fr), 'funding'), + oi: addDetail(sigOI(oi, oiPrev), 'oi'), + mf: addDetail(sigFlow(mf.buyPct), 'mf'), + }; + + // External data (parallel, no-fail) + const [lsr, cg] = await Promise.allSettled([ + fetchBinanceLSR(coin), + fetchCGData(coin), + ]).then(rs => rs.map(r => r.status==='fulfilled' ? r.value : null)); + + sigs.lsr = sigLSR(lsr); + sigs.cg = sigCG(cg); + sigs.nansen = sigNansenFlow(coin); + sigs.fg = sigFGGlobal(); + sigs.cvdoi = sigCVDOI(priceChg4, recentCVD, oiChgPct); + + const sr = findSR(highs, lows, closes); + const dir = scoreDirection(sigs); + const setup = calcTradeSetup(dir.direction, price, sr, atr); + + return { sigs, dir, setup, price, coin, tf, atr, sr }; +} + +// ── Rendering ───────────────────────────────────────────────────────────────── +function _dirCls(d) { return d === 'LONG' ? 'pos' : d === 'SHORT' ? 'neg' : 'muted'; } +function _pct(a, b) { return b > 0 ? ((a - b) / b * 100) : 0; } + +function _checkRow(icon, name, sig) { + if (!sig) return ''; + const clsMap = { bull:'ta-ck-bull', bear:'ta-ck-bear', warn:'ta-ck-warn', info:'ta-ck-info', neut:'ta-ck-neut' }; + const iconMap = { bull:'✅', bear:'🔴', warn:'⚠️', info:'🟦', neut:'⬜' }; + return `
    + ${iconMap[sig.cls]||'⬜'} +
    +
    + ${name} + ${sig.label} +
    +
    ${sig.sub}${sig.detail ? ` — ${sig.detail}` : ''}
    +
    +
    `; +} + +function renderTARec(ta) { + const { sigs: s, dir, setup, price, coin, tf, sr } = ta; + const { direction, bull, bear, bullPct } = dir; + const { entry, tp1, tp2, sl, rr } = setup; + const dirCls = _dirCls(direction); + const total = bull + bear; + const barW = Math.round(bullPct * 100); + + const fmtSetup = (v, ref) => { + if (!v || !ref) return fmtPrice(v); + const p = _pct(v, ref); + return `${fmtPrice(v)} ${p>=0?'+':''}${p.toFixed(1)}%`; + }; + + const entryNote = direction === 'LONG' + ? (entry < price * 0.999 ? `Ideal entry on pullback` : `Current price is entry zone`) + : direction === 'SHORT' + ? (entry > price * 1.001 ? `Ideal entry on bounce` : `Current price is entry zone`) + : 'Neutral — wait for clearer direction'; + + const srHtml = (sr.support.length || sr.resistance.length) ? ` +
    + Support + ${sr.support.length ? sr.support.map(v=>fmtPrice(v)).join(' · ') : '—'} +
    +
    + Resistance + ${sr.resistance.length ? sr.resistance.map(v=>fmtPrice(v)).join(' · ') : '—'} +
    ` : ''; + + return ` +
    +
    +
    +
    ${direction}
    +
    ${coin} · ${tf} · ${fmtPrice(price)} · ${new Date().toLocaleTimeString()}
    +
    +
    +
    ${bull.toFixed(0)}/${total.toFixed(0)} signals bullish
    +
    +
    ${bull.toFixed(0)} bull ${bear.toFixed(0)} bear
    +
    +
    + +
    +
    +
    💵 Entry
    +
    ${fmtSetup(entry, price)}
    +
    ${entryNote}
    +
    +
    +
    🎯 TP1
    +
    ${fmtSetup(tp1, entry)}
    +
    First target
    +
    +
    +
    🎯 TP2
    +
    ${fmtSetup(tp2, entry)}
    +
    Extended target
    +
    +
    +
    🛡 Stop Loss
    +
    ${fmtSetup(sl, entry)}
    +
    R/R ${rr > 0 ? rr.toFixed(1) + ':1' : '—'}
    +
    +
    + + ${srHtml ? `
    ${srHtml}
    ` : ''} + +
    +
    Signal Checklist
    + ${_checkRow('📏','EMA Bias', s.ema)} + ${_checkRow('〰️','MACD', s.macd)} + ${_checkRow('⚡','RSI (14)', s.rsi)} + ${_checkRow('🔁','Stochastic', s.stoch)} + ${_checkRow('🎯','Bollinger Bands', s.bb)} + ${_checkRow('📐','Volatility (ATR)', s.atr)} + ${_checkRow('💰','Funding Rate', s.funding)} + ${_checkRow('📊','Open Interest', s.oi)} + ${_checkRow('🌊','Buy/Sell Flow', s.mf)} + ${_checkRow('⚖️','L/S Ratio (Binance)',s.lsr)} + ${_checkRow('📈','24h Change (CG)', s.cg)} + ${_checkRow('🏦','Smart Money', s.nansen)} + ${_checkRow('😱','Fear & Greed', s.fg)} + ${_checkRow('🔄','CVD + OI', s.cvdoi)} +
    +
    `; +} + +// ── Modal Signal tab helper ─────────────────────────────────────────────────── +async function loadModalSignal(coin) { + const el = document.getElementById('pm-signal-body'); + if (!el) return; + el.innerHTML = `
    Analysing ${coin}…
    `; + try { + const tf = '1h'; + const days = 15; + const [candles, meta] = await Promise.all([ + getCandles(coin, tf, days), + getMetaAndAssetCtxs(), + ]); + const universe = meta[0]?.universe || []; + const ctxs = meta[1] || []; + const idx = universe.findIndex(a => a.name === coin); + const rawCtx = idx >= 0 ? ctxs[idx] : null; + + const ta = await buildFullTA(coin, tf, candles, rawCtx); + const pos = typeof _pmGetPos === 'function' ? _pmGetPos(coin) : null; + + let conflictBanner = ''; + if (pos) { + const posDir = pos.side === 'long' ? 'LONG' : 'SHORT'; + const taDir = ta.dir.direction; + if (taDir !== 'NEUTRAL' && taDir !== posDir) { + conflictBanner = `
    + ⚠️ TA recommends ${taDir} but your position is ${posDir}. + Consider reviewing your thesis or reducing size. +
    `; + } else if (taDir === posDir) { + conflictBanner = `
    + ✅ TA aligns with your ${posDir} position. +
    `; + } + } + + // Offer to pre-fill stop price + const slBtn = pos ? `` : ''; + + el.innerHTML = conflictBanner + renderTARec(ta) + slBtn; + } catch(e) { + el.innerHTML = `
    Analysis failed: ${e.message}
    `; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..1adc181 --- /dev/null +++ b/index.html @@ -0,0 +1,232 @@ + + + + + + + + + + Hype — Analyzer + + + + + + + + + + + + +
    + + + + + +
    + Docs + + + + + + 0x6e4c…2015 +
    + +
    +
    + + + + + + + + + + + + + +
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Connecting…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    + + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + diff --git a/package.json b/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..42b4b6b --- /dev/null +++ b/styles.css @@ -0,0 +1,1367 @@ +:root { + --bg: #0a0a0a; + --surface: #111111; + --surface2: #1a1a1a; + --surface-hover: #1e1e1e; + --border: #242424; + --border-strong: #383838; + --text: #e2e2e2; + --text-muted: #6b7280; + --text-faint: #4b5563; + --accent: #38bdf8; + --accent-bg: #38bdf8; + --accent-text: #0a0a0a; + --accent-subtle: rgba(56,189,248,0.10); + --green: #4ade80; + --green-bg: rgba(74,222,128,0.08); + --red: #f87171; + --red-bg: rgba(248,113,113,0.08); + --yellow: #fbbf24; + --yellow-bg: rgba(251,191,36,0.08); + --blue: #38bdf8; + --blue-bg: rgba(56,189,248,0.10); + --orange: #fb923c; + --phase-accum: #38bdf8; + --phase-markup: #4ade80; + --phase-dist: #fbbf24; + --phase-down: #f87171; + --phase-neutral: #6b7280; + --font: 'Inter', system-ui, sans-serif; + --mono: 'JetBrains Mono', 'Fira Code', monospace; + --shadow-sm: 0 1px 4px rgba(0,0,0,0.50); + --shadow-md: 0 4px 16px rgba(0,0,0,0.60); + --shadow-lg: 0 8px 32px rgba(0,0,0,0.70); + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-pill: 100px; + --topbar-h: 48px; + --sidebar-w: 48px; + --bottom-nav-h: 62px; +} + +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } +html, body { height: 100%; } +body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 13px; line-height: 1.4; -webkit-font-smoothing: antialiased; overflow: hidden; } + +/* ── Topbar ─────────────────────────────────────────────────────────────────── */ + +.topbar { + position: fixed; top: 0; left: 0; right: 0; z-index: 60; + height: var(--topbar-h); + background: var(--surface); + border-bottom: 1px solid var(--border); + box-shadow: var(--shadow-sm); + display: flex; align-items: center; +} + +.topbar-logo { + width: var(--sidebar-w); height: 100%; + display: flex; align-items: center; justify-content: center; + font-weight: 700; font-size: 15px; + color: var(--accent); + border-right: 1px solid var(--border); + flex-shrink: 0; cursor: default; user-select: none; +} + +.topbar-nav { + display: flex; align-items: center; height: 100%; + overflow-x: auto; flex: 1; padding-left: 4px; + scrollbar-width: none; +} +.topbar-nav::-webkit-scrollbar { display: none; } + +.topbar-tab { + display: flex; align-items: center; height: 100%; + padding: 0 12px; font-size: 13px; font-weight: 500; + color: var(--text-muted); cursor: pointer; + border: none; background: none; white-space: nowrap; + border-bottom: 2px solid transparent; + transition: color 0.12s, border-color 0.12s; + font-family: var(--font); +} +.topbar-tab:hover { color: var(--text); } +.topbar-tab.active { color: var(--accent); border-bottom-color: var(--accent); } + +.topbar-right { display: flex; align-items: center; gap: 8px; padding-right: 12px; flex-shrink: 0; } + +.logo { display: none; } + +.wallet-badge { + background: var(--surface2); border: 1px solid var(--border); + border-radius: var(--radius-pill); padding: 3px 9px; + font-family: var(--mono); font-size: 10px; color: var(--text-muted); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 120px; +} +.refresh-info { font-size: 10px; color: var(--text-muted); white-space: nowrap; } +.status-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 5px var(--green); flex-shrink: 0; } +.status-dot.off { background: var(--red); box-shadow: 0 0 5px var(--red); } + +/* ── Sidebar icon rail ───────────────────────────────────────────────────────── */ + +.sidebar { + position: fixed; top: var(--topbar-h); left: 0; bottom: 0; + width: var(--sidebar-w); z-index: 40; + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; align-items: center; + padding: 8px 0; overflow: hidden; +} + +.nav-section { display: none; } + +.nav-item { + width: 32px; height: 32px; + display: flex; align-items: center; justify-content: center; + border-radius: var(--radius-md); cursor: pointer; + margin: 2px 8px; color: var(--text-muted); + background: none; border: none; + transition: background 0.12s, color 0.12s; + font-size: 14px; text-decoration: none; + user-select: none; flex-shrink: 0; +} +.nav-item:hover { background: var(--surface2); color: var(--text); } +.nav-item.active { background: var(--accent-subtle); color: var(--accent); } +.nav-item .icon { width: auto; font-size: 14px; } + +/* ── Main content ────────────────────────────────────────────────────────────── */ + +.main { + position: fixed; top: var(--topbar-h); left: var(--sidebar-w); + right: 0; bottom: 0; + overflow-y: auto; -webkit-overflow-scrolling: touch; + background: var(--bg); +} + +/* ── Pages ───────────────────────────────────────────────────────────────────── */ + +.page { display: none; animation: fadein 0.15s ease; } +.page.active { display: block; } +@keyframes fadein { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } } +@keyframes refresh-flash { 0%,100% { color: var(--text-muted); } 50% { color: var(--green); } } +.refresh-flash { animation: refresh-flash 0.5s ease; } + +/* ── Stat strip ──────────────────────────────────────────────────────────────── */ + +.stat-strip { display: flex; align-items: stretch; border-bottom: 1px solid var(--border); background: var(--surface); } +.stat-cell { flex: 1; padding: 13px 16px; border-right: 1px solid var(--border); min-width: 100px; } +.stat-cell:last-child { border-right: none; } +.s-label { font-size: 10px; color: var(--text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.6px; margin-bottom: 4px; } +.s-value { font-family: var(--mono); font-size: 17px; font-weight: 600; color: var(--text); font-variant-numeric: tabular-nums; line-height: 1.2; } +.s-sub { font-size: 10px; color: var(--text-muted); margin-top: 2px; } + +/* ── Legacy stat cards ───────────────────────────────────────────────────────── */ + +.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 13px 15px; margin-bottom: 12px; } +.card-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.8px; color: var(--text-muted); margin-bottom: 10px; } +.grid-4 { display: grid; grid-template-columns: repeat(4,1fr); gap: 10px; margin-bottom: 12px; } +.grid-3 { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-bottom: 12px; } +.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 12px; } +.stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 13px; } +.stat-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } +.stat-value { font-size: 18px; font-weight: 700; font-family: var(--mono); color: var(--text); } +.stat-sub { font-size: 10px; color: var(--text-muted); margin-top: 3px; } + +/* ── Filter chip bar ─────────────────────────────────────────────────────────── */ + +.filter-bar { + display: flex; align-items: center; gap: 6px; + padding: 9px 14px; border-bottom: 1px solid var(--border); + background: var(--surface); overflow-x: auto; scrollbar-width: none; flex-wrap: nowrap; +} +.filter-bar::-webkit-scrollbar { display: none; } +.filter-sep { width: 1px; height: 14px; background: var(--border); margin: 0 2px; flex-shrink: 0; } + +/* ── Tables ──────────────────────────────────────────────────────────────────── */ + +.table-wrap { overflow-x: auto; -webkit-overflow-scrolling: touch; background: var(--surface); } +table { width: 100%; border-collapse: collapse; font-size: 12px; } +th { text-align: left; padding: 8px 10px; color: var(--text-muted); font-weight: 600; font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; border-bottom: 1px solid var(--border); white-space: nowrap; background: var(--surface); } +th.num { text-align: right; } +td { padding: 8px 10px; border-bottom: 1px solid var(--border); color: var(--text); font-size: 12px; white-space: nowrap; } +td.num { text-align: right; font-family: var(--mono); font-variant-numeric: tabular-nums; } +tr:last-child td { border-bottom: none; } +tr:hover td { background: var(--surface-hover); } +.coin-cell { font-weight: 600; } + +/* ── Chips ───────────────────────────────────────────────────────────────────── */ + +.chip { + display: inline-flex; align-items: center; gap: 4px; + padding: 3px 10px; border-radius: var(--radius-pill); + font-size: 12px; font-weight: 500; + border: 1px solid var(--border); background: var(--surface2); + color: var(--text-muted); cursor: pointer; white-space: nowrap; + transition: all 0.12s; touch-action: manipulation; font-family: var(--font); +} +.chip:hover { border-color: var(--border-strong); color: var(--text); } +.chip.active { background: var(--accent-subtle); color: var(--accent); border-color: var(--accent); } + +.narrative-row { display: flex; gap: 6px; padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--surface); overflow-x: auto; scrollbar-width: none; flex-wrap: nowrap; } +.narrative-row::-webkit-scrollbar { display: none; } + +/* ── Colors ──────────────────────────────────────────────────────────────────── */ + +.pos { color: var(--green); } +.neg { color: var(--red); } +.muted { color: var(--text-muted); } +.accent { color: var(--accent); } +.flow-in { color: var(--green); } +.flow-out { color: var(--red); } + +/* ── Badges ──────────────────────────────────────────────────────────────────── */ + +.side-badge { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--radius-sm); font-size: 11px; font-weight: 600; } +.side-badge.long { background: var(--green-bg); color: var(--green); } +.side-badge.short { background: var(--red-bg); color: var(--red); } +.side-badge.inflow { background: var(--green-bg); color: var(--green); } +.side-badge.outflow { background: var(--red-bg); color: var(--red); } + +.health-badge { display:inline-flex; align-items:center; gap:4px; padding:2px 7px; border-radius:var(--radius-pill); font-size:10px; font-weight:600; border:1px solid transparent; } +.health-score { font-family:var(--mono); font-size:11px; } +.health-ok { background:var(--green-bg); color:var(--green); border-color:rgba(74,222,128,0.2); } +.health-caution { background:var(--yellow-bg); color:var(--yellow); border-color:rgba(251,191,36,0.2); } +.health-risky { background:var(--red-bg); color:var(--red); border-color:rgba(248,113,113,0.2); } +.risk-summary { display:flex; align-items:flex-start; gap:16px; padding:12px 16px; background:var(--surface); border-bottom:1px solid var(--border); border-radius:var(--radius); margin-bottom:10px; } +.risk-score-big { font-family:var(--mono); font-size:28px; font-weight:800; } +.risk-chips { display:flex; flex-wrap:wrap; gap:6px; margin-top:6px; } +.health-chip { display:inline-flex; align-items:center; gap:4px; padding:3px 8px; border-radius:var(--radius-pill); font-size:11px; } +.risk-flag-row { font-size:11px; color:var(--yellow); padding:3px 8px; background:var(--yellow-bg); border-radius:var(--radius-sm); margin-top:6px; } + +.health-modal-overlay { position:fixed; inset:0; z-index:200; background:rgba(0,0,0,0.72); display:flex; align-items:center; justify-content:center; } +.health-modal-box { background:var(--surface); border:1px solid var(--border-strong); border-radius:var(--radius-lg); padding:20px; min-width:300px; max-width:430px; width:90%; box-shadow:var(--shadow-lg); max-height:90vh; overflow-y:auto; } +.health-modal-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:14px; } +.hm-close { background:none; border:none; color:var(--text-muted); font-size:20px; cursor:pointer; line-height:1; padding:0 4px; } +.hm-close:hover { color:var(--text); } +.health-modal-score-row { display:flex; align-items:center; gap:12px; margin-bottom:16px; padding-bottom:14px; border-bottom:1px solid var(--border); } +.health-modal-big-score { font-family:var(--mono); font-size:44px; font-weight:800; line-height:1; } +.hm-factor { display:flex; justify-content:space-between; align-items:flex-start; padding:9px 0; border-bottom:1px solid var(--border); } +.hm-factor:last-child { border-bottom:none; } +.hm-factor-left { display:flex; gap:10px; align-items:flex-start; flex:1; min-width:0; } +.hm-icon { width:20px; height:20px; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:10px; font-weight:700; flex-shrink:0; margin-top:1px; } +.hm-pass { background:var(--green-bg); color:var(--green); } +.hm-fail { background:var(--red-bg); color:var(--red); } +.hm-warn { background:var(--yellow-bg); color:var(--yellow); } +.hm-na { background:var(--surface2); color:var(--text-muted); } +.hm-factor-name { font-size:12px; font-weight:600; margin-bottom:2px; } +.hm-factor-detail { font-size:11px; color:var(--text-muted); } +.hm-ded { font-family:var(--mono); font-size:13px; font-weight:700; white-space:nowrap; padding-left:10px; flex-shrink:0; } +.hm-ded-neg { color:var(--red); } +.hm-ded-pos { color:var(--green); } +.hm-ded-na { color:var(--text-muted); } + +.phase-badge { display: inline-flex; align-items: center; gap: 4px; padding: 2px 8px; border-radius: var(--radius-pill); font-size: 11px; font-weight: 600; } +.phase-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex-shrink: 0; } +.phase-ACCUMULATION { background: var(--blue-bg); color: var(--phase-accum); } +.phase-MARKUP { background: var(--green-bg); color: var(--phase-markup); } +.phase-DISTRIBUTION { background: var(--yellow-bg); color: var(--phase-dist); } +.phase-MARKDOWN { background: var(--red-bg); color: var(--phase-down); } +.phase-NEUTRAL { background: var(--surface2); color: var(--phase-neutral); } + +.funding-pill { display: inline-block; padding: 1px 6px; border-radius: var(--radius-sm); font-size: 10px; font-family: var(--mono); } +.funding-pos { background: var(--green-bg); color: var(--green); } +.funding-neg { background: var(--red-bg); color: var(--red); } +.funding-neu { background: var(--surface2); color: var(--text-muted); } + +.flow-bar { display: flex; height: 5px; border-radius: 3px; overflow: hidden; gap: 1px; } +.flow-bar-in { background: var(--green); border-radius: 3px 0 0 3px; } +.flow-bar-out { background: var(--red); border-radius: 0 3px 3px 0; } + +.change-pill { display: inline-flex; align-items: center; padding: 2px 7px; border-radius: var(--radius-pill); font-size: 12px; font-weight: 600; font-family: var(--mono); } +.change-pos { background: var(--green-bg); color: var(--green); } +.change-neg { background: var(--red-bg); color: var(--red); } + +/* ── Market cards ────────────────────────────────────────────────────────────── */ + +.market-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; } +.market-card-coin { font-weight: 700; font-size: 14px; color: var(--text); } +.market-card-price { font-family: var(--mono); font-size: 17px; font-weight: 700; color: var(--text); } +.market-card-row { display: flex; justify-content: space-between; align-items: center; } +.market-card-label { font-size: 10px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; } +.market-card-val { font-family: var(--mono); font-size: 12px; color: var(--text); } + +/* ── Regime / play cards (intel) ─────────────────────────────────────────────── */ + +.regime-pill { display: inline-flex; align-items: center; padding: 3px 10px; border-radius: var(--radius-pill); font-size: 11px; font-weight: 700; letter-spacing: 0.4px; } +.regime-CAUTION { background: var(--yellow-bg); color: var(--yellow); border: 1px solid rgba(251,191,36,0.2); } +.regime-WAIT { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); } +.regime-BUY,.regime-BULL { background: var(--green-bg); color: var(--green); border: 1px solid rgba(74,222,128,0.2); } +.regime-SELL,.regime-BEAR { background: var(--red-bg); color: var(--red); border: 1px solid rgba(248,113,113,0.2); } + +.play-card { padding: 10px 12px; border-radius: var(--radius-md); margin-bottom: 8px; border: 1px solid; } +.play-ENTRY { background: var(--green-bg); border-color: rgba(74,222,128,0.15); } +.play-HOLD { background: var(--blue-bg); border-color: rgba(56,189,248,0.15); } +.play-AVOID { background: var(--red-bg); border-color: rgba(248,113,113,0.15); } +.play-action-badge { display: inline-block; padding: 2px 7px; border-radius: var(--radius-sm); font-size: 10px; font-weight: 700; } +.action-ENTRY { background: var(--green-bg); color: var(--green); } +.action-HOLD { background: var(--blue-bg); color: var(--blue); } +.action-AVOID { background: var(--red-bg); color: var(--red); } + +.intel-row { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid var(--border); } +.intel-label { font-size: 11px; color: var(--text-muted); } +.intel-val { font-size: 12px; font-family: var(--mono); text-align: right; color: var(--text); } + +/* ── Intel page redesign ────────────────────────────────────────────────────── */ + +.intel-posture-banner { + background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius-lg); + padding: 14px 16px; margin-bottom: 12px; + display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; +} +.intel-posture-main { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; flex: 1; } +.intel-posture-label { font-size: 9px; font-weight: 700; letter-spacing: 0.1em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 5px; } +.intel-posture-verdict { font-size: 18px !important; font-weight: 800 !important; padding: 5px 16px !important; } +.intel-posture-score-wrap { min-width: 180px; flex-shrink: 0; } +.intel-posture-score-label { font-size: 11px; color: var(--text-muted); margin-bottom: 5px; } +.intel-score-track { position: relative; height: 6px; background: var(--surface2); border-radius: 3px; overflow: hidden; margin-bottom: 4px; } +.intel-score-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 3px; } +.intel-score-mid { position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border-strong); } +.intel-posture-conf { font-size: 10px; color: var(--text-muted); font-family: var(--mono); } +.intel-posture-meta { display: flex; flex-direction: column; gap: 4px; } +.intel-meta-label { font-size: 10px; color: var(--text-muted); margin-right: 6px; } +.intel-meta-val { font-size: 11px; font-weight: 500; } +.intel-posture-badges { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } + +.intel-main-grid { display: grid; grid-template-columns: 1fr 300px; gap: 12px; margin-bottom: 12px; } +.intel-col { display: flex; flex-direction: column; gap: 12px; } + +.intel-radar-wrap { display: flex; justify-content: center; padding: 4px 0; } +.intel-radar-wrap canvas { max-width: 220px; max-height: 220px; } + +.intel-zbar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; } +.intel-zbar-fill { height: 100%; border-radius: 2px; } +.intel-zbar-fill.pos-fill { background: var(--green); } +.intel-zbar-fill.neg-fill { background: var(--red); } + +.intel-bottom-score { display: flex; align-items: baseline; justify-content: center; gap: 4px; padding: 8px 0 4px; } +.intel-radar-score { font-size: 44px; font-weight: 800; font-family: var(--mono); line-height: 1; } +.intel-vote-row { display: flex; border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; } +.intel-vote-cell { flex: 1; padding: 8px 6px; text-align: center; border-right: 1px solid var(--border); } +.intel-vote-cell:last-child { border-right: none; } + +.intel-quote { font-size: 12px; color: var(--text-muted); line-height: 1.65; padding: 10px 12px; background: var(--surface2); border-radius: var(--radius-md); border-left: 3px solid var(--accent); margin-bottom: 10px; } +.intel-conviction { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 3px 8px; border-radius: var(--radius-md); } +.conv-pass { background: rgba(74,222,128,0.10); color: var(--green); } +.conv-fail { background: rgba(248,113,113,0.10); color: var(--red); } +.intel-narrative-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--surface2); border-radius: var(--radius-md); flex-wrap: wrap; margin-bottom: 8px; } +.intel-narrative-chip { padding: 4px 10px; border-radius: var(--radius-pill); background: var(--accent-subtle); border: 1px solid rgba(56,189,248,0.2); font-size: 11px; color: var(--accent); } +.intel-avoid-card { padding: 10px 12px; background: var(--red-bg); border: 1px solid rgba(248,113,113,0.15); border-radius: var(--radius-md); margin-bottom: 8px; } +.intel-risk-box { padding: 10px 12px; background: var(--yellow-bg); border: 1px solid rgba(251,191,36,0.2); border-radius: var(--radius-md); margin-top: 10px; } +.intel-desk-stat { flex: 1; min-width: 100px; padding: 8px 10px; background: var(--surface2); border-radius: var(--radius-md); border: 1px solid var(--border); } +.intel-desk-stat-label { font-size: 9px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; } +.intel-desk-stat-value { font-family: var(--mono); font-size: 13px; font-weight: 600; margin-bottom: 2px; } + +/* ── TA signal dashboard ─────────────────────────────────────────────────────── */ + +.ta-group { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 10px; } +.ta-last { border-bottom: none; margin-bottom: 0; padding-bottom: 0; } +.ta-gtitle { font-size: 10px; font-weight: 700; letter-spacing: 0.08em; color: var(--text-muted); text-transform: uppercase; margin-bottom: 8px; } +.ta-row { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; padding: 4px 0; min-height: 32px; } +.ta-row-label { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-muted); min-width: 100px; padding-top: 2px; flex-shrink: 0; } +.ta-sig { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; flex: 1; } +.ta-badge { font-size: 10px; font-weight: 700; padding: 2px 7px; border-radius: var(--radius-sm); letter-spacing: 0.04em; white-space: nowrap; } +.ta-sub { font-size: 10px; color: var(--text-muted); text-align: right; font-family: var(--mono); } +.ta-bull .ta-badge { background: var(--green-bg); color: var(--green); } +.ta-bear .ta-badge { background: var(--red-bg); color: var(--red); } +.ta-neut .ta-badge { background: var(--surface2); color: var(--text-muted); } +.ta-warn .ta-badge { background: var(--yellow-bg);color: var(--yellow); } +.ta-info .ta-badge { background: var(--blue-bg); color: var(--blue); } + +/* ── TA Recommendation card ─────────────────────────────────────────────────── */ + +.ta-rec-card { border: 1px solid var(--border); border-radius: var(--radius-md); overflow: hidden; } +.ta-rec-long { border-color: rgba(74,222,128,0.3); } +.ta-rec-short { border-color: rgba(248,113,113,0.3); } +.ta-rec-neutral { border-color: var(--border); } + +.ta-rec-top { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding: 12px 14px; background: var(--surface2); flex-wrap: wrap; } +.ta-rec-dir { font-size: 22px; font-weight: 800; letter-spacing: 0.05em; } +.ta-rec-meta { font-size: 10px; color: var(--text-muted); margin-top: 2px; } + +.ta-rec-score-wrap { text-align: right; min-width: 140px; } +.ta-rec-score-label { font-size: 11px; color: var(--text-muted); margin-bottom: 4px; } +.ta-rec-bar { height: 6px; background: var(--red-bg); border-radius: 3px; overflow: hidden; } +.ta-rec-bar-fill { height: 100%; background: var(--green); border-radius: 3px; transition: width 0.3s; } +.ta-rec-score-subs { font-size: 10px; margin-top: 3px; display: flex; gap: 8px; justify-content: flex-end; } + +.ta-setup-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); } +.ta-setup-cell { padding: 8px 10px; background: var(--surface); } +.ta-setup-label { font-size: 10px; color: var(--text-muted); margin-bottom: 3px; } +.ta-setup-val { font-size: 13px; font-weight: 600; font-family: var(--mono); } +.ta-setup-note { font-size: 10px; color: var(--text-muted); margin-top: 2px; } + +.ta-sr-block { padding: 8px 14px; border-top: 1px solid var(--border); background: var(--surface2); } +.ta-sr-row { display: flex; align-items: baseline; gap: 8px; font-size: 11px; padding: 2px 0; } +.ta-sr-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); min-width: 72px; } + +.ta-ck-section { padding: 10px 14px 12px; } +.ta-ck-title { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 8px; } +.ta-ck-row { display: flex; align-items: flex-start; gap: 8px; padding: 5px 0; border-bottom: 1px solid var(--border); } +.ta-ck-row:last-child { border-bottom: none; } +.ta-ck-icon { font-size: 14px; flex-shrink: 0; width: 20px; text-align: center; padding-top: 1px; } +.ta-ck-body { flex: 1; min-width: 0; } +.ta-ck-head { display: flex; align-items: center; justify-content: space-between; gap: 6px; margin-bottom: 2px; } +.ta-ck-name { font-size: 12px; color: var(--text); } +.ta-sig-badge { font-size: 10px; font-weight: 700; padding: 1px 6px; border-radius: var(--radius-sm); letter-spacing: 0.04em; white-space: nowrap; flex-shrink: 0; } +.ta-bull .ta-sig-badge, .ta-ck-bull .ta-sig-badge { background: var(--green-bg); color: var(--green); } +.ta-bear .ta-sig-badge, .ta-ck-bear .ta-sig-badge { background: var(--red-bg); color: var(--red); } +.ta-neut .ta-sig-badge, .ta-ck-neut .ta-sig-badge { background: var(--surface2); color: var(--text-muted); } +.ta-warn .ta-sig-badge, .ta-ck-warn .ta-sig-badge { background: var(--yellow-bg);color: var(--yellow); } +.ta-info .ta-sig-badge, .ta-ck-info .ta-sig-badge { background: var(--blue-bg); color: var(--blue); } +.ta-ck-sub { font-size: 10px; color: var(--text-muted); line-height: 1.4; } +.ta-ck-exp { color: var(--text-dim, var(--text-muted)); font-style: italic; } + +.ta-conflict-banner { background: rgba(248,113,113,0.1); border: 1px solid rgba(248,113,113,0.4); border-radius: var(--radius-md); padding: 8px 12px; font-size: 12px; color: var(--red); margin-bottom: 10px; } +.ta-aligned-banner { background: rgba(74,222,128,0.1); border: 1px solid rgba(74,222,128,0.4); border-radius: var(--radius-md); padding: 8px 12px; font-size: 12px; color: var(--green); margin-bottom: 10px; } + +@media (max-width: 480px) { + .ta-setup-grid { grid-template-columns: repeat(2, 1fr); } +} + +/* ── CVD + OI scanner table ──────────────────────────────────────────────────── */ + +.cvd-table { width: 100%; } +.cvd-head { + display: grid; + grid-template-columns: 56px 58px 70px 70px 1fr; + gap: 4px; padding: 4px 0 6px; + border-bottom: 1px solid var(--border); + font-size: 10px; font-weight: 700; text-transform: uppercase; + letter-spacing: 0.05em; color: var(--text-muted); +} +.cvd-row { + display: grid; + grid-template-columns: 56px 58px 70px 70px 1fr; + gap: 4px; padding: 6px 0; + border-bottom: 1px solid var(--border); + font-size: 12px; align-items: center; +} +.cvd-row:last-child { border-bottom: none; } +.cvd-pos-row { background: rgba(125,130,255,0.04); border-radius: 4px; } +.cvd-coin { font-weight: 600; display: flex; align-items: center; gap: 4px; } +.cvd-dot { color: var(--accent); font-size: 8px; } +.cvd-legend { font-size: 10px; color: var(--text-muted); padding-top: 8px; } + +.cvd-charts-grid { + display: grid; + grid-template-columns: 1fr; + gap: 10px; + margin-top: 14px; +} +.cvd-chart-card { + background: var(--surface2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + overflow: hidden; +} +.cvd-chart-pos { border-color: rgba(124,106,255,0.35); } +.cvd-chart-hdr { + display: flex; justify-content: space-between; align-items: center; + padding: 10px 12px; gap: 8px; +} +.cvd-chart-hdr.cvd-chart-toggle { cursor: pointer; user-select: none; } +.cvd-chart-hdr.cvd-chart-toggle:hover { background: rgba(255,255,255,0.03); } +.cvd-chart-left { display: flex; flex-direction: column; gap: 1px; } +.cvd-chart-coin { font-size: 13px; font-weight: 700; display: flex; align-items: center; gap: 4px; } +.cvd-toggle-icon { font-size: 11px; color: var(--text-muted); flex-shrink: 0; transition: transform 0.15s; } +.cvd-chart-body { padding: 0 10px 10px; border-top: 1px solid var(--border); } +.cvd-panel-label { font-size: 10px; color: var(--text-muted); margin: 7px 0 3px; display: flex; align-items: center; gap: 5px; } +.cvd-panel { position: relative; height: 80px; overflow: hidden; } +.cvd-panel canvas { max-width: 100%; display: block; } +.cvd-analysis { + font-size: 11px; color: var(--text-muted); line-height: 1.5; + margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--border); +} +.cvd-oi-empty { font-size: 10px; color: var(--text-muted); padding: 18px 0; text-align: center; } +.cvd-track { font-size: 10px; color: var(--text-muted); } + +/* ── Money flow panel ────────────────────────────────────────────────────────── */ + +.mf-dom-strip { + display: flex; gap: 12px; flex-wrap: wrap; align-items: center; + padding: 8px 10px; background: var(--surface2); border-radius: var(--radius-md); + margin-bottom: 10px; +} +.mf-dom-val { font-size: 12px; font-weight: 600; font-family: var(--mono); } + +/* ── Live monitor ────────────────────────────────────────────────────────────── */ + +.mono { font-family: var(--mono); font-size: 12px; } +@keyframes flash-up { 0% { background: rgba(74,222,128,0.20); } 100% { background: transparent; } } +@keyframes flash-dn { 0% { background: rgba(248,113,113,0.20); } 100% { background: transparent; } } +.ticker-flash-up { animation: flash-up 0.4s ease-out; } +.ticker-flash-dn { animation: flash-dn 0.4s ease-out; } +.alert-row { display: flex; align-items: center; gap: 6px; padding: 6px 8px; background: var(--surface2); border-radius: var(--radius-md); margin-bottom: 4px; font-size: 12px; border: 1px solid var(--border); } +.alert-log-row { padding: 5px 8px; border-radius: var(--radius-md); background: var(--surface2); margin-bottom: 3px; line-height: 1.5; font-size: 12px; } + +/* ── Confidence ──────────────────────────────────────────────────────────────── */ + +.conf-inline { display: flex; align-items: center; gap: 6px; justify-content: flex-end; } +.conf-track { width: 48px; height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; flex-shrink: 0; } +.conf-fill { height: 100%; background: var(--accent); border-radius: 2px; } +.conf-label { font-family: var(--mono); font-size: 11px; min-width: 32px; text-align: right; color: var(--text-muted); } +.conf-bar { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; } +.conf-val { font-family: var(--mono); font-size: 12px; min-width: 36px; } +.progress-bar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; } +.progress-fill { height: 100%; border-radius: 2px; background: var(--accent); transition: width 0.3s; } + +/* ── Phase legend ────────────────────────────────────────────────────────────── */ + +.phase-legend { display: flex; gap: 8px; flex-wrap: wrap; padding: 8px 14px; border-bottom: 1px solid var(--border); background: var(--surface); } + +/* ── Buttons ─────────────────────────────────────────────────────────────────── */ + +.btn { display: inline-flex; align-items: center; justify-content: center; gap: 5px; padding: 6px 12px; border-radius: var(--radius-md); border: none; cursor: pointer; font-size: 13px; font-weight: 500; transition: all 0.12s; touch-action: manipulation; min-height: 32px; font-family: var(--font); } +.btn-primary { background: var(--accent-bg); color: var(--accent-text); font-weight: 600; } +.btn-primary:hover { opacity: 0.87; } +.btn-ghost { background: var(--surface2); color: var(--text-muted); border: 1px solid var(--border); } +.btn-ghost:hover { border-color: var(--border-strong); color: var(--text); } +.btn-danger { background: var(--red-bg); color: var(--red); border: 1px solid rgba(248,113,113,0.2); } +.btn-danger:hover { background: rgba(248,113,113,0.15); } +.btn-sm { padding: 4px 9px; font-size: 12px; min-height: 28px; } + +/* ── Inputs ──────────────────────────────────────────────────────────────────── */ + +.input { background: var(--surface2); border: 1px solid var(--border); border-radius: var(--radius-md); padding: 8px 11px; color: var(--text); font-size: 13px; width: 100%; outline: none; transition: border 0.12s; min-height: 36px; font-family: var(--font); } +.input:focus { border-color: var(--accent); } +.input::placeholder { color: var(--text-faint); } +.input-group { display: flex; gap: 8px; flex-wrap: wrap; } +.input-group .input { flex: 1; min-width: 0; } +.add-bar { display: flex; gap: 8px; padding: 10px 14px; background: var(--surface); border-bottom: 1px solid var(--border); align-items: center; flex-wrap: wrap; } + +/* ── Loading / skeleton ──────────────────────────────────────────────────────── */ + +.loading { display: flex; align-items: center; justify-content: center; padding: 40px 16px; color: var(--text-muted); gap: 8px; font-size: 13px; } +.spinner { width: 15px; height: 15px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; flex-shrink: 0; } +@keyframes spin { to { transform: rotate(360deg); } } +.skeleton { background: linear-gradient(90deg, var(--surface2) 25%, var(--border) 50%, var(--surface2) 75%); background-size: 200% 100%; animation: shimmer 1.4s infinite; border-radius: var(--radius-sm); } +@keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } +.skeleton-cell { height: 11px; border-radius: var(--radius-sm); } + +/* ── Section headers / tabs ──────────────────────────────────────────────────── */ + +.section-header { display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; flex-wrap: wrap; gap: 8px; background: var(--surface); border-bottom: 1px solid var(--border); } +.section-title { font-size: 14px; font-weight: 600; color: var(--text); } +.tabs { display: flex; gap: 2px; } +.tab { padding: 4px 10px; border-radius: var(--radius-pill); cursor: pointer; font-size: 12px; color: var(--text-muted); border: 1px solid var(--border); background: var(--surface2); transition: all 0.12s; touch-action: manipulation; font-family: var(--font); } +.tab.active { background: var(--accent-subtle); color: var(--accent); border-color: var(--accent); } +.tab:hover:not(.active) { border-color: var(--border-strong); color: var(--text); } +.empty-state { text-align: center; padding: 32px 16px; color: var(--text-muted); font-size: 13px; line-height: 1.6; } + +/* ── Scrollbar ───────────────────────────────────────────────────────────────── */ + +::-webkit-scrollbar { width: 4px; height: 4px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 2px; } +::-webkit-scrollbar-thumb:hover { background: var(--text-muted); } + +/* ── Bottom nav (hidden — replaced by hamburger drawer) ──────────────────────── */ + +.bottom-nav { display: none !important; } + +/* ── Hamburger button ────────────────────────────────────────────────────────── */ + +.hamburger-btn { + display: none; flex: 1; height: 100%; + align-items: center; gap: 10px; + background: none; border: none; cursor: pointer; padding: 0 14px; + touch-action: manipulation; +} +.hbars { display: flex; flex-direction: column; gap: 5px; width: 22px; flex-shrink: 0; } +.hbars span { display: block; height: 2px; background: var(--text-muted); border-radius: 1px; transition: all 0.22s ease; } +.hamburger-btn.open .hbars span:nth-child(1) { transform: translateY(7px) rotate(45deg); background: var(--accent); } +.hamburger-btn.open .hbars span:nth-child(2) { opacity: 0; transform: scaleX(0); } +.hamburger-btn.open .hbars span:nth-child(3) { transform: translateY(-7px) rotate(-45deg); background: var(--accent); } + +/* ── Nav drawer + overlay ────────────────────────────────────────────────────── */ + +.nav-overlay { + display: none; position: fixed; inset: 0; z-index: 80; + background: rgba(0,0,0,0.55); +} +.nav-overlay.open { display: block; } + +.nav-drawer { + position: fixed; top: 0; left: 0; bottom: 0; width: 270px; z-index: 90; + background: var(--surface); border-right: 1px solid var(--border); + display: flex; flex-direction: column; + transform: translateX(-100%); + transition: transform 0.26s cubic-bezier(0.22, 1, 0.36, 1); + box-shadow: var(--shadow-lg); will-change: transform; +} +.nav-drawer.open { transform: translateX(0); } + +.nav-drawer-header { + display: flex; align-items: center; justify-content: space-between; + padding: 0 16px; height: var(--topbar-h); + border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.nav-drawer-logo { font-weight: 800; font-size: 16px; color: var(--accent); } +.nav-drawer-close { + background: none; border: none; color: var(--text-muted); cursor: pointer; + font-size: 18px; padding: 6px; border-radius: var(--radius-sm); + transition: color 0.12s; touch-action: manipulation; +} +.nav-drawer-close:hover { color: var(--text); } +.nav-drawer-body { flex: 1; overflow-y: auto; padding: 6px 8px; -webkit-overflow-scrolling: touch; } +.nav-drawer-section { + padding: 10px 10px 4px; font-size: 9px; font-weight: 700; + text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); +} +.drawer-item { + display: flex; align-items: center; gap: 12px; + width: 100%; padding: 11px 12px; + background: none; border: none; cursor: pointer; + color: var(--text-muted); font-size: 14px; font-weight: 500; + text-align: left; font-family: var(--font); + border-radius: var(--radius-md); + transition: background 0.12s, color 0.12s; + touch-action: manipulation; min-height: 44px; +} +.drawer-item:hover { background: var(--surface2); color: var(--text); } +.drawer-item.active { color: var(--accent); background: var(--accent-subtle); } +.drawer-item-icon { font-size: 16px; flex-shrink: 0; width: 22px; text-align: center; } + +/* ── Mobile card-style tables ─────────────────────────────────────────────────── */ + +@media (max-width: 600px) { + .mobile-cards { display: block; width: 100%; } + .mobile-cards thead { display: none; } + .mobile-cards tbody { display: block; } + .mobile-cards tbody tr { + display: block; background: var(--surface); + border: 1px solid var(--border); border-radius: var(--radius-lg); + margin-bottom: 10px; overflow: hidden; + } + .mobile-cards tbody tr:last-child { margin-bottom: 0; } + .mobile-cards td { + display: flex; justify-content: space-between; align-items: center; + padding: 9px 13px; border-bottom: 1px solid var(--border); + white-space: normal; word-break: break-word; font-size: 13px; gap: 8px; + text-align: right; + } + .mobile-cards td:last-child { border-bottom: none; } + .mobile-cards td::before { + content: attr(data-label); font-size: 10px; font-weight: 700; + color: var(--text-muted); text-transform: uppercase; + letter-spacing: 0.5px; flex-shrink: 0; white-space: nowrap; text-align: left; + } + .table-wrap:has(.mobile-cards) { overflow-x: visible; } +} + +/* ── Tablet (intel 2-col collapses) ─────────────────────────────────────────── */ + +@media (max-width: 900px) { + .intel-main-grid { grid-template-columns: 1fr; } + .intel-posture-main { gap: 12px; } +} + +/* ── Mobile ──────────────────────────────────────────────────────────────────── */ + +@media (max-width: 768px) { + body { overflow: hidden; font-size: 14px; } + .sidebar { display: none; } + .topbar-nav { display: none; } + .hamburger-btn { display: flex; } + .main { left: 0; bottom: 0; } + .refresh-info { display: none !important; } + + .stat-strip { flex-wrap: wrap; } + .stat-cell { min-width: 50%; border-bottom: 1px solid var(--border); } + .grid-4 { grid-template-columns: 1fr 1fr; gap: 8px; } + .grid-3 { grid-template-columns: 1fr 1fr; gap: 8px; } + .grid-2 { grid-template-columns: 1fr; gap: 8px; } + .stat-value { font-size: 16px; } + .stat-card, .card { padding: 11px 12px; } + #phase-cards { grid-template-columns: 1fr !important; } + .input-group { flex-direction: column; } + .input-group .btn { width: 100%; } + .narrative-row { flex-wrap: nowrap; overflow-x: auto; } + .intel-main-grid { grid-template-columns: 1fr; } + .intel-posture-verdict { font-size: 15px !important; } + .intel-radar-score { font-size: 36px; } + .mvrv-grid { grid-template-columns: repeat(2, 1fr); } + .mvrv-legend { grid-template-columns: 1fr; } + + /* Larger touch targets */ + .btn { min-height: 40px; } + .tab { padding: 6px 12px; font-size: 13px; min-height: 36px; } + .chip { padding: 5px 12px; font-size: 13px; min-height: 36px; } + .filter-bar { padding: 10px 14px; gap: 8px; } + + /* Better table readability */ + th { font-size: 11px; padding: 9px 10px; } + td { font-size: 13px; padding: 9px 10px; } + + /* Prevent inner content overflow */ + .page { min-width: 0; } + .card { overflow: hidden; } +} + +@media (max-width: 380px) { + .stat-value { font-size: 14px; } + .mvrv-grid { grid-template-columns: 1fr; } +} + +/* ── MVRV Monitor ────────────────────────────────────────────────────────────── */ + +.mvrv-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1px; + background: var(--border); + border-bottom: 1px solid var(--border); +} + +.mvrv-card { + background: var(--surface); + padding: 20px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.mvrv-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.mvrv-coin { + font-size: 16px; + font-weight: 700; + color: var(--text); + letter-spacing: -0.3px; +} + +.mvrv-coin-name { + font-size: 11px; + color: var(--text-muted); + margin-top: 1px; +} + +.mvrv-ratio { + font-family: var(--mono); + font-size: 36px; + font-weight: 700; + letter-spacing: -1px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.mvrv-ratio-label { + font-size: 10px; + color: var(--text-faint); + text-transform: uppercase; + letter-spacing: 0.6px; + margin-top: -6px; +} + +.mvrv-sparkline { + margin: 2px 0; +} + +.mvrv-stats { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 12px; + border-top: 1px solid var(--border); + padding-top: 10px; +} + +.mvrv-stat-label { + font-size: 10px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 2px; +} + +.mvrv-stat-val { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.mvrv-desc { + font-size: 11px; + color: var(--text-faint); + line-height: 1.4; + border-top: 1px solid var(--border); + padding-top: 8px; +} + +.mvrv-zone-badge { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: var(--radius-pill); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + white-space: nowrap; +} + +.mvrv-zone-hot { background: var(--red-bg); color: var(--red); } +.mvrv-zone-bull { background: var(--yellow-bg); color: var(--yellow); } +.mvrv-zone-neutral { background: var(--surface2); color: var(--text-muted); } +.mvrv-zone-under { background: var(--green-bg); color: var(--green); } + +.mvrv-legend { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 10px; + padding: 16px; + background: var(--surface); + border-bottom: 1px solid var(--border); +} + +.mvrv-legend-item { + display: flex; + align-items: flex-start; + gap: 8px; +} + +.mvrv-legend-desc { + font-size: 11px; + color: var(--text-muted); + line-height: 1.4; + padding-top: 2px; +} + +/* ── AI Knowledge Base ───────────────────────────────────────────────────────── */ + +.chat-wrap { + display: flex; + flex-direction: column; + height: calc(100vh - var(--topbar-h) - 120px); + min-height: 320px; +} + +.chat-history { + flex: 1; + overflow-y: auto; + padding: 16px; + display: flex; + flex-direction: column; + gap: 12px; + background: var(--bg); +} + +.chat-empty { + margin: auto; + color: var(--text-faint); + font-size: 13px; + text-align: center; +} + +.chat-bubble { + max-width: 80%; + padding: 10px 14px; + border-radius: var(--radius-lg); + font-size: 13px; + line-height: 1.6; +} +.chat-bubble.user { + align-self: flex-end; + background: var(--accent-subtle); + color: var(--accent); + border: 1px solid rgba(56,189,248,0.2); +} +.chat-bubble.assistant { + align-self: flex-start; + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + max-width: 92%; +} + +.chat-answer code { + background: var(--surface2); + padding: 1px 4px; + border-radius: 3px; + font-family: var(--mono); + font-size: 12px; +} + +.chat-sources { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border); +} + +.chat-sources-label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-faint); + margin-bottom: 6px; +} + +.chat-source { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; + font-size: 11px; +} + +.chat-source-type { + background: var(--surface2); + color: var(--text-muted); + padding: 1px 6px; + border-radius: var(--radius-pill); + font-size: 10px; + font-weight: 600; +} + +.chat-source-title { color: var(--text-muted); font-family: var(--mono); } +.chat-source-score { color: var(--text-faint); margin-left: auto; } + +.chat-powered-by { + margin-top: 6px; + font-size: 10px; + color: var(--text-faint); + text-align: right; +} + +.chat-input-row { + display: flex; + gap: 8px; + padding: 12px 16px; + background: var(--surface); + border-top: 1px solid var(--border); +} + +.chat-input { flex: 1; } + +.kgraph-node circle { transition: fill-opacity 0.15s; } +.kgraph-node:hover circle { fill-opacity: 0.45; } + +.wiki-list { background: var(--surface); } + +.wiki-file { border-bottom: 1px solid var(--border); } + +.wiki-file-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 16px; + cursor: pointer; + user-select: none; + gap: 12px; +} +.wiki-file-header:hover { background: var(--surface-hover); } + +.wiki-filename { + font-size: 13px; + font-weight: 600; + font-family: var(--mono); + color: var(--text); +} + +.wiki-lang { + font-size: 10px; + padding: 1px 6px; + border-radius: var(--radius-pill); + background: var(--accent-subtle); + color: var(--accent); + margin-left: 6px; + font-weight: 600; +} + +.wiki-path { + display: block; + font-size: 10px; + color: var(--text-faint); + font-family: var(--mono); + margin-top: 2px; +} + +.wiki-chevron { font-size: 10px; color: var(--text-faint); flex-shrink: 0; } + +.wiki-entries { background: var(--bg); } + +.wiki-entry { + padding: 10px 16px 10px 32px; + border-top: 1px solid var(--border); +} + +.wiki-entry-type { + display: inline-flex; + padding: 1px 6px; + border-radius: var(--radius-sm); + font-size: 10px; + font-weight: 600; + margin-right: 6px; +} +.wiki-entry-type.function { background: rgba(129,140,248,0.12); color: #818cf8; } +.wiki-entry-type.class { background: rgba(251,191,36,0.12); color: var(--yellow); } + +.wiki-entry-name { + font-family: var(--mono); + font-size: 13px; + font-weight: 600; + color: var(--text); +} + +.wiki-entry-line { + font-size: 10px; + color: var(--text-faint); + margin-left: 6px; +} + +.wiki-snippet { + margin-top: 6px; + font-size: 11px; + font-family: var(--mono); + color: var(--text-muted); + white-space: pre-wrap; + overflow: hidden; + max-height: 80px; + background: var(--surface2); + border-radius: var(--radius-sm); + padding: 6px 10px; +} + +.notes-wrap { + padding: 16px; + display: flex; + flex-direction: column; + gap: 16px; + background: var(--bg); + min-height: 400px; +} + +.note-add { + display: flex; + flex-direction: column; + gap: 8px; + padding: 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); +} + +.note-textarea { + resize: vertical; + min-height: 72px; + font-family: var(--font); +} + +.notes-list { display: flex; flex-direction: column; gap: 8px; } + +.note-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 12px 14px; +} + +.note-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; } + +.note-title { flex: 1; font-size: 13px; color: var(--text); } + +.note-body { + font-size: 12px; + color: var(--text-muted); + white-space: pre-wrap; + line-height: 1.5; +} + +.ai-stat-strip { + display: flex; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.ai-stat-cell { + flex: 1; + padding: 10px 14px; + border-right: 1px solid var(--border); +} +.ai-stat-cell:last-child { border-right: none; } + +.ai-stat-label { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); + margin-bottom: 3px; +} + +.ai-stat-val { + font-family: var(--mono); + font-size: 15px; + font-weight: 600; + color: var(--text); +} + +/* ── Position Meta Modal ────────────────────────────────────────────────────── */ +.pos-modal-overlay { position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:9000; } +.pos-modal-box { background:#111;border:1px solid #2a2a2a;border-radius:10px;width:100%;max-width:460px;max-height:90vh;overflow-y:auto;display:flex;flex-direction:column; } +.pos-modal-header { display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid #1f1f1f; } +.pos-modal-title { font-size:15px;font-weight:700;color:var(--accent); } +.pos-close-btn { background:none;border:none;color:#666;font-size:18px;cursor:pointer;line-height:1; } +.pos-close-btn:hover { color:var(--text); } +.pos-tabs { display:flex;border-bottom:1px solid #1f1f1f; } +.pos-tab { flex:1;padding:9px;background:none;border:none;color:#666;font-size:13px;cursor:pointer;border-bottom:2px solid transparent;transition:color .15s; } +.pos-tab.active { color:var(--accent);border-bottom-color:var(--accent); } +.pos-tab-panel { padding:16px;display:flex;flex-direction:column;gap:14px; } +.pos-field-group { display:flex;flex-direction:column;gap:6px; } +.pos-field-label { font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted); } +.pos-field-row { display:flex;gap:8px;align-items:center; } +.pos-input { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:6px;color:var(--text);padding:7px 10px;font-size:13px;font-family:var(--font);flex:1;outline:none; } +.pos-input:focus { border-color:var(--accent); } +.pos-textarea { background:#0d0d0d;border:1px solid #2a2a2a;border-radius:6px;color:var(--text);padding:8px 10px;font-size:13px;font-family:var(--font);width:100%;resize:vertical;outline:none;box-sizing:border-box; } +.pos-textarea:focus { border-color:var(--accent); } +.pos-save-btn { background:var(--accent);color:#000;border:none;border-radius:6px;padding:7px 14px;font-size:12px;font-weight:700;cursor:pointer;white-space:nowrap; } +.pos-save-btn:hover { opacity:.85; } +.pos-age-display { font-size:11px;color:var(--text-muted); } +.pos-field-hint { font-size:11px;color:var(--text-muted); } +.intent-chips { display:flex;gap:8px;flex-wrap:wrap; } +.intent-chip { background:#1a1a1a;border:1px solid #2a2a2a;border-radius:6px;color:#888;padding:6px 14px;font-size:12px;cursor:pointer;transition:all .15s; } +.intent-chip.active { background:rgba(56,189,248,.12);border-color:var(--accent);color:var(--accent); } +.pos-age-badge { display:inline-flex;align-items:center;gap:4px;font-size:11px;font-family:var(--mono);color:var(--text-muted);background:#1a1a1a;border:1px solid #2a2a2a;border-radius:4px;padding:2px 6px;white-space:nowrap; } +.pos-age-stale { color:#facc15;border-color:rgba(250,204,21,.3); } +.pos-intent-chip { font-size:10px;font-weight:700;color:var(--accent); } +.thesis-recheck { background:#0d0d0d;border:1px solid #1f1f1f;border-radius:8px;padding:12px;display:flex;flex-direction:column;gap:8px; } +.thesis-recheck-header { font-size:13px;font-weight:600;color:var(--text);margin-bottom:2px; } +.thesis-signal { display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0; } +.thesis-signal.sig-ok { color:var(--green); } +.thesis-signal.sig-fail { color:var(--red); } +.sig-icon { font-weight:700;width:14px;text-align:center; } +.sig-name { font-weight:600;min-width:70px; } +.sig-detail { color:inherit;opacity:.8; } +.stop-metrics { display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-top:8px; } +.stop-metric { background:#0d0d0d;border:1px solid #1f1f1f;border-radius:8px;padding:10px 12px; } +.stop-metric-label { font-size:10px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);margin-bottom:4px; } +.stop-metric-val { font-family:var(--mono);font-size:15px;font-weight:700;color:var(--text); } +.stop-metric-val.neg { color:var(--red); } + +/* ── Journal ────────────────────────────────────────────────────────────────── */ +.journal-insight-grid { display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:14px; } +@media(max-width:600px){.journal-insight-grid{grid-template-columns:1fr;}} +.journal-insight-card { background:var(--surface);border:1px solid var(--border);border-radius:var(--radius-lg);padding:14px 16px; } +.journal-hold-comparison { display:flex;flex-direction:column;gap:10px;margin-top:8px; } +.journal-hold-bar { height:8px;border-radius:4px;min-width:4px;transition:width .4s; } +.journal-coin-table table { width:100%;border-collapse:collapse;font-size:12px; } +.journal-coin-table th { text-align:left;color:var(--text-muted);font-size:10px;text-transform:uppercase;padding:4px 6px;border-bottom:1px solid var(--border); } +.journal-coin-table td { padding:5px 6px;border-bottom:1px solid #1a1a1a; } + +/* ── Indicators ────────────────────────────────────────────────────────────── */ +.ind-strip { display:flex; align-items:center; gap:0; border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; } +.ind-chip { display:flex; align-items:center; gap:6px; padding:8px 16px; border-right:1px solid var(--border); font-size:12px; } +.ind-chip:last-child { border-right:none; } +.ind-label { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.5px; color:var(--text-muted); } +.ind-badge { padding:1px 6px; border-radius:var(--radius-sm); font-size:10px; font-weight:600; } +.ind-extreme_fear { background:var(--red-bg); color:var(--red); } +.ind-fear { background:rgba(251,146,60,0.1); color:#fb923c; } +.ind-neutral { background:var(--surface2); color:var(--text-muted); } +.ind-greed { background:rgba(74,222,128,0.06); color:#a3e635; } +.ind-extreme_greed { background:var(--green-bg); color:var(--green); } +.ind-bull { background:var(--green-bg); color:var(--green); } +.ind-bear { background:var(--red-bg); color:var(--red); } +.ind-top { background:var(--red-bg); color:var(--red); } +.ind-warning { background:var(--yellow-bg); color:var(--yellow); } +.ind-normal { background:var(--surface2); color:var(--text-muted); } +.ind-strip-loading { padding:8px 16px; font-size:11px; color:var(--text-muted); border-bottom:1px solid var(--border); } +.ind-grid { display:grid; grid-template-columns:repeat(2,1fr); gap:10px; padding:14px; } +.ind-section-title { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.8px; color:var(--text-muted); padding:10px 14px 4px; } +.ind-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:13px 15px; } +.ind-card-title { font-size:10px; font-weight:700; text-transform:uppercase; letter-spacing:0.6px; color:var(--text-muted); margin-bottom:8px; } +.ind-card-value { font-family:var(--mono); font-size:28px; font-weight:800; line-height:1; margin-bottom:4px; } +.ind-card-detail { font-size:11px; color:var(--text-muted); margin-top:6px; line-height:1.5; } +.ind-signal-badge { display:inline-block; padding:2px 8px; border-radius:var(--radius-pill); font-size:11px; font-weight:700; margin-bottom:6px; } +.ind-bar-wrap { height:6px; background:var(--surface2); border-radius:3px; margin:6px 0; overflow:hidden; } +.ind-bar-fill { height:100%; border-radius:3px; } +.ind-fg-bar { background:linear-gradient(to right, var(--red), var(--yellow), var(--green)); } +.ind-floor-grid { display:grid; grid-template-columns:repeat(4,1fr); gap:8px; padding:0 14px 14px; } +.ind-floor-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-md); padding:10px 12px; } +.ind-floor-name { font-size:10px; font-weight:600; color:var(--text-muted); margin-bottom:3px; } +.ind-floor-price { font-family:var(--mono); font-size:14px; font-weight:700; } +.ind-floor-desc { font-size:10px; color:var(--text-muted); margin-top:2px; } +.ind-unavail { opacity:0.45; } +.ind-unavail-badge { display:inline-block; padding:1px 6px; border-radius:var(--radius-sm); font-size:10px; background:var(--surface2); color:var(--text-muted); } +.ind-sparkline-wrap { height:60px; margin-top:8px; } +@media (max-width:768px) { .ind-grid { grid-template-columns:1fr; } .ind-floor-grid { grid-template-columns:repeat(2,1fr); } } + +/* ── Nansen Smart Money ─────────────────────────────────────────────────────── */ +.nansen-header { display:flex; align-items:center; gap:12px; padding:10px 14px; background:var(--surface); border-bottom:1px solid var(--border); flex-wrap:wrap; } +.nansen-status { font-size:11px; color:var(--text-muted); } +.nansen-countdown { font-family:var(--mono); font-size:11px; color:var(--accent); } +.nansen-refresh-btn { padding:3px 10px; border-radius:var(--radius-sm); background:var(--surface2); border:1px solid var(--border); color:var(--text-muted); font-size:11px; cursor:pointer; } +.nansen-refresh-btn:hover { color:var(--text); } +.nansen-tabs { display:flex; border-bottom:1px solid var(--border); background:var(--surface); } +.nansen-tab { padding:9px 16px; font-size:13px; font-weight:500; color:var(--text-muted); cursor:pointer; border:none; background:none; border-bottom:2px solid transparent; transition:color 0.12s,border-color 0.12s; } +.nansen-tab:hover { color:var(--text); } +.nansen-tab.active { color:var(--accent); border-bottom-color:var(--accent); } +.nansen-pos-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(280px,1fr)); gap:10px; padding:14px; } +.nansen-pos-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius-lg); padding:13px 15px; } +.nansen-pos-card.pos-accum { border-left:3px solid var(--green); } +.nansen-pos-card.pos-distrib { border-left:3px solid var(--red); } +.nansen-pos-card.pos-neutral { border-left:3px solid var(--border); } +.nansen-pos-header { display:flex; align-items:center; gap:8px; margin-bottom:10px; } +.nansen-pos-coin { font-weight:700; font-size:14px; } +.nansen-flows { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; margin-top:8px; } +.nansen-flow-cell { text-align:center; } +.nansen-flow-label { font-size:10px; color:var(--text-muted); font-weight:600; text-transform:uppercase; letter-spacing:0.5px; } +.nansen-flow-val { font-family:var(--mono); font-size:12px; font-weight:700; margin-top:2px; } +.nansen-badge { display:inline-block; padding:2px 8px; border-radius:var(--radius-pill); font-size:10px; font-weight:700; letter-spacing:0.04em; } +.nansen-accum { background:var(--green-bg); color:var(--green); } +.nansen-distrib { background:var(--red-bg); color:var(--red); } +.nansen-neutral { background:var(--surface2); color:var(--text-muted); } +.nansen-key-form { padding:24px 14px; max-width:400px; } +.nansen-key-form input { width:100%; padding:8px 12px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-family:var(--mono); font-size:12px; outline:none; } +.nansen-key-form input:focus { border-color:var(--accent); } +.nansen-save-key-btn { margin-top:8px; padding:6px 16px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); cursor:pointer; font-size:12px; } +.nansen-pos-open { border-left:2px solid var(--accent) !important; } +.nansen-watchlist-input { display:flex; gap:8px; padding:12px 14px; border-bottom:1px solid var(--border); } +.nansen-watchlist-input input { flex:1; padding:6px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-size:12px; outline:none; } +.nansen-watchlist-input input:focus { border-color:var(--accent); } +.nansen-add-btn { padding:6px 12px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); cursor:pointer; font-size:12px; } +.nansen-wl-item { display:flex; align-items:center; gap:8px; padding:4px 14px; } +.nansen-wl-remove { background:none; border:none; color:var(--text-muted); cursor:pointer; font-size:14px; padding:2px 6px; } +.nansen-no-data { padding:24px 14px; color:var(--text-muted); font-size:13px; } +@media (max-width:768px) { .nansen-pos-grid { grid-template-columns:1fr; } } + +/* ── Analytics ── */ +.an-wrap { padding:14px; max-width:1200px; } +.an-cards { display:grid; grid-template-columns:repeat(auto-fill,minmax(130px,1fr)); gap:8px; margin-bottom:16px; } +.an-card { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; } +.an-card-label { font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.05em; font-weight:600; } +.an-card-value { font-size:18px; font-weight:700; font-family:var(--mono); margin-top:4px; } +.an-charts-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:16px; } +.an-chart-box { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px; } +.an-chart-title { font-size:11px; font-weight:600; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px; } +.an-breakdowns { display:flex; flex-direction:column; gap:12px; margin-bottom:16px; } +.an-section { background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); padding:12px 14px; margin-bottom:12px; } +.an-title { font-size:12px; font-weight:700; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.06em; margin-bottom:10px; } +.an-title-sub { font-size:10px; color:var(--text-muted); font-weight:400; text-transform:none; letter-spacing:0; } +.an-table-wrap { overflow-x:auto; } +.an-table { width:100%; border-collapse:collapse; font-size:12px; } +.an-table th { text-align:left; padding:5px 8px; color:var(--text-muted); font-weight:600; font-size:10px; text-transform:uppercase; letter-spacing:0.04em; border-bottom:1px solid var(--border); } +.an-table td { padding:5px 8px; border-bottom:1px solid var(--border2,#111); } +.an-table tr:last-child td { border-bottom:none; } +.an-kelly-body { display:flex; flex-direction:column; gap:10px; } +.an-kelly-formula { font-family:var(--mono); font-size:13px; color:var(--text-muted); } +.an-kelly-formula strong { color:var(--text); font-size:15px; } +.an-kelly-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(160px,1fr)); gap:6px; font-size:12px; color:var(--text-muted); } +.an-kelly-grid strong { color:var(--text); } +.an-kelly-recs { display:flex; flex-direction:column; gap:4px; } +.an-kelly-rec { font-size:12px; padding:5px 10px; background:var(--surface2); border-radius:var(--radius-sm); } +.an-kelly-rec.pos { background:var(--green-bg); } +.an-kelly-rec.neg { background:var(--red-bg); } +.an-kelly-rec strong { font-family:var(--mono); } +.an-kelly-note { font-size:11px; color:var(--text-muted); } +.an-patterns-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(170px,1fr)); gap:8px; } +.an-pattern-card { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:10px 12px; } +.an-pattern-card.good { border-left:3px solid var(--green); } +.an-pattern-card.bad { border-left:3px solid var(--red); } +.an-pattern-rank { font-size:10px; color:var(--text-muted); font-weight:700; margin-bottom:3px; } +.an-pattern-dim { font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; } +.an-pattern-val { font-size:14px; font-weight:700; margin:3px 0; } +.an-pattern-wr { font-size:13px; font-weight:700; font-family:var(--mono); } +.an-pattern-meta { font-size:10px; color:var(--text-muted); margin-top:3px; } +.an-pattern-edge { font-size:11px; font-weight:600; font-family:var(--mono); margin-top:4px; } +.an-ai-section { } +.an-ai-form { margin-bottom:12px; } +.an-ai-row { display:grid; grid-template-columns:1fr 1fr; gap:10px; margin-bottom:10px; } +.an-ai-field label { display:block; font-size:11px; color:var(--text-muted); margin-bottom:4px; } +.an-ai-field input { width:100%; padding:7px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-family:var(--mono); font-size:12px; outline:none; box-sizing:border-box; } +.an-ai-field input:focus { border-color:var(--accent); } +.an-ai-btns { display:flex; gap:8px; flex-wrap:wrap; } +.an-ai-btn { padding:7px 16px; border-radius:var(--radius-sm); font-size:12px; font-weight:600; cursor:pointer; border:1px solid var(--accent); background:var(--accent-subtle); color:var(--accent); } +.an-ai-btn.primary { background:var(--accent); color:#000; } +.an-ai-btn:hover { opacity:0.85; } +.an-ai-result { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:14px; margin-top:12px; } +.an-ai-response { font-size:13px; line-height:1.6; } +.an-ai-response p { margin:0 0 8px; } +.an-ai-response ul { margin:4px 0 8px 16px; padding:0; } +.an-ai-response li { margin-bottom:4px; } +.an-ai-h { font-size:13px; font-weight:700; color:var(--accent); margin:12px 0 6px; } +@media (max-width:768px) { + .an-charts-row { grid-template-columns:1fr; } + .an-ai-row { grid-template-columns:1fr; } + .an-patterns-grid { grid-template-columns:repeat(2,1fr); } +} + +/* ── Logger ── */ +.logger-dot { display:inline-block; width:8px; height:8px; border-radius:50%; margin:0 4px; cursor:pointer; flex-shrink:0; } +.logger-dot.ok { background:var(--green); box-shadow:0 0 4px var(--green); } +.logger-dot.warn { background:var(--yellow,#f59e0b); } +.logger-dot.off { background:var(--border); } +.logger-dot.running { background:var(--accent); animation:pulse 1s infinite; } +.logger-dot.error { background:var(--red); } +.an-tabbar { display:flex; gap:0; border-bottom:1px solid var(--border); margin-bottom:14px; } +.an-tab { padding:8px 16px; background:none; border:none; border-bottom:2px solid transparent; color:var(--text-muted); cursor:pointer; font-size:12px; font-weight:600; } +.an-tab:hover { color:var(--text); } +.an-tab.active { color:var(--accent); border-bottom-color:var(--accent); } +.log-setup { padding:14px; max-width:900px; } +.log-status-badge { display:inline-block; margin-left:8px; padding:2px 8px; border-radius:var(--radius-pill); font-size:10px; font-weight:700; } +.log-status-badge.ok { background:var(--green-bg); color:var(--green); } +.log-status-badge.warn { background:rgba(245,158,11,0.15); color:#f59e0b; } +.log-status-badge.off { background:var(--surface2); color:var(--text-muted); } +.log-step { display:flex; gap:12px; margin-bottom:20px; } +.log-step-num { width:24px; height:24px; border-radius:50%; background:var(--accent); color:#000; font-size:12px; font-weight:700; display:flex; align-items:center; justify-content:center; flex-shrink:0; margin-top:2px; } +.log-step-body { flex:1; } +.log-step-title { font-size:13px; font-weight:700; margin-bottom:4px; } +.log-step-desc { font-size:12px; color:var(--text-muted); margin-bottom:8px; } +.log-sql-wrap { position:relative; } +.log-copy-btn { position:absolute; top:8px; right:8px; padding:4px 10px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); font-size:11px; cursor:pointer; z-index:1; } +.log-sql { background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius); padding:12px; font-family:var(--mono); font-size:11px; color:var(--text-muted); overflow-x:auto; max-height:220px; overflow-y:auto; white-space:pre; margin:0; } +.log-fields { display:grid; grid-template-columns:1fr 1fr; gap:10px; } +.log-field label { display:block; font-size:11px; color:var(--text-muted); margin-bottom:4px; } +.log-field input { width:100%; padding:7px 10px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text); font-family:var(--mono); font-size:12px; outline:none; box-sizing:border-box; } +.log-field input:focus { border-color:var(--accent); } +.log-save-btn { padding:7px 16px; background:var(--accent); color:#000; border:none; border-radius:var(--radius-sm); font-size:12px; font-weight:700; cursor:pointer; } +.log-test-btn { padding:7px 14px; background:var(--surface2); border:1px solid var(--border); border-radius:var(--radius-sm); color:var(--text-muted); font-size:12px; cursor:pointer; } +.log-status-row { display:flex; align-items:center; gap:16px; flex-wrap:wrap; padding:12px 14px; background:var(--surface); border:1px solid var(--border); border-radius:var(--radius); margin-bottom:16px; } +.log-stat { display:flex; flex-direction:column; gap:2px; } +.log-stat-label { font-size:10px; color:var(--text-muted); text-transform:uppercase; letter-spacing:0.04em; } +.log-stat-val { font-size:13px; font-weight:600; font-family:var(--mono); } +.log-now-btn { margin-left:auto; padding:6px 14px; background:var(--accent-subtle); border:1px solid var(--accent); border-radius:var(--radius-sm); color:var(--accent); font-size:12px; cursor:pointer; } +.log-what { margin-top:4px; } +.log-what-title { font-size:11px; color:var(--text-muted); font-weight:700; text-transform:uppercase; letter-spacing:0.05em; margin-bottom:8px; } +.log-what-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(250px,1fr)); gap:6px; } +.log-what-item { font-size:11px; color:var(--text-muted); background:var(--surface2); padding:8px 10px; border-radius:var(--radius-sm); line-height:1.5; } +.log-what-item strong { color:var(--text); } +@media (max-width:768px) { .log-fields { grid-template-columns:1fr; } .log-step { flex-direction:column; } } + +/* ── Market Detail Modal ────────────────────────────────────────────────────── */ +.mkt-overlay { position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9500;display:flex;align-items:center;justify-content:center;padding:16px;backdrop-filter:blur(3px);opacity:0;pointer-events:none;transition:opacity .18s; } +.mkt-overlay.open { opacity:1;pointer-events:all; } +.mkt-modal { background:var(--card);border:1px solid var(--border-strong);border-radius:var(--radius-lg);width:100%;max-width:700px;max-height:88vh;overflow-y:auto;box-shadow:var(--shadow-lg);animation:mktSlideIn .18s ease; } +@keyframes mktSlideIn { from{transform:translateY(16px);opacity:0} to{transform:translateY(0);opacity:1} } +.mkt-modal-head { display:flex;justify-content:space-between;align-items:flex-start;padding:18px 20px 14px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--card);z-index:1; } +.mkt-modal-coin { font-size:22px;font-weight:800;color:var(--text); } +.mkt-modal-price { font-family:var(--mono);font-size:17px;font-weight:600;color:var(--text);margin-left:10px; } +.mkt-modal-chg { font-family:var(--mono);font-size:13px;margin-left:8px; } +.mkt-close { background:none;border:none;color:var(--text-faint);font-size:20px;cursor:pointer;line-height:1;padding:2px 6px;border-radius:var(--radius-sm); } +.mkt-close:hover { color:var(--text);background:var(--surface2); } +.mkt-modal-body { padding:16px 20px 20px; } +.mkt-stats-grid { display:flex;flex-wrap:wrap;gap:16px;padding:12px 0 16px;border-bottom:1px solid var(--border);margin-bottom:16px; } +.mkt-stat { display:flex;flex-direction:column;gap:2px; } +.mkt-stat-label { font-size:10px;color:var(--text-faint);text-transform:uppercase;letter-spacing:.05em; } +.mkt-stat-val { font-family:var(--mono);font-size:13px;font-weight:600; } +.mkt-section { margin-bottom:16px; } +.mkt-section-title { font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--text-faint);margin-bottom:8px; } +.mkt-sig-row { display:flex;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--border); } +.mkt-sig-row:last-child { border-bottom:none; } +.mkt-sig-name { font-size:11px;font-weight:600;color:var(--text-muted);width:54px;flex-shrink:0; } +.mkt-sig-sub { font-size:11px;color:var(--text-faint);font-family:var(--mono);margin-left:auto;text-align:right; } +.mkt-phase-box { background:var(--surface2);border-radius:var(--radius);padding:12px 14px; } +.mkt-phase-row { display:flex;align-items:center;justify-content:space-between;margin-bottom:8px; } +.mkt-conf-bar { height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:10px; } +.mkt-conf-fill { height:100%;border-radius:2px;transition:width .4s; } +.mkt-signals-list { display:flex;flex-direction:column;gap:4px; } +.mkt-signal-item { font-size:11px;color:var(--text-muted);display:flex;align-items:center;gap:5px; } +.mkt-signal-item::before { content:'';width:4px;height:4px;border-radius:50%;background:var(--accent);flex-shrink:0; } +.mkt-lsr-row { display:flex;align-items:center;gap:12px;padding:10px 0; } +.mkt-lsr-bar { flex:1;height:8px;border-radius:4px;overflow:hidden;display:flex; } +.mkt-lsr-long { background:var(--green);height:100%; } +.mkt-lsr-short { background:var(--red);height:100%; } +.mkt-oi-wrap { background:var(--surface2);border-radius:var(--radius);padding:10px 12px; } +.mkt-row-click { cursor:pointer; } +.mkt-row-click:hover td { background:var(--surface2); } +@media(max-width:600px){.mkt-modal{max-height:95vh}.mkt-modal-head{padding:14px 14px 10px}.mkt-modal-body{padding:12px 14px 16px}} diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..f4b06ca --- /dev/null +++ b/sw.js @@ -0,0 +1,49 @@ +const CACHE = 'hype-v8'; +const STATIC = [ + './', + './styles.css', + './app.js', + './ta-signal.js', + './position-meta.js', + './intel.js', + './indicators.js', + './analytics.js', + './logger.js', + './nansen.js', + './mvrv-ai.js', + './kb.js', + './docs.html', + './icons/icon.svg', + './manifest.json', + 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap', +]; + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE).then(c => c.addAll(STATIC)).then(() => self.skipWaiting()) + ); +}); + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) + ).then(() => self.clients.claim()) + ); +}); + +self.addEventListener('fetch', e => { + if (e.request.method !== 'GET') return; + e.respondWith( + caches.match(e.request).then(cached => { + if (cached) return cached; + return fetch(e.request).then(res => { + if (res.ok) { + const clone = res.clone(); + caches.open(CACHE).then(c => c.put(e.request, clone)); + } + return res; + }); + }) + ); +}); diff --git a/ta-signal.js b/ta-signal.js new file mode 100644 index 0000000..d85e60c --- /dev/null +++ b/ta-signal.js @@ -0,0 +1,994 @@ +// ── TA Recommendation Engine ────────────────────────────────────────────────── +// Depends on iEMA/iMACD/iRSI/iStoch/iBB/iATR/iMoneyFlow/sig* from app.js + +// ── Shared TTL cache helpers ────────────────────────────────────────────────── +function _cacheGet(cache, key, ttlMs) { + const hit = cache[key]; + return (hit && Date.now() - hit.ts < ttlMs) ? hit.data : undefined; +} +function _cacheSet(cache, key, data) { cache[key] = { ts: Date.now(), data }; return data; } + +// ── Support / Resistance pivot detection ───────────────────────────────────── +function findSR(highs, lows, closes, lb = 3) { + const pH = [], pL = []; + for (let i = lb; i < closes.length - lb; i++) { + const h = highs[i], l = lows[i]; + if (highs.slice(i-lb,i).every(v=>v<=h) && highs.slice(i+1,i+lb+1).every(v=>v<=h)) pH.push(h); + if (lows.slice(i-lb,i).every(v=>v>=l) && lows.slice(i+1,i+lb+1).every(v=>v>=l)) pL.push(l); + } + function cluster(arr) { + const sorted = [...arr].sort((a,b)=>a-b); + const out = []; + for (const v of sorted) { + if (!out.length || (v - out.at(-1)) / out.at(-1) > 0.004) out.push(v); + else out[out.length-1] = out.at(-1) * 0.55 + v * 0.45; + } + return out; + } + const price = closes.at(-1); + return { + resistance: cluster(pH).filter(l => l > price * 1.002).slice(0, 4), + support: cluster(pL).filter(l => l < price * 0.998).slice(-4), + }; +} + +// ── Direction scoring (bull/bear/neutral) ───────────────────────────────────── +function scoreDirection(sigsObj) { + let bull = 0, bear = 0; + for (const s of Object.values(sigsObj)) { + if (!s) continue; + if (s.cls === 'bull') bull += 1; + else if (s.cls === 'bear') bear += 1; + else if (s.cls === 'warn') bear += 0.4; // overbought / high vol + else if (s.cls === 'info') bull += 0.4; // oversold / crowded short + } + const total = bull + bear || 1; + const bullPct = bull / total; + const direction = bullPct >= 0.62 ? 'LONG' : bullPct <= 0.38 ? 'SHORT' : 'NEUTRAL'; + return { direction, bull: +bull.toFixed(1), bear: +bear.toFixed(1), bullPct }; +} + +// ── Entry / TP / SL from S/R + ATR ─────────────────────────────────────────── +function calcTradeSetup(direction, price, sr, atr) { + const a = atr || price * 0.02; + const buf = a * 0.35; + let entry, tp1, tp2, sl; + + if (direction === 'LONG') { + const sup = sr.support.length ? Math.max(...sr.support) : null; + const res1 = sr.resistance.length ? Math.min(...sr.resistance) : null; + const res2 = sr.resistance.length > 1 + ? sr.resistance.slice().sort((a,b)=>a-b)[1] : null; + + entry = sup && sup > price * 0.97 ? sup : price; + tp1 = res1 || entry + a * 2.5; + tp2 = res2 || tp1 + a * 1.5; + sl = sup ? sup - buf : entry - a * 1.5; + + } else if (direction === 'SHORT') { + const res = sr.resistance.length ? Math.min(...sr.resistance) : null; + const sup1 = sr.support.length ? Math.max(...sr.support) : null; + const sup2 = sr.support.length > 1 + ? sr.support.slice().sort((a,b)=>b-a)[1] : null; + + entry = res && res < price * 1.03 ? res : price; + tp1 = sup1 || entry - a * 2.5; + tp2 = sup2 || tp1 - a * 1.5; + sl = res ? res + buf : entry + a * 1.5; + + } else { + entry = price; + tp1 = price + a * 2; + tp2 = price + a * 3.5; + sl = price - a * 1.5; + } + + const risk = Math.abs(entry - sl); + const rew = Math.abs(tp1 - entry); + const rr = risk > 0 ? rew / risk : 0; + return { entry, tp1, tp2, sl, rr }; +} + +// ── Binance Futures L/S ratio (free, no API key) ────────────────────────────── +const _lsrCache = {}; +async function fetchBinanceLSR(coin) { + const key = coin + '_lsr'; + const hit = _cacheGet(_lsrCache, key, 120000); + if (hit !== undefined) return hit; + try { + const sym = coin.toUpperCase().replace(/^1000/, '') + 'USDT'; + const r = await fetch(`https://fapi.binance.com/futures/data/globalLongShortAccountRatio?symbol=${sym}&period=5m&limit=1`); + if (!r.ok) return null; + const d = await r.json(); + if (!Array.isArray(d) || !d[0]) return null; + return _cacheSet(_lsrCache, key, { longPct: parseFloat(d[0].longAccount)*100, shortPct: parseFloat(d[0].shortAccount)*100, ratio: parseFloat(d[0].longShortRatio) }); + } catch { return null; } +} + +// ── CoinGecko 24h / 7d change (free, no API key) ────────────────────────────── +const CG_IDS = { + BTC:'bitcoin',ETH:'ethereum',SOL:'solana',HYPE:'hyperliquid',BNB:'binancecoin', + ADA:'cardano',AVAX:'avalanche-2',DOT:'polkadot',MATIC:'matic-network',LINK:'chainlink', + ARB:'arbitrum',OP:'optimism',INJ:'injective-protocol',SUI:'sui',APT:'aptos', + DOGE:'dogecoin',SHIB:'shiba-inu',WIF:'dogwifcoin',PEPE:'pepe',NEAR:'near', + ATOM:'cosmos',FTM:'fantom',LTC:'litecoin',XRP:'ripple',TRX:'tron', + AAVE:'aave',UNI:'uniswap',MKR:'maker',TAO:'bittensor',RENDER:'render-token', + JTO:'jito-governance-token',TON:'the-open-network',NOT:'notcoin', + BONK:'bonk',PYTH:'pyth-network',TIA:'celestia',SEI:'sei-network', + STRK:'starknet',IMX:'immutable-x',BLUR:'blur',GMX:'gmx', +}; +const _cgCache = {}; +async function fetchCGData(coin) { + const id = CG_IDS[coin.toUpperCase()]; + if (!id) return null; + const hit = _cacheGet(_cgCache, id, 180000); + if (hit !== undefined) return hit; + try { + const r = await fetch(`https://api.coingecko.com/api/v3/simple/price?ids=${id}&vs_currencies=usd&include_24hr_change=true&include_7d_change=true&include_market_cap=true`); + if (!r.ok) return null; + const d = (await r.json())[id]; + if (!d) return null; + return _cacheSet(_cgCache, id, { change24h: d.usd_24h_change, change7d: d.usd_7d_change, marketCap: d.usd_market_cap }); + } catch { return null; } +} + +// ── Signal constructors for external data ───────────────────────────────────── +function sigLSR(lsr) { + if (!lsr) return null; + let label, cls; + if (lsr.ratio > 2.5) { label = 'LONGS CROWDED'; cls = 'warn'; } + else if (lsr.ratio > 1.4) { label = 'LONG BIASED'; cls = 'bull'; } + else if (lsr.ratio < 0.6) { label = 'SHORTS CROWDED'; cls = 'info'; } + else if (lsr.ratio < 0.8) { label = 'SHORT BIASED'; cls = 'bear'; } + else { label = 'BALANCED'; cls = 'neut'; } + return { label, cls, sub: `L ${lsr.longPct.toFixed(0)}% / S ${lsr.shortPct.toFixed(0)}% · Ratio ${lsr.ratio.toFixed(2)}`, + detail: lsr.ratio > 2.5 ? 'Longs very crowded — forced long liquidations can cascade down' + : lsr.ratio < 0.6 ? 'Shorts very crowded — short squeeze risk elevated' + : lsr.ratio > 1.4 ? 'More longs than shorts — mild bullish positioning' + : lsr.ratio < 0.8 ? 'More shorts than longs — mild bearish positioning' + : 'Balanced positioning — no directional crowding signal' }; +} + +function sigCG(cg) { + if (!cg) return null; + const c = cg.change24h || 0; + let label, cls; + if (c > 6) { label = 'STRONG RALLY'; cls = 'bull'; } + else if (c > 2) { label = 'BULLISH 24H'; cls = 'bull'; } + else if (c < -6) { label = 'STRONG SELLOFF'; cls = 'bear'; } + else if (c < -2) { label = 'BEARISH 24H'; cls = 'bear'; } + else { label = 'FLAT 24H'; cls = 'neut'; } + const w = cg.change7d; + return { label, cls, + sub: `24h ${c>=0?'+':''}${c.toFixed(2)}%${w!=null?' · 7d '+(w>=0?'+':'')+w.toFixed(2)+'%':''}`, + detail: `${Math.abs(c).toFixed(1)}% move in 24h on CoinGecko — ${c>0?'buy-side pressure dominant':'sell-side pressure dominant'}` }; +} + +function sigNansenFlow(coin) { + const data = window._nansenData?.netflows; + if (!data?.length) return null; + const entry = data.find(d => d.token?.symbol?.toUpperCase() === coin.toUpperCase()); + if (!entry) return null; + const flow = entry.netflow_usd_24h; + if (flow == null) return null; + let label, cls; + if (flow > 500000) { label = 'SMART MONEY BUY'; cls = 'bull'; } + else if (flow > 100000) { label = 'INFLOW'; cls = 'bull'; } + else if (flow < -500000) { label = 'SMART MONEY SELL'; cls = 'bear'; } + else if (flow < -100000) { label = 'OUTFLOW'; cls = 'bear'; } + else { label = 'NEUTRAL FLOW'; cls = 'neut'; } + const fmt = v => v >= 1e6 ? `$${(v/1e6).toFixed(1)}M` : v >= 1e3 ? `$${(v/1e3).toFixed(0)}K` : `$${v.toFixed(0)}`; + return { label, cls, sub: `24h ${flow>=0?'+':''}${fmt(flow)} smart money`, + detail: `${flow>0?'Smart money is buying':'Smart money is selling'} — Nansen wallet flow ${fmt(Math.abs(flow))} net in 24h` }; +} + +function sigFGGlobal() { + const fg = window._indData?.fear_greed; + if (!fg) return null; + const v = fg.value; + let label, cls; + if (v >= 75) { label = 'EXTREME GREED'; cls = 'warn'; } + else if (v >= 60) { label = 'GREED'; cls = 'bull'; } + else if (v <= 25) { label = 'EXTREME FEAR'; cls = 'info'; } + else if (v <= 40) { label = 'FEAR'; cls = 'bear'; } + else { label = 'NEUTRAL'; cls = 'neut'; } + return { label, cls, sub: `F&G ${v} — ${fg.classification}`, + detail: v >= 75 ? 'Market euphoria — historically high-risk zone for longs, potential distribution' + : v <= 25 ? 'Market panic — historically good accumulation zone, watch for capitulation end' + : v >= 60 ? 'Greed present — momentum favors longs but watch for exhaustion' + : 'Neutral to fearful sentiment — pick direction carefully' }; +} + +// ── Signal detail (explanation) map ────────────────────────────────────────── +const SIG_DETAIL = { + ema: { bull: 'Price above all major EMAs — clear uptrend, dip-buys are valid', bear: 'Price below key EMAs — downtrend in force, rallies are sell opps', warn: 'Mixed EMA stack — wait for a cleaner structure before entry', neut: 'Price at EMA level — potential support/resistance, watch reaction' }, + macd: { bull: 'MACD histogram expanding above zero — buy-side momentum accelerating', bear: 'MACD expanding below zero — sell-side momentum increasing', warn: 'Bullish momentum fading — potential local top, tighten stops', neut: 'MACD near zero — no momentum edge, avoid chasing' }, + rsi: { bull: 'RSI 60–70 — bullish without being overbought, good for entries', bear: 'RSI 30–40 — sell pressure dominant, bounces are likely short-lived', warn: 'RSI >70 — overbought zone, profit-taking risk elevated', info: 'RSI <30 — oversold, watch for reversal candle before entering long', neut: 'RSI neutral (40–60) — no directional edge from momentum' }, + stoch:{ bull: 'Stoch K>D above 50 — momentum aligned with bulls', bear: 'Stoch K80 — exhaustion area, reduce longs', info: 'Stochastic oversold <20 — potential bounce zone', neut: 'Stochastic midrange — no clear bias' }, + bb: { bull: 'Price in upper half of Bollinger Bands — trend strength', bear: 'Price in lower half — weakness, selling pressure', warn: 'Price at/above upper band — stretched, mean reversion risk', info: 'Price at/below lower band — oversold extreme, snap-back likely', neut: 'Price at midband — equilibrium, watch for breakout direction' }, + atr: { bull: 'Low volatility — positions can use tighter stops, lower noise', bear: 'Low volatility may precede a sharp move — stay alert', warn: 'High volatility — widen stops, reduce position size, not ideal entry', neut: 'Normal volatility — standard sizing and stop placement OK' }, + funding: { bull: 'Mild positive funding — modest long bias, not yet crowded', bear: 'Negative funding — shorts paying longs, selling pressure present', warn: 'High positive funding — crowded longs, squeeze risk if price drops', info: 'Very negative funding — crowded shorts, long squeeze risk elevated', neut: 'Near-zero funding — balanced positioning, no crowding signal' }, + oi: { bull: 'Rising OI — new money entering, potential for sustained move', bear: 'Falling OI — positions closing, move may be exhausting', warn: 'OI rising very fast — leverage building, fragile', neut: 'Stable OI — no strong conviction signal from positioning' }, + mf: { bull: 'Strong buy flow — aggressive buys outpacing sells on candles', bear: 'Strong sell flow — sellers more aggressive, bearish pressure', warn: 'Buy flow slightly elevated — mildly bullish, not conclusive', neut: 'Balanced buy/sell flow — no directional edge' }, +}; + +function addDetail(sig, name) { + if (!sig) return sig; + const map = SIG_DETAIL[name]; + if (!map) return sig; + const detail = map[sig.cls] || map.neut || ''; + return { ...sig, detail }; +} + +// ── CVD (Candle Volume Delta) ───────────────────────────────────────────────── +function calcCVD(opens, closes, highs, lows, volumes) { + let cvd = 0; + return closes.map((_, i) => { + const range = highs[i] - lows[i]; + if (range > 0) cvd += volumes[i] * ((closes[i] - lows[i]) - (highs[i] - closes[i])) / range; + return cvd; + }); +} + +// ── OI history (localStorage, per-coin) ────────────────────────────────────── +const _OI_HIST_KEY = 'hype_oi_hist_v1'; +function _oiHistGet() { + try { return JSON.parse(localStorage.getItem(_OI_HIST_KEY) || '{}'); } catch { return {}; } +} +function saveOIPoint(coin, oi) { + if (!oi || oi <= 0) return; + const h = _oiHistGet(); + if (!h[coin]) h[coin] = []; + h[coin].push({ ts: Date.now(), oi }); + if (h[coin].length > 96) h[coin] = h[coin].slice(-96); + try { localStorage.setItem(_OI_HIST_KEY, JSON.stringify(h)); } catch {} +} +function getPrevOI(coin, lookbackMs = 3600000) { + const h = _oiHistGet(); + const arr = h[coin] || []; + if (!arr.length) return null; + // Exclude entries saved in the last 3 minutes (to avoid comparing with freshly-saved point) + const candidates = arr.filter(e => Date.now() - e.ts > 180000); + if (!candidates.length) return arr.length > 1 ? arr[arr.length - 2].oi : null; + const target = Date.now() - lookbackMs; + let best = candidates[0]; + for (const e of candidates) { + if (Math.abs(e.ts - target) < Math.abs(best.ts - target)) best = e; + } + return best.oi; +} + +// ── CVD + OI combined signal ────────────────────────────────────────────────── +function sigCVDOI(priceChg, recentCVD, oiChgPct) { + const pUp = priceChg > 0.2; + const pDn = priceChg < -0.2; + const cUp = recentCVD > 0; + const oUp = oiChgPct != null && oiChgPct > 1.5; + const oDn = oiChgPct != null && oiChgPct < -1.5; + const oNa = oiChgPct == null; + let label, cls, detail; + + if (pUp && cUp && (oUp || oNa)) { + label = 'STRONG BULL'; cls = 'bull'; + detail = 'Price up + net buying CVD + OI expanding — real demand with new longs, continuation likely'; + } else if (pUp && cUp) { + label = 'SPOT DRIVEN'; cls = 'bull'; + detail = 'Price and CVD rising, OI flat/down — spot buyers driving move, shorts likely covering'; + } else if (pUp && !cUp && oUp) { + label = 'SUSPECT PUMP'; cls = 'warn'; + detail = 'Price up but sellers dominate CVD — move driven by short squeeze, not real demand'; + } else if (pUp && !cUp) { + label = 'WEAK RALLY'; cls = 'warn'; + detail = 'Price up with net selling CVD and falling OI — fragile move, likely reversal ahead'; + } else if (pDn && !cUp && (oDn || oNa)) { + label = 'STRONG BEAR'; cls = 'bear'; + detail = 'Price, CVD and OI all falling — real selling with longs exiting, continuation likely'; + } else if (pDn && !cUp && oUp) { + label = 'LEVERAGED SELL'; cls = 'bear'; + detail = 'Price and CVD down but OI rising — shorts adding leverage, squeeze risk if wrong'; + } else if (pDn && cUp && oDn) { + label = 'ACCUMULATION'; cls = 'info'; + detail = 'Price falling but net buyers absorb — dip buying while longs reduce exposure'; + } else if (pDn && cUp) { + label = 'BULL DIVERGENCE';cls = 'info'; + detail = 'Price down but buyers dominating CVD — hidden accumulation, watch for reversal'; + } else { + label = 'NEUTRAL'; cls = 'neut'; + detail = 'Price sideways or CVD/OI signals mixed — no clear directional edge'; + } + + const oStr = oNa ? 'OI N/A' : `OI ${oiChgPct >= 0 ? '+' : ''}${oiChgPct.toFixed(1)}%`; + return { label, cls, + sub: `Price ${priceChg >= 0 ? '+' : ''}${priceChg.toFixed(2)}% · CVD ${cUp ? '▲ buying' : '▼ selling'} · ${oStr}`, + detail }; +} + +// ── CVD+OI multi-coin overview table ───────────────────────────────────────── +function renderCVDOITable(rows) { + if (!rows.length) return '
    No data
    '; + return ` +
    +
    + Coin4c ChgCVDOISignal +
    + ${rows.map(r => { + const oiAbs = r.currentOI > 0 ? fmtB(r.currentOI) : '—'; + const oiChgStr = r.oiChgPct != null + ? `${r.oiChgPct >= 0 ? '+' : ''}${r.oiChgPct.toFixed(1)}%` + : `tracking`; + return `
    + ${r.coin}${r.hasPosition ? '' : ''} + ${r.priceChg >= 0 ? '+' : ''}${r.priceChg.toFixed(2)}% + ${r.cvdUp ? '▲ BUY' : '▼ SELL'} + ${oiAbs}${oiChgStr} + ${r.sig.label} +
    `; + }).join('')} +
    +
    ◆ = open position · CVD = 4-candle volume delta · OI vs ~1h ago
    `; +} + +// ── Binance historical OI (free, no API key) ────────────────────────────────── +const _binanceOICache = {}; +async function fetchBinanceOI(coin, tf = '1h', limit = 60) { + const sym = coin.toUpperCase().replace(/^1000/, '') + 'USDT'; + const period = tf === '4h' ? '4h' : tf === '1d' ? '1d' : '1h'; + const key = `${sym}_${period}`; + const hit = _cacheGet(_binanceOICache, key, 300000); + if (hit !== undefined) return hit; + try { + const r = await fetch(`https://fapi.binance.com/futures/data/openInterestHist?symbol=${sym}&period=${period}&limit=${limit}`); + if (!r.ok) return null; + const d = await r.json(); + if (!Array.isArray(d) || !d.length) return null; + return _cacheSet(_binanceOICache, key, d.map(e => ({ ts: e.timestamp, oi: parseFloat(e.sumOpenInterestValue) }))); + } catch { return null; } +} + +// ── SVG sparkline helpers (no Chart.js — zero init cost) ───────────────────── +function _svgSparkline(data, stroke, fill, w = 200, h = 36) { + if (!data?.length) return ``; + const mn = Math.min(...data), mx = Math.max(...data); + const range = mx - mn || 1; + const xs = data.map((_, i) => (i / Math.max(data.length - 1, 1)) * w); + const ys = data.map(v => h - ((v - mn) / range) * (h - 4) - 2); + const pts = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(' '); + const area = `${xs[0].toFixed(1)},${h} ${pts} ${xs.at(-1).toFixed(1)},${h}`; + return ` + + + `; +} + +function _svgSparklineWithZero(data, stroke, fill, w = 200, h = 36) { + if (!data?.length) return ``; + const mn = Math.min(0, ...data), mx = Math.max(0, ...data); + const range = mx - mn || 1; + const xs = data.map((_, i) => (i / Math.max(data.length - 1, 1)) * w); + const ys = data.map(v => h - ((v - mn) / range) * (h - 4) - 2); + const zeroY = (h - ((0 - mn) / range) * (h - 4) - 2).toFixed(1); + const pts = xs.map((x, i) => `${x.toFixed(1)},${ys[i].toFixed(1)}`).join(' '); + const area = `${xs[0].toFixed(1)},${zeroY} ${pts} ${xs.at(-1).toFixed(1)},${zeroY}`; + return ` + + + + `; +} + +// ── Combined CVD+OI sparkline cards (Price / CVD / OI per coin) ─────────────── +function renderCVDOICharts(rows) { + return `
    ${rows.map(r => { + const oiAbs = r.currentOI > 0 ? fmtB(r.currentOI) : '—'; + const oiChgHtml = r.oiChgPct != null + ? `${r.oiChgPct >= 0 ? '+' : ''}${r.oiChgPct.toFixed(1)}%` + : `tracking…`; + const priceStroke = 'rgba(148,163,184,0.9)', priceFill = 'rgba(148,163,184,0.15)'; + const cvdStroke = r.cvdUp ? 'rgba(74,222,128,0.9)' : 'rgba(248,113,113,0.9)'; + const cvdFill = r.cvdUp ? 'rgba(74,222,128,0.15)' : 'rgba(248,113,113,0.15)'; + const oiVals = (r.oiHistory || []).map(e => e.oi); + const oiUp = oiVals.length > 1 && oiVals.at(-1) > oiVals[0]; + const oiStroke = oiUp ? 'rgba(251,191,36,0.9)' : 'rgba(156,163,175,0.8)'; + const oiFill = oiUp ? 'rgba(251,191,36,0.1)' : 'rgba(156,163,175,0.08)'; + return `
    +
    +
    + ${r.coin}${r.hasPosition ? '' : ''} + ${r.priceChg >= 0 ? '+' : ''}${r.priceChg.toFixed(2)}% +
    +
    + ${r.sig.label} + +
    +
    + +
    `; + }).join('')}
    `; +} + +function toggleCVDCard(coin) { + const body = document.getElementById(`cvd-body-${coin}`); + const icon = document.getElementById(`cvd-tog-${coin}`); + if (!body) return; + const open = body.style.display !== 'none'; + body.style.display = open ? 'none' : ''; + if (icon) icon.textContent = open ? '▶' : '▼'; +} + +// ── Smart Money Flow (Binance L/S + taker, CoinGecko dominance) ────────────── + +const _lsCache = {}; +const _LS_TTL = 5 * 60 * 1000; + +async function _lsFetch(endpoint, sym, period = '1h', limit = 2) { + const key = `${endpoint}_${sym}_${period}`; + const hit = _cacheGet(_lsCache, key, _LS_TTL); + if (hit !== undefined) return hit; + try { + const res = await fetch(`https://fapi.binance.com/futures/data/${endpoint}?symbol=${sym}&period=${period}&limit=${limit}`); + if (!res.ok) return null; + return _cacheSet(_lsCache, key, await res.json()); + } catch { return null; } +} + +const _BINANCE_PERP_COINS = new Set(['BTC','ETH','SOL','BNB','XRP','DOGE','AVAX','ADA','SUI', + 'ARB','OP','INJ','TIA','ATOM','LTC','LINK','MATIC','FTM','APT','SEI','WIF','PEPE','BONK','FET']); + +async function fetchMoneyFlow(coin) { + if (!_BINANCE_PERP_COINS.has(coin)) return null; + const sym = coin + 'USDT'; + const [glsR, topR, tkrR] = await Promise.allSettled([ + _lsFetch('globalLongShortAccountRatio', sym, '1h', 2), + _lsFetch('topLongShortAccountRatio', sym, '1h', 2), + _lsFetch('takerlongshortRatio', sym, '1h', 2), + ]); + const gls = glsR.value?.at(-1); + const tls = topR.value?.at(-1); + const tkr = tkrR.value?.at(-1); + if (!gls && !tls && !tkr) return null; + return { + coin, + lsRatio: gls ? parseFloat(gls.longShortRatio) : null, + topRatio: tls ? parseFloat(tls.longShortRatio) : null, + takerRatio: tkr ? parseFloat(tkr.buySellRatio) : null, + }; +} + +async function fetchBTCDom() { + const key = 'cg_global'; + const hit = _cacheGet(_lsCache, key, _LS_TTL); + if (hit !== undefined) return hit; + try { + const res = await fetch('https://api.coingecko.com/api/v3/global'); + if (!res.ok) return null; + const { data: d } = await res.json(); + const mp = d.market_cap_percentage || {}; + return _cacheSet(_lsCache, key, { + btcDom: mp.btc || 0, + ethDom: mp.eth || 0, + stablePct: (mp.usdt || 0) + (mp.usdc || 0), + mcap24h: d.market_cap_change_percentage_24h_usd || 0, + }); + } catch { return null; } +} + +function _signalLS({ lsRatio, topRatio, takerRatio }) { + const topBull = topRatio != null && topRatio > 1.1; + const topBear = topRatio != null && topRatio < 0.9; + const crowdedLong = lsRatio != null && lsRatio > 1.55; + const crowdedShort = lsRatio != null && lsRatio < 0.7; + const takerBuy = takerRatio != null && takerRatio > 1.08; + const takerSell = takerRatio != null && takerRatio < 0.92; + + if (topBull && takerBuy && !crowdedLong) return { label:'SMART ACCUM', cls:'bull', detail:'Top traders long + buy aggression — high quality setup' }; + if (topBear && takerSell && !crowdedShort) return { label:'SMART DIST', cls:'bear', detail:'Top traders short + sell aggression — distribution signal' }; + if (topBull && crowdedShort) return { label:'SQUEEZE ↑', cls:'bull', detail:'Retail heavily short, smart money long — short squeeze risk' }; + if (topBear && crowdedLong) return { label:'SQUEEZE ↓', cls:'bear', detail:'Retail overleveraged long, smart money short — long squeeze risk' }; + if (topBear && takerBuy) return { label:'FAKE PUMP', cls:'warn', detail:'Retail buying aggressor, top traders positioned short — potential trap' }; + if (topBull && takerSell) return { label:'DEGEN SELL', cls:'warn', detail:'Smart money holding long but sell pressure mounting' }; + if (crowdedLong && !topBull) return { label:'CROWDED LONG',cls:'warn', detail:'Retail overleveraged long without smart money confirmation' }; + if (crowdedShort && !topBear) return { label:'CROWDED SHORT',cls:'info', detail:'Retail heavily short — watch for squeeze if catalyst appears' }; + if (topBull) return { label:'TOP BULL', cls:'bull', detail:'Smart money (top 20%) positioned net long' }; + if (topBear) return { label:'TOP BEAR', cls:'bear', detail:'Smart money (top 20%) positioned net short' }; + if (takerBuy) return { label:'BUY PRESS', cls:'bull', detail:'Aggressive buyers dominating taker volume' }; + if (takerSell)return { label:'SELL PRESS',cls:'bear', detail:'Aggressive sellers dominating taker volume' }; + return { label:'NEUTRAL', cls:'neut', detail:'No strong directional bias from L/S or taker data' }; +} + +function renderMoneyFlowCard() { + return `
    +
    +
    💸 Smart Money Flow
    + loading… +
    +
    +
    ${spinnerHtml()} fetching L/S & taker data…
    +
    + L/S All = all retail accounts · L/S Top = top 20% by volume (smart money) · Taker = buy/sell aggressor ratio · source: Binance Futures +
    +
    `; +} + +async function loadMoneyFlowSignals(coins) { + const riskEl = document.getElementById('mf-risk'); + const domBarEl = document.getElementById('mf-dom-bar'); + const tblEl = document.getElementById('mf-table'); + if (!tblEl) return; + + // BTC dominance + global market data + fetchBTCDom().then(dom => { + if (!dom) return; + const risk = dom.btcDom > 54 ? 'RISK OFF' : dom.btcDom < 47 ? 'RISK ON' : 'NEUTRAL'; + const rCls = risk === 'RISK ON' ? 'pos' : risk === 'RISK OFF' ? 'neg' : 'muted'; + if (riskEl) riskEl.innerHTML = `BTC.D ${dom.btcDom.toFixed(1)}% · Stable ${dom.stablePct.toFixed(1)}% · MCap ${dom.mcap24h>=0?'+':''}${dom.mcap24h.toFixed(1)}% · ${risk}`; + if (domBarEl) domBarEl.innerHTML = ` +
    +
    +
    +
    +
    +
    +
    + ■ BTC ${dom.btcDom.toFixed(1)}% + ■ ETH ${dom.ethDom.toFixed(1)}% + ■ Stables ${dom.stablePct.toFixed(1)}% + ■ Others +
    `; + }); + + // Per-coin L/S + taker from Binance + const tracked = coins.filter(c => _BINANCE_PERP_COINS.has(c)); + if (!tracked.length) { + if (tblEl) tblEl.innerHTML = '
    No tracked coins available on Binance Futures (positions are Hyperliquid-only)
    '; + return; + } + + const flows = await Promise.all(tracked.map(fetchMoneyFlow)); + if (!tblEl) return; + + const fmtR = (v, lowGood = false) => { + if (v == null) return ''; + const hi = lowGood ? v < 0.9 : v > 1.1; + const lo = lowGood ? v > 1.1 : v < 0.9; + const cls = hi ? 'pos' : lo ? 'neg' : 'muted'; + return `${v.toFixed(2)}`; + }; + + const hasAny = flows.some(Boolean); + if (!hasAny) { + tblEl.innerHTML = '
    Binance API unavailable — try again shortly
    '; + return; + } + + tblEl.innerHTML = ` +
    + CoinL/S AllL/S TopTakerSignal +
    + ${flows.map(f => { + if (!f) return ''; + const sig = _signalLS(f); + return `
    + ${f.coin} + ${fmtR(f.lsRatio)} + ${fmtR(f.topRatio)} + ${fmtR(f.takerRatio)} + ${sig.label} +
    `; + }).join('')}`; +} + +// ── HYPE Intelligence ───────────────────────────────────────────────────────── + +const HYPE_SUPPLY = 1_000_000_000; + +function _fundingSignal(f8h) { + if (f8h > 0.0005) return { label:'OVERLEVERAGED LONG', cls:'warn', detail:'Longs paying a lot → flush risk on pullbacks' }; + if (f8h > 0.0001) return { label:'BULLISH POSITIONING', cls:'bull', detail:'Longs paying → market expects higher' }; + if (f8h < -0.0005) return { label:'EXTREME SHORT BIAS', cls:'info', detail:'Shorts paying heavily → squeeze candidate' }; + if (f8h < -0.0001) return { label:'BEARISH POSITIONING', cls:'bear', detail:'Shorts paying → market expects lower' }; + return { label:'NEUTRAL FUNDING', cls:'neut', detail:'Balanced positioning, no extreme leverage bias' }; +} + +function _revenueSignal(rev) { + if (rev > 5e6) return { label:'HIGH REVENUE', cls:'bull', detail:'Strong fee income → active buyback pressure' }; + if (rev > 1.5e6) return { label:'NORMAL REVENUE', cls:'neut', detail:'Healthy platform activity' }; + return { label:'LOW REVENUE', cls:'warn', detail:'Reduced trading activity → lower buyback pressure' }; +} + +function _stakeSignal(pct) { + if (pct > 40) return { label:'HIGH LOCK', cls:'bull', detail:`${pct.toFixed(1)}% staked → tight float, reduced sell pressure` }; + if (pct > 20) return { label:'MODERATE', cls:'neut', detail:`${pct.toFixed(1)}% staked — normal distribution` }; + return { label:'LOW STAKE', cls:'warn', detail:`Only ${pct.toFixed(1)}% staked → more circulating supply` }; +} + +function renderHYPECard() { + return `
    +
    +
    ⚡ HYPE Intelligence
    + Platform health · HL-native signals · staking float +
    +
    ${spinnerHtml()} loading platform & staking data…
    +
    `; +} + +async function loadHYPEIntel(phaseMeta) { + const el = document.getElementById('hype-intel-body'); + if (!el) return; + try { + // ── Platform stats from phaseMeta (already fetched in loadPhases) ─────────── + let platformVol = 0, platformOI = 0, hype = null; + if (phaseMeta) { + const [meta, ctxs] = phaseMeta; + (meta?.universe || []).forEach((asset, i) => { + const ctx = ctxs[i] || {}; + const mark = parseFloat(ctx.markPx || ctx.midPx || 0); + const vol = parseFloat(ctx.dayNtlVlm || 0); + const oi = parseFloat(ctx.openInterest || 0) * mark; + platformVol += vol; + platformOI += oi; + if (asset.name === 'HYPE') { + hype = { + price: mark, + prevPx: parseFloat(ctx.prevDayPx || mark), + oi, + vol, + funding: parseFloat(ctx.funding || 0), + }; + } + }); + } + + if (!hype) { + el.innerHTML = '
    HYPE not found in market data — try refreshing
    '; + return; + } + + const priceChg24h = hype.prevPx > 0 ? (hype.price - hype.prevPx) / hype.prevPx * 100 : 0; + const estRevenue = platformVol * 0.0003; // ~0.03% avg fee (maker+taker blended) + const hypeVolShare = platformVol > 0 ? hype.vol / platformVol * 100 : 0; + const revSig = _revenueSignal(estRevenue); + const fundSig = _fundingSignal(hype.funding); + + // ── HYPE OI vs price divergence (using our localStorage history) ───────── + const prevOI = getPrevOI('HYPE'); + if (hype.oi > 0) saveOIPoint('HYPE', hype.oi); + const oiChgPct = prevOI > 0 && hype.oi > 0 ? (hype.oi - prevOI) / prevOI * 100 : null; + const oiSig = sigCVDOI(priceChg24h, 0, oiChgPct); + + // ── Staking / validator data ───────────────────────────────────────────── + let stakingHtml = ''; + try { + const validators = await (typeof hlPost === 'function' + ? hlPost({ type: 'validatorSummaries' }) + : null); + if (Array.isArray(validators) && validators.length) { + // Try several possible field names for stake amount + const stakeFields = ['stake', 'stkd', 'totalStake', 'stakedHype', 'delegatedStake']; + let raw = 0, usedField = null; + for (const f of stakeFields) { + const s = validators.reduce((a, v) => a + parseFloat(v[f] || 0), 0); + if (s > 1e6 && s < 2e12) { raw = s; usedField = f; break; } + } + // If values look like they're in raw units (>1B for total), divide by 1e8 + let staked = raw > HYPE_SUPPLY * 2 ? raw / 1e8 : raw; + if (staked > 0 && staked <= HYPE_SUPPLY) { + const stakePct = staked / HYPE_SUPPLY * 100; + const stakeSig = _stakeSignal(stakePct); + stakingHtml = ` +
    +
    Staking / Float
    +
    +
    +
    Total Staked
    +
    ${fmtB(staked)}
    +
    ${stakePct.toFixed(1)}% of 1B supply · ${validators.length} validators
    +
    +
    + ${stakeSig.label} +
    ${stakeSig.detail}
    +
    +
    +
    +
    `; + } + } + } catch(_) {} + if (!stakingHtml) { + stakingHtml = ` +
    +
    Staking / Float
    +
    Validator data unavailable from this context — check HL staking page
    +
    `; + } + + // ── Render ─────────────────────────────────────────────────────────────── + el.innerHTML = ` +
    +
    +
    HL 24h Volume
    +
    ${fmtB(platformVol)}
    +
    ${revSig.label}
    +
    +
    +
    Est. Daily Revenue
    +
    ${fmtB(estRevenue)}
    +
    ~0.03% avg fee
    +
    +
    +
    Total Platform OI
    +
    ${fmtB(platformOI)}
    +
    all perp markets
    +
    +
    + +
    +
    +
    HYPE Price
    +
    ${fmtPrice(hype.price)}
    +
    ${priceChg24h>=0?'+':''}${priceChg24h.toFixed(2)}% 24h
    +
    +
    +
    HYPE Perp OI
    +
    ${fmtB(hype.oi)}
    + ${oiChgPct != null + ? `
    ${oiChgPct>=0?'+':''}${oiChgPct.toFixed(1)}% vs prev
    ` + : '
    tracking OI…
    '} +
    +
    +
    Funding /8h
    +
    ${(hype.funding*100).toFixed(4)}%
    +
    ${hype.funding>0?'longs paying':'shorts paying'}
    +
    +
    +
    HYPE Vol Share
    +
    ${hypeVolShare.toFixed(1)}%
    +
    ${fmtB(hype.vol)} / platform
    +
    +
    +
    +
    Price+OI Signal
    + ${oiSig.label} +
    +
    +
    Funding Signal
    + ${fundSig.label} +
    +
    +
    + ${stakingHtml}`; + } catch(e) { + if (el) el.innerHTML = `
    Error loading HYPE data: ${e.message}
    `; + } +} +// ── Full TA build (async) ───────────────────────────────────────────────────── +async function buildFullTA(coin, tf, candles, rawMarketCtx) { + const opens = candles.map(c=>parseFloat(c.o)); + const closes = candles.map(c=>parseFloat(c.c)); + const highs = candles.map(c=>parseFloat(c.h)); + const lows = candles.map(c=>parseFloat(c.l)); + const vols = candles.map(c=>parseFloat(c.v)); + const price = closes.at(-1); + + const ema20 = iEMA(closes, 20); + const ema50 = iEMA(closes, 50); + const ema200 = closes.length >= 200 ? iEMA(closes, 200) : null; + const { hist, macd } = iMACD(closes); + const rsiArr = iRSI(closes); + const rsiVal = rsiArr.filter(v=>v!==null).at(-1); + const { k: stochK, d: stochD } = iStoch(highs, lows, closes); + const kVal = stochK.filter(v=>v!==null).at(-1); + const dVal = stochD.filter(v=>v!==null).at(-1); + const bbArr = iBB(closes); + const bb = bbArr.filter(v=>v!==null).at(-1); + const atrArr = iATR(highs, lows, closes); + const atr = atrArr.filter(v=>v!==null).at(-1); + const mf = iMoneyFlow(opens, closes, vols); + + const fr = rawMarketCtx ? parseFloat(rawMarketCtx.funding || 0) : 0; + const oi = rawMarketCtx ? parseFloat(rawMarketCtx.openInterest || 0) * price : 0; + const oiPrev = taOIPrev?.[coin + tf] ?? null; + if (typeof taOIPrev !== 'undefined') taOIPrev[coin + tf] = oi; + + // CVD + OI + const cvdArr = calcCVD(opens, closes, highs, lows, vols); + const lb = 4; + const recentCVD = cvdArr.at(-1) - (cvdArr.length > lb ? cvdArr[cvdArr.length - 1 - lb] : 0); + const priceChg4 = closes.length > lb ? (closes.at(-1) - closes[closes.length - 1 - lb]) / closes[closes.length - 1 - lb] * 100 : 0; + const prevOI = getPrevOI(coin); + if (oi > 0) saveOIPoint(coin, oi); + const oiChgPct = (prevOI && prevOI > 0 && oi > 0) ? (oi - prevOI) / prevOI * 100 : null; + + const sigs = { + ema: addDetail(sigEMA(price, ema20.at(-1), ema50.at(-1), ema200?ema200.at(-1):null), 'ema'), + macd: addDetail(sigMACD(hist, macd), 'macd'), + rsi: rsiVal!=null ? addDetail(sigRSI(rsiVal), 'rsi') : null, + stoch: kVal!=null&&dVal!=null ? addDetail(sigStoch(kVal,dVal), 'stoch') : null, + bb: bb ? addDetail(sigBB(bb), 'bb') : null, + atr: atr ? addDetail(sigATR(atr, price), 'atr') : null, + funding: addDetail(sigFunding(fr), 'funding'), + oi: addDetail(sigOI(oi, oiPrev), 'oi'), + mf: addDetail(sigFlow(mf.buyPct), 'mf'), + }; + + // External data (parallel, no-fail) + const [lsr, cg] = await Promise.allSettled([ + fetchBinanceLSR(coin), + fetchCGData(coin), + ]).then(rs => rs.map(r => r.status==='fulfilled' ? r.value : null)); + + sigs.lsr = sigLSR(lsr); + sigs.cg = sigCG(cg); + sigs.nansen = sigNansenFlow(coin); + sigs.fg = sigFGGlobal(); + sigs.cvdoi = sigCVDOI(priceChg4, recentCVD, oiChgPct); + + const sr = findSR(highs, lows, closes); + const dir = scoreDirection(sigs); + const setup = calcTradeSetup(dir.direction, price, sr, atr); + + return { sigs, dir, setup, price, coin, tf, atr, sr }; +} + +// ── Rendering ───────────────────────────────────────────────────────────────── +function _dirCls(d) { return d === 'LONG' ? 'pos' : d === 'SHORT' ? 'neg' : 'muted'; } +function _pct(a, b) { return b > 0 ? ((a - b) / b * 100) : 0; } + +function _checkRow(icon, name, sig) { + if (!sig) return ''; + const clsMap = { bull:'ta-ck-bull', bear:'ta-ck-bear', warn:'ta-ck-warn', info:'ta-ck-info', neut:'ta-ck-neut' }; + const iconMap = { bull:'✅', bear:'🔴', warn:'⚠️', info:'🟦', neut:'⬜' }; + return `
    + ${iconMap[sig.cls]||'⬜'} +
    +
    + ${name} + ${sig.label} +
    +
    ${sig.sub}${sig.detail ? ` — ${sig.detail}` : ''}
    +
    +
    `; +} + +function renderTARec(ta) { + const { sigs: s, dir, setup, price, coin, tf, sr } = ta; + const { direction, bull, bear, bullPct } = dir; + const { entry, tp1, tp2, sl, rr } = setup; + const dirCls = _dirCls(direction); + const total = bull + bear; + const barW = Math.round(bullPct * 100); + + const fmtSetup = (v, ref) => { + if (!v || !ref) return fmtPrice(v); + const p = _pct(v, ref); + return `${fmtPrice(v)} ${p>=0?'+':''}${p.toFixed(1)}%`; + }; + + const entryNote = direction === 'LONG' + ? (entry < price * 0.999 ? `Ideal entry on pullback` : `Current price is entry zone`) + : direction === 'SHORT' + ? (entry > price * 1.001 ? `Ideal entry on bounce` : `Current price is entry zone`) + : 'Neutral — wait for clearer direction'; + + const srHtml = (sr.support.length || sr.resistance.length) ? ` +
    + Support + ${sr.support.length ? sr.support.map(v=>fmtPrice(v)).join(' · ') : '—'} +
    +
    + Resistance + ${sr.resistance.length ? sr.resistance.map(v=>fmtPrice(v)).join(' · ') : '—'} +
    ` : ''; + + return ` +
    +
    +
    +
    ${direction}
    +
    ${coin} · ${tf} · ${fmtPrice(price)} · ${new Date().toLocaleTimeString()}
    +
    +
    +
    ${bull.toFixed(0)}/${total.toFixed(0)} signals bullish
    +
    +
    ${bull.toFixed(0)} bull ${bear.toFixed(0)} bear
    +
    +
    + +
    +
    +
    💵 Entry
    +
    ${fmtSetup(entry, price)}
    +
    ${entryNote}
    +
    +
    +
    🎯 TP1
    +
    ${fmtSetup(tp1, entry)}
    +
    First target
    +
    +
    +
    🎯 TP2
    +
    ${fmtSetup(tp2, entry)}
    +
    Extended target
    +
    +
    +
    🛡 Stop Loss
    +
    ${fmtSetup(sl, entry)}
    +
    R/R ${rr > 0 ? rr.toFixed(1) + ':1' : '—'}
    +
    +
    + + ${srHtml ? `
    ${srHtml}
    ` : ''} + +
    +
    Signal Checklist
    + ${_checkRow('📏','EMA Bias', s.ema)} + ${_checkRow('〰️','MACD', s.macd)} + ${_checkRow('⚡','RSI (14)', s.rsi)} + ${_checkRow('🔁','Stochastic', s.stoch)} + ${_checkRow('🎯','Bollinger Bands', s.bb)} + ${_checkRow('📐','Volatility (ATR)', s.atr)} + ${_checkRow('💰','Funding Rate', s.funding)} + ${_checkRow('📊','Open Interest', s.oi)} + ${_checkRow('🌊','Buy/Sell Flow', s.mf)} + ${_checkRow('⚖️','L/S Ratio (Binance)',s.lsr)} + ${_checkRow('📈','24h Change (CG)', s.cg)} + ${_checkRow('🏦','Smart Money', s.nansen)} + ${_checkRow('😱','Fear & Greed', s.fg)} + ${_checkRow('🔄','CVD + OI', s.cvdoi)} +
    +
    `; +} + +// ── Modal Signal tab helper ─────────────────────────────────────────────────── +async function loadModalSignal(coin) { + const el = document.getElementById('pm-signal-body'); + if (!el) return; + el.innerHTML = `
    Analysing ${coin}…
    `; + try { + const tf = '1h'; + const days = 15; + const [candles, meta] = await Promise.all([ + getCandles(coin, tf, days), + getMetaAndAssetCtxs(), + ]); + const universe = meta[0]?.universe || []; + const ctxs = meta[1] || []; + const idx = universe.findIndex(a => a.name === coin); + const rawCtx = idx >= 0 ? ctxs[idx] : null; + + const ta = await buildFullTA(coin, tf, candles, rawCtx); + const pos = typeof _pmGetPos === 'function' ? _pmGetPos(coin) : null; + + let conflictBanner = ''; + if (pos) { + const posDir = pos.side === 'long' ? 'LONG' : 'SHORT'; + const taDir = ta.dir.direction; + if (taDir !== 'NEUTRAL' && taDir !== posDir) { + conflictBanner = `
    + ⚠️ TA recommends ${taDir} but your position is ${posDir}. + Consider reviewing your thesis or reducing size. +
    `; + } else if (taDir === posDir) { + conflictBanner = `
    + ✅ TA aligns with your ${posDir} position. +
    `; + } + } + + // Offer to pre-fill stop price + const slBtn = pos ? `` : ''; + + el.innerHTML = conflictBanner + renderTARec(ta) + slBtn; + } catch(e) { + el.innerHTML = `
    Analysis failed: ${e.message}
    `; + } +} diff --git a/vercel.json b/vercel.json new file mode 100644 index 0000000..690f7d4 --- /dev/null +++ b/vercel.json @@ -0,0 +1,10 @@ +{ + "outputDirectory": "frontend", + "functions": { + "api/*.js": { "runtime": "nodejs20.x" } + }, + "rewrites": [ + { "source": "/api/(.*)", "destination": "/api/$1" }, + { "source": "/(.*)", "destination": "/$1" } + ] +} diff --git a/worker.js b/worker.js new file mode 100644 index 0000000..e83826c --- /dev/null +++ b/worker.js @@ -0,0 +1,100 @@ +// Cloudflare Worker — Hyperliquid API proxy with edge caching +// Deploy: https://dash.cloudflare.com → Workers → Create Worker → paste & save +// Then set the worker URL in the dashboard: Settings → Proxy URL + +const HL_API = 'https://api.hyperliquid.xyz/info'; + +// Cache TTL per request type (seconds). 0 = no cache. +const TTL = { + candleSnapshot: 300, // 5 min — candles change slowly + metaAndAssetCtxs: 120, // 2 min — coin metadata + funding rates + spotMeta: 600, // 10 min — spot market metadata + clearinghouseState: 30, // 30 sec — positions + margin + openOrders: 20, // 20 sec — open orders + userFunding: 60, // 1 min — funding history + userFills: 60, // 1 min — fill history + allMids: 5, // 5 sec — live mid prices +}; + +const CORS = { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'POST, OPTIONS', + 'Access-Control-Allow-Headers': 'Content-Type', +}; + +export default { + async fetch(request, env, ctx) { + if (request.method === 'OPTIONS') { + return new Response(null, { headers: CORS }); + } + + if (request.method !== 'POST') { + return new Response('Method Not Allowed', { status: 405, headers: CORS }); + } + + let body; + try { + body = await request.json(); + } catch { + return new Response('Bad Request', { status: 400, headers: CORS }); + } + + const reqType = body?.type || 'unknown'; + const ttl = TTL[reqType] ?? 0; + + // Use a fake GET URL as the cache key (caches.default only stores GET) + const cacheKey = new Request( + `https://hl-cache.invalid/${reqType}/${encodeURIComponent(JSON.stringify(body))}`, + { method: 'GET' } + ); + + if (ttl > 0) { + const cached = await caches.default.match(cacheKey); + if (cached) { + const resp = new Response(cached.body, { + status: cached.status, + headers: { ...Object.fromEntries(cached.headers), ...CORS, 'X-Cache': 'HIT' }, + }); + return resp; + } + } + + let upstream; + try { + upstream = await fetch(HL_API, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } catch (e) { + return new Response(JSON.stringify({ error: 'upstream_error', message: e.message }), { + status: 502, + headers: { 'Content-Type': 'application/json', ...CORS }, + }); + } + + if (!upstream.ok) { + const text = await upstream.text(); + return new Response(text, { + status: upstream.status, + headers: { 'Content-Type': 'application/json', ...CORS }, + }); + } + + const upstreamText = await upstream.text(); + const responseHeaders = { + 'Content-Type': 'application/json', + ...CORS, + 'X-Cache': 'MISS', + 'Cache-Control': ttl > 0 ? `public, max-age=${ttl}` : 'no-store', + }; + + const response = new Response(upstreamText, { status: 200, headers: responseHeaders }); + + if (ttl > 0) { + ctx.waitUntil(caches.default.put(cacheKey, response.clone())); + } + + return response; + }, +};