alphax/static/app.html
2026-05-16 14:52:10 +08:00

981 lines
73 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 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); }
.onchain-brief { margin: 0 18px 8px; border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.045); padding: 9px 10px; display: grid; gap: 6px; }
.onchain-brief.risk { border-color: rgba(229,62,62,.18); background: var(--red-light); }
.onchain-head { display:flex; align-items:center; justify-content:space-between; gap:8px; color:var(--ink); font-size:12px; font-weight:900; }
.onchain-meta { color:var(--stone); font-size:11px; line-height:1.45; }
.onchain-score { color:var(--blue); font-weight:950; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
.onchain-brief.risk .onchain-score { color:var(--red); }
/* ===== 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); }
.onchain-brief { margin: 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">
<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 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 esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function(c) {
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c];
});
}
function escHtml(s) { return esc(s); }
function fmtCompactNumber(v) {
v = Number(v || 0);
var abs = Math.abs(v);
if (abs >= 1e9) return (v / 1e9).toFixed(2) + 'B';
if (abs >= 1e6) return (v / 1e6).toFixed(2) + 'M';
if (abs >= 1e3) return (v / 1e3).toFixed(1) + 'K';
return v.toFixed(abs >= 100 ? 0 : abs >= 10 ? 1 : 2);
}
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);
}
// ====== 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;
}
try {
applyFilterAndRender();
} catch (renderErr) {
console.error('live render failed', renderErr);
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
var weakCount = visible.filter(isWeakObserveRec).length;
var fallbackItems = currentFilter === 'weak_observe'
? visible.filter(function(r){ return r.observe_tier === 'weak'; })
: (currentFilter === 'buy_now'
? visible.filter(function(r){ return (r.execution_status === 'buy_now' || r.display_bucket === 'realtime') && !isWeakObserveRec(r); })
: currentFilter === 'observe'
? visible.filter(function(r){ return (r.display_bucket === 'watch_pool' || (r.execution_status !== 'buy_now' && r.display_bucket !== 'realtime')) && r.observe_tier !== 'weak'; })
: visible.filter(function(r){ return !isWeakObserveRec(r); }));
renderLiveStats(cachedLiveData);
$('liveCards').innerHTML = fallbackItems.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount && !currentFilter ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '') + (liveHasMore ? '<div class="load-more-row"><button class="load-more-btn" onclick="loadMoreLive()">加载更多</button></div>' : '<div class="page-hint">已加载全部实时记录</div>');
}
$('liveCount').textContent = '';
// Load K-lines after DOM has fully settled
setTimeout(function() { loadAllKlines('#liveCards'); }, 150);
} catch(e) {
console.error('loadContent failed', e);
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(r); });
var weakCount = visible.filter(isWeakObserveRec).length;
$('liveCards').innerHTML = visible.length
? visible.map(function(r){ return renderLiveFallbackCard(r); }).join('') + (weakCount ? '<div class="weak-summary"><span>另有 '+weakCount+' 个弱观察候选已收起。</span><button onclick="setFilter(\'weak_observe\')">查看弱观察</button></div>' : '')
: '<div class="empty-state"><p>加载失败<br>请稍后再试</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 isRenderableLiveRec(r) {
if (!isLiveVisibleRec(r)) return false;
if (r.display_bucket === 'realtime' || r.execution_status === 'buy_now') return true;
if (r.display_bucket === 'watch_pool' || r.execution_status === 'wait_pullback' || r.execution_status === 'observe') return true;
return false;
}
function applyFilterAndRender() {
var visible = cachedLiveData.filter(function(r){ return isRenderableLiveRec(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 = [];
try {
visible = (Array.isArray(data) ? data : []).filter(function(r){ return isRenderableLiveRec(r); });
} catch (e) {
console.error('renderLiveStats failed', e);
}
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) {
try { return renderRecCard(r); }
catch (e) {
console.error('renderRecCard failed', r && r.symbol, e);
return renderLiveFallbackCard(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 renderLiveFallbackCard(r) {
var symbol = (r && r.symbol) || '--';
var base = String(symbol).replace('/USDT','');
var status = (r && (r.execution_label || r.action_status || r.execution_status)) || '观察';
var score = Number((r && r.rec_score) || 0);
var price = Number((r && (r.current_price || r.entry_price)) || 0);
var entry = Number((r && r.entry_price) || 0);
var change = price && entry ? ((price - entry) / entry * 100) : null;
var changeHtml = change != null ? '<span class="price-change zero"><span class="pc-label">参考</span><span class="pc-value">'+(change>0?'+':'')+change.toFixed(1)+'%</span></span>' : '';
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">'+esc(symbol)+'</span></div></div><div class="badge-group"><span class="action-badge weak">'+esc(status)+'</span><span class="score-badge tier-none"><span class="score-num">'+score+'</span><span class="score-label">评分</span></span></div></div><div class="price-bar"><span class="price">'+(price ? '$'+fmtPrice(price) : '--')+'</span>'+changeHtml+'</div><div class="decision-strip observe"><div class="decision-head"><span class="decision-label">最终建议</span><span class="decision-title">观察</span></div><div class="decision-body"><span class="decision-focus">降级展示</span><span class="decision-reason">该候选存在兼容问题,已用安全卡片显示。</span></div></div><div class="entry-plan"><div class="ep-item"><span class="ep-label">阶段</span><span class="ep-val phase-ref">观察</span><span class="ep-sub">降级展示</span></div><div class="ep-item"><span class="ep-label">当前参考</span><span class="ep-val entry-ref">'+(price ? '$'+fmtPrice(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">待补充</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><div class="card-footer"><span>'+fmtTime(r && r.rec_time)+'</span><span class="card-ver">'+esc((r && r.strategy_version) || '')+'</span></div></div>';
}
function renderRecCard(r) {
try {
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';
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;
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 : '';
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 ? '失效参考' : '参考价位');
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 onchainHtml = '';
var oc = r.onchain_context || null;
if (oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score)) {
var ocRisk = Number(oc.risk_event_count_24h || 0) > 0 || Number(oc.risk_score || 0) >= 60;
var ocTitle = cleanDisplayText(oc.headline || (ocRisk ? '链上风险升温' : '链上资金异动'));
var ocScore = ocRisk ? Number(oc.risk_score || 0).toFixed(0) : Number(oc.onchain_score || 0).toFixed(0);
var ocMeta = [oc.chain || '链上', '24h事件 '+(oc.event_count_24h || 0), oc.dex_volume_usd ? ('DEX量 $'+fmtCompactNumber(oc.dex_volume_usd)) : ''].filter(Boolean).join(' · ');
onchainHtml = '<div class="onchain-brief '+(ocRisk?'risk':'')+'"><div class="onchain-head"><span>'+ocTitle+'</span><span class="onchain-score">'+ocScore+'</span></div><div class="onchain-meta">'+escHtml(ocMeta)+'</div></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>';
}
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+onchainHtml+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>';
} catch (e) {
console.error('renderRecCard hard fail', r && r.symbol, e);
return renderLiveFallbackCard(r);
}
}
// ====== 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 hitFailure = status === 'stopped_out' || pnl <= -3 || maxDd <= -5;
if (hitFailure) {
return { resolved: true, type: 'failure', pnl: pnl, label: '风险边界' };
}
var hitSuccess = status === 'hit_tp1' || status === 'hit_tp2' || maxPnl >= 5;
if (hitSuccess) {
return { resolved: true, type: 'success', pnl: maxPnl || 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 = isRiskExit ? 0 : (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 %}