feat: 新增合约数据时间戳表;增加刷新等功能;独立数据库表

master^2
Lxy 2 weeks ago
parent a30144f409
commit bff2ab8b61

@ -5,14 +5,16 @@ import json
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import threading
from fastapi import APIRouter, Depends, HTTPException from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.database import get_db from app.database import get_db
from app.analysis_db import get_analysis_db from app.analysis_db import get_analysis_db
from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings from app.analysis_models import FuturesAnalysis, WatchedSymbol, AIModelConfig, AnalysisSettings
from app.services.cache import get_cached_data, get_latest_cached from app.services.cache import get_cached_data, get_latest_cached, save_market_data
from app.services.collector import fetch_symbol_data
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(prefix="/futures", tags=["期货智析"]) router = APIRouter(prefix="/futures", tags=["期货智析"])
@ -687,3 +689,100 @@ def delete_ai_model(model_id: int, adb: Session = Depends(get_analysis_db)):
except Exception as e: except Exception as e:
adb.rollback() adb.rollback()
return {"success": False, "message": str(e)} return {"success": False, "message": str(e)}
# ==================== 数据刷新接口 ====================
from app.services.cache import needs_refresh, get_symbol_timestamp
refresh_lock = threading.Lock()
refresh_status = {"running": False, "progress": 0, "total": 0, "message": ""}
REFRESH_THRESHOLD = 300 # 5分钟阈值
def _refresh_single_symbol_sync(db: Session, symbol: str) -> dict:
"""同步刷新单个品种数据(会等待采集完成)"""
try:
# 先检查是否需要刷新
if not needs_refresh(db, symbol, "futures", REFRESH_THRESHOLD):
last_refresh = get_symbol_timestamp(db, symbol, "futures")
return {
"success": True,
"message": f"{symbol} 数据仍然新鲜,无需刷新",
"last_refresh": last_refresh.isoformat() if last_refresh else None,
"refreshed": False
}
# 需要刷新,执行采集
logger.info(f"开始刷新 {symbol} 数据...")
result = fetch_symbol_data(symbol, "futures")
if result.get("timeframes"):
save_market_data(db, symbol, result)
logger.info(f"{symbol} 数据刷新完成")
return {
"success": True,
"message": f"{symbol} 数据已更新",
"refreshed": True
}
return {"success": False, "message": f"{symbol} 未获取到数据", "refreshed": False}
except Exception as e:
logger.error(f"刷新 {symbol} 失败: {e}")
return {"success": False, "message": f"{symbol} 刷新失败: {str(e)}", "refreshed": False}
@router.post("/refresh/{symbol}")
def refresh_single_symbol_api(symbol: str, db: Session = Depends(get_db)):
"""刷新单个品种合约数据(同步执行,检查时间戳)"""
if refresh_lock.locked():
return {"success": False, "message": "数据刷新中,请稍后再试"}
try:
refresh_lock.acquire()
result = _refresh_single_symbol_sync(db, symbol)
return result
finally:
refresh_lock.release()
@router.post("/refresh-all")
def refresh_all_symbols_api(background_tasks: BackgroundTasks):
"""刷新所有品种合约数据(异步执行)"""
global refresh_status
if refresh_lock.locked():
return {"success": False, "message": "数据刷新中,请稍后再试"}
# 从配置加载所有品种
config = _load_symbols_config()
futures_config = config.get("futures", {})
symbols = list(futures_config.values())
def refresh_all_task():
global refresh_status
# 在后台任务内部创建新的数据库会话
local_db = next(get_db())
try:
with refresh_lock:
refresh_status = {"running": True, "progress": 0, "total": len(symbols), "message": "开始刷新..."}
for i, symbol in enumerate(symbols):
refresh_status["message"] = f"正在刷新 {symbol} ({i + 1}/{len(symbols)})"
refresh_status["progress"] = i + 1
_refresh_single_symbol_sync(local_db, symbol)
with refresh_lock:
refresh_status = {"running": False, "progress": len(symbols), "total": len(symbols), "message": "全部刷新完成"}
finally:
local_db.close()
background_tasks.add_task(refresh_all_task)
return {"success": True, "message": "开始刷新所有品种数据...", "count": len(symbols)}
@router.get("/refresh-status")
def get_refresh_status():
"""获取刷新状态"""
return {"success": True, "data": refresh_status}

@ -28,6 +28,20 @@ class MarketData(Base):
return f"<MarketData {self.symbol} {self.period} candles={self.candle_count}>" return f"<MarketData {self.symbol} {self.period} candles={self.candle_count}>"
class SymbolTimestamp(Base):
"""合约数据时间戳表 - 记录每个合约的最新数据时间"""
__tablename__ = "symbol_timestamps"
id = Column(Integer, primary_key=True, autoincrement=True)
symbol = Column(String(32), nullable=False, unique=True, index=True, comment="品种合约代码")
data_type = Column(String(16), nullable=False, default="futures", comment="数据类型")
last_refresh_at = Column(DateTime, nullable=False, default=datetime.now, comment="最后刷新时间")
refresh_count = Column(Integer, default=0, comment="刷新次数")
def __repr__(self):
return f"<SymbolTimestamp {self.symbol} last_refresh={self.last_refresh_at}>"
class ScheduledTask(Base): class ScheduledTask(Base):
"""定时任务配置表""" """定时任务配置表"""
__tablename__ = "scheduled_tasks" __tablename__ = "scheduled_tasks"

@ -8,7 +8,7 @@ from typing import Dict, List, Optional
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models import MarketData, ScheduledTask from app.models import MarketData, ScheduledTask, SymbolTimestamp
from app.config import CACHE_TTL_SECONDS from app.config import CACHE_TTL_SECONDS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -67,7 +67,7 @@ def check_cache_status(
def save_market_data(db: Session, symbol: str, data: Dict) -> MarketData: def save_market_data(db: Session, symbol: str, data: Dict) -> MarketData:
""" """
保存采集结果到缓存 保存采集结果到缓存并同步更新合约时间戳
Args: Args:
symbol: 品种代码 symbol: 品种代码
@ -105,6 +105,9 @@ def save_market_data(db: Session, symbol: str, data: Dict) -> MarketData:
) )
db.add(record) db.add(record)
# 更新合约时间戳
update_symbol_timestamp(db, symbol, data.get("type", "futures"), now)
db.commit() db.commit()
logger.info(f"缓存已更新: {symbol}, {len(data.get('timeframes', {}))} 个周期") logger.info(f"缓存已更新: {symbol}, {len(data.get('timeframes', {}))} 个周期")
@ -115,6 +118,58 @@ def save_market_data(db: Session, symbol: str, data: Dict) -> MarketData:
).order_by(MarketData.fetched_at.desc()).first() ).order_by(MarketData.fetched_at.desc()).first()
def update_symbol_timestamp(db: Session, symbol: str, data_type: str, refresh_time: datetime) -> None:
"""更新或创建合约时间戳记录"""
timestamp_record = db.query(SymbolTimestamp).filter_by(
symbol=symbol,
data_type=data_type
).first()
if timestamp_record:
timestamp_record.last_refresh_at = refresh_time
timestamp_record.refresh_count += 1
else:
timestamp_record = SymbolTimestamp(
symbol=symbol,
data_type=data_type,
last_refresh_at=refresh_time,
refresh_count=1
)
db.add(timestamp_record)
db.commit()
def get_symbol_timestamp(db: Session, symbol: str, data_type: str = "futures") -> Optional[datetime]:
"""获取合约最后刷新时间"""
record = db.query(SymbolTimestamp).filter_by(
symbol=symbol,
data_type=data_type
).first()
return record.last_refresh_at if record else None
def needs_refresh(db: Session, symbol: str, data_type: str = "futures", threshold_seconds: int = 300) -> bool:
"""
检查合约是否需要刷新数据是否超过阈值时间
Args:
db: 数据库会话
symbol: 品种代码
data_type: 数据类型
threshold_seconds: 阈值时间默认300秒5分钟
Returns:
True 表示需要刷新False 表示数据仍然新鲜
"""
last_refresh = get_symbol_timestamp(db, symbol, data_type)
if last_refresh is None:
return True # 从未刷新过,需要刷新
age = (datetime.now() - last_refresh).total_seconds()
return age > threshold_seconds
def get_latest_cached( def get_latest_cached(
db: Session, db: Session,
symbol: str, symbol: str,

@ -1694,6 +1694,37 @@ body.theme-minimal .watch-btn.active {
background: rgba(236, 126, 0, 0.15); background: rgba(236, 126, 0, 0.15);
} }
/* 卡片刷新按钮 */
.card-refresh-btn {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
transition: all 0.2s;
font-size: 12px;
}
.card-refresh-btn:hover {
border-color: var(--cyan);
color: var(--cyan);
background: rgba(6, 182, 212, 0.1);
}
body.theme-minimal .card-refresh-btn {
border-radius: 9999px;
background: var(--bg-card);
}
body.theme-minimal .card-refresh-btn:hover {
background: rgba(6, 182, 212, 0.1);
}
/* ============================================ /* ============================================
============================================ */ ============================================ */
@ -1725,6 +1756,201 @@ body.theme-minimal .level-tag {
border-radius: 9999px; border-radius: 9999px;
} }
/* ============================================
Toast
============================================ */
.toast-container {
position: fixed;
top: 80px;
right: 24px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 20px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
animation: slideIn 0.3s ease-out;
min-width: 280px;
max-width: 400px;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes slideOut {
from {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
}
}
.toast.removing {
animation: slideOut 0.3s ease-in forwards;
}
.toast-icon {
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-size: 12px;
flex-shrink: 0;
}
.toast.success .toast-icon {
background: rgba(16, 185, 129, 0.15);
color: var(--green);
}
.toast.info .toast-icon {
background: rgba(6, 182, 212, 0.15);
color: var(--cyan);
}
.toast.warning .toast-icon {
background: rgba(245, 158, 11, 0.15);
color: var(--amber);
}
.toast.error .toast-icon {
background: rgba(239, 68, 68, 0.15);
color: var(--red);
}
.toast-content {
flex: 1;
}
.toast-title {
font-size: 13px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 2px;
}
.toast-message {
font-size: 12px;
color: var(--text-secondary);
}
body.theme-minimal .toast {
background: #ffffff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
}
/* ============================================
============================================ */
.refresh-all-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: var(--cyan);
border: none;
border-radius: 9999px;
color: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.refresh-all-btn:hover {
background: var(--cyan-glow);
transform: translateY(-1px);
}
.refresh-all-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.refresh-all-btn i {
font-size: 14px;
}
.refresh-all-btn.spinning i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.filter-actions {
display: flex;
align-items: center;
gap: 12px;
}
/* 详情页面刷新按钮 */
.detail-actions {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.refresh-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
background: var(--bg-card);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.refresh-btn:hover {
background: var(--bg-card-hover);
color: var(--text-primary);
border-color: var(--border-glow);
}
.refresh-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
body.theme-minimal .refresh-btn {
background: var(--bg-secondary);
border-radius: 9999px;
}
body.theme-minimal .refresh-all-btn {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
/* ============================================ /* ============================================
============================================ */ ============================================ */

@ -62,6 +62,9 @@
</div> </div>
</header> </header>
<!-- Toast 提示容器 -->
<div class="toast-container" id="toast-container"></div>
<!-- 主内容区 --> <!-- 主内容区 -->
<main class="main-content"> <main class="main-content">
<!-- 品种列表视图 --> <!-- 品种列表视图 -->
@ -114,6 +117,11 @@
<span>金融</span> <span>金融</span>
</button> </button>
</div> </div>
<div class="filter-actions">
<button class="refresh-all-btn" id="refresh-all-btn" title="刷新全部品种">
<i class="fas fa-sync-alt"></i>
<span>刷新全部</span>
</button>
<div class="sort-select"> <div class="sort-select">
<select id="sort-select"> <select id="sort-select">
<option value="trend_score">趋势评分</option> <option value="trend_score">趋势评分</option>
@ -123,6 +131,7 @@
</select> </select>
</div> </div>
</div> </div>
</div>
<!-- 统计概览 --> <!-- 统计概览 -->
<div class="stats-overview"> <div class="stats-overview">
@ -165,10 +174,16 @@
<!-- 详情分析视图 --> <!-- 详情分析视图 -->
<div id="detail-view" class="view"> <div id="detail-view" class="view">
<!-- 返回按钮 --> <!-- 返回按钮 -->
<div class="detail-actions">
<button class="back-btn" id="back-btn"> <button class="back-btn" id="back-btn">
<i class="fas fa-arrow-left"></i> <i class="fas fa-arrow-left"></i>
<span>返回</span> <span>返回</span>
</button> </button>
<button class="refresh-btn" id="refresh-symbol-btn" title="刷新合约数据">
<i class="fas fa-sync-alt"></i>
<span>刷新数据</span>
</button>
</div>
<!-- 品种标题区 --> <!-- 品种标题区 -->
<div class="detail-header"> <div class="detail-header">

@ -70,6 +70,16 @@ function initEventListeners() {
} }
}); });
// 刷新全部按钮
document.getElementById('refresh-all-btn').addEventListener('click', refreshAllSymbols);
// 详情页刷新按钮
document.getElementById('refresh-symbol-btn').addEventListener('click', function() {
if (currentSymbol) {
refreshSingleSymbol(currentSymbol);
}
});
const savedTheme = localStorage.getItem('futures-theme'); const savedTheme = localStorage.getItem('futures-theme');
if (savedTheme === 'dark') { if (savedTheme === 'dark') {
document.body.classList.remove('theme-minimal'); document.body.classList.remove('theme-minimal');
@ -321,6 +331,9 @@ function renderFuturesGrid(data) {
<span><span class="label">支撑</span> <span class="up">${formatNumber(item.support)}</span></span> <span><span class="label">支撑</span> <span class="up">${formatNumber(item.support)}</span></span>
</div> </div>
<div style="display:flex;align-items:center;gap:8px;"> <div style="display:flex;align-items:center;gap:8px;">
<button class="card-refresh-btn" onclick="event.stopPropagation(); refreshSingleSymbol('${item.symbol}', this)" title="刷新数据">
<i class="fas fa-sync-alt"></i>
</button>
<button class="watch-btn ${isWatched ? 'active' : ''}" onclick="toggleWatch('${item.symbol}', '${item.name}', event)" title="${isWatched ? '取消自选' : '加入自选'}"> <button class="watch-btn ${isWatched ? 'active' : ''}" onclick="toggleWatch('${item.symbol}', '${item.name}', event)" title="${isWatched ? '取消自选' : '加入自选'}">
<i class="fas fa-star"></i> <i class="fas fa-star"></i>
</button> </button>
@ -925,3 +938,169 @@ function calcEMA(data, period) {
return result; return result;
} }
// ==================== Toast 提示 ====================
function showToast(type, title, message, duration = 3000) {
const container = document.getElementById('toast-container');
const iconMap = {
success: 'fas fa-check',
info: 'fas fa-info',
warning: 'fas fa-exclamation',
error: 'fas fa-times'
};
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<div class="toast-icon"><i class="${iconMap[type]}"></i></div>
<div class="toast-content">
<div class="toast-title">${title}</div>
<div class="toast-message">${message}</div>
</div>
`;
container.appendChild(toast);
setTimeout(() => {
toast.classList.add('removing');
setTimeout(() => toast.remove(), 300);
}, duration);
}
// ==================== 数据刷新功能 ====================
let isRefreshing = false;
async function refreshAllSymbols() {
if (isRefreshing) return;
const btn = document.getElementById('refresh-all-btn');
btn.disabled = true;
btn.classList.add('spinning');
isRefreshing = true;
showToast('info', '开始刷新', '正在同步所有品种数据...');
try {
const response = await fetch(`${API_BASE}/refresh-all`, { method: 'POST' });
const data = await response.json();
if (data.success) {
pollRefreshStatus();
} else {
showToast('error', '刷新失败', data.message || '请稍后重试');
resetRefreshButton(btn);
}
} catch (error) {
console.error('刷新全部失败:', error);
showToast('error', '刷新失败', '网络错误,请稍后重试');
resetRefreshButton(btn);
}
}
async function pollRefreshStatus() {
const btn = document.getElementById('refresh-all-btn');
try {
const response = await fetch(`${API_BASE}/refresh-status`);
const data = await response.json();
if (data.success && data.data) {
const status = data.data;
if (!status.running) {
resetRefreshButton(btn);
await loadFuturesList();
if (currentSymbol) {
await loadFuturesDetail(currentSymbol);
await loadKlineData(currentSymbol, currentPeriod);
}
showToast('success', '刷新完成', `已同步 ${status.total} 个品种数据`);
} else {
btn.innerHTML = `<i class="fas fa-sync-alt"></i><span>刷新中 ${status.progress}/${status.total}</span>`;
setTimeout(pollRefreshStatus, 2000);
}
}
} catch (error) {
console.error('获取刷新状态失败:', error);
resetRefreshButton(btn);
}
}
function resetRefreshButton(btn) {
btn.disabled = false;
btn.classList.remove('spinning');
btn.innerHTML = '<i class="fas fa-sync-alt"></i><span>刷新全部</span>';
isRefreshing = false;
}
async function refreshSingleSymbol(symbol, btnElement = null) {
// 优先使用传入的按钮元素,其次尝试从事件获取,最后使用详情页按钮
let btn = btnElement;
if (!btn) {
try {
const evt = event;
if (evt && evt.target) {
const cardBtn = evt.target.closest('.card-refresh-btn');
if (cardBtn) {
btn = cardBtn;
}
}
} catch (e) {
// event 不存在时忽略
}
}
if (!btn) {
btn = document.getElementById('refresh-symbol-btn');
}
if (!btn) {
showToast('error', '刷新失败', '无法找到刷新按钮');
return;
}
const originalContent = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i>';
showToast('info', '检查数据', `正在检查 ${symbol} 数据新鲜度...`);
try {
const response = await fetch(`${API_BASE}/refresh/${symbol}`, { method: 'POST' });
const data = await response.json();
if (data.success) {
if (data.refreshed) {
btn.innerHTML = '<i class="fas fa-check"></i>';
await loadFuturesDetail(symbol);
await loadKlineData(symbol, currentPeriod);
await loadFuturesList();
showToast('success', '数据已更新', `${symbol} 最新数据已同步`);
} else {
btn.innerHTML = '<i class="fas fa-check"></i>';
showToast('success', '数据新鲜', `${symbol} 数据仍在有效期内,无需刷新`);
}
setTimeout(() => {
btn.disabled = false;
btn.innerHTML = originalContent;
}, 1000);
} else {
showToast('error', '刷新失败', data.message || '请稍后重试');
btn.disabled = false;
btn.innerHTML = originalContent;
}
} catch (error) {
console.error('刷新失败:', error);
showToast('error', '刷新失败', '网络错误,请稍后重试');
btn.disabled = false;
btn.innerHTML = originalContent;
}
}

Binary file not shown.
Loading…
Cancel
Save