fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정
- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복 - DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복 - 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정 - KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카 - 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증 - 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정 - 전문가 에이전트 6종 추가(.claude/agents) - 유닛 스위트 2667 passed (소켓 차단 하), tsc OK Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
@@ -24,6 +24,7 @@ strategy_type='swing' 진입만 처리하는 독립 서비스.
|
||||
"""
|
||||
import asyncio
|
||||
import logging
|
||||
import math
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from datetime import date
|
||||
@@ -252,6 +253,129 @@ def _inject_strategy_type(rec: dict, strategy_type: str) -> None:
|
||||
stock["strategy_type"] = strategy_type
|
||||
|
||||
|
||||
def _below_min_price_krw_v2(price) -> bool:
|
||||
"""국내 가격 필터 — True 면 진입 거부 (CLAUDE.md §5.1 '국내 >5,000원' = 초과만 허용).
|
||||
|
||||
[하드닝 R2 2026-10-09] 듀얼 스캔은 `<`(정확히 MIN_PRICE_KRW 허용), 박스권은 `<=`(거부)로
|
||||
경로마다 경계가 달랐다. 진입 단계(trade_executor·manual_buy·portfolio_v2)는 `<=` 이므로
|
||||
후보 단계에서 통과한 5,000원 종목이 뒤에서 조용히 탈락해 평가 슬롯을 소모했다.
|
||||
해석 불가(None·문자열·NaN·inf)도 거부한다(fail-closed).
|
||||
"""
|
||||
try:
|
||||
p = float(price if price is not None else 0)
|
||||
except (TypeError, ValueError):
|
||||
return True
|
||||
if not math.isfinite(p):
|
||||
return True
|
||||
return p <= float(settings.MIN_PRICE_KRW)
|
||||
|
||||
|
||||
# ─── 듀얼·박스권 스캔용 일봉 (하드닝 R2 2026-10-09) ─────────────────────────
|
||||
# KIS get_daily_chart(FHKST01010400 inquire-daily-price)는 limit 과 무관하게 30행만 돌려준다
|
||||
# (로컬 로그 'Fetched N chart data points' 전부 N=30). 눌림목 v2 는 60봉(MA60), 박스권은 40봉을
|
||||
# 요구하므로 두 스캔은 구조적으로 한 번도 발화하지 못했다. 네이버 일봉(market_analyzer·
|
||||
# adaptive 와 같은 1차 소스) → KIS 기간 조회(FHKST03010100, 날짜 소급 페이지네이션) →
|
||||
# 기존 get_daily_chart 순으로 확보한다.
|
||||
DUAL_SCAN_MIN_BARS = 60 # check_pullback_buy_signal_v2: len(closes) < 60 이면 None
|
||||
RANGE_SCAN_MIN_BARS = 40 # scan_range_trading_signals 기존 하한
|
||||
# get_naver_daily_chart(days=N) 은 캘린더 (N+30)일 창 — 120 → 150일 ≈ 100 거래일
|
||||
# (market_analyzer 실측: 80→75~77봉, 200→약 155봉).
|
||||
_SCAN_NAVER_CHART_DAYS = 120
|
||||
_SCAN_KIS_RANGE_CALENDAR_DAYS = 150
|
||||
|
||||
|
||||
def _bars_end_today_v2(bars_oldest_first: list) -> bool:
|
||||
"""마지막 봉이 오늘(KST) 봉인가.
|
||||
|
||||
스캔 로직은 `closes[-1]`/`volumes[-1]` 을 '오늘(장중)' 값으로 쓴다(모멘텀 v2·눌림목 v2 의
|
||||
오늘 봉 제외 계산 포함). 오늘 봉이 빠진 소스를 그대로 쓰면 어제 종가로 신호·주문가가
|
||||
정해지므로, 그런 소스는 채택하지 않고 다음 소스로 넘어간다.
|
||||
"""
|
||||
if not bars_oldest_first:
|
||||
return False
|
||||
from app.core.timeutil import today_kst
|
||||
last = str(bars_oldest_first[-1].get("stck_bsop_date") or "")
|
||||
return last == today_kst().strftime("%Y%m%d")
|
||||
|
||||
|
||||
async def _fetch_scan_daily_bars_v2(api, stock_code: str, min_bars: int) -> tuple[list, str]:
|
||||
"""스캔용 일봉(오래된 → 최신, KIS 필드명). 반환: (bars, source).
|
||||
|
||||
1) 네이버 일봉 — 오늘 봉으로 끝나고 min_bars 이상이면 채택
|
||||
2) KIS 기간 조회(get_daily_ohlcv_range) — 같은 조건
|
||||
3) 둘 다 실패(빈 결과·오늘 봉 없음·예외)면 기존 get_daily_chart(limit=60) (≈30봉, 기존 동작)
|
||||
1·2 가 오늘 봉으로 끝나지만 min_bars 미달이면(신규 상장 등) 그중 긴 쪽을 쓴다.
|
||||
봉수 부족 경고는 호출부가 스캔 단위로 집계해 남긴다.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
from app.core.timeutil import today_kst
|
||||
|
||||
best: list = []
|
||||
best_src = ""
|
||||
|
||||
# 1) 네이버
|
||||
try:
|
||||
from app.services.naver_finance import get_naver_daily_chart
|
||||
nv = await get_naver_daily_chart(stock_code, days=_SCAN_NAVER_CHART_DAYS)
|
||||
nv = [b for b in (nv or []) if isinstance(b, dict) and b.get("stck_bsop_date")]
|
||||
if _bars_end_today_v2(nv):
|
||||
if len(nv) >= min_bars:
|
||||
return nv, "naver"
|
||||
best, best_src = nv, "naver"
|
||||
except Exception as e:
|
||||
warn_throttled(
|
||||
logger, "entry_service:scan_bars_naver",
|
||||
f"[ScanBars] {stock_code} 네이버 일봉 조회 실패(KIS 폴백): {type(e).__name__}: {e}",
|
||||
)
|
||||
|
||||
# 2) KIS 기간 조회 (FHKST03010100) — 시세 실서버 위임 설정을 get_daily_chart 와 동일하게 따른다
|
||||
try:
|
||||
src_api = api
|
||||
_delegate = getattr(api, "_quote_delegate", None)
|
||||
if callable(_delegate):
|
||||
_d = _delegate()
|
||||
if _d is not None:
|
||||
src_api = _d
|
||||
end = today_kst()
|
||||
start = end - timedelta(days=_SCAN_KIS_RANGE_CALENDAR_DAYS)
|
||||
rows = await src_api.get_daily_ohlcv_range(
|
||||
stock_code, start.strftime("%Y%m%d"), end.strftime("%Y%m%d"),
|
||||
)
|
||||
rows = [r for r in (rows or []) if isinstance(r, dict) and r.get("stck_bsop_date")]
|
||||
# 최신 → 오래된 순으로 오므로 날짜 기준 정렬 + 중복 제거
|
||||
kis = sorted({r["stck_bsop_date"]: r for r in rows}.values(),
|
||||
key=lambda r: r["stck_bsop_date"])
|
||||
if _bars_end_today_v2(kis):
|
||||
if len(kis) >= min_bars:
|
||||
return kis, "kis_range"
|
||||
if len(kis) > len(best):
|
||||
best, best_src = kis, "kis_range"
|
||||
except Exception as e:
|
||||
warn_throttled(
|
||||
logger, "entry_service:scan_bars_kis_range",
|
||||
f"[ScanBars] {stock_code} KIS 기간 일봉 조회 실패: {type(e).__name__}: {e}",
|
||||
)
|
||||
|
||||
if best:
|
||||
return best, best_src
|
||||
|
||||
# 3) 기존 경로 (≈30봉). 예외는 호출부의 종목 단위 except 로 전파 — 기존 동작 유지.
|
||||
chart = await api.get_daily_chart(stock_code, limit=60)
|
||||
output2 = (chart or {}).get("output2") or []
|
||||
return list(reversed(output2)), "kis_daily_price"
|
||||
|
||||
|
||||
def _warn_short_scan_bars_v2(tag: str, short: list, min_bars: int) -> None:
|
||||
"""스캔 1회분의 봉수 부족 종목을 경고 1줄로 가시화한다(조용한 미발화 방지)."""
|
||||
if not short:
|
||||
return
|
||||
sample = ", ".join(f"{c}:{n}봉({s})" for c, n, s in short[:5])
|
||||
warn_throttled(
|
||||
logger, f"entry_service:short_bars:{tag}",
|
||||
f"[{tag}] 일봉 {min_bars}봉 미달 {len(short)}종목 — 해당 전략 판정 불가: {sample}",
|
||||
)
|
||||
|
||||
|
||||
async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
|
||||
"""듀얼 전략 시그널 스캔 및 진입 실행 (CLAUDE.md 섹션 8.4).
|
||||
|
||||
@@ -366,29 +490,35 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
|
||||
|
||||
dual_stocks = []
|
||||
scan_count = 0
|
||||
short_bars: list = []
|
||||
|
||||
for stock in candidates:
|
||||
if scan_count >= 10:
|
||||
break
|
||||
if stock.stock_code in held_codes:
|
||||
continue
|
||||
if float(stock.current_price or 0) < settings.MIN_PRICE_KRW:
|
||||
if _below_min_price_krw_v2(stock.current_price):
|
||||
continue
|
||||
|
||||
try:
|
||||
chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60)
|
||||
output2 = chart.get("output2", [])
|
||||
if not output2 or len(output2) < 20:
|
||||
# [하드닝 R2 2026-10-09] get_daily_chart 는 30봉 상한이라 눌림목(60봉)이 구조적으로
|
||||
# 미발화였다 → 네이버/KIS 기간 조회로 60봉 이상 확보 (_fetch_scan_daily_bars_v2).
|
||||
bars, bars_src = await _fetch_scan_daily_bars_v2(
|
||||
korea_investment_api, stock.stock_code, DUAL_SCAN_MIN_BARS,
|
||||
)
|
||||
if len(bars) < DUAL_SCAN_MIN_BARS:
|
||||
short_bars.append((stock.stock_code, len(bars), bars_src))
|
||||
if not bars or len(bars) < 20:
|
||||
continue
|
||||
|
||||
# 최신이 앞에 오므로 reverse
|
||||
bars = list(reversed(output2))
|
||||
closes = [float(b.get("stck_clpr", 0)) for b in bars]
|
||||
highs = [float(b.get("stck_hgpr", 0)) for b in bars]
|
||||
volumes = [int(b.get("acml_vol", 0)) for b in bars]
|
||||
closes = [float(b.get("stck_clpr", 0) or 0) for b in bars]
|
||||
highs = [float(b.get("stck_hgpr", 0) or 0) for b in bars]
|
||||
volumes = [int(float(b.get("acml_vol", 0) or 0)) for b in bars]
|
||||
|
||||
if not closes or closes[-1] <= 0:
|
||||
continue
|
||||
if _below_min_price_krw_v2(closes[-1]):
|
||||
continue
|
||||
|
||||
current_close = closes[-1]
|
||||
current_volume = volumes[-1] if volumes else 0
|
||||
@@ -484,6 +614,8 @@ async def scan_dual_strategy_signals(db: AsyncSession) -> list[dict]:
|
||||
)
|
||||
continue
|
||||
|
||||
_warn_short_scan_bars_v2("DualStrategy", short_bars, DUAL_SCAN_MIN_BARS)
|
||||
|
||||
if not dual_stocks:
|
||||
logger.debug("[DualStrategy] 시그널 없음")
|
||||
return []
|
||||
@@ -585,7 +717,7 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]:
|
||||
8. 플레이북 이벤트 차단
|
||||
9. 보유 종목 중복 진입 방지
|
||||
10. 연속손실 밴 (loss_tracker.is_banned_safe)
|
||||
11. 가격 필터 (MIN_PRICE_KRW)
|
||||
11. 가격 필터 (MIN_PRICE_KRW 이하 거부 — _below_min_price_krw_v2)
|
||||
12. 이후 execute_auto_trade() 내부에서 가격필터·MDD·밴·보유수·당일매도쿨다운,
|
||||
_execute_buy 내부에서 correlation_filter·레버리지ETF한도·뉴스필터·
|
||||
MIN_BUY_AMOUNT_KRW 가 다시 적용된다.
|
||||
@@ -706,6 +838,7 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]:
|
||||
|
||||
range_stocks: list[dict] = []
|
||||
scan_count = 0
|
||||
short_bars: list = []
|
||||
|
||||
for stock in candidates:
|
||||
if scan_count >= settings.RANGE_TRADE_SCAN_LIMIT:
|
||||
@@ -716,7 +849,7 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]:
|
||||
if stock.stock_code in held_codes:
|
||||
continue
|
||||
# 게이트 11: 가격 필터
|
||||
if float(stock.current_price or 0) <= settings.MIN_PRICE_KRW:
|
||||
if _below_min_price_krw_v2(stock.current_price):
|
||||
continue
|
||||
# 게이트 10: 연속손실 밴 (try 밖 — 실패를 삼키지 않는다)
|
||||
if await loss_tracker.is_banned_safe(stock.stock_code):
|
||||
@@ -724,20 +857,24 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]:
|
||||
continue
|
||||
|
||||
try:
|
||||
chart = await korea_investment_api.get_daily_chart(stock.stock_code, limit=60)
|
||||
output2 = chart.get("output2", [])
|
||||
if not output2 or len(output2) < 40:
|
||||
# [하드닝 R2 2026-10-09] get_daily_chart 30봉 상한 → 40봉 하한에 항상 걸렸다.
|
||||
bars, bars_src = await _fetch_scan_daily_bars_v2(
|
||||
korea_investment_api, stock.stock_code, RANGE_SCAN_MIN_BARS,
|
||||
)
|
||||
if len(bars) < RANGE_SCAN_MIN_BARS:
|
||||
short_bars.append((stock.stock_code, len(bars), bars_src))
|
||||
continue
|
||||
|
||||
bars = list(reversed(output2)) # 최신이 앞 → 오래된 순으로
|
||||
opens = [float(b.get("stck_oprc", 0)) for b in bars]
|
||||
closes = [float(b.get("stck_clpr", 0)) for b in bars]
|
||||
highs = [float(b.get("stck_hgpr", 0)) for b in bars]
|
||||
lows = [float(b.get("stck_lwpr", 0)) for b in bars]
|
||||
volumes = [int(b.get("acml_vol", 0)) for b in bars]
|
||||
opens = [float(b.get("stck_oprc", 0) or 0) for b in bars]
|
||||
closes = [float(b.get("stck_clpr", 0) or 0) for b in bars]
|
||||
highs = [float(b.get("stck_hgpr", 0) or 0) for b in bars]
|
||||
lows = [float(b.get("stck_lwpr", 0) or 0) for b in bars]
|
||||
volumes = [int(float(b.get("acml_vol", 0) or 0)) for b in bars]
|
||||
|
||||
if not closes or closes[-1] <= 0:
|
||||
continue
|
||||
if _below_min_price_krw_v2(closes[-1]):
|
||||
continue
|
||||
scan_count += 1
|
||||
|
||||
current_close = closes[-1]
|
||||
@@ -803,6 +940,8 @@ async def scan_range_trading_signals(db: AsyncSession) -> list[dict]:
|
||||
warn_throttled(logger, "entry_service:682", f"[RangeTrade] {stock.stock_code} 스캔 실패: {e}")
|
||||
continue
|
||||
|
||||
_warn_short_scan_bars_v2("RangeTrade", short_bars, RANGE_SCAN_MIN_BARS)
|
||||
|
||||
if not range_stocks:
|
||||
logger.debug("[RangeTrade] 시그널 없음")
|
||||
return []
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
452
backend/app/services/swing/fill_sweep.py
Normal file
452
backend/app/services/swing/fill_sweep.py
Normal file
@@ -0,0 +1,452 @@
|
||||
"""체결가 미확인 TradeHistory 후속 보정 (스윙 스케줄러 사이클에서 호출).
|
||||
|
||||
[2026-10-08] 운영 관측: TradeHistory #432(풍산)·#433(현대차)의 executed_price 가 주문가
|
||||
(현재가×1.005, 호가단위상 불가능한 값)로 남았다. 주문 직후의 체결 확인
|
||||
(`trade_executor._verify_and_sync_execution_v3`)이 폴링 15초 안에 체결을 못 보거나 조회가
|
||||
실패하면 그 뒤로 아무도 체결가를 채우지 않았다.
|
||||
|
||||
이제 주문 경로들은 확인 전 executed_price 를 **NULL** 로 기록한다(주문가 대체 금지). 이 모듈은
|
||||
"당일 · COMPLETED · order_id 있음 · executed_price IS NULL · 일정 시간 경과" 행을 골라
|
||||
KIS 체결조회(inquire-daily-ccld)로 실제 체결가·체결수량을 확인하고 db_worker 큐로 갱신한다.
|
||||
|
||||
규칙
|
||||
- DB 는 **읽기만**(호출부의 SELECT 전용 세션). 쓰기는 db_worker 큐(§3.2).
|
||||
- 사이클당 KIS 조회 상한·행별 재시도 간격·최대 시도 수를 둔다(모의 초당 2건 제한 대비).
|
||||
- 체결 0 · 미종결 부분체결은 갱신하지 않는다(상태 변경 없음 — 수동 확인 대상, warning).
|
||||
- SELL 손익은 기록 시점 추정치에서 진입가를 역산해 체결가로 재계산한다:
|
||||
p(추정가) = total_amount / order_quantity,
|
||||
entry = p − (net_profit_loss + commission) / order_quantity
|
||||
(모든 SELL 기록 경로가 total_amount=추정가×수량, net = (추정가−진입가)×수량 − commission
|
||||
으로 기록한다는 전제. 전제가 깨지면 손익은 갱신하지 않고 가격·금액만 갱신한다.)
|
||||
- [하드닝 R1 2026-10-09] 미확인 판정은 `unconfirmed_fill_clause_v2`(NULL + 레거시 '체결가=주문가 &
|
||||
slippage NULL'). BUY 의 ActiveTrade 진입가 보정은 entry_reason 으로 확정한 전략명(동등)만 —
|
||||
확정 불가면 보정하지 않고 warning. 일일 지표는 `get_fill_sweep_health_v2()`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime, time as dtime, timedelta
|
||||
|
||||
from sqlalchemy import select, or_, and_
|
||||
|
||||
from app.core.timeutil import now_kst
|
||||
from app.models.models import ActiveTrade, TradeHistory
|
||||
from app.services.utils import _is_domestic
|
||||
from app.services.db_worker import DT_STRATEGY_NAMES, not_dt_strategy_clause
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 사이클당 최대 KIS 체결조회 수 / 주문 후 최소 경과(직후 확인 태스크와 경합 방지) /
|
||||
# 같은 행 재조회 최소 간격 / 행별 최대 시도
|
||||
FILL_SWEEP_MAX_LOOKUPS_PER_CYCLE = 2
|
||||
FILL_SWEEP_MIN_AGE_SEC = 120
|
||||
FILL_SWEEP_RETRY_INTERVAL_SEC = 180
|
||||
FILL_SWEEP_MAX_ATTEMPTS = 5
|
||||
|
||||
# [하드닝 R4 2026-10-09] 종가 단일가(15:20~15:30) 주문 체결 확인.
|
||||
# 동시호가 주문은 15:30 에야 체결되는데 sweep 은 장중(is_market_open, 15:30 미만)에만 돌았다 →
|
||||
# 15:28 주문은 sweep 기회 0회로 executed_price 가 영구 NULL 이었다(운영 10/7 스윙 BUY 2건 15:22·15:28).
|
||||
# - 장 마감 직후 창(15:30 ~ +SWING_FILL_SWEEP_POST_CLOSE_MINUTES)에 스케줄러의 장외 분기가
|
||||
# `run_post_close_fill_sweep_v2` 로 같은 sweep 을 돌린다(당일 행 그대로 — 같은 날짜 조회).
|
||||
# - 동시호가 구간에 들어온 주문은 15:30 전엔 체결될 수 없으므로 조회하지 않는다(시도 횟수 보존).
|
||||
_KRX_CLOSING_AUCTION_START = dtime(15, 20)
|
||||
_KRX_MARKET_CLOSE = dtime(15, 30)
|
||||
_DEFAULT_POST_CLOSE_MINUTES = 30
|
||||
_DEFAULT_POST_CLOSE_INTERVAL_SEC = 60.0
|
||||
_DEFAULT_POST_CLOSE_TIMEOUT_SEC = 60.0
|
||||
|
||||
|
||||
def _settings_num_v2(name: str, default: float) -> float:
|
||||
try:
|
||||
from app.core.config import settings
|
||||
v = float(getattr(settings, name, default))
|
||||
except Exception as e:
|
||||
logger.warning(f"[FillSweep] 설정 {name} 읽기 실패 — 기본값 {default}: {type(e).__name__}: {e}")
|
||||
return float(default)
|
||||
return v if v > 0 else float(default)
|
||||
|
||||
|
||||
def _post_close_window_v2(now: datetime) -> tuple[datetime, datetime]:
|
||||
start = now.replace(hour=_KRX_MARKET_CLOSE.hour, minute=_KRX_MARKET_CLOSE.minute,
|
||||
second=0, microsecond=0)
|
||||
minutes = _settings_num_v2("SWING_FILL_SWEEP_POST_CLOSE_MINUTES", _DEFAULT_POST_CLOSE_MINUTES)
|
||||
return start, start + timedelta(minutes=minutes)
|
||||
|
||||
|
||||
def post_close_sweep_active_v2(now: datetime | None = None) -> bool:
|
||||
"""KRX 영업일 15:30 ~ 15:30+N분(장 마감 직후 체결 확인 창) 여부."""
|
||||
from app.core.market_schedule import is_krx_holiday
|
||||
now = now or now_kst()
|
||||
if now.weekday() >= 5 or is_krx_holiday(now.date()):
|
||||
return False
|
||||
start, end = _post_close_window_v2(now)
|
||||
return start <= now < end
|
||||
|
||||
|
||||
def _auction_fill_pending_v2(created_at, now: datetime) -> bool:
|
||||
"""동시호가(15:20~) 당일 주문인데 아직 15:30 전 — 체결될 수 없으므로 조회 보류."""
|
||||
if created_at is None or not hasattr(created_at, "time"):
|
||||
return False
|
||||
try:
|
||||
same_day = created_at.date() == now.date()
|
||||
except Exception as e:
|
||||
logger.warning(f"[FillSweep] created_at 비교 실패: {type(e).__name__}: {e}")
|
||||
return False
|
||||
return same_day and created_at.time() >= _KRX_CLOSING_AUCTION_START and now.time() < _KRX_MARKET_CLOSE
|
||||
|
||||
|
||||
async def run_post_close_fill_sweep_v2(now: datetime | None = None) -> float | None:
|
||||
"""장 마감 직후 체결 확인 1회 (스윙 스케줄러 장외 분기에서 호출).
|
||||
|
||||
창 밖이면 None(호출부는 기존 대기 그대로). 창 안이면 sweep 을 돌리고 다음 호출까지의
|
||||
권장 대기 상한(초)을 반환한다 — 장외 분기의 최대 1800초 대기가 창을 건너뛰지 않게.
|
||||
DB 는 SELECT 전용 세션, 쓰기는 db_worker 큐(sweep 본체와 동일). 예외·시간초과는 warning
|
||||
후 흡수한다(체결가 보정은 리스크 가드가 아니다 — 다음 호출에서 재시도).
|
||||
"""
|
||||
now = now or now_kst()
|
||||
if not post_close_sweep_active_v2(now):
|
||||
return None
|
||||
_, end = _post_close_window_v2(now)
|
||||
interval = _settings_num_v2("SWING_FILL_SWEEP_POST_CLOSE_INTERVAL_SEC", _DEFAULT_POST_CLOSE_INTERVAL_SEC)
|
||||
timeout = _settings_num_v2("SWING_FILL_SWEEP_TIMEOUT_SEC", _DEFAULT_POST_CLOSE_TIMEOUT_SEC)
|
||||
try:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
# DB-READ-EXCEPTION: 장 마감 직후 체결가 미확인 TradeHistory/ActiveTrade SELECT 전용(commit 없음, 갱신은 db_task_queue 경유) — CLAUDE.md §3.2 ③-b 스윙 스케줄러 SELECT
|
||||
async with AsyncSessionLocal() as session:
|
||||
n = await asyncio.wait_for(sweep_unconfirmed_fills_v2(session), timeout=timeout)
|
||||
if n:
|
||||
logger.info(f"[FillSweep] 장 마감 후 체결가 보정 {n}건")
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(f"[FillSweep] 장 마감 후 체결가 보정 {timeout:.0f}초 초과 — 다음 호출에서 재시도")
|
||||
except Exception as e:
|
||||
logger.warning(f"[FillSweep] 장 마감 후 체결가 보정 실패: {type(e).__name__}: {e}")
|
||||
remain = (end - now).total_seconds()
|
||||
return max(15.0, min(interval, remain))
|
||||
|
||||
|
||||
# {trade_id: (attempts, last_try_monotonic)} — 당일 한정(날짜가 바뀌면 비운다)
|
||||
_attempts: dict[int, tuple[int, float]] = {}
|
||||
_attempts_day: str = ""
|
||||
# [하드닝 R5 2026-10-09] 당일 갱신을 이미 큐잉한 TradeHistory id. 워커 반영 전(큐 지연·DLQ)에는 행이
|
||||
# 여전히 '미확인'으로 조회되므로, 재조회·재큐잉(특히 비멱등 진입가 보정)을 막기 위해 당일 제외한다.
|
||||
_queued_ids: set[int] = set()
|
||||
|
||||
|
||||
def _reset_if_new_day(day_iso: str) -> None:
|
||||
global _attempts_day
|
||||
if _attempts_day != day_iso:
|
||||
_attempts.clear()
|
||||
_queued_ids.clear()
|
||||
_attempts_day = day_iso
|
||||
|
||||
|
||||
def _exhausted_ids() -> list[int]:
|
||||
return [tid for tid, (n, _) in _attempts.items() if n >= FILL_SWEEP_MAX_ATTEMPTS]
|
||||
|
||||
|
||||
def _due(trade_id: int, now_mono: float) -> bool:
|
||||
n, last = _attempts.get(trade_id, (0, 0.0))
|
||||
if n >= FILL_SWEEP_MAX_ATTEMPTS:
|
||||
return False
|
||||
return n == 0 or (now_mono - last) >= FILL_SWEEP_RETRY_INTERVAL_SEC
|
||||
|
||||
|
||||
def _note_attempt(trade_id: int, now_mono: float) -> int:
|
||||
n, _ = _attempts.get(trade_id, (0, 0.0))
|
||||
n += 1
|
||||
_attempts[trade_id] = (n, now_mono)
|
||||
return n
|
||||
|
||||
|
||||
def derive_sell_entry_price_v2(row) -> float | None:
|
||||
"""기록 시점 추정치에서 진입가를 역산 (순수 함수). 불가하면 None."""
|
||||
try:
|
||||
q = int(row.order_quantity or 0)
|
||||
amt = float(row.total_amount or 0)
|
||||
if row.net_profit_loss is None or q <= 0 or amt <= 0:
|
||||
return None
|
||||
p = amt / q
|
||||
entry = p - (float(row.net_profit_loss) + float(row.commission or 0)) / q
|
||||
except (TypeError, ValueError, ZeroDivisionError):
|
||||
return None
|
||||
return entry if entry > 0 else None
|
||||
|
||||
|
||||
# 진입가 보정 대상 TradeHistory.strategy_type — 스윙 소유만(DT 는 fill_reconciler, 수동·유입은
|
||||
# ActiveTrade 진입가를 이 주문가로 기록하지 않는다).
|
||||
_SWING_BUY_STRATEGY_TYPES = (None, "", "swing")
|
||||
|
||||
|
||||
# [하드닝 R1 2026-10-09] 시딩(동기화 유입) ActiveTrade 식별 — swing/exit_service 와 같은 규약.
|
||||
# 시딩 행의 entry_price 는 KIS 평단이라 주문가→체결가 보정 대상이 아니다.
|
||||
_SEEDED_STRATEGY_NAMES = ("synced",)
|
||||
_SEEDED_ENTRY_REASON_PREFIXES = ("동기화 유입", "외부 유입 포지션")
|
||||
|
||||
|
||||
def _is_seeded_active_row_v2(r) -> bool:
|
||||
name = (getattr(r, "strategy_name", None) or "").strip()
|
||||
reason = (getattr(r, "entry_reason", None) or "").strip()
|
||||
return name in _SEEDED_STRATEGY_NAMES or reason.startswith(_SEEDED_ENTRY_REASON_PREFIXES)
|
||||
|
||||
|
||||
def resolve_entry_fill_target_v2(row, active_rows) -> tuple[str | None, str]:
|
||||
"""BUY TradeHistory 행이 만든(갱신한) 스윙 ActiveTrade 의 strategy_name 을 확정 (순수 함수).
|
||||
|
||||
[하드닝 R1 2026-10-09] 이전에는 `strategy_name_not=DT` 로 '가장 최근 비-DT active 행'을
|
||||
보정했다. 같은 종목에 시딩('synced') 행이나 다른 듀얼 전략 행이 있으면 엉뚱한 행의
|
||||
entry_price 가 (체결가−주문가)×수량/행수량 만큼 틀어졌다(운영: 103140·005380 은 스윙 행이
|
||||
이미 closed 이고 synced 행만 active — 구 경로라면 synced 행을 보정했다).
|
||||
|
||||
매칭: 진입 시 executor 가 TradeHistory·ActiveTrade 양쪽에 **같은 entry_reason** 을 기록한다
|
||||
(추가매수도 ActiveTrade.entry_reason 을 최신 매수 사유로 덮어쓴다). 따라서
|
||||
1) 비-DT·비시딩·strategy_name 있는 active 행 중 entry_reason 이 같은 행을 찾고
|
||||
2) 그 strategy_name 이 하나로 확정되며
|
||||
3) db_worker 가 그 전략명으로 고를 행(같은 전략명 중 id 최대)이 바로 그 행일 때만
|
||||
전략명을 돌려준다. 하나라도 어긋나면 (None, 사유) — 보정하지 않는다(확정 불가 시 보정 금지).
|
||||
|
||||
Returns:
|
||||
(strategy_name | None, 사유 문자열)
|
||||
"""
|
||||
reason = (getattr(row, "entry_reason", None) or "").strip()
|
||||
if not reason:
|
||||
return None, "TradeHistory.entry_reason 없음"
|
||||
eligible = [
|
||||
r for r in (active_rows or [])
|
||||
if (getattr(r, "strategy_name", None) or "").strip()
|
||||
and (getattr(r, "strategy_name", None) or "") not in DT_STRATEGY_NAMES
|
||||
and not _is_seeded_active_row_v2(r)
|
||||
]
|
||||
matched = [r for r in eligible if (getattr(r, "entry_reason", None) or "").strip() == reason]
|
||||
if not matched:
|
||||
return None, "entry_reason 일치하는 스윙 소유 active 행 없음(청산됐거나 사유 갱신됨)"
|
||||
names = {r.strategy_name for r in matched}
|
||||
if len(names) != 1:
|
||||
return None, f"entry_reason 일치 행의 전략명이 여러 개 {sorted(names)}"
|
||||
name = next(iter(names))
|
||||
same_name = [r for r in eligible if r.strategy_name == name]
|
||||
newest = max(same_name, key=lambda r: int(getattr(r, "id", 0) or 0))
|
||||
if newest not in matched:
|
||||
return None, f"전략명 {name} 의 최신 active 행(id={getattr(newest, 'id', None)})이 이 매수의 행이 아님"
|
||||
return name, "ok"
|
||||
|
||||
|
||||
async def _load_active_rows_for_fill_v2(db, stock_code: str):
|
||||
"""SELECT 전용 — 호출부 세션(스윙 스케줄러 exit_session, DB-READ-EXCEPTION)으로 읽는다."""
|
||||
return (await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active",
|
||||
not_dt_strategy_clause(ActiveTrade.strategy_name),
|
||||
)
|
||||
)).scalars().all()
|
||||
|
||||
|
||||
async def _enqueue_buy_entry_adjust_v2(row, filled_qty: int, filled_price: float, db=None) -> bool:
|
||||
"""스윙 BUY 체결 확인 시 **이 매수가 만든** ActiveTrade.entry_price 를 체결가로 보정 (큐 경유).
|
||||
|
||||
직후 확인(`_verify_and_sync_execution_v3`)이 이미 보정을 큐잉한 주문이면 건너뛴다(비멱등).
|
||||
대상 행은 `resolve_entry_fill_target_v2` 로 전략명을 확정해 `strategy_name`(동등) 으로 지정한다.
|
||||
확정할 수 없으면(db 없음 포함) 보정하지 않고 warning 만 남긴다 — 엉뚱한 행(시딩·타 전략)의
|
||||
진입가를 움직이면 손절선·손익이 틀어지기 때문.
|
||||
"""
|
||||
from app.services.trade_executor import entry_fill_already_adjusted_v2, note_entry_fill_adjusted_v2
|
||||
from app.services.db_worker import enqueue_active_trade_upsert
|
||||
|
||||
if getattr(row, "strategy_type", None) not in _SWING_BUY_STRATEGY_TYPES:
|
||||
return False
|
||||
if entry_fill_already_adjusted_v2(row.order_id):
|
||||
return False
|
||||
order_price = float(row.order_price or 0)
|
||||
if order_price <= 0 or filled_price <= 0 or filled_qty <= 0:
|
||||
return False
|
||||
if db is None:
|
||||
logger.warning(
|
||||
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} 진입가 보정 생략 — "
|
||||
f"ActiveTrade 조회 세션 없음(대상 행 확정 불가)"
|
||||
)
|
||||
return False
|
||||
try:
|
||||
active_rows = await _load_active_rows_for_fill_v2(db, row.stock_code)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} 진입가 보정 생략 — "
|
||||
f"ActiveTrade 조회 실패: {type(e).__name__}: {e}"
|
||||
)
|
||||
return False
|
||||
target, why = resolve_entry_fill_target_v2(row, active_rows)
|
||||
if not target:
|
||||
logger.warning(
|
||||
f"[FillSweep] {row.stock_code} #{getattr(row, 'id', None)} ActiveTrade 진입가 보정 생략 — "
|
||||
f"{why} (주문가 {order_price:,.2f} → 체결가 {float(filled_price):,.2f}, 수동 확인)"
|
||||
)
|
||||
return False
|
||||
# [하드닝 R5 2026-10-09] 큐잉 **전에** 보정 완료로 표시한다(trade_executor 직후 확인 경로와 동일 순서).
|
||||
# entry_fill_adjust 는 비멱등(entry_price += 차이)이라, 태스크가 큐에 머무는/DLQ 재시도 중인 동안
|
||||
# 다음 sweep 이 같은 주문을 다시 보정하면 진입가·손절선이 누적 이동한다. 표시 후 큐잉이 실패하면
|
||||
# 보정이 유실될 뿐(손절선 불변) 이중 이동은 일어나지 않는다 — 더 안전한 쪽.
|
||||
note_entry_fill_adjusted_v2(row.order_id)
|
||||
await enqueue_active_trade_upsert({
|
||||
"stock_code": row.stock_code,
|
||||
"strategy_name": target,
|
||||
"entry_fill_adjust": {
|
||||
"order_price": order_price,
|
||||
"filled_price": float(filled_price),
|
||||
"filled_qty": int(filled_qty),
|
||||
},
|
||||
})
|
||||
return True
|
||||
|
||||
|
||||
def unconfirmed_fill_clause_v2():
|
||||
"""'체결가 미확인' TradeHistory 판정식 (모니터링 지표와 sweep 이 공유하는 단일 정의)."""
|
||||
return or_(
|
||||
TradeHistory.executed_price.is_(None),
|
||||
and_(
|
||||
TradeHistory.slippage.is_(None),
|
||||
TradeHistory.order_price.isnot(None),
|
||||
TradeHistory.executed_price == TradeHistory.order_price,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# 일일 모니터링 지표 — 마지막 sweep 기준(프로세스 메모리, /health·로그용)
|
||||
_last_stats: dict = {"day": "", "candidates": 0, "exhausted": 0, "queued": 0, "exhausted_warned": 0}
|
||||
|
||||
|
||||
def get_fill_sweep_health_v2() -> dict:
|
||||
"""당일 체결 미확인 지표: candidates=마지막 sweep 이 본 미확인 행 수(상한 20),
|
||||
exhausted=최대 시도 소진(수동 확인 대상) 행 수, queued=당일 보정 큐잉 누계."""
|
||||
return dict(_last_stats)
|
||||
|
||||
|
||||
async def sweep_unconfirmed_fills_v2(db) -> int:
|
||||
"""체결가 미확인 행 보정. 반환 = 갱신 큐잉 건수.
|
||||
|
||||
호출부는 SELECT 전용 세션을 넘긴다(commit 없음).
|
||||
"""
|
||||
from app.services.korea_investment_api import korea_investment_api
|
||||
from app.services.trade_executor import _fill_update_fields_v2
|
||||
from app.services.db_worker import enqueue_update_trade_history
|
||||
|
||||
now = now_kst()
|
||||
_reset_if_new_day(now.date().isoformat())
|
||||
day_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
# created_at 은 naive KST 로 저장된다(now_kst 기본값과 동일 규약)
|
||||
cutoff = now - timedelta(seconds=FILL_SWEEP_MIN_AGE_SEC)
|
||||
if getattr(day_start, "tzinfo", None) is not None:
|
||||
day_start = day_start.replace(tzinfo=None)
|
||||
cutoff = cutoff.replace(tzinfo=None)
|
||||
|
||||
if _last_stats.get("day") != now.date().isoformat():
|
||||
_last_stats.update(day=now.date().isoformat(), candidates=0, exhausted=0, queued=0,
|
||||
exhausted_warned=0)
|
||||
rows = (await db.execute(
|
||||
select(TradeHistory)
|
||||
.where(
|
||||
TradeHistory.status == "COMPLETED",
|
||||
# [하드닝 R1 2026-10-09] 미확인 = 체결가 NULL(현 규약) 또는 레거시 규약
|
||||
# (체결가=주문가 대체 + slippage NULL — 체결 확인은 항상 slippage 를 채운다).
|
||||
# 운영 10/1~: 스윙 SELL 15/15·BUY 3/12 가 후자로 남아 손익·슬리피지가 주문가 기준이었다.
|
||||
unconfirmed_fill_clause_v2(),
|
||||
TradeHistory.order_id.isnot(None),
|
||||
TradeHistory.order_id != "",
|
||||
TradeHistory.created_at >= day_start,
|
||||
TradeHistory.created_at <= cutoff,
|
||||
or_(TradeHistory.currency_code.is_(None), TradeHistory.currency_code == "KRW"),
|
||||
TradeHistory.order_type.in_(("BUY", "SELL")),
|
||||
# DT 체결 보정은 daytrading/fill_reconciler 소관 — 이중 보정 방지(NULL 은 포함)
|
||||
or_(TradeHistory.strategy_type.is_(None),
|
||||
TradeHistory.strategy_type.notin_(DT_STRATEGY_NAMES)),
|
||||
# 시도 소진 행이 상위 20건을 영구 점유해 새 행이 밀리지 않도록 제외
|
||||
# 큐잉 완료(워커 반영 대기) 행도 제외 — 재보정·상위 20건 점유 방지
|
||||
TradeHistory.id.notin_((_exhausted_ids() + sorted(_queued_ids)) or [-1]),
|
||||
)
|
||||
.order_by(TradeHistory.created_at.asc())
|
||||
.limit(20)
|
||||
)).scalars().all()
|
||||
_last_stats["candidates"] = len(rows)
|
||||
_last_stats["exhausted"] = len(_exhausted_ids())
|
||||
if _last_stats["exhausted"] > int(_last_stats.get("exhausted_warned", 0)):
|
||||
# 소진 건수가 늘 때만 경보(2분 주기 로그 반복 방지)
|
||||
_last_stats["exhausted_warned"] = _last_stats["exhausted"]
|
||||
logger.warning(
|
||||
f"[FillSweep] 체결 미확인 시도 소진 {_last_stats['exhausted']}건(당일) — "
|
||||
f"executed_price/slippage 수동 확인 필요: ids={sorted(_exhausted_ids())[:10]}"
|
||||
)
|
||||
if not rows:
|
||||
return 0
|
||||
|
||||
mono = time.monotonic()
|
||||
lookups = 0
|
||||
queued = 0
|
||||
for row in rows:
|
||||
if lookups >= FILL_SWEEP_MAX_LOOKUPS_PER_CYCLE:
|
||||
break
|
||||
if row.id in _queued_ids or not _due(row.id, mono):
|
||||
continue
|
||||
if _auction_fill_pending_v2(getattr(row, "created_at", None), now):
|
||||
continue # 종가 동시호가 주문 — 15:30 체결 전 조회는 시도만 소진한다(장 마감 후 창에서 확인)
|
||||
if not _is_domestic(row.stock_code):
|
||||
continue # 국내 체결조회 전용(해외는 currency_code 가 비어 있는 행이 있어 코드로도 거른다)
|
||||
lookups += 1
|
||||
n = _note_attempt(row.id, mono)
|
||||
info = await korea_investment_api.get_order_execution(str(row.order_id), True)
|
||||
filled_qty = int((info or {}).get("filled_qty", 0) or 0)
|
||||
filled_price = float((info or {}).get("filled_price", 0) or 0)
|
||||
remaining = (info or {}).get("remaining_qty")
|
||||
ordered = int(row.order_quantity or 0)
|
||||
|
||||
if not (info or {}).get("ok"):
|
||||
logger.warning(
|
||||
f"[FillSweep] {row.stock_code} #{row.id} 체결조회 실패 ({n}/{FILL_SWEEP_MAX_ATTEMPTS}) "
|
||||
f"order_id={row.order_id}"
|
||||
)
|
||||
continue
|
||||
if filled_qty <= 0 or filled_price <= 0:
|
||||
logger.warning(
|
||||
f"[FillSweep] {row.stock_code} #{row.id} {row.order_type} 체결 미확인 "
|
||||
f"(체결 {filled_qty}주, 주문행발견={info.get('found')}, 잔량={remaining}) "
|
||||
f"({n}/{FILL_SWEEP_MAX_ATTEMPTS}) — 갱신 안 함, 소진 시 수동 확인"
|
||||
)
|
||||
continue
|
||||
if filled_qty < ordered and remaining != 0:
|
||||
logger.warning(
|
||||
f"[FillSweep] {row.stock_code} #{row.id} 부분체결 {filled_qty}/{ordered}주 미종결 "
|
||||
f"(잔량={remaining}) — 종결 후 재시도"
|
||||
)
|
||||
continue
|
||||
filled_qty = min(filled_qty, ordered) if ordered > 0 else filled_qty
|
||||
|
||||
ref = float(row.order_price or 0) or (float(row.total_amount or 0) / ordered if ordered else 0.0)
|
||||
slippage = ((filled_price - ref) / ref * 100) if ref > 0 else 0.0
|
||||
basis = None
|
||||
if row.order_type == "SELL":
|
||||
entry = derive_sell_entry_price_v2(row)
|
||||
if entry is not None:
|
||||
basis = {
|
||||
"entry_price": entry,
|
||||
"market": "KOSPI",
|
||||
"is_domestic": True,
|
||||
"net_rate": False,
|
||||
}
|
||||
else:
|
||||
logger.warning(
|
||||
f"[FillSweep] {row.stock_code} #{row.id} SELL 진입가 역산 불가 — 체결가·금액만 갱신"
|
||||
)
|
||||
fields = _fill_update_fields_v2(row.order_type, filled_qty, filled_price, slippage, basis)
|
||||
fields.pop("executed_at", None) # 실제 체결 시각을 모른다 — 기존 값 유지
|
||||
# 큐잉 전에 표시 — 이후 단계가 예외로 빠져도 같은 행을 당일 다시 보정하지 않는다
|
||||
_queued_ids.add(row.id)
|
||||
_attempts.pop(row.id, None)
|
||||
await enqueue_update_trade_history(row.id, fields)
|
||||
if row.order_type == "BUY":
|
||||
await _enqueue_buy_entry_adjust_v2(row, filled_qty, filled_price, db)
|
||||
queued += 1
|
||||
_last_stats["queued"] = int(_last_stats.get("queued", 0)) + 1
|
||||
logger.info(
|
||||
f"[FillSweep] {row.stock_code} #{row.id} {row.order_type} 체결 확인 — "
|
||||
f"{filled_qty}주 @ {filled_price:,.2f} (기준가 {ref:,.2f}, 슬리피지 {slippage:+.3f}%)"
|
||||
)
|
||||
return queued
|
||||
@@ -44,7 +44,134 @@ async def get_swing_holdings(db: AsyncSession) -> list:
|
||||
Portfolio.strategy_type == STRATEGY_TYPE,
|
||||
)
|
||||
)
|
||||
return result.scalars().all()
|
||||
holdings = result.scalars().all()
|
||||
# [2026-10-09 하드닝 R1] 어느 청산 엔진에도 속하지 않는 보유분 탐지(경보 전용).
|
||||
# 경보는 리스크 가드가 아니므로 실패해도 청산 대상 반환을 막지 않는다(청산 우선 §8.1-11).
|
||||
try:
|
||||
await detect_strategy_tag_mismatch_v2(db)
|
||||
except Exception as e:
|
||||
logger.warning(f"[SwingPM] 전략 태그 불일치 탐지 실패(청산 대상 조회는 계속): {e}")
|
||||
return holdings
|
||||
|
||||
|
||||
# ── [2026-10-09 하드닝 R1] Portfolio 태그 ↔ ActiveTrade 전략명 불일치 탐지 ─────────
|
||||
# 사례: 105560 3주가 Portfolio.strategy_type='daytrading' + active ActiveTrade
|
||||
# strategy_name='synced'(비-DT). get_swing_holdings 는 'swing' 태그만 보므로 스윙 청산이
|
||||
# 보지 않고, DT 엔진은 인메모리 포지션만 관리하므로(당일 매수분은 EOD 청산) 잔여 수량에는
|
||||
# **손절이 걸려 있지 않다**. 자동 편입(스윙 청산 대상으로 승격)은 과다매도·소유권 규칙
|
||||
# (trade_executor._sell_ownership_block_reason_v2) 충돌 위험이 있어 하지 않고, error 경보로
|
||||
# 운영자가 KIS 실보유를 확인하게 한다.
|
||||
def find_strategy_tag_mismatches_v2(
|
||||
portfolio_rows,
|
||||
active_rows,
|
||||
dt_inmemory_qty: dict | None = None,
|
||||
) -> list[dict]:
|
||||
"""순수 함수 — DT 태그 Portfolio 행 중 비-DT active ActiveTrade 가 걸려 있고,
|
||||
DT 인메모리 수량이 보유 수량을 덮지 못하는(= 관리 주체 없는 수량이 있는) 종목.
|
||||
|
||||
Args:
|
||||
portfolio_rows: Portfolio 유사 객체(stock_code, quantity, strategy_type)
|
||||
active_rows: status='active' ActiveTrade 유사 객체(stock_code, strategy_name, id)
|
||||
dt_inmemory_qty: {stock_code: DT 인메모리 보유 수량}
|
||||
|
||||
Returns:
|
||||
[{stock_code, portfolio_qty, portfolio_tag, dt_inmemory_qty, unmanaged_qty,
|
||||
active_trade_ids, active_strategy_names}]
|
||||
"""
|
||||
dt_mem = dt_inmemory_qty or {}
|
||||
non_dt_active: dict[str, list] = {}
|
||||
for a in active_rows or []:
|
||||
name = getattr(a, "strategy_name", None)
|
||||
if name in DT_STRATEGY_NAMES:
|
||||
continue
|
||||
non_dt_active.setdefault(getattr(a, "stock_code", None), []).append(a)
|
||||
|
||||
out: list[dict] = []
|
||||
for p in portfolio_rows or []:
|
||||
if getattr(p, "strategy_type", None) not in DT_STRATEGY_NAMES:
|
||||
continue
|
||||
try:
|
||||
qty = int(getattr(p, "quantity", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
qty = 0
|
||||
if qty <= 0:
|
||||
continue
|
||||
code = getattr(p, "stock_code", None)
|
||||
rows = non_dt_active.get(code)
|
||||
if not rows:
|
||||
continue
|
||||
try:
|
||||
mem = max(0, int(dt_mem.get(code, 0) or 0))
|
||||
except (TypeError, ValueError):
|
||||
mem = 0
|
||||
unmanaged = qty - min(mem, qty)
|
||||
if unmanaged <= 0:
|
||||
continue
|
||||
out.append({
|
||||
"stock_code": code,
|
||||
"portfolio_qty": qty,
|
||||
"portfolio_tag": getattr(p, "strategy_type", None),
|
||||
"dt_inmemory_qty": mem,
|
||||
"unmanaged_qty": unmanaged,
|
||||
"active_trade_ids": [getattr(r, "id", None) for r in rows],
|
||||
"active_strategy_names": [getattr(r, "strategy_name", None) for r in rows],
|
||||
})
|
||||
return out
|
||||
|
||||
|
||||
def _dt_inmemory_qty_snapshot_v2() -> dict:
|
||||
"""DT 인메모리 보유 수량 스냅샷(읽기 전용). DT 상태를 못 읽으면 빈 dict —
|
||||
그 경우 DT 태그 수량 전부를 '관리 주체 없음'으로 보아 경보 쪽으로 기운다."""
|
||||
try:
|
||||
from app.services.daytrading.position_manager import day_trading_state
|
||||
return {
|
||||
code: int((pos or {}).get("quantity", 0) or 0)
|
||||
for code, pos in dict(day_trading_state.positions).items()
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"[SwingPM] DT 인메모리 수량 조회 실패 — 빈 스냅샷으로 판정: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
async def detect_strategy_tag_mismatch_v2(db: AsyncSession) -> list[dict]:
|
||||
"""DB SELECT 2회(쓰기 없음) + DT 인메모리 대조 → 불일치 종목을 error 로그(종목별 레이트리밋).
|
||||
|
||||
반환값은 테스트·상태 조회용. 자동 수정은 하지 않는다(운영자 확인 필요).
|
||||
"""
|
||||
p_res = await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.quantity > 0,
|
||||
Portfolio.strategy_type.in_(DT_STRATEGY_NAMES),
|
||||
)
|
||||
)
|
||||
p_rows = p_res.scalars().all()
|
||||
if not p_rows:
|
||||
return []
|
||||
codes = [getattr(p, "stock_code", None) for p in p_rows]
|
||||
a_res = await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
ActiveTrade.status == "active",
|
||||
ActiveTrade.stock_code.in_(codes),
|
||||
not_dt_strategy_clause(ActiveTrade.strategy_name),
|
||||
)
|
||||
)
|
||||
a_rows = a_res.scalars().all()
|
||||
found = find_strategy_tag_mismatches_v2(p_rows, a_rows, _dt_inmemory_qty_snapshot_v2())
|
||||
if found:
|
||||
from app.core.log_throttle import should_log
|
||||
for m in found:
|
||||
ok, sup = should_log(f"swing_pm:tag_mismatch:{m['stock_code']}")
|
||||
if not ok:
|
||||
continue
|
||||
logger.error(
|
||||
f"[SwingPM] 🚨 {m['stock_code']}: Portfolio 태그 '{m['portfolio_tag']}' "
|
||||
f"{m['portfolio_qty']}주인데 비-DT active ActiveTrade "
|
||||
f"{m['active_strategy_names']}(id={m['active_trade_ids']}) 존재, DT 인메모리 "
|
||||
f"{m['dt_inmemory_qty']}주 → {m['unmanaged_qty']}주가 스윙·DT 어느 청산 엔진에도 "
|
||||
f"속하지 않아 손절 없음. KIS 실보유 확인 후 태그/ActiveTrade 수동 정리 필요"
|
||||
+ (f" (+{sup}건 억제)" if sup else "")
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
async def get_swing_active_trades(db: AsyncSession) -> list:
|
||||
@@ -122,3 +249,138 @@ async def count_swing_positions(db: AsyncSession) -> int:
|
||||
)
|
||||
)
|
||||
return result.scalar_one() or 0
|
||||
|
||||
|
||||
# ── [2026-10-08] 스윙 전략 진입가 (Portfolio 평단 혼합 방지) ──────────────────
|
||||
# Portfolio 행은 stock_code UNIQUE 라 같은 종목의 스윙+DT 수량·평단이 합산된다(§3.5).
|
||||
# KIS pchs_avg_pric 는 이동평균법이라 DT 가 전량 매도한 뒤에도 혼합 평단이 그대로 남는다
|
||||
# (예: 스윙 5주@68,000 + DT 8주@66,900 → 67,323 — DT 청산 후에도 67,323).
|
||||
# 따라서 스윙 손익·손절은 **스윙 소유 ActiveTrade.entry_price** 를 기준으로 한다.
|
||||
# ActiveTrade 가 오래된 유령 행이면 진입가가 엉뚱할 수 있으므로 Portfolio 평단과의 괴리가
|
||||
# 이 비율을 넘으면 **Portfolio 평단(KIS 실제 매입 평단)** 을 쓰고 warning 을 남긴다.
|
||||
#
|
||||
# [2026-10-09 하드닝 R1 / 검증 1회차 V1a M-2] 이전에는 괴리 > 30% 면 "높은 값"을 썼다.
|
||||
# 유령 entry 100,000 / 실평단 50,000 / 현재가 52,000 이면 -48% 로 판정돼 **허위 전량 손절**
|
||||
# + loss_tracker 허위 손실(3연패 → 42일 진입 금지)이 났다. 30% 괴리는 DT 혼합 평단으로는
|
||||
# 만들어지기 어렵고(혼합 평단은 두 매입가 사이에 있다) 유령/오염 행일 가능성이 압도적이므로,
|
||||
# 실제 돈이 걸린 KIS 평단을 기준으로 한다. 반대 방향(유령 entry 가 낮은 경우)도 KIS 평단이
|
||||
# 높으므로 손절이 늦춰지지 않는다 — 계좌 기준 손실이 그대로 손절선에 반영된다.
|
||||
SWING_ENTRY_PRICE_MAX_DEVIATION = 0.30
|
||||
|
||||
|
||||
def resolve_swing_entry_price_v2(
|
||||
active_trade_entry_price,
|
||||
portfolio_avg_price,
|
||||
stock_code: str = "",
|
||||
) -> tuple[float, str]:
|
||||
"""스윙 손익·손절 판정용 진입가 (순수 함수 — DB·네트워크 없음).
|
||||
|
||||
Returns:
|
||||
(price, basis) — basis:
|
||||
"active_trade" : 스윙 ActiveTrade.entry_price (정상)
|
||||
"portfolio_avg_deviation" : 두 값 괴리 > SWING_ENTRY_PRICE_MAX_DEVIATION →
|
||||
Portfolio 평단(KIS 실제 매입 평단). ActiveTrade 는 유령 의심.
|
||||
(2026-10-09 이전의 "conservative_max"=max(두 값) 는 폐기 —
|
||||
유령 고가 entry 로 허위 전량 손절을 냈다)
|
||||
"portfolio_avg" : ActiveTrade 진입가 없음 → Portfolio 평단(혼합 가능) 폴백
|
||||
"none" : 둘 다 없음(0) — 호출부가 판정을 건너뛴다
|
||||
"""
|
||||
try:
|
||||
at = float(active_trade_entry_price or 0)
|
||||
except (TypeError, ValueError):
|
||||
at = 0.0
|
||||
try:
|
||||
pa = float(portfolio_avg_price or 0)
|
||||
except (TypeError, ValueError):
|
||||
pa = 0.0
|
||||
|
||||
if at > 0 and pa > 0:
|
||||
dev = abs(at / pa - 1.0)
|
||||
if dev <= SWING_ENTRY_PRICE_MAX_DEVIATION + 1e-9: # 부동소수 경계(정확히 30%) 포함
|
||||
return at, "active_trade"
|
||||
from app.core.log_throttle import warn_throttled
|
||||
warn_throttled(
|
||||
logger, f"swing_entry_price:{stock_code}",
|
||||
f"[SwingEntryPrice] {stock_code}: ActiveTrade 진입가 {at:,.2f} 와 Portfolio 평단 "
|
||||
f"{pa:,.2f} 괴리 {dev:.1%} > {SWING_ENTRY_PRICE_MAX_DEVIATION:.0%} — 유령/오염 행 의심, "
|
||||
f"KIS 실제 평단 {pa:,.2f} 사용(ActiveTrade 수동 확인 필요)"
|
||||
)
|
||||
return pa, "portfolio_avg_deviation"
|
||||
if at > 0:
|
||||
return at, "active_trade"
|
||||
if pa > 0:
|
||||
return pa, "portfolio_avg"
|
||||
return 0.0, "none"
|
||||
|
||||
|
||||
# ── [2026-10-09 하드닝 R1] 스윙 SELL 의 출처(시스템 진입 vs 동기화 유입) 분리 ─────────
|
||||
# 운영 실측: swing SELL 중 선행 스윙 BUY 가 없는 매도 35건(pnl +62,789) vs 스윙 BUY 이후
|
||||
# 매도 30건(pnl -166,885). 유입분이 sync 로 'swing' 태그를 받아 청산되면 전략 성과에 섞여
|
||||
# 시스템 진입 신호의 품질이 가려진다. 통계 API 가 쓸 수 있도록 **원장 순수량** 기준으로
|
||||
# 매도 수량을 system/inflow 로 배분하는 순수 함수를 둔다(DB·네트워크 없음).
|
||||
_SELL_ORIGIN_EXCLUDED_STATUSES = frozenset({"FAILED", "CANCELLED", "CANCELED", "REJECTED"})
|
||||
|
||||
|
||||
def _row_get_v2(row, key, default=None):
|
||||
if isinstance(row, dict):
|
||||
return row.get(key, default)
|
||||
return getattr(row, key, default)
|
||||
|
||||
|
||||
def split_swing_sells_by_origin_v2(trades) -> list[dict]:
|
||||
"""스윙 TradeHistory 행들을 시간순으로 훑어 각 SELL 의 수량을 출처별로 나눈다.
|
||||
|
||||
규칙: 종목별 '시스템 보유' = 누적 스윙 BUY 체결수량 − 이미 system 으로 배분된 SELL 수량.
|
||||
SELL 은 시스템 보유분부터 소진하고(FIFO 무관, 수량만), 초과분은 inflow(동기화 유입)로 본다.
|
||||
strategy_type 이 swing(또는 NULL) 이 아닌 행, 실패·취소·거부 행은 무시한다.
|
||||
|
||||
Returns:
|
||||
SELL 마다 {id, stock_code, created_at, sell_qty, system_qty, inflow_qty,
|
||||
origin: "system"|"inflow"|"mixed"} (입력 시간순)
|
||||
"""
|
||||
rows = []
|
||||
for idx, t in enumerate(trades or []):
|
||||
st = _row_get_v2(t, "strategy_type")
|
||||
if st not in (None, STRATEGY_TYPE):
|
||||
continue
|
||||
status = str(_row_get_v2(t, "status") or "").upper()
|
||||
if status in _SELL_ORIGIN_EXCLUDED_STATUSES:
|
||||
continue
|
||||
side = str(_row_get_v2(t, "order_type") or "").upper()
|
||||
if side not in ("BUY", "SELL"):
|
||||
continue
|
||||
q = _row_get_v2(t, "executed_quantity")
|
||||
if q is None:
|
||||
q = _row_get_v2(t, "order_quantity")
|
||||
try:
|
||||
q = int(q or 0)
|
||||
except (TypeError, ValueError):
|
||||
q = 0
|
||||
if q <= 0:
|
||||
continue
|
||||
rows.append((_row_get_v2(t, "created_at"), idx, side, q, t))
|
||||
|
||||
# created_at 없는 행은 입력 순서를 유지(idx 보조키)
|
||||
rows.sort(key=lambda r: (r[0] is None, r[0] if r[0] is not None else 0, r[1]))
|
||||
|
||||
held: dict[str, int] = {}
|
||||
out: list[dict] = []
|
||||
for created_at, _idx, side, q, t in rows:
|
||||
code = _row_get_v2(t, "stock_code")
|
||||
if side == "BUY":
|
||||
held[code] = held.get(code, 0) + q
|
||||
continue
|
||||
sys_q = min(q, held.get(code, 0))
|
||||
held[code] = held.get(code, 0) - sys_q
|
||||
inflow_q = q - sys_q
|
||||
origin = "system" if inflow_q == 0 else ("inflow" if sys_q == 0 else "mixed")
|
||||
out.append({
|
||||
"id": _row_get_v2(t, "id"),
|
||||
"stock_code": code,
|
||||
"created_at": created_at,
|
||||
"sell_qty": q,
|
||||
"system_qty": sys_q,
|
||||
"inflow_qty": inflow_q,
|
||||
"origin": origin,
|
||||
})
|
||||
return out
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user