Spaces:
Paused
Paused
Download app/core/dashboard.py from Ethscriptions/coinpush: direct link, hf CLI and curl.
- Browser
- Download file 86.7 kB
-
https://huggingface.co/spaces/Ethscriptions/coinpush/resolve/main/app/core/dashboard.py
- Command line
-
hf download hf://spaces/Ethscriptions/coinpush/app/core/dashboard.py
-
curl -L -o dashboard.py https://huggingface.co/spaces/Ethscriptions/coinpush/resolve/main/app/core/dashboard.py
86.7 kB
| def render_dashboard(): | |
| """Single-file operational dashboard rendered by FastAPI.""" | |
| return """ | |
| <!doctype html> | |
| <html lang="zh-CN"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <link rel="icon" href="/icons/icon.svg"> | |
| <link rel="manifest" href="/manifest.webmanifest"> | |
| <meta name="theme-color" content="#111827"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-title" content="监控平台"> | |
| <title>监控平台</title> | |
| <style> | |
| :root { | |
| color-scheme: light; | |
| --bg: #f3f5f8; | |
| --panel: #ffffff; | |
| --soft: #f7f8fa; | |
| --soft-2: #eef1f5; | |
| --text: #111827; | |
| --muted: #667085; | |
| --line: #d9dee7; | |
| --ok: #067647; | |
| --ok-bg: #ecfdf3; | |
| --warn: #b54708; | |
| --warn-bg: #fffaeb; | |
| --err: #b42318; | |
| --err-bg: #fef3f2; | |
| --run: #175cd3; | |
| --run-bg: #eff6ff; | |
| --focus: #2563eb; | |
| --ink: #111827; | |
| --amber: #f59e0b; | |
| --teal: #0f766e; | |
| --rose: #be123c; | |
| --shadow: 0 18px 45px rgba(17, 24, 39, .10); | |
| --shadow-soft: 0 1px 2px rgba(17, 24, 39, .06); | |
| } | |
| * { box-sizing: border-box; } | |
| body { | |
| margin: 0; | |
| background: | |
| linear-gradient(180deg, #fbfcfd 0%, var(--bg) 18rem); | |
| color: var(--text); | |
| font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| } | |
| header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 16px; | |
| padding: 16px 24px; | |
| border-bottom: 1px solid var(--line); | |
| background: rgba(255, 255, 255, .94); | |
| backdrop-filter: blur(14px); | |
| position: sticky; | |
| top: 0; | |
| z-index: 5; | |
| } | |
| h1 { margin: 0; font-size: 19px; letter-spacing: 0; } | |
| h2, h3 { letter-spacing: 0; } | |
| main { | |
| display: flex; | |
| flex-direction: column; | |
| padding: 20px 24px 40px; | |
| max-width: 1540px; | |
| margin: 0 auto; | |
| } | |
| .app-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; } | |
| .app-subtitle { color: var(--muted); font-size: 12px; } | |
| section { | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| margin-bottom: 18px; | |
| overflow: hidden; | |
| box-shadow: var(--shadow-soft); | |
| } | |
| section > .section-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| padding: 13px 16px; | |
| border-bottom: 1px solid var(--line); | |
| background: linear-gradient(180deg, #ffffff 0%, var(--soft) 100%); | |
| } | |
| section h2 { margin: 0; font-size: 15px; font-weight: 750; } | |
| .summary { | |
| display: grid; | |
| grid-template-columns: repeat(4, minmax(0, 1fr)); | |
| gap: 14px; | |
| margin-bottom: 18px; | |
| } | |
| .metric { | |
| background: var(--panel); | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| padding: 14px 16px; | |
| min-height: 82px; | |
| box-shadow: var(--shadow-soft); | |
| } | |
| .metric span { color: var(--muted); display: block; font-size: 12px; } | |
| .metric strong { display: block; font-size: 24px; margin-top: 5px; letter-spacing: 0; } | |
| .market-hero { | |
| background: #111827; | |
| color: #f9fafb; | |
| border-color: #111827; | |
| box-shadow: var(--shadow); | |
| } | |
| .market-hero > .section-head { | |
| background: transparent; | |
| border-color: rgba(255, 255, 255, .12); | |
| color: #f9fafb; | |
| } | |
| .market-hero .muted { color: #cbd5e1; } | |
| .core-market { | |
| display: grid; | |
| grid-template-columns: minmax(280px, 1.15fr) minmax(320px, 1.85fr); | |
| gap: 0; | |
| } | |
| .core-price-panel { | |
| padding: 22px; | |
| border-right: 1px solid rgba(255, 255, 255, .12); | |
| min-width: 0; | |
| } | |
| .asset-line { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| flex-wrap: wrap; | |
| margin-bottom: 10px; | |
| } | |
| .asset-badge { | |
| display: inline-flex; | |
| align-items: center; | |
| border-radius: 999px; | |
| border: 1px solid rgba(255, 255, 255, .16); | |
| background: rgba(255, 255, 255, .08); | |
| color: #f9fafb; | |
| padding: 4px 10px; | |
| font-size: 12px; | |
| font-weight: 750; | |
| } | |
| .asset-source { | |
| color: #cbd5e1; | |
| font-size: 12px; | |
| overflow-wrap: anywhere; | |
| } | |
| .core-price { | |
| font-size: clamp(34px, 5vw, 56px); | |
| line-height: 1; | |
| font-weight: 820; | |
| letter-spacing: 0; | |
| margin: 6px 0 12px; | |
| } | |
| .price-subline { | |
| display: flex; | |
| gap: 8px; | |
| flex-wrap: wrap; | |
| color: #cbd5e1; | |
| font-size: 12px; | |
| } | |
| .change-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| border-radius: 999px; | |
| padding: 4px 9px; | |
| font-weight: 750; | |
| background: rgba(255, 255, 255, .08); | |
| color: #e5e7eb; | |
| } | |
| .change-chip.up { color: #bbf7d0; background: rgba(22, 163, 74, .16); } | |
| .change-chip.down { color: #fecdd3; background: rgba(225, 29, 72, .16); } | |
| .market-detail-grid { | |
| display: grid; | |
| grid-template-columns: repeat(3, minmax(0, 1fr)); | |
| gap: 1px; | |
| background: rgba(255, 255, 255, .10); | |
| min-width: 0; | |
| } | |
| .market-tile { | |
| background: rgba(255, 255, 255, .055); | |
| padding: 16px; | |
| min-height: 102px; | |
| min-width: 0; | |
| } | |
| .market-tile span { | |
| display: block; | |
| color: #cbd5e1; | |
| font-size: 12px; | |
| margin-bottom: 6px; | |
| } | |
| .market-tile strong { | |
| display: block; | |
| color: #fff; | |
| font-size: 21px; | |
| line-height: 1.18; | |
| overflow-wrap: anywhere; | |
| } | |
| .market-tile small { | |
| display: block; | |
| color: #cbd5e1; | |
| margin-top: 7px; | |
| font-size: 12px; | |
| overflow-wrap: anywhere; | |
| } | |
| .core-chart { | |
| padding: 18px 20px 20px; | |
| border-top: 1px solid rgba(255, 255, 255, .12); | |
| } | |
| .core-chart-head { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| flex-wrap: wrap; | |
| margin-bottom: 14px; | |
| } | |
| .core-chart-title h3 { | |
| margin: 0; | |
| font-size: 16px; | |
| font-weight: 750; | |
| } | |
| .core-chart-title small { | |
| display: block; | |
| margin-top: 4px; | |
| color: #cbd5e1; | |
| font-size: 12px; | |
| } | |
| .chart-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 6px; | |
| flex-wrap: wrap; | |
| } | |
| .chart-pill { | |
| min-height: 30px; | |
| padding: 5px 10px; | |
| border: 1px solid rgba(255, 255, 255, .16); | |
| border-radius: 999px; | |
| background: rgba(255, 255, 255, .07); | |
| color: #e5e7eb; | |
| cursor: pointer; | |
| box-shadow: none; | |
| } | |
| .chart-pill:hover { border-color: rgba(255, 255, 255, .30); background: rgba(255, 255, 255, .11); } | |
| .chart-pill.active { | |
| border-color: rgba(45, 212, 191, .55); | |
| background: rgba(45, 212, 191, .14); | |
| color: #99f6e4; | |
| font-weight: 750; | |
| } | |
| .chart-shell { | |
| position: relative; | |
| height: 330px; | |
| border: 1px solid rgba(255, 255, 255, .10); | |
| border-radius: 8px; | |
| background: linear-gradient(180deg, rgba(255,255,255,.045) 0%, rgba(255,255,255,.015) 100%); | |
| } | |
| #coreCandleChart { display: block; width: 100%; height: 100%; cursor: crosshair; } | |
| .chart-tooltip { | |
| position: absolute; | |
| z-index: 3; | |
| display: none; | |
| min-width: 168px; | |
| padding: 9px 10px; | |
| border: 1px solid rgba(255, 255, 255, .16); | |
| border-radius: 7px; | |
| background: rgba(17, 24, 39, .95); | |
| color: #f9fafb; | |
| font-size: 12px; | |
| line-height: 1.45; | |
| pointer-events: none; | |
| box-shadow: 0 10px 24px rgba(0, 0, 0, .28); | |
| } | |
| .chart-message { | |
| min-height: 20px; | |
| margin-top: 9px; | |
| color: #cbd5e1; | |
| font-size: 12px; | |
| } | |
| .chart-message.error { color: #fda4af; } | |
| .chart-tooltip { white-space: pre-line; } | |
| .btc-reference { | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| gap: 10px; | |
| align-items: center; | |
| margin-top: 18px; | |
| padding: 13px 14px; | |
| border: 1px solid rgba(255, 255, 255, .14); | |
| border-radius: 8px; | |
| background: rgba(255, 255, 255, .06); | |
| } | |
| .btc-reference span { | |
| color: #cbd5e1; | |
| font-size: 12px; | |
| display: block; | |
| } | |
| .btc-reference strong { | |
| color: #fff; | |
| font-size: 20px; | |
| white-space: nowrap; | |
| } | |
| .strategy-grid { | |
| display: grid; | |
| grid-template-columns: repeat(2, minmax(0, 1fr)); | |
| gap: 14px; | |
| padding: 14px; | |
| } | |
| .strategy-card { | |
| border: 1px solid var(--line); | |
| border-radius: 8px; | |
| padding: 14px; | |
| min-height: 118px; | |
| background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); | |
| } | |
| .strategy-card span { | |
| color: var(--muted); | |
| display: block; | |
| font-size: 12px; | |
| } | |
| .strategy-card strong { | |
| display: block; | |
| font-size: 28px; | |
| margin: 4px 0 8px; | |
| } | |
| .strategy-meta { | |
| color: var(--muted); | |
| font-size: 12px; | |
| overflow-wrap: anywhere; | |
| } | |
| .table-shell { | |
| max-height: 560px; | |
| overflow: auto; | |
| scrollbar-gutter: stable; | |
| } | |
| .table-shell.compact { max-height: 360px; } | |
| table { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: fixed; } | |
| th, td { | |
| padding: 10px 12px; | |
| border-bottom: 1px solid var(--line); | |
| text-align: left; | |
| vertical-align: top; | |
| overflow-wrap: normal; | |
| } | |
| th { | |
| color: var(--muted); | |
| font-size: 12px; | |
| font-weight: 650; | |
| background: var(--soft); | |
| position: sticky; | |
| top: 0; | |
| z-index: 1; | |
| } | |
| tbody tr { background: #fff; } | |
| tbody tr:hover { background: #fbfdff; } | |
| tr:last-child td { border-bottom: 0; } | |
| .status { | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| min-width: 72px; | |
| border-radius: 999px; | |
| padding: 3px 9px; | |
| font-size: 12px; | |
| font-weight: 750; | |
| border: 1px solid transparent; | |
| } | |
| .status.ok { color: var(--ok); background: var(--ok-bg); border-color: #bbf7d0; } | |
| .status.error { color: var(--err); background: var(--err-bg); border-color: #fecaca; } | |
| .status.running { color: var(--run); background: var(--run-bg); border-color: #bfdbfe; } | |
| .status.unknown { color: var(--warn); background: var(--warn-bg); border-color: #fde68a; } | |
| .status.paused, .status.disabled { color: var(--muted); background: #f2f4f7; border-color: #e4e7ec; } | |
| .monitor-name { font-weight: 750; } | |
| .monitor-id { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12px; overflow-wrap: anywhere; } | |
| .type-badge { | |
| display: inline-flex; | |
| margin-top: 7px; | |
| color: #475467; | |
| background: #f2f4f7; | |
| border-radius: 999px; | |
| padding: 2px 8px; | |
| font-size: 11px; | |
| } | |
| .state-stack { display: flex; flex-direction: column; gap: 8px; align-items: flex-start; } | |
| .failure-note { color: var(--muted); font-size: 12px; } | |
| .muted { color: var(--muted); } | |
| .toolbar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } | |
| button { | |
| border: 1px solid var(--line); | |
| background: #fff; | |
| border-radius: 7px; | |
| padding: 7px 10px; | |
| cursor: pointer; | |
| min-height: 34px; | |
| box-shadow: var(--shadow-soft); | |
| } | |
| button:hover { border-color: #aeb8c6; background: #fbfdff; } | |
| button.primary { background: #111827; color: #fff; border-color: #111827; } | |
| button.primary:hover { background: #1f2937; } | |
| button.danger { color: var(--err); } | |
| button.small { padding: 5px 8px; min-height: 30px; } | |
| .icon-button { | |
| width: 36px; | |
| min-width: 36px; | |
| padding: 0; | |
| display: inline-flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-size: 18px; | |
| line-height: 1; | |
| } | |
| pre { | |
| margin: 0; | |
| white-space: pre-wrap; | |
| overflow-wrap: anywhere; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| font-size: 12px; | |
| } | |
| details summary { cursor: pointer; color: var(--run); font-size: 12px; margin-top: 8px; } | |
| .value-card { | |
| display: flex; | |
| flex-direction: column; | |
| gap: 8px; | |
| max-width: 100%; | |
| } | |
| .value-summary { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 6px; | |
| align-items: center; | |
| min-width: 0; | |
| } | |
| .kv-chip { | |
| display: inline-flex; | |
| align-items: center; | |
| gap: 5px; | |
| max-width: 100%; | |
| border: 1px solid #e4e7ec; | |
| border-radius: 999px; | |
| background: #f8fafc; | |
| color: #344054; | |
| padding: 3px 8px; | |
| font-size: 12px; | |
| line-height: 1.35; | |
| } | |
| .kv-chip b { color: var(--text); font-weight: 750; } | |
| .value-json { | |
| max-height: 180px; | |
| overflow: auto; | |
| padding: 9px; | |
| border: 1px solid var(--line); | |
| border-radius: 7px; | |
| background: #0f172a; | |
| color: #e5e7eb; | |
| overflow-wrap: anywhere; | |
| } | |
| .error-box { | |
| border: 1px solid #fecaca; | |
| border-radius: 7px; | |
| background: var(--err-bg); | |
| color: var(--err); | |
| padding: 8px 10px; | |
| max-height: 130px; | |
| overflow: auto; | |
| overflow-wrap: anywhere; | |
| } | |
| .stale-note { | |
| color: var(--muted); | |
| font-size: 12px; | |
| line-height: 1.4; | |
| } | |
| .log-snippet { | |
| max-height: 120px; | |
| overflow: auto; | |
| padding: 8px; | |
| border-radius: 7px; | |
| background: #f8fafc; | |
| border: 1px solid #e4e7ec; | |
| overflow-wrap: anywhere; | |
| } | |
| textarea { | |
| width: 100%; | |
| min-height: 320px; | |
| border: 1px solid var(--line); | |
| border-radius: 7px; | |
| padding: 10px; | |
| font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| font-size: 12px; | |
| resize: vertical; | |
| outline: none; | |
| } | |
| input, select { | |
| width: 100%; | |
| min-height: 34px; | |
| border: 1px solid var(--line); | |
| border-radius: 7px; | |
| padding: 7px 8px; | |
| background: #fff; | |
| color: var(--text); | |
| outline: none; | |
| } | |
| input:focus, select:focus, textarea:focus { border-color: var(--focus); box-shadow: 0 0 0 2px rgba(37, 99, 235, .12); } | |
| label { | |
| display: block; | |
| color: var(--muted); | |
| font-size: 12px; | |
| margin-bottom: 5px; | |
| } | |
| .switch-row { | |
| display: flex; | |
| align-items: center; | |
| gap: 8px; | |
| min-height: 34px; | |
| } | |
| .switch-row.pause-cell { min-height: 0; margin: 0; } | |
| .switch-row input { width: auto; min-height: auto; } | |
| .message { color: var(--muted); } | |
| .message.error { color: var(--err); } | |
| .message.ok { color: var(--ok); } | |
| .hint { | |
| color: var(--muted); | |
| font-size: 12px; | |
| margin: 8px 0 0; | |
| } | |
| .config-shell { | |
| display: grid; | |
| grid-template-columns: 230px minmax(0, 1fr); | |
| min-height: 0; | |
| max-height: 720px; | |
| } | |
| .config-section { order: 20; } | |
| .health-section { order: 2; } | |
| .alerts-section { order: 3; } | |
| .events-section { order: 4; } | |
| .config-nav { | |
| border-right: 1px solid var(--line); | |
| background: var(--soft); | |
| padding: 10px; | |
| max-height: 720px; | |
| overflow: auto; | |
| } | |
| .nav-button { | |
| display: block; | |
| width: 100%; | |
| text-align: left; | |
| margin-bottom: 6px; | |
| background: transparent; | |
| } | |
| .nav-button.active { | |
| background: #eaf1ff; | |
| border-color: #bfd2ff; | |
| color: #123f8c; | |
| font-weight: 700; | |
| } | |
| .config-panel { | |
| padding: 14px; | |
| max-height: 720px; | |
| overflow: auto; | |
| } | |
| .panel-title { | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 12px; | |
| margin-bottom: 14px; | |
| } | |
| .panel-title h3 { margin: 0; font-size: 16px; } | |
| .form-grid { | |
| display: grid; | |
| grid-template-columns: repeat(4, minmax(0, 1fr)); | |
| gap: 12px; | |
| margin-bottom: 14px; | |
| } | |
| .form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .field { | |
| min-width: 0; | |
| background: #fff; | |
| } | |
| .group { | |
| border: 1px solid var(--line); | |
| border-radius: 6px; | |
| margin-bottom: 14px; | |
| overflow: hidden; | |
| } | |
| .group h4 { | |
| margin: 0; | |
| padding: 10px 12px; | |
| border-bottom: 1px solid var(--line); | |
| background: var(--soft); | |
| font-size: 13px; | |
| } | |
| .group-body { padding: 12px; } | |
| .inline-table input, .inline-table select { min-height: 30px; padding: 5px 7px; } | |
| .tab-row { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; } | |
| .pill { | |
| border: 1px solid var(--line); | |
| border-radius: 999px; | |
| padding: 6px 10px; | |
| background: #fff; | |
| cursor: pointer; | |
| } | |
| .pill.active { | |
| border-color: #bfd2ff; | |
| background: #eaf1ff; | |
| color: #123f8c; | |
| font-weight: 700; | |
| } | |
| .hidden { display: none; } | |
| .section-actions { padding: 10px 12px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } | |
| .compact-json { padding: 12px; } | |
| @media (max-width: 1100px) { | |
| .summary { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .core-market { grid-template-columns: 1fr; } | |
| .core-price-panel { border-right: 0; border-bottom: 1px solid rgba(255, 255, 255, .12); } | |
| .market-detail-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .strategy-grid { grid-template-columns: 1fr; } | |
| .core-chart { padding: 16px; } | |
| .chart-shell { height: 300px; } | |
| .config-shell { grid-template-columns: 1fr; } | |
| .config-nav { border-right: 0; border-bottom: 1px solid var(--line); display: flex; gap: 8px; overflow-x: auto; } | |
| .config-panel { max-height: 680px; } | |
| .nav-button { min-width: 130px; margin-bottom: 0; } | |
| .form-grid, .form-grid.two { grid-template-columns: repeat(2, minmax(0, 1fr)); } | |
| .table-shell { max-height: 520px; } | |
| } | |
| @media (max-width: 720px) { | |
| header { align-items: flex-start; flex-direction: column; } | |
| main { padding: 14px; } | |
| .summary, .form-grid, .form-grid.two { grid-template-columns: 1fr; } | |
| .market-detail-grid { grid-template-columns: 1fr; } | |
| .core-price-panel { padding: 18px; } | |
| .market-tile { min-height: 86px; } | |
| .core-chart { padding: 14px; } | |
| .chart-shell { height: 250px; } | |
| .btc-reference { grid-template-columns: 1fr; } | |
| .btc-reference strong { white-space: normal; } | |
| .strategy-grid { grid-template-columns: 1fr; } | |
| .table-shell { max-height: 620px; overflow: auto; } | |
| .table-shell.compact { max-height: 360px; } | |
| table, thead, tbody, th, td, tr { display: block; width: 100%; } | |
| thead { display: none; } | |
| tbody tr { | |
| border-bottom: 1px solid var(--line); | |
| padding: 10px 12px; | |
| } | |
| td { | |
| border-bottom: 0; | |
| padding: 6px 0; | |
| } | |
| td[data-label]::before { | |
| content: attr(data-label); | |
| display: block; | |
| color: var(--muted); | |
| font-size: 11px; | |
| margin-bottom: 3px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <header> | |
| <div class="app-title"> | |
| <h1>监控平台</h1> | |
| <div class="app-subtitle">价格、财务指标和脚本健康统一监控</div> | |
| </div> | |
| <div class="toolbar"> | |
| <span id="updated" class="muted">等待刷新</span> | |
| <button class="icon-button" onclick="loadStatus()" title="刷新状态" aria-label="刷新状态">↻</button> | |
| <button class="icon-button primary" onclick="saveFormConfig()" title="保存参数" aria-label="保存参数">✓</button> | |
| <span id="topMessage" class="message"></span> | |
| </div> | |
| </header> | |
| <main> | |
| <div class="summary"> | |
| <div class="metric"><span>平台状态</span><strong id="platformState">--</strong></div> | |
| <div class="metric"><span>监控项</span><strong id="monitorCount">--</strong></div> | |
| <div class="metric"><span>异常项</span><strong id="errorCount">--</strong></div> | |
| <div class="metric"><span>累计告警</span><strong id="alertCount">--</strong></div> | |
| </div> | |
| <section class="market-hero"> | |
| <div class="section-head"> | |
| <h2>核心资产</h2> | |
| <span id="coreUpdated" class="muted">等待行情</span> | |
| </div> | |
| <div class="core-market"> | |
| <div class="core-price-panel"> | |
| <div class="asset-line"> | |
| <span id="coreName" class="asset-badge">GRAM</span> | |
| <span id="coreSource" class="asset-source">Binance</span> | |
| </div> | |
| <div id="corePrice" class="core-price">--</div> | |
| <div class="price-subline"> | |
| <span id="coreChange1m" class="change-chip">1m --</span> | |
| <span id="coreChange5m" class="change-chip">5m --</span> | |
| <span id="coreChange1h" class="change-chip">1h --</span> | |
| </div> | |
| <div class="btc-reference"> | |
| <div> | |
| <span>BTC 参照价格</span> | |
| <small id="btcMeta" class="muted">等待数据</small> | |
| </div> | |
| <strong id="btcPrice">--</strong> | |
| </div> | |
| </div> | |
| <div class="market-detail-grid"> | |
| <div class="market-tile"> | |
| <span>聚合 24h 成交额</span> | |
| <strong id="coreVolume24h">--</strong> | |
| <small id="coreVolume5m">近 5m --</small> | |
| </div> | |
| <div class="market-tile"> | |
| <span>24h 区间</span> | |
| <strong id="coreRange24h">--</strong> | |
| <small id="coreRsi">RSI --</small> | |
| </div> | |
| <div class="market-tile"> | |
| <span>资金费率</span> | |
| <strong id="coreFunding">--</strong> | |
| <small id="coreFuturesSymbol">合约未启用</small> | |
| </div> | |
| <div class="market-tile"> | |
| <span>聚合合约持仓量</span> | |
| <strong id="coreOpenInterest">--</strong> | |
| <small id="coreOpenInterestChange">变化 --</small> | |
| </div> | |
| <div class="market-tile"> | |
| <span>资金异动</span> | |
| <strong id="coreFlow">--</strong> | |
| <small id="coreFlowMeta">等待样本</small> | |
| </div> | |
| <div class="market-tile"> | |
| <span>数据覆盖</span> | |
| <strong id="coreCoverage">--</strong> | |
| <small id="coreCoverageMeta">等待来源</small> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="core-chart"> | |
| <div class="core-chart-head"> | |
| <div class="core-chart-title"> | |
| <h3 id="coreChartTitle">核心资产 K线</h3> | |
| <small id="coreChartMeta">等待行情数据</small> | |
| </div> | |
| <div class="chart-controls" id="coreChartControls"> | |
| <button class="chart-pill" data-chart-interval="1m">1m</button> | |
| <button class="chart-pill" data-chart-interval="15m">15m</button> | |
| <button class="chart-pill active" data-chart-interval="1h">1h</button> | |
| <button class="chart-pill" data-chart-interval="4h">4h</button> | |
| <button class="chart-pill" data-chart-interval="1d">1d</button> | |
| </div> | |
| </div> | |
| <div class="chart-shell"> | |
| <canvas id="coreCandleChart"></canvas> | |
| <div id="coreChartTooltip" class="chart-tooltip"></div> | |
| </div> | |
| <div id="coreChartMessage" class="chart-message">正在加载K线...</div> | |
| </div> | |
| </section> | |
| <section> | |
| <div class="section-head"> | |
| <h2>Strategy 财务监控</h2> | |
| <span class="muted">仅解析最新 8-K;获取失败会在这里和每日健康报告中显示</span> | |
| </div> | |
| <div class="strategy-grid"> | |
| <div class="strategy-card"> | |
| <span>BTC 持仓</span> | |
| <strong id="strategyBtcValue">--</strong> | |
| <div id="strategyBtcMeta" class="strategy-meta">等待数据</div> | |
| </div> | |
| <div class="strategy-card"> | |
| <span>USD Reserve / 现金储备</span> | |
| <strong id="strategyCashValue">--</strong> | |
| <div id="strategyCashMeta" class="strategy-meta">等待数据</div> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="config-section"> | |
| <div class="section-head"> | |
| <h2>参数设置</h2> | |
| <div class="toolbar"> | |
| <button onclick="loadConfig()">重新加载</button> | |
| <button onclick="calibrateSelected()">校准当前币种</button> | |
| <button onclick="calibrateAll()">校准全部</button> | |
| </div> | |
| </div> | |
| <div class="config-shell"> | |
| <aside class="config-nav"> | |
| <button class="nav-button active" data-panel="global" onclick="showPanel('global')">全局设置</button> | |
| <button class="nav-button" data-panel="coins" onclick="showPanel('coins')">币种参数</button> | |
| <button class="nav-button" data-panel="exchanges" onclick="showPanel('exchanges')">跨所差价</button> | |
| <button class="nav-button" data-panel="advanced" onclick="showPanel('advanced')">高级 JSON</button> | |
| </aside> | |
| <div class="config-panel"> | |
| <div id="panel-global"> | |
| <div class="panel-title"> | |
| <h3>全局设置</h3> | |
| <span class="muted">影响所有监控项</span> | |
| </div> | |
| <div class="form-grid"> | |
| <div class="field"> | |
| <label for="global_poll_interval_sec">轮询间隔(秒)</label> | |
| <input id="global_poll_interval_sec" type="number" min="5" step="1"> | |
| </div> | |
| <div class="field"> | |
| <label for="global_daily_report_hour">日报小时(北京时间)</label> | |
| <input id="global_daily_report_hour" type="number" min="0" max="23" step="1"> | |
| </div> | |
| <div class="field"> | |
| <label for="global_alert_sensitivity">通知灵敏度</label> | |
| <input id="global_alert_sensitivity" type="number" min="0.1" step="0.1"> | |
| </div> | |
| <div class="field"> | |
| <label for="global_alert_strictness">阈值严格度</label> | |
| <input id="global_alert_strictness" type="number" min="0.5" step="0.1"> | |
| </div> | |
| <div class="field"> | |
| <label for="global_calibration_mode">校准模式</label> | |
| <select id="global_calibration_mode"> | |
| <option value="auto">自动校准</option> | |
| <option value="manual">手动阈值</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label for="global_auto_calibrate_hours">自动校准间隔(小时)</label> | |
| <input id="global_auto_calibrate_hours" type="number" min="0" step="0.5"> | |
| </div> | |
| <div class="field"> | |
| <label>下跌优先推送</label> | |
| <div class="switch-row"> | |
| <input id="global_down_priority" type="checkbox"> | |
| <span>启用</span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="panel-coins" class="hidden"> | |
| <div class="panel-title"> | |
| <h3>币种参数</h3> | |
| <div class="toolbar"> | |
| <select id="coinSelector" onchange="renderCoinForm()"></select> | |
| <button onclick="addCoin()">新增币种</button> | |
| <button onclick="addAlphaCoin()">新增 Alpha</button> | |
| <button class="danger" onclick="deleteCoin()">删除当前</button> | |
| </div> | |
| </div> | |
| <div class="group"> | |
| <h4>基础信息</h4> | |
| <div class="group-body"> | |
| <div class="form-grid"> | |
| <div class="field"> | |
| <label for="coin_symbol">交易标的</label> | |
| <input id="coin_symbol" data-coin-key="symbol"> | |
| </div> | |
| <div class="field"> | |
| <label for="coin_futures_symbol">合约交易对</label> | |
| <input id="coin_futures_symbol" data-coin-key="futures_symbol"> | |
| </div> | |
| <div class="field"> | |
| <label for="coin_data_source">数据来源</label> | |
| <select id="coin_data_source" data-coin-key="data_source" onchange="handleCoinSourceChange()"> | |
| <option value="binance">Binance 现货</option> | |
| <option value="binance_futures">Binance 合约</option> | |
| <option value="binance_alpha">Binance Alpha</option> | |
| <option value="okx">OKX</option> | |
| </select> | |
| </div> | |
| <div class="field"> | |
| <label>合约监控</label> | |
| <div class="switch-row"> | |
| <input id="coin_enable_futures" type="checkbox" data-coin-key="enable_futures"> | |
| <span>启用</span> | |
| </div> | |
| </div> | |
| <div class="field"> | |
| <label>首页核心展示</label> | |
| <div class="switch-row"> | |
| <input id="coin_core_display" type="checkbox" data-coin-key="core_display"> | |
| <span>设为核心</span> | |
| </div> | |
| </div> | |
| <div class="field"> | |
| <label>股票类休市判断</label> | |
| <div class="switch-row"> | |
| <input id="coin_market_close" type="checkbox" data-coin-key="market_close"> | |
| <span>启用</span> | |
| </div> | |
| </div> | |
| <div class="field"> | |
| <label for="coin_market_close_static_secs">休市静止秒数</label> | |
| <input id="coin_market_close_static_secs" type="number" min="60" step="60" data-coin-key="market_close_static_secs"> | |
| </div> | |
| </div> | |
| <p class="hint">Binance Alpha 标的可填 DATAIP、ALPHA_1011 或 ALPHA_1011USDT;如该币也有 U 本位合约,可填写合约交易对并打开合约监控。</p> | |
| </div> | |
| </div> | |
| <div class="group"> | |
| <h4>关键价位</h4> | |
| <div class="group-body"> | |
| <div class="form-grid"> | |
| <div class="field"><label>成本价</label><input type="number" step="0.0001" data-coin-key="cost_price"></div> | |
| <div class="field"><label>目标价</label><input type="number" step="0.0001" data-coin-key="target_price"></div> | |
| <div class="field"><label>止损价</label><input type="number" step="0.0001" data-coin-key="stop_price"></div> | |
| <div class="field"><label>接近关键价阈值 %</label><input type="number" step="0.1" data-coin-key="near_key_pct"></div> | |
| <div class="field"><label>接近成本阈值 %</label><input type="number" step="0.1" data-coin-key="near_cost_pct"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="group"> | |
| <h4>快速涨跌阈值 %</h4> | |
| <div class="group-body"> | |
| <div class="form-grid"> | |
| <div class="field"><label>1 分钟</label><input type="number" step="0.01" data-price-window="1m"></div> | |
| <div class="field"><label>5 分钟</label><input type="number" step="0.01" data-price-window="5m"></div> | |
| <div class="field"><label>15 分钟</label><input type="number" step="0.01" data-price-window="15m"></div> | |
| <div class="field"><label>1 小时</label><input type="number" step="0.01" data-price-window="1h"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="group"> | |
| <h4>突破、成交量、振幅</h4> | |
| <div class="group-body"> | |
| <div class="form-grid"> | |
| <div class="field"><label>突破 24h 高低 %</label><input type="number" step="0.1" data-coin-key="break_24h_pct"></div> | |
| <div class="field"><label>突破维持秒数</label><input type="number" step="1" data-coin-key="break_24h_hold_sec"></div> | |
| <div class="field"><label>突破 7 日高低 %</label><input type="number" step="0.1" data-coin-key="break_7d_pct"></div> | |
| <div class="field"><label>放量倍数</label><input type="number" step="0.1" data-coin-key="vol_anomaly_mult"></div> | |
| <div class="field"><label>严重放量倍数</label><input type="number" step="0.1" data-coin-key="vol_severe_mult"></div> | |
| <div class="field"><label>5m 振幅 %</label><input type="number" step="0.1" data-coin-key="amp_5m"></div> | |
| <div class="field"><label>15m 振幅 %</label><input type="number" step="0.1" data-coin-key="amp_15m"></div> | |
| <div class="field"><label>1h 振幅 %</label><input type="number" step="0.1" data-coin-key="amp_1h"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="group"> | |
| <h4>盘口、大额成交、技术指标</h4> | |
| <div class="group-body"> | |
| <div class="form-grid"> | |
| <div class="field"><label>单笔大额 USDT</label><input type="number" step="1000" data-coin-key="big_trade_usdt"></div> | |
| <div class="field"><label>5m 累计大额 USDT</label><input type="number" step="1000" data-coin-key="cum_trade_5m_usdt"></div> | |
| <div class="field"><label>买卖价差 %</label><input type="number" step="0.01" data-coin-key="spread_pct"></div> | |
| <div class="field"><label>严重价差 %</label><input type="number" step="0.01" data-coin-key="spread_severe_pct"></div> | |
| <div class="field"><label>盘口深度 %</label><input type="number" step="0.1" data-coin-key="imbalance_depth_pct"></div> | |
| <div class="field"><label>盘口失衡倍数</label><input type="number" step="0.1" data-coin-key="imbalance_ratio"></div> | |
| <div class="field"><label>买卖墙深度 %</label><input type="number" step="0.1" data-coin-key="wall_depth_pct"></div> | |
| <div class="field"><label>买卖墙倍数</label><input type="number" step="0.1" data-coin-key="wall_mult"></div> | |
| <div class="field"><label>买卖墙最小 USDT</label><input type="number" step="1000" data-coin-key="wall_min_usdt"></div> | |
| <div class="field"><label>墙方向主导倍数</label><input type="number" step="0.1" data-coin-key="wall_dominance"></div> | |
| <div class="field"><label>墙消失阈值 %</label><input type="number" step="1" data-coin-key="wall_vanish_pct"></div> | |
| <div class="field"><label>偏离均线 %</label><input type="number" step="0.1" data-coin-key="ma_deviation_pct"></div> | |
| <div class="field"><label>连续 K 线根数</label><input type="number" step="1" data-coin-key="consecutive_klines"></div> | |
| <div class="field"><label>RSI 高位</label><input type="number" step="1" data-coin-key="rsi_high"></div> | |
| <div class="field"><label>RSI 低位</label><input type="number" step="1" data-coin-key="rsi_low"></div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="group"> | |
| <h4>合约指标</h4> | |
| <div class="group-body"> | |
| <div class="form-grid"> | |
| <div class="field"><label>资金费率高位 %</label><input type="number" step="0.001" data-coin-key="funding_high"></div> | |
| <div class="field"><label>资金费率过热 %</label><input type="number" step="0.001" data-coin-key="funding_hot"></div> | |
| <div class="field"><label>资金费率负位 %</label><input type="number" step="0.001" data-coin-key="funding_neg"></div> | |
| <div class="field"><label>持仓量 15m %</label><input type="number" step="0.1" data-coin-key="oi_15m"></div> | |
| <div class="field"><label>持仓量 1h %</label><input type="number" step="0.1" data-coin-key="oi_1h"></div> | |
| <div class="field"><label>持仓量 4h %</label><input type="number" step="0.1" data-coin-key="oi_4h"></div> | |
| <div class="field"><label>5m 强平 USDT</label><input type="number" step="1000" data-coin-key="liq_5m_usdt"></div> | |
| <div class="field"><label>严重强平 USDT</label><input type="number" step="1000" data-coin-key="liq_severe_usdt"></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div id="panel-exchanges" class="hidden"> | |
| <div class="panel-title"> | |
| <h3>跨交易所差价</h3> | |
| <button onclick="addSpreadPair()">新增差价币种</button> | |
| </div> | |
| <div class="form-grid"> | |
| <div class="field"> | |
| <label>启用 Bitget</label> | |
| <div class="switch-row"><input id="ex_enable_bitget" type="checkbox"><span>启用</span></div> | |
| </div> | |
| <div class="field"> | |
| <label>启用 OKX</label> | |
| <div class="switch-row"><input id="ex_enable_okx" type="checkbox"><span>启用</span></div> | |
| </div> | |
| <div class="field"> | |
| <label>启用差价提醒</label> | |
| <div class="switch-row"><input id="spread_enable" type="checkbox"><span>启用</span></div> | |
| </div> | |
| <div class="field"> | |
| <label>默认差价阈值 %</label> | |
| <input id="spread_threshold_pct" type="number" step="0.1"> | |
| </div> | |
| </div> | |
| <table class="inline-table"> | |
| <thead> | |
| <tr> | |
| <th style="width: 13%">名称</th> | |
| <th>Binance</th> | |
| <th>Bitget</th> | |
| <th>OKX</th> | |
| <th style="width: 14%">阈值 %</th> | |
| <th style="width: 70px">操作</th> | |
| </tr> | |
| </thead> | |
| <tbody id="spreadPairRows"></tbody> | |
| </table> | |
| </div> | |
| <div id="panel-advanced" class="hidden"> | |
| <div class="panel-title"> | |
| <h3>高级 JSON</h3> | |
| <span class="muted">适合批量修改或复制配置</span> | |
| </div> | |
| <textarea id="configText" spellcheck="false"></textarea> | |
| <div class="section-actions"> | |
| <button onclick="syncFormToJson()">从表单同步到 JSON</button> | |
| <button onclick="applyJsonToForm()">从 JSON 应用到表单</button> | |
| <button class="primary" onclick="saveJsonConfig()">保存 JSON</button> | |
| <span id="configMessage" class="message"></span> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </section> | |
| <section class="health-section"> | |
| <div class="section-head"><h2>监控健康状态</h2></div> | |
| <div class="table-shell"> | |
| <table class="monitor-table"> | |
| <thead> | |
| <tr> | |
| <th style="width: 26%">监控项</th> | |
| <th style="width: 13%">状态</th> | |
| <th style="width: 17%">最近成功</th> | |
| <th style="width: 17%">下次运行</th> | |
| <th>最近值 / 错误</th> | |
| </tr> | |
| </thead> | |
| <tbody id="monitorRows"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| <section class="alerts-section"> | |
| <div class="section-head"><h2>最近告警</h2></div> | |
| <div class="table-shell compact"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th style="width: 17%">时间</th> | |
| <th style="width: 18%">监控项</th> | |
| <th style="width: 12%">类别</th> | |
| <th style="width: 10%">发送</th> | |
| <th>内容</th> | |
| </tr> | |
| </thead> | |
| <tbody id="alertRows"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| <section class="events-section"> | |
| <div class="section-head"><h2>运行事件</h2></div> | |
| <div class="table-shell compact"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th style="width: 17%">时间</th> | |
| <th style="width: 18%">监控项</th> | |
| <th style="width: 10%">级别</th> | |
| <th>消息</th> | |
| </tr> | |
| </thead> | |
| <tbody id="eventRows"></tbody> | |
| </table> | |
| </div> | |
| </section> | |
| </main> | |
| <script> | |
| let currentConfig = null; | |
| let currentCoin = null; | |
| let activePanel = "global"; | |
| let chartInterval = "1h"; | |
| let chartCoin = null; | |
| let chartCandles = []; | |
| let chartLayout = null; | |
| let chartRequestToken = 0; | |
| let uiEvents = []; | |
| const globalFields = [ | |
| ["poll_interval_sec", "number"], | |
| ["daily_report_hour", "number"], | |
| ["alert_sensitivity", "number"], | |
| ["alert_strictness", "number"], | |
| ["calibration_mode", "string"], | |
| ["auto_calibrate_hours", "number"], | |
| ["down_priority", "boolean"] | |
| ]; | |
| function safeText(value) { | |
| if (value === null || value === undefined || value === "") return "--"; | |
| if (typeof value === "object") return JSON.stringify(value); | |
| return String(value); | |
| } | |
| function escapeHtml(value) { | |
| return safeText(value) | |
| .replaceAll("&", "&") | |
| .replaceAll("<", "<") | |
| .replaceAll(">", ">") | |
| .replaceAll('"', """); | |
| } | |
| function row(cells, labels = []) { | |
| return "<tr>" + cells.map((cell, index) => { | |
| const label = labels[index] ? ` data-label="${escapeHtml(labels[index])}"` : ""; | |
| return "<td" + label + ">" + cell + "</td>"; | |
| }).join("") + "</tr>"; | |
| } | |
| function parseJsonField(value) { | |
| if (!value) return null; | |
| try { | |
| return typeof value === "string" ? JSON.parse(value) : value; | |
| } catch (_err) { | |
| return null; | |
| } | |
| } | |
| function isPaused(monitor) { | |
| return Boolean(Number(monitor?.paused || 0)); | |
| } | |
| function isEnabled(monitor) { | |
| return Boolean(Number(monitor?.enabled ?? 1)); | |
| } | |
| function formatBtc(value) { | |
| const n = Number(value); | |
| return Number.isFinite(n) ? n.toLocaleString("en-US", {maximumFractionDigits: 0}) + " BTC" : "--"; | |
| } | |
| function formatUsd(value) { | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return "--"; | |
| const sign = n < 0 ? "-" : ""; | |
| const abs = Math.abs(n); | |
| if (abs >= 1_000_000_000) return sign + "$" + (abs / 1_000_000_000).toFixed(2) + "B"; | |
| if (abs >= 1_000_000) return sign + "$" + (abs / 1_000_000).toFixed(2) + "M"; | |
| return sign + "$" + abs.toLocaleString("en-US", {maximumFractionDigits: 0}); | |
| } | |
| function formatNumber(value, digits = 2) { | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return "--"; | |
| return n.toLocaleString("en-US", {maximumFractionDigits: digits}); | |
| } | |
| function formatPrice(value) { | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return "--"; | |
| const digits = Math.abs(n) >= 100 ? 2 : 6; | |
| return n.toLocaleString("en-US", {maximumFractionDigits: digits}); | |
| } | |
| function formatPercent(value, digits = 2) { | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return "--"; | |
| return n.toFixed(digits) + "%"; | |
| } | |
| function formatSignedPercent(value, digits = 2) { | |
| const n = Number(value); | |
| if (!Number.isFinite(n)) return "--"; | |
| return (n > 0 ? "+" : "") + n.toFixed(digits) + "%"; | |
| } | |
| function setText(id, value) { | |
| const el = document.getElementById(id); | |
| if (el) el.textContent = value; | |
| } | |
| function sourceLabel(source) { | |
| return { | |
| binance: "Binance 现货", | |
| binance_futures: "Binance 合约", | |
| binance_alpha: "Binance Alpha", | |
| okx: "OKX" | |
| }[source] || source || "--"; | |
| } | |
| function setChangeChip(id, label, value) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| const n = Number(value); | |
| el.textContent = label + " " + (Number.isFinite(n) ? formatSignedPercent(n, 2) : "--"); | |
| el.classList.toggle("up", Number.isFinite(n) && n > 0); | |
| el.classList.toggle("down", Number.isFinite(n) && n < 0); | |
| } | |
| function chartIntervalLabel(interval) { | |
| return { | |
| "1m": "1分钟", | |
| "5m": "5分钟", | |
| "15m": "15分钟", | |
| "1h": "1小时", | |
| "4h": "4小时", | |
| "1d": "日线" | |
| }[interval] || interval; | |
| } | |
| function chartTimeLabel(value, short = false) { | |
| const date = new Date(Number(value)); | |
| if (Number.isNaN(date.getTime())) return "--"; | |
| if (chartInterval === "1d") { | |
| return date.toLocaleDateString(undefined, {month: "2-digit", day: "2-digit"}); | |
| } | |
| if (short) { | |
| return date.toLocaleString(undefined, {month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false}); | |
| } | |
| return date.toLocaleString(undefined, {hour12: false}); | |
| } | |
| async function loadCoreChart() { | |
| const core = currentConfig?.coins ? Object.values(currentConfig.coins).find((coin) => coin.core_display) : null; | |
| const coin = core ? Object.keys(currentConfig.coins).find((name) => currentConfig.coins[name].core_display) : "GRAM"; | |
| if (!coin) return; | |
| const token = ++chartRequestToken; | |
| const message = document.getElementById("coreChartMessage"); | |
| if (message) { | |
| message.textContent = "正在加载K线..."; | |
| message.className = "chart-message"; | |
| } | |
| try { | |
| const res = await fetch(`/api/crypto/${encodeURIComponent(coin)}/klines?interval=${encodeURIComponent(chartInterval)}&limit=160`); | |
| if (!res.ok) throw new Error(await res.text()); | |
| const data = await res.json(); | |
| if (token !== chartRequestToken) return; | |
| chartCoin = coin; | |
| chartCandles = (data.candles || []).map((item) => ({ | |
| time: Number(item.time), | |
| open: Number(item.open), | |
| high: Number(item.high), | |
| low: Number(item.low), | |
| close: Number(item.close), | |
| volume: Number(item.volume), | |
| quoteVolume: Number(item.quote_volume) | |
| })).filter((item) => [item.time, item.open, item.high, item.low, item.close].every(Number.isFinite)); | |
| if (!chartCandles.length) throw new Error("K线数据为空"); | |
| setText("coreChartTitle", `${coin} K线`); | |
| setText("coreChartMeta", `${data.symbol || "--"} · ${chartIntervalLabel(chartInterval)} · ${chartCandles.length} 根`); | |
| message.textContent = ""; | |
| drawCoreCandleChart(); | |
| } catch (err) { | |
| if (token === chartRequestToken) { | |
| chartCandles = []; | |
| drawCoreCandleChart(); | |
| if (message) { | |
| message.textContent = "K线加载失败:" + err.message; | |
| message.className = "chart-message error"; | |
| } | |
| } | |
| } | |
| } | |
| function drawCoreCandleChart() { | |
| const canvas = document.getElementById("coreCandleChart"); | |
| if (!canvas) return; | |
| const rect = canvas.getBoundingClientRect(); | |
| const width = Math.max(280, rect.width); | |
| const height = Math.max(220, rect.height); | |
| const dpr = window.devicePixelRatio || 1; | |
| canvas.width = Math.round(width * dpr); | |
| canvas.height = Math.round(height * dpr); | |
| const ctx = canvas.getContext("2d"); | |
| ctx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| ctx.clearRect(0, 0, width, height); | |
| const left = 62; | |
| const right = 18; | |
| const top = 16; | |
| const xAxisHeight = 40; | |
| const volumeHeight = Math.max(52, Math.round(height * 0.18)); | |
| const volumeTop = height - xAxisHeight - volumeHeight; | |
| const priceBottom = volumeTop - 12; | |
| const plotWidth = Math.max(20, width - left - right); | |
| const plotHeight = Math.max(60, priceBottom - top); | |
| const count = chartCandles.length; | |
| chartLayout = {left, right, top, priceBottom, plotWidth, plotHeight}; | |
| if (!count) { | |
| ctx.fillStyle = "#94a3b8"; | |
| ctx.font = "13px system-ui, -apple-system, BlinkMacSystemFont, sans-serif"; | |
| ctx.textAlign = "center"; | |
| ctx.fillText("暂无K线数据", width / 2, height / 2); | |
| return; | |
| } | |
| let minPrice = Math.min(...chartCandles.map((item) => item.low)); | |
| let maxPrice = Math.max(...chartCandles.map((item) => item.high)); | |
| if (minPrice === maxPrice) { minPrice -= 1; maxPrice += 1; } | |
| const pricePadding = (maxPrice - minPrice) * 0.06; | |
| minPrice -= pricePadding; | |
| maxPrice += pricePadding; | |
| const maxVolume = Math.max(...chartCandles.map((item) => item.volume), 0) * 1.05 || 1; | |
| const slot = plotWidth / count; | |
| const bodyWidth = Math.max(1.5, Math.min(16, slot * 0.66)); | |
| const priceY = (price) => top + (maxPrice - price) / (maxPrice - minPrice) * plotHeight; | |
| const volumeY = (volume) => volumeTop + (1 - volume / maxVolume) * volumeHeight; | |
| ctx.font = "11px system-ui, -apple-system, BlinkMacSystemFont, sans-serif"; | |
| ctx.strokeStyle = "rgba(255, 255, 255, .08)"; | |
| ctx.fillStyle = "#94a3b8"; | |
| ctx.lineWidth = 1; | |
| for (let index = 0; index <= 4; index++) { | |
| const price = minPrice + (maxPrice - minPrice) * (index / 4); | |
| const y = priceY(price); | |
| ctx.beginPath(); | |
| ctx.moveTo(left, y); | |
| ctx.lineTo(width - right, y); | |
| ctx.stroke(); | |
| ctx.textAlign = "right"; | |
| ctx.fillText(formatPrice(price), left - 8, y + 4); | |
| } | |
| ctx.fillStyle = "rgba(148, 163, 184, .38)"; | |
| chartCandles.forEach((item, index) => { | |
| const x = left + slot * (index + 0.5); | |
| const y = volumeY(item.volume); | |
| ctx.fillRect(x - bodyWidth / 2, y, bodyWidth, volumeTop - y); | |
| }); | |
| chartCandles.forEach((item, index) => { | |
| const x = left + slot * (index + 0.5); | |
| const up = item.close >= item.open; | |
| const color = up ? "#34d399" : "#f87171"; | |
| ctx.strokeStyle = color; | |
| ctx.fillStyle = color; | |
| ctx.beginPath(); | |
| ctx.moveTo(x, priceY(item.high)); | |
| ctx.lineTo(x, priceY(item.low)); | |
| ctx.stroke(); | |
| const openY = priceY(item.open); | |
| const closeY = priceY(item.close); | |
| const bodyTop = Math.min(openY, closeY); | |
| const bodyHeight = Math.max(1, Math.abs(closeY - openY)); | |
| ctx.fillRect(x - bodyWidth / 2, bodyTop, bodyWidth, bodyHeight); | |
| }); | |
| const last = chartCandles[count - 1]; | |
| const lastY = priceY(last.close); | |
| ctx.strokeStyle = "rgba(255, 255, 255, .38)"; | |
| ctx.setLineDash([4, 4]); | |
| ctx.beginPath(); | |
| ctx.moveTo(left, lastY); | |
| ctx.lineTo(width - right, lastY); | |
| ctx.stroke(); | |
| ctx.setLineDash([]); | |
| ctx.fillStyle = "#94a3b8"; | |
| ctx.textAlign = "center"; | |
| const labelCount = Math.min(5, count); | |
| for (let index = 0; index < labelCount; index++) { | |
| const sourceIndex = Math.round(index * (count - 1) / Math.max(1, labelCount - 1)); | |
| const x = left + slot * (sourceIndex + 0.5); | |
| ctx.fillText(chartTimeLabel(chartCandles[sourceIndex].time, true), x, height - 15); | |
| } | |
| } | |
| function showCoreChartTooltip(event) { | |
| const tooltip = document.getElementById("coreChartTooltip"); | |
| const shell = event.currentTarget; | |
| if (!tooltip || !chartLayout || !chartCandles.length) return; | |
| const rect = shell.getBoundingClientRect(); | |
| const x = event.clientX - rect.left; | |
| if (x < chartLayout.left || x > rect.width - chartLayout.right) { | |
| tooltip.style.display = "none"; | |
| return; | |
| } | |
| const slot = chartLayout.plotWidth / chartCandles.length; | |
| const index = Math.min(chartCandles.length - 1, Math.max(0, Math.floor((x - chartLayout.left) / slot))); | |
| const item = chartCandles[index]; | |
| const previous = chartCandles[index - 1] || item; | |
| const change = previous.close ? (item.close / previous.close - 1) * 100 : null; | |
| tooltip.innerHTML = [ | |
| `<strong>${escapeHtml(chartTimeLabel(item.time))}</strong>`, | |
| `开 ${escapeHtml(formatPrice(item.open))}`, | |
| `高 ${escapeHtml(formatPrice(item.high))}`, | |
| `低 ${escapeHtml(formatPrice(item.low))}`, | |
| `收 ${escapeHtml(formatPrice(item.close))}`, | |
| `涨跌 ${escapeHtml(formatSignedPercent(change, 2))}`, | |
| `成交额 ${escapeHtml(formatUsd(item.quoteVolume))}` | |
| ].join("<br>"); | |
| tooltip.style.display = "block"; | |
| const tooltipWidth = tooltip.offsetWidth; | |
| const tooltipHeight = tooltip.offsetHeight; | |
| const tooltipLeft = x + tooltipWidth + 24 < rect.width ? x + 14 : x - tooltipWidth - 14; | |
| const tooltipTop = Math.max(8, Math.min(event.clientY - rect.top - tooltipHeight / 2, rect.height - tooltipHeight - 8)); | |
| tooltip.style.left = tooltipLeft + "px"; | |
| tooltip.style.top = tooltipTop + "px"; | |
| } | |
| function hideCoreChartTooltip() { | |
| const tooltip = document.getElementById("coreChartTooltip"); | |
| if (tooltip) tooltip.style.display = "none"; | |
| } | |
| function setCoreChartInterval(interval) { | |
| if (!["1m", "5m", "15m", "1h", "4h", "1d"].includes(interval)) return; | |
| chartInterval = interval; | |
| document.querySelectorAll("#coreChartControls .chart-pill").forEach((button) => { | |
| button.classList.toggle("active", button.dataset.chartInterval === interval); | |
| }); | |
| loadCoreChart(); | |
| } | |
| function bindCoreChart() { | |
| document.querySelectorAll("#coreChartControls .chart-pill").forEach((button) => { | |
| button.addEventListener("click", () => setCoreChartInterval(button.dataset.chartInterval)); | |
| }); | |
| const shell = document.querySelector(".chart-shell"); | |
| if (shell) { | |
| shell.addEventListener("pointermove", showCoreChartTooltip); | |
| shell.addEventListener("pointerleave", hideCoreChartTooltip); | |
| } | |
| window.addEventListener("resize", () => { | |
| if (chartCandles.length) drawCoreCandleChart(); | |
| }); | |
| } | |
| function formatMonitorMeta(monitor) { | |
| if (!monitor) return "等待监控注册"; | |
| if (isPaused(monitor)) return "已暂停"; | |
| if (monitor.last_success_at) return "最近成功 " + formatDateTime(monitor.last_success_at); | |
| if (monitor.last_error) return "错误:" + monitor.last_error; | |
| return "等待首次成功"; | |
| } | |
| function sourceNames(sources, limit = 3) { | |
| const list = Array.isArray(sources) ? sources : []; | |
| if (!list.length) return "--"; | |
| const names = list.slice(0, limit).map((item) => item.name).filter(Boolean); | |
| const extra = list.length > limit ? ` +${list.length - limit}` : ""; | |
| return names.join(" / ") + extra; | |
| } | |
| function sourceCountLabel(count, total) { | |
| const n = Number(count || 0); | |
| return `${n}/${total}`; | |
| } | |
| function addUiEvent(message, level = "info") { | |
| uiEvents.unshift({ | |
| created_at: new Date().toISOString(), | |
| monitor_id: "dashboard", | |
| level, | |
| message | |
| }); | |
| uiEvents = uiEvents.slice(0, 12); | |
| } | |
| function formatDateTime(value) { | |
| if (!value) return "--"; | |
| const date = new Date(value); | |
| if (Number.isNaN(date.getTime())) return value; | |
| return date.toLocaleString(); | |
| } | |
| function statusText(status) { | |
| return { | |
| ok: "正常", | |
| running: "运行中", | |
| error: "异常", | |
| unknown: "未知", | |
| paused: "暂停", | |
| disabled: "禁用" | |
| }[status] || status || "--"; | |
| } | |
| function chip(label, value) { | |
| if (value === null || value === undefined || value === "") return ""; | |
| return `<span class="kv-chip"><span>${escapeHtml(label)}</span><b>${escapeHtml(value)}</b></span>`; | |
| } | |
| function chipValue(label, value, formatter = null) { | |
| if (value === null || value === undefined || value === "") return ""; | |
| return chip(label, formatter ? formatter(value) : value); | |
| } | |
| function rawDetails(payload, raw, summary = "原始数据") { | |
| if (!payload && !raw) return ""; | |
| const text = payload ? JSON.stringify(payload, null, 2) : String(raw); | |
| return `<details><summary>${escapeHtml(summary)}</summary><pre class="value-json">${escapeHtml(text)}</pre></details>`; | |
| } | |
| function isCalibrationOnlyPayload(payload) { | |
| if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; | |
| const keys = Object.keys(payload); | |
| return keys.length === 1 && payload.calibrated === true; | |
| } | |
| function firstScalarChips(payload) { | |
| if (!payload || typeof payload !== "object") return []; | |
| return Object.entries(payload) | |
| .filter(([_key, value]) => value === null || ["string", "number", "boolean"].includes(typeof value)) | |
| .slice(0, 5) | |
| .map(([key, value]) => chip(key, value === null ? "--" : value)); | |
| } | |
| function monitorSummaryChips(monitor, payload) { | |
| if (!payload || typeof payload !== "object") return []; | |
| const type = monitor.type || ""; | |
| const id = monitor.id || ""; | |
| if (type === "crypto_coin") { | |
| return [ | |
| chipValue("价格", payload.price, formatPrice), | |
| chipValue("24h 高", payload.high24, formatPrice), | |
| chipValue("24h 低", payload.low24, formatPrice), | |
| chipValue("24h 成交额", payload.quoteVolume24h, formatUsd), | |
| chipValue("RSI", payload.rsi, (value) => formatNumber(value, 1)), | |
| chipValue("资金费率", payload.funding, (value) => formatPercent(value, 4)) | |
| ].filter(Boolean); | |
| } | |
| if (type === "strategy_btc_holdings") { | |
| return [ | |
| chipValue("持仓", payload.value, formatBtc), | |
| chipValue("数据日", payload.end), | |
| chipValue("提交", payload.filed), | |
| chipValue("来源", payload.source) | |
| ].filter(Boolean); | |
| } | |
| if (type === "strategy_cash_reserve") { | |
| return [ | |
| chipValue("现金", payload.value, formatUsd), | |
| chipValue("数据日", payload.end), | |
| chipValue("提交", payload.filed), | |
| chipValue("来源", payload.source) | |
| ].filter(Boolean); | |
| } | |
| if (id === "strategy:service") { | |
| const stats = payload.stats || {}; | |
| return [ | |
| chipValue("轮询", stats.loops), | |
| chipValue("告警", stats.alerts), | |
| chipValue("失败", stats.api_fails), | |
| payload.btc ? chipValue("BTC", payload.btc.value, formatBtc) : "", | |
| payload.cash ? chipValue("现金", payload.cash.value, formatUsd) : "" | |
| ].filter(Boolean); | |
| } | |
| if (id === "crypto:service") { | |
| const stats = payload.stats || {}; | |
| return [ | |
| chipValue("状态", payload.status), | |
| chipValue("轮询", stats.loops), | |
| chipValue("告警", stats.alerts), | |
| chipValue("接口失败", stats.api_fails) | |
| ].filter(Boolean); | |
| } | |
| if (id === "crypto:cross_exchange") { | |
| return [chipValue("差价对", payload.pairs)].filter(Boolean); | |
| } | |
| return firstScalarChips(payload).filter(Boolean); | |
| } | |
| function renderMonitorValue(monitor) { | |
| const payload = parseJsonField(monitor.last_value_json); | |
| const raw = monitor.last_value_json; | |
| const hasMarketPayload = !isCalibrationOnlyPayload(payload); | |
| const displayPayload = hasMarketPayload ? payload : null; | |
| const displayRaw = hasMarketPayload ? raw : null; | |
| if (monitor.last_error) { | |
| const staleNote = displayRaw && monitor.last_success_at | |
| ? `<div class="stale-note">下方为上次成功数据:${escapeHtml(formatDateTime(monitor.last_success_at))}</div>` | |
| : ""; | |
| const details = rawDetails(displayPayload, displayRaw, "上次成功数据"); | |
| return ( | |
| `<div class="value-card">` + | |
| `<div class="error-box">${escapeHtml(monitor.last_error)}</div>` + | |
| staleNote + | |
| details + | |
| `</div>` | |
| ); | |
| } | |
| const details = rawDetails(displayPayload, displayRaw); | |
| const chips = monitorSummaryChips(monitor, displayPayload); | |
| if (!chips.length && !details) return "<span class='muted'>暂无数据</span>"; | |
| return ( | |
| `<div class="value-card">` + | |
| `<div class="value-summary">${chips.join("") || "<span class='muted'>已记录数据</span>"}</div>` + | |
| details + | |
| `</div>` | |
| ); | |
| } | |
| function renderMonitorName(monitor) { | |
| return ( | |
| `<div class="monitor-name">${escapeHtml(monitor.name)}</div>` + | |
| `<div class="monitor-id">${escapeHtml(monitor.id)}</div>` + | |
| `<span class="type-badge">${escapeHtml(monitor.type)}</span>` | |
| ); | |
| } | |
| function renderMonitorState(monitor, displayStatus, status) { | |
| const failures = Number(monitor.consecutive_failures || 0); | |
| return ( | |
| `<div class="state-stack">` + | |
| `<span class="status ${status}">${escapeHtml(statusText(displayStatus))}</span>` + | |
| `<span class="failure-note">连续失败 ${failures}</span>` + | |
| pauseControl(monitor) + | |
| `</div>` | |
| ); | |
| } | |
| function renderCoreMarket(summary) { | |
| const core = summary?.core || {}; | |
| const coreSnap = core.snapshot || {}; | |
| const btcSnap = summary?.btc?.snapshot || {}; | |
| const coreMonitor = core.monitor || null; | |
| const aggregate = core.aggregate || {}; | |
| const aggVolume = aggregate.volume24h || {}; | |
| const aggOi = aggregate.openInterest || {}; | |
| const aggFunding = aggregate.fundingRate || {}; | |
| const oiChange = coreSnap.openInterestChange || {}; | |
| const flowBits = []; | |
| if (Number.isFinite(Number(coreSnap.volumeRatio5m))) { | |
| flowBits.push("5m 量能 x" + formatNumber(coreSnap.volumeRatio5m, 1)); | |
| } | |
| if (Number.isFinite(Number(coreSnap.liquidation5m))) { | |
| flowBits.push("强平 " + formatUsd(coreSnap.liquidation5m)); | |
| } | |
| if (Number.isFinite(Number(oiChange["15m"]))) { | |
| flowBits.push("OI 15m " + formatSignedPercent(oiChange["15m"], 1)); | |
| } | |
| setText("coreName", core.name || "GRAM"); | |
| setText("coreSource", `${sourceLabel(core.data_source)} · ${core.symbol || "--"}`); | |
| setText("corePrice", coreSnap.price ? `${formatPrice(coreSnap.price)} USDT` : "--"); | |
| setChangeChip("coreChange1m", "1m", coreSnap.priceChange?.["1m"]); | |
| setChangeChip("coreChange5m", "5m", coreSnap.priceChange?.["5m"]); | |
| setChangeChip("coreChange1h", "1h", coreSnap.priceChange?.["1h"]); | |
| setText("btcPrice", btcSnap.price ? `${formatPrice(btcSnap.price)} USDT` : "--"); | |
| setText("btcMeta", btcSnap.priceChange?.["1h"] !== undefined | |
| ? `1h ${formatSignedPercent(btcSnap.priceChange["1h"], 2)}` | |
| : "等待数据"); | |
| setText("coreVolume24h", formatUsd(aggVolume.total || coreSnap.quoteVolume24h)); | |
| setText("coreVolume5m", aggVolume.sourceCount | |
| ? `${sourceCountLabel(aggVolume.sourceCount, 11)} 来源 · ${sourceNames(aggVolume.sources)}` | |
| : `近 5m ${formatUsd(coreSnap.volume5m)} · ${coreSnap.volumeRatio5m ? "x" + formatNumber(coreSnap.volumeRatio5m, 1) : "--"}`); | |
| setText("coreRange24h", coreSnap.high24 && coreSnap.low24 | |
| ? `${formatPrice(coreSnap.low24)} - ${formatPrice(coreSnap.high24)}` | |
| : "--"); | |
| setText("coreRsi", `Binance 24h 区间 · RSI ${formatNumber(coreSnap.rsi, 1)}`); | |
| setText("coreFunding", aggFunding.value !== null && aggFunding.value !== undefined | |
| ? formatPercent(aggFunding.value, 4) | |
| : "--"); | |
| setText("coreFuturesSymbol", core.enable_futures | |
| ? `${aggFunding.source || "Binance Futures"} · ${aggFunding.symbol || core.futures_symbol || "--"}` | |
| : "合约未启用"); | |
| setText("coreOpenInterest", aggOi.total ? formatUsd(aggOi.total) : (coreSnap.openInterest ? formatNumber(coreSnap.openInterest, 2) : "--")); | |
| setText("coreOpenInterestChange", aggOi.sourceCount | |
| ? `${sourceCountLabel(aggOi.sourceCount, 6)} 来源 · ${sourceNames(aggOi.sources)}` | |
| : oiChange["15m"] !== undefined | |
| ? `15m ${formatSignedPercent(oiChange["15m"], 1)}` | |
| : "等待持仓样本"); | |
| setText("coreFlow", flowBits[0] || "--"); | |
| setText("coreFlowMeta", flowBits.slice(1).join(";") || "等待资金异动样本"); | |
| const displayStatus = !isEnabled(coreMonitor) ? "disabled" : (isPaused(coreMonitor) ? "paused" : coreMonitor?.status); | |
| const statusMeta = `${statusText(displayStatus)} · ${formatMonitorMeta(coreMonitor)}`; | |
| setText("coreCoverage", `成交 ${aggVolume.sourceCount || 0} · OI ${aggOi.sourceCount || 0}`); | |
| setText("coreCoverageMeta", `成交额覆盖 11 所;持仓覆盖 6 所`); | |
| setText("coreUpdated", coreSnap.updatedAt ? `更新 ${formatDateTime(coreSnap.updatedAt)} · ${statusMeta}` : `等待行情 · ${statusMeta}`); | |
| } | |
| function renderStrategySummary(monitors) { | |
| const byId = Object.fromEntries(monitors.map((m) => [m.id, m])); | |
| renderStrategyMetric(byId["strategy:btc_holdings"], "strategyBtcValue", "strategyBtcMeta", formatBtc); | |
| renderStrategyMetric(byId["strategy:cash_reserve"], "strategyCashValue", "strategyCashMeta", formatUsd); | |
| } | |
| function renderStrategyMetric(monitor, valueId, metaId, formatter) { | |
| const valueEl = document.getElementById(valueId); | |
| const metaEl = document.getElementById(metaId); | |
| const payload = parseJsonField(monitor?.last_value_json); | |
| if (!monitor) { | |
| valueEl.textContent = "--"; | |
| metaEl.textContent = "等待数据"; | |
| return; | |
| } | |
| if (isPaused(monitor)) { | |
| valueEl.textContent = "已暂停"; | |
| metaEl.textContent = "该监控项已手动暂停"; | |
| return; | |
| } | |
| if (monitor.status === "error") { | |
| const lastValue = payload?.value ? `;上次值:${formatter(payload.value)}` : ""; | |
| const lastSuccess = monitor.last_success_at ? `;上次成功:${monitor.last_success_at}` : ""; | |
| valueEl.textContent = "获取失败"; | |
| metaEl.textContent = `错误:${monitor.last_error || "--"}${lastSuccess}${lastValue}`; | |
| return; | |
| } | |
| valueEl.textContent = formatter(payload?.value); | |
| metaEl.textContent = payload | |
| ? `日期:${payload.end || "--"};提交:${payload.filed || "--"};来源:${payload.source || "--"}` | |
| : "等待数据"; | |
| } | |
| function clone(value) { | |
| return JSON.parse(JSON.stringify(value)); | |
| } | |
| function setMessage(text, kind = "") { | |
| for (const id of ["topMessage", "configMessage"]) { | |
| const el = document.getElementById(id); | |
| if (!el) continue; | |
| el.textContent = text || ""; | |
| el.className = "message" + (kind ? " " + kind : ""); | |
| } | |
| } | |
| function numberOrZero(value) { | |
| const n = Number(value); | |
| return Number.isFinite(n) ? n : 0; | |
| } | |
| function readNumberInput(el) { | |
| if (el.value === "") return 0; | |
| return numberOrZero(el.value); | |
| } | |
| function setInputValue(id, value) { | |
| const el = document.getElementById(id); | |
| if (!el) return; | |
| if (el.type === "checkbox") { | |
| el.checked = Boolean(value); | |
| } else { | |
| el.value = value ?? ""; | |
| } | |
| } | |
| function getInputValue(id, type = "string") { | |
| const el = document.getElementById(id); | |
| if (!el) return null; | |
| if (type === "boolean") return Boolean(el.checked); | |
| if (type === "number") return readNumberInput(el); | |
| return el.value; | |
| } | |
| function showPanel(name) { | |
| activePanel = name; | |
| for (const panel of ["global", "coins", "exchanges", "advanced"]) { | |
| document.getElementById("panel-" + panel).classList.toggle("hidden", panel !== name); | |
| document.querySelector(`[data-panel="${panel}"]`).classList.toggle("active", panel === name); | |
| } | |
| if (name === "advanced") syncFormToJson(); | |
| } | |
| async function loadStatus() { | |
| const res = await fetch("/api/status"); | |
| const data = await res.json(); | |
| const monitors = data.monitors || []; | |
| const alerts = data.alerts || []; | |
| const events = [...uiEvents, ...(data.events || [])]; | |
| const errorCount = monitors.filter((m) => m.status === "error" && !isPaused(m)).length; | |
| document.getElementById("platformState").textContent = data.started ? "运行中" : "未启动"; | |
| document.getElementById("monitorCount").textContent = monitors.length; | |
| document.getElementById("errorCount").textContent = errorCount; | |
| document.getElementById("alertCount").textContent = data.crypto?.stats?.alerts ?? alerts.length; | |
| document.getElementById("updated").textContent = new Date().toLocaleString(); | |
| renderCoreMarket(data.crypto?.core_market); | |
| const core = data.crypto?.core_market?.core || {}; | |
| if (core.name && core.name !== chartCoin) { | |
| chartCoin = core.name; | |
| loadCoreChart(); | |
| } | |
| renderStrategySummary(monitors); | |
| document.getElementById("monitorRows").innerHTML = monitors.map((m) => { | |
| const displayStatus = !isEnabled(m) ? "disabled" : (isPaused(m) ? "paused" : m.status); | |
| const status = escapeHtml(displayStatus); | |
| return row([ | |
| renderMonitorName(m), | |
| renderMonitorState(m, displayStatus, status), | |
| escapeHtml(formatDateTime(m.last_success_at)), | |
| escapeHtml(formatDateTime(m.next_run_at)), | |
| renderMonitorValue(m) | |
| ], ["监控项", "状态", "最近成功", "下次运行", "最近值 / 错误"]); | |
| }).join(""); | |
| document.getElementById("alertRows").innerHTML = alerts.map((a) => row([ | |
| escapeHtml(formatDateTime(a.created_at)), | |
| escapeHtml(a.monitor_id), | |
| escapeHtml(a.category), | |
| a.delivered ? "成功" : "失败", | |
| "<pre class='log-snippet'>" + escapeHtml(a.text) + "</pre>" | |
| ], ["时间", "监控项", "类别", "发送", "内容"])).join(""); | |
| document.getElementById("eventRows").innerHTML = events.map((e) => row([ | |
| escapeHtml(formatDateTime(e.created_at)), | |
| escapeHtml(e.monitor_id), | |
| escapeHtml(e.level), | |
| escapeHtml(e.message) | |
| ], ["时间", "监控项", "级别", "消息"])).join(""); | |
| } | |
| function pauseControl(monitor) { | |
| const checked = isPaused(monitor) ? " checked" : ""; | |
| const disabled = isEnabled(monitor) ? "" : " disabled"; | |
| const label = !isEnabled(monitor) ? "配置禁用" : (isPaused(monitor) ? "暂停中" : "运行"); | |
| return ( | |
| `<label class="switch-row pause-cell">` + | |
| `<input type="checkbox" data-monitor-id="${escapeHtml(monitor.id)}"${checked}${disabled} onchange="setMonitorPaused(this)">` + | |
| `<span>${label}</span>` + | |
| `</label>` | |
| ); | |
| } | |
| async function setMonitorPaused(input) { | |
| const monitorId = input.dataset.monitorId; | |
| const paused = input.checked; | |
| input.disabled = true; | |
| try { | |
| const action = paused ? "pause" : "resume"; | |
| const res = await fetch("/api/monitors/" + encodeURIComponent(monitorId) + "/" + action, {method: "POST"}); | |
| if (!res.ok) throw new Error(await res.text()); | |
| setMessage(paused ? "监控项已暂停" : "监控项已恢复", "ok"); | |
| await loadStatus(); | |
| } catch (err) { | |
| input.checked = !paused; | |
| input.disabled = false; | |
| setMessage("操作失败:" + err.message, "error"); | |
| } | |
| } | |
| async function loadConfig() { | |
| const res = await fetch("/api/config"); | |
| currentConfig = await res.json(); | |
| const names = Object.keys(currentConfig.coins || {}); | |
| currentCoin = currentCoin && currentConfig.coins[currentCoin] ? currentCoin : names[0]; | |
| renderGlobalForm(); | |
| renderCoinSelector(); | |
| renderCoinForm(); | |
| renderExchangeForm(); | |
| syncFormToJson(); | |
| setMessage(""); | |
| addUiEvent("配置已加载"); | |
| await loadStatus(); | |
| } | |
| function renderGlobalForm() { | |
| const g = currentConfig.global || {}; | |
| for (const [key, type] of globalFields) { | |
| setInputValue("global_" + key, g[key]); | |
| } | |
| } | |
| function collectGlobalForm() { | |
| currentConfig.global = currentConfig.global || {}; | |
| for (const [key, type] of globalFields) { | |
| currentConfig.global[key] = getInputValue("global_" + key, type); | |
| } | |
| } | |
| function renderCoinSelector() { | |
| const selector = document.getElementById("coinSelector"); | |
| const names = Object.keys(currentConfig.coins || {}); | |
| selector.innerHTML = names.map((name) => { | |
| const suffix = currentConfig.coins[name]?.core_display ? "(核心)" : ""; | |
| return `<option value="${escapeHtml(name)}">${escapeHtml(name + suffix)}</option>`; | |
| }).join(""); | |
| if (currentCoin) selector.value = currentCoin; | |
| } | |
| function selectedCoinName() { | |
| const selector = document.getElementById("coinSelector"); | |
| return selector.value || currentCoin; | |
| } | |
| function selectedCoin() { | |
| const name = selectedCoinName(); | |
| return currentConfig.coins[name]; | |
| } | |
| function renderCoinForm() { | |
| const name = selectedCoinName(); | |
| currentCoin = name; | |
| const coin = selectedCoin(); | |
| if (!coin) return; | |
| document.querySelectorAll("[data-coin-key]").forEach((el) => { | |
| const key = el.dataset.coinKey; | |
| if (el.type === "checkbox") { | |
| el.checked = Boolean(coin[key]); | |
| } else { | |
| el.value = coin[key] ?? ""; | |
| } | |
| }); | |
| document.querySelectorAll("[data-price-window]").forEach((el) => { | |
| const win = el.dataset.priceWindow; | |
| const value = (coin.price_change || {})[win]; | |
| el.value = Array.isArray(value) ? value[0] : (value ?? ""); | |
| }); | |
| updateCoinSourceControls(); | |
| } | |
| function collectCoinForm() { | |
| const coin = selectedCoin(); | |
| if (!coin) return; | |
| document.querySelectorAll("[data-coin-key]").forEach((el) => { | |
| const key = el.dataset.coinKey; | |
| if (el.type === "checkbox") { | |
| coin[key] = Boolean(el.checked); | |
| } else if (el.type === "number") { | |
| coin[key] = readNumberInput(el); | |
| } else { | |
| coin[key] = el.value; | |
| } | |
| }); | |
| coin.price_change = coin.price_change || {}; | |
| document.querySelectorAll("[data-price-window]").forEach((el) => { | |
| coin.price_change[el.dataset.priceWindow] = readNumberInput(el); | |
| }); | |
| if (coin.core_display) { | |
| for (const item of Object.values(currentConfig.coins || {})) { | |
| item.core_display = false; | |
| } | |
| coin.core_display = true; | |
| } | |
| } | |
| function handleCoinSourceChange() { | |
| updateCoinSourceControls(); | |
| } | |
| function updateCoinSourceControls() { | |
| const source = document.getElementById("coin_data_source").value; | |
| const futuresSymbol = document.getElementById("coin_futures_symbol"); | |
| const futuresEnabled = document.getElementById("coin_enable_futures"); | |
| const isAlpha = source === "binance_alpha"; | |
| futuresSymbol.disabled = false; | |
| futuresEnabled.disabled = false; | |
| futuresSymbol.placeholder = isAlpha ? "例如 TACUSDT;没有合约可留空" : "例如 BTCUSDT"; | |
| if (isAlpha && !futuresSymbol.value) { | |
| const name = selectedCoinName(); | |
| if (name && !name.startsWith("ALPHA_")) futuresSymbol.value = name + "USDT"; | |
| } | |
| } | |
| function addCoin() { | |
| collectFormConfig(); | |
| const name = prompt("请输入币种代号,例如 SOL"); | |
| if (!name) return; | |
| const coinName = name.trim().toUpperCase(); | |
| if (!coinName) return; | |
| if (currentConfig.coins[coinName]) { | |
| setMessage("币种已存在", "error"); | |
| return; | |
| } | |
| const symbol = prompt("请输入交易标的,例如 SOLUSDT", coinName + "USDT"); | |
| if (!symbol) return; | |
| const template = clone(currentConfig.coins.BTC || Object.values(currentConfig.coins)[0] || {}); | |
| template.symbol = symbol.trim().toUpperCase(); | |
| template.futures_symbol = template.symbol; | |
| template.data_source = "binance"; | |
| template.enable_futures = false; | |
| template.core_display = false; | |
| currentConfig.coins[coinName] = template; | |
| currentCoin = coinName; | |
| renderCoinSelector(); | |
| renderCoinForm(); | |
| syncFormToJson(); | |
| setMessage("已新增币种,保存后生效", "ok"); | |
| } | |
| function alphaCoinName(raw) { | |
| let text = String(raw || "").trim().toUpperCase(); | |
| if (text.endsWith("USDT")) text = text.slice(0, -4); | |
| return text.replace(/[^A-Z0-9]+/g, "_").replace(/^_+|_+$/g, "") || "ALPHA"; | |
| } | |
| function alphaTemplate(symbol) { | |
| const existingAlpha = Object.values(currentConfig.coins || {}).find((coin) => coin.data_source === "binance_alpha"); | |
| const template = clone(existingAlpha || currentConfig.coins.BTC || Object.values(currentConfig.coins)[0] || {}); | |
| const fallbackFuturesSymbol = alphaCoinName(symbol).startsWith("ALPHA_") ? "" : alphaCoinName(symbol) + "USDT"; | |
| Object.assign(template, { | |
| symbol, | |
| futures_symbol: fallbackFuturesSymbol, | |
| data_source: "binance_alpha", | |
| enable_futures: false, | |
| core_display: false, | |
| market_close: false, | |
| price_change: { "1m": 1.20, "5m": 3.00, "15m": 6.00, "1h": 10.00 }, | |
| near_cost_pct: 2.0, | |
| near_key_pct: 2.0, | |
| break_24h_pct: 1.0, | |
| break_24h_hold_sec: 180, | |
| break_7d_pct: 3.0, | |
| vol_anomaly_mult: 4.0, | |
| vol_severe_mult: 10.0, | |
| amp_5m: 4.0, | |
| amp_15m: 8.0, | |
| amp_1h: 14.0, | |
| big_trade_usdt: 5000, | |
| cum_trade_5m_usdt: 30000, | |
| spread_pct: 0.30, | |
| spread_severe_pct: 1.00, | |
| imbalance_depth_pct: 2.0, | |
| imbalance_ratio: 4.0, | |
| wall_depth_pct: 3.0, | |
| wall_mult: 5.0, | |
| wall_vanish_pct: 75.0, | |
| wall_min_usdt: 20000, | |
| wall_dominance: 2.0, | |
| ma_deviation_pct: 10.0, | |
| consecutive_klines: 3, | |
| rsi_high: 85.0, | |
| rsi_low: 15.0 | |
| }); | |
| return template; | |
| } | |
| function addAlphaCoin() { | |
| collectFormConfig(); | |
| const rawSymbol = prompt("请输入 Binance Alpha 标的,可填 DATAIP / ALPHA_1011 / ALPHA_1011USDT"); | |
| if (!rawSymbol) return; | |
| const symbol = rawSymbol.trim().toUpperCase(); | |
| const coinName = alphaCoinName(symbol); | |
| if (currentConfig.coins[coinName]) { | |
| setMessage("Alpha 币种已存在:" + coinName, "error"); | |
| return; | |
| } | |
| currentConfig.coins[coinName] = alphaTemplate(symbol); | |
| currentCoin = coinName; | |
| renderCoinSelector(); | |
| renderCoinForm(); | |
| syncFormToJson(); | |
| setMessage("已新增 Binance Alpha 标的 " + coinName + ",保存后生效", "ok"); | |
| } | |
| function deleteCoin() { | |
| collectFormConfig(); | |
| const name = selectedCoinName(); | |
| const names = Object.keys(currentConfig.coins || {}); | |
| if (names.length <= 1) { | |
| setMessage("至少保留一个币种", "error"); | |
| return; | |
| } | |
| if (!confirm("确认删除 " + name + " 吗?保存后生效。")) return; | |
| const wasCore = Boolean(currentConfig.coins[name]?.core_display); | |
| delete currentConfig.coins[name]; | |
| currentCoin = Object.keys(currentConfig.coins)[0]; | |
| if (wasCore && currentCoin) currentConfig.coins[currentCoin].core_display = true; | |
| renderCoinSelector(); | |
| renderCoinForm(); | |
| syncFormToJson(); | |
| setMessage("已删除币种,保存后生效", "ok"); | |
| } | |
| function renderExchangeForm() { | |
| const ex = currentConfig.exchanges || {}; | |
| const spread = ex.spread || {}; | |
| setInputValue("ex_enable_bitget", ex.enable_bitget); | |
| setInputValue("ex_enable_okx", ex.enable_okx); | |
| setInputValue("spread_enable", spread.enable); | |
| setInputValue("spread_threshold_pct", spread.threshold_pct); | |
| renderSpreadPairs(); | |
| } | |
| function renderSpreadPairs() { | |
| const pairs = (((currentConfig.exchanges || {}).spread || {}).pairs || []); | |
| const tbody = document.getElementById("spreadPairRows"); | |
| tbody.innerHTML = pairs.map((pair, index) => row([ | |
| `<input data-pair-index="${index}" data-pair-key="name" value="${escapeHtml(pair.name)}">`, | |
| `<input data-pair-index="${index}" data-pair-key="binance" value="${escapeHtml(pair.binance)}">`, | |
| `<input data-pair-index="${index}" data-pair-key="bitget" value="${escapeHtml(pair.bitget)}">`, | |
| `<input data-pair-index="${index}" data-pair-key="okx" value="${escapeHtml(pair.okx)}">`, | |
| `<input type="number" step="0.1" data-pair-index="${index}" data-pair-key="threshold_pct" value="${escapeHtml(pair.threshold_pct)}">`, | |
| `<button class="small danger" onclick="deleteSpreadPair(${index})">删除</button>` | |
| ])).join(""); | |
| } | |
| function collectExchangeForm() { | |
| currentConfig.exchanges = currentConfig.exchanges || {}; | |
| currentConfig.exchanges.spread = currentConfig.exchanges.spread || {}; | |
| currentConfig.exchanges.enable_bitget = getInputValue("ex_enable_bitget", "boolean"); | |
| currentConfig.exchanges.enable_okx = getInputValue("ex_enable_okx", "boolean"); | |
| currentConfig.exchanges.spread.enable = getInputValue("spread_enable", "boolean"); | |
| currentConfig.exchanges.spread.threshold_pct = getInputValue("spread_threshold_pct", "number"); | |
| const pairs = currentConfig.exchanges.spread.pairs || []; | |
| document.querySelectorAll("[data-pair-index]").forEach((el) => { | |
| const index = Number(el.dataset.pairIndex); | |
| const key = el.dataset.pairKey; | |
| pairs[index] = pairs[index] || {}; | |
| pairs[index][key] = key === "threshold_pct" ? readNumberInput(el) : el.value.trim().toUpperCase(); | |
| }); | |
| currentConfig.exchanges.spread.pairs = pairs.filter((pair) => pair.name); | |
| } | |
| function addSpreadPair() { | |
| collectFormConfig(); | |
| currentConfig.exchanges = currentConfig.exchanges || {}; | |
| currentConfig.exchanges.spread = currentConfig.exchanges.spread || {}; | |
| currentConfig.exchanges.spread.pairs = currentConfig.exchanges.spread.pairs || []; | |
| currentConfig.exchanges.spread.pairs.push({ | |
| name: "NEW", | |
| binance: "NEWUSDT", | |
| bitget: "NEWUSDT", | |
| okx: "NEW-USDT", | |
| threshold_pct: currentConfig.exchanges.spread.threshold_pct || 3 | |
| }); | |
| renderSpreadPairs(); | |
| syncFormToJson(); | |
| setMessage("已新增差价币种,保存后生效", "ok"); | |
| } | |
| function deleteSpreadPair(index) { | |
| collectExchangeForm(); | |
| currentConfig.exchanges.spread.pairs.splice(index, 1); | |
| renderSpreadPairs(); | |
| syncFormToJson(); | |
| } | |
| function collectFormConfig() { | |
| if (!currentConfig) return null; | |
| collectGlobalForm(); | |
| collectCoinForm(); | |
| collectExchangeForm(); | |
| return currentConfig; | |
| } | |
| function syncFormToJson() { | |
| if (!currentConfig) return; | |
| collectFormConfig(); | |
| document.getElementById("configText").value = JSON.stringify(currentConfig, null, 2); | |
| } | |
| function applyJsonToForm() { | |
| try { | |
| currentConfig = JSON.parse(document.getElementById("configText").value); | |
| const names = Object.keys(currentConfig.coins || {}); | |
| currentCoin = currentCoin && currentConfig.coins[currentCoin] ? currentCoin : names[0]; | |
| renderGlobalForm(); | |
| renderCoinSelector(); | |
| renderCoinForm(); | |
| renderExchangeForm(); | |
| setMessage("JSON 已应用到表单,保存后生效", "ok"); | |
| } catch (err) { | |
| setMessage("JSON 格式错误:" + err.message, "error"); | |
| } | |
| } | |
| async function savePayload(payload) { | |
| const res = await fetch("/api/config", { | |
| method: "PUT", | |
| headers: {"Content-Type": "application/json"}, | |
| body: JSON.stringify(payload) | |
| }); | |
| if (!res.ok) throw new Error(await res.text()); | |
| currentConfig = await res.json(); | |
| const names = Object.keys(currentConfig.coins || {}); | |
| currentCoin = currentCoin && currentConfig.coins[currentCoin] ? currentCoin : names[0]; | |
| renderGlobalForm(); | |
| renderCoinSelector(); | |
| renderCoinForm(); | |
| renderExchangeForm(); | |
| syncFormToJson(); | |
| await loadStatus(); | |
| } | |
| async function saveFormConfig() { | |
| try { | |
| const payload = collectFormConfig(); | |
| await savePayload(payload); | |
| setMessage("参数已保存并热更新", "ok"); | |
| } catch (err) { | |
| setMessage("保存失败:" + err.message, "error"); | |
| } | |
| } | |
| async function saveJsonConfig() { | |
| try { | |
| const payload = JSON.parse(document.getElementById("configText").value); | |
| await savePayload(payload); | |
| setMessage("JSON 配置已保存", "ok"); | |
| } catch (err) { | |
| setMessage("保存失败:" + err.message, "error"); | |
| } | |
| } | |
| async function calibrateAll() { | |
| setMessage("正在校准全部币种,可能需要一些时间"); | |
| try { | |
| await savePayload(collectFormConfig()); | |
| } catch (err) { | |
| setMessage("校准前保存失败:" + err.message, "error"); | |
| return; | |
| } | |
| const res = await fetch("/api/calibrate", {method: "POST"}); | |
| if (res.ok) { | |
| setMessage("全部币种校准完成", "ok"); | |
| await loadConfig(); | |
| await loadStatus(); | |
| } else { | |
| setMessage("校准失败:" + await res.text(), "error"); | |
| } | |
| } | |
| async function calibrateSelected() { | |
| const coin = selectedCoinName(); | |
| if (!coin) return; | |
| currentCoin = coin; | |
| setMessage("正在保存并校准 " + coin); | |
| try { | |
| await savePayload(collectFormConfig()); | |
| } catch (err) { | |
| setMessage("校准前保存失败:" + err.message, "error"); | |
| return; | |
| } | |
| if (!currentConfig.coins || !currentConfig.coins[coin]) { | |
| setMessage("校准失败:配置中没有 " + coin + ",请重新加载后再试", "error"); | |
| return; | |
| } | |
| const res = await fetch("/api/calibrate/" + encodeURIComponent(coin), {method: "POST"}); | |
| if (res.ok) { | |
| setMessage(coin + " 校准完成", "ok"); | |
| await loadConfig(); | |
| await loadStatus(); | |
| } else { | |
| setMessage("校准失败:" + await res.text(), "error"); | |
| } | |
| } | |
| loadStatus(); | |
| loadConfig(); | |
| bindCoreChart(); | |
| loadCoreChart(); | |
| setInterval(loadStatus, 15000); | |
| if ("serviceWorker" in navigator) { | |
| window.addEventListener("load", () => { | |
| navigator.serviceWorker.register("/service-worker.js").catch(() => {}); | |
| }); | |
| } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |