fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,33 @@ _watchlist: dict[str, str] = {}
|
||||
_watchlist_auto: bool = False
|
||||
|
||||
|
||||
def _clamp_day_trade_budget(
|
||||
budget: float,
|
||||
total_assets: float,
|
||||
available_cash: float,
|
||||
current_day_trade_amount: float,
|
||||
) -> float:
|
||||
"""[H3] 데이트레이딩 주문 예산을 세 가지 한도로 재클램프.
|
||||
|
||||
calculate_day_trade_position_size()와 동일한 한도를 적용한다
|
||||
(종목당 DAY_TRADE_MAX_CAPITAL_RATIO / DT 전체 DAY_TRADE_MAX_RATIO_DT 잔여 / 보유 현금).
|
||||
confidence 배율처럼 클램프 이후 예산을 변형하는 로직 뒤에 반드시 호출해야 한다.
|
||||
calculate_day_trade_position_size() 자체는 변경하지 않는다(시그니처 보존).
|
||||
|
||||
Args:
|
||||
budget: 클램프 대상 예산 (원)
|
||||
total_assets: 총자산
|
||||
available_cash: 주문 가능 현금
|
||||
current_day_trade_amount: 현재 데이트레이딩 투입 금액
|
||||
|
||||
Returns:
|
||||
float: 세 한도를 모두 만족하는 예산 (0 이상)
|
||||
"""
|
||||
per_position_cap = total_assets * settings.DAY_TRADE_MAX_CAPITAL_RATIO
|
||||
remaining_dt_cap = total_assets * settings.DAY_TRADE_MAX_RATIO_DT - current_day_trade_amount
|
||||
return max(0.0, min(budget, per_position_cap, remaining_dt_cap, available_cash))
|
||||
|
||||
|
||||
def set_watchlist(stocks: list[dict]) -> None:
|
||||
"""감시 종목 설정 (수동 — 이후 자동 갱신 대상에서 제외).
|
||||
|
||||
@@ -123,35 +150,45 @@ async def populate_daytrading_watchlist(min_score: int = 1, max_stocks: int = 20
|
||||
from sqlalchemy import select, func
|
||||
|
||||
today = datetime.now(_KST).date()
|
||||
async with AsyncSessionLocal() as session:
|
||||
# [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 →
|
||||
# ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근
|
||||
# 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨.
|
||||
latest_date = (
|
||||
await session.execute(
|
||||
select(func.max(StockAnalysis.analysis_date)).where(
|
||||
StockAnalysis.analysis_date <= today
|
||||
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
|
||||
# 이 세션은 SELECT 2건만 수행하며 commit()/flush()/add() 를 호출하지 않는다.
|
||||
# 세션 수명은 아래 블록으로 최소화하고, 실패 시 warning 으로 운영에 노출한다.
|
||||
latest_date = None
|
||||
rows: list = []
|
||||
try:
|
||||
async with AsyncSessionLocal() as session:
|
||||
# [ORB 복구] 09:00 시점엔 당일 배치 분석이 아직 없어 watchlist가 비고 → ORB 수집 0 →
|
||||
# ORB 전략이 하루 종일 불가했음(2026-09-22). 당일 데이터가 없으면 가장 최근
|
||||
# 분석일(전일 등)의 결과로 채운다. 이후 30분 주기 갱신에서 당일 데이터로 교체됨.
|
||||
latest_date = (
|
||||
await session.execute(
|
||||
select(func.max(StockAnalysis.analysis_date)).where(
|
||||
StockAnalysis.analysis_date <= today
|
||||
)
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if latest_date is None:
|
||||
logger.info("[DayEntry] stock_analysis 데이터 없음")
|
||||
return 0
|
||||
if latest_date != today:
|
||||
logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성")
|
||||
).scalar_one_or_none()
|
||||
if latest_date is None:
|
||||
logger.info("[DayEntry] stock_analysis 데이터 없음")
|
||||
return 0
|
||||
|
||||
result = await session.execute(
|
||||
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
|
||||
.where(
|
||||
StockAnalysis.analysis_date == latest_date,
|
||||
StockAnalysis.score >= min_score,
|
||||
StockAnalysis.rsi.isnot(None),
|
||||
StockAnalysis.current_price > 5000,
|
||||
result = await session.execute(
|
||||
select(StockAnalysis.stock_code, StockAnalysis.stock_name, StockAnalysis.score)
|
||||
.where(
|
||||
StockAnalysis.analysis_date == latest_date,
|
||||
StockAnalysis.score >= min_score,
|
||||
StockAnalysis.rsi.isnot(None),
|
||||
StockAnalysis.current_price > 5000,
|
||||
)
|
||||
.order_by(StockAnalysis.score.desc())
|
||||
.limit(max_stocks)
|
||||
)
|
||||
.order_by(StockAnalysis.score.desc())
|
||||
.limit(max_stocks)
|
||||
)
|
||||
rows = result.all()
|
||||
rows = result.all()
|
||||
except Exception as e:
|
||||
logger.warning(f"[DayEntry] watchlist 자동 구성용 분석 조회 실패(읽기 전용): {e}")
|
||||
return 0
|
||||
|
||||
if latest_date != today:
|
||||
logger.info(f"[DayEntry] 당일({today}) 분석 없음 → 최근 분석일 {latest_date} 결과로 watchlist 구성")
|
||||
|
||||
if not rows:
|
||||
logger.info(f"[DayEntry] stock_analysis({latest_date})에 조건 충족 종목 없음")
|
||||
@@ -209,6 +246,7 @@ async def scan_and_enter() -> list[dict]:
|
||||
|
||||
진입 전 체크 순서 (P0 = 최우선, P1 = 중요):
|
||||
[P0] MDD -10% 체크 → 단기 전략 비활성화
|
||||
[P0] MDD 공통 진입 게이트 can_open_new_position() (is_paused + 갱신 장애 fail-safe)
|
||||
[P0] 플레이북 체크 → FOMC/옵션만기 등 이벤트 당일 차단
|
||||
[P0] 일일 손실 한도 -3% 체크
|
||||
[P1] 3연패 쿨다운 체크
|
||||
@@ -222,12 +260,31 @@ async def scan_and_enter() -> list[dict]:
|
||||
day_trading_state.reset_if_new_day()
|
||||
entries = []
|
||||
|
||||
# MDD 체크
|
||||
# MDD 체크 ① — 단기/데이트레이딩 전략 비활성화 여부 (MDD -10% 시 pause)
|
||||
short_term_ok = await mdd_manager.is_short_term_strategy_enabled()
|
||||
if not short_term_ok:
|
||||
logger.info("[DayEntry] MDD 차단 — 단기 전략 비활성화")
|
||||
return entries
|
||||
|
||||
# MDD 체크 ② — 공통 신규 진입 게이트 (CLAUDE.md §5.3)
|
||||
# [항목7 2026-09-27] DT 진입 경로는 is_short_term_strategy_enabled() 만 봤기 때문에
|
||||
# can_open_new_position() 의 fail-safe(MDD 갱신 연속 실패 = peak/drawdown 정지로
|
||||
# -10%/-20% 게이트 무력화)와 is_paused 가 적용되지 않았다. 두 게이트를 모두
|
||||
# 통과해야 진입한다. 이 블록은 try 밖에 두어 예외로 우회되지 않게 하고,
|
||||
# 종목 루프/분봉 API 호출 전에 배치해 불필요한 KIS 호출을 없앤다.
|
||||
# 청산 경로(exit_service)에는 영향 없음.
|
||||
if not await mdd_manager.can_open_new_position():
|
||||
_health = mdd_manager.get_update_health()
|
||||
if _health.get("blind_block_active"):
|
||||
logger.error(
|
||||
"[DayEntry] ⛔ MDD 갱신 장애(fail-safe) — 데이트레이딩 신규 진입 차단 "
|
||||
f"(연속 실패 {_health.get('consecutive_failures')}회 / 임계 "
|
||||
f"{_health.get('blind_block_threshold')}, 마지막 오류: {_health.get('last_error')})"
|
||||
)
|
||||
else:
|
||||
logger.warning("[DayEntry] ⛔ MDD 임계 초과(is_paused) — 데이트레이딩 신규 진입 차단")
|
||||
return entries
|
||||
|
||||
# 적응형 전략 체크 — Defensive/Crisis 모드에서 데이트레이딩 비활성화
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
@@ -433,8 +490,17 @@ async def _try_enter(
|
||||
current_day_trade_amount=current_day_trade_amount,
|
||||
)
|
||||
# confidence 스케일링: 기준 0.7 대비 비례 조정 (0.6→86%, 0.7→100%, 0.85→121%)
|
||||
# [H3 fix 2026-09-27] 예전 코드는 calculate_day_trade_position_size()가 이미
|
||||
# min(종목당 6%, DT 잔여 30%, 보유현금)로 클램프한 값에 최대 1.2배를 곱해
|
||||
# 세 한도를 모두 최대 20% 초과했다(ORB confidence 상한 0.85 → 실제로 1.2배 발동).
|
||||
# → 배율 적용 후 동일한 세 한도로 재클램프한다. 감액(<1.0)은 그대로 적용된다.
|
||||
confidence_scale = min(1.2, signal.confidence / 0.7)
|
||||
budget = budget * confidence_scale
|
||||
budget = _clamp_day_trade_budget(
|
||||
budget * confidence_scale,
|
||||
total_assets=total_assets,
|
||||
available_cash=available_cash,
|
||||
current_day_trade_amount=current_day_trade_amount,
|
||||
)
|
||||
quantity = int(budget // current_price) if current_price > 0 else 0
|
||||
if quantity <= 0:
|
||||
return None
|
||||
|
||||
@@ -25,6 +25,7 @@ from datetime import datetime
|
||||
|
||||
import pytz
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.market_schedule import is_eod_flat_time, is_market_open
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.day_trading_strategy import (
|
||||
@@ -40,6 +41,42 @@ logger = logging.getLogger(__name__)
|
||||
# 한국 표준시(KST) 타임존
|
||||
_KST = pytz.timezone("Asia/Seoul")
|
||||
|
||||
# [M1] 전일(stale) 포지션 강제청산으로 발생한 실현손익 누적 (당일 집계에서 제외된 금액).
|
||||
# 당일 손실 한도 판단에는 쓰지 않고 추적/로그 용도로만 사용한다.
|
||||
_stale_realized_pnl: float = 0.0
|
||||
|
||||
|
||||
def get_stale_realized_pnl() -> float:
|
||||
"""전일 포지션 강제청산으로 실현된 손익 누적값(당일 daily_pnl 에서 제외된 금액)."""
|
||||
return _stale_realized_pnl
|
||||
|
||||
|
||||
def _fallback_stop_pct() -> float:
|
||||
"""[M7] stop_loss 가 없을 때 사용할 폴백 손절 비율.
|
||||
|
||||
기존 하드코딩 0.06(-6%)은 전략 하드캡(ORB 3%, VWAP/RSI_BB 3.5%)의 2배로 느슨했다.
|
||||
가장 보수적(타이트)한 하드캡을 사용해 폴백이 실제 전략보다 헐거워지지 않게 한다.
|
||||
position_manager.restore_from_db() 의 폴백(DAY_TRADE_STOP_LOSS_PCT)과도 일치.
|
||||
"""
|
||||
return min(
|
||||
settings.DAY_TRADE_STOP_LOSS_PCT,
|
||||
settings.DAY_TRADE_ORB_STOP_LOSS_PCT,
|
||||
settings.DAY_TRADE_REVERSION_STOP_LOSS_PCT,
|
||||
)
|
||||
|
||||
|
||||
def _fallback_stop_price(entry_price: float) -> float:
|
||||
"""진입가 기준 폴백 손절가."""
|
||||
return entry_price * (1 - _fallback_stop_pct())
|
||||
|
||||
|
||||
def _fallback_target_price(entry_price: float, stop_loss: float) -> float:
|
||||
"""진입가·손절가 기준 폴백 목표가 (DAY_TRADE_TARGET_MULTIPLIER 정합)."""
|
||||
risk = entry_price - stop_loss
|
||||
if risk <= 0:
|
||||
risk = entry_price * _fallback_stop_pct()
|
||||
return entry_price + risk * settings.DAY_TRADE_TARGET_MULTIPLIER
|
||||
|
||||
|
||||
def _kst_now() -> datetime:
|
||||
"""현재 KST 시간을 timezone-naive datetime으로 반환.
|
||||
@@ -134,23 +171,42 @@ async def _check_single_position(stock_code: str) -> dict | None:
|
||||
return None
|
||||
|
||||
entry_price = pos.get("entry_price", 0)
|
||||
# [FIX] 0 값이 저장된 경우 dict.get() 기본값이 적용되지 않으므로 `or`로 방어
|
||||
# [M7] stop_loss / target_price 는 None 뿐 아니라 0 으로 저장될 수 있다.
|
||||
# `or` 패턴은 0 도 폴백으로 넘기지만 의도가 모호하므로 명시적으로 None/비정상값을 검사한다.
|
||||
# (target_price=0이면 day_trading_strategy.py의 목표가 도달 조건이 항상 참이 되어 즉시 오매도됨)
|
||||
stop_loss = pos.get("stop_loss") or (entry_price * 0.94)
|
||||
target_price = pos.get("target_price") or (entry_price * 1.06)
|
||||
quantity = pos.get("quantity", 0)
|
||||
_sl_raw = pos.get("stop_loss")
|
||||
stop_loss = float(_sl_raw) if (_sl_raw is not None and float(_sl_raw) > 0) else _fallback_stop_price(entry_price)
|
||||
_tp_raw = pos.get("target_price")
|
||||
target_price = (
|
||||
float(_tp_raw) if (_tp_raw is not None and float(_tp_raw) > 0)
|
||||
else _fallback_target_price(entry_price, stop_loss)
|
||||
)
|
||||
highest_price = pos.get("highest_price", entry_price)
|
||||
entry_time = pos.get("entry_time")
|
||||
|
||||
# 5분봉 데이터 조회
|
||||
# [H4] try 범위를 "분봉 조회 + 지표/신호 계산"까지로 한정한다.
|
||||
# 예전 코드는 _execute_exit 까지 하나의 try 안에 있고 로그가 debug 였기 때문에
|
||||
# 분봉 조회 실패 시 손절·트레일링·시간손절이 전부 미적용된 채 15:20 까지 방치되고,
|
||||
# 주문 실패조차 로그에 보이지 않았다.
|
||||
exit_signal = None
|
||||
current_price = 0.0
|
||||
try:
|
||||
raw = await korea_investment_api.get_minute_chart_full_day(stock_code)
|
||||
candles = parse_minute_candles(raw)
|
||||
candles_5m = aggregate_to_5min(candles)
|
||||
if len(candles_5m) < 5:
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.warning(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e} — 폴백 손절만 판정")
|
||||
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
|
||||
|
||||
current_price = float(candles_5m[-1].close) if candles_5m else 0.0
|
||||
if not candles_5m or len(candles_5m) < 5:
|
||||
# 분봉이 부족하면 ATR/RSI 기반 판정이 불가 → 손절선만 지킨다(방치 금지)
|
||||
logger.warning(
|
||||
f"[DayExit] 5분봉 부족 ({stock_code}): {len(candles_5m or [])}개 — 폴백 손절만 판정"
|
||||
)
|
||||
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
|
||||
|
||||
try:
|
||||
current_price = float(candles_5m[-1].close)
|
||||
|
||||
# highest_price 갱신 — 5분봉 high 값도 반영 (30초 주기 보완)
|
||||
candle_high = max(c.high for c in candles_5m[-3:]) if len(candles_5m) >= 3 else current_price
|
||||
@@ -176,18 +232,83 @@ async def _check_single_position(stock_code: str) -> dict | None:
|
||||
current_time=current_time,
|
||||
entry_time=entry_time,
|
||||
)
|
||||
|
||||
if exit_signal:
|
||||
sell_pct = exit_signal.sell_pct
|
||||
return await _execute_exit(
|
||||
stock_code, pos, current_price, exit_signal.reason,
|
||||
sell_pct=sell_pct,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"[DayExit] 5분봉 조회 실패 ({stock_code}): {e}")
|
||||
logger.warning(f"[DayExit] 청산 지표 계산 실패 ({stock_code}): {e} — 폴백 손절만 판정")
|
||||
return await _check_fallback_stop_loss(stock_code, pos, stop_loss)
|
||||
|
||||
return None
|
||||
if not exit_signal or current_price <= 0:
|
||||
return None
|
||||
|
||||
# 주문 실행은 try 밖(별도 try) — 실패를 조회 실패와 구분해 error 로 남긴다.
|
||||
try:
|
||||
return await _execute_exit(
|
||||
stock_code, pos, current_price, exit_signal.reason,
|
||||
sell_pct=exit_signal.sell_pct,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[DayExit] 청산 주문 실행 실패 ({stock_code}): {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _check_fallback_stop_loss(
|
||||
stock_code: str, pos: dict, stop_loss: float,
|
||||
) -> dict | None:
|
||||
"""[H4] 분봉 미확보 시 최소 안전장치 — 손절선 이탈만 판정해 전량 청산.
|
||||
|
||||
swing/exit_service.py 의 고정비율 폴백 손절과 같은 취지. 분봉이 없으면
|
||||
트레일링/시간손절/RSI 청산은 판정할 수 없으므로 "손절선"만 지킨다.
|
||||
현재가는 KIS 현재가 조회 → 인메모리 최신가(current_price) 순으로 확보하고,
|
||||
둘 다 실패하면 logger.error 로 남긴다(손절 미적용을 침묵시키지 않는다).
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
pos: 포지션 정보
|
||||
stop_loss: 적용할 손절가 (0 이하이면 진입가 기준 폴백 산출)
|
||||
|
||||
Returns:
|
||||
dict | None: 청산 결과 또는 None (손절 미도달·판정 불가)
|
||||
"""
|
||||
entry_price = float(pos.get("entry_price", 0) or 0)
|
||||
if entry_price <= 0 or int(pos.get("quantity", 0) or 0) <= 0:
|
||||
return None
|
||||
if stop_loss <= 0:
|
||||
stop_loss = _fallback_stop_price(entry_price)
|
||||
|
||||
current_price = 0.0
|
||||
try:
|
||||
resp = await korea_investment_api.get_current_price(stock_code)
|
||||
current_price = float(resp.get("output", {}).get("stck_prpr", 0) or 0) if resp else 0.0
|
||||
except Exception as e:
|
||||
logger.warning(f"[DayExit] 폴백 손절 현재가 조회 실패 ({stock_code}): {e}")
|
||||
if current_price <= 0:
|
||||
current_price = float(pos.get("current_price", 0) or 0)
|
||||
if current_price <= 0:
|
||||
logger.error(
|
||||
f"[DayExit] 폴백 손절 현재가 확보 실패 ({stock_code}) — 손절 미적용 상태, 수동 확인 필요"
|
||||
)
|
||||
return None
|
||||
|
||||
async with day_trading_state.trade_lock:
|
||||
if stock_code in day_trading_state.positions:
|
||||
day_trading_state.positions[stock_code]["current_price"] = current_price
|
||||
|
||||
if current_price > stop_loss:
|
||||
return None
|
||||
|
||||
loss_rate = (current_price - entry_price) / entry_price * 100
|
||||
logger.warning(
|
||||
f"[DayExit] ⚠️ 폴백 손절 발동 ({stock_code}): 현재가={current_price:.0f} "
|
||||
f"<= 손절={stop_loss:.0f} ({loss_rate:.2f}%) — 분봉 미확보"
|
||||
)
|
||||
try:
|
||||
return await _execute_exit(
|
||||
stock_code, pos, current_price,
|
||||
reason=f"폴백 손절(분봉 미확보) {loss_rate:.1f}%",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"[DayExit] 폴백 손절 주문 실패 ({stock_code}): {e}", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
async def _execute_exit(
|
||||
@@ -213,9 +334,31 @@ async def _execute_exit(
|
||||
Returns:
|
||||
dict | None: 청산 결과 (stock_code, price, quantity, profit_loss 등)
|
||||
"""
|
||||
return await _execute_exit_impl(stock_code, pos, price, reason, sell_pct, is_stale=False)
|
||||
|
||||
|
||||
async def _execute_exit_stale(
|
||||
stock_code: str, pos: dict, price: float, reason: str,
|
||||
) -> dict | None:
|
||||
"""[M1] 전일 미청산(stale) 포지션 전용 청산 경로.
|
||||
|
||||
_execute_exit 과 동일하게 주문·상태반영·DB 기록을 수행하지만,
|
||||
실현손익을 **당일** daily_pnl / loss_streak_today 에 가산하지 않는다.
|
||||
(전일 손실이 당일 손실 한도(-3%)를 오발동시켜 정상 포지션까지 전량 청산되고
|
||||
신규 진입이 종일 차단되던 문제 — 2026-09-27 수정)
|
||||
"""
|
||||
return await _execute_exit_impl(stock_code, pos, price, reason, 1.0, is_stale=True)
|
||||
|
||||
|
||||
async def _execute_exit_impl(
|
||||
stock_code: str, pos: dict, price: float, reason: str,
|
||||
sell_pct: float, is_stale: bool,
|
||||
) -> dict | None:
|
||||
"""매도 주문 실행 + 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변).
|
||||
|
||||
is_stale=True 이면 당일 손익 집계(update_pnl)를 건너뛴다.
|
||||
"""
|
||||
total_qty = pos.get("quantity", 0)
|
||||
entry_price = pos.get("entry_price", 0)
|
||||
stock_name = pos.get("stock_name", stock_code)
|
||||
|
||||
if total_qty <= 0 or price <= 0:
|
||||
return None
|
||||
@@ -247,6 +390,10 @@ async def _execute_exit(
|
||||
# [2026-09-23] 여기서부터는 이미 체결된 주문의 상태 반영이다. 스케줄러의 단계
|
||||
# 타임아웃(_guard)이 이 지점을 취소하면 "브로커는 팔았는데 인메모리는 그대로" 상태가
|
||||
# 되어 다음 사이클이 같은 물량을 다시 판다 → shield 로 취소로부터 보호한다.
|
||||
if is_stale:
|
||||
return await asyncio.shield(_finalize_exit_stale(
|
||||
stock_code, pos, price, reason, order_resp, quantity, total_qty,
|
||||
))
|
||||
return await asyncio.shield(_finalize_exit(
|
||||
stock_code, pos, price, reason, sell_pct, order_resp, quantity, total_qty, is_full_exit
|
||||
))
|
||||
@@ -256,7 +403,44 @@ async def _finalize_exit(
|
||||
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
|
||||
order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool,
|
||||
) -> dict | None:
|
||||
"""체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간."""
|
||||
"""체결 이후 상태 반영 (인메모리·DB·캐시). 취소되면 안 되는 구간.
|
||||
|
||||
당일 포지션 전용 — 실현손익을 당일 daily_pnl / loss_streak_today 에 반영한다.
|
||||
(전일 포지션은 _finalize_exit_stale 사용 — M1)
|
||||
"""
|
||||
return await _finalize_exit_impl(
|
||||
stock_code, pos, price, reason, sell_pct, order_resp,
|
||||
quantity, total_qty, is_full_exit, count_daily_pnl=True,
|
||||
)
|
||||
|
||||
|
||||
async def _finalize_exit_stale(
|
||||
stock_code: str, pos: dict, price: float, reason: str,
|
||||
order_resp: dict, quantity: int, total_qty: int,
|
||||
) -> dict | None:
|
||||
"""[M1] 전일(stale) 포지션 체결 이후 상태 반영.
|
||||
|
||||
당일 손익 집계(daily_pnl·loss_streak_today)에는 가산하지 않고,
|
||||
별도 누적값(_stale_realized_pnl)과 로그로만 추적한다.
|
||||
DB TradeHistory 기록은 동일하게 수행되므로 실제 손익 추적은 유지된다.
|
||||
"""
|
||||
return await _finalize_exit_impl(
|
||||
stock_code, pos, price, reason, 1.0, order_resp,
|
||||
quantity, total_qty, True, count_daily_pnl=False,
|
||||
)
|
||||
|
||||
|
||||
async def _finalize_exit_impl(
|
||||
stock_code: str, pos: dict, price: float, reason: str, sell_pct: float,
|
||||
order_resp: dict, quantity: int, total_qty: int, is_full_exit: bool,
|
||||
count_daily_pnl: bool = True,
|
||||
) -> dict | None:
|
||||
"""체결 이후 상태 반영 공통 구현 (신규 함수 — 기존 시그니처 불변).
|
||||
|
||||
count_daily_pnl=False 이면 당일 손익/연속손실 집계를 건너뛰고
|
||||
전일 손익 누적값에만 더한다(전일 포지션 강제청산 경로).
|
||||
"""
|
||||
global _stale_realized_pnl
|
||||
entry_price = pos.get("entry_price", 0)
|
||||
stock_name = pos.get("stock_name", stock_code)
|
||||
order_id = order_resp.get("output", {}).get("ODNO", "")
|
||||
@@ -267,13 +451,25 @@ async def _finalize_exit(
|
||||
async with day_trading_state.trade_lock:
|
||||
if is_full_exit:
|
||||
day_trading_state.remove_position(stock_code)
|
||||
if not count_daily_pnl:
|
||||
# [M10] DB에서만 발견된 전일 포지션은 인메모리에 없어 remove_position()의
|
||||
# closed_today 추가가 일어나지 않는다 → 강제청산한 종목이 같은 날
|
||||
# scan_and_enter 후보로 되살아난다. 명시적으로 재진입 금지 집합에 넣는다.
|
||||
day_trading_state.closed_today.add(stock_code)
|
||||
else:
|
||||
# 분할 매도: 잔량 유지, target_price 제거(트레일링만 적용)
|
||||
remaining = total_qty - quantity
|
||||
day_trading_state.positions[stock_code]["quantity"] = remaining
|
||||
day_trading_state.positions[stock_code]["target_price"] = float("inf")
|
||||
day_trading_state.positions[stock_code]["partial_sold"] = True
|
||||
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
|
||||
if count_daily_pnl:
|
||||
day_trading_state.update_pnl(profit_loss, is_full_close=is_full_exit)
|
||||
else:
|
||||
_stale_realized_pnl += profit_loss
|
||||
logger.warning(
|
||||
f"[DayExit] 전일 포지션 실현손익은 당일 집계 제외: {stock_code} "
|
||||
f"{profit_loss:+.0f}원 (전일 누적={_stale_realized_pnl:+.0f}원)"
|
||||
)
|
||||
|
||||
# [P0] loss_tracker 연동 — 전량 매도 시 연속 손실 추적
|
||||
if is_full_exit:
|
||||
@@ -356,6 +552,10 @@ async def close_stale_positions() -> list[dict]:
|
||||
if pos and entry_date is not None and entry_date < today:
|
||||
stale[stock_code] = pos
|
||||
|
||||
# [M9 / CLAUDE.md §3.1 예외 — 읽기 전용, 쓰기는 db_worker(db_task_queue) 경유]
|
||||
# SELECT 1건만 수행하며 commit()/flush()/add() 호출이 없다. 세션 수명은 아래
|
||||
# async with 블록(쿼리 1회)로 최소화하고, 실패는 warning 으로 운영에 노출한다.
|
||||
# 실제 청산 기록은 아래 _execute_exit_stale → db_task_queue 경유로만 이뤄진다.
|
||||
try:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import ActiveTrade
|
||||
@@ -407,10 +607,16 @@ async def close_stale_positions() -> list[dict]:
|
||||
f"[DayExit] ⚠️ 전일 미청산 포지션 즉시 청산: {stock_code} "
|
||||
f"(진입일={entry_date}, 수량={pos.get('quantity')})"
|
||||
)
|
||||
result = await _execute_exit(
|
||||
# [M10] 청산 성공/실패와 무관하게 당일 재진입은 금지한다. 주문이 실패해도
|
||||
# 다음 사이클에서 재시도되며, 그 사이 scan_and_enter 가 같은 종목을 다시
|
||||
# 매수하면 전일 포지션이 되살아난다.
|
||||
async with day_trading_state.trade_lock:
|
||||
day_trading_state.closed_today.add(stock_code)
|
||||
|
||||
# [M1] 전일 손익은 당일 daily_pnl / loss_streak_today 에 가산하지 않는다.
|
||||
result = await _execute_exit_stale(
|
||||
stock_code, pos, price,
|
||||
reason=f"전일 미청산 포지션 강제 청산 (진입일 {entry_date}, EOD 누락)",
|
||||
sell_pct=1.0,
|
||||
)
|
||||
if result:
|
||||
results.append(result)
|
||||
|
||||
@@ -85,19 +85,22 @@ async def daytrading_scheduler() -> None:
|
||||
|
||||
# 앱 재시작 시 DB에서 당일 데이트레이딩 포지션 복원 (CRITICAL 버그 수정)
|
||||
try:
|
||||
# [M9 / CLAUDE.md §3.1 예외 — 기동 시 1회, 읽기 전용 복원]
|
||||
# restore_from_db()는 SELECT 만 수행하며 commit() 이 없다. 쓰기는 db_worker 경유.
|
||||
# 세션 수명을 복원 쿼리로 한정하고, 주문이 발생하는 stale 청산은 블록 밖에서 수행한다.
|
||||
from app.core.database import AsyncSessionLocal
|
||||
async with AsyncSessionLocal() as db:
|
||||
restored = await day_trading_state.restore_from_db(db)
|
||||
if restored:
|
||||
logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨")
|
||||
# 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치
|
||||
try:
|
||||
from app.services.daytrading.exit_service import close_stale_positions
|
||||
stale = await close_stale_positions()
|
||||
if stale:
|
||||
logger.warning(f"[DayTrading] 기동 시 전일 포지션 {len(stale)}건 청산")
|
||||
except Exception as e:
|
||||
logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True)
|
||||
if restored:
|
||||
logger.info(f"[DayTrading] 시작 시 {restored}개 포지션 복원됨")
|
||||
# 전일 미청산 포지션은 기동 직후(장중이면) 즉시 청산 — EOD 누락 안전장치
|
||||
try:
|
||||
from app.services.daytrading.exit_service import close_stale_positions
|
||||
stale = await close_stale_positions()
|
||||
if stale:
|
||||
logger.warning(f"[DayTrading] 기동 시 전일 포지션 {len(stale)}건 청산")
|
||||
except Exception as e:
|
||||
logger.error(f"[DayTrading] 기동 시 전일 포지션 청산 실패: {e}", exc_info=True)
|
||||
except Exception as e:
|
||||
logger.error(f"[DayTrading] 시작 시 포지션 복원 실패: {e}", exc_info=True)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user