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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user