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

alphaFuthures
Lxy 2 weeks ago
parent 36c33eb005
commit 56c87386bf

@ -36,5 +36,5 @@ def init_analysis_db():
# 确保导入所有模型类,使其注册到 AnalysisBase # 确保导入所有模型类,使其注册到 AnalysisBase
from app import analysis_models from app import analysis_models
# 直接导入 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) AnalysisBase.metadata.create_all(bind=analysis_engine)

@ -100,3 +100,57 @@ class AIAnalysisCache(AnalysisBase):
def __repr__(self): def __repr__(self):
return f"<AIAnalysisCache {self.symbol} {self.created_at}>" 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 import logging
from typing import Optional from typing import Optional
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -222,3 +223,26 @@ def cache_status(symbol: str, db: Session = Depends(get_db)):
"cached_periods": periods_info, "cached_periods": periods_info,
"status": "ok", "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.database import engine, Base
from app.user_models import Base as UserBase from app.user_models import Base as UserBase
from app.config import HOST, PORT, LOG_LEVEL 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 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(config.router, prefix="/api/v1")
app.include_router(futures_analysis.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(ai_config.router, prefix="/api/v1")
app.include_router(review_plan.router, prefix="/api/v1")
@app.get("/futures-analysis") @app.get("/futures-analysis")
@ -134,6 +135,12 @@ def ai_config_page():
return FileResponse(str(STATIC_DIR / "ai_config.html")) 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") @app.get("/api/v1/health")
def health(): def health():
return {"status": "ok", "service": "market-data-buffer"} return {"status": "ok", "service": "market-data-buffer"}

@ -182,6 +182,13 @@
/* 详情视图样式 */ /* 详情视图样式 */
.view { display: none; } .view { display: none; }
.view.active { display: block; } .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; } .detail-actions { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
.back-btn, .refresh-btn { .back-btn, .refresh-btn {
@ -328,6 +335,58 @@
.header { padding: 0 16px; } .header { padding: 0 16px; }
.container { padding: 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> </style>
</head> </head>
<body> <body>
@ -339,6 +398,7 @@
<a href="#" class="nav-item" data-page="watched">自选</a> <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="market">市场概览</a>
<a href="#" class="nav-item" data-page="risk">风险预警</a> <a href="#" class="nav-item" data-page="risk">风险预警</a>
<a href="#" class="nav-item" data-page="review">复盘计划</a>
</div> </div>
<div class="live-badge"> <div class="live-badge">
<div class="dot"></div> LIVE <div class="dot"></div> LIVE
@ -370,7 +430,6 @@
</div> </div>
<div class="pills"> <div class="pills">
<button class="pill active" data-category="all">全部 <span id="count-all">32</span></button> <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="energy">能源</button>
<button class="pill" data-category="metal">金属</button> <button class="pill" data-category="metal">金属</button>
<button class="pill" data-category="agriculture">农产品</button> <button class="pill" data-category="agriculture">农产品</button>
@ -545,6 +604,75 @@
</div> </div>
</div> </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> </div>
<!-- AI分析详情对话框 --> <!-- 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) { function closeModal(modalId) {
@ -133,12 +152,40 @@ function closeModal(modalId) {
function showListView() { function showListView() {
document.getElementById('list-view').classList.add('active'); document.getElementById('list-view').classList.add('active');
document.getElementById('detail-view').classList.remove('active'); document.getElementById('detail-view').classList.remove('active');
document.getElementById('review-view').classList.remove('active');
document.getElementById('watched-view').classList.remove('active');
if (klineChart) { if (klineChart) {
klineChart.dispose(); klineChart.dispose();
klineChart = null; 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) { async function showDetailView(symbol) {
currentSymbol = symbol; currentSymbol = symbol;
document.getElementById('list-view').classList.remove('active'); document.getElementById('list-view').classList.remove('active');
@ -231,7 +278,6 @@ async function loadWatchedSymbols() {
const data = await response.json(); const data = await response.json();
if (data.success) { if (data.success) {
watchedSymbols = data.data.map(s => s.symbol); watchedSymbols = data.data.map(s => s.symbol);
document.getElementById('count-watched').textContent = watchedSymbols.length;
} }
} catch (error) { } catch (error) {
console.error('加载自选列表失败:', error); console.error('加载自选列表失败:', error);
@ -263,14 +309,13 @@ async function toggleWatch(symbol, name, event) {
showToast('success', '已添加自选', `${name}(${symbol}) 已添加到自选列表`); showToast('success', '已添加自选', `${name}(${symbol}) 已添加到自选列表`);
} }
} }
document.getElementById('count-watched').textContent = watchedSymbols.length;
// 重新渲染当前视图 // 重新渲染当前视图
const activePill = document.querySelector('.pill.active'); const activePill = document.querySelector('.pill.active');
const category = activePill ? activePill.dataset.category : 'all'; const category = activePill ? activePill.dataset.category : 'all';
if (category === 'watched') {
filterByCategory('watched'); // 如果当前视图不是自选页面,重新渲染主网格
} else { if (category !== 'watched') {
renderFuturesGrid(getCurrentFilteredData()); renderFuturesGrid(getCurrentFilteredData());
} }
} catch (error) { } catch (error) {
@ -283,8 +328,13 @@ function toggleFav(symbol, name, event) {
event.stopPropagation(); event.stopPropagation();
// 调用原有的toggleWatch函数处理API请求和数据更新 // 调用原有的toggleWatch函数处理API请求和数据更新
// toggleWatch 会重新渲染网格,星标状态会根据 watchedSymbols 自动更新
toggleWatch(symbol, name, event); toggleWatch(symbol, name, event);
// 如果当前在自选页面,刷新自选列表
const watchedView = document.getElementById('watched-view');
if (watchedView && watchedView.classList.contains('active')) {
setTimeout(() => renderWatchedList(), 200);
}
} }
function getCurrentFilteredData() { function getCurrentFilteredData() {
@ -295,9 +345,6 @@ function getCurrentFilteredData() {
function filterDataByCategory(data, category) { function filterDataByCategory(data, category) {
if (category === 'all') return data; if (category === 'all') return data;
if (category === 'watched') {
return data.filter(item => watchedSymbols.includes(item.symbol));
}
const categoryMap = { const categoryMap = {
'energy': ['SC', 'FU', 'LU', 'BU', 'RU', 'NR'], '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'], '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> </div>
`; `;
// 设置5分钟超时
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 300000); // 5分钟 = 300000毫秒
try { 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(); const data = await response.json();
if (data.success) { if (data.success) {
@ -1745,13 +1800,24 @@ async function runAIAnalysis(forceRefresh = false) {
showToast('error', '分析失败', data.error || 'AI分析失败'); showToast('error', '分析失败', data.error || 'AI分析失败');
} }
} catch (error) { } catch (error) {
clearTimeout(timeoutId);
console.error('AI分析请求失败:', error); console.error('AI分析请求失败:', error);
content.innerHTML = `
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);"> if (error.name === 'AbortError') {
<p>网络错误请检查网络连接</p> content.innerHTML = `
</div> <div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
`; <p>分析超时请稍后重试或联系管理员</p>
showToast('error', '请求失败', '网络错误,请稍后重试'); </div>
`;
showToast('error', '请求超时', 'AI分析耗时过长请稍后重试');
} else {
content.innerHTML = `
<div style="text-align: center; padding: 28px; color: var(--text-tertiary);">
<p>网络错误请检查网络连接</p>
</div>
`;
showToast('error', '请求失败', '网络错误,请稍后重试');
}
} finally { } finally {
btn.disabled = false; btn.disabled = false;
btn.textContent = '智能分析'; btn.textContent = '智能分析';
@ -2122,3 +2188,470 @@ function showAIDetailModal() {
document.getElementById('ai-analysis-modal').classList.add('active'); 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": { "futures": {
"燃油": "FU2606", "燃油": "FU2609",
"低硫燃油": "LU2607", "低硫燃油": "LU2607",
"沪镍": "NI2606", "沪镍": "NI2606",
"沪锡": "SN2606", "沪锡": "SN2606",

Binary file not shown.

Binary file not shown.
Loading…
Cancel
Save