Files
auto-stock/backend/app/api/endpoints/trades_v2.py
shuim ec2fa2b423 fix: DB 타임스탬프 KST 통일 + KIS 토큰 캐시 영속화
시간대 혼재: 모델 기본값은 datetime.utcnow(UTC), 수동 매매 등 일부 경로는 _get_kst_now(KST)로
저장해 같은 테이블에 9시간 차가 섞였다(화면 매매시각 02:14 등). 조회 구간(utcnow - days)도
KST 저장분과 비교돼 어긋났고, 추가매수 쿨다운은 (UTC now - KST created_at)이 음수라 상시
발동했다. app.core.timeutil.now_kst() 도입 후 모델 기본값 27곳과 app/ 전역 utcnow() 호출을
모두 KST 기준으로 통일(잔존 0건, 테스트로 고정).

KIS 토큰 캐시(~/.cache/auto-stock)가 컨테이너에만 있어 배포마다 사라지고 토큰을 새로
발급받다 KIS가 EGW00103(유효하지 않은 AppKey)로 거부 → 502·잔고 0 유발. 볼륨 마운트로
재배포 후에도 재사용.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 11:24:14 +09:00

127 lines
5.4 KiB
Python

"""사용자 스코프 거래 이력 조회 API (`_v2`, 멀티테넌트 1단계 §5.7).
기존 `trades.py`의 `GET /trades`(파라미터·손익 계산 로직)는 그대로 두고,
`TradeHistory.user_id` 필터만 추가한 신규 라우터. 레거시 호환 규칙은
`portfolio_v2.py`의 `_user_scope_filter`와 동일(`MULTI_TENANT_ENABLED=False`면
`user_id == 대상 id` 또는 `user_id IS NULL` 모두 포함).
"""
import logging
import re
from datetime import datetime, timedelta
from decimal import Decimal
from typing import List, Optional
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.tenant_auth import CurrentUser, get_current_user
from app.models.models import TradeHistory
from app.api.endpoints.portfolio_v2 import _resolve_target_user_id, _user_scope_filter
from app.api.endpoints.trades import TradeHistoryResponse
from app.core.timeutil import now_kst
logger = logging.getLogger(__name__)
router = APIRouter()
@router.get("", response_model=List[TradeHistoryResponse])
async def get_trade_history_v2(
skip: int = 0,
limit: int = 100,
stock_code: Optional[str] = None,
status: Optional[str] = None,
order_type: Optional[str] = None,
days: int = 30,
user_id: Optional[int] = None,
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 거래 이력 조회 (`GET /trades`와 동일 파라미터 + 손익 파싱)."""
target_user_id = await _resolve_target_user_id(user, user_id, db)
query = select(TradeHistory).where(_user_scope_filter(TradeHistory.user_id, target_user_id))
since_date = now_kst() - timedelta(days=days)
query = query.where(TradeHistory.created_at >= since_date)
if stock_code:
query = query.where(TradeHistory.stock_code == stock_code)
if status:
query = query.where(TradeHistory.status == status)
if order_type:
query = query.where(TradeHistory.order_type == order_type)
query = query.order_by(TradeHistory.created_at.desc()).offset(skip).limit(limit)
result = await db.execute(query)
trades = result.scalars().all()
trade_responses = []
for trade in trades:
response = TradeHistoryResponse.model_validate(trade)
if trade.order_type == "SELL" and trade.status in ("EXECUTED", "COMPLETED"):
if hasattr(trade, "net_profit_loss") and trade.net_profit_loss is not None:
response.profit_loss = trade.net_profit_loss
if hasattr(trade, "profit_loss_rate") and trade.profit_loss_rate is not None:
response.profit_loss_rate = float(trade.profit_loss_rate)
if response.profit_loss is None or response.profit_loss_rate is None:
text_to_parse = trade.exit_reason or trade.message or ""
if response.profit_loss is None:
profit_match = re.search(r"손익:\s*([+-]?[\d,]+)원", text_to_parse)
if profit_match:
try:
response.profit_loss = Decimal(profit_match.group(1).replace(",", ""))
except Exception as e:
logger.debug(f"[trades_v2] 손익 금액 파싱 실패: {e}")
if response.profit_loss_rate is None:
message_rate_match = re.search(r"손익률\s*([+-]?[\d.]+)%", trade.message or "")
rate_match = message_rate_match or re.search(r"([+-]?[\d.]+)%", text_to_parse)
if rate_match:
try:
response.profit_loss_rate = float(rate_match.group(1))
except Exception as e:
logger.debug(f"[trades_v2] 손익률 파싱 실패: {e}")
trade_responses.append(response)
return trade_responses
# ── DELETE /trades/v2/pending ────────────────────────────────────────────
@router.delete("/pending")
async def delete_pending_trades_v2(
user: CurrentUser = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""사용자 스코프 PENDING/FAILED 매수 시도 정리 (M6).
기존 `DELETE /trades/pending`(trades.py)은 `user_id` 조건 없이 전체 삭제라
`MULTI_TENANT_ENABLED=True`에서 일반 사용자가 타 사용자의 이력을 지울 수 있었다.
`_user_scope_filter`와 동일 규칙(MT=False면 본인 id 또는 NULL 행 포함, MT=True면
본인 행만)을 적용해 legacy 컨텍스트(관리자/API 키, MT=False)는 기존 동작과 동일하고,
일반 사용자는 본인 이력만 지운다.
"""
target_user_id = await _resolve_target_user_id(user, None, db)
query = select(TradeHistory).where(
TradeHistory.status.in_(["PENDING", "FAILED"]),
_user_scope_filter(TradeHistory.user_id, target_user_id),
)
result = await db.execute(query)
pending_trades = result.scalars().all()
count = len(pending_trades)
for trade in pending_trades:
await db.delete(trade)
await db.commit()
logger.info(f"✅ [v2] user_id={target_user_id} 정리 완료: {count}개의 PENDING/FAILED 매수 시도 삭제")
return {"deleted_count": count, "message": f"Cleaned up {count} pending/failed trades"}