""" 매매일지 API 엔드포인트 - GET /: 최근 AI 생성 매매일지 목록 - GET /stats: 일지 통계 (전략별, 감정 분포) - GET /{journal_id}: 특정 일지 상세 - POST /generate/{trade_id}: 특정 거래에 대한 일지 즉시 생성 """ from fastapi import APIRouter, Depends, HTTPException, Query from app.core.security import require_api_key from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, desc, func from typing import Optional from datetime import datetime, timedelta, timezone import logging # KST 고정 오프셋. pytz 의 `replace(tzinfo=KST)` 는 역사적 LMT(+08:28)를 물고 올 수 # 있어 쓰지 않는다. 한국은 1988 년 이후 DST 가 없으므로 고정 +09:00 으로 충분하다. _KST_TZ = timezone(timedelta(hours=9)) def _kst_iso(dt: datetime | None) -> str | None: """KST naive datetime → **`+09:00` 오프셋을 명시한** ISO 8601 문자열. [2026-09-29] 이전 구현은 UTC tzinfo 를 덧붙여 `+00:00` 라벨을 달았다. 그러나 `trade_journals.journal_date/created_at` 은 `timeutil.now_kst()`(KST naive) 로 저장되므로 UTC 라벨은 거짓이었고, 프론트가 `timeZone: 'Asia/Seoul'` 로 렌더하면서 **+9시간** 밀렸다(운영 실측: `2026-09-29T15:48:49.580747+00:00` → 화면 09-30 00:48). **왜 raw naive(`/trades` 규약)가 아니라 오프셋 명시인가** — 프론트 소비 지점이 규약별로 짝이 맞춰져 있다: - `/trades` 는 raw naive 를 주고 **프론트가 `+09:00` 을 덧붙여** 보정한다 (`JournalPanel.tsx:101-103`, `TradeHistory.tsx:11-16`). - 일지 경로(`JournalPanel.tsx:167-169`)에는 **그 보정이 없다** — 백엔드 문자열을 `new Date()` 에 그대로 넘긴다. 따라서 일지를 raw naive 로 바꾸면 비-KST 브라우저에서 같은 증상이 형태만 바꿔 남는다. 오프셋을 명시하면 `new Date()` 가 어느 타임존에서도 정확히 파싱하고 프론트는 손댈 필요가 없다. `/trades` 의 프론트 보정은 `includes('+')` 조건부라 오프셋 문자열과 충돌하지 않는다(이중 보정 없음). ⚠️ **한계 — 2026-09-23(`ec2fa2b`) 이전 행에는 이 라벨이 거짓이다.** 그 시점 전 `TradeJournal.journal_date/created_at` 은 `default=datetime.utcnow`(UTC naive) 였으므로 `+09:00` 을 붙이면 **9시간 이르게** 표시된다(구 `+00:00` 구현에서는 그 행들만 맞았다). 두 규약이 한 테이블에 섞여 있어 행 단위 구분이 불가능하고, 영향은 `limit` 목록에 구 행이 올라올 때뿐이므로 보정 마이그레이션은 하지 않는다. 신규 행(대다수)이 맞는 쪽을 택한 것이다. """ if dt is None: return None return dt.replace(tzinfo=_KST_TZ).isoformat() from app.core.database import get_db from app.models.models import TradeJournal, TradeHistory logger = logging.getLogger(__name__) router = APIRouter() @router.get("") async def list_journals( limit: int = Query(default=20, ge=1, le=100), offset: int = Query(default=0, ge=0), stock_code: Optional[str] = None, db: AsyncSession = Depends(get_db), ): """최근 AI 생성 매매일지 목록 조회""" try: query = select(TradeJournal).order_by(desc(TradeJournal.journal_date)) if stock_code: query = query.where(TradeJournal.stock_code == stock_code) query = query.offset(offset).limit(limit) result = await db.execute(query) journals = result.scalars().all() return [ { "id": j.id, "trade_id": j.trade_id, "stock_code": j.stock_code, "stock_name": j.stock_name, "journal_date": _kst_iso(j.journal_date), "content": j.content, "strategy_name": j.strategy_name, "mood": j.mood, "tags": j.tags, "created_at": _kst_iso(j.created_at), } for j in journals ] except Exception as e: logger.error(f"journals list error: {e}", exc_info=True) return [] @router.get("/stats") async def get_journal_stats(db: AsyncSession = Depends(get_db)): """일지 통계: 전략별 / 감정별 분포.""" try: result_mood = await db.execute( select(TradeJournal.mood, func.count(TradeJournal.id).label("cnt")) .group_by(TradeJournal.mood) ) mood_dist = {row[0] or "unknown": row[1] for row in result_mood.fetchall()} result_strat = await db.execute( select(TradeJournal.strategy_name, func.count(TradeJournal.id).label("cnt")) .group_by(TradeJournal.strategy_name) ) strat_dist = {row[0] or "unknown": row[1] for row in result_strat.fetchall()} total = sum(mood_dist.values()) return { "total": total, "by_mood": mood_dist, "by_strategy": strat_dist, } except Exception as e: logger.error(f"journal stats error: {e}", exc_info=True) return {"total": 0, "by_mood": {}, "by_strategy": {}} @router.post("/generate/{trade_id}", dependencies=[Depends(require_api_key)]) async def generate_journal_for_trade_id( trade_id: int, db: AsyncSession = Depends(get_db), ): """특정 완료 거래(trade_id)에 대해 템플릿 기반 매매일지를 즉시 생성.""" from app.services.trade_analyzer import ( analyze_performance, generate_journal_for_trade, create_journal_record, ) # 기존 일지 확인 existing = await db.execute( select(TradeJournal).where(TradeJournal.trade_id == trade_id) ) if existing.scalar_one_or_none(): raise HTTPException(status_code=409, detail="이미 일지가 존재합니다.") # 거래 조회 trade_result = await db.execute( select(TradeHistory).where(TradeHistory.id == trade_id) ) trade = trade_result.scalar_one_or_none() if not trade: raise HTTPException(status_code=404, detail="거래 이력을 찾을 수 없습니다.") if trade.order_type != "SELL" or trade.status not in ("COMPLETED", "FILLED"): raise HTTPException(status_code=400, detail="완료된 매도 거래에 대해서만 일지를 생성할 수 있습니다.") perf = await analyze_performance(db, days=30) content = generate_journal_for_trade(trade, perf) journal = await create_journal_record(db, trade, content) await db.commit() await db.refresh(journal) return { "id": journal.id, "trade_id": journal.trade_id, "stock_code": journal.stock_code, "stock_name": journal.stock_name, "journal_date": _kst_iso(journal.journal_date), "content": journal.content, "strategy_name": journal.strategy_name, "mood": journal.mood, "tags": journal.tags, "created_at": _kst_iso(journal.created_at), } @router.get("/{journal_id}") async def get_journal(journal_id: int, db: AsyncSession = Depends(get_db)): """특정 매매일지 상세 조회""" result = await db.execute(select(TradeJournal).where(TradeJournal.id == journal_id)) journal = result.scalar_one_or_none() if not journal: raise HTTPException(status_code=404, detail="Journal not found") return { "id": journal.id, "trade_id": journal.trade_id, "stock_code": journal.stock_code, "stock_name": journal.stock_name, "journal_date": _kst_iso(journal.journal_date), "content": journal.content, "strategy_name": journal.strategy_name, "mood": journal.mood, "tags": journal.tags, "created_at": _kst_iso(journal.created_at), }