diff --git a/.gitignore b/.gitignore index 704c830..909cbd1 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,9 @@ __pycache__/ *.pyo .DS_Store backend/wallets.json + +# wrangler files +.wrangler +.dev.vars* +!.dev.vars.example +!.env.example 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..795896f 100644 --- a/backend/config.py +++ b/backend/config.py @@ -10,3 +10,10 @@ 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", "nsn_26ca358673bb886639703ba43524fead") 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/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..fa8738a --- /dev/null +++ b/bot/main.py @@ -0,0 +1,895 @@ +""" +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, poll_commands +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.""" + if state.get("manual_halt"): + logger.info("Trading halted — manual halt active (/resume to clear)") + return True + + 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"]) + + +# ── Telegram command handler +def handle_command(chat_id: str, text: str, state: dict, info) -> None: + """Process an incoming Telegram command and reply to the sender.""" + parts = text.strip().split(None, 1) + cmd = parts[0].lower().split("@")[0] # strip @botname suffix if present + + if cmd in ("/help", "/start"): + reply = ( + "HYPE-BOT commands\n" + "/status — account value, risk stats, halt status\n" + "/positions — open bot-managed positions\n" + "/halt — pause new trade entries\n" + "/resume — re-enable entries after manual halt\n" + "/help — this menu" + ) + + elif cmd == "/status": + try: + account_value = get_account_value(info) + stats = state.get("risk_stats", {}) + halted_until = stats.get("halted_until") + manual_halt = state.get("manual_halt", False) + daily_pnl = stats.get("realized_pnl", 0.0) + consec_losses = stats.get("consecutive_losses", 0) + open_count = len(state.get("bot_positions", {})) + + halt_line = "" + if manual_halt: + halt_line = "\nStatus: HALTED (manual) — use /resume to re-enable" + elif halted_until: + dt = datetime.fromisoformat(halted_until) + if datetime.now(timezone.utc) < dt: + remaining = int((dt - datetime.now(timezone.utc)).total_seconds() / 3600) + halt_line = f"\nStatus: HALTED — auto-resumes in ~{remaining}h" + + reply = ( + f"Bot Status\n" + f"Account: ${account_value:,.2f}\n" + f"Open positions: {open_count}/{MAX_OPEN_BOT_POSITIONS}\n" + f"Daily PnL: {'+'if daily_pnl>=0 else ''}{daily_pnl:.2f} USDC\n" + f"Consec losses: {consec_losses}" + + halt_line + ) + except Exception as e: + reply = f"Status error: {e}" + + elif cmd == "/positions": + positions = state.get("bot_positions", {}) + if not positions: + reply = "No open bot positions." + else: + lines = [] + for coin, pos in positions.items(): + try: + price = get_price(info, coin) + pnl_pct = ((price / pos["entry"]) - 1) * 100 if pos["side"] == "long" \ + else ((pos["entry"] / price) - 1) * 100 + pnl_sign = "+" if pnl_pct >= 0 else "" + lines.append( + f"▲ {coin} {pos['lev']}× LONG\n" + f" entry {pos['entry']:.4f} " + f"now {price:.4f} " + f"PnL {pnl_sign}{pnl_pct:.1f}%\n" + f" SL {pos['sl']:.4f} score {pos.get('score','?')}/10" + ) + except Exception: + lines.append(f"▲ {coin} — price unavailable") + reply = f"Open positions ({len(positions)})\n" + "\n".join(lines) + + elif cmd == "/halt": + state["manual_halt"] = True + save_state(state) + reply = "⛔ New entries halted. Use /resume to re-enable." + + elif cmd == "/resume": + state.pop("manual_halt", None) + save_state(state) + reply = "✅ Entries re-enabled." + + else: + return # ignore unknown commands silently + + send(reply, chat_id=chat_id) + + +# ── 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() + + # ── Telegram command polling (non-blocking, runs every loop tick) + try: + for chat_id, text in poll_commands(): + if text.startswith("/"): + logger.info("Telegram command from %s: %s", chat_id, text.split()[0]) + handle_command(chat_id, text, state, info) + except Exception as e: + logger.warning("Command poll error: %s", e) + + 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..7bfc8a5 --- /dev/null +++ b/bot/telegram_notifier.py @@ -0,0 +1,116 @@ +"""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}" + +# Long-poll update offset — tracks last processed update_id +_update_offset: int = 0 + + +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 poll_commands() -> list[tuple[str, str]]: + """ + Non-blocking poll for new Telegram messages. + Returns list of (chat_id, text) tuples for each new message. + Advances the internal offset so each update is only returned once. + """ + global _update_offset + result = _call("getUpdates", {"offset": _update_offset, "limit": 20, "timeout": 0}) + if not result or not result.get("ok"): + return [] + commands = [] + for upd in result.get("result", []): + _update_offset = upd["update_id"] + 1 + msg = upd.get("message") or upd.get("channel_post") + if not msg: + continue + text = (msg.get("text") or "").strip() + if not text: + continue + chat_id = str(msg["chat"]["id"]) + commands.append((chat_id, text)) + return commands + + +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/docs/apis.md b/docs/apis.md new file mode 100644 index 0000000..1c28c73 --- /dev/null +++ b/docs/apis.md @@ -0,0 +1,156 @@ +# API Integrations + +All external data sources used by the dashboard and bot. + +--- + +## Free APIs (no key required) + +| Source | Used by | Data | +|---|---|---| +| Hyperliquid REST | app.js, intel.js, trend.js, onchain.js, bot | Positions, fills, funding rates, OI, candles, perp meta | +| Hyperliquid WebSocket | app.js (monitor tab) | Real-time price feed, order book | +| Binance Futures | app.js, intel.js, arb.js, trend.js, signals.js, bot | OI, funding, L/S ratio | +| Bybit | arb.js, bot | Funding rates | +| OKX | arb.js | Funding rates | +| CoinGecko (public) | intel.js, fundamentals.js | Markets top 100, global stats, trending, BTC/ETH/SOL price | +| Kraken | intel.js | BTC/ETH spot prices (CoinGecko fallback) | +| DeFiLlama | defillama.js, onchain.js | TVL, stablecoins, chains, protocols | +| alternative.me | news.js, indicators.js, bot | Fear & Greed Index | +| blockchain.com | onchain.js | BTC mempool stats, hash rate, difficulty | +| mempool.space | onchain.js | Mempool transaction count, fee rates | +| CryptoCompare | news.js | Crypto news articles (50 per request) | +| Messari | news.js | Crypto news articles (50 per request) | +| Reddit (`r/CryptoCurrency`) | news.js | Community posts (50 per request) | + +### RSS Feeds (news.js) + +Proxied via CORS relays (allorigins.win / corsproxy.io / rss2json.com): + +| Source | Feed URL | +|---|---| +| CoinDesk | `https://www.coindesk.com/arc/outboundfeeds/rss/` | +| Cointelegraph | `https://cointelegraph.com/rss` | +| Decrypt | `https://decrypt.co/feed` | +| CryptoNews | `https://cryptonews.com/news/feed/` | +| The Block | `https://www.theblock.co/rss/all` | +| Bitcoin Magazine | `https://bitcoinmagazine.com/.rss/full/` | + +--- + +## Keyed APIs (optional, entered in-app) + +### CoinGlass + +- **Header:** `coinglassSecret: ` +- **Base URL:** `https://open-api.coinglass.com/public/v2/` +- **Endpoints used:** + - `liquidation_ex_chart?ex=Binance&pair=BTCUSDT&interval=4h` + - `liquidation_ex_chart?ex=Binance&pair=ETHUSDT&interval=4h` +- **Used by:** onchain.js (browser), bot-worker.js (server-side) +- **Dashboard setting:** On-chain tab → Settings → CoinGlass API Key → saved to `localStorage['hype_coinglass_key']` +- **Bot secret:** `COINGLASS_KEY` (set via `wrangler secret put`) +- **Note:** Browser requests may fail due to CORS. Bot calls work fine server-side. The dashboard shows a notice if CORS blocks the call. + +### CryptoQuant + +- **Header:** `Authorization: Bearer ` +- **Used by:** onchain.js (browser only — not in bot) +- **Data:** Exchange reserve flows (bull/bear signal) +- **Dashboard setting:** On-chain tab → Settings → CryptoQuant API Key → saved to `localStorage['hype_cryptoquant_key']` + +### CoinGecko Demo Key (optional) + +- **Used by:** fundamentals.js, intel.js via `getCGSimplePrices()` +- **Purpose:** Higher rate limits on public endpoints +- **Setting:** Settings tab → CoinGecko API key (if wired — check settings panel) + +--- + +## Supabase (optional backend) + +- **Used by:** ai.js (trade staging, Claude proxy), autojournal.js (sync), bot (snapshots) +- **Auth:** anon key in localStorage (`hype_sb_url`, `hype_sb_anon`) or bot secrets +- **Tables:** `staged_trades`, `hype_snapshots`, `hype_journal` +- **Edge Function:** `claude-proxy` — routes Claude AI chat (keeps API key server-side) + +--- + +## Cloudflare Workers AI + +- **Binding:** `[ai]` in `wrangler-bot.toml` +- **Model:** `@cf/meta/llama-3.1-8b-instruct-fast` +- **Used by:** `/analyze-news` endpoint in bot-worker.js +- **Called by:** news.js → fetches `${hype_bot_url}/analyze-news` +- **Cost:** Free on Cloudflare Workers free tier (~31k tokens/day limit) +- **Input:** Top 10 news articles (title + body) +- **Output:** `{ sentiment: 'BULL'|'BEAR'|'NEUTRAL', coins: [], reasoning, timeframe }` + +--- + +## Cloudflare Worker RSS Proxy + +- **File:** `cloudflare/worker.js` +- **Config:** `cloudflare/wrangler.toml` +- **Purpose:** CORS proxy for RSS feeds that block direct browser fetches +- **Setting:** Settings tab → RSS Proxy URL → set after deploying + +--- + +## CORS Relay Cascade (news.js) + +RSS feeds are fetched via a 3-way race: + +1. `https://api.allorigins.win/get?url=` +2. `https://corsproxy.io/?url=` +3. `https://api.rss2json.com/v1/api.json?rss_url=` + +`Promise.any()` takes whichever resolves first. If all fail, source shows as failed in the status bar. + +--- + +## Hyperliquid API Details + +- **Base URL:** `https://api.hyperliquid.xyz/info` +- **Method:** POST with JSON body +- **Key request types:** + +| `type` field | Returns | +|---|---| +| `metaAndAssetCtxs` | All perp metadata + asset contexts (OI, funding, mark price) | +| `clearinghouseState` | Positions, margin, account value for wallet | +| `userFills` | Trade fills for wallet | +| `candleSnapshot` | OHLCV candles for coin/interval | +| `userFundingHistory` | Funding payments for wallet | +| `historicalOrders` | Order history | + +--- + +## Rate Limits & Caching + +| Source | Cache | Notes | +|---|---|---| +| News articles | 5-min sessionStorage | Per-tab, cleared on page reload | +| AI news analysis | 30-min localStorage | `hype_news_ai_cache` key | +| Heatmap data | 60-second in-memory | `_hmCache` object | +| CoinGecko prices | Shared in-memory | `getCGSimplePrices()` with Kraken fallback | +| Intel tab | 3-min auto-refresh | Interval-based | +| Trend tab | Per-load | No cache, parallel coin fetches | + +--- + +## localStorage Keys + +| Key | Set by | Content | +|---|---|---| +| `hype_wallet` | Portfolio setup | Hyperliquid wallet address | +| `hype_tg_token` | Settings | Telegram bot token (dashboard alerts) | +| `hype_tg_chat` | Settings | Telegram chat ID | +| `hype_tg_threshold` | Settings | PnL alert threshold | +| `hype_coinglass_key` | On-chain settings | CoinGlass API key | +| `hype_cryptoquant_key` | On-chain settings | CryptoQuant API key | +| `hype_sb_url` | AI / Journal settings | Supabase project URL | +| `hype_sb_anon` | AI / Journal settings | Supabase anon key | +| `hype_bot_url` | News AI settings | Cloudflare bot worker URL | +| `hype_news_ai_cache` | news.js | Cached AI analysis results | +| `hype_rss_proxy` | Settings | Cloudflare RSS proxy URL | diff --git a/docs/bot.md b/docs/bot.md new file mode 100644 index 0000000..aacbe42 --- /dev/null +++ b/docs/bot.md @@ -0,0 +1,298 @@ +# Hype Bot — Cloudflare Worker + +Autonomous Telegram alert bot running on Cloudflare Workers. No VPS required. + +**File:** `cloudflare/bot-worker.js` (1089 lines) +**Config:** `cloudflare/wrangler-bot.toml` + +--- + +## Cron Schedule + +| Trigger | What runs | +|---|---| +| `*/15 * * * *` (odd 15-min window) | `checkSignals` + `checkFundingFlips` | +| `*/15 * * * *` (even 15-min window) | `checkFundingArb` | +| `0 */4 * * *` | `checkReversals` + `checkTrendAlignment` + `checkOISpikes` + `checkLiqCascade` | +| `0 0 * * *` | `dailySnapshot` | +| `0 0 * * 0` | `weeklyReview` (Sunday midnight UTC) | + +The 15-min cron alternates between signal/flip and arb every 15 minutes using `new Date().getMinutes() % 30`. + +--- + +## Alert Functions + +### `checkSignals` +- Coins: `SIGNAL_COINS` env var (default: BTC, ETH, SOL, HYPE, SUI) +- Fetches 1h candles (3 days), funding rates, Fear & Greed +- Score 0–10 based on: EMA20/50 alignment, MACD cross, RSI zone, funding gate, F&G sentiment gate +- **Gates:** skips coin if 8h funding > `MAX_FUNDING` (0.0020 default) or F&G > `FG_GREED_GATE` (80) +- Fires alert if score ≥ 7 (ENTRY) or ≥ 6 with strong confirmation (WATCH) +- **Dedup:** 4h KV cooldown per coin (`signal:{coin}` key) + +### `checkFundingFlips` +- Checks current funding sign vs last stored sign in KV (`fund:{coin}:sign`) +- Fires alert on positive→negative or negative→positive flip +- **Dedup:** 4h cooldown (`fund:{coin}:sign` stored with 86400s TTL) + +### `checkFundingArb` +- Fetches HL + Binance + Bybit funding for all coins +- Fires if spread between any two exchanges > `ARB_THRESHOLD` (0.001 = 0.1%) +- **Dedup:** 4h cooldown per coin + +### `checkReversals` (4h) +- Detects candle patterns on 4h candles: Hammer, Engulfing, Doji, Pin Bar, etc. +- Includes volume multiplier (`volMult = cV / avgVol`) per pattern +- **Dedup:** 12h cooldown + +### `checkTrendAlignment` (4h) +- Analyzes 1h/4h/1d trend bias per coin via `analyzeTrend()` +- Each timeframe: Supertrend, ADX, market structure, RSI divergence +- Fires when all 3 TFs align (full BULL or full BEAR) +- Also fires on RSI divergence detection (4h) +- Stores current alignment in KV (`{coin}:bias`) to detect changes + +### `checkOISpikes` (4h) +- Fetches current HL open interest per coin +- Compares vs previous stored value (`oi:{coin}` in KV) +- Fires SPIKE alert if OI grew > `OI_SPIKE_PCT` (8%), FLUSH if dropped > 8% +- **Dedup:** 12h cooldown + +### `checkLiqCascade` (4h) +- Fetches CoinGlass 4h liquidation data for BTC and ETH (requires `COINGLASS_KEY`) +- Sums total USD liquidated across both +- Fires if total ≥ `LIQ_CASCADE_USD` ($150M default) +- **Dedup:** 12h cooldown + +### `dailySnapshot` +- Requires `WALLET` secret +- Fetches portfolio positions + account value +- Upserts to Supabase `hype_snapshots` table (if Supabase configured) +- Sends Telegram summary: NAV, position count, position list + +### `weeklyReview` +- Requires `WALLET` secret +- Fetches 7-day fill history +- Sends Telegram review: trade count, total PnL, win rate, best/worst trade + +--- + +## Telegram Commands + +| Command | Action | +|---|---| +| `/start` or `/help` | Show command menu with auto-alert schedule | +| `/signals` | Run signal scan now, returns count of alerts fired | +| `/arb` | Run arb scan now | +| `/snapshot` | Run daily snapshot now | +| `/positions` | Show open positions with entry, PnL, liq price | +| `/trend [coin]` | 1h/4h/1d bias · ADX · Supertrend · market structure · RSI | +| `/price [coin]` | Price · RSI · funding rate · OI · 4h candle patterns | +| `/status` | Market pulse: BTC/ETH funding, OI, F&G, liq data | + +--- + +## HTTP Endpoints + +All endpoints return immediately; heavy work runs via `ctx.waitUntil()`. + +| Endpoint | Method | Action | +|---|---|---| +| `/webhook` | POST | Telegram webhook receiver | +| `/register-webhook` | GET | Auto-registers bot URL with Telegram | +| `/run-signals` | GET | Trigger signal scan (202 response) | +| `/run-arb` | GET | Trigger arb scan (202 response) | +| `/run-snapshot` | GET | Trigger daily snapshot (202 response) | +| `/run-reversals` | GET | Trigger reversal scan (202 response) | +| `/run-trend` | GET | Trigger trend alignment check (202 response) | +| `/run-weekly` | GET | Trigger weekly review (202 response) | +| `/health` | GET | Bot health: timestamp + BTC price | +| `/analyze-news` | POST | AI analysis of news articles (Workers AI) | +| `/` | GET | Endpoint listing | + +--- + +## AI News Analysis (`/analyze-news`) + +- **Model:** `@cf/meta/llama-3.1-8b-instruct-fast` via Cloudflare Workers AI (`[ai]` binding) +- **Input:** JSON array of articles `{ id, title, body }` +- **Output:** `{ analyses: [{ id, sentiment, coins, reasoning, timeframe }] }` +- Sentiments: `BULL`, `BEAR`, `NEUTRAL` +- Processes up to 10 articles per request +- Free on Cloudflare Workers free tier (~31k tokens/day) +- CORS headers enabled for browser calls from the dashboard + +--- + +## Secrets (set via `wrangler secret put`) + +| Secret | Required | Description | +|---|---|---| +| `WALLET` | Yes (snapshot/positions) | Hyperliquid wallet address | +| `TG_TOKEN` | Yes | Telegram bot token from BotFather | +| `TG_CHAT` | Yes | Telegram chat ID for alerts | +| `SUPABASE_URL` | Optional | Supabase project URL | +| `SUPABASE_KEY` | Optional | Supabase anon key | +| `COINGLASS_KEY` | Optional | CoinGlass API key (liquidation data) | + +--- + +## Environment Variables (`wrangler-bot.toml [vars]`) + +| Variable | Default | Description | +|---|---|---| +| `SIGNAL_COINS` | `BTC,ETH,SOL,HYPE,SUI` | Coins to scan for TA signals | +| `ARB_THRESHOLD` | `0.001` | Min 8h funding spread for arb alert (0.1%) | +| `MAX_FUNDING` | `0.0020` | Max HL 8h funding — coins above this excluded from signal alerts | +| `BTC_GATE_PCT` | `-5` | Min BTC 24h% for bullish macro gate (reserved — not yet wired) | +| `FG_GREED_GATE` | `80` | F&G above this adds caution warning to signal alerts | +| `OI_SPIKE_PCT` | `0.08` | OI % change to trigger spike/flush alert (8%) | +| `LIQ_CASCADE_USD` | `150000000` | Total 24h liq in USD to trigger cascade alert ($150M) | + +--- + +## KV Namespace + +**Binding:** `ALERT_STATE` +**ID:** configured in `wrangler-bot.toml [[kv_namespaces]]` + +Keys stored: + +| Key pattern | TTL | Purpose | +|---|---|---| +| `signal:{coin}` | 4h (14400s) | Signal alert dedup per coin | +| `arb:{coin}` | 4h | Arb alert dedup per coin | +| `reversal:{coin}` | 12h | Reversal/pattern alert dedup | +| `fund:{coin}:sign` | 24h | Last funding sign (pos/neg/flat) for flip detection | +| `oi:{coin}` | 24h | Last OI value for spike detection | +| `{coin}:bias` | 24h | Last TF alignment state (BULL/BEAR/MIXED) | +| `{coin}:div` | 12h | RSI divergence alert dedup | + +--- + +## TA Implementations (in bot-worker.js) + +All computed server-side on Cloudflare edge — no external TA library. + +| Function | Algorithm | +|---|---| +| `iEMA(arr, p)` | Exponential Moving Average | +| `iMACD(arr, f, s, sig)` | MACD (12/26/9 default) | +| `iRSI(arr, p)` | RSI with Wilder smoothing (14 default) | +| `iATR(highs, lows, closes, p)` | Average True Range | +| `iADX(highs, lows, closes, p)` | ADX + +DI / −DI (Wilder) | +| `iSupertrend(highs, lows, closes, period, mult)` | Supertrend (10/3 default) | +| `detectMarketStructure(candles)` | HH/HL (uptrend), LH/LL (downtrend), NEUTRAL | +| `detectRSIDivergence(closes, p)` | Bullish / bearish divergence | +| `detectCandlePatterns(candles)` | Hammer, Engulfing, Doji, Pin Bar + volMult | +| `avgVolume(candles, period)` | 20-period average volume | + +--- + +## Deployment + +```bash +cd cloudflare + +# Create KV namespace (one-time) +npx wrangler kv:namespace create ALERT_STATE +# Paste returned ID into wrangler-bot.toml [[kv_namespaces]] id = "..." + +# Set secrets (one-time) +npx wrangler secret put WALLET --config wrangler-bot.toml +npx wrangler secret put TG_TOKEN --config wrangler-bot.toml +npx wrangler secret put TG_CHAT --config wrangler-bot.toml +npx wrangler secret put SUPABASE_URL --config wrangler-bot.toml +npx wrangler secret put SUPABASE_KEY --config wrangler-bot.toml +npx wrangler secret put COINGLASS_KEY --config wrangler-bot.toml + +# Deploy +npx wrangler deploy --config wrangler-bot.toml + +# Register webhook (one-time, open in browser after deploy) +https://hype-bot..workers.dev/register-webhook +``` + +--- + +## Supabase Tables + +```sql +-- Snapshot table (daily portfolio snapshots) +CREATE TABLE hype_snapshots ( + id TEXT PRIMARY KEY, + wallet TEXT, + ts BIGINT, + account_value NUMERIC, + position_count INTEGER, + positions_json TEXT, + created_at TIMESTAMPTZ DEFAULT NOW() +); + +-- Auto-journal table (closed trades from autojournal.js) +CREATE TABLE hype_journal ( + id TEXT PRIMARY KEY, + wallet TEXT, + coin TEXT, + side TEXT, + entry_time BIGINT, + exit_time BIGINT, + pnl NUMERIC, + fees NUMERIC, + net_pnl NUMERIC, + hold_ms BIGINT, + tag TEXT, + notes TEXT, + lesson TEXT, + created_at BIGINT, + source TEXT +); + +-- Staged trades table (AI tab) +CREATE TABLE staged_trades ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + coin TEXT NOT NULL, + direction TEXT NOT NULL, + entry_price NUMERIC, stop_loss NUMERIC, take_profit NUMERIC, + rationale TEXT, tags TEXT, + status TEXT DEFAULT 'staged' +); +ALTER TABLE staged_trades ENABLE ROW LEVEL SECURITY; +CREATE POLICY "anon_all" ON staged_trades FOR ALL TO anon USING (true) WITH CHECK (true); +``` + +--- + +## BotFather Commands (current) + +``` +signals - TA scan now +snapshot - portfolio state +positions - open positions +trend - 1h/4h/1d · ADX · ST · struct +price - price · RSI · funding · patterns +arb - funding spread scan +status - market pulse +help - show command menu +``` + +Update via BotFather → `/setcommands` → select your bot → paste the list. + +--- + +## Message Format + +All Telegram messages use terminal-style compact formatting: + +- Direction prefixes: `▲` (bullish), `▼` (bearish), `◈` (neutral) +- Dividers: `─` repeated (via `_hr(n)` helper) +- HTML parse mode with `` blocks for tabular data +- Key formatting helpers: + - `_px(coin, p)` — price decimals by coin (BTC=0, ≥100=2, else 4) + - `_f8(r)` — funding rate as `±0.0000%` + - `_fmtM(n)` — millions/billions shorthand (1.2M, 3.4B) + - `_adxStr(v)` — ADX label (RANGE/TREND/STRONG) + - `_hr(n)` — horizontal rule of `─` × n diff --git a/docs/features.md b/docs/features.md new file mode 100644 index 0000000..7ce4f13 --- /dev/null +++ b/docs/features.md @@ -0,0 +1,245 @@ +# Feature Inventory — Hype Dashboard + +Complete audit of all dashboard tabs and modules as of 2026-05-31. + +--- + +## Navigation Tabs (24 pages) + +| Page ID | Label | Loader | Source File | +|---|---|---|---| +| `overview` | Portfolio | `loadOverview` | app.js | +| `trades` | Trades | `loadTrades` | app.js | +| `funding` | Funding | `loadFunding` | app.js | +| `flows` | Flows | `loadFlows` | app.js | +| `monitor` | Live | `loadMonitor` | app.js | +| `markets` | Markets | `loadMarkets` | app.js | +| `phases` | Phases | `loadPhases` | app.js | +| `intel` | Intel | `loadIntel` | intel.js | +| `mvrv` | MVRV | (inline) | mvrv-ai.js | +| `ai` | AI | `loadAI` | ai.js | +| `watchlist` | Watchlist | `loadWatchlist` | app.js | +| `journal` | Journal | `loadJournal` | journal.js / autojournal.js | +| `indicators` | Indicators | `loadIndicators` | indicators.js | +| `smartmoney` | Smart Money | `loadNansen` | nansen.js | +| `analytics` | Analytics | `loadAnalytics` | analytics.js | +| `kb` | KB | `loadKB` | kb.js | +| `signals` | Signals | `loadSignals` | signals.js + ta-signal.js | +| `news` | News | `loadNews` | news.js | +| `fundamentals` | Fundamentals | `loadFundamentals` | fundamentals.js | +| `defi` | DeFi | `loadDefi` | defillama.js | +| `arb` | Arb | `loadArb` | arb.js | +| `trend` | Trend | `loadTrend` | trend.js | +| `onchain` | On-chain | `loadOnchain` | onchain.js | +| `heatmap` | Heatmap | `loadHeatmap` | heatmap.js | + +--- + +## Tab Details + +### Portfolio (`overview`) +- Live positions from Hyperliquid REST API +- Spot balances and margin breakdown +- Unrealized PnL per position + total +- Account health score / margin utilization +- Portfolio growth chart +- Recent PnL widget (24h / 7d collapsible) +- Order scenario analysis (margin impact, liq-before-SL warning) + +### Trades +- Full fill history — perps and spot +- Win rate, realized PnL, fee totals +- Per-trade breakdown: coin, side, size, price, PnL, fees + +### Funding +- Funding payments by coin +- Daily bar chart of cumulative funding +- Annualized rate per open position + +### Flows +- Deposit/withdrawal ledger +- Historical IDR (Indonesian Rupiah) exchange rate conversion + +### Live (`monitor`) +- Real-time WebSocket price feed from Hyperliquid +- Order book depth display +- Liquidation tracker + +### Markets +- Top coins by open interest +- Funding rate leaderboard +- Market cap dominance stats +- Market detail modal: click any coin → phase, TA signals, L/S ratio, OI sparkline + +### Phases +- Wyckoff phase detection: Accumulation / Markup / Distribution / Markdown +- Timeframes: 1h, 4h, 1d +- Applied to all SIGNAL_COINS + +### Intel +- Auto-scoring regime engine — 7 weighted signals: + +| Signal | Weight | Bullish condition | +|---|---|---| +| MVRV Z-Score | 3× | Z < 1 | +| Fear & Greed | 2× | < 35 | +| BTC Funding APR | 2× | < 5% | +| Alt Breadth | 1× | > 65% top-100 up | +| Global MCap 24h | 1× | > +3% | +| BTC OI Change 24h | 1× | < −8% | +| BTC Dominance | 1× | < 50% | + +- Score: normalized ±10 → BUY / BULL / WAIT / CAUTION / SELL verdict +- Radar chart: Macro, Cycle, OnChain, Derivs, Funding, Breadth, Sentiment axes +- Auto-refresh every 3 minutes +- "Generate Setups" button — Claude AI trade suggestions (needs Edge Function) + +### MVRV +- Bitcoin MVRV Z-Score cycle indicator +- Historical chart with cycle top/bottom zones +- Beginner guide (expandable explainer) +- AI commentary via Supabase Edge Function (optional) + +### AI +- Trade staging panel: coin, direction, entry/SL/TP, rationale, tags, auto R:R +- Status workflow: `staged → watching → executed / cancelled` +- Synced to Supabase `staged_trades` table +- Claude AI chat via Edge Function (API key stays server-side) +- Staged trades visible in Intel tab context + +### Watchlist +- Monitor any Hyperliquid wallet address +- Position list, unrealized PnL, account value + +### Journal +Two implementations coexist: + +**autojournal.js** — Auto-Journal (primary): +- Detects closed trades from Hyperliquid fills automatically +- Runs on load + every 10 minutes +- Each entry: coin, side, PnL, hold time, open/close dates +- Outcome tagging: Textbook / Disciplined / FOMO / Tilted +- AI lesson per trade (Claude, when configured) +- Weekly AI pattern review (Mondays) +- Daily snapshot at midnight (120-day retention) +- JSON export + GitHub Gist backup +- Supabase sync to `hype_journal` table + +**journal.js** — Manual Trade Journal (Supabase-backed): +- Manual trade entry form: coin, direction, entry/exit price, dates, setup type +- Auto-PnL calculation +- Stats: total PnL, win rate, trade count, avg PnL +- Filter by coin and setup type +- Credentials stored in localStorage: `hype_sb_url`, `hype_sb_anon` + +### Indicators +- Fear & Greed Index (alternative.me) — 7-day chart +- BMSB (Bull Market Support Band) — 20-week SMA vs 21-week EMA +- Pi Cycle Top indicator — 111-day MA vs 2× 350-day MA +- MVRV Z-Score widget +- **Regime Summary card** — synthesizes all 4 indicators: + - RISK-ON / RISK-OFF / RISK-CAUTIOUS / TRANSITIONAL verdict + - Bullet-point explanation per signal + +### Smart Money (`smartmoney`) +- On-chain whale wallet tracking (Nansen-style) +- Monitor large wallet activity on-chain + +### Analytics +- PnL equity curve +- Trade statistics and performance breakdown +- Signal → AI wiring: includes current signal results in Daily Regime Briefing + +### KB (Knowledge Base) +- Personal trading notes and rules +- 911-line knowledge base module + +### Signals +- Multi-factor confluence scanner +- Factors: funding z-score, CVD, OI momentum, trend alignment +- Binance global Long/Short account ratio column +- Crowded-longs warning (ratio > 1.8) +- Score per coin → ranked list + +### News +- Progressive rendering: first articles < 500ms +- 9 data sources (see APIs doc) +- 3-day history cutoff, 5-min sessionStorage cache +- Source filter tabs +- **AI Analysis** (Cloudflare Workers AI): + - Analyzes top 10 articles via `/analyze-news` endpoint on bot worker + - Per-article badges: BULL / BEAR / NEUTRAL + timeframe + coin chips + reasoning + - Settings: enter bot worker URL in ⚙ panel → stored in `hype_bot_url` localStorage + - Cache TTL: 30 minutes + +### Fundamentals +- CoinGecko top 100 coins +- Global bar: total mcap, BTC/ETH dominance, trending coins +- Sortable table: price, 24h/7d/30d%, mcap, volume, vol/mcap ratio, ATH drawdown +- 5-minute auto-refresh + +### DeFi +- DeFiLlama macro dashboard (free, unlimited API) +- Total DeFi TVL with 24h change +- Stablecoin supply breakdown: USDT / USDC / DAI / USDe +- Top 20 protocols by TVL +- Chain dominance bar chart +- Plain-language regime banner: EXPANSION / GROWING / STABLE / COOLING / CONTRACTION + +### Arb +- Funding arb scanner: HL vs Binance vs Bybit vs OKX +- 4-exchange funding rate comparison table +- Highlights spreads above threshold + +### Trend +- Multi-timeframe trend alignment scanner (1h, 4h, 1d) +- Per-coin grid showing: + - Bias (BULL / BEAR / NEUTRAL) + - ADX value + label (RANGE / TREND / STRONG) + - Supertrend direction + - Market structure (HH/HL, LH/LL, NEUTRAL) + - RSI divergence badge + - OI direction from Binance +- Coins: configurable via `SIGNAL_COINS` +- Each coin analyzed in parallel, progressive render + +### On-chain (`onchain`) +- Regime signal score from on-chain data +- **Network section** (blockchain.com / mempool.space): + - Mempool transaction count + - Fee rates (fastest / 30-min / 1-hour) + - BTC hash rate, difficulty +- **CoinGlass section** (requires API key in Settings): + - BTC and ETH 24h liquidation data + - Long vs short liq bias → bull/bear signal + - OI change data +- **CryptoQuant section** (requires API key in Settings): + - Exchange reserve flows → bull/bear signal +- **Hyperliquid on-chain**: + - BTC and ETH open interest + - Funding rates + - Stablecoin supply stats from DeFiLlama +- Signal array: bull/bear/neutral per metric → regime verdict +- Settings panel: CoinGlass key + CryptoQuant key (stored in localStorage) + +### Heatmap +- All Hyperliquid perps fetched via `metaAndAssetCtxs` +- Color-coded grid by funding rate: + - Red = high positive (crowded longs) + - Green = negative (short squeeze risk) +- Sort modes: |Fund|, Fund↑, Fund↓, OI, Name +- Filter by coin name +- Stats bar: positive count, negative count, total OI +- 60-second cache TTL +- Auto-refresh on load + +--- + +## PWA / Infrastructure + +- Service worker (`sw.js`): offline static asset caching +- Installable on iOS/Android +- Single-file CSS (`styles.css`) with CSS custom properties +- No build step — pure static HTML/JS/CSS +- GitHub Pages deployment on `gh-pages` branch +- Cloudflare Worker RSS proxy for CORS bypass (`cloudflare/worker.js`) diff --git a/docs/gaps.md b/docs/gaps.md new file mode 100644 index 0000000..8fd2cd5 --- /dev/null +++ b/docs/gaps.md @@ -0,0 +1,133 @@ +# Known Gaps & TODOs + +Audit findings: stubs, partially wired features, and future work identified as of 2026-05-31. + +--- + +## Unwired Environment Variables + +### `BTC_GATE_PCT` +- **Defined in:** `wrangler-bot.toml [vars]` (default: `-5`) +- **Intent:** Skip bullish signal alerts if BTC 24h change is below this threshold (macro gate) +- **Status:** Variable is declared but never read in `checkSignals()` or any bot function +- **Fix:** In `checkSignals`, fetch BTC 24h change from Binance/HL and skip coin loop if below threshold + +--- + +## Dashboard Stubs (nav items with partial/no implementation) + +### Binance L/S Ratio History +- `signals.js` shows current L/S ratio per coin in the signal scanner table +- No historical chart or trend line — just the current snapshot value + +### Binance OI History +- OI is shown as a current value and 24h change in several tabs +- No historical OI chart (sparkline or full chart) — referenced comments exist in app.js but functions are not implemented + +--- + +## Modules with Unclear/Partial Status + +### `nansen.js` — Smart Money +- 501 lines of wallet tracking code +- Loads via `loadNansen()` in the Smart Money tab +- Nansen is a paid on-chain analytics service — unclear if this uses the real Nansen API or a free simulation +- Check: does this require a Nansen API key? If so, it should be listed in the APIs doc and the settings panel + +### `position-meta.js` +- 334 lines — per-position intent/thesis modal +- Wired into portfolio positions as a click-to-annotate feature +- Storage: unclear whether this persists to localStorage, Supabase, or is ephemeral per session + +### `logger.js` +- 480 lines — data logger and portfolio snapshots +- Separate from the `dailySnapshot` in the bot and the `autojournal.js` daily snapshot +- Unclear if this creates redundant snapshots or handles a distinct use case (e.g. more frequent logging) +- Check: does it conflict with `autojournal.js` daily capture? + +### `mvrv-ai.js` (1090 lines) +- Includes full Supabase client implementation +- Also contains AI commentary integration +- Very large file — likely contains functionality beyond just the MVRV tab (check for shared utilities used elsewhere) + +--- + +## CORS Issues + +### CoinGlass (browser) +- The CoinGlass API does not send CORS headers for browser requests +- Dashboard shows a notice: "Data loads normally from the Telegram bot" +- **Workaround options:** + 1. Route through the Cloudflare RSS proxy worker (extend it to proxy CoinGlass) + 2. Add a `/coinglass-proxy` endpoint to the bot worker with CORS headers + +--- + +## Cooldown Asymmetry + +- `checkReversals`, `checkOISpikes`, `checkLiqCascade`, `checkTrendAlignment` all use the `reversal` cooldown type (12h) +- `checkSignals` and `checkFundingArb` use their own cooldown types (4h) +- `checkFundingFlips` uses direct KV put with 86400s TTL (24h) — does not go through `isOnCooldown`/`setCooldown` +- Inconsistency: funding flip cooldown is effectively 24h vs 4h for other alerts. May result in missed flips if funding oscillates within a day + +--- + +## Weekly Review Fires on All Sundays +- `weeklyReview` is triggered by cron `0 0 * * 0` (Sunday midnight UTC) +- But in `scheduled()`, the cron dispatcher checks `cron === '0 0 * * *'` (daily midnight) and calls `dailySnapshot` +- There is no separate `0 0 * * 0` branch in the cron dispatcher — both daily and weekly run on the daily midnight cron +- **Verify:** `weeklyReview` may never fire automatically unless the cron string in the dispatcher matches exactly + +--- + +## AI News Analysis — Bot URL Dependency + +- The dashboard's AI news analysis (`/analyze-news`) requires the user to manually enter their bot worker URL in the News tab settings +- If the URL is not set, `_analyzeTopArticles()` silently returns without analyzing +- No onboarding prompt or hint for new users — they may not know to set this + +--- + +## BotFather Commands Outdated + +- `/positions`, `/trend`, `/price`, `/status` were added to `handleTgCommand` but may not be listed in BotFather's command menu yet +- BotFather command list must be updated manually via `/setcommands` +- Current recommended list is in `docs/bot.md` + +--- + +## Journal Duplication + +- Two journal implementations exist: `autojournal.js` (auto-detection) and `journal.js` (manual Supabase) +- Both render into `#page-journal` +- The `navigate()` dispatcher calls `loadJournal` which maps to whichever `loadJournal` is defined last in script load order +- **Risk:** One may silently shadow the other depending on script tag order in `index.html` + +--- + +## `BTC_GATE_PCT` Implementation Path + +If you want to wire it in: + +```js +// In checkSignals(env), before the coin loop: +const btcGatePct = parseFloat(env.BTC_GATE_PCT || '-5'); +const btcCtx = (await getFundingRates())['BTC']; +if (btcCtx) { + // Approximate 24h change from funding/price — or fetch Binance 24h ticker + // If BTC 24h change < btcGatePct, skip bullish alerts (still allow bearish) +} +``` + +Full implementation requires a 24h BTC price change fetch (Binance `/fapi/v1/ticker/24hr?symbol=BTCUSDT`). + +--- + +## Future Ideas (not yet planned) + +- Portfolio heat map: color positions by PnL% in a grid view +- Delta-neutral arb P&L tracker: track cumulative funding collected on arb positions +- Alert configurability: per-coin thresholds in dashboard settings (vs global env vars) +- Telegram `/alerts` command: show/toggle which alert types are enabled +- On-chain regime score card in bot `/status` output +- CoinGlass CORS proxy: route browser CoinGlass calls through bot worker diff --git a/frontend/css/styles.css b/frontend/css/styles.css index d0bea31..891c6f5 100644 --- a/frontend/css/styles.css +++ b/frontend/css/styles.css @@ -1,165 +1,1329 @@ :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; } -/* 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 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: 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; } -/* Progress bar */ -.progress-bar { height: 4px; background: var(--surface2); border-radius: 2px; overflow: hidden; } +.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; } -/* 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 ─────────────────────────────────────────────────────────────────── */ -/* Confidence bar */ -.conf-bar { display: flex; align-items: center; gap: 8px; } -.conf-val { font-family: var(--mono); font-size: 12px; min-width: 36px; } +.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; } -/* Section header */ -.section-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px; } -.section-title { font-size: 16px; font-weight: 600; } +/* ── Inputs ──────────────────────────────────────────────────────────────────── */ -/* 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); } +.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); } -/* 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 headers / tabs ──────────────────────────────────────────────────── */ -/* Chart container */ -.chart-container { width: 100%; height: 240px; } +.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: 5px; height: 5px; } +/* ── Scrollbar ───────────────────────────────────────────────────────────────── */ + +::-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; } } 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..52cdd95 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,124 +2,218 @@ - - Hype — Trade Analyzer + + + + + + Hype — Analyzer + + - - + + + -
- - 0x6e4c…2015 + + + + +
-
- + + + 0x6e4c…2015 +
+
- -
-
- Notifications - -
-
Loading…
-
- - + - -
- - -
-
-
Loading…
-
-
- - -
-
-
Loading…
-
-
- - -
-
-
Loading…
-
-
- - -
-
-
Loading…
-
-
+ + - -
-
-
Loading…
-
-
+ + - -
-
-
Loading…
-
+ + +
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Connecting…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
Loading…
+
-
- + + + + + + + + + + + + + + + + + diff --git a/frontend/js/ai.js b/frontend/js/ai.js new file mode 100644 index 0000000..774790b --- /dev/null +++ b/frontend/js/ai.js @@ -0,0 +1,355 @@ +// ── AI Page: Trade Staging + AI Chat ───────────────────────────────────────── +// +// Supabase table — run once in Supabase SQL Editor: +// +// create table staged_trades ( +// id uuid default gen_random_uuid() primary key, +// created_at timestamptz default now(), +// updated_at timestamptz default now(), +// coin text not null, +// direction text not null, +// entry_price numeric, +// stop_loss numeric, +// take_profit numeric, +// rationale text, +// tags text, +// status text default 'staged' +// ); +// alter table staged_trades enable row level security; +// create policy "anon_all" on staged_trades for all to anon using (true) with check (true); + +const _STAGE_COINS = ['BTC','ETH','SOL','BNB','AVAX','ARB','OP','MATIC','DOGE','PEPE','WIF','BONK','SUI','APT','INJ','TIA','JUP','PYTH','W','SEI','STX','RUNE']; +const _STAGE_DIRS = ['Long','Short','Spot Buy','Spot Sell']; + +let _stageTrades = []; +let _chatMsgs = []; +let _chatLoading = false; + +// ── Supabase persistence ────────────────────────────────────────────────────── + +async function _stageLoad() { + if (!_db) return; + try { + const { data } = await _db.from('staged_trades').select('*').order('created_at', { ascending: false }).limit(100); + if (data) _stageTrades = data; + } catch {} +} + +async function _stageInsert(trade) { + if (_db) { + const { data } = await _db.from('staged_trades').insert(trade).select().single(); + if (data) { _stageTrades.unshift(data); return; } + } + _stageTrades.unshift({ ...trade, id: crypto.randomUUID(), created_at: new Date().toISOString() }); +} + +async function _stageSetStatus(id, status) { + if (_db) await _db.from('staged_trades').update({ status, updated_at: new Date().toISOString() }).eq('id', id); + _stageTrades = _stageTrades.map(t => t.id === id ? { ...t, status } : t); +} + +async function _stageDelete(id) { + if (_db) await _db.from('staged_trades').delete().eq('id', id); + _stageTrades = _stageTrades.filter(t => t.id !== id); +} + +// ── Edge Function / AI Chat ─────────────────────────────────────────────────── + +function _edgeUrl() { return localStorage.getItem('hype_edge_fn_url') || ''; } + +async function _callClaude(message) { + const url = _edgeUrl(); + if (!url) throw new Error('no-edge-fn'); + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ message, history: _chatMsgs.slice(-12) }), + }); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + const d = await r.json(); + return d.reply || d.content || d.text || '(empty response)'; +} + +// ── Page entry ──────────────────────────────────────────────────────────────── + +async function loadAI() { + const el = document.getElementById('ai-content'); + if (!el) return; + el.innerHTML = `
Loading…
`; + await _stageLoad(); + el.innerHTML = _renderAIPage(); +} + +function _renderAIPage() { + return `
${_renderStagePanel()}
${_renderChatPanel()}
`; +} + +// ── Trade Staging ───────────────────────────────────────────────────────────── + +function _renderStagePanel() { + const active = _stageTrades.filter(t => t.status === 'staged' || t.status === 'watching'); + const history = _stageTrades.filter(t => t.status === 'executed' || t.status === 'cancelled'); + return ` +
+
Trade Staging
+ +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+ +
+ + ${active.length ? `
Active (${active.length})
${active.map(_renderTradeCard).join('')}
` : ''} + + ${history.length ? ` +
+ + History (${history.length}) ▸ + +
${history.map(_renderTradeCard).join('')}
+
` : ''} + + ${!_stageTrades.length ? `
No staged trades yet. Use the form above to track your next setup.
` : ''} +
`; +} + +function _renderTradeCard(t) { + const dirCls = (t.direction === 'Long' || t.direction === 'Spot Buy') ? 'pos' : 'neg'; + const stColor = { staged: 'var(--yellow)', watching: 'var(--accent)', executed: 'var(--green)', cancelled: 'var(--text-faint)' }; + const tags = (t.tags || '').split(',').map(s => s.trim()).filter(Boolean); + const rr = t.stop_loss && t.take_profit && t.entry_price + ? Math.abs((t.take_profit - t.entry_price) / (t.entry_price - t.stop_loss)).toFixed(1) + : null; + const isActive = t.status === 'staged' || t.status === 'watching'; + return ` +
+
+ ${t.coin} + ${(t.direction || '').toUpperCase()} + ${t.status} +
+ ${t.status === 'staged' ? `` : ''} + ${isActive ? `` : ''} + ${isActive ? `` : ''} + +
+
+ ${t.entry_price || t.stop_loss || t.take_profit ? ` +
+ ${t.entry_price ? `Entry ${(+t.entry_price).toLocaleString()}` : ''} + ${t.stop_loss ? `SL ${(+t.stop_loss).toLocaleString()}` : ''} + ${t.take_profit ? `TP ${(+t.take_profit).toLocaleString()}` : ''} + ${rr ? `R:R ${rr}x` : ''} +
` : ''} + ${t.rationale ? `
${t.rationale}
` : ''} + ${tags.length ? `
${tags.map(g => `${g}`).join('')}
` : ''} +
${new Date(t.created_at).toLocaleString()}
+
`; +} + +// ── AI Chat ─────────────────────────────────────────────────────────────────── + +function _renderChatPanel() { + const url = _edgeUrl(); + const active = !!url; + return ` +
+
+ AI Chat + ${active ? '● Connected' : '● Not configured'} + +
+ +
+

+ Your Anthropic API key stays secure in Supabase — never in the browser.
+ One-time setup: +

+
    +
  1. Go to supabase.com → your project → Edge Functions → New Function → name it claude-proxy
  2. +
  3. Paste the code shown below, then deploy
  4. +
  5. In Supabase → Settings → Secrets → add ANTHROPIC_API_KEY = your key
  6. +
  7. Copy the function URL (e.g. https://xxx.supabase.co/functions/v1/claude-proxy) and paste below:
  8. +
+
+ + +
+
+ Edge Function code (copy this) +
${_escHtml(_EDGE_FN)}
+
+
+ +
+ ${_chatMsgs.length + ? _chatMsgs.map(_renderMsg).join('') + : `
${active ? 'Ask me about your portfolio, market conditions, or trade ideas.' : 'Configure the Edge Function above to enable AI chat.'}
` + } +
+ +
+ + +
+
`; +} + +function _renderMsg(m) { + const isUser = m.role === 'user'; + const body = m.content + .replace(/&/g,'&').replace(//g,'>') + .replace(/\n/g,'
') + .replace(/\*\*(.+?)\*\*/g,'$1') + .replace(/`(.+?)`/g,'$1'); + return ` +
+
${isUser ? 'You' : 'Claude'}
+
${body}
+
`; +} + +function _escHtml(s) { return s.replace(/&/g,'&').replace(//g,'>'); } + +// ── Edge Function template ──────────────────────────────────────────────────── + +const _EDGE_FN = `import Anthropic from "npm:@anthropic-ai/sdk"; + +const client = new Anthropic({ apiKey: Deno.env.get("ANTHROPIC_API_KEY") }); + +Deno.serve(async (req) => { + const cors = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Headers": "content-type, authorization", + "Access-Control-Allow-Methods": "POST, OPTIONS", + }; + if (req.method === "OPTIONS") return new Response(null, { headers: cors }); + + try { + const { message, history = [] } = await req.json(); + const messages = [ + ...history.map((m) => ({ role: m.role, content: m.content })), + { role: "user", content: message }, + ]; + const res = await client.messages.create({ + model: "claude-opus-4-8", + max_tokens: 1024, + system: "You are a crypto trading analyst. Give concise, actionable insights about market conditions, trade setups, and portfolio positioning. Be direct.", + messages, + }); + return new Response( + JSON.stringify({ reply: res.content[0]?.text || "" }), + { headers: { "Content-Type": "application/json", ...cors } } + ); + } catch (err) { + return new Response( + JSON.stringify({ error: err.message }), + { status: 500, headers: { "Content-Type": "application/json", ...cors } } + ); + } +});`; + +// ── Actions ─────────────────────────────────────────────────────────────────── + +async function stageTrade() { + const coin = document.getElementById('sf-coin')?.value; + const direction = document.getElementById('sf-dir')?.value; + const entry = parseFloat(document.getElementById('sf-entry')?.value) || null; + const sl = parseFloat(document.getElementById('sf-sl')?.value) || null; + const tp = parseFloat(document.getElementById('sf-tp')?.value) || null; + const rationale = document.getElementById('sf-rationale')?.value.trim() || ''; + const tags = document.getElementById('sf-tags')?.value.trim() || ''; + if (!coin || !direction) return; + await _stageInsert({ coin, direction, entry_price: entry, stop_loss: sl, take_profit: tp, rationale, tags, status: 'staged' }); + document.getElementById('ai-content').innerHTML = _renderAIPage(); +} + +async function aiStatus(id, status) { + await _stageSetStatus(id, status); + document.getElementById('ai-content').innerHTML = _renderAIPage(); +} + +async function aiDelete(id) { + if (!confirm('Delete this staged trade?')) return; + await _stageDelete(id); + document.getElementById('ai-content').innerHTML = _renderAIPage(); +} + +function toggleChatSetup() { + const p = document.getElementById('chat-setup'); + if (p) p.style.display = p.style.display === 'none' ? 'block' : 'none'; +} + +function saveEdgeUrl() { + const url = document.getElementById('edge-url-input')?.value.trim(); + if (url) localStorage.setItem('hype_edge_fn_url', url); + else localStorage.removeItem('hype_edge_fn_url'); + document.getElementById('ai-content').innerHTML = _renderAIPage(); +} + +async function sendChat() { + const input = document.getElementById('chat-input'); + const msg = input?.value.trim(); + if (!msg || _chatLoading) return; + if (input) input.value = ''; + _chatMsgs.push({ role: 'user', content: msg }); + _chatLoading = true; + _renderChatMsgs(true); + try { + const reply = await _callClaude(msg); + _chatMsgs.push({ role: 'assistant', content: reply }); + } catch (e) { + const text = e.message === 'no-edge-fn' + ? 'Edge Function URL not set. Click ⚙ Setup above.' + : `Error: ${e.message}`; + _chatMsgs.push({ role: 'assistant', content: text }); + } + _chatLoading = false; + _renderChatMsgs(false); +} + +function _renderChatMsgs(loading) { + const el = document.getElementById('chat-msgs'); + if (!el) return; + const spinner = `
Claude
`; + el.innerHTML = _chatMsgs.map(_renderMsg).join('') + (loading ? spinner : ''); + el.scrollTop = el.scrollHeight; +} + +function chatKey(e) { + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendChat(); } +} 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..81dc831 100644 --- a/frontend/js/app.js +++ b/frontend/js/app.js @@ -1,562 +1,2924 @@ -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 }); } + +// ── Shared CoinGecko cache (3-min TTL, shared by intel.js + fundamentals.js) ─ +const _cgShared = { global: null, globalTs: 0, markets: null, marketsTs: 0 }; +async function getCGGlobal() { + if (_cgShared.global && Date.now() - _cgShared.globalTs < 3*60*1000) return _cgShared.global; + const r = await fetch('https://api.coingecko.com/api/v3/global'); + if (!r.ok) throw new Error('CG global ' + r.status); + _cgShared.global = (await r.json()).data; + _cgShared.globalTs = Date.now(); + return _cgShared.global; +} +async function getCGMarkets() { + if (_cgShared.markets && Date.now() - _cgShared.marketsTs < 3*60*1000) return _cgShared.markets; + const r = await fetch('https://api.coingecko.com/api/v3/coins/markets?vs_currency=usd&order=market_cap_desc&per_page=100&page=1&sparkline=false&price_change_percentage=24h,7d,30d'); + if (!r.ok) throw new Error('CG markets ' + r.status); + _cgShared.markets = await r.json(); + _cgShared.marketsTs = Date.now(); + return _cgShared.markets; +} + +// ── 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 setStatus(online) { - document.getElementById('ws-status').className = 'status-dot' + (online ? '' : ' off'); +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); +} + +// 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('')} +
    `:''} +
    +
    +
    +
    +
    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)}%
    - `; - } catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } +
    +
    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':''}
    -
    -
    -
    - Price: ${p.price_trend}   - Volume: ${p.volume_trend} +
    ${w.address}
    -
      - ${(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(); } -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 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 = ''; +} + +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.
    +
    +
    +
    Bot Token
    + +
    +
    +
    Your Chat ID
    +
    + + +
    +
    Send any message to your bot first, then click Auto-detect.
    +
    - ${w.label} - ${isPrimary ? 'PRIMARY' : ''} -
    ${w.address}
    +
    P&L Milestone — Alert every $
    +
    -
    - - ${!isPrimary ? `` : ''} +
    + + + ${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 +let _chartDataTs = 0; + +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(); + + if (_chartData.all && !_silentRefresh && Date.now() - _chartDataTs < 60000) { + const d = _chartData[chartMode]; + if (d?.pts?.length) { _drawPortfolioChart(d.pts, d.current); updateChartLabels(); } + return; + } + + let taggedFills = _ovData?.recentFills || [], funding = []; 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}) + if (!taggedFills.length) { + const rawFills = await getUserFills(currentWallet); + const spotIndexMap = buildSpotIndexMap(_ovData?.spotMetaRaw); + taggedFills = tagFills(parseFills(rawFills), spotIndexMap); + } + 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 }, + }; + _chartDataTs = Date.now(); + + 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 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 toggleNotifications() { - const panel = document.getElementById('notif-panel'); - panel.classList.toggle('open'); - if (panel.classList.contains('open')) loadNotifications(); +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)'; } -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; +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 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; +} + +// ── 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 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 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(); } -function updateNotifBadge(count) { - const badge = document.getElementById('notif-count'); - badge.textContent = count; - badge.style.display = count > 0 ? 'flex' : 'none'; +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 markRead(id) { - await fetch(`${API}/api/notifications/${id}/read`, {method:'POST'}); - loadNotifications(); +function taRow(icon, name, sig) { + if (!sig) return ''; + return `
    ${icon}${name}
    ${sig.label}${sig.sub?`${sig.sub}`:''}
    `; } -async function markAllRead() { - await fetch(`${API}/api/notifications/read-all`, {method:'POST'}); - loadNotifications(); +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; -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 orderSide = o.side === 'B' ? 'buy' : 'sell'; + const isReduce = (pos.side === 'long' && orderSide === 'sell') || + (pos.side === 'short' && orderSide === 'buy'); + if (!isReduce) return; + + 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 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 fmtTime(ms) { - if (!ms) return '—'; - return new Date(ms).toLocaleString('en-US', {month:'short', day:'numeric', hour:'2-digit', minute:'2-digit'}); +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/fundamentals.js b/frontend/js/fundamentals.js new file mode 100644 index 0000000..6b4bd2e --- /dev/null +++ b/frontend/js/fundamentals.js @@ -0,0 +1,178 @@ +// ── Fundamentals — CoinGecko public API (no key) ───────────────────────────── + +const CG_TRENDING = 'https://api.coingecko.com/api/v3/search/trending'; + +let _fundCoins = []; +let _fundGlobal = null; +let _fundTrending = []; +let _fundSort = { col: 'market_cap_rank', dir: 1 }; +let _fundSearch = ''; +let _fundLoaded = false; +let _fundTimer = null; + +// ── Fetch ───────────────────────────────────────────────────────────────────── + +async function loadFundamentals() { + const el = document.getElementById('fundamentals-content'); + if (!el) return; + if (_fundLoaded && _fundCoins.length) { _renderFund(); return; } + el.innerHTML = `
    Loading market data…
    `; + await _fetchFund(); + _renderFund(); + clearInterval(_fundTimer); + _fundTimer = setInterval(async () => { await _fetchFund(); _renderFund(); }, 5 * 60 * 1000); +} + +async function _fetchFund() { + const [coinsR, globalR, trendR] = await Promise.allSettled([ + getCGMarkets(), + getCGGlobal(), + fetch(CG_TRENDING).then(r => r.ok ? r.json() : Promise.reject()), + ]); + if (coinsR.status === 'fulfilled') _fundCoins = coinsR.value; + if (globalR.status === 'fulfilled') _fundGlobal = globalR.value; + if (trendR.status === 'fulfilled') _fundTrending = (trendR.value.coins || []).slice(0, 7).map(c => c.item); + _fundLoaded = true; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _fShort(n) { + if (n >= 1e12) return (n / 1e12).toFixed(2) + 'T'; + if (n >= 1e9) return (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return (n / 1e6).toFixed(1) + 'M'; + return n?.toLocaleString() || '—'; +} + +function _fPct(v) { + if (v == null) return ''; + const cls = v >= 0 ? 'pos' : 'neg'; + return `${v >= 0 ? '+' : ''}${v.toFixed(2)}%`; +} + +function _fPrice(p) { + if (p == null) return '—'; + const dec = p < 0.01 ? 6 : p < 1 ? 4 : 2; + return '$' + p.toLocaleString(undefined, { minimumFractionDigits: dec, maximumFractionDigits: dec }); +} + +// ── Render ──────────────────────────────────────────────────────────────────── + +function _renderFund() { + const el = document.getElementById('fundamentals-content'); + if (!el) return; + el.innerHTML = _renderFundGlobalBar() + _renderFundToolbar() + _renderFundTable(); +} + +function _renderFundGlobalBar() { + if (!_fundGlobal) return ''; + const g = _fundGlobal; + const mcap = g.total_market_cap?.usd || 0; + const vol = g.total_volume?.usd || 0; + const chg = g.market_cap_change_percentage_24h_usd || 0; + const btcD = (g.market_cap_percentage?.btc || 0).toFixed(1); + const ethD = (g.market_cap_percentage?.eth || 0).toFixed(1); + const trend = _fundTrending.length + ? `Trending: ${_fundTrending.map(t => `${t.symbol}`).join('')}` + : ''; + return ` +
    + Total MCap $${_fShort(mcap)} ${chg >= 0 ? '+' : ''}${chg.toFixed(2)}% + | + 24h Vol $${_fShort(vol)} + | + BTC Dom ${btcD}% + ETH Dom ${ethD}% + | + Coins ${(g.active_cryptocurrencies || 0).toLocaleString()} +
    ${trend}
    +
    `; +} + +function _renderFundToolbar() { + const ts = _fundLoaded ? `updated ${new Date().toLocaleTimeString()}` : ''; + return ` +
    + + ${_fundCoins.length} coins · ${ts} + +
    `; +} + +function _renderFundTable() { + if (!_fundCoins.length) return `
    No data — CoinGecko may be rate-limited, try refreshing in a moment.
    `; + + let coins = [..._fundCoins]; + if (_fundSearch) { + const q = _fundSearch.toLowerCase(); + coins = coins.filter(c => c.symbol.toLowerCase().includes(q) || c.name.toLowerCase().includes(q)); + } + const { col, dir } = _fundSort; + coins.sort((a, b) => { + const av = a[col] ?? (dir > 0 ? Infinity : -Infinity); + const bv = b[col] ?? (dir > 0 ? Infinity : -Infinity); + return (av < bv ? -1 : av > bv ? 1 : 0) * dir; + }); + + const si = (c) => _fundSort.col === c ? (_fundSort.dir > 0 ? ' ↑' : ' ↓') : ''; + const th = (c, lbl, align = '') => + `${lbl}${si(c)}`; + + const rows = coins.map(c => { + const vm = c.total_volume && c.market_cap ? (c.total_volume / c.market_cap * 100) : null; + const athPct = c.ath_change_percentage; + return ` + ${c.market_cap_rank || '—'} + +
    + + ${c.symbol.toUpperCase()} + ${c.name} +
    + + ${_fPrice(c.current_price)} + ${_fPct(c.price_change_percentage_24h_in_currency ?? c.price_change_percentage_24h)} + ${_fPct(c.price_change_percentage_7d_in_currency)} + ${_fPct(c.price_change_percentage_30d_in_currency)} + $${_fShort(c.market_cap || 0)} + $${_fShort(c.total_volume || 0)} + ${vm != null ? `${vm.toFixed(1)}%` : '—'} + ${_fPct(athPct)} + `; + }).join(''); + + return ` +
    + + + ${th('market_cap_rank', '#')} + ${th('name', 'Coin')} + ${th('current_price', 'Price', 'right')} + ${th('price_change_percentage_24h', '24h', 'right')} + ${th('price_change_percentage_7d_in_currency', '7d', 'right')} + ${th('price_change_percentage_30d_in_currency', '30d', 'right')} + ${th('market_cap', 'Mkt Cap', 'right')} + ${th('total_volume', '24h Vol', 'right')} + ${th('total_volume', 'Vol/MCap', 'right')} + ${th('ath_change_percentage', 'ATH %', 'right')} + + ${rows || ''} +
    No results
    +
    `; +} + +// ── Actions ─────────────────────────────────────────────────────────────────── + +function fundSort(col) { + if (_fundSort.col === col) _fundSort.dir *= -1; + else { _fundSort.col = col; _fundSort.dir = 1; } + _renderFund(); +} +function fundSearch(q) { _fundSearch = q; _renderFund(); } +async function fundRefresh() { + _fundLoaded = false; + const el = document.getElementById('fundamentals-content'); + if (el) el.innerHTML = `
    Refreshing…
    `; + await _fetchFund(); + _renderFund(); +} 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..33a084b --- /dev/null +++ b/frontend/js/intel.js @@ -0,0 +1,784 @@ +// ── Intel Tab — Live Market Intelligence ───────────────────────────────────── +// Replaces the static Cryptowatch paste. All data auto-fetched. +// +// Sources (all free, no API key): +// • Hyperliquid: prices, funding, OI for tracked coins +// • Binance Futures: BTC/ETH OI (market-wide), funding, 24h OI history +// • Bybit: BTC funding rate (cross-exchange average) +// • CoinGecko: BTC dominance, altcoin breadth, global mcap +// • alternative.me: Fear & Greed Index +// • _mvrvData global (mvrv-ai.js): MVRV Z-Score +// +// Auto-scores: regime verdict, radar axes, evidence trail +// Claude: synthesis paragraph + desk setups via Edge Function + +// ── Endpoints ───────────────────────────────────────────────────────────────── + +const _IL = { + BN_OI: 'https://fapi.binance.com/fapi/v1/openInterest?symbol=BTCUSDT', + BN_FUND: 'https://fapi.binance.com/fapi/v1/premiumIndex', + BN_OI_HIST: 'https://fapi.binance.com/futures/data/openInterestHist?symbol=BTCUSDT&period=1h&limit=25', + BY_FUND: 'https://api.bybit.com/v5/market/funding/history?category=linear&symbol=BTCUSDT&limit=1', + FNG: 'https://api.alternative.me/fng/?limit=1', +}; + +const INTEL_TRACK = ['BTC', 'ETH', 'SOL', 'HYPE']; + +let _intelData = null; +let _intelRegime = null; +let _intelSetups = null; // Claude-generated desk setups (persisted per session) +let _intelSynth = null; // Claude synthesis text +let _intelTimer = null; +let _intelGenState = { setups: false, synth: false }; // loading flags + +// ── Fetchers ────────────────────────────────────────────────────────────────── + +async function _ilFetchHL() { + const [meta, ctxs] = await getMetaAndAssetCtxs(); + const map = {}; + meta.universe.forEach((u, i) => { + const c = ctxs[i]; + map[u.name] = { + price: parseFloat(c.markPx || 0), + funding: parseFloat(c.funding || 0), // per 1h (HL is hourly) + oiCoins: parseFloat(c.openInterest || 0), + get oi() { return this.oiCoins * this.price; }, + get aprHL(){ return this.funding * 24 * 365 * 100; }, + }; + }); + return map; +} + +async function _ilFetchBinance() { + const [oiR, fundBtcR, fundEthR, oiHistR] = await Promise.allSettled([ + fetch(_IL.BN_OI).then(r => r.json()), + fetch(_IL.BN_FUND + '?symbol=BTCUSDT').then(r => r.json()), + fetch(_IL.BN_FUND + '?symbol=ETHUSDT').then(r => r.json()), + fetch(_IL.BN_OI_HIST).then(r => r.json()), + ]); + + const btcOiCoins = oiR.status === 'fulfilled' ? parseFloat(oiR.value.openInterest || 0) : null; + const btcFund8h = fundBtcR.status === 'fulfilled' ? parseFloat(fundBtcR.value.lastFundingRate || 0) : null; + const ethFund8h = fundEthR.status === 'fulfilled' ? parseFloat(fundEthR.value.lastFundingRate || 0) : null; + const btcMarkPx = fundBtcR.status === 'fulfilled' ? parseFloat(fundBtcR.value.markPrice || 0) : null; + + let oiChange24h = null; + if (oiHistR.status === 'fulfilled' && Array.isArray(oiHistR.value) && oiHistR.value.length >= 24) { + const h = oiHistR.value; + const newest = parseFloat(h[h.length - 1].sumOpenInterestValue || 0); + const oldest = parseFloat(h[0].sumOpenInterestValue || 0); + oiChange24h = oldest > 0 ? ((newest - oldest) / oldest) * 100 : null; + // sumOpenInterestValue is in USD + var btcOiUsd = newest; + } + + return { + btcOiCoins, + btcOiUsd: typeof btcOiUsd !== 'undefined' ? btcOiUsd : (btcOiCoins && btcMarkPx ? btcOiCoins * btcMarkPx : null), + btcFund8h, // decimal (e.g. 0.0001 = 0.01%/8h) + btcFundApr: btcFund8h != null ? btcFund8h * 3 * 365 * 100 : null, // annualised % + ethFundApr: ethFund8h != null ? ethFund8h * 3 * 365 * 100 : null, + oiChange24h, + }; +} + +async function _ilFetchBybit() { + const r = await fetch(_IL.BY_FUND); + const d = await r.json(); + const rate = parseFloat(d.result?.list?.[0]?.fundingRate || 0); // 8h rate + return rate * 3 * 365 * 100; // APR % +} + +async function _ilFetchCG() { + const [gR, mR] = await Promise.allSettled([ + getCGGlobal(), + getCGMarkets(), + ]); + let btcDom = null, mcapChange24h = null, totalMcap = null, totalVol = null, altBreadth = null; + let cgCoins = []; + + if (gR.status === 'fulfilled') { + const g = gR.value; + btcDom = g.market_cap_percentage?.btc ?? null; + mcapChange24h = g.market_cap_change_percentage_24h_usd ?? null; + totalMcap = g.total_market_cap?.usd ?? null; + totalVol = g.total_volume?.usd ?? null; + } + if (mR.status === 'fulfilled' && Array.isArray(mR.value)) { + cgCoins = mR.value.filter(c => !['usdt','usdc','dai','busd','tusd','usdd'].includes(c.symbol)); + const up = cgCoins.filter(c => (c.price_change_percentage_24h || 0) > 0).length; + altBreadth = cgCoins.length ? Math.round(up / cgCoins.length * 100) : null; + } + return { btcDom, mcapChange24h, totalMcap, totalVol, altBreadth, cgCoins }; +} + +async function _ilFetchFNG() { + const r = await fetch(_IL.FNG); + const d = await r.json(); + const e = d.data?.[0]; + return e ? { value: parseInt(e.value), label: e.value_classification } : null; +} + +async function _fetchIntelData() { + const [hl, bn, bybitApr, cg, fng] = await Promise.all([ + _ilFetchHL().catch(() => ({})), + _ilFetchBinance().catch(() => ({})), + _ilFetchBybit().catch(() => null), + _ilFetchCG().catch(() => ({})), + _ilFetchFNG().catch(() => null), + ]); + + const btc = hl['BTC'] || {}; + const eth = hl['ETH'] || {}; + const sol = hl['SOL'] || {}; + const hype = hl['HYPE'] || {}; + + // Cross-exchange BTC funding average (HL + Binance + Bybit) + const fundSamples = [btc.aprHL, bn.btcFundApr, bybitApr].filter(f => f != null); + const btcFundApr = fundSamples.length ? fundSamples.reduce((a, b) => a + b, 0) / fundSamples.length : null; + + // MVRV Z from mvrv-ai.js global + const mvrvZ = (typeof _mvrvData !== 'undefined' && _mvrvData?.summary?.z_score != null) + ? _mvrvData.summary.z_score : null; + + return { + ts: Date.now(), + prices: { BTC: btc.price, ETH: eth.price, SOL: sol.price, HYPE: hype.price }, + hlFundApr: { BTC: btc.aprHL, ETH: eth.aprHL, SOL: sol.aprHL, HYPE: hype.aprHL }, + hlOI: { BTC: btc.oi, ETH: eth.oi, SOL: sol.oi, HYPE: hype.oi }, + btcFundApr, // cross-exchange avg, annualised % + ethFundApr: bn.ethFundApr, + bnBtcOiUsd: bn.btcOiUsd, // Binance BTC perp OI in USD (market-wide) + oiChange24h: bn.oiChange24h, + btcDom: cg.btcDom, + mcapChange24h: cg.mcapChange24h, + totalMcap: cg.totalMcap, + totalVol: cg.totalVol, + altBreadth: cg.altBreadth, + cgCoins: cg.cgCoins, + fng: fng, + mvrvZ, + }; +} + +// ── Scoring Engine ──────────────────────────────────────────────────────────── + +function _scoreIntel(d) { + const signals = []; + + // Helper: add signal with weighted score + const sig = (name, score, note, value) => signals.push({ name, score, note, value }); + + // MVRV Z-Score (weight 3) + if (d.mvrvZ != null) { + const z = d.mvrvZ; + if (z < 0) sig('MVRV Z', 3, 'Undervalued — historical buy zone', z.toFixed(2)); + else if (z < 1) sig('MVRV Z', 2, 'Below fair value', z.toFixed(2)); + else if (z < 2) sig('MVRV Z', 1, 'Fair value range', z.toFixed(2)); + else if (z < 4) sig('MVRV Z', 0, 'Neutral — watch closely', z.toFixed(2)); + else if (z < 6) sig('MVRV Z', -1, 'Elevated — late cycle', z.toFixed(2)); + else sig('MVRV Z', -3, 'Danger zone — distribution risk', z.toFixed(2)); + } + + // Fear & Greed (weight 2) + if (d.fng?.value != null) { + const fg = d.fng.value; + if (fg < 15) sig('Fear & Greed', 2, 'Extreme fear — historical buy zone', fg); + else if (fg < 35) sig('Fear & Greed', 1, 'Fear — opportunistic zone', fg); + else if (fg < 55) sig('Fear & Greed', 0, 'Neutral', fg); + else if (fg < 75) sig('Fear & Greed', -1, 'Greed — tighten stops', fg); + else sig('Fear & Greed', -2, 'Extreme greed — reduce exposure', fg); + } + + // BTC Funding APR (weight 2) + if (d.btcFundApr != null) { + const f = d.btcFundApr; + if (f < 0) sig('BTC Funding', 2, 'Negative — strong setup for longs', f.toFixed(1) + '%'); + else if (f < 5) sig('BTC Funding', 1, 'Low — no crowding', f.toFixed(1) + '%'); + else if (f < 15) sig('BTC Funding', 0, 'Neutral', f.toFixed(1) + '%'); + else if (f < 30) sig('BTC Funding', -1, 'Elevated — longs crowding', f.toFixed(1) + '%'); + else sig('BTC Funding', -2, 'Very high — crowded, flush risk', f.toFixed(1) + '%'); + } + + // Alt Breadth (weight 1) + if (d.altBreadth != null) { + const b = d.altBreadth; + if (b > 65) sig('Alt Breadth', 1, 'Broad rally — risk appetite healthy', b + '%'); + else if (b > 45) sig('Alt Breadth', 0, 'Mixed — selective strength', b + '%'); + else sig('Alt Breadth', -1, 'Broad weakness — risk-off', b + '%'); + } + + // Global MCap 24h (weight 1) + if (d.mcapChange24h != null) { + const m = d.mcapChange24h; + if (m > 3) sig('MCap 24h', 1, 'Rising — inflows present', (m > 0 ? '+' : '') + m.toFixed(1) + '%'); + else if (m > -3) sig('MCap 24h', 0, 'Flat', m.toFixed(1) + '%'); + else sig('MCap 24h', -1, 'Falling — outflows present', m.toFixed(1) + '%'); + } + + // OI Change 24h (weight 1) + if (d.oiChange24h != null) { + const o = d.oiChange24h; + if (o < -8) sig('BTC OI 24h', 1, 'Flushed — reset complete', o.toFixed(1) + '%'); + else if (o < 5) sig('BTC OI 24h', 0, 'Stable', o.toFixed(1) + '%'); + else sig('BTC OI 24h', -1, 'Rising fast — crowding building', o.toFixed(1) + '%'); + } + + // BTC Dominance (weight 1) + if (d.btcDom != null) { + const dom = d.btcDom; + if (dom < 50) sig('BTC Dom', 1, 'Alt season conditions', dom.toFixed(1) + '%'); + else if (dom < 58) sig('BTC Dom', 0, 'Neutral — BTC/Alt balance', dom.toFixed(1) + '%'); + else sig('BTC Dom', -1, 'BTC dominance suppressing alts', dom.toFixed(1) + '%'); + } + + // Aggregate + const raw = signals.reduce((sum, s) => sum + s.score, 0); + const maxRaw = 11; // 3+2+2+1+1+1+1 + const normScore = Math.max(-10, Math.min(10, Math.round(raw * 10 / maxRaw))); + + const bullish = signals.filter(s => s.score > 0).length; + const bearish = signals.filter(s => s.score < 0).length; + const neutral = signals.filter(s => s.score === 0).length; + const agree = Math.max(bullish, bearish); + const confidence = signals.length + ? Math.round(40 + (Math.abs(normScore) / 10) * 50) + : 50; + + let verdict; + if (normScore >= 6) verdict = 'BUY'; + else if (normScore >= 3) verdict = 'BULL'; + else if (normScore >= -2) verdict = 'WAIT'; + else if (normScore >= -5) verdict = 'CAUTION'; + else verdict = 'SELL'; + + // Radar axes (0–10, higher = more bullish) + const radar = { + Macro: _r(5 + (d.mcapChange24h ?? 0) / 2), + Cycle: d.mvrvZ != null ? _r(Math.max(0, 10 - d.mvrvZ * 1.4)) : 5, + OnChain: d.mvrvZ != null ? _r(Math.max(0, 10 - d.mvrvZ * 1.2)) : 5, + Derivs: d.oiChange24h != null ? _r(5 - d.oiChange24h / 3) : 5, + Funding: d.btcFundApr != null ? _r(8 - d.btcFundApr / 5) : 5, + Breadth: d.altBreadth != null ? _r(d.altBreadth / 10) : 5, + Sentiment: d.fng != null ? _r((100 - d.fng.value) / 10) : 5, + }; + + // Evidence layers (auto-grouped) + const layers = [ + _layer('L1 Macro', [signals.find(s => s.name === 'MCap 24h'), signals.find(s => s.name === 'BTC Dom')]), + _layer('L2 Cycle', [signals.find(s => s.name === 'MVRV Z')]), + _layer('L3 Derivs', [signals.find(s => s.name === 'BTC Funding'), signals.find(s => s.name === 'BTC OI 24h')]), + _layer('L4 Sentiment',[signals.find(s => s.name === 'Fear & Greed'), signals.find(s => s.name === 'Alt Breadth')]), + ]; + + return { score: normScore, verdict, confidence, signals, bullish, bearish, neutral, radar, layers }; +} + +function _r(v) { return Math.round(Math.max(0, Math.min(10, v))); } + +function _layer(name, sigs) { + const valid = sigs.filter(Boolean); + if (!valid.length) return { name, score: 5, max: 10, receipts: 0, verdict: 'NEUTRAL' }; + const raw = valid.reduce((sum, s) => sum + s.score, 0); + const max = valid.length * 3; + const score = Math.round(((raw + max) / (2 * max)) * 10); + const verdict = score >= 7 ? 'BULLISH' : score >= 4 ? 'NEUTRAL' : 'BEARISH'; + return { name, score, max: 10, receipts: valid.length, verdict }; +} + +// ── Formatting helpers ───────────────────────────────────────────────────────── + +function _ilFmt$(n) { + if (n == null) return '—'; + if (n >= 1e9) return '$' + (n / 1e9).toFixed(2) + 'B'; + if (n >= 1e6) return '$' + (n / 1e6).toFixed(1) + 'M'; + return '$' + n.toLocaleString(); +} +function _ilFmtP(n, dp = 2) { + if (n == null) return '—'; + return (n > 0 ? '+' : '') + n.toFixed(dp) + '%'; +} +function _ilFmtPrice(n) { + if (!n) return '—'; + const dp = n < 1 ? 4 : n < 100 ? 2 : 0; + return '$' + n.toLocaleString(undefined, { minimumFractionDigits: dp, maximumFractionDigits: dp }); +} +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'; +} + +// ── Load & Render ───────────────────────────────────────────────────────────── + +async function loadIntel() { + const el = document.getElementById('intel-content'); + if (!el) return; + + // If we have data < 3 min old, just re-render + if (_intelData && Date.now() - _intelData.ts < 3 * 60 * 1000) { + _renderIntel(el, _intelData, _intelRegime); + return; + } + + el.innerHTML = `
    Fetching live market data…
    `; + + try { + _intelData = await _fetchIntelData(); + _intelRegime = _scoreIntel(_intelData); + } catch (e) { + el.innerHTML = `
    Failed to load intel data: ${e.message}
    `; + return; + } + + _renderIntel(el, _intelData, _intelRegime); + + clearInterval(_intelTimer); + _intelTimer = setInterval(async () => { + _intelData = await _fetchIntelData().catch(() => _intelData); + _intelRegime = _scoreIntel(_intelData); + _renderIntel(document.getElementById('intel-content'), _intelData, _intelRegime); + }, 3 * 60 * 1000); +} + +function _renderIntel(el, d, r) { + if (!el) return; + const scorePct = ((r.score + 10) / 20) * 100; + const scoreColor = r.score > 2 ? 'var(--green)' : r.score < -2 ? 'var(--red)' : 'var(--yellow)'; + const heatColor = r.score > 4 ? 'var(--green)' : r.score < -3 ? 'var(--red)' : 'var(--yellow)'; + const btcDomNum = d.btcDom ?? 0; + const evTotal = r.layers.reduce((a, l) => a + l.score, 0); + const evMax = r.layers.reduce((a, l) => a + l.max, 0); + const evPct = Math.round(evTotal / evMax * 100); + const fgVal = d.fng?.value ?? null; + const updStr = new Date(d.ts).toLocaleTimeString(); + + el.innerHTML = ` + + ${typeof intelIndicatorStrip === 'function' ? intelIndicatorStrip() : ''} + + +
    +
    +
    +
    PORTFOLIO POSTURE LIVE · ${updStr}
    +
    ${r.verdict}
    +
    +
    +
    + Score ${r.score > 0 ? '+' : ''}${r.score} / ±10 +
    +
    +
    +
    +
    +
    ${r.confidence}% confidence · ${r.bullish} bull / ${r.neutral} neutral / ${r.bearish} bear signals
    +
    +
    +
    MVRV Z${d.mvrvZ != null ? d.mvrvZ.toFixed(2) : '—'}
    +
    F&G${fgVal ?? '—'} ${d.fng?.label ?? ''}
    +
    SourcesHL · BN · BY · CG
    +
    +
    +
    + + + +
    +
    + + +
    + ${_renderMetricCell('BTC Price', _ilFmtPrice(d.prices.BTC), d.cgCoins?.find(c => c.symbol === 'btc')?.price_change_percentage_24h, 'vs 24h ago')} + ${_renderMetricCell('BTC Funding APR', d.btcFundApr != null ? (d.btcFundApr > 0 ? '+' : '') + d.btcFundApr.toFixed(1) + '%' : '—', null, d.btcFundApr != null ? (d.btcFundApr < 5 ? 'low · uncrowded' : d.btcFundApr > 20 ? 'very crowded' : 'elevated') : 'HL + BN + BY avg', false)} + ${_renderMetricCell('BTC OI (Binance)', _ilFmt$(d.bnBtcOiUsd), d.oiChange24h, 'vs 24h ago')} + ${_renderMetricCell('Alt Breadth', d.altBreadth != null ? d.altBreadth + '%' : '—', null, 'top 100 coins up 24h', false, d.altBreadth != null && d.altBreadth > 60, d.altBreadth != null && d.altBreadth < 35)} + ${_renderMetricCell('BTC Dominance', d.btcDom != null ? d.btcDom.toFixed(1) + '%' : '—', null, `alt season ${btcDomNum > 55 ? 'far' : 'near'}`, false)} + ${_renderMetricCell('Total MCap', _ilFmt$(d.totalMcap), d.mcapChange24h, 'vs 24h ago')} +
    + + +
    +
    Live Funding & OI
    +
    + + + + ${INTEL_TRACK.map(coin => { + const apr = d.hlFundApr[coin]; + const aprCls = apr != null ? (apr < 0 ? 'pos' : apr > 20 ? 'neg' : '') : ''; + return ` + + + + + + `; + }).join('')} + +
    CoinPriceHL Funding APRHL OIETH Funding APR (BN)
    ${coin}${_ilFmtPrice(d.prices[coin])}${apr != null ? (apr > 0 ? '+' : '') + apr.toFixed(1) + '%' : '—'}${_ilFmt$(d.hlOI[coin])}${coin === 'ETH' ? (d.ethFundApr != null ? (d.ethFundApr > 0 ? '+' : '') + d.ethFundApr.toFixed(1) + '%' : '—') : '—'}
    +
    +
    + + +
    + + +
    + +
    +
    +
    Regime Radar
    + 0–10 per axis · auto-scored +
    +
    + +
    +
    + ${Object.entries(r.radar).map(([k, v]) => { + const color = v >= 7 ? 'var(--green)' : v >= 5 ? 'var(--yellow)' : 'var(--red)'; + return `
    + ${k} + ${v} +
    `; + }).join('')} +
    +
    + +
    +
    Signal Breakdown
    +
    + + + + ${r.signals.map(s => { + const bar = Math.abs(s.score) / 3 * 100; + const cls = s.score > 0 ? 'pos' : s.score < 0 ? 'neg' : 'muted'; + return ` + + + + + `; + }).join('')} + +
    SignalReadingWeightNote
    ${s.name}${s.value ?? '—'} +
    +
    +
    +
    ${s.note}
    +
    +
    + +
    + + +
    + +
    +
    +
    Evidence Trail
    + ${evPct}% +
    + ${r.layers.map(l => { + const pct = Math.round(l.score / l.max * 100); + const color = l.score >= 7 ? 'var(--green)' : l.score >= 4 ? 'var(--yellow)' : 'var(--red)'; + return `
    +
    + ${l.name} +
    + ${l.verdict} + ${l.score}/${l.max} + ${l.receipts} sig${l.receipts !== 1 ? 's' : ''} +
    +
    +
    +
    `; + }).join('')} +
    + + ${_renderTopMovers(d.cgCoins)} + +
    +
    Cycle Score
    +
    + ${Math.round((r.score + 10) / 20 * 100)} + /100 +
    +
    +
    +
    +
    +
    +
    ${r.bullish}
    +
    Bull
    +
    +
    +
    ${r.neutral}
    +
    Neutral
    +
    +
    +
    ${r.bearish}
    +
    Bear
    +
    +
    +
    + +
    +
    + + +
    +
    +
    AI Synthesis
    + +
    + ${_intelSynth + ? `
    ${_intelSynth}
    ` + : `
    ${_intelEdgeUrl() ? 'Click "AI Synthesis" above to generate market analysis.' : 'Configure the Claude Edge Function in the AI tab to enable synthesis.'}
    ` + } +
    + + +
    +
    +
    Desk Setups
    + +
    + ${_intelSetups ? _renderParsedSetups(_intelSetups) : `
    ${_intelEdgeUrl() ? 'Click "Generate Setups" above — Claude will analyse live funding, OI, and price data to suggest entries.' : 'Configure the Claude Edge Function in the AI tab to generate setups.'}
    `} +
    + + + ${_renderStagedInIntel()} + `; + + setTimeout(_renderIntelRadar, 0); +} + +// ── Sub-renders ─────────────────────────────────────────────────────────────── + +function _renderMetricCell(label, value, pctChange, sub, colorPct = true, isPos = false, isNeg = false) { + const changePart = pctChange != null + ? `
    ${_ilFmtP(pctChange)} ${sub || ''}
    ` + : `
    ${sub || ''}
    `; + const valueColor = isPos ? 'color:var(--green)' : isNeg ? 'color:var(--red)' : ''; + return `
    +
    ${label}
    +
    ${value}
    + ${changePart} +
    `; +} + +function _renderTopMovers(cgCoins) { + if (!cgCoins?.length) return ''; + const sorted = [...cgCoins].sort((a, b) => Math.abs(b.price_change_percentage_24h || 0) - Math.abs(a.price_change_percentage_24h || 0)); + const movers = sorted.slice(0, 8); + return `
    +
    Top Movers (24h)
    +
    + + + + ${movers.map(c => { + const p24 = c.price_change_percentage_24h || 0; + const p7 = c.price_change_percentage_7d_in_currency; + return ` + + + + + + `; + }).join('')} + +
    #CoinPrice24h7d
    ${c.market_cap_rank}${c.symbol.toUpperCase()} ${c.name}${_ilFmtPrice(c.current_price)}${_ilFmtP(p24)}${p7 != null ? _ilFmtP(p7) : '—'}
    +
    +
    `; +} + +function _intelEdgeUrl() { + return (typeof _edgeUrl === 'function' ? _intelEdgeUrl() : null) || localStorage.getItem('hype_edge_fn_url') || ''; +} + +function _renderStagedInIntel() { + if (typeof _stageTrades === 'undefined' || !Array.isArray(_stageTrades) || !_stageTrades.length) return ''; + const active = _stageTrades.filter(t => t.status === 'staged' || t.status === 'watching'); + if (!active.length) return ''; + return `
    +
    Staged Trades (from AI tab)
    +
    + ${active.map(t => { + const cls = (t.direction === 'Long' || t.direction === 'Spot Buy') ? 'pos' : 'neg'; + return `
    + ${t.coin} + ${t.direction.toUpperCase()} + ${t.entry_price ? `Entry ${(+t.entry_price).toLocaleString()}` : ''} + ${t.rationale ? `${t.rationale}` : ''} +
    `; + }).join('')} +
    +
    `; +} + +function _renderParsedSetups(text) { + return `
    ${text.replace(/\*\*(.+?)\*\*/g,'$1').replace(/`(.+?)`/g,'$1')}
    `; +} + +// ── Regime Radar Chart ──────────────────────────────────────────────────────── + +function _renderIntelRadar() { + const canvas = document.getElementById('regime-radar-chart'); + if (!canvas || typeof Chart === 'undefined' || !_intelRegime) return; + // Destroy existing chart instance if any + const existing = Chart.getChart(canvas); + if (existing) existing.destroy(); + + const data = _intelRegime.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 } }, + }, + }); +} + +// ── Claude Actions ──────────────────────────────────────────────────────────── + +function _intelClaudeCtx() { + if (!_intelData || !_intelRegime) return null; + const d = _intelData, r = _intelRegime; + return `Live market snapshot (${new Date(d.ts).toUTCString()}): +- Regime verdict: ${r.verdict} (score ${r.score}/±10, ${r.confidence}% confidence) +- BTC price: ${_ilFmtPrice(d.prices.BTC)}, ETH: ${_ilFmtPrice(d.prices.ETH)}, SOL: ${_ilFmtPrice(d.prices.SOL)}, HYPE: ${_ilFmtPrice(d.prices.HYPE)} +- BTC funding APR (cross-exchange avg): ${d.btcFundApr != null ? d.btcFundApr.toFixed(2) + '%' : 'N/A'} +- Binance BTC OI: ${_ilFmt$(d.bnBtcOiUsd)}, 24h change: ${d.oiChange24h != null ? _ilFmtP(d.oiChange24h) : 'N/A'} +- BTC dominance: ${d.btcDom != null ? d.btcDom.toFixed(1) + '%' : 'N/A'} +- Altcoin breadth: ${d.altBreadth != null ? d.altBreadth + '%' : 'N/A'} of top 100 coins up 24h +- Fear & Greed: ${d.fng ? d.fng.value + ' (' + d.fng.label + ')' : 'N/A'} +- MVRV Z-Score: ${d.mvrvZ != null ? d.mvrvZ.toFixed(2) : 'N/A'} +- Total market cap: ${_ilFmt$(d.totalMcap)}, 24h change: ${d.mcapChange24h != null ? _ilFmtP(d.mcapChange24h) : 'N/A'} +- Signal breakdown: ${r.signals.map(s => `${s.name} ${s.value} (${s.score > 0 ? '+' : ''}${s.score})`).join(', ')} + +HL per-coin funding APR: ${INTEL_TRACK.map(c => `${c} ${d.hlFundApr[c] != null ? d.hlFundApr[c].toFixed(1) + '%' : 'N/A'}`).join(', ')}`; +} + +async function intelGenSynth() { + const url = _intelEdgeUrl(); + if (!url) { alert('Configure Claude in the AI tab first (⚙ Setup).'); return; } + const ctx = _intelClaudeCtx(); + if (!ctx) { alert('Data not loaded yet — wait for the page to finish loading.'); return; } + + const btn = document.getElementById('intel-synth-btn') || document.getElementById('intel-synth-btn2'); + if (btn) { btn.disabled = true; btn.textContent = '✦ Generating…'; } + + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: `Based on this live market data, write a concise 3-4 sentence trading posture synthesis. Include: current regime assessment, key risk or opportunity, and one actionable note. Be direct and specific.\n\n${ctx}`, + history: [], + }), + }); + const d = await r.json(); + _intelSynth = d.reply || d.content || d.text || '(empty response)'; + } catch (e) { + _intelSynth = `Error: ${e.message}`; + } + + // Update just the synthesis card + const card = document.getElementById('intel-synth-card'); + if (card) { + card.innerHTML = `
    +
    AI Synthesis
    + +
    +
    ${_intelSynth}
    `; + } +} + +async function intelGenSetups() { + const url = _intelEdgeUrl(); + if (!url) { alert('Configure Claude in the AI tab first (⚙ Setup).'); return; } + const ctx = _intelClaudeCtx(); + if (!ctx) { alert('Data not loaded yet.'); return; } + + const btn = document.getElementById('intel-setups-btn') || document.getElementById('intel-setups-btn2'); + if (btn) { btn.disabled = true; btn.textContent = '✦ Generating…'; } + + try { + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + message: `Based on this live market data, suggest 2-3 specific trade setups. For each: coin, direction (Long/Short), approximate entry price (% from current), stop loss, take profit, and a 1-sentence rationale grounded in the data. Format clearly with **COIN** headers.\n\n${ctx}`, + history: [], + }), + }); + const d = await r.json(); + _intelSetups = d.reply || d.content || d.text || '(empty response)'; + } catch (e) { + _intelSetups = `Error: ${e.message}`; + } + + const card = document.getElementById('intel-setups-card'); + if (card) { + card.innerHTML = `
    +
    Desk Setups
    + +
    + ${_renderParsedSetups(_intelSetups)}`; + } +} + +async function intelRefresh() { + _intelData = null; + await loadIntel(); +} + +// ── Keep backward-compat strip (uses mvrv-ai.js + indicators.js globals) ────── + +function intelIndicatorStrip() { + const ind = window._indData; + const fg = ind?.fear_greed; + const bmsb = ind?.bmsb; + const 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) : '—'}
    +
    `; +} 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..1bda25f --- /dev/null +++ b/frontend/js/kb.js @@ -0,0 +1,911 @@ +/* ============================================================ + kb.js — Knowledge Base: Wiki · Journal + ============================================================ */ + +'use strict'; + +// ── Supabase init ───────────────────────────────────────────── +const _KB_URL = 'https://eiqlvbylkcmgvksrxqld.supabase.co'; +const _KB_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVpcWx2Ynlsa2NtZ3Zrc3J4cWxkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg5NTI4NjgsImV4cCI6MjA5NDUyODg2OH0.PcGDHYlajqwnZ7c3ZPtssG534kd3sKwE8aT1ROlFpo8'; +const _kbDb = (window.supabase && window.supabase.createClient) + ? window.supabase.createClient(_KB_URL, _KB_KEY) + : null; + +// ── State ──────────────────────────────────────────────────── +let _kbTab = 'wiki'; +let _kbWiki = []; +let _kbTrades = []; +let _kbWikiFilter = 'all'; +let _kbLoaded = false; +let _activeEntryId = null; // trade currently showing entry questions +let _kbCloseId = null; // trade currently being closed +let _kbFormOpen = false; // wiki add form toggle + +// ── HTML escape ─────────────────────────────────────────────── +function kbEsc(s) { + if (s == null) return ''; + return String(s) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +// ── ID generator ───────────────────────────────────────────── +function kbId() { + return Date.now().toString(36) + Math.random().toString(36).slice(2, 7); +} + +// ── Toast ───────────────────────────────────────────────────── +function kbToast(msg, type = 'success') { + const el = document.createElement('div'); + const bg = type === 'success' ? 'var(--green)' : type === 'error' ? 'var(--red)' : 'var(--accent)'; + el.style.cssText = `position:fixed;bottom:20px;right:20px;z-index:99999;background:${bg};color:#fff;` + + `padding:10px 18px;border-radius:var(--radius-md);font-size:13px;font-weight:600;` + + `box-shadow:0 4px 16px rgba(0,0,0,.4);transition:opacity .3s;max-width:320px;line-height:1.4;`; + el.textContent = msg; + document.body.appendChild(el); + setTimeout(() => { el.style.opacity = '0'; setTimeout(() => el.remove(), 300); }, 3000); +} + +// ── Conviction dots ─────────────────────────────────────────── +function kbConvictionDots(n) { + let s = ''; + for (let i = 1; i <= 5; i++) { + s += ``; + } + return s; +} + +// ── Data loading ────────────────────────────────────────────── +async function _kbLoadData() { + if (!_kbDb) return; + try { + const [wr, tr] = await Promise.all([ + _kbDb.from('kb_wiki').select('*').order('created_at', { ascending: false }), + _kbDb.from('kb_trades').select('*').order('created_at', { ascending: false }) + ]); + if (wr.data) _kbWiki = wr.data; + if (tr.data) _kbTrades = tr.data; + } catch (e) { + console.warn('[KB] load error', e); + } +} + +async function loadKB() { + const el = document.getElementById('kb-content'); + if (!el) return; + el.innerHTML = '
    Loading…
    '; + await _kbLoadData(); + _kbLoaded = true; + renderKBShell(); +} + +// ── Shell ───────────────────────────────────────────────────── +function renderKBShell() { + const el = document.getElementById('kb-content'); + if (!el) return; + + if (!_kbDb) { + el.innerHTML = '
    ⚠️ Supabase not connected.
    '; + return; + } + + const openTrades = _kbTrades.filter(t => t.status === 'OPEN' || t.status === 'open'); + const closedTrades = _kbTrades.filter(t => t.status === 'CLOSED' || t.status === 'closed'); + const wins = closedTrades.filter(t => (t.pnl_usd || 0) > 0).length; + const winRate = closedTrades.length ? Math.round(wins / closedTrades.length * 100) : 0; + const totalPnl = closedTrades.reduce((s, t) => s + (t.pnl_usd || 0), 0); + const pnlClass = totalPnl >= 0 ? 'pos' : 'neg'; + const pnlAbs = (typeof fmt$ === 'function') ? fmt$(Math.abs(totalPnl)) : '$' + Math.abs(totalPnl).toFixed(2); + const pnlStr = (totalPnl >= 0 ? '+' : '-') + pnlAbs; + + const statsHTML = ` +
    +
    +
    Wiki Entries
    +
    ${_kbWiki.length}
    +
    +
    +
    Open Trades
    +
    ${openTrades.length}
    +
    +
    +
    Win Rate
    +
    ${closedTrades.length ? winRate + '%' : '—'}
    +
    +
    +
    Total P&L
    +
    ${closedTrades.length ? pnlStr : '—'}
    +
    +
    `; + + const tabBar = ` +
    + + +
    `; + + el.innerHTML = statsHTML + tabBar + '
    '; + _kbRenderActiveTab(); +} + +function kbSetTab(tab) { + _kbTab = tab; + const el = document.getElementById('kb-tab-body'); + if (!el) { renderKBShell(); return; } + _kbRenderActiveTab(); +} + +function _kbRenderActiveTab() { + const el = document.getElementById('kb-tab-body'); + if (!el) return; + if (_kbTab === 'wiki') el.innerHTML = renderWikiTab(); + if (_kbTab === 'journal') el.innerHTML = renderJournalTab(); +} + +// ══════════════════════════════════════════════════════════════ +// WIKI TAB +// ══════════════════════════════════════════════════════════════ + +const _WIKI_CATS = ['concept','pattern','rule','playbook','lesson']; +const _WIKI_CAT_COLORS = { + concept: '#3b82f6', + pattern: '#8b5cf6', + rule: '#f97316', + playbook: '#22c55e', + lesson: '#eab308' +}; + +function renderWikiTab() { + // category filter chips + const filterChips = ['all', ..._WIKI_CATS].map(c => + `` + ).join(''); + + // add form — show by default if empty + const showForm = _kbFormOpen || _kbWiki.length === 0; + const addForm = ` +
    +
    + + New Wiki Entry + ${showForm ? '▲ collapse' : '▼ expand'} +
    +
    +
    + + +
    +
    + + +
    + +
    + + +
    +
    +
    `; + + // filtered entries + const filtered = _kbWikiFilter === 'all' + ? _kbWiki + : _kbWiki.filter(w => w.category === _kbWikiFilter); + + const cardsHTML = filtered.length + ? filtered.map(w => _wikiCardHTML(w)).join('') + : `
    No wiki entries yet.
    `; + + return ` +
    +
    ${filterChips}
    + ${addForm} +
    ${cardsHTML}
    +
    `; +} + +function _wikiCardHTML(w) { + const col = _WIKI_CAT_COLORS[w.category] || 'var(--text-muted)'; + const coins = Array.isArray(w.coins) ? w.coins : []; + const tags = Array.isArray(w.tags) ? w.tags : []; + const refs = Array.isArray(w.trade_refs) ? w.trade_refs : []; + const dateStr = w.created_at + ? new Date(typeof w.created_at === 'number' ? w.created_at : Number(w.created_at)).toLocaleDateString() + : ''; + + return ` +
    +
    + ${kbEsc(w.category)} + ${w.source && w.source !== 'manual' ? `AI` : ''} + ${kbEsc(w.title)} + ${dateStr} +
    + ${w.summary ? `
    ${kbEsc(w.summary)}
    ` : ''} + +
    + ${coins.map(c => `${kbEsc(c)}`).join('')} + ${tags.map(t => `${kbEsc(t)}`).join('')} + ${refs.length ? `📓 ${refs.length} trade${refs.length > 1 ? 's' : ''}` : ''} +
    +
    + + + + +
    + +
    `; +} + +function kbToggleWikiForm() { + _kbFormOpen = !_kbFormOpen; + const form = document.getElementById('kb-wiki-form'); + const tog = document.getElementById('kb-wiki-form-toggle'); + if (form) form.style.display = _kbFormOpen ? 'block' : 'none'; + if (tog) tog.textContent = _kbFormOpen ? '▲ collapse' : '▼ expand'; +} + +function kbSetWikiFilter(f) { + _kbWikiFilter = f; + _kbRenderActiveTab(); +} + +function kbWikiToggleContent(id) { + const el = document.getElementById('wcontent-' + id); + const btn = document.getElementById('wexp-' + id); + if (!el) return; + const open = el.style.display === 'none'; + el.style.display = open ? 'block' : 'none'; + if (btn) btn.textContent = open ? 'Collapse' : 'Expand'; +} + +async function kbSaveWikiEntry() { + const title = (document.getElementById('kbwf-title')?.value || '').trim(); + const cat = document.getElementById('kbwf-cat')?.value || 'lesson'; + const coinsRaw= (document.getElementById('kbwf-coins')?.value || '').trim(); + const tagsRaw = (document.getElementById('kbwf-tags')?.value || '').trim(); + const content = (document.getElementById('kbwf-content')?.value || '').trim(); + + if (!title) { kbToast('Title is required', 'error'); return; } + + const coins = coinsRaw ? coinsRaw.split(',').map(s => s.trim()).filter(Boolean) : []; + const tags = tagsRaw ? tagsRaw.split(',').map(s => s.trim()).filter(Boolean) : []; + const now = Date.now(); + + const entry = { + id: kbId(), created_at: now, updated_at: now, + title, category: cat, coins, tags, content, + summary: '', trade_refs: [], source: 'manual', wiki_ids: [] + }; + + const { error } = await _kbDb.from('kb_wiki').insert([entry]); + if (error) { kbToast('Save failed: ' + error.message, 'error'); return; } + + _kbWiki.unshift(entry); + _kbFormOpen = false; + _kbRenderActiveTab(); + kbToast('Wiki entry saved'); +} + +async function kbDeleteWikiEntry(id) { + if (!confirm('Delete this wiki entry?')) return; + await _kbDb.from('kb_wiki').delete().eq('id', id); + _kbWiki = _kbWiki.filter(w => w.id !== id); + _kbRenderActiveTab(); + kbToast('Deleted'); +} + +function kbWikiLinkTradeToggle(wikiId) { + const el = document.getElementById('wlink-' + wikiId); + if (!el) return; + if (el.style.display !== 'none') { el.style.display = 'none'; return; } + + const openTrades = _kbTrades.filter(t => t.status === 'OPEN' || t.status === 'open'); + if (!openTrades.length) { + el.style.display = 'block'; + el.innerHTML = 'No open trades to link.'; + return; + } + + const opts = openTrades.map(t => + `` + ).join(''); + + el.style.display = 'block'; + el.innerHTML = `
    Link to trade:
    ${opts}`; +} + +async function kbWikiLinkTrade(wikiId, tradeId) { + const entry = _kbWiki.find(w => w.id === wikiId); + if (!entry) return; + const refs = Array.isArray(entry.trade_refs) ? [...entry.trade_refs] : []; + if (!refs.includes(tradeId)) refs.push(tradeId); + entry.trade_refs = refs; + entry.updated_at = Date.now(); + await _kbDb.from('kb_wiki').update({ trade_refs: refs, updated_at: entry.updated_at }).eq('id', wikiId); + + const trade = _kbTrades.find(t => t.id === tradeId); + if (trade) { + const wids = Array.isArray(trade.wiki_ids) ? [...trade.wiki_ids] : []; + if (!wids.includes(wikiId)) wids.push(wikiId); + trade.wiki_ids = wids; + await _kbDb.from('kb_trades').update({ wiki_ids: wids }).eq('id', tradeId); + } + + kbToast('Trade linked'); + const el = document.getElementById('wlink-' + wikiId); + if (el) el.style.display = 'none'; + _kbRenderActiveTab(); +} + +// ── Wiki AI ─────────────────────────────────────────────────── +async function kbWikiAiGenerate() { + const title = (document.getElementById('kbwf-title')?.value || '').trim(); + const cat = document.getElementById('kbwf-cat')?.value || 'lesson'; + const coinsRaw= (document.getElementById('kbwf-coins')?.value || '').trim(); + const tagsRaw = (document.getElementById('kbwf-tags')?.value || '').trim(); + + if (!title) { kbToast('Enter a title first', 'error'); return; } + + const btn = document.getElementById('kbwf-ai-btn'); + if (btn) { btn.disabled = true; btn.textContent = '⏳ Generating…'; } + + try { + const resp = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'wiki-generate', title, category: cat, coins: coinsRaw, tags: tagsRaw }) + }); + const data = await resp.json(); + if (data.title) { + const titleEl = document.getElementById('kbwf-title'); + const contentEl = document.getElementById('kbwf-content'); + if (titleEl) titleEl.value = data.title; + if (contentEl) contentEl.value = data.content || ''; + kbToast('AI content generated — review and save'); + } else { + kbToast('AI generation failed', 'error'); + } + } catch (e) { + kbToast('AI error: ' + e.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '🤖 AI Generate'; } + } +} + +async function kbWikiAiEnhance(wikiId) { + const entry = _kbWiki.find(w => w.id === wikiId); + if (!entry) return; + + const btn = document.getElementById('wenh-' + wikiId); + if (btn) { btn.disabled = true; btn.textContent = '⏳ Enhancing…'; } + + try { + const resp = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'wiki-enhance', title: entry.title, category: entry.category, content: entry.content }) + }); + const data = await resp.json(); + if (data.content) { + entry.summary = data.summary || entry.summary; + entry.content = data.content; + entry.updated_at = Date.now(); + await _kbDb.from('kb_wiki').update({ + summary: entry.summary, content: entry.content, updated_at: entry.updated_at + }).eq('id', wikiId); + _kbRenderActiveTab(); + kbToast('Entry enhanced'); + } else { + kbToast('AI enhance failed', 'error'); + } + } catch (e) { + kbToast('AI error: ' + e.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.textContent = '🤖 AI Enhance'; } + } +} + +// ══════════════════════════════════════════════════════════════ +// JOURNAL TAB +// ══════════════════════════════════════════════════════════════ + +function renderJournalTab() { + const open = _kbTrades.filter(t => t.status === 'OPEN' || t.status === 'open'); + const closed = _kbTrades.filter(t => t.status === 'CLOSED' || t.status === 'closed'); + const wins = closed.filter(t => (t.pnl_usd || 0) > 0).length; + const winRate = closed.length ? Math.round(wins / closed.length * 100) : 0; + const totalPnl = closed.reduce((s, t) => s + (t.pnl_usd || 0), 0); + const pnlClass = totalPnl >= 0 ? 'pos' : 'neg'; + const pnlAbs = (typeof fmt$ === 'function') ? fmt$(Math.abs(totalPnl)) : '$' + Math.abs(totalPnl).toFixed(2); + + const summaryStrip = ` +
    +
    Open
    ${open.length}
    +
    Closed
    ${closed.length}
    +
    Win Rate
    ${closed.length ? winRate + '%' : '—'}
    +
    Total P&L
    ${closed.length ? (totalPnl >= 0 ? '+' : '-') + pnlAbs : '—'}
    +
    `; + + const tradeForm = ` +
    +
    + + Log Trade + ▼ expand +
    + +
    `; + + let entryPanel = ''; + if (_activeEntryId) { + const t = _kbTrades.find(x => x.id === _activeEntryId); + if (t) entryPanel = _entryQuestionsHTML(t); + } + + const openCards = open.length + ? open.map(t => _openTradeCardHTML(t)).join('') + : `
    No open trades.
    `; + + const closedCards = closed.length + ? closed.map(t => _closedTradeCardHTML(t)).join('') + : `
    No closed trades yet.
    `; + + return ` +
    + ${summaryStrip} + ${tradeForm} + ${entryPanel} +
    +
    Open Positions
    +
    ${openCards}
    +
    +
    +
    Closed Positions
    +
    ${closedCards}
    +
    +
    `; +} + +function _entryQuestionsHTML(t) { + const conv = t.conviction || 0; + const convChips = [1,2,3,4,5].map(n => + `` + ).join(''); + + return ` +
    +
    🧠 Why did you enter this trade? ${kbEsc(t.coin)} ${kbEsc(t.direction)}
    +
    +
    +
    1. What technical setup or signal triggered your entry?
    + +
    +
    +
    2. In your own words, why will this trade work?
    + +
    +
    +
    3. What would tell you this trade idea is WRONG (beyond just hitting stop)?
    + +
    +
    +
    4. How confident are you? 1–5
    +
    ${convChips}
    +
    +
    +
    5. How long do you expect this to play out?
    + +
    +
    +
    + + +
    +
    `; +} + +let _selectedConviction = 3; +function kbSetConviction(n) { + _selectedConviction = n; + const wrap = document.getElementById('eq-conviction-wrap'); + if (!wrap) return; + wrap.querySelectorAll('.chip').forEach((btn, i) => { + btn.classList.toggle('chip-active', i + 1 === n); + }); +} + +function kbCancelEntryQuestions() { + _activeEntryId = null; + _kbRenderActiveTab(); +} + +async function kbSaveEntryAnswers(tradeId) { + const trade = _kbTrades.find(t => t.id === tradeId); + if (!trade) return; + + const setup = (document.getElementById('eq-setup')?.value || '').trim(); + const thesis = (document.getElementById('eq-thesis')?.value || '').trim(); + const invalidation = (document.getElementById('eq-invalidation')?.value || '').trim(); + const conviction = _selectedConviction; + const expected_tf = (document.getElementById('eq-timeline')?.value || '').trim(); + + const updates = { setup, thesis, invalidation, conviction, expected_tf, updated_at: Date.now() }; + const { error } = await _kbDb.from('kb_trades').update(updates).eq('id', tradeId); + if (error) { kbToast('Save failed: ' + error.message, 'error'); return; } + + Object.assign(trade, updates); + _activeEntryId = null; + kbToast('Entry answers recorded'); + _kbRenderActiveTab(); +} + +function _openTradeCardHTML(t) { + const dirColor = t.direction === 'LONG' ? 'var(--green)' : 'var(--red)'; + const entryFmt = t.entry_price != null ? '$' + Number(t.entry_price).toLocaleString() : '—'; + const slFmt = t.stop_loss != null ? '$' + Number(t.stop_loss).toLocaleString() : '—'; + const tpFmt = t.take_profit != null ? '$' + Number(t.take_profit).toLocaleString() : '—'; + const sizeFmt = t.size_usd != null ? '$' + Number(t.size_usd).toLocaleString() : '—'; + const dateStr = t.created_at + ? new Date(typeof t.created_at === 'number' ? t.created_at : Number(t.created_at)).toLocaleDateString() + : ''; + const conv = t.conviction || 0; + const hasAnswers = t.thesis || t.invalidation; + const isClosing = _kbCloseId === t.id; + + const convDots = kbConvictionDots(conv); + const closePanel = isClosing ? _closePanelHTML(t) : ''; + + return ` +
    +
    + ${kbEsc(t.coin || '—')} + ${kbEsc(t.direction || '')} + ${t.timeframe ? `${kbEsc(t.timeframe)}` : ''} + + ${dateStr} +
    +
    + Entry: ${entryFmt} + SL: ${slFmt} + TP: ${tpFmt} + Size: ${sizeFmt} +
    + ${t.setup ? `
    Setup: ${kbEsc(t.setup)}
    ` : ''} + ${t.thesis ? `
    Thesis: ${kbEsc(t.thesis.slice(0,120))}${t.thesis.length > 120 ? '…' : ''}
    ` : ''} + ${conv ? `
    Conviction:${convDots}
    ` : ''} + ${!hasAnswers ? `
    ⚠️ Answer entry questions
    ` : ''} + ${!isClosing ? ` +
    + + ${!hasAnswers ? `` : ''} + +
    ` : ''} + ${closePanel} +
    `; +} + +function kbShowEntryQuestions(tradeId) { + _activeEntryId = tradeId; + _selectedConviction = (_kbTrades.find(t => t.id === tradeId)?.conviction) || 3; + _kbRenderActiveTab(); + setTimeout(() => { + const panel = document.querySelector('[style*="border-left:3px solid var(--accent)"]'); + if (panel) panel.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }, 100); +} + +function kbStartClose(tradeId) { + _kbCloseId = tradeId; + _kbRenderActiveTab(); +} + +function _closePanelHTML(t) { + return ` +
    +
    📊 Close Trade — ${kbEsc(t.coin)} ${kbEsc(t.direction)}
    + +
    +
    Did your thesis play out?
    +
    + + + +
    +
    + + + +
    + + +
    +
    `; +} + +let _thesisResult = ''; +function kbSetThesisResult(val) { + _thesisResult = val; + ['yes','partly','no'].forEach(v => { + const btn = document.getElementById('kbcp-' + v); + if (btn) btn.classList.toggle('chip-active', v === val); + }); +} + +function kbCancelClose() { + _kbCloseId = null; + _kbRenderActiveTab(); +} + +async function kbConfirmClose(tradeId) { + const trade = _kbTrades.find(t => t.id === tradeId); + if (!trade) return; + + const exitPrice = parseFloat(document.getElementById('kbcp-exit')?.value); + if (!exitPrice) { kbToast('Exit price is required', 'error'); return; } + + const exit_review = (document.getElementById('kbcp-review')?.value || '').trim(); + const mistakes = (document.getElementById('kbcp-mistakes')?.value || '').trim(); + const lessons = (document.getElementById('kbcp-lesson')?.value || '').trim(); + const thesis_correct = _thesisResult; + + let pnl_usd = null, pnl_pct = null; + if (trade.entry_price && trade.size_usd) { + pnl_usd = trade.direction === 'LONG' + ? (exitPrice - trade.entry_price) / trade.entry_price * trade.size_usd + : (trade.entry_price - exitPrice) / trade.entry_price * trade.size_usd; + pnl_pct = pnl_usd / trade.size_usd * 100; + } else if (trade.entry_price) { + pnl_pct = trade.direction === 'LONG' + ? (exitPrice - trade.entry_price) / trade.entry_price * 100 + : (trade.entry_price - exitPrice) / trade.entry_price * 100; + } + + const now = Date.now(); + const updates = { + status: 'CLOSED', exit_price: exitPrice, + pnl_usd: pnl_usd != null ? parseFloat(pnl_usd.toFixed(4)) : null, + pnl_pct: pnl_pct != null ? parseFloat(pnl_pct.toFixed(4)) : null, + exit_review, mistakes, lessons, thesis_correct, + updated_at: now + }; + + const { error } = await _kbDb.from('kb_trades').update(updates).eq('id', tradeId); + if (error) { kbToast('Close failed: ' + error.message, 'error'); return; } + + Object.assign(trade, updates); + _kbCloseId = null; + _thesisResult = ''; + _kbRenderActiveTab(); + kbToast('Trade closed! Running AI analysis…', 'info'); + + _analyzeTradeWithAI(trade); +} + +async function _analyzeTradeWithAI(trade) { + try { + const resp = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: 'trade-analyze', trade }) + }); + const data = await resp.json(); + if (!data.assessment && !data.wiki_entry) { + kbToast('AI analysis returned empty result', 'error'); + return; + } + + if (data.assessment) { + await _kbDb.from('kb_trades').update({ ai_analysis: data.assessment }).eq('id', trade.id); + trade.ai_analysis = data.assessment; + } + + if (data.wiki_entry) { + const we = data.wiki_entry; + const now = Date.now(); + const newEntry = { + id: kbId(), created_at: now, updated_at: now, + title: we.title || 'Lesson from ' + trade.coin, + category: we.category || 'lesson', + summary: we.summary || '', + content: we.content || '', + tags: we.tags || [], + coins: we.coins || [trade.coin], + trade_refs: [trade.id], + source: 'ai-trade', + wiki_ids: [] + }; + const { error: we_err } = await _kbDb.from('kb_wiki').insert([newEntry]); + if (!we_err) { + _kbWiki.unshift(newEntry); + const wids = Array.isArray(trade.wiki_ids) ? [...trade.wiki_ids, newEntry.id] : [newEntry.id]; + trade.wiki_ids = wids; + await _kbDb.from('kb_trades').update({ wiki_ids: wids }).eq('id', trade.id); + kbToast('✅ Wiki lesson created: "' + newEntry.title + '"'); + } else { + kbToast('AI analysis done (wiki save failed)', 'error'); + } + } + + renderKBShell(); + } catch (e) { + kbToast('AI analysis error: ' + e.message, 'error'); + } +} + +function _closedTradeCardHTML(t) { + const dirColor = t.direction === 'LONG' ? 'var(--green)' : 'var(--red)'; + const pnlClass = (t.pnl_usd || 0) >= 0 ? 'pos' : 'neg'; + const pnlSign = (t.pnl_usd || 0) >= 0 ? '+' : '-'; + const pnlAbs = (typeof fmt$ === 'function') ? fmt$(Math.abs(t.pnl_usd || 0)) : '$' + Math.abs(t.pnl_usd || 0).toFixed(2); + const pctStr = t.pnl_pct != null ? ` (${t.pnl_pct >= 0 ? '+' : ''}${Number(t.pnl_pct).toFixed(2)}%)` : ''; + const entryFmt = t.entry_price != null ? '$' + Number(t.entry_price).toLocaleString() : '—'; + const exitFmt = t.exit_price != null ? '$' + Number(t.exit_price).toLocaleString() : '—'; + const dateStr = t.created_at + ? new Date(typeof t.created_at === 'number' ? t.created_at : Number(t.created_at)).toLocaleDateString() + : ''; + + const thesisIcon = t.thesis_correct === 'yes' ? '✅' : t.thesis_correct === 'partly' ? '〰️' : t.thesis_correct === 'no' ? '❌' : ''; + + const linkedWiki = Array.isArray(t.wiki_ids) && t.wiki_ids.length + ? `
    + ${t.wiki_ids.map(wid => { + const we = _kbWiki.find(w => w.id === wid); + return we + ? `` + : ''; + }).join('')} +
    ` + : ''; + + const aiText = t.ai_analysis + ? `
    + ${kbEsc(t.ai_analysis.slice(0, 150))}${t.ai_analysis.length > 150 ? '…' : ''} + ${t.ai_analysis.length > 150 + ? `` + : ''} +
    ` + : ''; + + return ` +
    +
    + ${kbEsc(t.coin || '—')} + ${kbEsc(t.direction || '')} + ${thesisIcon ? `${thesisIcon}` : ''} + + ${pnlSign}${pnlAbs}${pctStr} + ${dateStr} +
    +
    + Entry: ${entryFmt} + → Exit: ${exitFmt} + ${t.size_usd != null ? `Size: $${Number(t.size_usd).toLocaleString()}` : ''} +
    + ${t.exit_review ? `
    ${kbEsc(t.exit_review.slice(0,120))}${t.exit_review.length > 120 ? '…' : ''}
    ` : ''} + ${aiText} + ${linkedWiki} +
    + +
    +
    `; +} + +function kbToggleAI(tradeId) { + const trade = _kbTrades.find(t => t.id === tradeId); + if (!trade || !trade.ai_analysis) return; + const preview = document.getElementById('ai-preview-' + tradeId); + const btn = document.getElementById('ai-toggle-' + tradeId); + if (!preview || !btn) return; + const expanded = btn.textContent === 'Show less'; + if (expanded) { + preview.textContent = trade.ai_analysis.slice(0, 150) + (trade.ai_analysis.length > 150 ? '…' : ''); + btn.textContent = 'Show more'; + } else { + preview.textContent = trade.ai_analysis; + btn.textContent = 'Show less'; + } +} + +function kbJumpToWiki(wikiId) { + _kbTab = 'wiki'; + _kbWikiFilter = 'all'; + const el = document.getElementById('kb-tab-body'); + if (el) el.innerHTML = renderWikiTab(); + setTimeout(() => { + const card = document.getElementById('wcard-' + wikiId); + if (card) { + card.scrollIntoView({ behavior: 'smooth', block: 'start' }); + card.style.outline = '2px solid var(--accent)'; + setTimeout(() => { card.style.outline = ''; }, 2000); + } + }, 100); +} + +function kbToggleTradeForm() { + const form = document.getElementById('kb-trade-form'); + const tog = document.getElementById('kb-trade-form-toggle'); + if (!form) return; + const hidden = form.style.display === 'none'; + form.style.display = hidden ? 'block' : 'none'; + if (tog) tog.textContent = hidden ? '▲ collapse' : '▼ expand'; +} + +async function kbSaveTrade() { + const coin = (document.getElementById('kbtf-coin')?.value || '').trim().toUpperCase(); + const direction = document.getElementById('kbtf-direction')?.value || 'LONG'; + const timeframe = (document.getElementById('kbtf-timeframe')?.value || '').trim(); + const entry = parseFloat(document.getElementById('kbtf-entry')?.value) || null; + const sl = parseFloat(document.getElementById('kbtf-sl')?.value) || null; + const tp = parseFloat(document.getElementById('kbtf-tp')?.value) || null; + const size = parseFloat(document.getElementById('kbtf-size')?.value) || null; + const setup = (document.getElementById('kbtf-setup')?.value || '').trim(); + + if (!coin) { kbToast('Coin is required', 'error'); return; } + if (!entry) { kbToast('Entry price is required', 'error'); return; } + + const now = Date.now(); + const trade = { + id: kbId(), created_at: now, updated_at: now, + coin, direction, status: 'OPEN', + entry_price: entry, exit_price: null, + stop_loss: sl, take_profit: tp, size_usd: size, + pnl_usd: null, pnl_pct: null, + timeframe, setup, + thesis: '', invalidation: '', conviction: 3, expected_tf: '', + thesis_correct: '', exit_review: '', mistakes: '', lessons: '', + ai_analysis: '', wiki_ids: [], tags: [] + }; + + const { error } = await _kbDb.from('kb_trades').insert([trade]); + if (error) { kbToast('Save failed: ' + error.message, 'error'); return; } + + _kbTrades.unshift(trade); + _activeEntryId = trade.id; + _selectedConviction = 3; + kbToast('Trade logged! Answer entry questions below.'); + _kbRenderActiveTab(); +} + +async function kbDeleteTrade(id) { + if (!confirm('Delete this trade?')) return; + await _kbDb.from('kb_trades').delete().eq('id', id); + _kbTrades = _kbTrades.filter(t => t.id !== id); + _kbRenderActiveTab(); + kbToast('Trade deleted'); +} + +// ══════════════════════════════════════════════════════════════ +// NAVIGATION PATCH +// ══════════════════════════════════════════════════════════════ +(function() { + const _orig = window.navigate; + if (!_orig) return; + window.navigate = function(page) { + _orig(page); + if (page === 'kb') loadKB(); + }; +})(); diff --git a/frontend/js/logger.js b/frontend/js/logger.js new file mode 100644 index 0000000..8a8d577 --- /dev/null +++ b/frontend/js/logger.js @@ -0,0 +1,480 @@ +// ── 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 ──────────────────────────────────────────────────────────── + +function sbClient() { + 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() { + 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/mvrv-ai.js b/frontend/js/mvrv-ai.js new file mode 100644 index 0000000..92119bb --- /dev/null +++ b/frontend/js/mvrv-ai.js @@ -0,0 +1,1089 @@ +// ── MVRV + AI Knowledge Base (client-side, gh-pages) ──────────────────────── + +// ── MVRV ───────────────────────────────────────────────────────────────────── + +const MVRV_ZONE_META = { + OVERHEATED: { label: 'Overheated', cls: 'mvrv-zone-hot', color: 'var(--red)', desc: 'Price well above 90d avg — elevated risk' }, + BULLISH: { label: 'Bullish', cls: 'mvrv-zone-bull', color: 'var(--yellow)', desc: 'Above average — uptrend, watch for reversal' }, + NEUTRAL: { label: 'Neutral', cls: 'mvrv-zone-neutral', color: 'var(--text-muted)', desc: 'Near 90d avg — fair value range' }, + UNDERVALUED: { label: 'Undervalued', cls: 'mvrv-zone-under', color: 'var(--green)', desc: 'Below 90d avg — potential accumulation zone' }, +}; + +let _mvrvLearnOpen = false; + +function mvrvToggleLearn() { + _mvrvLearnOpen = !_mvrvLearnOpen; + const body = document.getElementById('mvrv-learn-body'); + const icon = document.getElementById('mvrv-learn-icon'); + if (body) body.style.display = _mvrvLearnOpen ? 'block' : 'none'; + if (icon) icon.style.transform = _mvrvLearnOpen ? 'rotate(180deg)' : 'rotate(0deg)'; +} + +function _mvrvLearnHtml(coins) { + const belowOne = Object.values(coins).filter(c => c.mvrv < 1.0); + + const zones = [ + { range: '> 2.0', label: 'Danger Zone', cls: 'mvrv-zone-hot', meaning: 'Price far above realized value — historically near major market tops.', action: 'Consider reducing exposure significantly. Strong distribution territory.' }, + { range: '1.40 – 2.0', label: 'Overheated', cls: 'mvrv-zone-hot', meaning: 'Price well above average holder cost basis. Risk is elevated.', action: 'Stay cautious. Tighten trailing stops. Consider partial profit-taking.' }, + { range: '1.15 – 1.40', label: 'Bullish', cls: 'mvrv-zone-bull', meaning: 'Momentum phase — holders on average are in profit.', action: 'Trend is your friend. Trail stops up. Watch for reversal signals at resistance.' }, + { range: '0.85 – 1.15', label: 'Fair Value', cls: 'mvrv-zone-neutral', meaning: 'Price near the average holder\'s cost basis. Neutral, wait-and-see zone.', action: 'Look for confluence with other signals (phase, volume, TA) before entering.' }, + { range: '0.50 – 0.85', label: 'Undervalued', cls: 'mvrv-zone-under', meaning: 'Below average cost basis. Buyers have a statistical edge here.', action: 'Dollar-cost averaging makes sense. Scale in slowly with defined risk.' }, + { range: '< 0.50', label: 'Capitulation', cls: 'mvrv-zone-under', meaning: 'Extreme undervaluation. Very rare — everyone on average is deeply in loss.', action: 'Maximum historical accumulation zone. Requires patience but odds are strongly in your favor.' }, + ]; + + const belowOneAlert = belowOne.length > 0 ? ` +
    +
    📉
    +
    + ${belowOne.map(c => c.symbol).join(', ')} ${belowOne.length > 1 ? 'are' : 'is'} below MVRV 1.0 — Accumulation Signal +
    + The current price is below the average cost basis of all market participants — + the average holder is sitting at a loss. In Bitcoin's history MVRV has only dropped below 1 three times: + the Dec 2018 bottom (~$3,200 → +1,800% over 2 years), the March 2020 COVID crash (~$4,000 → +1,400% over 12 months), + and the Nov 2022 post-FTX collapse (~$16,000 → +400% over 18 months). + Not a guaranteed signal, but historically one of the strongest long-term buy zones. +
    +
    +
    ` : ''; + + return ` +
    + +
    +
    + ${belowOneAlert} + +
    +

    What is MVRV?

    +

    MVRV stands for Market Value to Realized Value. It compares the current market price of a coin to the average price at which all existing coins were last moved on-chain — essentially everyone's average cost basis.

    +
      +
    • Market Value — current price × circulating supply (what the network is worth right now)
    • +
    • Realized Value — each coin valued at the price it last moved (what everyone paid on average)
    • +
    • MVRV Ratio = Market Value ÷ Realized Value
    • +
    +

    ⚠ Hype uses an approximation: Current Price ÷ 90-day average price, since true on-chain realized value requires blockchain data. Treat this as a directional signal, not precise MVRV.

    +
    + +
    +

    What does MVRV below 1.0 mean?

    +

    When MVRV drops below 1.0, the current price is below the average cost basis of all holders. In plain English: the average person holding this coin is at a loss.

    +

    This matters because:

    +
      +
    • Weak hands have already sold — only committed long-term holders remain
    • +
    • Statistical edge for new buyers — you're entering below the "average break-even" level
    • +
    • Capitulation is priced in — the worst of the selling pressure has typically already passed
    • +
    • Risk/reward skews favorably — downside is limited (everyone's already at a loss), upside can be large
    • +
    +
    +
    Dec 2018  BTC ~$3,200 → +1,800% over the next 2 years
    +
    Mar 2020  BTC ~$4,000 (COVID crash) → +1,400% over 12 months
    +
    Nov 2022  BTC ~$16,000 (post-FTX) → +400% over 18 months
    +
    +
    + +
    +

    Zone Reference Guide

    +
    + ${zones.map(z => ` +
    +
    + ${z.label} + ${z.range} +
    +
    +
    ${z.meaning}
    +
    → ${z.action}
    +
    +
    `).join('')} +
    +
    + +
    +

    How to use MVRV in this dashboard

    +
      +
    • Use MVRV as a macro backdrop, not a direct trade trigger
    • +
    • In the Signals tab, MVRV zone is one factor in the confluence score (undervalued = positive, overheated = negative for longs)
    • +
    • Best combined with: phase detector, TA signals (EMA/MACD/RSI), and funding rate
    • +
    • Overheated MVRV + overbought RSI + high funding = avoid new longs
    • +
    • Undervalued MVRV + bull phase + low funding = strong confluence for entry
    • +
    • Data refreshes from CoinGecko (free tier, rate-limited) — use the refresh button manually
    • +
    +
    +
    +
    +
    `; +} +const MVRV_COIN_NAMES = { BTC: 'Bitcoin', ETH: 'Ethereum', SOL: 'Solana', HYPE: 'Hyperliquid' }; +const MVRV_CG_IDS = { BTC: 'bitcoin', ETH: 'ethereum', SOL: 'solana', HYPE: 'hyperliquid' }; +const MVRV_ORDER = ['BTC', 'ETH', 'SOL', 'HYPE']; + +let _mvrvCache = null, _mvrvCacheTs = 0; + +function _mvrvZone(r) { + if (r >= 1.4) return 'OVERHEATED'; + if (r >= 1.15) return 'BULLISH'; + if (r >= 0.85) return 'NEUTRAL'; + return 'UNDERVALUED'; +} + +async function fetchMVRVData() { + if (_mvrvCache && Date.now() - _mvrvCacheTs < 300000) return _mvrvCache; + const CG = 'https://api.coingecko.com/api/v3'; + const ids = Object.values(MVRV_CG_IDS).join(','); + let pricesNow = {}; + try { + const r = await fetch(`${CG}/simple/price?ids=${ids}&vs_currencies=usd&include_market_cap=true&include_24hr_change=true`); + pricesNow = await r.json(); + } catch(e) {} + + const coinEntries = await Promise.all(Object.entries(MVRV_CG_IDS).map(async ([sym, cgId]) => { + const now = pricesNow[cgId] || {}; + const currentPrice = now.usd || 0; + const marketCap = now.usd_market_cap || 0; + const change24h = now.usd_24h_change || 0; + let chart = [], mvrv = 1.0, avg90d = currentPrice; + try { + const rh = await fetch(`${CG}/coins/${cgId}/market_chart?vs_currency=usd&days=90&interval=daily`); + if (rh.ok) { + const raw = (await rh.json()).prices || []; + if (raw.length >= 10) { + const tss = raw.map(p => p[0]), prices = raw.map(p => p[1]); + avg90d = prices.reduce((a, b) => a + b, 0) / prices.length; + mvrv = prices[prices.length - 1] / avg90d; + for (let i = 30; i < prices.length; i++) { + const w = prices.slice(i - 30, i); + const aw = w.reduce((a, b) => a + b, 0) / w.length; + chart.push({ t: tss[i], v: +(prices[i] / aw).toFixed(4) }); + } + } + } + } catch(e) {} + return [sym, { symbol: sym, price: currentPrice, market_cap: marketCap, + change_24h: +change24h.toFixed(2), mvrv: +mvrv.toFixed(4), + avg_90d: +avg90d.toFixed(4), zone: _mvrvZone(mvrv), chart }]; + })); + const coins = Object.fromEntries(coinEntries); + _mvrvCache = { coins, source: 'CoinGecko · approx MVRV = price ÷ 90-day avg', updated: Math.floor(Date.now()/1000) }; + _mvrvCacheTs = Date.now(); + return _mvrvCache; +} + +async function loadMVRV() { + const el = document.getElementById('mvrv-content'); + if (!el) return; + el.innerHTML = '
    Fetching MVRV from CoinGecko…
    '; + try { renderMVRV(await fetchMVRVData()); } + catch(e) { el.innerHTML = `
    Error: ${e.message}
    `; } +} + +function mvrvSparkline(chart) { + if (!chart || chart.length < 2) return ''; + const vals = chart.map(p => p.v), W = 120, H = 36, pad = 3; + const min = Math.min(...vals), max = Math.max(...vals), range = max - min || 0.01; + const pts = vals.map((v, i) => { + const x = pad + (i / (vals.length - 1)) * (W - pad * 2); + const y = pad + (1 - (v - min) / range) * (H - pad * 2); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(' '); + const color = vals[vals.length-1] >= vals[0] ? 'var(--green)' : 'var(--red)'; + const baseY = Math.max(pad, Math.min(H - pad, pad + (1 - (1 - min) / range) * (H - pad * 2))); + return ` + + + `; +} + +function renderMVRV(data) { + const el = document.getElementById('mvrv-content'); + if (!el) return; + const coins = data.coins || {}; + + const stripHtml = `
    ${MVRV_ORDER.map(sym => { + const c = coins[sym]; if (!c) return `
    ${sym}
    `; + const meta = MVRV_ZONE_META[c.zone]; + return `
    ${sym}
    ${c.mvrv.toFixed(3)}
    ${meta.label}
    `; + }).join('')}
    `; + + const cards = MVRV_ORDER.map(sym => { + const c = coins[sym]; if (!c) return `
    No data for ${sym}
    `; + const meta = MVRV_ZONE_META[c.zone]; + const chg = c.change_24h, chgCls = chg >= 0 ? 'pos' : 'neg', chgStr = (chg>=0?'+':'') + chg.toFixed(2) + '%'; + const mcStr = c.market_cap >= 1e9 ? '$'+(c.market_cap/1e9).toFixed(2)+'B' : c.market_cap >= 1e6 ? '$'+(c.market_cap/1e6).toFixed(0)+'M' : '—'; + return `
    +
    +
    ${sym}
    ${MVRV_COIN_NAMES[sym]||sym}
    + ${meta.label} +
    +
    ${c.mvrv.toFixed(3)}
    +
    MVRV Ratio
    +
    ${mvrvSparkline(c.chart)}
    +
    +
    Price
    ${fmt$(c.price)}
    +
    24h
    ${chgStr}
    +
    90d Avg
    ${fmt$(c.avg_90d)}
    +
    Mkt Cap
    ${mcStr}
    +
    +
    ${meta.desc}
    +
    `; + }).join(''); + + const updatedStr = data.updated ? new Date(data.updated*1000).toLocaleString('en-US',{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'}) : '—'; + el.innerHTML = ` + ${stripHtml} + ${_mvrvLearnHtml(coins)} +
    + ${data.source} +
    + Updated ${updatedStr} + +
    +
    +
    ${cards}
    +
    + ${Object.entries(MVRV_ZONE_META).map(([,m])=>`
    ${m.label}${m.desc}
    `).join('')} +
    + ⓘ Approx MVRV = Current Price ÷ 90-day rolling average. Not true on-chain realized cap. Chart shows 30-day rolling window. +
    +
    `; +} + +// ── AI Knowledge Base ───────────────────────────────────────────────────────── + +let _aiSubTab = 'intel'; +let _chatHistory = []; +let _intelLog = []; +let _intelLoaded = false; + +// Supabase — fill in your project URL and anon key from supabase.com → Project Settings → API +const _SUPABASE_URL = 'https://eiqlvbylkcmgvksrxqld.supabase.co'; +const _SUPABASE_ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6ImVpcWx2Ynlsa2NtZ3Zrc3J4cWxkIiwicm9sZSI6ImFub24iLCJpYXQiOjE3Nzg5NTI4NjgsImV4cCI6MjA5NDUyODg2OH0.PcGDHYlajqwnZ7c3ZPtssG534kd3sKwE8aT1ROlFpo8'; +const _db = (_SUPABASE_URL.startsWith('https://') && window.supabase) + ? window.supabase.createClient(_SUPABASE_URL, _SUPABASE_ANON_KEY) + : null; + +async function loadIntelFromDB() { + if (_intelLoaded) return; + _intelLoaded = true; + try { + // Migrate hype_kb_notes → hype_intel_log (legacy one-time) + const oldNotes = JSON.parse(localStorage.getItem('hype_kb_notes') || '[]'); + if (oldNotes.length) { + const migrated = oldNotes.map(n => ({ + id: n.id || 'intel::' + Date.now() + Math.random(), + timestamp: Date.now(), source: 'note', + raw: (n.title ? n.title + '\n' : '') + (n.content || ''), + coins: [], signals: [], phase: null, + })); + const prev = JSON.parse(localStorage.getItem('hype_intel_log') || '[]'); + localStorage.setItem('hype_intel_log', JSON.stringify([...prev, ...migrated])); + localStorage.removeItem('hype_kb_notes'); + } + + if (_db) { + // One-time migration: push any localStorage intel → Supabase + const local = JSON.parse(localStorage.getItem('hype_intel_log') || '[]'); + if (local.length > 0) { + await _db.from('intel_log').upsert( + local.map(e => ({ id: e.id, timestamp: e.timestamp, source: e.source || 'manual', + raw: e.raw || '', coins: e.coins || [], signals: e.signals || [], phase: e.phase || null })), + { onConflict: 'id' } + ); + localStorage.removeItem('hype_intel_log'); + } + const { data } = await _db.from('intel_log') + .select('id,timestamp,source,raw,coins,signals,phase') + .order('timestamp', { ascending: false }); + _intelLog = data || []; + if (_intelLog.length === 0) await _seedDefaultIntel(); + } else { + _intelLog = JSON.parse(localStorage.getItem('hype_intel_log') || '[]'); + if (_intelLog.length === 0) await _seedDefaultIntel(); + } + } catch(e) { + console.error('Intel load:', e); + _intelLog = JSON.parse(localStorage.getItem('hype_intel_log') || '[]'); + } +} + +async function _saveIntel(entry) { + if (_db) { + await _db.from('intel_log').upsert({ + id: entry.id, timestamp: entry.timestamp, source: entry.source, + raw: entry.raw, coins: entry.coins, signals: entry.signals, phase: entry.phase || null, + }); + } else { + localStorage.setItem('hype_intel_log', JSON.stringify(_intelLog)); + } +} + +// Pre-seed with Cryptowatch intel on first load +async function _seedDefaultIntel() { + const T = 1778942400000; // 2026-05-16 14:44 UTC + const seeds = [ + { + id: 'intel::1778942400003', + timestamp: T, + source: 'cryptowatch.id/desk', + raw: `CRYPTOWATCH DESK · 2026-05-16 14:44 UTC · Killzone NY_AM + +MARKET CONDITIONS: +Funding 0.0026% (z 0.50) · OI $58.33B (Δ4H -0.46%) · Liq 24H $28.2M +Options Skew 25Δ: -12.9 (mild risk-off) · VWAP +0.23% · CVD 4H 383.32 +MSAC Tail Risk: ELEVATED (Confluence 79.6) +ETF Dump scenario: $95,000 → $88,000 (-7.4%) + +BTC — HTF 1D BULL +Scalp LONG: moderate entry 78,010 · stop 77,924 · tp1 78,140 · tp2 78,227 · tp3 78,313 · R:R 1:2.5 · conf 8/10 +Intraday LONG: moderate entry 79,027 · stop 78,542 · tp1 79,998 · tp2 80,484 · R:R 1:3.0 · conf 5/10 · invalidation 1H EMA50 79,355 +Swing: NO-TRADE (Regime RANGE — no directional edge) +Quant stat-arb: SHORT BTC / LONG ETH · z=+2.34 (60d) · BTC rich vs ETH · delta-flat · mean revert 3-10d + +ETH — HTF 1D BEAR +Scalp SHORT: moderate entry 2,176 · stop 2,179 · tp1 2,171 · R:R 1:2.5 · conf 6/10 · invalidation above 2,180.99 +Intraday SHORT: moderate entry 2,216 · stop 2,234 · tp1 2,181 · R:R 1:3.0 · conf 5/10 · invalidation 1H EMA50 2,229 +Swing: NO-TRADE (Regime RANGE) + +SOL — HTF 1D BEAR · Funding +10.9% APR (z 1.63 elevated — longs paying) · OI $0.36B +Scalp SHORT: moderate 86.24 · stop 86.39 · tp1 86.01 · R:R 1:2.5 · conf 5/10 · invalidation above 86.41 +Intraday: NO-TRADE (Confluence 4/10 — below 5+ minimum) +Quant: Funding harvest SHORT perp / LONG spot · est +10.9% APR gross / ~7.1% net · delta-neutral + +HYPE — HTF 1D BULL · Funding -11.9% APR (z -1.92 — shorts paying longs) · OI $0.90B +Scalp LONG: moderate 41.02 · stop 40.87 · tp1 41.24 · R:R 1:2.5 · conf 6/10 · invalidation below 40.69 +Intraday LONG: aggressive 41.12 · stop 39.74 · tp1 45.27 · tp2 48.03 · R:R 1:5.0 · conf 4/10 · invalidation 1H EMA50 42.67 +Quant: Funding harvest LONG perp / SHORT spot · est +11.9% APR gross / ~7.8% net · delta-neutral + +SMART MONEY TOP WALLETS (Solana-dominant): +#1 Whale $21.1M · Quality 89.1 · Perf 70.2 +#2 Whale $5.7M · Quality 64.4 · Perf 83.3 +#3 Diversified Trader 22 tokens $6.8M · Quality 87.6 + +KEY ALERTS TODAY: +btc_ecosystem entered top 3 narratives (score 3.1) · 23:31 UTC +perps_dex entered top 3 (score 3.2) · 19:47 UTC +Smart Ape #1 bought BABYMANYU, SCHWAB1000, MILF SEX (new tokens, <1d old)`, + coins: ['BTC', 'ETH', 'SOL', 'HYPE'], + signals: ['funding', 'OI', 'EMA', 'VWAP', 'support', 'resistance', 'RSI'], + phase: null, + }, + { + id: 'intel::1778942400002', + timestamp: T - 14400000, // 10:44 UTC + source: 'cryptowatch.id/macro', + raw: `CRYPTOWATCH MACRO · 2026-05-16 + +BTC Price: ~$78,195–$80,900 +Portfolio Posture: WAIT · score -1 · 71% confidence +Cycle Phase: ACCUMULATION · Bottom Proximity +28% (far from bottom) +Net Capital Flow 30D: +$6.01B (Stablecoins leading · INFLOW) +BTC Funding: +2.8% APR (calm · no crowding · OI $2.20B) +Today Call: SIDEWAYS · Cycle calls accuracy 70% since 2015 +Evidence: 26 receipts · 6 bull / 7 bear / 13 flat · 27% agreement + +REGIME RADAR: Accumulation · BTC Season (not Altseason) +Evidence trail: +L1 Macro +1 · 7/7 scored +L2 Cycle 0 · 5/5 scored +L3 Capital/On-Chain 0 · 9/9 scored +L4 Execution -3 · 5/5 scored + +ON-CHAIN z-SCORE MOVES (notable): +Puell Multiple +1.65σ (0.9953) — accumulation range +AHR999 +1.30σ (0.5286) — DCA zone +BTC price +0.78σ (+1.32%) $80.90k +MVRV Z-Score +0.73σ (0.9187) — accumulation +Reserve Risk +0.72σ (0.001214) — buy zone +Active Supply 1y+ +0.72σ (0.60%) +LTH-MVRV +0.68σ (1.685) — accumulation +NUPL +0.63σ (0.3297) — hope/fear transition +Hot Capital Share -0.11σ (12.09%) — extreme cap distribution + +CYCLE BOTTOM RADAR: 84/100 (Strong bottom cluster — multi-indicator confluence) +Bottom signals active (10/14): Puell 0.87 (accumulation) · 2Y MA -7% (buy) · AHR999 0.51 (DCA) · Mayer 0.99 (discount) · Hash Ribbons aligned · Reserve Risk 0.0012 (buy) · MVRV-Z 0.92 (accumulation) · LTH-MVRV 1.69 (accumulation) · NUPL 0.33 (hope/fear) · HCS 12.1% (extreme cap) +Top signals (2): NVT Cross 15.4 (death cross) · Active 1y+ 59.9% (active cohort) +30d trajectory: +8 (bottom score improving) + +COHORTS: +LTH: ACCUMULATING +131,133 BTC 30d +ETF/TradFi: DISTRIBUTING -$541M 7d +Smart Money: ACCUMULATING (stable · Δ -3.63% 24h) +Alignment: 2/3 cohorts accumulating — contrarian-bull setup; ETF disagreeing + +CYCLE POSITION: +24.9 months since halving · 706 days to next +AVIV 1.035 (fair value) +LTH SOPR 0.94 (loss-taking → constructive) +MA Compression Index 30.9% — tight bands · big move building · 10th pct 20% / 90th pct 78% +Price/EMA50W velocity divergence +0.114 (neutral · bottom extreme below -1.0) +Weekly RSI 49.4 — transition zone · MA Stack 2/4 + +HISTORICAL at 20-40 proximity (n=79): +7d median +0.52% (range -2.56% to +3.89%) +30d median +2.73% (range -0.53% to +7.18%) +90d median -8.18% (range -22.23% to +1.62%) + +RISK: NVT death cross + ETF distributing -$541M. If smart money stays idle 48h and BTC fails to reclaim 7d range → accumulation read was wrong, cut quickly.`, + coins: ['BTC'], + signals: ['MVRV', 'RSI', 'EMA', 'funding', 'OI', 'support', 'dominance', 'divergence', 'volume', 'higher low'], + phase: 'ACCUMULATION', + }, + { + id: 'intel::1778942400001', + timestamp: T - 18000000, // 09:44 UTC + source: 'cryptowatch.id/hunter', + raw: `CRYPTOWATCH HUNTER · 2026-05-16 + +MARKET REGIME: CAUTION · Score 0/+10 (max ±10) +Heat: 41.8 (cool/opportunity range — threshold 55 for confirmation) +BTC Dominance: 60.4% (day 1 neutral) +Altcoin Breadth: 21% (most alts down) +Smart Money: IDLE · $0 volume · 0 trades +BTC Funding: +2.8% APR (calm) +Fear & Greed 7d: unavailable — re-check tomorrow + +NARRATIVE ROTATION: +Leading narrative rotated: perps_dex → btc_ecosystem +Hottest narrative: RWA (ONDO lead · 7d -10.3%) +BTC Ecosystem: mindshare leading price on -9% basket (BTC -2.6%, ORDI -25%, SATS -14.8%) +→ Textbook early-rotation setup — attention front-running beaten-down basket +→ BUT: smart money flat-out idle, heat cool → NO confirmation yet + +VERDICT: CAUTION — BTC Ecosystem attention leading price but smart money won't confirm +Conviction 3/5 (60%) · signals: Morning Verdict ✓ · Risk Regime ✓ · Smart Money ✓ +Signals disagreeing: Narrative Entries ✗ · Concentration ✗ + +AI SYNTHESIS: +"Stay small, scale into BTC on confirmation. Treat ORDI/SATS as second-leg trades only if BTC leads first. Ignore CT shill noise around Mirage Narrative and Last Man Standing — exit liquidity." + +PLAYS: +BTC: ENTRY (small) — low-beta leader of BTC Ecosystem rotation, -2.6% 7d +ORDI: HOLD/WATCH — -25% 7d deepest drawdown, second-leg beta if BTC confirms +SATS: HOLD/WATCH — -14.8% 7d, sympathy candidate, not a leader + +AVOID: +GAMBLE — CT emergence + strong shill signal + Toshi.bet casino promo = exit liquidity +BIOHACK — MIXED CT, possible shill, no smart money or price confirmation +Mirage Narrative / XRP conspiracy — no ticker, pure noise + +WATCH TRIGGERS (CAUTION → HUNT flip): +1. Smart money inflow into BTC Ecosystem names ($0 → any = biggest tell) +2. BTC breaks higher + ORDI/SATS catch bid within 24-48h → rotation confirmed +3. Heat score returns above 55 with stable BTC.D → confirmation +4. AI/AI_agents correlation r=1.00 — if BTC Ecosystem fails, AI basket is next candidate + +WHALE FLOW (last 50 min): +Net: +$1.27M · 200 trades · Buy $9.82M (109 trades) · Sell $8.55M (91 trades) +Buy pressure 54% · Token: WETH dominant + +SMART APE ALERTS: +Smart Ape #1 bought large MUSK (5d old) — 15 May 14:46 +Smart Ape #1 bought large GPT (5d old) — 15 May 14:13 + +SPOTLIGHT (5 aligned): +SOL: -3.5% 24h · Smart money #1 accumulation · EDGE 2.9/10 · hot L1s +SUI: -4.9% 24h · L1s narrative · EDGE 4.7/10 · Conviction 2/5 +DOGE: -3.2% 24h · memecoins narrative · EDGE 3.1/10 +SHIB: -3.9% 24h · memecoins narrative +PEPE: -3.9% 24h · memecoins narrative + +RISK TO THESIS: Smart money stays idle + BTC.D grinds higher without follow-through → rotation is attention noise on a still-bleeding basket. Concentration: 2 narratives (top 60%) — no forced-rebalance pressure, patience is cheap.`, + coins: ['BTC', 'ETH', 'SOL', 'SUI', 'DOGE', 'PEPE'], + signals: ['funding', 'dominance', 'RSI', 'OI', 'volume', 'bullish', 'bearish', 'support'], + phase: 'ACCUMULATION', + }, + ]; + _intelLog = seeds; + if (_db) { + await _db.from('intel_log').upsert( + seeds.map(e => ({ id: e.id, timestamp: e.timestamp, source: e.source, + raw: e.raw, coins: e.coins, signals: e.signals, phase: e.phase || null })) + ); + } else { + localStorage.setItem('hype_intel_log', JSON.stringify(_intelLog)); + } +} + +// ── Intel parsing ───────────────────────────────────────────────────────────── + +const KNOWN_COINS = ['BTC','ETH','SOL','HYPE','SUI','AVAX','DOGE','WIF','PEPE','ARB','OP','INJ','LINK','ATOM','DOT','ADA','BNB','XRP','TON','TRX','SEI','APT','NEAR','FTM','STX','RNDR','ORDI','SATS']; + +const PHASE_KW = { + ACCUMULATION: ['accumulation','accumulate','accumulating','wyckoff bottom','spring','reaccumulation','phase b','phase c','shakeout','lps','last point of support'], + MARKUP: ['markup','uptrend','breakout','break out','bull run','rally','pump','impulse','higher high','higher low','upside'], + DISTRIBUTION: ['distribution','distributing','lower high','lfh','wyckoff top','redistribution','phase d','buying climax','bcx','utad'], + MARKDOWN: ['markdown','downtrend','breakdown','break down','bear','dump','capitulation','flush','lower low','downside'], +}; + +const SIGNAL_KW = [ + 'RSI','MACD','EMA','SMA','volume','OI','open interest','funding','dominance', + 'support','resistance','divergence','golden cross','death cross','squeeze', + 'oversold','overbought','bullish','bearish','consolidat','higher low', + 'lower high','higher high','lower low','liquidity','order block','fair value gap', + 'FVG','OB','imbalance','HTF','LTF','weekly','daily','4h','1h','MVRV','NUPL', +]; + +function parseIntel(text) { + const upper = text.toUpperCase(); + const coins = KNOWN_COINS.filter(c => new RegExp(`\\b${c}\\b`).test(upper)); + const signals = [...new Set(SIGNAL_KW.filter(s => text.toLowerCase().includes(s.toLowerCase())))]; + let phase = null; + for (const [p, kws] of Object.entries(PHASE_KW)) { + if (kws.some(kw => text.toLowerCase().includes(kw))) { phase = p; break; } + } + return { coins, signals, phase }; +} + +function intelToMD(entry) { + const dt = new Date(entry.timestamp).toISOString().replace('T', ' ').slice(0, 19); + const tags = [ + entry.coins.length ? `**Coins:** ${entry.coins.join(', ')}` : '', + entry.phase ? `**Phase:** ${entry.phase}` : '', + entry.signals.length ? `**Signals:** ${entry.signals.slice(0, 6).join(', ')}` : '', + ].filter(Boolean).join(' · '); + return `## [${entry.source}] ${dt} UTC\n\n${entry.raw}\n\n${tags ? `> ${tags}` : ''}`; +} + +// ── Live prices (for graph) ─────────────────────────────────────────────────── + +let _liveCache = null, _liveCacheTs = 0; + +async function fetchLivePricesForGraph() { + if (_liveCache && Date.now() - _liveCacheTs < 30000) return _liveCache; + if (typeof livePrices !== 'undefined' && Object.keys(livePrices).length > 4) { + const pd = typeof livePrevDay !== 'undefined' ? livePrevDay : {}; + _liveCache = { prices: { ...livePrices }, prevDay: { ...pd } }; + _liveCacheTs = Date.now(); + return _liveCache; + } + try { + const [mids, ctx] = await Promise.all([ + fetch('https://api.hyperliquid.xyz/info', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'allMids' }) }).then(r => r.json()), + fetch('https://api.hyperliquid.xyz/info', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ type: 'metaAndAssetCtxs' }) }).then(r => r.json()), + ]); + const prices = {}, prevDay = {}; + if (mids) for (const [c, p] of Object.entries(mids)) prices[c] = parseFloat(p); + if (ctx?.[0]?.universe && ctx?.[1]) { + ctx[0].universe.forEach((a, i) => { + if (ctx[1][i]?.prevDayPx) prevDay[a.name] = parseFloat(ctx[1][i].prevDayPx); + }); + } + _liveCache = { prices, prevDay }; + _liveCacheTs = Date.now(); + } catch(e) { + _liveCache = { prices: {}, prevDay: {} }; + _liveCacheTs = Date.now(); + } + return _liveCache; +} + +function pctChange(coin, prices, prevDay) { + const now = prices[coin], prev = prevDay[coin]; + if (!now || !prev) return null; + return (now - prev) / prev * 100; +} + +function coinNodeColor(pct) { + if (pct === null) return '#6b7280'; + if (pct > 3) return '#4ade80'; + if (pct > 0.5) return '#86efac'; + if (pct < -3) return '#f87171'; + if (pct < -0.5) return '#fca5a5'; + return '#6b7280'; +} + +// ── AI shell ────────────────────────────────────────────────────────────────── + +async function loadAI() { + const el = document.getElementById('ai-content'); + if (!el) return; + el.innerHTML = '
    Loading intel…
    '; + await loadIntelFromDB(); + renderAIShell(); +} + +function renderAIShell() { + const el = document.getElementById('ai-content'); + if (!el) return; + const coinCount = [...new Set(_intelLog.flatMap(e => e.coins))].length; + const lastDate = _intelLog.length ? new Date(_intelLog[0].timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) : '—'; + + el.innerHTML = ` +
    +
    Intel Entries
    ${_intelLog.length}
    +
    Coins Tracked
    ${coinCount}
    +
    AI Engine
    Claude
    +
    Last Intel
    ${lastDate}
    +
    +
    +
    + ${['intel', 'graph', 'chat'].map(t => { + const icons = { intel: '📋', graph: '🕸', chat: '💬' }; + const labels = { intel: 'Intel Log', graph: 'Graph', chat: 'Chat' }; + return ``; + }).join('')} +
    +
    + Powered by Claude · key secured server-side +
    +
    `; + + if (_aiSubTab === 'intel') renderIntelTab(); + if (_aiSubTab === 'graph') renderKGraph(); + if (_aiSubTab === 'chat') renderChatTab(); +} + +function setAITab(tab) { _aiSubTab = tab; renderAIShell(); } + +// ── Intel Log tab ───────────────────────────────────────────────────────────── + +function renderIntelTab() { + const el = document.getElementById('ai-sub-content'); + if (!el) return; + + const rows = _intelLog.length === 0 + ? '
    No intel yet. Paste analysis from cryptowatch.id or anywhere else.
    ' + : _intelLog.map(entry => { + const dt = new Date(entry.timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + const coinBadges = entry.coins.map(c => + `${c}` + ).join(''); + const phaseBadge = entry.phase + ? `${entry.phase}` + : ''; + const preview = entry.raw.slice(0, 300).replace(/\n+/g, ' '); + const hasMore = entry.raw.length > 300; + return `
    +
    +
    +
    + ${aiEsc(entry.source)} + ${dt} + ${phaseBadge} +
    + ${coinBadges ? `
    ${coinBadges}
    ` : ''} + ${entry.signals.length ? `
    ${aiEsc(entry.signals.slice(0, 6).join(' · '))}
    ` : ''} +
    + +
    +
    ${aiEsc(preview)}${hasMore ? ' [more]' : ''}
    + +
    `; + }).join(''); + + el.innerHTML = ` +
    +
    +
    + + Coins, phases & signals auto-extracted +
    + + +
    + ${_intelLog.length > 0 ? ` +
    + ${_intelLog.length} entries · newest first + +
    ` : ''} +
    ${rows}
    +
    `; +} + +function toggleIntelExpand(id) { + const full = document.getElementById(`intel-full-${id}`); + const preview = document.getElementById(`intel-preview-${id}`); + if (!full) return; + const open = full.style.display !== 'none'; + full.style.display = open ? 'none' : 'block'; + if (preview) preview.style.display = open ? '' : 'none'; +} + +async function addIntel() { + const source = document.getElementById('intel-source')?.value?.trim() || 'manual'; + const raw = document.getElementById('intel-text')?.value?.trim(); + if (!raw) return; + const parsed = parseIntel(raw); + const entry = { id: 'intel::' + Date.now(), timestamp: Date.now(), source, raw, ...parsed }; + _intelLog.unshift(entry); + await _saveIntel(entry); + document.getElementById('intel-text').value = ''; + renderIntelTab(); +} + +async function deleteIntel(id) { + if (!confirm('Delete this intel entry? It cannot be recovered.')) return; + _intelLog = _intelLog.filter(e => e.id !== id); + if (_db) { + await _db.from('intel_log').delete().eq('id', id); + } else { + localStorage.setItem('hype_intel_log', JSON.stringify(_intelLog)); + } + renderIntelTab(); +} + +function exportIntel() { + const md = _intelLog.map(intelToMD).join('\n\n---\n\n'); + const blob = new Blob([md], { type: 'text/markdown' }); + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = `hype-intel-${new Date().toISOString().slice(0, 10)}.md`; + a.click(); +} + +// ── Knowledge Graph with live prices ───────────────────────────────────────── + +async function renderKGraph() { + const el = document.getElementById('ai-sub-content'); + if (!el) return; + el.innerHTML = '
    Loading live prices…
    '; + + let prices = {}, prevDay = {}; + try { ({ prices, prevDay } = await fetchLivePricesForGraph()); } catch(e) {} + + const W = Math.min(el.clientWidth || 900, window.innerWidth - 16); + const H = 560; + + const GRAPH_COINS = ['BTC', 'ETH', 'SOL', 'HYPE']; + const TYPE_COLOR = { coin: null, intel: '#818cf8', phase: '#fbbf24', signal: '#fb923c', narrative: '#34d399' }; + const TYPE_R = { coin: 22, intel: 11, phase: 13, signal: 9, narrative: 10 }; + + const nodes = []; + const idMap = {}; + + // Coin nodes with live price + for (const coin of GRAPH_COINS) { + const pct = pctChange(coin, prices, prevDay); + const color = coinNodeColor(pct); + const price = prices[coin]; + nodes.push({ + id: `c:${coin}`, label: coin, type: 'coin', color, + price, pct, + priceStr: price ? fmt$(price) : '—', + pctStr: pct !== null ? (pct >= 0 ? '+' : '') + pct.toFixed(1) + '%' : '', + r: TYPE_R.coin, + x: W / 2 + (Math.random() - .5) * 180, + y: H / 2 + (Math.random() - .5) * 130, + vx: 0, vy: 0, + }); + } + + // Intel nodes (last 20) + const recentIntel = _intelLog.slice(0, 20); + for (const entry of recentIntel) { + const dt = new Date(entry.timestamp).toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); + nodes.push({ + id: entry.id, label: dt, sublabel: entry.source.slice(0, 16), + type: 'intel', color: TYPE_COLOR.intel, r: TYPE_R.intel, + x: W / 2 + (Math.random() - .5) * W * .68, + y: H / 2 + (Math.random() - .5) * H * .55, + vx: 0, vy: 0, _entry: entry, + }); + } + + // Phase nodes + const PHASE_COLORS = { ACCUMULATION: '#38bdf8', MARKUP: '#4ade80', DISTRIBUTION: '#fbbf24', MARKDOWN: '#f87171' }; + const phasesUsed = [...new Set(_intelLog.map(e => e.phase).filter(Boolean))]; + for (const phase of phasesUsed) { + nodes.push({ + id: `p:${phase}`, label: phase.slice(0, 5), sublabel: phase, + type: 'phase', color: PHASE_COLORS[phase] || '#6b7280', r: TYPE_R.phase, + x: W / 2 + (Math.random() - .5) * W * .4, + y: H / 2 + (Math.random() - .5) * H * .4, + vx: 0, vy: 0, + }); + } + + // Narrative nodes (extracted from intel: btc_ecosystem, perps_dex, RWA, l1s, memecoins) + const NARRATIVES_KW = { 'btc_ecosystem': ['btc ecosystem','ordi','sats'], 'perps_dex': ['perps dex','perps_dex'], 'RWA': ['rwa','ondo'], 'l1s': ['l1s','l1 narrative'], 'memecoins': ['memecoin','doge','pepe','shib'] }; + const narrativesUsed = new Set(); + for (const entry of _intelLog) { + for (const [name, kws] of Object.entries(NARRATIVES_KW)) { + if (kws.some(kw => entry.raw.toLowerCase().includes(kw))) narrativesUsed.add(name); + } + } + for (const narr of narrativesUsed) { + nodes.push({ + id: `n:${narr}`, label: narr.slice(0, 10), sublabel: narr, + type: 'narrative', color: TYPE_COLOR.narrative, r: TYPE_R.narrative, + x: W / 2 + (Math.random() - .5) * W * .5, + y: H / 2 + (Math.random() - .5) * H * .45, + vx: 0, vy: 0, + }); + } + + // Signal nodes (top 8) + const sigCount = {}; + for (const entry of _intelLog) for (const s of entry.signals) sigCount[s] = (sigCount[s] || 0) + 1; + const topSignals = Object.entries(sigCount).sort((a, b) => b[1] - a[1]).slice(0, 8).map(e => e[0]); + for (const sig of topSignals) { + nodes.push({ + id: `s:${sig}`, label: sig.slice(0, 9), sublabel: `${sig} ×${sigCount[sig]}`, + type: 'signal', color: TYPE_COLOR.signal, r: TYPE_R.signal, + x: W / 2 + (Math.random() - .5) * W * .55, + y: H / 2 + (Math.random() - .5) * H * .5, + vx: 0, vy: 0, + }); + } + + for (const n of nodes) idMap[n.id] = n; + + // ── Build edges ── + const edges = []; + const edgeSet = new Set(); + function addEdge(a, b, strength = 1) { + const key = [a, b].sort().join('||'); + if (!edgeSet.has(key) && idMap[a] && idMap[b]) { + edgeSet.add(key); + edges.push({ source: idMap[a], target: idMap[b], strength }); + } + } + + for (const entry of recentIntel) { + for (const coin of entry.coins.filter(c => GRAPH_COINS.includes(c))) addEdge(entry.id, `c:${coin}`, 2); + if (entry.phase) addEdge(entry.id, `p:${entry.phase}`, 1.5); + for (const sig of entry.signals.filter(s => topSignals.includes(s))) addEdge(entry.id, `s:${sig}`, 0.6); + // Intel ↔ narrative + for (const [name, kws] of Object.entries(NARRATIVES_KW)) { + if (narrativesUsed.has(name) && kws.some(kw => entry.raw.toLowerCase().includes(kw))) addEdge(entry.id, `n:${name}`, 1); + } + // Intel ↔ Intel (shared 2+ tracked coins) + for (const other of recentIntel) { + if (other.id <= entry.id) continue; + const shared = entry.coins.filter(c => other.coins.includes(c) && GRAPH_COINS.includes(c)); + if (shared.length >= 2) addEdge(entry.id, other.id, 0.5); + } + } + + // Coin → phase / signal / narrative + for (const coin of GRAPH_COINS) { + const match = recentIntel.find(e => e.coins.includes(coin) && e.phase); + if (match) addEdge(`c:${coin}`, `p:${match.phase}`, 2); + const matches = recentIntel.filter(e => e.coins.includes(coin)); + const coinSigs = [...new Set(matches.flatMap(e => e.signals).filter(s => topSignals.includes(s)))].slice(0, 2); + for (const sig of coinSigs) addEdge(`c:${coin}`, `s:${sig}`, 1); + } + // Narrative → coin (if mentioned together) + for (const [name] of Object.entries(NARRATIVES_KW)) { + if (!narrativesUsed.has(name)) continue; + const nEntry = recentIntel.find(e => e.raw.toLowerCase().includes(name.replace('_', ' '))); + if (nEntry) for (const coin of nEntry.coins.filter(c => GRAPH_COINS.includes(c))) addEdge(`n:${name}`, `c:${coin}`, 1); + } + + // ── Force-directed layout ── + const k = Math.sqrt(W * H / Math.max(nodes.length, 1)) * 0.92; + for (let iter = 0; iter < 160; iter++) { + for (const n of nodes) { n.fx = 0; n.fy = 0; } + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const dx = nodes[i].x - nodes[j].x || .1, dy = nodes[i].y - nodes[j].y || .1; + const d = Math.sqrt(dx * dx + dy * dy) || 1, f = (k * k) / d; + nodes[i].fx += dx / d * f; nodes[i].fy += dy / d * f; + nodes[j].fx -= dx / d * f; nodes[j].fy -= dy / d * f; + } + } + for (const e of edges) { + const dx = e.target.x - e.source.x, dy = e.target.y - e.source.y; + const d = Math.sqrt(dx * dx + dy * dy) || 1, f = (d * d) / k * 0.07 * e.strength; + e.source.fx += dx / d * f; e.source.fy += dy / d * f; + e.target.fx -= dx / d * f; e.target.fy -= dy / d * f; + } + for (const n of nodes) { + n.fx += (W / 2 - n.x) * .013; n.fy += (H / 2 - n.y) * .013; + n.vx = (n.vx + n.fx) * .78; n.vy = (n.vy + n.fy) * .78; + n.x = Math.max(n.r + 6, Math.min(W - n.r - 6, n.x + n.vx)); + n.y = Math.max(n.r + 6, Math.min(H - n.r - 6, n.y + n.vy)); + } + } + + // ── Render ── + const edgeSvg = edges.map(e => { + const w = e.strength >= 1.5 ? '2' : e.strength >= 1 ? '1.3' : '0.7'; + const op = e.strength >= 1.5 ? '0.6' : e.strength >= 1 ? '0.45' : '0.25'; + return ``; + }).join(''); + + const nodesSvg = nodes.map(n => { + const x = n.x.toFixed(0), y = n.y.toFixed(0), r = n.r; + if (n.type === 'coin') { + return ` + + ${aiEsc(n.label)} + ${aiEsc(n.priceStr)} + ${aiEsc(n.pctStr)} + `; + } + const lbl = (n.label || '').slice(0, 10); + return ` + + ${aiEsc(lbl)} + `; + }).join(''); + + const legend = [ + { c: '#38bdf8', l: 'Coin (live)' }, + { c: TYPE_COLOR.intel, l: 'Intel entry' }, + { c: TYPE_COLOR.phase, l: 'Phase' }, + { c: TYPE_COLOR.narrative, l: 'Narrative' }, + { c: TYPE_COLOR.signal, l: 'Signal' }, + ].map(({ c, l }) => `
    ${l}
    `).join(''); + + el.innerHTML = ` + ${edgeSvg}${nodesSvg} +
    + ${legend} + ${nodes.length} nodes · ${edges.length} edges + +
    +
    Click a node to inspect · Coin nodes show live price from Hyperliquid
    `; +} + +function kgClick(el) { + const type = el.dataset.type; + const label = el.dataset.label; + let html = `${aiEsc(label)} ${type}`; + + if (type === 'coin') { + html += ` ${aiEsc(el.dataset.price)}`; + const pct = parseFloat(el.dataset.pct); + if (!isNaN(pct)) html += ` ${aiEsc(el.dataset.pct)}`; + const coinId = el.dataset.id?.replace('c:', '') || label; + const coinIntel = _intelLog.filter(e => e.coins.includes(coinId)).slice(0, 3); + if (coinIntel.length) { + html += '
    ' + + coinIntel.map(e => `
    ${new Date(e.timestamp).toLocaleDateString()} · ${aiEsc(e.source)}
    ${aiEsc(e.raw.slice(0, 130))}…
    `).join('') + + '
    '; + } + } + + if (type === 'intel') { + const id = el.dataset.id; + const entry = _intelLog.find(e => e.id === id); + if (entry) { + const dt = new Date(entry.timestamp).toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }); + html += `
    ${dt} · ${aiEsc(entry.source)}
    `; + if (entry.coins.length) html += `
    Coins: ${entry.coins.map(c => `${c}`).join(' ')}
    `; + html += `
    ${aiEsc(entry.raw.slice(0, 220))}${entry.raw.length > 220 ? '…' : ''}
    `; + } + } + + if (type === 'phase' || type === 'signal' || type === 'narrative') { + const related = _intelLog.filter(e => + type === 'phase' ? e.phase === label.toUpperCase() : + type === 'signal' ? e.signals.some(s => s.toLowerCase() === label.toLowerCase()) : + e.raw.toLowerCase().includes(label.toLowerCase().replace('_', ' ')) + ).slice(0, 3); + if (related.length) { + html += `
    ${related.length} intel entr${related.length > 1 ? 'ies' : 'y'}:
    ` + + '
    ' + + related.map(e => `
    ${new Date(e.timestamp).toLocaleDateString()} · ${aiEsc(e.source)} — ${aiEsc(e.raw.slice(0, 80))}…
    `).join('') + + '
    '; + } + } + + document.getElementById('kg-detail').innerHTML = html; +} + +// ── Chat tab ────────────────────────────────────────────────────────────────── + +function renderChatTab() { + const el = document.getElementById('ai-sub-content'); + if (!el) return; + const histHtml = _chatHistory.length === 0 + ? `
    Ask anything about your intel, MVRV signals, or setups.

    Intel log used as context · Add Anthropic API key above to enable Claude
    ` + : _chatHistory.map(m => m.role === 'user' + ? `
    ${aiEsc(m.content)}
    ` + : `
    ${mdToHtml(m.content)}
    ` + ).join(''); + el.innerHTML = ` +
    +
    ${histHtml}
    +
    + + + ${_chatHistory.length ? `` : ''} +
    +
    `; + const h = document.getElementById('chat-history'); + if (h) h.scrollTop = h.scrollHeight; +} + +async function sendChat() { + const input = document.getElementById('chat-input'); + const q = input?.value?.trim(); + if (!q) return; + input.value = ''; input.disabled = true; + _chatHistory.push({ role: 'user', content: q }); + renderChatTab(); + + let intelCtx = '', ctxLen = 0; + for (const entry of _intelLog) { + const line = `[${new Date(entry.timestamp).toLocaleDateString()} · ${entry.source}]\n${entry.raw}\n\n`; + if (ctxLen + line.length > 3000) break; + intelCtx += line; ctxLen += line.length; + } + + const mvrvCtx = _mvrvCache + ? 'MVRV: ' + MVRV_ORDER.map(s => { const c = _mvrvCache.coins[s]; return c ? `${s}=${c.mvrv.toFixed(3)}(${c.zone})` : ''; }).join(' ') + : ''; + + const system = [ + 'You are an AI trading research assistant for the Hype crypto dashboard.', + 'Analyze BTC, ETH, SOL, HYPE using the saved intel log below.', + 'MVRV zones: >1.4 overheated, 1.15–1.4 bullish, 0.85–1.15 neutral, <0.85 undervalued.', + mvrvCtx, + intelCtx ? `\nIntel Log (newest first):\n${intelCtx}` : '', + 'Be concise. Reference specific intel entries when relevant. Flag when signals conflict.', + ].filter(Boolean).join('\n'); + + try { + const res = await fetch('/api/chat', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ system, messages: _chatHistory.map(m => ({ role: m.role, content: m.content })) }), + }); + const d = await res.json(); + if (!res.ok) throw new Error(d.error || `HTTP ${res.status}`); + _chatHistory.push({ role: 'assistant', content: d.content || JSON.stringify(d) }); + } catch(e) { + _chatHistory.push({ role: 'assistant', content: `Error: ${e.message}` }); + } + renderChatTab(); + if (input) input.disabled = false; +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function aiEsc(s) { + return String(s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +function mdToHtml(md) { + return String(md || '') + .replace(/&/g, '&').replace(//g, '>') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/`([^`]+)`/g, '$1') + .replace(/\n/g, '
    '); +} + +// ── Patch navigate() ────────────────────────────────────────────────────────── + +(function patchNavigate() { + const _orig = window.navigate; + if (!_orig) return; + window.navigate = function(page) { + _orig(page); + if (page === 'mvrv') loadMVRV(); + if (page === 'ai') loadAI(); + }; +})(); 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/news.js b/frontend/js/news.js new file mode 100644 index 0000000..c2983c3 --- /dev/null +++ b/frontend/js/news.js @@ -0,0 +1,391 @@ +// ── Crypto News Monitor ─────────────────────────────────────────────────────── +// Speed approach: +// 1. sessionStorage cache (5 min TTL) — instant on tab switch +// 2. Progressive render — each source updates the feed as it resolves +// 3. RSS: Promise.any([allorigins, rss2json]) — race both proxies, first wins +// 4. CC: max 2 pages (3-day window needs ~50-100 articles, 1 page usually enough) + +const NEWS_HISTORY_DAYS = 3; +const NEWS_PER_PAGE = 30; +const NEWS_CACHE_KEY = 'hype_news_v2'; +const NEWS_CACHE_TTL = 5 * 60 * 1000; + +const NEWS_SOURCES = { + cryptocompare: { label: 'CryptoCompare', cls: 'src-cryptocompare' }, + messari: { label: 'Messari', cls: 'src-cryptocompare' }, + reddit: { label: 'Reddit', cls: 'src-reddit' }, + coindesk: { label: 'CoinDesk', cls: 'src-coindesk' }, + cointelegraph: { label: 'Cointelegraph', cls: 'src-cointelegraph' }, + decrypt: { label: 'Decrypt', cls: 'src-decrypt' }, + cryptonews: { label: 'CryptoNews', cls: 'src-cryptonews' }, + theblock: { label: 'The Block', cls: 'src-theblock' }, + bitcoinmag: { label: 'Bitcoin Mag', cls: 'src-coindesk' }, +}; + +const RSS_FEEDS = [ + { id: 'coindesk', url: 'https://www.coindesk.com/arc/outboundfeeds/rss/' }, + { id: 'cointelegraph', url: 'https://cointelegraph.com/rss' }, + { id: 'decrypt', url: 'https://decrypt.co/feed' }, + { id: 'cryptonews', url: 'https://cryptonews.com/news/feed/' }, + { id: 'theblock', url: 'https://www.theblock.co/rss/all' }, + { id: 'bitcoinmag', url: 'https://bitcoinmagazine.com/.rss/full/' }, +]; + +const ALLORIGINS = 'https://api.allorigins.win/get?url='; +const RSS2JSON = 'https://api.rss2json.com/v1/api.json?count=20&rss_url='; +const FNG_URL = 'https://api.alternative.me/fng/?limit=7'; +const CC_BASE = 'https://min-api.cryptocompare.com/data/v2/news/?lang=EN&sortOrder=latest&limit=50'; +const MESSARI_URL = 'https://data.messari.io/api/v1/news?limit=50&fields=id,title,content,published_at,url,references/name'; +const REDDIT_URL = 'https://www.reddit.com/r/CryptoCurrency/new.json?limit=50&raw_json=1'; + +let _newsItems = []; +let _newsFng = []; +let _newsStatus = {}; +let _newsFilter = 'all'; +let _newsPage = 1; +let _newsLoaded = false; +let _newsSeenSet = new Set(); +let _newsTimer = null; + +// ── Cache ───────────────────────────────────────────────────────────────────── + +function _cacheLoad() { + try { + const raw = sessionStorage.getItem(NEWS_CACHE_KEY); + if (!raw) return null; + const { ts, items, fng, status } = JSON.parse(raw); + if (Date.now() - ts > NEWS_CACHE_TTL) return null; + return { items, fng, status }; + } catch { return null; } +} + +function _cacheSave() { + try { + sessionStorage.setItem(NEWS_CACHE_KEY, JSON.stringify({ + ts: Date.now(), items: _newsItems, fng: _newsFng, status: _newsStatus, + })); + } catch {} +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function _strip(s = '') { + return s.replace(//g, '').replace(/<[^>]+>/g, ' ') + .replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,"'") + .replace(/\s+/g,' ').trim(); +} +function _ago(ts) { + const d = Math.floor((Date.now() - ts) / 1000); + if (d < 60) return `${d}s ago`; if (d < 3600) return `${Math.floor(d/60)}m ago`; + if (d < 86400) return `${Math.floor(d/3600)}h ago`; return `${Math.floor(d/86400)}d ago`; +} +function _cutoff() { return Date.now() - NEWS_HISTORY_DAYS * 86400 * 1000; } + +function _merge(incoming) { + let added = 0; + for (const item of incoming) { + const key = item.title.toLowerCase().slice(0, 60); + if (!item.title || _newsSeenSet.has(key)) continue; + _newsSeenSet.add(key); + _newsItems.push(item); + added++; + } + if (added) _newsItems.sort((a, b) => b.ts - a.ts); + return added; +} + +// ── Fetchers ────────────────────────────────────────────────────────────────── + +async function _fetchCC() { + const cutSec = Math.floor(_cutoff() / 1000); + const items = []; + try { + for (let page = 0; page < 2; page++) { // max 2 pages for 3-day window + const url = page === 0 ? CC_BASE : `${CC_BASE}&before_ts=${items[items.length-1]?.published_on}`; + const r = await fetch(url); + if (!r.ok) break; + const d = await r.json(); + if (d.Response === 'Error' || !d.Data?.length) break; + items.push(...d.Data); + if (d.Data[d.Data.length-1].published_on < cutSec) break; + if (d.Data.length < 50) break; + } + const result = items.filter(n => n.published_on >= cutSec).map(n => ({ + id: 'cc:' + n.id, source: 'cryptocompare', title: n.title || '', + excerpt: _strip(n.body || '').slice(0, 220), url: n.url, + ts: n.published_on * 1000, + tags: (n.categories || '').split('|').map(t => t.trim()).filter(Boolean).slice(0, 5), + })); + _newsStatus.cryptocompare = { ok: true, count: result.length }; + return result; + } catch (e) { _newsStatus.cryptocompare = { ok: false, err: e.message }; return []; } +} + +async function _fetchMessari() { + try { + const r = await fetch(MESSARI_URL, { headers: { Accept: 'application/json' } }); + if (!r.ok) throw new Error(`${r.status}`); + const d = await r.json(); + const result = (d.data || []).filter(n => new Date(n.published_at).getTime() >= _cutoff()).map(n => ({ + id: 'ms:' + n.id, source: 'messari', title: n.title || '', + excerpt: _strip(n.content || '').slice(0, 220), url: n.url, + ts: new Date(n.published_at).getTime(), + tags: (n.references || []).map(r => r.name).filter(Boolean).slice(0, 5), + })); + _newsStatus.messari = { ok: true, count: result.length }; + return result; + } catch (e) { _newsStatus.messari = { ok: false, err: e.message }; return []; } +} + +async function _fetchReddit() { + try { + const r = await fetch(REDDIT_URL, { headers: { Accept: 'application/json' } }); + if (!r.ok) throw new Error(`${r.status}`); + const d = await r.json(); + const result = (d?.data?.children || []).map(c => c.data) + .filter(p => !p.stickied && p.created_utc * 1000 >= _cutoff()) + .map(p => ({ + id: 'rd:' + p.id, source: 'reddit', title: p.title, + excerpt: p.selftext ? _strip(p.selftext).slice(0, 220) : '', + url: p.url?.startsWith('http') ? p.url : 'https://reddit.com' + p.permalink, + ts: p.created_utc * 1000, tags: [], + redditMeta: `↑ ${(p.score||0).toLocaleString()} · ${p.num_comments||0} comments`, + })); + _newsStatus.reddit = { ok: true, count: result.length }; + return result; + } catch (e) { _newsStatus.reddit = { ok: false, err: e.message }; return []; } +} + +async function _parseXml(xml) { + const doc = new DOMParser().parseFromString(xml, 'text/xml'); + const items = [...doc.querySelectorAll('item, entry')]; + return items.map(item => { + const g = tag => { const el = item.querySelector(tag); return el ? (el.textContent || el.getAttribute('href') || '') : ''; }; + const ts = new Date(g('pubDate') || g('updated') || g('published') || '').getTime(); + return { title: _strip(g('title')), link: g('link') || g('guid'), ts, desc: _strip(g('description') || g('summary') || g('content')) }; + }).filter(i => i.title && !isNaN(i.ts) && i.ts >= _cutoff()); +} + +async function _fetchViaAllOrigins(url) { + const r = await fetch(ALLORIGINS + encodeURIComponent(url)); + if (!r.ok) throw new Error(`ao:${r.status}`); + const j = await r.json(); + if (!j.contents) throw new Error('ao:empty'); + return _parseXml(j.contents); +} + +async function _fetchViaRss2json(url) { + const r = await fetch(RSS2JSON + encodeURIComponent(url)); + if (!r.ok) throw new Error(`r2j:${r.status}`); + const d = await r.json(); + if (d.status !== 'ok') throw new Error(`r2j:${d.status}`); + return (d.items || []) + .map(i => ({ title: _strip(i.title||''), link: i.link||'', ts: new Date(i.pubDate).getTime(), desc: _strip(i.description||i.content||'') })) + .filter(i => i.title && !isNaN(i.ts) && i.ts >= _cutoff()); +} + +async function _fetchRSS(feed) { + try { + // Race both proxies — whichever responds first wins + const parsed = await Promise.any([ + _fetchViaAllOrigins(feed.url), + _fetchViaRss2json(feed.url), + ]); + const result = parsed.map(p => ({ + id: feed.id + ':' + encodeURIComponent(p.link || p.title).slice(0, 80), + source: feed.id, title: p.title, excerpt: p.desc.slice(0, 220), + url: p.link || '#', ts: p.ts, tags: [], + })); + _newsStatus[feed.id] = { ok: true, count: result.length }; + return result; + } catch { _newsStatus[feed.id] = { ok: false }; return []; } +} + +async function _fetchFNG() { + try { + const r = await fetch(FNG_URL); + if (!r.ok) return []; + const d = await r.json(); + return (d.data || []).map(e => ({ value: parseInt(e.value,10), label: e.value_classification, ts: parseInt(e.timestamp,10)*1000 })); + } catch { return []; } +} + +// ── Progressive load ────────────────────────────────────────────────────────── + +async function _fetchAllProgressively() { + _newsStatus = {}; _newsItems = []; _newsSeenSet = new Set(); + + // FNG runs in background — update bar when it arrives + _fetchFNG().then(fng => { _newsFng = fng; _updateFngEl(); }); + + // Each source updates the feed as it resolves + const sources = [ + _fetchCC(), + _fetchMessari(), + _fetchReddit(), + ...RSS_FEEDS.map(_fetchRSS), + ]; + + for (const promise of sources) { + promise.then(items => { + if (_merge(items)) _updateFeedEl(); + _updateStatusEl(); + }); + } + + await Promise.allSettled(sources); + _cacheSave(); +} + +// ── Render ──────────────────────────────────────────────────────────────────── + +function _fngClass(v) { + if (v <= 25) return 'fng-extreme-fear'; if (v <= 45) return 'fng-fear'; + if (v <= 55) return 'fng-neutral'; if (v <= 75) return 'fng-greed'; + return 'fng-extreme-greed'; +} + +function _renderFNG() { + if (!_newsFng.length) return '
    '; + const labels = ['Today','Yst','2d','3d','4d','5d','6d']; + const chips = _newsFng.map((e,i) => ` +
    + ${e.value} + ${labels[i]||''} +
    ${i < _newsFng.length-1 ? '' : ''}`) + .join(''); + return `
    + Fear & Greed${chips} + ${_newsFng[0].label} +
    `; +} + +function _renderStatus() { + const entries = Object.entries(_newsStatus); + if (!entries.length) return '
    '; + const ok = entries.filter(([,v]) => v.ok).length; + const failed = entries.filter(([,v]) => !v.ok).map(([k]) => NEWS_SOURCES[k]?.label || k); + const failNote = failed.length ? ` · ${failed.join(', ')} unavailable` : ''; + return `
    + ${_newsItems.length} articles · ${ok}/${entries.length} sources · last ${NEWS_HISTORY_DAYS} days${failNote} +
    `; +} + +function _renderFilters() { + const counts = {}; + _newsItems.forEach(n => { counts[n.source] = (counts[n.source]||0)+1; }); + const srcs = ['all', ...Object.keys(NEWS_SOURCES).filter(s => (counts[s]||0) > 0)]; + return `
    + ${srcs.map(s => { + const active = _newsFilter === s ? ' active' : ''; + const lbl = s === 'all' ? `All (${_newsItems.length})` : `${NEWS_SOURCES[s].label} (${counts[s]||0})`; + return ``; + }).join('')} +
    + +
    +
    `; +} + +function _renderCard(item) { + const meta = NEWS_SOURCES[item.source] || { label: item.source, cls: '' }; + const tags = item.tags?.length ? `
    ${item.tags.map(t=>`${t}`).join('')}
    ` : ''; + const reddit = item.redditMeta ? `
    ${item.redditMeta}
    ` : ''; + return `
    +
    + ${meta.label} + ${_ago(item.ts)} +
    + ${item.title} + ${item.excerpt ? `
    ${item.excerpt}
    ` : ''} + ${tags}${reddit} +
    `; +} + +function _renderFeed() { + const visible = _newsFilter === 'all' ? _newsItems : _newsItems.filter(n => n.source === _newsFilter); + const page = visible.slice(0, _newsPage * NEWS_PER_PAGE); + if (!page.length) return '
    No articles yet — loading…
    '; + const more = visible.length > page.length + ? `` + : ''; + return `
    ${page.map(_renderCard).join('')}${more}
    `; +} + +// ── Incremental DOM updates (called by progressive loader) ──────────────────── + +function _updateFngEl() { + const el = document.getElementById('news-fng-bar'); + if (el) el.outerHTML = _renderFNG(); +} + +function _updateStatusEl() { + const el = document.getElementById('news-status-bar'); + if (el) el.outerHTML = _renderStatus(); + const fb = document.getElementById('news-filter-bar'); + if (fb) fb.outerHTML = _renderFilters(); +} + +function _updateFeedEl() { + const el = document.getElementById('news-feed'); + const parent = document.getElementById('news-content'); + if (el) { el.outerHTML = _renderFeed(); return; } + // Feed div doesn't exist yet (still in skeleton) — replace entire content + if (parent && _newsItems.length) _buildPage(); +} + +function _buildPage() { + const el = document.getElementById('news-content'); + if (!el) return; + el.innerHTML = _renderFNG() + _renderStatus() + _renderFilters() + _renderFeed(); +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +async function loadNews() { + const el = document.getElementById('news-content'); + if (!el) return; + + // Serve from cache immediately, then background-refresh if stale + const cached = _cacheLoad(); + if (cached) { + _newsItems = cached.items; + _newsFng = cached.fng; + _newsStatus = cached.status; + _newsSeenSet = new Set(_newsItems.map(i => i.title.toLowerCase().slice(0,60))); + _newsLoaded = true; + _buildPage(); + return; + } + + // No cache — show skeleton + load progressively + el.innerHTML = `${_renderFNG()}
    Fetching from ${RSS_FEEDS.length + 3} sources…
    Loading…
    `; + + await _fetchAllProgressively(); + _newsLoaded = true; + _buildPage(); + + clearInterval(_newsTimer); + _newsTimer = setInterval(async () => { + sessionStorage.removeItem(NEWS_CACHE_KEY); + _newsPage = 1; + await _fetchAllProgressively(); + _buildPage(); + }, 5 * 60 * 1000); +} + +function newsFilter(src) { _newsFilter = src; _newsPage = 1; _buildPage(); } +function newsLoadMore() { _newsPage++; _buildPage(); } + +async function newsRefresh() { + sessionStorage.removeItem(NEWS_CACHE_KEY); + const btn = document.getElementById('news-refresh-btn'); + if (btn) { btn.disabled = true; btn.textContent = '↺ …'; } + _newsPage = 1; + _newsLoaded = false; + const el = document.getElementById('news-content'); + if (el) el.innerHTML = `
    Refreshing…
    `; + await _fetchAllProgressively(); + _newsLoaded = true; + _buildPage(); +} 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/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..efe7bfb --- /dev/null +++ b/index.html @@ -0,0 +1,215 @@ + + + + + + + + + + Hype — Analyzer + + + + + + + + + + + + +
    + + + + + +
    + Docs + + + + + 0x6e4c…2015 +
    + +
    +
    + + + + + + + + + + + + + +
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Connecting…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    Loading…
    +
    + + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + 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; + }, +}; diff --git a/wrangler.jsonc b/wrangler.jsonc new file mode 100644 index 0000000..9d16b04 --- /dev/null +++ b/wrangler.jsonc @@ -0,0 +1,14 @@ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "hype-bot", + "compatibility_date": "2026-05-31", + "observability": { + "enabled": true + }, + "assets": { + "directory": "." + }, + "compatibility_flags": [ + "nodejs_compat" + ] +}