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