1067 lines
76 KiB
HTML
1067 lines
76 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}AlphaX Agent | Crypto — 看板{% endblock %}
|
||
<!-- BUILD: 2026-05-09T18:25:00 grid+kline-autoload -->
|
||
|
||
{% block extra_head_css %}
|
||
<!-- BUILD: 2026-05-09T18:28:00 grid+kline-autoload+history -->
|
||
<style>
|
||
/* ===== SHELL ===== */
|
||
.shell { width: min(100% - 40px, 1280px); margin: 0 auto; padding: 24px 0 48px; }
|
||
|
||
/* ===== TOP CONTROLS ===== */
|
||
.controls-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
|
||
.tabs { display: flex; gap: 6px; padding: 4px; background: var(--surface); border: 1px solid var(--hairline-soft); border-radius: var(--radius-full); width: fit-content; }
|
||
.tab-btn { border: 1px solid transparent; background: var(--canvas); color: var(--steel); padding: 8px 18px; border-radius: var(--radius-full); font-weight: 600; font-size: 14px; cursor: pointer; transition: .15s; white-space: nowrap; line-height: 1.3; box-shadow: 0 1px 2px rgba(5,0,56,.03); }
|
||
.tab-btn:hover { color: var(--ink); border-color: var(--hairline); background: rgba(66,98,255,.04); }
|
||
.tab-btn.active { color: var(--on-primary); background: var(--primary); border-color: var(--primary); box-shadow: 0 4px 12px rgba(5,0,56,.10); }
|
||
.tab-btn .count { font-size: 11px; margin-left: 4px; opacity: .7; }
|
||
.version-select { height: 40px; border: 1px solid var(--hairline-strong); border-radius: var(--radius-full); padding: 8px 36px 8px 14px; font-size: 14px; font-weight: 500; line-height: 1.3; background: var(--canvas); color: var(--ink); cursor: pointer; min-width: 168px; outline: none; appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--steel) 50%), linear-gradient(135deg, var(--steel) 50%, transparent 50%); background-position: calc(100% - 18px) 17px, calc(100% - 13px) 17px; background-size: 5px 5px, 5px 5px; background-repeat: no-repeat; }
|
||
.version-select:focus { border-color: var(--blue); box-shadow: 0 0 0 2px rgba(66,98,255,.10); }
|
||
.history-version-bar { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin: -6px 0 16px; padding: 10px 14px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); }
|
||
.history-version-bar label { font-size: 12px; color: var(--stone); font-weight: 600; line-height: 1.4; white-space: nowrap; }
|
||
|
||
/* ===== DASHBOARD OVERVIEW ===== */
|
||
.dashboard-overview { display: flex; flex-direction: column; gap: 14px; margin-bottom: 18px; }
|
||
.overview-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
|
||
.overview-title { font-size: 22px; font-weight: 900; letter-spacing: -.6px; color: var(--ink); }
|
||
.overview-subtitle { margin-top: 4px; color: var(--stone); font-size: 13px; line-height: 1.45; }
|
||
.overview-status { display: inline-flex; align-items: center; min-height: 38px; padding: 0 14px; border-radius: var(--radius-full); background: var(--primary); color: var(--on-primary); font-size: 13px; font-weight: 800; }
|
||
.overview-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; }
|
||
.overview-card { border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); padding: 16px; min-width: 0; }
|
||
.overview-card .ov-label { display: flex; align-items: center; gap: 6px; color: var(--stone); font-size: 11px; font-weight: 900; letter-spacing: .2px; }
|
||
.overview-card .ov-value { margin-top: 9px; font-size: 30px; line-height: 1; font-weight: 900; letter-spacing: -1px; color: var(--ink); }
|
||
.overview-card .ov-value.green { color: var(--green); } .overview-card .ov-value.red { color: var(--red); } .overview-card .ov-value.blue { color: var(--blue); } .overview-card .ov-value.yellow { color: var(--yellow-dark); }
|
||
.overview-card .ov-sub { margin-top: 7px; color: var(--stone); font-size: 12px; line-height: 1.35; }
|
||
.overview-card .ov-meter { height: 7px; border-radius: var(--radius-full); background: var(--surface); margin-top: 12px; overflow: hidden; }
|
||
.overview-card .ov-meter span { display: block; height: 100%; border-radius: inherit; background: var(--blue); }
|
||
.overview-card .ov-meter span.green { background: var(--green); } .overview-card .ov-meter span.red { background: var(--red); } .overview-card .ov-meter span.yellow { background: var(--yellow-deep); }
|
||
.market-panels { display: grid; grid-template-columns: 1.15fr .85fr; gap: 12px; }
|
||
.market-panel { border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); padding: 16px; min-width: 0; }
|
||
.panel-title { display:flex; align-items:center; justify-content:space-between; gap:10px; font-size: 14px; font-weight: 900; color: var(--ink); margin-bottom: 12px; }
|
||
.panel-title .hint { color: var(--stone); font-size: 11px; font-weight: 700; }
|
||
.trending-list, .sector-list { display: flex; flex-wrap: wrap; gap: 8px; }
|
||
.trend-chip, .sector-chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; border: 1px solid var(--hairline-soft); border-radius: var(--radius-full); background: var(--surface); padding: 7px 10px; font-size: 12px; font-weight: 800; color: var(--slate); }
|
||
.trend-chip.hot { color: var(--green); background: var(--green-light); border-color: rgba(0,180,115,.18); }
|
||
.trend-chip .rank, .sector-chip .count { color: var(--stone); font-size: 11px; font-weight: 900; }
|
||
.risk-notes { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; }
|
||
.risk-note { background: var(--surface); border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); padding: 10px; min-width:0; }
|
||
.risk-note .rn-label { font-size: 10px; color: var(--stone); font-weight: 900; }
|
||
.risk-note .rn-value { margin-top: 5px; color: var(--ink); font-size: 14px; font-weight: 900; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.overview-loading { border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); padding: 18px; color: var(--stone); font-size: 13px; }
|
||
@media(max-width:980px){ .overview-grid{grid-template-columns:repeat(2,minmax(0,1fr));}.market-panels{grid-template-columns:1fr}.risk-notes{grid-template-columns:repeat(2,minmax(0,1fr));} }
|
||
@media(max-width:480px){ .overview-grid{grid-template-columns:1fr}.overview-title{font-size:20px}.overview-card .ov-value{font-size:26px}.risk-notes{grid-template-columns:1fr} }
|
||
|
||
/* ===== STATS STRIP ===== */
|
||
.stats-strip { display: flex; align-items: center; justify-content: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 20px; padding: 14px 18px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); }
|
||
.stats-main { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||
.stat-chip { display: flex; align-items: center; gap: 7px; font-size: 13px; color: var(--slate); font-weight: 700; }
|
||
.stat-chip.filterable { cursor: pointer; padding: 8px 13px; min-height: 38px; border-radius: var(--radius-full); transition: .15s; user-select: none; border: 1px solid var(--hairline-strong); background: var(--canvas); box-shadow: 0 1px 3px rgba(5,0,56,.05); }
|
||
.stat-chip.filterable::after { content: '筛选'; font-size: 10px; color: var(--muted); font-weight: 800; margin-left: 2px; }
|
||
.stat-chip.filterable:hover { background: rgba(66,98,255,.05); border-color: rgba(66,98,255,.28); color: var(--blue); transform: translateY(-1px); }
|
||
.stat-chip.filterable.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); box-shadow: 0 5px 14px rgba(5,0,56,.13); }
|
||
.stat-chip.filterable.active::after { color: rgba(255,255,255,.72); content: '已筛选'; }
|
||
.stat-chip.filterable.active .val { color: var(--on-primary); }
|
||
.stat-chip .dot { width: 8px; height: 8px; border-radius: 50%; }
|
||
.stat-chip .val { font-weight: 800; color: var(--ink); font-size: 16px; }
|
||
.dot.all { background: var(--slate); }
|
||
.dot.buy { background: var(--green); } .dot.wait { background: var(--yellow-deep); } .dot.obs { background: var(--blue); } .dot.weak { background: var(--muted); }
|
||
|
||
/* ===== CARDS GRID ===== */
|
||
.cards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||
@media(max-width:820px){ .cards{ grid-template-columns: 1fr; } }
|
||
.card { border: 1px solid var(--hairline-soft); background: var(--canvas); border-radius: var(--radius-xl); overflow: hidden; transition: .18s; cursor: pointer; }
|
||
.card:hover { border-color: var(--hairline); box-shadow: 0 4px 12px rgba(5,0,56,.04); }
|
||
.card-bar { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px 0; gap: 8px; }
|
||
.coin-left { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||
.coin-icon { width: 36px; height: 36px; border-radius: var(--radius-md); background: var(--surface); display: grid; place-items: center; font-weight: 800; font-size: 12px; color: var(--steel); border: 1px solid var(--hairline); flex-shrink: 0; }
|
||
.coin-symbol { font-weight: 800; font-size: 16px; letter-spacing: -.3px; color: var(--ink); }
|
||
.action-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; line-height: 1.3; margin-left: auto; }
|
||
.action-badge.buy { color: var(--green); background: var(--green-light); }
|
||
.action-badge.wait { color: var(--yellow-dark); background: var(--yellow-light); }
|
||
.action-badge.obs { color: var(--blue); background: rgba(66,98,255,.06); }
|
||
.action-badge.weak { color: var(--muted); background: var(--surface); border:1px solid var(--hairline-soft); }
|
||
.card.weak-observe { opacity:.78; }
|
||
.card.weak-observe .entry-plan { display:none; }
|
||
.weak-note { margin: 0 18px 8px; padding: 8px 10px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); color: var(--stone); font-size: 12px; line-height: 1.45; }
|
||
.weak-summary { grid-column:1 / -1; display:flex; align-items:center; justify-content:space-between; gap:10px; padding:12px 14px; border:1px dashed var(--hairline-strong); border-radius:var(--radius-xl); background:var(--surface); color:var(--stone); font-size:13px; }
|
||
.weak-summary button { border:1px solid var(--hairline-strong); background:var(--canvas); border-radius:999px; min-height:36px; padding:0 12px; font-size:12px; font-weight:800; cursor:pointer; color:var(--ink); }
|
||
.action-badge.warn { color: var(--yellow-dark); background: var(--yellow-light); }
|
||
.action-badge.caution { color: var(--red); background: var(--red-light); }
|
||
.score-badge { display: inline-flex; align-items: baseline; gap: 5px; padding: 5px 10px; border-radius: var(--radius-full); font-weight: 800; white-space: nowrap; border: 1px solid var(--hairline-soft); }
|
||
.score-badge .score-num { font-size: 19px; font-weight: 900; letter-spacing: -.5px; line-height: 1; }
|
||
.score-badge .score-label { font-size: 11px; font-weight: 800; opacity: .9; }
|
||
.score-badge.tier-strong { background: var(--green); color: var(--on-primary); }
|
||
.score-badge.tier-good { background: rgba(15,188,176,.12); color: #187574; }
|
||
.score-badge.tier-ok { background: var(--yellow); color: var(--primary); }
|
||
.score-badge.tier-watch { background: var(--yellow-light); color: var(--yellow-dark); }
|
||
.score-badge.tier-weak { background: var(--surface); color: var(--steel); border: 1px solid var(--hairline); }
|
||
.score-badge.tier-none { background: var(--hairline-soft); color: var(--muted); border: 1px solid var(--hairline); }
|
||
.card-bar .badge-group { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||
.card-bar .win-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; color: var(--green); background: var(--green-light); }
|
||
.card-bar .lose-badge { padding: 4px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; color: var(--red); background: var(--red-light); }
|
||
.hist-pnl-badge { display: flex; align-items: baseline; gap: 4px; padding: 6px 14px; border-radius: var(--radius-full); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-weight: 900; white-space: nowrap; margin-left: auto; }
|
||
.hist-pnl-badge.pos { color: var(--green); background: var(--green-light); }
|
||
.hist-pnl-badge.neg { color: var(--red); background: var(--red-light); }
|
||
.hist-pnl-badge.zero { color: var(--slate); background: var(--surface); }
|
||
.hist-pnl-badge .pnl-num { font-size: 22px; line-height: 1; letter-spacing: -.5px; }
|
||
.hist-pnl-badge .pnl-unit { font-size: 11px; opacity: .8; }
|
||
.hist-score-pill { font-size: 11px; font-weight: 700; color: #187574; background: rgba(15,188,176,.12); border: 1px solid rgba(15,188,176,.22); padding: 3px 8px; border-radius: var(--radius-full); white-space: nowrap; }
|
||
.hist-score-pill.weak { color: var(--yellow-dark); background: var(--yellow-light); border-color: rgba(252,185,0,.28); }
|
||
.hist-score-pill.danger { color: var(--red); background: var(--red-light); border-color: rgba(229,62,62,.20); }
|
||
.hist-result-badge { padding: 4px 10px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; white-space: nowrap; }
|
||
.hist-result-badge.win { color: var(--green); background: var(--green-light); }
|
||
.hist-result-badge.loss { color: var(--red); background: var(--red-light); }
|
||
.hist-result-badge.neutral { color: var(--blue); background: rgba(66,98,255,.06); }
|
||
.h-pnl-row .price.h-entry-price { color: var(--blue); }
|
||
.h-pnl-row .price.h-exit-price.win { color: var(--green); }
|
||
.h-pnl-row .price.h-exit-price.loss { color: var(--red); }
|
||
.h-arrow.win { color: var(--green); }
|
||
.h-arrow.loss { color: var(--red); }
|
||
.h-duration { color: var(--blue); background: rgba(66,98,255,.06); padding: 3px 8px; border-radius: var(--radius-full); font-weight: 700; }
|
||
|
||
.hist-metric-row { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; padding: 8px 18px 10px; }
|
||
.hist-metric { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 7px 9px; min-width: 0; }
|
||
.hist-metric .hm-label { display: block; font-size: 10px; color: var(--stone); font-weight: 800; line-height: 1.2; }
|
||
.hist-metric .hm-val { display: block; margin-top: 3px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; font-weight: 900; color: var(--ink); }
|
||
.hist-metric .hm-val.win { color: var(--green); }
|
||
.hist-metric .hm-val.loss { color: var(--red); }
|
||
.hist-metric .hm-val.blue { color: var(--blue); }
|
||
.exit-mode { display: inline-flex; align-items: center; padding: 3px 8px; border-radius: var(--radius-full); background: rgba(66,98,255,.06); color: var(--blue); font-size: 11px; font-weight: 800; white-space: nowrap; }
|
||
|
||
/* ===== PRICE BAR ===== */
|
||
.price-bar { display: flex; align-items: center; gap: 10px; padding: 8px 18px 12px; font-size: 12px; color: var(--slate); }
|
||
.price { font-size: 20px; font-weight: 800; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--ink); }
|
||
.price-change { display:inline-flex; align-items:center; gap:4px; font-weight: 700; font-size: 11px; padding: 3px 8px; border-radius: 999px; background: var(--surface); border: 1px solid var(--hairline-soft); }
|
||
.price-change .pc-label { color: var(--stone); font-weight: 600; }
|
||
.price-change .pc-value { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
||
.price-change.up .pc-value { color: var(--green); } .price-change.down .pc-value { color: var(--red); } .price-change.zero .pc-value { color: var(--stone); }
|
||
.card-ver { font-size: 10px; color: var(--stone); background: var(--surface); padding: 2px 8px; border-radius: var(--radius-sm); }
|
||
.h-pnl-row { display: flex; align-items: center; gap: 8px; padding: 4px 18px 8px; }
|
||
.h-arrow { color: var(--stone); font-size: 12px; }
|
||
.h-duration { font-size: 11px; margin-left: auto; }
|
||
.decision-strip { display: grid; grid-template-columns: minmax(92px, auto) minmax(0, 1fr); align-items: center; gap: 10px; margin: 0 18px 10px; padding: 9px 10px; min-height: 48px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); min-width: 0; }
|
||
.decision-head { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||
.decision-label { color: var(--stone); font-size: 10px; font-weight: 900; line-height: 1.1; white-space: nowrap; }
|
||
.decision-title { font-size: 13px; font-weight: 900; line-height: 1.2; white-space: nowrap; }
|
||
.decision-body { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
|
||
.decision-focus { color: var(--ink); font-size: 13px; font-weight: 900; line-height: 1.2; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.decision-reason { color: var(--stone); font-size: 11px; font-weight: 700; line-height: 1.25; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.decision-strip.buy { background: var(--green-light); border-color: rgba(0,180,115,.18); }
|
||
.decision-strip.buy .decision-title { color: var(--green); }
|
||
.decision-strip.wait { background: var(--yellow-light); border-color: rgba(252,185,0,.24); }
|
||
.decision-strip.wait .decision-title { color: var(--yellow-dark); }
|
||
.decision-strip.observe,
|
||
.decision-strip.weak { background: rgba(66,98,255,.04); border-color: rgba(66,98,255,.12); }
|
||
.decision-strip.observe .decision-title { color: var(--blue); }
|
||
.decision-strip.weak .decision-title { color: var(--muted); }
|
||
|
||
.ai-insight { margin: 0 18px 8px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); overflow: hidden; }
|
||
.ai-insight summary { list-style: none; cursor: pointer; padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: 11px; font-weight: 900; color: var(--ink); }
|
||
.ai-insight summary::-webkit-details-marker { display: none; }
|
||
.ai-insight .ai-tag { font-size: 10px; color: var(--blue); background: rgba(66,98,255,.08); border-radius: 999px; padding: 2px 8px; white-space: nowrap; }
|
||
.ai-insight .ai-body { border-top: 1px solid var(--hairline-soft); padding: 8px 10px 10px; display: grid; gap: 8px; }
|
||
.ai-insight .ai-summary { color: var(--slate); font-size: 12px; line-height: 1.5; }
|
||
.ai-insight .ai-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; }
|
||
.ai-insight .ai-item { border: 1px solid var(--hairline-soft); border-radius: 10px; background: var(--canvas); padding: 6px 8px; min-width: 0; }
|
||
.ai-insight .ai-label { color: var(--stone); font-size: 10px; font-weight: 800; }
|
||
.ai-insight .ai-text { color: var(--ink); font-size: 12px; line-height: 1.45; margin-top: 3px; word-break: break-word; }
|
||
.ai-insight .ai-list { display: flex; flex-wrap: wrap; gap: 4px; }
|
||
.ai-insight .ai-pill { display: inline-flex; padding: 4px 7px; border-radius: 999px; font-size: 11px; color: var(--slate); background: var(--canvas); border: 1px solid var(--hairline-soft); }
|
||
|
||
/* ===== K-LINE ===== */
|
||
.kline-wrap { padding: 0 8px 4px; }
|
||
.kline-int-bar { display: flex; gap: 2px; padding: 0 10px 6px; }
|
||
.kline-int-btn { border: 1px solid var(--hairline); background: var(--canvas); color: var(--stone); padding: 3px 8px; border-radius: 5px; font-size: 10px; font-weight: 700; cursor: pointer; transition: .15s; }
|
||
.kline-int-btn:hover { border-color: var(--hairline-strong); color: var(--slate); }
|
||
.kline-int-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); }
|
||
.kline-container svg { display:block; margin:0 auto; }
|
||
.chart-loading { color: var(--stone); font-size: 12px; text-align: center; padding: 16px; }
|
||
|
||
/* ===== ENTRY PLAN ===== */
|
||
.entry-plan { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; padding: 10px 18px; }
|
||
.ep-item { display: flex; flex-direction: column; gap: 3px; min-width: 0; padding: 8px 10px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); }
|
||
.ep-label { color: var(--stone); font-size: 10px; font-weight: 800; line-height: 1.2; white-space: nowrap; }
|
||
.ep-val { font-weight: 900; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 13px; line-height: 1.25; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
.ep-val.entry-ref { color: var(--yellow-dark); } .ep-val.risk-line { color: var(--red); } .ep-val.space-ref { color: var(--blue); } .ep-val.phase-ref { color: var(--green); }
|
||
.ep-sub { color: var(--muted); font-size: 10px; font-weight: 600; line-height: 1.2; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||
/* ===== SIGNALS ===== */
|
||
.signals-row { display: flex; flex-wrap: nowrap; gap: 4px; padding: 0 18px 8px; min-height: 25px; overflow: hidden; }
|
||
.sig { font-size: 11px; padding: 3px 8px; border-radius: var(--radius-full); font-weight: 700; white-space: nowrap; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; max-width: 50%; flex: 0 1 auto; }
|
||
.sig.strong { color: #600000; background: #ffc6c6; }
|
||
.sig.forward { color: var(--green); background: var(--green-light); }
|
||
.sig.pa { color: var(--blue); background: rgba(66,98,255,.06); }
|
||
.sig.info { color: var(--slate); background: var(--surface); }
|
||
.sig.warn { color: var(--red); background: var(--red-light); }
|
||
|
||
/* ===== CARD FOOTER ===== */
|
||
.card-footer { display: flex; align-items: center; justify-content: space-between; padding: 8px 18px 14px; font-size: 11px; color: var(--stone); border-top: 1px solid var(--hairline-soft); }
|
||
.hist-footer { gap: 8px; }
|
||
.hist-footer .card-ver { margin-left: auto; }
|
||
.pnl-block { font-size: 14px; font-weight: 800; }
|
||
.pnl-block.pos { color: var(--green); } .pnl-block.neg { color: var(--red); } .pnl-block.zero { color: var(--slate); }
|
||
|
||
/* ===== HISTORY STATS ===== */
|
||
.history-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
||
.history-stats .hstat { padding: 20px 16px; border: 1px solid var(--hairline-soft); border-radius: var(--radius-xl); background: var(--canvas); text-align: center; }
|
||
.history-stats .hstat .num { font-size: 36px; font-weight: 900; letter-spacing: -1px; line-height: 1.1; }
|
||
.history-stats .hstat .lbl { font-size: 12px; color: var(--stone); margin-top: 8px; font-weight: 600; display: flex; align-items: center; justify-content: center; gap: 4px; }
|
||
.history-stats .hstat .sub { font-size: 10px; color: var(--muted); margin-top: 4px; }
|
||
#historyCards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
|
||
.load-more-row { grid-column: 1 / -1; display: flex; justify-content: center; padding: 10px 0 4px; }
|
||
.load-more-btn { min-height: 44px; padding: 0 18px; border: 1px solid var(--hairline-strong); border-radius: var(--radius-full); background: var(--canvas); color: var(--ink); font-size: 14px; font-weight: 700; cursor: pointer; box-shadow: 0 1px 2px rgba(5,0,56,.03); }
|
||
.load-more-btn:hover { background: rgba(66,98,255,.04); border-color: var(--blue); color: var(--blue); }
|
||
.load-more-btn:disabled { cursor: not-allowed; opacity: .55; background: var(--surface); color: var(--stone); }
|
||
.page-hint { grid-column: 1 / -1; text-align: center; color: var(--stone); font-size: 12px; padding: 2px 0 8px; }
|
||
.history-load-more { grid-column: 1 / -1; display: flex; justify-content: center; padding: 10px 0 4px; }
|
||
.history-page-hint { grid-column: 1 / -1; text-align: center; color: var(--stone); font-size: 12px; padding: 2px 0 8px; }
|
||
@media(max-width:820px){ #historyCards{ grid-template-columns: 1fr; } }
|
||
|
||
/* ===== UTILS ===== */
|
||
.empty-state { text-align:center; padding:48px 20px; color:var(--stone); grid-column: 1 / -1; width: 100%; }
|
||
.empty-state p { font-size:14px; }
|
||
.loading-state { text-align:center; padding:36px; color:var(--stone); display:flex; align-items:center; justify-content:center; gap:8px; }
|
||
.spin { animation: spin 1s linear infinite; }
|
||
@keyframes spin { to{ transform:rotate(360deg) } }
|
||
|
||
@media(max-width:480px) {
|
||
.shell { width: min(100% - 24px, 1280px); padding: 16px 0 32px; }
|
||
.controls-row { align-items: stretch; gap: 10px; }
|
||
.tabs { width: 100%; order: 1; }
|
||
.version-strip { width: 100%; margin-left: 0; order: 2; justify-content: flex-end; }
|
||
.version-select { min-width: 0; flex: 0 1 160px; height: 44px; }
|
||
.tab-btn { padding: 8px 14px; font-size: 13px; flex: 1; text-align: center; min-height: 44px; }
|
||
.history-stats { grid-template-columns: repeat(2, 1fr); }
|
||
.history-stats .hstat .num { font-size: 28px; }
|
||
.history-stats .hstat { padding: 14px 10px; }
|
||
.hist-pnl-badge { padding: 5px 10px; }
|
||
.hist-pnl-badge .pnl-num { font-size: 18px; }
|
||
.hist-score-pill { display: none; }
|
||
.hist-metric-row { grid-template-columns: 1fr; padding: 6px 14px 8px; }
|
||
.stats-strip { align-items: stretch; }
|
||
.stats-main { width: 100%; }
|
||
.entry-plan { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 8px 14px; }
|
||
.decision-strip { margin: 0 14px 8px; grid-template-columns: 86px minmax(0,1fr); }
|
||
}
|
||
|
||
@media(max-width:360px) {
|
||
.card-bar { padding: 12px 14px 0; }
|
||
.price-bar { padding: 6px 14px 8px; }
|
||
.h-pnl-row { padding: 2px 14px 6px; }
|
||
.signals-row { padding: 0 14px 6px; }
|
||
.card-footer { padding: 6px 14px 12px; }
|
||
}
|
||
|
||
</style>
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="shell">
|
||
<!-- compatibility markers: 实时推荐 / 历史推荐 / drawPin / data-entry-price / v.count / 止损 / 止盈 -->
|
||
<div class="controls-row">
|
||
<div class="tabs">
|
||
<button class="tab-btn active" data-tab="live" onclick="switchTab('live')">实时推荐<span class="count" id="liveCount"></span></button>
|
||
<button class="tab-btn" data-tab="history" onclick="switchTab('history')">历史推荐<span class="count" id="histCount"></span></button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- LIVE VIEW -->
|
||
<div id="liveView">
|
||
<section class="dashboard-overview" id="dashboardOverview">
|
||
<div class="overview-loading">正在汇总市场情绪、热度、动能与机会雷达…</div>
|
||
</section>
|
||
<div class="stats-strip" id="liveStats"></div>
|
||
<div class="cards" id="liveCards"><div class="loading-state"><svg class="spin" width="18" height="18" color="#8e91a0"><use href="#svg-spinner"/></svg> 加载中…</div></div>
|
||
</div>
|
||
|
||
<!-- HISTORY VIEW -->
|
||
<div id="historyView" style="display:none">
|
||
<div class="history-stats" id="historyStats"></div>
|
||
<div class="history-version-bar">
|
||
<label for="historyVersionSelect">列表版本</label>
|
||
<select class="version-select" id="historyVersionSelect" onchange="onVersionChange()">
|
||
<option value="">全部版本</option>
|
||
</select>
|
||
</div>
|
||
<div id="historyCards"><div class="loading-state"><svg class="spin" width="18" height="18" color="#8e91a0"><use href="#svg-spinner"/></svg> 加载中…</div></div>
|
||
</div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_script %}
|
||
<script>
|
||
function drawPin(){ return null; }
|
||
var curTab = 'live';
|
||
var latestVersion = '';
|
||
var currentVersion = '';
|
||
var currentFilter = '';
|
||
var cachedLiveData = [];
|
||
var liveOffset = 0;
|
||
var liveLimit = 24;
|
||
var liveHasMore = false;
|
||
var liveLoading = false;
|
||
var liveSummary = { buy_now: 0, wait_pullback: 0, observe: 0, expired: 0, total: 0 };
|
||
var dashboardOverviewLoaded = false;
|
||
var historyItems = [];
|
||
var historyOffset = 0;
|
||
var historyLimit = 24;
|
||
var historyHasMore = false;
|
||
var historyLoading = false;
|
||
// ====== VERSIONS ======
|
||
async function loadVersions() {
|
||
try {
|
||
var activeResp = await fetch(API + '/api/versions?view=all');
|
||
var activeData = await activeResp.json();
|
||
var activeVersions = Array.isArray(activeData) ? activeData : (Array.isArray(activeData.versions) ? activeData.versions : []);
|
||
if (activeVersions.length > 0) {
|
||
latestVersion = activeVersions[0].version;
|
||
}
|
||
|
||
var histResp = await fetch(API + '/api/recommendations?limit=1&offset=0&decision_only=true&compact=true');
|
||
var histPage = await histResp.json();
|
||
var histData = Array.isArray(histPage.items) ? histPage.items : [];
|
||
var counts = {};
|
||
(histPage.version_counts || []).forEach(function(v){ if(v.version) counts[v.version] = v.count || 0; });
|
||
var totalHist = Number(histPage.total || histData.length || 0);
|
||
var versions = Object.keys(counts).sort(function(a,b){ return versionKey(b) - versionKey(a); }).map(function(v){ return {version:v, count:counts[v]}; });
|
||
var sel = $('historyVersionSelect');
|
||
sel.innerHTML = '<option value="">全部版本 ('+totalHist+'只)</option>';
|
||
for (var i = 0; i < versions.length; i++) {
|
||
var v = versions[i];
|
||
var opt = document.createElement('option');
|
||
opt.value = v.version;
|
||
opt.textContent = v.version + ' (' + v.count + '只)';
|
||
sel.appendChild(opt);
|
||
}
|
||
sel.value = '';
|
||
loadContent(true);
|
||
} catch(e) { loadContent(true); }
|
||
}
|
||
|
||
async function onVersionChange() {
|
||
currentVersion = $('historyVersionSelect').value;
|
||
currentFilter = '';
|
||
if (curTab === 'history') await loadHistoryRecommendations(true);
|
||
else await loadContent(true);
|
||
}
|
||
|
||
// ====== LOAD ======
|
||
async function switchTab(tab) {
|
||
curTab = tab;
|
||
document.querySelectorAll('.tab-btn').forEach(function(b){ b.classList.toggle('active', b.dataset.tab === tab); });
|
||
$('liveView').style.display = tab === 'live' ? 'block' : 'none';
|
||
$('historyView').style.display = tab === 'history' ? 'block' : 'none';
|
||
if (tab === 'history') {
|
||
await loadHistoryRecommendations(true);
|
||
} else {
|
||
currentVersion = latestVersion;
|
||
await loadContent(true);
|
||
}
|
||
}
|
||
|
||
function fmtTime(t) { if(!t) return '--'; var diff = (Date.now() - new Date(t).getTime()) / 1000; if(diff < 60) return '刚刚'; if(diff < 3600) return Math.round(diff/60)+'分钟前'; if(diff < 86400) return Math.round(diff/3600)+'小时前'; var d = new Date(t); return d.getMonth()+1 + '/' + d.getDate() + ' ' + ('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2); }
|
||
function daysBetween(a,b) { if(!a||!b) return '--'; var diff = (new Date(b)-new Date(a)) / 1000; if(diff < 3600) return Math.round(diff/60)+'分钟'; if(diff < 86400) return Math.round(diff/3600)+'小时'; return Math.round(diff/86400)+'天'; }
|
||
function versionKey(v) {
|
||
var parts = String(v||'').replace(/^v/,'').split('.');
|
||
var n = 0;
|
||
for (var i=0;i<parts.length;i++) n = n * 1000 + (parseInt(parts[i],10) || 0);
|
||
return n;
|
||
}
|
||
function cleanDisplayText(s) {
|
||
return String(s||'')
|
||
.replace(/[🟢🟡🔴🔥⚠️✅❌⏳👀]/g, '')
|
||
.replace(/即刻买入|可即刻买入|即刻入场/g, '入场窗口')
|
||
.replace(/强烈推荐/g, '强势异动')
|
||
.trim();
|
||
}
|
||
function normalizeTriggerCause(s) {
|
||
return cleanDisplayText(s)
|
||
.replace(/^15min入场窗口/, '15min 触发')
|
||
.replace(/^15min\s*入场窗口/, '15min 触发')
|
||
.replace(/^等回踩到/, '回踩触发价 ')
|
||
.replace(/\(([^)]*)\)/g, ' · $1')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
function priceDecimals(p) {
|
||
p = Math.abs(Number(p || 0));
|
||
if (p <= 0) return 2;
|
||
if (p < 0.0001) return 8;
|
||
if (p < 0.001) return 7;
|
||
if (p < 0.01) return 6;
|
||
if (p < 0.1) return 5;
|
||
if (p < 1) return 4;
|
||
if (p < 10) return 3;
|
||
return 2;
|
||
}
|
||
function fmtPrice(p, decimals) {
|
||
p = Number(p || 0);
|
||
if (!p || p <= 0) return '--';
|
||
var d = decimals == null ? priceDecimals(p) : decimals;
|
||
return p.toFixed(d);
|
||
}
|
||
|
||
// ====== DASHBOARD OVERVIEW ======
|
||
function escHtml(s) {
|
||
return String(s == null ? '' : s).replace(/[&<>'"]/g, function(c){ return ({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c]); });
|
||
}
|
||
function fmtCompactNumber(n) {
|
||
n = Number(n || 0);
|
||
if (!n) return '--';
|
||
if (Math.abs(n) >= 1e9) return (n/1e9).toFixed(1) + 'B';
|
||
if (Math.abs(n) >= 1e6) return (n/1e6).toFixed(1) + 'M';
|
||
if (Math.abs(n) >= 1e3) return (n/1e3).toFixed(1) + 'K';
|
||
return n.toFixed(0);
|
||
}
|
||
function fearGreedTone(value) {
|
||
value = Number(value || 50);
|
||
if (value <= 24) return {cls:'red', label:'极度恐慌', advice:'风险释放,但需要结构确认'};
|
||
if (value <= 44) return {cls:'yellow', label:'恐慌', advice:'偏防守,观察强结构'};
|
||
if (value <= 55) return {cls:'blue', label:'中性', advice:'等待方向选择'};
|
||
if (value <= 74) return {cls:'green', label:'贪婪', advice:'机会活跃,避免追高'};
|
||
return {cls:'red', label:'极度贪婪', advice:'热度过高,谨慎追高'};
|
||
}
|
||
function fundingTone(funding) {
|
||
funding = Number(funding || 0);
|
||
if (funding > 0.0015) return {cls:'red', text:'Funding 过热'};
|
||
if (funding > 0.0005) return {cls:'yellow', text:'多头偏热'};
|
||
if (funding < -0.0005) return {cls:'green', text:'空头拥挤'};
|
||
return {cls:'blue', text:'资金费率中性'};
|
||
}
|
||
function deriveMarketStatus(fg, activeItems, stats) {
|
||
var value = fg && fg.value != null ? Number(fg.value) : 50;
|
||
var buy = activeItems.filter(function(x){ return x.execution_status === 'buy_now'; }).length;
|
||
var total = activeItems.length;
|
||
var m = (stats && stats.market_context_overview) || {};
|
||
var acc = Math.max(Number(m.avg_turnover_acceleration_1h || 0), Number(m.avg_turnover_acceleration_4h || 0));
|
||
var funding = Number(m.avg_funding_rate || 0);
|
||
if (value >= 75 || funding > 0.0015) return '风险升温 · 谨慎追高';
|
||
if (buy > 0 && acc >= 1.5) return '机会活跃 · 重点看入场窗口';
|
||
if (total >= 10 && acc >= 1.1) return '结构活跃 · 观察池筛选';
|
||
if (value <= 30) return '情绪偏冷 · 等待确认';
|
||
return '中性观察 · 等待强信号';
|
||
}
|
||
function renderDashboardOverview(news, stats, activeItems) {
|
||
news = news || {}; stats = stats || {}; activeItems = Array.isArray(activeItems) ? activeItems : [];
|
||
var fg = news.fear_greed || {};
|
||
var fgValue = Number(fg.value || 50);
|
||
var fgTone = fearGreedTone(fgValue);
|
||
var total = activeItems.length;
|
||
var buy = activeItems.filter(function(x){ return x.execution_status === 'buy_now'; }).length;
|
||
var observe = activeItems.filter(function(x){ return x.execution_status !== 'buy_now'; }).length;
|
||
var highScore = activeItems.filter(function(x){ return Number(x.rec_score || 0) >= 65; }).length;
|
||
function avgFromItems(group, key) {
|
||
var vals = [];
|
||
activeItems.forEach(function(x){ var obj = x[group] || {}; var v = Number(obj[key]); if (isFinite(v) && v !== 0) vals.push(v); });
|
||
if (!vals.length) return 0;
|
||
return vals.reduce(function(a,b){ return a+b; }, 0) / vals.length;
|
||
}
|
||
var sectorCounter = {};
|
||
activeItems.forEach(function(x){ ((x.sector_context || {}).hot_sectors || []).forEach(function(sec){ sectorCounter[sec] = (sectorCounter[sec] || 0) + 1; }); });
|
||
var topSectors = Object.keys(sectorCounter).sort(function(a,b){ return sectorCounter[b] - sectorCounter[a]; }).slice(0,6).map(function(sec){ return {sector:sec, count:sectorCounter[sec]}; });
|
||
var market = {
|
||
actionable_sample_count: total,
|
||
avg_turnover_acceleration_1h: avgFromItems('market_context', 'turnover_acceleration_1h'),
|
||
avg_turnover_acceleration_4h: avgFromItems('market_context', 'turnover_acceleration_4h'),
|
||
avg_volume_24h: avgFromItems('market_context', 'volume_24h'),
|
||
avg_funding_rate: avgFromItems('derivatives_context', 'funding_rate'),
|
||
avg_top_trader_long_pct: avgFromItems('derivatives_context', 'top_trader_long_pct'),
|
||
avg_top_trader_long_short_ratio: avgFromItems('derivatives_context', 'top_trader_long_short_ratio'),
|
||
top_hot_sectors: topSectors.length ? topSectors : ((stats.market_context_overview || {}).top_hot_sectors || [])
|
||
};
|
||
var acc1 = Number(market.avg_turnover_acceleration_1h || 0);
|
||
var acc4 = Number(market.avg_turnover_acceleration_4h || 0);
|
||
var funding = Number(market.avg_funding_rate || 0);
|
||
var fTone = fundingTone(funding);
|
||
var status = deriveMarketStatus(fg, activeItems, {market_context_overview: market});
|
||
var activeSymbols = {};
|
||
activeItems.forEach(function(x){ activeSymbols[String(x.symbol || '').replace('/USDT','').toUpperCase()] = true; });
|
||
var trending = (news.trending || []).slice(0, 8);
|
||
var trendHtml = trending.length ? trending.map(function(t, idx){
|
||
var sym = String(t.symbol || '').toUpperCase();
|
||
var hot = activeSymbols[sym];
|
||
return '<span class="trend-chip '+(hot?'hot':'')+'"><span class="rank">#'+(idx+1)+'</span>'+escHtml(sym || t.name || '--')+(hot?'<span>机会池</span>':'')+'</span>';
|
||
}).join('') : '<span class="trend-chip">暂无热榜数据</span>';
|
||
var sectors = (market.top_hot_sectors || []).slice(0, 6);
|
||
var sectorHtml = sectors.length ? sectors.map(function(s){ return '<span class="sector-chip">'+escHtml(s.sector || '--')+'<span class="count">'+(s.count || 0)+'</span></span>'; }).join('') : '<span class="sector-chip">暂无板块聚合</span>';
|
||
var overlap = trending.filter(function(t){ return activeSymbols[String(t.symbol || '').toUpperCase()]; }).length;
|
||
var meterCls = fgTone.cls;
|
||
$('dashboardOverview').innerHTML =
|
||
'<div class="overview-head"><div><div class="overview-title">市场总览</div><div class="overview-subtitle">先看市场情绪、资金动能与热点方向,再进入具体机会。</div></div><div class="overview-status">'+escHtml(status)+'</div></div>'+
|
||
'<div class="overview-grid">'+
|
||
'<div class="overview-card"><div class="ov-label">市场情绪</div><div class="ov-value '+fgTone.cls+'">'+fgValue+'</div><div class="ov-sub">'+escHtml(fg.classification || fgTone.label)+' · '+fgTone.advice+'</div><div class="ov-meter"><span class="'+meterCls+'" style="width:'+Math.max(0,Math.min(100,fgValue))+'%"></span></div></div>'+
|
||
'<div class="overview-card"><div class="ov-label">市场动能</div><div class="ov-value '+(Math.max(acc1,acc4)>=1.5?'green':'blue')+'">'+(Math.max(acc1,acc4)||0).toFixed(1)+'x</div><div class="ov-sub">1H '+acc1.toFixed(1)+'x · 4H '+acc4.toFixed(1)+'x</div></div>'+
|
||
'<div class="overview-card"><div class="ov-label">机会雷达</div><div class="ov-value blue">'+total+'</div><div class="ov-sub">入场窗口 '+buy+' · 重点观察 '+(liveSummary.observe_strong!=null?liveSummary.observe_strong:observe)+' · 弱观察 '+(liveSummary.observe_weak||0)+'</div></div>'+
|
||
'<div class="overview-card"><div class="ov-label">风险温度</div><div class="ov-value '+fTone.cls+'">'+(funding*100).toFixed(3)+'%</div><div class="ov-sub">'+fTone.text+' · 24H均量 '+fmtCompactNumber(market.avg_volume_24h)+'</div></div>'+
|
||
'</div>'+
|
||
'<div class="market-panels"><div class="market-panel"><div class="panel-title">市场热度榜<span class="hint">绿色代表已进入机会池</span></div><div class="trending-list">'+trendHtml+'</div></div>'+
|
||
'<div class="market-panel"><div class="panel-title">热点方向<span class="hint">热榜重叠 '+overlap+'</span></div><div class="sector-list">'+sectorHtml+'</div><div class="risk-notes" style="margin-top:12px"><div class="risk-note"><div class="rn-label">大户多头</div><div class="rn-value">'+Number(market.avg_top_trader_long_pct||0).toFixed(1)+'%</div></div><div class="risk-note"><div class="rn-label">多空比</div><div class="rn-value">'+Number(market.avg_top_trader_long_short_ratio||0).toFixed(2)+'</div></div><div class="risk-note"><div class="rn-label">样本数</div><div class="rn-value">'+Number(market.actionable_sample_count||0)+'</div></div></div></div></div>';
|
||
}
|
||
async function loadDashboardOverview(force) {
|
||
if (dashboardOverviewLoaded && !force) return;
|
||
dashboardOverviewLoaded = true;
|
||
try {
|
||
var activeForOverview = cachedLiveData.slice();
|
||
var res = await Promise.all([
|
||
fetch(API + '/api/newsfeed'),
|
||
fetch(API + '/api/stats')
|
||
]);
|
||
var news = res[0].ok ? await res[0].json() : {};
|
||
var stats = res[1].ok ? await res[1].json() : {};
|
||
renderDashboardOverview(news, stats, activeForOverview);
|
||
} catch(e) {
|
||
$('dashboardOverview').innerHTML = '<div class="overview-loading">市场总览加载失败,机会雷达仍可正常使用。</div>';
|
||
}
|
||
}
|
||
|
||
// ====== LIVE ======
|
||
function isExpiredRec(r) {
|
||
return !r || r.display_bucket === 'history' || r.execution_status === 'invalid' || r.status === 'invalid' || r.status === 'expired';
|
||
}
|
||
async function loadContent(reset) {
|
||
if (liveLoading) return;
|
||
if (reset !== false) {
|
||
cachedLiveData = [];
|
||
liveOffset = 0;
|
||
liveHasMore = false;
|
||
}
|
||
liveLoading = true;
|
||
try {
|
||
var offset = (reset === false) ? liveOffset : 0;
|
||
var url = API+'/api/recommendations/active?with_tracking=true&actionable_only=false&hours=12&limit='+liveLimit+'&offset='+offset+'&compact=true';
|
||
var resp = await fetch(url);
|
||
var page = await resp.json();
|
||
var items = Array.isArray(page.items) ? page.items : (Array.isArray(page) ? page : []);
|
||
if (!items.length && offset === 0) {
|
||
var obsResp = await fetch(API+'/api/observations/active?limit='+liveLimit);
|
||
if (obsResp.ok) {
|
||
var obsPage = await obsResp.json();
|
||
var obsItems = Array.isArray(obsPage.items) ? obsPage.items : [];
|
||
if (obsItems.length) {
|
||
page = obsPage;
|
||
items = obsItems;
|
||
}
|
||
}
|
||
}
|
||
liveSummary = page.summary || liveSummary;
|
||
liveHasMore = !!page.has_more;
|
||
if (reset === false) {
|
||
cachedLiveData = cachedLiveData.concat(items);
|
||
liveOffset += items.length;
|
||
} else {
|
||
cachedLiveData = items;
|
||
liveOffset = items.length;
|
||
}
|
||
applyFilterAndRender();
|
||
loadDashboardOverview(true);
|
||
$('liveCount').textContent = '';
|
||
// Load K-lines after DOM has fully settled
|
||
setTimeout(function() { loadAllKlines('#liveCards'); }, 150);
|
||
} catch(e) { $('liveCards').innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
|
||
finally { liveLoading = false; }
|
||
}
|
||
|
||
async function loadMoreLive() {
|
||
await loadContent(false);
|
||
}
|
||
|
||
function isLiveVisibleRec(r) {
|
||
return !isExpiredRec(r) && (r.display_bucket === 'realtime' || r.display_bucket === 'watch_pool' || r.execution_status === 'buy_now' || r.execution_status === 'wait_pullback' || r.execution_status === 'observe');
|
||
}
|
||
function isObservationRec(r) {
|
||
return isLiveVisibleRec(r) && r.display_bucket !== 'realtime' && r.execution_status !== 'buy_now';
|
||
}
|
||
function isWeakObserveRec(r){ return r && r.observe_tier === 'weak'; }
|
||
function applyFilterAndRender() {
|
||
var visible = cachedLiveData.filter(function(r){ return isLiveVisibleRec(r); });
|
||
var weakCount = visible.filter(isWeakObserveRec).length;
|
||
var filtered = visible.filter(function(r){ return !isWeakObserveRec(r); });
|
||
if (!currentFilter) {
|
||
filtered = visible.filter(function(r){ return !isWeakObserveRec(r); });
|
||
} else if (currentFilter === 'buy_now') {
|
||
filtered = visible.filter(function(r){ return (r.execution_status === 'buy_now' || r.display_bucket === 'realtime') && !isWeakObserveRec(r); });
|
||
} else if (currentFilter === 'observe') {
|
||
filtered = visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; });
|
||
} else if (currentFilter === 'weak_observe') {
|
||
filtered = visible.filter(function(r){ return r.observe_tier === 'weak'; });
|
||
}
|
||
renderLiveStats(cachedLiveData);
|
||
renderLiveCards(filtered, weakCount);
|
||
}
|
||
|
||
function setFilter(status) {
|
||
currentFilter = status || '';
|
||
applyFilterAndRender();
|
||
refreshVisibleKlines();
|
||
}
|
||
|
||
function renderLiveStats(data) {
|
||
var visible = (Array.isArray(data) ? data : []).filter(function(r){ return isLiveVisibleRec(r); });
|
||
var total = visible.length;
|
||
var buy = visible.filter(function(r){ return r.execution_status === 'buy_now' || r.display_bucket === 'realtime'; }).length;
|
||
var observeStrong = visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; }).length;
|
||
var observeWeak = visible.filter(function(r){ return r.observe_tier === 'weak'; }).length;
|
||
var allCls = 'stat-chip' + (!currentFilter ? ' filterable active' : ' filterable');
|
||
var bCls = 'stat-chip' + (currentFilter === 'buy_now' ? ' filterable active' : ' filterable');
|
||
var oCls = 'stat-chip observe-chip' + (currentFilter === 'observe' ? ' filterable active' : ' filterable');
|
||
var wCls = 'stat-chip weak-chip' + (currentFilter === 'weak_observe' ? ' filterable active' : ' filterable');
|
||
$('liveStats').innerHTML =
|
||
'<div class="stats-main">' +
|
||
'<div class="'+allCls+'" onclick="setFilter(\'\')"><span class="dot all"></span><span>全部候选</span><span class="val">'+total+'</span></div>' +
|
||
'<div class="'+bCls+'" onclick="setFilter(\'buy_now\')"><span class="dot buy"></span><span>入场窗口</span><span class="val">'+buy+'</span></div>' +
|
||
'<div class="'+oCls+'" onclick="setFilter(\'observe\')"><span class="dot obs"></span><span>重点观察</span><span class="val">'+observeStrong+'</span></div>' +
|
||
'<div class="'+wCls+'" onclick="setFilter(\'weak_observe\')"><span class="dot weak"></span><span>弱观察</span><span class="val">'+observeWeak+'</span></div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderLiveCards(data, weakCount) {
|
||
var items = Array.isArray(data) ? data : [];
|
||
if (!items.length) {
|
||
var weakOnly = weakCount ? '<div class="weak-summary"><span>当前只有 '+weakCount+' 个弱观察候选,已默认收起,避免干扰主机会流。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '';
|
||
$('liveCards').innerHTML = weakOnly || '<div class="empty-state"><p>暂无实时推荐或观察候选<br>系统持续扫描中,有机会会实时更新</p></div>'; return;
|
||
}
|
||
var order = { buy_now: 0, wait_pullback: 1, observe: 2, holding: 3, completed: 4, invalid: 9 };
|
||
items.sort(function(a,b){
|
||
var oa = (a.observe_tier === 'weak') ? 8 : (order[a.execution_status] != null ? order[a.execution_status] : 2);
|
||
var ob = (b.observe_tier === 'weak') ? 8 : (order[b.execution_status] != null ? order[b.execution_status] : 2);
|
||
if (oa !== ob) return oa - ob;
|
||
return (b.rec_score || 0) - (a.rec_score || 0);
|
||
});
|
||
var cardsHtml = items.map(function(r) { return renderRecCard(r); }).join('');
|
||
var weakHint = (!currentFilter && weakCount) ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '';
|
||
var moreHtml = liveHasMore ? '<div class="load-more-row"><button class="load-more-btn" onclick="loadMoreLive()">加载更多</button></div>' : '<div class="page-hint">已加载全部实时记录</div>';
|
||
$('liveCards').innerHTML = cardsHtml + weakHint + moreHtml;
|
||
}
|
||
|
||
function renderRecCard(r) {
|
||
var base = (r.symbol||'').replace('/USDT','');
|
||
function scoreTier(s) {
|
||
if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'};
|
||
if(s>=50) return{label:'有所异动',cls:'tier-ok'}; if(s>=35) return{label:'重点观察',cls:'tier-watch'};
|
||
if(s>=25) return{label:'弱观察',cls:'tier-weak'}; return{label:'信号不足',cls:'tier-none'};
|
||
}
|
||
function opportunityPhase(r, triggerText, sigText) {
|
||
var text = cleanDisplayText([r.execution_label, r.execution_reason, triggerText, sigText].join(' '));
|
||
if (r.execution_status === 'buy_now') return {label:'入场窗口', cls:'buy', short:'窗口'};
|
||
if (r.execution_status === 'wait_pullback' || r.lifecycle_state === 'waiting_entry') return {label:'等回踩', cls:'wait', short:'回踩'};
|
||
if (r.execution_status === 'observe' || r.display_bucket === 'watch_pool') return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||
if (/突破|breakout|上破|放量突破|突破确认/i.test(text)) return {label:'等突破', cls:'wait', short:'突破'};
|
||
if (/确认|静K|收线|站稳|量能|放量|confirm/i.test(text)) return {label:'等确认', cls:'obs', short:'确认'};
|
||
return (r.observe_tier === 'weak') ? {label:'弱观察', cls:'weak', short:'弱观察'} : {label:'观察中', cls:'obs', short:'观察'};
|
||
}
|
||
var ep = r.entry_plan || {};
|
||
var sigs = Array.isArray(r.signals)?r.signals:[];
|
||
var entryMethod = ep.entry_method || '';
|
||
var signalText = sigs.join(' ');
|
||
var phase = opportunityPhase(r, entryMethod, signalText);
|
||
var isBuy = r.execution_status === 'buy_now' || r.display_bucket === 'realtime', isWait = phase.label === '等回踩' || r.lifecycle_state === 'waiting_entry', isWeakObserve = r.observe_tier === 'weak', isObs = r.display_bucket === 'watch_pool' || r.execution_status !== 'buy_now';
|
||
var isExecuted = !!r.entry_triggered || r.display_bucket === 'position' || r.execution_status === 'holding' || r.execution_status === 'completed';
|
||
var isTradePlan = isBuy || isWait || isExecuted || r.entry_triggered;
|
||
|
||
// ---- Action badge with expiry/surge detection ----
|
||
var recMs = r.rec_time ? new Date(r.rec_time).getTime() : 0;
|
||
var ageHours = recMs ? (Date.now() - recMs) / 3600000 : 0;
|
||
var chgSinceRec = r.current_price && r.entry_price && r.entry_price > 0 ? ((r.current_price - r.entry_price) / r.entry_price * 100) : 0;
|
||
var isOld = ageHours > 12;
|
||
var hasSurged = chgSinceRec > 3;
|
||
var actionBadge = '';
|
||
if (isBuy && isOld && hasSurged) {
|
||
actionBadge = '<span class="action-badge caution">追高风险(+'+chgSinceRec.toFixed(1)+'%)</span>';
|
||
} else if (isBuy && isOld) {
|
||
actionBadge = '<span class="action-badge warn">信号偏弱</span>';
|
||
} else if (!isOld && hasSurged && isBuy) {
|
||
actionBadge = '<span class="action-badge caution">追高风险(+'+chgSinceRec.toFixed(1)+'%)</span>';
|
||
} else if (!isOld && (r.rec_score||0) < 50 && isBuy) {
|
||
actionBadge = '<span class="action-badge warn">信号偏弱</span>';
|
||
} else if (isBuy) {
|
||
actionBadge = '<span class="action-badge buy">入场窗口</span>';
|
||
} else {
|
||
actionBadge = '<span class="action-badge '+phase.cls+'">'+phase.label+'</span>';
|
||
if (isWeakObserve) actionBadge = '<span class="action-badge weak">弱观察</span>';
|
||
}
|
||
|
||
var ePrice = r.entry_price||'';
|
||
var sl = (r.stop_loss&&r.stop_loss>0) ? r.stop_loss : '';
|
||
var tp = (r.tp1&&r.tp1>0) ? r.tp1 : '';
|
||
// 实时看板是机会雷达,不是成交记录。只有“入场窗口”才在 K 线上标记触发价;
|
||
// 等确认/观察中/等回踩都尚未入场,不能显示蓝色入场价格 marker。
|
||
var klineEntryPrice = isBuy ? ePrice : '';
|
||
var klineStopLoss = isBuy ? sl : '';
|
||
var klineTp1 = isBuy ? tp : '';
|
||
var entryTime = isBuy ? (r.rec_time||'') : '';
|
||
var tp1EventTime = (r.status==='hit_tp1'||r.status==='hit_tp2') ? (r.hit_tp1_time||'') : '';
|
||
var slEventTime = (r.status==='stopped_out') ? (r.stopped_out_time||'') : '';
|
||
var isTpOrSl = r.status==='hit_tp1'||r.status==='hit_tp2'||r.status==='stopped_out';
|
||
var price = r.current_price||r.entry_price||0;
|
||
function fmtP(p) { return fmtPrice(p, priceDecimals(price || p)); }
|
||
var pnl = r.pnl_pct||0, pnlCls = pnl>0?'pos':pnl<0?'neg':'zero', pnlSign = pnl>0?'+':'';
|
||
var priceFmt = fmtPrice(price);
|
||
function displaySignalText(s) {
|
||
var text = cleanDisplayText(s);
|
||
if (!isBuy) {
|
||
text = text
|
||
.replace(/15min\s*入场窗口信号/g, '15min触发信号')
|
||
.replace(/入场窗口信号/g, '触发信号')
|
||
.replace(/入场窗口确认/g, '触发确认');
|
||
}
|
||
return text;
|
||
}
|
||
var sigHtml = sigs.slice(0,2).map(function(s){
|
||
var cls = 'info'; if(/量价齐飞|起爆点|放量/.test(s)) cls='strong';
|
||
else if(/静K|筑底|回踩|突破|蓄力|底部抬高|压缩/.test(s)) cls='forward';
|
||
else if(/动K|PA|转折/.test(s)) cls='pa'; else if(/衰减|空头|风险|背离|闸门/.test(s)) cls='warn';
|
||
return '<span class="sig '+cls+'">'+displaySignalText(s)+'</span>';
|
||
}).join('');
|
||
var score = r.rec_score||0, st = scoreTier(score), ver = r.strategy_version||'';
|
||
var hasQualityGate = ep.entry_quality_gate && Array.isArray(ep.entry_quality_gate.reasons) && ep.entry_quality_gate.reasons.length;
|
||
var entryLabel = isWait ? '回踩参考' : (hasQualityGate ? '失效参考' : '参考价位');
|
||
// 等回踩/观察池卡片的参考价应优先使用 entry_plan 中的计划价,不能用 tracker 重置后的真实触发价,
|
||
// 否则会出现“回踩参考价=现价”的误导展示。入场窗口则仍以 DB 主链路 entry_price 为准。
|
||
var entryRef = (isWait || hasQualityGate) ? (ep.entry_price || r.entry_price || 0) : (r.entry_price || ep.entry_price || 0);
|
||
var changeRef = entryRef || r.entry_price || 0;
|
||
var changeLabel = isExecuted ? '持仓盈亏' : (isWait ? '较回踩参考' : (isBuy ? '较触发价' : '较参考价'));
|
||
var changePct = price && changeRef ? ((price - changeRef) / changeRef * 100) : null;
|
||
var changeCls = changePct!=null?(changePct>0?'up':changePct<0?'down':'zero'):'zero';
|
||
var changeSign = changePct!=null&&changePct>0?'+':'';
|
||
var changeHtml = changePct!=null ? '<span class="price-change '+changeCls+'" title="当前价相对'+changeLabel+',不是24h涨跌"><span class="pc-label">'+changeLabel+'</span><span class="pc-value">'+changeSign+changePct.toFixed(1)+'%</span></span>' : '';
|
||
var riskLine = ep.stop_loss || r.stop_loss || 0;
|
||
var spaceRef = ep.tp1 || r.tp1 || 0;
|
||
var upsidePct = entryRef && spaceRef ? ((spaceRef / entryRef - 1) * 100) : 0;
|
||
function entryWindowSummary() {
|
||
var w = r.entry_window || {};
|
||
if (!isBuy || !w.status) return '';
|
||
var mins = Number(w.remaining_minutes || 0);
|
||
var remain = mins >= 60 ? (Math.floor(mins/60)+'h'+Math.round(mins%60)+'m') : (Math.max(0, Math.round(mins))+'m');
|
||
var dev = Number(w.deviation_pct || 0);
|
||
var devText = (dev>0?'+':'') + dev.toFixed(2) + '%';
|
||
return '剩余 '+remain+' · 偏离 '+devText;
|
||
}
|
||
var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
|
||
var decisionCls = isBuy ? 'buy' : (isWait ? 'wait' : (isWeakObserve ? 'weak' : 'observe'));
|
||
var decisionTitle = cleanDisplayText(r.execution_label || phase.label);
|
||
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('等 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
|
||
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || '入场窗口有效') : (isWait ? '现价不追,等回踩价附近再评估' : (r.observe_reason || r.state_reason || '未形成入场窗口')));
|
||
var decisionHtml = '<div class="decision-strip '+decisionCls+'"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">'+decisionTitle+'</span></div><div class="decision-body"><span class="decision-focus">'+decisionFocus+'</span><span class="decision-reason">'+decisionReason+'</span></div></div>';
|
||
var aiInsightHtml = '';
|
||
var aiInsight = r.llm_insight && r.llm_insight.content ? r.llm_insight.content : null;
|
||
function hasAiText(v) {
|
||
if (Array.isArray(v)) return v.some(function(x){ return cleanDisplayText(x).replace(/^-+$/,'').trim(); });
|
||
return !!cleanDisplayText(v).replace(/^-+$/,'').trim();
|
||
}
|
||
if (aiInsight && (
|
||
hasAiText(aiInsight.summary) ||
|
||
hasAiText(aiInsight.why_now_or_not) ||
|
||
hasAiText(aiInsight.key_evidence) ||
|
||
hasAiText(aiInsight.risk_flags) ||
|
||
hasAiText(aiInsight.watch_points) ||
|
||
hasAiText(aiInsight.invalid_if)
|
||
)) {
|
||
var evidenceHtml = (aiInsight.key_evidence || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||
var riskHtml = (aiInsight.risk_flags || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||
var watchHtml = (aiInsight.watch_points || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||
var invalidHtml = (aiInsight.invalid_if || []).slice(0, 4).map(function(x){ return '<span class="ai-pill">'+cleanDisplayText(x)+'</span>'; }).join('');
|
||
aiInsightHtml =
|
||
'<details class="ai-insight">'+
|
||
'<summary><span>AI 解读</span><span class="ai-tag">缓存</span></summary>'+
|
||
'<div class="ai-body">'+
|
||
'<div class="ai-summary">'+cleanDisplayText(aiInsight.summary || aiInsight.why_now_or_not || '暂无摘要')+'</div>'+
|
||
'<div class="ai-grid">'+
|
||
'<div class="ai-item"><div class="ai-label">为什么现在 / 为什么不现在</div><div class="ai-text">'+cleanDisplayText(aiInsight.why_now_or_not || '--')+'</div></div>'+
|
||
'<div class="ai-item"><div class="ai-label">关键证据</div><div class="ai-list">'+(evidenceHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||
'<div class="ai-item"><div class="ai-label">风险提示</div><div class="ai-list">'+(riskHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||
'<div class="ai-item"><div class="ai-label">观察点</div><div class="ai-list">'+(watchHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||
'</div>'+
|
||
'<div class="ai-item"><div class="ai-label">失效条件</div><div class="ai-list">'+(invalidHtml || '<span class="ai-pill">--</span>')+'</div></div>'+
|
||
'</div>'+
|
||
'</details>';
|
||
}
|
||
var entryPlanHtml = '';
|
||
if (isTradePlan) {
|
||
entryPlanHtml = '<div class="entry-plan">' +
|
||
'<div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">机会所处阶段</span></div>'+
|
||
'<div class="ep-item"><span class="ep-label">'+entryLabel+'</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">触发/计划价</span></div>'+
|
||
'<div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">跌破则逻辑失效</span></div>'+
|
||
'<div class="ep-item"><span class="ep-label">上方空间</span><span class="ep-val space-ref">'+(upsidePct?('+'+upsidePct.toFixed(1)+'%'):'--')+'</span><span class="ep-sub">参考位 '+fmtP(spaceRef)+'</span></div>'+
|
||
'</div>';
|
||
} else {
|
||
entryPlanHtml = '<div class="entry-plan">' +
|
||
'<div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">'+phase.short+'</span><span class="ep-sub">观察池候选</span></div>'+
|
||
'<div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+fmtP(price)+'</span><span class="ep-sub">不是入场价</span></div>'+
|
||
'<div class="ep-item"><span class="ep-label">确认条件</span><span class="ep-val space-ref">待触发</span><span class="ep-sub">需15m/1H当前信号</span></div>'+
|
||
'<div class="ep-item"><span class="ep-label">绩效口径</span><span class="ep-val risk-line">不计入</span><span class="ep-sub">未成交易推荐</span></div>'+
|
||
'</div>';
|
||
}
|
||
return '<div class="card '+(isWeakObserve?'weak-observe':'')+'"><div class="card-bar"><div class="coin-left"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span></div></div><div class="badge-group">'+actionBadge+'<span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">'+st.label+'</span></span></div></div>'+
|
||
'<div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+
|
||
decisionHtml+
|
||
aiInsightHtml+
|
||
'<div class="kline-wrap"><div class="kline-int-bar"><button class="kline-int-btn" data-int="15m" onclick="switchKlineInterval(this);event.stopPropagation()">15m</button><button class="kline-int-btn active" data-int="1h" onclick="switchKlineInterval(this);event.stopPropagation()">1H</button><button class="kline-int-btn" data-int="4h" onclick="switchKlineInterval(this);event.stopPropagation()">4H</button><button class="kline-int-btn" data-int="1d" onclick="switchKlineInterval(this);event.stopPropagation()">1D</button></div><div class="kline-container loading" data-symbol="'+r.symbol+'" data-entry-price="'+klineEntryPrice+'" data-stop-loss="'+klineStopLoss+'" data-tp1="'+klineTp1+'" data-rec-time="'+entryTime+'" data-tp1-time="'+tp1EventTime+'" data-sl-time="'+slEventTime+'" data-ref-price="'+price+'" data-action-status="'+(r.action_status||'')+'"><div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div></div></div>'+
|
||
(isWeakObserve ? weakNoteHtml : entryPlanHtml)+
|
||
(sigHtml?'<div class="signals-row">'+sigHtml+'</div>':'')+
|
||
'<div class="card-footer"><span>'+fmtTime(r.rec_time)+'</span><span class="card-ver">'+ver+'</span>'+(isTpOrSl?'<span class="pnl-block '+pnlCls+'">'+pnlSign+pnl.toFixed(1)+'%</span>':'')+'</div></div>';
|
||
}
|
||
|
||
// ====== KLINE ======
|
||
function loadOneKline(container) {
|
||
if (!container || container.dataset.klineLoading === '1') return Promise.resolve();
|
||
if (container.dataset.klineLoaded === '1') return Promise.resolve();
|
||
container.dataset.klineLoading = '1';
|
||
var symbol = container.dataset.symbol;
|
||
var interval = (container.closest('.kline-wrap')||container).querySelector('.kline-int-btn.active');
|
||
interval = interval ? interval.dataset.int : '1h';
|
||
return fetch(API+'/api/kline?symbol='+encodeURIComponent(symbol)+'&interval='+interval+'&limit=60')
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(resp){
|
||
var candles = (resp&&resp.candles) ? resp.candles : [];
|
||
var entryPrice = Number(container.dataset.entryPrice||0);
|
||
var stopLoss = Number(container.dataset.stopLoss||0);
|
||
var tp1 = Number(container.dataset.tp1||0);
|
||
var recTime = container.dataset.recTime||'';
|
||
var tp1Time = container.dataset.tp1Time||'';
|
||
var slTime = container.dataset.slTime||'';
|
||
var actionStatus = container.dataset.actionStatus || container.dataset.status || '';
|
||
var refPrice = Number(container.dataset.refPrice || entryPrice || (candles[0] && candles[0].close) || 0);
|
||
container.innerHTML = renderKlineChart(symbol, candles, entryPrice, stopLoss, tp1, recTime, tp1Time, slTime, actionStatus, refPrice);
|
||
container.classList.remove('loading');
|
||
container.dataset.klineLoaded = '1';
|
||
delete container.dataset.klineLoading;
|
||
}).catch(function(){
|
||
container.innerHTML = '<div class="chart-loading">K线加载失败</div>';
|
||
container.classList.remove('loading');
|
||
delete container.dataset.klineLoading;
|
||
});
|
||
}
|
||
var klineObserver = null;
|
||
function getKlineObserver() {
|
||
if (!('IntersectionObserver' in window)) return null;
|
||
if (klineObserver) return klineObserver;
|
||
klineObserver = new IntersectionObserver(function(entries){
|
||
entries.forEach(function(entry){
|
||
if (entry.isIntersecting) {
|
||
klineObserver.unobserve(entry.target);
|
||
loadOneKline(entry.target);
|
||
}
|
||
});
|
||
}, { root: null, rootMargin: '240px 0px', threshold: 0.01 });
|
||
return klineObserver;
|
||
}
|
||
function loadAllKlines(selector) {
|
||
var containers = document.querySelectorAll((selector||'') + ' .kline-container');
|
||
var observer = getKlineObserver();
|
||
for(var i=0; i<containers.length; i++) {
|
||
if (containers[i].dataset.klineLoaded === '1') continue;
|
||
if (observer) observer.observe(containers[i]);
|
||
else if (i < 6) loadOneKline(containers[i]);
|
||
}
|
||
}
|
||
function resetKlineContainers(selector) {
|
||
var containers = document.querySelectorAll((selector||'') + ' .kline-container');
|
||
for(var i=0; i<containers.length; i++) {
|
||
if (klineObserver) klineObserver.unobserve(containers[i]);
|
||
delete containers[i].dataset.klineLoaded;
|
||
delete containers[i].dataset.klineLoading;
|
||
containers[i].classList.add('loading');
|
||
containers[i].innerHTML = '<div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div>';
|
||
}
|
||
}
|
||
function refreshVisibleKlines() {
|
||
var selector = curTab === 'history' ? '#historyCards' : '#liveCards';
|
||
resetKlineContainers(selector);
|
||
setTimeout(function() { loadAllKlines(selector); }, 180);
|
||
}
|
||
function switchKlineInterval(btn) {
|
||
var wrap = btn.closest('.kline-wrap');
|
||
wrap.querySelectorAll('.kline-int-btn').forEach(function(b){ b.classList.remove('active'); });
|
||
btn.classList.add('active');
|
||
var container = wrap.querySelector('.kline-container');
|
||
if (klineObserver) klineObserver.unobserve(container);
|
||
delete container.dataset.klineLoaded;
|
||
delete container.dataset.klineLoading;
|
||
container.classList.add('loading');
|
||
container.innerHTML = '<div class="chart-loading"><svg class="spin" width="16" height="16" color="#8e91a0"><use href="#svg-spinner"/></svg></div>';
|
||
loadOneKline(container);
|
||
}
|
||
|
||
function renderKlineChart(symbol, candles, entryPrice, stopLoss, tp1, recTime, tp1Time, slTime, actionStatus, refPrice) {
|
||
if(!candles||candles.length<5) return '<div class="chart-loading">K线数据不足</div>';
|
||
var recPrice=Number(entryPrice||0), slPrice=Number(stopLoss||0), tpPrice=Number(tp1||0);
|
||
var isWait = (actionStatus === '等回踩');
|
||
var W=360,H=200,volH=28,padL=2,padR=2,padT=8,padB=18,chartW=W-padL-padR,chartH=H-padT-padB-volH;
|
||
var data=candles.slice(-60),n=data.length;
|
||
var candleW=Math.max(1.5,(chartW/n)*0.75),gap=Math.max(0.5,(chartW/n)-candleW);
|
||
var minPrice=Infinity,maxPrice=-Infinity,maxVol=0;
|
||
data.forEach(function(c){minPrice=Math.min(minPrice,c.low);maxPrice=Math.max(maxPrice,c.high);maxVol=Math.max(maxVol,c.volume||0);});
|
||
if(recPrice>0){minPrice=Math.min(minPrice,recPrice);maxPrice=Math.max(maxPrice,recPrice);}
|
||
if(slPrice>0){minPrice=Math.min(minPrice,slPrice);maxPrice=Math.max(maxPrice,slPrice);}
|
||
if(tpPrice>0){minPrice=Math.min(minPrice,tpPrice);maxPrice=Math.max(maxPrice,tpPrice);}
|
||
var priceRange=maxPrice-minPrice||1,volMaxH=volH-2;
|
||
var decimals = priceDecimals(refPrice || recPrice || maxPrice || minPrice);
|
||
var pt=function(p){return padT+chartH-(p-minPrice)/priceRange*chartH;};
|
||
function cx(i){return padL+i*(candleW+gap)+candleW/2;}
|
||
|
||
var svg='<svg viewBox="0 0 '+W+' '+H+'" width="100%" style="display:block"><style>.c-up{fill:#00b473;stroke:#00b473}.c-down{fill:#e53e3e;stroke:#e53e3e}.c-wick{stroke-width:1}.v-up{fill:rgba(0,180,115,.2)}.v-down{fill:rgba(229,62,62,.15)}.grid{stroke:#eef0f3;stroke-width:0.5}.label{font:8px sans-serif;fill:#a5a8b5}.htag{font:8px sans-serif}.event-marker{font:9px sans-serif;font-weight:700}</style>';
|
||
|
||
for(var i=0;i<=4;i++){var y=padT+chartH*i/4;svg+='<line class="grid" x1="'+padL+'" y1="'+y.toFixed(1)+'" x2="'+(W-padR)+'" y2="'+y.toFixed(1)+'"/><text class="label" x="'+(W-padR)+'" y="'+(y+10)+'" text-anchor="end">'+fmtPrice(maxPrice-priceRange*i/4, decimals)+'</text>';}
|
||
|
||
data.forEach(function(c,i){
|
||
var x=padL+i*(candleW+gap),open=pt(c.open),close=pt(c.close),high=pt(c.high),low=pt(c.low);
|
||
var isUp=c.close>=c.open,cls=isUp?'c-up':'c-down',vCls=isUp?'v-up':'v-down';
|
||
svg+='<line class="'+cls+' c-wick" x1="'+(x+candleW/2)+'" y1="'+high+'" x2="'+(x+candleW/2)+'" y2="'+low+'"/>';
|
||
svg+='<rect class="'+cls+'" x="'+x+'" y="'+Math.min(open,close)+'" width="'+candleW+'" height="'+Math.max(1,Math.abs(close-open))+'\"/>';
|
||
var vh=Math.max(1,(c.volume||0)/maxVol*volMaxH);
|
||
svg+='<rect class="'+vCls+'" x="'+x+'" y="'+(H-padB-vh)+'" width="'+candleW+'" height="'+vh+'"/>';
|
||
});
|
||
|
||
function markerAt(eventTime,label,color,price){
|
||
if(!eventTime) return;
|
||
var et=new Date(eventTime).getTime(),bestIdx=-1,bestDiff=Infinity;
|
||
data.forEach(function(c,i){var diff=Math.abs(c.time-et);if(diff<bestDiff){bestDiff=diff;bestIdx=i;}});
|
||
if(bestIdx<0||bestIdx>=n) return;
|
||
var mx=cx(bestIdx),candleLow=pt(data[bestIdx].low);
|
||
var my=H-padB-volH+12;
|
||
svg+='<line stroke="'+color+'" stroke-width="0.8" stroke-dasharray="2,2" x1="'+mx+'" y1="'+(candleLow+2)+'" x2="'+mx+'" y2="'+my+'"/>';
|
||
svg+='<text class="event-marker" fill="'+color+'" x="'+mx+'" y="'+(my+10)+'" text-anchor="middle">'+label+'</text>';
|
||
if(price>0) svg+='<text class="htag" fill="'+color+'" x="'+mx+'" y="'+(my+22)+'" text-anchor="middle">$'+fmtPrice(price, decimals)+'</text>';
|
||
}
|
||
if(recTime) {
|
||
if(isWait) {
|
||
markerAt(recTime,'\u25B3','#a5a8b5',0);
|
||
} else {
|
||
markerAt(recTime,'\u25B2','#4262ff',recPrice);
|
||
}
|
||
}
|
||
if(tp1Time) markerAt(tp1Time,'\u2713','#00b473',tpPrice);
|
||
if(slTime) markerAt(slTime,'\u2717','#e53e3e',slPrice);
|
||
|
||
svg+='</svg>';
|
||
return svg;
|
||
}
|
||
|
||
|
||
// ====== HISTORY ======
|
||
function historyOutcome(r) {
|
||
var status = (r && r.status) || '';
|
||
var pnl = Number((r && r.pnl_pct) || 0);
|
||
var maxPnl = Number((r && r.max_pnl_pct) || 0);
|
||
var maxDd = Number((r && r.max_drawdown_pct) || 0);
|
||
var hitSuccess = status === 'hit_tp1' || status === 'hit_tp2' || maxPnl >= 5;
|
||
var hitFailure = status === 'stopped_out' || pnl <= -3 || maxDd <= -5;
|
||
if (hitSuccess) {
|
||
return { resolved: true, type: 'success', pnl: maxPnl || pnl, label: '阶段兑现' };
|
||
}
|
||
if (hitFailure) {
|
||
return { resolved: true, type: 'failure', pnl: pnl, label: '风险边界' };
|
||
}
|
||
return { resolved: false, type: 'pending', pnl: pnl, label: '跟踪中' };
|
||
}
|
||
function isResolvedHistory(r) {
|
||
return historyOutcome(r).resolved;
|
||
}
|
||
async function loadHistoryRecommendations(reset) {
|
||
if (historyLoading) return;
|
||
if (reset) {
|
||
historyItems = [];
|
||
historyOffset = 0;
|
||
historyHasMore = false;
|
||
}
|
||
historyLoading = true;
|
||
try {
|
||
var offset = reset ? 0 : historyOffset;
|
||
var pageSize = historyLimit;
|
||
var url = API+'/api/recommendations?limit='+pageSize+'&offset='+offset+'&decision_only=true&compact=true';
|
||
if (currentVersion) url += '&version=' + encodeURIComponent(currentVersion);
|
||
var resp = await fetch(url);
|
||
var page = await resp.json();
|
||
var summary = page.summary || {};
|
||
var totalCount = Number(page.total || summary.total || 0);
|
||
var successCount = Number(summary.success_count || 0);
|
||
var failureCount = Number(summary.failure_count || 0);
|
||
var totalPnl = Number(summary.total_pnl || 0);
|
||
var bestPnl = Number(summary.best_pnl || 0);
|
||
var avgSl = Number(summary.avg_failure_pnl || 0);
|
||
$('histCount').textContent = totalCount ? ' ' + totalCount : '';
|
||
$('historyStats').innerHTML =
|
||
'<div class="hstat"><div class="num" style="color:var(--green)">'+successCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-target"/></svg> 已兑现样本</div><div class="sub">全部历史</div></div>'+
|
||
'<div class="hstat"><div class="num" style="color:'+(totalPnl>=0?'var(--green)':'var(--red)')+'">'+(totalPnl>=0?'+':'')+totalPnl.toFixed(1)+'%</div><div class="lbl"><svg width="14" height="14" color="'+(totalPnl>=0?'var(--green)':'var(--red)')+'"><use href="#svg-trendup"/></svg> 累计表现</div><div class="sub">全部历史</div></div>'+
|
||
'<div class="hstat"><div class="num" style="color:var(--green)">+'+bestPnl.toFixed(1)+'%</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-star"/></svg> 最大单笔表现</div><div class="sub">全部历史</div></div>'+
|
||
'<div class="hstat"><div class="num" style="color:var(--red)">'+avgSl.toFixed(1)+'%</div><div class="lbl"><svg width="14" height="14" color="var(--red)"><use href="#svg-shield"/></svg> 风险边界失效</div><div class="sub">'+failureCount+'次 · 平均</div></div>';
|
||
var items = Array.isArray(page.items) ? page.items : [];
|
||
var completed = items.filter(isResolvedHistory);
|
||
if (reset) {
|
||
historyItems = completed;
|
||
historyOffset = completed.length;
|
||
} else {
|
||
historyItems = historyItems.concat(completed);
|
||
historyOffset += completed.length;
|
||
}
|
||
historyHasMore = !!page.has_more;
|
||
if(!historyItems.length){ $('historyCards').innerHTML='<div class="empty-state"><p>暂无已完成交易记录<br>机会完成兑现或风险边界失效后会出现在这里</p></div>'; return; }
|
||
var cardsHtml = historyItems.map(function(r,idx) {
|
||
var base = (r.symbol||'').replace('/USDT',''), outcome = historyOutcome(r), pnl = outcome.pnl, win = pnl>0;
|
||
var pnlSign = pnl>0?'+':'', pnlCls = win?'pos':pnl<0?'neg':'zero';
|
||
var exitP = (r.status==='stopped_out'||Number(r.pnl_pct||0)<0) ? (r.current_price||r.stop_loss||0) : (r.tp1||r.current_price||0);
|
||
var entryP = r.entry_price||0;
|
||
function fmtN(n) { return fmtPrice(n, priceDecimals(r.current_price || entryP || exitP || n)); }
|
||
var statusLabel = outcome.type === 'failure' ? '风险边界失效' : '阶段兑现';
|
||
var isWin = Number(pnl || 0) > 0;
|
||
var resultCls = isWin ? 'win' : 'loss';
|
||
var maxPnl = Number(r.max_pnl_pct || pnl || 0);
|
||
var maxPnlSign = maxPnl > 0 ? '+' : '';
|
||
var maxPnlCls = maxPnl > 0 ? 'win' : (maxPnl < 0 ? 'loss' : '');
|
||
var maxDd = Number(r.max_drawdown_pct || 0);
|
||
var exitMode = outcome.type === 'failure' ? '风险边界' : ((r.action_status==='跟踪止盈') ? '跟踪止盈' : '阶段兑现');
|
||
var isRiskExit = outcome.type === 'failure';
|
||
var hEntryTime = r.rec_time||'', hTpTime = (!isRiskExit && (r.status==='hit_tp1'||r.status==='hit_tp2'||Number(r.max_pnl_pct||0)>=5))?(r.hit_tp1_time||r.last_track_time||''):'';
|
||
var hSlTime = isRiskExit ? (r.stopped_out_time||r.last_track_time||r.expired_time||'') : '';
|
||
var hEntryPrice = r.entry_price||0, hSl = isRiskExit ? exitP : (r.stop_loss||0), hTp = r.tp1||0, hid = 'hkline'+idx;
|
||
var score = r.rec_score||0;
|
||
function scoreTier(s) {
|
||
if(s>=80) return{label:'强势异动',cls:'tier-strong'}; if(s>=65) return{label:'值得关注',cls:'tier-good'};
|
||
if(s>=50) return{label:'有所异动',cls:'tier-ok'}; if(s>=35) return{label:'重点观察',cls:'tier-watch'};
|
||
if(s>=25) return{label:'弱观察',cls:'tier-weak'}; return{label:'信号不足',cls:'tier-none'};
|
||
}
|
||
var st = scoreTier(score);
|
||
var scoreCls = score < 25 ? 'danger' : (score < 50 ? 'weak' : '');
|
||
var sigs = Array.isArray(r.signals)?r.signals:[];
|
||
var sigHtml = sigs.slice(0,4).map(function(s){ return '<span class=\"sig info\">'+cleanDisplayText(s).replace(/^(\\d+H|\\d+m|日线|周线)\\s*/,'').slice(0,12)+'</span>'; }).join('');
|
||
var duration = daysBetween(r.rec_time, r.last_track_time||r.hit_tp1_time||r.stopped_out_time);
|
||
return '<div class=\"card\">'+
|
||
'<div class=\"card-bar\"><div class=\"coin-left\"><div class=\"coin-icon\">'+base.slice(0,2).toUpperCase()+'</div><div><span class=\"coin-symbol\">'+base+'</span></div></div><span class=\"hist-pnl-badge '+pnlCls+'\"><span class=\"pnl-num\">'+pnlSign+pnl.toFixed(1)+'</span><span class=\"pnl-unit\">%</span></span></div>'+
|
||
'<div class=\"h-pnl-row\"><span class=\"price h-entry-price\">$'+fmtN(entryP)+'</span><span class=\"h-arrow '+resultCls+'\">→</span><span class=\"price h-exit-price '+resultCls+'\">$'+fmtN(exitP)+'</span><span class=\"hist-score-pill '+scoreCls+'\">评分 '+score+' · '+st.label+'</span><span class=\"h-duration\">'+duration+'</span></div>'+
|
||
'<div class=\"hist-metric-row\"><div class=\"hist-metric\"><span class=\"hm-label\">最大表现</span><span class=\"hm-val '+maxPnlCls+'\">'+maxPnlSign+maxPnl.toFixed(1)+'%</span></div><div class=\"hist-metric\"><span class=\"hm-label\">最大回撤</span><span class=\"hm-val loss\">'+maxDd.toFixed(1)+'%</span></div><div class=\"hist-metric\"><span class=\"hm-label\">退出方式</span><span class=\"hm-val blue\">'+exitMode+'</span></div></div>'+
|
||
'<div class=\"kline-wrap\" id=\"wrap_'+hid+'\"><div class=\"kline-int-bar\"><button class=\"kline-int-btn\" data-int=\"15m\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">15m</button><button class=\"kline-int-btn active\" data-int=\"1h\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">1H</button><button class=\"kline-int-btn\" data-int=\"4h\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">4H</button><button class=\"kline-int-btn\" data-int=\"1d\" onclick=\"switchKlineInterval(this);event.stopPropagation()\">1D</button></div><div class=\"kline-container loading\" id=\"'+hid+'\" data-symbol=\"'+(r.symbol||'')+'\" data-entry-price=\"'+hEntryPrice+'\" data-stop-loss=\"'+hSl+'\" data-tp1=\"'+hTp+'\" data-rec-time=\"'+hEntryTime+'\" data-tp1-time=\"'+hTpTime+'\" data-sl-time=\"'+hSlTime+'\" data-ref-price=\"'+(r.current_price||hEntryPrice||hTp||hSl||0)+'\" data-status=\"'+(r.status||'')+'\" ><div class=\"chart-loading\"><svg class=\"spin\" width=\"16\" height=\"16\" color=\"#8e91a0\"><use href=\"#svg-spinner\"/></svg></div></div></div>'+
|
||
(sigHtml?'<div class=\"signals-row\">'+sigHtml+'</div>':'')+
|
||
'<div class=\"card-footer hist-footer\"><span>'+fmtTime(r.rec_time)+'</span><span class=\"card-ver\">'+(r.strategy_version||'')+'</span></div></div>';
|
||
}).join('');
|
||
var loadMoreHtml = historyHasMore ? '<div class="history-load-more"><button class="load-more-btn" id="historyLoadMoreBtn" onclick="loadMoreHistory()">加载更多</button></div>' : '<div class="history-page-hint">已加载全部历史记录</div>';
|
||
$('historyCards').innerHTML = cardsHtml + loadMoreHtml;
|
||
// Auto-load visible history K-lines only
|
||
setTimeout(function() { loadAllKlines('#historyCards'); }, 200);
|
||
} catch(e) { $('historyCards').innerHTML = '<div class="empty-state"><p>加载失败</p></div>'; }
|
||
finally { historyLoading = false; }
|
||
}
|
||
|
||
async function loadMoreHistory() {
|
||
await loadHistoryRecommendations(false);
|
||
}
|
||
|
||
var histKlineLoaded = {};
|
||
function toggleHistKline(hid, symbol, ePrice, sl, tp1, recTime, tp1Time, slTime) {
|
||
var wrap = document.getElementById('wrap_'+hid);
|
||
var container = document.getElementById(hid);
|
||
if(!wrap || !container) return;
|
||
if(wrap.style.display === 'none' || wrap.style.display === '') {
|
||
wrap.style.display = 'block';
|
||
if(!histKlineLoaded[hid]) {
|
||
loadOneKline(container);
|
||
histKlineLoaded[hid] = true;
|
||
}
|
||
} else {
|
||
wrap.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
// Init
|
||
loadVersions();
|
||
</script>
|
||
{% endblock %}
|