6620 lines
248 KiB
HTML
6620 lines
248 KiB
HTML
<!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><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> 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 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 < 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, '"')}"` : '';
|
||
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>
|