자산API DB캐쉬 사용
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -52,7 +52,9 @@ build/
|
|||||||
*.db
|
*.db
|
||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
|
*.sql
|
||||||
|
*.sql.gz
|
||||||
|
*.dump
|
||||||
# Logs
|
# Logs
|
||||||
logs/
|
logs/
|
||||||
*.log
|
*.log
|
||||||
|
|||||||
@@ -488,6 +488,81 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
|
|||||||
})
|
})
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
async def _sync_portfolio_background():
|
||||||
|
"""백그라운드 포트폴리오 KIS 동기화 (비차단). 이미 동기화 중이면 건너뜀."""
|
||||||
|
if portfolio_cache.sync_lock.locked():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
async with AsyncSessionLocal() as db:
|
||||||
|
await sync_portfolio(db, force=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Background portfolio sync: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_combined_resp() -> dict:
|
||||||
|
"""portfolio_cache에서 combined 응답 딕셔너리 생성"""
|
||||||
|
base = portfolio_cache.summary or {
|
||||||
|
"total_stocks": 0, "total_value": 0, "total_investment": 0,
|
||||||
|
"total_profit_loss": 0, "total_profit_loss_rate": 0,
|
||||||
|
"available_cash": 0, "total_assets": 0,
|
||||||
|
}
|
||||||
|
resp = dict(base)
|
||||||
|
resp.update({
|
||||||
|
"sync_status": "ok" if portfolio_cache.last_error is None else "error",
|
||||||
|
"last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None,
|
||||||
|
"last_error": portfolio_cache.last_error,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"holdings": [h for h in portfolio_cache.holdings if h.quantity > 0],
|
||||||
|
"summary": resp,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/combined")
|
||||||
|
async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""보유종목 목록 + 요약을 반환.
|
||||||
|
|
||||||
|
응답 우선순위:
|
||||||
|
① 신선한 캐시 → 즉시 반환
|
||||||
|
② 캐시 만료 + 매매 변경 없음 → 기존 캐시 즉시 반환 + 백그라운드 KIS 갱신
|
||||||
|
③ 서버 재시작 등 캐시 비어있음 → DB 즉시 반환 + 백그라운드 KIS 갱신
|
||||||
|
④ 매매 발생(change_flag) 또는 DB도 비어있음 → KIS API 동기 호출 (필수)
|
||||||
|
"""
|
||||||
|
latest_trade_id = await _get_latest_executed_trade_id(db)
|
||||||
|
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
|
||||||
|
portfolio_cache.mark_changed()
|
||||||
|
|
||||||
|
# ① 신선한 캐시
|
||||||
|
if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.should_sync():
|
||||||
|
logger.debug("Portfolio cache hit (combined)")
|
||||||
|
return _build_combined_resp()
|
||||||
|
|
||||||
|
# ② 캐시 만료, 매매 변경 없음 → stale 데이터 즉시 반환 + 백그라운드 갱신
|
||||||
|
if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.change_flag:
|
||||||
|
logger.debug("Portfolio cache stale (no trade) → returning cached, syncing in background")
|
||||||
|
asyncio.create_task(_sync_portfolio_background())
|
||||||
|
return _build_combined_resp()
|
||||||
|
|
||||||
|
# ③ 캐시 비어있음 (서버 재시작) → DB에서 즉시 로드 + 백그라운드 KIS 갱신
|
||||||
|
if not portfolio_cache.holdings:
|
||||||
|
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||||
|
db_holdings = list(result.scalars().all())
|
||||||
|
if db_holdings:
|
||||||
|
portfolio_cache.holdings = db_holdings
|
||||||
|
portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
|
||||||
|
logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작")
|
||||||
|
asyncio.create_task(_sync_portfolio_background())
|
||||||
|
return _build_combined_resp()
|
||||||
|
|
||||||
|
# ④ 매매 발생 또는 DB도 비어있음 → KIS API 동기 호출
|
||||||
|
try:
|
||||||
|
await sync_portfolio(db, force=False)
|
||||||
|
except HTTPException:
|
||||||
|
logger.warning("get_portfolio_combined: sync failed, returning cached data")
|
||||||
|
|
||||||
|
return _build_combined_resp()
|
||||||
|
|
||||||
|
|
||||||
@router.post("/refresh")
|
@router.post("/refresh")
|
||||||
async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
|
async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
|
||||||
"""강제 캐시 무효화 및 재동기화"""
|
"""강제 캐시 무효화 및 재동기화"""
|
||||||
|
|||||||
@@ -111,6 +111,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./backend:/app
|
- ./backend:/app
|
||||||
- /app/__pycache__
|
- /app/__pycache__
|
||||||
|
- ./logs:/logs
|
||||||
networks:
|
networks:
|
||||||
- autostock-network
|
- autostock-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -56,7 +56,13 @@ const PortfolioPanel = React.memo(({
|
|||||||
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
|
className={`w-full p-3 hover:bg-gray-700/30 transition-all duration-200 group ${selectedStock === stock.stock_code ? 'bg-blue-500/10 border-l-2 border-blue-500' : 'border-l-2 border-transparent'}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
onClick={() => { onSelectStock(stock.stock_code); onRefetchAiAnalysis() }}
|
onClick={() => {
|
||||||
|
if (selectedStock === stock.stock_code) {
|
||||||
|
onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침
|
||||||
|
} else {
|
||||||
|
onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
|||||||
@@ -77,7 +77,13 @@ const RecommendationPanel = React.memo(({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={`${stock.stock_code}-${idx}`}
|
key={`${stock.stock_code}-${idx}`}
|
||||||
onClick={() => { onSelectStock(stock.stock_code); onRefetchAiAnalysis() }}
|
onClick={() => {
|
||||||
|
if (selectedStock === stock.stock_code) {
|
||||||
|
onRefetchAiAnalysis() // 같은 종목 재클릭 → 강제 새로고침
|
||||||
|
} else {
|
||||||
|
onSelectStock(stock.stock_code) // 새 종목 → queryKey 변경으로 자동 fetch
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
|
className={`w-full p-3 text-left hover:bg-gray-700/30 transition-all duration-200 ${selectedStock === stock.stock_code ? 'bg-purple-500/10 border-l-2 border-purple-500' : 'border-l-2 border-transparent'}`}
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-start mb-1">
|
<div className="flex justify-between items-start mb-1">
|
||||||
|
|||||||
@@ -55,22 +55,20 @@ const Dashboard = () => {
|
|||||||
// 실시간성이 필요한 것만 refetchInterval 유지
|
// 실시간성이 필요한 것만 refetchInterval 유지
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// ✅ 보유종목 요약 자산 - 매매 시에만 갱신 (polling 없음)
|
// ✅ 보유종목 목록 + 요약 자산 - 단일 API 호출 (lock 경쟁 제거)
|
||||||
const { data: portfolioSummary, error: portfolioSummaryError } = useQuery({
|
const { data: portfolioCombined, error: portfolioSummaryError } = useQuery({
|
||||||
queryKey: ['portfolio-summary'],
|
queryKey: ['portfolio-combined'],
|
||||||
queryFn: async () => (await api.get('/portfolio/summary')).data,
|
|
||||||
staleTime: Infinity,
|
|
||||||
})
|
|
||||||
|
|
||||||
// ✅ 보유종목 목록 - 매매 시에만 갱신 (polling 없음)
|
|
||||||
const { data: portfolio } = useQuery({
|
|
||||||
queryKey: ['portfolio'],
|
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const data = (await api.get('/portfolio')).data;
|
const data = (await api.get('/portfolio/combined')).data;
|
||||||
return data.filter((item: any) => item.quantity > 0);
|
return {
|
||||||
|
holdings: (data.holdings as any[]).filter((item) => item.quantity > 0),
|
||||||
|
summary: data.summary,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
staleTime: Infinity,
|
staleTime: Infinity,
|
||||||
})
|
})
|
||||||
|
const portfolio = portfolioCombined?.holdings
|
||||||
|
const portfolioSummary = portfolioCombined?.summary
|
||||||
|
|
||||||
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
|
// MCP 상태 - 30초 (UI 정보용, 느려도 무방)
|
||||||
const { data: mcpStatus } = useQuery({
|
const { data: mcpStatus } = useQuery({
|
||||||
@@ -98,8 +96,7 @@ const Dashboard = () => {
|
|||||||
if (data.trades_executed && data.trades_executed.length > 0) {
|
if (data.trades_executed && data.trades_executed.length > 0) {
|
||||||
console.log('✅ 자동매매 체결:', data.trades_executed.length, '건');
|
console.log('✅ 자동매매 체결:', data.trades_executed.length, '건');
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
@@ -189,9 +186,7 @@ const Dashboard = () => {
|
|||||||
if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) {
|
if (prevLatestTradeIdRef.current !== null && latestId > prevLatestTradeIdRef.current) {
|
||||||
const t = tradeHistory[0];
|
const t = tradeHistory[0];
|
||||||
console.log('✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신');
|
console.log('✅ 새 매매 감지:', t.stock_name, t.trade_type, '→ 포트폴리오 갱신');
|
||||||
// setTimeout 중복 제거 → 1회만 호출
|
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] });
|
|
||||||
}
|
}
|
||||||
prevLatestTradeIdRef.current = latestId;
|
prevLatestTradeIdRef.current = latestId;
|
||||||
}, [tradeHistory, queryClient])
|
}, [tradeHistory, queryClient])
|
||||||
@@ -207,8 +202,7 @@ const Dashboard = () => {
|
|||||||
|
|
||||||
if (latest.type === 'trade_executed') {
|
if (latest.type === 'trade_executed') {
|
||||||
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신');
|
console.log('🔔 WebSocket trade_executed → 포트폴리오 즉시 갱신');
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] });
|
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] });
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,34 +211,22 @@ const Dashboard = () => {
|
|||||||
}
|
}
|
||||||
}, [wsMessages, queryClient])
|
}, [wsMessages, queryClient])
|
||||||
|
|
||||||
// 보유종목 코드 목록 (useMemo로 안정화)
|
// ✅ 현재가 - /combined에서 이미 받은 current_price 활용 (별도 API 호출 불필요)
|
||||||
const allStockCodes = useMemo(() => {
|
const livePrices = useMemo<Record<string, number>>(() => {
|
||||||
return (portfolio || []).map((s: any) => s.stock_code)
|
const prices: Record<string, number> = {}
|
||||||
|
;(portfolio || []).forEach((s: any) => {
|
||||||
|
if (s.current_price) prices[s.stock_code] = Number(s.current_price)
|
||||||
|
})
|
||||||
|
return prices
|
||||||
}, [portfolio])
|
}, [portfolio])
|
||||||
|
|
||||||
// ✅ 현재가 - 보유종목이 있을 때만, 30초 간격
|
|
||||||
// (portfolio가 변경될 때만 queryKey도 바뀌어 자동 재조회)
|
|
||||||
const { data: livePrices } = useQuery({
|
|
||||||
queryKey: ['live-prices', allStockCodes.join(',')],
|
|
||||||
queryFn: async () => {
|
|
||||||
if (allStockCodes.length === 0) return {}
|
|
||||||
const response = await api.get('/market-data/prices', {
|
|
||||||
params: { codes: allStockCodes.join(',') }
|
|
||||||
})
|
|
||||||
return (response.data.prices || {}) as Record<string, number>
|
|
||||||
},
|
|
||||||
enabled: allStockCodes.length > 0,
|
|
||||||
refetchInterval: isMarketOpen ? 30000 : false,
|
|
||||||
staleTime: 20000,
|
|
||||||
})
|
|
||||||
|
|
||||||
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
|
// useMemo로 Set 안정화 → React.memo 패널에 전달 시 레퍼런스 유지
|
||||||
const heldStockCodes = useMemo(
|
const heldStockCodes = useMemo<Set<string>>(
|
||||||
() => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
|
() => new Set((portfolio || []).map((s: any) => s.stock_code as string)),
|
||||||
[portfolio]
|
[portfolio]
|
||||||
)
|
)
|
||||||
|
|
||||||
const pendingBuyStockCodes = useMemo(
|
const pendingBuyStockCodes = useMemo<Set<string>>(
|
||||||
() => new Set(
|
() => new Set(
|
||||||
(tradeHistory || [])
|
(tradeHistory || [])
|
||||||
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
.filter((t: any) => t.status === 'PENDING' && t.trade_type === 'BUY')
|
||||||
@@ -267,8 +249,7 @@ const Dashboard = () => {
|
|||||||
const handleSell = useCallback(async (stockCode: string, stockName: string, quantity: number) => {
|
const handleSell = useCallback(async (stockCode: string, stockName: string, quantity: number) => {
|
||||||
await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null })
|
await api.post('/portfolio/sell', { stock_code: stockCode, quantity, price: null })
|
||||||
// 매도 즉시 포트폴리오 갱신
|
// 매도 즉시 포트폴리오 갱신
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio'] })
|
queryClient.invalidateQueries({ queryKey: ['portfolio-combined'] })
|
||||||
queryClient.invalidateQueries({ queryKey: ['portfolio-summary'] })
|
|
||||||
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
queryClient.invalidateQueries({ queryKey: ['trade-history'] })
|
||||||
console.log(`✅ ${stockName} 매도 주문 완료`)
|
console.log(`✅ ${stockName} 매도 주문 완료`)
|
||||||
}, [queryClient])
|
}, [queryClient])
|
||||||
|
|||||||
Reference in New Issue
Block a user