fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
325
backend/app/services/daytrading/position_manager.py
Normal file
325
backend/app/services/daytrading/position_manager.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""데이트레이딩 인메모리 포지션 상태 관리
|
||||
|
||||
기존 day_trading.py의 전역 변수들을 DayTradingState 클래스로 캡슐화.
|
||||
싱글톤 인스턴스 day_trading_state 를 패키지 전체에서 공유.
|
||||
|
||||
CLAUDE.md 3.1: 실시간 레이어에서 DB I/O 절대 금지.
|
||||
포지션 상태는 인메모리 유지, 체결 확정 시에만 db_task_queue 경유.
|
||||
|
||||
사용 패턴:
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
|
||||
# 날짜 초기화 (스케줄러 사이클 시작 시 호출)
|
||||
day_trading_state.reset_if_new_day()
|
||||
|
||||
# 포지션 추가/제거 시 반드시 trade_lock 획득
|
||||
async with day_trading_state.trade_lock:
|
||||
day_trading_state.add_position(code, data)
|
||||
|
||||
주의사항:
|
||||
- [동시성] positions, daily_pnl, closed_today 수정 시 반드시 trade_lock 획득
|
||||
- [영속성] 앱 재시작 시 인메모리 상태가 유실됨 (DB 복원 메커니즘 없음)
|
||||
- [단일 프로세스] 다중 워커 환경에서는 상태 불일치 발생 가능
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
import pytz
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 한국 표준시(KST) 타임존 객체 — 날짜 비교 및 시간 계산에 사용
|
||||
_KST = pytz.timezone("Asia/Seoul")
|
||||
|
||||
|
||||
def _today_kst() -> date:
|
||||
"""현재 KST 기준 날짜 반환.
|
||||
|
||||
Returns:
|
||||
date: 오늘 날짜 (KST 기준)
|
||||
"""
|
||||
return datetime.now(_KST).date()
|
||||
|
||||
|
||||
class DayTradingState:
|
||||
"""데이트레이딩 일일 상태 컨테이너.
|
||||
|
||||
날짜가 바뀌면 reset_if_new_day()로 자동 초기화.
|
||||
trade_lock 획득 후 positions / daily_pnl / closed_today 수정.
|
||||
|
||||
Attributes:
|
||||
positions: 활성 포지션 딕셔너리 {종목코드: 포지션정보}
|
||||
orb_cache: 시초가 레인지(ORB) 캐시 {종목코드: OpeningRange}
|
||||
daily_pnl: 당일 누적 실현 손익 (원)
|
||||
last_total_assets: 마지막 조회된 총자산 (포지션 사이징 기준)
|
||||
closed_today: 당일 청산 완료된 종목 코드 집합 (재진입 방지)
|
||||
loss_streak_today: 당일 연속 손실 횟수 (3회 이상 시 쿨다운)
|
||||
trade_lock: 포지션 수정 동시성 보호용 asyncio.Lock
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""DayTradingState 초기화.
|
||||
|
||||
모든 상태를 기본값으로 설정. 실제 초기화는 reset_if_new_day()에서 수행.
|
||||
"""
|
||||
# 활성 포지션: {stock_code: {...entry info...}}
|
||||
self.positions: dict[str, dict[str, Any]] = {}
|
||||
# ORB 캐시: {stock_code: OpeningRange}
|
||||
self.orb_cache: dict[str, Any] = {}
|
||||
# 당일 실현 손익
|
||||
self.daily_pnl: float = 0.0
|
||||
# 마지막으로 조회된 총자산
|
||||
self.last_total_assets: float = 0.0
|
||||
# 당일 청산 종목 (재진입 방지)
|
||||
self.closed_today: set[str] = set()
|
||||
# 당일 연속 손실 횟수 (쿨다운 트리거)
|
||||
self.loss_streak_today: int = 0
|
||||
# 마지막 초기화 날짜
|
||||
self.last_reset_date: date | None = None
|
||||
# 포트폴리오 요약 캐시 (총자산, 현금 등)
|
||||
self.portfolio_summary_cache: dict[str, float] = {}
|
||||
# 포트폴리오 캐시 타임스탬프
|
||||
self.portfolio_summary_ts: datetime | None = None
|
||||
# 포트폴리오 캐시 무효화 플래그
|
||||
self.portfolio_cache_dirty: bool = True
|
||||
# 매매 실행 락 — positions 수정 시 반드시 획득
|
||||
self.trade_lock = asyncio.Lock()
|
||||
|
||||
def reset_if_new_day(self) -> bool:
|
||||
"""날짜가 바뀌면 일일 상태 초기화.
|
||||
|
||||
스케줄러 사이클 시작 시 호출하여 전일 데이터가
|
||||
당일 매매에 영향을 주지 않도록 보장.
|
||||
|
||||
Returns:
|
||||
bool: 초기화가 수행되었으면 True, 아니면 False
|
||||
"""
|
||||
today = _today_kst()
|
||||
if self.last_reset_date != today:
|
||||
self.positions = {}
|
||||
self.orb_cache = {}
|
||||
self.daily_pnl = 0.0
|
||||
self.last_total_assets = 0.0
|
||||
self.closed_today = set()
|
||||
self.loss_streak_today = 0
|
||||
self.last_reset_date = today
|
||||
self.portfolio_summary_cache = {}
|
||||
self.portfolio_summary_ts = None
|
||||
self.portfolio_cache_dirty = True
|
||||
logger.info(f"[DayTrading] 일일 상태 초기화: {today}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_position(self, stock_code: str, data: dict) -> None:
|
||||
"""포지션 추가.
|
||||
|
||||
반드시 trade_lock 획득 후 호출해야 함.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드 (예: "005930")
|
||||
data: 포지션 정보 딕셔너리
|
||||
- stock_name: 종목명
|
||||
- strategy: 진입 전략 (ORB/VWAP/RSI_BB)
|
||||
- entry_price: 진입가
|
||||
- stop_loss: 손절가
|
||||
- target_price: 목표가
|
||||
- quantity: 수량
|
||||
- highest_price: 최고가 (트레일링스탑용)
|
||||
- entry_time: 진입 시간
|
||||
- order_id: KIS 주문 ID
|
||||
"""
|
||||
self.positions[stock_code] = data
|
||||
self.portfolio_cache_dirty = True
|
||||
|
||||
def remove_position(self, stock_code: str) -> dict | None:
|
||||
"""포지션 제거 및 반환.
|
||||
|
||||
반드시 trade_lock 획득 후 호출해야 함.
|
||||
제거된 종목은 closed_today에 추가되어 당일 재진입 방지.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
|
||||
Returns:
|
||||
dict | None: 제거된 포지션 정보, 없으면 None
|
||||
"""
|
||||
pos = self.positions.pop(stock_code, None)
|
||||
if pos is not None:
|
||||
self.closed_today.add(stock_code)
|
||||
self.portfolio_cache_dirty = True
|
||||
return pos
|
||||
|
||||
def update_pnl(self, realized: float) -> None:
|
||||
"""실현 손익 누적 및 연속 손실 카운트 업데이트.
|
||||
|
||||
손실 시 loss_streak_today 증가, 이익 시 리셋.
|
||||
3연패 이상이면 is_cooldown_active()가 True 반환.
|
||||
|
||||
Args:
|
||||
realized: 실현 손익 (원). 양수=이익, 음수=손실
|
||||
"""
|
||||
self.daily_pnl += realized
|
||||
if realized < 0:
|
||||
self.loss_streak_today += 1
|
||||
else:
|
||||
# 이익 발생 시 연속 손실 카운트 리셋
|
||||
self.loss_streak_today = 0
|
||||
|
||||
def get_position(self, stock_code: str) -> dict | None:
|
||||
"""특정 종목의 활성 포지션 정보 반환.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
|
||||
Returns:
|
||||
dict | None: 포지션 정보 딕셔너리, 없으면 None
|
||||
"""
|
||||
return self.positions.get(stock_code)
|
||||
|
||||
def is_closed_today(self, stock_code: str) -> bool:
|
||||
"""해당 종목이 당일 이미 청산되었는지 확인.
|
||||
|
||||
데이트레이딩은 동일 종목 당일 재진입을 허용하지 않음.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
|
||||
Returns:
|
||||
bool: 당일 청산 완료 여부
|
||||
"""
|
||||
return stock_code in self.closed_today
|
||||
|
||||
def position_count(self) -> int:
|
||||
"""현재 활성 포지션 수 반환.
|
||||
|
||||
MAX_POSITIONS(3) 한도 체크에 사용.
|
||||
|
||||
Returns:
|
||||
int: 활성 포지션 수
|
||||
"""
|
||||
return len(self.positions)
|
||||
|
||||
def is_cooldown_active(self) -> bool:
|
||||
"""당일 3연패 이상이면 쿨다운 (신규 진입 차단).
|
||||
|
||||
CLAUDE.md 7.3: 당일 3연패 시 신규 진입 차단.
|
||||
|
||||
Returns:
|
||||
bool: 쿨다운 활성화 여부
|
||||
"""
|
||||
return self.loss_streak_today >= 3
|
||||
|
||||
def has_orb(self, stock_code: str) -> bool:
|
||||
"""해당 종목의 ORB(시초가 레인지)가 수집되었는지 확인.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
|
||||
Returns:
|
||||
bool: ORB 데이터 존재 여부
|
||||
"""
|
||||
return stock_code in self.orb_cache
|
||||
|
||||
def set_orb(self, stock_code: str, orb: Any) -> None:
|
||||
"""ORB(시초가 레인지) 데이터 저장.
|
||||
|
||||
09:00~09:30 사이에 collect_opening_ranges()에서 호출.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
orb: OpeningRange 객체 (고가, 저가, VWAP 포함)
|
||||
"""
|
||||
self.orb_cache[stock_code] = orb
|
||||
|
||||
def get_orb(self, stock_code: str) -> Any | None:
|
||||
"""해당 종목의 ORB(시초가 레인지) 데이터 반환.
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
|
||||
Returns:
|
||||
Any | None: OpeningRange 객체, 없으면 None
|
||||
"""
|
||||
return self.orb_cache.get(stock_code)
|
||||
|
||||
|
||||
async def restore_from_db(self, db: AsyncSession) -> int:
|
||||
"""앱 재시작 시 ActiveTrade 테이블에서 당일 데이트레이딩 포지션 복원.
|
||||
|
||||
strategy_name='day_trade' AND status='active' AND created_at이 오늘인
|
||||
레코드를 인메모리 positions 딕셔너리에 복원합니다.
|
||||
이미 positions에 존재하는 종목은 건너뜁니다.
|
||||
|
||||
Args:
|
||||
db: AsyncSession 인스턴스
|
||||
|
||||
Returns:
|
||||
int: 복원된 포지션 수
|
||||
"""
|
||||
from app.models.models import ActiveTrade
|
||||
|
||||
today = _today_kst()
|
||||
restored = 0
|
||||
|
||||
try:
|
||||
result = await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.strategy_name == "day_trade",
|
||||
ActiveTrade.status == "active",
|
||||
)
|
||||
)
|
||||
active_trades = result.scalars().all()
|
||||
|
||||
async with self.trade_lock:
|
||||
for trade in active_trades:
|
||||
# 당일 생성된 포지션만 복원 (데이트레이딩은 당일 청산 원칙)
|
||||
trade_date = trade.created_at.date() if trade.created_at else None
|
||||
if trade_date != today:
|
||||
logger.warning(
|
||||
f"[DayTrading] 전일 미청산 포지션 발견: {trade.stock_code} "
|
||||
f"(created_at={trade.created_at}) — EOD 청산 대상"
|
||||
)
|
||||
# 전일 포지션도 복원하여 EOD 청산 가능하게 함
|
||||
if trade.stock_code in self.positions:
|
||||
continue
|
||||
|
||||
self.positions[trade.stock_code] = {
|
||||
"stock_name": trade.stock_name or trade.stock_code,
|
||||
"strategy": trade.strategy_name or "day_trade",
|
||||
"entry_price": float(trade.entry_price) if trade.entry_price else 0,
|
||||
"stop_loss": float(trade.stop_loss) if trade.stop_loss else 0,
|
||||
"target_price": float(trade.take_profit) if trade.take_profit else 0,
|
||||
"quantity": trade.quantity or 0,
|
||||
"highest_price": float(trade.highest_since_entry) if trade.highest_since_entry else (
|
||||
float(trade.entry_price) if trade.entry_price else 0
|
||||
),
|
||||
"entry_time": trade.created_at,
|
||||
"reason": trade.entry_reason or "",
|
||||
"order_id": trade.order_id or "",
|
||||
"restored_from_db": True,
|
||||
}
|
||||
restored += 1
|
||||
logger.info(
|
||||
f"[DayTrading] DB에서 포지션 복원: {trade.stock_code} "
|
||||
f"{trade.stock_name} qty={trade.quantity} "
|
||||
f"entry={trade.entry_price}"
|
||||
)
|
||||
|
||||
if restored:
|
||||
logger.info(f"[DayTrading] 총 {restored}개 포지션 DB에서 복원 완료")
|
||||
else:
|
||||
logger.debug("[DayTrading] 복원할 데이트레이딩 포지션 없음")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[DayTrading] DB 포지션 복원 실패: {e}", exc_info=True)
|
||||
|
||||
return restored
|
||||
|
||||
|
||||
# 싱글톤 인스턴스 — daytrading 패키지 전체에서 공유
|
||||
# entry_service, exit_service, scheduler에서 동일 인스턴스 참조
|
||||
day_trading_state = DayTradingState()
|
||||
Reference in New Issue
Block a user