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:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -32,6 +32,7 @@ from datetime import datetime, timedelta
from app.core.timeutil import now_kst
from app.core.log_throttle import warn_throttled # noqa: E402
from app.core.background_tasks import spawn_tracked_v2 # noqa: E402
logger = logging.getLogger(__name__)
@@ -79,6 +80,26 @@ _SEEDED_AT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 동기화 시 자
# (PENDING 을 넣으면 실현손익이 오염된다) 여기는 "매수 시도 흔적이 있는가"로
# 의미가 다르다. 같은 상수로 묶으면 한쪽을 고칠 때 반대쪽이 조용히 망가진다.
_NON_FILL_BUY_STATUSES = ("FAILED",)
from app.core.trade_status import REALIZED_PNL_STATUSES # noqa: E402 (상태 집합 단일 출처)
# ── 팬텀 ActiveTrade close 유예 (2026-10-09) ─────────────────────────────
# 매수/분할매도 직후 KIS 잔고·Portfolio 반영이 늦으면 held=0 으로 보여 갓 만든 행이
# 즉시 closed 되고 'synced' 로 재시딩되어 remaining_pct·전략명을 잃었다.
_PHANTOM_CLOSE_GRACE_SEC = 300.0 # 행 생성/수정 후 이 시간 안에는 close 하지 않는다
_PHANTOM_CLOSE_CONFIRM_SYNCS = 2 # 연속 미관측 횟수
_PHANTOM_RECENT_TRADE_SEC = 1800.0 # 이 시간 내 체결(BUY/SELL)이 있으면 보류
_PHANTOM_INHERIT_WINDOW_SEC = 1800.0 # 재시딩 시 승계 대상 closed 행의 최대 경과
_PHANTOM_INHERIT_SELL_LOOKBACK_SEC = 120.0 # close 직전 이 시간 안의 SELL 도 그 close 의 매도로 본다
_PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE = 0.03 # 옛 진입가-현재 평단 괴리가 이를 넘으면 다른 포지션으로 본다
_INHERIT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 재시딩 승계: " # exit_service 시딩 표식 접두사와 동일 계열
_phantom_miss_state: dict = {} # {stock_code: 연속 미관측 횟수}
def _phantom_state_reset(stock_code: str = None) -> None:
if stock_code is None:
_phantom_miss_state.clear()
else:
_phantom_miss_state.pop(stock_code, None)
# ── 유입 잔고 강등(swing → external) 확정 규칙 ──────────────────────────────
# [2026-09-30] 사용자 결정: "스윙으로 편입하는 건 없어야 해" → 승격뿐 아니라 강등도
@@ -645,11 +666,127 @@ def _should_correct_active_trade_quantity(
return True
async def _recent_closed_non_dt_row_v2(db, stock_code: str, now=None):
"""같은 종목에서 최근(_PHANTOM_INHERIT_WINDOW_SEC 이내) closed 된 비-DT ActiveTrade 중 최신 1행. 없으면/실패 시 None.
읽기 전용 SELECT (sync_portfolio 의 기존 트랜잭션 안).
"""
try:
from app.services.db_worker import not_dt_strategy_clause
_n = now or _get_kst_now()
# 오승계 방지: 전량 청산 후 다른 경로로 재편입한 포지션이 옛 단계를 물려받지 않도록 짧게.
_since = _n - timedelta(seconds=_PHANTOM_INHERIT_WINDOW_SEC)
r = await db.execute(
select(ActiveTrade).where(
ActiveTrade.stock_code == stock_code,
ActiveTrade.status == "closed",
ActiveTrade.updated_at >= _since,
not_dt_strategy_clause(ActiveTrade.strategy_name),
).order_by(ActiveTrade.id.desc()).limit(1)
)
return r.scalars().first()
except Exception as e:
logger.warning(f"[ActiveTrade] 최근 closed 행 조회 실패({stock_code}): {e}")
return None
async def _closed_row_was_real_exit_v2(db, row, now=None):
"""closed 행이 실제 전량 청산(SELL 체결)으로 닫혔는가. True/False, 판정 불가면 None.
팬텀 close 는 SELL 없이 닫힌다(최근 체결이 있으면 close 자체가 유예됨). close 시각
(updated_at) 직전 _PHANTOM_INHERIT_SELL_LOOKBACK_SEC 이후의 비-DT COMPLETED SELL 수량
합이 행 수량 이상이면 실제 전량 청산 → 승계 대상이 아니다. 읽기 전용 SELECT.
"""
try:
from app.services.db_worker import not_dt_strategy_clause
_closed_at = getattr(row, "updated_at", None) or getattr(row, "created_at", None)
_qty = int(getattr(row, "quantity", 0) or 0)
if _closed_at is None or _qty <= 0:
return None
_lo = _closed_at - timedelta(seconds=_PHANTOM_INHERIT_SELL_LOOKBACK_SEC)
r = await db.execute(
select(TradeHistory.executed_quantity, TradeHistory.order_quantity).where(
TradeHistory.stock_code == row.stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status == "COMPLETED",
TradeHistory.created_at >= _lo,
not_dt_strategy_clause(TradeHistory.strategy_type),
)
)
_sold = 0
for _eq, _oq in r.all():
_sold += int(_eq if _eq is not None else (_oq or 0))
return _sold >= _qty
except Exception as e:
logger.warning(f"[ActiveTrade] closed 행 실청산 여부 조회 실패({getattr(row, 'stock_code', '?')}): {e}")
return None
async def _inheritable_closed_row_v3(db, stock_code: str, avg_price: float, now=None):
"""재시딩 승계 대상 행. 최근 closed 비-DT 행 중 (a) 실제 전량 청산이 아니고(판정 불가도 제외)
(b) 옛 진입가가 현재 평단과 크게 다르지 않은 것만. 없으면 None."""
_row = await _recent_closed_non_dt_row_v2(db, stock_code, now)
if _row is None:
return None
_real = await _closed_row_was_real_exit_v2(db, _row, now)
if _real is not False:
logger.warning(
f"[ActiveTrade] 재시딩 승계 건너뜀({stock_code}): 이전 행 id={_row.id} "
f"{'실제 전량 청산' if _real else '청산 여부 불명'} — 새 포지션으로 취급"
)
return None
_ep = float(getattr(_row, "entry_price", 0) or 0)
_avg = float(avg_price or 0)
if _ep > 0 and _avg > 0 and abs(_ep - _avg) / _avg > _PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE:
logger.warning(
f"[ActiveTrade] 재시딩 승계 건너뜀({stock_code}): 옛 진입가 {_ep:,.0f} 와 평단 {_avg:,.0f} 괴리 "
f"> {_PHANTOM_INHERIT_MAX_ENTRY_DIVERGENCE:.0%} — 다른 포지션"
)
return None
return _row
def _inherited_entry_reason_v2(reason: str) -> str:
"""승계 행의 entry_reason 에 시딩 표식 접두사를 붙인다 — created_at 이 재시딩 시각이므로
swing/exit_service._is_seeded_active_trade_v2 가 인식해 원 BUY 일자로 시계·승패를 보정하게 한다."""
if reason.startswith(("동기화 유입", "외부 유입 포지션")):
return reason
return _INHERIT_ENTRY_REASON_PREFIX + reason
def _phantom_close_defer_reason(code, row, recent_trade_codes, now=None):
"""보유 0 으로 보이는 단일 active 행을 지금 close 해도 되는가. 유예 사유 문자열 or None.
recent_trade_codes=None 은 "최근 체결 조회 실패" → 확인 불가이므로 보류(fail-safe).
"""
if recent_trade_codes is None:
return "recent_trades_unknown"
if code in recent_trade_codes:
return "recent_trade"
_now = now or _get_kst_now()
_ts = getattr(row, "updated_at", None) or getattr(row, "created_at", None)
if _ts is not None:
try:
if (_now - _ts).total_seconds() < _PHANTOM_CLOSE_GRACE_SEC:
return "row_recent"
except Exception as e: # tz 혼재 등 — 판단 불가는 보류
logger.warning(f"[ActiveTrade] 행 나이 계산 실패({code}): {e}")
return "row_age_unknown"
_n = _phantom_miss_state.get(code, 0) + 1
_phantom_miss_state[code] = _n
if _n < _PHANTOM_CLOSE_CONFIRM_SYNCS:
return f"miss_{_n}"
return None
def _reconcile_active_trade_quantities(
active_rows_by_code: dict,
held_qty: dict,
strategy_by_code: dict,
system_qty: dict = None,
phantom_guard: bool = False,
recent_trade_codes: set = None,
now: datetime = None,
) -> list:
"""active ActiveTrade 행을 실제 보유 수량에 맞춘다 (ORM 객체를 **제자리 수정**).
@@ -678,7 +815,19 @@ def _reconcile_active_trade_quantities(
for _code, _rows in (active_rows_by_code or {}).items():
_actual = int(held_qty.get(_code, 0) or 0)
_tag = strategy_by_code.get(_code, "swing")
if _actual > 0:
_phantom_miss_state.pop(_code, None)
if len(_rows) == 1 and _actual == 0:
if phantom_guard:
_defer = _phantom_close_defer_reason(
_code, _rows[0], recent_trade_codes, now
)
if _defer:
actions.append({"action": "close_deferred", "stock_code": _code,
"from": int(_rows[0].quantity or 0), "to": 0,
"reason": _defer})
continue
_phantom_miss_state.pop(_code, None)
_rows[0].status = "closed"
actions.append({"action": "closed", "stock_code": _code,
"from": int(_rows[0].quantity or 0), "to": 0})
@@ -799,6 +948,15 @@ def _calculate_portfolio_values(holdings: list) -> dict:
}
def _quantize_price_v2(value) -> Decimal:
"""가격 반올림 정책 — 소수 2자리 ROUND_HALF_UP (DB Numeric(15,2) 와 동일)."""
from decimal import ROUND_HALF_UP, InvalidOperation
try:
return Decimal(str(value or 0)).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
except (InvalidOperation, ValueError, TypeError):
return Decimal("0")
def _extract_balance_data(balance_data: dict) -> dict:
"""Extract cash and total assets from KIS API balance response.
@@ -898,13 +1056,107 @@ def _extract_overseas_balance(overseas_balance_data: dict) -> dict:
if isinstance(ovrs_out2, list):
ovrs_out2 = ovrs_out2[0] if ovrs_out2 else {}
# tot_evlu_amt: 해외주식 평가금액 합계 (원화)
overseas_total_krw = _pick_float(ovrs_out2, ["tot_evlu_amt", "frcr_evlu_tota"])
# [2026-10-08] frcr_evlu_tota 는 **외화(USD)** 평가금액이라 원화 폴백으로 쓰면 단위가 섞인다
# → 제거. 원화 값이 없으면 cache_summary 가 보유 USD × 환율로 환산한다.
overseas_total_krw = _pick_float(ovrs_out2, ["tot_evlu_amt"])
if overseas_total_krw > 0:
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
return {"overseas_total_krw": overseas_total_krw}
# ── [2026-10-08] USD/KRW 환율 해석 ────────────────────────────────────────────
# 이전엔 sync_portfolio 가 cache_summary(balance, overseas) 로 환율 인자를 넘기지 않아 항상 0 →
# 미국 보유 원화 환산이 0 이었다. 우선순위: KIS 응답에서 얻은 환율 → 마지막 성공값 →
# settings.US_DEFAULT_FX_RATE → (모두 없으면) 0 = 환산 생략 + exchange_rate_unavailable 플래그.
# 해외잔고(TTTS3012R) 응답에는 환율 필드가 없을 수 있어 실제로는 config 폴백이 흔하다.
_FX_RATE_SANE_MIN = 500.0
_FX_RATE_SANE_MAX = 3000.0
_last_good_fx_rate: float = 0.0
def _fx_from_overseas_balance(overseas_balance_data: dict | None) -> float:
"""해외잔고 응답에서 USD/KRW 환율 추출 (없으면 0). 순수 함수."""
if not isinstance(overseas_balance_data, dict):
return 0.0
out1 = overseas_balance_data.get("output1")
out2 = overseas_balance_data.get("output2")
if isinstance(out2, list):
out2 = out2[0] if out2 else {}
out2 = out2 if isinstance(out2, dict) else {}
rows = out1 if isinstance(out1, list) else []
# 1) 명시적 환율 필드
for src in [out2] + [r for r in rows if isinstance(r, dict)]:
v = _pick_float(src, ["bass_exrt", "frst_bltn_exrt", "exrt"])
if v > 0:
return v
# 2) 원화 총평가 / 외화 총액 역산 (두 값이 모두 있을 때만)
tot_krw = _pick_float(out2, ["tot_evlu_amt"])
usd = _pick_float(out2, ["frcr_evlu_tota", "usd_amt"]) # 첫 양수 1개(합산 시 이중 계상)
if tot_krw > 0 and usd > 0:
return tot_krw / usd
return 0.0
def _resolve_fx_rate_v2(overseas_balance_data: dict | None) -> tuple[float, str]:
"""(환율, 출처) — 출처: kis | last_good | config_default | unavailable."""
global _last_good_fx_rate
from app.core.config import settings as _settings
v = _fx_from_overseas_balance(overseas_balance_data)
if _FX_RATE_SANE_MIN <= v <= _FX_RATE_SANE_MAX:
_last_good_fx_rate = float(v)
return float(v), "kis"
if v > 0:
logger.warning(
f"[FX] KIS 응답 환율 {v:,.4f} 이 정상 범위({_FX_RATE_SANE_MIN:,.0f}~{_FX_RATE_SANE_MAX:,.0f}) 밖 — 무시"
)
if _last_good_fx_rate > 0:
return _last_good_fx_rate, "last_good"
cfg = float(getattr(_settings, "US_DEFAULT_FX_RATE", 0) or 0)
if cfg > 0:
return cfg, "config_default"
return 0.0, "unavailable"
def _cash_breakdown_v2(balance_data: dict | None, total_assets_domestic: float) -> dict:
"""[2026-10-08] 총자산·예수금 기준 분해 (순수 함수).
KIS 국내잔고 output2 의 항등식: tot_evlu_amt(총평가) = scts_evlu_amt(유가증권 평가)
+ prvs_rcdl_excc_amt(가수도정산금액 = D+2 예수금). 화면의 '예수금'(available_cash)은 매수용
**주문가능현금**(inquire-psbl-order ord_psbl_cash)이라 D+2 예수금과 다르다(미체결 매수
증거금·당일 매도대금 반영 차이) → '주식 + 예수금 ≠ 총자산' 의 원인.
반환 필드(summary 에 그대로 병합):
settlement_cash : D+2 예수금(prvs_rcdl_excc_amt) — 총자산을 구성하는 현금 기준
orderable_cash : 주문가능현금(_ord_psbl_cash, 없으면 None)
deposit_total : 예수금총액(dnca_tot_amt, 참고용)
cash_basis_gap : settlement_cash − available_cash(화면 예수금)
total_assets_residual : tot_evlu_amt − (scts_evlu_amt + prvs_rcdl_excc_amt) — 0 이 정상
cash_for_total_assets : 총자산(국내) − 주식평가(scts) — '주식 + 예수금 = 총자산' 이 정확히
성립하는 현금. 화면 예수금 표시는 이 값을 쓴다(주문가능은 별도 표기).
"""
out2 = {}
if isinstance(balance_data, dict) and balance_data.get("output2"):
o = balance_data["output2"]
out2 = (o[0] if isinstance(o, list) and o else o) if o else {}
out2 = out2 if isinstance(out2, dict) else {}
settlement = _pick_float(out2, ["prvs_rcdl_excc_amt"])
scts = _pick_float(out2, ["scts_evlu_amt"])
tot = _pick_float(out2, ["tot_evlu_amt"])
ord_raw = out2.get("_ord_psbl_cash")
orderable = float(ord_raw) if ord_raw not in (None, "") else None
residual = (tot - scts - settlement) if tot > 0 else 0.0
cash_for_total = (total_assets_domestic - scts) if (total_assets_domestic > 0 and scts >= 0 and tot > 0) else None
return {
"settlement_cash": settlement,
"orderable_cash": orderable,
"deposit_total": _pick_float(out2, ["dnca_tot_amt"]),
"total_assets_residual": residual,
"cash_for_total_assets": cash_for_total,
"total_assets_basis": "kis_tot_evlu_amt" if tot > 0 else "cash_plus_holdings",
}
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
class PortfolioCache:
def __init__(self):
@@ -1092,6 +1344,10 @@ class PortfolioCache:
if overseas_total_krw > 0:
total_assets += overseas_total_krw
elif overseas_value_krw > 0:
# [2026-10-08] KIS 원화 총평가가 없으면 보유 USD × 환율 환산값을 더한다
# (total_value 에는 이미 포함 — 화면의 주식+예수금과 총자산을 같은 범위로 맞춘다)
total_assets += overseas_value_krw
# 5. Calculate daily profit/loss
today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0
@@ -1126,6 +1382,70 @@ class PortfolioCache:
async with self.summary_lock:
self.summary = summary_data
async def cache_summary_v2(
self,
balance_data: dict = None,
overseas_balance_data: dict = None,
exchange_rate: float | None = None,
exchange_rate_source: str | None = None,
):
"""[2026-10-08] cache_summary + 환율 해석 + 현금 기준 분해 필드.
exchange_rate 가 None 이면 `_resolve_fx_rate_v2` 로 정한다(마지막 성공값/설정값 폴백).
0 이면 해외 원화 환산을 생략하고 exchange_rate_unavailable=True 로 표시한다.
"""
if exchange_rate is None:
exchange_rate, exchange_rate_source = _resolve_fx_rate_v2(overseas_balance_data)
exchange_rate = float(exchange_rate or 0)
# 설정 기본값(1350 등)은 추정치다 — 총자산(=4% 사이징·당일손익 기준)에 합산하면
# 실환율과 어긋난 만큼 리스크 지표가 왜곡되고 화면은 확정값처럼 보인다. 합산에서 제외하고
# (KIS 가 준 원화 총평가가 있으면 그것만 합산) 추정 환율은 별도 필드로만 노출한다.
_fx_estimate = 0.0
if exchange_rate_source == "config_default" and exchange_rate > 0:
_fx_estimate = exchange_rate
exchange_rate = 0.0
await self.cache_summary(balance_data, overseas_balance_data, exchange_rate)
_has_overseas = any(
not _is_domestic(h.stock_code, getattr(h, "market", None))
for h in (self.holdings or []) if (h.quantity or 0) > 0
)
_unavailable = exchange_rate <= 0
if _unavailable and _has_overseas:
warn_throttled(
logger, "portfolio:fx_unavailable",
"[FX] USD/KRW 환율 없음(KIS·마지막 성공값·설정값 모두 0) — 해외 보유 원화 환산 생략",
)
# _extract_balance_data 는 INFO 로그 3줄을 남기므로 재호출하지 않는다 — 원화 총평가는
# _cash_breakdown_v2 가 같은 output2 에서 직접 읽는다.
_kis_total = 0.0
if isinstance(balance_data, dict) and balance_data.get("output2"):
_o2 = balance_data["output2"]
_o2 = (_o2[0] if isinstance(_o2, list) and _o2 else _o2) or {}
_kis_total = _pick_float(_o2, ["tot_evlu_amt"]) if isinstance(_o2, dict) else 0.0
extra = {
"exchange_rate_source": exchange_rate_source or ("unavailable" if _unavailable else "caller"),
"exchange_rate_unavailable": bool(_unavailable),
"fx_conversion_skipped": bool(_unavailable and _has_overseas),
}
if _fx_estimate > 0:
extra["exchange_rate_estimate"] = _fx_estimate
extra["exchange_rate_source"] = "config_default"
if balance_data:
extra.update(_cash_breakdown_v2(balance_data, _kis_total))
if abs(float(extra.get("total_assets_residual") or 0)) > 1000:
warn_throttled(
logger, "portfolio:total_assets_residual",
f"[Portfolio] 총자산 항등식 불일치: tot_evlu_amt − (scts_evlu_amt + prvs_rcdl_excc_amt) = "
f"{float(extra['total_assets_residual']):,.0f}원 — KIS 필드 정의 확인 필요",
)
async with self.summary_lock:
if self.summary is not None:
self.summary.update(extra)
if "settlement_cash" in extra:
_avail = float(self.summary.get("available_cash") or 0)
self.summary["cash_basis_gap"] = float(extra.get("settlement_cash") or 0) - _avail
portfolio_cache = PortfolioCache()
from pydantic import BaseModel
@@ -1143,11 +1463,131 @@ class PortfolioResponse(BaseModel):
profit_loss_rate: Optional[Decimal] = None
market: str = "KRX"
strategy_type: str = "swing"
# [2026-10-08] 전략별 분해 (기존 필드 의미 불변 — quantity/average_price 는 계좌 합산·혼합 평단).
# `_annotate_strategy_breakdown_v2` 가 채운다. 정의는 그 함수 docstring 참조.
swing_quantity: Optional[int] = None
swing_avg_price: Optional[float] = None
swing_avg_price_basis: Optional[str] = None
swing_profit_loss: Optional[float] = None
swing_profit_loss_rate: Optional[float] = None
dt_quantity: Optional[int] = None
dt_avg_price: Optional[float] = None
avg_price_mixed: Optional[bool] = None
class Config:
from_attributes = True
_BREAKDOWN_FIELDS: tuple[str, ...] = (
"swing_quantity", "swing_avg_price", "swing_avg_price_basis",
"swing_profit_loss", "swing_profit_loss_rate",
"dt_quantity", "dt_avg_price", "avg_price_mixed",
)
def _round_price_v2(value) -> Optional[float]:
"""API 응답 가격 반올림 정책: 소수 2자리(DB Numeric(15,2) 와 동일). 0/None → None."""
try:
v = float(value or 0)
except (TypeError, ValueError):
return None
return round(v, 2) if v > 0 else None
def _strategy_breakdown_v2(holding, swing_entry_price, dt_pos: dict | None) -> dict:
"""[2026-10-08] 보유 행 1개의 전략별 수량·평단 (순수 함수).
Portfolio 행은 stock_code UNIQUE 라 스윙+DT 가 합산돼 있고 평단도 혼합된다(§3.5).
dt_quantity / dt_avg_price : DT 엔진 인메모리 포지션(수량·체결가 보정된 진입가)
swing_quantity : 행 수량 − DT 수량 (DT 아닌 보유분: swing/external/manual 포함)
swing_avg_price : 스윙 ActiveTrade.entry_price (resolve_swing_entry_price_v2 —
괴리 30% 초과면 보수적 max, 없으면 Portfolio 평단 폴백)
swing_avg_price_basis : active_trade | conservative_max | portfolio_avg | none
swing_profit_loss(_rate) : (현재가 − swing_avg_price) × swing_quantity, 비용 전
avg_price_mixed : DT 보유가 있거나 스윙 진입가가 Portfolio 평단과 다르면 True
"""
from app.services.swing.position_manager import resolve_swing_entry_price_v2
qty = int(getattr(holding, "quantity", 0) or 0)
dt_qty = int((dt_pos or {}).get("quantity", 0) or 0)
dt_qty = max(0, min(dt_qty, qty))
dt_avg = _round_price_v2((dt_pos or {}).get("entry_price")) if dt_qty > 0 else None
swing_qty = max(0, qty - dt_qty)
pa = float(getattr(holding, "average_price", 0) or 0)
cur = float(getattr(holding, "current_price", 0) or 0)
swing_avg = None
basis = "none"
pl = None
pl_rate = None
if swing_qty > 0:
if dt_qty > 0 and not swing_entry_price:
# DT 공존 + 스윙 진입가 출처 없음 → 혼합 평단에서 DT 분을 빼서 역산
_rest = pa * qty - float((dt_pos or {}).get("entry_price") or 0) * dt_qty
_derived = _rest / swing_qty if swing_qty > 0 else 0.0
if _derived > 0:
swing_avg, basis = _derived, "derived_from_mixed"
if swing_avg is None:
swing_avg, basis = resolve_swing_entry_price_v2(
swing_entry_price, pa, getattr(holding, "stock_code", ""),
)
swing_avg = _round_price_v2(swing_avg)
if swing_avg and cur > 0:
pl = round((cur - swing_avg) * swing_qty, 2)
pl_rate = round((cur / swing_avg - 1.0) * 100, 4)
mixed = dt_qty > 0 or (swing_avg is not None and pa > 0 and abs(swing_avg - round(pa, 2)) >= 0.01)
return {
"swing_quantity": swing_qty,
"swing_avg_price": swing_avg,
"swing_avg_price_basis": basis,
"swing_profit_loss": pl,
"swing_profit_loss_rate": pl_rate,
"dt_quantity": dt_qty,
"dt_avg_price": dt_avg,
"avg_price_mixed": bool(mixed),
}
async def _annotate_strategy_breakdown_v2(db: AsyncSession, holdings: list) -> list:
"""보유 ORM 객체에 전략별 분해 필드를 **비매핑 속성**으로 붙인다(DB 쓰기 없음).
API 응답 직렬화(PortfolioResponse / jsonable_encoder)가 이 속성을 그대로 내보낸다.
조회 실패 시 warning 후 원본 그대로 반환(화면 표시용 — 매매 판정 경로 아님).
"""
if not holdings:
return holdings
try:
from app.services.db_worker import not_dt_strategy_clause
codes = [h.stock_code for h in holdings]
rows = (await db.execute(
select(ActiveTrade.stock_code, ActiveTrade.entry_price, ActiveTrade.id)
.where(
ActiveTrade.status == "active",
ActiveTrade.stock_code.in_(codes),
not_dt_strategy_clause(ActiveTrade.strategy_name),
)
.order_by(ActiveTrade.id.desc())
)).all()
swing_entry: dict = {}
for code, entry, _id in rows:
swing_entry.setdefault(code, entry) # 최신 행 우선(get_swing_active_trade 와 동일)
except Exception as e:
warn_throttled(logger, "portfolio:breakdown", f"[Portfolio] 전략별 평단 조회 실패 — 분해 필드 생략: {e}")
return holdings
try:
from app.services.daytrading.position_manager import day_trading_state as _dts
dt_positions = dict(_dts.positions)
except Exception as e:
warn_throttled(logger, "portfolio:breakdown_dt", f"[Portfolio] DT 인메모리 조회 실패 — DT 0 으로 표시: {e}")
dt_positions = {}
for h in holdings:
try:
info = _strategy_breakdown_v2(h, swing_entry.get(h.stock_code), dt_positions.get(h.stock_code))
for k, v in info.items():
setattr(h, k, v)
except Exception as e:
warn_throttled(logger, "portfolio:breakdown_row", f"[Portfolio] {h.stock_code} 전략별 분해 실패: {e}")
return holdings
async def _get_latest_executed_trade_id(db: AsyncSession) -> int | None:
try:
result = await db.execute(
@@ -1170,7 +1610,9 @@ async def get_portfolio(db: AsyncSession = Depends(get_db)):
if portfolio_cache.holdings and not portfolio_cache.should_sync():
logger.debug("Portfolio cache hit (holdings)")
# 수량 0인 종목 필터링 (안전장치)
return [h for h in portfolio_cache.holdings if h.quantity > 0]
return await _annotate_strategy_breakdown_v2(
db, [h for h in portfolio_cache.holdings if h.quantity > 0]
)
# 필요하면 동기화 시도
try:
await sync_portfolio(db, force=False)
@@ -1178,7 +1620,9 @@ async def get_portfolio(db: AsyncSession = Depends(get_db)):
# 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환
logger.warning("get_portfolio: sync failed, returning cached holdings")
# 수량 0인 종목 필터링 (안전장치)
return [h for h in portfolio_cache.holdings if h.quantity > 0]
return await _annotate_strategy_breakdown_v2(
db, [h for h in portfolio_cache.holdings if h.quantity > 0]
)
@router.get("/sync")
@@ -1258,7 +1702,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 현금 계산이 영영 막히지 않도록 holdings 기반 부분값(available_cash=0)이라도 채워둔다.
if portfolio_cache.summary is None:
try:
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 반영
await portfolio_cache.cache_summary_v2() # available_cash=0이지만 holdings는 즉시 반영
logger.info("📦 summary 최초 미존재 — holdings 기반 부분값으로 채움 (available_cash=0)")
except Exception as summary_err:
logger.warning(f"⚠️ 부분 summary 생성 실패 (summary는 None 유지): {summary_err}")
@@ -1369,6 +1813,34 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# db_worker 큐 적체 여부 — 강등 보류 판단(사이클당 1회 조회).
_queue_busy = _db_queue_busy()
# 최근 체결 종목(팬텀 ActiveTrade close 유예용) — 조회 실패 시 None(= close 보류).
_recent_trade_codes = None
try:
_rt_since = now - timedelta(seconds=_PHANTOM_RECENT_TRADE_SEC)
_rt_rows = await db.execute(
select(TradeHistory.stock_code).where(
TradeHistory.executed_at >= _rt_since,
TradeHistory.status != "FAILED",
).distinct()
)
_recent_trade_codes = {c for c in _rt_rows.scalars().all() if c}
except Exception as _rt_err:
logger.warning(f"최근 체결 조회 실패 — ActiveTrade close 보류: {_rt_err}")
# DT active ActiveTrade 종목(DB) — 'daytrading' 태그 해제 판단 근거. 실패 시 None(해제 안 함).
_dt_db_codes = None
try:
from app.services.db_worker import DT_STRATEGY_NAMES as _DT_NAMES
_dtdb = await db.execute(
select(ActiveTrade.stock_code).where(
ActiveTrade.status == "active",
ActiveTrade.strategy_name.in_(list(_DT_NAMES)),
)
)
_dt_db_codes = {c for c in _dtdb.scalars().all() if c}
except Exception as _dtdb_err:
logger.warning(f"DT ActiveTrade 조회 실패 — daytrading 태그 해제 보류: {_dtdb_err}")
def _classify_durable(_code: str, _qty: int) -> str:
"""레지스트리를 **제외한** 근거만으로 판정 — 승격 판정 전용 (M-2).
@@ -1454,7 +1926,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
continue
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
# [2026-10-08] KIS pchs_avg_pric 는 소수 4자리("67323.0769")다. DB 컬럼은
# Numeric(15,2) 라 커밋 후엔 2자리인데 캐시 ORM 객체는 원문을 들고 있어 화면에
# 67,323.076 처럼 보였다 → 저장 규약(소수 2자리, 반올림)으로 맞춘다.
average_price = _quantize_price_v2(item.get("pchs_avg_pric", 0))
# 실시간 현재가 (병렬 조회 결과 참조)
price_resp = _realtime_price_map.get(stock_code)
@@ -1495,6 +1970,21 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
# 전량이 DT 포지션일 때만 태깅(혼재 시 스윙 청산 대상 유지)
if stock_code in _dt_codes and quantity <= _dt_qty.get(stock_code, 0):
h.strategy_type = "daytrading"
elif (
(h.strategy_type or "swing") == "daytrading"
and stock_code not in _dt_codes
and _dt_db_codes is not None
and stock_code not in _dt_db_codes
):
# DT 인메모리·DB active 모두 없는데 태그만 남았다(비-DT 잔량/유입 잔존).
# 'daytrading' 이면 스윙 청산·DT 고아 청산·추천 매도 모두에서 빠져
# 손절이 무력화된다(105560 KB금융). durable 근거로 재분류한다.
_re = _classify_durable(stock_code, quantity)
logger.warning(
f"🏷️ daytrading 태그 해제: {stock_name}({stock_code}) {quantity}주 "
f"daytrading → {_re} (DT 포지션 없음)"
)
h.strategy_type = _re
elif (
(h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE
and _classify_durable(stock_code, quantity) == "swing"
@@ -1575,7 +2065,10 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
stock_name = item.get("ovrs_item_name", stock_code)
exchange_code = item.get("ovrs_excg_cd", "NASD")
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
# [2026-10-08] KIS pchs_avg_pric 는 소수 4자리("67323.0769")다. DB 컬럼은
# Numeric(15,2) 라 커밋 후엔 2자리인데 캐시 ORM 객체는 원문을 들고 있어 화면에
# 67,323.076 처럼 보였다 → 저장 규약(소수 2자리, 반올림)으로 맞춘다.
average_price = _quantize_price_v2(item.get("pchs_avg_pric", 0))
# 실시간 현재가
price_resp = _ovrs_price_map.get(stock_code)
@@ -1691,12 +2184,20 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
h.stock_code: (h.strategy_type or "swing") for h in new_holdings
}
for _act in _reconcile_active_trade_quantities(
_by_code, _held, _strategy_by_code, _system_qty_durable
_by_code, _held, _strategy_by_code, _system_qty_durable,
phantom_guard=True,
recent_trade_codes=_recent_trade_codes,
now=now,
):
if _act["action"] == "closed":
logger.info(
f"🧹 ActiveTrade 정리: {_act['stock_code']} 보유 없음 → closed"
)
elif _act["action"] == "close_deferred":
logger.info(
f"⏳ ActiveTrade 정리 유예: {_act['stock_code']} 보유 0 관측 "
f"({_act.get('reason')})"
)
elif _act["action"] == "corrected":
logger.info(
f"🧹 ActiveTrade 잔량 교정: {_act['stock_code']} "
@@ -1753,6 +2254,7 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
if _seed_name == "swing":
_seed_name = "synced"
# DLQ 직렬화(json) 대비 — Decimal 대신 순수 float/int/str 만 담는다.
_inh = await _inheritable_closed_row_v3(db, _h.stock_code, _avg, now)
_seed_payload = {
"stock_code": _h.stock_code,
"stock_name": _h.stock_name,
@@ -1768,6 +2270,33 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
"currency_code": _h.currency_code or "KRW",
"exchange_code": _h.exchange_code,
}
if _inh is not None:
# 방금 닫힌 비-DT 행의 분할 단계·전략명·진입근거를 승계 — 안 하면
# remaining_pct 가 1.0 으로 되돌아 1차 익절이 재실행되고 모멘텀/눌림목이
# 평균회귀 청산을 탄다. (created_at 은 payload 로 못 넣어 승계 불가 → entry_reason 시딩 접두사로 원 BUY 일자 보정)
_inh_rem = float(_inh.remaining_pct if _inh.remaining_pct is not None else 1.0)
_seed_payload["remaining_pct"] = max(0.0, min(1.0, _inh_rem))
if _inh.strategy_name:
_seed_payload["strategy_name"] = _inh.strategy_name
if _inh.entry_reason:
_seed_payload["entry_reason"] = _inherited_entry_reason_v2(_inh.entry_reason)
_inh_hi = float(_inh.highest_since_entry or 0)
if _inh_hi > _seed_payload["highest_since_entry"]:
_seed_payload["highest_since_entry"] = _inh_hi
_inh_sl = float(_inh.stop_loss or 0)
if _inh_sl > 0:
_seed_payload["stop_loss"] = _inh_sl
_inh_tp = float(_inh.take_profit or 0)
if _inh_tp > 0:
_seed_payload["take_profit"] = _inh_tp
_inh_ep = float(_inh.entry_price or 0)
if _inh_ep > 0:
_seed_payload["entry_price"] = _inh_ep
logger.warning(
f"♻️ ActiveTrade 재시딩 승계: {_h.stock_code} 이전 행 id={_inh.id} "
f"remaining={_seed_payload['remaining_pct']} "
f"strategy={_seed_payload['strategy_name']}"
)
_seed_uid = getattr(_h, "user_id", None)
if _seed_uid is not None:
_seed_payload["user_id"] = int(_seed_uid)
@@ -1832,7 +2361,8 @@ async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True)
)
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
await portfolio_cache.cache_summary(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
# [2026-10-08] v2 — 환율(0 금지: 마지막 성공값/설정값 폴백) + 현금 기준 분해 필드
await portfolio_cache.cache_summary_v2(balance_data, overseas_balance_data) # 국내·해외 잔고 전달
portfolio_cache.last_synced = datetime.now()
portfolio_cache.change_flag = False
portfolio_cache.last_trade_id = await _get_latest_executed_trade_id(db)
@@ -1887,7 +2417,7 @@ async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
realized_result = await db.execute(
select(func.sum(TradeHistory.net_profit_loss)).where(
TradeHistory.order_type == "SELL",
TradeHistory.status.in_(["EXECUTED", "COMPLETED"]),
TradeHistory.status.in_(list(REALIZED_PNL_STATUSES)),
TradeHistory.net_profit_loss.isnot(None),
)
)
@@ -1945,6 +2475,13 @@ def _build_combined_resp() -> dict:
@router.get("/combined")
async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
"""보유종목 + 요약 — [2026-10-08] holdings 에 전략별 분해 필드를 붙인다(_impl 은 기존 본체)."""
resp = await _get_portfolio_combined_impl(db)
await _annotate_strategy_breakdown_v2(db, resp.get("holdings") or [])
return resp
async def _get_portfolio_combined_impl(db: AsyncSession):
"""보유종목 목록 + 요약을 반환.
응답 우선순위:
@@ -1965,7 +2502,7 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
# ② 캐시 만료, 매매 변경 없음 → stale 데이터 즉시 반환 + 백그라운드 갱신
if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.change_flag:
logger.debug("Portfolio cache stale (no trade) → returning cached, syncing in background")
asyncio.create_task(_sync_portfolio_background())
spawn_tracked_v2(_sync_portfolio_background())
return _build_combined_resp()
# ③ 캐시 비어있음 (서버 재시작) → DB에서 즉시 로드 + 백그라운드 KIS 갱신
@@ -1974,9 +2511,9 @@ async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
db_holdings = list(result.scalars().all())
if db_holdings:
portfolio_cache.holdings = db_holdings
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
await portfolio_cache.cache_summary_v2() # available_cash=0이지만 holdings는 즉시 표시
logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작")
asyncio.create_task(_sync_portfolio_background())
spawn_tracked_v2(_sync_portfolio_background())
return _build_combined_resp()
# ④ 매매 발생 또는 DB도 비어있음 → KIS API 동기 호출
@@ -2317,7 +2854,7 @@ async def check_and_execute_exit_trades(db: AsyncSession):
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
asyncio.create_task(_create_journal_background(
spawn_tracked_v2(_create_journal_background(
trade_id=None,
order_id=_order_id,
stock_code=stock_code,
@@ -2530,7 +3067,9 @@ async def sell_stock(
"order_type": "SELL",
"order_price": float(price) if price else None,
"order_quantity": quantity,
"executed_price": float(executed_price),
# [2026-10-08] 체결가는 체결 확인(swing/fill_sweep)이 채운다 — 현재가/평단을 체결가로
# 쓰지 않는다. total_amount·손익은 확인 전 추정치.
"executed_price": None,
"executed_quantity": quantity,
"total_amount": float(executed_price * quantity),
"status": "COMPLETED",
@@ -2615,7 +3154,7 @@ async def sell_stock(
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
asyncio.create_task(_create_journal_background(
spawn_tracked_v2(_create_journal_background(
trade_id=None,
order_id=order_result.get('output', {}).get('ODNO'),
stock_code=stock_code,