alphax/static/app.html
2026-05-14 01:20:47 +08:00

1042 lines
74 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

{% extends "base.html" %}
{% block title %}AlphaX / Omnix — 看板{% 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; }
/* ===== 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; }
.trigger-cause { margin: 0 18px 8px; padding: 8px 10px; border: 1px solid rgba(66,98,255,.12); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); display: flex; align-items: center; gap: 8px; min-width: 0; }
.trigger-cause .tc-label { flex-shrink: 0; color: var(--blue); font-size: 10px; font-weight: 900; line-height: 1.2; }
.trigger-cause .tc-value { color: var(--slate); font-size: 12px; font-weight: 700; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.signal-context { display: flex; flex-direction: column; gap: 6px; padding: 0 18px 8px; }
.signal-context .trigger-cause,
.signal-context .trigger-meta { margin: 0; }
.trigger-meta { padding: 8px 10px; border-radius: var(--radius-lg); border: 1px solid var(--hairline-soft); background: var(--surface); font-size: 12px; color: var(--stone); display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.trigger-meta span { font-size: 10px; font-weight: 900; color: var(--stone); line-height: 1.2; }
.trigger-meta small { font-size: 11px; color: var(--stone); line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.trigger-meta.current { border-color: rgba(0,180,115,.18); background: rgba(0,180,115,.045); }
.trigger-meta.current span { color: var(--green); }
.trigger-meta.event { border-color: rgba(66,98,255,.16); background: rgba(66,98,255,.04); }
.trigger-meta.event span { color: var(--blue); }
.trigger-meta.stale { border-color: var(--hairline-soft); background: var(--surface); }
.trigger-meta.stale span { color: var(--muted); }
.trust-row { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; padding: 0 18px 10px; }
.trust-pill { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 8px 10px; min-width: 0; }
.trust-pill .trust-label { display: block; font-size: 10px; color: var(--stone); font-weight: 700; text-transform: uppercase; margin-bottom: 3px; }
.trust-pill .trust-value { display: block; font-size: 13px; color: var(--ink); font-weight: 800; line-height: 1.25; }
.trust-pill .trust-sub { display: block; font-size: 10px; color: var(--stone); margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.trust-pill.window-active { background: var(--green-light); border-color: rgba(0,180,115,.18); }
.trust-pill.window-active .trust-value { color: var(--green); }
.trust-pill.window-warn { background: var(--yellow-light); border-color: rgba(252,185,0,.24); }
.trust-pill.window-warn .trust-value { color: var(--yellow-dark); }
.trust-pill.window-danger { background: var(--red-light); border-color: rgba(229,62,62,.20); }
.trust-pill.window-danger .trust-value { color: var(--red); }
.trust-pill.risk { background: rgba(66,98,255,.06); border-color: rgba(66,98,255,.12); }
.trust-pill.risk .trust-value { color: var(--blue); }
/* ===== SIGNALS ===== */
.signals-row { display: flex; flex-wrap: wrap; gap: 4px; padding: 0 18px 8px; }
.sig { font-size: 11px; padding: 3px 8px; border-radius: var(--radius-full); font-weight: 700; white-space: nowrap; line-height: 1.3; }
.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; }
.trigger-cause { margin: 0 14px 8px; align-items: flex-start; }
.trigger-cause .tc-value { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.signal-context { padding: 0 14px 8px; }
.signal-context .trigger-cause { margin: 0; }
.trigger-meta small { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.trust-row { grid-template-columns: 1fr; padding: 0 14px 8px; }
}
@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 ({'&':'&amp;','<':'&lt;','>':'&gt;',"'":'&#39;','"':'&quot;'}[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 (/回踩|pullback/i.test(text)) return {label:'等回踩', cls:'wait', 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);
var sigHtml = sigs.slice(0,3).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+'">'+cleanDisplayText(s)+'</span>';
}).join('');
var entryMethod = ep.entry_method || '';
var triggerCause = normalizeTriggerCause(entryMethod || (isBuy?'15min 触发 · 窗口有效':phase.label+' · 等待条件满足'));
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 trustWindowHtml() {
var w = r.entry_window || {};
if (!isBuy || !w.status) return '';
var cls = w.status === 'active' ? 'window-active' : (w.status === 'price_left_up' ? 'window-warn' : 'window-danger');
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 '<div class="trust-pill '+cls+'"><span class="trust-label">窗口有效期</span><span class="trust-value">'+cleanDisplayText(w.label||'入场窗口')+'</span><span class="trust-sub">剩余 '+remain+' · 偏离 '+devText+'</span></div>';
}
var trustHtml = trustWindowHtml();
var weakNoteHtml = isWeakObserve ? '<div class="weak-note">'+cleanDisplayText(r.observe_reason || '信号强度不足,仅保留为低优先级观察,不构成实时机会。')+'</div>' : '';
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>';
}
var triggerCauseHtml = triggerCause ? '<div class="trigger-cause"><span class="tc-label">'+(hasQualityGate?'观察原因':'触发依据')+'</span><span class="tc-value">'+(hasQualityGate ? cleanDisplayText(r.observe_reason || triggerCause).slice(0,96) : triggerCause.slice(0,80))+'</span></div>' : '';
var triggerCtx = (r.market_context && r.market_context.trigger_context) || (r.sector_context && r.sector_context.trigger_context) || ep.trigger_context || {};
var curTriggers = Array.isArray(triggerCtx.current_triggers) ? triggerCtx.current_triggers : [];
var staleTriggers = Array.isArray(triggerCtx.stale_background) ? triggerCtx.stale_background : [];
var triggerBadgeHtml = '';
if (triggerCtx.trigger_status || curTriggers.length || staleTriggers.length) {
var tCls = /news/.test(triggerCtx.trigger_status || '') ? 'event' : (/stale/.test(triggerCtx.trigger_status || '') ? 'stale' : 'current');
var tLabel = triggerCtx.trigger_label || (curTriggers.length ? '当前触发' : '历史背景');
if (tCls === 'stale') tLabel = '历史背景';
var firstCur = curTriggers[0] || {};
var sub = firstCur.title || firstCur.label || (staleTriggers[0] && staleTriggers[0].label) || '';
triggerBadgeHtml = '<div class="trigger-meta '+tCls+'"><span>'+cleanDisplayText(tLabel).slice(0,32)+'</span>'+(sub?'<small>'+cleanDisplayText(sub).slice(0,72)+'</small>':'')+'</div>';
}
var contextHtml = (triggerCauseHtml || triggerBadgeHtml) ? '<div class="signal-context">'+triggerCauseHtml+triggerBadgeHtml+'</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>'+
'<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)+
(!isWeakObserve && trustHtml?'<div class="trust-row">'+trustHtml+'</div>':'')+
contextHtml+
(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+'\">&rarr;</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 %}