하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소. 주문·리스크 - MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문) - KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류) - 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백 - 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락 - 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드) - 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단 데이트레이딩 - 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한 - 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정 스윙 - 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT) - 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed - 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일 - 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON 인프라·보안 - 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹 - KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환 - db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시 - KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패 하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준 CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신 backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
12 KiB
Python
245 lines
12 KiB
Python
"""
|
|
거래 성과 분석 + 전략 자동 최적화 API
|
|
|
|
GET /api/v1/analysis/performance?days=30 — 성과 통계
|
|
GET /api/v1/analysis/insights?days=30 — 전략 인사이트 + 개선 제안
|
|
POST /api/v1/analysis/generate-journals — 미생성 일지 일괄 생성
|
|
POST /api/v1/analysis/optimize-strategy — 전략 최적화 즉시 실행
|
|
GET /api/v1/analysis/optimization-history — 최적화 이력
|
|
"""
|
|
import logging
|
|
from datetime import datetime, date
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastapi import APIRouter, BackgroundTasks, Depends, Query
|
|
from app.core.security import require_api_key
|
|
from sqlalchemy import select, desc
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.database import get_db, AsyncSessionLocal
|
|
from app.models.models import StrategyOptimizationLog, TradeJournal
|
|
from app.core.timeutil import now_kst
|
|
from app.services.trade_analyzer import (
|
|
analyze_performance,
|
|
build_insights,
|
|
create_journal_record,
|
|
generate_journal_for_trade,
|
|
get_uncovered_trades,
|
|
)
|
|
from app.services.strategy_optimizer import (
|
|
get_last_optimization_date,
|
|
run_optimization,
|
|
save_optimization_log,
|
|
)
|
|
|
|
router = APIRouter()
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GET /performance
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/performance")
|
|
async def get_performance(
|
|
days: int = Query(default=30, ge=1, le=365, description="분석 기간 (일)"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""최근 N일 완료 거래 성과 통계 반환."""
|
|
try:
|
|
return await analyze_performance(db, days=days)
|
|
except Exception as e:
|
|
logger.error(f"performance 분석 실패: {e}", exc_info=True)
|
|
return {"error": "성과 분석 실패 — 서버 로그 확인", "summary": {}, "total_trades": 0}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GET /insights
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/insights")
|
|
async def get_insights(
|
|
days: int = Query(default=30, ge=1, le=365),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""전략 인사이트 및 개선 제안 반환."""
|
|
try:
|
|
perf = await analyze_performance(db, days=days)
|
|
insights = build_insights(perf)
|
|
last_opt_date = await get_last_optimization_date(db)
|
|
return {
|
|
"insights": insights,
|
|
"performance_summary": perf.get("summary", {}),
|
|
"last_optimization_date": last_opt_date.isoformat() if last_opt_date else None,
|
|
"period_days": days,
|
|
"generated_at": now_kst().isoformat(),
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"insights 생성 실패: {e}", exc_info=True)
|
|
return {"insights": [], "error": "인사이트 생성 실패 — 서버 로그 확인"}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# POST /generate-journals
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
async def _generate_journals_task(days: int = 7) -> Dict[str, Any]:
|
|
"""백그라운드에서 미생성 일지를 일괄 생성."""
|
|
async with AsyncSessionLocal() as db:
|
|
try:
|
|
trades = await get_uncovered_trades(db, days=days)
|
|
if not trades:
|
|
return {"created": 0, "message": "생성할 미처리 거래 없음"}
|
|
|
|
perf = await analyze_performance(db, days=30)
|
|
created = 0
|
|
for trade in trades:
|
|
try:
|
|
content = generate_journal_for_trade(trade, perf)
|
|
await create_journal_record(db, trade, content)
|
|
created += 1
|
|
except Exception as e:
|
|
logger.warning(f"일지 생성 실패 ({trade.stock_code}): {e}")
|
|
|
|
await db.commit()
|
|
logger.info(f"✅ 매매일지 {created}건 생성 완료 (대상: {len(trades)}건)")
|
|
return {"created": created, "total_uncovered": len(trades)}
|
|
except Exception as e:
|
|
await db.rollback()
|
|
logger.error(f"일지 일괄 생성 실패: {e}", exc_info=True)
|
|
return {"created": 0, "error": "일지 생성 실패 — 서버 로그 확인"}
|
|
|
|
|
|
@router.post("/generate-journals", dependencies=[Depends(require_api_key)])
|
|
async def generate_journals(
|
|
background_tasks: BackgroundTasks,
|
|
days: int = Query(default=7, ge=1, le=30, description="최근 N일 내 미생성 거래 대상"),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> Dict[str, Any]:
|
|
"""일지가 없는 완료 거래에 대해 템플릿 기반 매매일지를 일괄 생성."""
|
|
try:
|
|
uncovered = await get_uncovered_trades(db, days=days)
|
|
count = len(uncovered)
|
|
if count == 0:
|
|
return {"status": "skipped", "message": "생성할 미처리 거래 없음", "queued": 0}
|
|
|
|
background_tasks.add_task(_generate_journals_task, days)
|
|
return {
|
|
"status": "started",
|
|
"message": f"{count}건의 거래 일지 생성을 시작했습니다.",
|
|
"queued": count,
|
|
}
|
|
except Exception as e:
|
|
logger.error(f"generate-journals 실패: {e}", exc_info=True)
|
|
return {"status": "error", "message": "거래 일지 생성 시작 실패 — 서버 로그 확인"}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# POST /optimize-strategy
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
# 최적화 진행 상태 (단순 인메모리 플래그)
|
|
_optimization_running: bool = False
|
|
|
|
|
|
@router.post("/optimize-strategy", dependencies=[Depends(require_api_key)])
|
|
async def optimize_strategy(
|
|
background_tasks: BackgroundTasks,
|
|
days: int = Query(default=30, ge=7, le=180, description="성과 분석 기간"),
|
|
period_days: int = Query(default=90, ge=30, le=365, description="백테스트 기간"),
|
|
) -> Dict[str, Any]:
|
|
"""
|
|
전략 파라미터 최적화를 즉시 실행 (수동 트리거).
|
|
|
|
- 성과 분석 → 파라미터 그리드 서치 → Sharpe 10% 개선 시 자동 적용
|
|
- 백그라운드에서 실행 (수 분 소요 가능)
|
|
"""
|
|
global _optimization_running
|
|
if _optimization_running:
|
|
return {"status": "already_running", "message": "최적화가 이미 실행 중입니다."}
|
|
|
|
async def _opt_task():
|
|
global _optimization_running
|
|
_optimization_running = True
|
|
try:
|
|
async with AsyncSessionLocal() as db:
|
|
perf = await analyze_performance(db, days=days)
|
|
result = await run_optimization(db, perf, trigger="manual", period_days=period_days)
|
|
await save_optimization_log(db, result, trigger="manual")
|
|
logger.info(f"✅ 수동 최적화 완료: applied={result.applied}, reason={result.apply_reason}")
|
|
except Exception as e:
|
|
logger.error(f"최적화 실패: {e}", exc_info=True)
|
|
finally:
|
|
_optimization_running = False
|
|
|
|
background_tasks.add_task(_opt_task)
|
|
return {
|
|
"status": "started",
|
|
"message": "전략 최적화를 시작했습니다. /optimization-history 에서 결과를 확인하세요.",
|
|
"performance_days": days,
|
|
"backtest_period_days": period_days,
|
|
}
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GET /optimization-history
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/optimization-history")
|
|
async def get_optimization_history(
|
|
limit: int = Query(default=10, ge=1, le=50),
|
|
db: AsyncSession = Depends(get_db),
|
|
) -> List[Dict[str, Any]]:
|
|
"""전략 최적화 실행 이력 반환."""
|
|
try:
|
|
stmt = (
|
|
select(StrategyOptimizationLog)
|
|
.order_by(desc(StrategyOptimizationLog.created_at))
|
|
.limit(limit)
|
|
)
|
|
result = await db.execute(stmt)
|
|
logs = result.scalars().all()
|
|
|
|
return [
|
|
{
|
|
"id": log.id,
|
|
"run_date": log.run_date.isoformat() if log.run_date else None,
|
|
"trigger": log.trigger,
|
|
"period_days": log.period_days,
|
|
"best_sharpe": float(log.best_sharpe) if log.best_sharpe else None,
|
|
"current_sharpe": float(log.current_sharpe) if log.current_sharpe else None,
|
|
"sharpe_improvement_pct": (
|
|
round(
|
|
(float(log.best_sharpe) - float(log.current_sharpe))
|
|
/ max(abs(float(log.current_sharpe)), 0.001)
|
|
* 100,
|
|
2,
|
|
)
|
|
if log.best_sharpe and log.current_sharpe
|
|
else None
|
|
),
|
|
"best_win_rate": float(log.best_win_rate) if log.best_win_rate else None,
|
|
"best_mdd": float(log.best_mdd) if log.best_mdd else None,
|
|
"applied": log.applied,
|
|
"apply_reason": log.apply_reason,
|
|
"best_params": log.best_params,
|
|
"previous_params": log.previous_params,
|
|
"total_combinations": log.total_combinations,
|
|
"created_at": log.created_at.isoformat() if log.created_at else None,
|
|
}
|
|
for log in logs
|
|
]
|
|
except Exception as e:
|
|
logger.error(f"optimization-history 조회 실패: {e}", exc_info=True)
|
|
return []
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# GET /optimization-status
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
@router.get("/optimization-status")
|
|
async def get_optimization_status() -> Dict[str, Any]:
|
|
"""현재 최적화 실행 상태 조회."""
|
|
return {"running": _optimization_running}
|