This commit is contained in:
aaron 2026-04-17 14:51:15 +08:00
parent 6d741a7ec2
commit 865db50369
17 changed files with 154 additions and 174 deletions

View File

@ -2,6 +2,7 @@
import asyncio
import logging
import traceback
from datetime import datetime
from fastapi import APIRouter
@ -114,6 +115,8 @@ async def _run_scan_background(scan_session: str):
})
except Exception as e:
logger.error(f"后台扫描失败: {e}")
from app.db.error_logger import log_error
await log_error("recommender_api", f"后台扫描失败: {e}", detail=traceback.format_exc())
await broadcast_update({
"type": "scan_error",
"session": scan_session,

View File

@ -2,11 +2,21 @@
from contextlib import asynccontextmanager
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import event
from app.config import settings
# SQLite 异步需要 aiosqlite
db_url = settings.database_url.replace("sqlite:///", "sqlite+aiosqlite:///")
engine = create_async_engine(db_url, echo=False)
# 启用 WAL 模式:允许读写并发,写操作不阻塞读操作
@event.listens_for(engine.sync_engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA journal_mode=WAL")
cursor.execute("PRAGMA busy_timeout=5000") # 写锁最多等待 5 秒
cursor.close()
async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
@ -26,8 +36,6 @@ async def init_db():
await conn.run_sync(metadata.create_all)
# 补充新增列SQLite ALTER TABLE ADD COLUMN已存在会忽略
for col_sql in [
"ALTER TABLE recommendations ADD COLUMN supply_demand_score REAL DEFAULT 0",
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
"ALTER TABLE recommendations ADD COLUMN supply_demand_score REAL DEFAULT 0",
"ALTER TABLE recommendations ADD COLUMN price_action_score REAL DEFAULT 0",
"ALTER TABLE recommendations ADD COLUMN position_score REAL",

View File

@ -39,7 +39,7 @@ async def refresh_recommendations(trade_date: str = None, scan_session: str = "m
rec.scan_session = scan_session
rec.created_at = datetime.now()
# 持久化到数据库
# 持久化到数据库(这是 async 操作,需要在主线程中执行)
await _save_to_db(result)
# 更新历史推荐跟踪(检查之前推荐的后续表现)
@ -392,73 +392,79 @@ async def _save_to_db(result: dict):
"temp": mt.temperature,
})
# 保存板块热度(先清除同一 trade_date 的旧数据,避免重复
# 保存板块热度(先清除同一 trade_date 的旧数据,再批量插入
trade_date_val = mt.trade_date if mt else ""
if trade_date_val:
await db.execute(
text("DELETE FROM sector_heat WHERE trade_date = :td"),
{"td": trade_date_val},
)
for sector in result.get("hot_sectors", []):
stmt = tables.sector_heat_table.insert().values(
sector_code=sector.sector_code,
sector_name=sector.sector_name,
pct_change=sector.pct_change,
capital_inflow=sector.capital_inflow,
limit_up_count=sector.limit_up_count,
heat_score=sector.heat_score,
stage=sector.stage,
days_continuous=sector.days_continuous,
member_count=sector.member_count,
leading_stocks=json.dumps(sector.leading_stocks, ensure_ascii=False),
pct_trend=json.dumps(sector.pct_trend, ensure_ascii=False),
turnover_avg=sector.turnover_avg,
main_force_ratio=sector.main_force_ratio,
trade_date=trade_date_val,
)
await db.execute(stmt)
sector_values = [
{
"sector_code": sector.sector_code,
"sector_name": sector.sector_name,
"pct_change": sector.pct_change,
"capital_inflow": sector.capital_inflow,
"limit_up_count": sector.limit_up_count,
"heat_score": sector.heat_score,
"stage": sector.stage,
"days_continuous": sector.days_continuous,
"member_count": sector.member_count,
"leading_stocks": json.dumps(sector.leading_stocks, ensure_ascii=False),
"pct_trend": json.dumps(sector.pct_trend, ensure_ascii=False),
"turnover_avg": sector.turnover_avg,
"main_force_ratio": sector.main_force_ratio,
"trade_date": trade_date_val,
}
for sector in result.get("hot_sectors", [])
]
if sector_values:
await db.execute(tables.sector_heat_table.insert(), sector_values)
# 保存推荐(按 ts_code 清除当日旧记录,避免同一天多次扫描产生重复)
# 保存推荐:先批量清除当日旧记录,再批量插入
today_str = datetime.now().strftime("%Y-%m-%d")
now_dt = datetime.now()
saved_count = 0
for rec in result.get("recommendations", []):
if rec.score < 60:
continue
qualified_recs = [rec for rec in result.get("recommendations", []) if rec.score >= 60]
if qualified_recs:
# 批量删除当日同一 ts_code 的旧记录
codes = [rec.ts_code for rec in qualified_recs]
await db.execute(
text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code = :code"),
{"today": today_str, "code": rec.ts_code},
text("DELETE FROM recommendations WHERE date(created_at) = :today AND ts_code IN :codes"),
{"today": today_str, "codes": tuple(codes)},
)
stmt = tables.recommendations_table.insert().values(
ts_code=rec.ts_code,
name=rec.name,
sector=rec.sector,
score=rec.score,
market_temp_score=rec.market_temp_score,
sector_score=rec.sector_score,
capital_score=rec.capital_score,
technical_score=rec.technical_score,
supply_demand_score=rec.supply_demand_score,
price_action_score=rec.price_action_score,
position_score=rec.position_score,
valuation_score=rec.valuation_score,
signal=rec.signal,
entry_price=rec.entry_price,
target_price=rec.target_price,
stop_loss=rec.stop_loss,
reasons=json.dumps(rec.reasons, ensure_ascii=False),
llm_analysis=rec.llm_analysis,
strategy=rec.strategy,
entry_signal_type=rec.entry_signal_type,
llm_score=rec.llm_score,
scan_session=rec.scan_session,
created_at=now_dt,
)
await db.execute(stmt)
saved_count += 1
# 批量插入新记录
rec_values = [
{
"ts_code": rec.ts_code,
"name": rec.name,
"sector": rec.sector,
"score": rec.score,
"market_temp_score": rec.market_temp_score,
"sector_score": rec.sector_score,
"capital_score": rec.capital_score,
"technical_score": rec.technical_score,
"supply_demand_score": rec.supply_demand_score,
"price_action_score": rec.price_action_score,
"position_score": rec.position_score,
"valuation_score": rec.valuation_score,
"signal": rec.signal,
"entry_price": rec.entry_price,
"target_price": rec.target_price,
"stop_loss": rec.stop_loss,
"reasons": json.dumps(rec.reasons, ensure_ascii=False),
"llm_analysis": rec.llm_analysis,
"strategy": rec.strategy,
"entry_signal_type": rec.entry_signal_type,
"llm_score": rec.llm_score,
"scan_session": rec.scan_session,
"created_at": now_dt,
}
for rec in qualified_recs
]
await db.execute(tables.recommendations_table.insert(), rec_values)
await db.commit()
logger.info(f"已保存 {saved_count} 条推荐到数据库(共 {len(result.get('recommendations', []))} 条,过滤掉 <60 分)")
logger.info(f"已保存 {len(qualified_recs)} 条推荐到数据库(共 {len(result.get('recommendations', []))} 条,过滤掉 <60 分)")
except Exception as e:
logger.error(f"保存推荐到数据库失败: {e}")
from app.db.error_logger import log_error

View File

@ -17,6 +17,7 @@
止损止盈基于市场结构阻力位/支撑MA/近期低点而非固定百分比
"""
import asyncio
import logging
import traceback

Binary file not shown.

View File

@ -1,21 +1,5 @@
{
"pages": {
"/(public)/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(public)/page.js"
],
"/(public)/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(public)/layout.js"
],
"/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/(auth)/dashboard/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
@ -26,6 +10,12 @@
"static/chunks/main-app.js",
"static/chunks/app/(auth)/layout.js"
],
"/layout": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/css/app/layout.css",
"static/chunks/app/layout.js"
],
"/(auth)/recommendations/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
@ -35,6 +25,11 @@
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/(auth)/settings/page.js"
],
"/_not-found/page": [
"static/chunks/webpack.js",
"static/chunks/main-app.js",
"static/chunks/app/_not-found/page.js"
]
}
}

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
{
"/(public)/page": "app/(public)/page.js",
"/_not-found/page": "app/_not-found/page.js",
"/(auth)/dashboard/page": "app/(auth)/dashboard/page.js",
"/(auth)/recommendations/page": "app/(auth)/recommendations/page.js",
"/(auth)/settings/page": "app/(auth)/settings/page.js"

View File

@ -1 +1 @@
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";

View File

@ -1,5 +1,5 @@
{
"node": {},
"edge": {},
"encryptionKey": "q9MinLsRBzgOwAUsxhHLGTEgY9kBDWvoZzf7iynqzyI="
"encryptionKey": "f4eykmt9lLjeIDNHjaA0ZKJupk05dXT0k2cBaExPwP8="
}

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useEffect, useState, useCallback, useRef } from "react";
import { fetchAPI, postAPI } from "@/lib/api";
import type { LatestResult, SectorData, IndexOverview, DailyReviewResponse } from "@/lib/api";
import MarketTemp from "@/components/market-temp";
@ -17,6 +17,8 @@ interface ScanStatus {
description: string;
}
const SCAN_TIMEOUT_MS = 120_000; // 扫描超时120秒后自动刷新数据
export default function DashboardPage() {
const { user } = useAuth();
const [data, setData] = useState<LatestResult | null>(null);
@ -29,6 +31,7 @@ export default function DashboardPage() {
const [indices, setIndices] = useState<IndexOverview[]>([]);
const [dailyReview, setDailyReview] = useState<string | null>(null);
const [generatingReview, setGeneratingReview] = useState(false);
const scanTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const loadData = useCallback(async () => {
try {
@ -55,12 +58,21 @@ export default function DashboardPage() {
}
}, []);
// 清除扫描超时计时器
const clearScanTimeout = useCallback(() => {
if (scanTimeoutRef.current) {
clearTimeout(scanTimeoutRef.current);
scanTimeoutRef.current = null;
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
useWebSocket(
useCallback((msg: { type: string; count?: number; scan_mode?: string; message?: string }) => {
clearScanTimeout();
if (msg.type === "scan_update") {
const modeLabel = msg.scan_mode === "intraday" ? "盘中实时" : "盘后";
setRefreshResult(`${modeLabel}扫描完成,发现 ${msg.count ?? 0} 只股票`);
@ -75,7 +87,7 @@ export default function DashboardPage() {
// 其他消息类型(如 llm_analysis_ready刷新数据
loadData();
}
}, [loadData]),
}, [loadData, clearScanTimeout]),
["scan_update", "scan_error", "llm_analysis_ready", "sector_scan_ready", "scan_complete"]
);
@ -96,6 +108,14 @@ export default function DashboardPage() {
setRefreshResult("扫描已启动,完成后自动刷新...");
// 保持 refreshing等待 WS 推送
}
// 设置超时:如果 120 秒内没收到 WebSocket 消息,自动停止加载状态并刷新数据
scanTimeoutRef.current = setTimeout(() => {
setRefreshResult("扫描超时,已自动刷新数据");
setRefreshing(false);
loadData();
setTimeout(() => setRefreshResult(null), 5000);
}, SCAN_TIMEOUT_MS);
} catch (e) {
console.error("触发扫描失败:", e);
setRefreshResult("触发扫描失败,请重试");
@ -104,6 +124,11 @@ export default function DashboardPage() {
}
};
// 清理超时计时器
useEffect(() => {
return () => clearScanTimeout();
}, [clearScanTimeout]);
if (loading) {
return (
<div className="max-w-7xl mx-auto px-4 md:px-8 pt-6 space-y-5">

View File

@ -1,10 +1,9 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { fetchAPI, postAPI } from "@/lib/api";
import { fetchAPI } from "@/lib/api";
import type { DayGroup, PerformanceStats } from "@/lib/api";
import StockCard from "@/components/stock-card";
import { useWebSocket } from "@/hooks/use-websocket";
function formatDate(dateStr: string): string {
const d = new Date(dateStr);
@ -23,20 +22,15 @@ export default function RecommendationsPage() {
const [dayGroups, setDayGroups] = useState<DayGroup[]>([]);
const [expandedDays, setExpandedDays] = useState<Set<string>>(new Set());
const [filter, setFilter] = useState<string>("all");
const [llmEnabled, setLlmEnabled] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [refreshResult, setRefreshResult] = useState<string | null>(null);
const [performance, setPerformance] = useState<PerformanceStats | null>(null);
const loadData = useCallback(async () => {
try {
const [history, health, perf] = await Promise.all([
const [history, perf] = await Promise.all([
fetchAPI<DayGroup[]>("/api/recommendations/history?days=14"),
fetchAPI<{ llm_enabled: boolean }>("/api/health"),
fetchAPI<PerformanceStats>("/api/recommendations/performance").catch(() => null),
]);
setDayGroups(history);
setLlmEnabled(health.llm_enabled);
setPerformance(perf);
// 默认展开最近一天
@ -56,50 +50,6 @@ export default function RecommendationsPage() {
loadData();
}, [loadData]);
// WebSocket 监听扫描结果
useWebSocket(
useCallback((data: { type: string; session?: string; count?: number; temperature?: number; scan_mode?: string; message?: string }) => {
if (data.type === "scan_update") {
const modeLabel = data.scan_mode === "intraday" ? "盘中实时" : "盘后";
setRefreshResult(`${modeLabel}扫描完成,发现 ${data.count ?? 0} 只股票`);
setRefreshing(false);
loadData();
setTimeout(() => setRefreshResult(null), 5000);
} else if (data.type === "scan_error") {
setRefreshResult("扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
}, [loadData]),
["scan_update", "scan_error"]
);
const handleRefresh = async () => {
setRefreshing(true);
setRefreshResult(null);
try {
const res = await postAPI<{
status: string;
message?: string;
is_trading: boolean;
}>("/api/recommendations/refresh?scan_session=manual");
if (res.status === "already_running") {
setRefreshResult(res.message || "扫描正在执行中,请稍候");
setTimeout(() => setRefreshResult(null), 5000);
// 不关闭 refreshing等待 WS 推送完成通知
} else if (res.status === "scanning") {
setRefreshResult("扫描已启动,完成后自动刷新...");
// 保持 refreshing 状态,等待 WS 推送
}
} catch (e) {
console.error("触发扫描失败:", e);
setRefreshResult("触发扫描失败,请重试");
setRefreshing(false);
setTimeout(() => setRefreshResult(null), 5000);
}
};
const toggleDay = (date: string) => {
setExpandedDays((prev) => {
const next = new Set(prev);
@ -135,30 +85,8 @@ export default function RecommendationsPage() {
<span className="font-mono tabular-nums ml-1">{dayGroups.length}</span>
</p>
</div>
<button
onClick={handleRefresh}
disabled={refreshing}
className="text-xs px-3 py-1.5 sm:px-4 sm:py-2 bg-gradient-to-r from-amber-500/20 to-amber-600/15 text-amber-400 rounded-lg sm:rounded-xl hover:from-amber-500/30 hover:to-amber-600/25 disabled:opacity-40 transition-all duration-200 border border-amber-500/10 font-medium self-start sm:self-auto"
>
{refreshing ? (
<span className="inline-flex items-center gap-1.5">
<span className="w-3 h-3 border border-amber-400/40 border-t-amber-400 rounded-full animate-spin" />
...
</span>
) : (
"立即扫描"
)}
</button>
</div>
{/* Scan result toast */}
{refreshResult && (
<div className="glass-card-static border-amber-500/15 px-4 py-2.5 text-xs text-amber-400 animate-fade-in-up flex items-center gap-2 mb-5">
<span className="w-1 h-1 rounded-full bg-amber-400" />
{refreshResult}
</div>
)}
{/* Performance Stats */}
{performance && performance.total_recommendations > 0 && (
<div className="glass-card-static p-4 mb-5 animate-fade-in-up">
@ -316,7 +244,7 @@ export default function RecommendationsPage() {
className="animate-fade-in-up"
style={{ animationDelay: `${i * 40}ms` }}
>
<StockCard rec={rec} showLLMLoading={isToday && llmEnabled} />
<StockCard rec={rec} />
</div>
))}
</div>