Files
auto-stock/backend/app/services/daytrading/position_manager.py
shuim d140055e64 fix: 하드닝 루프 9회차 — 매수 원복·잔고 연속조회·결과 불명 매도·브레이커·경보 보강
- 8회차 HIGH 수정 16건 레드팀 재검증: 12 확인·3 부분·1 미수정 → 전부 보강
- trade_executor/fill_sweep: 늦게 확정된 0체결 BUY → CANCELLED + ActiveTrade close, 부분체결 → 수량 축소(멱등, 재기동 후 TH 재구성)
- korea_investment_api: get_balance_all_v2(tr_cont 연속조회, _partial), get_todays_orders_v2(실패 None), 브레이커 청산용 조회 면제(OPEN 중 미집계)
- swing/exit_service·mdd_manager: 매도 직전 상한·MDD 축소가 전체 잔고 페이지 사용(§8.5-37), 불명 대기 중 부분익절 skip·쿨다운·pre_hldg 차감, 대기 TTL 15:30+재매수 폐기(대조 1회 선행)(§8.5-38 해소)
- swing/scheduler: 청산 TIMED_OUT/SKIPPED/in-flight 시 MDD 축소·진입 보류(예외는 진입만), 3사이클 연속 시 error+텔레그램
- daytrading: 결과 불명 매도 재판정 v3(주문조회 v2 None→보류, 등록 이후 일치 행→accepted, 원장≤0→보류), 재기동 복원 min(주문가능,보유), 잔고 조회 후 일일손실한도 재확인, 단독 실행 Redis 접속 제거
- main: 스트림 무수신 경보 v4(희망 구독 기준), 첫 하트비트 없음 stale(HB_FIRST_BEAT_GRACE_SEC)
- 운영: Jenkinsfile health_watch.py 디렉터리 잔존 복구, prod_smoke 사이드카 확인, config 키 12개 선언(동작 무변경)
- 진입가 v3(max(AT,PA))는 KIS 이동평균 평단 전제 오류로 레드팀 반증 → 폐기
- CLAUDE.md §3.2 ③-d, §8.5-37/38 갱신, §8.5-39~48 신규(Crisis 손절 소급 §8.5-39 는 사용자 결정 대기)
- 유닛 스위트 2781 passed / 0 failed (소켓 차단), 네트워크 시도 0건

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-12 15:37:16 +09:00

1197 lines
60 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""데이트레이딩 인메모리 포지션 상태 관리
기존 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 획득
- [영속성] 앱 재시작 시 restore_from_db()/load_orb_cache() 로 복원한다.
복원 직전에 반드시 begin_restore() 를 호출할 것 — 호출하지 않으면
첫 reset_if_new_day() 가 복원분을 전부 지운다(2026-09-28 수정).
- [단일 프로세스] 다중 워커 환경에서는 상태 불일치 발생 가능
"""
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")
# [R2 2026-10-09] 일일 안전장치 원장 재구성 (Redis 복원 실패 시) — 운영 상수(전략 파라미터 아님)
_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC = 15.0
_DAILY_STATE_REBUILD_RETRY_SEC = 120.0
_DAILY_STATE_NOTIFY_TIMEOUT_SEC = 10.0
# 전일·고아 청산의 exit_reason 접두사(daytrading/exit_service.close_stale_positions).
# 이 청산 손익은 당일 실현손익·연패에 넣지 않는다(§7, count_daily_pnl=False) — 재구성도 동일.
# 문자열이 바뀌면 test_dt_daily_state_rebuild_r2_20261009 의 동기화 테스트가 깨진다.
_STALE_EXIT_REASON_PREFIXES = ("전일 미청산 포지션 강제 청산", "DT 고아 잔고 청산")
_DT_STRATEGY_TYPE = "daytrading"
_VOID_STATUSES = frozenset({"CANCELLED", "FAILED", "REJECTED"})
_daily_state_retry_task: "asyncio.Task | None" = None
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
# 당일 청산 종목 (재진입 방지)
# ⚠️ 스윙 손절(swing/exit_service)·MDD 등 **타 전략 청산도** 전략 간 당일 쿨다운으로
# 여기에 들어온다 → "DT 청산 수"로 세면 안 된다(아래 dt_exited_today 사용).
self.closed_today: set[str] = set()
# [2026-10-08] 당일 **DT 포지션이 전량 청산된** 종목 (표시·집계 전용, 재진입 판정에 쓰지 않음)
# DT 엔진 카드 '청산 N' 에 스윙 손절 종목이 섞이던 문제(10/08: DT 6 + 스윙 2 = 8).
self.dt_exited_today: set[str] = set()
# [R2 하드닝 2026-10-09] 당일 DT 전량 청산(dt_exited_today) 뒤 **보유가 재개된** 종목.
# 전량 매도가 0체결·부분체결로 종결돼 exit_service._on_unfilled 가 포지션을 되살린 경우
# (add_position 이 dt_exited_today 종목을 받는 유일한 경로 — DT 는 당일 재진입 금지)에
# 표시한다. 기동 복원 검증·장중 복원이 이 종목을 '청산 완료'로 보지 않게 한다.
# remove_position(다시 전량 청산)에서 해제된다. 재진입 차단(closed_today)과 무관.
self.dt_reheld_today: set[str] = set()
# [R2 하드닝] 구버전 Redis 페이로드(dt_exited_today 키 없음)로 복원된 closed_today —
# DT 청산과 재진입 차단을 구분할 수 없으므로 복원 판정에서만 '청산 완료'로 간주(보수적).
self.restore_block_legacy: 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()
# [H-B 2026-10-02] 청산 주문 진행 중 종목. exit_service._execute_exit_impl 이
# trade_lock 안에서 등록하고 shield 된 주문 작업의 finally 에서 해제한다.
# 청산 판정 경로(손절 선판정·분봉 판정·폴백 손절·force_close_all)는 이 종목을 건너뛴다
# — 주문은 진행 중인데 인메모리 수량이 아직 그대로라 같은 물량을 다시 파는 것을 막는다.
# 날짜 초기화(reset_if_new_day)로 지우지 않는다(진행 중 주문의 보호가 풀린다).
self.exits_in_progress: set[str] = set()
# [R2 2026-10-09] 일일 안전장치 **미검증** 상태 — Redis 복원 실패 + TradeHistory 재구성
# 실패(또는 손실 상태인데 총자산 분모 미확보)면 True. is_cooldown_active() 가 True 를
# 반환해 **DT 신규 진입만** 막는다(청산 경로는 이 값을 보지 않는다 — §8.1-11).
# 재구성 재시도가 성공하거나 날짜가 바뀌면(reset_if_new_day) 해제된다.
self.daily_state_unverified: bool = False
self.daily_state_unverified_reason: str = ""
# 일일 안전장치의 출처: "" | "redis" | "trade_history" (표시·진단용)
self.daily_state_source: str = ""
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.dt_exited_today = set()
self.dt_reheld_today = set()
self.restore_block_legacy = set()
self.loss_streak_today = 0
self.last_reset_date = today
# [R2] 미검증 차단은 **그날의** 상태에 대한 것 — 새 날은 0 에서 시작하는 것이 정상
self.daily_state_unverified = False
self.daily_state_unverified_reason = ""
self.daily_state_source = ""
self.portfolio_summary_cache = {}
self.portfolio_summary_ts = None
self.portfolio_cache_dirty = True
logger.info(f"[DayTrading] 일일 상태 초기화: {today}")
return True
return False
def begin_restore(self) -> bool:
"""기동 시 복원(load_orb_cache/restore_from_db) **직전에 1회** 호출.
[2026-09-28 재기동 내구성 수정]
새 프로세스는 ``last_reset_date is None`` 이고 복원 경로 3개
(load_orb_cache / restore_from_db / close_stale_positions) 중 어느 것도
이 필드를 설정하지 않았다. 그래서 스케줄러 첫 루프의
``reset_if_new_day()`` 가 무조건 참이 되어 **방금 복원한
positions/orb_cache 를 전부 지웠다**(복원 성공 로그는 그대로 출력되므로
조용히 실패). 손절·트레일링·시간손절·15:20 EOD 청산이 인메모리
positions 만 순회하므로 재기동한 당일의 포지션이 고아가 됐다.
복원 **전에** 날짜 경계를 확정하면 두 불변식을 동시에 만족한다:
1) 같은 날 첫 루프의 reset 은 no-op → 복원분 생존
2) 자정을 넘기면 ``last_reset_date != today`` → 정상 초기화되어
전일 closed_today / daily_pnl / loss_streak_today 가 새지 않는다
(§7 일일 -3% 한도·3연패 쿨다운·재진입 차단 보호)
Returns:
bool: 날짜 경계 확정 과정에서 초기화가 수행되었으면 True
"""
return self.reset_if_new_day()
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
# [R2 하드닝] 당일 DT 전량 청산 기록이 있는 종목이 다시 들어오면 = 매도 미체결 종결 복원
# (closed_today 로 신규 진입은 막혀 있다). 재기동 복원 검증이 '청산 완료'로 오판하지 않게 표시.
if stock_code in self.dt_exited_today or stock_code in self.restore_block_legacy:
self.dt_reheld_today.add(stock_code)
self.restore_block_legacy.discard(stock_code)
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.dt_exited_today.add(stock_code)
self.dt_reheld_today.discard(stock_code)
self.portfolio_cache_dirty = True
return pos
def restore_block_reason_v2(self, stock_code: str) -> str | None:
"""[R2 하드닝 2026-10-09] 당일 DT active 행을 인메모리로 **되살리면 안 되는** 사유(없으면 None).
closed_today 는 **재진입 차단** 집합이다 — 불명 매수 등록(entry_service)·스윙 손절(전략 간
쿨다운)·매도 0체결 종결 뒤에도 남아 있어 '청산 완료'의 근거가 될 수 없다. 예전에는
closed_today 만으로 복원을 막아 실제 보유 중인 당일 DT 포지션이 손절·EOD 관리 밖으로
빠졌다(오버나이트). 이제 '청산 완료'는 DT 전량 청산(dt_exited_today) 기준이고, 그 뒤
보유가 재개된 종목(dt_reheld_today)은 제외한다. 나머지는 호출부가 KIS 실보유 − 타전략
수량(§3.5) 규칙으로 판정한다(이 메서드는 그 판정을 대신하지 않는다).
"""
if stock_code in self.exits_in_progress:
return "청산 주문 진행/결과 불명 보류 중(exits_in_progress)"
if stock_code in self.dt_reheld_today:
return None
if stock_code in self.dt_exited_today:
return "당일 DT 전량 청산 기록(dt_exited_today) 있음"
if stock_code in self.restore_block_legacy:
return "구버전 일일상태(dt_exited_today 없음) — closed_today 를 청산 근거로 간주"
return None
def update_pnl(self, realized: float, is_full_close: bool = True) -> None:
"""실현 손익 누적 및 연속 손실 카운트 업데이트.
손실 시 loss_streak_today 증가, 이익 시 리셋.
3연패 이상이면 is_cooldown_active()가 True 반환.
연속 손실 카운트는 전량 매도(is_full_close=True)일 때만 업데이트.
분할 매도(is_full_close=False)나 손익분기(realized==0)는 카운트에 영향 없음.
Args:
realized: 실현 손익 (원). 양수=이익, 음수=손실
is_full_close: 전량 매도 여부. False이면 streak 카운트 미변경
"""
self.daily_pnl += realized
if not is_full_close:
return
if realized < 0:
self.loss_streak_today += 1
elif realized > 0:
# 이익 발생 시 연속 손실 카운트 리셋 (손익분기 realized==0은 중립 — 리셋하지 않음)
self.loss_streak_today = 0
def adjust_realized_pnl(self, delta: float) -> None:
"""[2026-10-01] 실현손익 사후 보정 (체결가 확인으로 기록 손익이 바뀐 경우).
반드시 trade_lock 획득 후 호출해야 함.
daily_pnl 만 보정하고 loss_streak_today 는 건드리지 않는다 — 연패 판정은 청산
시점에 한 번만 한다(같은 청산을 두 번 세지 않기 위함).
"""
self.daily_pnl += float(delta)
def is_exit_in_progress(self, stock_code: str) -> bool:
"""[H-B] 해당 종목의 청산 주문이 진행 중(또는 결과 불명 보류 중)인지."""
return stock_code in self.exits_in_progress
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연패 시 신규 진입 차단.
[R2 2026-10-09] 일일 안전장치가 미검증(daily_state_unverified)이어도 True —
Redis·TradeHistory 어느 쪽으로도 당일 손익·연패·재진입 차단을 복원하지 못한 상태에서
0 부터 매매를 재개하면 -3% 한도·3연패 쿨다운이 풀린다(fail-open). 진입 게이트
(entry_service.scan_and_enter / scheduler._entry_blocked_by_exit_results)가 이
메서드를 쓰므로 시그니처를 바꾸지 않고 여기서 함께 막는다. 청산 경로는 무관.
Returns:
bool: 쿨다운 활성화 여부
"""
if self.daily_state_unverified:
return True
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
async def save_orb_cache(self) -> bool:
"""ORB 캐시를 Redis 에 저장 (당일 키, 자정까지 TTL).
컨테이너 재기동 시 인메모리 캐시가 사라져 당일 ORB 전략이 불가능해지는 것을 막는다.
"""
try:
from app.core.redis import setex_json
payload = {
code: {
"high": float(getattr(o, "high", 0) or 0),
"low": float(getattr(o, "low", 0) or 0),
"total_volume": int(getattr(o, "total_volume", 0) or 0),
"avg_vol_per_min": float(getattr(o, "avg_vol_per_min", 0) or 0),
"open_price": float(getattr(o, "open_price", 0) or 0),
}
for code, o in self.orb_cache.items()
}
if not payload:
return False
return await setex_json(f"daytrading:orb:{_today_kst()}", 86400, payload)
except Exception as e:
logger.warning(f"[DayTrading] ORB 캐시 저장 실패: {e}")
return False
async def load_orb_cache(self) -> int:
"""Redis 에서 당일 ORB 캐시 복원. 복원한 종목 수 반환."""
try:
from app.core.redis import get_json
from app.services.day_trading_strategy import OpeningRange
data = await get_json(f"daytrading:orb:{_today_kst()}")
if not data:
return 0
restored = 0
for code, o in data.items():
if code in self.orb_cache:
continue
self.orb_cache[code] = OpeningRange(
high=float(o.get("high", 0) or 0),
low=float(o.get("low", 0) or 0),
total_volume=int(o.get("total_volume", 0) or 0),
avg_vol_per_min=float(o.get("avg_vol_per_min", 0) or 0),
open_price=float(o.get("open_price", 0) or 0),
)
restored += 1
if restored:
logger.info(f"[DayTrading] ORB 캐시 복원: {restored}개 종목")
return restored
except Exception as e:
logger.warning(f"[DayTrading] ORB 캐시 복원 실패: {e}")
return 0
async def save_daily_state(self) -> bool:
"""[F9 2026-09-28] 일일 안전장치 3종을 Redis 에 저장 (당일 키, 자정까지 TTL).
``daily_pnl`` / ``loss_streak_today`` / ``closed_today`` 는 어디에도
영속화되지 않아 **장중 재기동 시 전부 0/빈 집합으로 돌아갔다.** 이것은 단순
상태 유실이 아니라 **안전장치가 풀리는 방향**이다:
- ``daily_pnl=0`` → §7 일일 -3% 손실한도(DAY_TRADE_DAILY_LOSS_LIMIT)가
리셋되어, 이미 한도를 맞고 중단됐어야 할 날에 매매가 재개된다
- ``loss_streak_today=0`` → 당일 3연패 쿨다운이 풀린다
- ``closed_today=∅`` → 손절로 나온 종목을 같은 날 다시 매수한다
저장 포맷에 ``date`` 를 실어 ``load_daily_state()`` 가 **다른 날짜면 버린다**
(전일 값이 다음 날로 새면 F1 불변식 ②를 정면으로 깨뜨린다).
ORB 캐시(save_orb_cache)와 동일한 Redis 경로를 그대로 확장한 것이다.
"""
try:
from app.core.redis import setex_json
today = _today_kst()
payload = {
"date": today.isoformat(),
"daily_pnl": float(self.daily_pnl),
"loss_streak_today": int(self.loss_streak_today),
"closed_today": sorted(self.closed_today),
# [2026-10-08] DT 전용 청산 집합(표시용) — 없으면 구버전 페이로드
"dt_exited_today": sorted(self.dt_exited_today),
# [R2 하드닝] 청산 뒤 보유 재개(매도 미체결 종결 복원) — 재기동 복원 검증용
"dt_reheld_today": sorted(self.dt_reheld_today),
"restore_block_legacy": sorted(self.restore_block_legacy),
# [C2 2026-09-28] 손실률 분모. 이 값이 0 이면
# is_daily_loss_exceeded() 가 total_assets<=0 에서 **무조건 False** 라
# 재기동 직후 첫 scan_and_enter() 가 일일 -3% 한도를 통과해버린다
# (scan_and_enter 는 잔고 조회 전에 이 필드를 읽는다).
"last_total_assets": float(self.last_total_assets),
}
ok = await setex_json(f"daytrading:daily:{today}", 86400, payload)
if not ok:
logger.warning(
"[DayTrading] ⚠️ 일일 안전장치 저장 실패(Redis) — 재기동 시 "
"일일 손실한도·연패 쿨다운·재진입 차단이 초기화된다"
)
return ok
except Exception as e:
logger.warning(f"[DayTrading] ⚠️ 일일 안전장치 저장 실패: {e}")
return False
async def load_daily_state(self) -> bool:
"""[F9] Redis 에서 당일 안전장치 3종을 복원. 복원했으면 True.
**날짜 게이트**: 저장된 ``date`` 가 오늘이 아니면 복원하지 않고 버린다.
키 자체에도 날짜가 들어 있지만(자정 TTL), 페이로드 날짜를 한 번 더 확인해
키 충돌·시계 오차·수동 주입으로 전일 값이 새는 경로를 이중으로 막는다.
복원값은 현재 인메모리 값과 **병합하지 않고 더 보수적인 쪽을 취한다**
(손익은 합산이 아니라 저장값 채택, closed_today 는 합집합, 연패는 최대값)
— 재기동 직후 인메모리는 비어 있는 것이 정상이고, 만약 비어 있지 않다면
안전장치를 약화시키지 않는 방향을 골라야 한다.
"""
try:
from app.core.redis import get_json
today = _today_kst()
data = await get_json(f"daytrading:daily:{today}")
if not data:
return False
saved_date = str(data.get("date") or "")
if saved_date != today.isoformat():
logger.warning(
f"[DayTrading] 일일 안전장치 복원 건너뜀: 저장 날짜 {saved_date!r} "
f"≠ 오늘 {today.isoformat()} — 전일 값 누수 방지(폐기)"
)
return False
_pnl = float(data.get("daily_pnl", 0) or 0)
_streak = int(data.get("loss_streak_today", 0) or 0)
_closed = {str(c) for c in (data.get("closed_today") or []) if c}
_assets = float(data.get("last_total_assets", 0) or 0)
self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl)
self.loss_streak_today = max(self.loss_streak_today, _streak)
self.closed_today |= _closed
# [2026-10-08] 구버전 페이로드(키 없음)면 표시용 집합은 비워 둔다 — 스윙 쿨다운
# 종목이 섞인 closed_today 로 대체하면 같은 오집계가 되살아난다.
self.dt_exited_today |= {
str(c) for c in (data.get("dt_exited_today") or []) if c
}
self.dt_reheld_today |= {
str(c) for c in (data.get("dt_reheld_today") or []) if c
}
self.restore_block_legacy |= {
str(c) for c in (data.get("restore_block_legacy") or []) if c
}
if "dt_exited_today" not in data:
# [R2 하드닝] 구버전 페이로드 — DT 청산과 재진입 차단을 구분할 수 없다. 복원 판정에서만
# closed_today 를 청산 근거로 쓴다(재매도 방지 쪽, 구 동작과 동일).
self.restore_block_legacy |= _closed
# [C2] 분모는 **작은 쪽이 보수적**이다: pnl<0 일 때 (pnl/assets) 가 더 음수가
# 되어 -3% 한도가 더 빨리 걸린다. 단 0/음수는 게이트를 통째로 무력화하므로
# 후보에서 제외하고, 양수가 하나도 없으면 현재값(0.0)을 그대로 둔다.
_cands = [v for v in (self.last_total_assets, _assets) if v > 0]
if _cands:
self.last_total_assets = min(_cands)
logger.info(
f"[DayTrading] 일일 안전장치 복원: 실현손익={self.daily_pnl:+,.0f}원, "
f"연패={self.loss_streak_today}, 당일청산={len(self.closed_today)}종목, "
f"총자산기준={self.last_total_assets:,.0f}원"
)
return True
except Exception as e:
logger.warning(
f"[DayTrading] ⚠️ 일일 안전장치 복원 실패: {e} — 일일 손실한도·연패 "
f"쿨다운·재진입 차단이 초기화된 상태로 시작한다"
)
return False
async def rebuild_daily_state_after_redis_miss_v2(self, session_factory=None) -> bool:
"""[R2 2026-10-09] Redis 복원 실패 시 당일 DT 원장(TradeHistory)으로 일일 안전장치 재구성.
``load_daily_state()`` 는 Redis 단일 소스라, 장중에 Redis 재기동(RDB 간격 내 유실)·
``volatile-lru`` 축출(이 키는 setex TTL 을 가진다)·저장 실패 뒤 백엔드를 재기동하면
daily_pnl=0 / 연패 0 / closed_today=∅ 로 시작해 **이미 -3% 한도로 중단된 날에 매매가
재개**됐다(fail-open, warning 만). 당일 DT SELL 원장(net_profit_loss)은 DB 에 있으므로
그것으로 재구성한다.
- 성공(행 0건 포함 — 당일 매매가 없었다는 검증된 사실): 보수적 병합 후 Redis 재저장, True.
- 실패: ``daily_state_unverified=True`` → is_cooldown_active() 가 DT **신규 진입만**
차단, error 로그 + 텔레그램, 백그라운드 재시도 예약. False.
- 손실 상태인데 총자산 분모(last_total_assets)를 확보하지 못해도 미검증으로 둔다 —
분모 0 이면 is_daily_loss_exceeded() 가 무조건 False 라 -3% 한도가 무력화된다.
[§3.1 / §3.2 ③ — 기동 시 1회(+실패 시 재시도), 읽기 전용] SELECT 만 수행하고
commit()/add() 가 없다. 이 메서드는 쓰기를 하지 않는다 — 추가하지 말 것.
이 메서드는 예외를 던지지 않는다(차단 판정은 try 밖에서 한다, §4).
Args:
session_factory: 테스트 주입용 세션 팩토리(기본 AsyncSessionLocal).
"""
ok, err = await self._rebuild_daily_state_attempt_impl(session_factory)
if ok:
return True
await self._mark_daily_state_unverified_impl(err)
self._schedule_daily_state_rebuild_retry_impl(session_factory)
return False
async def _rebuild_daily_state_attempt_impl(self, session_factory=None) -> tuple[bool, str]:
"""재구성 1회 시도. (검증완료 여부, 실패 사유) — 예외를 던지지 않는다."""
today = _today_kst()
rows = None
err = ""
try:
rows = await asyncio.wait_for(
_fetch_today_trade_rows_impl(today, session_factory),
timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
err = f"TradeHistory 조회 {_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC:.0f}초 초과"
except Exception as e:
err = f"TradeHistory 조회 실패: {type(e).__name__}: {e}"
if rows is None:
logger.warning(f"[DayTrading] ⚠️ 일일 안전장치 원장 재구성 실패 — {err}")
return False, err or "TradeHistory 조회 결과 없음"
rebuilt = _rebuild_daily_state_from_rows(rows)
async with self.trade_lock:
# 같은 날 재시도(진입 차단 중에도 청산은 돌아 인메모리 손익이 쌓인다) 시 이중 집계를
# 피하려고 합산하지 않는다 — 원장 합계가 이미 그 청산을 포함한다. 둘 중 **더 보수적인**
# 쪽(손익 min, 연패 max, 집합 합집합)을 취한다(load_daily_state 와 같은 규칙).
_pnl = float(rebuilt["daily_pnl"])
self.daily_pnl = _pnl if not self.daily_pnl else min(self.daily_pnl, _pnl)
self.loss_streak_today = max(self.loss_streak_today, int(rebuilt["loss_streak_today"]))
self.closed_today |= set(rebuilt["closed_today"])
self.dt_exited_today |= set(rebuilt["dt_exited_today"])
self.dt_reheld_today |= set(rebuilt.get("dt_reheld_today") or ())
_need_assets = self.daily_pnl < 0 and float(self.last_total_assets or 0) <= 0
if _need_assets:
# 분모 확보(청산 경로와 같은 함수 — KIS 잔고 → 마지막 값). 실패해도 예외를 던지지 않는다.
try:
from app.services.daytrading.exit_service import _ensure_total_assets_for_exit_impl
await asyncio.wait_for(
_ensure_total_assets_for_exit_impl(), timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 일일 안전장치 재구성 후 총자산 조회 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 일일 안전장치 재구성 후 총자산 조회 실패: {e}")
# 판정은 try 밖 (§4)
if self.daily_pnl < 0 and float(self.last_total_assets or 0) <= 0:
err = (
f"당일 실현손익 {self.daily_pnl:+,.0f}원(손실)인데 총자산 분모 미확보 — "
f"일일 손실한도 판정 불가"
)
logger.warning(f"[DayTrading] ⚠️ {err}")
return False, err
self.daily_state_source = "trade_history"
_was_blocked = self.daily_state_unverified
self.daily_state_unverified = False
self.daily_state_unverified_reason = ""
logger.warning(
f"[DayTrading] 일일 안전장치 원장(TradeHistory) 재구성: 실현손익={self.daily_pnl:+,.0f}원 "
f"(전일·고아 청산 {rebuilt['stale_excluded']}건 제외), 연패={self.loss_streak_today}, "
f"당일청산={len(self.closed_today)}종목, 원장 {rebuilt['dt_sell_rows']}건"
+ (" — 신규 진입 차단 해제" if _was_blocked else "")
)
try:
await self.save_daily_state()
except Exception as e:
logger.warning(f"[DayTrading] 재구성 상태 Redis 재저장 실패: {e}")
if _was_blocked:
await _notify_daily_state_impl(
"DT 일일 안전장치 원장 재구성 성공 — 신규 진입 차단 해제 "
f"(실현손익 {self.daily_pnl:+,.0f}원, 연패 {self.loss_streak_today})"
)
return True, ""
async def _mark_daily_state_unverified_impl(self, reason: str) -> None:
"""미검증 상태 표시 + error 로그 + 텔레그램 (예외를 던지지 않는다)."""
_first = not self.daily_state_unverified
self.daily_state_unverified = True
self.daily_state_unverified_reason = reason
logger.error(
f"[DayTrading] 🚨 일일 안전장치 복원 불가(Redis·TradeHistory) — DT 신규 진입 차단 "
f"(청산은 계속). 사유: {reason}"
)
if _first:
await _notify_daily_state_impl(
"DT 일일 안전장치(일일 손실한도·연패·재진입 차단) 복원 불가 — 당일 DT 신규 진입 차단. "
f"사유: {reason}. 원장 재구성을 {_DAILY_STATE_REBUILD_RETRY_SEC:.0f}초 간격으로 재시도한다."
)
def _schedule_daily_state_rebuild_retry_impl(self, session_factory=None) -> None:
"""미검증 상태에서 재구성을 주기적으로 재시도(같은 날 한정). 중복 예약하지 않는다."""
global _daily_state_retry_task
if _daily_state_retry_task is not None and not _daily_state_retry_task.done():
return
try:
_daily_state_retry_task = asyncio.get_running_loop().create_task(
self._daily_state_rebuild_retry_loop_impl(_today_kst(), session_factory)
)
except RuntimeError as e:
logger.warning(f"[DayTrading] 일일 안전장치 재구성 재시도 예약 실패: {e}")
async def _daily_state_rebuild_retry_loop_impl(self, day: date, session_factory=None) -> None:
attempt = 0
while self.daily_state_unverified:
await asyncio.sleep(_DAILY_STATE_REBUILD_RETRY_SEC)
if _today_kst() != day or self.last_reset_date not in (None, day):
# 날짜가 바뀌면 reset_if_new_day 가 플래그를 내린다 — 전일 재구성은 의미 없음
return
if not self.daily_state_unverified:
return
attempt += 1
ok, err = await self._rebuild_daily_state_attempt_impl(session_factory)
if ok:
return
self.daily_state_unverified_reason = err
logger.warning(
f"[DayTrading] 일일 안전장치 재구성 재시도 {attempt}회 실패 — 신규 진입 차단 유지: {err}"
)
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
from app.core.config import settings
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
_restored = _active_trade_row_to_position(trade)
self.positions[trade.stock_code] = _restored
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
async def verify_restored_today_positions_v2(
self, session_factory=None, holdings_fetcher=None,
) -> dict:
"""[R3 2026-10-09] 기동 시 restore_from_db 가 되살린 **당일** DT 포지션 검증.
restore_from_db 는 closed_today·KIS 실보유를 보지 않고 active 행 수량으로 포지션을
되살린다. 전량 청산 직후 close_active_trade 큐 작업이 반영되지 못한 채(DLQ·drain 초과)
재기동하거나 분할 매도 뒤 잔량 upsert 가 유실되면, 이미 판 물량을 일반 청산 경로가
다시 시장가로 판다(같은 종목 스윙 보유분 매도 또는 반복 거부 — §3.5).
장중 복원 경로(exit_service._restore_lost_today_positions_impl)와 같은 규칙을 적용한다:
- 당일 DT 전량 청산 종목(restore_block_reason_v2: dt_exited_today − dt_reheld_today) → 복원 보류
[R2 하드닝] closed_today(재진입 차단)만으로는 보류하지 않는다 — 불명 매수 접수 확정·
매도 0체결 종결 복원·스윙 손절 쿨다운 종목도 들어 있어 실제 보유 포지션이 관리에서 빠졌다.
- KIS 보유 − 비DT active 수량 < 행 수량(또는 수량 ≤ 0) → 복원 보류
- 판정 불가(잔고·DB 조회 실패) → DAY_TRADE_ORPHAN_DETECT_ENABLED 면 보류
(close_stale_positions 가 매 사이클 같은 규칙으로 재검증·복원한다), 꺼져 있으면
복원 유지(아무도 되살리지 않아 손절 공백이 되므로) + error.
보류는 인메모리에서만 뺀다(closed_today 에 넣지 않음). 수량·판정불가 보류는 잔고가 맞으면 장중
복원이 다시 잡는다. 청산 기록 보류는 장중 복원도 같은 사유로 막으므로 수동 확인 대상이다.
전일 진입분은 대상이 아니다(close_stale_positions 가 실보유·타전략 차감으로 처리).
Returns: {"checked": n, "held": [codes], "unverified": bool}
"""
from app.core.config import settings
today = _today_kst()
held: list[str] = []
reasons: list[str] = []
mismatch = False # 실제 불일치(청산 기록·수량 부족) — 판정 불가와 구분해 경보 수준을 정한다
exit_held = False # 청산 기록으로 보류 — 장중 재복원 경로도 같은 사유로 막는다
async with self.trade_lock:
cands = {
c: p for c, p in self.positions.items()
if p.get("restored_from_db") and _entry_date(p.get("entry_time")) == today
}
for c in list(cands):
_why = self.restore_block_reason_v2(c)
if _why is not None:
if self.positions.get(c) is cands[c]:
self.positions.pop(c, None)
held.append(c)
reasons.append(f"{c}: {_why}")
mismatch = True
exit_held = True
cands.pop(c)
out = {"checked": len(cands) + len(held), "held": held, "unverified": False}
if cands:
other_qty = None
try:
other_qty = await asyncio.wait_for(
_fetch_non_dt_active_qty_by_code_impl(list(cands), session_factory),
timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 복원 검증: 비DT 보유 조회 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 복원 검증: 비DT 보유 조회 실패: {type(e).__name__}: {e}")
holdings = None
if other_qty is not None:
try:
if holdings_fetcher is None:
from app.services.daytrading.exit_service import (
_fetch_kis_holdings_v2_impl as holdings_fetcher,
)
holdings = await holdings_fetcher()
except Exception as e:
logger.warning(f"[DayTrading] 복원 검증: KIS 잔고 조회 실패: {type(e).__name__}: {e}")
holdings = None
# 판정·보류는 try 밖 (§4 — 가드를 try 안에 두지 않는다)
unverified = other_qty is None or holdings is None
out["unverified"] = unverified
if unverified and not settings.DAY_TRADE_ORPHAN_DETECT_ENABLED:
logger.error(
f"[DayTrading] 🚨 기동 복원 검증 불가(실보유·타전략 미확인) — 장중 재검증 경로"
f"(DAY_TRADE_ORPHAN_DETECT_ENABLED=False)가 없어 복원 유지: {sorted(cands)}. "
f"이미 매도된 물량이면 재매도 위험 — 수동 확인 필요"
)
else:
async with self.trade_lock:
for c, p in cands.items():
qty = int(p.get("quantity", 0) or 0)
if unverified:
why = "실보유·타전략 수량 판정 불가 → 장중 close_stale_positions 재검증 대기"
else:
hldg = int((holdings.get(c) or {}).get("hldg", 0) or 0)
oth = int(other_qty.get(c, 0) or 0)
if qty > 0 and hldg - oth >= qty:
continue
why = f"실보유 {hldg}주 − 타전략 {oth}주 = {hldg - oth}주 < 행 수량 {qty}주"
if self.positions.get(c) is p:
self.positions.pop(c, None)
held.append(c)
reasons.append(f"{c}: {why}")
mismatch = mismatch or not unverified
if held:
msg = (
f"[DayTrading] 🚨 기동 시 당일 DT 포지션 복원 보류 {len(held)}건 — "
+ "; ".join(reasons)
+ " (이미 매도된 물량 재매도 방지. 수량·판정불가 보류는 잔고가 맞으면 장중 "
"close_stale_positions 가 재복원"
+ ("; 청산 기록 보류는 장중에도 재복원하지 않음 — 실보유가 남아 있으면 수동 청산 필요)"
if exit_held else ")")
)
if mismatch:
logger.error(msg)
await _notify_daily_state_impl(msg)
else:
logger.warning(msg)
return out
async def verify_restored_today_positions_v3(
self, session_factory=None, holdings_fetcher=None, today_rows_fetcher=None,
) -> dict:
"""[C-2 r9] v2 + 주문가능수량·당일 PENDING DT 매도 반영.
v2 는 KIS **보유수량(hldg)** 만 봤다. 재기동 직전 접수된 미체결 DT 매도(VI 단일가 등)가
있으면 hldg 는 그대로라 포지션이 복원되고, 일반 청산이 다시 팔아 같은 종목 스윙 보유분이
팔릴 수 있다(§3.5). 이제:
- 판정 수량 = min(주문가능, 보유) − 비DT active (주문가능에서는 접수된 매도가 이미 빠진다)
- 당일 TradeHistory 에 DT SELL PENDING 이 있는 종목은 복원 보류(warning).
⚠️ **현재 DT 경로는 PENDING SELL 행을 쓰지 않는다** — 매도 TradeHistory 는 접수 확정 뒤
COMPLETED 로만 들어가고, 결과 불명 매도는 행을 남기지 않는다. 따라서 이 분기는 현재 발동하지
않는 방어 코드다(PENDING 을 쓰는 경로가 생길 때 대비). 재기동 직전 미체결 매도의 **실효 방어는
위의 min(주문가능, 보유)** 이다. 장중 close_stale_positions 도 같은 규칙
(_restore_lost_today_positions_v2_impl)으로 재검증한다.
- 원장 조회 실패: DAY_TRADE_ORPHAN_DETECT_ENABLED 면 전부 보류(v2 의 판정 불가 규칙과 동일).
이 보류는 실효 동작이다(조회 실패 시 기동 복원을 장중 재검증에 맡김).
DB 는 SELECT 전용(§3.2 ③ restore 경로, commit 없음).
"""
from app.core.config import settings
today = _today_kst()
cand_codes = [
c for c, p in self.positions.items()
if p.get("restored_from_db") and _entry_date(p.get("entry_time")) == today
]
pre_held: list[str] = []
if cand_codes:
rows = None
try:
_f = today_rows_fetcher or (lambda: _fetch_today_trade_rows_impl(today, session_factory))
rows = await asyncio.wait_for(_f(), timeout=_DAILY_STATE_REBUILD_DB_TIMEOUT_SEC)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 복원 검증: 당일 원장(PENDING 매도) 조회 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 복원 검증: 당일 원장(PENDING 매도) 조회 실패: {type(e).__name__}: {e}")
# 보류 판정은 try 밖 (§4)
if rows is None:
hold_codes = set(cand_codes) if settings.DAY_TRADE_ORPHAN_DETECT_ENABLED else set()
_why = "당일 PENDING 매도 확인 불가"
else:
hold_codes = {
getattr(r, "stock_code", None) for r in rows
if str(getattr(r, "strategy_type", "") or "") == "daytrading"
and str(getattr(r, "order_type", "") or "").upper() == "SELL"
and str(getattr(r, "status", "") or "").upper() == "PENDING"
} & set(cand_codes)
_why = "당일 DT 매도 PENDING(결과 미확정)"
if hold_codes:
async with self.trade_lock:
for c in sorted(hold_codes):
if self.positions.pop(c, None) is not None:
pre_held.append(c)
logger.warning(
f"[DayTrading] ⚠️ 기동 복원 보류 {pre_held} — {_why}. 미체결 매도 물량 재매도 방지, "
f"장중 close_stale_positions 가 다음 사이클에 재검증"
)
async def _sellable_capped_holdings():
if holdings_fetcher is None:
from app.services.daytrading.exit_service import _fetch_kis_holdings_v2_impl as _hf
else:
_hf = holdings_fetcher
h = await _hf()
if h is None:
return None
return {c: _cap_hldg_by_sellable_v2(v) for c, v in h.items()}
out = await self.verify_restored_today_positions_v2(
session_factory=session_factory, holdings_fetcher=_sellable_capped_holdings,
)
out["held"] = pre_held + list(out.get("held") or [])
out["checked"] = int(out.get("checked", 0)) + len(pre_held)
return out
def _active_trade_row_to_position(trade) -> dict:
"""ActiveTrade(day_trade) 행 → 인메모리 포지션 dict (restore_from_db 와 고아 복원 공용).
[2026-10-08] restore_from_db 의 행 변환을 그대로 옮겼다(동작 무변경).
"""
from app.core.config import settings
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
# [FIX] stop_loss/target_price가 0/None으로 복원되면 entry_price 기반
# 기본값으로 대체 — day_trading_strategy.py의 target_price<=0 즉시매도 방지
if entry_price > 0 and stop_loss <= 0:
stop_loss = entry_price * (1 - settings.DAY_TRADE_STOP_LOSS_PCT)
logger.warning(
f"[DayTrading] {trade.stock_code} stop_loss 복원값 없음 → "
f"기본값 적용: {stop_loss:.0f}"
)
if entry_price > 0 and target_price <= 0:
risk = entry_price - stop_loss
target_price = entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER
logger.warning(
f"[DayTrading] {trade.stock_code} target_price 복원값 없음 → "
f"기본값 적용: {target_price:.0f}"
)
_restored = {
"stock_name": trade.stock_name or trade.stock_code,
"strategy": trade.strategy_name or "day_trade",
"entry_price": entry_price,
"stop_loss": stop_loss,
"target_price": target_price,
"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,
}
# [M1 2026-10-02] 분할매도 이력 복원. exit_service 의 분할매도가
# remaining_pct(<1.0) 를 ActiveTrade 에 남긴다. 복원 시 이를 보지 않으면
# target_price 가 원래 목표가로 되살아나 재기동 직후 같은 50% 분할이
# 재발동한다(잔량의 절반을 또 판다). 인메모리 분할 후 상태와 동일하게
# 목표가를 제거(inf)하고 partial_sold 를 세운다 → 트레일링만 적용.
if _is_partial_sold_row(trade):
_restored["target_price"] = float("inf")
_restored["partial_sold"] = True
logger.warning(
f"[DayTrading] {trade.stock_code} 분할매도 이력 복원 "
f"(remaining_pct={trade.remaining_pct}) — 목표가 분할 재발동 차단, "
f"트레일링 고점={_restored['highest_price']:.0f}"
)
return _restored
def _entry_date(entry_time) -> date | None:
"""entry_time(datetime|None) → date. restore_from_db 의 created_at.date() 비교와 같은 기준."""
if isinstance(entry_time, datetime):
return entry_time.date()
return None
def _cap_hldg_by_sellable_v2(v) -> dict:
"""[C-2 r9] {"hldg","sellable"} → hldg 를 min(주문가능, 보유) 로 낮춘 사본. sellable 없으면 그대로."""
d = dict(v or {})
if d.get("sellable") is not None:
try:
d["hldg"] = min(int(d.get("hldg", 0) or 0), int(d.get("sellable", 0) or 0))
except (TypeError, ValueError):
d["hldg"] = 0 # 파싱 불가 → 복원 근거 없음(보류 쪽)
return d
async def _fetch_non_dt_active_qty_by_code_impl(codes: list, session_factory=None) -> dict[str, int]:
"""비-DT(스윙·유입·legacy NULL) active ActiveTrade 종목별 수량 합 — SELECT 전용(§3.2 ③, commit 없음).
close_stale_positions 와 같은 필터(db_worker.not_dt_strategy_clause). 실패는 예외로 전파한다.
"""
from app.models.models import ActiveTrade
from app.services.db_worker import not_dt_strategy_clause
if session_factory is None:
from app.core.database import AsyncSessionLocal as session_factory # noqa: N813
out: dict[str, int] = {}
if not codes:
return out
# DB-READ-EXCEPTION: 기동 시 DT 복원 검증(§3.5 타전략 차감), SELECT 전용·commit 없음
async with session_factory() as db:
rows = (await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code.in_(list(codes)),
ActiveTrade.status == "active",
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
)).scalars().all()
for r in rows or []:
out[r.stock_code] = out.get(r.stock_code, 0) + int(r.quantity or 0)
return out
def _is_partial_sold_row(trade) -> bool:
"""[M1] ActiveTrade 행이 분할매도 이후 상태인지 (remaining_pct < 1.0)."""
try:
rp = getattr(trade, "remaining_pct", None)
return rp is not None and 0.0 < float(rp) < 1.0
except (TypeError, ValueError):
return False
async def _fetch_today_trade_rows_impl(today: date, session_factory=None) -> list:
"""당일(KST) TradeHistory 행 조회 — SELECT 전용(§3.2 ③, commit 없음)."""
from datetime import timedelta
from app.models.models import TradeHistory
if session_factory is None:
from app.core.database import AsyncSessionLocal as session_factory # noqa: N813
start = datetime.combine(today, datetime.min.time())
end = start + timedelta(days=1)
# DB-READ-EXCEPTION: DT 일일 안전장치 원장 재구성(기동 시/미검증 재시도), SELECT 전용·commit 없음
async with session_factory() as db:
result = await db.execute(
select(TradeHistory).where(
TradeHistory.created_at >= start,
TradeHistory.created_at < end,
TradeHistory.order_type.in_(["BUY", "SELL"]),
).order_by(TradeHistory.created_at, TradeHistory.id)
)
return list(result.scalars().all())
def _rebuild_daily_state_from_rows(rows) -> dict:
"""당일 TradeHistory 행 → 일일 안전장치 값 (순수 함수, 입력은 created_at·id 순 정렬 가정).
규칙 (exit_service._finalize_exit_impl 과 동일 기준):
- daily_pnl: DT SELL 의 net_profit_loss(비용 차감 순손익) 합. 취소·실패 행 제외.
**전일·고아 청산은 제외** — exit_reason 접두사, 또는 당일 DT BUY 가 없는 종목의 SELL.
- loss_streak_today: 종목별 **마지막** 당일 SELL 시점을 전량 청산으로 보고, 그 종목의
당일 SELL net 합(분할 레그 포함 = 왕복 순손익, exit_service._apply_round_trip_streak_v2)으로
시간순 판정한다. 손실 +1, 이익 0 리셋, 0 중립. 시간손절도 센다(§5.1 — loss_tracker 와 달리 당일 연패는 시간손절 포함).
- closed_today: DT SELL 종목 전부(취소 포함 — 전일 청산은 주문 실패해도 재진입 금지) +
타 전략 체결 SELL 종목(전략 간 당일 쿨다운, swing/exit_service). 보수적 상위집합.
- dt_exited_today: 취소·실패가 아닌 DT SELL 종목(표시용).
- dt_reheld_today: [R2 하드닝] dt_exited_today 중 당일 DT 원장 순체결(BUY COMPLETED 체결 −
SELL 체결, exit_service._dt_net_qty_from_history 와 같은 규칙)이 양수인 종목 — 전량 매도가
부분체결로 종결돼 보유가 남은 경우. 기동 복원 검증이 '청산 완료'로 오판하지 않게 한다.
"""
dt_rows = [r for r in rows if str(getattr(r, "strategy_type", "") or "") == _DT_STRATEGY_TYPE]
buy_codes = {
str(r.stock_code) for r in dt_rows
if str(getattr(r, "order_type", "")).upper() == "BUY"
and str(getattr(r, "status", "") or "").upper() not in _VOID_STATUSES
}
closed: set[str] = set()
dt_exited: set[str] = set()
counted: list = []
stale_excluded = 0
dt_sell_rows = 0
for r in rows:
if str(getattr(r, "order_type", "")).upper() != "SELL":
continue
code = str(r.stock_code)
status = str(getattr(r, "status", "") or "").upper()
is_dt = str(getattr(r, "strategy_type", "") or "") == _DT_STRATEGY_TYPE
if not is_dt:
if status not in _VOID_STATUSES:
closed.add(code)
continue
dt_sell_rows += 1
closed.add(code)
if status in _VOID_STATUSES:
continue
dt_exited.add(code)
reason = str(getattr(r, "exit_reason", "") or "")
if reason.startswith(_STALE_EXIT_REASON_PREFIXES) or code not in buy_codes:
stale_excluded += 1
continue
counted.append(r)
pnl = 0.0
last_idx: dict[str, int] = {}
for i, r in enumerate(counted):
net = getattr(r, "net_profit_loss", None)
if net is None:
logger.warning(
f"[DayTrading] 원장 재구성: {r.stock_code} SELL(id={getattr(r, 'id', '?')}) "
f"net_profit_loss 없음 — 0 으로 간주"
)
pnl += float(net or 0)
last_idx[str(r.stock_code)] = i
# [R3 2026-10-09] 연패는 **왕복 순손익**(분할 레그 포함 종목별 당일 SELL net 합)으로 판정한다.
# 실시간 경로(exit_service._apply_round_trip_streak_v2, R2-4)와 같은 기준. 예전에는 마지막
# 레그만 봐 +1.5R 분할 익절(+3,000) 뒤 잔량 -300 청산을 손실로 셌다 → Redis 미스 재기동만으로
# 3연패 쿨다운이 걸릴 수 있었다. DT 는 당일 재진입이 금지(closed_today)라 종목당 왕복은 1회다.
streak = 0
round_trip: dict[str, float] = {}
for i, r in enumerate(counted):
code = str(r.stock_code)
round_trip[code] = round_trip.get(code, 0.0) + float(getattr(r, "net_profit_loss", None) or 0)
if last_idx.get(code) != i:
continue # 분할 매도 — 왕복 합에만 누적
net = round_trip[code]
if net < 0:
streak += 1
elif net > 0:
streak = 0
# [R2 하드닝] 당일 DT 순체결 수량 — 양수면 전량 매도가 일부만 체결돼 보유가 남았다.
net_qty: dict[str, int] = {}
for r in dt_rows:
code = str(r.stock_code)
ot = str(getattr(r, "order_type", "") or "").upper()
st = str(getattr(r, "status", "") or "").upper()
eq = getattr(r, "executed_quantity", None)
try:
q = int(eq if eq is not None else (getattr(r, "order_quantity", 0) or 0))
except (TypeError, ValueError):
logger.warning(f"[DayTrading] 원장 재구성: {code} 수량 파싱 실패 — 순체결 판정 제외")
continue
if ot == "BUY" and st == "COMPLETED":
net_qty[code] = net_qty.get(code, 0) + q
elif ot == "SELL" and st not in ("FAILED", "REJECTED"):
net_qty[code] = net_qty.get(code, 0) - q
reheld = {c for c in dt_exited if net_qty.get(c, 0) > 0}
return {
"daily_pnl": pnl,
"loss_streak_today": streak,
"closed_today": closed,
"dt_exited_today": dt_exited,
"dt_reheld_today": reheld,
"stale_excluded": stale_excluded,
"dt_sell_rows": dt_sell_rows,
}
async def _notify_daily_state_impl(message: str) -> None:
"""텔레그램 시스템 경고 (실패해도 예외를 던지지 않는다)."""
try:
from app.services.telegram_notifier import telegram_notifier
await asyncio.wait_for(
telegram_notifier.notify_system_warning("DAYTRADING", message),
timeout=_DAILY_STATE_NOTIFY_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning("[DayTrading] 일일 안전장치 텔레그램 알림 시간 초과")
except Exception as e:
logger.warning(f"[DayTrading] 일일 안전장치 텔레그램 알림 실패: {e}")
# 싱글톤 인스턴스 — daytrading 패키지 전체에서 공유
# entry_service, exit_service, scheduler에서 동일 인스턴스 참조
day_trading_state = DayTradingState()
# [M5 2026-10-02] 데이트레이딩 사이클 전역 락 — 스케줄러와 수동 API(/api/daytrading/cycle)가
# 공유한다. 같은 프로세스에서 청산 체크·진입 스캔이 두 경로로 동시에 돌면 같은 포지션을
# 두 번 매도하거나 슬롯·예산 검사를 동시에 통과해 한도를 넘겨 매수한다.
# trade_lock(인메모리 dict 보호, 짧게 잡음)과 다르다 — 이 락은 '한 사이클 단계' 전체를 감싼다.
dt_cycle_lock = asyncio.Lock()