fix: 增加复盘计划功能;修复ai分析超时问题;自选功能移到页签上

alphaFuthures
Lxy 2 weeks ago
parent 36c33eb005
commit 56c87386bf

@ -36,5 +36,5 @@ def init_analysis_db():
# 确保导入所有模型类,使其注册到 AnalysisBase
from app import analysis_models
# 直接导入 analysis_models 模块中的所有类
from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings, AIAnalysisCache
from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings, AIAnalysisCache, ReviewDate, SymbolRanking, TradingPlan
AnalysisBase.metadata.create_all(bind=analysis_engine)

@ -100,3 +100,57 @@ class AIAnalysisCache(AnalysisBase):
def __repr__(self):
return f"<AIAnalysisCache {self.symbol} {self.created_at}>"
class ReviewDate(AnalysisBase):
"""复盘日期表"""
__tablename__ = "review_dates"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date = Column(String(16), nullable=False, unique=True, index=True, comment="复盘日期 YYYY-MM-DD")
week_day = Column(String(8), nullable=True, comment="星期")
created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self):
return f"<ReviewDate {self.review_date}>"
class SymbolRanking(AnalysisBase):
"""品种排名表"""
__tablename__ = "symbol_rankings"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
rank_type = Column(String(32), nullable=False, comment="排名类型: volume/amplitude/change/open_interest")
rank = Column(Integer, nullable=False, comment="排名")
value = Column(String(64), nullable=True, comment="数值(如成交量、振幅等)")
price = Column(String(32), nullable=True, comment="价格")
change_pct = Column(String(16), nullable=True, comment="涨跌幅")
def __repr__(self):
return f"<SymbolRanking {self.symbol} {self.rank_type} rank={self.rank}>"
class TradingPlan(AnalysisBase):
"""交易计划表"""
__tablename__ = "trading_plans"
id = Column(Integer, primary_key=True, autoincrement=True)
review_date_id = Column(Integer, nullable=False, index=True, comment="关联复盘日期ID")
symbol = Column(String(32), nullable=False, comment="品种合约代码")
name = Column(String(64), nullable=True, comment="品种名称")
plan_type = Column(String(16), nullable=False, comment="计划类型: long/short")
score = Column(Integer, nullable=False, comment="评分 0-100")
logic = Column(Text, nullable=True, comment="多空逻辑")
reason = Column(Text, nullable=True, comment="入选理由")
entry_price = Column(String(64), nullable=True, comment="入场价位")
stop_loss = Column(String(32), nullable=True, comment="止损价位")
take_profit = Column(String(64), nullable=True, comment="止盈价位")
confidence = Column(String(16), nullable=True, comment="置信度: 高/中高/中/低")
position_suggestion = Column(String(32), nullable=True, comment="仓位建议")
created_at = Column(DateTime, nullable=False, default=datetime.now)
def __repr__(self):
return f"<TradingPlan {self.symbol} {self.plan_type} score={self.score}>"

@ -3,6 +3,7 @@
"""
import logging
from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
@ -222,3 +223,26 @@ def cache_status(symbol: str, db: Session = Depends(get_db)):
"cached_periods": periods_info,
"status": "ok",
}
@router.get("/latest-timestamps")
def get_latest_timestamps(db: Session = Depends(get_db)):
"""
获取所有品种的最新数据时间戳
"""
from app.models import SymbolTimestamp
timestamps = db.query(SymbolTimestamp).all()
data = []
for ts in timestamps:
data.append({
"symbol": ts.symbol,
"data_type": ts.data_type,
"last_refresh_at": ts.last_refresh_at.isoformat() if ts.last_refresh_at else None
})
return {
"success": True,
"data": data
}

@ -13,7 +13,7 @@ from fastapi.responses import FileResponse, RedirectResponse
from app.database import engine, Base
from app.user_models import Base as UserBase
from app.config import HOST, PORT, LOG_LEVEL
from app.api import data, tasks, config, futures_analysis, ai_config, auth
from app.api import data, tasks, config, futures_analysis, ai_config, auth, review_plan
from app.services.scheduler import start_scheduler, stop_scheduler
# 配置日志
@ -120,6 +120,7 @@ app.include_router(tasks.router, prefix="/api/v1")
app.include_router(config.router, prefix="/api/v1")
app.include_router(futures_analysis.router, prefix="/api/v1")
app.include_router(ai_config.router, prefix="/api/v1")
app.include_router(review_plan.router, prefix="/api/v1")
@app.get("/futures-analysis")
@ -134,6 +135,12 @@ def ai_config_page():
return FileResponse(str(STATIC_DIR / "ai_config.html"))
@app.get("/review-plan")
def review_plan_page():
"""复盘计划页面"""
return FileResponse(str(STATIC_DIR / "review_plan.html"))
@app.get("/api/v1/health")
def health():
return {"status": "ok", "service": "market-data-buffer"}

@ -182,6 +182,13 @@
/* 详情视图样式 */
.view { display: none; }
.view.active { display: block; }
#review-view.active { display: block; }
#watched-view.active { display: block; }
.watched-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 24px; padding: 20px 24px; background: #fff; border-radius: 20px; box-shadow: var(--shadow-sm); }
.watched-header h2 { font-size: 20px; font-weight: 700; }
.watched-count { font-size: 14px; color: var(--text-secondary); background: #F5F5F7; padding: 6px 14px; border-radius: 8px; font-weight: 500; }
.empty-hint { font-size: 13px; color: var(--text-tertiary); margin-top: 8px; }
.detail-actions { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.back-btn, .refresh-btn {
@ -328,6 +335,58 @@
.header { padding: 0 16px; }
.container { padding: 16px; }
}
/* 复盘计划样式 */
.review-toolbar { display: flex; gap: 16px; margin-bottom: 24px; align-items: center; justify-content: center; }
.date-selector { background: #fff; border: 1px solid rgba(0,0,0,0.05); border-radius: 12px; padding: 0 16px; height: 44px; font-size: 14px; color: var(--text-primary); box-shadow: var(--shadow-sm); cursor: pointer; min-width: 180px; font-family: inherit; outline: none; }
.date-selector:hover, .date-selector:focus { border-color: var(--color-brand); }
.summary-header { background: #fff; border-radius: 20px; padding: 20px 24px; box-shadow: var(--shadow-sm); margin-bottom: 24px; display: flex; justify-content: space-between; align-items: center; }
.summary-header h2 { font-size: 20px; font-weight: 700; }
.current-date { font-size: 14px; color: var(--text-secondary); background: #F5F5F7; padding: 6px 14px; border-radius: 8px; font-weight: 500; }
.summary-layout { display: grid; grid-template-columns: 1fr 360px; gap: 20px; }
.summary-left { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.rank-section { background: #fff; border-radius: 16px; padding: 16px; box-shadow: var(--shadow-sm); }
.rank-title { font-size: 13px; font-weight: 600; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid rgba(0,0,0,0.05); }
.rank-list { display: flex; flex-direction: column; gap: 6px; }
.rank-row { display: flex; justify-content: space-between; align-items: center; padding: 7px 0; border-bottom: 1px dashed rgba(0,0,0,0.04); cursor: pointer; }
.rank-row:last-child { border-bottom: none; }
.rank-rank { width: 20px; font-size: 12px; font-weight: 700; color: var(--text-tertiary); text-align: center; }
.rank-rank.top { color: var(--color-brand); }
.rank-name { flex: 1; margin-left: 8px; font-size: 13px; font-weight: 500; }
.rank-val { font-size: 13px; font-weight: 600; }
.rank-val.up { color: var(--color-up); }
.rank-val.down { color: var(--color-down); }
.summary-right { display: flex; flex-direction: column; gap: 16px; }
.plan-header { font-size: 15px; font-weight: 700; color: var(--color-ai); }
.plan-list { display: flex; flex-direction: column; gap: 6px; }
.plan-list-item { background: #fff; border-radius: 12px; padding: 12px 16px; border: 1px solid rgba(0,0,0,0.05); cursor: pointer; display: flex; justify-content: space-between; align-items: center; }
.plan-list-item:hover { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.plan-list-item.expanded { border-color: var(--color-ai); box-shadow: var(--shadow-md); }
.plan-list-left { display: flex; align-items: center; gap: 10px; }
.plan-list-code { font-size: 14px; font-weight: 700; }
.plan-badge { font-size: 10px; padding: 4px 12px; border-radius: 9999px; font-weight: 700; }
.plan-badge.long { background: rgba(52,199,89,0.15); color: var(--color-down); }
.plan-badge.short { background: rgba(255,59,48,0.15); color: var(--color-up); }
.plan-list-score { font-size: 12px; font-weight: 600; color: var(--color-ai); background: #F8F5FF; padding: 2px 8px; border-radius: 6px; }
.plan-list-arrow { font-size: 12px; color: var(--text-tertiary); transition: transform .2s; }
.plan-list-item.expanded .plan-list-arrow { transform: rotate(180deg); }
.plan-detail { max-height: 0; overflow: hidden; transition: max-height .35s ease; }
.plan-detail.open { max-height: 600px; }
.plan-card { background: #fff; border-radius: 16px; padding: 20px; box-shadow: var(--shadow-md); margin-bottom: 16px; border: 1px solid rgba(0,0,0,0.05); position: relative; }
.plan-card::before { content: "AI"; position: absolute; top: -9px; left: 16px; background: var(--color-ai); color: #fff; font-size: 10px; padding: 2px 8px; border-radius: 8px; font-weight: 600; }
.plan-ai { background: #F8F5FF; border-radius: 12px; padding: 12px; margin-bottom: 12px; font-size: 12px; line-height: 1.5; color: var(--text-secondary); }
.plan-ai strong { color: var(--color-ai); }
.plan-targets { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 12px; }
.plan-target { background: #F5F5F7; border-radius: 10px; padding: 10px 8px; text-align: center; }
.plan-target-label { font-size: 10px; color: var(--text-tertiary); margin-bottom: 4px; }
.plan-target-val { font-size: 14px; font-weight: 700; }
.plan-target-val.green { color: var(--color-down); }
.plan-target-val.red { color: var(--color-up); }
.plan-target-val.neutral { color: var(--text-primary); }
.plan-note { font-size: 10px; color: var(--text-tertiary); text-align: center; margin-top: 8px; }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; gap: 16px; }
.empty-icon { font-size: 64px; opacity: 0.3; }
.empty-text { font-size: 16px; color: var(--text-tertiary); }
</style>
</head>
<body>
@ -339,6 +398,7 @@
<a href="#" class="nav-item" data-page="watched">自选</a>
<a href="#" class="nav-item" data-page="market">市场概览</a>
<a href="#" class="nav-item" data-page="risk">风险预警</a>
<a href="#" class="nav-item" data-page="review">复盘计划</a>
</div>
<div class="live-badge">
<div class="dot"></div> LIVE
@ -370,7 +430,6 @@
</div>
<div class="pills">
<button class="pill active" data-category="all">全部 <span id="count-all">32</span></button>
<button class="pill" data-category="watched">自选 <span id="count-watched">0</span></button>
<button class="pill" data-category="energy">能源</button>
<button class="pill" data-category="metal">金属</button>
<button class="pill" data-category="agriculture">农产品</button>
@ -545,6 +604,75 @@
</div>
</div>
</div>
<!-- 自选视图 -->
<div id="watched-view" class="view">
<div class="watched-header">
<h2>我的自选</h2>
<div class="watched-count" id="watched-total-count">0 个品种</div>
</div>
<div id="watched-grid" class="grid">
<!-- 动态生成 -->
</div>
<div id="watched-empty" class="empty-state" style="display: none;">
<div class="empty-icon"></div>
<div class="empty-text">暂无自选品种</div>
<div class="empty-hint">在品种列表中点击 ★ 添加自选</div>
</div>
</div>
<!-- 复盘计划视图 -->
<div id="review-view" class="view">
<div class="review-toolbar">
<input type="date" id="review-date-selector" class="date-selector" />
<button id="btn-review-plan" class="btn-primary" style="padding: 10px 20px; border-radius: 12px; font-size: 14px; font-weight: 600; cursor: pointer; border: none; background: var(--color-brand); color: #fff; display: flex; align-items: center; gap: 8px;">
<span>📊</span>
<span>复盘与计划</span>
</button>
</div>
<div id="review-summary-content" class="summary-content" style="display: none;">
<div class="summary-header">
<h2>复盘与交易计划</h2>
<div class="current-date" id="review-current-date"></div>
</div>
<div class="summary-layout">
<div class="summary-left">
<div class="rank-section">
<div class="rank-title">成交量排名 Top 5</div>
<div class="rank-list" id="review-rank-volume"></div>
</div>
<div class="rank-section">
<div class="rank-title">振幅排名 Top 5</div>
<div class="rank-list" id="review-rank-amplitude"></div>
</div>
<div class="rank-section">
<div class="rank-title">涨跌幅排名 Top 5</div>
<div class="rank-list" id="review-rank-change"></div>
</div>
<div class="rank-section">
<div class="rank-title">持仓量排名 Top 5</div>
<div class="rank-list" id="review-rank-oi"></div>
</div>
</div>
<div class="summary-right">
<div class="plan-header">交易机会</div>
<div class="plan-list" id="review-plan-list"></div>
<div class="plan-note">以上由 AI 基于多维因子自动生成,仅供参考,不构成投资建议。</div>
</div>
</div>
</div>
<div id="review-empty-state" class="empty-state">
<div class="empty-icon">📊</div>
<div class="empty-text">请选择日期或点击"复盘与计划"按钮</div>
</div>
</div>
</div>
<!-- AI分析详情对话框 -->

@ -124,6 +124,25 @@ function initEventListeners() {
}
});
});
// 导航项点击事件
document.querySelectorAll('.nav-item[data-page]').forEach(navItem => {
navItem.addEventListener('click', function(e) {
e.preventDefault();
const page = this.dataset.page;
document.querySelectorAll('.nav-item').forEach(item => item.classList.remove('active'));
this.classList.add('active');
if (page === 'review') {
showReviewView();
} else if (page === 'watched') {
showWatchedView();
} else {
showListView();
}
});
});
}
function closeModal(modalId) {
@ -133,12 +152,40 @@ function closeModal(modalId) {
function showListView() {
document.getElementById('list-view').classList.add('active');
document.getElementById('detail-view').classList.remove('active');
document.getElementById('review-view').classList.remove('active');
document.getElementById('watched-view').classList.remove('active');
if (klineChart) {
klineChart.dispose();
klineChart = null;
}
}
function showWatchedView() {
document.getElementById('list-view').classList.remove('active');
document.getElementById('detail-view').classList.remove('active');
document.getElementById('review-view').classList.remove('active');
document.getElementById('watched-view').classList.add('active');
if (klineChart) {
klineChart.dispose();
klineChart = null;
}
renderWatchedList();
}
function showReviewView() {
document.getElementById('list-view').classList.remove('active');
document.getElementById('detail-view').classList.remove('active');
document.getElementById('review-view').classList.add('active');
if (klineChart) {
klineChart.dispose();
klineChart = null;
}
// 初始化复盘计划功能
initReviewPlan();
}
async function showDetailView(symbol) {
currentSymbol = symbol;
document.getElementById('list-view').classList.remove('active');
@ -231,7 +278,6 @@ async function loadWatchedSymbols() {
const data = await response.json();
if (data.success) {
watchedSymbols = data.data.map(s => s.symbol);
document.getElementById('count-watched').textContent = watchedSymbols.length;
}
} catch (error) {
console.error('加载自选列表失败:', error);
@ -263,14 +309,13 @@ async function toggleWatch(symbol, name, event) {
showToast('success', '已添加自选', `${name}(${symbol}) 已添加到自选列表`);
}
}
document.getElementById('count-watched').textContent = watchedSymbols.length;
// 重新渲染当前视图
const activePill = document.querySelector('.pill.active');
const category = activePill ? activePill.dataset.category : 'all';
if (category === 'watched') {
filterByCategory('watched');
} else {
// 如果当前视图不是自选页面,重新渲染主网格
if (category !== 'watched') {
renderFuturesGrid(getCurrentFilteredData());
}
} catch (error) {
@ -283,8 +328,13 @@ function toggleFav(symbol, name, event) {
event.stopPropagation();
// 调用原有的toggleWatch函数处理API请求和数据更新
// toggleWatch 会重新渲染网格,星标状态会根据 watchedSymbols 自动更新
toggleWatch(symbol, name, event);
// 如果当前在自选页面,刷新自选列表
const watchedView = document.getElementById('watched-view');
if (watchedView && watchedView.classList.contains('active')) {
setTimeout(() => renderWatchedList(), 200);
}
}
function getCurrentFilteredData() {
@ -295,9 +345,6 @@ function getCurrentFilteredData() {
function filterDataByCategory(data, category) {
if (category === 'all') return data;
if (category === 'watched') {
return data.filter(item => watchedSymbols.includes(item.symbol));
}
const categoryMap = {
'energy': ['SC', 'FU', 'LU', 'BU', 'RU', 'NR'],
'metal': ['AU', 'AG', 'CU', 'AL', 'ZN', 'NI', 'SN', 'PB', 'SS', 'RB', 'HC', 'I', 'J', 'JM', 'AO', 'SI', 'LC', 'PS'],
@ -1728,8 +1775,16 @@ async function runAIAnalysis(forceRefresh = false) {
</div>
`;
// 设置5分钟超时
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 300000); // 5分钟 = 300000毫秒
try {
const response = await fetch(`${API_BASE}/ai-analysis/${currentSymbol}?force_refresh=${forceRefresh}`);
const response = await fetch(`${API_BASE}/ai-analysis/${currentSymbol}?force_refresh=${forceRefresh}`, {
signal: controller.signal
});
clearTimeout(timeoutId);
const data = await response.json();
if (data.success) {
@ -1745,13 +1800,24 @@ async function runAIAnalysis(forceRefresh = false) {
showToast('error', '分析失败', data.error || 'AI分析失败');
}
} catch (error) {
clearTimeout(timeoutId);
console.error('AI分析请求失败:', error);
content.innerHTML = `
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
<p>网络错误请检查网络连接</p>
</div>
`;
showToast('error', '请求失败', '网络错误,请稍后重试');
if (error.name === 'AbortError') {
content.innerHTML = `
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
<p>分析超时请稍后重试或联系管理员</p>
</div>
`;
showToast('error', '请求超时', 'AI分析耗时过长请稍后重试');
} else {
content.innerHTML = `
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
<p>网络错误请检查网络连接</p>
</div>
`;
showToast('error', '请求失败', '网络错误,请稍后重试');
}
} finally {
btn.disabled = false;
btn.textContent = '智能分析';
@ -2122,3 +2188,470 @@ function showAIDetailModal() {
document.getElementById('ai-analysis-modal').classList.add('active');
}
// ==================== 复盘计划功能 ====================
const REVIEW_API_BASE = '/api/v1/review';
const DATA_API_BASE = '/api/v1/data';
let currentReviewDateId = null;
function initReviewPlan() {
const reviewDateSelector = document.getElementById('review-date-selector');
const btnReviewPlan = document.getElementById('btn-review-plan');
if (reviewDateSelector) {
reviewDateSelector.addEventListener('change', function(e) {
const selectedDate = e.target.value;
if (selectedDate) {
loadReviewByDate(selectedDate);
} else {
hideReviewSummary();
}
});
}
if (btnReviewPlan) {
btnReviewPlan.addEventListener('click', function() {
handleReviewAndPlan();
});
}
loadReviewDates();
}
function handleReviewAndPlan() {
const now = new Date();
const hour = now.getHours();
const dayOfWeek = now.getDay();
const isTradingTime = checkIsTradingTime(dayOfWeek, hour);
if (isTradingTime) {
alert('当前时间在交易时间内,请在每日收盘后进行复盘与计划。\n可复盘与计划的时间为\n1. 非交易日(周六、周日)\n2. 交易日的 0:00 - 9:00\n3. 交易日的 15:00 - 21:00');
return;
}
checkDataConsistency();
}
function checkIsTradingTime(dayOfWeek, hour) {
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
if (isWeekend) {
return false;
}
// 交易日 9:00 之前可以复盘
if (hour < 9) {
return false;
}
// 交易日 15:00 - 21:00 可以复盘
if (hour >= 15 && hour < 21) {
return false;
}
return true;
}
function checkDataConsistency() {
const today = new Date();
const todayStr = today.toISOString().split('T')[0];
fetch(`${DATA_API_BASE}/latest-timestamps`)
.then(res => res.json())
.then(data => {
if (!data.success || !data.data || data.data.length === 0) {
alert('交易数据库中没有数据,请先进行数据更新');
return;
}
let latestDate = null;
data.data.forEach(item => {
if (item.last_refresh_at) {
const dateStr = item.last_refresh_at.split('T')[0];
if (!latestDate || dateStr > latestDate) {
latestDate = dateStr;
}
}
});
if (!latestDate) {
alert('交易数据库中没有数据,请先进行数据更新');
return;
}
if (latestDate < todayStr) {
alert(`交易数据库中的最新数据日期为 ${latestDate},与当前日期 ${todayStr} 不一致。\n请先进行数据更新,确保数据是最新的。`);
return;
}
loadOrCreateReviewPlan(todayStr);
})
.catch(err => {
console.error('检查数据一致性失败:', err);
alert('无法获取交易数据状态,请稍后重试');
});
}
function loadOrCreateReviewPlan(todayStr) {
fetch(`${REVIEW_API_BASE}/dates?limit=1`)
.then(res => res.json())
.then(data => {
if (data.success && data.data.length > 0) {
const latestReview = data.data[0];
if (latestReview.review_date === todayStr) {
currentReviewDateId = latestReview.id;
loadReviewSummary(currentReviewDateId);
} else {
createReviewPlan(todayStr);
}
} else {
createReviewPlan(todayStr);
}
})
.catch(err => {
console.error('加载复盘日期失败:', err);
createReviewPlan(todayStr);
});
}
function createReviewPlan(todayStr) {
const weekDays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
const now = new Date();
const weekDay = weekDays[now.getDay() - 1] || "";
alert('开始执行复盘与交易计划生成...');
fetch(`${REVIEW_API_BASE}/plans/batch`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
review_date: todayStr,
week_day: weekDay,
rankings: [],
plans: []
})
})
.then(res => res.json())
.then(data => {
if (data.success) {
currentReviewDateId = data.data.review_date_id;
loadReviewDates();
loadReviewSummary(currentReviewDateId);
alert('复盘与计划创建成功');
} else {
alert('创建复盘计划失败');
}
})
.catch(err => {
console.error('创建复盘计划失败:', err);
alert('创建失败,请稍后重试');
});
}
function loadReviewDates() {
fetch(`${REVIEW_API_BASE}/dates?limit=20`)
.then(res => res.json())
.then(data => {
if (data.success && data.data.length > 0) {
const lastDate = data.data[0];
document.getElementById('review-date-selector').value = lastDate.review_date;
currentReviewDateId = lastDate.id;
loadReviewSummary(lastDate.id);
}
})
.catch(err => {
console.error('加载复盘日期失败:', err);
});
}
function loadReviewByDate(selectedDate) {
fetch(`${REVIEW_API_BASE}/dates?limit=100`)
.then(res => res.json())
.then(data => {
if (data.success && data.data.length > 0) {
const matchedReview = data.data.find(d => d.review_date === selectedDate);
if (matchedReview) {
currentReviewDateId = matchedReview.id;
loadReviewSummary(currentReviewDateId);
} else {
hideReviewSummary();
document.getElementById('review-summary-content').style.display = 'block';
document.getElementById('review-empty-state').style.display = 'none';
document.getElementById('review-rank-volume').innerHTML = '';
document.getElementById('review-rank-amplitude').innerHTML = '';
document.getElementById('review-rank-change').innerHTML = '';
document.getElementById('review-rank-oi').innerHTML = '';
document.getElementById('review-plan-list').innerHTML = '<div class="plan-note">该日期暂无复盘数据</div>';
}
} else {
hideReviewSummary();
}
})
.catch(err => {
console.error('加载复盘日期失败:', err);
});
}
function loadReviewSummary(reviewDateId) {
fetch(`${REVIEW_API_BASE}/summary/${reviewDateId}`)
.then(res => res.json())
.then(data => {
if (data.success) {
renderReviewSummary(data.data);
} else {
alert('加载复盘数据失败');
}
})
.catch(err => {
console.error('加载复盘数据失败:', err);
alert('加载失败');
});
}
function renderReviewSummary(data) {
document.getElementById('review-current-date').textContent = `${data.review_date} (${data.week_day})`;
document.getElementById('review-summary-content').style.display = 'block';
document.getElementById('review-empty-state').style.display = 'none';
renderReviewRankings(data.rankings);
renderReviewPlans(data.plans, data.review_date);
}
function renderReviewRankings(rankings) {
const rankTypes = {
'volume': 'review-rank-volume',
'amplitude': 'review-rank-amplitude',
'change': 'review-rank-change',
'open_interest': 'review-rank-oi'
};
for (const [type, elementId] of Object.entries(rankTypes)) {
const container = document.getElementById(elementId);
container.innerHTML = '';
if (rankings[type] && rankings[type].items) {
rankings[type].items.forEach(item => {
const row = document.createElement('div');
row.className = 'rank-row';
const isTop = item.rank <= 3;
const changeClass = item.change_pct && item.change_pct.startsWith('+') ? 'up' :
item.change_pct && item.change_pct.startsWith('-') ? 'down' : '';
row.innerHTML = `
<div class="rank-rank ${isTop ? 'top' : ''}">${item.rank}</div>
<div class="rank-name">${item.symbol} ${item.name}</div>
<div class="rank-val ${changeClass}">${item.value || item.price || ''}</div>
`;
container.appendChild(row);
});
}
}
}
function renderReviewPlans(plans, reviewDate) {
const container = document.getElementById('review-plan-list');
container.innerHTML = '';
if (!plans || plans.length === 0) {
container.innerHTML = '<div class="plan-note">暂无交易计划</div>';
return;
}
plans.forEach((plan, index) => {
const item = document.createElement('div');
item.className = 'plan-list-item';
item.onclick = function() { toggleReviewPlan(this); };
const planTypeText = plan.plan_type === 'long' ? '做多' : '做空';
const planTypeClass = plan.plan_type === 'long' ? 'long' : 'short';
item.innerHTML = `
<div class="plan-list-left">
<span class="plan-list-code">${plan.symbol} ${plan.name}</span>
<span class="plan-badge ${planTypeClass}">${planTypeText}</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span class="plan-list-score">${plan.score}</span>
<span class="plan-list-arrow"></span>
</div>
`;
const detail = document.createElement('div');
detail.className = 'plan-detail';
const generatedDate = plan.created_at ? new Date(plan.created_at).toLocaleString('zh-CN') : reviewDate;
detail.innerHTML = `
<div class="plan-card" style="margin-bottom:0">
<div class="plan-ai">
<strong>评分: ${plan.score}/100</strong><br>
多空逻辑: ${plan.logic || '暂无'}<br>
入选理由: ${plan.reason || '暂无'}
</div>
<div class="plan-targets">
<div class="plan-target">
<div class="plan-target-label">入场</div>
<div class="plan-target-val neutral">${plan.entry_price || '-'}</div>
</div>
<div class="plan-target">
<div class="plan-target-label">止损</div>
<div class="plan-target-val red">${plan.stop_loss || '-'}</div>
</div>
<div class="plan-target">
<div class="plan-target-label">止盈</div>
<div class="plan-target-val green">${plan.take_profit || '-'}</div>
</div>
</div>
<div class="plan-note">置信度: ${plan.confidence || '-'} | 仓位建议: ${plan.position_suggestion || '-'}</div>
<div class="plan-note" style="margin-top:12px;color:var(--color-ai);font-weight:500;">计划生成时间: ${generatedDate}</div>
</div>
`;
container.appendChild(item);
container.appendChild(detail);
});
}
function toggleReviewPlan(element) {
const detail = element.nextElementSibling;
const isOpen = detail.classList.contains('open');
document.querySelectorAll('#review-plan-list .plan-detail.open').forEach(d => {
d.classList.remove('open');
d.previousElementSibling.classList.remove('expanded');
});
if (!isOpen) {
detail.classList.add('open');
element.classList.add('expanded');
}
}
function hideReviewSummary() {
document.getElementById('review-summary-content').style.display = 'none';
document.getElementById('review-empty-state').style.display = 'flex';
currentReviewDateId = null;
}
function clearReviewData() {
if (confirm('确定要清除所有复盘与交易计划数据吗?此操作不可恢复。')) {
fetch(`${REVIEW_API_BASE}/clear`, {
method: 'DELETE'
})
.then(res => res.json())
.then(data => {
if (data.success) {
alert('复盘数据已清除');
document.getElementById('review-date-selector').value = '';
hideReviewSummary();
loadReviewDates();
} else {
alert(data.message || '清除失败');
}
})
.catch(err => {
console.error('清除数据失败:', err);
alert('清除失败,请稍后重试');
});
}
}
// ==================== 自选品种功能 ====================
function renderWatchedList() {
const grid = document.getElementById('watched-grid');
const emptyState = document.getElementById('watched-empty');
const totalCountEl = document.getElementById('watched-total-count');
if (watchedSymbols.length === 0) {
grid.style.display = 'none';
emptyState.style.display = 'flex';
totalCountEl.textContent = '0 个品种';
return;
}
grid.style.display = 'grid';
emptyState.style.display = 'none';
totalCountEl.textContent = `${watchedSymbols.length} 个品种`;
const watchedData = allFuturesData.filter(item => watchedSymbols.includes(item.symbol));
grid.innerHTML = watchedData.map(item => {
const code = item.symbol.replace(/[0-9]/g, '').substring(0, 2);
const changeIcon = item.change >= 0 ? '↑' : '↓';
const changeSign = item.change >= 0 ? '+' : '';
const pctSign = item.changePct >= 0 ? '+' : '';
let actionPillClass = 'watch';
let actionPillText = '观望';
if (item.suggestion?.includes('做多') || item.suggestion?.includes('试多')) {
actionPillClass = 'do-more';
actionPillText = '做多';
} else if (item.suggestion?.includes('做空') || item.suggestion?.includes('试空')) {
actionPillClass = 'do-more';
actionPillText = '做空';
}
const priceClass = item.change >= 0 ? 'up' : 'down';
const changeClass = item.change >= 0 ? 'up' : 'down';
const successRate = item.successRate || 0;
const trendScore = item.trendScore || 0;
const successColor = successRate >= 70 ? 'var(--color-down)' : successRate >= 60 ? 'var(--color-neutral)' : 'var(--color-up)';
const trendColor = trendScore >= 70 ? 'var(--color-down)' : trendScore >= 50 ? 'var(--color-neutral)' : 'var(--color-up)';
const periods = item.periods || {};
const resistance = item.resistance ? formatNumber(item.resistance) : '--';
const support = item.support ? formatNumber(item.support) : '--';
const isWatched = true;
return `
<div class="card" onclick="showDetailView('${item.symbol}')">
<div class="card-header">
<div class="card-left">
<div class="code-box">${code}</div>
<div class="info-group">
<div class="name-row">${item.name} <span class="fav-icon active" onclick="toggleFav('${item.symbol}', '${item.name}', event)"></span></div>
<div class="contract-code">${item.symbol}</div>
</div>
</div>
<div class="price-area">
<div class="price ${priceClass}">¥${formatNumber(item.price)}</div>
<div class="change ${changeClass}">${changeIcon} ${changeSign}${formatNumber(item.change)} (${pctSign}${item.changePct.toFixed(2)}%)</div>
</div>
</div>
<div class="action-pill ${actionPillClass}">${actionPillText}</div>
<div class="metrics">
<div class="metric">
<div class="metric-label">成功率 ${successRate}%</div>
<div class="bar-bg"><div class="bar-fill" style="width:${successRate}%; background:${successColor};"></div></div>
</div>
<div class="metric">
<div class="metric-label">趋势评分 ${trendScore}</div>
<div class="bar-bg"><div class="bar-fill" style="width:${trendScore}%; background:${trendColor};"></div></div>
</div>
</div>
<div class="timeframes">
<div class="tf ${periods['5'] === 'up' ? 'active' : ''}">5M</div>
<div class="tf ${periods['15'] === 'up' ? 'active' : ''}">15M</div>
<div class="tf ${periods['30'] === 'up' ? 'active' : ''}">30M</div>
<div class="tf ${periods['60'] === 'up' ? 'active' : ''}">1H</div>
</div>
<div class="card-footer">
<div class="support-resist">
<span>压力 <b class="red">${resistance}</b></span>
<span>支撑 <b class="green">${support}</b></span>
</div>
<a href="#" class="link" onclick="event.stopPropagation(); showDetailView('${item.symbol}'); return false;">详情 </a>
</div>
</div>
`}).join('');
}

@ -1,6 +1,6 @@
{
"futures": {
"燃油": "FU2606",
"燃油": "FU2609",
"低硫燃油": "LU2607",
"沪镍": "NI2606",
"沪锡": "SN2606",

Binary file not shown.

Binary file not shown.
Loading…
Cancel
Save