Files
auto-stock/backend/app/api/endpoints/journals.py
shuim e4346d30fb fix(journal): 장중 일지 자동 생성 + 전략명 unknown + profit_factor 단위 오류
- 장중 10분 주기 journal_intraday_scheduler 신설 (SELECT 전용, 쓰기는 db_worker 큐)
  - 매도 후 3분 유예(JOURNAL_INTRADAY_GRACE_MIN), trade_id in-flight TTL 30분
  - order_id 로 해소된 trade_id 에 일지가 있으면 INSERT skip (중복 방지)
- 16:00 배치 enqueue_trade_journal 이 strategy_type 을 넘기지 않아 전 일지가 'unknown' 이던 결함 수정
- analyze_performance profit_factor 를 손익률(%) 합이 아닌 금액 기준으로 (1.22 → 0.62)
- 일지 대상 SELL 상태에 PARTIAL 포함 (성과 집계 필터는 불변)
- journals 단건 생성: 중복 행 시 500 대신 409, JOURNAL_SELL_STATUSES 사용
- 16:00 배치 조회일수 config 화, 손익 0 기간 PF 오경고 제거

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 17:00:05 +09:00

192 lines
7.8 KiB
Python

"""
매매일지 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,
JOURNAL_SELL_STATUSES,
)
# 기존 일지 확인
existing = await db.execute(
select(TradeJournal).where(TradeJournal.trade_id == trade_id)
)
if existing.scalars().first(): # 중복 행이 있어도 500 이 아니라 409
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 JOURNAL_SELL_STATUSES:
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),
}