1
This commit is contained in:
parent
6d741a7ec2
commit
865db50369
Binary file not shown.
@ -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,
|
||||
|
||||
Binary file not shown.
@ -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",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -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
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
止损止盈:基于市场结构(阻力位/支撑MA/近期低点),而非固定百分比。
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import traceback
|
||||
|
||||
|
||||
Binary file not shown.
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
2
frontend/.next/cache/.tsbuildinfo
vendored
2
frontend/.next/cache/.tsbuildinfo
vendored
File diff suppressed because one or more lines are too long
@ -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"
|
||||
|
||||
@ -1 +1 @@
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]"
|
||||
self.__INTERCEPTION_ROUTE_REWRITE_MANIFEST="[]";
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"node": {},
|
||||
"edge": {},
|
||||
"encryptionKey": "q9MinLsRBzgOwAUsxhHLGTEgY9kBDWvoZzf7iynqzyI="
|
||||
"encryptionKey": "f4eykmt9lLjeIDNHjaA0ZKJupk05dXT0k2cBaExPwP8="
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user