1138 lines
83 KiB
HTML
1138 lines
83 KiB
HTML
{% 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); }
|
||
.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; }
|
||
|
||
/* ===== 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); }
|
||
.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 {'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 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 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">总分</span></span></div></div><div class="price-bar"><span class="price">$'+priceFmt+'</span>'+changeHtml+'</div>'+decisionHtml+renderStrategyDiagnostics(r)+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);
|
||
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 '<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\">→</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\">→</span><span class=\"price h-exit-price muted\">失效/归档</span>')+'<span class=\"hist-score-pill '+scoreCls+'\">总分 '+score+'</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>'+
|
||
renderStrategyDiagnostics(r)+
|
||
'<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 %}
|