Files
auto-stock/backend/app/api/endpoints/journals.py
shuim 5f0f839039 전략 취약점 15건 수정 (코드 리뷰 기반)
## CRITICAL/HIGH (S1-S8)
- S1: Aggressive 모드 %B 스케일링 캡 1.3 (BB 중앙선 위 매수 방지)
- S2: ML scaler train-only fit (데이터 누수 제거)
- S3: SIDEWAYS = ADX<20 AND slope≈0 (트렌드 초기 차단 해소)
- S4: EOD 청산 실패 시 재시도 + CRITICAL 로그
- S5: 일일 손실 한도에 미실현 P&L 포함
- S6: trailing stop 고점 매 사이클 갱신
- S7: 섹터 필터 미매핑 종목 "기타" 분류 (max 3 적용)
- S8: 스캐너 RSI 기준 25→46, BB 점수 -3%~+1.5% 구간

## MEDIUM (M1-M7)
- M1: 스캐너 최소 거래량 100만→10만주
- M2: ADX 히스테리시스 18/22 (국면 플리핑 방지)
- M3: 분할매도 최소 수익 1.0%→0.5%
- M4: ORB 갭 필터 4% + 레인지 폭 0.3% 최소
- M5: 쿨다운 스트릭 부분매도 시 미변경
- M6: MDD 강제매도 loss_tracker 기록 제거 (허위 ban 방지)
- M7: RSI Wilder 스무딩으로 통일

## 인프라/보안
- 인증 누락 엔드포인트 수정 (analysis, journals)
- nginx 보안 헤더 location 블록 누락 수정
- Alembic 마이그레이션 IF NOT EXISTS 가드 추가
- SellRequest/UsOrderRequest 입력 검증 강화
- 차트 네이버 폴백 추가 (DB 데이터 없을 때)
- 서버 이전 시 자동 DB 마이그레이션 + KRX 초기 수집
- CLAUDE.md 섹션 5.1 백테스트 값 동기화 (4%→6%, -6%→-7%)
- MAX_SWING_POSITIONS 25→20 (CLAUDE.md 준수)
- mdd_manager failed_count에서 skip 종목 제외

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 11:44:14 +09:00

163 lines
5.7 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, timezone
import logging
def _utc_iso(dt: datetime | None) -> str | None:
"""UTC naive datetime → ISO 8601 with +00:00 (JS가 UTC로 올바르게 파싱)"""
if dt is None:
return None
return dt.replace(tzinfo=timezone.utc).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": _utc_iso(j.journal_date) if j.journal_date else None,
"content": j.content,
"strategy_name": j.strategy_name,
"mood": j.mood,
"tags": j.tags,
"created_at": _utc_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": _utc_iso(journal.journal_date),
"content": journal.content,
"strategy_name": journal.strategy_name,
"mood": journal.mood,
"tags": journal.tags,
"created_at": _utc_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": journal.journal_date.isoformat() if journal.journal_date else None,
"content": journal.content,
"strategy_name": journal.strategy_name,
"mood": journal.mood,
"tags": journal.tags,
"created_at": journal.created_at.isoformat() if journal.created_at else None,
}