alphax/static/app.html
2026-05-18 08:39:38 +08:00

1041 lines
80 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 — 看板{% 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; }
.history-filter-bar { display: flex; align-items: center; justify-content: flex-end; gap: 6px; flex-wrap: wrap; margin: 0 0 14px; }
.history-filter-btn { border: 1px solid var(--hairline); background: var(--canvas); color: var(--steel); padding: 7px 12px; border-radius: var(--radius-full); font-size: 12px; font-weight: 700; line-height: 1.3; cursor: pointer; transition: .15s; white-space: nowrap; }
.history-filter-btn:hover { color: var(--ink); border-color: var(--hairline-strong); }
.history-filter-btn.active { background: var(--primary); color: var(--on-primary); border-color: var(--primary); box-shadow: 0 4px 12px rgba(5,0,56,.10); }
/* ===== 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; }
.level-badge { padding: 4px 10px; border-radius: var(--radius-full); font-size: 12px; font-weight: 800; white-space: nowrap; color: var(--blue); background: rgba(66,98,255,.07); border: 1px solid rgba(66,98,255,.12); }
.level-badge.intraday_breakout { color: var(--green); background: var(--green-light); border-color: rgba(0,180,115,.14); }
.level-badge.short_swing { color: #187574; background: rgba(15,188,176,.12); border-color: rgba(15,188,176,.20); }
.level-badge.structure_watch { color: var(--yellow-dark); background: var(--yellow-light); border-color: rgba(252,185,0,.22); }
.level-badge.theme_trend { color: var(--blue); background: rgba(66,98,255,.07); border-color: rgba(66,98,255,.12); }
.signal-level-strip { margin: 0 18px 10px; border: 1px solid var(--hairline-soft); background: linear-gradient(180deg, rgba(248,250,252,.96), rgba(255,255,255,.98)); border-radius: var(--radius-lg); padding: 10px 12px; display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); gap: 10px; align-items: center; }
.signal-level-title { display:flex; align-items:center; gap:7px; min-width:0; }
.signal-level-dot { width:8px; height:8px; border-radius:50%; background: var(--blue); box-shadow: 0 0 0 4px rgba(66,98,255,.08); flex-shrink:0; }
.signal-level-strip.intraday_breakout .signal-level-dot { background: var(--green); box-shadow:0 0 0 4px rgba(0,180,115,.10); }
.signal-level-strip.short_swing .signal-level-dot { background:#0f9f98; box-shadow:0 0 0 4px rgba(15,188,176,.12); }
.signal-level-strip.structure_watch .signal-level-dot { background:var(--yellow); box-shadow:0 0 0 4px rgba(252,185,0,.14); }
.signal-level-k { display:block; color:var(--stone); font-size:10px; font-weight:900; line-height:1.2; }
.signal-level-v { display:block; margin-top:3px; color:var(--ink); font-size:13px; font-weight:950; line-height:1.25; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.signal-level-sub { color:var(--stone); font-size:11px; font-weight:800; line-height:1.35; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.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); }
.price.muted { color: var(--stone); }
.h-arrow.neutral { color: var(--stone); }
.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.level-ref { color: var(--ink); font-family: inherit; }
.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%; }
.signal-level-strip { grid-template-columns: 1fr; margin: 0 14px 8px; }
.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 class="history-filter-bar" id="historyFilterBar">
<button class="history-filter-btn active" data-filter="" onclick="setHistoryFilter('')">全部</button>
<button class="history-filter-btn" data-filter="executed" onclick="setHistoryFilter('executed')">已执行</button>
<button class="history-filter-btn" data-filter="invalid" onclick="setHistoryFilter('invalid')">失效</button>
</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;
var historyArchiveFilter = '';
// ====== 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);
}
function setHistoryFilter(filter) {
historyArchiveFilter = filter || '';
document.querySelectorAll('#historyFilterBar .history-filter-btn').forEach(function(btn){
btn.classList.toggle('active', (btn.dataset.filter || '') === historyArchiveFilter);
});
loadHistoryRecommendations(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 levelKey = r.opportunity_level || ep.opportunity_level || 'structure_watch';
var levelLabel = r.opportunity_level_label || ep.opportunity_level_label || '结构观察';
var horizon = r.holding_horizon || ep.holding_horizon || '';
var entryModel = r.entry_model || ep.entry_model || '';
var stopModel = r.stop_model || ep.stop_model || ep.stop_basis || '风险边界';
var tpModel = r.tp_model || ep.tp_model || ep.tp_basis || '上方目标';
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 = r.execution_status === 'wait_pullback' || 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 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 = isBuy ? '现在可买' : (isWait ? '等回踩,不追高' : (isWeakObserve ? '弱观察' : '观察'));
var decisionFocus = isBuy ? ('现价 '+fmtP(price)) : (isWait ? ('参考 '+fmtP(entryRef)) : (isWeakObserve ? '低优先级观察' : '等待确认'));
var decisionReason = cleanDisplayText(isBuy ? (entryWindowSummary() || r.execution_reason || '入场窗口有效') : (r.execution_reason || (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>';
}
function levelFrameText(key) {
if (key === 'intraday_breakout') return '15m/1H';
if (key === 'short_swing') return '1H/4H';
if (key === 'structure_watch') return '4H/1D';
if (key === 'theme_trend') return '1D/主题';
return '多周期';
}
var levelBasis = Array.isArray(ep.plan_basis) ? ep.plan_basis.slice(0,2).join(' · ') : '';
var signalLevelHtml = '<div class="signal-level-strip '+cleanDisplayText(levelKey)+'"><div class="signal-level-title"><span class="signal-level-dot"></span><div><span class="signal-level-k">机会级别</span><span class="signal-level-v">'+cleanDisplayText(levelLabel)+'</span><span class="signal-level-sub">'+cleanDisplayText(horizon || levelFrameText(levelKey))+'</span></div></div><div><span class="signal-level-k">触发门槛</span><span class="signal-level-v">'+cleanDisplayText(entryModel || '等待当前触发')+'</span><span class="signal-level-sub">'+cleanDisplayText(levelBasis || phase.short || '当前触发 + 风险边界')+'</span></div></div>';
var entryPlanHtml = '';
if (isTradePlan) {
entryPlanHtml = '<div class="entry-plan"><div class="ep-item"><span class="ep-label">入场参考</span><span class="ep-val entry-ref">'+fmtP(entryRef)+'</span><span class="ep-sub">'+cleanDisplayText(entryLabel+' · '+(entryModel || '触发/计划价'))+'</span></div><div class="ep-item"><span class="ep-label">风险边界</span><span class="ep-val risk-line">'+fmtP(riskLine)+'</span><span class="ep-sub">'+cleanDisplayText(stopModel)+'</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">'+cleanDisplayText(tpModel)+' · '+fmtP(spaceRef)+'</span></div><div class="ep-item"><span class="ep-label">持有阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || phase.short)+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</span></div></div>';
} else {
entryPlanHtml = '<div class="entry-plan"><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">'+cleanDisplayText(entryModel || '需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 class="ep-item"><span class="ep-label">观察阶段</span><span class="ep-val level-ref">'+cleanDisplayText(horizon || '观察池候选')+'</span><span class="ep-sub">'+cleanDisplayText(levelLabel)+'</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"><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+signalLevelHtml+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 execution = (r && r.execution_status) || '';
var bucket = (r && r.display_bucket) || '';
var triggered = !!(r && Number(r.entry_triggered || 0));
var paper = r && r.paper_trade ? r.paper_trade : null;
if (paper && paper.status === 'closed') {
var exitReason = String(paper.exit_reason || '').toLowerCase();
if (exitReason === 'stop_loss' || exitReason === 'sl' || exitReason === 'stopped_out') {
return { resolved: true, type: 'executed_failed', label: '模拟交易止损', detail: '已进入模拟交易并触发止损' };
}
return { resolved: true, type: 'executed_success', label: '模拟交易兑现', detail: '已进入模拟交易并完成退出' };
}
if (paper && paper.status === 'open') {
return { resolved: true, type: 'executed_open', label: '模拟交易持有', detail: '已进入模拟交易,仍在持仓中' };
}
if (status === 'hit_tp1' || status === 'hit_tp2' || execution === 'completed') {
return { resolved: true, type: 'executed_success', label: '执行后兑现', detail: '已进入模拟/持仓口径验证' };
}
if (status === 'stopped_out') {
return { resolved: true, type: 'executed_failed', label: '执行后止损', detail: '执行样本触发风险边界' };
}
if (status === 'expired' || status === 'invalid' || status === 'archived' || execution === 'invalid' || bucket === 'history') {
return { resolved: true, type: triggered ? 'executed_invalid' : 'not_executed', label: triggered ? '执行后失效' : '未执行失效', detail: triggered ? '曾进入执行态,后续失效' : '推荐/观察后未形成真实交易' };
}
return { resolved: false, type: 'pending', label: '仍在跟踪', detail: '尚未归档' };
}
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&archive_filter='+encodeURIComponent(historyArchiveFilter || '');
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 executedCount = Number(summary.executed_count || 0);
var completedCount = Number(summary.completed_count || 0);
var invalidCount = Number(summary.invalid_count || 0);
var notExecutedCount = Number(summary.not_executed_count || 0);
$('histCount').textContent = totalCount ? ' ' + totalCount : '';
$('historyStats').innerHTML =
'<div class="hstat"><div class="num" style="color:var(--blue)">'+totalCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--blue)"><use href="#svg-target"/></svg> 归档信号</div><div class="sub">推荐/观察历史</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--green)">'+executedCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--green)"><use href="#svg-trendup"/></svg> 进入执行</div><div class="sub">收益见模拟交易</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--yellow-dark)">'+notExecutedCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--yellow-dark)"><use href="#svg-star"/></svg> 未执行归档</div><div class="sub">观察/等回踩失效</div></div>'+
'<div class="hstat"><div class="num" style="color:var(--red)">'+invalidCount+'</div><div class="lbl"><svg width="14" height="14" color="var(--red)"><use href="#svg-shield"/></svg> 信号失效</div><div class="sub">含过期/风控失效</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);
var paper = r.paper_trade || null;
var hasPaper = !!(paper && paper.id);
var exitP = hasPaper ? Number(paper.exit_price || 0) : 0;
var entryP = hasPaper ? Number(paper.entry_price || 0) : 0;
function fmtN(n) { return fmtPrice(n, priceDecimals(r.current_price || entryP || exitP || n)); }
var resultCls = (outcome.type === 'executed_success') ? 'win' : ((outcome.type === 'executed_failed') ? 'loss' : 'neutral');
var statusColorCls = resultCls === 'win' ? 'pos' : (resultCls === 'loss' ? 'neg' : 'zero');
var afterMove = hasPaper && entryP && exitP ? ((exitP / entryP - 1) * 100) : 0;
var afterMoveSign = afterMove > 0 ? '+' : '';
var maxPnl = Number(r.max_pnl_pct || 0);
var maxDd = Number(r.max_drawdown_pct || 0);
var exitMode = outcome.label;
var isPaperClosed = hasPaper && paper.status === 'closed';
var isPaperStop = isPaperClosed && /stop|sl|stopped/i.test(String(paper.exit_reason || ''));
var hEntryTime = hasPaper ? (paper.opened_at||r.rec_time||'') : '';
var hTpTime = isPaperClosed && !isPaperStop ? (paper.closed_at||'') : '';
var hSlTime = isPaperClosed && isPaperStop ? (paper.closed_at||'') : '';
var hEntryPrice = hasPaper ? entryP : 0, hSl = isPaperClosed ? (isPaperStop ? exitP : Number(paper.stop_loss || 0)) : 0, hTp = isPaperClosed ? (isPaperStop ? 0 : (Number(paper.tp1 || 0) || exitP)) : 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);
var execText = hasPaper ? (paper.status === 'closed' ? '模拟交易已完成' : '模拟交易持有中') : (Number(r.entry_triggered || 0) ? '已触发执行' : '未执行');
var signalStateText = hasPaper ? '已执行归档' : (historyArchiveFilter === 'invalid' ? '失效归档' : '未执行归档');
var outcomeText = outcome.label;
var outcomeDetail = outcome.detail;
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-result-badge '+resultCls+'\">'+outcomeText+'</span></div>'+
'<div class=\"h-pnl-row\">'+(hasPaper ? '<span class=\"price h-entry-price\">$'+fmtN(entryP)+'</span><span class=\"h-arrow neutral\">&rarr;</span><span class=\"price h-exit-price '+(paper.status === 'closed' ? resultCls : 'muted')+'\">'+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')+'</span>' : '<span class=\"price h-entry-price muted\">未执行</span><span class=\"h-arrow neutral\">&rarr;</span><span class=\"price h-exit-price muted\">失效/归档</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 '+(hasPaper ? 'win' : 'blue')+'\">'+(hasPaper ? (paper.status === 'closed' ? '已完成模拟交易' : '模拟交易持有中') : '未执行归档')+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">结果说明</span><span class=\"hm-val blue\">'+outcomeDetail+'</span></div><div class=\"hist-metric\"><span class=\"hm-label\">执行状态</span><span class=\"hm-val '+(Number(r.entry_triggered||0)?'win':'blue')+'\">'+execText+'</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 %}