fix: 修复复盘计划bug

master^2
Lxy 4 weeks ago
parent 90cccbf1df
commit 8a1918fc8d

@ -42,3 +42,28 @@ def init_analysis_db():
SymbolScoreV2, TradingPlanV2, SectorHeat, ReviewPlanV2, SymbolScoreV2, TradingPlanV2, SectorHeat, ReviewPlanV2,
) )
AnalysisBase.metadata.create_all(bind=analysis_engine) AnalysisBase.metadata.create_all(bind=analysis_engine)
# 迁移为已有表添加新增列SQLite 不支持 IF NOT EXISTS用异常处理
_migrate_add_columns()
def _migrate_add_columns():
"""为已有表添加新增列(兼容旧数据库)"""
import sqlite3
conn = sqlite3.connect(str(ANALYSIS_DB_PATH))
cursor = conn.cursor()
migrations = [
("symbol_scores_v2", "data_date", "VARCHAR(16)"),
("review_plans_v2", "actual_data_date", "VARCHAR(16)"),
("review_plans_v2", "data_date_matches", "INTEGER"),
]
for table, column, col_type in migrations:
try:
cursor.execute(f"ALTER TABLE {table} ADD COLUMN {column} {col_type}")
except sqlite3.OperationalError:
pass # 列已存在
conn.commit()
conn.close()

@ -200,6 +200,8 @@ class SymbolScoreV2(AnalysisBase):
s2 = Column(Float, nullable=True, comment="支撑位2") s2 = Column(Float, nullable=True, comment="支撑位2")
# 排名 # 排名
rank = Column(Integer, nullable=True, comment="综合排名") rank = Column(Integer, nullable=True, comment="综合排名")
# 实际数据日期
data_date = Column(String(16), nullable=True, comment="实际数据日期 YYYY-MM-DD日线最后一根K线的日期")
def __repr__(self): def __repr__(self):
return f"<SymbolScoreV2 {self.symbol} score={self.composite_score}>" return f"<SymbolScoreV2 {self.symbol} score={self.composite_score}>"
@ -267,6 +269,8 @@ class ReviewPlanV2(AnalysisBase):
neutral_count = Column(Integer, nullable=True, comment="震荡品种数") neutral_count = Column(Integer, nullable=True, comment="震荡品种数")
opportunity_count = Column(Integer, nullable=True, comment="交易机会数") opportunity_count = Column(Integer, nullable=True, comment="交易机会数")
risk_warnings = Column(JSON, nullable=True, comment="风险提示列表") risk_warnings = Column(JSON, nullable=True, comment="风险提示列表")
actual_data_date = Column(String(16), nullable=True, comment="实际数据日期 YYYY-MM-DD")
data_date_matches = Column(Integer, nullable=True, comment="数据日期是否与复盘日期一致 1=是 0=否")
created_at = Column(DateTime, nullable=False, default=datetime.now) created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self): def __repr__(self):

@ -3,6 +3,7 @@ V2 交易计划生成器 - 5维度综合评分 + 多周期共振分析
""" """
import json import json
import logging import logging
from collections import Counter
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional, Tuple from typing import Dict, List, Optional, Tuple
from datetime import datetime from datetime import datetime
@ -54,6 +55,21 @@ def load_symbols_config() -> Dict[str, str]:
return data.get("futures", {}) return data.get("futures", {})
def _get_candle_date(candle: dict) -> Optional[str]:
"""从K线数据中提取日期字符串 (YYYY-MM-DD)"""
dt_str = candle.get("datetime") or candle.get("time")
if not dt_str:
return None
try:
if isinstance(dt_str, str):
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
else:
dt = dt_str
return dt.strftime("%Y-%m-%d")
except Exception:
return None
def _get_candle_values(candles: List[dict]) -> Tuple[list, list, list, list, list]: def _get_candle_values(candles: List[dict]) -> Tuple[list, list, list, list, list]:
"""从K线列表提取 OHLCV""" """从K线列表提取 OHLCV"""
opens, highs, lows, closes, volumes = [], [], [], [], [] opens, highs, lows, closes, volumes = [], [], [], [], []
@ -322,6 +338,9 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict:
prev_close = d_closes[-2] if len(d_closes) >= 2 else today_close prev_close = d_closes[-2] if len(d_closes) >= 2 else today_close
today_volume = d_volumes[-1] today_volume = d_volumes[-1]
# 提取实际数据日期日线最后一根K线的日期
data_date = _get_candle_date(daily[-1])
# 近5日均量 # 近5日均量
recent_vols = d_volumes[-6:-1] if len(d_volumes) >= 6 else d_volumes[:-1] recent_vols = d_volumes[-6:-1] if len(d_volumes) >= 6 else d_volumes[:-1]
avg_vol_5 = sum(recent_vols) / len(recent_vols) if recent_vols else today_volume avg_vol_5 = sum(recent_vols) / len(recent_vols) if recent_vols else today_volume
@ -364,6 +383,7 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict:
raw_scores.append({ raw_scores.append({
"symbol": symbol, "symbol": symbol,
"name": name, "name": name,
"data_date": data_date,
"close_price": today_close, "close_price": today_close,
"prev_close": prev_close, "prev_close": prev_close,
"high_price": today_high, "high_price": today_high,
@ -488,6 +508,12 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict:
bear_count = sum(1 for s in raw_scores if s["trend_score"] < 30) bear_count = sum(1 for s in raw_scores if s["trend_score"] < 30)
neutral_count = len(raw_scores) - bull_count - bear_count neutral_count = len(raw_scores) - bull_count - bear_count
# 统计实际数据日期(取出现最多的日期作为基准数据日期)
date_counts = Counter(s.get("data_date") for s in raw_scores if s.get("data_date"))
actual_data_date = date_counts.most_common(1)[0][0] if date_counts else review_date_str
# 判断数据日期是否与复盘日期一致
data_date_matches = (actual_data_date == review_date_str)
# 核心结论 # 核心结论
top3 = raw_scores[:3] top3 = raw_scores[:3]
top_names = [f"{s['symbol']} {s['name']}" for s in top3] top_names = [f"{s['symbol']} {s['name']}" for s in top3]
@ -516,10 +542,18 @@ def generate_plan(db: Session, review_date_str: str, week_day: str) -> dict:
f"量比超2.5倍,需确认量能可持续性" f"量比超2.5倍,需确认量能可持续性"
) )
# 构建数据基准描述
if data_date_matches:
data_basis = f"{actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析"
else:
data_basis = f"复盘日期: {review_date_str} | 数据截至: {actual_data_date} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析"
return { return {
"review_date": review_date_str, "review_date": review_date_str,
"week_day": week_day, "week_day": week_day,
"data_basis": f"{review_date_str} 收盘 ({len(symbol_data)} 品种) | 多周期共振分析", "actual_data_date": actual_data_date,
"data_date_matches": data_date_matches,
"data_basis": data_basis,
"core_conclusion": core_conclusion, "core_conclusion": core_conclusion,
"bull_count": bull_count, "bull_count": bull_count,
"bear_count": bear_count, "bear_count": bear_count,
@ -617,6 +651,8 @@ def save_plan_to_db(db: Session, plan_data: dict) -> int:
existing.neutral_count = plan_data.get("neutral_count") existing.neutral_count = plan_data.get("neutral_count")
existing.opportunity_count = plan_data.get("opportunity_count") existing.opportunity_count = plan_data.get("opportunity_count")
existing.risk_warnings = plan_data.get("risk_warnings") existing.risk_warnings = plan_data.get("risk_warnings")
existing.actual_data_date = plan_data.get("actual_data_date")
existing.data_date_matches = 1 if plan_data.get("data_date_matches") else 0
else: else:
review_plan = ReviewPlanV2( review_plan = ReviewPlanV2(
review_date=review_date_str, review_date=review_date_str,
@ -628,6 +664,8 @@ def save_plan_to_db(db: Session, plan_data: dict) -> int:
neutral_count=plan_data.get("neutral_count"), neutral_count=plan_data.get("neutral_count"),
opportunity_count=plan_data.get("opportunity_count"), opportunity_count=plan_data.get("opportunity_count"),
risk_warnings=plan_data.get("risk_warnings"), risk_warnings=plan_data.get("risk_warnings"),
actual_data_date=plan_data.get("actual_data_date"),
data_date_matches=1 if plan_data.get("data_date_matches") else 0,
) )
db.add(review_plan) db.add(review_plan)
db.flush() db.flush()
@ -667,6 +705,7 @@ def save_plan_to_db(db: Session, plan_data: dict) -> int:
s1=pivots.get("s1"), s1=pivots.get("s1"),
s2=pivots.get("s2"), s2=pivots.get("s2"),
rank=s.get("rank"), rank=s.get("rank"),
data_date=s.get("data_date"),
) )
db.add(score_record) db.add(score_record)
@ -736,6 +775,8 @@ def get_plan_data(db: Session, review_plan_id: int) -> Optional[dict]:
"review_date": plan_meta.review_date, "review_date": plan_meta.review_date,
"week_day": plan_meta.week_day, "week_day": plan_meta.week_day,
"data_basis": plan_meta.data_basis, "data_basis": plan_meta.data_basis,
"actual_data_date": plan_meta.actual_data_date,
"data_date_matches": bool(plan_meta.data_date_matches),
"core_conclusion": plan_meta.core_conclusion, "core_conclusion": plan_meta.core_conclusion,
"bull_count": plan_meta.bull_count, "bull_count": plan_meta.bull_count,
"bear_count": plan_meta.bear_count, "bear_count": plan_meta.bear_count,
@ -755,6 +796,7 @@ def _score_to_dict(s: SymbolScoreV2) -> dict:
"id": s.id, "id": s.id,
"symbol": s.symbol, "symbol": s.symbol,
"name": s.name, "name": s.name,
"data_date": s.data_date,
"close_price": s.close_price, "close_price": s.close_price,
"prev_close": s.prev_close, "prev_close": s.prev_close,
"high_price": s.high_price, "high_price": s.high_price,

@ -410,6 +410,45 @@
.rv-tag-bear { background: rgba(255,59,48,0.12); color: var(--color-up); } .rv-tag-bear { background: rgba(255,59,48,0.12); color: var(--color-up); }
.rv-tag-neutral { background: #F5F5F7; color: var(--text-tertiary); } .rv-tag-neutral { background: #F5F5F7; color: var(--text-tertiary); }
/* 列表表格样式(重点关注/规避) */
.rv-list-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rv-list-table th { background: #F5F5F7; padding: 12px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; }
.rv-list-table td { padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); }
.rv-list-table tbody tr { cursor: pointer; transition: background 0.15s; }
.rv-yellow-table tbody tr:hover { background: rgba(255,149,0,0.05); }
.rv-red-table tbody tr:hover { background: rgba(255,59,48,0.05); }
/* 排序控件 */
.rv-sort-controls { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.rv-sort-btn { background: #fff; color: var(--text-secondary); border: 1px solid rgba(0,0,0,0.08); border-radius: 9999px; padding: 6px 14px; font-size: 12px; font-weight: 500; cursor: pointer; transition: all 0.2s; }
.rv-sort-btn:hover { background: #F5F5F7; border-color: var(--color-brand); }
.rv-sort-btn.active { background: var(--color-brand); color: #fff; border-color: var(--color-brand); }
/* 弹窗样式 */
.rv-modal-overlay { position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.5); backdrop-filter: blur(8px); z-index: 2000; display: none; align-items: center; justify-content: center; }
.rv-modal-overlay.show { display: flex; }
.rv-modal { background: #fff; border-radius: 20px; width: 90%; max-width: 700px; max-height: 85vh; overflow-y: auto; box-shadow: 0 25px 50px rgba(0,0,0,0.25); animation: rv-modal-in 0.25s ease; }
@keyframes rv-modal-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
.rv-modal-header { display: flex; justify-content: space-between; align-items: center; padding: 20px 24px; border-bottom: 1px solid rgba(0,0,0,0.06); }
.rv-modal-title { font-size: 18px; font-weight: 700; color: var(--text-primary); }
.rv-modal-close { width: 32px; height: 32px; border-radius: 50%; border: none; background: #F5F5F7; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; color: var(--text-secondary); transition: all 0.2s; }
.rv-modal-close:hover { background: #E5E5E7; }
.rv-modal-body { padding: 20px 24px; }
.rv-modal-section { margin-bottom: 20px; }
.rv-modal-section-title { font-size: 12px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.rv-modal-metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.rv-modal-metric { background: #F5F5F7; border-radius: 10px; padding: 12px; text-align: center; }
.rv-modal-metric .mm-label { font-size: 11px; color: var(--text-tertiary); display: block; margin-bottom: 4px; }
.rv-modal-metric .mm-val { font-size: 16px; font-weight: 700; color: var(--text-primary); }
.rv-modal-plan-box { background: #F5F5F7; border-radius: 12px; padding: 14px; }
.rv-modal-plan-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 13px; }
.rv-modal-plan-row .mp-label { color: var(--text-tertiary); }
.rv-modal-plan-row .mp-val { font-weight: 600; color: var(--text-primary); }
.rv-modal-plan-row .mp-val.stop { color: var(--color-up); }
.rv-modal-plan-row .mp-val.target { color: var(--color-down); }
.rv-modal-pivots { display: flex; flex-wrap: wrap; gap: 8px; }
.rv-data-date-tag { display: inline-block; font-size: 10px; color: var(--text-tertiary); background: #F5F5F7; padding: 2px 6px; border-radius: 4px; margin-left: 4px; }
.rv-ranking-table { width: 100%; border-collapse: collapse; font-size: 13px; } .rv-ranking-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.rv-ranking-table th { background: #F5F5F7; padding: 12px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; text-transform: uppercase; } .rv-ranking-table th { background: #F5F5F7; padding: 12px; text-align: left; font-weight: 600; color: var(--text-tertiary); font-size: 12px; text-transform: uppercase; }
.rv-ranking-table td { padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); } .rv-ranking-table td { padding: 10px 12px; border-bottom: 1px solid rgba(0,0,0,0.04); color: var(--text-primary); }
@ -744,18 +783,61 @@
<!-- 重点关注 --> <!-- 重点关注 -->
<section id="rv-yellow-section" class="rv-section" style="display: none;"> <section id="rv-yellow-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">👀 重点关注品种</h2> <h2 class="rv-section-title">👀 重点关注品种</h2>
<div id="rv-yellow-list" class="rv-watch-list"></div> <div style="overflow-x: auto;">
<table class="rv-list-table rv-yellow-table">
<thead>
<tr>
<th>#</th>
<th>品种</th>
<th>收盘价</th>
<th>涨跌幅</th>
<th>综合评分</th>
<th>振幅%</th>
<th>量比</th>
<th>方向</th>
<th>数据日期</th>
</tr>
</thead>
<tbody id="rv-yellow-list"></tbody>
</table>
</div>
</section> </section>
<!-- 规避 --> <!-- 规避 -->
<section id="rv-red-section" class="rv-section" style="display: none;"> <section id="rv-red-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">🔴 规避品种</h2> <h2 class="rv-section-title">🔴 规避品种</h2>
<div id="rv-red-list" class="rv-avoid-list"></div> <div style="overflow-x: auto;">
<table class="rv-list-table rv-red-table">
<thead>
<tr>
<th>#</th>
<th>品种</th>
<th>收盘价</th>
<th>涨跌幅</th>
<th>综合评分</th>
<th>振幅%</th>
<th>量比</th>
<th>方向</th>
<th>数据日期</th>
</tr>
</thead>
<tbody id="rv-red-list"></tbody>
</table>
</div>
</section> </section>
<!-- 全品种排名 --> <!-- 全品种排名 -->
<section id="rv-table-section" class="rv-section" style="display: none;"> <section id="rv-table-section" class="rv-section" style="display: none;">
<h2 class="rv-section-title">📊 全品种综合排名</h2> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
<h2 class="rv-section-title" style="margin-bottom: 0;">📊 全品种综合排名</h2>
<div class="rv-sort-controls" id="rv-sort-controls">
<span style="font-size: 12px; color: var(--text-tertiary); margin-right: 6px;">排序:</span>
<button class="rv-sort-btn active" data-sort="composite_score" data-order="desc">综合评分</button>
<button class="rv-sort-btn" data-sort="activity_score" data-order="desc">活跃度</button>
<button class="rv-sort-btn" data-sort="volume_ratio" data-order="desc">量比</button>
<button class="rv-sort-btn" data-sort="direction_tag" data-order="asc">方向</button>
</div>
</div>
<div style="overflow-x: auto;"> <div style="overflow-x: auto;">
<table id="rv-ranking-table" class="rv-ranking-table"></table> <table id="rv-ranking-table" class="rv-ranking-table"></table>
</div> </div>
@ -788,6 +870,19 @@
</div> </div>
</div> </div>
<!-- 复盘品种详情弹窗(移到 review-view 外部) -->
<div id="rv-detail-modal" class="rv-modal-overlay" onclick="if(event.target===this)rvCloseDetailModal()">
<div class="rv-modal">
<div class="rv-modal-header">
<span id="rv-modal-title" class="rv-modal-title">品种详情</span>
<button class="rv-modal-close" onclick="rvCloseDetailModal()"></button>
</div>
<div id="rv-modal-body" class="rv-modal-body">
<!-- 由 JS 动态填充 -->
</div>
</div>
</div>
<!-- AI分析详情对话框 --> <!-- AI分析详情对话框 -->
<div class="modal-overlay" id="ai-analysis-modal"> <div class="modal-overlay" id="ai-analysis-modal">
<div class="modal-content modal-large"> <div class="modal-content modal-large">

@ -2211,6 +2211,8 @@ function initReviewPlan() {
rvHandleClear(); rvHandleClear();
}); });
rvSetupSortButtons();
rvLoadLatestPlan(); rvLoadLatestPlan();
} }
@ -2462,18 +2464,19 @@ function rvRenderGreenSection(plans, scores) {
const score = scores.find(s => s.symbol === p.symbol) || {}; const score = scores.find(s => s.symbol === p.symbol) || {};
const dirText = p.direction === 'long' ? '做多' : '做空'; const dirText = p.direction === 'long' ? '做多' : '做空';
const dirColor = p.direction === 'long' ? 'var(--color-down)' : 'var(--color-up)'; const dirColor = p.direction === 'long' ? 'var(--color-down)' : 'var(--color-up)';
const dataDateTag = score.data_date ? `<span class="rv-data-date-tag">${score.data_date}</span>` : '';
return ` return `
<div class="rv-opp-card"> <div class="rv-opp-card" onclick="rvShowDetail('${p.symbol}','${(p.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<div class="rv-opp-header"> <div class="rv-opp-header">
<span class="rv-opp-symbol">${p.symbol} ${p.name}</span> <span class="rv-opp-symbol">${p.symbol} ${p.name}</span>
<span class="rv-opp-score">${p.composite_score}</span> <span class="rv-opp-score">${p.composite_score}</span>
</div> </div>
<div class="rv-opp-info"> <div class="rv-opp-info">
<div class="rv-opp-info-item"><span class="rv-opp-info-label">方向</span><span style="color: ${dirColor}; font-weight: 600;">${dirText}</span></div> <div class="rv-opp-info-item"><span class="rv-opp-info-label">方向</span><span style="color: ${dirColor}; font-weight: 600;">${dirText}</span></div>
<div class="rv-opp-info-item"><span class="rv-opp-info-label">收盘价</span><span>${score.close_price || '-'} ${dataDateTag}</span></div>
<div class="rv-opp-info-item"><span class="rv-opp-info-label">涨跌幅</span><span>${score.change_pct || 0}%</span></div> <div class="rv-opp-info-item"><span class="rv-opp-info-label">涨跌幅</span><span>${score.change_pct || 0}%</span></div>
<div class="rv-opp-info-item"><span class="rv-opp-info-label">量比</span><span>${score.volume_ratio || '-'}</span></div> <div class="rv-opp-info-item"><span class="rv-opp-info-label">量比</span><span>${score.volume_ratio || '-'}</span></div>
<div class="rv-opp-info-item"><span class="rv-opp-info-label">振幅</span><span>${score.amplitude_pct || 0}%</span></div>
</div> </div>
<div class="rv-score-bars"> <div class="rv-score-bars">
${rvScoreBar(p.amplitude_score || 0, '振幅')} ${rvScoreBar(p.amplitude_score || 0, '振幅')}
@ -2497,8 +2500,8 @@ function rvRenderGreenSection(plans, scores) {
function rvRenderYellowSection(scores) { function rvRenderYellowSection(scores) {
const section = document.getElementById('rv-yellow-section'); const section = document.getElementById('rv-yellow-section');
const list = document.getElementById('rv-yellow-list'); const tbody = document.getElementById('rv-yellow-list');
if (!section || !list) return; if (!section || !tbody) return;
const yellowItems = scores.filter(s => s.category === 'yellow'); const yellowItems = scores.filter(s => s.category === 'yellow');
if (yellowItems.length === 0) { if (yellowItems.length === 0) {
@ -2506,22 +2509,30 @@ function rvRenderYellowSection(scores) {
return; return;
} }
list.innerHTML = yellowItems.map(s => ` tbody.innerHTML = yellowItems.map((s, i) => {
<div class="rv-watch-item"> const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
<span class="rv-item-symbol">${s.symbol} ${s.name}</span> return `
<span class="rv-item-score">${s.composite_score}</span> <tr onclick="rvShowDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
${rvDirectionTag(s.direction_tag)} <td>${i + 1}</td>
<span style="color: ${s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)'};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct}%</span> <td><strong>${s.symbol}</strong> ${s.name}</td>
</div> <td>${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</td>
`).join(''); <td style="color: ${changeColor};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</td>
<td><strong>${s.composite_score}</strong></td>
<td>${s.amplitude_pct || 0}%</td>
<td>${s.volume_ratio || '-'}</td>
<td>${rvDirectionTag(s.direction_tag)}</td>
<td style="font-size:11px;color:var(--text-tertiary);">${s.data_date || '-'}</td>
</tr>
`;
}).join('');
section.style.display = ''; section.style.display = '';
} }
function rvRenderRedSection(scores) { function rvRenderRedSection(scores) {
const section = document.getElementById('rv-red-section'); const section = document.getElementById('rv-red-section');
const list = document.getElementById('rv-red-list'); const tbody = document.getElementById('rv-red-list');
if (!section || !list) return; if (!section || !tbody) return;
const redItems = scores.filter(s => s.category === 'red'); const redItems = scores.filter(s => s.category === 'red');
if (redItems.length === 0) { if (redItems.length === 0) {
@ -2529,18 +2540,29 @@ function rvRenderRedSection(scores) {
return; return;
} }
list.innerHTML = redItems.map(s => ` tbody.innerHTML = redItems.map((s, i) => {
<div class="rv-avoid-item"> const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
<span class="rv-item-symbol">${s.symbol} ${s.name}</span> return `
<span class="rv-item-score">${s.composite_score}</span> <tr onclick="rvShowDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
${rvDirectionTag(s.direction_tag)} <td>${i + 1}</td>
<span style="color: ${s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)'};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct}%</span> <td><strong>${s.symbol}</strong> ${s.name}</td>
</div> <td>${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</td>
`).join(''); <td style="color: ${changeColor};">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</td>
<td><strong>${s.composite_score}</strong></td>
<td>${s.amplitude_pct || 0}%</td>
<td>${s.volume_ratio || '-'}</td>
<td>${rvDirectionTag(s.direction_tag)}</td>
<td style="font-size:11px;color:var(--text-tertiary);">${s.data_date || '-'}</td>
</tr>
`;
}).join('');
section.style.display = ''; section.style.display = '';
} }
let rvCurrentSortField = 'composite_score';
let rvCurrentSortOrder = 'desc';
function rvRenderRankingTable(scores) { function rvRenderRankingTable(scores) {
const section = document.getElementById('rv-table-section'); const section = document.getElementById('rv-table-section');
const table = document.getElementById('rv-ranking-table'); const table = document.getElementById('rv-ranking-table');
@ -2551,6 +2573,25 @@ function rvRenderRankingTable(scores) {
return; return;
} }
// 排序
const sorted = [...scores].sort((a, b) => {
let va = a[rvCurrentSortField];
let vb = b[rvCurrentSortField];
// 方向排序特殊处理
if (rvCurrentSortField === 'direction_tag') {
const dirOrder = { '强多': 1, '偏多': 2, '多头共振': 1, '震荡': 3, '偏空': 4, '强空': 5, '空头共振': 5 };
va = dirOrder[va] || 3;
vb = dirOrder[vb] || 3;
}
if (va == null) va = -Infinity;
if (vb == null) vb = -Infinity;
if (rvCurrentSortOrder === 'asc') return va > vb ? 1 : va < vb ? -1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
let html = ` let html = `
<thead> <thead>
<tr> <tr>
@ -2564,20 +2605,21 @@ function rvRenderRankingTable(scores) {
<th>趋势分</th> <th>趋势分</th>
<th>方向</th> <th>方向</th>
<th>活跃度</th> <th>活跃度</th>
<th>数据日期</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
`; `;
scores.forEach(s => { sorted.forEach((s, i) => {
const scoreColor = rvScoreBarColor(s.composite_score); const scoreColor = rvScoreBarColor(s.composite_score);
const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)'; const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
html += ` html += `
<tr> <tr onclick="rvShowDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<td>${s.rank || '-'}</td> <td>${i + 1}</td>
<td><strong>${s.symbol}</strong> ${s.name}</td> <td><strong>${s.symbol}</strong> ${s.name}</td>
<td>${s.close_price || '-'}</td> <td>${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</td>
<td> <td>
<strong>${s.composite_score}</strong> <strong>${s.composite_score}</strong>
<div class="rv-table-score-bar"> <div class="rv-table-score-bar">
@ -2590,6 +2632,7 @@ function rvRenderRankingTable(scores) {
<td>${s.trend_score || 0}</td> <td>${s.trend_score || 0}</td>
<td>${rvDirectionTag(s.direction_tag)}</td> <td>${rvDirectionTag(s.direction_tag)}</td>
<td>${s.activity_score || 0}</td> <td>${s.activity_score || 0}</td>
<td style="font-size:11px;color:var(--text-tertiary);">${s.data_date || '-'}</td>
</tr> </tr>
`; `;
}); });
@ -2599,6 +2642,47 @@ function rvRenderRankingTable(scores) {
section.style.display = ''; section.style.display = '';
} }
let rvSortButtonsInitialized = false;
function rvSetupSortButtons() {
if (rvSortButtonsInitialized) return;
const controls = document.getElementById('rv-sort-controls');
if (!controls) return;
controls.addEventListener('click', function (e) {
const btn = e.target.closest('.rv-sort-btn');
if (!btn) return;
const field = btn.dataset.sort;
const defaultOrder = btn.dataset.order || 'desc';
// 切换排序方向
if (rvCurrentSortField === field) {
rvCurrentSortOrder = rvCurrentSortOrder === 'desc' ? 'asc' : 'desc';
} else {
rvCurrentSortField = field;
rvCurrentSortOrder = defaultOrder;
}
// 更新按钮状态
controls.querySelectorAll('.rv-sort-btn').forEach(b => {
b.classList.remove('active');
b.textContent = b.textContent.replace(/ [▲▼]$/, '');
});
btn.classList.add('active');
const arrow = rvCurrentSortOrder === 'desc' ? ' ▼' : ' ▲';
btn.textContent = btn.textContent + arrow;
// 重新渲染
if (rvCurrentPlanData) {
rvRenderRankingTable(rvCurrentPlanData.scores);
}
});
rvSortButtonsInitialized = true;
}
function rvRenderDetails(scores) { function rvRenderDetails(scores) {
const section = document.getElementById('rv-details-section'); const section = document.getElementById('rv-details-section');
const grid = document.getElementById('rv-details-grid'); const grid = document.getElementById('rv-details-grid');
@ -2692,6 +2776,94 @@ function rvHideAllSections() {
}); });
} }
// ==================== 复盘品种详情弹窗 ====================
function rvShowDetail(symbol, name) {
const scores = rvCurrentPlanData ? rvCurrentPlanData.scores : [];
const plans = rvCurrentPlanData ? rvCurrentPlanData.plans : [];
const s = scores.find(x => x.symbol === symbol);
if (!s) return;
const plan = plans.find(p => p.symbol === symbol);
const modal = document.getElementById('rv-detail-modal');
if (!modal) return;
document.getElementById('rv-modal-title').textContent = `${symbol} ${name || s.name || ''}`;
const body = document.getElementById('rv-modal-body');
// 交易计划部分
let planHtml = '';
if (plan) {
const dirText = plan.direction === 'long' ? '做多' : '做空';
const dirColor = plan.direction === 'long' ? 'var(--color-down)' : 'var(--color-up)';
planHtml = `
<div class="rv-modal-section">
<div class="rv-modal-section-title">交易计划</div>
<div class="rv-modal-plan-box">
<div class="rv-modal-plan-row"><span class="mp-label">方向</span><span class="mp-val" style="color:${dirColor}">${dirText}</span></div>
<div class="rv-modal-plan-row"><span class="mp-label">入场区间</span><span class="mp-val">${rvFormatPrice(plan.entry_low, s.close_price)} ~ ${rvFormatPrice(plan.entry_high, s.close_price)}</span></div>
<div class="rv-modal-plan-row"><span class="mp-label">止损位</span><span class="mp-val stop">${rvFormatPrice(plan.stop_loss, s.close_price)}</span></div>
<div class="rv-modal-plan-row"><span class="mp-label">目标一</span><span class="mp-val target">${rvFormatPrice(plan.target1, s.close_price)}</span></div>
<div class="rv-modal-plan-row"><span class="mp-label">目标二</span><span class="mp-val target">${rvFormatPrice(plan.target2, s.close_price)}</span></div>
<div class="rv-modal-plan-row"><span class="mp-label">触发条件</span><span class="mp-val">${plan.trigger || '-'}</span></div>
</div>
</div>
`;
}
const changeColor = s.change_pct >= 0 ? 'var(--color-down)' : 'var(--color-up)';
body.innerHTML = `
<div class="rv-modal-section">
<div class="rv-modal-section-title">基本信息</div>
<div class="rv-modal-metrics-grid">
<div class="rv-modal-metric"><span class="mm-label">收盘价</span><span class="mm-val">${s.close_price || '-'}${s.data_date ? '<span class="rv-data-date-tag">' + s.data_date + '</span>' : ''}</span></div>
<div class="rv-modal-metric"><span class="mm-label">涨跌幅</span><span class="mm-val" style="color:${changeColor}">${s.change_pct >= 0 ? '+' : ''}${s.change_pct || 0}%</span></div>
<div class="rv-modal-metric"><span class="mm-label">振幅</span><span class="mm-val">${s.amplitude_pct || 0}%</span></div>
<div class="rv-modal-metric"><span class="mm-label">量比</span><span class="mm-val">${s.volume_ratio || '-'}</span></div>
<div class="rv-modal-metric"><span class="mm-label">综合评分</span><span class="mm-val" style="color:${rvScoreBarColor(s.composite_score)}"><b>${s.composite_score}</b></span></div>
<div class="rv-modal-metric"><span class="mm-label">排名</span><span class="mm-val">#${s.rank || '-'}</span></div>
</div>
</div>
${planHtml}
<div class="rv-modal-section">
<div class="rv-modal-section-title">评分明细</div>
<div class="rv-modal-metrics-grid">
<div class="rv-modal-metric"><span class="mm-label">振幅评分</span><span class="mm-val">${s.amplitude_score || 0}</span></div>
<div class="rv-modal-metric"><span class="mm-label">量能评分</span><span class="mm-val">${s.volume_score || 0}</span></div>
<div class="rv-modal-metric"><span class="mm-label">趋势评分</span><span class="mm-val">${s.trend_score || 0}</span></div>
<div class="rv-modal-metric"><span class="mm-label">活跃度</span><span class="mm-val">${s.activity_score || 0}</span></div>
</div>
</div>
<div class="rv-modal-section">
<div class="rv-modal-section-title">多周期趋势</div>
<div class="rv-modal-metrics-grid">
<div class="rv-modal-metric"><span class="mm-label">60分钟</span><span class="mm-val">${s.trend_60m || 0}</span></div>
<div class="rv-modal-metric"><span class="mm-label">15分钟</span><span class="mm-val">${s.trend_15m || 0}</span></div>
<div class="rv-modal-metric"><span class="mm-label">5分钟</span><span class="mm-val">${s.trend_5m || 0}</span></div>
<div class="rv-modal-metric"><span class="mm-label">方向</span><span class="mm-val">${rvDirectionTag(s.direction_tag)}</span></div>
</div>
</div>
<div class="rv-modal-section">
<div class="rv-modal-section-title">枢轴点位</div>
<div class="rv-modal-pivots">
<span class="rv-pivot-tag resist">R2: ${rvFormatPrice(s.r2, s.close_price)}</span>
<span class="rv-pivot-tag resist">R1: ${rvFormatPrice(s.r1, s.close_price)}</span>
<span class="rv-pivot-tag pivot">P: ${rvFormatPrice(s.pivot, s.close_price)}</span>
<span class="rv-pivot-tag support">S1: ${rvFormatPrice(s.s1, s.close_price)}</span>
<span class="rv-pivot-tag support">S2: ${rvFormatPrice(s.s2, s.close_price)}</span>
</div>
</div>
`;
modal.classList.add('show');
}
function rvCloseDetailModal() {
const modal = document.getElementById('rv-detail-modal');
if (modal) modal.classList.remove('show');
}
// ==================== 自选品种功能 ==================== // ==================== 自选品种功能 ====================
function renderWatchedList() { function renderWatchedList() {

@ -371,6 +371,28 @@ a:hover {
} }
/* ===== Section Titles ===== */ /* ===== Section Titles ===== */
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--s2);
}
.section-header .section-title {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.section-badge {
font-size: 14px;
margin-right: 6px;
}
.section-title { .section-title {
font-size: 22px; font-size: 22px;
font-weight: 800; font-weight: 800;
@ -1522,4 +1544,218 @@ a:hover {
width: 40px; width: 40px;
height: 40px; height: 40px;
} }
.modal-metrics-grid {
grid-template-columns: repeat(2, 1fr);
}
.section-header {
flex-direction: column;
align-items: flex-start;
}
.sort-controls {
width: 100%;
}
}
/* ===== 数据日期标注 ===== */
.data-date-notice {
background: rgba(245, 158, 11, 0.15);
border: 1px solid rgba(245, 158, 11, 0.4);
border-radius: 8px;
padding: 10px 16px;
margin: 10px 0;
color: var(--yw);
font-size: 0.9rem;
text-align: center;
}
.data-date-tag {
display: inline-block;
font-size: 0.7rem;
color: var(--t2);
background: var(--s2);
padding: 1px 6px;
border-radius: 4px;
margin-left: 4px;
vertical-align: middle;
white-space: nowrap;
}
.data-date-cell {
font-size: 0.75rem;
color: var(--t2);
white-space: nowrap;
}
/* ===== 排序控件 ===== */
.sort-controls {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.sort-label {
font-size: 13px;
color: var(--t2);
margin-right: 4px;
}
.sort-btn {
background: var(--s2);
color: var(--t2);
border: 1px solid transparent;
border-radius: 100px;
padding: 5px 14px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all .2s;
white-space: nowrap;
}
.sort-btn:hover {
background: #475569;
color: var(--tx);
}
.sort-btn.active {
background: var(--ac);
color: #fff;
border-color: var(--ac);
}
/* ===== 列表表格(重点关注/规避) ===== */
.list-table-wrap {
overflow-x: auto;
margin-bottom: 20px;
}
.list-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.list-table thead th {
background: var(--s1);
color: var(--t2);
font-weight: 600;
padding: 10px 12px;
text-align: left;
white-space: nowrap;
border-bottom: 2px solid var(--s2);
font-size: 12px;
}
.list-table tbody tr {
border-bottom: 1px solid var(--s2);
transition: background .15s;
}
.list-table tbody tr:hover {
background: rgba(59, 130, 246, 0.06);
}
.list-table tbody td {
padding: 10px 12px;
vertical-align: middle;
}
.yellow-table tbody tr:hover {
background: rgba(245, 158, 11, 0.06);
}
.red-table tbody tr:hover {
background: rgba(239, 68, 68, 0.06);
}
/* ===== 弹窗指标网格 ===== */
.modal-metrics-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.modal-metric {
background: var(--s2);
border-radius: 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.mm-label {
font-size: 11px;
color: var(--t2);
}
.mm-val {
font-size: 15px;
font-weight: 700;
color: var(--tx);
}
/* ===== 弹窗交易计划 ===== */
.modal-plan-box {
background: var(--s2);
border-radius: 12px;
padding: 14px;
display: flex;
flex-direction: column;
gap: 8px;
}
.modal-plan-row {
display: flex;
justify-content: space-between;
align-items: center;
}
.mp-label {
font-size: 12px;
color: var(--t2);
}
.mp-val {
font-size: 14px;
font-weight: 700;
color: var(--tx);
}
.mp-val.stop {
color: var(--rd);
}
.mp-val.target {
color: var(--gn);
}
/* ===== 弹窗枢轴点位 ===== */
.modal-pivots {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
/* ===== 绿色卡片可点击样式 ===== */
.opp-card {
transition: transform .15s, box-shadow .15s;
}
.opp-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
}
/* ===== 排名表格行可点击 ===== */
.ranking-table tbody tr {
transition: background .15s;
}
.ranking-table tbody tr:hover {
background: rgba(59, 130, 246, 0.06);
} }

@ -90,8 +90,25 @@
<span class="section-badge badge-yellow"></span> <span class="section-badge badge-yellow"></span>
<h2 class="section-title">重点关注品种</h2> <h2 class="section-title">重点关注品种</h2>
</div> </div>
<div id="yellow-list" class="item-list"> <div class="list-table-wrap">
<table class="list-table yellow-table">
<thead>
<tr>
<th>#</th>
<th>品种</th>
<th>收盘价</th>
<th>涨跌幅</th>
<th>综合评分</th>
<th>振幅%</th>
<th>量比</th>
<th>方向</th>
<th>数据日期</th>
</tr>
</thead>
<tbody id="yellow-list-body">
<!-- 由 JS 动态填充 --> <!-- 由 JS 动态填充 -->
</tbody>
</table>
</div> </div>
</section> </section>
@ -101,8 +118,25 @@
<span class="section-badge badge-red"></span> <span class="section-badge badge-red"></span>
<h2 class="section-title">规避品种</h2> <h2 class="section-title">规避品种</h2>
</div> </div>
<div id="red-list" class="item-list"> <div class="list-table-wrap">
<table class="list-table red-table">
<thead>
<tr>
<th>#</th>
<th>品种</th>
<th>收盘价</th>
<th>涨跌幅</th>
<th>综合评分</th>
<th>振幅%</th>
<th>量比</th>
<th>方向</th>
<th>数据日期</th>
</tr>
</thead>
<tbody id="red-list-body">
<!-- 由 JS 动态填充 --> <!-- 由 JS 动态填充 -->
</tbody>
</table>
</div> </div>
</section> </section>
@ -110,6 +144,13 @@
<section id="table-section" class="content-section table-section"> <section id="table-section" class="content-section table-section">
<div class="section-header"> <div class="section-header">
<h2 class="section-title">全品种排名</h2> <h2 class="section-title">全品种排名</h2>
<div class="sort-controls" id="sort-controls">
<span class="sort-label">排序:</span>
<button class="sort-btn active" data-sort="composite_score" data-order="desc">综合评分</button>
<button class="sort-btn" data-sort="activity_score" data-order="desc">活跃度</button>
<button class="sort-btn" data-sort="volume_ratio" data-order="desc">量比</button>
<button class="sort-btn" data-sort="direction_tag" data-order="asc">方向</button>
</div>
</div> </div>
<div class="table-wrapper"> <div class="table-wrapper">
<table id="ranking-table" class="ranking-table"> <table id="ranking-table" class="ranking-table">
@ -119,10 +160,13 @@
<th>品种</th> <th>品种</th>
<th>最新价</th> <th>最新价</th>
<th>涨跌幅</th> <th>涨跌幅</th>
<th>成交量</th> <th>综合评分</th>
<th>持仓量</th> <th>振幅%</th>
<th>量比</th>
<th>趋势</th> <th>趋势</th>
<th>信号</th> <th>方向</th>
<th>活跃度</th>
<th>数据日期</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -166,6 +210,19 @@
</div> </div>
</main> </main>
<!-- 品种详情弹窗 -->
<div id="detail-modal" class="modal-overlay" onclick="if(event.target===this)closeModal()">
<div class="modal">
<div class="modal-header">
<span id="modal-title" class="modal-title">品种详情</span>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<div id="modal-body" class="modal-body">
<!-- 由 JS 动态填充 -->
</div>
</div>
</div>
<script src="/static/review_plan.js"></script> <script src="/static/review_plan.js"></script>
</body> </body>
</html> </html>

@ -26,6 +26,8 @@ function setupEventListeners() {
document.getElementById('btn-clear').addEventListener('click', function () { document.getElementById('btn-clear').addEventListener('click', function () {
handleClear(); handleClear();
}); });
setupSortButtons();
} }
// ==================== 生成复盘 ==================== // ==================== 生成复盘 ====================
@ -191,11 +193,18 @@ function renderHero(meta) {
const el = document.getElementById('hero-section'); const el = document.getElementById('hero-section');
if (!el) return; if (!el) return;
// 当数据日期与复盘日期不一致时,显示提示
let dataDateNotice = '';
if (meta.actual_data_date && !meta.data_date_matches) {
dataDateNotice = `<div class="data-date-notice">⚠ 当前页面收盘价、涨跌幅为 <b>${meta.actual_data_date}</b>(最近交易日)数据,非 ${meta.review_date} 当日数据</div>`;
}
el.innerHTML = ` el.innerHTML = `
<div class="hero-title"> <div class="hero-title">
<h1>${meta.review_date} <span class="hero-weekday">${meta.week_day || ''}</span></h1> <h1>${meta.review_date} <span class="hero-weekday">${meta.week_day || ''}</span></h1>
</div> </div>
<div class="hero-subtitle">${meta.data_basis || ''}</div> <div class="hero-subtitle">${meta.data_basis || ''}</div>
${dataDateNotice}
<div class="hero-conclusion">${meta.core_conclusion || ''}</div> <div class="hero-conclusion">${meta.core_conclusion || ''}</div>
`; `;
showSection('hero-section'); showSection('hero-section');
@ -264,8 +273,11 @@ function renderGreenSection(plans, scores) {
const el = document.getElementById('green-section'); const el = document.getElementById('green-section');
if (!el) return; if (!el) return;
const grid = document.getElementById('green-grid');
if (!grid) return;
if (!plans || plans.length === 0) { if (!plans || plans.length === 0) {
el.innerHTML = `<div class="section-title green-title">🟢 交易机会(绿色区)</div><div class="empty-hint">暂无交易机会</div>`; grid.innerHTML = `<div class="empty-hint">暂无交易机会</div>`;
showSection('green-section'); showSection('green-section');
return; return;
} }
@ -277,16 +289,17 @@ function renderGreenSection(plans, scores) {
const s = scoreMap[plan.symbol] || {}; const s = scoreMap[plan.symbol] || {};
const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short'; const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short';
const dirText = plan.direction === 'long' ? '做多' : '做空'; const dirText = plan.direction === 'long' ? '做多' : '做空';
const dataDateTag = s.data_date ? `<span class="data-date-tag" title="数据日期">${s.data_date}</span>` : '';
return ` return `
<div class="opp-card green-card"> <div class="opp-card green-card" onclick="showDetail('${plan.symbol}','${(plan.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<div class="opp-card-header"> <div class="opp-card-header">
<a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${plan.symbol}','${plan.name || ''}')">${plan.symbol} ${plan.name || ''}</a> <span class="symbol-link">${plan.symbol} ${plan.name || ''}</span>
<span class="score-badge">${plan.composite_score}</span> <span class="score-badge">${plan.composite_score}</span>
</div> </div>
<div class="opp-card-rows"> <div class="opp-card-rows">
<div class="opp-row"><span class="opp-label">方向</span><span class="${dirClass}">${dirText}</span></div> <div class="opp-row"><span class="opp-label">方向</span><span class="${dirClass}">${dirText}</span></div>
<div class="opp-row"><span class="opp-label">收盘价</span><span>${formatPrice(s.close_price)}</span></div> <div class="opp-row"><span class="opp-label">收盘价</span><span>${formatPrice(s.close_price)} ${dataDateTag}</span></div>
<div class="opp-row"><span class="opp-label">涨跌幅</span>${formatChange(s.change_pct)}</div> <div class="opp-row"><span class="opp-label">涨跌幅</span>${formatChange(s.change_pct)}</div>
<div class="opp-row"><span class="opp-label">量比</span><span>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div> <div class="opp-row"><span class="opp-label">量比</span><span>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
</div> </div>
@ -306,142 +319,164 @@ function renderGreenSection(plans, scores) {
</div>`; </div>`;
}).join(''); }).join('');
el.innerHTML = ` grid.innerHTML = cards;
<div class="section-title green-title">🟢 交易机会绿色区</div>
<div class="opp-grid">${cards}</div>
`;
showSection('green-section'); showSection('green-section');
} }
// ==================== 黄色关注区 ==================== // ==================== 黄色关注区 ====================
function renderYellowSection(scores) { function renderYellowSection(scores) {
const el = document.getElementById('yellow-section'); const tbody = document.getElementById('yellow-list-body');
if (!el) return; if (!tbody) return;
const yellowItems = (scores || []).filter(s => s.category === 'yellow'); const yellowItems = (scores || []).filter(s => s.category === 'yellow');
if (yellowItems.length === 0) { if (yellowItems.length === 0) {
el.innerHTML = `<div class="section-title yellow-title">🟡 关注列表(黄色区)</div><div class="empty-hint">暂无关注品种</div>`; tbody.innerHTML = `<tr><td colspan="9" class="empty-hint">暂无关注品种</td></tr>`;
showSection('yellow-section'); showSection('yellow-section');
return; return;
} }
const items = yellowItems.map(s => ` tbody.innerHTML = yellowItems.map((s, i) => `
<div class="watch-item yellow-item"> <tr onclick="showDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<div class="watch-left"> <td>${i + 1}</td>
<a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${s.symbol}','${s.name || ''}')">${s.symbol} ${s.name || ''}</a> <td><span class="symbol-link">${s.symbol} ${s.name || ''}</span></td>
${directionTag(s.direction_tag)} <td>${formatPrice(s.close_price)}</td>
</div> <td>${formatChange(s.change_pct)}</td>
<div class="watch-mid"> <td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td>
${scoreBar(s.composite_score)} <td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td>
</div> <td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td>
<div class="watch-right"> <td>${directionTag(s.direction_tag)}</td>
<span class="watch-score">${s.composite_score}</span> <td class="data-date-cell">${s.data_date || '-'}</td>
${formatChange(s.change_pct)} </tr>
</div>
</div>
`).join(''); `).join('');
el.innerHTML = `
<div class="section-title yellow-title">🟡 关注列表黄色区</div>
<div class="watch-list">${items}</div>
`;
showSection('yellow-section'); showSection('yellow-section');
} }
// ==================== 红色风险区 ==================== // ==================== 红色风险区 ====================
function renderRedSection(scores) { function renderRedSection(scores) {
const el = document.getElementById('red-section'); const tbody = document.getElementById('red-list-body');
if (!el) return; if (!tbody) return;
const redItems = (scores || []).filter(s => s.category === 'red'); const redItems = (scores || []).filter(s => s.category === 'red');
if (redItems.length === 0) { if (redItems.length === 0) {
el.innerHTML = `<div class="section-title red-title">🔴 规避列表(红色区)</div><div class="empty-hint">暂无规避品种</div>`; tbody.innerHTML = `<tr><td colspan="9" class="empty-hint">暂无规避品种</td></tr>`;
showSection('red-section'); showSection('red-section');
return; return;
} }
const items = redItems.map(s => ` tbody.innerHTML = redItems.map((s, i) => `
<div class="watch-item red-item"> <tr onclick="showDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<div class="watch-left"> <td>${i + 1}</td>
<a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${s.symbol}','${s.name || ''}')">${s.symbol} ${s.name || ''}</a> <td><span class="symbol-link">${s.symbol} ${s.name || ''}</span></td>
${directionTag(s.direction_tag)} <td>${formatPrice(s.close_price)}</td>
</div> <td>${formatChange(s.change_pct)}</td>
<div class="watch-mid"> <td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td>
${scoreBar(s.composite_score)} <td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td>
</div> <td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td>
<div class="watch-right"> <td>${directionTag(s.direction_tag)}</td>
<span class="watch-score">${s.composite_score}</span> <td class="data-date-cell">${s.data_date || '-'}</td>
${formatChange(s.change_pct)} </tr>
</div>
</div>
`).join(''); `).join('');
el.innerHTML = `
<div class="section-title red-title">🔴 规避列表红色区</div>
<div class="watch-list">${items}</div>
`;
showSection('red-section'); showSection('red-section');
} }
// ==================== 排名表格 ==================== // ==================== 排名表格(支持多列排序) ====================
let currentSortField = 'composite_score';
let currentSortOrder = 'desc';
function renderRankingTable(scores) { function renderRankingTable(scores) {
const el = document.getElementById('ranking-table'); const table = document.getElementById('ranking-table');
if (!el) return; if (!table) return;
const tbody = table.querySelector('tbody');
if (!tbody) return;
if (!scores || scores.length === 0) { if (!scores || scores.length === 0) {
el.innerHTML = `<div class="section-title">📊 综合排名</div><div class="empty-hint">暂无排名数据</div>`; tbody.innerHTML = `<tr><td colspan="11" class="empty-hint">暂无排名数据</td></tr>`;
showSection('ranking-table'); showSection('table-section');
return; return;
} }
const rows = scores.map((s, i) => { // 排序
const rank = s.rank || (i + 1); const sorted = [...scores].sort((a, b) => {
let va = a[currentSortField];
let vb = b[currentSortField];
// 方向排序特殊处理
if (currentSortField === 'direction_tag') {
const dirOrder = { '强多': 1, '偏多': 2, '多头共振': 1, '震荡': 3, '偏空': 4, '强空': 5, '空头共振': 5 };
va = dirOrder[va] || 3;
vb = dirOrder[vb] || 3;
}
if (va == null) va = -Infinity;
if (vb == null) vb = -Infinity;
if (currentSortOrder === 'asc') return va > vb ? 1 : va < vb ? -1 : 0;
return va < vb ? 1 : va > vb ? -1 : 0;
});
tbody.innerHTML = sorted.map((s, i) => {
const rank = i + 1;
const rankClass = rank <= 3 ? 'rank-top' : ''; const rankClass = rank <= 3 ? 'rank-top' : '';
return ` return `
<tr> <tr onclick="showDetail('${s.symbol}','${(s.name || '').replace(/'/g, "\\'")}')" style="cursor:pointer">
<td class="${rankClass}">${rank}</td> <td class="${rankClass}">${rank}</td>
<td><a href="javascript:void(0)" class="symbol-link" onclick="showDetail('${s.symbol}','${s.name || ''}')">${s.symbol}</a></td> <td><span class="symbol-link">${s.symbol} ${s.name || ''}</span></td>
<td>${s.name || ''}</td>
<td>${formatPrice(s.close_price)}</td> <td>${formatPrice(s.close_price)}</td>
<td>${formatChange(s.change_pct)}</td>
<td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td> <td><div class="table-score-bar-wrap">${scoreBar(s.composite_score)}<span class="table-score-num">${s.composite_score}</span></div></td>
<td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td> <td>${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</td>
<td>${formatChange(s.change_pct)}</td>
<td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td> <td>${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</td>
<td>${s.trend_score != null ? s.trend_score : '-'}</td> <td>${s.trend_score != null ? s.trend_score : '-'}</td>
<td>${directionTag(s.direction_tag)}</td> <td>${directionTag(s.direction_tag)}</td>
<td>${s.activity_score != null ? s.activity_score : '-'}</td> <td>${s.activity_score != null ? s.activity_score : '-'}</td>
<td class="data-date-cell">${s.data_date || '-'}</td>
</tr>`; </tr>`;
}).join(''); }).join('');
el.innerHTML = ` showSection('table-section');
<div class="section-title">📊 综合排名</div> }
<div class="table-wrap">
<table class="ranking-tbl"> function setupSortButtons() {
<thead> const controls = document.getElementById('sort-controls');
<tr> if (!controls) return;
<th>#</th>
<th>代码</th> controls.addEventListener('click', function (e) {
<th>名称</th> const btn = e.target.closest('.sort-btn');
<th>收盘价</th> if (!btn) return;
<th>综合评分</th>
<th>振幅%</th> const field = btn.dataset.sort;
<th>涨跌幅</th> const defaultOrder = btn.dataset.order || 'desc';
<th>量比</th>
<th>趋势</th> // 切换排序方向
<th>方向</th> if (currentSortField === field) {
<th>活跃度</th> currentSortOrder = currentSortOrder === 'desc' ? 'asc' : 'desc';
</tr> } else {
</thead> currentSortField = field;
<tbody>${rows}</tbody> currentSortOrder = defaultOrder;
</table> }
</div>
`; // 更新按钮状态
showSection('ranking-table'); controls.querySelectorAll('.sort-btn').forEach(b => {
b.classList.remove('active');
b.textContent = b.textContent.replace(/ [▲▼]$/, '');
});
btn.classList.add('active');
const arrow = currentSortOrder === 'desc' ? ' ▼' : ' ▲';
btn.textContent = btn.textContent + arrow;
// 重新渲染
if (currentPlanData) {
renderRankingTable(currentPlanData.scores);
}
});
} }
// ==================== 品种详情卡片 ==================== // ==================== 品种详情卡片 ====================
@ -471,7 +506,7 @@ function renderDetails(scores) {
<span class="detail-score">${s.composite_score}</span> <span class="detail-score">${s.composite_score}</span>
</div> </div>
<div class="detail-metrics"> <div class="detail-metrics">
<div class="detail-metric"><span class="dm-label">收盘价</span><span class="dm-val">${formatPrice(s.close_price)}</span></div> <div class="detail-metric"><span class="dm-label">收盘价</span><span class="dm-val">${formatPrice(s.close_price)}${s.data_date ? ' <span class="data-date-tag" title="">' + s.data_date + '</span>' : ''}</span></div>
<div class="detail-metric"><span class="dm-label">振幅</span><span class="dm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div> <div class="detail-metric"><span class="dm-label">振幅</span><span class="dm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div>
<div class="detail-metric"><span class="dm-label">涨跌幅</span><span class="dm-val">${formatChange(s.change_pct)}</span></div> <div class="detail-metric"><span class="dm-label">涨跌幅</span><span class="dm-val">${formatChange(s.change_pct)}</span></div>
<div class="detail-metric"><span class="dm-label">量比</span><span class="dm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div> <div class="detail-metric"><span class="dm-label">量比</span><span class="dm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
@ -543,20 +578,51 @@ function renderSectors(sectors) {
function showDetail(symbol, name) { function showDetail(symbol, name) {
const scores = currentPlanData ? currentPlanData.scores : []; const scores = currentPlanData ? currentPlanData.scores : [];
const plans = currentPlanData ? currentPlanData.plans : [];
const s = scores.find(x => x.symbol === symbol); const s = scores.find(x => x.symbol === symbol);
if (!s) return; if (!s) return;
// 查找对应的交易计划
const plan = plans.find(p => p.symbol === symbol);
const modal = document.getElementById('detail-modal'); const modal = document.getElementById('detail-modal');
if (!modal) return; if (!modal) return;
document.getElementById('modal-title').textContent = `${symbol} ${name || s.name || ''}`; document.getElementById('modal-title').textContent = `${symbol} ${name || s.name || ''}`;
const body = document.getElementById('modal-body'); const body = document.getElementById('modal-body');
// 交易计划部分(仅绿色品种有)
let planHtml = '';
if (plan) {
const dirClass = plan.direction === 'long' ? 'dir-long' : 'dir-short';
const dirText = plan.direction === 'long' ? '做多' : '做空';
planHtml = `
<div class="modal-section">
<div class="modal-section-title">交易计划</div>
<div class="modal-plan-box">
<div class="modal-plan-row"><span class="mp-label">方向</span><span class="${dirClass} mp-val">${dirText}</span></div>
<div class="modal-plan-row"><span class="mp-label">入场区间</span><span class="mp-val">${formatPrice(plan.entry_low)} - ${formatPrice(plan.entry_high)}</span></div>
<div class="modal-plan-row"><span class="mp-label">止损</span><span class="mp-val stop">${formatPrice(plan.stop_loss)}</span></div>
<div class="modal-plan-row"><span class="mp-label">目标1</span><span class="mp-val target">${formatPrice(plan.target1)}</span></div>
<div class="modal-plan-row"><span class="mp-label">目标2</span><span class="mp-val target">${formatPrice(plan.target2)}</span></div>
<div class="modal-plan-row"><span class="mp-label">触发条件</span><span class="mp-val">${plan.trigger || '-'}</span></div>
</div>
</div>`;
}
const pivotTags = [];
if (s.pivot != null) pivotTags.push(`<span class="pivot-tag pivot">Pivot ${formatPrice(s.pivot)}</span>`);
if (s.r1 != null) pivotTags.push(`<span class="pivot-tag r1">R1 ${formatPrice(s.r1)}</span>`);
if (s.r2 != null) pivotTags.push(`<span class="pivot-tag r2">R2 ${formatPrice(s.r2)}</span>`);
if (s.s1 != null) pivotTags.push(`<span class="pivot-tag s1">S1 ${formatPrice(s.s1)}</span>`);
if (s.s2 != null) pivotTags.push(`<span class="pivot-tag s2">S2 ${formatPrice(s.s2)}</span>`);
body.innerHTML = ` body.innerHTML = `
<div class="modal-section"> <div class="modal-section">
<div class="modal-section-title">基本信息</div> <div class="modal-section-title">基本信息</div>
<div class="modal-metrics-grid"> <div class="modal-metrics-grid">
<div class="modal-metric"><span class="mm-label">收盘价</span><span class="mm-val">${formatPrice(s.close_price)}</span></div> <div class="modal-metric"><span class="mm-label">收盘价</span><span class="mm-val">${formatPrice(s.close_price)}${s.data_date ? ' <span class="data-date-tag">' + s.data_date + '</span>' : ''}</span></div>
<div class="modal-metric"><span class="mm-label">振幅</span><span class="mm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div> <div class="modal-metric"><span class="mm-label">振幅</span><span class="mm-val">${s.amplitude_pct != null ? s.amplitude_pct.toFixed(2) + '%' : '-'}</span></div>
<div class="modal-metric"><span class="mm-label">涨跌幅</span><span class="mm-val">${formatChange(s.change_pct)}</span></div> <div class="modal-metric"><span class="mm-label">涨跌幅</span><span class="mm-val">${formatChange(s.change_pct)}</span></div>
<div class="modal-metric"><span class="mm-label">量比</span><span class="mm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div> <div class="modal-metric"><span class="mm-label">量比</span><span class="mm-val">${s.volume_ratio != null ? s.volume_ratio.toFixed(2) : '-'}</span></div>
@ -564,6 +630,7 @@ function showDetail(symbol, name) {
<div class="modal-metric"><span class="mm-label">排名</span><span class="mm-val">#${s.rank || '-'}</span></div> <div class="modal-metric"><span class="mm-label">排名</span><span class="mm-val">#${s.rank || '-'}</span></div>
</div> </div>
</div> </div>
${planHtml}
<div class="modal-section"> <div class="modal-section">
<div class="modal-section-title">评分明细</div> <div class="modal-section-title">评分明细</div>
<div class="modal-metrics-grid"> <div class="modal-metrics-grid">
@ -584,13 +651,7 @@ function showDetail(symbol, name) {
</div> </div>
<div class="modal-section"> <div class="modal-section">
<div class="modal-section-title">枢轴点位</div> <div class="modal-section-title">枢轴点位</div>
<div class="modal-pivots"> <div class="modal-pivots">${pivotTags.join('')}</div>
${s.r2 != null ? `<span class="pivot-tag r2">R2 ${formatPrice(s.r2)}</span>` : ''}
${s.r1 != null ? `<span class="pivot-tag r1">R1 ${formatPrice(s.r1)}</span>` : ''}
${s.pivot != null ? `<span class="pivot-tag pivot">Pivot ${formatPrice(s.pivot)}</span>` : ''}
${s.s1 != null ? `<span class="pivot-tag s1">S1 ${formatPrice(s.s1)}</span>` : ''}
${s.s2 != null ? `<span class="pivot-tag s2">S2 ${formatPrice(s.s2)}</span>` : ''}
</div>
</div> </div>
`; `;
@ -657,7 +718,7 @@ function hideAllSections() {
const ids = [ const ids = [
'hero-section', 'stats-grid', 'risk-banner', 'hero-section', 'stats-grid', 'risk-banner',
'green-section', 'yellow-section', 'red-section', 'green-section', 'yellow-section', 'red-section',
'ranking-table', 'details-section', 'sectors-section', 'table-section', 'details-section', 'sectors-section',
'summary-content' 'summary-content'
]; ];
ids.forEach(id => hideSection(id)); ids.forEach(id => hideSection(id));

Binary file not shown.

Binary file not shown.
Loading…
Cancel
Save