alphax/static/app.html
2026-05-29 10:09:30 +08:00

1208 lines
88 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.summary-card { padding: 16px; display: grid; gap: 12px; }
.summary-top { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
.summary-main { min-width:0; }
.summary-symbol { display:flex; align-items:center; gap:10px; min-width:0; }
.summary-symbol .coin-symbol { font-size:18px; }
.summary-meta { margin-top:5px; color:var(--stone); font-size:11px; font-weight:800; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.summary-score { display:flex; align-items:center; gap:8px; flex-shrink:0; }
.summary-price-row { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:8px; }
.summary-stat { border:1px solid var(--hairline-soft); background:var(--surface); border-radius:var(--radius-lg); padding:9px 10px; min-width:0; }
.summary-stat span { display:block; color:var(--stone); font-size:10px; font-weight:900; line-height:1.2; }
.summary-stat b { display:block; margin-top:4px; color:var(--ink); font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; font-weight:950; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.summary-stat b.green { color:var(--green); }
.summary-stat b.red { color:var(--red); }
.summary-decision { border:1px solid var(--hairline-soft); background:var(--surface); border-radius:var(--radius-lg); padding:10px 12px; display:grid; gap:3px; }
.summary-decision.buy { background:var(--green-light); border-color:rgba(0,180,115,.18); }
.summary-decision.wait { background:var(--yellow-light); border-color:rgba(252,185,0,.24); }
.summary-decision.observe,.summary-decision.weak { background:rgba(66,98,255,.045); border-color:rgba(66,98,255,.14); }
.summary-decision h3 { font-size:13px; font-weight:950; color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.summary-decision p { color:var(--stone); font-size:11px; font-weight:800; line-height:1.4; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.summary-tags { display:flex; align-items:center; justify-content:space-between; gap:10px; border-top:1px solid var(--hairline-soft); padding-top:10px; }
.summary-chips { display:flex; gap:5px; flex-wrap:wrap; min-width:0; }
.summary-chip { display:inline-flex; border:1px solid var(--hairline-soft); background:var(--surface); border-radius:999px; padding:4px 8px; color:var(--slate); font-size:11px; font-weight:850; max-width:150px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.detail-link { color:var(--blue); font-size:12px; font-weight:950; white-space:nowrap; }
.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); }
.strategy-diagnostics { margin: 0 18px 8px; display: grid; gap: 8px; }
.score-split { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 6px; }
.score-part { border: 1px solid var(--hairline-soft); border-radius: var(--radius-lg); background: var(--surface); padding: 8px 9px; min-width: 0; }
.score-part span { display: block; color: var(--stone); font-size: 10px; font-weight: 900; line-height: 1.2; }
.score-part b { display: block; margin-top: 4px; font-size: 16px; line-height: 1; font-weight: 950; font-family: ui-monospace,SFMono-Regular,Menlo,monospace; color: var(--ink); }
.score-part.opportunity b { color: var(--blue); }
.score-part.entry b { color: var(--green); }
.score-part.risk b { color: var(--red); }
.regime-brief { border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); padding: 8px 10px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.regime-brief.risk_off,.regime-brief.critical { border-color: rgba(229,62,62,.18); background: var(--red-light); }
.regime-brief.altcoin_rotation { border-color: rgba(0,180,115,.18); background: var(--green-light); }
.regime-brief.sideways_chop,.regime-brief.meme_frenzy { border-color: rgba(252,185,0,.24); background: var(--yellow-light); }
.regime-name { color: var(--ink); font-size: 12px; font-weight: 950; white-space: nowrap; }
.regime-reason { color: var(--stone); font-size: 11px; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: right; }
.decision-log-brief { border: 1px dashed var(--hairline-strong); border-radius: var(--radius-lg); background: var(--canvas); padding: 8px 10px; color: var(--slate); font-size: 11px; line-height: 1.45; }
.decision-log-brief b { color: var(--ink); font-weight: 950; }
.paper-order-brief { margin: 0 18px 8px; border: 1px solid rgba(66,98,255,.14); border-radius: var(--radius-lg); background: rgba(66,98,255,.04); padding: 8px 10px; display:flex; align-items:center; justify-content:space-between; gap:10px; color: var(--slate); font-size: 11px; line-height: 1.4; }
.paper-order-brief.pending { border-color: rgba(252,185,0,.24); background: var(--yellow-light); }
.paper-order-brief.filled { border-color: rgba(0,180,115,.18); background: var(--green-light); }
.paper-order-brief.canceled,.paper-order-brief.expired,.paper-order-brief.rejected { border-color: rgba(229,62,62,.18); background: var(--red-light); }
.paper-order-title { color: var(--ink); font-size: 12px; font-weight: 950; white-space: nowrap; }
.paper-order-meta { color: var(--stone); text-align:right; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* ===== 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 { position: relative; width: 100%; height: 200px; }
.kline-container .ax-chart { min-height: 200px; }
.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); }
.summary-price-row { grid-template-columns:repeat(2,minmax(0,1fr)); }
.summary-top { display:block; }
.summary-score { margin-top:10px; }
.onchain-brief { margin: 0 14px 8px; }
.strategy-diagnostics { margin: 0 14px 8px; }
.score-split { grid-template-columns: 1fr; }
.regime-brief { display: block; }
.regime-reason { margin-top: 4px; text-align: left; white-space: normal; }
}
@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 src="/static/chart_widgets.js"></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);
}
var MARKET_REGIME_LABELS = {
risk_off: '避险期',
btc_main_uptrend: 'BTC 主导上涨',
altcoin_rotation: '山寨轮动期',
sideways_chop: '震荡拉锯期',
meme_frenzy: 'Meme 过热期',
unknown: '市场环境待确认'
};
var MARKET_RISK_LABELS = {
low: '低风险',
medium: '中等风险',
high: '高风险',
critical: '极高风险'
};
var DECISION_LABELS = {
confirmed: '已确认',
rejected: '未通过',
observe: '观察中',
wait_pullback: '等待回踩',
buy_now: '入场窗口',
weak_observe: '弱观察',
invalid: '已失效'
};
var MODULE_LABELS = {
confirm_burst: '爆发确认',
confirm_static_accumulation: '蓄力确认',
confirm_event: '事件确认',
strategy: '策略判断',
screener: '初筛',
tracker: '跟踪',
paper_trader: '策略交易'
};
var PAPER_ORDER_STATUS_LABELS = {
pending: '挂单中',
filled: '已成交',
canceled: '已取消',
expired: '已过期',
rejected: '已拒绝'
};
var PAPER_ORDER_REASON_LABELS = {
upgraded_to_buy_now: '信号已升级为入场窗口',
too_far_from_entry: '距离计划价过远',
recommendation_invalid: '信号已失效',
recommendation_missing: '原信号不存在',
expired: '超过有效时间',
global_risk_rejected: '全局风控拦截',
cumulative_leverage_exceeded: '累计杠杆超限',
stop_loss_leverage_risk_exceeded: '止损杠杆风险过高'
};
var FLAG_LABELS = {
market_regime: '市场环境',
market_risk: '市场风险',
global_risk: '全局风险',
risk_reward_bad: '盈亏比不足',
false_breakout: '假突破风险',
liquidity_risk: '流动性风险',
funding_overheat: '资金费率过热',
overextended: '短线涨幅过大',
entry_quality_low: '买点质量偏低'
};
function regimeLabel(v) {
v = String(v || '');
return MARKET_REGIME_LABELS[v] || cleanDisplayText(v).replace(/_/g, ' ');
}
function riskLabel(v) {
v = String(v || '');
return MARKET_RISK_LABELS[v] || cleanDisplayText(v).replace(/_/g, ' ');
}
function translateInternalToken(token) {
token = String(token == null ? '' : token).trim();
if (!token) return '';
if (DECISION_LABELS[token]) return DECISION_LABELS[token];
if (MODULE_LABELS[token]) return MODULE_LABELS[token];
if (MARKET_REGIME_LABELS[token]) return MARKET_REGIME_LABELS[token];
if (MARKET_RISK_LABELS[token]) return MARKET_RISK_LABELS[token];
var kv = token.match(/^([a-zA-Z0-9_]+):(.*)$/);
if (kv) {
var key = kv[1], value = kv[2];
if (key === 'market_regime') return '市场环境:' + regimeLabel(value);
if (key === 'market_risk') return '市场风险:' + riskLabel(value);
if (key === 'global_risk') return '全局风险:' + riskLabel(value);
return (FLAG_LABELS[key] || key.replace(/_/g, ' ')) + '' + translateInternalToken(value);
}
return FLAG_LABELS[token] || cleanDisplayText(token).replace(/_/g, ' ');
}
function scoreComponentsFrom(r) {
var ep = (r && r.entry_plan) || {};
var mc = (r && r.market_context) || {};
return ep.score_components || mc.score_components || null;
}
function marketRegimeFrom(r) {
var ep = (r && r.entry_plan) || {};
var mc = (r && r.market_context) || {};
return ep.market_regime || mc.market_regime || null;
}
function decisionLogFrom(r) {
var ep = (r && r.entry_plan) || {};
var mc = (r && r.market_context) || {};
return ep.decision_log || mc.decision_log || null;
}
function renderScoreComponents(r) {
var sc = scoreComponentsFrom(r);
if (!sc) return '';
var opp = Number(sc.opportunity_score || 0);
var entry = Number(sc.entry_score || 0);
var risk = Number(sc.risk_score || 0);
return '<div class="score-split" title="机会分看币本身,买点分看当前是否适合进,风险分看扣分风险">'+
'<div class="score-part opportunity"><span>机会</span><b>'+fmtCompactNumber(opp)+'</b></div>'+
'<div class="score-part entry"><span>买点</span><b>'+fmtCompactNumber(entry)+'</b></div>'+
'<div class="score-part risk"><span>风险</span><b>'+fmtCompactNumber(risk)+'</b></div>'+
'</div>';
}
function renderRegimeBrief(r) {
var rg = marketRegimeFrom(r);
if (!rg || !rg.regime) return '';
var reasons = Array.isArray(rg.reasons) ? rg.reasons : [];
var cls = String(rg.regime || '') + ' ' + String(rg.risk_level || '');
return '<div class="regime-brief '+esc(cls)+'"><span class="regime-name">'+esc(rg.label || regimeLabel(rg.regime) || '市场环境')+' · '+esc(riskLabel(rg.risk_level || 'medium'))+'</span><span class="regime-reason">'+esc(reasons[0] || '市场环境已记录到策略上下文')+'</span></div>';
}
function renderDecisionLogBrief(r) {
var log = decisionLogFrom(r);
if (!log || !log.decision) return '';
var flags = Array.isArray(log.risk_flags)
? log.risk_flags.slice(0,3).map(translateInternalToken).filter(Boolean).join(' · ')
: '';
return '<div class="decision-log-brief"><b>'+esc(translateInternalToken(log.decision))+'</b> · '+esc(translateInternalToken(log.module || 'strategy'))+(flags ? ' · '+esc(flags) : '')+'</div>';
}
function renderStrategyDiagnostics(r) {
var html = renderScoreComponents(r) + renderRegimeBrief(r) + renderDecisionLogBrief(r);
return html ? '<div class="strategy-diagnostics">'+html+'</div>' : '';
}
function renderPaperOrderBrief(r) {
var order = r && r.paper_order;
if (!order || !order.id) return '';
var status = String(order.status || '').toLowerCase();
var label = PAPER_ORDER_STATUS_LABELS[status] || translateInternalToken(status || '挂单');
var target = Number(order.target_price || 0);
var current = Number(r.current_price || order.current_price_at_create || 0);
var meta = [];
if (target > 0) meta.push('计划价 $' + fmtPrice(target, priceDecimals(target)));
if (current > 0 && status === 'pending') meta.push('现价 $' + fmtPrice(current, priceDecimals(current)));
if (order.cancel_reason) meta.push(PAPER_ORDER_REASON_LABELS[order.cancel_reason] || translateInternalToken(order.cancel_reason));
return '<div class="paper-order-brief '+esc(status)+'"><span class="paper-order-title">策略挂单 · '+esc(label)+'</span><span class="paper-order-meta">'+esc(meta.join(' · ') || '已关联到当前信号')+'</span></div>';
}
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);
}
function opportunityUrl(r) {
var symbol = encodeURIComponent((r && r.symbol) || '');
var recId = encodeURIComponent((r && r.id) || '');
return '/opportunity?symbol=' + symbol + (recId ? '&rec_id=' + recId : '');
}
function goOpportunity(r) {
location.href = opportunityUrl(r);
}
// ====== 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 = '';
} 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 '<a class="card summary-card" href="'+opportunityUrl(r||{symbol:symbol})+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+esc(symbol)+'</span><div class="summary-meta">'+fmtTime(r && r.rec_time)+' · 安全降级展示</div></div></div></div><div class="summary-score"><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="summary-price-row"><div class="summary-stat"><span>当前价</span><b>'+(price ? '$'+fmtPrice(price) : '--')+'</b></div><div class="summary-stat"><span>相对参考</span><b>'+esc(change==null?'--':((change>0?'+':'')+change.toFixed(1)+'%'))+'</b></div><div class="summary-stat"><span>阶段</span><b>观察</b></div><div class="summary-stat"><span>版本</span><b>'+esc((r && r.strategy_version) || '--')+'</b></div></div><div class="summary-decision observe"><h3>观察</h3><p>该候选存在兼容问题,点击进入详情查看原始记录。</p></div><div class="summary-tags"><div class="summary-chips"><span class="summary-chip">降级展示</span></div><span class="detail-link">查看详情</span></div></a>';
}
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 strategyLabel = r.strategy_name || ({main_composite_v1:'综合确认策略',box_retest_1h_v1:'1H箱体突破回踩',box_retest_4h_v1:'4H箱体突破回踩',volume_ignition_1h_v1:'1H放量突破启动',compression_breakout_4h_v1:'4H压缩蓄力突破',intraday_momentum_15m_v1:'15m日内动量延续'}[r.strategy_code||''] || r.strategy_code || '');
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>';
}
var detailHref = opportunityUrl(r);
var riskText = riskLine ? fmtP(riskLine) : '--';
var targetText = spaceRef ? fmtP(spaceRef) : '--';
var compactSignals = sigs.slice(0,3).map(function(s){ return '<span class="summary-chip">'+displaySignalText(s)+'</span>'; }).join('');
var onchainChip = oc && (oc.event_count_24h || oc.onchain_score || oc.risk_score) ? '<span class="summary-chip">'+(ocRisk?'链上风险':'链上异动')+' '+(oc.event_count_24h||0)+'</span>' : '';
var orderChip = r.paper_order && r.paper_order.id ? '<span class="summary-chip">挂单 '+(PAPER_ORDER_STATUS_LABELS[String(r.paper_order.status||'').toLowerCase()]||r.paper_order.status)+'</span>' : '';
return '<a class="card summary-card '+(isWeakObserve?'weak-observe':'')+'" href="'+detailHref+'"><div class="summary-top"><div class="summary-main"><div class="summary-symbol"><div class="coin-icon">'+base.slice(0,2).toUpperCase()+'</div><div><span class="coin-symbol">'+base+'</span><div class="summary-meta">'+fmtTime(r.rec_time)+' · '+cleanDisplayText(strategyLabel || levelLabel)+' · '+cleanDisplayText(horizon || levelFrameText(levelKey))+'</div></div></div></div><div class="summary-score"><span class="action-badge '+phase.cls+'">'+phase.label+'</span><span class="score-badge '+st.cls+'"><span class="score-num">'+score+'</span><span class="score-label">总分</span></span></div></div><div class="summary-price-row"><div class="summary-stat"><span>当前价</span><b>$'+priceFmt+'</b></div><div class="summary-stat"><span>'+changeLabel+'</span><b class="'+(changePct>=0?'green':'red')+'">'+(changePct==null?'--':changeSign+changePct.toFixed(1)+'%')+'</b></div><div class="summary-stat"><span>'+(isWait?'计划回踩':'计划入场')+'</span><b>'+fmtP(entryRef || price)+'</b></div><div class="summary-stat"><span>策略</span><b>'+cleanDisplayText(strategyLabel || '--')+'</b></div></div><div class="summary-decision '+decisionCls+'"><h3>'+decisionTitle+' · '+decisionFocus+'</h3><p>'+decisionReason+'</p></div><div class="summary-tags"><div class="summary-chips">'+(compactSignals||'<span class="summary-chip">暂无明确信号</span>')+onchainChip+orderChip+'</div><span class="detail-link">查看详情</span></div></a>';
} 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);
if (!window.AlphaXCharts || !window.AlphaXCharts.renderKline) {
throw new Error('AlphaXCharts.renderKline is not available');
}
container.innerHTML = '';
window.AlphaXCharts.renderKline(container, {
symbol: symbol,
candles: candles,
entryPrice: entryPrice,
stopLoss: stopLoss,
tp1: tp1,
recTime: recTime,
tp1Time: tp1Time,
slTime: slTime,
actionStatus: actionStatus,
refPrice: 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);
}
// ====== 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 '<a class=\"card summary-card\" href=\"'+opportunityUrl(r)+'\">'+
'<div class=\"summary-top\"><div class=\"summary-main\"><div class=\"summary-symbol\"><div class=\"coin-icon\">'+base.slice(0,2).toUpperCase()+'</div><div><span class=\"coin-symbol\">'+base+'</span><div class=\"summary-meta\">'+fmtTime(r.rec_time)+' · '+signalStateText+' · '+duration+'</div></div></div></div><div class=\"summary-score\"><span class=\"hist-result-badge '+resultCls+'\">'+outcomeText+'</span><span class=\"score-badge '+st.cls+'\"><span class=\"score-num\">'+score+'</span><span class=\"score-label\">总分</span></span></div></div>'+
'<div class=\"summary-price-row\"><div class=\"summary-stat\"><span>执行状态</span><b>'+(hasPaper ? (paper.status === 'closed' ? '已完成' : '持有中') : '未执行')+'</b></div><div class=\"summary-stat\"><span>入场 / 退出</span><b>'+(hasPaper ? ('$'+fmtN(entryP)+' / '+(paper.status === 'closed' ? '$'+fmtN(exitP) : '持有中')) : '未执行 / 归档')+'</b></div><div class=\"summary-stat\"><span>交易收益</span><b class=\"'+(afterMove>=0?'green':'red')+'\">'+(hasPaper ? afterMoveSign+afterMove.toFixed(1)+'%' : '--')+'</b></div><div class=\"summary-stat\"><span>最大浮盈 / 回撤</span><b>'+maxPnl.toFixed(1)+'% / '+maxDd.toFixed(1)+'%</b></div></div>'+
'<div class=\"summary-decision '+(resultCls==='win'?'buy':resultCls==='loss'?'wait':'observe')+'\"><h3>'+outcomeDetail+'</h3><p>'+execText+'</p></div>'+
'<div class=\"summary-tags\"><div class=\"summary-chips\">'+(sigHtml||'<span class=\"summary-chip\">归档样本</span>')+'</div><span class=\"detail-link\">查看详情</span></div></a>';
}).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;
} 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 %}