Files
2026-05-31 13:49:36 +08:00

6620 lines
248 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>BTC 5m Up/Down Order Book</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: #0d1117;
color: #e0e0e0;
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
font-size: 13px;
display: flex;
flex-direction: column;
min-height: 100vh;
}
.up { color: #3fb950; }
.down { color: #f85149; }
.gray { color: #666; }
.yellow { color: #f0a500; }
/* ── Top ── */
#header {
background: #161b22;
border-bottom: 1px solid #21262d;
padding: 10px 20px;
display: flex; align-items: center; gap: 24px; flex-wrap: wrap;
}
#header .window-info { font-size: 14px; font-weight: 600; }
#header .window-info span { color: #f0a500; }
#remaining { min-width: 48px; }
#remaining.ending { color: #f85149 !important; animation: pulse 0.5s infinite; }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:0.5} }
#recent-results {
display: flex; align-items: center; gap: 5px;
margin-left: auto;
}
.result-dot {
position: relative;
width: 18px; height: 18px; border-radius: 50%;
background: #21262d; cursor: default;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 700;
}
.result-dot.up { background: #2ea043; color: #fff; }
.result-dot.down { background: #d73a3a; color: #fff; }
.result-dot .dot-tip {
display: none; position: absolute;
top: calc(100% + 6px); left: 50%; transform: translateX(-50%);
background: #1c2128; border: 1px solid #30363d;
border-radius: 5px; padding: 4px 8px;
font-size: 11px; color: #ccc; white-space: nowrap; z-index: 100;
}
.result-dot:hover .dot-tip { display: block; }
#conn-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #3fb950; box-shadow: 0 0 6px #3fb950;
transition: background 0.3s;
}
#conn-dot.disconnected { background: #f85149; box-shadow: 0 0 6px #f85149; }
#ws-status {
display: flex; gap: 6px; align-items: center;
}
.mode-toggle {
display: inline-flex; align-items: center; gap: 8px;
margin-left: 8px;
padding: 4px 8px;
border: 1px solid #21262d;
border-radius: 999px;
background: #0d1117;
}
.mode-toggle-label {
font-size: 11px; color: #888; white-space: nowrap;
}
.mode-toggle-btn {
width: 42px; height: 22px;
border: 1px solid #30363d;
border-radius: 999px;
background: #21262d;
position: relative;
cursor: pointer;
transition: background 0.2s ease, border-color 0.2s ease;
}
.mode-toggle-btn::after {
content: '';
position: absolute;
top: 2px; left: 2px;
width: 16px; height: 16px;
border-radius: 50%;
background: #e0e0e0;
transition: transform 0.2s ease;
}
.mode-toggle-btn.on {
background: #0d3b22;
border-color: #2ea043;
}
.mode-toggle-btn.on::after {
transform: translateX(20px);
background: #3fb950;
}
.ws-light {
display: flex; align-items: center; gap: 3px;
font-size: 10px; color: #666;
}
.ws-light .dot {
width: 7px; height: 7px; border-radius: 50%;
background: #f85149; box-shadow: 0 0 4px #f85149;
transition: background 0.3s, box-shadow 0.3s;
}
.ws-light.on .dot { background: #3fb950; box-shadow: 0 0 4px #3fb950; }
/* HTTP latency three-tier colors */
.ws-light.http-fast .dot { background: #3fb950; box-shadow: 0 0 4px #3fb950; }
.ws-light.http-slow .dot { background: #f0a500; box-shadow: 0 0 4px #f0a500; }
.ws-light.http-bad .dot { background: #f85149; box-shadow: 0 0 4px #f85149; }
/* ── BTC price ── */
#price-bar {
background: #0d1117;
padding: 8px 20px;
display: flex; align-items: center; gap: 32px;
border-bottom: 1px solid #21262d;
}
#compact-overview {
display: none;
padding: 12px 20px 0;
}
.compact-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 10px;
}
.compact-card {
background: #161b22;
border: 1px solid #21262d;
border-radius: 8px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
.compact-label {
font-size: 10px;
color: #777;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.compact-main {
font-size: 17px;
font-weight: 700;
color: #e0e0e0;
}
.compact-sub {
font-size: 11px;
color: #888;
line-height: 1.5;
}
.price-item { display: flex; flex-direction: column; gap: 2px; }
.price-label { font-size: 10px; color: #777; text-transform: uppercase; letter-spacing: 0.5px; }
.price-val { font-size: 22px; font-weight: 700; }
#btc-diff { font-size: 20px; font-weight: 700; }
/* ── Account (inside the order panel) ── */
#account-block {
background: #161b22;
border-radius: 8px;
padding: 7px 10px;
display: flex; flex-direction: column; gap: 5px;
}
.acct-item { display: flex; justify-content: space-between; align-items: center; }
.acct-label { font-size: 12px; color: #888; }
.acct-val { font-size: 15px; font-weight: 600; }
.acct-sub { font-size: 11px; color: #666; }
#sync-time { font-size: 11px; color: #666; text-align: right; }
#calib-countdown { margin-left: 8px; color: #f0a500; font-weight: 600; }
.verified-tag { font-size: 11px; padding: 2px 5px; border-radius: 3px; background: #2a1f00; color: #888; }
.verified-tag.ok { background: #1a3a1f; color: #3fb950; }
.allowance-wrap { position: relative; display: inline-flex; align-items: center; gap: 6px; }
.allowance-status { font-size: 12px; font-weight: 600; cursor: default; }
.allowance-wrap:hover .allowance-tooltip { display: flex; flex-direction: column; gap: 4px; }
.allowance-tooltip {
display: none; position: absolute;
top: calc(100% + 6px); right: 0;
background: #1c2128; border: 1px solid #30363d;
border-radius: 6px; padding: 8px 10px;
white-space: nowrap; z-index: 100;
box-shadow: 0 4px 12px rgba(0,0,0,0.4);
}
.allowance-tooltip div { font-size: 11px; color: #aaa; }
/* ── Main body (order book + order panel) ── */
#main-wrap {
display: flex;
gap: 0;
flex: 1;
}
/* ── Low data mode ── */
body.low-data-mode { background: #0a0e13; }
body.low-data-mode #vol-box,
body.low-data-mode #main-wrap,
body.low-data-mode #compact-overview {
display: none !important;
}
/* Low data mode: observe-bar keeps showing strategy observed values */
body.low-data-mode #observe-bar {
width: min(920px, calc(100% - 24px));
margin: 8px auto 0;
background: #11151b;
border: 1px solid #1a1f28;
border-radius: 14px;
padding: 10px 14px;
font-size: 11px;
}
body.low-data-mode #header,
body.low-data-mode #price-bar,
body.low-data-mode #strat-bar {
width: min(920px, calc(100% - 24px));
margin-left: auto;
margin-right: auto;
}
/* ── header ── */
body.low-data-mode #header {
background: transparent;
border: none;
padding: 14px 4px 4px;
gap: 14px;
}
body.low-data-mode #header .window-info { font-size: 13px; color: #6a7380; }
body.low-data-mode #header .window-info span { color: #8a9bb0; }
body.low-data-mode .result-dot { width: 22px; height: 22px; font-size: 10px; }
/* ── price-bar: two-row layout ── */
body.low-data-mode #price-bar {
background: #11151b;
border: 1px solid #1a1f28;
border-radius: 14px;
padding: 0;
display: flex;
flex-direction: column;
margin-top: 4px;
}
/* First row: probability + countdown (large focal text) */
body.low-data-mode #price-bar .price-item:nth-child(1),
body.low-data-mode #price-bar .price-item:nth-child(2) {
display: none;
}
body.low-data-mode #price-bar .price-item:nth-child(3) { display: none; }
body.low-data-mode #price-bar .price-item:nth-child(4) { display: none; }
/* Time remaining occupies the top by itself */
body.low-data-mode #price-bar .price-item:last-of-type {
display: none;
}
/* ::before pseudo-element can't display dynamically; use a new dedicated focus area instead */
body.low-data-mode #price-bar {
display: block;
padding: 0;
}
body.low-data-mode #price-bar .price-item {
display: none;
}
body.low-data-mode #low-data-hero {
display: flex !important;
}
body.low-data-mode #low-data-detail,
body.low-data-mode #low-data-account {
display: flex !important;
}
/* ── Strategy control bar ── */
body.low-data-mode #strat-bar {
margin-top: 8px;
background: #11151b;
border: 1px solid #1a1f28;
border-radius: 14px;
padding: 12px 16px;
gap: 8px 14px;
}
body.low-data-mode #strat-bar #strat-state-text { font-size: 13px; }
body.low-data-mode #strat-bar #strat-scan-info { font-size: 12px; color: #6a7380; }
body.low-data-mode #strat-bar .strat-right {
width: 100%;
margin-left: 0;
align-items: stretch !important;
padding-top: 8px;
border-top: 1px solid #1a1f28;
gap: 8px !important;
}
body.low-data-mode #strat-bar .strat-right > div {
flex-wrap: wrap;
justify-content: flex-start !important;
}
body.low-data-mode #strat-bar .strat-divider { display: none; }
body.low-data-mode #claim-bar { align-items: flex-start !important; }
body.low-data-mode #claim-status { max-width: none !important; text-align: left !important; }
/* Strategy category row in low data mode: label + switch, compact */
body.low-data-mode .strat-row {
padding: 2px 0;
border-bottom: 1px solid #1a1f28;
}
body.low-data-mode .strat-row:last-child { border-bottom: none; }
/* ── Responsive ── */
@media (max-width: 680px) {
body.low-data-mode #header,
body.low-data-mode #price-bar,
body.low-data-mode #strat-bar {
width: calc(100% - 16px);
}
body.low-data-mode #low-data-hero {
flex-direction: column;
align-items: center;
gap: 4px;
padding: 16px 12px 8px;
}
body.low-data-mode #low-data-hero .ldh-timer {
font-size: 20px;
}
body.low-data-mode #low-data-detail {
flex-wrap: wrap;
gap: 6px 16px;
padding: 8px 12px 12px;
}
body.low-data-mode #strat-bar .strat-right {
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
body.low-data-mode #recent-results { margin-left: 0; order: 4; }
body.low-data-mode #ws-status { flex-wrap: wrap; }
body.low-data-mode .mode-toggle { margin-left: 0; }
}
/* ── Order book ── */
#book {
flex: 1;
display: flex; flex-direction: column;
min-width: 0;
padding: 12px 20px;
}
.book-header {
display: grid; grid-template-columns: 1fr 1fr 1fr;
padding: 4px 8px;
font-size: 10px; color: #666;
text-transform: uppercase; letter-spacing: 0.5px;
}
.book-header .col-r { text-align: right; }
.book-header .col-c { text-align: center; }
.order-row {
display: grid; grid-template-columns: 1fr 1fr 1fr;
padding: 3px 8px; border-radius: 3px;
position: relative; overflow: hidden;
}
.order-row .depth-bar {
position: absolute; top: 0; bottom: 0;
opacity: 0.12; pointer-events: none;
}
.order-row.ask .depth-bar { background: #f85149; right: 0; }
.order-row.bid .depth-bar { background: #3fb950; left: 0; }
.order-row .up-price { font-weight: 600; }
.order-row .qty { text-align: center; color: #888; }
.order-row .dn-price { text-align: right; font-weight: 600; }
#spread-bar {
display: flex; align-items: center; gap: 12px;
padding: 6px 8px;
border-top: 1px solid #21262d; border-bottom: 1px solid #21262d;
margin: 2px 0; font-size: 11px; color: #777;
}
#spread-val { color: #888; font-weight: 600; }
#momentum-bar {
display: flex; gap: 6px; padding: 4px 8px;
border-bottom: 1px solid #21262d;
font-size: 11px; color: #777;
}
.mom-group { display: flex; align-items: center; gap: 4px; flex: 1; }
.mom-label { color: #555; white-space: nowrap; }
.mom-item { display: flex; align-items: center; gap: 3px; white-space: nowrap; }
.mom-name { color: #555; }
.mom-val { font-weight: 600; min-width: 48px; text-align: right; }
.mom-val.up { color: #3fb950; }
.mom-val.down { color: #f85149; }
.mom-val.flat { color: #555; }
#prob-bar {
padding: 8px 0; display: flex; align-items: center;
gap: 20px; font-size: 12px; margin-top: 8px;
}
.prob-item { display: flex; align-items: center; gap: 8px; }
.prob-label { color: #777; }
.prob-val { font-size: 20px; font-weight: 700; }
#prob-track {
flex: 1; height: 6px; background: #21262d;
border-radius: 3px; overflow: hidden;
}
#prob-fill-up {
height: 100%;
background: linear-gradient(90deg, #3fb950, #f0a500);
border-radius: 3px; transition: width 0.3s ease;
}
/* ── Polymarket real PnL ── */
#pm-pnl {
background: #0d1117;
border-top: 1px solid #21262d;
padding: 8px 16px 10px;
}
#pm-pnl .hist-title {
font-size: 11px; color: #555; text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 6px;
display: flex; justify-content: space-between; align-items: center;
}
#pm-pnl .pm-total {
font-size: 11px; color: #888; font-weight: normal; text-transform: none; letter-spacing: normal;
}
#pm-pnl .pm-total .val-pos { color: #3fb950; font-weight: 700; }
#pm-pnl .pm-total .val-neg { color: #f85149; font-weight: 700; }
#pm-pnl-refresh {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
}
.pm-refresh-btn {
background: #161b22; color: #8b949e;
border: 1px solid #21262d; border-radius: 4px;
padding: 3px 10px; font-size: 11px; cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.pm-refresh-btn:hover { background: #1f6feb; color: #fff; border-color: #1f6feb; }
.pm-refresh-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.pm-countdown { color: #6e7681; font-size: 11px; }
#pm-pnl-list {
display: flex; flex-direction: column; gap: 3px;
height: 420px; overflow-y: auto;
padding-right: 2px;
}
.pm-item {
display: flex; align-items: center; gap: 8px;
font-size: 11px; color: #888;
padding: 4px 8px; border-radius: 4px;
background: #161b22;
}
/* Header: shares pm-item column widths but with a different style (no background, small text, uppercase letter spacing) */
.pm-header {
background: transparent !important;
color: #6e7681;
font-size: 10px;
letter-spacing: 0.5px;
padding: 3px 8px 4px;
margin-bottom: 2px;
border-bottom: 1px solid #21262d;
border-radius: 0;
}
.pm-header > span {
color: #6e7681 !important;
background: transparent !important;
font-weight: 600 !important;
font-size: 10px !important;
padding: 0 !important;
min-width: auto;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Header column widths align with rows, all columns centered (for readability) */
.pm-header .pm-time { width: 78px; flex-shrink: 0; text-align: center !important; }
.pm-header .pm-kind { width: 44px; flex-shrink: 0; text-align: center !important; }
.pm-header .pm-outcome { width: 64px; flex-shrink: 0; text-align: center !important; }
.pm-header .pm-source { width: 60px; flex-shrink: 0; text-align: center !important; }
.pm-header .pm-detail { width: 105px; flex-shrink: 0; text-align: center !important; }
.pm-header .pm-fee { width: 48px; flex-shrink: 0; text-align: center !important; }
.pm-header .pm-title { flex: 1; min-width: 0; text-align: center !important; }
.pm-header .pm-amount { width: 110px; flex-shrink: 0; text-align: center !important; }
.pm-header .pm-pos-pnl { width: 96px; flex-shrink: 0; text-align: center !important; }
.pm-item .pm-time { color: #555; width: 78px; flex-shrink: 0; font-size: 10px; }
.pm-item .pm-kind { font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 700; width: 44px; flex-shrink: 0; text-align: center; box-sizing: border-box; }
.pm-item .pm-kind.buy { background: #10261a; color: #3fb950; }
.pm-item .pm-kind.sell { background: #2a1712; color: #f85149; }
.pm-item .pm-kind.claim { background: #1f2a0d; color: #f0a500; }
.pm-item .pm-kind.lost { background: #2a1712; color: #8b949e; }
.pm-item .pm-outcome { font-size: 10px; padding: 1px 5px; border-radius: 3px; font-weight: 700; width: 64px; flex-shrink: 0; text-align: center; box-sizing: border-box; }
.pm-item .pm-outcome.up { background: #10261a; color: #3fb950; }
.pm-item .pm-outcome.down { background: #2a1712; color: #f85149; }
.pm-item .pm-source { font-size: 10px; padding: 1px 4px; border-radius: 3px; background: #0d1f33; color: #7cc8ff; width: 60px; flex-shrink: 0; text-align: center; box-sizing: border-box; white-space: nowrap; overflow: hidden; }
.pm-item .pm-source.manual { background: #2a1200; color: #ffb347; }
/* Detail: size x price */
.pm-item .pm-detail {
color: #6e7681; font-size: 10px;
width: 105px; flex-shrink: 0;
}
/* Fee separate column */
.pm-item .pm-fee {
color: #f0a500; font-size: 10px;
width: 48px; flex-shrink: 0; text-align: right;
}
.pm-item .pm-fee .dim { color: #444; }
/* Market title - flexes to fill space */
.pm-item .pm-title {
flex: 1; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
color: #8b949e; font-size: 10px;
}
/* Cash flow (expense/income/claim) */
.pm-item .pm-amount {
width: 110px; flex-shrink: 0; text-align: right;
font-size: 11px;
}
.pm-item .pm-amount .pm-amount-label { color: #6e7681; font-size: 10px; margin-right: 4px; }
.pm-item .pm-amount .pm-amount-val { font-weight: 700; }
.pm-item .pm-amount .pm-amount-val.pos { color: #3fb950; }
.pm-item .pm-amount .pm-amount-val.neg { color: #f85149; }
/* Position PnL */
.pm-item .pm-pos-pnl {
width: 96px; flex-shrink: 0; text-align: right;
font-size: 11px;
}
.pm-item .pm-pos-pnl .pm-pos-pnl-label { color: #6e7681; font-size: 10px; margin-right: 4px; }
.pm-item .pm-pos-pnl .pm-pos-pnl-val { font-weight: 700; }
.pm-item .pm-pos-pnl .pm-pos-pnl-val.pos { color: #3fb950; }
.pm-item .pm-pos-pnl .pm-pos-pnl-val.neg { color: #f85149; }
.pm-item .pm-pos-pnl .pm-pos-pnl-val.dim { color: #444; font-weight: 400; }
/* ── Price curve ── */
#price-chart {
background: #0d1117;
border-bottom: 1px solid #21262d;
margin-bottom: 8px;
}
#price-chart canvas {
width: 100%; height: 480px; display: block;
}
#chart-toggle {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; cursor: pointer;
border-bottom: 1px solid #21262d;
margin-bottom: 4px;
user-select: none;
}
#chart-toggle:hover { background: #161b22; border-radius: 4px; }
#chart-toggle .toggle-label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; flex: 1; }
#chart-toggle .toggle-arrow { font-size: 10px; color: #555; transition: transform 0.2s; }
#chart-toggle.collapsed .toggle-arrow { transform: rotate(-90deg); }
#chart-collapsible { overflow: hidden; transition: max-height 0.25s ease; max-height: 520px; }
#chart-collapsible.collapsed { max-height: 0; }
/* ── Order book collapse ── */
#book-toggle {
display: flex; align-items: center; gap: 8px;
padding: 6px 8px; cursor: pointer;
border-bottom: 1px solid #21262d;
margin-bottom: 4px;
user-select: none;
}
#book-toggle:hover { background: #161b22; border-radius: 4px; }
#book-toggle .toggle-label { font-size: 11px; color: #555; text-transform: uppercase; letter-spacing: 0.5px; flex: 1; }
#book-toggle .toggle-arrow { font-size: 10px; color: #555; transition: transform 0.2s; }
#book-toggle.collapsed .toggle-arrow { transform: rotate(-90deg); }
#book-collapsible { overflow: hidden; transition: max-height 0.25s ease; max-height: 600px; }
#book-collapsible.collapsed { max-height: 0; }
/* ── Order panel ── */
#order-panel {
width: 320px;
border-left: 1px solid #21262d;
padding: 12px 16px;
display: flex; flex-direction: column; gap: 10px;
background: #0d1117;
}
#order-panel .panel-title {
font-size: 12px; color: #888; text-transform: uppercase; letter-spacing: 0.5px;
}
/* Direction/action button group */
.btn-group { display: flex; gap: 8px; }
.tog-btn {
flex: 1; padding: 11px 0;
border: 1px solid #30363d; border-radius: 7px;
background: #161b22; color: #666;
cursor: pointer; font-size: 15px; font-weight: 600;
transition: all 0.15s;
}
.tog-btn:hover { border-color: #555; color: #aaa; }
.dir-pct { font-size: 15px; font-weight: 700; }
.tog-btn.active-up { border-color: #3fb950; background: #1a3a1f; color: #3fb950; }
.tog-btn.active-down { border-color: #f85149; background: #3a1a1f; color: #f85149; }
.tog-btn.active { border-color: #58a6ff; background: #1a2a4a; color: #58a6ff; }
/* Buy/sell buttons: filled-block style */
.side-btn {
flex: 1; padding: 8px 0;
border: none; border-radius: 7px;
background: #21262d; color: #999;
cursor: pointer; font-size: 15px; font-weight: 700;
transition: all 0.15s;
}
.side-btn:hover { background: #2d333b; color: #ccc; }
.side-btn.active-buy { background: #1a4a7a; color: #7cc8ff; }
.side-btn.active-sell { background: #5a2800; color: #ffb347; }
/* Input area */
.input-row {
display: flex; align-items: stretch; gap: 0;
border: 1px solid #30363d; border-radius: 7px; overflow: hidden;
}
.input-row:focus-within { border-color: #4e9eff; }
.input-row input {
flex: 1; background: #161b22; border: none;
color: #e0e0e0; padding: 7px 8px 5px;
font-size: 16px; font-family: inherit; font-weight: 600;
outline: none; text-align: center; min-width: 0;
}
/* Hide native number arrows */
.input-row input::-webkit-outer-spin-button,
.input-row input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.input-row input[type=number] { -moz-appearance: textfield; }
.step-btn {
background: #1c2128; border: none;
color: #666; width: 38px;
font-size: 18px; font-weight: 300;
cursor: pointer; transition: all 0.12s;
display: flex; align-items: center; justify-content: center;
}
.step-btn:hover { background: #21262d; color: #e0e0e0; }
.step-btn:active { background: #30363d; }
.input-unit { font-size: 12px; color: #555; min-width: 32px; }
.max-btn {
background: #21262d; border: none; border-left: 1px solid #30363d;
color: #888; padding: 0 10px;
cursor: pointer; font-size: 11px; white-space: nowrap;
}
.max-btn:hover { color: #f0a500; }
.quick-amt-btn {
flex: 1; padding: 3px 0; font-size: 11px;
background: #161b22; color: #8b949e;
border: 1px solid #30363d; border-radius: 4px;
cursor: pointer; transition: all 0.12s;
}
.quick-amt-btn:hover { background: #21262d; color: #f0a500; border-color: #f0a500; }
.quick-amt-btn.active { background: #1a2332; color: #f0a500; border-color: #f0a500; }
/* Estimate */
#est-row {
font-size: 12px; color: #888;
padding: 6px 10px; background: #161b22;
border-radius: 7px; line-height: 1.6;
}
#est-row span { color: #bbb; }
/* Confirm button */
#confirm-btn, #limit-confirm-btn {
padding: 14px; border-radius: 8px;
border: none; font-size: 16px; font-weight: 700;
cursor: pointer; transition: all 0.15s;
background: #21262d; color: #888;
}
#confirm-btn.ready-buy, #limit-confirm-btn.ready-buy { background: #1a4a7a; color: #7cc8ff; }
#confirm-btn.ready-sell, #limit-confirm-btn.ready-sell { background: #5a2800; color: #ffb347; }
#confirm-btn:disabled, #limit-confirm-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Close-all button: red-brown warning style, shorter to distinguish from the main button */
#clear-all-btn {
margin-top: 6px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid #5a2a1a;
background: #2a1410;
color: #f85149;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
width: 100%;
}
#clear-all-btn:hover:not(:disabled) { background: #3a1a14; border-color: #8a3a2a; }
#clear-all-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* Result message */
#order-result {
font-size: 13px; padding: 8px 10px; border-radius: 6px;
display: none;
}
#order-result.ok { background: #1a3a1f; color: #3fb950; display: block; }
#order-result.fail { background: #3a1a1f; color: #f85149; display: block; }
/* ── Strategy bar ── */
#strat-bar {
background: #161b22;
border-bottom: 1px solid #21262d;
padding: 8px 20px;
display: flex; align-items: flex-start; gap: 20px; flex-wrap: wrap;
font-size: 12px;
}
.strat-right { margin-left: auto; display: flex; align-items: center; gap: 16px; }
.strat-divider { width: 1px; height: 20px; background: #30363d; }
.tg-help-trigger:hover .tg-help-tip,
.tg-help-tip:hover { display: block !important; }
/* Connect the trigger and tooltip with a transparent strip, to avoid gaps along the mouse path closing the tooltip */
.tg-help-trigger::after {
content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 8px;
}
.tg-help-trigger { position: relative; }
.order-latency-trigger:hover .order-latency-tip,
.order-latency-tip:hover { display: block !important; }
.strat-group { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; }
.strat-main-row { display: flex; align-items: center; gap: 6px; }
.strat-summary-row { display: flex; align-items: center; padding-left: 22px; }
.strat-group label { display: flex; align-items: center; gap: 6px; color: #888; cursor: pointer; white-space: nowrap; }
.strat-group input[type=checkbox] { display: none; }
.strat-group input[type=checkbox] + .strat-tip::before {
content: '○';
font-size: 13px; color: #444; margin-right: 4px;
transition: color 0.2s;
}
.strat-group input[type=checkbox]:checked + .strat-tip::before {
content: '◉';
color: #f0a500;
}
.strat-group label:has(input:checked) { color: #f0a500; }
.strat-amt {
width: 40px; background: #0d1117; border: 1px solid #333;
color: #ccc; font-size: 11px; padding: 1px 3px; border-radius: 3px; text-align: center;
}
/* Hide the up/down arrows on strategy-bar number inputs */
.strat-amt::-webkit-inner-spin-button,
.strat-amt::-webkit-outer-spin-button,
#max-round-entries::-webkit-inner-spin-button,
#max-round-entries::-webkit-outer-spin-button,
#strat-slippage::-webkit-inner-spin-button,
#strat-slippage::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.strat-amt, #max-round-entries, #strat-slippage { -moz-appearance: textfield; }
/* ── Param summary / expand button / reset button / param popup ── */
/* Summary = sub-row small text: labels faint gray, numbers gold-highlighted, separators dark gray */
.strat-summary {
font-size: 11px;
letter-spacing: 0.02em;
user-select: none;
line-height: 1.5;
}
.strat-summary .sum-lbl { color: #8b96a8; margin-right: 2px; }
.strat-summary .sum-val { color: #f0a500; font-weight: 600; }
.strat-summary .sum-sep { color: #4a5260; margin: 0 5px; }
/* Popup wrapper: position:relative so .strat-popup positions relative to it */
.strat-popup-wrap {
position: relative;
display: inline-flex;
align-items: center;
margin-left: 4px;
}
/* When the popup opens, raise stacking level by level:
* - Within the same .strat-row, L1/L2's .strat-group by default has the later one cover the earlier per DOM order,
* raise the whole z-index of the group whose popup is open
* - .strat-popup-wrap raises itself too, to avoid being covered by other elements in the same group
*/
.strat-row:has(.strat-popup.open) {
position: relative;
z-index: 100021;
}
.strat-group:has(.strat-popup.open) {
position: relative;
z-index: 100022;
}
.strat-popup-wrap:has(.strat-popup.open) {
z-index: 100023;
}
.strat-toggle {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 8px;
background: rgba(244, 183, 64, 0.06);
border: 1px solid rgba(244, 183, 64, 0.20);
color: #d29922;
font-size: 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
font-family: inherit;
}
.strat-toggle:hover {
background: rgba(244, 183, 64, 0.14);
border-color: rgba(244, 183, 64, 0.45);
}
/* Popup: hidden by default; .open shows it; absolutely positioned below the button */
.strat-popup {
display: none;
position: absolute;
top: calc(100% + 6px);
left: 0;
z-index: 100020;
min-width: 240px;
/* Fully opaque background covering the layer below (to avoid backdrop-filter / blend-mode bleed-through) */
background-color: #1c2128;
background-image: none;
isolation: isolate;
border: 1px solid #30363d;
border-radius: 8px;
padding: 10px 12px;
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.55);
/* Prevent hover/click from passing through to lower elements */
pointer-events: auto;
}
.strat-popup.open {
display: block;
}
.strat-popup-title {
font-size: 11px;
font-weight: 600;
color: #f0a500;
margin-bottom: 8px;
letter-spacing: 0.02em;
}
.strat-popup-body {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.strat-popup-foot {
display: flex;
justify-content: flex-end;
border-top: 1px solid rgba(148, 163, 184, 0.12);
padding-top: 8px;
}
.strat-reset {
display: inline-flex;
align-items: center;
height: 22px;
padding: 0 10px;
background: rgba(148, 163, 184, 0.05);
border: 1px solid rgba(148, 163, 184, 0.18);
color: #8b96a8;
font-size: 10px;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s, border-color 0.15s, color 0.15s;
font-family: inherit;
}
.strat-reset:hover {
background: rgba(248, 81, 73, 0.10);
border-color: rgba(248, 81, 73, 0.40);
color: #f85149;
}
/* ── Strategy param pills: each param [label|input|unit] laid out compactly (used inside the popup) ── */
.strat-pill {
display: inline-flex;
align-items: center;
height: 24px;
padding: 0 4px 0 6px;
background: rgba(255, 255, 255, 0.025);
border: 1px solid rgba(148, 163, 184, 0.10);
border-radius: 6px;
cursor: help;
transition: border-color 0.15s ease, background 0.15s ease;
}
.strat-pill:hover {
border-color: rgba(244, 183, 64, 0.35);
background: rgba(244, 183, 64, 0.06);
}
.strat-pill-lbl {
color: #8b96a8;
font-size: 10px;
letter-spacing: 0.02em;
margin-right: 4px;
user-select: none;
}
.strat-pill-input {
width: 38px !important;
height: 20px !important;
padding: 0 2px !important;
border: none !important;
background: transparent !important;
color: #e6edf3 !important;
font-size: 11px !important;
font-weight: 600 !important;
border-radius: 0 !important;
text-align: center;
cursor: text;
}
.strat-pill-input:focus {
outline: 1px solid rgba(244, 183, 64, 0.5);
outline-offset: -1px;
border-radius: 4px !important;
}
.strat-pill-unit {
color: #6a737d;
font-size: 9px;
margin-left: 1px;
user-select: none;
}
/* Visual grouping: add a vertical divider after the 2nd and 5th pills, separating "trigger / position / time window"
* Only applies to strategies with >= 6 params (multi-param strategies like L1); few-param strategies stay clean without grouping */
.strat-popup-body .strat-pill:nth-child(2):not(:last-child),
.strat-popup-body .strat-pill:nth-child(5):not(:last-child) {
margin-right: 6px;
position: relative;
}
.strat-popup-body .strat-pill:nth-child(2):not(:last-child)::after,
.strat-popup-body .strat-pill:nth-child(5):not(:last-child)::after {
content: "";
position: absolute;
right: -4px;
top: 4px;
bottom: 4px;
width: 1px;
background: rgba(148, 163, 184, 0.15);
}
#strat-status { display: flex; align-items: center; gap: 8px; }
#strat-state-text { font-size: 12px; font-weight: 600; color: #f0a500; }
#strat-state-text.idle { color: #555; }
#strat-state-text.scan { color: #888; }
#strat-state-text.active { color: #f0a500; }
#strat-state-text.hold { color: #3fb950; }
#strat-state-text.done { color: #58a6ff; }
#strat-state-text.fail { color: #f85149; }
.strat-tip {
position: relative; display: inline-flex; align-items: center;
}
.strat-tip .tip-box {
display: none; position: absolute; top: 100%; left: 0; margin-top: 6px;
background: #1c2128; border: 1px solid #30363d; border-radius: 6px;
padding: 12px 16px; font-size: 13px; color: #f0f6fc; line-height: 1.85;
font-weight: 500;
white-space: nowrap; z-index: 100;
box-shadow: 0 4px 12px rgba(0,0,0,0.5);
user-select: text; cursor: text;
}
/* Bridge area: eliminate the 6px gap between the label and tip-box, to prevent the mouse passing through from triggering hide */
.strat-tip .tip-box::before {
content: ""; position: absolute; top: -6px; left: 0; right: 0; height: 6px;
}
.strat-tip:hover .tip-box,
.strat-tip .tip-box:hover { display: block; }
/* ── Advanced trading terminal visual rework: only overrides the visual layer, doesn't change existing data bindings ── */
:root {
--app-bg: #05070b;
--app-bg-2: #0a1118;
--panel: rgba(13, 20, 30, 0.78);
--panel-strong: rgba(17, 26, 38, 0.92);
--panel-soft: rgba(255, 255, 255, 0.045);
--line: rgba(148, 163, 184, 0.16);
--line-strong: rgba(148, 163, 184, 0.28);
--text-main: #eef5ff;
--text-sub: #9aa8b8;
--text-dim: #617083;
--green: #25d695;
--red: #ff5b6b;
--amber: #f4b740;
--blue: #59b7ff;
--cyan: #45e2ff;
--radius-lg: 18px;
--radius-xl: 24px;
--shadow-card: 0 18px 60px rgba(0, 0, 0, 0.38), inset 0 1px 0 rgba(255, 255, 255, 0.045);
--shadow-float: 0 24px 90px rgba(0, 0, 0, 0.52);
--font-ui: "Avenir Next", "DIN Alternate", "PingFang SC", "Microsoft YaHei", sans-serif;
--font-num: "JetBrains Mono", "SF Mono", "Fira Code", "Cascadia Code", monospace;
}
html {
background: var(--app-bg);
}
body {
min-width: 0;
color: var(--text-main);
font-family: var(--font-ui);
letter-spacing: 0.01em;
background:
radial-gradient(circle at 12% -10%, rgba(89, 183, 255, 0.22), transparent 28rem),
radial-gradient(circle at 84% 2%, rgba(37, 214, 149, 0.16), transparent 32rem),
linear-gradient(135deg, var(--app-bg) 0%, #07111a 45%, #090d14 100%);
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background-image:
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255, 255, 255, 0.02) 1px, transparent 1px);
background-size: 38px 38px;
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.8), transparent 78%);
}
body::after {
content: "";
position: fixed;
inset: auto 12% 0 12%;
height: 160px;
pointer-events: none;
background: radial-gradient(ellipse at center, rgba(89, 183, 255, 0.12), transparent 70%);
filter: blur(30px);
}
.up { color: var(--green); }
.down { color: var(--red); }
.gray { color: var(--text-dim); }
.yellow { color: var(--amber); }
button,
input {
font-family: inherit;
}
input {
color-scheme: dark;
}
::selection {
background: rgba(89, 183, 255, 0.28);
color: #fff;
}
::-webkit-scrollbar {
width: 9px;
height: 9px;
}
::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.03);
}
::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.24);
border-radius: 999px;
border: 2px solid rgba(5, 7, 11, 0.8);
}
::-webkit-scrollbar-thumb:hover {
background: rgba(148, 163, 184, 0.38);
}
#header {
position: sticky;
top: 12px;
z-index: 50;
margin: 14px 16px 0;
padding: 12px 16px !important;
gap: 12px 16px;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.025)),
rgba(8, 13, 20, 0.78);
box-shadow: var(--shadow-float);
backdrop-filter: blur(22px) saturate(1.25);
}
#header .window-info {
display: inline-flex;
align-items: center;
gap: 8px;
color: var(--text-main);
font-size: 16px;
font-weight: 800;
letter-spacing: 0.02em;
}
#header .window-info::before {
content: "";
width: 9px;
height: 30px;
border-radius: 999px;
background: linear-gradient(180deg, var(--green), var(--blue));
box-shadow: 0 0 22px rgba(37, 214, 149, 0.45);
}
#header .window-info span {
color: var(--amber);
}
#header > a {
padding: 6px 10px;
border: 1px solid rgba(244, 183, 64, 0.24);
border-radius: 999px;
background: rgba(244, 183, 64, 0.08);
color: var(--amber) !important;
}
#app-version {
padding: 2px 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.07);
color: var(--text-dim) !important;
}
#recent-results {
gap: 7px;
}
.result-dot {
width: 24px;
height: 24px;
border: 1px solid var(--line);
background: rgba(255, 255, 255, 0.055);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
}
.result-dot.up {
background: linear-gradient(135deg, rgba(37, 214, 149, 0.94), rgba(13, 129, 89, 0.94));
}
.result-dot.down {
background: linear-gradient(135deg, rgba(255, 91, 107, 0.95), rgba(143, 39, 54, 0.95));
}
.result-dot .dot-tip,
.allowance-tooltip,
.strat-tip .tip-box,
.tg-help-tip,
.order-latency-tip > div {
border-color: var(--line-strong) !important;
background: rgba(8, 13, 20, 0.96) !important;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.54) !important;
backdrop-filter: blur(18px);
}
#conn-dot {
width: 10px;
height: 10px;
background: var(--green);
box-shadow: 0 0 0 5px rgba(37, 214, 149, 0.13), 0 0 22px rgba(37, 214, 149, 0.8);
}
#conn-dot.disconnected {
background: var(--red);
box-shadow: 0 0 0 5px rgba(255, 91, 107, 0.12), 0 0 18px rgba(255, 91, 107, 0.7);
}
#ws-status {
gap: 7px;
flex-wrap: wrap;
}
.ws-light {
min-height: 26px;
padding: 5px 9px;
gap: 6px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.045);
color: var(--text-sub);
font-size: 11px;
white-space: nowrap;
}
.ws-light .dot {
width: 7px;
height: 7px;
background: var(--red);
box-shadow: 0 0 10px rgba(255, 91, 107, 0.75);
}
.ws-light.on .dot,
.ws-light.http-fast .dot {
background: var(--green);
box-shadow: 0 0 12px rgba(37, 214, 149, 0.78);
}
.ws-light.http-slow .dot {
background: var(--amber);
box-shadow: 0 0 12px rgba(244, 183, 64, 0.72);
}
.ws-light.http-bad .dot {
background: var(--red);
box-shadow: 0 0 12px rgba(255, 91, 107, 0.72);
}
#kline-status {
padding: 5px 9px;
border: 1px solid rgba(148, 163, 184, 0.1);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: var(--text-dim) !important;
}
.mode-toggle {
margin-left: 0 !important;
padding: 5px 6px 5px 11px;
border-color: var(--line);
background: rgba(255, 255, 255, 0.045);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06);
}
.mode-toggle-label {
color: var(--text-sub);
}
.mode-toggle-btn {
border-color: rgba(148, 163, 184, 0.22);
background: rgba(255, 255, 255, 0.08);
}
.mode-toggle-btn::after {
background: #d7e4f2;
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.45);
}
.mode-toggle-btn.on {
border-color: rgba(37, 214, 149, 0.65);
background: rgba(37, 214, 149, 0.18);
}
.mode-toggle-btn.on::after {
background: var(--green);
}
#price-bar {
margin: 14px 16px 0;
padding: 0;
display: grid;
grid-template-columns: repeat(6, minmax(140px, 1fr));
gap: 12px;
border: none;
background: transparent;
}
.price-item {
position: relative;
min-height: 94px;
padding: 16px 17px;
justify-content: space-between;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.085), rgba(255, 255, 255, 0.02)),
var(--panel);
box-shadow: var(--shadow-card);
}
.price-item::after {
content: "";
position: absolute;
inset: auto -18px -34px auto;
width: 94px;
height: 94px;
border-radius: 50%;
background: rgba(89, 183, 255, 0.1);
filter: blur(3px);
}
.price-item:nth-child(3)::after,
.price-item:nth-child(4)::after {
background: rgba(244, 183, 64, 0.12);
}
.price-item:last-of-type::after {
background: rgba(37, 214, 149, 0.12);
}
.price-label,
.compact-label {
position: relative;
z-index: 1;
color: var(--text-dim);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.13em;
}
.price-val,
#btc-diff,
#btc-diff-bn,
#btc-vol {
position: relative;
z-index: 1;
color: var(--text-main);
font-family: var(--font-num);
font-size: 25px;
font-weight: 800;
letter-spacing: -0.04em;
}
#btc-diff,
#btc-diff-bn {
font-size: 24px;
}
#remaining {
color: var(--amber);
text-shadow: 0 0 24px rgba(244, 183, 64, 0.35);
}
#remaining.ending {
color: var(--red) !important;
text-shadow: 0 0 24px rgba(255, 91, 107, 0.55);
}
#vol-threshold,
.strat-amt,
#max-round-entries,
#strat-slippage,
#tg-interval,
#cond-tp-pct,
#cond-sl-pct,
#cond-sl-slippage,
#slippage-input {
border-color: rgba(148, 163, 184, 0.22) !important;
background: rgba(255, 255, 255, 0.05) !important;
color: var(--text-main) !important;
}
#compact-overview {
padding: 12px 16px 0;
}
.compact-grid {
gap: 12px;
}
.compact-card {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: var(--panel);
box-shadow: var(--shadow-card);
}
.compact-main {
color: var(--text-main);
font-family: var(--font-num);
}
.compact-sub {
color: var(--text-sub);
}
#strat-bar,
#observe-bar {
margin: 12px 16px 0;
border: 1px solid var(--line) !important;
border-radius: var(--radius-lg);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.018)),
rgba(9, 15, 24, 0.78) !important;
box-shadow: var(--shadow-card);
backdrop-filter: blur(14px);
}
#strat-bar {
padding: 14px 16px;
}
#observe-bar {
padding: 10px 14px !important;
color: var(--text-sub) !important;
}
.strat-row {
align-items: center;
padding: 4px 0;
}
.strat-group label {
padding: 6px 9px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.035);
color: var(--text-sub);
transition: transform 0.16s ease, border-color 0.16s ease, background 0.16s ease;
}
.strat-group label:hover {
transform: translateY(-1px);
border-color: rgba(244, 183, 64, 0.28);
background: rgba(244, 183, 64, 0.055);
}
.strat-group input[type=checkbox] + .strat-tip::before {
color: rgba(148, 163, 184, 0.42);
}
.strat-group input[type=checkbox]:checked + .strat-tip::before,
.strat-group label:has(input:checked) {
color: var(--amber);
}
.strat-amt {
height: 26px;
border-radius: 9px;
}
#strat-status {
min-height: 28px;
padding: 5px 10px;
border: 1px solid rgba(244, 183, 64, 0.18);
border-radius: 999px;
background: rgba(244, 183, 64, 0.07);
}
#strat-state-text {
font-weight: 800;
letter-spacing: 0.02em;
}
#strat-state-text.idle { color: var(--text-dim); }
#strat-state-text.scan { color: var(--text-sub); }
#strat-state-text.active { color: var(--amber); }
#strat-state-text.hold { color: var(--green); }
#strat-state-text.done { color: var(--blue); }
#strat-state-text.fail { color: var(--red); }
.strat-divider {
background: var(--line-strong);
}
#backtest-btn,
#tg-config-body button,
#cond-cancel-all-btn {
border-color: rgba(148, 163, 184, 0.22) !important;
background: rgba(255, 255, 255, 0.06) !important;
color: var(--text-sub) !important;
}
#tg-config-body {
width: min(460px, calc(100vw - 32px)) !important;
border-color: var(--line-strong) !important;
background: rgba(8, 13, 20, 0.96) !important;
box-shadow: var(--shadow-float) !important;
backdrop-filter: blur(18px);
}
#tg-bot-token,
#tg-chat-id {
border-color: rgba(148, 163, 184, 0.22) !important;
background: rgba(255, 255, 255, 0.05) !important;
color: var(--text-main) !important;
}
#main-wrap {
gap: 14px;
align-items: flex-start;
padding: 14px 16px 0;
}
#book {
padding: 0;
overflow: hidden;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.065), rgba(255, 255, 255, 0.018)),
rgba(8, 13, 20, 0.76);
box-shadow: var(--shadow-card);
backdrop-filter: blur(14px);
}
#chart-toggle,
#book-toggle {
margin: 0;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
background: rgba(255, 255, 255, 0.018);
}
#chart-toggle:hover,
#book-toggle:hover {
border-radius: 0;
background: rgba(89, 183, 255, 0.07);
}
#chart-toggle .toggle-label,
#book-toggle .toggle-label {
color: var(--text-sub);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.16em;
}
#chart-toggle .toggle-label::before,
#book-toggle .toggle-label::before {
content: "";
display: inline-block;
width: 8px;
height: 8px;
margin-right: 8px;
border-radius: 50%;
background: var(--blue);
box-shadow: 0 0 12px rgba(89, 183, 255, 0.7);
}
#book-toggle .toggle-label::before {
background: var(--amber);
box-shadow: 0 0 12px rgba(244, 183, 64, 0.68);
}
#chart-toggle .toggle-arrow,
#book-toggle .toggle-arrow {
color: var(--text-sub);
}
#chart-collapsible {
max-height: 600px;
}
#price-chart {
margin: 12px;
overflow: hidden;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 20px;
background:
radial-gradient(circle at 70% 18%, rgba(89, 183, 255, 0.12), transparent 34%),
linear-gradient(180deg, rgba(7, 12, 20, 0.9), rgba(5, 8, 13, 0.9));
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
}
#price-chart canvas {
height: min(52vh, 540px);
min-height: 390px;
}
#book-collapsible {
max-height: 700px;
padding: 0 12px 14px;
}
#book-collapsible.collapsed {
padding-top: 0;
padding-bottom: 0;
}
#momentum-bar,
#spread-bar {
border-color: var(--line);
color: var(--text-sub);
}
#momentum-bar {
margin-top: 10px;
padding: 9px 10px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
}
.mom-label,
.mom-name {
color: var(--text-dim);
}
.mom-val {
font-family: var(--font-num);
}
.mom-val.up { color: var(--green); }
.mom-val.down { color: var(--red); }
.mom-val.flat { color: var(--text-dim); }
.book-header {
margin-top: 8px;
padding: 9px 10px;
color: var(--text-dim);
font-weight: 900;
}
.order-row {
min-height: 26px;
align-items: center;
margin: 2px 0;
padding: 5px 10px;
border: 1px solid transparent;
border-radius: 10px;
background: rgba(255, 255, 255, 0.025);
font-family: var(--font-num);
}
.order-row:hover {
border-color: rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.05);
}
.order-row.ask .depth-bar {
background: linear-gradient(90deg, transparent, rgba(255, 91, 107, 0.55));
opacity: 0.2;
}
.order-row.bid .depth-bar {
background: linear-gradient(90deg, rgba(37, 214, 149, 0.55), transparent);
opacity: 0.2;
}
.order-row .qty {
color: var(--text-sub);
}
#spread-bar {
margin: 8px 0;
padding: 10px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
}
#prob-bar {
padding: 12px 2px 0;
}
.prob-label {
color: var(--text-sub);
}
.prob-val {
font-family: var(--font-num);
font-size: 24px;
}
#prob-track {
height: 10px;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.06);
}
#prob-fill-up {
background: linear-gradient(90deg, var(--green), var(--cyan), var(--amber));
box-shadow: 0 0 18px rgba(69, 226, 255, 0.25);
}
#order-panel {
position: sticky;
top: 94px;
width: 360px;
padding: 16px;
gap: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background:
radial-gradient(circle at 80% 0%, rgba(244, 183, 64, 0.12), transparent 28%),
linear-gradient(145deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.02)),
rgba(9, 14, 22, 0.86);
box-shadow: var(--shadow-card);
backdrop-filter: blur(16px);
}
#order-panel .panel-title {
color: var(--text-main);
font-size: 15px;
font-weight: 900;
letter-spacing: 0.14em;
}
#order-panel > div:first-child {
padding-bottom: 10px;
border-bottom: 1px solid var(--line);
}
#account-block {
padding: 12px;
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
}
.acct-item {
gap: 8px;
min-height: 29px;
}
.acct-label,
.acct-sub,
#sync-time {
color: var(--text-dim);
}
.acct-val {
font-family: var(--font-num);
color: var(--text-main);
}
.allowance-status {
color: var(--text-sub);
}
.verified-tag {
border: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 999px;
background: rgba(255, 255, 255, 0.045);
color: var(--text-dim);
}
.verified-tag.ok {
border-color: rgba(37, 214, 149, 0.28);
background: rgba(37, 214, 149, 0.1);
color: var(--green);
}
.btn-group {
gap: 9px;
}
.tog-btn,
.side-btn,
.quick-amt-btn,
.pm-refresh-btn,
.max-btn,
.step-btn,
#clear-all-btn,
#confirm-btn,
#limit-confirm-btn {
border-radius: 13px;
transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease, color 0.15s ease, box-shadow 0.15s ease;
}
.tog-btn {
border-color: rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.055);
color: var(--text-sub);
}
.tog-btn:hover,
.side-btn:hover,
.quick-amt-btn:hover,
.pm-refresh-btn:hover,
.max-btn:hover,
.step-btn:hover {
transform: translateY(-1px);
}
.tog-btn.active {
border-color: rgba(89, 183, 255, 0.55);
background: rgba(89, 183, 255, 0.13);
color: var(--blue);
box-shadow: 0 0 0 3px rgba(89, 183, 255, 0.08);
}
.tog-btn.active-up {
border-color: rgba(37, 214, 149, 0.54);
background: rgba(37, 214, 149, 0.13);
color: var(--green);
}
.tog-btn.active-down {
border-color: rgba(255, 91, 107, 0.54);
background: rgba(255, 91, 107, 0.13);
color: var(--red);
}
.side-btn {
border: 1px solid rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.055);
color: var(--text-sub);
}
.side-btn.active-buy {
border-color: rgba(89, 183, 255, 0.5);
background: linear-gradient(135deg, rgba(89, 183, 255, 0.28), rgba(89, 183, 255, 0.09));
color: #bfe7ff;
}
.side-btn.active-sell {
border-color: rgba(244, 183, 64, 0.5);
background: linear-gradient(135deg, rgba(244, 183, 64, 0.3), rgba(244, 183, 64, 0.08));
color: #ffe0a1;
}
.dir-pct {
font-family: var(--font-num);
}
.input-row {
border-color: rgba(148, 163, 184, 0.18);
border-radius: 14px;
background: rgba(255, 255, 255, 0.04);
}
.input-row:focus-within {
border-color: rgba(89, 183, 255, 0.65);
box-shadow: 0 0 0 3px rgba(89, 183, 255, 0.1);
}
.input-row input {
background: transparent;
color: var(--text-main);
font-family: var(--font-num);
}
.step-btn,
.max-btn {
background: rgba(255, 255, 255, 0.055);
color: var(--text-sub);
}
.quick-amt-btn {
min-height: 28px;
border-color: rgba(148, 163, 184, 0.16);
background: rgba(255, 255, 255, 0.045);
color: var(--text-sub);
}
.quick-amt-btn:hover,
.quick-amt-btn.active {
border-color: rgba(244, 183, 64, 0.5);
background: rgba(244, 183, 64, 0.1);
color: var(--amber);
}
#amount-unit {
color: var(--text-dim) !important;
font-family: var(--font-num);
}
#est-row,
#pos-summary,
#cond-panel,
#slippage-row,
#manual-limit-list {
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 14px;
background: rgba(255, 255, 255, 0.035);
}
#est-row {
padding: 9px 11px;
color: var(--text-sub);
}
#est-row span {
color: var(--text-main);
font-family: var(--font-num);
}
#confirm-btn,
#limit-confirm-btn {
min-height: 50px;
border: 1px solid rgba(148, 163, 184, 0.16);
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.035));
color: var(--text-sub);
letter-spacing: 0.03em;
}
#confirm-btn.ready-buy,
#limit-confirm-btn.ready-buy {
border-color: rgba(37, 214, 149, 0.45);
background: linear-gradient(135deg, rgba(37, 214, 149, 0.95), rgba(18, 129, 91, 0.95));
color: #04130e;
box-shadow: 0 14px 38px rgba(37, 214, 149, 0.22);
}
#confirm-btn.ready-sell,
#limit-confirm-btn.ready-sell {
border-color: rgba(244, 183, 64, 0.48);
background: linear-gradient(135deg, rgba(244, 183, 64, 0.96), rgba(164, 88, 28, 0.95));
color: #170d04;
box-shadow: 0 14px 38px rgba(244, 183, 64, 0.2);
}
#confirm-btn:not(:disabled):hover,
#limit-confirm-btn:not(:disabled):hover,
#clear-all-btn:hover:not(:disabled) {
transform: translateY(-1px);
}
#clear-all-btn {
border-color: rgba(255, 91, 107, 0.3);
background: rgba(255, 91, 107, 0.08);
color: var(--red);
}
#clear-all-btn:hover:not(:disabled) {
border-color: rgba(255, 91, 107, 0.5);
background: rgba(255, 91, 107, 0.13);
}
#order-result.ok {
background: rgba(37, 214, 149, 0.12);
color: var(--green);
}
#order-result.fail {
background: rgba(255, 91, 107, 0.12);
color: var(--red);
}
#cond-panel,
#slippage-row {
padding: 9px 10px;
}
#cond-head {
color: var(--text-sub) !important;
}
#cond-body {
border-left-color: var(--line) !important;
}
#pm-pnl {
margin: 14px 16px 16px;
padding: 14px;
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background:
linear-gradient(145deg, rgba(255, 255, 255, 0.055), rgba(255, 255, 255, 0.018)),
rgba(8, 13, 20, 0.76);
box-shadow: var(--shadow-card);
}
#pm-pnl .hist-title {
margin-bottom: 10px;
color: var(--text-sub);
font-size: 12px;
font-weight: 900;
letter-spacing: 0.14em;
}
#pm-pnl .pm-total {
color: var(--text-sub);
font-family: var(--font-num);
}
#pm-pnl .pm-total .val-pos { color: var(--green); }
#pm-pnl .pm-total .val-neg { color: var(--red); }
.pm-refresh-btn {
padding: 6px 12px;
border-color: rgba(148, 163, 184, 0.18);
background: rgba(255, 255, 255, 0.055);
color: var(--text-sub);
}
.pm-refresh-btn:hover {
border-color: rgba(89, 183, 255, 0.5);
background: rgba(89, 183, 255, 0.12);
color: #d9f1ff;
}
.pm-countdown {
color: var(--text-dim);
}
#pm-pnl-list {
gap: 5px;
height: min(42vh, 420px);
}
.pm-item {
min-width: 900px;
padding: 7px 9px;
border: 1px solid rgba(148, 163, 184, 0.08);
border-radius: 11px;
background: rgba(255, 255, 255, 0.035);
color: var(--text-sub);
font-family: var(--font-num);
}
.pm-item:hover {
border-color: rgba(148, 163, 184, 0.17);
background: rgba(255, 255, 255, 0.055);
}
.pm-header {
border-color: var(--line) !important;
background: transparent !important;
}
.pm-header > span {
color: var(--text-dim) !important;
}
.pm-item .pm-time,
.pm-item .pm-detail,
.pm-item .pm-title,
.pm-item .pm-amount .pm-amount-label,
.pm-item .pm-pos-pnl .pm-pos-pnl-label {
color: var(--text-dim);
}
.pm-item .pm-kind.buy,
.pm-item .pm-outcome.up {
background: rgba(37, 214, 149, 0.12);
color: var(--green);
}
.pm-item .pm-kind.sell,
.pm-item .pm-outcome.down {
background: rgba(255, 91, 107, 0.12);
color: var(--red);
}
.pm-item .pm-kind.claim {
background: rgba(244, 183, 64, 0.13);
color: var(--amber);
}
.pm-item .pm-source {
background: rgba(89, 183, 255, 0.12);
color: var(--blue);
}
.pm-item .pm-source.manual {
background: rgba(244, 183, 64, 0.13);
color: var(--amber);
}
.pm-item .pm-amount .pm-amount-val.pos,
.pm-item .pm-pos-pnl .pm-pos-pnl-val.pos {
color: var(--green);
}
.pm-item .pm-amount .pm-amount-val.neg,
.pm-item .pm-pos-pnl .pm-pos-pnl-val.neg {
color: var(--red);
}
#app-footer {
margin: 0 16px 16px;
border: 1px solid var(--line) !important;
border-radius: 16px;
background: rgba(255, 255, 255, 0.025);
color: var(--text-dim) !important;
}
body.low-data-mode {
background:
radial-gradient(circle at 50% -10%, rgba(89, 183, 255, 0.18), transparent 30rem),
linear-gradient(135deg, var(--app-bg), #071018 58%, #06080c);
}
body.low-data-mode #header,
body.low-data-mode #price-bar,
body.low-data-mode #strat-bar,
body.low-data-mode #observe-bar {
width: min(980px, calc(100% - 32px));
margin-left: auto;
margin-right: auto;
}
body.low-data-mode #header {
padding: 12px 16px !important;
}
body.low-data-mode #price-bar {
display: block;
margin-top: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-xl);
background: var(--panel-strong);
box-shadow: var(--shadow-card);
}
body.low-data-mode #low-data-hero {
padding: 24px 24px 10px !important;
}
body.low-data-mode #low-data-detail {
padding: 10px 24px 18px !important;
border-top-color: var(--line) !important;
color: var(--text-sub) !important;
}
body.low-data-mode #strat-bar,
body.low-data-mode #observe-bar {
margin-top: 12px;
}
@media (max-width: 1280px) {
#price-bar {
grid-template-columns: repeat(3, minmax(160px, 1fr));
}
#main-wrap {
flex-direction: column;
}
#book,
#order-panel {
width: 100%;
}
#order-panel {
position: static;
}
}
@media (max-width: 820px) {
#header {
position: static;
margin: 10px 10px 0;
border-radius: 18px;
}
#recent-results {
margin-left: 0;
order: 3;
}
#ws-status {
order: 5;
width: 100%;
}
.mode-toggle {
order: 4;
}
#price-bar,
#strat-bar,
#observe-bar,
#pm-pnl,
#main-wrap {
margin-left: 10px;
margin-right: 10px;
}
#main-wrap {
padding-left: 10px;
padding-right: 10px;
}
#price-bar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.price-item {
min-height: 86px;
padding: 14px;
}
.price-val,
#btc-diff,
#btc-diff-bn,
#btc-vol {
font-size: 21px;
}
#price-chart canvas {
min-height: 320px;
height: 44vh;
}
#momentum-bar {
flex-direction: column;
}
.mom-group {
width: 100%;
}
#prob-bar {
gap: 12px;
}
.prob-val {
font-size: 20px;
}
#order-panel {
border-radius: 18px;
}
}
@media (max-width: 560px) {
body {
font-size: 12px;
}
#header .window-info {
width: 100%;
font-size: 15px;
}
#price-bar {
grid-template-columns: 1fr;
}
#book-collapsible {
padding: 0 8px 10px;
}
#price-chart {
margin: 8px;
}
#price-chart canvas {
min-height: 280px;
}
#order-panel {
padding: 12px;
}
.btn-group {
gap: 7px;
}
.tog-btn,
.side-btn {
font-size: 13px;
}
#low-data-hero {
flex-direction: column;
align-items: flex-start !important;
}
body.low-data-mode #header,
body.low-data-mode #price-bar,
body.low-data-mode #strat-bar,
body.low-data-mode #observe-bar {
width: calc(100% - 20px);
}
}
</style>
<link rel="stylesheet" href="/terminal.css">
</head>
<body>
<div id="header">
<div class="window-info">
<select id="market-symbol-select" title="Switch asset (cannot switch while holding a position)" style="background:#1a1f28;color:#f0a500;border:1px solid #2a3340;border-radius:3px;padding:1px 4px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit">
<option value="btc">BTC</option>
<option value="eth">ETH</option>
<option value="sol">SOL</option>
</select>
<select id="market-period-select" title="Switch period (cannot switch while holding a position)" style="background:#1a1f28;color:#f0a500;border:1px solid #2a3340;border-radius:3px;padding:1px 4px;font-size:13px;font-weight:600;cursor:pointer;font-family:inherit;margin-left:2px">
<option value="5m">5m</option>
<option value="15m">15m</option>
</select>
<span style="font-size:13px">Up/Down</span>
<span id="window-range"></span>
<span id="app-version" style="font-size:10px;color:#555;margin-left:4px;font-weight:400" title="Version">v-</span>
</div>
<div id="recent-results">
<div class="result-dot" id="rd-0"><span class="dot-tip"></span></div>
<div class="result-dot" id="rd-1"><span class="dot-tip"></span></div>
<div class="result-dot" id="rd-2"><span class="dot-tip"></span></div>
<div class="result-dot" id="rd-3"><span class="dot-tip"></span></div>
</div>
<div id="conn-dot" class="disconnected"></div>
<div id="ws-status">
<div class="ws-light" id="ws-market"><div class="dot"></div>Order book</div>
<div class="ws-light" id="ws-chainlink"><div class="dot"></div>Price</div>
<div class="ws-light" id="ws-user"><div class="dot"></div>Account</div>
<div class="ws-light" id="ws-binance"><div class="dot"></div>Binance</div>
<div class="ws-light" id="ws-coinbase"><div class="dot"></div>Coinbase</div>
<div class="ws-light" id="frontend-latency" title="Frontend <-> backend WebSocket round-trip latency (RTT), probed every 2 seconds"><div class="dot"></div>Frontend latency <span id="frontend-latency-val">-</span></div>
<div class="ws-light" id="http-latency" title="undici (fetch) channel latency: order book / Gamma / TG go through here, pings CLOB /time every 20 seconds"><div class="dot"></div>Undici heartbeat <span id="http-latency-val">-</span></div>
<div class="ws-light" id="axios-latency" title="axios channel latency: place order / query-cancel open orders go through here, pings CLOB /time every 20 seconds"><div class="dot"></div>Axios heartbeat <span id="axios-latency-val">-</span></div>
<!-- Order latency monitor: local postOrder -> UserWS receives MATCHED -->
<div class="ws-light order-latency-trigger" id="order-latency" style="cursor:help;position:relative">
<div class="dot" id="order-latency-dot"></div>
Order latency <span id="order-latency-val">-</span>
<div class="order-latency-tip" id="order-latency-tip" style="display:none;position:absolute;left:0;top:100%;margin-top:-2px;padding-top:8px;z-index:10000;min-width:340px;max-width:400px;font-size:11px;color:#c9d1d9;line-height:1.6;white-space:normal;text-align:left"><div style="background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:10px 12px;box-shadow:0 8px 24px rgba(0,0,0,0.6)">
<div style="color:#f0a500;font-weight:600;margin-bottom:4px">⏱ Order latency</div>
<div style="color:#888;font-size:10px;margin-bottom:8px">Time from local order send -> receiving match acknowledgement</div>
<div>Last: <b id="ol-last" style="color:#e0e0e0">-</b> · Avg: <b id="ol-avg" style="color:#e0e0e0">-</b></div>
<div style="color:#888;font-size:10px;margin-top:2px">Timeouts <span id="ol-timeouts">0</span> · Lost on disconnect <span id="ol-disconnects">0</span></div>
<div style="border-top:1px dashed #21262d;padding-top:6px;margin-top:8px">
<div style="color:#666;font-size:10px;margin-bottom:3px">Recent records</div>
<div id="ol-history" style="font-family:monospace;font-size:10px;color:#888;line-height:1.5"></div>
</div>
</div>
</div>
</div>
<div id="kline-status" style="font-size:10px;color:#666;margin-left:4px" title="Binance candle cache count (1m/5m)">Candles 1m:<span id="kline-1m-count">-</span> 5m:<span id="kline-5m-count">-</span></div>
</div>
<div class="mode-toggle" title="Low data mode keeps only a simplified view and switches to server-side throttled push" style="margin-left:auto">
<span class="mode-toggle-label">Low data</span>
<button id="low-data-toggle" class="mode-toggle-btn" type="button" onclick="toggleLowDataMode()" aria-pressed="false"></button>
</div>
</div>
<div id="price-bar">
<div class="price-item">
<div class="price-label">Price to Beat</div>
<div class="price-val gray" id="price-to-beat"></div>
</div>
<div class="price-item">
<div class="price-label" id="current-price-label">BTC current price</div>
<div class="price-val gray" id="btc-current"></div>
</div>
<div class="price-item">
<div class="price-label">Diff (CL)</div>
<div id="btc-diff" class="gray"></div>
</div>
<div class="price-item">
<div class="price-label">Diff (BN)</div>
<div id="btc-diff-bn" class="gray"></div>
</div>
<div class="price-item" id="vol-box">
<div class="price-label">Volatility (30s) high&gt;<input id="vol-threshold" type="number" value="0.15" step="0.01" min="0"
style="width:44px;background:#0d1117;border:1px solid #333;color:#ccc;font-size:11px;padding:1px 3px;border-radius:3px;text-align:center">%</div>
<div id="btc-vol" class="gray"></div>
</div>
<div class="price-item">
<div class="price-label">Remaining</div>
<div id="remaining" class="price-val yellow">--:--</div>
</div>
<!-- Low data mode: focus area -->
<div id="low-data-hero" style="display:none;align-items:baseline;justify-content:space-between;padding:20px 24px 6px">
<div class="ldh-prob" style="font-size:34px;font-weight:800;letter-spacing:-1px">
<span id="ldh-up" style="color:#e0e0e0">Up -</span>
<span style="color:#2a2a2a;margin:0 10px">/</span>
<span id="ldh-dn" style="color:#e0e0e0">Down -</span>
</div>
<div class="ldh-timer" style="font-size:28px;font-weight:800;letter-spacing:-0.5px;color:#e0e0e0" id="ldh-timer">--:--</div>
</div>
<!-- User info row: account + positions + today's PnL/win rate (prominent) -->
<div id="low-data-account" style="display:none;align-items:center;justify-content:space-between;gap:10px 18px;padding:10px 24px;font-size:16px;font-weight:700;border-top:1px solid #1a1f28;border-bottom:1px solid #1a1f28;flex-wrap:wrap">
<span style="color:#9aa5b8;font-weight:500;font-size:14px" id="ldh-account"></span>
<div style="display:flex;align-items:center;gap:14px 18px;flex-wrap:wrap">
<span style="color:#888;font-weight:500">USDC<b id="ldh-usdc" style="color:#58a6ff;margin-left:4px"></b></span>
<span style="color:#888;font-weight:500">Up<b id="ldh-pos-up" style="color:#e0e0e0;margin-left:4px">0.00</b></span>
<span style="color:#888;font-weight:500">Down<b id="ldh-pos-dn" style="color:#e0e0e0;margin-left:4px">0.00</b></span>
<span style="color:#888;font-weight:500">Today<b id="ldh-today-pnl" style="color:#666;margin-left:4px">-</b></span>
<span style="color:#888;font-weight:500">Win rate<b id="ldh-today-wr" style="color:#f0a500;margin-left:4px">-</b></span>
</div>
</div>
<!-- Market params row: secondary info (PTB / BTC / diff / strategy)-->
<div id="low-data-detail" style="display:none;align-items:center;gap:8px 24px;padding:8px 24px 14px;font-size:13px;color:#6a7380;flex-wrap:wrap">
<span>PTB <b id="ldh-ptb" style="color:#888"></b></span>
<span>BTC <b id="ldh-btc" style="color:#888"></b></span>
<span>CL <b id="ldh-diff-cl" style="color:#888"></b></span>
<span>BN <b id="ldh-diff-bn" style="color:#f0a500"></b></span>
<span>Vol <b id="ldh-vol" style="color:#888">-</b></span>
<span id="ldh-strat" style="margin-left:auto;font-weight:600;color:#888"></span>
</div>
</div>
<div id="compact-overview">
<div class="compact-grid">
<div class="compact-card compact-card-prob">
<div class="compact-label">Probability</div>
<div class="compact-main" id="compact-prob-main">Waiting for order book</div>
<div class="compact-sub" id="compact-prob-sub"></div>
</div>
<div class="compact-card compact-card-book">
<div class="compact-label">Order book summary</div>
<div class="compact-main" id="compact-book-main">Waiting for order book</div>
<div class="compact-sub" id="compact-book-sub"></div>
</div>
<div class="compact-card compact-card-account">
<div class="compact-label">Account</div>
<div class="compact-main" id="compact-account-main">USDC —</div>
<div class="compact-sub" id="compact-account-sub">Up - / Down -</div>
</div>
<div class="compact-card compact-card-strategy">
<div class="compact-label">Strategy</div>
<div class="compact-main" id="compact-strategy-main"></div>
<div class="compact-sub" id="compact-strategy-sub"></div>
</div>
</div>
</div>
<!-- Strategy bar -->
<div id="strat-bar">
<div style="display:flex;flex-direction:column;gap:6px;flex:1">
<div id="strat-groups" style="display:flex;flex-direction:column;gap:6px"></div>
<!-- Strategy run params: entries per round + US-hours only (strongly strategy-related) -->
<div class="strat-row" style="display:flex;align-items:center;gap:14px;flex-wrap:wrap">
<span style="font-size:11px;font-weight:600;color:#888;min-width:60px">Run params</span>
<div style="display:flex;align-items:center;gap:4px;font-size:11px;color:#888" title="Max entries per round (how many times each strategy can trigger within the current 5-minute window)">
<span>Per round</span>
<input type="number" id="max-round-entries" value="1" min="1" max="10" step="1" style="width:36px;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;text-align:center;font-size:11px;padding:2px">
<span>x</span>
</div>
<div style="display:flex;align-items:center;gap:4px;font-size:11px;color:#888" title="Strategy auto-order slippage (buy markup / sell markdown range)">
<span>Slippage</span>
<input type="number" id="strat-slippage" value="6" min="0" max="99" step="1" style="width:36px;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;text-align:center;font-size:11px;padding:2px">
<span>%</span>
</div>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#888;cursor:pointer" title="When checked, momentum strategies only enter during US market hours (Mon~Fri UTC 13:30-21:00); not triggered on weekends or while the US market is closed">
<input type="checkbox" id="market-hours-only" style="cursor:pointer">
<span>US hours only</span>
</label>
<span id="strat-config-note" style="font-size:11px;color:#555" title="Strategy config only applies to this run; reverts to .env defaults after restart">ⓘ This run only</span>
</div>
</div>
<div class="strat-right" style="display:flex;flex-direction:column;gap:6px;align-items:flex-end">
<!-- Row 1: strategy run status + peripheral tools -->
<div style="display:flex;align-items:center;gap:10px">
<div id="strat-status" title="—">
<span id="strat-scan-info" style="display:none"></span>
<span id="strat-state-text" class="idle">Disabled</span>
</div>
<span id="strat-limit-status" style="display:none;font-size:13px;font-weight:600;color:#d4b3ff;background:#2a1f3d;border:1px solid #a371f7;padding:3px 10px;border-radius:6px"></span>
<span style="color:#30363d;margin:0 2px">|</span>
<span id="strat-cond-status" style="font-size:13px;font-weight:600;display:inline-flex;align-items:center;gap:6px;flex-wrap:wrap"><span style="color:#444;font-size:12px;font-weight:500">TP/SL: none</span></span>
<div class="strat-divider"></div>
<div id="claim-bar" style="display:flex;align-items:center;gap:8px">
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#c9d1d9;cursor:pointer;user-select:none">
<input type="checkbox" id="auto-claim-enabled" style="cursor:pointer">
<span>Auto Claim</span>
</label>
<label style="display:flex;align-items:center;gap:4px;font-size:11px;color:#c9d1d9;cursor:pointer;user-select:none"
title="Anti-manipulation low-liquidity guard: when checked, pauses all strategy entries during the low-liquidity period while the US stock market is closed (Fri 20:00 ET ~ Mon 4:00 ET, 56 hours total), avoiding manipulator pump/dump. Existing open orders / TP / SL / Claim are not affected.">
<input type="checkbox" id="weekend-pause" style="cursor:pointer">
<span>AntiManip</span>
<span id="weekend-status" style="font-size:10px;color:#888;margin-left:2px"></span>
</label>
<span id="claim-status" style="font-size:11px;color:#888;max-width:240px;text-align:right"></span>
<span class="tg-help-trigger" style="font-size:11px;color:#f0a500;cursor:help;user-select:none;position:relative">
<span class="tg-help-tip" style="display:none;position:absolute;right:0;top:18px;z-index:10000;min-width:280px;max-width:340px;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:8px 12px;font-size:11px;color:#c9d1d9;line-height:1.6;box-shadow:0 8px 24px rgba(0,0,0,0.6);white-space:normal;text-align:left">
<div style="color:#f0a500;font-weight:600;margin-bottom:4px">Auto-claim expired positions</div>
<div style="margin-bottom:6px">When checked, queries the claimable amount every 15 seconds and batch-claims on-chain settled winning positions every 5 minutes (redeemed into the Safe wallet's pUSD balance).</div>
<div style="color:#888;font-size:10px;line-height:1.5">
· Calls V2 CtfCollateralAdapter.redeemPositions<br>
· 5-second interval between claims, auto-retries once on failure<br>
· Each claim costs ~$0.01 POL gas, paid by the local private-key wallet<br>
· Skips this round automatically while a strategy is placing an order, to avoid nonce conflicts
</div>
</span>
</span>
<span id="claim-cooldown" style="font-size:11px;color:#888;display:none"></span>
</div>
<div class="strat-divider"></div>
<div style="display:flex;align-items:center;gap:6px">
<span id="backtest-label" style="font-size:11px;color:#666">Backtest data</span>
<button id="backtest-btn" onclick="toggleBacktest()" style="font-size:11px;padding:2px 8px;border-radius:4px;border:1px solid #30363d;background:#21262d;color:#888;cursor:pointer">Off</button>
</div>
</div>
<!-- Telegram push config -->
<div id="tg-config-panel" style="display:flex;flex-direction:column;gap:4px;align-self:stretch;align-items:flex-end;position:relative">
<div style="display:flex;align-items:center;gap:6px">
<span style="font-size:11px;color:#666;cursor:pointer;user-select:none" onclick="toggleTgPanel()" id="tg-toggle-label" title="Click to expand/collapse TG push config">📱 TG push ></span>
<span class="tg-help-trigger" style="font-size:11px;color:#58a6ff;cursor:help;user-select:none;position:relative">ⓘ Help
<span class="tg-help-tip" style="display:none;position:absolute;right:0;top:18px;z-index:10000;min-width:380px;max-width:440px;background:#0d1117;border:1px solid #30363d;border-radius:6px;padding:10px 14px;font-size:11px;color:#c9d1d9;line-height:1.7;box-shadow:0 8px 24px rgba(0,0,0,0.6);white-space:normal;text-align:left">
<div style="color:#f0a500;font-weight:600;margin-bottom:6px">📱 How to configure Telegram push</div>
<div style="color:#888;margin-bottom:4px">① Create a bot (get a Bot Token)</div>
<div style="padding-left:14px;color:#c9d1d9;margin-bottom:8px">
· Search <span style="color:#58a6ff">@BotFather</span> in Telegram and start a chat<br>
· Send <code style="background:#21262d;padding:1px 4px;border-radius:3px;color:#f0a500">/newbot</code>, and follow the prompts to set the bot name<br>
· BotFather returns a <b>Bot Token</b> (looks like <code style="background:#21262d;padding:1px 3px;border-radius:3px;color:#f0a500">1234:ABCD...</code>); copy and paste it into the input box below
</div>
<div style="color:#888;margin-bottom:4px">② Get the Chat ID (the account that receives pushes)</div>
<div style="padding-left:14px;color:#c9d1d9;margin-bottom:8px">
· Open <b>the bot you created</b> in Telegram, click "Start" or send <code style="background:#21262d;padding:1px 4px;border-radius:3px;color:#f0a500">/start</code><br>
· Return to this panel, fill in the Bot Token, then click <span style="color:#58a6ff">🔍 Auto detect</span>; the Chat ID will be filled in automatically
</div>
<div style="color:#888;margin-bottom:4px">③ Enable push</div>
<div style="padding-left:14px;color:#c9d1d9;margin-bottom:8px">
· Check "Enable" and set the push interval<br>
· Click "Save" to apply<br>
· Click "Test send" to verify the bot can push messages
</div>
<div style="color:#666;font-size:10px;border-top:1px dashed #21262d;padding-top:6px;margin-top:6px">
Push content: account balance, today's PnL, strategy status, last 5 trades
</div>
</span>
</span>
</div>
<div id="tg-config-body" style="display:none;flex-direction:column;gap:8px;font-size:11px;position:absolute;right:0;top:22px;z-index:9999;width:460px;background:#0d1117;border:1px solid #30363d;border-radius:8px;padding:12px 14px;box-shadow:0 8px 24px rgba(0,0,0,0.6);align-items:stretch">
<div style="color:#666;font-size:10px;line-height:1.5">
ⓘ Before use, send <code style="background:#21262d;padding:1px 4px;border-radius:3px;color:#f0a500">/start</code> to the bot in Telegram, then click "Auto detect"
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="color:#888;min-width:64px">Bot Token</span>
<input type="text" id="tg-bot-token" placeholder="Get from @BotFather" style="flex:1;min-width:0;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;font-size:11px;padding:3px 8px">
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="color:#888;min-width:64px">Chat ID</span>
<input type="text" id="tg-chat-id" placeholder="Numeric ID (auto-detect works)" style="flex:1;min-width:0;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;font-size:11px;padding:3px 8px">
<button onclick="detectChatId()" title="First send /start to the bot in Telegram, then click this button to auto-detect" style="font-size:11px;padding:3px 10px;border-radius:4px;border:1px solid #1f6feb;background:#0d253f;color:#58a6ff;cursor:pointer;white-space:nowrap">🔍 Auto detect</button>
</div>
<div style="display:flex;align-items:center;gap:6px">
<span style="color:#888;min-width:64px">Push settings</span>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer" title="Master switch: when off, no pushes are sent">
<input type="checkbox" id="tg-enabled" style="cursor:pointer">
<span>Enable</span>
</label>
</div>
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
<span style="color:#888;min-width:64px">Push mode</span>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer" title="Push account summary on a fixed interval">
<input type="checkbox" id="tg-scheduled" style="cursor:pointer">
<span>Scheduled</span>
</label>
<span style="color:#888">Every</span>
<input type="number" id="tg-interval" min="5" max="1440" step="5" value="60" style="width:48px;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;text-align:center;font-size:11px;padding:2px">
<span style="color:#888">min</span>
<span style="color:#444;margin:0 2px">|</span>
<label style="display:flex;align-items:center;gap:4px;cursor:pointer" title="Pushes an account summary 10 minutes after each buy fill">
<input type="checkbox" id="tg-post-trade" style="cursor:pointer">
<span>10 min after trade</span>
</label>
</div>
<div style="display:flex;align-items:center;gap:6px;padding-top:2px;border-top:1px dashed #21262d;margin-top:2px">
<span style="color:#888;min-width:64px">Action</span>
<button onclick="saveTgConfig()" style="font-size:11px;padding:3px 12px;border-radius:4px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer">Save</button>
<button onclick="testTgMessage()" style="font-size:11px;padding:3px 12px;border-radius:4px;border:1px solid #30363d;background:#21262d;color:#c9d1d9;cursor:pointer">Test send</button>
<span id="tg-status" style="font-size:11px;color:#888;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Observe panel: a separate row showing momentum factors / prob-chase factors (placed below the strategy bar) -->
<div id="observe-bar" style="padding:6px 16px;background:#0d1117;border-bottom:1px solid #1a1f28;font-size:11px;display:flex;align-items:center;gap:20px;flex-wrap:wrap">
<div id="observe-panels" style="display:flex;align-items:center;gap:20px;flex-wrap:wrap"></div>
<div id="fair-prob-panel" style="color:#888;white-space:nowrap;margin-left:auto" title="Prob-chase strategy: deviation of current Polymarket probability vs historical fair probability; entry triggered when deviation >= 10%">
<span style="color:#f0a500;font-weight:600">Prob chase</span>
<span id="fp-symbol-tag" style="color:#666;margin-left:6px;font-size:10px">[BTC]</span>
<span style="color:#444;margin:0 6px">|</span>
<span id="fp-input"></span>
<span style="color:#444;margin:0 6px">|</span>
<span id="fp-compare"></span>
<span style="color:#444;margin:0 6px">|</span>
<span id="fp-bias">Deviation -</span>
</div>
</div>
<div id="main-wrap">
<!-- Order book + curve -->
<div id="book">
<div id="chart-toggle" onclick="toggleChart()">
<span class="toggle-label">Curve</span>
<span class="toggle-arrow"></span>
</div>
<div id="chart-collapsible">
<div id="price-chart">
<canvas id="chart-canvas"></canvas>
</div>
</div>
<div id="book-toggle" class="collapsed" onclick="toggleBook()">
<span class="toggle-label">Order book</span>
<span class="toggle-arrow"></span>
</div>
<div id="book-collapsible" class="collapsed">
<div id="momentum-bar">
<div class="mom-group">
<span class="mom-label">3s:</span>
<div class="mom-item"><span class="mom-name">Poly</span><span class="mom-val flat" id="mom5-poly"></span></div>
<div class="mom-item"><span class="mom-name">Binance</span><span class="mom-val flat" id="mom5-bnb">-</span></div>
<div class="mom-item"><span class="mom-name">Probability</span><span class="mom-val flat" id="mom5-prob">-</span></div>
</div>
<div class="mom-group">
<span class="mom-label">5s:</span>
<div class="mom-item"><span class="mom-name">Poly</span><span class="mom-val flat" id="mom10-poly"></span></div>
<div class="mom-item"><span class="mom-name">Binance</span><span class="mom-val flat" id="mom10-bnb">-</span></div>
<div class="mom-item"><span class="mom-name">Probability</span><span class="mom-val flat" id="mom10-prob">-</span></div>
</div>
</div>
<div class="book-header">
<div>Up price</div>
<div class="col-c">Size</div>
<div class="col-r">Down price</div>
</div>
<div id="asks-wrap"></div>
<div id="spread-bar">
<span>Best bid: <b id="best-bid" class="up">-</b></span>
<span>Best ask: <b id="best-ask" class="down">-</b></span>
<span>Spread: <span id="spread-val">-</span></span>
</div>
<div id="bids-wrap"></div>
<div id="prob-bar">
<div class="prob-item">
<div class="prob-label">Up</div>
<div class="prob-val up" id="prob-up"></div>
</div>
<div id="prob-track"><div id="prob-fill-up" style="width:50%"></div></div>
<div class="prob-item">
<div class="prob-label">Down</div>
<div class="prob-val down" id="prob-down"></div>
</div>
</div>
</div>
</div>
<!-- Order panel -->
<div id="order-panel">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
<div class="panel-title" style="margin:0">Order <span id="order-account-id" style="font-size:13px;font-weight:500;color:#9aa5b8;margin-left:6px;letter-spacing:normal;text-transform:none"></span></div>
<div style="display:flex;gap:0">
<button class="tog-btn active" id="mode-market" onclick="setOrderMode('market')" style="font-size:11px;padding:2px 10px;border-radius:4px 0 0 4px">Market</button>
<button class="tog-btn" id="mode-limit" onclick="setOrderMode('limit')" style="font-size:11px;padding:2px 10px;border-radius:0 4px 4px 0">Limit</button>
</div>
</div>
<!-- Account positions -->
<div id="account-block">
<div class="acct-item">
<div class="acct-label">USDC</div>
<div class="allowance-wrap">
<div class="allowance-status" id="acct-allowance-status"></div>
<div class="allowance-tooltip" id="acct-allowance-tooltip"></div>
</div>
<div class="acct-val yellow" id="acct-usdc"></div>
</div>
<div class="acct-item">
<div class="acct-label">Up position</div>
<div class="acct-val up" id="acct-up-local"></div>
<span class="verified-tag" id="up-verified-tag">Not calibrated</span>
<div class="acct-sub">API: <span id="acct-up-api"></span></div>
</div>
<div class="acct-item">
<div class="acct-label">Down position</div>
<div class="acct-val down" id="acct-dn-local"></div>
<span class="verified-tag" id="dn-verified-tag">Not calibrated</span>
<div class="acct-sub">API: <span id="acct-dn-api"></span></div>
</div>
<div id="sync-time">API sync: <span id="last-sync">-</span><span id="calib-countdown"></span></div>
</div>
<!-- Action -->
<div>
<div style="font-size:10px;color:#777;margin-bottom:3px">Action</div>
<div class="btn-group">
<button class="side-btn" id="btn-buy" onclick="setSide('buy')">Buy <span style="font-size:10px;opacity:0.5">F1</span></button>
<button class="side-btn" id="btn-sell" onclick="setSide('sell')">Sell <span style="font-size:10px;opacity:0.5">F2</span></button>
</div>
</div>
<!-- Direction -->
<div>
<div style="position:relative;display:flex;align-items:center;font-size:10px;color:#777;margin-bottom:3px">
<span>Direction</span>
<div style="position:absolute;left:50%;transform:translateX(-50%);display:flex;gap:14px;font-size:11px;color:#777;justify-content:center;white-space:nowrap">
<span>Remaining: <b id="order-remaining" class="yellow">--:--</b></span>
<span>Diff: <b id="order-diff">-</b></span>
<span title="Probability deviation (fair probability - current Polymarket probability)">Deviation: <b id="order-bias">-</b></span>
</div>
</div>
<div class="btn-group">
<button class="tog-btn" id="btn-up" onclick="setDir('up')"><span style="color:#3fb950">Up ▲</span> <span id="dir-up-pct" class="dir-pct">-</span> <span style="font-size:10px;opacity:0.5">F3</span></button>
<button class="tog-btn" id="btn-down" onclick="setDir('down')"><span style="color:#f85149">Down ▼</span> <span id="dir-dn-pct" class="dir-pct">-</span> <span style="font-size:10px;opacity:0.5">F4</span></button>
</div>
</div>
<!-- ── Market panel ── -->
<!-- ── Market panel ── -->
<div id="market-panel" style="display:flex;flex-direction:column;gap:6px;min-height:148px">
<div>
<div style="font-size:12px;color:#777;margin-bottom:3px"><span id="amount-label">Amount</span></div>
<div class="input-row">
<button class="step-btn" onclick="stepAmount(-1)"></button>
<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:2px">
<input type="number" id="amount-input" placeholder="0.00" min="0" step="1" oninput="updateEst()" style="width:100%;text-align:center">
<span id="amount-unit" style="font-size:13px;font-weight:700;color:#555;pointer-events:none;line-height:1">USDC</span>
</div>
<button class="step-btn" onclick="stepAmount(1)">+</button>
<button class="max-btn" id="max-btn" onclick="fillMax()" style="visibility:hidden">Max</button>
</div>
<div id="amount-quick" style="display:flex;gap:4px;margin-top:4px">
<button type="button" class="quick-amt-btn" onclick="setAmount(1)">1</button>
<button type="button" class="quick-amt-btn" onclick="setAmount(5)">5</button>
<button type="button" class="quick-amt-btn" onclick="setAmount(10)">10</button>
<button type="button" class="quick-amt-btn" onclick="setAmount(20)">20</button>
</div>
</div>
<div id="est-row">Price: <span id="est-price">-</span>&nbsp;&nbsp;Estimate: <span id="est-result">-</span></div>
<button id="confirm-btn" onclick="submitOrder()" disabled>Select direction and action</button>
<div id="order-result"></div>
</div>
<!-- ── Limit panel ── -->
<div id="limit-panel" style="display:none;flex-direction:column;gap:8px;min-height:148px">
<div style="display:flex;gap:8px">
<div style="flex:1">
<div style="font-size:12px;color:#777;margin-bottom:3px">Shares</div>
<div class="input-row">
<button class="step-btn" onclick="document.getElementById('limit-size-input').stepDown();updateOrderAvailability()"></button>
<input type="number" id="limit-size-input" value="5" min="5" step="1" oninput="updateOrderAvailability()" style="width:100%;text-align:center">
<button class="step-btn" onclick="document.getElementById('limit-size-input').stepUp();updateOrderAvailability()">+</button>
</div>
</div>
<div style="flex:1">
<div style="font-size:12px;color:#777;margin-bottom:3px">Limit <span style="font-size:11px;color:#555">(0~1)</span></div>
<div class="input-row">
<button class="step-btn" onclick="document.getElementById('limit-price-input').stepDown();updateOrderAvailability()"></button>
<input type="number" id="limit-price-input" value="0.01" min="0.01" max="0.99" step="0.01" oninput="updateOrderAvailability()" style="width:100%;text-align:center">
<button class="step-btn" onclick="document.getElementById('limit-price-input').stepUp();updateOrderAvailability()">+</button>
</div>
</div>
</div>
<div id="est-row">Minimum 5 shares&nbsp;&nbsp;Total: <span id="limit-notional" style="color:#bbb">-</span></div>
<button id="limit-confirm-btn" onclick="submitOrder()" disabled>Select direction and action</button>
<div id="limit-order-result" style="font-size:12px;min-height:18px"></div>
<!-- Active limit open-order list -->
<div id="manual-limit-list" style="display:none;flex-direction:column;gap:4px;border-top:1px dashed #21262d;padding-top:8px">
<div style="font-size:11px;color:#666;margin-bottom:2px">Active open orders <span id="manual-limit-count">0</span></div>
<div id="manual-limit-items" style="display:flex;flex-direction:column;gap:3px;font-size:11px"></div>
</div>
</div>
<!-- ── Common area (shared by market/limit, doesn't move when switching) ── -->
<!-- Position summary + close all -->
<div id="pos-summary" style="font-size:12px;color:#888;padding:4px 2px;line-height:1.8;min-height:22px"></div>
<button id="clear-all-btn" onclick="submitClearAll()" title="Close all (F5)">🧹 Close all <span style="font-size:10px;opacity:0.5">F5</span></button>
<!-- Conditional orders (TP/SL, only valid for market orders) -->
<div id="cond-panel">
<div id="cond-head" onclick="toggleCondPanel()" style="cursor:pointer;user-select:none;display:flex;align-items:center;gap:6px;font-size:12px;color:#888;padding:4px 0">
<span id="cond-arrow"></span>
<span>📌 Conditional orders</span>
<span id="cond-summary" style="font-size:11px;color:#666"></span>
</div>
<div id="cond-body" style="display:none;flex-direction:column;gap:6px;font-size:12px;padding:6px 0 4px 16px;border-left:1px solid #21262d;margin-left:4px">
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer">
<input type="checkbox" id="cond-tp-enabled" onchange="saveCondConfig()">
<span style="color:#3fb950">Limit take profit</span>
</label>
<span style="color:#888">Fill price +</span>
<input type="number" id="cond-tp-pct" min="0" max="99" step="1" value="0" onchange="saveCondConfig()" style="width:48px;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;text-align:center;font-size:12px;padding:2px">
<span style="color:#888">% sell with open order</span>
</div>
<div style="color:#666;font-size:10px;line-height:1.4;padding-left:4px">When filled shares &lt; 5, auto-downgrades to a locally-monitored market take profit</div>
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer">
<input type="checkbox" id="cond-sl-enabled" onchange="saveCondConfig()">
<span style="color:#f85149">Market stop loss</span>
</label>
<label style="display:flex;align-items:center;gap:3px;cursor:pointer;color:#888">
<input type="radio" name="cond-sl-mode" id="cond-sl-mode-price" value="price" checked onchange="saveCondConfig()">
<span>By price</span>
</label>
<label style="display:flex;align-items:center;gap:3px;cursor:pointer;color:#888">
<input type="radio" name="cond-sl-mode" id="cond-sl-mode-diff" value="diff" onchange="saveCondConfig()">
<span>Crossover</span>
</label>
<span style="color:#444;margin-left:2px">|</span>
<span style="color:#888">Slippage</span>
<input type="number" id="cond-sl-slippage" min="0" max="99" step="1" value="15" onchange="saveCondConfig()" style="width:48px;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;text-align:center;font-size:12px;padding:2px">
<span style="color:#888">%</span>
</div>
<div id="cond-sl-row-price" style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;padding-left:18px">
<span style="color:#888">Fill price -</span>
<input type="number" id="cond-sl-pct" min="0" max="99" step="1" value="0" onchange="saveCondConfig()" style="width:48px;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;text-align:center;font-size:12px;padding:2px">
<span style="color:#888">% market close</span>
</div>
<div id="cond-sl-row-diff" style="display:none;align-items:center;gap:6px;flex-wrap:wrap;padding-left:18px">
<span style="color:#888">Buy-up diff <=</span>
<input type="number" id="cond-sl-diff" min="-200" max="200" step="1" value="-5" onchange="saveCondConfig()" style="width:60px;background:#0d1117;border:1px solid #30363d;border-radius:4px;color:#e0e0e0;text-align:center;font-size:12px;padding:2px">
<span style="color:#888" title="Buy-down direction auto-mirrors: triggered when diff >= -X">Market close (buy-down mirror)</span>
</div>
<div id="cond-list" style="margin-top:6px;border-top:1px dashed #21262d;padding-top:6px;display:none">
<div style="color:#666;font-size:11px;margin-bottom:4px;display:flex;align-items:center;gap:6px">
<span>Active conditional orders <span id="cond-list-count">0</span></span>
<button id="cond-cancel-all-btn" onclick="cancelAllCondOrders()" title="Cancel all active conditional orders in one click"
style="background:#161b22;color:#f85149;border:1px solid #30363d;border-radius:3px;padding:1px 6px;font-size:10px;cursor:pointer">Clear all</button>
</div>
<div id="cond-list-items" style="display:flex;flex-direction:column;gap:3px;font-size:11px"></div>
</div>
</div>
</div>
<!-- Slippage -->
<div id="slippage-row">
<div style="font-size:12px;color:#777;margin-bottom:5px">Slippage protection <span style="font-size:11px;color:#666">(buy+/sell-)</span></div>
<div class="input-row">
<input type="number" id="slippage-input" value="6" min="0" max="99" step="1" style="max-width:72px">
<span style="color:#888;margin-left:4px">%</span>
</div>
</div>
</div>
</div>
<!-- Polymarket real PnL (one row each, with fee and Claim) -->
<div id="pm-pnl">
<div class="hist-title">
<span>Real PnL - Polymarket</span>
<span class="pm-total" id="pm-pnl-total">Loading...</span>
</div>
<div id="pm-pnl-refresh">
<button class="pm-refresh-btn" id="pm-pnl-refresh-btn" title="Refresh all now">↻ Refresh</button>
<span class="pm-countdown" id="pm-pnl-countdown"></span>
</div>
<div id="pm-pnl-header" class="pm-item pm-header">
<span class="pm-time">Time</span>
<span class="pm-kind">Type</span>
<span class="pm-outcome">Direction</span>
<span class="pm-source">Strategy</span>
<span class="pm-detail">Shares x Price</span>
<span class="pm-fee">Fee</span>
<span class="pm-title">Market</span>
<span class="pm-amount">Cash flow</span>
<span class="pm-pos-pnl">Position PnL</span>
</div>
<div id="pm-pnl-list"><div style="color:#444;font-size:12px">Loading...</div></div>
</div>
<script>
// ── Layout tweak: move #price-bar before #observe-bar (strategy -> price -> entry factors)──
(function reorderTopBars() {
const priceBar = document.getElementById('price-bar');
const observeBar = document.getElementById('observe-bar');
if (priceBar && observeBar && priceBar.parentNode && observeBar.parentNode) {
observeBar.parentNode.insertBefore(priceBar, observeBar);
}
})();
// ── Order book collapse ──────────────────────────────────────────────────
function toggleChart() {
const toggle = document.getElementById('chart-toggle');
const panel = document.getElementById('chart-collapsible');
const collapsed = panel.classList.toggle('collapsed');
toggle.classList.toggle('collapsed', collapsed);
}
function toggleBook() {
const toggle = document.getElementById('book-toggle');
const panel = document.getElementById('book-collapsible');
const collapsed = panel.classList.toggle('collapsed');
toggle.classList.toggle('collapsed', collapsed);
}
const LOW_DATA_STORAGE_KEY = 'btc5m.lowDataMode';
function loadLowDataModePreference() {
try {
return localStorage.getItem(LOW_DATA_STORAGE_KEY) === '1';
} catch {
return false;
}
}
function persistLowDataModePreference() {
try {
localStorage.setItem(LOW_DATA_STORAGE_KEY, lowDataMode ? '1' : '0');
} catch {}
}
let lowDataMode = loadLowDataModePreference();
document.body.classList.toggle('low-data-mode', lowDataMode);
function buildWsUrl() {
const protocol = location.protocol === 'https:' ? 'wss' : 'ws';
const url = new URL(`${protocol}://${location.host}`);
url.searchParams.set('dataMode', lowDataMode ? 'low' : 'full');
return url.toString();
}
// ── DOM ───────────────────────────────────────────────────────
const $range = document.getElementById('window-range');
const $remaining = document.getElementById('remaining');
const $connDot = document.getElementById('conn-dot');
const $ptb = document.getElementById('price-to-beat');
const $cur = document.getElementById('btc-current');
const $diff = document.getElementById('btc-diff');
const $orderRemaining = document.getElementById('order-remaining');
const $orderDiff = document.getElementById('order-diff');
const $bestBid = document.getElementById('best-bid');
const $bestAsk = document.getElementById('best-ask');
const $spreadVal = document.getElementById('spread-val');
const $lowDataToggle = document.getElementById('low-data-toggle');
const $compactProbMain = document.getElementById('compact-prob-main');
const $compactProbSub = document.getElementById('compact-prob-sub');
const $compactBookMain = document.getElementById('compact-book-main');
const $compactBookSub = document.getElementById('compact-book-sub');
const $compactAccountMain = document.getElementById('compact-account-main');
const $compactAccountSub = document.getElementById('compact-account-sub');
const $compactStrategyMain = document.getElementById('compact-strategy-main');
const $compactStrategySub = document.getElementById('compact-strategy-sub');
function updateLowDataToggleUi() {
if (!$lowDataToggle) return;
$lowDataToggle.classList.toggle('on', lowDataMode);
$lowDataToggle.setAttribute('aria-pressed', lowDataMode ? 'true' : 'false');
}
function updateCompactOverview() {
if (!lowDataMode) return;
const rem = windowEnd ? Math.max(0, windowEnd - Math.floor(Date.now() / 1000)) : 0;
const remStr = `${Math.floor(rem / 60)}:${String(rem % 60).padStart(2, '0')}`;
const remColor = rem <= 30 ? '#f85149' : rem <= 60 ? '#f0a500' : '#e0e0e0';
// Probability
const $ldhUp = document.getElementById('ldh-up');
const $ldhDn = document.getElementById('ldh-dn');
if ($ldhUp && $ldhDn) {
if (isNaN(_signalUpPct)) {
$ldhUp.textContent = 'Up -'; $ldhUp.style.color = '#555';
$ldhDn.textContent = 'Down -'; $ldhDn.style.color = '#555';
} else {
$ldhUp.textContent = `Up ${_signalUpPct}%`;
$ldhUp.style.color = _signalUpPct >= 60 ? '#3fb950' : _signalUpPct <= 40 ? '#f85149' : '#e0e0e0';
$ldhDn.textContent = `Down ${_signalDnPct}%`;
$ldhDn.style.color = _signalDnPct >= 60 ? '#f85149' : _signalDnPct <= 40 ? '#3fb950' : '#e0e0e0';
}
}
// Countdown
const $ldhTimer = document.getElementById('ldh-timer');
if ($ldhTimer) {
$ldhTimer.textContent = remStr;
$ldhTimer.style.color = remColor;
}
// Detail row
const $ldhPtb = document.getElementById('ldh-ptb');
const $ldhBtc = document.getElementById('ldh-btc');
const $ldhDiffCl = document.getElementById('ldh-diff-cl');
const $ldhDiffBn = document.getElementById('ldh-diff-bn');
const $ldhVol = document.getElementById('ldh-vol');
const $ldhUsdc = document.getElementById('ldh-usdc');
const $ldhPosUp = document.getElementById('ldh-pos-up');
const $ldhPosDn = document.getElementById('ldh-pos-dn');
const $ldhStrat = document.getElementById('ldh-strat');
if ($ldhPtb) $ldhPtb.textContent = document.getElementById('price-to-beat')?.textContent || '—';
if ($ldhBtc) $ldhBtc.textContent = document.getElementById('btc-current')?.textContent || '—';
// Diff decimals follow the current asset's price magnitude (BTC 0 / ETH 2 / SOL 4)
function _fmtDelta(v) {
let dec = 2;
try {
if (typeof priceDecimals === 'function') {
const d = priceDecimals();
if (Number.isInteger(d) && d >= 0 && d <= 8) dec = d;
}
} catch (e) {}
return `${v >= 0 ? '+' : '-'}$${Math.abs(v).toFixed(dec)}`;
}
if ($ldhDiffCl) {
if (isNaN(_signalDiff)) {
$ldhDiffCl.textContent = '—';
$ldhDiffCl.style.color = '#888';
} else {
$ldhDiffCl.textContent = _fmtDelta(_signalDiff);
$ldhDiffCl.style.color = _signalDiff > 0 ? '#3fb950' : _signalDiff < 0 ? '#f85149' : '#888';
}
}
if ($ldhDiffBn) {
if (isNaN(serverBinanceDiff)) {
$ldhDiffBn.textContent = '—';
} else {
$ldhDiffBn.textContent = _fmtDelta(serverBinanceDiff);
}
}
// Volatility: reuse the text and color already prepared in #btc-vol
if ($ldhVol) {
const volEl = document.getElementById('btc-vol');
if (volEl) {
$ldhVol.textContent = volEl.textContent || '—';
// #btc-vol controls color via className (up/yellow/down/gray); map to color values
const cls = volEl.className || 'gray';
const colorMap = { up: '#3fb950', yellow: '#f0a500', down: '#f85149', gray: '#888' };
$ldhVol.style.color = colorMap[cls] || '#888';
}
}
if ($ldhUsdc) {
$ldhUsdc.textContent = curUsdc != null ? `$${curUsdc.toFixed(2)}` : '—';
$ldhUsdc.style.color = curUsdc != null && curUsdc > 0 ? '#58a6ff' : '#555';
}
if ($ldhPosUp) {
$ldhPosUp.textContent = fmtSize(curUpLocal);
$ldhPosUp.style.color = curUpLocal > 0.01 ? '#3fb950' : '#444';
}
if ($ldhPosDn) {
$ldhPosDn.textContent = fmtSize(curDnLocal);
$ldhPosDn.style.color = curDnLocal > 0.01 ? '#f85149' : '#444';
}
if ($ldhStrat) {
const stateEl = document.getElementById('strat-state-text');
$ldhStrat.textContent = stateEl?.textContent || '—';
$ldhStrat.style.color =
stateEl?.className === 'hold' ? '#f0a500'
: stateEl?.className === 'active' ? '#3fb950'
: stateEl?.className === 'scan' ? '#7a8a9a'
: '#555';
}
}
function setLowDataMode(enabled, options = {}) {
const { persist = true, syncServer = true, clearBuffers = true } = options;
lowDataMode = !!enabled;
document.body.classList.toggle('low-data-mode', lowDataMode);
updateLowDataToggleUi();
if (persist) persistLowDataModePreference();
if (clearBuffers) clearRealtimeBuffers();
updateCompactOverview();
if (syncServer && ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'clientConfig', dataMode: lowDataMode ? 'low' : 'full' }));
}
}
function toggleLowDataMode() {
setLowDataMode(!lowDataMode);
}
updateLowDataToggleUi();
// ── Polymarket real PnL ──────────────────────────────────────
const $pmPnlList = document.getElementById('pm-pnl-list');
const $pmPnlTotal = document.getElementById('pm-pnl-total');
const $pmPnlRefreshBtn = document.getElementById('pm-pnl-refresh-btn');
const $pmPnlCountdown = document.getElementById('pm-pnl-countdown');
// Local cache of the last full set of events fetched
let pmEventsAll = [];
// Next refresh time (ms), broadcast by backend; countdown derives from it
let pmPnlNextRefreshAt = 0;
function formatPmTime(tsSec) {
return new Date(tsSec * 1000).toLocaleString([], {
month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
hour12: false,
});
}
/** Unix seconds since today CST (UTC+8) 00:00 */
function todayStartSec() {
const now = new Date();
now.setHours(0, 0, 0, 0);
return Math.floor(now.getTime() / 1000);
}
// Cache the latest "today snapshot" pushed by server (one entry = one trade window; win-rate denominator = number of settled windows)
let pmTodaySnapshot = null;
function renderPmPnl(events, total, nextRefreshAt, today) {
if (Array.isArray(events)) pmEventsAll = events;
if (Number.isFinite(nextRefreshAt) && nextRefreshAt > 0) pmPnlNextRefreshAt = nextRefreshAt;
if (today && typeof today === 'object') pmTodaySnapshot = today;
applyPmPnlFilter();
}
function applyPmPnlFilter() {
const start = todayStartSec();
const filtered = pmEventsAll.filter(e => e.ts >= start);
// Overview/win-rate/PnL: always read the server-computed snapshot (keeps front/back in sync; frontend no longer recomputes)
const snap = pmTodaySnapshot;
if ($pmPnlTotal) {
if (snap) {
const net = Number(snap.netPnl || 0);
const cls = net >= 0 ? 'val-pos' : 'val-neg';
const sign = net >= 0 ? '+' : '';
const feeText = Number(snap.totalFee || 0).toFixed(2);
$pmPnlTotal.innerHTML = `${snap.positions} pos - Fee $${feeText} - Net<span class="${cls}">${sign}$${net.toFixed(2)}</span>`;
} else {
$pmPnlTotal.innerHTML = '- pos - Fee $0.00 - Net <span class="val-neg">$0.00</span>';
}
}
// Low data mode: today's PnL + win rate
const $ldhPnl = document.getElementById('ldh-today-pnl');
const $ldhWr = document.getElementById('ldh-today-wr');
if ($ldhPnl) {
const net = Number(snap?.netPnl || 0);
const sign = net >= 0 ? '+' : '';
$ldhPnl.textContent = `${sign}$${net.toFixed(2)}`;
$ldhPnl.style.color = net >= 0 ? '#3fb950' : '#f85149';
}
if ($ldhWr) {
if (snap && snap.closedPositions > 0) {
$ldhWr.textContent = `${(snap.wins / snap.closedPositions * 100).toFixed(0)}% (${snap.wins}/${snap.closedPositions})`;
} else {
$ldhWr.textContent = '—';
}
}
if (!$pmPnlList) return;
if (filtered.length === 0) {
$pmPnlList.innerHTML = '<div style="color:#444;font-size:12px">No records for this period</div>';
return;
}
renderPmPnlList(filtered);
}
// Manual refresh button
if ($pmPnlRefreshBtn) {
$pmPnlRefreshBtn.addEventListener('click', async () => {
$pmPnlRefreshBtn.disabled = true;
$pmPnlRefreshBtn.textContent = '↻ Refreshing...';
try {
const res = await fetch('/api/pmpnl/refresh', { method: 'POST' });
const j = await res.json().catch(() => ({}));
if (Number.isFinite(j.nextRefreshAt) && j.nextRefreshAt > 0) {
pmPnlNextRefreshAt = j.nextRefreshAt;
}
} catch (err) {
console.warn('Manual refresh failed', err);
} finally {
$pmPnlRefreshBtn.disabled = false;
$pmPnlRefreshBtn.textContent = '↻ Refresh';
}
});
}
// Update countdown every second
setInterval(() => {
if (!$pmPnlCountdown) return;
if (!pmPnlNextRefreshAt) { $pmPnlCountdown.textContent = '—'; return; }
const remainMs = pmPnlNextRefreshAt - Date.now();
if (remainMs <= 0) { $pmPnlCountdown.textContent = 'Refreshing soon'; return; }
const sec = Math.floor(remainMs / 1000);
const mm = Math.floor(sec / 60);
const ss = sec % 60;
$pmPnlCountdown.textContent = `Next refresh ${mm}:${ss.toString().padStart(2, '0')}`;
}, 1000);
function renderPmPnlList(events) {
$pmPnlList.innerHTML = events.map((e) => {
const outcome = (e.outcome || '').toLowerCase();
const outcomeText = outcome === 'up' ? 'Up▲' : outcome === 'down' ? 'Down▼' : e.outcome;
const kindLabel = e.kind === 'BUY' ? 'Buy' : e.kind === 'SELL' ? 'Sell' : e.kind === 'REDEEM' ? 'Claim' : 'Zeroed';
const kindCls = e.kind === 'BUY' ? 'buy' : e.kind === 'SELL' ? 'sell' : e.kind === 'REDEEM' ? 'claim' : 'lost';
const timeText = formatPmTime(e.ts);
const titleShort = e.pending
? (e.title || '').replace(/^[a-z]+-updown-5m-/, 'win:')
: (e.title || '').replace(/^(Bitcoin|Ethereum|Solana) Up or Down - /, '');
// Strategy source badge (placeholder when none, to keep columns aligned)
let sourceBadge;
if (e.strategySource) {
const isManual = e.strategySource === 'manual';
const srcText = isManual ? 'Manual' : e.strategySource.replace(/^strategy/i, '').toUpperCase();
sourceBadge = `<span class="pm-source ${isManual ? 'manual' : ''}">${srcText}</span>`;
} else {
sourceBadge = `<span class="pm-source" style="visibility:hidden">—</span>`;
}
// Details (trade params)
let detailHtml = '';
if (e.kind === 'REDEEM') {
detailHtml = `${(e.size).toFixed(2)} shares x 100%`;
} else if (e.kind === 'LOST') {
detailHtml = `${(e.size).toFixed(2)} shares x 0%`;
} else {
detailHtml = `${(e.size).toFixed(2)} shares x ${(e.price * 100).toFixed(1)}%`;
}
// Fee separate column
const feeVal = Number(e.fee || 0);
const feeHtml = feeVal > 0
? `$${feeVal.toFixed(2)}`
: `<span class="dim">—</span>`;
// Right-side cash flow (with label)
const net = Number(e.netAmount || 0);
const amountLabel = e.kind === 'BUY' ? 'Expense' : e.kind === 'SELL' ? 'Income' : e.kind === 'REDEEM' ? 'Claim' : 'Zeroed';
let amountHtml;
if (e.kind === 'LOST') {
// LOST produces no cash flow (money deducted at BUY); shows "Zeroed $0.00" (gray)
amountHtml = `<span class="pm-amount-label">${amountLabel}</span><span class="pm-amount-val" style="color:#555">$0.00</span>`;
} else {
const amountCls = net >= 0 ? 'pos' : 'neg';
const amountSign = net >= 0 ? '+' : '';
amountHtml = `<span class="pm-amount-label">${amountLabel}</span><span class="pm-amount-val ${amountCls}">${amountSign}$${net.toFixed(2)}</span>`;
}
// Position PnL (SELL/REDEEM/LOST show real value, pending shows "Pending calibration", BUY shows placeholder)
let posPnlHtml;
if (e.positionPnl != null && (e.kind === 'SELL' || e.kind === 'REDEEM' || e.kind === 'LOST')) {
const p = Number(e.positionPnl);
const cls = p >= 0 ? 'pos' : 'neg';
const sign = p >= 0 ? '+' : '';
posPnlHtml = `<span class="pm-pos-pnl-label">Position</span><span class="pm-pos-pnl-val ${cls}">${sign}$${p.toFixed(2)}</span>`;
} else if (e.pending) {
posPnlHtml = `<span class="pm-pos-pnl-label">Position</span><span class="pm-pos-pnl-val" style="color:#f0a500;font-size:10px">Pending calibration</span>`;
} else {
posPnlHtml = `<span class="pm-pos-pnl-label">Position</span><span class="pm-pos-pnl-val dim">-</span>`;
}
return `<div class="pm-item">
<span class="pm-time">${timeText}</span>
<span class="pm-kind ${kindCls}">${kindLabel}</span>
<span class="pm-outcome ${outcome}">${outcomeText}</span>
${sourceBadge}
<span class="pm-detail">${detailHtml}</span>
<span class="pm-fee">${feeHtml}</span>
<span class="pm-title">${titleShort}</span>
<span class="pm-amount">${amountHtml}</span>
<span class="pm-pos-pnl">${posPnlHtml}</span>
</div>`;
}).join('');
}
const $mom5Poly = document.getElementById('mom5-poly');
const $mom5Bnb = document.getElementById('mom5-bnb');
const $mom5Prob = document.getElementById('mom5-prob');
const $mom10Poly = document.getElementById('mom10-poly');
const $mom10Bnb = document.getElementById('mom10-bnb');
const $mom10Prob = document.getElementById('mom10-prob');
const probHistory = []; // [{t, mid}]
function setMomVal(el, val, isProb) {
if (val === null) { el.textContent = '—'; el.className = 'mom-val flat'; return; }
const threshold = isProb ? 0.5 : 2;
const arrow = val > threshold ? ' ▲' : val < -threshold ? ' ▼' : '';
el.textContent = (val >= 0 ? '+' : '') + (isProb ? val.toFixed(1) + '%' : '$' + Math.round(val)) + arrow;
el.className = 'mom-val ' + (val > threshold ? 'up' : val < -threshold ? 'down' : 'flat');
}
function updateVol() {
const $vol = document.getElementById('btc-vol');
if (!$vol) return;
const now = Date.now();
const pts = binancePoints.filter(p => p.t >= now - 30000);
const elapsed = now - _volStartTime;
if (pts.length < 2 || elapsed < 30000) {
const remain = Math.ceil((30000 - elapsed) / 1000);
$vol.textContent = remain + 's';
$vol.className = 'gray';
return;
}
// Compute amplitude from the high and low within a 30-second window
const prices = pts.map(p => p.price);
const hi = Math.max(...prices), lo = Math.min(...prices);
const rangePct = (hi - lo) / lo * 100;
const hiThreshold = parseFloat(document.getElementById('vol-threshold')?.value) || 0.15;
$vol.textContent = rangePct.toFixed(3) + '%';
$vol.className = rangePct < hiThreshold * 0.33 ? 'up' : rangePct < hiThreshold ? 'yellow' : 'down';
}
// Render with the backend-pushed volPct (the only source in low mode; a supplement in full mode where the higher-frequency binancePrice prefers updateVol)
function updateVolFromState() {
const $vol = document.getElementById('btc-vol');
if (!$vol) return;
// In full mode, if binancePoints already has data, prefer the frontend updateVol() (higher frequency)
if (!lowDataMode && binancePoints.length >= 2) return;
if (serverVolPct == null) {
$vol.textContent = '—';
$vol.className = 'gray';
return;
}
const hiThreshold = parseFloat(document.getElementById('vol-threshold')?.value) || 0.15;
$vol.textContent = serverVolPct.toFixed(3) + '%';
$vol.className = serverVolPct < hiThreshold * 0.33 ? 'up' : serverVolPct < hiThreshold ? 'yellow' : 'down';
}
function updateBnDiff() {
const $el = document.getElementById('btc-diff-bn');
if (!$el) return;
const d = getBnDiff();
if (isNaN(d)) { $el.textContent = '—'; $el.className = 'gray'; return; }
$el.textContent = fmtDiffWithPct(d);
$el.className = d >= 0 ? 'up' : 'down';
}
function updateMomentum() {
const now = Date.now();
const getPriceBefore = (arr, ms) => {
const target = now - ms;
let best = null;
for (const p of arr) {
if (p.t <= target) best = p;
else break;
}
return best?.price ?? null;
};
const getProbBefore = (ms) => {
const target = now - ms;
let best = null;
for (const p of probHistory) {
if (p.t <= target) best = p;
else break;
}
return best?.mid ?? null;
};
const curPoly = chartPoints.length ? chartPoints[chartPoints.length - 1].price : null;
const curBnb = binancePoints.length ? binancePoints[binancePoints.length - 1].price : null;
const curProb = probHistory.length ? probHistory[probHistory.length - 1].mid : null;
const poly5 = getPriceBefore(chartPoints, 3000);
const poly10 = getPriceBefore(chartPoints, 5000);
const bnb5 = getPriceBefore(binancePoints, 3000);
const bnb10 = getPriceBefore(binancePoints, 5000);
const prob5 = getProbBefore(3000);
const prob10 = getProbBefore(5000);
setMomVal($mom5Poly, curPoly && poly5 ? curPoly - poly5 : null, false);
setMomVal($mom10Poly, curPoly && poly10 ? curPoly - poly10 : null, false);
setMomVal($mom5Bnb, curBnb && bnb5 ? curBnb - bnb5 : null, false);
setMomVal($mom10Bnb, curBnb && bnb10 ? curBnb - bnb10 : null, false);
setMomVal($mom5Prob, curProb && prob5 ? (curProb - prob5) * 100 : null, true);
setMomVal($mom10Prob, curProb && prob10 ? (curProb - prob10) * 100 : null, true);
}
const $asksWrap = document.getElementById('asks-wrap');
const $bidsWrap = document.getElementById('bids-wrap');
const $probUp = document.getElementById('prob-up');
const $probDown = document.getElementById('prob-down');
const $probFill = document.getElementById('prob-fill-up');
const $acctUsdc = document.getElementById('acct-usdc');
const $acctAllowanceStatus = document.getElementById('acct-allowance-status');
const $acctAllowanceTooltip = document.getElementById('acct-allowance-tooltip');
const $acctUpL = document.getElementById('acct-up-local');
const $acctUpA = document.getElementById('acct-up-api');
const $acctDnL = document.getElementById('acct-dn-local');
const $acctDnA = document.getElementById('acct-dn-api');
const $lastSync = document.getElementById('last-sync');
const $amtInput = document.getElementById('amount-input');
const $amtUnit = document.getElementById('amount-unit');
const $amtLabel = document.getElementById('amount-label');
const $maxBtn = document.getElementById('max-btn');
const $estPrice = document.getElementById('est-price');
const $estResult = document.getElementById('est-result');
const $confirmBtn = document.getElementById('confirm-btn');
const $orderResult= document.getElementById('order-result');
const $stratConfigNote = document.getElementById('strat-config-note');
// Dynamic strategy controls (populated by initStrategyUI after init)
let strategyKeys = [];
const stratChkEls = {}; // { s1: HTMLInputElement, s2: ..., ... }
const stratAmtEls = {}; // { s1: HTMLInputElement, s2: ..., ... }
// ── State ─────────────────────────────────────────────────────
let windowEnd = 0;
let curBestBid = NaN, curBestAsk = NaN;
let _signalDiff = NaN, _signalUpPct = NaN, _signalDnPct = NaN;
let probabilityReady = false;
let curUsdc = null;
let curUpLocal = 0, curDnLocal = 0;
let curUpApi = 0, curDnApi = 0, curApiSyncAt = 0;
let curUpVerified = false, curDnVerified = false;
let curLastTradeAt = 0;
let selDir = null; // 'up' | 'down'
let selSide = null; // 'buy' | 'sell'
// ── Strategy state ──────────────────────────────────────────────────
const strat = {
state: 'IDLE',
activeStrategy: null,
direction: null,
buyAmount: 0,
posBeforeBuy: 0,
posBeforeSell: 0,
waitVerifyAfterSell: false,
cleanupAfterVerify: false,
actionTs: 0,
prevUpPct: NaN,
perStrategy: {}, // Per-strategy private state (broadcast from server)
};
const stratEnabled = {};
const stratAmount = {};
const stratShares = {};
const stratParams = {}; // { [strategyKey]: { [paramKey]: number } }
let maxRoundEntries = 1;
let marketHoursOnly = false;
let autoClaimEnabled = false;
let weekendPause = false;
const claimState = { total: 0, count: 0, running: false, nextCheckAt: 0, cooldownUntil: 0, claiming: false, claimText: '', claimColor: '' };
function renderClaimStatus() {
const status = document.getElementById('claim-status');
const cooldown = document.getElementById('claim-cooldown');
const now = Date.now();
// Main state: shows progress while a claim is in progress, otherwise the amount or nothing to claim
if (status) {
if (claimState.claiming) {
status.style.color = claimState.claimColor || '#f0a500';
status.textContent = claimState.claimText || 'Claiming...';
} else if (claimState.count > 0) {
status.style.color = '#3fb950';
status.textContent = `Claimable $${claimState.total.toFixed(2)} (${claimState.count})`;
} else {
status.style.color = '#666';
status.textContent = 'Nothing to claim';
}
}
// Right side: claim check countdown (5-minute cooldown)
if (cooldown) {
if (claimState.cooldownUntil && now < claimState.cooldownUntil) {
const remain = Math.ceil((claimState.cooldownUntil - now) / 1000);
const m = Math.floor(remain / 60), s = remain % 60;
cooldown.style.display = '';
cooldown.textContent = `Next check ${m}:${String(s).padStart(2,'0')}`;
} else {
cooldown.style.display = 'none';
}
}
}
setInterval(renderClaimStatus, 1000);
const $marketHoursOnly = document.getElementById('market-hours-only');
const $autoClaimEnabled = document.getElementById('auto-claim-enabled');
const $weekendPause = document.getElementById('weekend-pause');
const $maxRoundEntries = document.getElementById('max-round-entries');
const $stratSlippage = document.getElementById('strat-slippage');
const UNVERIFIED_SELL_BUFFER = 0.05;
const STRAT_BUY_LOCK_MS = 15000;
let stratBuyLockUntil = 0;
let serverBinanceDiff = NaN;
let serverVolPct = null; // Backend-pushed 30-second amplitude (in low mode the frontend doesn't compute volatility and relies on this)
let stratConfigSaving = false;
let _pendingStrategyConfig = null;
function fmtSize(n) { return (Math.floor(n * 100) / 100).toFixed(2); }
updateCompactOverview();
function setStratConfigNote(text = 'ⓘ', color = '#555') {
if (!$stratConfigNote) return;
$stratConfigNote.textContent = text;
$stratConfigNote.style.color = color;
}
function setStratControlsDisabled(disabled) {
for (const key of strategyKeys) {
if (stratChkEls[key]) stratChkEls[key].disabled = disabled;
if (stratAmtEls[key]) stratAmtEls[key].disabled = disabled;
}
if ($maxRoundEntries) $maxRoundEntries.disabled = disabled;
if ($stratSlippage) $stratSlippage.disabled = disabled;
if ($marketHoursOnly) $marketHoursOnly.disabled = disabled;
if ($autoClaimEnabled) $autoClaimEnabled.disabled = disabled;
if ($weekendPause) $weekendPause.disabled = disabled;
}
function updateFairProb(msg) {
const $wrap = document.getElementById('fair-prob-panel');
if (!$wrap) return;
$wrap.style.display = '';
const $orderBias = document.getElementById('order-bias');
const fp = msg && msg.fairProb;
// Update asset tag ([BTC]/[ETH]/[SOL])
const $symTag = document.getElementById('fp-symbol-tag');
if ($symTag && fp && fp.symbol) {
$symTag.textContent = '[' + fp.symbol.toUpperCase() + ']';
$symTag.style.color = fp.hasFairTable ? '#666' : '#f85149';
$symTag.title = fp.hasFairTable ? "Fair probability table based on this asset's historical data" : 'No historical deviation table for this asset (only BTC is calibrated); deviation column is grayed out';
}
if (!fp || fp.diff == null || fp.upPct == null) {
document.getElementById('fp-input').textContent = '—';
document.getElementById('fp-compare').textContent = '—';
document.getElementById('fp-bias').innerHTML = '<span style="color:#666">Deviation -</span>';
if ($orderBias) { $orderBias.textContent = '—'; $orderBias.style.color = ''; }
return;
}
// No deviation table for this asset: still show diff/rem/upPct but don't compute fairUp/bias
if (!fp.hasFairTable) {
const diffSign = fp.diff > 0 ? '+' : '';
document.getElementById('fp-input').innerHTML =
`<span style="color:#666">diff</span> <span style="color:#e0e0e0">${diffSign}${Math.round(fp.diff)}</span>` +
` <span style="color:#666">rem</span> <span style="color:#e0e0e0">${fp.rem}s</span>`;
document.getElementById('fp-compare').innerHTML =
`<span style="color:#666">Up</span> <span style="color:#e0e0e0">${fp.upPct.toFixed(0)}%</span>` +
` <span style="color:#444">→</span> <span style="color:#666">Fair N/A</span>`;
document.getElementById('fp-bias').innerHTML =
'<span style="color:#666">Deviation table not calibrated</span>';
if ($orderBias) { $orderBias.textContent = 'N/A'; $orderBias.style.color = '#666'; }
return;
}
const diffSign = fp.diff > 0 ? '+' : '';
document.getElementById('fp-input').innerHTML =
`<span style="color:#666">diff</span> <span style="color:#e0e0e0">${diffSign}${Math.round(fp.diff)}</span>` +
` <span style="color:#666">rem</span> <span style="color:#e0e0e0">${fp.rem}s</span>`;
if (fp.fairUp == null) {
document.getElementById('fp-compare').innerHTML =
`<span style="color:#666">Up</span> <span style="color:#e0e0e0">${fp.upPct.toFixed(0)}%</span>` +
` <span style="color:#444">→</span> <span style="color:#666">Fair -</span>`;
document.getElementById('fp-bias').innerHTML =
'<span style="color:#666">Deviation -</span> <span style="color:#666">·</span>';
if ($orderBias) { $orderBias.textContent = '—'; $orderBias.style.color = ''; }
return;
}
document.getElementById('fp-compare').innerHTML =
`<span style="color:#666">Up</span> <span style="color:#e0e0e0">${fp.upPct.toFixed(0)}%</span>` +
` <span style="color:#444">→</span>` +
` <span style="color:#666">Fair</span> <span style="color:#58a6ff">${fp.fairUp}%</span>`;
const bias = fp.biasUp;
const absBias = Math.abs(bias);
let color = '#666', dirLabel = '';
if (bias >= 10) { color = '#3fb950'; dirLabel = '▲Up'; }
else if (bias <= -10) { color = '#f85149'; dirLabel = '▼Down'; }
else if (absBias >= 5) { color = '#f0a500'; dirLabel = bias > 0 ? '▲Up?' : '▼Down?'; }
const sign = bias >= 0 ? '+' : '';
document.getElementById('fp-bias').innerHTML =
`<span style="color:#666">Deviation</span>` +
` <span style="color:${color};font-weight:600">${sign}${bias.toFixed(0)}%</span>` +
(dirLabel ? ` <span style="color:${color}">${dirLabel}</span>` : '');
if ($orderBias) {
$orderBias.textContent = `${sign}${bias.toFixed(0)}%${dirLabel ? ' ' + dirLabel : ''}`;
$orderBias.style.color = color;
}
}
// ── Generic observe-panel renderer ────────────────────────────────────────
// Dynamically generate UI from each strategy's declared observePanel data; no per-strategy code needed
function renderObservePanels(panels) {
const $host = document.getElementById('observe-panels');
if (!$host) return;
if (!panels || typeof panels !== 'object') panels = {};
const keys = Object.keys(panels);
if (keys.length === 0) { $host.innerHTML = ''; return; }
const dirZh = (d) => d === 'up' ? 'Up' : d === 'down' ? 'Down' : '-';
const dirColor = (d) => d === 'up' ? '#3fb950' : d === 'down' ? '#f85149' : '#666';
const scoreColor = (v) => v >= 0.8 ? '#3fb950' : v >= 0.5 ? '#f0a500' : '#f85149';
const html = keys.map(sk => {
const p = panels[sk];
if (!p || !Array.isArray(p.rows)) return '';
const rowsHtml = p.rows.map(row => {
if (row.type === 'separator') {
return `<span style="color:#444;margin:0 4px">|</span>`;
}
if (row.type === 'score') {
const v = row.value;
if (v == null) return `<span style="color:#888">${row.label}</span> <span style="color:#666">—</span>`;
const passed = row.threshold != null && v >= row.threshold;
const color = passed ? '#3fb950' : '#f0a500';
return `<span style="color:#888">${row.label}</span> <span style="color:${color};font-weight:600">${v.toFixed(2)}</span>${row.threshold != null ? `<span style="color:#9aa5b8">/${row.threshold}</span>` : ''}`;
}
if (row.type === 'direction') {
const v = row.value;
const text = dirZh(v);
return `<span style="color:#888">${row.label}</span><span style="color:${dirColor(v)}">${text}</span>${row.extra ? `<span style="color:#3fb950"> ${row.extra}</span>` : ''}`;
}
if (row.type === 'text') {
// If value contains a parenthesized format like "62 (0.8)", try to highlight the score in parentheses
const v = row.value;
const color = row.color || '#c9d1d9';
if (typeof v === 'string' && /\(([\d.]+)\)/.test(v)) {
const html = v.replace(/\(([\d.]+)\)/, (_, num) => `<span style="color:#555">(</span><span style="color:${scoreColor(parseFloat(num))}">${num}</span><span style="color:#555">)</span>`);
return `<span style="color:#888">${row.label}</span><span style="color:${color}">${html}</span>`;
}
// Pure numbers are colored by score
if (typeof v === 'number' || (typeof v === 'string' && /^\d+(\.\d+)?$/.test(v))) {
const num = typeof v === 'number' ? v : parseFloat(v);
return `<span style="color:#888">${row.label}</span><span style="color:${scoreColor(num)}">${v}</span>`;
}
return `<span style="color:#888">${row.label}</span><span style="color:${color}">${v}</span>`;
}
return '';
}).join(' ');
return `<div style="color:#888;white-space:nowrap" title="Strategy ${sk.toUpperCase()} observe panel"><span style="color:${p.color || '#f0a500'};font-weight:600">${p.title}</span><span style="color:#444;margin:0 6px">|</span>${rowsHtml}</div>`;
}).join('');
$host.innerHTML = html;
}
function applyStrategyConfig(config) {
const activeEl = document.activeElement;
for (const key of strategyKeys) {
stratEnabled[key] = !!config.enabled?.[key];
stratAmount[key] = Number(config.amount?.[key]) || 1;
stratShares[key] = Number(config.shares?.[key]) || 5;
// Don't overwrite the current value of an input the user is focusing
if (stratChkEls[key] && stratChkEls[key] !== activeEl) stratChkEls[key].checked = stratEnabled[key];
if (stratAmtEls[key] && stratAmtEls[key] !== activeEl) stratAmtEls[key].value = String(stratAmount[key]);
const sharesEl = document.getElementById(`${key}-shares`);
if (sharesEl && sharesEl !== activeEl) sharesEl.value = String(stratShares[key]);
// Tunable params (e.g. l1's tpDelta/slDiff)
const paramVals = config.params?.[key];
if (paramVals && typeof paramVals === 'object') {
if (!stratParams[key]) stratParams[key] = {};
for (const pk of Object.keys(paramVals)) {
const v = Number(paramVals[pk]);
if (Number.isFinite(v)) {
stratParams[key][pk] = v;
const el = document.getElementById(`${key}-param-${pk}`);
if (el && el !== activeEl) el.value = String(v);
}
}
// Sync the sub-row summary text (input.value already updated, let the summary catch up)
const summaryEl = document.getElementById(`${key}-summary`);
const desc = (_allStrategyDescriptions || []).find(d => d.key === key);
if (summaryEl && desc?.tunableParams?.length) {
summaryEl.innerHTML = desc.tunableParams.map(p => {
const v = stratParams[key]?.[p.key] ?? p.defaultValue;
return `<span class="sum-lbl">${p.label}</span><span class="sum-val">${v}</span>`;
}).join('<span class="sum-sep">·</span>');
}
}
}
if (config.maxRoundEntries != null) {
maxRoundEntries = Number(config.maxRoundEntries) || 1;
if ($maxRoundEntries && $maxRoundEntries !== activeEl) $maxRoundEntries.value = String(maxRoundEntries);
}
if (config.slippage != null) {
const pct = Math.round(Number(config.slippage) * 100);
if ($stratSlippage && $stratSlippage !== activeEl) $stratSlippage.value = String(pct);
}
if (typeof config.marketHoursOnly === 'boolean') {
marketHoursOnly = config.marketHoursOnly;
if ($marketHoursOnly && $marketHoursOnly !== activeEl) $marketHoursOnly.checked = marketHoursOnly;
}
if (typeof config.weekendPause === 'boolean') {
weekendPause = config.weekendPause;
if ($weekendPause && $weekendPause !== activeEl) $weekendPause.checked = weekendPause;
// Refresh status text immediately after checkbox change (applyStrategyConfig is triggered by server push)
if (typeof renderWeekendStatus === 'function') renderWeekendStatus();
}
if (typeof config.autoClaimEnabled === 'boolean') {
autoClaimEnabled = config.autoClaimEnabled;
if ($autoClaimEnabled && $autoClaimEnabled !== activeEl) $autoClaimEnabled.checked = autoClaimEnabled;
}
}
async function saveStrategyConfigFromControls() {
if (stratConfigSaving) return;
const nextEnabled = {};
const nextAmount = {};
const nextShares = {};
const nextParams = {};
for (const key of strategyKeys) {
nextEnabled[key] = !!stratChkEls[key]?.checked;
// amount box (market strategies may not have one)
const amtEl = stratAmtEls[key];
if (amtEl) {
const amt = Number(amtEl.value);
if (Number.isFinite(amt) && amt >= 0.01) {
nextAmount[key] = amt;
}
}
// shares box (only limit strategies have one)
const sharesEl = document.getElementById(`${key}-shares`);
if (sharesEl) {
const sh = Number(sharesEl.value);
if (!Number.isFinite(sh) || sh < 5) {
setStratConfigNote(`Strategy ${key.slice(1)} shares must be >= 5`, '#f85149');
applyStrategyConfig({ enabled: stratEnabled, amount: stratAmount, shares: stratShares, params: stratParams });
setTimeout(() => { if (!stratConfigSaving) setStratConfigNote(); }, 2500);
return;
}
nextShares[key] = sh;
}
// Tunable params (tunableParams declared per strategy)
const desc = (_allStrategyDescriptions || []).find(d => d.key === key);
const defs = desc?.tunableParams || [];
if (defs.length) {
const sub = {};
for (const def of defs) {
const el = document.getElementById(`${key}-param-${def.key}`);
if (!el) continue;
const v = Number(el.value);
if (!Number.isFinite(v) || v < def.min || (def.max != null && v > def.max)) {
const range = def.max != null ? `[${def.min}, ${def.max}]` : `≥${def.min}`;
setStratConfigNote(`${key.toUpperCase()}·${def.label} must be in ${range}`, '#f85149');
applyStrategyConfig({ enabled: stratEnabled, amount: stratAmount, shares: stratShares, params: stratParams });
setTimeout(() => { if (!stratConfigSaving) setStratConfigNote(); }, 2500);
return;
}
sub[def.key] = v;
}
if (Object.keys(sub).length) nextParams[key] = sub;
}
}
// L1 cross-parameter constraints (caught early in the browser to avoid a server-side error rollback)
if (nextParams.l1) {
const p = nextParams.l1;
if (p.cancelDiff != null && p.entryDiff != null && p.cancelDiff >= p.entryDiff) {
setStratConfigNote(`L1 cancel threshold must be less than entry threshold`, '#f85149');
applyStrategyConfig({ enabled: stratEnabled, amount: stratAmount, shares: stratShares, params: stratParams });
setTimeout(() => { if (!stratConfigSaving) setStratConfigNote(); }, 2500);
return;
}
if (p.limitPrice != null && p.tpDelta != null && p.limitPrice + p.tpDelta > 0.99 + 1e-9) {
setStratConfigNote(`L1 entry price + TP price diff must be <= 0.99`, '#f85149');
applyStrategyConfig({ enabled: stratEnabled, amount: stratAmount, shares: stratShares, params: stratParams });
setTimeout(() => { if (!stratConfigSaving) setStratConfigNote(); }, 2500);
return;
}
if (p.remMin != null && p.remMax != null && p.remMin >= p.remMax) {
setStratConfigNote(`L1 entry time-remaining lower bound (${p.remMin}) must be less than the upper bound (${p.remMax})`, '#f85149');
applyStrategyConfig({ enabled: stratEnabled, amount: stratAmount, shares: stratShares, params: stratParams });
setTimeout(() => { if (!stratConfigSaving) setStratConfigNote(); }, 2500);
return;
}
}
stratConfigSaving = true;
setStratControlsDisabled(true);
setStratConfigNote('Saving...', '#f0a500');
try {
const res = await fetch('/api/strategy/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
enabled: nextEnabled,
amount: nextAmount,
shares: nextShares,
params: nextParams,
maxRoundEntries: Number($maxRoundEntries?.value) || 1,
slippage: (() => { const v = parseFloat($stratSlippage?.value); return (isFinite(v) ? v : 6) / 100; })(),
marketHoursOnly: !!$marketHoursOnly?.checked,
weekendPause: !!$weekendPause?.checked,
autoClaimEnabled: !!$autoClaimEnabled?.checked,
}),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
if (data.strategyConfig) applyStrategyConfig(data.strategyConfig);
setStratConfigNote('Saved', '#3fb950');
setTimeout(() => {
if (!stratConfigSaving) setStratConfigNote();
}, 1500);
} catch (e) {
applyStrategyConfig({ enabled: stratEnabled, amount: stratAmount });
setStratConfigNote(e.message?.slice(0, 40) || 'Save failed', '#f85149');
setTimeout(() => {
if (!stratConfigSaving) setStratConfigNote();
}, 2500);
} finally {
stratConfigSaving = false;
setStratControlsDisabled(false);
}
}
function isDirectionVerified(dir) {
if (!dir) return false;
return dir === 'up' ? curUpVerified : curDnVerified;
}
function hasOpenPosition() {
return curUpLocal > 0.01 || curDnLocal > 0.01;
}
function hasPendingBuyLock() {
return Date.now() < stratBuyLockUntil;
}
function isWindowExpired() {
return !!windowEnd && Math.floor(Date.now() / 1000) >= windowEnd;
}
// ── Countdown ────────────────────────────────────────────────────
setInterval(() => {
if (windowEnd) {
const rem = windowEnd - Math.floor(Date.now() / 1000);
if (rem <= 0) {
$remaining.textContent = '0:00';
$orderRemaining.textContent = '0:00';
} else {
const remStr = `${Math.floor(rem/60)}:${String(rem%60).padStart(2,'0')}`;
$remaining.textContent = remStr;
$orderRemaining.textContent = remStr;
$remaining.classList.toggle('ending', rem <= 30);
}
}
// Calibration countdown
const $cd = document.getElementById('calib-countdown');
if ($cd) {
const msSinceTrade = curLastTradeAt ? Date.now() - curLastTradeAt : Infinity;
const calibRem = Math.ceil((15000 - msSinceTrade) / 1000);
if (calibRem > 0) {
$cd.textContent = `Calibrating ${calibRem}s`;
} else {
$cd.textContent = '';
}
}
// Claim feature removed: enable Auto Redeem in Settings on the Polymarket website
updateCompactOverview();
}, 250);
// ── Order book rendering ──────────────────────────────────────────────────
function renderOrders(bids, asks) {
const maxSize = Math.max(...bids.map(b=>b.size), ...asks.map(a=>a.size), 1);
$asksWrap.innerHTML = [...asks].sort((a,b)=>a.price-b.price).slice(0,6).sort((a,b)=>b.price-a.price).map(a => {
const pct = (a.size/maxSize*100).toFixed(1);
return `<div class="order-row ask">
<div class="depth-bar" style="width:${pct}%"></div>
<span class="up-price down">${Math.round(a.price*100)}%</span>
<span class="qty">${a.size.toFixed(1)}</span>
<span class="dn-price down">${Math.round((1-a.price)*100)}%</span>
</div>`;
}).join('');
$bidsWrap.innerHTML = [...bids].sort((a,b)=>b.price-a.price).slice(0,6).map(b => {
const pct = (b.size/maxSize*100).toFixed(1);
return `<div class="order-row bid">
<div class="depth-bar" style="width:${pct}%"></div>
<span class="up-price up">${Math.round(b.price*100)}%</span>
<span class="qty">${b.size.toFixed(1)}</span>
<span class="dn-price up">${Math.round((1-b.price)*100)}%</span>
</div>`;
}).join('');
}
// ── WS message handling ───────────────────────────────────────────────
function onMessage(msg) {
// Frontend <-> backend RTT pong handling (handled first, high frequency)
if (msg.type === 'pong') {
if (typeof msg.clientTs === 'number') {
const rtt = Date.now() - msg.clientTs;
lastPongAt = Date.now();
const el = document.getElementById('frontend-latency');
const val = document.getElementById('frontend-latency-val');
if (el && val) {
el.classList.remove('http-fast', 'http-slow', 'http-bad');
val.textContent = rtt + 'ms';
if (rtt < 50) el.classList.add('http-fast');
else if (rtt < 200) el.classList.add('http-slow');
else el.classList.add('http-bad');
}
}
return;
}
if (msg.type === 'clientConfig') {
if (msg.dataMode === 'low' || msg.dataMode === 'full') {
setLowDataMode(msg.dataMode === 'low', { persist: true, syncServer: false, clearBuffers: msg.dataMode === 'low' });
}
return;
}
if (msg.type === 'pmPnl') {
renderPmPnl(msg.events || [], msg.total || null, msg.nextRefreshAt, msg.today || null);
return;
}
if (msg.type === 'window') {
windowEnd = msg.windowEnd;
$range.textContent = `${new Date(msg.windowStart*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})} → ${new Date(msg.windowEnd*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`;
updateCompactOverview();
return;
}
if (msg.type === 'claimProgress') {
claimState.claiming = msg.current < msg.total || msg.status === 'running';
claimState.claimColor = msg.status === 'error' ? '#f85149' : '#f0a500';
claimState.claimText = msg.status === 'error'
? `Claiming ${msg.current}/${msg.total}, ${msg.title} failed`
: `Claiming ${msg.current}/${msg.total}`;
// Batch finished (last item handled, success or fail) -> release lock
if (msg.current >= msg.total && msg.status !== 'running') {
setTimeout(() => { claimState.claiming = false; renderClaimStatus(); }, 1500);
}
renderClaimStatus();
return;
}
if (msg.type === 'claimable') {
claimState.total = Number(msg.total) || 0;
claimState.count = (msg.positions || []).length;
renderClaimStatus();
return;
}
if (msg.type === 'claimCooldown') {
claimState.running = !!msg.running;
claimState.nextCheckAt = msg.nextCheckAt || 0;
claimState.cooldownUntil = msg.cooldownUntil || 0;
renderClaimStatus();
return;
}
if (msg.type === 'backtestStatus') {
backtestCollecting = !!msg.collecting;
updateBacktestUI();
return;
}
if (msg.type === 'recentResults') {
msg.results.forEach((r, i) => {
const dot = document.getElementById(`rd-${i}`);
if (!dot) return;
const tip = r.result
? `${r.timeRange} ${r.result === 'up' ? 'Up won' : 'Down won'}`
: `${r.timeRange} Pending`;
dot.className = 'result-dot' + (r.result ? ' ' + r.result : '');
dot.innerHTML = `${r.result === 'up' ? '▲' : r.result === 'down' ? '▼' : '?'}<span class="dot-tip">${tip}</span>`;
});
return;
}
if (msg.type === 'wsStatus') {
['market','chainlink','user','binance','coinbase'].forEach(k => {
const el = document.getElementById('ws-' + k);
if (el) el.classList.toggle('on', !!msg[k]);
});
return;
}
if (msg.type === 'httpHeartbeat' || msg.type === 'axiosHeartbeat') {
const elId = msg.type === 'axiosHeartbeat' ? 'axios-latency' : 'http-latency';
const valId = msg.type === 'axiosHeartbeat' ? 'axios-latency-val' : 'http-latency-val';
const el = document.getElementById(elId);
const val = document.getElementById(valId);
if (el && val) {
el.classList.remove('http-fast', 'http-slow', 'http-bad');
if (!msg.ok || msg.latencyMs < 0) {
val.textContent = 'DC';
el.classList.add('http-bad');
} else {
val.textContent = msg.latencyMs + 'ms';
if (msg.latencyMs < 500) el.classList.add('http-fast');
else if (msg.latencyMs < 1000) el.classList.add('http-slow');
else el.classList.add('http-bad');
}
}
return;
}
if (msg.type === 'orderLatency') {
renderOrderLatency(msg);
return;
}
if (msg.type === 'conditionOrders') {
renderConditionOrders(Array.isArray(msg.list) ? msg.list : []);
return;
}
if (msg.type === 'condFilled') {
const dirZh = msg.direction === 'up' ? 'Up▲' : 'Down▼';
const price = ((msg.price ?? 0) * 100).toFixed(1);
const isSl = msg.kind === 'sl';
const label = isSl ? 'Stop-loss filled' : 'Take-profit filled';
const cls = isSl ? 'fail' : 'ok';
const $r = document.getElementById('order-result');
if ($r) { $r.textContent = `✓ ${label} ${dirZh} @${price}%`; $r.className = cls; setTimeout(() => { $r.className = ''; }, 8000); }
return;
}
if (msg.type === 'manualLimitOrders') {
const list = Array.isArray(msg.list) ? msg.list : [];
renderManualLimitOrders(list);
renderStrategyLimitStatus(list);
return;
}
if (msg.type === 'manualConfig') {
if (msg.manualConfig && !manualConfigSaving) {
manualConfig = msg.manualConfig;
condConfig = manualConfig.cond;
applyManualConfigToUi();
manualConfigInitialized = true;
}
return;
}
if (msg.type === 'tgConfig') {
applyTgConfig(msg);
return;
}
if (msg.type === 'chainlinkDown') {
$cur.textContent = '-';
$diff.textContent = '-'; $orderDiff.textContent = '-';
_signalDiff = NaN;
serverBinanceDiff = NaN;
updateBnDiff();
updateCompactOverview();
return;
}
if (msg.type === 'chainlinkPrice') {
if (lowDataMode) return;
chartPoints.push({ t: msg.t, price: msg.price });
const cutoff = Date.now() - 130000;
while (chartPoints.length > 0 && chartPoints[0].t < cutoff) chartPoints.shift();
if (chartPoints.length > 2000) chartPoints.splice(0, chartPoints.length - 2000);
drawChart();
return;
}
if (msg.type === 'marketDown') {
clearBestDrivenState('Waiting for order book...');
$asksWrap.innerHTML = '';
$bidsWrap.innerHTML = '';
serverBinanceDiff = NaN;
updateBnDiff();
updateOrderAvailability();
updatePosSummary();
updateEst();
updateStratUI();
return;
}
if (msg.type === 'binancePrice') {
if (lowDataMode) return;
binancePoints.push({ t: msg.t, price: msg.price });
const cutoff = Date.now() - 130000;
while (binancePoints.length > 0 && binancePoints[0].t < cutoff) binancePoints.shift();
updateMomentum();
updateVol();
updateBnDiff();
return;
}
if (msg.type === 'coinbasePrice') {
if (lowDataMode) return;
coinbasePoints.push({ t: msg.t, price: msg.price });
const cutoff = Date.now() - 130000;
while (coinbasePoints.length > 0 && coinbasePoints[0].t < cutoff) coinbasePoints.shift();
return;
}
if (msg.type !== 'state') return;
// US market-closed status (single source: server push)
if (msg.marketStatus && typeof msg.marketStatus.paused === 'boolean') {
_marketStatus = {
paused: msg.marketStatus.paused,
secondsUntilNext: Number(msg.marketStatus.secondsUntilNext) || 0,
syncedAt: Date.now(),
};
if (typeof renderWeekendStatus === 'function') renderWeekendStatus();
}
// Current market (multi-asset + multi-period switching)
if (msg.activeMarket && msg.activeMarket.symbol) {
updateMarketUI(msg.activeMarket);
}
// Account username (to distinguish multiple instances)
if (typeof msg.accountName === 'string') {
const txt = msg.accountName ? `👤 ${msg.accountName}` : '';
const $id = document.getElementById('order-account-id');
if ($id && $id.textContent !== txt) $id.textContent = txt;
const $ldh = document.getElementById('ldh-account');
if ($ldh && $ldh.textContent !== txt) $ldh.textContent = txt;
}
// Window
if (msg.windowEnd) {
windowEnd = msg.windowEnd;
$range.textContent = `${new Date(msg.windowStart*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})} → ${new Date(msg.windowEnd*1000).toLocaleTimeString([],{hour:'2-digit',minute:'2-digit'})}`;
// New window resets the chart
if (msg.windowStart && msg.windowStart !== chartWindowStart) {
chartWindowStart = msg.windowStart;
chartReset();
}
}
// Chart update: always use the timestamps in the server's priceHistory to avoid frontend local-time drift
const prevPtb = chartPtb;
chartPtb = msg.priceToBeat ?? null;
// On first PTB (or a magnitude jump > 10x, e.g. switching asset), reset probability scaling to the current price magnitude
if (chartPtb != null && (prevPtb == null || Math.abs(Math.log10(chartPtb / Math.max(prevPtb, 0.0001))) > 1)) {
probScale = getDefaultProbScale();
}
if (Array.isArray(msg.priceHistory)) {
chartPoints.length = 0;
for (const p of msg.priceHistory) chartPoints.push(p);
const cutoff = Date.now() - 130000;
while (chartPoints.length > 0 && chartPoints[0].t < cutoff) chartPoints.shift();
drawChart();
}
// Binance history: backfill the historical trace after refresh
if (Array.isArray(msg.binanceHistory)) {
binancePoints.length = 0;
for (const p of msg.binanceHistory) binancePoints.push(p);
drawChart();
}
// Coinbase history
if (Array.isArray(msg.coinbaseHistory)) {
coinbasePoints.length = 0;
for (const p of msg.coinbaseHistory) coinbasePoints.push(p);
drawChart();
}
if (typeof msg.binanceOffset === 'number') {
binanceOffset = msg.binanceOffset;
} else if (msg.binanceOffset == null) {
binanceOffset = 0;
}
if (typeof msg.coinbaseOffset === 'number') {
coinbaseOffset = msg.coinbaseOffset;
} else if (msg.coinbaseOffset == null) {
coinbaseOffset = 0;
}
serverBinanceDiff = typeof msg.binanceDiff === 'number' ? msg.binanceDiff : NaN;
updateBnDiff();
// Backend-pushed volatility: overrides #btc-vol (in low mode the frontend stops computing and relies on the backend)
// In full mode the frontend updateVol() still computes one, but state push frequency is low (200ms) so they don't conflict
if (typeof msg.volPct === 'number' || msg.volPct === null) {
serverVolPct = msg.volPct;
updateVolFromState();
}
// Update candle status
if (msg.klineCounts) {
const $k1 = document.getElementById('kline-1m-count');
const $k5 = document.getElementById('kline-5m-count');
const k1 = Number(msg.klineCounts.k1m) || 0;
const k5 = Number(msg.klineCounts.k5m) || 0;
if ($k1) {
$k1.textContent = String(k1);
$k1.style.color = k1 >= 120 ? '#3fb950' : k1 >= 30 ? '#f0a500' : '#f85149';
}
if ($k5) {
$k5.textContent = String(k5);
$k5.style.color = k5 >= 20 ? '#3fb950' : k5 >= 5 ? '#f0a500' : '#f85149';
}
}
// Price (decimals shown by the current asset's magnitude)
const dec = priceDecimals();
$ptb.textContent = msg.priceToBeat != null ? `$${msg.priceToBeat.toFixed(dec)}` : '—';
$ptb.className = 'price-val gray';
$cur.textContent = msg.currentPrice != null ? `$${msg.currentPrice.toFixed(dec)}` : '—';
$cur.className = msg.currentPrice != null ? 'price-val' : 'price-val gray';
if (msg.priceToBeat != null && msg.currentPrice != null) {
const d = msg.currentPrice - msg.priceToBeat;
_signalDiff = d;
const diffStr = fmtDiffWithPct(d);
const diffCls = d >= 0 ? 'up' : 'down';
$diff.textContent = diffStr; $diff.className = diffCls;
} else {
_signalDiff = NaN;
$diff.textContent = '—'; $diff.className = 'gray';
}
// The order panel's "diff" shows the Binance diff (strategies also use Binance, for consistency)
// Diff decimals follow the current asset's magnitude (BTC 0 / ETH 2 / SOL 4)
const bnD = getBnDiff();
if (!isNaN(bnD)) {
let bdec = 2;
try {
const d = priceDecimals();
if (Number.isInteger(d) && d >= 0 && d <= 8) bdec = d;
} catch (e) {}
$orderDiff.textContent = `${bnD >= 0 ? '+' : '-'}$${Math.abs(bnD).toFixed(bdec)}`;
$orderDiff.className = bnD >= 0 ? 'up' : 'down';
} else {
$orderDiff.textContent = '—';
$orderDiff.className = 'gray';
}
// Order book
probabilityReady = !!msg.probabilityReady;
const bid = probabilityReady ? parseFloat(msg.bestBid) : NaN;
const ask = probabilityReady ? parseFloat(msg.bestAsk) : NaN;
curBestBid = bid; curBestAsk = ask;
$bestBid.textContent = isNaN(bid) ? '—' : Math.round(bid*100) + '%';
$bestAsk.textContent = isNaN(ask) ? '—' : Math.round(ask*100) + '%';
$spreadVal.textContent = !isNaN(bid) && !isNaN(ask) ? Math.round((ask-bid)*100) + '%' : '—';
if (msg.bids && msg.asks) renderOrders(msg.bids, msg.asks);
// Probability
if (!isNaN(bid) && !isNaN(ask)) {
const mid = (bid+ask)/2;
const up = Math.round(mid*100), dn = Math.round((1-mid)*100);
_signalUpPct = up; _signalDnPct = dn;
$probUp.textContent = up+'%'; $probDown.textContent = dn+'%';
$probFill.style.width = up+'%';
const $upPct = document.getElementById('dir-up-pct');
const $dnPct = document.getElementById('dir-dn-pct');
$upPct.textContent = up+'%'; $upPct.style.color = '#3fb950';
$dnPct.textContent = dn+'%'; $dnPct.style.color = '#f85149';
// Record probability history
probHistory.push({ t: Date.now(), mid });
const cutoff = Date.now() - 130000;
while (probHistory.length > 0 && probHistory[0].t < cutoff) probHistory.shift();
} else {
_signalUpPct = NaN;
_signalDnPct = NaN;
$probUp.textContent = '—';
$probDown.textContent = '—';
$probFill.style.width = '50%';
}
updateMomentum();
// Account positions
curUpLocal = msg.upLocalSize ?? 0;
curDnLocal = msg.downLocalSize ?? 0;
curUpApi = msg.upApiSize ?? 0;
curDnApi = msg.downApiSize ?? 0;
curApiSyncAt = msg.lastApiSyncAt ?? 0;
curUpVerified = msg.upApiVerified ?? false;
curDnVerified = msg.downApiVerified ?? false;
curLastTradeAt = msg.lastTradeAt ?? 0;
curUsdc = typeof msg.usdc === 'number' ? msg.usdc : null;
$acctUsdc.textContent = msg.usdc != null ? `$${msg.usdc.toFixed(2)}` : '—';
if ($acctAllowanceStatus) {
const status = msg.usdcAllowanceStatus || '—';
$acctAllowanceStatus.textContent = status;
$acctAllowanceStatus.className = 'allowance-status ' + (
status === 'Authorized' ? 'up' : status === 'Not fully authorized' ? 'yellow' : 'down'
);
const details = Array.isArray(msg.usdcAllowanceDetails) ? msg.usdcAllowanceDetails : [];
if ($acctAllowanceTooltip) {
const allowanceNameMap = {
'0x4bFb41d5B3570DeFd03C39a9A4D8dE6Bd8B8982E': 'Regular market',
'0xC5d563A36AE78145C45a50134d48A1215220f80a': 'Multi-outcome market',
'0xd91E80cF2E7be2e162c6513ceD06f1dD0dA35296': 'Multi-outcome adapter',
'default': 'Default approval',
};
$acctAllowanceTooltip.innerHTML = details.length
? details.map((item) => {
const short = allowanceNameMap[item.spender] || 'Other approval';
const amount = typeof item.amount === 'number'
? (item.amount > 1e9 ? 'Unlimited' : '$' + item.amount.toFixed(2))
: '—';
return `<div>${short}: ${amount}</div>`;
}).join('')
: '<div>No approval details</div>';
}
}
$acctUpL.textContent = fmtSize(curUpLocal);
$acctUpA.textContent = fmtSize(curUpApi);
$acctDnL.textContent = fmtSize(curDnLocal);
$acctDnA.textContent = fmtSize(curDnApi);
$lastSync.textContent = curApiSyncAt ? new Date(curApiSyncAt).toLocaleTimeString() : '—';
const $upTag = document.getElementById('up-verified-tag');
const $dnTag = document.getElementById('dn-verified-tag');
$upTag.textContent = curUpVerified ? 'Calibrated' : 'Not calibrated';
$upTag.className = 'verified-tag' + (curUpVerified ? ' ok' : '');
$dnTag.textContent = curDnVerified ? 'Calibrated' : 'Not calibrated';
$dnTag.className = 'verified-tag' + (curDnVerified ? ' ok' : '');
if (msg.strategyConfig && !stratConfigSaving) {
// Cache the latest config; reapplied after initStrategyUI completes
_pendingStrategyConfig = msg.strategyConfig;
if (strategyKeys.length > 0) {
applyStrategyConfig(msg.strategyConfig);
}
}
if (msg.strategy) {
strat.state = msg.strategy.state || 'IDLE';
strat.activeStrategy = msg.strategy.activeStrategy ?? null;
strat.direction = msg.strategy.direction ?? null;
strat.buyAmount = Number(msg.strategy.buyAmount) || 0;
strat.posBeforeBuy = Number(msg.strategy.posBeforeBuy) || 0;
strat.posBeforeSell = Number(msg.strategy.posBeforeSell) || 0;
strat.waitVerifyAfterSell = !!msg.strategy.waitVerifyAfterSell;
strat.cleanupAfterVerify = !!msg.strategy.cleanupAfterVerify;
strat.actionTs = Number(msg.strategy.actionTs) || 0;
strat.prevUpPct = typeof msg.strategy.prevUpPct === 'number' ? msg.strategy.prevUpPct : NaN;
strat.perStrategy = msg.strategy.perStrategy || {};
stratBuyLockUntil = Number(msg.strategy.buyLockUntil) || 0;
// Generic observe panels (rendered from the server-pushed observePanels)
renderObservePanels(msg.strategy.observePanels || {});
}
updateFairProb(msg);
updateOrderAvailability();
updatePosSummary();
updateEst();
updateSignal();
updateStratUI();
updateCompactOverview();
}
let _lastSignalKey = '';
let _lastSignalTime = 0; // Timestamp of the last history record triggered (ms)
const SIGNAL_COOLDOWN = 5000; // 5-second cooldown
// Decide buy direction from diff and probability
function pickSignalDir(d, upPct, dnPct) {
if (d > 0) return 'down'; // Up too much, buy down
if (d < 0) return 'up'; // Down too much, buy up
return dnPct < upPct ? 'down' : 'up'; // Diff is 0, choose the cheaper one
}
function clearBestDrivenState() {
curBestBid = NaN;
curBestAsk = NaN;
probabilityReady = false;
_signalDiff = NaN;
_signalUpPct = NaN;
_signalDnPct = NaN;
serverBinanceDiff = NaN;
_lastSignalKey = '';
$bestBid.textContent = '—';
$bestAsk.textContent = '—';
$spreadVal.textContent = '—';
$probUp.textContent = '—';
$probDown.textContent = '—';
$probFill.style.width = '50%';
updateBnDiff();
updateCompactOverview();
}
// ── Auto strategy ──────────────────────────────────────────────────
function updateStratUI() {
const el = document.getElementById('strat-state-text');
if (!el) return;
const anyEnabled = strategyKeys.some(k => stratEnabled[k]);
// Scan info: diff, probability, offset-active countdown -> attach to the status block's title (shown on hover)
const statusBox = document.getElementById('strat-status');
if (statusBox) {
const bnDiff = getBnDiff();
const cooldown = Math.ceil((500 - (Date.now() - _bnDragEndTime)) / 1000);
const cooldownStr = cooldown > 0 ? ` ⏳${cooldown}s` : '';
const diffStr = isNaN(bnDiff) ? (cooldown > 0 ? `Offset active ${cooldownStr}` : 'Diff -') : `Diff ${bnDiff >= 0 ? '+' : ''}${Math.round(bnDiff)}`;
const upStr = isNaN(_signalUpPct) ? '' : ` Up ${_signalUpPct}% Down ${_signalDnPct}%`;
statusBox.title = diffStr + upStr;
}
// Strategy tag highlight: active strategy in orange
const rem = windowEnd ? windowEnd - Math.floor(Date.now() / 1000) : 0;
for (const key of strategyKeys) {
const lbl = stratChkEls[key]?.closest('label');
if (!lbl) continue;
if (strat.activeStrategy === key) {
lbl.style.color = '#f0a500';
} else {
lbl.style.color = '';
}
}
const s = strat.state;
const sn = strat.activeStrategy;
const dir = strat.direction === 'up' ? 'Up' : 'Down';
const myPct = strat.direction === 'up' ? _signalUpPct : _signalDnPct;
if (!anyEnabled && s === 'IDLE') {
el.className = 'idle'; el.textContent = 'Strategy: disabled'; return;
}
if (isNaN(curBestBid) || isNaN(curBestAsk)) {
if (s === 'HOLDING') {
const pctStr = isNaN(myPct) ? '' : ` prob ${myPct}%`;
el.className = 'hold'; el.textContent = `Strategy ${sn} holding ${dir}${pctStr} | waiting for order book best`; return;
}
if (s === 'WAIT_FILL') {
const elapsed = Math.floor((Date.now() - strat.actionTs) / 1000);
el.className = 'active'; el.textContent = `Strategy ${sn} waiting for fill ${elapsed}s`; return;
}
if (s === 'RECONCILING_FILL') {
const elapsed = Math.floor((Date.now() - strat.actionTs) / 1000);
el.className = 'active'; el.textContent = `Strategy ${sn} confirming delay ${elapsed}s`; return;
}
if (s === 'WAIT_SELL_FILL') {
el.className = 'active'; el.textContent = `Strategy ${sn} waiting for sell confirmation`; return;
}
if (s === 'BUYING') {
el.className = 'active'; el.textContent = `Strategy ${sn} buying ${dir}`; return;
}
if (s === 'SELLING') {
el.className = 'active'; el.textContent = `Strategy ${sn} selling`; return;
}
el.className = 'idle'; el.textContent = 'Strategy: paused (waiting for order book best)'; return;
}
if (s === 'IDLE' || s === 'SCANNING') {
el.className = 'scan'; el.textContent = `Strategy: scanning...`; return;
}
if (s === 'BUYING') {
el.className = 'active'; el.textContent = `Strategy ${sn} buying ${dir}`; return;
}
if (s === 'WAIT_FILL') {
const elapsed = Math.floor((Date.now() - strat.actionTs) / 1000);
el.className = 'active'; el.textContent = `Strategy ${sn} waiting for fill ${elapsed}s`; return;
}
if (s === 'RECONCILING_FILL') {
const elapsed = Math.floor((Date.now() - strat.actionTs) / 1000);
el.className = 'active'; el.textContent = `Strategy ${sn} confirming delay ${elapsed}s`; return;
}
if (s === 'HOLDING') {
const pctStr = isNaN(myPct) ? '' : ` prob ${myPct}%`;
el.className = 'hold'; el.textContent = `Strategy ${sn} holding ${dir}${pctStr}`; return;
}
if (s === 'SELLING') {
el.className = 'active'; el.textContent = `Strategy ${sn} selling`; return;
}
if (s === 'WAIT_SELL_FILL') {
el.className = 'active'; el.textContent = `Strategy ${sn} waiting for sell confirmation`; return;
}
if (s === 'DONE') {
if (strat.cleanupAfterVerify) {
el.className = 'done'; el.textContent = `Strategy ${sn} waiting for calibration to check residual position`; return;
}
el.className = 'done'; el.textContent = `Strategy ${sn} done ✓`; return;
}
}
function getBnDiff() {
if (!isNaN(serverBinanceDiff)) return serverBinanceDiff;
if (!binancePoints.length || chartPtb == null) return NaN;
const bnPrice = binancePoints[binancePoints.length - 1].price;
return bnPrice - (chartPtb - binanceOffset);
}
// stratBuy/stratSell/stratReset/checkEntry/checkExit logic has all moved to the backend strategy modules
// The frontend only receives the server-broadcast strategy state over WS and updates the UI
// The strategy state-machine logic runs entirely on the backend; the frontend only updates the UI
function strategyTick() {
updateStratUI();
}
// ── Dynamic strategy UI initialization ───────────────────────────────────────
let _allStrategyDescriptions = []; // All raw strategy descriptions (including supportedMarkets)
// Whether the current activeMarket is in the strategy's support list (same semantics as server-side isStrategySupportedHere)
function isStrategySupportedForCurrentMarket(desc) {
const list = desc && desc.supportedMarkets;
if (!list || list.length === 0) return true;
return list.includes(activeMarketSymbol + '-' + activeMarketPeriod);
}
function renderStrategyUI() {
const container = document.getElementById('strat-groups');
if (!container) return;
// Filter by current market
const descriptions = _allStrategyDescriptions.filter(isStrategySupportedForCurrentMarket);
container.innerHTML = '';
if (descriptions.length === 0) {
container.innerHTML = `<span style="color:#888;font-size:11px">Current market (${activeMarketSymbol.toUpperCase()} ${activeMarketPeriod}) has no available strategies</span>`;
return;
}
// Group by type
const catMap = new Map();
const catOrder = [];
for (const desc of descriptions) {
const cat = desc.category || { id: 'other', label: 'Other', color: '#888' };
if (!catMap.has(cat.id)) {
catMap.set(cat.id, { label: cat.label, color: cat.color, keys: [] });
catOrder.push(cat.id);
}
catMap.get(cat.id).keys.push(desc.key);
}
const categories = catOrder.map(id => ({ id, ...catMap.get(id) }));
const seen = new Set();
const buildGroup = (desc) => {
const group = document.createElement('div');
group.className = 'strat-group';
let tipHtml = `<div style="color:#f0a500;margin-bottom:4px;font-size:14px;font-weight:700">${desc.title}</div>`;
// Color remap: the dark grays written in strategy code (#888 / #7d8590 / #666) are hard to read on the popup background
// Map them uniformly to a lighter gray here, without touching strategy code
const remapDimColor = (c) => {
if (!c) return c;
const lc = c.toLowerCase();
if (lc === '#888' || lc === '#7d8590' || lc === '#666' || lc === '#999') return '#b6becb';
return c;
};
for (const line of desc.lines) {
const style = [];
const color = remapDimColor(line.color);
if (color) style.push(`color:${color}`);
if (line.marginTop) style.push('margin-top:4px');
const styleAttr = style.length ? ` style="${style.join(';')}"` : '';
tipHtml += `<div${styleAttr}>${line.text}</div>`;
}
const orderType = desc.orderType || 'market';
const showAmount = orderType === 'market' || orderType === 'both';
const showShares = orderType === 'limit' || orderType === 'both';
const amountHtml = showAmount
? `<input type="number" class="strat-amt" id="${desc.key}-amt" value="1" min="0.01" step="0.01" title="Market position amount (USDC)"><span style="color:#555">U</span>`
: '';
const sharesHtml = showShares
? `<input type="number" class="strat-amt" id="${desc.key}-shares" value="5" min="5" step="1" title="Limit order shares (minimum 5)" style="background:#1a2030"><span style="color:#888;font-size:10px">shares</span>`
: '';
// Params area: summary always shown (current values visible even when collapsed); click [> Params] to open the popup
// Summary = all current params as "label:value" joined by - into one line
const params = desc.tunableParams || [];
const hasParams = params.length > 0;
// Summary initial value: prefer the persisted stratParams, fall back to defaultValue if none
const summaryText = hasParams
? params.map(p => {
const persisted = stratParams[desc.key]?.[p.key];
const v = (typeof persisted === 'number' && Number.isFinite(persisted)) ? persisted : p.defaultValue;
return `<span class="sum-lbl">${p.label}</span><span class="sum-val">${v}</span>`;
}).join('<span class="sum-sep">·</span>')
: '';
const paramsInnerHtml = params.map((p) => {
const maxAttr = p.max != null ? ` max="${p.max}"` : '';
const titleAttr = p.title ? ` title="${p.title.replace(/"/g, '&quot;')}"` : '';
const unit = p.unit ? `<span class="strat-pill-unit">${p.unit}</span>` : '';
// First render: input.value prefers the persisted stratParams, otherwise defaultValue
const persisted = stratParams[desc.key]?.[p.key];
const initVal = (typeof persisted === 'number' && Number.isFinite(persisted)) ? persisted : p.defaultValue;
// Float params (step < 1) don't render the min attribute -- to avoid "min=0 + value=0" causing the browser to swallow decimal-point input
// Validation is still done by JS at save time per p.min/p.max (see saveStrategyConfigFromControls)
const isFloat = typeof p.step === 'number' && p.step > 0 && p.step < 1;
const minAttr = isFloat ? '' : ` min="${p.min}"`;
return `<span class="strat-pill"${titleAttr}>`
+ `<span class="strat-pill-lbl">${p.label}</span>`
+ `<input type="number" class="strat-amt strat-pill-input" id="${desc.key}-param-${p.key}" value="${initVal}"${minAttr}${maxAttr} step="${p.step}">`
+ unit
+ `</span>`;
}).join('');
// Main row = label + amount/shares + params button (compact and tidy)
// Sub-row = current param summary (faint small text, fully shown, not truncated)
// The popup is still triggered by the button
const popupHtml = hasParams
? `<span class="strat-popup-wrap">`
+ `<button type="button" class="strat-toggle" data-key="${desc.key}" title="Expand param panel">> Params</button>`
+ `<div class="strat-popup" id="${desc.key}-popup" data-key="${desc.key}">`
+ ` <div class="strat-popup-title">${desc.key.toUpperCase()}·${desc.name} params</div>`
+ ` <div class="strat-popup-body">${paramsInnerHtml}</div>`
+ ` <div class="strat-popup-foot">`
+ ` <button type="button" class="strat-reset" data-key="${desc.key}" title="Reset to defaults">↺ Restore defaults</button>`
+ ` </div>`
+ `</div>`
+ `</span>`
: '';
const summaryRowHtml = hasParams
? `<div class="strat-summary-row"><span class="strat-summary" id="${desc.key}-summary">${summaryText}</span></div>`
: '';
group.innerHTML = `
<div class="strat-main-row">
<label>
<input type="checkbox" id="${desc.key}-chk" title="Only modify the current run state">
<span class="strat-tip">${desc.key.toUpperCase()}·${desc.name}
<div class="tip-box">${tipHtml}</div>
</span>
</label>
${amountHtml}
${sharesHtml}
${popupHtml}
</div>
${summaryRowHtml}
`;
return group;
};
for (const cat of categories) {
const cat_descs = descriptions.filter((d) => cat.keys.includes(d.key));
if (!cat_descs.length) continue;
const row = document.createElement('div');
row.className = 'strat-row';
row.style.cssText = 'display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap';
const catLabel = document.createElement('span');
catLabel.style.cssText = `font-size:11px;font-weight:600;color:${cat.color};min-width:60px;padding-top:9px`;
catLabel.textContent = cat.label;
row.appendChild(catLabel);
for (const desc of cat_descs) {
row.appendChild(buildGroup(desc));
seen.add(desc.key);
}
container.appendChild(row);
}
const others = descriptions.filter((d) => !seen.has(d.key));
if (others.length) {
const row = document.createElement('div');
row.className = 'strat-row';
row.style.cssText = 'display:flex;align-items:flex-start;gap:12px;flex-wrap:wrap';
const catLabel = document.createElement('span');
catLabel.style.cssText = 'font-size:11px;font-weight:600;color:#888;min-width:60px;padding-top:9px';
catLabel.textContent = 'Other';
row.appendChild(catLabel);
for (const desc of others) {
row.appendChild(buildGroup(desc));
}
container.appendChild(row);
}
// Rebind control references (old elements were cleared by innerHTML)
// Clear the cache first to avoid referencing removed DOM
for (const k of Object.keys(stratChkEls)) delete stratChkEls[k];
for (const k of Object.keys(stratAmtEls)) delete stratAmtEls[k];
// Summary refresh: render all current params as "label<span>value</span>" rich text, with highlighted numbers
const refreshStratSummary = (key) => {
const summaryEl = document.getElementById(`${key}-summary`);
if (!summaryEl) return;
const desc = descriptions.find(d => d.key === key);
if (!desc) return;
const parts = (desc.tunableParams || []).map(p => {
const el = document.getElementById(`${key}-param-${p.key}`);
const v = el ? el.value : p.defaultValue;
return `<span class="sum-lbl">${p.label}</span><span class="sum-val">${v}</span>`;
});
summaryEl.innerHTML = parts.join('<span class="sum-sep">·</span>');
};
for (const desc of descriptions) {
stratChkEls[desc.key] = document.getElementById(`${desc.key}-chk`);
stratAmtEls[desc.key] = document.getElementById(`${desc.key}-amt`);
if (stratChkEls[desc.key]) stratChkEls[desc.key].addEventListener('change', saveStrategyConfigFromControls);
if (stratAmtEls[desc.key]) stratAmtEls[desc.key].addEventListener('change', saveStrategyConfigFromControls);
const sharesEl = document.getElementById(`${desc.key}-shares`);
if (sharesEl) sharesEl.addEventListener('change', saveStrategyConfigFromControls);
for (const p of (desc.tunableParams || [])) {
const el = document.getElementById(`${desc.key}-param-${p.key}`);
if (el) {
el.addEventListener('change', () => {
refreshStratSummary(desc.key);
saveStrategyConfigFromControls();
});
}
}
// Expand button: toggle the popup .open + switch >/v; also close any other open popups (only one open at a time)
const toggleBtn = document.querySelector(`.strat-toggle[data-key="${desc.key}"]`);
const popup = document.getElementById(`${desc.key}-popup`);
if (toggleBtn && popup) {
toggleBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
const isOpen = popup.classList.contains('open');
// Close all other popups first
document.querySelectorAll('.strat-popup.open').forEach((el) => {
el.classList.remove('open');
const k = el.getAttribute('data-key');
const t = document.querySelector(`.strat-toggle[data-key="${k}"]`);
if (t) t.textContent = '> Params';
});
// Then toggle the current one
if (!isOpen) {
popup.classList.add('open');
toggleBtn.textContent = 'v Params';
}
});
}
// Reset button: restore all inputs to defaultValue + sync summary + save
const resetBtn = document.querySelector(`.strat-reset[data-key="${desc.key}"]`);
if (resetBtn) {
resetBtn.addEventListener('click', (ev) => {
ev.stopPropagation();
for (const p of (desc.tunableParams || [])) {
const el = document.getElementById(`${desc.key}-param-${p.key}`);
if (el) el.value = p.defaultValue;
}
refreshStratSummary(desc.key);
saveStrategyConfigFromControls();
});
}
}
// Global: click outside the popup to close all popups (bound only once)
if (!window.__stratPopupCloseBound) {
window.__stratPopupCloseBound = true;
document.addEventListener('click', (ev) => {
const target = ev.target;
if (target.closest && (target.closest('.strat-popup') || target.closest('.strat-toggle'))) return;
document.querySelectorAll('.strat-popup.open').forEach((el) => {
el.classList.remove('open');
const k = el.getAttribute('data-key');
const t = document.querySelector(`.strat-toggle[data-key="${k}"]`);
if (t) t.textContent = '> Params';
});
});
}
// Bug fix: the strategy-name tooltip .tip-box is inside <label>, so clicking/selecting text triggers the associated checkbox
// Use global event delegation + capture-phase preventDefault: intercept before the label receives the event
// capture=true is required because the label->checkbox default behavior isn't triggered in the bubbling phase
if (!window.__tipBoxFixBound) {
window.__tipBoxFixBound = true;
const stopInTipBox = (ev) => {
if (ev.target && ev.target.closest && ev.target.closest('.tip-box')) {
ev.stopPropagation();
ev.preventDefault(); // Prevent the label's default checkbox toggle
}
};
document.addEventListener('click', stopInTipBox, true);
document.addEventListener('mousedown', stopInTipBox, true);
document.addEventListener('mouseup', stopInTipBox, true);
}
// After re-render, write the current stratEnabled/stratAmount state back to the DOM
for (const desc of descriptions) {
const chk = stratChkEls[desc.key];
const amt = stratAmtEls[desc.key];
if (chk) chk.checked = !!stratEnabled[desc.key];
if (amt && stratAmount[desc.key] != null) amt.value = stratAmount[desc.key];
const sharesEl = document.getElementById(`${desc.key}-shares`);
if (sharesEl && stratShares[desc.key] != null) sharesEl.value = stratShares[desc.key];
for (const p of (desc.tunableParams || [])) {
const el = document.getElementById(`${desc.key}-param-${p.key}`);
const v = stratParams[desc.key]?.[p.key];
if (el && v != null) el.value = v;
}
// Sync the summary text (using the actual written-back values)
refreshStratSummary(desc.key);
}
}
async function initStrategyUI() {
try {
const res = await fetch('/api/strategy/descriptions');
if (!res.ok) return;
_allStrategyDescriptions = await res.json();
// strategyKeys / defaults use all strategies, unfiltered (positions/config are shared across markets per key)
strategyKeys = _allStrategyDescriptions.map(d => d.key);
for (const key of strategyKeys) {
if (!(key in stratEnabled)) stratEnabled[key] = false;
if (!(key in stratAmount)) stratAmount[key] = 1;
}
renderStrategyUI();
if (_pendingStrategyConfig) {
applyStrategyConfig(_pendingStrategyConfig);
_pendingStrategyConfig = null;
}
} catch (e) {
console.warn('[initStrategyUI] Failed to fetch strategy descriptions:', e.message);
}
}
initStrategyUI();
// Order latency monitor: local postOrder -> UserWS receives MATCHED
function renderOrderLatency(m) {
const row = document.getElementById('order-latency');
const val = document.getElementById('order-latency-val');
if (!row || !val) return;
const clsFor = (v) => {
if (v === -1 || v == null) return 'http-bad';
if (v < 500) return 'http-fast';
if (v < 1000) return 'http-slow';
return 'http-bad';
};
// Main row display
if (m.lastMs === -1 || m.lastMs == null) {
val.innerHTML = (m.timeouts || 0) > 0 || (m.avgMs || -1) >= 0
? '<span style="color:#f85149">Timeout</span>'
: '—';
} else {
val.textContent = m.lastMs + 'ms';
}
row.classList.remove('http-fast', 'http-slow', 'http-bad');
if (m.lastMs >= 0 || m.timeouts > 0) row.classList.add(clsFor(m.lastMs));
// hover details
const $ = (id) => document.getElementById(id);
$('ol-last').innerHTML = m.lastMs >= 0
? m.lastMs + ' ms'
: (m.timeouts > 0 ? '<span style="color:#f85149">Timeout</span>' : '-');
$('ol-avg').textContent = m.avgMs >= 0 ? m.avgMs + ' ms' : '—';
$('ol-timeouts').textContent = m.timeouts || 0;
$('ol-disconnects').textContent = m.wsDisconnects || 0;
// History list
const hist = Array.isArray(m.history) ? m.history : [];
const histEl = $('ol-history');
if (histEl) {
if (!hist.length) {
histEl.innerHTML = '<span style="color:#555">None</span>';
} else {
histEl.innerHTML = hist.map(s => {
const t = new Date(s.ts).toLocaleTimeString('zh-CN', { hour12: false });
const latency = s.latencyMs >= 0
? `<b style="color:#f0a500">${s.latencyMs}ms</b>`
: '<span style="color:#f85149">Timeout</span>';
return `<div style="display:flex;gap:12px;font-family:monospace">
<span style="color:#666;min-width:70px">${t}</span>
<span>Order latency ${latency}</span>
</div>`;
}).join('');
}
}
}
// Fetch the version number and show it in the header
fetch('/api/version').then(r => r.json()).then(d => {
const el = document.getElementById('app-version');
if (el && d && d.version) el.textContent = 'v' + d.version;
}).catch(() => {});
// ── Market switch (asset + period)─────────────────────────────────
const $marketSymbolSelect = document.getElementById('market-symbol-select');
const $marketPeriodSelect = document.getElementById('market-period-select');
const $currentPriceLabel = document.getElementById('current-price-label');
let activeMarketSymbol = 'btc';
let activeMarketPeriod = '5m';
let activeMarketPeriodSeconds = 300;
let activeMarketPriceDecimals = null; // Pushed by backend, dynamic by current price magnitude
function updateMarketUI(am) {
if (!am || !am.symbol || !am.period) return;
const changed = activeMarketSymbol !== am.symbol || activeMarketPeriod !== am.period;
activeMarketSymbol = am.symbol;
activeMarketPeriod = am.period;
if (am.periodSeconds) activeMarketPeriodSeconds = am.periodSeconds;
if (typeof am.priceDecimals === 'number') activeMarketPriceDecimals = am.priceDecimals;
if ($marketSymbolSelect && $marketSymbolSelect.value !== am.symbol) $marketSymbolSelect.value = am.symbol;
if ($marketPeriodSelect && $marketPeriodSelect.value !== am.period) $marketPeriodSelect.value = am.period;
const dn = am.displayName || (am.symbol.toUpperCase() + ' ' + am.period);
if ($currentPriceLabel) $currentPriceLabel.textContent = am.symbol.toUpperCase() + ' current price';
document.title = dn + ' Up/Down Order Book';
if (changed && typeof clearRealtimeBuffers === 'function') {
clearRealtimeBuffers();
if (typeof chartReset === 'function') chartReset();
}
// After switching market, filter the strategy UI by the new activeMarket (strategies not supporting the current market are hidden)
if (changed && typeof renderStrategyUI === 'function' && _allStrategyDescriptions.length) {
renderStrategyUI();
}
}
async function requestMarketSwitch(targetSym, targetPeriod) {
const prevSym = activeMarketSymbol;
const prevPeriod = activeMarketPeriod;
if (targetSym === prevSym && targetPeriod === prevPeriod) return;
if ($marketSymbolSelect) $marketSymbolSelect.disabled = true;
if ($marketPeriodSelect) $marketPeriodSelect.disabled = true;
try {
const resp = await fetch('/api/market/switch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ symbol: targetSym, period: targetPeriod }),
});
const data = await resp.json();
if (!data.ok) {
alert('Switch failed: ' + (data.reason || 'unknown reason'));
// Rollback
if ($marketSymbolSelect) $marketSymbolSelect.value = prevSym;
if ($marketPeriodSelect) $marketPeriodSelect.value = prevPeriod;
}
} catch (err) {
alert('Switch request failed: ' + err.message);
if ($marketSymbolSelect) $marketSymbolSelect.value = prevSym;
if ($marketPeriodSelect) $marketPeriodSelect.value = prevPeriod;
} finally {
if ($marketSymbolSelect) $marketSymbolSelect.disabled = false;
if ($marketPeriodSelect) $marketPeriodSelect.disabled = false;
}
}
if ($marketSymbolSelect) {
$marketSymbolSelect.addEventListener('change', (e) => {
requestMarketSwitch(e.target.value, activeMarketPeriod);
});
}
if ($marketPeriodSelect) {
$marketPeriodSelect.addEventListener('change', (e) => {
requestMarketSwitch(activeMarketSymbol, e.target.value);
});
}
if ($maxRoundEntries) $maxRoundEntries.addEventListener('change', saveStrategyConfigFromControls);
if ($stratSlippage) $stratSlippage.addEventListener('change', saveStrategyConfigFromControls);
if ($marketHoursOnly) $marketHoursOnly.addEventListener('change', saveStrategyConfigFromControls);
if ($autoClaimEnabled) $autoClaimEnabled.addEventListener('change', saveStrategyConfigFromControls);
if ($weekendPause) $weekendPause.addEventListener('change', saveStrategyConfigFromControls);
// US market-closed status: driven entirely by the server-side marketStatus field (single source)
// On receiving state, record paused + secondsUntilNext + receive time; the frontend only does a local countdown
let _marketStatus = { paused: false, secondsUntilNext: 0, syncedAt: 0 };
function fmtHours(totalMin) {
const h = Math.floor(totalMin / 60);
const m = totalMin % 60;
if (h <= 0) return `${m}m`;
return m === 0 ? `${h}h` : `${h}h${m}m`;
}
function renderWeekendStatus() {
const $el = document.getElementById('weekend-status');
if (!$el) return;
if (!$weekendPause || !$weekendPause.checked) {
$el.textContent = '';
return;
}
if (!_marketStatus.syncedAt) { $el.textContent = ''; return; }
// Local countdown based on the server-pushed remaining seconds
const elapsedSec = Math.floor((Date.now() - _marketStatus.syncedAt) / 1000);
const remainSec = Math.max(0, _marketStatus.secondsUntilNext - elapsedSec);
const remainMin = Math.ceil(remainSec / 60);
if (_marketStatus.paused) {
$el.textContent = `🛑 Paused - ${fmtHours(remainMin)} left`;
$el.style.color = '#f85149';
} else {
$el.textContent = `Weekday - closes in ${fmtHours(remainMin)}`;
$el.style.color = '#888';
}
}
renderWeekendStatus();
setInterval(renderWeekendStatus, 60000); // Refresh every minute
// Refresh immediately on checkbox change too
if ($weekendPause) $weekendPause.addEventListener('change', renderWeekendStatus);
// F1/F2 shortcuts toggle buy/sell, F3/F4 switch up/down direction, F5 close all
document.addEventListener('keydown', e => {
if (e.key === 'F1') { e.preventDefault(); setSide('buy'); }
if (e.key === 'F2') { e.preventDefault(); setSide('sell'); }
if (e.key === 'F3') { e.preventDefault(); setDir('up'); }
if (e.key === 'F4') { e.preventDefault(); setDir('down'); }
if (e.key === 'F5') { e.preventDefault(); submitClearAll(); }
});
function updateSignal() {
// Legacy function; the opportunity hint was removed; _lastSignalKey cleanup is kept in case it's referenced elsewhere
_lastSignalKey = '';
}
// ── Order panel logic ──────────────────────────────────────────────
let orderMode = 'market'; // 'market' | 'limit'
function setOrderMode(mode) {
orderMode = mode;
document.getElementById('mode-market').className = 'tog-btn' + (mode === 'market' ? ' active' : '');
document.getElementById('mode-limit').className = 'tog-btn' + (mode === 'limit' ? ' active' : '');
document.getElementById('market-panel').style.display = mode === 'market' ? 'flex' : 'none';
document.getElementById('limit-panel').style.display = mode === 'limit' ? 'flex' : 'none';
updateOrderAvailability();
}
function setDir(d) {
selDir = d;
document.getElementById('btn-up').className = 'tog-btn' + (d==='up' ? ' active-up' : '');
document.getElementById('btn-down').className = 'tog-btn' + (d==='down' ? ' active-down' : '');
updatePanel();
}
function setSide(s) {
selSide = s;
document.getElementById('btn-buy').className = 'side-btn' + (s==='buy' ? ' active-buy' : '');
document.getElementById('btn-sell').className = 'side-btn' + (s==='sell' ? ' active-sell' : '');
updatePanel();
}
function updateOrderAvailability() {
const ready = selDir && selSide;
if (orderMode === 'limit') {
const $btn = document.getElementById('limit-confirm-btn');
const limitPrice = parseFloat(document.getElementById('limit-price-input').value);
const limitSize = parseFloat(document.getElementById('limit-size-input').value);
const limitPriceOk = isFinite(limitPrice) && limitPrice > 0 && limitPrice < 1;
const sizeOk = isFinite(limitSize) && limitSize >= 5;
const notional = limitSize * limitPrice;
const $notional = document.getElementById('limit-notional');
if ($notional) $notional.textContent = isFinite(notional) && notional > 0 ? `$${notional.toFixed(2)}` : '—';
if (ready && limitPriceOk && sizeOk) {
$btn.className = selSide === 'buy' ? 'ready-buy' : 'ready-sell';
$btn.textContent = selSide === 'buy'
? `Limit buy ${selDir === 'up' ? 'Up▲' : 'Down▼'}`
: `Limit sell ${selDir === 'up' ? 'Up▲' : 'Down▼'}`;
$btn.disabled = false;
} else {
$btn.className = '';
if (!ready) $btn.textContent = 'Select direction and action';
else if (!limitPriceOk) $btn.textContent = 'Enter a limit price';
else $btn.textContent = `Less than 5 shares (current ${isFinite(limitSize) ? limitSize : 0})`;
$btn.disabled = true;
}
return;
}
if (ready && isProbabilityUsable()) {
$confirmBtn.className = selSide === 'buy' ? 'ready-buy' : 'ready-sell';
$confirmBtn.textContent = selSide === 'buy'
? `Buy ${selDir === 'up' ? 'Up▲' : 'Down▼'}`
: `Sell ${selDir === 'up' ? 'Up▲' : 'Down▼'}`;
$confirmBtn.disabled = false;
return;
}
if (ready) {
$confirmBtn.className = '';
$confirmBtn.textContent = 'Waiting for order book to recover';
$confirmBtn.disabled = true;
return;
}
$confirmBtn.className = '';
$confirmBtn.textContent = 'Select direction and action';
$confirmBtn.disabled = true;
}
function updatePanel() {
if (selSide === 'buy') {
$amtLabel.textContent = 'Buy amount';
$amtUnit.textContent = 'USDC';
$amtUnit.style.color = '#4e9eff';
$maxBtn.style.visibility = 'hidden';
$amtInput.placeholder = '0.00';
$amtInput.step = '1';
if (!$amtInput.value) $amtInput.value = '1';
} else if (selSide === 'sell') {
$amtLabel.textContent = 'Sell shares';
$amtUnit.textContent = 'shares';
$amtUnit.style.color = '#f0a500';
$amtInput.step = '0.1';
$maxBtn.style.visibility = 'visible';
$amtInput.placeholder = '0.0';
if (selDir) fillMax();
else $amtInput.value = '';
}
updateOrderAvailability();
updateEst();
}
function stepAmount(dir) {
const step = selSide === 'sell' ? 0.1 : 1;
const cur = parseFloat($amtInput.value) || 0;
const next = Math.max(0, Math.round((cur + dir * step) * 100) / 100);
$amtInput.value = selSide === 'sell' ? next.toFixed(1) : next.toFixed(0);
updateEst();
// Dispatch an input event to trigger the config-save listener (steppers / quick buttons go through here)
$amtInput.dispatchEvent(new Event('input', { bubbles: true }));
}
function setAmount(v) {
$amtInput.value = String(v);
updateEst();
$amtInput.dispatchEvent(new Event('input', { bubbles: true }));
updateQuickAmtActive();
}
function updateQuickAmtActive() {
const cur = parseFloat($amtInput.value);
document.querySelectorAll('.quick-amt-btn').forEach(b => {
b.classList.toggle('active', parseFloat(b.textContent) === cur);
});
}
function fillMax() {
$amtInput.value = fmtSize(getSellableShares(selDir));
updateEst();
$amtInput.dispatchEvent(new Event('input', { bubbles: true }));
}
function hasEnoughUsdcForBuy(amount) {
if (curUsdc == null || !Number.isFinite(amount)) return true;
return curUsdc + 1e-6 >= amount;
}
function isProbabilityUsable() {
return probabilityReady && !isNaN(curBestBid) && !isNaN(curBestAsk);
}
function updatePosSummary() {
const $ps = document.getElementById('pos-summary');
if (!$ps) return;
const hasUp = curUpLocal > 0.01;
const hasDn = curDnLocal > 0.01;
if (!hasUp && !hasDn) { $ps.innerHTML = ''; return; }
const bidUp = isNaN(curBestBid) ? NaN : curBestBid;
const bidDn = isNaN(curBestAsk) ? NaN : 1 - curBestAsk;
const valUp = hasUp && !isNaN(bidUp) ? curUpLocal * bidUp : NaN;
const valDn = hasDn && !isNaN(bidDn) ? curDnLocal * bidDn : NaN;
const total = (!isNaN(valUp) ? valUp : 0) + (!isNaN(valDn) ? valDn : 0);
const fmt = (n, decimals=2) => isNaN(n) ? '—' : n.toFixed(decimals);
const lines = [];
if (hasUp) lines.push(`<span style="color:#3fb950">Up▲</span> ${fmt(curUpLocal,0)} shares @ ${fmt(bidUp*100,1)}% ≈ <b>$${fmt(valUp)}</b>`);
if (hasDn) lines.push(`<span style="color:#f85149">Down▼</span> ${fmt(curDnLocal,0)} shares @ ${fmt(bidDn*100,1)}% ≈ <b>$${fmt(valDn)}</b>`);
if (hasUp && hasDn) lines.push(`Total <b style="color:#e0e0e0">$${fmt(total)}</b>`);
let html = lines.join('<br>');
$ps.innerHTML = html;
}
function getSellableShares(dir) {
if (!dir) return 0;
const localSize = dir === 'up' ? curUpLocal : curDnLocal;
const verified = isDirectionVerified(dir);
if (localSize <= 0) return 0;
if (verified) return localSize;
return Math.max(0, localSize - UNVERIFIED_SELL_BUFFER);
}
function updateEst() {
const amount = parseFloat($amtInput.value);
if (!selSide || isNaN(amount) || amount <= 0 || !isProbabilityUsable()) {
$estPrice.textContent = '—'; $estResult.textContent = '—'; return;
}
// Buy-up uses ask, buy-down uses 1-bid; sell-up uses bid, sell-down uses 1-ask
let price = null;
if (selSide === 'buy') {
if (selDir === 'up') price = isNaN(curBestAsk) ? null : curBestAsk;
if (selDir === 'down') price = isNaN(curBestBid) ? null : 1 - curBestBid;
$estPrice.textContent = price != null ? Math.round(price*100) + '%' : '—';
$estResult.textContent = price != null ? `~${(amount / price).toFixed(1)} shares` : '-';
} else {
if (selDir === 'up') price = isNaN(curBestBid) ? null : curBestBid;
if (selDir === 'down') price = isNaN(curBestAsk) ? null : 1 - curBestAsk;
$estPrice.textContent = price != null ? Math.round(price*100) + '%' : '—';
$estResult.textContent = price != null ? `~$${(amount * price).toFixed(2)} USDC` : '-';
}
}
function translateError(msg) {
if (!msg) return '';
if (msg.includes("couldn't be fully filled") || msg.includes("FOK")) return 'Insufficient liquidity, order could not be fully filled';
if (msg.includes("insufficient")) return 'Insufficient balance';
if (msg.includes("invalid")) return 'Invalid parameter';
if (msg.includes("Order rejected status=400")) return 'Order rejected by the API, usually insufficient liquidity or order parameters not meeting requirements';
return msg;
}
// Claim state variables removed (feature moved to the Polymarket website)
function refreshClaimDisplayMeta() { /* no-op */ }
// ── Backtest data collection ─────────────────────────────────────────────
let backtestCollecting = false;
function updateBacktestUI() {
const btn = document.getElementById('backtest-btn');
const label = document.getElementById('backtest-label');
if (btn) {
btn.textContent = backtestCollecting ? 'Collecting' : 'Off';
btn.style.borderColor = backtestCollecting ? '#3fb950' : '#30363d';
btn.style.color = backtestCollecting ? '#3fb950' : '#888';
}
if (label) {
label.style.color = backtestCollecting ? '#3fb950' : '#666';
}
}
async function toggleBacktest() {
try {
const res = await fetch('/api/backtest/toggle', { method: 'POST' });
const data = await res.json();
backtestCollecting = !!data.collecting;
updateBacktestUI();
} catch (e) {
console.warn('[Backtest] toggle failed:', e.message);
}
}
// ── Telegram push ─────────────────────────────────────────
let tgPanelExpanded = false;
function toggleTgPanel(e) {
if (e) e.stopPropagation();
tgPanelExpanded = !tgPanelExpanded;
const body = document.getElementById('tg-config-body');
const label = document.getElementById('tg-toggle-label');
if (body) body.style.display = tgPanelExpanded ? 'flex' : 'none';
if (label) label.textContent = tgPanelExpanded ? '📱 TG push v' : '📱 TG push >';
}
// Click outside the panel to auto-close
document.addEventListener('click', (e) => {
if (!tgPanelExpanded) return;
const panel = document.getElementById('tg-config-panel');
if (panel && !panel.contains(e.target)) toggleTgPanel();
});
function applyTgConfig(cfg) {
const en = document.getElementById('tg-enabled');
const tk = document.getElementById('tg-bot-token');
const ch = document.getElementById('tg-chat-id');
const iv = document.getElementById('tg-interval');
const sch = document.getElementById('tg-scheduled');
const pt = document.getElementById('tg-post-trade');
if (en) en.checked = !!cfg.enabled;
if (tk && cfg.botTokenSet) tk.placeholder = cfg.botToken || 'Bot Token (configured)';
if (ch && cfg.chatId) ch.value = cfg.chatId;
if (iv && cfg.intervalMinutes) iv.value = cfg.intervalMinutes;
if (sch) sch.checked = cfg.scheduledEnabled !== false; // checked by default
if (pt) pt.checked = !!cfg.postTradeEnabled;
}
async function saveTgConfig() {
const en = document.getElementById('tg-enabled').checked;
const tk = document.getElementById('tg-bot-token').value.trim();
const ch = document.getElementById('tg-chat-id').value.trim();
const iv = parseInt(document.getElementById('tg-interval').value, 10) || 60;
const sch = document.getElementById('tg-scheduled').checked;
const pt = document.getElementById('tg-post-trade').checked;
const status = document.getElementById('tg-status');
try {
const body = { enabled: en, chatId: ch, intervalMinutes: iv, scheduledEnabled: sch, postTradeEnabled: pt };
// Only send when the user entered a new token (avoid sending the masked string)
if (tk && !tk.includes('***')) body.botToken = tk;
const res = await fetch('/api/tg/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
status.style.color = '#3fb950';
status.textContent = '✓ Saved';
document.getElementById('tg-bot-token').value = '';
setTimeout(() => { status.textContent = ''; }, 2500);
} else {
status.style.color = '#f85149';
status.textContent = '✗ Failed';
}
} catch (e) {
status.style.color = '#f85149';
status.textContent = '✗ Error';
}
}
async function detectChatId() {
const status = document.getElementById('tg-status');
const tokenInput = document.getElementById('tg-bot-token');
const chatInput = document.getElementById('tg-chat-id');
status.style.color = '#f0a500';
status.textContent = 'Detecting...';
try {
const body = {};
const tk = tokenInput.value.trim();
if (tk && !tk.includes('***')) body.botToken = tk;
const res = await fetch('/api/tg/detect-chat-id', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const data = await res.json();
if (data.ok && data.chatId) {
chatInput.value = data.chatId;
status.style.color = '#3fb950';
status.textContent = '✓ Detected ' + data.chatId;
} else {
status.style.color = '#f85149';
status.textContent = '✗ ' + (data.error || 'Not detected');
}
setTimeout(() => { status.textContent = ''; }, 6000);
} catch (e) {
status.style.color = '#f85149';
status.textContent = '✗ Network error';
}
}
async function testTgMessage() {
const status = document.getElementById('tg-status');
status.style.color = '#f0a500';
status.textContent = 'Sending...';
try {
const res = await fetch('/api/tg/test', { method: 'POST' });
const data = await res.json();
if (data.ok) {
status.style.color = '#3fb950';
status.textContent = '✓ Sent';
} else {
status.style.color = '#f85149';
status.textContent = '✗ ' + (data.error || 'Failed');
}
setTimeout(() => { status.textContent = ''; }, 5000);
} catch (e) {
status.style.color = '#f85149';
status.textContent = '✗ Network error';
}
}
// submitClaim removed: enable Auto Redeem in Settings on the Polymarket website
async function submitOrder() {
const amount = parseFloat($amtInput.value);
if (!selDir || !selSide || isNaN(amount) || amount <= 0) return;
// Limit mode
if (orderMode === 'limit') {
const limitSize = parseFloat(document.getElementById('limit-size-input').value);
const limitPrice = parseFloat(document.getElementById('limit-price-input').value);
const $limitBtn = document.getElementById('limit-confirm-btn');
const $limitResult = document.getElementById('limit-order-result');
if (!isFinite(limitSize) || limitSize <= 0 || !isFinite(limitPrice) || limitPrice <= 0 || limitPrice >= 1) return;
$limitBtn.disabled = true;
$limitBtn.textContent = 'Placing order...';
$limitResult.textContent = '';
$limitResult.className = '';
try {
const res = await fetch('/api/order/limit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: selDir, side: selSide, size: limitSize, price: limitPrice }),
});
const data = await res.json();
if (res.ok && data.orderID) {
$limitResult.textContent = `✓ Order ${data.orderID.slice(0, 10)}...`;
$limitResult.style.color = '#3fb950';
} else {
$limitResult.textContent = `✗ ${data.error || 'Order placement failed'}`;
$limitResult.style.color = '#f85149';
}
} catch (e) {
$limitResult.textContent = `✗ ${e.message}`;
$limitResult.style.color = '#f85149';
}
setTimeout(() => { $limitResult.textContent = ''; }, 6000);
updateOrderAvailability();
return;
}
if (!isProbabilityUsable()) {
$orderResult.textContent = '✗ Waiting for order book to recover';
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
return;
}
if (selSide === 'buy' && !hasEnoughUsdcForBuy(amount)) {
$orderResult.textContent = '✗ Insufficient balance';
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
return;
}
$confirmBtn.disabled = true;
$confirmBtn.textContent = 'Placing order...';
$orderResult.className = '';
$orderResult.removeAttribute('style');
try {
// Only attach conditional-order params on buy (none on sell)
const condPayload = selSide === 'buy' ? buildCondPayload() : {};
const res = await fetch('/api/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
direction: selDir,
side: selSide,
amount,
slippage: (() => { const v = parseFloat(document.getElementById('slippage-input').value); return (isFinite(v) ? v : 6) / 100; })(),
...condPayload,
}),
});
const data = await res.json();
if (res.ok && data.success) {
$orderResult.textContent = `✓ Order placed status: ${data.result?.status ?? '-'}`;
$orderResult.className = 'ok';
recordTradeMarker(selSide, selDir);
updateEst();
} else {
$orderResult.textContent = `✗ ${translateError(data.error) || 'Order failed'}`;
$orderResult.className = 'fail';
}
} catch (err) {
$orderResult.textContent = `✗ Network error`;
$orderResult.className = 'fail';
}
// Auto-hide the result message after 5 seconds
setTimeout(() => { $orderResult.className = ''; }, 5000);
updatePanel(); // restore button
}
/**
* Close all: detect current positions -> sequential market sells (up before down when both exist, to avoid concurrent nonce issues)
* For simplicity: estimate using the frontend's pushed localSize, but the backend position is authoritative
*/
let clearAllInFlight = false;
async function submitClearAll() {
if (clearAllInFlight) return;
const $btn = document.getElementById('clear-all-btn');
if (!$btn) return;
// Fetch the currently sellable shares
const upShares = getSellableShares('up');
const dnShares = getSellableShares('down');
const tasks = [];
if (upShares > 0) tasks.push({ direction: 'up', amount: upShares });
if (dnShares > 0) tasks.push({ direction: 'down', amount: dnShares });
if (!tasks.length) {
$orderResult.textContent = '✗ No sellable position';
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
return;
}
if (!isProbabilityUsable()) {
$orderResult.textContent = '✗ Waiting for order book to recover';
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
return;
}
clearAllInFlight = true;
$btn.disabled = true;
const _sv = parseFloat(document.getElementById('slippage-input').value);
const slippage = (isFinite(_sv) ? _sv : 6) / 100;
const results = [];
// Run serially: wait for each HTTP response before sending the next, to avoid nonce issues
for (let i = 0; i < tasks.length; i++) {
const t = tasks[i];
$btn.textContent = `Closing ${i + 1}/${tasks.length} sell ${t.direction === 'up' ? 'Up' : 'Down'}...`;
try {
const res = await fetch('/api/order', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ direction: t.direction, side: 'sell', amount: t.amount, slippage }),
});
const data = await res.json();
if (res.ok && data.success) {
results.push(`${t.direction === 'up' ? 'Up' : 'Down'}${t.amount.toFixed(2)}✓`);
recordTradeMarker('sell', t.direction);
} else {
results.push(`${t.direction === 'up' ? 'Up' : 'Down'}${t.amount.toFixed(2)}✗(${translateError(data.error) || 'Failed'})`);
}
} catch {
results.push(`${t.direction === 'up' ? 'Up' : 'Down'}${t.amount.toFixed(2)}✗(Network error)`);
}
}
const allOk = results.every(r => r.includes('✓'));
$orderResult.textContent = (allOk ? '✓ Close all done: ' : '✗ Close all partially failed: ') + results.join(' · ');
$orderResult.className = allOk ? 'ok' : 'fail';
setTimeout(() => { $orderResult.className = ''; }, 8000);
clearAllInFlight = false;
$btn.disabled = false;
$btn.innerHTML = '🧹 Close all <span style="font-size:10px;opacity:0.5">F5</span>';
}
// ── Manual order config (amount / slippage / TP-SL) is persisted by the server ────────
// All synced via /api/manual/config + the WS "manualConfig" message
const MANUAL_DEFAULT = {
amount: 1,
slippage: 0.06,
cond: { tpEnabled: false, tpPct: 20, slEnabled: false, slPct: 20, slSlippage: 15, collapsed: true },
};
let manualConfig = structuredClone(MANUAL_DEFAULT);
let condConfig = manualConfig.cond; // Compatibility for legacy code references
let manualConfigInitialized = false;
let manualConfigSaving = false;
function applyManualConfigToUi() {
// Amount
const $amount = document.getElementById('amount-input');
if ($amount && document.activeElement !== $amount) {
$amount.value = String(manualConfig.amount ?? 1);
if (typeof updateQuickAmtActive === 'function') updateQuickAmtActive();
}
// Slippage (server stores a 0-1 float, UI shows a 0-99 integer)
const $slippage = document.getElementById('slippage-input');
if ($slippage && document.activeElement !== $slippage) {
$slippage.value = String(Math.round((manualConfig.slippage ?? 0.06) * 100));
}
// TP/SL
const c = manualConfig.cond;
const activeEl = document.activeElement;
const setIfNotFocused = (id, val) => {
const el = document.getElementById(id);
if (el && el !== activeEl) {
if (el.type === 'checkbox') el.checked = !!val;
else el.value = String(val);
}
};
setIfNotFocused('cond-tp-enabled', c.tpEnabled);
setIfNotFocused('cond-tp-pct', c.tpPct ?? 0);
setIfNotFocused('cond-sl-enabled', c.slEnabled);
setIfNotFocused('cond-sl-pct', c.slPct ?? 0);
setIfNotFocused('cond-sl-diff', c.slDiff ?? 5);
setIfNotFocused('cond-sl-slippage', c.slSlippage ?? 15);
// sl mode toggle (radio)
const slMode = c.slMode || 'price';
const $modePrice = document.getElementById('cond-sl-mode-price');
const $modeDiff = document.getElementById('cond-sl-mode-diff');
if ($modePrice && $modePrice !== activeEl) $modePrice.checked = slMode === 'price';
if ($modeDiff && $modeDiff !== activeEl) $modeDiff.checked = slMode === 'diff';
// Toggle which input box is shown
const $slPctRow = document.getElementById('cond-sl-row-price');
const $slDiffRow = document.getElementById('cond-sl-row-diff');
if ($slPctRow) $slPctRow.style.display = slMode === 'price' ? 'flex' : 'none';
if ($slDiffRow) $slDiffRow.style.display = slMode === 'diff' ? 'flex' : 'none';
// Collapsed state
const body = document.getElementById('cond-body');
const arrow = document.getElementById('cond-arrow');
if (body && arrow) {
body.style.display = c.collapsed ? 'none' : 'flex';
arrow.textContent = c.collapsed ? '▶' : '▼';
}
condConfig = manualConfig.cond;
updateCondSummary();
}
function updateCondSummary() {
const el = document.getElementById('cond-summary');
if (!el) return;
const c = manualConfig.cond;
const parts = [];
if (c.tpEnabled && c.tpPct > 0) parts.push(`TP+${c.tpPct}%`);
if (c.slEnabled) {
if (c.slMode === 'diff' && Number.isFinite(c.slDiff)) parts.push(`diff≤${c.slDiff}`);
else if (c.slPct > 0) parts.push(`SL-${c.slPct}%`);
}
el.textContent = parts.length ? `(${parts.join(' · ')})` : '';
el.style.color = parts.length ? '#f0a500' : '#666';
}
async function saveManualConfigPartial(patch) {
if (manualConfigSaving) return;
manualConfigSaving = true;
try {
const res = await fetch('/api/manual/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (res.ok && data.manualConfig) {
manualConfig = data.manualConfig;
condConfig = manualConfig.cond;
updateCondSummary();
}
} catch (err) {
console.warn('Failed to save manual config', err);
} finally {
manualConfigSaving = false;
}
}
// TP/SL changes trigger a save
function saveCondConfig() {
const slMode = document.getElementById('cond-sl-mode-diff').checked ? 'diff' : 'price';
const next = {
tpEnabled: document.getElementById('cond-tp-enabled').checked,
tpPct: Number(document.getElementById('cond-tp-pct').value) || 0,
slEnabled: document.getElementById('cond-sl-enabled').checked,
slMode,
slPct: Number(document.getElementById('cond-sl-pct').value) || 0,
slDiff: Number(document.getElementById('cond-sl-diff').value) || 0,
slSlippage: Number(document.getElementById('cond-sl-slippage').value) || 15,
collapsed: manualConfig.cond.collapsed,
};
// Update the row display immediately when switching mode
const $slPctRow = document.getElementById('cond-sl-row-price');
const $slDiffRow = document.getElementById('cond-sl-row-diff');
if ($slPctRow) $slPctRow.style.display = slMode === 'price' ? 'flex' : 'none';
if ($slDiffRow) $slDiffRow.style.display = slMode === 'diff' ? 'flex' : 'none';
manualConfig.cond = next;
condConfig = next;
updateCondSummary();
saveManualConfigPartial({ cond: next });
}
function toggleCondPanel() {
const c = manualConfig.cond;
c.collapsed = !c.collapsed;
const body = document.getElementById('cond-body');
const arrow = document.getElementById('cond-arrow');
if (body) body.style.display = c.collapsed ? 'none' : 'flex';
if (arrow) arrow.textContent = c.collapsed ? '▶' : '▼';
saveManualConfigPartial({ cond: c });
}
/** Build the conditional-order params carried with the order */
function buildCondPayload() {
const p = {};
const c = manualConfig.cond;
if (c.tpEnabled && c.tpPct > 0) {
p.stopProfit = { pctDelta: c.tpPct / 100 };
}
if (c.slEnabled) {
if (c.slMode === 'diff' && Number.isFinite(c.slDiff)) {
p.stopLoss = { diffValue: c.slDiff, slippage: (c.slSlippage || 15) / 100 };
} else if (c.slPct > 0) {
p.stopLoss = { pctDelta: c.slPct / 100, slippage: (c.slSlippage || 15) / 100 };
}
}
return p;
}
// Bind change events for the amount and slippage inputs
(function bindManualInputs() {
// Clicking the stepper button in Chrome often only fires input and not change immediately,
// so listen to both input (debounced 400ms) and change (immediate on blur/enter).
const debounceTimers = new Map();
function debounced(key, fn) {
clearTimeout(debounceTimers.get(key));
debounceTimers.set(key, setTimeout(fn, 400));
}
const $amount = document.getElementById('amount-input');
if ($amount) {
const save = () => {
const v = parseFloat($amount.value);
if (!isFinite(v) || v < 0) return;
if (manualConfig.amount === v) return; // no change, don't send
manualConfig.amount = v;
saveManualConfigPartial({ amount: v });
};
$amount.addEventListener('input', () => debounced('amount', save));
$amount.addEventListener('change', () => { clearTimeout(debounceTimers.get('amount')); save(); });
}
const $slippage = document.getElementById('slippage-input');
if ($slippage) {
const save = () => {
const v = parseFloat($slippage.value);
if (!isFinite(v) || v < 0 || v > 99) return;
const next = v / 100;
if (manualConfig.slippage === next) return;
manualConfig.slippage = next;
saveManualConfigPartial({ slippage: next });
};
$slippage.addEventListener('input', () => debounced('slippage', save));
$slippage.addEventListener('change', () => { clearTimeout(debounceTimers.get('slippage')); save(); });
}
})();
// One-time migration: if localStorage still has old config, push to server then clear it
(function migrateLegacyLocalStorage() {
try {
const legacyCond = localStorage.getItem('btc5m-cond-config');
const legacyAmount = localStorage.getItem('btc5m-manual-amount');
const legacySlippage = localStorage.getItem('btc5m-manual-slippage');
const patch = {};
if (legacyCond) {
try { patch.cond = JSON.parse(legacyCond); } catch {}
}
if (legacyAmount) {
const v = parseFloat(legacyAmount);
if (isFinite(v) && v >= 0) patch.amount = v;
}
if (legacySlippage) {
const v = parseFloat(legacySlippage);
if (isFinite(v) && v >= 0 && v <= 99) patch.slippage = v / 100;
}
if (Object.keys(patch).length > 0) {
// Wait until the first WS manualConfig arrives before migrating (to avoid being overwritten by initial values)
setTimeout(async () => {
if (!manualConfigInitialized) return;
await saveManualConfigPartial(patch);
localStorage.removeItem('btc5m-cond-config');
localStorage.removeItem('btc5m-manual-amount');
localStorage.removeItem('btc5m-manual-slippage');
}, 2000);
} else {
// Clear the key even when there's no legacy data (to prevent re-triggering next time)
localStorage.removeItem('btc5m-cond-config');
localStorage.removeItem('btc5m-manual-amount');
localStorage.removeItem('btc5m-manual-slippage');
}
} catch {}
})();
// ── Active conditional-order list (driven by the WS-pushed conditionOrders)───────────
function renderConditionOrders(list) {
// Update the chip area whether or not the list is empty (when empty the chip shows "TP/SL: none")
renderStratCondChips(list);
const container = document.getElementById('cond-list');
const countEl = document.getElementById('cond-list-count');
const itemsEl = document.getElementById('cond-list-items');
if (!container || !itemsEl || !countEl) return;
if (!list.length) {
container.style.display = 'none';
return;
}
container.style.display = 'block';
countEl.textContent = list.length;
itemsEl.innerHTML = list.map(c => {
const dirIcon = c.direction === 'up' ? '<span style="color:#3fb950">Up</span>' : '<span style="color:#f85149">Down</span>';
const kindIcon = c.kind === 'tp'
? '<span style="color:#3fb950">📈 TP</span>'
: c.kind === 'sl-diff'
? '<span style="color:#f85149">📉 Crossover SL</span>'
: '<span style="color:#f85149">📉 SL</span>';
const priceText = c.kind === 'sl-diff'
? (c.direction === 'up' ? `diff≤${c.triggerPrice}` : `diff≥${-c.triggerPrice}`)
: `@${(c.triggerPrice * 100).toFixed(1)}%`;
let statusText;
if (c.status === 'open') {
statusText = c.kind === 'tp'
? (c.polymarketOrderId ? 'Open order' : (c.failReason ? 'Local monitor' : 'Submitting...'))
: 'Monitoring';
} else if (c.status === 'filled') statusText = '<span style="color:#3fb950">✓ Filled</span>';
else if (c.status === 'triggered') statusText = '<span style="color:#3fb950">✓ Triggered</span>';
else if (c.status === 'canceled') statusText = 'Canceled';
else if (c.status === 'failed') statusText = `Failed: ${c.failReason || '-'}`;
else statusText = c.status;
const sizeText = c.remainingSize !== c.size
? `${c.remainingSize.toFixed(2)}/${c.size.toFixed(2)} shares`
: `${c.size.toFixed(2)} shares`;
return `<div style="display:flex;align-items:center;gap:8px;padding:3px 6px;background:#161b22;border-radius:4px">
<span style="min-width:28px">${dirIcon}</span>
<span style="min-width:70px">${kindIcon}</span>
<span style="color:#e0e0e0;min-width:60px">${priceText}</span>
<span style="color:#888;flex:1">${sizeText} · ${statusText}</span>
${c.status === 'open' ? `<button onclick="cancelCondOrder('${c.id}')" style="font-size:10px;padding:2px 8px;border-radius:3px;border:1px solid #30363d;background:#21262d;color:#888;cursor:pointer">✗ Cancel</button>` : ''}
</div>`;
}).join('');
}
// The TP/SL chip area next to the strategy status bar (always shown; shows "none" when there are no active orders)
function renderStratCondChips(list) {
const el = document.getElementById('strat-cond-status');
if (!el) return;
const open = list.filter(c => c.status === 'open');
const tps = open.filter(c => c.kind === 'tp');
const sls = open.filter(c => c.kind === 'sl' || c.kind === 'sl-diff');
const chips = [];
for (const c of tps) {
const dir = c.direction === 'up' ? 'Up' : 'Down';
const dirColor = c.direction === 'up' ? '#3fb950' : '#f85149';
const sizeText = c.remainingSize !== c.size
? `${c.remainingSize.toFixed(1)}/${c.size.toFixed(0)}`
: `${c.size.toFixed(0)}`;
const status = c.polymarketOrderId ? '' : (c.failReason ? '-local' : '-submitting');
chips.push(`<span style="background:#0d2818;border:1px solid #3fb950;color:#7ee0a0;padding:3px 8px;border-radius:6px">📈<span style="color:${dirColor};margin:0 2px">${dir}</span>${sizeText}@${(c.triggerPrice*100).toFixed(0)}%${status}</span>`);
}
for (const c of sls) {
const dir = c.direction === 'up' ? 'Up' : 'Down';
const dirColor = c.direction === 'up' ? '#3fb950' : '#f85149';
const sizeText = c.remainingSize !== c.size
? `${c.remainingSize.toFixed(1)}/${c.size.toFixed(0)}`
: `${c.size.toFixed(0)}`;
const trigText = c.kind === 'sl-diff'
? (c.direction === 'up' ? `diff≤${c.triggerPrice}` : `diff≥${-c.triggerPrice}`)
: `@${(c.triggerPrice*100).toFixed(0)}%`;
chips.push(`<span style="background:#2a1212;border:1px solid #f85149;color:#ff9b95;padding:3px 8px;border-radius:6px">📉<span style="color:${dirColor};margin:0 2px">${dir}</span>${sizeText} ${trigText}</span>`);
}
if (chips.length === 0) {
el.innerHTML = '<span style="color:#444;font-size:12px;font-weight:500">TP/SL: none</span>';
} else {
el.innerHTML = chips.join('');
}
}
async function cancelCondOrder(id) {
try {
const res = await fetch('/api/cond/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id }),
});
const data = await res.json();
if (!res.ok || !data.success) {
$orderResult.textContent = `✗ Cancel failed: ${data.error || 'unknown'}`;
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
}
} catch {
$orderResult.textContent = '✗ Network error';
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
}
}
async function cancelAllCondOrders() {
const btn = document.getElementById('cond-cancel-all-btn');
if (btn) { btn.disabled = true; btn.textContent = 'Clearing'; }
try {
const res = await fetch('/api/cond/cancel-all', { method: 'POST' });
const data = await res.json();
if (!res.ok) {
$orderResult.textContent = `✗ Clear-all failed: ${data.error || 'unknown'}`;
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
} else if (data.failed > 0) {
$orderResult.textContent = `⚠ Cleared ${data.count} succeeded / ${data.failed} failed; failed items kept, please retry`;
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
} else {
$orderResult.textContent = `✓ Cleared ${data.count} conditional orders`;
$orderResult.className = 'ok';
setTimeout(() => { $orderResult.className = ''; }, 3000);
}
} catch {
$orderResult.textContent = '✗ Network error';
$orderResult.className = 'fail';
setTimeout(() => { $orderResult.className = ''; }, 5000);
} finally {
if (btn) { btn.disabled = false; btn.textContent = 'Clear all'; }
}
}
// ── Strategy limit-order status display (t8 etc.)─────────────────────────────
function renderStrategyLimitStatus(list) {
const el = document.getElementById('strat-limit-status');
if (!el) return;
const stratOrders = list.filter(o => o.source && o.source.startsWith('strategy:'));
if (!stratOrders.length) {
el.style.display = 'none';
el.innerHTML = '';
return;
}
el.style.display = 'inline-block';
const lines = stratOrders.map(o => {
const key = (o.source || '').replace('strategy:', '').toUpperCase();
const dir = o.direction === 'up' ? '<span style="color:#3fb950">Up</span>' : '<span style="color:#f85149">Down</span>';
const filled = o.filledSize ? ` filled ${o.filledSize.toFixed(1)}` : '';
return `📌${key} ${dir}${o.size.toFixed(0)} shares@${(o.price * 100).toFixed(0)}%${filled}`;
});
el.innerHTML = lines.join(' · ');
}
// ── Manual limit-order list ──────────────────────────────────────────
function renderManualLimitOrders(list) {
const container = document.getElementById('manual-limit-list');
const countEl = document.getElementById('manual-limit-count');
const itemsEl = document.getElementById('manual-limit-items');
if (!container || !itemsEl || !countEl) return;
if (!list.length) {
container.style.display = 'none';
return;
}
container.style.display = 'flex';
countEl.textContent = list.length;
itemsEl.innerHTML = list.map(o => {
const dirCol = o.direction === 'up' ? '#3fb950' : '#f85149';
const dirText = o.direction === 'up' ? 'Up▲' : 'Down▼';
const sideText = o.side === 'buy' ? 'Buy' : 'Sell';
return `<div style="display:flex;align-items:center;gap:8px;padding:3px 6px;background:#161b22;border-radius:4px">
<span style="color:${dirCol};min-width:36px">${sideText}${dirText}</span>
<span style="color:#e0e0e0;flex:1">${o.size} shares @ ${o.price}</span>
<button onclick="cancelManualLimitOrder('${o.orderID}')" style="font-size:10px;padding:2px 8px;border-radius:3px;border:1px solid #30363d;background:#21262d;color:#888;cursor:pointer">✗ Cancel</button>
</div>`;
}).join('');
}
async function cancelManualLimitOrder(orderID) {
try {
const res = await fetch('/api/orders/cancel', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ orderID }),
});
const data = await res.json();
const $r = document.getElementById('limit-order-result');
if (res.ok && data.success) {
if ($r) { $r.textContent = '✓ Cancel succeeded'; $r.style.color = '#3fb950'; setTimeout(() => { $r.textContent = ''; }, 3000); }
} else {
if ($r) { $r.textContent = `✗ Cancel failed: ${data.error || 'unknown'}`; $r.style.color = '#f85149'; setTimeout(() => { $r.textContent = ''; }, 5000); }
}
} catch {
const $r = document.getElementById('limit-order-result');
if ($r) { $r.textContent = '✗ Network error'; $r.style.color = '#f85149'; setTimeout(() => { $r.textContent = ''; }, 5000); }
}
}
// ── Price curve (1-minute sliding window) ─────────────────────────────────
const chartCanvas = document.getElementById('chart-canvas');
const chartCtx = chartCanvas.getContext('2d');
const chartPoints = []; // Chainlink data points [{t, price}]
const binancePoints = []; // Binance data points [{t, price}]
const coinbasePoints = []; // Coinbase data points [{t, price}]
const tradeMarkers = []; // Buy/sell markers in the current window [{t, side, dir, prob, windowStart}]
const _volStartTime = Date.now();
let chartPtb = null;
let chartWindowStart = 0;
let binanceOffset = 0; // Binance price offset computed by the backend
let coinbaseOffset = 0; // Coinbase price offset computed by the backend
let probScale = 250; // Probability-line scaling: how many price units a mid change of 1 maps to
let chartZoom = 1.0; // Overall Y-axis scale (>1 zoom in, <1 zoom out)
const BINANCE_OFFSET_MANUAL_ENABLED = false;
function recordTradeMarker(side, dir) {
if (!side || !dir || !chartWindowStart) return;
const prob = dir === 'up' ? _signalUpPct : _signalDnPct;
tradeMarkers.push({
t: Date.now(),
side,
dir,
prob: Number.isFinite(prob) ? Math.round(prob) : null,
windowStart: chartWindowStart,
});
if (tradeMarkers.length > 100) tradeMarkers.splice(0, tradeMarkers.length - 100);
}
// Drag logic (right-click drag the Binance line)
let _bnDragging = false;
let _dragStartY = 0, _dragStartOffset = 0;
const chartCanvas2 = document.getElementById('chart-canvas');
chartCanvas2.addEventListener('mousedown', (e) => {
if (!BINANCE_OFFSET_MANUAL_ENABLED) return;
if (e.button !== 2 || e.shiftKey) return;
e.preventDefault();
_bnDragging = true;
_dragStartY = e.clientY;
_dragStartOffset = binanceOffset;
});
window.addEventListener('mousemove', (e) => {
if (!BINANCE_OFFSET_MANUAL_ENABLED) return;
if (!_bnDragging) return;
const h = chartCanvas2.offsetHeight;
const priceRange = (smoothMaxP ?? 100) - (smoothMinP ?? 0);
const pxPerPrice = h / (priceRange || 1);
binanceOffset = _dragStartOffset - (e.clientY - _dragStartY) / pxPerPrice;
drawChart();
});
window.addEventListener('mouseup', () => { if (_bnDragging) { _bnDragEndTime = Date.now(); } _bnDragging = false; });
let _bnDragEndTime = 0;
chartCanvas2.addEventListener('contextmenu', (e) => e.preventDefault());
chartCanvas2.addEventListener('dblclick', () => {
// Double-click always resets zoom (adapts to the current PTB magnitude)
probScale = getDefaultProbScale();
chartZoom = 1.0;
drawChart();
if (!BINANCE_OFFSET_MANUAL_ENABLED) return;
const now = Date.now();
const bnRecent = binancePoints.filter(p => p.t >= now - 60000);
const clRecent = chartPoints.filter(p => p.t >= now - 60000);
const bnSpan = bnRecent.length >= 2 ? bnRecent[bnRecent.length-1].t - bnRecent[0].t : 0;
const clSpan = clRecent.length >= 2 ? clRecent[clRecent.length-1].t - clRecent[0].t : 0;
const minSpan = Math.min(bnSpan, clSpan);
const stateEl = document.getElementById('strat-state-text');
const showHint = (msg) => {
if (!stateEl) return;
const prevText = stateEl.textContent;
const prevCls = stateEl.className;
stateEl.textContent = msg;
stateEl.className = 'fail';
setTimeout(() => { stateEl.textContent = prevText; stateEl.className = prevCls; }, 2000);
};
// Alignment disabled while a strategy is running
const s = strat.state;
if (s === 'BUYING' || s === 'HOLDING' || s === 'SELLING' || s === 'WAIT_FILL' || s === 'RECONCILING_FILL' || s === 'WAIT_SELL_FILL') {
showHint('Strategy running, cannot align');
return;
}
if (minSpan < 10000) {
const waitSec = Math.ceil((10000 - minSpan) / 1000);
showHint(`Not enough data to align, waiting ${waitSec}s`);
return;
}
binanceOffset = calcAlignOffset();
probScale = getDefaultProbScale();
chartZoom = 1.0;
_bnDragEndTime = now;
drawChart();
});
// Compute the alignment offset from the average difference of the two lines over the past 60 seconds
function calcMedian(values) {
if (!values.length) return NaN;
const sorted = values.slice().sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
}
function calcTrimmedMean(values, trimRatio = 0.15) {
if (!values.length) return NaN;
const sorted = values.slice().sort((a, b) => a - b);
const trim = sorted.length >= 8 ? Math.floor(sorted.length * trimRatio) : 0;
const trimmed = trim > 0 ? sorted.slice(trim, sorted.length - trim) : sorted;
return trimmed.reduce((sum, value) => sum + value, 0) / trimmed.length;
}
function calcAlignOffset() {
if (!binancePoints.length || !chartPoints.length) return 0;
const now = Date.now();
const bnRecent = binancePoints.filter(p => p.t >= now - 60000);
const clRecent = chartPoints.filter(p => p.t >= now - 60000);
if (!bnRecent.length) bnRecent.push(...binancePoints.slice(-1));
if (!clRecent.length) clRecent.push(...chartPoints.slice(-1));
const overlapStart = Math.max(bnRecent[0].t, clRecent[0].t);
const overlapEnd = Math.min(bnRecent[bnRecent.length - 1].t, clRecent[clRecent.length - 1].t);
const bucketMs = 500;
const diffs = [];
if (overlapEnd - overlapStart >= bucketMs * 2) {
let bnIdx = 0;
let clIdx = 0;
for (let bucketStart = overlapStart; bucketStart <= overlapEnd; bucketStart += bucketMs) {
const bucketEnd = bucketStart + bucketMs;
const bnBucket = [];
const clBucket = [];
while (bnIdx < bnRecent.length && bnRecent[bnIdx].t < bucketStart) bnIdx++;
while (clIdx < clRecent.length && clRecent[clIdx].t < bucketStart) clIdx++;
let i = bnIdx;
while (i < bnRecent.length && bnRecent[i].t < bucketEnd) {
bnBucket.push(bnRecent[i].price);
i++;
}
let j = clIdx;
while (j < clRecent.length && clRecent[j].t < bucketEnd) {
clBucket.push(clRecent[j].price);
j++;
}
if (bnBucket.length && clBucket.length) {
diffs.push(calcMedian(clBucket) - calcMedian(bnBucket));
}
}
}
if (!diffs.length) {
return clRecent[clRecent.length - 1].price - bnRecent[bnRecent.length - 1].price;
}
if (diffs.length < 5) {
return calcTrimmedMean(diffs, 0);
}
const median = calcMedian(diffs);
const absDeviations = diffs.map(diff => Math.abs(diff - median));
const mad = calcMedian(absDeviations);
const threshold = Math.max(10, mad * 3);
const filtered = diffs.filter(diff => Math.abs(diff - median) <= threshold);
const stable = filtered.length >= 3 ? filtered : diffs;
return calcTrimmedMean(stable, 0.15);
}
// Auto-correct the offset every 30 seconds using a 60-second data window (skipped within 0.5s after manual action, skipped while a strategy is active)
let _lastAutoAlign = 0;
setInterval(() => {
if (!BINANCE_OFFSET_MANUAL_ENABLED) return;
const now = Date.now();
if (now - _bnDragEndTime < 500) return;
const activeState = strat.state;
if (activeState === 'BUYING' || activeState === 'HOLDING' || activeState === 'SELLING' ||
activeState === 'WAIT_FILL' || activeState === 'RECONCILING_FILL' || activeState === 'WAIT_SELL_FILL') return;
const bnRecent = binancePoints.filter(p => p.t >= now - 60000);
const clRecent = chartPoints.filter(p => p.t >= now - 60000);
const bnSpan = bnRecent.length >= 2 ? bnRecent[bnRecent.length-1].t - bnRecent[0].t : 0;
const clSpan = clRecent.length >= 2 ? clRecent[clRecent.length-1].t - clRecent[0].t : 0;
if (Math.min(bnSpan, clSpan) < 10000) return;
binanceOffset = calcAlignOffset();
_lastAutoAlign = now;
drawChart();
}, 30000);
chartCanvas2.addEventListener('wheel', (e) => {
e.preventDefault();
// Some browsers convert shift+wheel deltaY into deltaX; check both
const delta = e.deltaY !== 0 ? e.deltaY : e.deltaX;
if (delta === 0) return;
const factor = delta < 0 ? 1.1 : 0.9;
if (e.shiftKey) {
// Bounds by the current PTB magnitude (defaults 0.001x ~ 100x)
const base = getDefaultProbScale();
probScale = Math.max(base * 0.01, Math.min(base * 1000, probScale * factor));
} else {
chartZoom = Math.max(0.2, Math.min(20, chartZoom * factor));
}
drawChart();
}, { passive: false });
// The sliding window changes dynamically with width, computed live in drawChart
let CHART_WINDOW_MS = 60000;
function calcWindowMs(canvasWidth) {
if (!canvasWidth) return 120000;
// 300px -> 60s, 900px+ -> 120s, linear interpolation
const t = Math.min(Math.max((canvasWidth - 300) / 600, 0), 1);
return Math.round((60000 + t * 60000) / 1000) * 1000;
}
// Smoothed Y-axis range
let smoothMinP = null;
let smoothMaxP = null;
// Smoothed end price (only the latest point is smoothed, historical points unchanged)
let smoothLastPrice = null;
let lastRealPrice = null;
let lastFrameTime = null;
function clearRealtimeBuffers() {
chartPoints.length = 0;
binancePoints.length = 0;
coinbasePoints.length = 0;
probHistory.length = 0;
tradeMarkers.length = 0;
smoothMinP = null;
smoothMaxP = null;
smoothLastPrice = null;
lastRealPrice = null;
lastFrameTime = null;
}
// Probability-scale adaptation: use 0.3% of chartPtb as the price span for the mid 0->1 mapping
// BTC 70000 -> 210; ETH 3000 -> 9; SOL 200 -> 0.6; XRP 2 -> 0.006
function getDefaultProbScale() {
if (chartPtb && chartPtb > 0) return Math.max(0.01, chartPtb * 0.003);
return 250; // Fallback: keep the old default for unknown magnitudes
}
// Return suitable decimals for the current price magnitude (hardcoded per-asset user preference)
// Decimals: prefer the server-pushed value (sharing the same threshold definition as backtest/backend), otherwise derive locally from chartPtb
function priceDecimals() {
if (typeof activeMarketPriceDecimals === 'number') return activeMarketPriceDecimals;
const ptb = chartPtb || 0;
if (ptb >= 10000) return 0;
if (ptb >= 1000) return 2;
if (ptb >= 10) return 4;
if (ptb >= 1) return 4;
return 5;
}
function fmtDiff(d) {
const dec = priceDecimals();
return (d >= 0 ? '+' : '') + '$' + d.toFixed(dec);
}
// Diff + percentage in parentheses, for cross-asset comparison (chartPtb is PriceToBeat)
function fmtDiffWithPct(d) {
const base = chartPtb;
const dec = priceDecimals();
const dStr = (d >= 0 ? '+' : '') + '$' + d.toFixed(dec);
if (!base || base <= 0) return dStr;
const pct = (d / base) * 100;
// Percentage uniformly to 3 decimals (bp), since low-priced assets have small percentage moves and need finer detail
const pctDec = 3;
return dStr + ' (' + (pct >= 0 ? '+' : '') + pct.toFixed(pctDec) + '%)';
}
function fmtOffset(o) {
const dec = priceDecimals();
return (o >= 0 ? '+' : '') + o.toFixed(dec);
}
function chartReset() {
chartPtb = null;
probHistory.length = 0;
tradeMarkers.length = 0;
binanceOffset = 0;
serverBinanceDiff = NaN;
probScale = getDefaultProbScale();
chartZoom = 1.0;
updateBnDiff();
}
// Use requestAnimationFrame to continuously redraw for smooth sliding
function chartLoop(frameTime) {
const dt = lastFrameTime ? Math.min(frameTime - lastFrameTime, 100) : 16;
lastFrameTime = frameTime;
if (chartPoints.length >= 2) {
const realLast = chartPoints[chartPoints.length - 1].price;
smoothLastPrice = realLast; // Disable EMA, use the real price directly
lastRealPrice = realLast;
drawChart();
}
requestAnimationFrame(chartLoop);
}
requestAnimationFrame(chartLoop);
function drawChart() {
if (lowDataMode) return;
const dpr = window.devicePixelRatio || 1;
const w = chartCanvas.offsetWidth;
const h = chartCanvas.offsetHeight;
if (w <= 0 || h <= 0) return;
// Dynamically update the sliding window by width
CHART_WINDOW_MS = calcWindowMs(w);
// Reset only on size change, to avoid force-clearing the canvas every frame
if (chartCanvas.width !== w * dpr || chartCanvas.height !== h * dpr) {
chartCanvas.width = w * dpr;
chartCanvas.height = h * dpr;
}
const ctx = chartCtx;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
if (chartPoints.length < 2) return;
const PAD_L = 58, PAD_R = 10, PAD_T = 12, PAD_B = 22;
const pw = w - PAD_L - PAD_R;
const ph = h - PAD_T - PAD_B;
// Time range: last 1 minute, the latest point at the far right
const now = Date.now();
const tEnd = now;
const tStart = now - CHART_WINDOW_MS;
// Draw only points within the window; replace the end point with the smoothed price + current time for smooth sliding
const raw = chartPoints.filter(p => p.t >= tStart);
if (raw.length < 2) return;
const visible = raw.slice(0, -1);
const sp = smoothLastPrice ?? raw[raw.length - 1].price;
visible.push({ t: now, price: sp });
// Price range (target value)
const prices = visible.map(p => p.price);
if (chartPtb) prices.push(chartPtb);
let targetMin = Math.min(...prices);
let targetMax = Math.max(...prices);
const range = targetMax - targetMin;
const pad = range * 0.18 || 5;
targetMin -= pad; targetMax += pad;
// Apply the overall scale: zoom around the center point
if (chartZoom !== 1.0) {
const center = (targetMin + targetMax) / 2;
const half = (targetMax - targetMin) / 2 / chartZoom;
targetMin = center - half;
targetMax = center + half;
}
// Smooth interpolation to avoid abrupt Y-axis range changes
const alpha = 0.06; // smaller = smoother, larger = more responsive
if (smoothMinP === null) { smoothMinP = targetMin; smoothMaxP = targetMax; }
else {
smoothMinP += (targetMin - smoothMinP) * alpha;
smoothMaxP += (targetMax - smoothMaxP) * alpha;
}
const minP = smoothMinP;
const maxP = smoothMaxP;
const tx = t => PAD_L + (t - tStart) / (tEnd - tStart) * pw;
const ty = p => PAD_T + ph - (p - minP) / (maxP - minP) * ph;
ctx.clearRect(0, 0, w, h);
// ── Grid lines + Y-axis ticks ──
ctx.setLineDash([2, 3]);
ctx.strokeStyle = '#1a1f28';
ctx.lineWidth = 1;
ctx.fillStyle = '#555';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
const yTicks = 5;
for (let i = 0; i <= yTicks; i++) {
const p = minP + (maxP - minP) * i / yTicks;
const y = ty(p);
ctx.beginPath();
ctx.moveTo(PAD_L, y); ctx.lineTo(w - PAD_R, y);
ctx.stroke();
ctx.fillText('$' + p.toFixed(priceDecimals()), PAD_L - 4, y + 3.5);
}
ctx.setLineDash([]);
ctx.textAlign = 'left';
// ── Window divider lines (dynamic by current market period: 5m=300000 / 15m=900000)──
const WINDOW_MS = (activeMarketPeriodSeconds || 300) * 1000;
let boundaryTs = Math.floor(tStart / WINDOW_MS) * WINDOW_MS;
if (boundaryTs < tStart) boundaryTs += WINDOW_MS;
ctx.setLineDash([4, 4]);
ctx.strokeStyle = '#f0a50055';
ctx.lineWidth = 1;
ctx.fillStyle = '#f0a500aa';
ctx.font = '9px monospace';
while (boundaryTs < tEnd) {
const x = tx(boundaryTs);
ctx.beginPath();
ctx.moveTo(x, PAD_T);
ctx.lineTo(x, PAD_T + ph);
ctx.stroke();
ctx.textAlign = 'center';
ctx.fillText(new Date(boundaryTs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }), x, PAD_T + 10);
boundaryTs += WINDOW_MS;
}
ctx.setLineDash([]);
ctx.textAlign = 'left';
// ── price to beat baseline ──
if (chartPtb) {
const y = ty(chartPtb);
ctx.setLineDash([5, 4]);
ctx.strokeStyle = '#f0a50088';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(PAD_L, y); ctx.lineTo(w - PAD_R, y);
ctx.stroke();
ctx.setLineDash([]);
// Label
ctx.fillStyle = '#f0a500';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText('PTB $' + chartPtb.toFixed(priceDecimals()), PAD_L - 4, y - 4);
ctx.textAlign = 'left';
}
// ── Diff reference lines (+/-50 / +/-100) ──
if (chartPtb != null) {
const diffLevels = [100, 50, -50, -100];
ctx.save();
ctx.setLineDash([3, 5]);
ctx.strokeStyle = '#f0a50044';
ctx.lineWidth = 1;
ctx.fillStyle = '#f0a50088';
ctx.font = '9px monospace';
ctx.textAlign = 'right';
diffLevels.forEach((diffLevel) => {
const refPrice = chartPtb + diffLevel;
if (refPrice < minP || refPrice > maxP) return;
const y = ty(refPrice);
ctx.beginPath();
ctx.moveTo(PAD_L, y);
ctx.lineTo(w - PAD_R, y);
ctx.stroke();
ctx.fillText(`${diffLevel > 0 ? '+' : ''}${diffLevel}`, PAD_L - 4, y + 3);
});
ctx.restore();
}
// ── Gradient fill (green above the baseline / red below) ──
if (chartPtb && visible.length >= 2) {
const ptbY = ty(chartPtb);
// Green fill (curve above the baseline)
ctx.save();
ctx.beginPath();
ctx.rect(PAD_L, PAD_T, pw, ptbY - PAD_T);
ctx.clip();
ctx.beginPath();
visible.forEach((p, i) => {
const x = tx(p.t), y = ty(p.price);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(tx(visible[visible.length-1].t), ptbY);
ctx.lineTo(tx(visible[0].t), ptbY);
ctx.closePath();
ctx.fillStyle = 'rgba(63,185,80,0.08)';
ctx.fill();
ctx.restore();
// Red fill (curve below the baseline)
ctx.save();
ctx.beginPath();
ctx.rect(PAD_L, ptbY, pw, PAD_T + ph - ptbY);
ctx.clip();
ctx.beginPath();
visible.forEach((p, i) => {
const x = tx(p.t), y = ty(p.price);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.lineTo(tx(visible[visible.length-1].t), ptbY);
ctx.lineTo(tx(visible[0].t), ptbY);
ctx.closePath();
ctx.fillStyle = 'rgba(248,81,73,0.08)';
ctx.fill();
ctx.restore();
}
// ── Vertical lines for buy/sell markers in the current window ──
const visibleMarkers = tradeMarkers.filter(m =>
m.windowStart === chartWindowStart && m.t >= tStart && m.t <= tEnd
);
visibleMarkers.forEach((marker) => {
const x = Math.max(PAD_L, Math.min(w - PAD_R, tx(marker.t)));
const color = marker.side === 'buy' ? '#3fb950cc' : '#f85149cc';
const label = marker.side === 'buy' ? 'Buy' : 'Sell';
const probText = marker.prob != null ? `${marker.dir === 'up' ? 'Up' : 'Down'}${marker.prob}%` : '';
const placeTop = marker.dir === 'up';
ctx.save();
ctx.setLineDash([5, 4]);
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, PAD_T);
ctx.lineTo(x, PAD_T + ph);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = color;
ctx.font = 'bold 10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(label, x, placeTop ? PAD_T + 10 : PAD_T + ph - 18);
if (probText) {
ctx.font = '9px sans-serif';
ctx.fillText(probText, x, placeTop ? PAD_T + 22 : PAD_T + ph - 30);
}
ctx.restore();
});
// ── Price curve (up-green/down-red gradient) ──
const last = visible[visible.length - 1];
const lineColor = chartPtb != null
? (last.price >= chartPtb ? '#3fb950' : '#f85149')
: '#f0a500';
ctx.beginPath();
ctx.lineWidth = 1.8;
ctx.strokeStyle = lineColor;
ctx.lineJoin = 'round';
visible.forEach((p, i) => {
const x = tx(p.t), y = ty(p.price);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
// ── Binance price line (solid yellow, offset applied) ──
const binanceRaw = binancePoints.filter(p => p.t >= tStart);
if (binanceRaw.length >= 2) {
const binanceVisible = binanceRaw.slice();
const lastBinance = binanceRaw[binanceRaw.length - 1];
if (lastBinance.t < now) {
binanceVisible.push({ t: now, price: lastBinance.price });
}
// Detect overflow
const bnPrices = binanceVisible.map(p => p.price + binanceOffset);
const bnMax = Math.max(...bnPrices);
const bnMin = Math.min(...bnPrices);
const overTop = bnMax > maxP;
const overBottom = bnMin < minP;
ctx.save();
ctx.beginPath();
ctx.rect(PAD_L, PAD_T, pw, ph);
ctx.clip();
ctx.beginPath();
ctx.setLineDash([]);
ctx.lineWidth = 1;
ctx.strokeStyle = '#f0a500cc';
ctx.lineJoin = 'round';
binanceVisible.forEach((p, i) => {
const x = tx(p.t), y = ty(p.price + binanceOffset);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
ctx.restore();
// Overflow indicator arrow
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'left';
if (overTop) {
ctx.fillStyle = '#f0a500cc';
ctx.fillText('▲ Binance above', PAD_L + 4, PAD_T + 14);
}
if (overBottom) {
ctx.fillStyle = '#f0a500cc';
ctx.fillText('▼ Binance below', PAD_L + 4, PAD_T + ph - 4);
}
// Offset
ctx.fillStyle = '#f0a50099';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(`Binance ${fmtOffset(binanceOffset)}`, w - PAD_R, PAD_T + 12);
ctx.textAlign = 'left';
// End point + diff label
if (chartPtb != null) {
const last = binanceVisible[binanceVisible.length - 1];
const bnBase = chartPtb - binanceOffset;
const d = last.price - bnBase;
const lx = tx(last.t);
const ly = ty(last.price + binanceOffset);
// End point
ctx.beginPath();
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#f0a500';
ctx.fill();
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, Math.PI * 2);
ctx.fillStyle = '#f0a50033';
ctx.fill();
const dStr = fmtDiffWithPct(d);
ctx.save();
ctx.font = 'bold 11px monospace';
ctx.fillStyle = '#f0a500';
ctx.textAlign = 'right';
ctx.fillText(dStr, w - PAD_R - 54, Math.max(Math.min(ty(last.price + binanceOffset), PAD_T + ph - 4), PAD_T + 10));
ctx.restore();
}
}
// ── Generic drawing of other price sources (Coinbase blue)──
const drawExtraSource = (points, offset, color, label, topY, bottomY, dotEndY, labelXOffset = 27) => {
const raw = points.filter(p => p.t >= tStart);
if (raw.length < 2) return;
const visible = raw.slice();
const last = raw[raw.length - 1];
if (last.t < now) visible.push({ t: now, price: last.price });
const prices = visible.map(p => p.price + offset);
const overTop = Math.max(...prices) > maxP;
const overBottom = Math.min(...prices) < minP;
ctx.save();
ctx.beginPath();
ctx.rect(PAD_L, PAD_T, pw, ph);
ctx.clip();
ctx.beginPath();
ctx.setLineDash([]);
ctx.lineWidth = 1;
ctx.strokeStyle = color + 'cc';
ctx.lineJoin = 'round';
visible.forEach((p, i) => {
const x = tx(p.t), y = ty(p.price + offset);
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
ctx.restore();
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'left';
if (overTop) {
ctx.fillStyle = color + 'cc';
ctx.fillText('▲ ' + label + ' above', PAD_L + 4, topY);
}
if (overBottom) {
ctx.fillStyle = color + 'cc';
ctx.fillText('▼ ' + label + ' below', PAD_L + 4, bottomY);
}
ctx.fillStyle = color + '99';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(label + fmtOffset(offset), w - PAD_R, topY - 2);
ctx.textAlign = 'left';
if (chartPtb != null) {
const lastV = visible[visible.length - 1];
const base = chartPtb - offset;
const d = lastV.price - base;
const lx = tx(lastV.t);
const ly = ty(lastV.price + offset);
ctx.beginPath();
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2);
ctx.fillStyle = color;
ctx.fill();
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, Math.PI * 2);
ctx.fillStyle = color + '33';
ctx.fill();
const dStr = fmtDiffWithPct(d);
ctx.save();
ctx.font = 'bold 11px monospace';
ctx.fillStyle = color;
ctx.textAlign = 'right';
ctx.fillText(dStr, w - PAD_R - labelXOffset, Math.max(Math.min(ty(lastV.price + offset), PAD_T + ph - 4), dotEndY));
ctx.restore();
}
};
drawExtraSource(coinbasePoints, coinbaseOffset, '#d670ff', 'Coinbase', PAD_T + 28, PAD_T + ph - 18, PAD_T + 22);
// ── Probability line (solid cyan, Shift+right-click drag) ──
const probVisible = probHistory.filter(p => p.t >= tStart);
if (probVisible.length >= 2 && chartPtb) {
const probToPrice = mid => chartPtb + (mid - 0.5) * probScale;
// Probability guide lines (one every 25%, skipping 50% since the baseline already covers it)
ctx.setLineDash([4, 4]);
ctx.lineWidth = 0.8;
ctx.strokeStyle = '#58a6ff88';
ctx.textAlign = 'left';
ctx.fillStyle = '#58a6ffaa';
ctx.font = '9px monospace';
[0, 25, 75, 100].forEach(pct => {
const y = ty(probToPrice(pct / 100));
if (y >= PAD_T && y <= PAD_T + ph) {
ctx.beginPath();
ctx.moveTo(PAD_L, y); ctx.lineTo(w - PAD_R, y);
ctx.stroke();
ctx.fillText(pct + '%', PAD_L + 2, y - 2);
}
});
ctx.setLineDash([]);
const probPrices = probVisible.map(p => probToPrice(p.mid));
const probMax = Math.max(...probPrices);
const probMin = Math.min(...probPrices);
const probOverTop = probMax > maxP;
const probOverBottom = probMin < minP;
ctx.save();
ctx.beginPath();
ctx.rect(PAD_L, PAD_T, pw, ph);
ctx.clip();
ctx.beginPath();
ctx.setLineDash([]);
ctx.lineWidth = 1;
ctx.strokeStyle = '#58a6ffaa';
ctx.lineJoin = 'round';
probVisible.forEach((p, i) => {
const x = tx(p.t), y = ty(probToPrice(p.mid));
i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
});
ctx.stroke();
ctx.restore();
// Latest probability point + label (always visible, pinned to the edge on overflow)
const lastProb = probVisible[probVisible.length - 1];
const lpx = tx(lastProb.t);
const lpyRaw = ty(probToPrice(lastProb.mid));
const lpyClamped = Math.max(PAD_T + 2, Math.min(PAD_T + ph - 2, lpyRaw));
const probPct = Math.round(lastProb.mid * 100);
const isOverflow = lpyRaw < PAD_T || lpyRaw > PAD_T + ph;
if (!isOverflow) {
ctx.beginPath();
ctx.arc(lpx, lpyClamped, 3.5, 0, Math.PI * 2);
ctx.fillStyle = '#58a6ff';
ctx.fill();
ctx.beginPath();
ctx.arc(lpx, lpyClamped, 6, 0, Math.PI * 2);
ctx.fillStyle = '#58a6ff33';
ctx.fill();
}
ctx.font = 'bold 11px monospace';
ctx.fillStyle = '#58a6ffee';
const probLabelY = Math.max(PAD_T + 14, Math.min(PAD_T + ph - 4, lpyClamped + 4));
ctx.textAlign = 'right';
ctx.fillText(`${probPct}% up`, w - PAD_R, probLabelY);
// Overflow hint
ctx.font = 'bold 11px monospace';
ctx.textAlign = 'left';
if (probOverTop) { ctx.fillStyle = '#58a6ffcc'; ctx.fillText(`▲ Probability above ${probPct}%`, PAD_L + 4, PAD_T + 28); }
if (probOverBottom) { ctx.fillStyle = '#58a6ffcc'; ctx.fillText(`▼ Probability below ${probPct}%`, PAD_L + 4, PAD_T + ph - 18); }
// Scale label
ctx.fillStyle = '#58a6ff99';
ctx.font = '10px monospace';
ctx.textAlign = 'right';
ctx.fillText(`Prob scale x${Math.round(probScale)}`, w - PAD_R, PAD_T + 40);
ctx.textAlign = 'left';
}
// ── Right-click drag hint (persistent) ──
ctx.fillStyle = '#ffffff66';
ctx.font = '11px monospace';
ctx.textAlign = 'right';
ctx.fillText('Wheel to zoom | Right-click drag Binance | Shift+wheel adjust probability | Double-click to reset', w - PAD_R, h - PAD_B - 6);
ctx.textAlign = 'left';
// ── Current price point + label ──
const lx = tx(last.t), ly = ty(last.price);
ctx.beginPath();
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2);
ctx.fillStyle = lineColor;
ctx.fill();
// Glow
ctx.beginPath();
ctx.arc(lx, ly, 6, 0, Math.PI * 2);
ctx.fillStyle = lineColor.replace(')', ',0.25)').replace('rgb', 'rgba');
if (!ctx.fillStyle.includes('rgba')) ctx.fillStyle = lineColor + '40';
ctx.fill();
// Current price + diff label
ctx.font = 'bold 11px monospace';
ctx.fillStyle = lineColor;
const dec = priceDecimals();
let priceLabel = '$' + last.price.toFixed(dec);
if (chartPtb != null) {
const d = last.price - chartPtb;
priceLabel = fmtDiffWithPct(d);
}
ctx.textAlign = 'right';
ctx.fillText(priceLabel, w - PAD_R - 81, Math.max(Math.min(ly + 4, PAD_T + ph - 4), PAD_T + 10));
// ── X-axis time ticks ──
ctx.fillStyle = '#444';
ctx.font = '9px monospace';
const xTicks = 4;
for (let i = 0; i <= xTicks; i++) {
const t = tStart + (tEnd - tStart) * i / xTicks;
const x = tx(t);
ctx.textAlign = i === xTicks ? 'right' : i === 0 ? 'left' : 'center';
const sec = Math.round((t - now) / 1000);
ctx.fillText(sec === 0 ? 'now' : sec + 's', x, h - 5);
}
ctx.textAlign = 'left';
}
// ── WS connection ───────────────────────────────────────────────────
let ws = null, reconnectDelay = 1000;
let frontendPingTimer = null;
let frontendStaleTimer = null;
let lastPongAt = 0;
function startFrontendPing() {
stopFrontendPing();
frontendPingTimer = setInterval(() => {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping', clientTs: Date.now() }));
}
}, 2000);
// Mark as disconnected if no pong received for 6 seconds
frontendStaleTimer = setInterval(() => {
if (lastPongAt && Date.now() - lastPongAt > 6000) {
const el = document.getElementById('frontend-latency');
const val = document.getElementById('frontend-latency-val');
if (el && val) {
el.classList.remove('http-fast', 'http-slow');
el.classList.add('http-bad');
val.textContent = 'DC';
}
}
}, 1000);
}
function stopFrontendPing() {
if (frontendPingTimer) { clearInterval(frontendPingTimer); frontendPingTimer = null; }
if (frontendStaleTimer) { clearInterval(frontendStaleTimer); frontendStaleTimer = null; }
}
function connect() {
ws = new WebSocket(buildWsUrl());
ws.onopen = () => {
$connDot.classList.remove('disconnected');
reconnectDelay = 1000;
ws.send(JSON.stringify({ type: 'clientConfig', dataMode: lowDataMode ? 'low' : 'full' }));
lastPongAt = Date.now();
startFrontendPing();
};
ws.onmessage = (e) => { try { onMessage(JSON.parse(e.data)); } catch {} };
ws.onclose = () => {
$connDot.classList.add('disconnected');
stopFrontendPing();
// Clear real-time data on disconnect, to avoid stale-data misjudgment
clearRealtimeBuffers();
$cur.textContent = '-';
$diff.textContent = '-'; $orderDiff.textContent = '-';
$ptb.textContent = '-';
curUsdc = null;
curUpLocal = 0; curDnLocal = 0;
curUpApi = 0; curDnApi = 0; curApiSyncAt = 0;
clearBestDrivenState('Waiting for data...');
updateEst();
updateStratUI();
$acctUsdc.textContent = '-';
// Mark frontend latency as disconnected
const flEl = document.getElementById('frontend-latency');
const flVal = document.getElementById('frontend-latency-val');
if (flEl && flVal) {
flEl.classList.remove('http-fast', 'http-slow');
flEl.classList.add('http-bad');
flVal.textContent = 'DC';
}
updateCompactOverview();
setTimeout(connect, reconnectDelay);
reconnectDelay = Math.min(reconnectDelay * 2, 10000);
};
ws.onerror = () => ws.close();
}
connect();
</script>
</body>
</html>