Spaces:
Running
Running
| <html lang="en" data-theme="dark"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <script>/* Theme restore — must run before first paint to prevent flash-of-wrong-theme */ | |
| (function(){try{var s=localStorage.getItem('tc_theme');if(s==='light'||s==='dark')document.documentElement.setAttribute('data-theme',s);}catch(e){}})(); | |
| </script> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> | |
| <title>Trade Copilot — Deterministic Crypto Scanner · Zero Hallucination</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <link rel="preload" as="style" href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&family=Instrument+Serif:ital@1&family=JetBrains+Mono:wght@400;500;600;700&display=swap" onload="this.rel='stylesheet'"> | |
| <noscript><link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700;800&family=Inter:wght@400;500;600;700&family=Instrument+Serif:ital@1&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"></noscript> | |
| <style> | |
| /* ═══════════════════════════════════════════════ | |
| TOKENS | |
| ═══════════════════════════════════════════════ */ | |
| :root, [data-theme="dark"] { | |
| color-scheme: dark; | |
| /* ── Obsidian Glass core ── */ | |
| --bg: #06090f; | |
| --bg2: #0a0f1a; | |
| --white: rgba(255,255,255,0.06); /* legacy "solid surface" → subtle glass on dark */ | |
| --glass: rgba(255,255,255,0.04); | |
| --glass2: rgba(255,255,255,0.065); | |
| --glassbdr: rgba(255,255,255,0.085); | |
| --gb: rgba(255,255,255,0.085); /* glass border */ | |
| --rim: rgba(255,255,255,0.2); /* top rim highlight */ | |
| --track: rgba(255,255,255,0.09); /* bar tracks / nested wells */ | |
| --shadow: 0 24px 60px -18px rgba(0,0,0,0.6); | |
| --glare: rgba(255,255,255,0.09); | |
| /* ── Typography ── */ | |
| --font-display: 'Sora', 'Inter', -apple-system, sans-serif; | |
| --font: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; | |
| --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; | |
| --font-serif: 'Instrument Serif', Georgia, serif; | |
| /* Text — 4 levels */ | |
| --tx: #e9eef7; | |
| --tx2: #98a3b8; | |
| --tx3: #7a86a0; | |
| --t1: var(--tx); | |
| --t2: var(--tx2); | |
| --t3: var(--tx3); | |
| --t4: #5a6580; | |
| --muted: var(--tx3); | |
| /* ── Palette ── */ | |
| --mint: #3ddc97; --mints: rgba(61,220,151,0.11); | |
| --red: #ff6b7a; --reds: rgba(255,107,122,0.10); | |
| --amber: #f5b52a; --ambers: rgba(245,181,42,0.10); | |
| --cyan: #4cc9f0; --cyans: rgba(76,201,240,0.10); | |
| --violet: #9b7bff; --violets: rgba(155,123,255,0.12); | |
| --grad: linear-gradient(120deg,#4cc9f0,#9b7bff 55%,#f072b6); | |
| /* ── Legacy aliases — JS templates + component CSS depend on these ── */ | |
| --teal: var(--cyan); --teal2: var(--cyan); --teal3: var(--cyans); --teal-glow: var(--cyans); | |
| --blue: var(--cyan); --blue2: var(--cyan); --blue3: var(--cyans); | |
| --profit: var(--mint); --profit2: var(--mint); --profit3: var(--mints); --profit4: rgba(61,220,151,0.06); | |
| --loss: var(--red); --loss2: var(--red); --loss3: var(--reds); --loss4: rgba(255,107,122,0.06); | |
| --warn: var(--amber);--warn2: var(--amber);--warn3: var(--ambers);--warn4: rgba(245,181,42,0.06); | |
| --green: var(--mint); --green2: var(--mint); --green3: var(--mints); | |
| --red2: var(--red); --red3: var(--reds); | |
| --amber2: var(--amber);--amber3: var(--ambers); | |
| --purple: var(--violet); --purple2: var(--violet); | |
| /* Radii */ | |
| --r: 10px; | |
| --r2: 18px; | |
| --r3: 24px; | |
| /* Motion */ | |
| --ease: cubic-bezier(0.22, 1, 0.36, 1); | |
| --ease-out: cubic-bezier(0.22, 1, 0.36, 1); | |
| --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); | |
| --spring: cubic-bezier(0.34, 1.56, 0.64, 1); | |
| --bounce: cubic-bezier(0.34, 1.56, 0.64, 1); | |
| } | |
| [data-theme="light"] { | |
| color-scheme: light; | |
| --bg: #edf0f6; | |
| --bg2: #e3e8f1; | |
| --white: #ffffff; | |
| --glass: rgba(255,255,255,0.5); | |
| --glass2: rgba(255,255,255,0.72); | |
| --glassbdr: rgba(15,23,42,0.08); | |
| --gb: rgba(15,23,42,0.08); | |
| --rim: rgba(255,255,255,0.95); | |
| --track: rgba(15,23,42,0.08); | |
| --shadow: 0 24px 60px -26px rgba(30,41,66,0.22); | |
| --glare: rgba(255,255,255,0.35); | |
| --tx: #0f1729; | |
| --tx2: #4e5d78; | |
| --tx3: #5e6c88; | |
| --t4: #9aa6bf; | |
| --mint: #0b9e72; --mints: rgba(11,158,114,0.10); | |
| --red: #dd4249; --reds: rgba(221,66,73,0.08); | |
| --amber: #a1750a; --ambers: rgba(161,117,10,0.10); | |
| --cyan: #0e7fa8; --cyans: rgba(14,127,168,0.09); | |
| --violet: #6d4de0; --violets: rgba(109,77,224,0.09); | |
| --grad: linear-gradient(120deg,#0e7fa8,#6d4de0 55%,#c2439a); | |
| } | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| html { scroll-behavior: smooth; } | |
| body { | |
| font-family: var(--font); | |
| background: var(--bg); | |
| color: var(--t1); | |
| min-height: 100vh; | |
| -webkit-font-smoothing: antialiased; | |
| overflow-x: hidden; | |
| transition: background 0.5s, color 0.5s; | |
| padding-bottom: calc(38px + env(safe-area-inset-bottom)); | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| AURORA BACKGROUND + GRAIN (Obsidian Glass) | |
| ═══════════════════════════════════════════════ */ | |
| .aurora { position: fixed; inset: 0; z-index: -2; overflow: hidden; pointer-events: none; } | |
| .a-orb { | |
| position: absolute; border-radius: 50%; | |
| filter: blur(50px); opacity: 0.33; | |
| will-change: transform; | |
| animation: auroraDrift 28s ease-in-out infinite alternate; | |
| } | |
| .a1 { width: 52vw; height: 52vw; left: -12vw; top: -18vh; background: radial-gradient(circle, #0f3e52, transparent 65%); } | |
| .a2 { width: 44vw; height: 44vw; right: -10vw; top: 8vh; background: radial-gradient(circle, #341a6b, transparent 65%); animation-delay: -9s; } | |
| .a3 { width: 42vw; height: 42vw; left: 26vw; bottom: -22vh; background: radial-gradient(circle, #0c4234, transparent 65%); animation-delay: -17s; } | |
| [data-theme="light"] .a-orb { opacity: 0.5; } | |
| [data-theme="light"] .a1 { background: radial-gradient(circle, #bcdffc, transparent 65%); } | |
| [data-theme="light"] .a2 { background: radial-gradient(circle, #d8ccff, transparent 65%); } | |
| [data-theme="light"] .a3 { background: radial-gradient(circle, #bff1dd, transparent 65%); } | |
| @keyframes auroraDrift { | |
| 0% { transform: translate(0,0) scale(1); } | |
| 50% { transform: translate(6vw,-4vh) scale(1.1); } | |
| 100% { transform: translate(-4vw,5vh) scale(0.96); } | |
| } | |
| .grain { | |
| position: fixed; inset: 0; z-index: -1; pointer-events: none; | |
| opacity: 0.045; mix-blend-mode: overlay; | |
| background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| NAVBAR | |
| ═══════════════════════════════════════════════ */ | |
| nav { | |
| position: fixed; top: 12px; left: 50%; transform: translateX(-50%); | |
| z-index: 300; | |
| width: calc(100% - 40px); max-width: 1400px; | |
| display: flex; align-items: center; gap: 10px; | |
| padding: 9px 16px; | |
| background: var(--glass); | |
| backdrop-filter: blur(22px) saturate(160%); | |
| -webkit-backdrop-filter: blur(22px) saturate(160%); | |
| border: 1px solid var(--gb); | |
| border-radius: 18px; | |
| box-shadow: 0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04), inset 0 1px 0 var(--rim); | |
| animation: navDrop 0.7s var(--ease) both; | |
| } | |
| @keyframes navDrop { | |
| from { opacity:0; transform:translateX(-50%) translateY(-20px); } | |
| to { opacity:1; transform:translateX(-50%) translateY(0); } | |
| } | |
| .brand { display:flex; align-items:center; gap:9px; text-decoration:none; flex-shrink:0; } | |
| .brand-mark { | |
| width:34px; height:34px; | |
| background: linear-gradient(135deg, var(--teal), var(--blue2)); | |
| border-radius:10px; | |
| display:grid; place-items:center; | |
| font-size:17px; | |
| box-shadow: 0 4px 14px rgba(76,201,240,0.4), inset 0 1px 0 var(--rim); | |
| transition: transform 0.2s var(--spring), box-shadow 0.2s; | |
| } | |
| .brand-mark:hover { transform: scale(1.1) rotate(-5deg); box-shadow: 0 8px 20px rgba(76,201,240,0.5); } | |
| .brand-name { font-size:15px; font-weight:700; letter-spacing:-0.4px; color:var(--t1); font-family:var(--font-display); } | |
| #status-pill { | |
| display:inline-flex; align-items:center; gap:5px; | |
| font-size:10px; font-weight:700; letter-spacing:0.3px; | |
| padding:4px 10px; border-radius:99px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| color:var(--t3); transition:all 0.4s var(--ease); | |
| } | |
| #status-pill.ok { color:var(--green); background:var(--mints); border-color:rgba(61,220,151,0.2); } | |
| #status-pill.err { color:var(--red); background:var(--reds); border-color:rgba(255,107,122,0.2); } | |
| .live-dot { width:5px; height:5px; border-radius:50%; background:currentColor; flex-shrink:0; } | |
| .ok .live-dot { animation: pulse 2s ease-in-out infinite; } | |
| @keyframes pulse { 0%,100%{box-shadow:0 0 0 0 rgba(61,220,151,0.5)} 50%{box-shadow:0 0 0 5px rgba(61,220,151,0)} } | |
| /* Fear & Greed pill */ | |
| #fng-pill { | |
| display:inline-flex; align-items:center; gap:5px; | |
| font-size:10px; font-weight:700; letter-spacing:0.3px; | |
| padding:4px 10px; border-radius:99px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| cursor:default; transition:all 0.4s var(--ease); | |
| white-space:nowrap; | |
| } | |
| #fng-pill.fng-fear { color:var(--loss); background:var(--reds); border-color:rgba(255,107,122,0.2); } | |
| #fng-pill.fng-xfear { color:var(--loss); background:var(--reds); border-color:rgba(255,107,122,0.3); font-weight:800; } | |
| #fng-pill.fng-greed { color:var(--warn); background:var(--ambers); border-color:rgba(245,181,42,0.2); } | |
| #fng-pill.fng-xgreed { color:var(--warn); background:var(--ambers); border-color:rgba(245,181,42,0.3); font-weight:800; } | |
| #fng-pill.fng-neutral { color:var(--t2); background:var(--glass2); border-color:var(--gb); } | |
| /* News sentiment badge on card */ | |
| .badge-news { font-size:10px; font-weight:700; border-radius:6px; padding:3px 8px; } | |
| .badge-news.news-bullish { color:var(--profit); background:var(--mints); border:1px solid rgba(61,220,151,0.2); } | |
| .badge-news.news-bearish { color:var(--loss); background:var(--reds); border:1px solid rgba(255,107,122,0.2); } | |
| .badge-news.news-neutral { color:var(--t3); background:var(--track); border:1px solid var(--gb); } | |
| .badge-news.news-nokey { color:var(--t4); background:var(--track); border:1px dashed var(--gb); } | |
| .nav-spacer { flex:1; } | |
| .nav-controls { display:flex; align-items:center; gap:6px; flex-wrap:nowrap; } | |
| .chip { | |
| display:flex; flex-direction:column; gap:2px; align-items:flex-start; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:5px 10px; | |
| transition:border-color 0.2s, box-shadow 0.2s, background 0.2s, transform 0.2s; | |
| box-shadow: inset 0 1px 0 var(--rim); | |
| } | |
| .chip:focus-within { | |
| border-color:rgba(76,201,240,0.4); | |
| box-shadow:0 0 0 3px rgba(76,201,240,0.1), inset 0 1px 0 var(--rim); | |
| background:var(--glass2); | |
| transform:translateY(-1px); | |
| } | |
| .chip label { font-size:8px; font-weight:800; letter-spacing:0.9px; text-transform:uppercase; color:var(--t3); white-space:nowrap; user-select:none; } | |
| .chip input, .chip select { | |
| background:none; border:none; outline:none; | |
| color:var(--t1); font-family:var(--font-mono); font-size:13px; font-weight:700; | |
| -moz-appearance:textfield; | |
| } | |
| .chip input::-webkit-inner-spin-button, | |
| .chip input::-webkit-outer-spin-button { -webkit-appearance:none; } | |
| .chip input { width:56px; text-align:right; } | |
| .chip select { | |
| cursor:pointer; width:auto; appearance:none; padding-right:16px; | |
| background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%2398a3b8' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); | |
| background-repeat:no-repeat; background-position:right 3px center; | |
| } | |
| #scan-btn { | |
| display:inline-flex; align-items:center; gap:7px; | |
| background:linear-gradient(135deg, var(--teal) 0%, #22a5cc 100%); | |
| color:#fff; border:none; | |
| padding:10px 20px; border-radius:12px; | |
| font-size:13px; font-weight:800; font-family:var(--font); letter-spacing:-0.2px; | |
| cursor:pointer; white-space:nowrap; | |
| transition:transform 0.2s var(--spring), box-shadow 0.2s, opacity 0.2s; | |
| box-shadow:0 4px 18px rgba(76,201,240,0.4), inset 0 1px 0 var(--rim); | |
| position:relative; overflow:hidden; | |
| } | |
| #scan-btn::after { | |
| content:''; position:absolute; inset:0; | |
| background:linear-gradient(135deg, rgba(255,255,255,0.15), transparent); | |
| border-radius:12px; | |
| } | |
| #scan-btn:hover { transform:translateY(-1px) scale(1.02); box-shadow:0 8px 28px rgba(76,201,240,0.5), inset 0 1px 0 var(--rim); } | |
| #scan-btn:active { transform:scale(0.97); } | |
| #scan-btn:disabled { opacity:0.5; cursor:not-allowed; transform:none; box-shadow:none; } | |
| /* countdown */ | |
| #refresh-countdown { | |
| display:none; font-size:11px; font-weight:700; letter-spacing:0.2px; color:var(--t3); | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:8px; padding:5px 9px; white-space:nowrap; | |
| } | |
| #refresh-countdown.urgent { color:var(--amber); border-color:rgba(245,181,42,0.2); background:var(--amber3); } | |
| /* Countdown ring — conic-gradient dot showing progress to next auto-refresh */ | |
| #refresh-countdown { position:relative; padding-left:22px; } | |
| #refresh-countdown::before { | |
| content:''; position:absolute; left:7px; top:50%; transform:translateY(-50%); | |
| width:11px; height:11px; border-radius:50%; | |
| background:conic-gradient(var(--teal) calc(var(--pct,0)*1%), var(--track) 0); | |
| transition:background 0.3s linear; | |
| } | |
| #refresh-countdown.urgent::before { background:conic-gradient(var(--amber) calc(var(--pct,0)*1%), var(--track) 0); } | |
| /* Idle-life engagement pulse (ADHD-friendly layer) */ | |
| .idle-pulse { animation: idleGlow 2.4s var(--ease-out); } | |
| @keyframes idleGlow { | |
| 0%,100% { filter:none; } | |
| 50% { filter:drop-shadow(0 0 8px currentColor) brightness(1.3); } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| .idle-pulse { animation:none; } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| HERO | |
| ═══════════════════════════════════════════════ */ | |
| .hero { | |
| position:relative; z-index:1; | |
| padding:130px 24px 70px; | |
| text-align:center; max-width:860px; margin:0 auto; | |
| } | |
| .eyebrow { | |
| display:inline-flex; align-items:center; gap:8px; | |
| font-size:10.5px; font-weight:700; letter-spacing:.18em; text-transform:uppercase; | |
| color:var(--teal); | |
| background:var(--cyans); border:1px solid rgba(76,201,240,0.2); | |
| padding:8px 17px; border-radius:99px; margin-bottom:26px; | |
| animation:slideUp 0.6s var(--ease) 0.3s both; | |
| } | |
| .eyebrow-dot { width:5px; height:5px; border-radius:50%; background:var(--teal); animation:pulse 2s infinite; } | |
| .hero-title { | |
| font-size:clamp(44px,6.5vw,82px); | |
| font-family: var(--font-display); | |
| font-weight:800; letter-spacing:-.045em; line-height:1.0; | |
| color:var(--t1); margin-bottom:20px; | |
| animation:slideUp 0.6s var(--ease) 0.4s both; | |
| } | |
| .hero-title .line2 { | |
| display:block; | |
| /* Teal → Blue only — on-brand, conveys trust + innovation, no purple distraction */ | |
| background:linear-gradient(135deg, var(--teal) 0%, var(--teal2) 35%, var(--blue2) 70%, var(--blue) 100%); | |
| -webkit-background-clip:text; -webkit-text-fill-color:transparent; | |
| background-size:200% 200%; animation:gradShift 5s ease infinite; | |
| } | |
| @keyframes gradShift { 0%,100%{background-position:0% 50%} 50%{background-position:100% 50%} } | |
| .hero-sub { | |
| /* Body text: Inter, 16px minimum, 1.7 line-height, secondary text color */ | |
| font-size:16px; line-height:1.75; color:var(--t2); font-weight:400; | |
| max-width:500px; margin:0 auto 38px; letter-spacing:0.01em; | |
| animation:slideUp 0.6s var(--ease) 0.5s both; | |
| } | |
| .hero-btns { | |
| display:flex; justify-content:center; gap:10px; flex-wrap:wrap; | |
| animation:slideUp 0.6s var(--ease) 0.6s both; | |
| } | |
| .btn-hero { | |
| display:inline-flex; align-items:center; gap:9px; | |
| background:linear-gradient(135deg, var(--teal), #22a5cc); | |
| color:#fff; border:none; padding:16px 36px; border-radius:16px; | |
| font-size:16px; font-weight:800; font-family:var(--font); letter-spacing:-0.3px; | |
| cursor:pointer; position:relative; overflow:hidden; | |
| transition:transform 0.2s var(--spring), box-shadow 0.2s; | |
| box-shadow:0 8px 32px rgba(76,201,240,0.4), inset 0 1px 0 var(--rim); | |
| } | |
| .btn-hero::before { | |
| content:''; position:absolute; top:-50%; left:-60%; width:40%; height:200%; | |
| background:rgba(255,255,255,0.22); transform:skewX(-20deg); | |
| transition:left 0.5s var(--ease); | |
| } | |
| .btn-hero:hover { transform:translateY(-2px); box-shadow:0 16px 48px rgba(76,201,240,0.5); } | |
| .btn-hero:hover::before { left:130%; } | |
| .btn-hero:active { transform:scale(0.97); } | |
| .btn-hero:disabled { opacity:0.45; cursor:not-allowed; transform:none; } | |
| .btn-ghost2 { | |
| display:inline-flex; align-items:center; gap:8px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| color:var(--t2); padding:16px 28px; border-radius:16px; | |
| font-size:16px; font-weight:700; font-family:var(--font); | |
| cursor:pointer; | |
| transition:transform 0.2s var(--spring), box-shadow 0.2s, border-color 0.2s; | |
| box-shadow:0 2px 12px rgba(0,0,0,0.06), inset 0 1px 0 var(--rim); | |
| } | |
| .btn-ghost2:hover { transform:translateY(-1px); border-color:rgba(76,201,240,0.3); box-shadow:0 8px 24px rgba(0,0,0,0.08); } | |
| /* Stats row */ | |
| .hero-stats { | |
| display:flex; justify-content:center; gap:10px; flex-wrap:wrap; | |
| margin-top:52px; | |
| animation:slideUp 0.6s var(--ease) 0.75s both; | |
| } | |
| .stat-glass { | |
| background:var(--glass); border:1px solid var(--glassbdr); border-radius:18px; | |
| padding:16px 24px; min-width:120px; text-align:center; | |
| box-shadow:0 4px 24px rgba(0,0,0,0.06), inset 0 1px 0 var(--rim); | |
| transition:transform 0.25s var(--spring), box-shadow 0.25s; | |
| cursor:default; | |
| } | |
| .stat-glass:hover { transform:translateY(-4px); box-shadow:0 12px 36px rgba(0,0,0,0.1), inset 0 1px 0 var(--rim); } | |
| .stat-num { font-size:26px; font-weight:700; letter-spacing:-1px; color:var(--t1); line-height:1; font-family:var(--font-display); font-variant-numeric:tabular-nums; } | |
| .stat-lbl { font-size:10px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase; color:var(--t3); margin-top:5px; } | |
| /* Ticker tape */ | |
| .ticker-wrap { | |
| position:fixed; left:0; right:0; bottom:0; z-index:90; | |
| overflow:hidden; height:calc(38px + env(safe-area-inset-bottom)); | |
| padding-bottom:env(safe-area-inset-bottom); | |
| background:var(--glass2); | |
| border-top:1px solid var(--gb); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| .ticker-inner { | |
| display:flex; align-items:center; gap:0; | |
| white-space:nowrap; height:100%; width:max-content; | |
| animation:ticker 30s linear infinite; | |
| mask-image:linear-gradient(90deg, transparent, black 8%, black 92%, transparent); | |
| -webkit-mask-image:linear-gradient(90deg, transparent, black 8%, black 92%, transparent); | |
| } | |
| .ticker-inner:hover { animation-play-state:paused; } | |
| @keyframes ticker { from{transform:translateX(0)} to{transform:translateX(-50%)} } | |
| .tick-item { | |
| display:inline-flex; align-items:center; gap:6px; | |
| padding:0 24px; font-size:12px; font-weight:700; letter-spacing:0.1px; | |
| border-right:1px solid var(--gb); | |
| } | |
| .tick-sym { color:var(--t3); font-weight:600; letter-spacing:0.3px; } | |
| .tick-price { color:var(--t1); font-family:var(--font-display); font-variant-numeric:tabular-nums; font-weight:600; } | |
| .tick-chg { font-size:11px; font-variant-numeric:tabular-nums; } | |
| .tick-chg.up { color:var(--green); } | |
| .tick-chg.dn { color:var(--red); } | |
| @keyframes slideUp { | |
| from { opacity:0; transform:translateY(30px); } | |
| to { opacity:1; transform:translateY(0); } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| SIGNALS SECTION | |
| ═══════════════════════════════════════════════ */ | |
| #signals-section { | |
| position:relative; z-index:1; | |
| max-width:1500px; margin:0 auto; | |
| padding:32px 24px 8px; | |
| } | |
| .dot-amber { background:var(--warn) !important; animation:pulseAmber 2s infinite !important; } | |
| @keyframes pulseAmber { 0%,100%{box-shadow:0 0 0 0 rgba(245,181,42,0.5)} 50%{box-shadow:0 0 0 6px rgba(245,181,42,0)} } | |
| .signals-meta-txt { font-size:12px; color:var(--t3); font-weight:600; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:99px; padding:4px 12px; } | |
| .signals-refresh-btn { | |
| display:inline-flex; align-items:center; justify-content:center; | |
| width:30px; height:30px; border-radius:8px; border:1px solid var(--gb); | |
| background:var(--glass2); color:var(--t3); cursor:pointer; | |
| transition:color 0.2s, transform 0.3s var(--spring); | |
| } | |
| .signals-refresh-btn:hover { color:var(--teal); transform:rotate(180deg); } | |
| .signals-refresh-btn.spinning svg { animation:spin 0.8s linear infinite; } | |
| @keyframes spin { to { transform:rotate(360deg); } } | |
| @keyframes ticker-scroll { 0%{transform:translateX(0)} 100%{transform:translateX(-50%)} } | |
| .signals-loader { | |
| display:flex; align-items:center; gap:12px; | |
| padding:20px; color:var(--t3); font-size:13px; font-weight:500; | |
| } | |
| .signals-spinner { | |
| width:18px; height:18px; border-radius:50%; | |
| border:2px solid var(--ambers); border-top-color:var(--warn); | |
| animation:spin 0.8s linear infinite; flex-shrink:0; | |
| } | |
| .signals-empty { | |
| display:flex; align-items:center; gap:10px; | |
| padding:20px; color:var(--t3); font-size:13px; font-weight:500; | |
| background:var(--glass2); border-radius:14px; | |
| border:1px dashed var(--gb); | |
| } | |
| /* Signal cards strip — horizontal scroll */ | |
| .signals-grid { | |
| display:flex; | |
| gap:14px; | |
| overflow-x:auto; | |
| padding:4px 2px 14px; | |
| margin-top:4px; | |
| scrollbar-width:none; | |
| } | |
| .signals-grid::-webkit-scrollbar { display:none; } | |
| /* Individual signal card */ | |
| .sig-card { | |
| background:var(--glass2); | |
| will-change:transform; | |
| border:1px solid var(--gb); | |
| border-radius:18px; | |
| padding:18px; | |
| box-shadow:0 4px 20px rgba(0,0,0,0.07), inset 0 1px 0 var(--rim); | |
| animation:cardIn 0.4s var(--ease) both; | |
| position:relative; overflow:hidden; | |
| min-width:270px; flex:0 0 auto; | |
| } | |
| .sig-card::before { | |
| content:''; position:absolute; top:0; left:0; right:0; height:3px; | |
| border-radius:18px 18px 0 0; | |
| } | |
| .sig-card.urgency-critical::before { background:var(--red); } | |
| .sig-card.urgency-high::before { background:linear-gradient(90deg, var(--warn), var(--warn2)); } | |
| .sig-card.urgency-medium::before { background:linear-gradient(90deg, var(--teal), var(--teal2)); } | |
| /* Critical cards pulse border */ | |
| .sig-card.urgency-critical { | |
| box-shadow:0 4px 20px rgba(255,107,122,0.12), 0 0 0 1px rgba(255,107,122,0.1), | |
| inset 0 1px 0 var(--rim); | |
| } | |
| .sig-header { display:flex; align-items:flex-start; justify-content:space-between; margin-bottom:12px; } | |
| .sig-sym { font-size:22px; font-weight:700; font-family:var(--font-display); letter-spacing:-0.5px; color:var(--t1); } | |
| .sig-sym .q { font-size:13px; color:var(--t3); font-weight:500; margin-left:2px; } | |
| .sig-dir { | |
| font-size:11px; font-weight:800; letter-spacing:0.5px; | |
| padding:4px 10px; border-radius:8px; | |
| } | |
| .sig-dir.long { background:var(--mints); color:var(--profit); border:1px solid rgba(61,220,151,0.2); } | |
| .sig-dir.short { background:var(--reds); color:var(--loss); border:1px solid rgba(255,107,122,0.2); } | |
| .sig-dir.neutral{ background:var(--track); color:var(--t3); border:1px solid var(--gb); } | |
| /* Move type badges */ | |
| .sig-types { display:flex; flex-wrap:wrap; gap:5px; margin-bottom:12px; } | |
| .sig-type-badge { | |
| font-size:10px; font-weight:700; letter-spacing:0.3px; | |
| padding:3px 8px; border-radius:6px; | |
| } | |
| .sig-type-badge.breakout { background:var(--cyans); color:var(--blue); border:1px solid rgba(76,201,240,0.15); } | |
| .sig-type-badge.acceleration { background:var(--cyans); color:var(--teal); border:1px solid rgba(76,201,240,0.15); } | |
| .sig-type-badge.reversal { background:var(--ambers); color:var(--warn); border:1px solid rgba(245,181,42,0.15); } | |
| .sig-type-badge.news { background:var(--violets); color:var(--violet); border:1px solid rgba(155,123,255,0.15); } | |
| /* Score bar */ | |
| .sig-score-row { display:flex; align-items:center; gap:8px; margin-bottom:12px; } | |
| .sig-score-lbl { font-size:10px; font-weight:800; letter-spacing:0.6px; text-transform:uppercase; color:var(--t3); white-space:nowrap; } | |
| .sig-score-track { flex:1; height:6px; background:var(--track); border-radius:3px; overflow:hidden; } | |
| .sig-score-bar { height:100%; border-radius:3px; transition:width 1s var(--ease) 0.3s; width:0; } | |
| .sig-score-bar.urgency-critical { background:var(--red); } | |
| .sig-score-bar.urgency-high { background:linear-gradient(90deg, var(--warn), var(--warn2)); } | |
| .sig-score-bar.urgency-medium { background:linear-gradient(90deg, var(--teal), var(--teal2)); } | |
| .sig-score-num { font-size:12px; font-weight:800; font-family:var(--font-display); color:var(--t1); white-space:nowrap; } | |
| /* Reasons list */ | |
| .sig-reasons { display:flex; flex-direction:column; gap:4px; } | |
| .sig-reason { | |
| font-size:11px; color:var(--t2); font-weight:500; line-height:1.4; | |
| padding:4px 8px; background:var(--track); border-radius:6px; | |
| border-left:2px solid var(--gb); | |
| } | |
| .sig-reason.warn { border-left-color:var(--warn2); color:var(--warn); } | |
| .sig-reason.bull { border-left-color:var(--profit2); } | |
| .sig-reason.bear { border-left-color:var(--loss2); } | |
| /* Trade it button */ | |
| .sig-cta { | |
| display:inline-flex; align-items:center; gap:6px; | |
| margin-top:12px; width:100%; | |
| background:linear-gradient(135deg, var(--teal), #22a5cc); | |
| color:#fff; border:none; border-radius:10px; | |
| padding:9px 14px; font-size:12px; font-weight:700; | |
| cursor:pointer; justify-content:center; | |
| transition:transform 0.2s var(--spring), box-shadow 0.2s; | |
| box-shadow:0 3px 12px rgba(76,201,240,0.35); | |
| } | |
| .sig-cta:hover { transform:translateY(-1px); box-shadow:0 6px 18px rgba(76,201,240,0.45); } | |
| /* ═══════════════════════════════════════════════ | |
| SCAN SECTION | |
| ═══════════════════════════════════════════════ */ | |
| #scan-section { | |
| position:relative; z-index:1; | |
| max-width:1500px; margin:0 auto; | |
| padding:32px 24px 100px; | |
| } | |
| .section-header { | |
| display:flex; align-items:center; justify-content:space-between; | |
| margin-bottom:20px; flex-wrap:wrap; gap:10px; | |
| } | |
| /* ── Cycle Tracker — Section Card ── */ | |
| #cycle-section { | |
| background:var(--white); | |
| border:1px solid var(--bg2); | |
| border-radius:16px; | |
| padding:24px; | |
| isolation:isolate; | |
| } | |
| /* ── Cycle Tracker — Tabs ── */ | |
| .cycle-tabs-wrap { | |
| display:flex; gap:6px; margin-bottom:16px; | |
| border-bottom:2px solid var(--bg2); padding-bottom:0; | |
| } | |
| .cycle-tab { | |
| padding:9px 22px; border-radius:8px 8px 0 0; | |
| border:1.5px solid transparent; border-bottom:none; | |
| background:transparent; color:var(--t3); font-size:13px; font-weight:600; | |
| cursor:pointer; transition:all .18s ease; font-family:var(--font-display); | |
| margin-bottom:-2px; position:relative; | |
| } | |
| .cycle-tab:hover { color:var(--blue2); background:var(--track); } | |
| .cycle-tab.active { | |
| background:var(--white); color:var(--blue2); | |
| border-color:var(--bg2); border-bottom-color:var(--white); | |
| box-shadow:0 -2px 8px rgba(76,201,240,.08); | |
| } | |
| /* ── Cycle Tracker — Banner (on-system blue) ── */ | |
| .cycle-banner { | |
| border-radius:14px; padding:20px 24px; margin-bottom:14px; | |
| background:linear-gradient(135deg,#1e3a5f 0%,#1e40af 100%); | |
| color:#fff; | |
| display:flex; align-items:center; justify-content:space-between; | |
| flex-wrap:wrap; gap:12px; | |
| } | |
| .cycle-banner-label { font-size:10px; font-weight:700; letter-spacing:.08em; opacity:.65; text-transform:uppercase; } | |
| .cycle-banner-phase { font-size:22px; font-weight:800; font-family:var(--font-display); margin-top:3px; } | |
| .cycle-banner-sub { font-size:12px; opacity:.75; margin-top:4px; } | |
| .cycle-banner-count { font-size:28px; font-weight:800; font-family:var(--font-display); color:#f5b52a; } | |
| .cycle-banner-hint { font-size:11px; opacity:.65; margin-top:3px; } | |
| /* SOL banner — amber since limited data */ | |
| .cycle-banner.sol { background:linear-gradient(135deg,#78350f 0%,#b45309 100%); } | |
| /* ── Cycle Tracker — Progress bar ── */ | |
| .cycle-progress-wrap { | |
| background:var(--track); border:1px solid var(--bg2); border-radius:14px; | |
| padding:16px 20px; margin-bottom:12px; | |
| } | |
| .cycle-progress-labels { | |
| display:flex; justify-content:space-between; margin-top:6px; | |
| } | |
| .cycle-progress-labels span { font-size:11px; color:var(--t3); } | |
| /* ── Cycle Tracker — Stat cards ── */ | |
| .cycle-stats-row { | |
| display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:10px; margin-bottom:12px; | |
| } | |
| .cycle-stat { | |
| background:var(--track); border:1px solid var(--bg2); border-radius:12px; padding:16px; | |
| } | |
| .cycle-stat-lbl { font-size:10px; font-weight:700; color:var(--t3); letter-spacing:.06em; text-transform:uppercase; margin-bottom:6px; } | |
| .cycle-stat-val { font-size:20px; font-weight:800; font-family:var(--font-display); color:var(--t1); } | |
| .cycle-stat-val.loss { color:var(--loss); } | |
| .cycle-stat-val.warn { color:var(--warn); } | |
| .cycle-stat-sub { font-size:11px; color:var(--t3); margin-top:3px; line-height:1.4; } | |
| /* ── Cycle Tracker — Table ── */ | |
| .cycle-table-wrap { | |
| background:var(--track); border:1px solid var(--bg2); border-radius:14px; | |
| overflow-x:auto; overflow-y:hidden; -webkit-overflow-scrolling:touch; | |
| margin-bottom:14px; | |
| } | |
| .cycle-table-hdr { padding:13px 18px; border-bottom:1px solid var(--bg2); font-size:12px; font-weight:700; color:var(--t2); white-space:nowrap; } | |
| .cycle-table { width:100%; min-width:460px; border-collapse:collapse; font-size:12px; } | |
| .cycle-table thead tr { background:var(--bg2); } | |
| .cycle-table th { padding:9px 14px; text-align:right; color:var(--t3); font-size:10px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; } | |
| .cycle-table th:first-child { text-align:left; } | |
| .cycle-table td { padding:11px 14px; text-align:right; border-top:1px solid var(--bg2); font-variant-numeric:tabular-nums; } | |
| .cycle-table td:first-child { text-align:left; font-weight:600; color:var(--t1); } | |
| .cycle-table tr.alt { background:var(--white); } | |
| .cycle-table tr.now-row { background:rgba(245,181,42,.07); } | |
| .cycle-table tr.now-row td { border-top:2px dashed var(--warn3); } | |
| .cycle-table tr.now-row td:first-child { color:var(--warn); font-weight:700; } | |
| .badge-bear { background:var(--loss3); color:var(--loss); padding:3px 9px; border-radius:6px; font-weight:700; white-space:nowrap; } | |
| .badge-bull { background:var(--profit3); color:var(--profit); padding:3px 9px; border-radius:6px; font-weight:700; white-space:nowrap; } | |
| .badge-now { background:rgba(245,181,42,.15); color:var(--warn); padding:3px 9px; border-radius:6px; font-weight:700; white-space:nowrap; } | |
| .loss-txt { color:var(--loss); font-weight:600; } | |
| .profit-txt { color:var(--profit); font-weight:600; } | |
| .warn-txt { color:var(--warn); font-weight:600; } | |
| /* ── Cycle Tracker — Disclaimer note ── */ | |
| .cycle-note { | |
| border-left:3px solid var(--warn); | |
| background:rgba(245,181,42,.07); | |
| border-radius:0 10px 10px 0; | |
| padding:12px 16px; | |
| font-size:12px; color:var(--t2); line-height:1.6; | |
| } | |
| .cycle-note strong { color:var(--t1); } | |
| /* ── Cycle Tracker — SOL warning banner ── */ | |
| .cycle-sol-warning { | |
| background:rgba(255,107,122,.07); border:1px solid rgba(255,107,122,.2); | |
| border-radius:12px; padding:14px 16px; margin-bottom:12px; | |
| font-size:12px; color:var(--loss); line-height:1.5; | |
| display:flex; gap:10px; align-items:flex-start; | |
| } | |
| .cycle-sol-warning strong { color:var(--loss); } | |
| @media(max-width:720px){ | |
| .cycle-tabs-wrap { gap:4px; } | |
| .cycle-tab { padding:8px 14px; font-size:12px; } | |
| .cycle-banner { flex-direction:column; gap:8px; } | |
| .cycle-banner > div:last-child { text-align:left; } | |
| .cycle-stats-row { grid-template-columns:1fr 1fr; } | |
| .cycle-table { font-size:11px; } | |
| .cycle-table th, .cycle-table td { padding:8px 8px; } | |
| } | |
| .section-title { | |
| font-size:20px; font-weight:700; letter-spacing:-0.4px; color:var(--t1); | |
| font-family:var(--font-display); | |
| display:flex; align-items:center; gap:10px; | |
| } | |
| .section-title .dot { width:8px; height:8px; border-radius:50%; background:var(--teal); animation:pulse 2s infinite; } | |
| #meta { | |
| font-size:12px; color:var(--t3); font-weight:600; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:99px; padding:4px 12px; | |
| } | |
| #error-banner { | |
| display:none; background:var(--red3); border:1px solid rgba(255,107,122,0.2); | |
| border-radius:14px; padding:14px 20px; color:var(--red); | |
| font-size:13px; font-weight:600; margin-bottom:20px; | |
| animation:slideUp 0.3s var(--ease); | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| LOADER — PREMIUM SCANNING UI | |
| ═══════════════════════════════════════════════ */ | |
| #loader { | |
| display:none; flex-direction:column; align-items:center; | |
| padding:56px 24px 72px; gap:0; | |
| animation:slideUp 0.4s var(--ease); | |
| } | |
| /* ── Morphing orb ── */ | |
| .scan-orb-wrap { | |
| position:relative; width:160px; height:160px; | |
| margin-bottom:36px; | |
| display:grid; place-items:center; | |
| } | |
| /* Outer glow halo */ | |
| .scan-orb-halo { | |
| position:absolute; inset:-24px; border-radius:50%; | |
| background:radial-gradient(circle, rgba(76,201,240,0.14) 0%, transparent 70%); | |
| animation:haloPulse 2.5s ease-in-out infinite; | |
| } | |
| @keyframes haloPulse { | |
| 0%,100% { transform:scale(1); opacity:0.7; } | |
| 50% { transform:scale(1.18); opacity:1; } | |
| } | |
| /* The morphing blob */ | |
| .scan-blob { | |
| position:absolute; inset:0; | |
| background:linear-gradient(135deg, var(--teal) 0%, var(--blue2) 50%, var(--purple) 100%); | |
| background-size:200% 200%; | |
| border-radius:60% 40% 55% 45% / 50% 60% 40% 50%; | |
| animation:blobMorph 4s ease-in-out infinite, gradShift 3s ease infinite; | |
| filter:blur(1px); | |
| opacity:0.9; | |
| } | |
| @keyframes blobMorph { | |
| 0% { border-radius:60% 40% 55% 45% / 50% 60% 40% 50%; } | |
| 25% { border-radius:50% 50% 40% 60% / 60% 40% 60% 40%; } | |
| 50% { border-radius:40% 60% 60% 40% / 45% 55% 45% 55%; } | |
| 75% { border-radius:55% 45% 45% 55% / 55% 45% 55% 45%; } | |
| 100%{ border-radius:60% 40% 55% 45% / 50% 60% 40% 50%; } | |
| } | |
| /* Inner frosted disc */ | |
| .scan-orb-core { | |
| position:relative; z-index:2; | |
| width:80px; height:80px; border-radius:50%; | |
| background:var(--glass2); | |
| border:1px solid var(--gb); | |
| box-shadow:0 8px 32px rgba(0,0,0,0.12), inset 0 1px 0 var(--rim); | |
| display:grid; place-items:center; | |
| } | |
| /* Custom SVG-style scan icon — pure CSS bars */ | |
| .scan-icon-bars { | |
| display:flex; align-items:flex-end; gap:3px; height:28px; | |
| } | |
| .sib { | |
| width:4px; border-radius:3px; | |
| background:linear-gradient(180deg, var(--teal), var(--blue2)); | |
| } | |
| .sib:nth-child(1) { height:12px; animation:barDance 1.2s ease-in-out 0.0s infinite alternate; } | |
| .sib:nth-child(2) { height:22px; animation:barDance 1.2s ease-in-out 0.15s infinite alternate; } | |
| .sib:nth-child(3) { height:28px; animation:barDance 1.2s ease-in-out 0.3s infinite alternate; } | |
| .sib:nth-child(4) { height:18px; animation:barDance 1.2s ease-in-out 0.45s infinite alternate; } | |
| .sib:nth-child(5) { height:10px; animation:barDance 1.2s ease-in-out 0.6s infinite alternate; } | |
| @keyframes barDance { | |
| from { transform:scaleY(0.35); opacity:0.5; } | |
| to { transform:scaleY(1); opacity:1; } | |
| } | |
| /* Orbiting dot */ | |
| .scan-orbit { | |
| position:absolute; inset:0; animation:spinRing 2.2s linear infinite; z-index:3; | |
| } | |
| .scan-orbit-dot { | |
| position:absolute; top:4px; left:50%; transform:translateX(-50%); | |
| width:10px; height:10px; border-radius:50%; | |
| background:white; | |
| box-shadow:0 0 0 2px rgba(76,201,240,0.4), 0 2px 8px rgba(76,201,240,0.6); | |
| } | |
| .scan-orbit-dot::after { | |
| content:''; position:absolute; inset:-3px; border-radius:50%; | |
| background:var(--glass2); | |
| animation:orbitPing 2.2s ease-out infinite; | |
| } | |
| @keyframes orbitPing { | |
| 0% { transform:scale(1); opacity:0.8; } | |
| 100% { transform:scale(2.5); opacity:0; } | |
| } | |
| @keyframes spinRing { to{transform:rotate(360deg)} } | |
| /* ── Headline ── */ | |
| .scan-headline { | |
| font-size:26px; font-weight:700; letter-spacing:-0.8px; color:var(--t1); | |
| font-family:var(--font-display); | |
| text-align:center; margin-bottom:6px; | |
| animation:slideUp 0.5s var(--ease) 0.1s both; | |
| } | |
| .scan-subline { | |
| font-size:13px; color:var(--t3); font-weight:500; text-align:center; | |
| margin-bottom:32px; | |
| animation:slideUp 0.5s var(--ease) 0.2s both; | |
| } | |
| .scan-symbol-stream { | |
| font-family:var(--font-mono); font-size:12px; color:var(--t3); | |
| margin-top:-14px; margin-bottom:24px; min-height:18px; text-align:center; | |
| transition:opacity 0.12s; | |
| animation:slideUp 0.5s var(--ease) 0.45s both; | |
| } | |
| .scan-symbol-stream .sym-cur { color:var(--teal); font-weight:700; } | |
| /* Loader exit — crossfades with the first cards revealing underneath */ | |
| #loader.exiting { animation:loaderExit 0.32s var(--ease) forwards; } | |
| @keyframes loaderExit { to { opacity:0; transform:scale(0.97) translateY(-8px); } } | |
| @media (prefers-reduced-motion: reduce) { | |
| .scan-symbol-stream { transition:none; } | |
| #loader.exiting { animation:none; opacity:0; } | |
| } | |
| /* ── Step pipeline ── */ | |
| .scan-pipeline { | |
| width:100%; max-width:520px; | |
| display:flex; flex-direction:column; gap:8px; | |
| margin-bottom:28px; | |
| animation:slideUp 0.5s var(--ease) 0.3s both; | |
| } | |
| .scan-step { | |
| display:flex; align-items:center; gap:12px; | |
| padding:12px 16px; border-radius:14px; | |
| background:var(--glass2); | |
| border:1px solid var(--gb); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| transition:all 0.45s var(--ease); | |
| opacity:0.38; | |
| } | |
| .scan-step.active { | |
| opacity:1; background:var(--glass2); | |
| border-color:rgba(76,201,240,0.22); | |
| box-shadow:0 4px 20px rgba(76,201,240,0.1), inset 0 1px 0 var(--rim); | |
| transform:scale(1.015) translateX(3px); | |
| } | |
| .scan-step.done { | |
| opacity:0.7; background:var(--mints); | |
| border-color:rgba(61,220,151,0.18); | |
| transform:none; | |
| } | |
| /* Step number badge */ | |
| .step-num { | |
| width:30px; height:30px; border-radius:9px; flex-shrink:0; | |
| display:grid; place-items:center; | |
| font-size:12px; font-weight:800; color:var(--t3); | |
| background:var(--glass2); border:1px solid var(--gb); | |
| transition:all 0.3s var(--ease); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| .scan-step.active .step-num { | |
| background:var(--grad); | |
| color:transparent; border-color:transparent; | |
| box-shadow:0 4px 12px rgba(76,201,240,0.4); | |
| position:relative; | |
| } | |
| /* Spinner inside active num */ | |
| .scan-step.active .step-num::after { | |
| content:''; position:absolute; inset:5px; | |
| border:2px solid var(--gb); | |
| border-top-color:white; | |
| border-radius:50%; animation:spinRing 0.65s linear infinite; | |
| } | |
| .scan-step.done .step-num { | |
| background:var(--mints); color:var(--green); | |
| border-color:rgba(61,220,151,0.2); | |
| font-size:15px; | |
| } | |
| .step-body { flex:1; min-width:0; } | |
| .step-label { font-size:13px; font-weight:700; color:var(--t1); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| .step-sub { font-size:11px; color:var(--t3); margin-top:1px; font-weight:500; } | |
| .scan-step.active .step-sub { color:var(--teal); } | |
| .step-time { font-size:11px; font-weight:700; color:var(--t4); font-variant-numeric:tabular-nums; flex-shrink:0; } | |
| .scan-step.done .step-time { color:var(--green2); } | |
| /* ── Live counters ── */ | |
| .scan-counter { | |
| display:flex; align-items:stretch; gap:0; | |
| border-radius:18px; overflow:hidden; | |
| background:var(--glass2); | |
| border:1px solid var(--gb); | |
| box-shadow:0 4px 20px rgba(0,0,0,0.07), inset 0 1px 0 var(--rim); | |
| animation:slideUp 0.5s var(--ease) 0.4s both; | |
| } | |
| .sc-item { padding:14px 24px; text-align:center; } | |
| .sc-item + .sc-item { border-left:1px solid var(--gb); } | |
| .sc-num { font-size:24px; font-weight:700; letter-spacing:-1px; color:var(--t1); line-height:1; font-variant-numeric:tabular-nums; font-family:var(--font-display); } | |
| .sc-lbl { font-size:10px; font-weight:700; letter-spacing:0.5px; text-transform:uppercase; color:var(--t3); margin-top:4px; } | |
| .sc-item.accent .sc-num { color:var(--teal); } | |
| /* ═══════════════════════════════════════════════ | |
| EMPTY STATE | |
| ═══════════════════════════════════════════════ */ | |
| #empty { | |
| display:flex; flex-direction:column; align-items:center; | |
| padding:80px 0; gap:14px; | |
| } | |
| .empty-icon { | |
| width:96px; height:96px; | |
| background:var(--glass); border:1px solid var(--glassbdr); | |
| border-radius:26px; display:grid; place-items:center; | |
| font-size:44px; | |
| box-shadow:0 8px 32px rgba(0,0,0,0.06), inset 0 1px 0 var(--rim); | |
| animation:float 3.5s ease-in-out infinite; | |
| } | |
| @keyframes float { 0%,100%{transform:translateY(0)} 50%{transform:translateY(-12px)} } | |
| .empty-h { font-size:22px; font-weight:800; letter-spacing:-0.5px; color:var(--t1); opacity:0.45; } | |
| .empty-p { font-size:14px; color:var(--t3); } | |
| /* ═══════════════════════════════════════════════ | |
| CARDS GRID | |
| ═══════════════════════════════════════════════ */ | |
| #grid { | |
| position:relative; | |
| display:flex; | |
| flex-direction:column; | |
| gap:14px; | |
| } | |
| /* Never-blank auto-refresh — scanline sweep instead of a blank/loading flash */ | |
| .grid-scanline { | |
| position:absolute; top:0; left:0; right:0; height:2px; | |
| background:linear-gradient(90deg, transparent, var(--teal), var(--violet), transparent); | |
| opacity:0.9; pointer-events:none; z-index:5; border-radius:2px; | |
| animation: scanSweep 0.9s var(--ease) forwards; | |
| } | |
| @keyframes scanSweep { | |
| 0% { transform:translateY(0) scaleX(0.3); opacity:0; } | |
| 15% { opacity:1; } | |
| 100% { transform:translateY(var(--sweep-h,400px)) scaleX(1); opacity:0; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| .grid-scanline { display:none; } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| BACKTEST SKELETON + CHOREOGRAPHED REVEAL | |
| ═══════════════════════════════════════════════ */ | |
| .bt-skeleton { display:flex; flex-direction:column; gap:8px; padding:6px 0; } | |
| .bt-skel-row { | |
| height:14px; border-radius:6px; | |
| background:linear-gradient(90deg, var(--track) 25%, var(--glass2) 50%, var(--track) 75%); | |
| background-size:200% 100%; | |
| animation:skelShimmer 1.4s ease-in-out infinite; | |
| } | |
| .bt-skel-row.w1 { width:60%; } | |
| .bt-skel-row.w2 { width:100%; height:44px; } | |
| .bt-skel-row.w3 { width:80%; } | |
| @keyframes skelShimmer { 0%{background-position:200% 0} 100%{background-position:-200% 0} } | |
| .bt-panel.reveal-in .bt-stat { animation: btStatIn 0.4s var(--ease) both; } | |
| .bt-panel.reveal-in .bt-stat:nth-child(1) { animation-delay:0.02s; } | |
| .bt-panel.reveal-in .bt-stat:nth-child(2) { animation-delay:0.09s; } | |
| .bt-panel.reveal-in .bt-stat:nth-child(3) { animation-delay:0.16s; } | |
| .bt-panel.reveal-in .bt-state-cell { animation: btStatIn 0.4s var(--ease) both; } | |
| .bt-panel.reveal-in .bt-state-cell:nth-child(1) { animation-delay:0.22s; } | |
| .bt-panel.reveal-in .bt-state-cell:nth-child(2) { animation-delay:0.27s; } | |
| .bt-panel.reveal-in .bt-state-cell:nth-child(3) { animation-delay:0.32s; } | |
| .bt-panel.reveal-in .bt-sparkline { animation: btStatIn 0.5s var(--ease) 0.36s both; } | |
| @keyframes btStatIn { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:translateY(0)} } | |
| @media (prefers-reduced-motion: reduce) { | |
| .bt-skel-row { animation:none; } | |
| .bt-panel.reveal-in .bt-stat, .bt-panel.reveal-in .bt-state-cell, .bt-panel.reveal-in .bt-sparkline { animation:none; } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| TOAST NOTIFICATIONS | |
| ═══════════════════════════════════════════════ */ | |
| #toast-root { | |
| position:fixed; bottom:calc(50px + env(safe-area-inset-bottom)); left:50%; transform:translateX(-50%); | |
| z-index:900; display:flex; flex-direction:column-reverse; gap:8px; align-items:center; | |
| pointer-events:none; | |
| } | |
| .toast { | |
| display:inline-flex; align-items:center; gap:8px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| backdrop-filter:blur(14px) saturate(160%); -webkit-backdrop-filter:blur(14px) saturate(160%); | |
| color:var(--t1); font-size:13px; font-weight:600; font-family:var(--font); | |
| padding:10px 18px; border-radius:99px; white-space:nowrap; | |
| box-shadow:0 8px 32px rgba(0,0,0,0.18), inset 0 1px 0 var(--rim); | |
| animation: toastIn 0.35s var(--ease-spring) both, toastOut 0.3s var(--ease) 2.1s forwards; | |
| } | |
| .toast.success { border-color:rgba(61,220,151,0.3); } | |
| .toast.success svg { color:var(--green); flex-shrink:0; } | |
| @keyframes toastIn { from{opacity:0;transform:translateY(16px) scale(0.92)} to{opacity:1;transform:translateY(0) scale(1)} } | |
| @keyframes toastOut { to{opacity:0;transform:translateY(8px) scale(0.95)} } | |
| @media (prefers-reduced-motion: reduce) { | |
| .toast { animation:none; opacity:1; } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| TRADE CARD — LIQUID GLASS PREMIUM | |
| ═══════════════════════════════════════════════ */ | |
| .card { | |
| position:relative; overflow:hidden; | |
| background:var(--glass); | |
| will-change:transform; | |
| border:1px solid var(--glassbdr); | |
| border-radius:var(--r3); | |
| box-shadow: | |
| inset 0 1px 0 var(--rim), | |
| inset 0 -1px 0 rgba(0,0,0,0.03), | |
| 0 8px 32px rgba(0,0,0,0.08), | |
| 0 2px 8px rgba(0,0,0,0.04); | |
| padding:22px; | |
| transition:transform 0.35s var(--ease), box-shadow 0.35s var(--ease), border-color 0.3s; | |
| animation:cardReveal 0.55s var(--ease) both; | |
| cursor:default; | |
| } | |
| @keyframes cardReveal { | |
| from { opacity:0; transform:translateY(24px) scale(0.97); } | |
| to { opacity:1; transform:translateY(0) scale(1); } | |
| } | |
| /* Top specular sheen */ | |
| .card::before { | |
| content:''; position:absolute; top:0; left:0; right:0; height:45%; | |
| background:linear-gradient(180deg, rgba(255,255,255,0.07) 0%, rgba(255,255,255,0) 100%); | |
| border-radius:var(--r3) var(--r3) 0 0; pointer-events:none; z-index:0; | |
| } | |
| /* Hover edge glow */ | |
| .card::after { | |
| content:''; position:absolute; top:0; left:8%; right:8%; height:1.5px; | |
| background:linear-gradient(90deg, transparent, var(--teal), transparent); | |
| opacity:0; transition:opacity 0.3s; z-index:0; | |
| } | |
| .card:hover { transform:translateY(-5px) scale(1.008); border-color:var(--rim); box-shadow:inset 0 1px 0 var(--rim), 0 24px 64px rgba(0,0,0,0.13), 0 0 0 1px rgba(76,201,240,0.12); } | |
| .card:hover::after { opacity:1; } | |
| .card > * { position:relative; z-index:1; } | |
| /* Card ambient background tint based on direction */ | |
| .card.long-card { background:linear-gradient(160deg, var(--glass) 0%, var(--mints) 130%); } | |
| .card.short-card { background:linear-gradient(160deg, var(--glass) 0%, var(--reds) 130%); } | |
| /* ── Card header ── */ | |
| .ch { display:flex; justify-content:space-between; align-items:flex-start; margin-bottom:16px; } | |
| .ch-left {} | |
| .ch-sym { | |
| font-size:30px; font-weight:700; letter-spacing:-1px; line-height:1; color:var(--t1); | |
| font-family:var(--font-display); | |
| display:flex; align-items:baseline; gap:5px; | |
| } | |
| /* Quote currency (USDT) in muted style — it's secondary info */ | |
| .ch-sym .q { font-size:13px; font-weight:500; color:var(--t4); letter-spacing:0.5px; text-transform:uppercase; font-family:var(--font); } | |
| .ch-badges { display:flex; align-items:center; gap:5px; margin-top:5px; flex-wrap:wrap; } | |
| .badge { | |
| font-size:10px; font-weight:700; letter-spacing:0.05em; text-transform:uppercase; | |
| padding:4px 10px; border-radius:7px; | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| .badge-exch { background:rgba(76,201,240,0.1); color:var(--teal); border:1px solid rgba(76,201,240,0.2); } | |
| .badge-cv { background:var(--cyans); color:var(--blue2); border:1px solid rgba(76,201,240,0.2); } | |
| .badge-chg.pos { background:var(--mints); color:var(--green); border:1px solid rgba(61,220,151,0.2); } | |
| .badge-chg.neg { background:var(--red3); color:var(--red); border:1px solid rgba(255,107,122,0.2); } | |
| .badge-fr.fr-hi { background:var(--reds); color:var(--red); border:1px solid rgba(255,107,122,0.15); } | |
| .badge-fr.fr-lo { background:var(--mints); color:var(--green); border:1px solid rgba(61,220,151,0.15); } | |
| .badge-fr.fr-n { background:var(--glass2); color:var(--t3); border:1px solid var(--gb); } | |
| .badge-oi { background:var(--violets); color:var(--purple); border:1px solid rgba(155,123,255,0.15); } | |
| .badge-vol { background:var(--glass2); color:var(--t3); border:1px solid var(--gb); } | |
| .dir-tag { | |
| font-size:10.5px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; | |
| padding:5px 12px; border-radius:8px; | |
| flex-shrink:0; | |
| position:relative; overflow:hidden; | |
| transition:transform 0.2s var(--spring); | |
| } | |
| .dir-tag:hover { transform:scale(1.05); } | |
| .dir-tag.long { background:var(--mints); color:var(--green); border:1px solid rgba(61,220,151,0.25); box-shadow:0 2px 8px rgba(61,220,151,0.2), inset 0 1px 0 var(--rim); } | |
| .dir-tag.short { background:var(--red3); color:var(--red); border:1px solid rgba(255,107,122,0.2); box-shadow:0 2px 8px rgba(255,107,122,0.15), inset 0 1px 0 var(--rim); } | |
| /* ── Confidence ── */ | |
| .conf-box { | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:16px; padding:14px 16px; margin-bottom:14px; | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| transition:transform 0.2s var(--spring); | |
| } | |
| .conf-box:hover { transform:scale(1.01); } | |
| .conf-row { display:flex; justify-content:space-between; align-items:center; margin-bottom:10px; } | |
| .conf-lbl { font-size:11px; font-weight:700; letter-spacing:1px; text-transform:uppercase; color:var(--t3); } | |
| /* Display font + tabular-nums = Bloomberg-terminal look */ | |
| .conf-num { font-size:38px; font-weight:700; letter-spacing:-2px; line-height:1; font-family:var(--font-display); font-variant-numeric:tabular-nums; } | |
| .conf-denom { font-size:16px; font-weight:400; color:var(--t4); letter-spacing:0; } | |
| /* Confidence bar thicker for visibility */ | |
| .conf-bar { height:6px; background:var(--track); border-radius:4px; overflow:hidden; margin-top:2px; } | |
| .conf-fill { height:100%; border-radius:4px; transition:width 1.4s var(--ease); } | |
| /* GREEN = safe bet, AMBER = caution, RED = risky — matches trading convention */ | |
| .conf-fill.hi { background:linear-gradient(90deg, var(--profit) 0%, var(--profit2) 100%); } | |
| .conf-fill.md { background:linear-gradient(90deg, var(--warn) 0%, var(--warn2) 100%); } | |
| .conf-fill.lo { background:linear-gradient(90deg, var(--loss) 0%, var(--loss2) 100%); } | |
| /* ── Price flash + delta float (live ticker updates) ── */ | |
| .lvl.entry { position:relative; overflow:visible; } | |
| .price-flash-up { animation: flashUp 0.9s var(--ease); border-radius:12px; } | |
| .price-flash-down { animation: flashDown 0.9s var(--ease); border-radius:12px; } | |
| @keyframes flashUp { 0%{background:rgba(61,220,151,0.25)} 100%{background:transparent} } | |
| @keyframes flashDown { 0%{background:rgba(255,107,122,0.25)} 100%{background:transparent} } | |
| .price-delta-float { | |
| position:absolute; top:-6px; right:6px; | |
| font-size:10px; font-weight:800; font-family:var(--font-mono); | |
| pointer-events:none; z-index:4; | |
| animation: deltaFloat 1.15s var(--ease) forwards; | |
| } | |
| .price-delta-float.up { color:var(--green); } | |
| .price-delta-float.down { color:var(--red); } | |
| @keyframes deltaFloat { | |
| 0% { transform:translateY(2px); opacity:0; } | |
| 18% { opacity:1; } | |
| 100% { transform:translateY(-20px); opacity:0; } | |
| } | |
| @media (prefers-reduced-motion: reduce) { | |
| .price-flash-up, .price-flash-down, .price-delta-float { animation:none !important; } | |
| } | |
| /* ── Timeframe chips ── */ | |
| .tf-row { display:flex; gap:5px; margin-bottom:14px; flex-wrap:wrap; } | |
| .tf { font-size:10px; font-weight:700; letter-spacing:0.3px; padding:4px 10px; border-radius:99px; text-transform:uppercase; transition:transform 0.15s var(--spring); cursor:default; } | |
| .tf:hover { transform:scale(1.05); } | |
| .tf.up { background:var(--mints); color:var(--green); border:1px solid rgba(61,220,151,0.2); } | |
| .tf.down { background:var(--red3); color:var(--red); border:1px solid rgba(255,107,122,0.2); } | |
| .tf.range { background:var(--amber3); color:var(--amber); border:1px solid rgba(245,181,42,0.2); } | |
| .tf.na { background:var(--glass2); color:var(--t3); border:1px solid var(--gb); } | |
| .rule { height:1px; background:var(--track); margin:14px -22px; } | |
| /* ── Levels grid ── */ | |
| /* Color-coded: each box tells a story at a glance (trading convention) */ | |
| .lvl-grid { display:grid; grid-template-columns:repeat(6,1fr); gap:9px; margin-bottom:10px; } | |
| .lvl-pnl { font-size:10px; font-weight:700; font-variant-numeric:tabular-nums; margin-top:2px; } | |
| .lvl-pnl.pos { color:var(--profit); } | |
| .lvl-pnl.neg { color:var(--loss); } | |
| .lvl-pnl.neutral { color:var(--t3); font-weight:600; } | |
| .lvl { | |
| border-radius:12px; padding:10px 12px; | |
| position:relative; overflow:hidden; | |
| transition:transform 0.2s var(--spring), box-shadow 0.2s; | |
| border:1px solid transparent; | |
| min-width:0; | |
| } | |
| .lvl:hover { transform:translateY(-2px); } | |
| /* Entry = teal tint — neutral action point */ | |
| .lvl.entry { | |
| background:var(--cyans); | |
| border-color:rgba(76,201,240,0.15); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| /* SL = red tint — danger, hard stop */ | |
| .lvl.sl { | |
| background:var(--reds); | |
| border-color:rgba(255,107,122,0.15); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| /* TP = green tint — profit target */ | |
| .lvl.tp { | |
| background:var(--mints); | |
| border-color:rgba(61,220,151,0.15); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| /* R/R — color shifts by quality: green ≥1.5, amber 1.0–1.5, red <1.0 */ | |
| .lvl.rr { | |
| background:var(--ambers); | |
| border-color:rgba(245,181,42,0.15); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| .lvl.rr.rr-good { background:var(--mints); border-color:rgba(61,220,151,0.15); } | |
| .lvl.rr.rr-bad { background:var(--reds); border-color:rgba(255,107,122,0.15); } | |
| /* Thick left border as a strong color signal — key for quick scanning */ | |
| .lvl::before { content:''; position:absolute; top:0; left:0; bottom:0; width:3px; } | |
| .lvl.entry::before { background:var(--teal); } | |
| .lvl.sl::before { background:var(--loss2); } | |
| .lvl.tp::before { background:var(--profit2); } | |
| .lvl.rr::before { background:var(--warn2); } | |
| .lvl-lbl { font-size:9px; font-weight:800; letter-spacing:.12em; text-transform:uppercase; margin-bottom:5px; } | |
| .lvl.entry .lvl-lbl { color:var(--teal); } | |
| .lvl.sl .lvl-lbl { color:var(--loss); } | |
| .lvl.tp .lvl-lbl { color:var(--profit); } | |
| .lvl.rr .lvl-lbl { color:var(--warn); } | |
| .lvl.rr.rr-good .lvl-lbl { color:var(--profit); } | |
| .lvl.rr.rr-bad .lvl-lbl { color:var(--loss); } | |
| /* Space Grotesk + tabular-nums = precise, Bloomberg-style data */ | |
| .lvl-val { font-size:14.5px; font-weight:600; letter-spacing:-0.2px; font-variant-numeric:tabular-nums; font-family:var(--font-display); overflow-wrap:anywhere; } | |
| .lvl.entry .lvl-val { color:var(--t1); } | |
| .lvl.sl .lvl-val { color:var(--loss); } | |
| .lvl.tp .lvl-val { color:var(--profit); } | |
| .lvl.rr .lvl-val { color:var(--warn); } | |
| .lvl.rr.rr-good .lvl-val { color:var(--profit); } | |
| .lvl.rr.rr-bad .lvl-val { color:var(--loss); } | |
| /* ── Sizing strip ── */ | |
| .sz-strip { | |
| display:flex; border-radius:12px; overflow:hidden; | |
| border:1px solid var(--gb); margin-bottom:12px; | |
| background:var(--glass2); | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| } | |
| .sz-item { flex:1; padding:10px 12px; text-align:center; } | |
| .sz-item + .sz-item { border-left:1px solid var(--gb); } | |
| .sz-lbl { font-size:11px; font-weight:700; letter-spacing:0.8px; text-transform:uppercase; color:var(--t3); margin-bottom:3px; } | |
| /* Tabular-nums + mono font for all financial figures (font-family also enforced by the shared numerals rule below) */ | |
| .sz-val { font-size:16px; font-weight:600; font-variant-numeric:tabular-nums; font-family:var(--font-mono); color:var(--t1); } | |
| /* ── Narrative ── */ | |
| .narr { | |
| font-size:12px; color:var(--t2); line-height:1.65; | |
| padding:11px 14px; margin-bottom:12px; | |
| background:var(--cyans); | |
| border-left:2px solid rgba(76,201,240,0.4); | |
| border-radius:0 10px 10px 0; | |
| position:relative; | |
| } | |
| /* ── Score breakdown ── */ | |
| .ev-title { font-size:11px; font-weight:800; letter-spacing:0.8px; text-transform:uppercase; color:var(--t3); margin-bottom:10px; } | |
| .ev-row { display:flex; align-items:center; gap:11px; margin-bottom:7px; } | |
| .ev-key { font-size:11px; font-weight:600; color:var(--t2); width:82px; flex-shrink:0; } | |
| .ev-track { flex:1; height:6px; background:var(--track); border-radius:99px; overflow:hidden; } | |
| .ev-bar { height:100%; border-radius:99px; transition:width 1s var(--ease) 0.2s; width:0; } | |
| .ev-bar.hi { background:linear-gradient(90deg, var(--profit), var(--profit2)); } | |
| .ev-bar.med { background:linear-gradient(90deg, var(--warn), var(--warn2)); } | |
| .ev-bar.lo { background:linear-gradient(90deg, var(--loss), var(--loss2)); } | |
| .ev-score { font-size:11px; font-weight:800; width:20px; text-align:right; color:var(--t1); } | |
| /* ── Probability / EV strip ── */ | |
| .prob-strip { | |
| display:flex; align-items:stretch; gap:6px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:10px 12px; margin-bottom:10px; | |
| } | |
| .prob-block { flex:1; text-align:center; } | |
| .prob-lbl { font-size:11px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); margin-bottom:3px; } | |
| .prob-val { font-size:20px; font-weight:700; font-family:var(--font-display); font-variant-numeric:tabular-nums; line-height:1; } | |
| .prob-val.pwin { color:var(--profit); } | |
| .prob-val.ploss { color:var(--loss); } | |
| .prob-val.ev-pos { color:var(--profit); } | |
| .prob-val.ev-neg { color:var(--loss); } | |
| .prob-val.ev-zero{ color:var(--t3); } | |
| .prob-divider { width:1px; background:var(--track); flex-shrink:0; } | |
| /* ── Leverage strip ── */ | |
| .lev-strip { | |
| display:flex; align-items:center; gap:8px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:9px 12px; margin-bottom:10px; | |
| } | |
| .lev-icon { font-size:16px; flex-shrink:0; } | |
| .lev-main { flex:1; } | |
| .lev-lbl { font-size:11px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); margin-bottom:1px; } | |
| .lev-val { font-size:17px; font-weight:700; font-family:var(--font-display); font-variant-numeric:tabular-nums; color:var(--t1); line-height:1.1; } | |
| .lev-max { font-size:10px; color:var(--t3); font-weight:500; } | |
| .lev-warn { font-size:10px; color:var(--warn); font-weight:600; margin-top:2px; } | |
| .lev-cap { font-size:10px; color:var(--loss); font-weight:500; margin-top:2px; } | |
| .lev-profit { font-size:11px; color:var(--profit); font-weight:600; margin-top:2px; font-variant-numeric:tabular-nums; } | |
| /* ── Duration strip ── */ | |
| .dur-strip { | |
| display:flex; align-items:center; gap:8px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:9px 12px; margin-bottom:10px; | |
| } | |
| .dur-icon { font-size:16px; flex-shrink:0; } | |
| .dur-main { flex:1; } | |
| .dur-lbl { font-size:11px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); margin-bottom:1px; } | |
| .dur-val { font-size:15px; font-weight:700; font-family:var(--font-display); color:var(--t1); line-height:1.2; } | |
| /* ── Veto banner ── */ | |
| .veto-banner { | |
| background: var(--reds); border: 1.5px solid rgba(255,107,122,0.4); | |
| border-radius: 10px; padding: 10px 14px; margin-bottom: 10px; | |
| display: flex; flex-direction: column; gap: 4px; | |
| } | |
| .veto-banner-title { | |
| font-size: 13px; font-weight: 700; color: var(--red); | |
| display: flex; align-items: center; gap: 6px; | |
| } | |
| .veto-banner-reason { | |
| font-size: 11.5px; color: var(--red); line-height: 1.45; | |
| } | |
| /* ── Price Action section ── */ | |
| .pa-section { | |
| margin-bottom:10px; | |
| } | |
| .pa-header { | |
| display:flex; align-items:center; gap:7px; margin-bottom:8px; | |
| } | |
| .pa-title { | |
| font-size:11px; font-weight:800; letter-spacing:0.8px; text-transform:uppercase; color:var(--t3); flex:1; | |
| } | |
| .pa-grade { | |
| font-size:11px; font-weight:800; padding:2px 8px; border-radius:6px; letter-spacing:0.4px; flex-shrink:0; | |
| } | |
| .pa-grade-ap { background:rgba(61,220,151,0.18); color:var(--mint); border:1px solid rgba(61,220,151,0.25); } | |
| .pa-grade-a { background:rgba(61,220,151,0.12); color:var(--mint); border:1px solid rgba(61,220,151,0.2); } | |
| .pa-grade-b { background:rgba(245,181,42,0.13); color:var(--warn); border:1px solid rgba(245,181,42,0.2); } | |
| .pa-grade-c { background:var(--ambers); color:var(--amber); border:1px solid rgba(245,181,42,0.2); } | |
| .pa-grade-d { background:rgba(255,107,122,0.13); color:var(--loss); border:1px solid rgba(255,107,122,0.2); } | |
| .pa-score-num { | |
| font-size:11px; font-weight:700; color:var(--t3); flex-shrink:0; | |
| } | |
| .pa-row { | |
| display:flex; align-items:flex-start; gap:7px; | |
| padding:7px 10px; border-radius:10px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| margin-bottom:6px; cursor:pointer; position:relative; | |
| } | |
| .pa-row-icon { font-size:13px; flex-shrink:0; margin-top:1px; } | |
| .pa-row-body { flex:1; min-width:0; } | |
| .pa-row-top { display:flex; align-items:center; gap:5px; flex-wrap:wrap; } | |
| .pa-pat-name { font-size:12px; font-weight:700; color:var(--t1); } | |
| .pa-signal-bull { color:var(--profit2); font-size:11px; } | |
| .pa-signal-bear { color:var(--loss2); font-size:11px; } | |
| .pa-dots { font-size:10px; color:var(--t3); letter-spacing:1px; flex-shrink:0; } | |
| .pa-key-badge { | |
| font-size:10px; font-weight:700; padding:1px 5px; border-radius:4px; | |
| background:rgba(245,181,42,0.15); color:var(--warn); border:1px solid rgba(245,181,42,0.2); | |
| } | |
| .pa-stage-confirmed { font-size:10px; font-weight:700; padding:1px 5px; border-radius:4px; background:rgba(61,220,151,0.13); color:var(--profit); border:1px solid rgba(61,220,151,0.2); } | |
| .pa-stage-forming { font-size:10px; font-weight:700; padding:1px 5px; border-radius:4px; background:rgba(245,181,42,0.12); color:var(--warn); border:1px solid rgba(245,181,42,0.18); } | |
| .pa-stage-broken { font-size:10px; font-weight:700; padding:1px 5px; border-radius:4px; background:rgba(155,123,255,0.13); color:var(--violet); border:1px solid rgba(155,123,255,0.22); } | |
| .pa-target { font-size:11px; font-weight:600; color:var(--profit); } | |
| .pa-more { font-size:11px; color:var(--t3); font-weight:600; white-space:nowrap; } | |
| .pa-desc { | |
| display:grid; grid-template-rows:0fr; | |
| transition:grid-template-rows 0.35s var(--ease), margin-top 0.35s var(--ease); | |
| margin-top:0; | |
| } | |
| .pa-desc-inner { | |
| overflow:hidden; min-height:0; | |
| font-size:11px; color:var(--t2); line-height:1.5; | |
| opacity:0; transition:opacity 0.2s var(--ease); | |
| } | |
| .pa-row.expanded .pa-desc { grid-template-rows:1fr; margin-top:4px; } | |
| .pa-row.expanded .pa-desc-inner { opacity:1; transition-delay:0.08s; } | |
| .pa-row-icon { transition:transform 0.3s var(--ease-spring); display:inline-block; } | |
| .pa-row.expanded .pa-row-icon { transform:scale(1.15) rotate(6deg); } | |
| @media (prefers-reduced-motion: reduce) { | |
| .pa-desc, .pa-desc-inner, .pa-row-icon { transition:none; } | |
| } | |
| .pa-ob-line { | |
| font-size:11px; color:var(--t3); padding:4px 2px 0; line-height:1.5; | |
| } | |
| .pa-conflict { | |
| display:flex; align-items:center; gap:5px; | |
| font-size:11px; font-weight:600; color:var(--warn); | |
| background:rgba(245,181,42,0.08); border:1px solid rgba(245,181,42,0.18); | |
| border-radius:8px; padding:5px 10px; margin-top:4px; | |
| } | |
| .pa-conf-grade { | |
| font-size:11px; font-weight:800; padding:1px 7px; border-radius:6px; | |
| letter-spacing:0.3px; margin-left:5px; vertical-align:middle; | |
| } | |
| /* ── Backtest panel ── */ | |
| .bt-panel { | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:9px 12px; margin-bottom:10px; | |
| } | |
| /* ── Live Accuracy panel ── */ | |
| .live-acc-panel { | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:9px 12px; margin-bottom:10px; | |
| } | |
| .live-acc-header { | |
| display:flex; align-items:center; gap:8px; margin-bottom:8px; flex-wrap:wrap; | |
| } | |
| .live-acc-lbl { font-size:11px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); flex:1; min-width:0; } | |
| .live-acc-badge { | |
| font-size:11px; font-weight:700; padding:2px 7px; border-radius:5px; | |
| white-space:nowrap; flex-shrink:0; | |
| } | |
| .live-acc-badge.win { background:var(--mints); color:var(--profit); border:1px solid rgba(61,220,151,0.2); } | |
| .live-acc-badge.loss { background:var(--reds); color:var(--loss); border:1px solid rgba(255,107,122,0.2); } | |
| .live-acc-badge.pending { background:var(--ambers); color:var(--warn); border:1px solid rgba(245,181,42,0.2); } | |
| .live-acc-badge.none { background:var(--track); color:var(--t3); border:1px solid var(--gb); } | |
| .live-acc-stats { display:flex; gap:6px; margin-bottom:8px; } | |
| .live-acc-stat { | |
| flex:1; background:var(--track); border-radius:8px; padding:5px 6px; text-align:center; | |
| } | |
| .live-acc-stat-val { font-size:16px; font-weight:800; font-family:var(--font-display); letter-spacing:-0.5px; color:var(--t1); } | |
| .live-acc-stat-val.win { color:var(--profit); } | |
| .live-acc-stat-val.loss { color:var(--loss); } | |
| .live-acc-stat-lbl { font-size:11px; font-weight:600; color:var(--t3); letter-spacing:0.3px; margin-top:1px; } | |
| .live-acc-note { font-size:10px; color:var(--t3); line-height:1.4; margin-top:4px; } | |
| .live-acc-log { margin-top:6px; } | |
| .live-acc-log-row { | |
| display:flex; align-items:center; gap:6px; padding:3px 0; | |
| border-bottom:1px solid var(--gb); font-size:10px; | |
| } | |
| .live-acc-log-row:last-child { border-bottom:none; } | |
| .live-acc-sym { font-weight:700; font-size:10px; color:var(--t1); flex:1; } | |
| .live-acc-dir { font-size:11px; font-weight:700; padding:1px 5px; border-radius:4px; } | |
| .live-acc-dir.long { background:var(--mints); color:var(--profit); } | |
| .live-acc-dir.short { background:var(--reds); color:var(--loss); } | |
| .live-acc-outcome { font-size:11px; font-weight:700; min-width:40px; text-align:center; } | |
| .live-acc-outcome.win { color:var(--profit); } | |
| .live-acc-outcome.loss { color:var(--loss); } | |
| .live-acc-outcome.pending { color:var(--warn); } | |
| .live-acc-outcome.expired { color:var(--t3); } | |
| .live-acc-r { font-size:11px; font-weight:700; color:var(--t2); min-width:28px; text-align:right; } | |
| .live-acc-resolve-btn { | |
| font-size:11px; font-weight:700; padding:3px 8px; border-radius:6px; border:none; cursor:pointer; | |
| background:var(--teal3); color:var(--teal); border:1px solid rgba(76,201,240,0.2); | |
| transition:all 0.2s; | |
| } | |
| .live-acc-resolve-btn:hover { background:var(--teal); color:#fff; } | |
| /* Global accuracy bar at top of page */ | |
| .global-acc-bar { | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:10px 14px; margin-bottom:14px; | |
| display:flex; align-items:center; gap:10px; flex-wrap:wrap; | |
| } | |
| .global-acc-title { font-size:11px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); } | |
| .global-acc-wr { font-size:18px; font-weight:800; font-family:var(--font-display); letter-spacing:-0.5px; } | |
| .global-acc-wr.win { color:var(--profit); } | |
| .global-acc-wr.loss { color:var(--loss); } | |
| .global-acc-wr.neutral { color:var(--t2); } | |
| .global-acc-wr.none { color:var(--t3); } | |
| .global-acc-sub { font-size:10px; color:var(--t3); } | |
| .global-acc-spacer { flex:1; } | |
| /* Resolve button at top */ | |
| .bt-header { | |
| display:flex; align-items:center; gap:8px; margin-bottom:8px; | |
| } | |
| .bt-lbl { font-size:11px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); flex:1; } | |
| .bt-run-btn { | |
| font-size:10px; font-weight:700; padding:3px 10px; border-radius:7px; border:none; cursor:pointer; | |
| background:var(--teal3); color:var(--teal); border:1px solid rgba(76,201,240,0.2); | |
| transition:all 0.2s; | |
| } | |
| .bt-run-btn:hover { background:var(--teal); color:#fff; } | |
| .bt-run-btn:disabled { opacity:0.5; cursor:wait; } | |
| .bt-grounded-badge { | |
| font-size:11px; font-weight:700; padding:2px 7px; border-radius:5px; | |
| background:rgba(76,201,240,0.08); color:var(--blue); border:1px solid rgba(76,201,240,0.15); | |
| } | |
| .bt-stats-row { | |
| display:flex; gap:6px; margin-bottom:8px; | |
| } | |
| .bt-stat { | |
| flex:1; background:var(--track); border-radius:8px; padding:5px 7px; text-align:center; | |
| } | |
| .bt-stat-val { font-size:14px; font-weight:800; font-family:var(--font-display); color:var(--t1); font-variant-numeric:tabular-nums; } | |
| .bt-stat-val.win { color:var(--profit); } | |
| .bt-stat-val.loss { color:var(--loss); } | |
| .bt-stat-val.neutral { color:var(--t2); } | |
| .bt-stat-lbl { font-size:11px; font-weight:600; color:var(--t3); margin-top:1px; } | |
| .bt-state-row { display:flex; gap:5px; margin-bottom:8px; flex-wrap:wrap; } | |
| .bt-state-cell { | |
| flex:1; min-width:70px; background:var(--track); border-radius:7px; padding:4px 6px; | |
| } | |
| .bt-state-name { font-size:11px; font-weight:800; letter-spacing:0.3px; color:var(--t3); } | |
| .bt-state-wr { font-size:13px; font-weight:800; font-family:var(--font-display); font-variant-numeric:tabular-nums; } | |
| .bt-state-wr.bull { color:var(--profit); } | |
| .bt-state-wr.bear { color:var(--loss); } | |
| .bt-state-wr.range { color:var(--warn); } | |
| .bt-state-n { font-size:11px; color:var(--t4); font-weight:600; } | |
| /* Equity curve sparkline */ | |
| .bt-sparkline { width:100%; height:40px; display:block; } | |
| /* Loading state */ | |
| .bt-loading { font-size:11px; color:var(--t3); font-weight:600; padding:4px 0; text-align:center; } | |
| /* p_win "grounded" indicator */ | |
| .prob-val.grounded { border-bottom:2px solid var(--blue2); } | |
| /* ── Market State strip (Markov + HMM) ── */ | |
| .state-strip { | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; padding:9px 12px; margin-bottom:10px; | |
| } | |
| .state-strip-top { | |
| display:flex; align-items:center; gap:8px; margin-bottom:6px; | |
| } | |
| .state-lbl { font-size:11px; font-weight:800; letter-spacing:0.7px; text-transform:uppercase; color:var(--t3); flex:1; } | |
| .state-trust { | |
| font-size:11px; font-weight:700; color:var(--teal); background:var(--teal3); | |
| border:1px solid rgba(76,201,240,0.18); border-radius:5px; padding:2px 6px; | |
| letter-spacing:0.3px; | |
| } | |
| .state-badges { display:flex; gap:6px; align-items:center; flex-wrap:wrap; } | |
| .state-badge { | |
| display:inline-flex; align-items:center; gap:4px; | |
| font-size:12px; font-weight:800; padding:7px 13px; border-radius:10px; | |
| letter-spacing:-0.2px; font-family:var(--font-display); | |
| } | |
| .state-badge.BULL { color:var(--profit); background:var(--mints); border:1px solid rgba(61,220,151,0.2); } | |
| .state-badge.BEAR { color:var(--loss); background:var(--reds); border:1px solid rgba(255,107,122,0.2); } | |
| .state-badge.RANGING { color:var(--warn); background:var(--ambers); border:1px solid rgba(245,181,42,0.2); } | |
| .state-conf { font-size:10px; font-weight:600; color:var(--t3); } | |
| .state-conflict { font-size:10px; font-weight:600; color:var(--loss); margin-top:3px; } | |
| /* ── Forecast row inside state strip ── */ | |
| .forecast-row { | |
| display:flex; gap:8px; margin-top:8px; | |
| } | |
| .fc-cell { | |
| flex:1; background:var(--track); border-radius:8px; padding:5px 6px; text-align:center; | |
| } | |
| .fc-horizon { font-size:11px; font-weight:800; letter-spacing:0.5px; text-transform:uppercase; color:var(--t3); margin-bottom:3px; } | |
| .fc-bars { display:flex; flex-direction:column; gap:2px; } | |
| .fc-bar-row { display:flex; align-items:center; gap:3px; } | |
| .fc-bar-lbl { font-size:11px; font-weight:700; color:var(--t3); width:16px; text-align:right; } | |
| .fc-bar-track { flex:1; height:5px; background:var(--track); border-radius:3px; overflow:hidden; } | |
| .fc-bar-fill { height:100%; border-radius:3px; transition:width 0.8s var(--ease); } | |
| .fc-bar-fill.BULL { background:var(--profit2); } | |
| .fc-bar-fill.BEAR { background:var(--loss2); } | |
| .fc-bar-fill.RANGING { background:var(--warn2); } | |
| .fc-bar-pct { font-size:11px; font-weight:700; color:var(--t2); width:22px; font-variant-numeric:tabular-nums; } | |
| /* ── State filter toggle ── */ | |
| .state-filter-bar { | |
| display:flex; align-items:center; gap:8px; margin-bottom:12px; | |
| padding:8px 12px; background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:12px; | |
| } | |
| .state-filter-lbl { font-size:10px; font-weight:800; letter-spacing:0.6px; text-transform:uppercase; color:var(--t3); } | |
| .state-filter-toggle { | |
| display:inline-flex; align-items:center; gap:5px; | |
| background:var(--glass2); border:1px solid var(--gb); | |
| border-radius:8px; padding:4px 10px; min-height:36px; box-sizing:border-box; | |
| font-size:11px; font-weight:700; color:var(--t2); cursor:pointer; | |
| transition:all 0.2s; | |
| } | |
| .state-filter-toggle.active { | |
| background:var(--teal3); border-color:rgba(76,201,240,0.3); color:var(--teal); | |
| } | |
| /* Signal type badge for state_transition */ | |
| .sig-type-badge.state_transition { background:rgba(155,123,255,0.12); color:var(--purple2); } | |
| /* ── Actions ── */ | |
| .card-acts { display:flex; gap:7px; margin-top:16px; } | |
| /* ═══ Rank-aware card shells — featured hero card + collapsible ranked rows ═══ */ | |
| .card.featured-card { | |
| box-shadow:inset 0 1px 0 var(--rim), inset 0 -1px 0 rgba(0,0,0,0.03), 0 8px 32px rgba(0,0,0,0.08), 0 2px 8px rgba(0,0,0,0.04), 0 0 0 1px rgba(76,201,240,0.15); | |
| border-color:rgba(76,201,240,0.3); | |
| } | |
| .featured-ring-row { display:flex; align-items:center; gap:18px; margin-bottom:16px; } | |
| .conf-ring-wrap { position:relative; width:84px; height:84px; flex-shrink:0; } | |
| .conf-ring-wrap svg { transform:rotate(-90deg); width:100%; height:100%; } | |
| .conf-ring-bg { fill:none; stroke:var(--gb); } | |
| .conf-ring-val { fill:none; stroke-linecap:round; transition:stroke-dashoffset 1.4s cubic-bezier(0.22,1,0.36,1); } | |
| .conf-ring-center { position:absolute; inset:0; display:grid; place-items:center; text-align:center; } | |
| .conf-ring-center b { display:block; font-family:var(--font-display); font-size:20px; font-weight:800; line-height:1; } | |
| .conf-ring-center span { font-size:9px; font-weight:700; letter-spacing:0.1em; color:var(--t3); } | |
| .featured-tag { display:inline-flex; align-items:center; gap:6px; font-family:var(--font-display); font-size:11px; font-weight:700; letter-spacing:0.06em; color:var(--teal); text-transform:uppercase; } | |
| /* ── Featured two-column layout: ring/grade/state/score rail + main content ── */ | |
| .featured-layout { display:grid; grid-template-columns:300px 1fr; gap:28px; align-items:start; } | |
| .featured-left { | |
| display:flex; flex-direction:column; align-items:center; gap:14px; | |
| padding:20px 16px; border-radius:16px; text-align:center; | |
| background:linear-gradient(160deg, var(--mints, rgba(61,220,151,0.08)), transparent 70%); | |
| border:1px solid var(--gb); | |
| } | |
| .featured-left .conf-ring-wrap { width:158px; height:158px; } | |
| .featured-left .conf-ring-center b { font-size:34px; } | |
| .featured-left .state-strip, .featured-left .panel, .featured-left .featured-state-pills { width:100%; text-align:left; } | |
| .featured-left .featured-state-pills { display:flex; flex-wrap:wrap; gap:7px; justify-content:center; } | |
| .featured-right { display:flex; flex-direction:column; gap:16px; min-width:0; } | |
| .two-col { display:grid; grid-template-columns:1.2fr .8fr; gap:14px; } | |
| @media (max-width:1080px) { .two-col { grid-template-columns:1fr; } } | |
| .tag-grade { display:inline-flex; align-items:center; font-family:var(--font-display); font-weight:700; font-size:12px; letter-spacing:0.06em; padding:4px 12px; border-radius:8px; } | |
| .tag-grade.gA { color:var(--profit, var(--green)); background:var(--mints, rgba(61,220,151,0.11)); border:1px solid rgba(61,220,151,0.3); } | |
| .tag-grade.gB { color:var(--warn, var(--amber)); background:var(--ambers, rgba(245,181,42,0.12)); border:1px solid rgba(245,181,42,0.3); } | |
| .tag-grade.gC { color:var(--loss, var(--red)); background:var(--reds, rgba(255,107,122,0.10)); border:1px solid rgba(255,107,122,0.3); } | |
| /* ── Real Price Action confluence grade chip — sits beside/below .tag-grade | |
| in the featured rail. Independent signal from confidence: derived from | |
| chart patterns + candlesticks + order blocks (confluence.py), not the | |
| AI confidence score. ── */ | |
| .pa-grade-chip { | |
| display:inline-flex; align-items:center; | |
| font-family:var(--font-display); font-weight:700; font-size:11px; | |
| letter-spacing:0.06em; padding:5px 12px; border-radius:9px; | |
| } | |
| .pa-grade-chip.pgA { color:var(--mint); background:var(--mints); border:1px solid rgba(61,220,151,0.3); } | |
| .pa-grade-chip.pgB { color:var(--cyan); background:var(--cyans); border:1px solid rgba(76,201,240,0.3); } | |
| .pa-grade-chip.pgC { color:var(--amber); background:var(--ambers); border:1px solid rgba(245,181,42,0.3); } | |
| .pa-grade-chip.pgD { color:var(--red); background:var(--reds); border:1px solid rgba(255,107,122,0.3); } | |
| .pa-grade-chip.pgNA { color:var(--t3); background:var(--glass2); border:1px solid var(--gb); } | |
| @media (max-width:900px) { | |
| .featured-layout { grid-template-columns:1fr; } | |
| .featured-left { flex-direction:row; flex-wrap:wrap; justify-content:center; text-align:left; } | |
| } | |
| .row-summary { display:none; } | |
| .card.row-mode .row-summary { | |
| display:grid; cursor:pointer; min-height:40px; | |
| grid-template-columns:40px 1.5fr 1.1fr .9fr .9fr .8fr .8fr .8fr 70px; | |
| align-items:center; gap:10px; | |
| } | |
| .card.row-mode .card-detail { | |
| display:grid; grid-template-rows:0fr; | |
| transition:grid-template-rows 0.5s var(--ease); | |
| } | |
| .card.row-mode.open .card-detail { grid-template-rows:1fr; } | |
| .card.row-mode .card-detail-inner { overflow:hidden; min-height:0; } | |
| .card.row-mode .card-detail-inner > .ch { margin-top:16px; } | |
| .rs-rank { font-family:var(--font-display); font-weight:800; font-size:14px; color:var(--t3); } | |
| .rs-sym-wrap { display:flex; align-items:center; gap:8px; min-width:0; } | |
| .rs-sym { font-family:var(--font-display); font-weight:700; font-size:14.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } | |
| .rs-dir { font-size:9px; font-weight:700; letter-spacing:0.08em; padding:2px 7px; border-radius:6px; flex-shrink:0; } | |
| .rs-dir.long { color:var(--green); background:rgba(61,220,151,0.11); } | |
| .rs-dir.short { color:var(--red); background:rgba(255,107,122,0.10); } | |
| .rs-conf { display:flex; align-items:center; gap:7px; } | |
| .rs-conf b { font-family:var(--font-display); font-size:15px; } | |
| .rs-mbar { width:44px; height:4px; border-radius:99px; background:var(--glass2); overflow:hidden; flex-shrink:0; } | |
| .rs-mbar i { display:block; height:100%; border-radius:99px; background:linear-gradient(90deg, var(--profit), var(--profit2)); width:0; transition:width 1s var(--ease) 0.15s; } | |
| .rs-mbar.lo i { background:linear-gradient(90deg, var(--warn), var(--loss2)); } | |
| .rs-stat { text-align:right; } | |
| .rs-stat .k { font-size:9px; font-weight:600; letter-spacing:0.1em; color:var(--t3); } | |
| .rs-stat .v { margin-top:2px; font-size:12.5px; font-weight:700; font-family:var(--font-display); } | |
| .rs-exp { justify-self:end; color:var(--t3); transition:transform 0.35s var(--spring); } | |
| .card.row-mode.open .rs-exp { transform:rotate(180deg); } | |
| @media (max-width:900px) { | |
| .card.row-mode .row-summary { grid-template-columns:22px 1fr 44px 18px; } | |
| .rs-stat { display:none; } | |
| } | |
| .ca { | |
| flex:1; display:inline-flex; align-items:center; justify-content:center; gap:5px; | |
| padding:10px 12px; border-radius:11px; | |
| font-size:12px; font-weight:700; font-family:var(--font); | |
| border:none; cursor:pointer; white-space:nowrap; | |
| transition:transform 0.15s var(--spring), box-shadow 0.15s, background 0.15s; | |
| text-decoration:none; | |
| box-shadow:inset 0 1px 0 var(--rim); | |
| position:relative; overflow:hidden; | |
| } | |
| .ca::after { content:''; position:absolute; inset:0; border-radius:11px; background:rgba(255,255,255,0); transition:background 0.15s; } | |
| .ca:hover::after { background:var(--glass2); } | |
| .ca:hover { transform:translateY(-1px) scale(1.02); } | |
| .ca:active { transform:scale(0.96); } | |
| .ca-chart { background:var(--cyans); color:var(--teal); border:1px solid rgba(76,201,240,0.2); } | |
| .ca-copy { background:var(--glass2); color:var(--t1); border:1px solid var(--gb); } | |
| .ca-ref { flex:0 0 auto; padding:10px 11px; background:var(--glass2); color:var(--t3); border:1px solid var(--gb); } | |
| .ca-ref svg { flex-shrink:0; } | |
| .ca-ref:hover { color:var(--teal); } | |
| /* ── Error card ── */ | |
| .err-card { | |
| background:var(--red3); border:1px solid rgba(255,107,122,0.2); | |
| border-radius:var(--r3); padding:18px; | |
| color:var(--red); font-size:13px; font-weight:600; | |
| animation:cardReveal 0.4s var(--ease); | |
| } | |
| /* ── Exchange badge / new badges ── */ | |
| .exch-sep { opacity:0.45; font-weight:400; margin:0 2px; } | |
| /* ═══════════════════════════════════════════════ | |
| SCROLL TO TOP | |
| ═══════════════════════════════════════════════ */ | |
| #scroll-top { | |
| position:fixed; bottom:calc(76px + env(safe-area-inset-bottom)); right:28px; z-index:200; | |
| width:44px; height:44px; border-radius:13px; border:none; | |
| background:var(--glass2); color:var(--t2); | |
| backdrop-filter:blur(16px); cursor:pointer; | |
| display:grid; place-items:center; | |
| box-shadow:0 4px 20px rgba(0,0,0,0.1), inset 0 1px 0 var(--rim); | |
| opacity:0; pointer-events:none; | |
| transition:opacity 0.3s, transform 0.2s var(--spring); | |
| border:1px solid var(--gb); | |
| } | |
| #scroll-top.show { opacity:1; pointer-events:auto; } | |
| #scroll-top:hover { transform:translateY(-2px); } | |
| /* ═══════════════════════════════════════════════ | |
| UTILITIES | |
| ═══════════════════════════════════════════════ */ | |
| .spin-icon { animation:spin 0.7s linear infinite; flex-shrink:0; } | |
| /* ── Touch targets — minimum 44px per WCAG 2.5.5 ── */ | |
| .chip { min-height:40px; } | |
| .chip input, .chip select { min-height:36px; } | |
| .bt-run-btn { min-height:36px; padding:6px 12px; } | |
| .live-acc-resolve-btn { min-height:36px; padding:6px 10px; } | |
| .signals-refresh-btn { width:36px !important; height:36px !important; } | |
| .scan-btn-wrap { min-height:44px; display:flex; align-items:center; } | |
| #scan-btn { min-height:40px; padding:10px 20px; } | |
| /* ── Disable hover animations on touch devices ── */ | |
| @media (hover:none) { | |
| .card:hover { transform:none; box-shadow:0 2px 20px rgba(0,0,0,0.06); } | |
| .stat-glass:hover { transform:none; } | |
| .conf-box:hover { transform:none; } | |
| .lvl:hover { transform:none; background:transparent; } | |
| .sig-card:hover { transform:none; } | |
| } | |
| /* Active states for touch feedback */ | |
| .btn-hero:active, .btn-ghost2:active, .sig-cta:active { transform:scale(0.96) !important; opacity:0.88; } | |
| .ca:active { transform:scale(0.96) !important; } | |
| /* ═══════════════════════════════════════════════ | |
| TABLET (900px) | |
| ═══════════════════════════════════════════════ */ | |
| @media (max-width:900px) { | |
| nav { height:auto; padding-top:10px; padding-bottom:10px; flex-wrap:wrap; gap:8px; } | |
| .nav-controls { flex-wrap:wrap; gap:6px; } | |
| .hero { padding:100px 20px 50px; } | |
| .hero-stats { gap:8px; } | |
| .lvl-grid { grid-template-columns:repeat(3,1fr); } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| MOBILE (720px) | |
| ═══════════════════════════════════════════════ */ | |
| @media (max-width:720px) { | |
| nav { top:8px; width:calc(100% - 16px); padding:0 12px; height:auto; flex-wrap:wrap; padding-top:10px; padding-bottom:10px; gap:8px; border-radius:16px; } | |
| .nav-controls { width:100%; flex-wrap:wrap; } | |
| .chip { flex:1; min-width:0; } | |
| .hero { padding:84px 20px 40px; } | |
| .hero-title { letter-spacing:-1px; line-height:1.05; font-size:clamp(32px,8vw,44px); } | |
| #scan-section { padding:20px 14px 80px; } | |
| .card { padding:18px; border-radius:22px; } | |
| .rule { margin:12px -18px; } | |
| .lvl-grid { grid-template-columns:repeat(3,1fr); } | |
| .hero-stats { gap:7px; } | |
| .stat-glass { padding:12px 16px; min-width:100px; } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| NAV COLLAPSE — gear-toggled controls drawer | |
| (placed after the 720px block so it wins the cascade) | |
| ═══════════════════════════════════════════════ */ | |
| #nav-gear { display:none; } | |
| @media (max-width:1180px) { | |
| nav { flex-wrap:wrap; } | |
| #nav-gear { display:inline-grid; } | |
| .nav-controls { | |
| display:none; order:10; width:100%; flex-wrap:wrap; gap:6px; | |
| padding-top:8px; margin-top:4px; border-top:1px solid var(--gb); | |
| } | |
| nav.controls-open .nav-controls { display:flex; } | |
| .chip { flex:1 1 30%; min-width:96px; } | |
| } | |
| @media (max-width:480px) { | |
| #fng-pill { display:none !important; } | |
| #status-text { display:none; } | |
| } | |
| /* ═══════════════════════════════════════════════ | |
| OBSIDIAN GLASS — RESKIN LAYER | |
| (overrides on top of legacy component CSS; | |
| no selectors removed, JS surface untouched) | |
| ═══════════════════════════════════════════════ */ | |
| /* sprite icon helper + serif accent */ | |
| svg.ic { width:1em; height:1em; stroke:currentColor; fill:none; stroke-width:2; stroke-linecap:round; stroke-linejoin:round; vertical-align:-0.12em; } | |
| .serif { font-family:var(--font-serif); font-style:italic; font-weight:400; letter-spacing:-0.02em; } | |
| /* ── Glass surfaces (backdrop budget: nav 22px; large cards 14px) ── */ | |
| nav { | |
| background:var(--glass); | |
| backdrop-filter:blur(22px) saturate(160%); | |
| -webkit-backdrop-filter:blur(22px) saturate(160%); | |
| border:1px solid var(--gb); | |
| box-shadow:var(--shadow), inset 0 1px 0 var(--rim), inset 0 -1px 0 rgba(0,0,0,0.12); | |
| } | |
| .card, .sig-card, #cycle-section, .stat-glass { | |
| background:var(--glass); | |
| backdrop-filter:blur(14px) saturate(160%); | |
| -webkit-backdrop-filter:blur(14px) saturate(160%); | |
| border:1px solid var(--gb); | |
| box-shadow:var(--shadow), inset 0 1px 0 var(--rim); | |
| } | |
| .ticker-wrap { | |
| background:var(--glass); | |
| backdrop-filter:blur(14px) saturate(160%); | |
| -webkit-backdrop-filter:blur(14px) saturate(160%); | |
| } | |
| #cycle-section { position:relative; } | |
| #grid { position:relative; } | |
| /* ── One gradient language: brand gradient only ── */ | |
| .brand-mark, #scan-btn, .btn-hero, .sig-cta { | |
| background:var(--grad); | |
| background-size:180% 180%; | |
| } | |
| /* Scan button: animated gradient shimmer (matches mockup .scan-btn) */ | |
| #scan-btn { animation:gradShift 6s ease infinite; } | |
| .hero-title .line2 { | |
| background:var(--grad); | |
| -webkit-background-clip:text; background-clip:text; | |
| -webkit-text-fill-color:transparent; | |
| background-size:200% auto; | |
| } | |
| /* ── Typography: numerals → JetBrains Mono, tabular ── */ | |
| .stat-num, .tick-price, .tick-chg, .conf-num, .lvl-val, .lvl-pnl, .sz-val, | |
| .prob-val, .lev-val, .bt-stat-val, .bt-state-wr, .bt-state-n, | |
| .live-acc-stat-val, .live-acc-r, .global-acc-wr, .sc-num, .step-time, | |
| .cycle-banner-count, .cycle-stat-val, .cycle-table td, .sig-score-num, | |
| .fc-bar-pct, .ev-score, #refresh-countdown, .chip input, | |
| .badge-chg, .badge-fr, .badge-vol, .badge-oi { | |
| font-family:var(--font-mono); | |
| font-variant-numeric:tabular-nums; | |
| } | |
| /* ── Labels: 10px / 600 / .09em (no 8px bold) ── */ | |
| .chip label, .stat-lbl, .sc-lbl, .conf-lbl, .lvl-lbl, .sz-lbl, .prob-lbl, | |
| .lev-lbl, .dur-lbl, .ev-title, .bt-lbl, .bt-stat-lbl, .live-acc-lbl, | |
| .state-lbl, .pa-title, .cycle-stat-lbl, .cycle-banner-label, .sig-score-lbl, | |
| .state-filter-lbl, .global-acc-title, .fc-horizon, .live-acc-stat-lbl { | |
| font-size:10px; font-weight:600; letter-spacing:0.09em; text-transform:uppercase; | |
| } | |
| /* .lvl-lbl (entry/SL/TP/RR box labels) matches mockup's .lvl .k spec exactly */ | |
| .lvl-lbl { font-size:9px; font-weight:800; letter-spacing:.12em; } | |
| /* ── Nav theme-toggle / icon buttons (solid, no backdrop) ── */ | |
| .icon-btn { | |
| width:36px; height:36px; border-radius:11px; flex-shrink:0; | |
| display:inline-grid; place-items:center; cursor:pointer; | |
| background:var(--glass2); border:1px solid var(--gb); color:var(--t1); | |
| font-size:15px; line-height:1; | |
| transition:transform 0.25s var(--ease-spring), border-color 0.2s; | |
| } | |
| .icon-btn:hover { transform:scale(1.08); border-color:var(--cyan); } | |
| .icon-btn:active { transform:scale(0.9); } | |
| /* native select dropdowns readable on dark */ | |
| .chip select option { background:var(--bg2); color:var(--t1); } | |
| /* focus visibility */ | |
| :focus-visible { outline:2px solid var(--cyan); outline-offset:2px; } | |
| /* ── Cursor glare on large glass surfaces ── */ | |
| .glass-lg::after { | |
| content:''; position:absolute; inset:0; border-radius:inherit; | |
| pointer-events:none; opacity:0; z-index:2; | |
| background:radial-gradient(430px circle at var(--mx,50%) var(--my,50%), var(--glare), transparent 45%); | |
| transition:opacity 0.35s; | |
| } | |
| .glass-lg:hover::after { opacity:1; } | |
| /* ── Scroll reveals ── */ | |
| .reveal { opacity:0; transform:translateY(18px); transition:opacity 0.7s var(--ease-out), transform 0.7s var(--ease-out); } | |
| .reveal.in { opacity:1; transform:translateY(0); } | |
| /* ── Theme-swap circular wipe (View Transitions) ── */ | |
| ::view-transition-old(root), ::view-transition-new(root) { animation:none; mix-blend-mode:normal; } | |
| /* ── Cycle tracker glass polish ── */ | |
| .cycle-tab.active { background:var(--glass2); border-color:var(--gb); border-bottom-color:transparent; } | |
| .cycle-tab:hover { background:var(--glass); } | |
| /* hide number spinners everywhere */ | |
| input[type=number]::-webkit-inner-spin-button, | |
| input[type=number]::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; } | |
| input[type=number] { -moz-appearance:textfield; } | |
| /* ═══════════════════════════════════════════════ | |
| ROUND 2 FIXES — mobile, visual polish, tokens | |
| ═══════════════════════════════════════════════ */ | |
| /* A3: anchored scrolls land below the fixed nav */ | |
| #scan-section, #signals-section, #cycle-section, .card { scroll-margin-top:84px; } | |
| /* A2: prevent iOS input-focus zoom (16px minimum) */ | |
| @media (max-width:768px) { | |
| .chip input, .chip select { font-size:16px; } | |
| } | |
| /* A4: level boxes on narrow phones */ | |
| @media (max-width:480px) { | |
| .lvl-grid { grid-template-columns:repeat(2,1fr); } | |
| .lvl-val { font-size:13px; } | |
| } | |
| /* B5: perf — rendering containment + no backdrop blur on mobile */ | |
| .card { contain:layout paint style; } | |
| @media (max-width:768px) { | |
| .card, .card.long-card, .card.short-card, .sig-card, #cycle-section, .stat-glass { | |
| backdrop-filter:none; -webkit-backdrop-filter:none; | |
| background:rgba(12,17,28,0.88); | |
| } | |
| [data-theme="light"] .card, [data-theme="light"] .card.long-card, | |
| [data-theme="light"] .card.short-card, [data-theme="light"] .sig-card, | |
| [data-theme="light"] #cycle-section, [data-theme="light"] .stat-glass { | |
| background:rgba(255,255,255,0.82); | |
| } | |
| .grain { display:none; } | |
| } | |
| /* B6: hero CTAs stack on small phones */ | |
| @media (max-width:420px) { | |
| .hero-btns { flex-direction:column; } | |
| .btn-hero, .btn-ghost2 { width:100%; justify-content:center; } | |
| } | |
| @media (max-width:400px) { | |
| .eyebrow { letter-spacing:.12em; } | |
| } | |
| /* C2: news trending chips (decoupled from nav .chip) */ | |
| .news-chip { | |
| display:inline-flex; align-items:center; gap:4px; | |
| font-size:11px; font-weight:700; padding:3px 8px; | |
| border:1px solid var(--gb); border-radius:99px; | |
| background:var(--glass2); color:var(--t2); white-space:nowrap; | |
| } | |
| /* C6: taker-flow badge (replaces inline hex styles in JS) */ | |
| .badge-taker { font-size:11px; } | |
| .badge-taker.buy { background:var(--mints); color:var(--mint); border:1px solid rgba(61,220,151,0.27); } | |
| .badge-taker.sell { background:var(--reds); color:var(--red); border:1px solid rgba(255,107,122,0.27); } | |
| .badge-taker.flow { background:var(--glass2); color:var(--t3); border:1px solid var(--gb); } | |
| /* E2: on-brand text selection */ | |
| ::selection { background:rgba(76,201,240,0.28); color:var(--tx); } | |
| [data-theme="light"] ::selection { background:rgba(14,127,168,.22); } | |
| /* E3: missing interactive states */ | |
| .live-acc-resolve-btn:disabled { opacity:.5; cursor:wait; } | |
| .state-filter-toggle:hover { border-color:rgba(76,201,240,.3); color:var(--t1); } | |
| .news-ticker-inner a:hover { color:var(--teal); } | |
| /* E7: news marquee — same edge mask as the price ticker */ | |
| .news-ticker-inner { | |
| mask-image:linear-gradient(90deg, transparent, black 8%, black 92%, transparent); | |
| -webkit-mask-image:linear-gradient(90deg, transparent, black 8%, black 92%, transparent); | |
| } | |
| /* ── Reduced motion ── */ | |
| @media (prefers-reduced-motion: reduce) { | |
| *, *::before, *::after { | |
| animation-duration:0.01ms !important; | |
| animation-iteration-count:1 !important; | |
| transition-duration:0.01ms !important; | |
| scroll-behavior:auto !important; | |
| } | |
| .a-orb { animation:none; } | |
| .reveal { opacity:1; transform:none; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ── SVG icon sprite ── --> | |
| <svg width="0" height="0" style="position:absolute" aria-hidden="true"> | |
| <defs> | |
| <symbol id="i-zap" viewBox="0 0 24 24"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></symbol> | |
| <symbol id="i-scan" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/></symbol> | |
| <symbol id="i-chart" viewBox="0 0 24 24"><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 5-6"/></symbol> | |
| <symbol id="i-copy" viewBox="0 0 24 24"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></symbol> | |
| <symbol id="i-play" viewBox="0 0 24 24"><polygon points="6 3 20 12 6 21 6 3"/></symbol> | |
| <symbol id="i-heart" viewBox="0 0 24 24"><path d="M19 14c1.5-1.5 3-3.2 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.8 0-3 .5-4.5 2C10.5 3.5 9.3 3 7.5 3A5.5 5.5 0 0 0 2 8.5c0 2.3 1.5 4 3 5.5l7 7z"/></symbol> | |
| <symbol id="i-chev" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol> | |
| <symbol id="i-alert" viewBox="0 0 24 24"><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></symbol> | |
| <symbol id="i-check" viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12"/></symbol> | |
| <symbol id="i-refresh" viewBox="0 0 24 24"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></symbol> | |
| <symbol id="i-news" viewBox="0 0 24 24"><path d="M4 3h13a2 2 0 0 1 2 2v13a2 2 0 0 1-2 2H4a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1z"/><line x1="7" y1="7" x2="15" y2="7"/><line x1="7" y1="11" x2="15" y2="11"/><line x1="7" y1="15" x2="12" y2="15"/></symbol> | |
| <symbol id="i-trend" viewBox="0 0 24 24"><polyline points="3 17 9 11 13 15 21 5"/><polyline points="14 5 21 5 21 12"/></symbol> | |
| <symbol id="i-trend-down" viewBox="0 0 24 24"><polyline points="3 5 9 11 13 7 21 17"/><polyline points="14 17 21 17 21 10"/></symbol> | |
| <symbol id="i-flame" viewBox="0 0 24 24"><path d="M12 22c4.4 0 8-3.6 8-8 0-3.5-2.5-6.3-4.5-8.5C13.5 3.3 13 2 13 2s-1 4-4 7c-1.9 1.9-3 4-3 6a8 8 0 0 0 6 7.7"/></symbol> | |
| <symbol id="i-rocket" viewBox="0 0 24 24"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="M12 15l-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></symbol> | |
| <symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8"/><path d="M1 6l11-4 11 4-11 4z"/><path d="M12 6v16"/></symbol> | |
| </defs> | |
| </svg> | |
| <!-- ── Aurora background + grain ── --> | |
| <div class="aurora" aria-hidden="true"><div class="a-orb a1"></div><div class="a-orb a2"></div><div class="a-orb a3"></div></div> | |
| <div class="grain" aria-hidden="true"></div> | |
| <!-- ── Nav ── --> | |
| <nav class="glass-lg"> | |
| <a class="brand" href="/"> | |
| <div class="brand-mark"><svg class="ic" style="font-size:16px;color:#fff"><use href="#i-zap"/></svg></div> | |
| <span class="brand-name">Trade Copilot</span> | |
| </a> | |
| <span id="status-pill"> | |
| <span class="live-dot"></span> | |
| <span id="status-text">Connecting…</span> | |
| </span> | |
| <span id="fng-pill" title="Crypto Fear & Greed Index" style="display:none"> | |
| <span id="fng-emoji"><svg class="ic"><use href="#i-flame"/></svg></span> | |
| <span id="fng-label">—</span> | |
| <span id="fng-value" style="opacity:0.7;font-size:10px"></span> | |
| </span> | |
| <div class="nav-spacer"></div> | |
| <div class="nav-controls"> | |
| <div class="chip"><label for="account">Account $</label><input id="account" type="number" value="10000" min="100"></div> | |
| <div class="chip"><label for="risk">Risk %</label><input id="risk" type="number" value="1" min="0.1" max="5" step="0.1"></div> | |
| <div class="chip"><label for="top">Top</label><input id="top" type="number" value="8" min="3" max="20"></div> | |
| <div class="chip" id="auto-wrap"><label for="auto-interval">Auto</label> | |
| <select id="auto-interval"> | |
| <option value="0">Off</option> | |
| <option value="60">1 min</option> | |
| <option value="120">2 min</option> | |
| <option value="300" selected>5 min</option> | |
| </select> | |
| </div> | |
| <div class="chip"><label for="max-hours">Max hold</label> | |
| <select id="max-hours"> | |
| <option value="0" selected>Any</option> | |
| <option value="6">6 h</option> | |
| <option value="12">12 h</option> | |
| <option value="24">24 h</option> | |
| <option value="48">48 h</option> | |
| </select> | |
| </div> | |
| <div class="chip"><label for="user-leverage">Lev ×</label> | |
| <select id="user-leverage"> | |
| <option value="1">1×</option> | |
| <option value="2">2×</option> | |
| <option value="3">3×</option> | |
| <option value="4">4×</option> | |
| <option value="5">5×</option> | |
| <option value="6" selected>6×</option> | |
| <option value="8">8×</option> | |
| <option value="10">10×</option> | |
| <option value="15">15×</option> | |
| <option value="20">20×</option> | |
| </select> | |
| </div> | |
| </div> | |
| <span id="refresh-countdown"></span> | |
| <button id="nav-gear" class="icon-btn" type="button" title="Scan settings" aria-label="Scan settings" aria-expanded="false">⚙</button> | |
| <button id="theme-toggle" class="icon-btn" type="button" title="Toggle light/dark theme" aria-label="Toggle light/dark theme">◐</button> | |
| <button id="scan-btn"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> | |
| Scan Market | |
| </button> | |
| </nav> | |
| <!-- ── Hero ── --> | |
| <section class="hero"> | |
| <div class="eyebrow"><span class="eyebrow-dot"></span>BingX · Real-Time · Zero Hallucination</div> | |
| <h1 class="hero-title"> | |
| Your edge in<br><span class="line2">every <span class="serif">market.</span></span> | |
| </h1> | |
| <p class="hero-sub"> | |
| Scans 300+ crypto pairs live. Surfaces top setups with entry, stop-loss, take-profit, and position sizing — computed deterministically from BingX + Binance data. | |
| </p> | |
| <div class="hero-btns"> | |
| <button class="btn-hero" id="hero-cta"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg> | |
| Scan Market Now | |
| </button> | |
| <button class="btn-ghost2" onclick="document.getElementById('scan-section').scrollIntoView({behavior:'smooth'})"> | |
| <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12l7 7 7-7"/></svg> | |
| See setups | |
| </button> | |
| </div> | |
| <div class="hero-stats reveal"> | |
| <div class="stat-glass"><div class="stat-num" id="stat-pairs">—</div><div class="stat-lbl">Top Setups</div></div> | |
| <div class="stat-glass"><div class="stat-num" id="stat-exchange">—</div><div class="stat-lbl">Exchange</div></div> | |
| <div class="stat-glass"><div class="stat-num" id="stat-market">USDT</div><div class="stat-lbl">Market</div></div> | |
| <div class="stat-glass"><div class="stat-num" style="font-size:15px;line-height:1.75">35/20/15/15/15</div><div class="stat-lbl">Score Weight</div></div> | |
| </div> | |
| </section> | |
| <!-- ── Ticker tape ── --> | |
| <div class="ticker-wrap"> | |
| <div class="ticker-inner" id="ticker"> | |
| <span class="tick-item"><span class="tick-sym">BTC</span><span class="tick-price" id="t-btc">—</span><span class="tick-chg" id="tc-btc">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">ETH</span><span class="tick-price" id="t-eth">—</span><span class="tick-chg" id="tc-eth">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">SOL</span><span class="tick-price" id="t-sol">—</span><span class="tick-chg" id="tc-sol">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">BNB</span><span class="tick-price" id="t-bnb">—</span><span class="tick-chg" id="tc-bnb">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">XRP</span><span class="tick-price" id="t-xrp">—</span><span class="tick-chg" id="tc-xrp">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">ADA</span><span class="tick-price" id="t-ada">—</span><span class="tick-chg" id="tc-ada">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">DOGE</span><span class="tick-price" id="t-doge">—</span><span class="tick-chg" id="tc-doge">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">AVAX</span><span class="tick-price" id="t-avax">—</span><span class="tick-chg" id="tc-avax">—</span></span> | |
| <!-- duplicate for seamless loop --> | |
| <span class="tick-item"><span class="tick-sym">BTC</span><span class="tick-price" id="t-btc2">—</span><span class="tick-chg" id="tc-btc2">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">ETH</span><span class="tick-price" id="t-eth2">—</span><span class="tick-chg" id="tc-eth2">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">SOL</span><span class="tick-price" id="t-sol2">—</span><span class="tick-chg" id="tc-sol2">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">BNB</span><span class="tick-price" id="t-bnb2">—</span><span class="tick-chg" id="tc-bnb2">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">XRP</span><span class="tick-price" id="t-xrp2">—</span><span class="tick-chg" id="tc-xrp2">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">ADA</span><span class="tick-price" id="t-ada2">—</span><span class="tick-chg" id="tc-ada2">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">DOGE</span><span class="tick-price" id="t-doge2">—</span><span class="tick-chg" id="tc-doge2">—</span></span> | |
| <span class="tick-item"><span class="tick-sym">AVAX</span><span class="tick-price" id="t-avax2">—</span><span class="tick-chg" id="tc-avax2">—</span></span> | |
| </div> | |
| </div> | |
| <!-- ── Results ── --> | |
| <!-- ── News Ticker ── --> | |
| <div id="news-ticker" style="display:none;overflow:hidden;background:var(--track);border-bottom:1px solid var(--gb);padding:7px 0;margin-bottom:0"> | |
| <div style="display:flex;align-items:center;gap:12px;padding:0 20px"> | |
| <span style="font-size:10px;font-weight:800;color:var(--t3);white-space:nowrap;letter-spacing:.06em;flex-shrink:0">TRENDING</span> | |
| <div id="trending-coins" style="display:flex;gap:6px;flex-wrap:nowrap;overflow:hidden"></div> | |
| </div> | |
| <div style="overflow:hidden;margin-top:5px;position:relative"> | |
| <div class="news-ticker-inner" style="display:flex;animation:ticker-scroll 60s linear infinite;width:max-content;font-size:11px"></div> | |
| </div> | |
| </div> | |
| <!-- ── Results (Top Setup + Ranked Setups) ── --> | |
| <section id="scan-section" class="reveal"> | |
| <div class="section-header"> | |
| <div class="section-title"><span class="dot"></span>Live Setups</div> | |
| <div id="meta"></div> | |
| </div> | |
| <div id="error-banner"></div> | |
| <!-- Global Live Accuracy bar — populated by fetchLiveAccuracy() on load --> | |
| <div class="global-acc-bar" id="global-acc-bar"> | |
| <span class="global-acc-title"><svg class="ic"><use href="#i-heart"/></svg> Live Accuracy</span> | |
| <span class="global-acc-wr none">—</span> | |
| <span class="global-acc-sub">Loading signal history…</span> | |
| <span class="global-acc-spacer"></span> | |
| <button class="live-acc-resolve-btn" onclick="resolveSignals(this)" title="Check outcomes of signals older than 48h">Check Outcomes</button> | |
| </div> | |
| <div id="loader"> | |
| <!-- Morphing orb --> | |
| <div class="scan-orb-wrap"> | |
| <div class="scan-orb-halo"></div> | |
| <div class="scan-blob"></div> | |
| <div class="scan-orb-core"> | |
| <div class="scan-icon-bars"> | |
| <div class="sib"></div> | |
| <div class="sib"></div> | |
| <div class="sib"></div> | |
| <div class="sib"></div> | |
| <div class="sib"></div> | |
| </div> | |
| </div> | |
| <div class="scan-orbit"><div class="scan-orbit-dot"></div></div> | |
| </div> | |
| <div class="scan-headline">Scanning the market</div> | |
| <div class="scan-subline">Analysing 300+ USDT pairs · BingX + Binance data fusion</div> | |
| <!-- Step pipeline --> | |
| <div class="scan-pipeline"> | |
| <div class="scan-step" id="sd1"> | |
| <div class="step-num">1</div> | |
| <div class="step-body"> | |
| <div class="step-label">Fetching universe</div> | |
| <div class="step-sub">300+ USDT perpetual pairs from BingX</div> | |
| </div> | |
| <div class="step-time" id="st1"></div> | |
| </div> | |
| <div class="scan-step" id="sd2"> | |
| <div class="step-num">2</div> | |
| <div class="step-body"> | |
| <div class="step-label">Computing indicators</div> | |
| <div class="step-sub">EMA · RSI · ATR · Support/Resistance</div> | |
| </div> | |
| <div class="step-time" id="st2"></div> | |
| </div> | |
| <div class="scan-step" id="sd3"> | |
| <div class="step-num">3</div> | |
| <div class="step-body"> | |
| <div class="step-label">Cross-validating sources</div> | |
| <div class="step-sub">BingX funding rate + Binance OI check</div> | |
| </div> | |
| <div class="step-time" id="st3"></div> | |
| </div> | |
| <div class="scan-step" id="sd4"> | |
| <div class="step-num">4</div> | |
| <div class="step-body"> | |
| <div class="step-label">Ranking setups</div> | |
| <div class="step-sub">35/20/15/15/15 scoring · sorting by confidence</div> | |
| </div> | |
| <div class="step-time" id="st4"></div> | |
| </div> | |
| </div> | |
| <div class="scan-symbol-stream" id="scan-sym-stream"></div> | |
| <!-- Live counters --> | |
| <div class="scan-counter"> | |
| <div class="sc-item accent"> | |
| <div class="sc-num" id="scan-count">0</div> | |
| <div class="sc-lbl">pairs scanned</div> | |
| </div> | |
| <div class="sc-item"> | |
| <div class="sc-num" id="scan-elapsed">0s</div> | |
| <div class="sc-lbl">elapsed</div> | |
| </div> | |
| <div class="sc-item"> | |
| <div class="sc-num">BingX</div> | |
| <div class="sc-lbl">source</div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="empty"> | |
| <div class="empty-icon" style="color:var(--t3)"><svg class="ic"><use href="#i-scan"/></svg></div> | |
| <div class="empty-h">Ready to scan</div> | |
| <span class="empty-p">Hit Scan Market to surface top trade setups</span> | |
| </div> | |
| <div id="state-filter-bar" class="state-filter-bar" style="display:none"> | |
| <span class="state-filter-lbl"><svg class="ic"><use href="#i-zap"/></svg> State Filter</span> | |
| <button id="state-filter-toggle" class="state-filter-toggle" title="Only show cards where HMM state confidence ≥ 60%"> | |
| Show all states | |
| </button> | |
| </div> | |
| <div id="grid"></div> | |
| </section> | |
| <!-- ── Market Signals Panel ── --> | |
| <section id="signals-section" class="reveal"> | |
| <div class="section-header"> | |
| <div class="section-title"><span class="dot dot-amber"></span>Market Signals</div> | |
| <div id="signals-meta" class="signals-meta-txt"></div> | |
| <button id="signals-refresh" class="signals-refresh-btn" title="Refresh signals"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> | |
| </button> | |
| </div> | |
| <div id="signals-loader" class="signals-loader"> | |
| <div class="signals-spinner"></div> | |
| <span>Scanning mid-cap perps for events…</span> | |
| </div> | |
| <div id="signals-empty" class="signals-empty" style="display:none"> | |
| <span style="display:inline-flex"><svg class="ic"><use href="#i-scan"/></svg></span> | |
| <span>No signals right now — market is calm. Will update automatically.</span> | |
| </div> | |
| <div id="signals-grid" class="signals-grid"></div> | |
| </section> | |
| <!-- ── Cycle Tracker ── --> | |
| <section id="cycle-section" class="glass-lg reveal"> | |
| <div class="section-header"> | |
| <div class="section-title"><span class="dot" style="background:var(--blue2)"></span>Market Cycle Tracker</div> | |
| <span style="font-size:11px;color:var(--t3);font-weight:500">Historical bull & bear pattern analysis</span> | |
| </div> | |
| <!-- Coin tabs — connected to panels below --> | |
| <div class="cycle-tabs-wrap" role="tablist"> | |
| <button class="cycle-tab active" data-coin="btc" role="tab" aria-selected="true" onclick="switchCycleTab('btc')">₿ Bitcoin</button> | |
| <button class="cycle-tab" data-coin="eth" role="tab" aria-selected="false" onclick="switchCycleTab('eth')">Ξ Ethereum</button> | |
| <button class="cycle-tab" data-coin="sol" role="tab" aria-selected="false" onclick="switchCycleTab('sol')">◎ Solana ⚠</button> | |
| </div> | |
| <!-- BTC Panel --> | |
| <div id="cycle-panel-btc" class="cycle-panel"> | |
| <div class="cycle-banner"> | |
| <div> | |
| <div class="cycle-banner-label">Current Phase</div> | |
| <div class="cycle-banner-phase">Bear Market</div> | |
| <div class="cycle-banner-sub">ATH: $125,836 · Oct 6, 2025 · 3 historical cycles</div> | |
| </div> | |
| <div style="text-align:right"> | |
| <div class="cycle-banner-label">Est. Days to Avg Bottom</div> | |
| <div id="btc-countdown" class="cycle-banner-count">—</div> | |
| <div class="cycle-banner-hint">414 days avg from ATH − days elapsed</div> | |
| </div> | |
| </div> | |
| <div class="cycle-progress-wrap"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"> | |
| <span style="font-size:12px;font-weight:700;color:var(--t2)">Days elapsed vs. avg bear duration (414 days)</span> | |
| <span id="btc-pct" style="font-size:12px;font-weight:700;color:var(--warn)">—% of avg</span> | |
| </div> | |
| <div style="background:var(--track);border-radius:99px;height:10px;overflow:hidden"> | |
| <div id="btc-bar" style="height:100%;border-radius:99px;background:linear-gradient(90deg,var(--red),var(--amber));width:0%;transition:width 1.2s ease"></div> | |
| </div> | |
| <div class="cycle-progress-labels"> | |
| <span>● ATH — Oct 6, 2025</span> | |
| <span>● Avg bottom — Dec 2026</span> | |
| <span style="color:var(--profit);font-weight:600">● Bull run →</span> | |
| </div> | |
| <div style="margin-top:8px;font-size:11px;color:var(--t3)">100% does not mean bottom is confirmed — it means the <em>average</em> bear duration has passed. Bottoms can arrive early or late.</div> | |
| </div> | |
| <div class="cycle-stats-row"> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Days In Bear</div> | |
| <div class="cycle-stat-val loss" id="btc-days-in">—</div> | |
| <div class="cycle-stat-sub">Avg across 3 cycles: ~414 days</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Current Drop</div> | |
| <div class="cycle-stat-val warn" id="btc-drop">—%</div> | |
| <div class="cycle-stat-sub">From $125,836 ATH</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Historical Drop Range</div> | |
| <div class="cycle-stat-val loss">−77–86%</div> | |
| <div class="cycle-stat-sub">2022: −77% · 2018: −84% · 2015: −86%</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Est. Bottom Price</div> | |
| <div class="cycle-stat-val" style="color:var(--blue2)" id="btc-bottom-est">$18–29K</div> | |
| <div class="cycle-stat-sub">If −77% to −86% repeats from ATH</div> | |
| </div> | |
| </div> | |
| <div class="cycle-table-wrap"> | |
| <div class="cycle-table-hdr"><svg class="ic"><use href="#i-chart"/></svg> All Historical BTC Bear + Bull Cycles</div> | |
| <table class="cycle-table"> | |
| <thead><tr><th>Cycle</th><th>ATH Price</th><th>Bear Days</th><th>Drop</th><th>Bull Days</th><th>Bull Gain</th></tr></thead> | |
| <tbody> | |
| <tr><td>2013–2015</td><td>$1,127</td><td><span class="badge-bear">477 days</span></td><td class="loss-txt">−86%</td><td><span class="badge-bull">~746 days</span></td><td class="profit-txt">+5,800%</td></tr> | |
| <tr class="alt"><td>2017–2018</td><td>$19,665</td><td><span class="badge-bear">389 days</span></td><td class="loss-txt">−84%</td><td><span class="badge-bull">~1,068 days</span></td><td class="profit-txt">+11,000%</td></tr> | |
| <tr><td>2021–2022</td><td>$69,044</td><td><span class="badge-bear">376 days</span></td><td class="loss-txt">−77%</td><td><span class="badge-bull">~1,061 days</span></td><td class="profit-txt">+2,200%</td></tr> | |
| <tr class="now-row"><td>2025–? · IN PROGRESS</td><td>$125,836</td><td><span class="badge-now" id="btc-now-days">— days in</span></td><td class="warn-txt" id="btc-now-drop">—% so far</td><td style="color:var(--t3);font-style:italic">~800–1,000 days?</td><td style="color:var(--t3);font-style:italic">TBD</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="cycle-note"><svg class="ic"><use href="#i-alert"/></svg> <strong>Pattern, not prediction.</strong> Each BTC cycle has been less extreme — drops get shallower, bull gains shrink. With 3 data points, confidence is moderate. ETF inflows & macro conditions can alter timing significantly. Never trade based on cycle timing alone.</div> | |
| </div> | |
| <!-- ETH Panel --> | |
| <div id="cycle-panel-eth" class="cycle-panel" style="display:none"> | |
| <div class="cycle-banner"> | |
| <div> | |
| <div class="cycle-banner-label">Current Phase</div> | |
| <div class="cycle-banner-phase">Bear Market</div> | |
| <div class="cycle-banner-sub">ATH: $4,953 · Aug 25, 2025 · Only 2 historical cycles</div> | |
| </div> | |
| <div style="text-align:right"> | |
| <div class="cycle-banner-label">Est. Days to Avg Bottom</div> | |
| <div id="eth-countdown" class="cycle-banner-count">—</div> | |
| <div class="cycle-banner-hint">365 days avg from ATH − days elapsed</div> | |
| </div> | |
| </div> | |
| <div class="cycle-progress-wrap"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"> | |
| <span style="font-size:12px;font-weight:700;color:var(--t2)">Days elapsed vs. avg bear duration (365 days)</span> | |
| <span id="eth-pct" style="font-size:12px;font-weight:700;color:var(--warn)">—% of avg</span> | |
| </div> | |
| <div style="background:var(--track);border-radius:99px;height:10px;overflow:hidden"> | |
| <div id="eth-bar" style="height:100%;border-radius:99px;background:linear-gradient(90deg,var(--red),var(--amber));width:0%;transition:width 1.2s ease"></div> | |
| </div> | |
| <div class="cycle-progress-labels"> | |
| <span>● ATH — Aug 25, 2025</span> | |
| <span>● Avg bottom — Aug 2026</span> | |
| <span style="color:var(--profit);font-weight:600">● Bull run →</span> | |
| </div> | |
| <div style="margin-top:8px;font-size:11px;color:var(--t3)">Based on only 2 cycles — treat this as a rough signal, not a forecast.</div> | |
| </div> | |
| <div class="cycle-stats-row"> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Days In Bear</div> | |
| <div class="cycle-stat-val loss" id="eth-days-in">—</div> | |
| <div class="cycle-stat-sub">Avg across 2 cycles: ~365 days</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Current Drop</div> | |
| <div class="cycle-stat-val warn" id="eth-drop">—%</div> | |
| <div class="cycle-stat-sub">From $4,953 ATH</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Historical Drop Range</div> | |
| <div class="cycle-stat-val loss">−80–94%</div> | |
| <div class="cycle-stat-sub">2022: −80% · 2018: −94%</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Est. Bottom Price</div> | |
| <div class="cycle-stat-val" style="color:var(--blue2)">$300–990</div> | |
| <div class="cycle-stat-sub">$990 = 2022 pattern · $300 = 2018 pattern</div> | |
| </div> | |
| </div> | |
| <div class="cycle-table-wrap"> | |
| <div class="cycle-table-hdr"><svg class="ic"><use href="#i-chart"/></svg> All Historical ETH Bear + Bull Cycles</div> | |
| <table class="cycle-table"> | |
| <thead><tr><th>Cycle</th><th>ATH Price</th><th>Bear Days</th><th>Drop</th><th>Bull Days</th><th>Bull Gain</th></tr></thead> | |
| <tbody> | |
| <tr><td>2017–2018</td><td>$1,400</td><td><span class="badge-bear">~365 days</span></td><td class="loss-txt">−94%</td><td><span class="badge-bull">~365 days</span></td><td class="profit-txt">+17,400%</td></tr> | |
| <tr class="alt"><td>2021–2022</td><td>$4,379</td><td><span class="badge-bear">~365 days</span></td><td class="loss-txt">−80%</td><td><span class="badge-bull">~882 days</span></td><td class="profit-txt">+6,000%</td></tr> | |
| <tr class="now-row"><td>2025–? · IN PROGRESS</td><td>$4,953</td><td><span class="badge-now" id="eth-now-days">— days in</span></td><td class="warn-txt" id="eth-now-drop">—% so far</td><td style="color:var(--t3);font-style:italic">~600–900 days?</td><td style="color:var(--t3);font-style:italic">TBD</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="cycle-note"><svg class="ic"><use href="#i-alert"/></svg> <strong>ETH has only 2 complete cycles — low confidence.</strong> Both bottomed at ~365 days from ATH. ETH's relationship to BTC is shifting with ETF dynamics and changing tokenomics. The $300–$990 range is wide because the 2018 and 2022 cycles were very different market regimes. Less data = less certainty.</div> | |
| </div> | |
| <!-- SOL Panel --> | |
| <div id="cycle-panel-sol" class="cycle-panel" style="display:none"> | |
| <div class="cycle-sol-warning"> | |
| <span style="font-size:18px;flex-shrink:0"><svg class="ic" style="width:1em;height:1em"><use href="#i-alert"/></svg></span> | |
| <div><strong>Insufficient cycle data.</strong> SOL has only 1 historical bear cycle and it was distorted by the FTX bankruptcy in Nov 2022, causing an unnatural −97% crash. Any projections below are illustrative only — there is no clean historical basis for a SOL bottom estimate.</div> | |
| </div> | |
| <div class="cycle-banner sol"> | |
| <div> | |
| <div class="cycle-banner-label">Current Phase</div> | |
| <div class="cycle-banner-phase">Bear Market</div> | |
| <div class="cycle-banner-sub">ATH: $294.85 · Jan 19, 2025 · 1 distorted cycle</div> | |
| </div> | |
| <div style="text-align:right"> | |
| <div class="cycle-banner-label">Days In Bear</div> | |
| <div id="sol-countdown" class="cycle-banner-count">—</div> | |
| <div class="cycle-banner-hint">No reliable avg — 1 data point only</div> | |
| </div> | |
| </div> | |
| <div class="cycle-progress-wrap"> | |
| <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px"> | |
| <span style="font-size:12px;font-weight:700;color:var(--t2)">Days elapsed vs. prior cycle (418 days — FTX distorted)</span> | |
| <span id="sol-pct" style="font-size:12px;font-weight:700;color:var(--warn)">—% of prior</span> | |
| </div> | |
| <div style="background:var(--track);border-radius:99px;height:10px;overflow:hidden"> | |
| <div id="sol-bar" style="height:100%;border-radius:99px;background:linear-gradient(90deg,var(--red),var(--amber));width:0%;transition:width 1.2s ease"></div> | |
| </div> | |
| <div class="cycle-progress-labels"> | |
| <span>● ATH — Jan 19, 2025</span> | |
| <span>⚠ Reference only — not a forecast</span> | |
| <span style="color:var(--profit);font-weight:600">● Recovery?</span> | |
| </div> | |
| </div> | |
| <div class="cycle-stats-row"> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Days In Bear</div> | |
| <div class="cycle-stat-val loss" id="sol-days-in">—</div> | |
| <div class="cycle-stat-sub">Prior cycle: ~418 days (distorted)</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Current Drop</div> | |
| <div class="cycle-stat-val warn">−77%</div> | |
| <div class="cycle-stat-sub">From $294.85 ATH</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Prior Bear Drop</div> | |
| <div class="cycle-stat-val loss">−97%</div> | |
| <div class="cycle-stat-sub">⚠ FTX collapse — not a market cycle</div> | |
| </div> | |
| <div class="cycle-stat"> | |
| <div class="cycle-stat-lbl">Est. Bottom Price</div> | |
| <div class="cycle-stat-val" style="color:var(--t3)">Unreliable</div> | |
| <div class="cycle-stat-sub">1 distorted data point — no valid range</div> | |
| </div> | |
| </div> | |
| <div class="cycle-table-wrap"> | |
| <div class="cycle-table-hdr"><svg class="ic"><use href="#i-chart"/></svg> SOL Cycle History — ⚠ Extremely Limited Data</div> | |
| <table class="cycle-table"> | |
| <thead><tr><th>Cycle</th><th>ATH Price</th><th>Bear Days</th><th>Drop</th><th>Bull Days</th><th>Bull Gain</th></tr></thead> | |
| <tbody> | |
| <tr><td>2021–2022 ⚠</td><td>$259</td><td><span class="badge-bear">~418 days</span></td><td class="loss-txt">−97% (FTX)</td><td><span class="badge-bull">~600 days</span></td><td class="profit-txt">+11,000%</td></tr> | |
| <tr class="alt"><td colspan="6" style="color:var(--loss);font-size:11px;padding:10px 14px">⚠ The −97% drop was caused by FTX bankruptcy (Nov 2022), not natural market forces. This cycle cannot be used as a reliable baseline for future bear depth or duration.</td></tr> | |
| <tr class="now-row"><td>2025–? · IN PROGRESS</td><td>$294.85</td><td><span class="badge-now" id="sol-now-days">— days in</span></td><td class="warn-txt">−77% so far</td><td style="color:var(--t3);font-style:italic">Unknown</td><td style="color:var(--t3);font-style:italic">TBD</td></tr> | |
| </tbody> | |
| </table> | |
| </div> | |
| <div class="cycle-note"><svg class="ic"><use href="#i-alert"/></svg> <strong>SOL projections are speculative.</strong> The current −77% drop already matches a normal crypto bear market without any exchange collapse. SOL may be closer to a natural bottom than the distorted 2022 cycle suggests. Watch BTC/ETH cycle positioning as a leading indicator instead.</div> | |
| </div> | |
| </section> | |
| <div id="toast-root"></div> | |
| <!-- Scroll to top --> | |
| <button id="scroll-top" onclick="window.scrollTo({top:0,behavior:'smooth'})"> | |
| <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 15l-6-6-6 6"/></svg> | |
| </button> | |
| <script> | |
| /* ═══ Helpers ═══ */ | |
| const $ = id => document.getElementById(id); | |
| const sf = n => { | |
| if (n == null) return '—'; | |
| const v = +n; | |
| // Magnitude-aware formatting — no trailing zeros, readable at a glance | |
| if (v >= 10000) return v.toLocaleString('en-US',{maximumFractionDigits:0}); | |
| if (v >= 1000) return v.toLocaleString('en-US',{maximumFractionDigits:1}); | |
| if (v >= 100) return v.toFixed(2); | |
| if (v >= 10) return v.toFixed(3); | |
| if (v >= 1) return v.toFixed(4); | |
| if (v >= 0.001) return v.toFixed(6); | |
| return v.toFixed(8); | |
| }; | |
| const fu = n => { | |
| if (n == null) return '—'; | |
| const v = +n; | |
| if (v >= 1e9) return '$'+(v/1e9).toFixed(2)+'B'; | |
| if (v >= 1e6) return '$'+(v/1e6).toFixed(1)+'M'; | |
| if (v >= 1e3) return '$'+(v/1e3).toFixed(1)+'K'; | |
| return '$'+v.toFixed(2); | |
| }; | |
| const pc = n => n==null?null:(n>=0?'+':'')+( +n).toFixed(2)+'%'; | |
| /* ═══ Toast notifications ═══ */ | |
| function showToast(msg, type='success') { | |
| const root = $('toast-root'); if (!root) return; | |
| const t = document.createElement('div'); | |
| t.className = 'toast ' + type; | |
| const icon = type==='success' ? '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>' : ''; | |
| t.innerHTML = icon + '<span>' + msg + '</span>'; | |
| root.appendChild(t); | |
| setTimeout(()=>t.remove(), 2450); | |
| } | |
| /* ═══ Scroll to top ═══ */ | |
| window.addEventListener('scroll', () => { | |
| $('scroll-top').classList.toggle('show', window.scrollY > 400); | |
| }, { passive: true }); | |
| /* ═══ Status ═══ */ | |
| async function checkStatus() { | |
| try { | |
| const d = await fetch('/api/status').then(r=>r.json()); | |
| const pill=$('status-pill'), tx=$('status-text'); | |
| if (d.ok) { | |
| tx.textContent = d.source.toUpperCase()+' · Live'; | |
| pill.className='ok'; | |
| const se=$('stat-exchange'); if(se&&se.textContent==='—') se.textContent=d.source.toUpperCase(); | |
| } else { tx.textContent='Offline'; pill.className='err'; } | |
| } catch { $('status-text').textContent='Offline'; $('status-pill').className='err'; } | |
| } | |
| /* ═══ Fear & Greed ═══ */ | |
| async function checkFearGreed() { | |
| try { | |
| const d = await fetch('/api/catalyst').then(r=>r.json()); | |
| const fg = d.fear_greed; if (!fg||fg.value==null) return; | |
| const pill=$('fng-pill'); | |
| if (!pill) return; | |
| const val=+fg.value, label=fg.label||''; | |
| // Sentiment class map (icon stays a single flame; color/label carry the sentiment, matching mockup's pill convention) | |
| let cls='fng-neutral'; | |
| if(val<=24) { cls='fng-xfear'; } | |
| else if(val<=44) { cls='fng-fear'; } | |
| else if(val<=55) { cls='fng-neutral'; } | |
| else if(val<=74) { cls='fng-greed'; } | |
| else { cls='fng-xgreed'; } | |
| const short=label.replace('Extreme ','X. '); | |
| pill.className=cls; pill.style.display='inline-flex'; | |
| $('fng-emoji').innerHTML='<svg class="ic"><use href="#i-flame"/></svg>'; | |
| $('fng-label').textContent=short; | |
| $('fng-value').textContent=val+'/100'; | |
| pill.title='Fear & Greed Index: '+label+' ('+val+'/100)'; | |
| } catch(e) { /* silent — F&G is a bonus */ } | |
| } | |
| /* ═══ Ticker tape ═══ */ | |
| const TICKER_SYMS = { | |
| 'BTC-USDT':['t-btc','tc-btc','t-btc2','tc-btc2'], | |
| 'ETH-USDT':['t-eth','tc-eth','t-eth2','tc-eth2'], | |
| 'SOL-USDT':['t-sol','tc-sol','t-sol2','tc-sol2'], | |
| 'BNB-USDT':['t-bnb','tc-bnb','t-bnb2','tc-bnb2'], | |
| 'XRP-USDT':['t-xrp','tc-xrp','t-xrp2','tc-xrp2'], | |
| 'ADA-USDT':['t-ada','tc-ada','t-ada2','tc-ada2'], | |
| 'DOGE-USDT':['t-doge','tc-doge','t-doge2','tc-doge2'], | |
| 'AVAX-USDT':['t-avax','tc-avax','t-avax2','tc-avax2'], | |
| }; | |
| async function updateTicker() { | |
| try { | |
| const syms = Object.keys(TICKER_SYMS).join(','); | |
| const d = await fetch(`/api/prices?symbols=${encodeURIComponent(syms)}`).then(r=>r.json()); | |
| for (const [sym,[pid,cid,pid2,cid2]] of Object.entries(TICKER_SYMS)) { | |
| const p = d.prices[sym]; if(!p) continue; | |
| const prEl=$( pid), chEl=$(cid), prEl2=$(pid2), chEl2=$(cid2); | |
| if(prEl) prEl.textContent = sf(p.price); | |
| if(prEl2) prEl2.textContent = sf(p.price); | |
| const chg = p.change_24h_pct; | |
| if(chEl && chg!=null) { | |
| chEl.textContent = (chg>=0?'▲ ':' ▼ ')+Math.abs(chg).toFixed(2)+'%'; | |
| chEl.className = 'tick-chg '+(chg>=0?'up':'dn'); | |
| if(chEl2) { chEl2.textContent=chEl.textContent; chEl2.className=chEl.className; } | |
| } | |
| } | |
| } catch {} | |
| } | |
| /* ═══ Scanning symbol stream — illustrative rotation, not literal per-pair progress ═══ */ | |
| const SCAN_SYM_POOL = ['BTC-USDT','ETH-USDT','SOL-USDT','BNB-USDT','XRP-USDT','ADA-USDT','DOGE-USDT','AVAX-USDT','LINK-USDT','DOT-USDT','MATIC-USDT','LTC-USDT','ATOM-USDT','NEAR-USDT','APT-USDT','ARB-USDT','OP-USDT','SUI-USDT','INJ-USDT','TIA-USDT','FIL-USDT','ETC-USDT','TRX-USDT','ICP-USDT']; | |
| let symStreamTimer=null, symStreamIdx=0; | |
| function startSymbolStream() { | |
| const el=$('scan-sym-stream'); if(!el) return; | |
| symStreamIdx=0; clearInterval(symStreamTimer); | |
| const tick=()=>{ | |
| el.style.opacity='0'; | |
| setTimeout(()=>{ | |
| el.innerHTML='Scanning <span class="sym-cur">'+SCAN_SYM_POOL[symStreamIdx % SCAN_SYM_POOL.length]+'</span>…'; | |
| el.style.opacity='1'; | |
| symStreamIdx++; | |
| },90); | |
| }; | |
| tick(); | |
| symStreamTimer=setInterval(tick,260); | |
| } | |
| function stopSymbolStream() { clearInterval(symStreamTimer); } | |
| /* ═══ Loader step animator ═══ */ | |
| let stepTimer=null, elapsedTimer=null, countTimer=null, currentStep=0; | |
| let scanStartTime=0; | |
| let step4WaitTimer=null, step4MsgIdx=0; | |
| // Step 4 ("Ranking setups") is the last step and has no further server signal to advance on — | |
| // on a slow cold-start scan (40-90s) it would otherwise sit frozen on "running…" for a long | |
| // time. Rotate its sub-label through reassurance copy every ~5s instead of appearing stuck, | |
| // until stopLoaderSteps() is called when the real fetch resolves. | |
| const STEP4_WAIT_MESSAGES = [ | |
| '35/20/15/15/15 scoring · sorting by confidence', | |
| 'Still ranking — large markets can take up to 90s', | |
| 'Cross-checking confidence bands…', | |
| 'Almost there — finalizing setup order…' | |
| ]; | |
| function startLoaderSteps() { | |
| currentStep=0; scanStartTime=Date.now(); | |
| clearInterval(stepTimer); clearInterval(elapsedTimer); clearInterval(countTimer); clearInterval(step4WaitTimer); | |
| step4MsgIdx=0; | |
| // Reset all steps | |
| for(let i=1;i<=4;i++){ | |
| const el=$('sd'+i); if(!el) continue; | |
| el.className='scan-step'; | |
| const num=el.querySelector('.step-num'); if(num) num.textContent=i; | |
| const t=$('st'+i); if(t) t.textContent=''; | |
| } | |
| const sd4sub=document.querySelector('#sd4 .step-sub'); | |
| if(sd4sub) sd4sub.textContent=STEP4_WAIT_MESSAGES[0]; | |
| const sc=$('scan-count'); if(sc) sc.textContent='0'; | |
| const se=$('scan-elapsed'); if(se) se.textContent='0s'; | |
| // Activate first step immediately | |
| activateStep(1); | |
| startSymbolStream(); | |
| // Progress through steps: 1→2 at 3s, 2→3 at 7s, 3→4 at 11s. These are illustrative pacing | |
| // for the first 3 steps only — step 4 has its own indefinite-wait handling below since the | |
| // real scan duration varies 40-90s and isn't streamed to the client. | |
| const timings=[3000,4000,4000]; | |
| let cumulative=0; | |
| timings.forEach((delay,i)=>{ | |
| cumulative+=delay; | |
| setTimeout(()=>{ | |
| markDone(i+1); | |
| activateStep(i+2); | |
| if(i+2===4) startStep4Wait(); | |
| }, cumulative); | |
| }); | |
| // Elapsed counter | |
| elapsedTimer=setInterval(()=>{ | |
| const s=Math.floor((Date.now()-scanStartTime)/1000); | |
| const se=$('scan-elapsed'); if(se) se.textContent=s+'s'; | |
| },500); | |
| // Fake count-up: 0→280 over ~12s (pace slows toward end), then holds at 280 gracefully | |
| // (rather than looking frozen mid-count) for the remainder of a slow scan. | |
| let count=0; | |
| countTimer=setInterval(()=>{ | |
| const target=280, elapsed=(Date.now()-scanStartTime)/1000; | |
| const progress=Math.min(elapsed/12,1); | |
| count=Math.floor(target*(1-Math.pow(1-progress,2.5))); | |
| const sc=$('scan-count'); if(sc) sc.textContent=count; | |
| if(progress>=1) clearInterval(countTimer); | |
| },120); | |
| } | |
| function startStep4Wait() { | |
| clearInterval(step4WaitTimer); | |
| step4MsgIdx=0; | |
| step4WaitTimer=setInterval(()=>{ | |
| step4MsgIdx=(step4MsgIdx+1)%STEP4_WAIT_MESSAGES.length; | |
| const sd4sub=document.querySelector('#sd4 .step-sub'); | |
| if(sd4sub) sd4sub.textContent=STEP4_WAIT_MESSAGES[step4MsgIdx]; | |
| const t=$('st4'); if(t) t.textContent=Math.floor((Date.now()-scanStartTime)/1000)+'s'; | |
| },5000); | |
| } | |
| function activateStep(n) { | |
| if(n>4) return; | |
| const el=$('sd'+n); if(!el) return; | |
| el.className='scan-step active'; | |
| const t=$('st'+n); if(t) t.textContent='running…'; | |
| } | |
| function markDone(n) { | |
| const el=$('sd'+n); if(!el) return; | |
| el.className='scan-step done'; | |
| const num=el.querySelector('.step-num'); if(num) num.textContent='✓'; | |
| const elapsed=((Date.now()-scanStartTime)/1000).toFixed(1); | |
| const t=$('st'+n); if(t) t.textContent=elapsed+'s'; | |
| } | |
| function stopLoaderSteps() { | |
| clearInterval(stepTimer); clearInterval(elapsedTimer); clearInterval(countTimer); clearInterval(step4WaitTimer); | |
| const sd4sub=document.querySelector('#sd4 .step-sub'); | |
| if(sd4sub) sd4sub.textContent=STEP4_WAIT_MESSAGES[0]; | |
| for(let i=1;i<=4;i++) markDone(i); | |
| const sc=$('scan-count'); if(sc) sc.textContent='280+'; | |
| stopSymbolStream(); | |
| } | |
| /* ═══ Auto-refresh ═══ */ | |
| let autoCountdown=0, countdownTimer=null, liveTickerTimer=null; | |
| const liveCards = new Map(); | |
| function getAutoInterval() { return parseInt($('auto-interval').value)||0; } | |
| // Arms the 1s countdown tick without resetting autoCountdown — used by startCountdown() | |
| // and by the visibilitychange resume path. Always clears first, so it can never double. | |
| function armCountdownInterval() { | |
| clearInterval(countdownTimer); | |
| countdownTimer=setInterval(()=>{ | |
| autoCountdown--; updateCD(); | |
| if(autoCountdown<=0) { clearInterval(countdownTimer); refreshScanQuietly().then(()=>startCountdown()); } | |
| },1000); | |
| } | |
| function startCountdown() { | |
| clearInterval(countdownTimer); | |
| const secs=getAutoInterval(), cd=$('refresh-countdown'), wrap=$('auto-wrap'); | |
| if(!secs) { | |
| cd.style.display='none'; | |
| if(wrap) wrap.style.borderColor=''; | |
| return; | |
| } | |
| cd.style.display='inline-flex'; autoCountdown=secs; | |
| updateCD(); | |
| armCountdownInterval(); | |
| } | |
| function updateCD() { | |
| const cd=$('refresh-countdown'); | |
| const m=Math.floor(autoCountdown/60), s=autoCountdown%60; | |
| cd.textContent=`↻ ${m}:${String(s).padStart(2,'0')}`; | |
| cd.className=autoCountdown<=15?'urgent':''; | |
| const total=getAutoInterval()||1; | |
| const pct=Math.max(0,Math.min(100,(autoCountdown/total)*100)); | |
| cd.style.setProperty('--pct', pct.toFixed(1)); | |
| } | |
| $('auto-interval').addEventListener('change', startCountdown); | |
| /* ═══ Settings persistence (localStorage) ═══ */ | |
| const SETTINGS_KEY = 'tc_settings_v1'; | |
| const SETTINGS_FIELDS = [ | |
| { id: 'account', parse: Number, def: 10000 }, | |
| { id: 'risk', parse: Number, def: 1 }, | |
| { id: 'top', parse: Number, def: 8 }, | |
| { id: 'auto-interval', parse: String, def: '300' }, | |
| { id: 'user-leverage', parse: Number, def: 6 }, | |
| { id: 'max-hours', parse: String, def: '0' }, | |
| ]; | |
| function saveSettings() { | |
| const out = {}; | |
| SETTINGS_FIELDS.forEach(f => { out[f.id] = $(f.id).value; }); | |
| try { localStorage.setItem(SETTINGS_KEY, JSON.stringify(out)); } catch(e) {} | |
| syncAlertConfig(); | |
| } | |
| // Push account/risk/top/leverage to the server so the background Telegram | |
| // alert loop uses the same settings as the dashboard, instead of its own | |
| // fixed defaults. Only fires from real user edits (saveSettings only runs on | |
| // 'change'/'blur'), never automatically on page load — so nothing changes | |
| // for the alert loop until you actually touch a setting here. Fire-and-forget: | |
| // failures never block or affect the on-screen scan. | |
| function syncAlertConfig() { | |
| const account = +$('account').value || 10000; | |
| const risk = +$('risk').value || 1; | |
| const top = +$('top').value || 8; | |
| const userLev = +$('user-leverage').value || 6; | |
| const qs = `account=${account}&risk_pct=${risk}&top=${top}&user_leverage=${userLev}`; | |
| fetch(`/api/alert-config?${qs}`, { method: 'POST' }).catch(() => {}); | |
| } | |
| function loadSettings() { | |
| let saved = {}; | |
| try { saved = JSON.parse(localStorage.getItem(SETTINGS_KEY) || '{}'); } catch(e) {} | |
| SETTINGS_FIELDS.forEach(f => { | |
| const el = $(f.id); | |
| if (!el) return; | |
| const val = saved[f.id] !== undefined ? saved[f.id] : null; | |
| if (val !== null) el.value = val; | |
| }); | |
| } | |
| // Save on every change | |
| SETTINGS_FIELDS.forEach(f => { | |
| const el = $(f.id); | |
| if (el) el.addEventListener('change', saveSettings); | |
| }); | |
| // Also save on input (for number fields, catch mid-type changes on blur) | |
| ['account','risk','top'].forEach(id => { | |
| const el = $(id); | |
| if (el) el.addEventListener('blur', saveSettings); | |
| }); | |
| // Restore on load, then auto-scan immediately | |
| loadSettings(); | |
| runScan(); | |
| /* ═══ Live price ticker (5s) ═══ */ | |
| async function tickPrices() { | |
| if(!liveCards.size) return; | |
| const syms=[...liveCards.keys()].join(','); | |
| try { | |
| const d=await fetch(`/api/prices?symbols=${encodeURIComponent(syms)}`).then(r=>r.json()); | |
| const now=new Date(d.ts).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}); | |
| for(const [sym,{el}] of liveCards) { | |
| const p=d.prices[sym]; if(!p) continue; | |
| // Row-mode collapsed cards: also mirror the live price into the summary bar's | |
| // ENTRY cell (no flash there — keep it simple and cheap). | |
| if (el.classList.contains('row-mode')) { | |
| const rsEntryVal = el.querySelector('.row-summary .rs-stat[data-rs="entry"] .v'); | |
| if (rsEntryVal) rsEntryVal.textContent = sf(p.price); | |
| } | |
| const priceEl=el.querySelector('.lvl.entry .lvl-val'); | |
| if(priceEl) { | |
| const old=parseFloat(priceEl.dataset.raw||0), nw=p.price; | |
| priceEl.textContent=sf(nw); priceEl.dataset.raw=nw; | |
| if(old&&nw!==old) { | |
| const up = nw>old; | |
| priceEl.style.transition='color 0.35s'; | |
| priceEl.style.color=up?'var(--green)':'var(--red)'; | |
| setTimeout(()=>{ priceEl.style.color=''; },1400); | |
| const wrap = priceEl.closest('.lvl.entry'); | |
| if (wrap) { | |
| wrap.classList.remove('price-flash-up','price-flash-down'); | |
| void wrap.offsetWidth; // restart animation if still in-flight | |
| wrap.classList.add(up?'price-flash-up':'price-flash-down'); | |
| const deltaPct = ((nw-old)/old*100); | |
| const d = document.createElement('span'); | |
| d.className = 'price-delta-float ' + (up?'up':'down'); | |
| d.textContent = (up?'+':'') + deltaPct.toFixed(3) + '%'; | |
| wrap.appendChild(d); | |
| setTimeout(()=>d.remove(), 1200); | |
| } | |
| } | |
| } | |
| const chgEl=el.querySelector('.badge-chg'); | |
| if(chgEl&&p.change_24h_pct!=null) { | |
| const chg=+p.change_24h_pct; | |
| chgEl.textContent=(chg>=0?'+':'')+chg.toFixed(2)+'%'; | |
| chgEl.className='badge badge-chg '+(chg>=0?'pos':'neg'); | |
| } | |
| const meta=$('meta'); | |
| if(meta&&meta.textContent) meta.textContent=meta.textContent.replace(/· \d\d:\d\d.*$/,'· '+now+' · live'); | |
| } | |
| } catch {} | |
| } | |
| function startLiveTicker() { clearInterval(liveTickerTimer); liveTickerTimer=setInterval(tickPrices,5000); } | |
| function stopLiveTicker() { clearInterval(liveTickerTimer); liveCards.clear(); } | |
| /* ═══ Backtest runner ═══ */ | |
| async function runBacktest(sym, btn) { | |
| const panel = document.getElementById('bt-' + sym); | |
| const resultEl = document.getElementById('bt-result-' + sym); | |
| if (!panel) return; | |
| if (btn) { btn.disabled = true; btn.textContent = '⏳'; } | |
| if (resultEl) resultEl.innerHTML = '<div class="bt-skeleton"><div class="bt-skel-row w1"></div><div class="bt-skel-row w2"></div><div class="bt-skel-row w3"></div></div>'; | |
| try { | |
| const bt = await fetch(`/api/backtest/${sym}`).then(r => r.json()); | |
| if (bt.error) { | |
| if (resultEl) resultEl.textContent = '⚠ ' + bt.error; | |
| } else { | |
| // Re-render the panel in place, then choreograph the reveal | |
| panel.outerHTML = renderBtResultGlobal(bt, sym); | |
| const fresh = document.getElementById('bt-' + sym); | |
| if (fresh) requestAnimationFrame(() => fresh.classList.add('reveal-in')); | |
| } | |
| } catch(e) { | |
| if (resultEl) resultEl.textContent = '⚠ ' + e.message; | |
| } finally { | |
| if (btn) { btn.disabled = false; btn.textContent = btn.textContent === '⏳' ? 'Run Backtest' : '↺'; } | |
| } | |
| } | |
| // Global version of renderBtResult (same logic, defined outside buildCard scope) | |
| function renderBtResultGlobal(bt, sym) { | |
| const wrTp1 = bt.win_rate_tp1 != null ? Math.round(bt.win_rate_tp1*100) : (bt.win_rate != null ? Math.round(bt.win_rate*100) : null); | |
| const wrTp2 = bt.win_rate_tp2 != null ? Math.round(bt.win_rate_tp2*100) : null; | |
| const wr = wrTp1; | |
| const wrCls = wr==null?'neutral':wr>=55?'win':wr>=45?'neutral':'loss'; | |
| const avgR = bt.avg_r_tp1 != null ? (bt.avg_r_tp1 > 0 ? '+' : '') + bt.avg_r_tp1.toFixed(2) + 'R' | |
| : bt.avg_r != null ? (bt.avg_r > 0 ? '+' : '') + bt.avg_r.toFixed(2) + 'R' : '—'; | |
| const dd = bt.max_drawdown_r != null ? bt.max_drawdown_r.toFixed(1)+'R' : '—'; | |
| const mo = bt.lookback_months || '?'; | |
| const n = bt.total_trades || 0; | |
| const tf = bt.candle_interval || '15m'; | |
| const blendActive = bt.blend_active === true; | |
| const groundedBadge = blendActive | |
| ? `<span class="bt-grounded-badge">✓ Data grounded</span>` | |
| : (n >= 10 ? `<span class="bt-grounded-badge" style="opacity:0.6"><svg class="ic"><use href="#i-chart"/></svg> ${n} trades</span>` : `<span style="font-size:10px;color:var(--muted);padding:2px 6px">⚠ ${n} trades (need 10+)</span>`); | |
| const stateRows = Object.entries(bt.by_state||{}).map(([state, s]) => { | |
| const swr1 = s.win_rate_tp1 != null ? Math.round(s.win_rate_tp1*100) : Math.round((s.win_rate||0)*100); | |
| const swr2 = s.win_rate_tp2 != null ? Math.round(s.win_rate_tp2*100) : null; | |
| const cls = state==='BULL'?'bull':state==='BEAR'?'bear':'range'; | |
| const tp2txt = swr2 != null ? `<span style="font-size:11px;color:var(--muted);margin-left:3px">TP2:${swr2}%</span>` : ''; | |
| return `<div class="bt-state-cell"> | |
| <div class="bt-state-name">${state}</div> | |
| <div class="bt-state-wr ${cls}">${swr1}%${tp2txt}</div> | |
| <div class="bt-state-n">${s.trades} trades</div> | |
| </div>`; | |
| }).join(''); | |
| const tp2Row = wrTp2 != null ? `<div style="font-size:10.5px;color:var(--muted);text-align:center;margin-top:2px;margin-bottom:4px"> | |
| TP1 exit: <b style="color:${wrTp1>=55?'var(--profit2)':wrTp1>=45?'var(--amber)':'var(--loss2)'}">${wrTp1}%</b> | | |
| TP2 hold: <b style="color:${wrTp2>=55?'var(--profit2)':wrTp2>=45?'var(--amber)':'var(--loss2)'}">${wrTp2}%</b> | |
| </div>` : ''; | |
| const curve = bt.equity_curve || []; | |
| let sparkSVG = ''; | |
| if (curve.length >= 2) { | |
| const mn = Math.min(...curve), mx = Math.max(...curve); | |
| const range = mx - mn || 1; | |
| const W = 260, H = 52; | |
| const pts = curve.map((v,i) => { | |
| const x = (i/(curve.length-1))*W; | |
| const y = (H-12) - ((v-mn)/range)*(H-12); | |
| return `${x.toFixed(1)},${y.toFixed(1)}`; | |
| }).join(' '); | |
| const finalColor = curve[curve.length-1] >= 0 ? 'var(--profit2)' : 'var(--loss2)'; | |
| const termVal = curve[curve.length-1]; | |
| const termLabel = (termVal>=0?'+':'')+termVal.toFixed(1)+'R'; | |
| const zeroY = (H-12) - ((0-mn)/range)*(H-12); | |
| sparkSVG = `<svg class="bt-sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"> | |
| <polyline fill="none" stroke="${finalColor}" stroke-width="1.5" points="${pts}"/> | |
| <line x1="0" y1="${zeroY.toFixed(1)}" x2="${W}" y2="${zeroY.toFixed(1)}" | |
| stroke="rgba(148,158,178,0.4)" stroke-width="0.5" stroke-dasharray="3,3"/> | |
| <text x="3" y="${(zeroY-2).toFixed(1)}" font-size="8" fill="rgba(148,158,178,0.75)" font-family="monospace">0R</text> | |
| <text x="${W-3}" y="${H}" font-size="9" fill="${finalColor}" font-family="monospace" text-anchor="end" font-weight="700">${termLabel}</text> | |
| </svg>`; | |
| } | |
| return `<div class="bt-panel" id="bt-${sym}"> | |
| <div class="bt-header"> | |
| <span class="bt-lbl" title="Momentum signal history — structure + volume signals only. Funding rates and news catalyst (40% of live score) are excluded."><svg class="ic"><use href="#i-chart"/></svg> Signal History · ${mo}mo · ${n} trades · ${tf}</span> | |
| ${groundedBadge} | |
| <button class="bt-run-btn" onclick="runBacktest('${sym}',this)" title="Refresh backtest">↺</button> | |
| </div> | |
| <div class="bt-stats-row"> | |
| <div class="bt-stat"><div class="bt-stat-val ${wrCls}">${wr!=null?wr+'%':'—'}</div><div class="bt-stat-lbl">TP1 Win Rate</div></div> | |
| <div class="bt-stat"><div class="bt-stat-val ${parseFloat(bt.avg_r_tp1||bt.avg_r)>=0?'win':'loss'}">${avgR}</div><div class="bt-stat-lbl">Avg R</div></div> | |
| <div class="bt-stat"><div class="bt-stat-val neutral">-${dd}</div><div class="bt-stat-lbl">Max DD</div></div> | |
| </div> | |
| ${tp2Row} | |
| ${stateRows?`<div class="bt-state-row">${stateRows}</div>`:''} | |
| ${sparkSVG} | |
| </div>`; | |
| } | |
| // ═══ Live Accuracy panel ═══ | |
| // Fetches signal accuracy from /api/signal-accuracy and renders a panel | |
| let _liveAccData = null; | |
| async function fetchLiveAccuracy() { | |
| try { | |
| const r = await fetch('/api/signal-accuracy'); | |
| _liveAccData = await r.json(); | |
| renderGlobalAccuracy(_liveAccData); | |
| } catch(e) { | |
| // Silently fail — accuracy panel is non-critical | |
| } | |
| } | |
| function renderGlobalAccuracy(acc) { | |
| const el = $('global-acc-bar'); | |
| if (!el) return; | |
| const n = acc.total_resolved || 0; | |
| const wr = acc.win_rate != null ? Math.round(acc.win_rate * 100) : null; | |
| const pending = acc.pending || 0; | |
| const wins = acc.wins || 0; | |
| const losses = acc.losses || 0; | |
| let wrHTML, wrCls; | |
| if (wr != null) { | |
| wrCls = wr >= 55 ? 'win' : wr >= 45 ? 'neutral' : 'loss'; | |
| wrHTML = `<span class="global-acc-wr ${wrCls}">${wr}%</span>`; | |
| } else { | |
| wrCls = 'none'; | |
| const needed = acc.min_signals_needed || 0; | |
| wrHTML = `<span class="global-acc-wr none">—</span>`; | |
| } | |
| const avgR = acc.avg_r != null ? (acc.avg_r > 0 ? '+' : '') + acc.avg_r.toFixed(2) + 'R' : ''; | |
| const subText = wr != null | |
| ? `${wins}W / ${losses}L · ${n} resolved${avgR ? ' · avg ' + avgR : ''}` | |
| : (n < (acc.min_signals_needed || 5) | |
| ? `${pending} pending · need ${acc.min_signals_needed || 5} resolved signals to show rate` | |
| : `${pending} pending · resolving in 48h`); | |
| el.innerHTML = ` | |
| <span class="global-acc-title"><svg class="ic"><use href="#i-heart"/></svg> Live Accuracy</span> | |
| ${wrHTML} | |
| <span class="global-acc-sub">${subText}</span> | |
| <span class="global-acc-spacer"></span> | |
| <button class="live-acc-resolve-btn" onclick="resolveSignals(this)" title="Check outcomes of signals older than 48h">Check Outcomes</button> | |
| `; | |
| } | |
| async function resolveSignals(btn) { | |
| if (btn) { btn.disabled = true; btn.textContent = '⏳'; } | |
| try { | |
| const r = await fetch('/api/resolve-signals', {method:'POST'}); | |
| const result = await r.json(); | |
| await fetchLiveAccuracy(); | |
| if (btn) btn.textContent = `✓ ${result.resolved} resolved`; | |
| setTimeout(() => { if(btn) { btn.disabled=false; btn.textContent='Check Outcomes'; }}, 2500); | |
| } catch(e) { | |
| if (btn) { btn.disabled=false; btn.textContent='Check Outcomes'; } | |
| } | |
| } | |
| function buildLiveAccPanel(sym) { | |
| if (!_liveAccData) return ''; | |
| const acc = _liveAccData; | |
| const symData = acc.symbol_breakdown; | |
| const n = acc.total_resolved || 0; | |
| const wr = acc.win_rate != null ? Math.round(acc.win_rate * 100) : null; | |
| const wins = acc.wins || 0; | |
| const losses = acc.losses || 0; | |
| const pending = acc.pending || 0; | |
| let badge, wrVal; | |
| if (wr != null) { | |
| const cls = wr >= 55 ? 'win' : wr >= 45 ? 'pending' : 'loss'; | |
| badge = `<span class="live-acc-badge ${cls}">${wr}% live win rate</span>`; | |
| wrVal = `<div class="live-acc-stat-val ${wr>=55?'win':wr<45?'loss':''}">${wr}%</div>`; | |
| } else { | |
| badge = `<span class="live-acc-badge none">Collecting data</span>`; | |
| wrVal = `<div class="live-acc-stat-val" style="color:var(--t3)">—</div>`; | |
| } | |
| const avgR = acc.avg_r != null ? (acc.avg_r > 0 ? '+' : '') + acc.avg_r.toFixed(2) + 'R' : '—'; | |
| const note = wr != null | |
| ? `Based on ${n} resolved signals · checks 48h price range for TP1/SL hit` | |
| : `${acc.min_signals_needed || 5} more signals needed · checking prices 48h after each signal`; | |
| // Recent log rows (last 5) | |
| const recent = (acc.recent || []).slice(0, 5); | |
| const logRows = recent.map(s => { | |
| const outcomeIcon = s.outcome==='win'?'✓':s.outcome==='loss'?'✗':s.outcome==='expired'?'–':'…'; | |
| const r = s.r_achieved != null ? (s.r_achieved>0?'+':'')+s.r_achieved+'R' : ''; | |
| const age = s.logged_at ? Math.round((Date.now()/1000 - s.logged_at)/3600) + 'h ago' : ''; | |
| return `<div class="live-acc-log-row"> | |
| <span class="live-acc-sym">${s.symbol}</span> | |
| <span class="live-acc-dir ${s.direction}">${s.direction}</span> | |
| <span class="live-acc-outcome ${s.outcome}">${outcomeIcon} ${s.outcome}</span> | |
| <span class="live-acc-r">${r}</span> | |
| <span style="font-size:11px;color:var(--t3)">${age}</span> | |
| </div>`; | |
| }).join(''); | |
| return `<div class="live-acc-panel"> | |
| <div class="live-acc-header"> | |
| <span class="live-acc-lbl"><svg class="ic"><use href="#i-heart"/></svg> Live Accuracy (real outcomes)</span> | |
| ${badge} | |
| </div> | |
| <div class="live-acc-stats"> | |
| <div class="live-acc-stat">${wrVal}<div class="live-acc-stat-lbl">Win Rate</div></div> | |
| <div class="live-acc-stat"><div class="live-acc-stat-val ${acc.avg_r==null?'':acc.avg_r>=0?'win':'loss'}"${acc.avg_r==null?' style="color:var(--t3)"':''}>${avgR}</div><div class="live-acc-stat-lbl">Avg R</div></div> | |
| <div class="live-acc-stat"><div class="live-acc-stat-val">${wins}/${n}</div><div class="live-acc-stat-lbl">W / Resolved</div></div> | |
| <div class="live-acc-stat"><div class="live-acc-stat-val">${pending}</div><div class="live-acc-stat-lbl">Pending</div></div> | |
| </div> | |
| ${logRows ? `<div class="live-acc-note">${note}</div><div class="live-acc-log">${logRows}</div>` : `<div class="live-acc-note">${note}</div>`} | |
| </div>`; | |
| } | |
| /* ═══ State filter toggle ═══ */ | |
| const _stateToggle = $('state-filter-toggle'); | |
| function applyStateFilter() { | |
| const isActive = _stateToggle?.classList.contains('active') || false; | |
| const cards = document.querySelectorAll('#grid .card'); | |
| let visible = 0, hidden = 0; | |
| cards.forEach(card => { | |
| const conf = parseFloat(card.dataset.conf || '1'); | |
| if (isActive && conf < 0.60) { | |
| card.style.display = 'none'; | |
| hidden++; | |
| } else { | |
| card.style.display = ''; | |
| visible++; | |
| } | |
| }); | |
| // Update toggle label with count | |
| if (_stateToggle) { | |
| if (isActive) { | |
| _stateToggle.textContent = `✓ HMM ≥60% · ${visible} card${visible!==1?'s':''}`; | |
| _stateToggle.title = `Showing ${visible} high-confidence cards · ${hidden} hidden`; | |
| } else { | |
| _stateToggle.textContent = 'Show all states'; | |
| _stateToggle.title = 'Only show cards where HMM state confidence ≥ 60%'; | |
| } | |
| } | |
| // Show empty message if all cards hidden | |
| const empty = $('empty'); | |
| if (empty) { | |
| if (visible === 0 && cards.length > 0 && isActive) { | |
| empty.style.display = 'flex'; | |
| const lbl = empty.querySelector('span:last-child'); | |
| if (lbl) lbl.textContent = `All ${cards.length} cards filtered out — HMM confidence < 60%. Try widening the filter or running a new scan.`; | |
| } else if (cards.length > 0) { | |
| empty.style.display = 'none'; | |
| } | |
| } | |
| } | |
| if (_stateToggle) { | |
| _stateToggle.addEventListener('click', () => { | |
| _stateToggle.classList.toggle('active'); | |
| applyStateFilter(); | |
| }); | |
| } | |
| /* ═══ Nav gear — toggles the collapsed controls drawer on narrow viewports ═══ */ | |
| const _navGear = $('nav-gear'); | |
| if (_navGear) _navGear.addEventListener('click', () => { | |
| const nav = document.querySelector('nav'); | |
| if (!nav) return; | |
| const open = nav.classList.toggle('controls-open'); | |
| _navGear.setAttribute('aria-expanded', open ? 'true' : 'false'); | |
| }); | |
| /* ═══ Scan buttons ═══ */ | |
| $('scan-btn').addEventListener('click', runScan); | |
| $('hero-cta').addEventListener('click', ()=>{ | |
| document.getElementById('scan-section').scrollIntoView({behavior:'smooth'}); | |
| setTimeout(runScan,350); | |
| }); | |
| function setLoading(on) { | |
| const btn=$('scan-btn'), hcta=$('hero-cta'); | |
| btn.disabled=hcta.disabled=on; | |
| const spinSVG=`<svg class="spin-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M21 12a9 9 0 1 1-2.12-9.36L21 3v6h-6"/></svg>`; | |
| btn.innerHTML = on ? spinSVG+'Scanning…' : `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>Scan Market`; | |
| hcta.innerHTML = on ? spinSVG+'Scanning…' : `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>Scan Market Now`; | |
| } | |
| /* ═══ FLIP re-rank + never-blank auto-refresh ═══ */ | |
| function flipReorder(container, mutation) { | |
| // Keyed by data-sym (stable identity) rather than DOM element reference: refreshScanQuietly | |
| // replaces unchanged cards with brand-new nodes via replaceWith(), so an element-reference | |
| // keyed map would never match post-mutation and the FLIP transform would always be skipped. | |
| const before = new Map([...container.children] | |
| .filter(el => el.dataset && el.dataset.sym) | |
| .map(el => [el.dataset.sym, el.getBoundingClientRect()])); | |
| mutation(); | |
| const reduced = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| if (reduced) return; | |
| [...container.children].forEach(el => { | |
| const key = el.dataset && el.dataset.sym; | |
| const f = key ? before.get(key) : null; | |
| if (!f) return; // brand-new/unkeyed element — handled by its own reveal animation | |
| const l = el.getBoundingClientRect(); | |
| const dx = f.left - l.left, dy = f.top - l.top; | |
| if (dx || dy) { | |
| el.style.transition = 'none'; | |
| el.style.transform = `translate(${dx}px,${dy}px)`; | |
| requestAnimationFrame(() => { | |
| el.style.transition = 'transform 0.5s var(--ease)'; | |
| el.style.transform = ''; | |
| }); | |
| } | |
| }); | |
| } | |
| function triggerScanlineSweep() { | |
| const grid = $('grid'); if (!grid) return; | |
| const sweep = document.createElement('div'); | |
| sweep.className = 'grid-scanline'; | |
| sweep.style.setProperty('--sweep-h', grid.offsetHeight + 'px'); | |
| grid.appendChild(sweep); | |
| sweep.addEventListener('animationend', () => sweep.remove()); | |
| setTimeout(() => sweep.remove(), 1200); // fallback if animationend doesn't fire | |
| } | |
| // Quiet refresh for the auto-refresh countdown — diffs by symbol, re-ranks in place via | |
| // FLIP, never blanks the grid or shows the big scan loader. Falls back to a full runScan() | |
| // if there's nothing on screen yet to diff against. | |
| async function refreshScanQuietly() { | |
| const grid = $('grid'); | |
| if (!grid || !grid.children.length) return runScan(); | |
| const account=+$('account').value||10000, risk=+$('risk').value||1; | |
| const top=+$('top').value||8, userLev=+$('user-leverage').value||6; | |
| try { | |
| const data = await fetch(`/api/scan?account=${account}&risk_pct=${risk}&top=${top}&user_leverage=${userLev}`) | |
| .then(r => { if(!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); | |
| const ts = new Date(data.fetched_at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}); | |
| const meta=$('meta'); if (meta) meta.textContent = `${data.cards.length} setups · ${data.source.toUpperCase()} · ${ts} · auto`; | |
| const maxHours=+($('max-hours')?.value||0); | |
| const allCards = data.cards.filter(c => { | |
| if (maxHours>0) { const durMax=c.duration?.max_hours; if (durMax!=null && durMax>maxHours) return false; } | |
| return true; | |
| }); | |
| if (!allCards.length) return; // don't blank an existing view on an empty auto-tick | |
| const seen = new Set(); | |
| const existingBySym = new Map([...grid.children].filter(el => el.dataset && el.dataset.sym).map(el => [el.dataset.sym, el])); | |
| flipReorder(grid, () => { | |
| allCards.forEach((c,i) => { | |
| seen.add(c.symbol); | |
| const old = existingBySym.get(c.symbol); | |
| const fresh = buildCard(c, account, risk, {rank:i+1, featured:i===0}); | |
| if (old) { | |
| fresh.style.animation = 'none'; // quiet update — no re-entry pop | |
| if (old.classList.contains('open')) fresh.classList.add('open'); | |
| old.replaceWith(fresh); | |
| } else { | |
| fresh.style.animationDelay = (i*40) + 'ms'; | |
| } | |
| grid.appendChild(fresh); // moves into rank position (appendChild relocates existing nodes) | |
| if (c.symbol) liveCards.set(c.symbol, {el:fresh, account, risk}); | |
| }); | |
| existingBySym.forEach((el, symKey) => { | |
| if (seen.has(symKey)) return; | |
| el.style.transition = 'opacity 0.3s var(--ease)'; | |
| el.style.opacity = '0'; | |
| setTimeout(() => el.remove(), 320); | |
| liveCards.delete(symKey); | |
| }); | |
| }); | |
| requestAnimationFrame(() => { grid.querySelectorAll('.ev-bar, .rs-mbar i').forEach(b => { if(b.dataset.w) b.style.width = b.dataset.w + '%'; }); }); | |
| applyStateFilter(); | |
| triggerScanlineSweep(); | |
| } catch(e) { | |
| // Auto-refresh failures stay silent — don't disrupt the current view, next tick retries | |
| } | |
| } | |
| /* ═══ Main scan ═══ */ | |
| async function runScan() { | |
| const grid=$('grid'), meta=$('meta'), err=$('error-banner'); | |
| const loader=$('loader'), empty=$('empty'); | |
| setLoading(true); grid.innerHTML=''; | |
| err.style.display='none'; empty.style.display='none'; | |
| loader.style.display='flex'; startLoaderSteps(); | |
| stopLiveTicker(); | |
| const account=+$('account').value||10000; | |
| const risk=+$('risk').value||1; | |
| const top=+$('top').value||8; | |
| const userLev=+$('user-leverage').value||6; | |
| try { | |
| const data=await fetch(`/api/scan?account=${account}&risk_pct=${risk}&top=${top}&user_leverage=${userLev}`) | |
| .then(r=>{ if(!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }); | |
| stopLoaderSteps(); | |
| loader.classList.add('exiting'); | |
| setTimeout(()=>{ loader.style.display='none'; loader.classList.remove('exiting'); }, 320); | |
| const ts=new Date(data.fetched_at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit',second:'2-digit'}); | |
| meta.textContent=`${data.cards.length} setups · ${data.source.toUpperCase()} · ${ts}`; | |
| const sp=$('stat-pairs'); if(sp) sp.textContent=data.cards.length+'+'; | |
| const se=$('stat-exchange'); if(se) se.textContent=data.source.toUpperCase(); | |
| if(!data.cards.length) { empty.style.display='flex'; return; } | |
| const maxHours=+($('max-hours')?.value||0); | |
| const stateFilterOn = $('state-filter-toggle')?.classList.contains('active') || false; | |
| // Show state filter bar now that we have data | |
| const sfBar = $('state-filter-bar'); | |
| if (sfBar) sfBar.style.display = 'flex'; | |
| // Render all cards (no server-side state filter — filter applied client-side via applyStateFilter) | |
| const allCards = data.cards.filter(c => { | |
| if(maxHours>0){ const durMax=c.duration?.max_hours; if(durMax!=null && durMax>maxHours) return false; } | |
| return true; | |
| }); | |
| allCards.forEach((c,i)=>{ | |
| const el=buildCard(c,account,risk,{rank:i+1,featured:i===0}); | |
| el.style.animationDelay=(i*60)+'ms'; | |
| grid.appendChild(el); | |
| // Batch bar animations — single rAF after all cards appended | |
| if(c.symbol) liveCards.set(c.symbol,{el,account,risk}); | |
| }); | |
| // Animate all evidence bars in one batch after DOM is settled | |
| requestAnimationFrame(()=>{ | |
| grid.querySelectorAll('.ev-bar, .rs-mbar i').forEach(b=>{ if(b.dataset.w) b.style.width=b.dataset.w+'%'; }); | |
| }); | |
| // Apply state filter client-side immediately | |
| applyStateFilter(); | |
| // If no cards at all (API returned 0) | |
| if(allCards.length===0){ | |
| empty.style.display='flex'; | |
| const lbl=empty.querySelector('span:last-child'); | |
| if(lbl) lbl.textContent='No setups found — market may be consolidating. Try again in a few minutes.'; | |
| } | |
| startLiveTicker(); | |
| if(getAutoInterval()) startCountdown(); | |
| } catch(e) { | |
| loader.style.display='none'; stopLoaderSteps(); | |
| // Show human-readable error with retry button | |
| const errMsg = e.message.includes('503') ? 'BingX may be rate-limiting. Try again in 30 seconds.' | |
| : e.message.includes('network') || e.message.includes('fetch') ? 'Network error — check your connection.' | |
| : e.message.includes('timeout') ? 'Scan timed out — server is busy. Try again.' | |
| : 'Scan failed: ' + e.message.slice(0,80); | |
| err.innerHTML = `<span>⚠ ${errMsg}</span> | |
| <button onclick="this.parentElement.style.display='none'; runScan();" style="margin-left:10px;padding:4px 10px;border-radius:6px;border:1px solid rgba(255,107,122,0.3);background:var(--reds);color:var(--red);font-size:11px;font-weight:700;cursor:pointer;">Try Again</button> | |
| <button onclick="this.parentElement.style.display='none';" style="margin-left:6px;padding:4px 8px;border-radius:6px;border:1px solid var(--gb);background:transparent;color:var(--t3);font-size:11px;cursor:pointer;">✕</button>`; | |
| err.style.display='block'; | |
| } finally { setLoading(false); } | |
| } | |
| /* ═══ Build card ═══ */ | |
| function buildCard(c, account, risk, meta) { | |
| meta = meta || {}; | |
| const rank = meta.rank != null ? meta.rank : null; | |
| const featured = !!meta.featured; | |
| const isRowMode = !featured && rank != null; | |
| if(c.error) { | |
| const d=document.createElement('div'); d.className='err-card'; | |
| d.textContent=`${c.symbol}: ${c.error}`; return d; | |
| } | |
| const conf=+(c.confidence||0); | |
| const confPct=Math.min(100,conf*10); | |
| const confCls=conf>=7?'hi':conf>=5?'md':'lo'; | |
| const confColor=conf>=7?'var(--green)':conf>=5?'var(--amber)':'var(--red)'; | |
| const dir=(c.direction||'long').toLowerCase(); | |
| const lv=c.levels||{}, sz=c.sizing||{}; | |
| const ev=c.evidence||{}, tf=c.timeframes||{}; | |
| const ri=c.rank_info||{}; | |
| const prob=c.probability||{}, lev=c.leverage||{}, dur=c.duration||{}; | |
| const ms=c.market_state||{}; | |
| const bts=c.backtest_summary||null; | |
| const sym=c.symbol||''; | |
| const hasDash=sym.includes('-'); | |
| const quote=sym.endsWith('USDT')||sym.endsWith('-USDT')?'USDT':'USD'; | |
| const base=hasDash?sym.replace(/-USDT$|-USD$/,''):sym.replace(/USDT$|USD$/,''); | |
| const exchName=(c.source||'').toUpperCase(); | |
| const secName=(c.secondary_source||'').toUpperCase(); | |
| const exchBadge=exchName?`<span class="badge badge-exch">${exchName}${secName?`<span class="exch-sep">+</span>${secName}`:''}</span>`:''; | |
| const cvBadge=c.cross_validated?`<span class="badge badge-cv">✓ Verified</span>`:''; | |
| // News sentiment badge from catalyst evidence | |
| const catEv=(c.evidence||{}).catalyst||{}; | |
| const catNotes=catEv.notes||[]; | |
| const newsNote=catNotes.find(n=>n.startsWith('News:'))||''; | |
| let newsBadge=''; | |
| if(newsNote) { | |
| if(newsNote.includes('no CryptoPanic key')||newsNote.includes('no_key')) { | |
| newsBadge=`<span class="badge badge-news news-nokey" title="Set CRYPTOPANIC_TOKEN in HF Secrets"><svg class="ic"><use href="#i-news"/></svg> No key</span>`; | |
| } else if(newsNote.toLowerCase().includes('bullish')) { | |
| newsBadge=`<span class="badge badge-news news-bullish" title="${newsNote}"><svg class="ic"><use href="#i-news"/></svg> Bullish</span>`; | |
| } else if(newsNote.toLowerCase().includes('bearish')) { | |
| newsBadge=`<span class="badge badge-news news-bearish" title="${newsNote}"><svg class="ic"><use href="#i-news"/></svg> Bearish</span>`; | |
| } else if(newsNote.includes('unavailable')||newsNote.includes('error')) { | |
| newsBadge=''; | |
| } else { | |
| newsBadge=`<span class="badge badge-news news-neutral" title="${newsNote}"><svg class="ic"><use href="#i-news"/></svg> Neutral</span>`; | |
| } | |
| } | |
| const chg=ri.change_24h_pct; | |
| const chgBadge=chg!=null?`<span class="badge badge-chg ${chg>=0?'pos':'neg'}">${pc(chg)}</span>`:''; | |
| const volBadge=ri.quote_volume_usd?`<span class="badge badge-vol">${fu(ri.quote_volume_usd)}</span>`:''; | |
| const fr=c.funding_rate; | |
| const frBadge=fr!=null?(()=>{ | |
| const frPct=(fr*100).toFixed(4); | |
| const cls=fr>0.0005?'fr-hi':fr<-0.0005?'fr-lo':'fr-n'; | |
| return `<span class="badge badge-fr ${cls}">FR ${fr>=0?'+':''}${frPct}%</span>`; | |
| })():''; | |
| const _tr=(c.evidence&&c.evidence.positioning)?c.evidence.positioning.taker_ratio:null; | |
| const takerBadge=_tr!=null?(()=>{ | |
| const pct=Math.round(_tr*100); | |
| if(_tr>0.55) return `<span class="badge badge-taker buy">● ${pct}% buy</span>`; | |
| if(_tr<0.45) return `<span class="badge badge-taker sell">● ${pct}% sell</span>`; | |
| return `<span class="badge badge-taker flow">● ${pct}% flow</span>`; | |
| })():''; | |
| const oi=c.open_interest; | |
| const oiBadge=oi!=null&&oi>0&&c.close?`<span class="badge badge-oi">OI ${fu(oi*c.close)}</span>`:''; | |
| const tfHTML=['1h','15m','5m'].map(t=>{ | |
| const s=(tf[t]?.structure||'').toLowerCase(); | |
| const cls=s==='uptrend'?'up':s==='downtrend'?'down':s?'range':'na'; | |
| return `<span class="tf ${cls}">${t} · ${s||'n/a'}</span>`; | |
| }).join(''); | |
| const evKeys = c.pa_available | |
| ? ['structure','volume','positioning','catalyst','price_action'] | |
| : ['structure','volume','positioning','catalyst']; | |
| const evLabels = {structure:'Structure',volume:'Volume',positioning:'Positioning',catalyst:'Catalyst',price_action:'Price Action'}; | |
| const evHTML = evKeys.map(k=>{ | |
| const e=ev[k]||{}, sc=+(e.score||0); | |
| const barCls = sc>=7?'hi':sc>=4?'med':'lo'; | |
| const lbl = evLabels[k] || (k[0].toUpperCase()+k.slice(1)); | |
| return `<div class="ev-row"> | |
| <span class="ev-key">${lbl}</span> | |
| <div class="ev-track"><div class="ev-bar ${barCls}" data-w="${sc*10}" style="width:0%"></div></div> | |
| <span class="ev-score" style="color:${sc>=7?'var(--profit)':sc>=4?'var(--warn)':'var(--loss)'}">${sc}</span> | |
| </div>`; | |
| }).join(''); | |
| // ── Probability / EV block ── | |
| const pwin=prob.p_win!=null?Math.round(prob.p_win*100):null; | |
| const ploss=pwin!=null?100-pwin:null; | |
| const evVal=prob.ev!=null?prob.ev:null; | |
| const evCls=evVal==null?'ev-zero':evVal>0?'ev-pos':'ev-neg'; | |
| const evStr=evVal!=null?(evVal>0?'+':'')+evVal.toFixed(2):'—'; | |
| const probHTML=pwin!=null?` | |
| <div class="prob-strip"> | |
| <div class="prob-block"> | |
| <div class="prob-lbl">Win %</div> | |
| <div class="prob-val pwin${prob.bt_grounded ? ' grounded' : ''}">${pwin}%</div> | |
| </div> | |
| <div class="prob-divider"></div> | |
| <div class="prob-block"> | |
| <div class="prob-lbl">Loss %</div> | |
| <div class="prob-val ploss">${ploss}%</div> | |
| </div> | |
| <div class="prob-divider"></div> | |
| <div class="prob-block"> | |
| <div class="prob-lbl">Exp. Value</div> | |
| <div class="prob-val ${evCls}">${evStr}</div> | |
| </div> | |
| </div>`:''; | |
| // ── Leverage block (user-configured) ── | |
| const userLevVal = +($('user-leverage')?.value || 6); | |
| const marginToPost = lev.margin_to_post != null ? lev.margin_to_post : (sz.notional ? +(sz.notional / userLevVal).toFixed(2) : null); | |
| const lossAtStop = lev.loss_at_stop != null ? lev.loss_at_stop : null; | |
| const liqDistPct = lev.liq_dist_pct != null ? lev.liq_dist_pct : +(( 1/userLevVal - 0.005)*100).toFixed(2); | |
| const stopDistPct = lv.sl && c.close ? +(Math.abs(lv.sl - c.close)/c.close*100).toFixed(2) : null; | |
| const levProfitUsd = lev.actual_profit_usd != null ? lev.actual_profit_usd : null; | |
| const levProfitPct = lev.actual_profit_pct != null ? lev.actual_profit_pct : null; | |
| const levNeeded = lev.needed_for_target != null ? lev.needed_for_target : null; | |
| const levHTML = ` | |
| <div class="lev-strip"> | |
| <div class="lev-icon" style="color:var(--cyan)"><svg class="ic"><use href="#i-zap"/></svg></div> | |
| <div class="lev-main"> | |
| <div class="lev-lbl">At ${userLevVal}× leverage (your setting)</div> | |
| ${marginToPost != null ? `<div class="lev-val">Margin to post: <strong>$${Number(marginToPost).toFixed(2)} USDT</strong> <span class="lev-max">← enter on BingX "By Cost"</span></div>` : ''} | |
| ${lossAtStop != null ? `<div class="lev-warn">If stop hit: lose $${Number(lossAtStop).toFixed(2)}${stopDistPct!=null?' (stop '+stopDistPct+'% away)':''}</div>` : ''} | |
| ${liqDistPct != null ? `<div class="lev-cap">Liq. ~${liqDistPct}% from entry${stopDistPct!=null&&liqDistPct>stopDistPct?' · stop fires first ✓':' · ⚠ check stop placement'}</div>` : ''} | |
| ${levProfitUsd != null ? `<div class="lev-profit">TP1 profit: +$${Number(levProfitUsd).toFixed(2)} (+${levProfitPct}% on account)</div>` : ''} | |
| ${levNeeded != null ? `<div class="lev-cap" style="margin-top:2px">Need ${levNeeded}× for +5% account at TP1</div>` : ''} | |
| <div class="lev-warn" style="margin-top:6px;font-size:10px">⚠ Set <strong>isolated margin</strong> on BingX before placing</div> | |
| ${lv.sl ? `<div class="lev-cap" style="font-size:10px;color:var(--loss)">⚠ Place stop order at ${sf(lv.sl)} before position opens</div>` : ''} | |
| </div> | |
| </div>`; | |
| // ── Duration block ── | |
| const durLabel=dur.label||null; | |
| const durHTML=durLabel?` | |
| <div class="dur-strip"> | |
| <div class="dur-icon">⏱</div> | |
| <div class="dur-main"> | |
| <div class="dur-lbl">Time to TP1 (est.)</div> | |
| <div class="dur-val">${durLabel}</div> | |
| </div> | |
| </div>`:''; | |
| // ── Market State block (Markov + HMM) ── | |
| let stateHTML = ''; | |
| let stateBadgesSimpleHTML = ''; // lightweight HMM/MK pills only — used in the featured card's left rail | |
| let cardStateConf = null; // exposed on card root so applyStateFilter() works (bug fix) | |
| const mkData = ms.markov, hmmData = ms.hmm; | |
| if (mkData || hmmData) { | |
| const bestState = hmmData?.state || mkData?.state || 'RANGING'; | |
| const bestConf = hmmData?.confidence || mkData?.confidence || 0.5; | |
| cardStateConf = bestConf; | |
| const confPctState = Math.round(bestConf * 100); | |
| const aligned = ms.aligned_with_trade !== false; | |
| const conflictNote = !aligned | |
| ? `<div class="state-conflict">⚠ State conflicts with trade direction — lower conviction</div>` : ''; | |
| // Markov + HMM badges | |
| let badges = ''; | |
| if (hmmData) badges += `<span class="state-badge ${hmmData.state}" title="HMM: probabilistic inference">HMM · ${hmmData.state} <span class="state-conf">${Math.round(hmmData.confidence*100)}%</span></span>`; | |
| if (mkData) badges += `<span class="state-badge ${mkData.state}" title="Markov: state + transition matrix">MK · ${mkData.state} <span class="state-conf">${Math.round(mkData.confidence*100)}%</span></span>`; | |
| stateBadgesSimpleHTML = `<div class="state-badges featured-state-pills">${badges}</div>`; | |
| // Forecast bars for 1d, 3d, 7d | |
| let forecastHTML = ''; | |
| const forecast = mkData?.forecast || {}; | |
| if (Object.keys(forecast).length > 0) { | |
| const horizons = [['1d','1 Day'], ['3d','3 Day'], ['7d','7 Day']]; | |
| forecastHTML = `<div class="forecast-row">` + horizons.map(([key, label]) => { | |
| const f = forecast[key] || {}; | |
| return `<div class="fc-cell"> | |
| <div class="fc-horizon">${label}</div> | |
| <div class="fc-bars"> | |
| ${['BULL','BEAR','RANGING'].map(s => { | |
| const pct = Math.round((f[s]||0)*100); | |
| return `<div class="fc-bar-row"> | |
| <span class="fc-bar-lbl">${s[0]}</span> | |
| <div class="fc-bar-track"><div class="fc-bar-fill ${s}" style="width:${pct}%"></div></div> | |
| <span class="fc-bar-pct">${pct}%</span> | |
| </div>`; | |
| }).join('')} | |
| </div> | |
| </div>`; | |
| }).join('') + `</div>`; | |
| } | |
| stateHTML = ` | |
| <div class="state-strip" data-state="${bestState}" data-conf="${bestConf}"> | |
| <div class="state-strip-top"> | |
| <span class="state-lbl"><svg class="ic"><use href="#i-scan"/></svg> Market State (Markov + HMM)</span> | |
| <span class="state-trust">✓ No lookahead bias</span> | |
| </div> | |
| <div class="state-badges">${badges}</div> | |
| ${conflictNote} | |
| ${forecastHTML} | |
| </div>`; | |
| } | |
| // ── Backtest panel ── | |
| function buildBtPanel(bt, sym) { | |
| if (!bt || !bt.total_trades) { | |
| return `<div class="bt-panel" id="bt-${sym}"> | |
| <div class="bt-header"> | |
| <span class="bt-lbl" title="Momentum signal history — structure + volume signals only. Funding rates and news catalyst (40% of live score) are excluded from backtest."><svg class="ic"><use href="#i-chart"/></svg> Signal History · structure+vol</span> | |
| <button class="bt-run-btn" onclick="runBacktest('${sym}',this)">Run Backtest</button> | |
| </div> | |
| <div class="bt-loading" id="bt-result-${sym}">Not yet run — click to backtest ~6 months of 15m history</div> | |
| </div>`; | |
| } | |
| return renderBtResult(bt, sym); | |
| } | |
| function renderBtResult(bt, sym) { | |
| // Primary = TP1 win rate (matches how most traders exit) | |
| // Secondary = TP2 win rate (ambitious hold target) | |
| const wrTp1 = bt.win_rate_tp1 != null ? Math.round(bt.win_rate_tp1*100) : (bt.win_rate != null ? Math.round(bt.win_rate*100) : null); | |
| const wrTp2 = bt.win_rate_tp2 != null ? Math.round(bt.win_rate_tp2*100) : null; | |
| const wrCls = wrTp1==null?'neutral':wrTp1>=55?'win':wrTp1>=45?'neutral':'loss'; | |
| const avgR = bt.avg_r_tp1 != null ? (bt.avg_r_tp1 > 0 ? '+' : '') + bt.avg_r_tp1.toFixed(2) + 'R' | |
| : bt.avg_r != null ? (bt.avg_r > 0 ? '+' : '') + bt.avg_r.toFixed(2) + 'R' : '—'; | |
| const dd = bt.max_drawdown_r != null ? bt.max_drawdown_r.toFixed(1)+'R' : '—'; | |
| const mo = bt.lookback_months || '?'; | |
| const n = bt.total_trades || 0; | |
| const tf = bt.candle_interval || '15m'; | |
| // Data Grounded badge — only show when blend actually fired (N >= 10) | |
| const blendActive = bt.blend_active === true; | |
| const groundedBadge = blendActive | |
| ? `<span class="bt-grounded-badge">✓ Data grounded</span>` | |
| : (n >= 10 ? `<span class="bt-grounded-badge" style="opacity:0.6"><svg class="ic"><use href="#i-chart"/></svg> ${n} trades</span>` : `<span style="font-size:10px;color:var(--muted);padding:2px 6px">⚠ ${n} trades (need 10+)</span>`); | |
| // State breakdown — show both TP1 and TP2 win rates per state | |
| const stateRows = Object.entries(bt.by_state||{}).map(([state, s]) => { | |
| const swr1 = s.win_rate_tp1 != null ? Math.round(s.win_rate_tp1*100) : Math.round((s.win_rate||0)*100); | |
| const swr2 = s.win_rate_tp2 != null ? Math.round(s.win_rate_tp2*100) : null; | |
| const cls = state==='BULL'?'bull':state==='BEAR'?'bear':'range'; | |
| const tp2txt = swr2 != null ? `<span style="font-size:11px;color:var(--muted);margin-left:3px">TP2:${swr2}%</span>` : ''; | |
| return `<div class="bt-state-cell"> | |
| <div class="bt-state-name">${state}</div> | |
| <div class="bt-state-wr ${cls}">${swr1}%${tp2txt}</div> | |
| <div class="bt-state-n">${s.trades} trades</div> | |
| </div>`; | |
| }).join(''); | |
| // Equity sparkline (based on TP1 — matches real trading) | |
| const curve = bt.equity_curve || []; | |
| let sparkSVG = ''; | |
| if (curve.length >= 2) { | |
| const mn = Math.min(...curve), mx = Math.max(...curve); | |
| const range = mx - mn || 1; | |
| const W = 260, H = 52; | |
| const pts = curve.map((v,i) => { | |
| const x = (i/(curve.length-1))*W; | |
| const y = (H-12) - ((v-mn)/range)*(H-12); | |
| return `${x.toFixed(1)},${y.toFixed(1)}`; | |
| }).join(' '); | |
| const finalColor = curve[curve.length-1] >= 0 ? 'var(--profit2)' : 'var(--loss2)'; | |
| const termVal = curve[curve.length-1]; | |
| const termLabel = (termVal>=0?'+':'')+termVal.toFixed(1)+'R'; | |
| const zeroY = (H-12) - ((0-mn)/range)*(H-12); | |
| sparkSVG = `<svg class="bt-sparkline" viewBox="0 0 ${W} ${H}" preserveAspectRatio="none"> | |
| <polyline fill="none" stroke="${finalColor}" stroke-width="1.5" points="${pts}"/> | |
| <line x1="0" y1="${zeroY.toFixed(1)}" x2="${W}" y2="${zeroY.toFixed(1)}" | |
| stroke="rgba(148,158,178,0.4)" stroke-width="0.5" stroke-dasharray="3,3"/> | |
| <text x="3" y="${(zeroY-2).toFixed(1)}" font-size="8" fill="rgba(148,158,178,0.75)" font-family="monospace">0R</text> | |
| <text x="${W-3}" y="${H}" font-size="9" fill="${finalColor}" font-family="monospace" text-anchor="end" font-weight="700">${termLabel}</text> | |
| </svg>`; | |
| } | |
| // TP2 comparison row | |
| const tp2Row = wrTp2 != null ? `<div style="font-size:10.5px;color:var(--muted);text-align:center;margin-top:2px;margin-bottom:4px"> | |
| TP1 exit: <b style="color:${wrTp1>=55?'var(--profit2)':wrTp1>=45?'var(--amber)':'var(--loss2)'}">${wrTp1}%</b> | | |
| TP2 hold: <b style="color:${wrTp2>=55?'var(--profit2)':wrTp2>=45?'var(--amber)':'var(--loss2)'}">${wrTp2}%</b> | |
| </div>` : ''; | |
| return `<div class="bt-panel" id="bt-${sym}"> | |
| <div class="bt-header"> | |
| <span class="bt-lbl" title="Momentum signal history — structure + volume signals only. Funding rates and news catalyst (40% of live score) are excluded."><svg class="ic"><use href="#i-chart"/></svg> Signal History · ${mo}mo · ${n} trades · ${tf}</span> | |
| ${groundedBadge} | |
| <button class="bt-run-btn" onclick="runBacktest('${sym}',this)" title="Refresh backtest">↺</button> | |
| </div> | |
| <div class="bt-stats-row"> | |
| <div class="bt-stat"><div class="bt-stat-val ${wrCls}">${wrTp1!=null?wrTp1+'%':'—'}</div><div class="bt-stat-lbl">TP1 Win Rate</div></div> | |
| <div class="bt-stat"><div class="bt-stat-val ${parseFloat(bt.avg_r_tp1||bt.avg_r)>=0?'win':'loss'}">${avgR}</div><div class="bt-stat-lbl">Avg R</div></div> | |
| <div class="bt-stat"><div class="bt-stat-val neutral">-${dd}</div><div class="bt-stat-lbl">Max DD</div></div> | |
| </div> | |
| ${tp2Row} | |
| ${stateRows?`<div class="bt-state-row">${stateRows}</div>`:''} | |
| ${sparkSVG} | |
| </div>`; | |
| } | |
| // ── Price Action section ── | |
| function buildPriceActionSection(card) { | |
| const pa = card.price_action; | |
| if (!pa || !pa.available) return ''; | |
| // Confluence grade badge | |
| const grade = (pa.confluence_grade || '').toUpperCase(); | |
| const gradeCls = grade === 'A+' ? 'pa-grade-ap' | |
| : grade === 'A' ? 'pa-grade-a' | |
| : grade === 'B' ? 'pa-grade-b' | |
| : grade === 'C' ? 'pa-grade-c' | |
| : grade === 'D' ? 'pa-grade-d' | |
| : 'pa-grade-b'; | |
| const gradeHtml = grade | |
| ? `<span class="pa-grade ${gradeCls}">${grade}</span>` : ''; | |
| const paScore = pa.pa_score != null | |
| ? `<span class="pa-score-num">PA ${Math.round(pa.pa_score * 100)}%</span>` : ''; | |
| // Strength dots helper: filled ● up to strength, empty ○ to max 3 | |
| function dots(n, max) { | |
| const filled = Math.min(n, max); | |
| return '●'.repeat(filled) + '○'.repeat(Math.max(0, max - filled)); | |
| } | |
| // ── Candlestick patterns row | |
| let csHTML = ''; | |
| const csPats = (pa.candlestick && pa.candlestick.patterns) || []; | |
| if (csPats.length > 0) { | |
| const shown = csPats.slice(0, 2); | |
| const extra = csPats.length - shown.length; | |
| const items = shown.map((p, i) => { | |
| const sigCls = p.signal === 'bullish' ? 'pa-signal-bull' : 'pa-signal-bear'; | |
| const sigArrow = p.signal === 'bullish' ? '<svg class="ic"><use href="#i-trend"/></svg>' : '<svg class="ic"><use href="#i-trend-down"/></svg>'; | |
| const keyBadge = p.at_key_level ? `<span class="pa-key-badge">KEY</span>` : ''; | |
| const descHtml = p.description | |
| ? `<div class="pa-desc"><div class="pa-desc-inner">${p.description}</div></div>` : ''; | |
| return `<div class="pa-row" onclick="this.classList.toggle('expanded')"> | |
| <span class="pa-row-icon ${sigCls}">${sigArrow}</span> | |
| <div class="pa-row-body"> | |
| <div class="pa-row-top"> | |
| <span class="pa-pat-name">${p.name}</span> | |
| <span class="${sigCls}">${p.signal}</span> | |
| <span class="pa-dots">${dots(p.strength || 1, 3)}</span> | |
| ${keyBadge} | |
| ${i === shown.length - 1 && extra > 0 ? `<span class="pa-more">+${extra} more</span>` : ''} | |
| </div> | |
| ${descHtml} | |
| </div> | |
| </div>`; | |
| }).join(''); | |
| csHTML = items; | |
| } | |
| // ── Chart pattern row | |
| let cpHTML = ''; | |
| const cpPats = (pa.chart_patterns && pa.chart_patterns.patterns) || []; | |
| if (cpPats.length > 0) { | |
| const p = cpPats[0]; // show first/strongest | |
| const stage = (p.stage || '').toLowerCase(); | |
| const stageCls = stage === 'confirmed' ? 'pa-stage-confirmed' | |
| : stage === 'broken' ? 'pa-stage-broken' | |
| : 'pa-stage-forming'; | |
| const stageLabel = stage.toUpperCase() || 'FORMING'; | |
| const targetHtml = p.target_pct != null | |
| ? `<span class="pa-target">Target: +${p.target_pct}%</span>` : ''; | |
| const sigArrow = p.signal === 'bullish' ? '<svg class="ic"><use href="#i-trend"/></svg>' : '<svg class="ic"><use href="#i-trend-down"/></svg>'; | |
| const sigCls = p.signal === 'bullish' ? 'pa-signal-bull' : 'pa-signal-bear'; | |
| const descHtml = p.description | |
| ? `<div class="pa-desc"><div class="pa-desc-inner">${p.description}</div></div>` : ''; | |
| cpHTML = `<div class="pa-row" onclick="this.classList.toggle('expanded')"> | |
| <span class="pa-row-icon ${sigCls}">${sigArrow}</span> | |
| <div class="pa-row-body"> | |
| <div class="pa-row-top"> | |
| <span class="pa-pat-name">${p.name}</span> | |
| <span class="${stageCls}">${stageLabel}</span> | |
| ${targetHtml} | |
| </div> | |
| ${descHtml} | |
| </div> | |
| </div>`; | |
| } | |
| // ── Order block summary line | |
| let obHTML = ''; | |
| const ob = pa.order_blocks && pa.order_blocks.result; | |
| if (ob) { | |
| if (ob.summary) { | |
| obHTML = `<div class="pa-ob-line"><svg class="ic"><use href="#i-box"/></svg> ${ob.summary}</div>`; | |
| } else { | |
| const lines = []; | |
| if (ob.nearest_ob_below) { | |
| const z = ob.nearest_ob_below; | |
| lines.push(`OB Support: ${z.zone_low.toLocaleString()}–${z.zone_high.toLocaleString()}${z.status === 'fresh' ? ' (fresh)' : ''}`); | |
| } | |
| if (ob.nearest_ob_above) { | |
| const z = ob.nearest_ob_above; | |
| lines.push(`OB Resistance: ${z.zone_low.toLocaleString()}–${z.zone_high.toLocaleString()}${z.status === 'fresh' ? ' (fresh)' : ''}`); | |
| } | |
| if (lines.length) obHTML = `<div class="pa-ob-line"><svg class="ic"><use href="#i-box"/></svg> ${lines.join(' · ')}</div>`; | |
| } | |
| } | |
| // ── Conflict warning | |
| const conflictHTML = card.pattern_direction_conflict | |
| ? `<div class="pa-conflict">⚠ Patterns suggest opposite direction</div>` : ''; | |
| // If we have nothing to show, return empty | |
| if (!csHTML && !cpHTML && !obHTML && !conflictHTML && !grade) return ''; | |
| return `<div class="pa-section"> | |
| <div class="pa-header"> | |
| <span class="pa-title">Price Action</span> | |
| ${gradeHtml} | |
| ${paScore} | |
| </div> | |
| ${csHTML} | |
| ${cpHTML} | |
| ${obHTML} | |
| ${conflictHTML} | |
| </div>`; | |
| } | |
| const btHTML = buildBtPanel(bts, sym); | |
| // ── Veto banner: shown when R/R < 1.0 (hard no-trade) ── | |
| const isVetoed = c.vetoed === true; | |
| const vetoBanner = isVetoed && c.veto_reason ? ` | |
| <div class="veto-banner"> | |
| <div class="veto-banner-title"><svg class="ic" style="width:1em;height:1em;vertical-align:-2px"><use href="#i-alert"/></svg> NO TRADE — Risk Exceeds Reward</div> | |
| <div class="veto-banner-reason">${c.veto_reason}</div> | |
| </div>` : ''; | |
| let narr=(c.narrative||''); | |
| if(narr.length>210) narr=narr.slice(0,207)+'…'; | |
| const plan=[ | |
| `${sym} ${dir.toUpperCase()} | Confidence ${conf}/10`, | |
| `Entry ${sf(c.close)}`, | |
| `SL ${sf(lv.sl)}`, | |
| `TP1 ${sf(lv.tp1)} TP2 ${sf(lv.tp2)} TP3 ${sf(lv.tp3)}`, | |
| `R/R ${lv.rr1??'—'}×`, | |
| `Risk ${fu(sz.risk_usd)} | Units ${sz.units??'—'} | Notional ${fu(sz.notional)}`, | |
| `Exchange ${exchName}${secName?'+'+secName:''}`, | |
| `Time ${new Date().toLocaleString()}` | |
| ].join('\n'); | |
| const tvUrl=`https://www.tradingview.com/chart/?symbol=${encodeURIComponent(c.tv_symbol||sym)}`; | |
| const el=document.createElement('div'); | |
| el.className=`card ${dir}-card${featured?' featured-card':''}${isRowMode?' row-mode':''}`; | |
| el.dataset.sym = sym; // used by never-blank auto-refresh to diff/re-rank in place | |
| if (rank != null) el.dataset.rank = String(rank); | |
| if (featured) el.dataset.featured = '1'; | |
| if (cardStateConf != null) el.dataset.conf = String(cardStateConf); // data-conf on card root (state filter fix) | |
| // ── Row-summary bar — collapsed header for non-featured ranked rows ── | |
| const rsArrow = dir==='long' ? '▲' : '▼'; | |
| const rr1forRow = lv.rr1; | |
| const rowSummaryHTML = isRowMode ? ` | |
| <div class="row-summary"> | |
| <span class="rs-rank">${String(rank).padStart(2,'0')}</span> | |
| <div class="rs-sym-wrap"><span class="rs-sym">${sym}</span><span class="rs-dir ${dir}">${rsArrow} ${dir.toUpperCase()}</span></div> | |
| <div class="rs-conf"><b style="color:${confColor}">${conf}</b><span class="rs-mbar ${conf<5?'lo':''}"><i data-w="${confPct}"></i></span></div> | |
| <div class="rs-stat" data-rs="entry"><div class="k">ENTRY</div><div class="v">${sf(c.close)}</div></div> | |
| <div class="rs-stat"><div class="k">R/R</div><div class="v" style="color:${rr1forRow!=null&&rr1forRow<1?'var(--loss)':'var(--t1)'}">${rr1forRow??'—'}×</div></div> | |
| <div class="rs-stat"><div class="k">WIN %</div><div class="v" style="color:${pwin!=null?(pwin>=50?'var(--green)':'var(--red)'):'var(--t1)'}">${pwin!=null?pwin+'%':'—'}</div></div> | |
| <div class="rs-stat"><div class="k">EV</div><div class="v" style="color:${evVal!=null?(evVal>0?'var(--green)':'var(--red)'):'var(--t1)'}">${evVal!=null?(evVal>0?'+':'')+evVal.toFixed(2):'—'}</div></div> | |
| <div class="rs-stat"><div class="k">EST. TP1</div><div class="v">${durLabel||'—'}</div></div> | |
| <span class="rs-exp">▾</span> | |
| </div>` : ''; | |
| // ── Featured ring + tier tag — shown only on the #1 ranked "Top Setup" card. | |
| // "TIER" here is derived from the AI confidence score (0–10) — it is NOT | |
| // a pattern-based grade. The real price-action confluence grade (from | |
| // chart patterns + candlesticks + order blocks, computed in confluence.py) | |
| // is shown separately via the "PA GRADE" chip below, sourced directly | |
| // from card.price_action.confluence_grade. The two can disagree. ── | |
| const ringCirc = 226.2; // 2*pi*36 | |
| const gradeLetter = conf>=7 ? 'A' : conf>=5 ? 'B' : 'C'; | |
| const gradeCssCls = isVetoed ? 'gC' : gradeLetter==='A' ? 'gA' : gradeLetter==='B' ? 'gB' : 'gC'; | |
| const gradeTagLabel = isVetoed ? 'VETOED · NO TRADE' : `TIER ${gradeLetter} · TRADEABLE`; | |
| // ── Real Price Action confluence grade (pattern-based, independent of confidence) ── | |
| const paGradeRaw = (c.pa_available && c.price_action?.confluence_grade) ? c.price_action.confluence_grade : null; | |
| const paScoreRaw = c.price_action?.confluence_score; | |
| const paChipCls = paGradeRaw === 'A+' || paGradeRaw === 'A' ? 'pgA' | |
| : paGradeRaw === 'B' ? 'pgB' | |
| : paGradeRaw === 'C' ? 'pgC' | |
| : paGradeRaw === 'D' ? 'pgD' | |
| : 'pgNA'; | |
| const paChipLabel = paGradeRaw | |
| ? `PA GRADE ${paGradeRaw}${paScoreRaw!=null?` · ${Math.round(paScoreRaw)}`:''}` | |
| : 'PA · N/A'; | |
| const paGradeChipHTML = `<span class="pa-grade-chip ${paChipCls}">${paChipLabel}</span>`; | |
| const featuredRingWrapHTML = featured ? ` | |
| <div class="conf-ring-wrap"> | |
| <svg viewBox="0 0 84 84"> | |
| <circle class="conf-ring-bg" cx="42" cy="42" r="36" stroke-width="7"/> | |
| <circle class="conf-ring-val" cx="42" cy="42" r="36" stroke-width="7" stroke="${confColor}" stroke-dasharray="${ringCirc}" stroke-dashoffset="${(ringCirc-(ringCirc*confPct/100)).toFixed(1)}"/> | |
| </svg> | |
| <div class="conf-ring-center"><b>${conf}</b><span>/ 10</span></div> | |
| </div>` : ''; | |
| // ── Named content blocks — shared between the normal single-column layout | |
| // and the featured two-column layout, so nothing is duplicated or lost ── | |
| const chHTML = ` | |
| <div class="ch"> | |
| <div class="ch-left"> | |
| <div class="ch-sym">${base}<span class="q">${quote}</span></div> | |
| <div class="ch-badges">${exchBadge}${cvBadge}${newsBadge}${chgBadge}${volBadge}${frBadge}${takerBadge}${oiBadge}</div> | |
| </div> | |
| <span class="dir-tag ${dir}">${dir.toUpperCase()}</span> | |
| </div>`; | |
| const confBoxHTML = ` | |
| <div class="conf-box"> | |
| <div class="conf-row"> | |
| <span class="conf-lbl">Confidence Score</span> | |
| <span class="conf-num" style="color:${confColor}">${conf}<span class="conf-denom">/10</span></span>${(()=>{const g=(c.price_action||{}).confluence_grade;if(!g)return '';const gc=g==='A+'?'pa-grade-ap':g==='A'?'pa-grade-a':g==='B'?'pa-grade-b':g==='C'?'pa-grade-c':'pa-grade-d';return `<span class="pa-conf-grade ${gc}">${g}</span>`;})()} | |
| </div> | |
| <div class="conf-bar"><div class="conf-fill ${confCls}" style="width:${confPct}%"></div></div> | |
| </div>`; | |
| const tfBlockHTML = ` | |
| ${vetoBanner} | |
| <div class="tf-row">${tfHTML}</div> | |
| <div class="rule"></div>`; | |
| const lvlGridHTML = ` | |
| <div class="lvl-grid"> | |
| ${(()=>{ | |
| // Dollar P&L at each level using recommended leverage | |
| const entry=c.close||0; | |
| const recLev=+($('user-leverage')?.value||lev.user_leverage||lev.recommended||6); | |
| const pos=sz.notional*recLev; // leveraged position size | |
| const sign=dir==='long'?1:-1; | |
| function pnl(price){ return price&&entry?round2(pos*(price-entry)/entry*sign):null; } | |
| function fmt(v){ if(v==null) return ''; const s=v>=0?'+':''; const abs=Math.abs(v); const dec=abs<1?2:abs<10?1:0; return `<div class="lvl-pnl ${v>=0?'pos':'neg'}">${s}$${abs.toLocaleString('en',{minimumFractionDigits:dec,maximumFractionDigits:dec})}</div>`; } | |
| function round2(v){ return Math.round(v*100)/100; } | |
| const pSL=pnl(lv.sl), pTP1=pnl(lv.tp1), pTP2=pnl(lv.tp2), pTP3=pnl(lv.tp3); | |
| return ` | |
| <div class="lvl entry"><div class="lvl-lbl">Entry</div><div class="lvl-val">${sf(entry)}</div><div class="lvl-pnl neutral">${recLev}× lev</div></div> | |
| <div class="lvl sl"> <div class="lvl-lbl">Stop Loss</div><div class="lvl-val">${sf(lv.sl)}</div>${fmt(pSL)}</div> | |
| ${(()=>{ const rr=lv.rr1; const rrCls=rr==null?'':rr>=1.5?'rr-good':rr<1.0?'rr-bad':''; const rrWarn=rr!=null&&rr<1.0?`<div class="lvl-pnl loss" style="font-size:11px;font-weight:700;">⚠ Risk > Reward</div>`:''; return `<div class="lvl rr ${rrCls}"><div class="lvl-lbl">R / R</div><div class="lvl-val">${rr??'—'}×</div>${rrWarn}</div>`; })()} | |
| <div class="lvl tp"> <div class="lvl-lbl">TP 1</div><div class="lvl-val">${sf(lv.tp1)}</div>${fmt(pTP1)}</div> | |
| <div class="lvl tp"> <div class="lvl-lbl">TP 2</div><div class="lvl-val">${sf(lv.tp2)}</div>${fmt(pTP2)}</div> | |
| <div class="lvl tp"> <div class="lvl-lbl">TP 3</div><div class="lvl-val">${sf(lv.tp3)}</div>${fmt(pTP3)}</div>`; | |
| })()} | |
| </div>`; | |
| const szStripHTML = ` | |
| <div class="sz-strip"> | |
| <div class="sz-item"><div class="sz-lbl">Risk $</div><div class="sz-val">${fu(sz.risk_usd)}</div></div> | |
| <div class="sz-item"><div class="sz-lbl">BingX qty</div><div class="sz-val">${sz.units??'—'}<span style="display:block;font-size:9px;color:var(--warn);font-weight:700;margin-top:1px">← enter on BingX</span></div></div> | |
| <div class="sz-item"><div class="sz-lbl">Notional</div><div class="sz-val">${fu(sz.notional)}</div></div> | |
| </div>`; | |
| const narrHTML = `${narr?`<div class="narr">${narr}</div>`:''}<div class="rule"></div>`; | |
| const evTitleHTML = `<div class="ev-title">Score Breakdown — ${c.pa_available ? '35/20/15/15/15' : '40/20/20/20'}</div>${evHTML}`; | |
| const priceActionHTML = buildPriceActionSection(c); | |
| const cardActsHTML = ` | |
| <div class="card-acts"> | |
| <a class="ca ca-chart" href="${tvUrl}" target="_blank" rel="noopener"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg> | |
| Chart | |
| </a> | |
| <button class="ca ca-copy" data-plan="${plan.replace(/"/g,'"').replace(/\n/g,'\\n')}"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg> | |
| Copy Plan | |
| </button> | |
| <button class="ca ca-ref" data-sym="${sym}" data-acc="${account}" data-risk="${risk}" data-rank="${rank??''}" data-featured="${featured?'1':'0'}" title="Refresh this card"> | |
| <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg> | |
| </button> | |
| </div>`; | |
| // ── Featured cards: two-column layout (ring/grade/state/score rail + main content). | |
| // Non-featured cards: original single-column stacked layout, unchanged. ── | |
| const bodyHTML = featured ? ` | |
| <div class="featured-layout"> | |
| <div class="featured-left"> | |
| ${featuredRingWrapHTML} | |
| <span class="tag-grade ${gradeCssCls}">${gradeTagLabel}</span> | |
| ${paGradeChipHTML} | |
| ${stateBadgesSimpleHTML} | |
| <div class="panel">${evTitleHTML}</div> | |
| </div> | |
| <div class="featured-right"> | |
| ${chHTML} | |
| ${tfBlockHTML} | |
| ${lvlGridHTML} | |
| ${szStripHTML} | |
| ${probHTML} | |
| <div class="two-col"> | |
| <div>${levHTML}</div> | |
| <div style="display:flex;flex-direction:column;gap:10px">${durHTML}${buildLiveAccPanel(sym)}</div> | |
| </div> | |
| ${stateHTML}${btHTML} | |
| ${narrHTML} | |
| ${priceActionHTML} | |
| ${cardActsHTML} | |
| </div> | |
| </div>` : ` | |
| ${chHTML} | |
| ${confBoxHTML} | |
| ${tfBlockHTML} | |
| ${lvlGridHTML} | |
| ${szStripHTML} | |
| ${probHTML}${levHTML}${durHTML}${stateHTML}${btHTML}${buildLiveAccPanel(sym)} | |
| ${narrHTML} | |
| ${evTitleHTML} | |
| ${priceActionHTML} | |
| ${cardActsHTML}`; | |
| el.innerHTML=`${rowSummaryHTML} | |
| <div class="card-detail"><div class="card-detail-inner"> | |
| ${bodyHTML} | |
| </div></div>`; | |
| if (isRowMode) { | |
| const rowHeader = el.querySelector('.row-summary'); | |
| if (rowHeader) rowHeader.addEventListener('click', function(){ | |
| el.classList.toggle('open'); | |
| if (el.classList.contains('open')) { | |
| el.querySelectorAll('.ev-bar, .rs-mbar i').forEach(b => { if(b.dataset.w) b.style.width = b.dataset.w + '%'; }); | |
| } | |
| }); | |
| } | |
| el.querySelector('.ca-copy').addEventListener('click', function(){ | |
| const text=this.dataset.plan.replace(/\\n/g,'\n'); | |
| navigator.clipboard.writeText(text).then(()=>{ | |
| const orig=this.innerHTML; | |
| this.innerHTML=`<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg> Copied!`; | |
| this.style.color='var(--green)'; this.style.borderColor='rgba(61,220,151,0.3)'; | |
| this.style.transform='scale(1.04)'; | |
| setTimeout(()=>{ this.style.transform=''; }, 220); | |
| showToast(base + ' trade plan copied to clipboard'); | |
| setTimeout(()=>{ this.innerHTML=orig; this.style.color=''; this.style.borderColor=''; },2200); | |
| }).catch(()=>prompt('Copy:',text)); | |
| }); | |
| el.querySelector('.ca-ref').addEventListener('click', async function(){ | |
| const {sym:s,acc,risk:r,rank:rk,featured:ft}=this.dataset, orig=this.innerHTML; | |
| this.disabled=true; | |
| this.innerHTML=`<svg class="spin-icon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 12a9 9 0 1 1-2.12-9.36L21 3v6h-6"/></svg>`; | |
| try { | |
| const data=await fetch(`/api/card/${s}?account=${acc}&risk_pct=${r}`).then(x=>x.json()); | |
| const newEl=buildCard(data,+acc,+r,{rank: rk?+rk:null, featured: ft==='1'}); | |
| if (el.classList.contains('open')) newEl.classList.add('open'); | |
| el.replaceWith(newEl); | |
| requestAnimationFrame(()=>{ | |
| setTimeout(()=>{ newEl.querySelectorAll('.ev-bar, .rs-mbar i').forEach(b=>{ if(b.dataset.w) b.style.width=b.dataset.w+'%'; }); },100); | |
| }); | |
| if(data.symbol) liveCards.set(data.symbol,{el:newEl,account:+acc,risk:+r}); | |
| } catch { this.disabled=false; this.innerHTML=orig; } | |
| }); | |
| return el; | |
| } | |
| /* ═══ Signal cards ═══ */ | |
| const MOVE_LABELS = { | |
| breakout:'Breakout', acceleration:'Trend Accel.', reversal:'Reversal', | |
| news:'News Spike', state_transition:'Regime Change' | |
| }; | |
| // ── Signal type metadata ───────────────────────────────────────────────────── | |
| const SIG_TYPE_META = { | |
| rsi_cross: { label:'RSI Cross', icon:'<svg class="ic"><use href="#i-chart"/></svg>', color:'var(--teal)' }, | |
| volume_surge: { label:'Volume Surge', icon:'<svg class="ic"><use href="#i-trend"/></svg>', color:'var(--profit)' }, | |
| bb_squeeze: { label:'Squeeze Breakout', icon:'<svg class="ic"><use href="#i-scan"/></svg>', color:'var(--warn)' }, | |
| funding_extreme: { label:'Funding Extreme', icon:'<svg class="ic"><use href="#i-zap"/></svg>', color:'var(--violet)' }, | |
| price_surge: { label:'Price Surge', icon:'<svg class="ic"><use href="#i-rocket"/></svg>', color:'var(--profit)' }, | |
| }; | |
| function buildSignalCard(a) { | |
| const sym = a.symbol || ''; | |
| const base = sym.replace(/-USDT$|-USD$|USDT$|USD$/,''); | |
| const quote = (sym.endsWith('USDT') || sym.endsWith('-USDT')) ? 'USDT' : 'USD'; | |
| const dir = (a.direction || 'neutral').toLowerCase(); | |
| const urgency = a.urgency || 'medium'; | |
| const tvUrl = `https://www.tradingview.com/chart/?symbol=${encodeURIComponent(a.tv_symbol || sym)}`; | |
| const meta = SIG_TYPE_META[a.signal_type] || { label: a.signal_type, icon:'<svg class="ic"><use href="#i-alert"/></svg>', color:'var(--t3)' }; | |
| const dirLabel = dir === 'long' ? '▲ LONG' | |
| : dir === 'short' ? '▼ SHORT' | |
| : '— WATCH'; | |
| const change24 = a.change_24h_pct != null | |
| ? `<span style="color:${a.change_24h_pct >= 0 ? 'var(--profit)' : 'var(--loss)'};font-size:11px;font-weight:600">${a.change_24h_pct >= 0 ? '+' : ''}${a.change_24h_pct.toFixed(2)}% 24h</span>` | |
| : ''; | |
| const priceRow = a.close != null | |
| ? `<div style="font-size:11px;color:var(--t3);margin-top:4px">${sf(a.close)} ${change24}</div>` | |
| : ''; | |
| const urgencyDot = urgency === 'critical' ? '<span style="color:var(--red)">●</span>' | |
| : urgency === 'high' ? '<span style="color:var(--warn)">●</span>' | |
| : '<span style="color:var(--amber)">●</span>'; | |
| const el = document.createElement('div'); | |
| el.className = `sig-card urgency-${urgency}`; | |
| el.innerHTML = ` | |
| <div class="sig-header"> | |
| <div> | |
| <div class="sig-sym">${base}<span class="q">${quote}</span></div> | |
| ${priceRow} | |
| </div> | |
| <span class="sig-dir ${dir}">${dirLabel}</span> | |
| </div> | |
| <div class="sig-types"> | |
| <span class="sig-type-badge" style="background:color-mix(in srgb, ${meta.color} 12%, transparent);color:${meta.color};border:1px solid color-mix(in srgb, ${meta.color} 30%, transparent)">${meta.icon} ${meta.label}</span> | |
| <span style="font-size:10px;color:var(--t3);margin-left:6px">${urgencyDot} ${urgency === 'critical' ? 'Critical' : urgency === 'high' ? 'High priority' : 'Watch'}</span> | |
| </div> | |
| <div class="sig-reasons"> | |
| <div class="sig-reason ${dir === 'long' ? 'bull' : dir === 'short' ? 'bear' : ''}" style="font-size:11.5px;line-height:1.5">${a.description || ''}</div> | |
| </div> | |
| <a class="sig-cta" href="${tvUrl}" target="_blank" rel="noopener"> | |
| <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/></svg> | |
| Open on TradingView | |
| </a>`; | |
| return el; | |
| } | |
| // ── News ticker ────────────────────────────────────────────────────────────── | |
| function renderNewsTicker(data) { | |
| const ticker = $('news-ticker'); | |
| const trendRow = $('trending-coins'); | |
| if (!ticker || !data) return; | |
| // Trending coins chips | |
| if (trendRow && data.trending_coins && data.trending_coins.length) { | |
| trendRow.innerHTML = data.trending_coins.map((c,i) => | |
| `<span class="news-chip" style="cursor:default" title="Trending #${i+1} on CoinGecko"><svg class="ic" style="color:var(--warn)"><use href="#i-flame"/></svg> ${c.symbol}</span>` | |
| ).join(''); | |
| } | |
| // Headlines marquee | |
| const headlines = (data.headlines || []).slice(0, 10); | |
| if (!headlines.length) { ticker.style.display = 'none'; return; } | |
| const items = headlines.map(h => { | |
| const coins = h.coins_mentioned && h.coins_mentioned.length | |
| ? ` <span style="color:var(--teal);font-weight:700">[${h.coins_mentioned.slice(0,3).join(', ')}]</span>` | |
| : ''; | |
| return `<a href="${h.link}" target="_blank" rel="noopener" style="color:var(--t2);text-decoration:none;white-space:nowrap;margin-right:48px">${h.title}${coins}</a>`; | |
| }).join('<span style="color:var(--t3);margin-right:48px">·</span>'); | |
| ticker.querySelector('.news-ticker-inner').innerHTML = items + items; // duplicate for seamless loop | |
| ticker.style.display = 'block'; | |
| } | |
| async function loadNews() { | |
| try { | |
| const data = await fetch('/api/news').then(r => r.json()); | |
| renderNewsTicker(data); | |
| } catch(e) { | |
| // News is non-critical — fail silently | |
| } | |
| } | |
| async function loadSignals() { | |
| const loader = $('signals-loader'); | |
| const empty = $('signals-empty'); | |
| const grid = $('signals-grid'); | |
| const meta = $('signals-meta'); | |
| const btn = $('signals-refresh'); | |
| if (!grid) return; | |
| grid.innerHTML = ''; | |
| if (empty) empty.style.display = 'none'; | |
| if (loader) loader.style.display = 'flex'; | |
| if (btn) btn.classList.add('spinning'); | |
| try { | |
| const data = await fetch('/api/signals').then(r => r.json()); | |
| if (loader) loader.style.display = 'none'; | |
| if (btn) btn.classList.remove('spinning'); | |
| const alerts = data.alerts || []; | |
| const ts = data.fetched_at ? new Date(data.fetched_at).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'}) : ''; | |
| if (meta) meta.textContent = `${alerts.length} signal${alerts.length!==1?'s':''} · ${data.scanned||0} scanned · ${ts}`; | |
| if (!alerts.length) { | |
| if (empty) { | |
| empty.style.display = 'flex'; | |
| const lbl = empty.querySelector('span:last-child'); | |
| if (lbl) lbl.textContent = 'No signals right now — market is calm. Will update automatically.'; | |
| } | |
| return; | |
| } | |
| alerts.forEach((a, i) => { | |
| const el = buildSignalCard(a); | |
| el.style.animationDelay = (i * 60) + 'ms'; | |
| grid.appendChild(el); | |
| }); | |
| } catch(e) { | |
| if (loader) loader.style.display = 'none'; | |
| if (btn) btn.classList.remove('spinning'); | |
| if (meta) meta.textContent = 'Signals unavailable'; | |
| if (empty) { | |
| empty.style.display = 'flex'; | |
| const lbl = empty.querySelector('span:last-child'); | |
| if (lbl) lbl.textContent = 'Signals failed to load — ' + e.message; | |
| } | |
| } | |
| } | |
| $('signals-refresh')?.addEventListener('click', () => { loadSignals(); loadNews(); }); | |
| /* ═══ Market Cycle Tracker (BTC / ETH / SOL) ═══ */ | |
| function switchCycleTab(coin) { | |
| document.querySelectorAll('.cycle-tab').forEach(t => { | |
| t.classList.remove('active'); | |
| t.setAttribute('aria-selected','false'); | |
| }); | |
| document.querySelectorAll('.cycle-panel').forEach(p => p.style.display = 'none'); | |
| const activeTab = document.querySelector(`.cycle-tab[data-coin="${coin}"]`); | |
| if (activeTab) { activeTab.classList.add('active'); activeTab.setAttribute('aria-selected','true'); } | |
| const panel = document.getElementById(`cycle-panel-${coin}`); | |
| if (panel) panel.style.display = 'block'; | |
| } | |
| function getLivePrice(tickerId, fallback) { | |
| const el = document.getElementById(tickerId); | |
| if (el && el.textContent && el.textContent !== '—') { | |
| const p = parseFloat(el.textContent.replace(/[$,]/g,'')); | |
| if (!isNaN(p) && p > 0) return p; | |
| } | |
| return fallback; | |
| } | |
| function setBarStyle(bar, pct) { | |
| if (!bar) return; | |
| if (pct >= 85) bar.style.background = 'linear-gradient(90deg,var(--red),var(--mint))'; | |
| else if (pct >= 60) bar.style.background = 'linear-gradient(90deg,var(--red),var(--amber))'; | |
| else bar.style.background = 'linear-gradient(90deg,var(--red),var(--amber))'; | |
| bar.style.width = pct + '%'; | |
| } | |
| function initCycleTracker() { | |
| const today = new Date(); | |
| const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; | |
| /* ── BTC ── */ | |
| const BTC_ATH_DATE = new Date('2025-10-06'); | |
| const BTC_ATH_PRICE = 125836; | |
| const BTC_AVG_BEAR = 414; | |
| const btcEstBottom = new Date(BTC_ATH_DATE.getTime() + BTC_AVG_BEAR * 86400000); | |
| const btcDaysIn = Math.floor((today - BTC_ATH_DATE) / 86400000); | |
| const btcDaysLeft = Math.max(0, Math.floor((btcEstBottom - today) / 86400000)); | |
| const btcPct = Math.min(100, Math.round((btcDaysIn / BTC_AVG_BEAR) * 100)); | |
| const btcPrice = getLivePrice('t-btc', 59000); | |
| const btcDrop = Math.round((1 - btcPrice / BTC_ATH_PRICE) * 100); | |
| // If price is already above estimated bottom range, flag it | |
| const btcBottomLow = Math.round(BTC_ATH_PRICE * 0.14); // −86% | |
| const btcBottomHigh = Math.round(BTC_ATH_PRICE * 0.23); // −77% | |
| const btcBottomEl = document.getElementById('btc-bottom-est'); | |
| if (btcBottomEl) { | |
| if (btcPrice < btcBottomHigh) { | |
| btcBottomEl.textContent = `$${Math.round(btcBottomLow/1000)}–${Math.round(btcBottomHigh/1000)}K`; | |
| } else { | |
| btcBottomEl.textContent = 'Above range'; | |
| btcBottomEl.style.color = 'var(--profit)'; | |
| btcBottomEl.closest('.cycle-stat').querySelector('.cycle-stat-sub').textContent = 'Price already above historical bottom estimate'; | |
| } | |
| } | |
| set('btc-days-in', btcDaysIn); | |
| set('btc-drop', `−${btcDrop}%`); | |
| set('btc-pct', `${btcPct}% of avg`); | |
| set('btc-now-days', `${btcDaysIn} days in`); | |
| set('btc-now-drop', `−${btcDrop}% so far`); | |
| const btcCd = document.getElementById('btc-countdown'); | |
| if (btcCd) btcCd.textContent = btcDaysLeft <= 0 ? 'Past avg' : `~${btcDaysLeft} days`; | |
| setBarStyle(document.getElementById('btc-bar'), btcPct); | |
| /* ── ETH ── */ | |
| const ETH_ATH_DATE = new Date('2025-08-25'); | |
| const ETH_ATH_PRICE = 4953; | |
| const ETH_AVG_BEAR = 365; | |
| const ethEstBottom = new Date(ETH_ATH_DATE.getTime() + ETH_AVG_BEAR * 86400000); | |
| // Use floor of ms diff — consistent with BTC calculation | |
| const ethDaysIn = Math.floor((today - ETH_ATH_DATE) / 86400000); | |
| const ethDaysLeft = Math.max(0, Math.floor((ethEstBottom - today) / 86400000)); | |
| const ethPct = Math.min(100, Math.round((ethDaysIn / ETH_AVG_BEAR) * 100)); | |
| const ethPrice = getLivePrice('t-eth', 1600); | |
| const ethDrop = Math.round((1 - ethPrice / ETH_ATH_PRICE) * 100); | |
| set('eth-days-in', ethDaysIn); | |
| set('eth-drop', `−${ethDrop}%`); | |
| set('eth-pct', `${ethPct}% of avg`); | |
| set('eth-now-days', `${ethDaysIn} days in`); | |
| set('eth-now-drop', `−${ethDrop}% so far`); | |
| const ethCd = document.getElementById('eth-countdown'); | |
| if (ethCd) ethCd.textContent = ethDaysLeft <= 0 ? 'Past avg' : `~${ethDaysLeft} days`; | |
| setBarStyle(document.getElementById('eth-bar'), ethPct); | |
| /* ── SOL ── */ | |
| const SOL_ATH_DATE = new Date('2025-01-19'); | |
| const SOL_AVG_BEAR = 418; // only 1 cycle, FTX-distorted — used as reference only | |
| const solDaysIn = Math.floor((today - SOL_ATH_DATE) / 86400000); | |
| const solPct = Math.min(100, Math.round((solDaysIn / SOL_AVG_BEAR) * 100)); | |
| set('sol-days-in', solDaysIn); | |
| set('sol-pct', `${solPct}% of prior`); | |
| set('sol-now-days', `${solDaysIn} days in`); | |
| const solCd = document.getElementById('sol-countdown'); | |
| if (solCd) solCd.textContent = `${solDaysIn} days`; | |
| setBarStyle(document.getElementById('sol-bar'), solPct); | |
| } | |
| /* ═══ Init ═══ */ | |
| checkStatus(); | |
| checkFearGreed(); | |
| loadSignals(); | |
| loadNews(); | |
| updateTicker(); | |
| fetchLiveAccuracy(); // load live accuracy on startup | |
| setTimeout(initCycleTracker, 2000); // init after ticker has had time to load BTC price | |
| // Store interval IDs so we can pause/resume on tab visibility | |
| const _intervals = { | |
| status: setInterval(checkStatus, 30000), | |
| fearGreed: setInterval(checkFearGreed, 3600000), | |
| ticker: setInterval(updateTicker, 8000), | |
| accuracy: setInterval(fetchLiveAccuracy, 300000), | |
| news: setInterval(loadNews, 600000), // refresh news every 10 min | |
| }; | |
| // Pause all background intervals when tab is hidden — saves CPU/battery | |
| document.addEventListener('visibilitychange', () => { | |
| if (document.hidden) { | |
| Object.values(_intervals).forEach(id => clearInterval(id)); | |
| stopLiveTicker(); | |
| clearInterval(countdownTimer); // pause auto-refresh countdown too | |
| } else { | |
| // Resume when tab becomes visible again | |
| _intervals.status = setInterval(checkStatus, 30000); | |
| _intervals.fearGreed = setInterval(checkFearGreed, 3600000); | |
| _intervals.ticker = setInterval(updateTicker, 8000); | |
| _intervals.accuracy = setInterval(fetchLiveAccuracy, 300000); | |
| _intervals.news = setInterval(loadNews, 600000); | |
| // Resume the auto-refresh countdown where it left off (armCountdownInterval | |
| // clears before setting, so this can never create a doubled interval) | |
| if (getAutoInterval() && autoCountdown > 0) armCountdownInterval(); | |
| // Immediately refresh key data | |
| checkStatus(); | |
| updateTicker(); | |
| startLiveTicker(); | |
| } | |
| }); | |
| if(getAutoInterval()) startCountdown(); | |
| /* ═══ Obsidian Glass additions — purely additive (theme, glare, reveals) ═══ */ | |
| (function(){ | |
| /* ── Theme toggle: localStorage 'tc_theme', default dark ── */ | |
| const root = document.documentElement, THEME_KEY = 'tc_theme'; | |
| // Theme already restored synchronously in <head> — prevents flash-of-wrong-theme on load | |
| function applyTheme(next){ | |
| root.setAttribute('data-theme', next); | |
| try { localStorage.setItem(THEME_KEY, next); } catch(e) {} | |
| } | |
| const tbtn = document.getElementById('theme-toggle'); | |
| if (tbtn) tbtn.addEventListener('click', function(e){ | |
| const next = root.getAttribute('data-theme') === 'dark' ? 'light' : 'dark'; | |
| if (document.startViewTransition) { | |
| const x = e.clientX || window.innerWidth - 60, y = e.clientY || 30; | |
| const r = Math.hypot(Math.max(x, window.innerWidth - x), Math.max(y, window.innerHeight - y)); | |
| const vt = document.startViewTransition(() => applyTheme(next)); | |
| vt.ready.then(() => { | |
| document.documentElement.animate( | |
| { clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${r}px at ${x}px ${y}px)`] }, | |
| { duration: 480, easing: 'cubic-bezier(.22,1,.36,1)', pseudoElement: '::view-transition-new(root)' } | |
| ); | |
| }).catch(function(){}); | |
| } else { | |
| applyTheme(next); | |
| } | |
| }); | |
| /* ── Cursor glare on large glass surfaces (.glass-lg), rAF-throttled ── */ | |
| let glareRaf = 0; | |
| document.addEventListener('pointermove', function(e){ | |
| if (glareRaf) return; | |
| const t = e.target, cx = e.clientX, cy = e.clientY; | |
| glareRaf = requestAnimationFrame(function(){ | |
| glareRaf = 0; | |
| if (!t || !t.closest) return; | |
| const g = t.closest('.glass-lg'); | |
| if (!g || g.offsetHeight < 180) return; // skip short surfaces | |
| const rc = g.getBoundingClientRect(); | |
| g.style.setProperty('--mx', ((cx - rc.left) / rc.width * 100) + '%'); | |
| g.style.setProperty('--my', ((cy - rc.top) / rc.height * 100) + '%'); | |
| }); | |
| }, { passive: true }); | |
| /* ── Scroll reveals: IntersectionObserver adds .in to .reveal ── */ | |
| if ('IntersectionObserver' in window) { | |
| const io = new IntersectionObserver(function(entries){ | |
| entries.forEach(function(en){ | |
| if (en.isIntersecting) { en.target.classList.add('in'); io.unobserve(en.target); } | |
| }); | |
| }, { threshold: 0.12 }); | |
| document.querySelectorAll('.reveal').forEach(function(el){ io.observe(el); }); | |
| } else { | |
| document.querySelectorAll('.reveal').forEach(function(el){ el.classList.add('in'); }); | |
| } | |
| })(); | |
| /* ═══ Idle-life scheduler (ADHD-friendly engagement layer) ═══ */ | |
| (function(){ | |
| if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; | |
| let idleTimer=null; | |
| const IDLE_MS = 45000; | |
| function pulseIdleTargets() { | |
| const targets = [ | |
| document.querySelector('.eyebrow-dot'), | |
| document.getElementById('scan-btn'), | |
| document.querySelector('.section-title .dot') | |
| ].filter(Boolean); | |
| targets.forEach(function(t){ | |
| t.classList.add('idle-pulse'); | |
| setTimeout(function(){ t.classList.remove('idle-pulse'); }, 2600); | |
| }); | |
| } | |
| function resetIdle() { | |
| clearTimeout(idleTimer); | |
| idleTimer = setTimeout(pulseIdleTargets, IDLE_MS); | |
| } | |
| ['mousemove','keydown','click','scroll','touchstart'].forEach(function(evt){ | |
| document.addEventListener(evt, resetIdle, { passive:true }); | |
| }); | |
| resetIdle(); | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |