사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 KIS
계좌 잔고가 strategy_type='swing' 이 되는 경로를 전부 닫았다. 2026-09-30
09:26 에 유한양행 8주가 -6.49%/-40,000원 손절된 것이 직접 계기다.
## 스윙 자동편입 차단
- 강등 허용(swing→external). 기존엔 승격만 가능해 유입 잔고가 영구히
자동 손절 대상이었다. 3중 방어로 정상 포지션 강등을 막는다:
system_buy_registry(주문 즉시 인메모리) / 연속2회+120초 / db_worker 큐 적체 보류
※ 레이스는 이론이 아니다 — _verify_and_sync_execution 이 매수 직후
_delayed_sync_portfolio(0) 을 지연 0초로 부른다
- 수량 인식 분류(_classify_holding_strategy_type_v2):
_sys<=0<qty → external / 0<_sys<qty AND _sys==_dt_held → external
== 을 쓴 이유: 과소·과대 어느 쪽으로 틀려도 swing(관리 유지)으로 실패한다
- _system_qty 를 과대 집계 쪽으로 편향 — 오판이 안전한 방향으로 나게
- external 행에 스윙 추가매수 차단(혼재 발생 자체를 봉쇄, 청산은 통과)
- 근거를 두 벌로 분리: 신규태깅·강등방어는 registry 포함("있다고 보는 게 안전"),
승격·ActiveTrade 교정은 durable("없다고 보는 게 안전"). 안전 방향이 반대다
- 정합 루프가 external/혼재 행을 교정하지 않게(전제 "active 1행 = 전량 대표"를
직접 검사). 미교정 시 ActiveTrade 가 부풀어 스윙 청산이 유입분까지 판다(§3.5)
- status 허용목록 → 거부목록(FAILED 만 제외). 진짜 구멍은 FILLED 가 아니라
PENDING 이었다(체결확인 실패 시 영구 PENDING, 주식은 보유 중)
- SELL 중복제거 키에 nullif+trim — order_id 는 NULL 이 아니라 '' 로 저장된다
(build_kis_order_id 가 미발견 시 "" 반환). coalesce 만으로는 그룹이 붕괴해
SELL 이 과소 집계되고 유입 행이 영구히 swing 이 된다
## DT 처리량
- 감시종목 20 → 30 (DAY_TRADE_WATCHLIST_SIZE, 예산 상한 32에서 마진)
- 선조회 동시성 4→8, per-stock 45초, 단계 데드라인 120초 신설
(120 < 150 _SCAN_TIME_BUDGET < 180 _guard). 오후는 종목당 32초가
타임아웃 20초를 넘겨 4~7종목만 스캔되던 포화 상태였다 — 약 9배 복구
- 리스크 노출 불변: 동시 5종목·종목당 6%·DT 30% 무변경. 기회만 증가
## 세션 경계 (ATR·양봉)
- _atr() 이 내부에서 세션을 끊는다. 워밍업 시계열의 당일 첫 봉 TR =
오버나이트 갭이라 갭 3%에서 ATR 1.50배, 10%에서 2.75배 부풀었고
클램프 없는 target=현재가+1.5×ATR 을 직접 왜곡했다
→ 진입 ATR == 청산 트레일링 ATR 보장
※ 부작용 승인됨: 09:30~10:10 은 ATR 부재로 폴백 -3.5% 가 실효 손절폭
- 양봉 판정이 형성 중 버킷을 보던 것 → 마지막 완성봉(거래량 게이트와 같은 봉)
- 1.5R 하한 보장(두 분기). risk=max(1.5×ATR,2%) 라 저변동 종목은
R:R 0.21 까지 떨어졌다 — §7 "1.5R 분할" 규약 복귀, RSI_BB 와 동일
## DLQ 안전
- replay_dlq 나이 제한: 상태 2시간+같은날짜 / 기록 24시간 / close 는
충돌판정만(7일). "닫아라"는 지시는 시간이 지나도 거짓이 되지 않는다 —
격리하면 반대로 유령 active 행이 남는다(§8.4.1 129개와 같은 형태)
- 상태 충돌 확인(유령 생성 위험 시 expired/ 격리, 삭제 안 함)
- DB 장애 시 단락 — 없으면 500파일 × 접속타임아웃만큼 기동이 정지한다
## 진단·테스트
- 진입 reason 에 [진단 ATR=…, 갭=…], 탈락 후보 RSI·갭 집계 로그(하루 ~60줄)
- 잔고조회 실패 debug→warning (available_cash=0 → 모든 진입 조용히 차단)
- 벽시계 의존 테스트 재발 방지: autouse 가드(시각 미고정 시 AssertionError)
+ 호출부 전수 테스트
- 소스 문자열 매칭 단언을 동작 검증으로 전환(값·분기 판정 한정)
테스트 848 → 865 passed / 0 failed. 전략 임계값 변경 0건. alembic 불필요.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2751 lines
141 KiB
Python
2751 lines
141 KiB
Python
"""
|
||
포트폴리오 API 엔드포인트
|
||
|
||
- GET /: 보유 종목 목록 조회 (캐시 활용)
|
||
- GET /summary: 포트폴리오 요약 (총자산, 평가손익)
|
||
- GET /sync: 실제 계좌와 동기화
|
||
- POST /sell: 수동 매도 (청산)
|
||
"""
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from sqlalchemy.orm.exc import StaleDataError
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy import select, func, or_, case
|
||
from typing import List, Optional
|
||
from decimal import Decimal
|
||
from pydantic import BaseModel, Field
|
||
import pytz
|
||
|
||
from app.core.database import get_db, AsyncSessionLocal
|
||
from app.models.models import Portfolio, TradeHistory, ActiveTrade, StockAnalysis, TradeJournal
|
||
from app.services.korea_investment_api import korea_investment_api
|
||
from app.services.mean_reversion_strategy import (
|
||
calculate_atr, calculate_rsi, calculate_bollinger, check_exit_signal,
|
||
MarketRegime, # ① regime 파라미터 전달용 추가
|
||
)
|
||
from app.services.mdd_manager import mdd_manager
|
||
from app.services.loss_tracker import loss_tracker
|
||
from app.services.websocket_manager import websocket_manager
|
||
from app.services.utils import _is_domestic
|
||
import asyncio
|
||
import logging
|
||
from datetime import datetime, timedelta
|
||
from app.core.timeutil import now_kst
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# 한국 시간대
|
||
KST = pytz.timezone('Asia/Seoul')
|
||
|
||
# 유입(외부) 잔고 격리 태그 — 시스템이 매수한 적 없는 보유분.
|
||
# 스윙 자동 청산(get_swing_holdings: strategy_type=='swing')에서 자동 제외된다.
|
||
EXTERNAL_STRATEGY_TYPE = "external"
|
||
# sync 가 자동 시딩한 ActiveTrade 전략명 — '시스템 매수 근거'로 인정하지 않는다.
|
||
_SEEDED_AT_STRATEGY_NAMES = ("synced",)
|
||
# sync 시딩 ActiveTrade 의 entry_reason 접두사.
|
||
# [2026-09-30] strategy_name='synced' 규약은 2026-09-29(7abbb34) 에 도입됐고, 그
|
||
# 이전(6207d13)에는 `strategy_name = Portfolio.strategy_type` 즉 제네릭 **'swing'**
|
||
# 으로 시딩했다. 그 legacy 행은 strategy_name 만 보면 시딩인지 알 수 없어 '시스템
|
||
# 매수 근거'로 오인되고, 유입 잔고 격리가 스스로 풀린다(강등 불가). entry_reason
|
||
# 은 두 시기 모두 동일 문구라 시딩 판별의 유일한 불변 표식이다.
|
||
_SEEDED_AT_ENTRY_REASON_PREFIX = "외부 유입 포지션 — 동기화 시 자동 등록"
|
||
|
||
# ── '시스템 매수 근거' 로 인정하지 않는 BUY status (거부목록) ────────────────
|
||
# [M1 2026-09-30] 이 판정은 **허용목록이면 안 된다.**
|
||
# 허용목록(`status.in_([...])`)은 값이 하나라도 빠지면 근거가 있는데도 못 찾아
|
||
# 정상 보유를 external 로 강등시킨다 — 손절·익절이 조용히 사라진다.
|
||
# 반대로 거부목록은 미지의 status 를 자동으로 "근거 있음"으로 처리하므로
|
||
# 불변식 "정상 포지션은 절대 강등되지 않는다" 를 **미래의 새 status 값에 대해서도**
|
||
# 지킨다. 유입 잔고 격리가 늦어지는 쪽은 허용 가능하고, 오강등은 허용 불가다.
|
||
#
|
||
# [실측 2026-09-30 11:39 KST] 운영 원장 전수(GET /api/v1/trades?days=800&limit=5000
|
||
# → 352건, 2026-04-02~09-30)의 status 분포는 **{'COMPLETED': 352}** — 단 하나다.
|
||
# 즉 이 수정은 **현재 영향이 없는 순수 방어**이며, 추측이 아니라 실측이다.
|
||
# 그리고 코드베이스에서
|
||
# 'FILLED'/'EXECUTED' 를 **쓰는 곳은 없다**(읽는 쪽 5개 파일의 방어적 레거시 값).
|
||
# 실제 writer 가 내는 값은 COMPLETED / PARTIAL / PENDING / FAILED 네 개이고,
|
||
# 이전 허용목록 `["EXECUTED","COMPLETED","PARTIAL"]` 의 진짜 구멍은 'FILLED' 가
|
||
# 아니라 **'PENDING'** 이었다: `trade_executor._execute_buy` 는 KIS rt_cd=="1"
|
||
# (주문접수·체결 미확인)에 PENDING 을 기록하고 `_verify_and_sync_execution` 이
|
||
# 나중에 COMPLETED/PARTIAL 로 갱신한다. 체결 확인이 실패하면 주식은 보유 중인데
|
||
# 원장은 영구 PENDING 으로 남아 강등 대상이 된다.
|
||
#
|
||
# 'FAILED'(주문 거부)만 제외한다 — 주식이 생기지 않으므로 Portfolio 행 자체가
|
||
# 없고, 근거로 인정하면 '매수 실패' 만으로 유입 잔고가 격리를 면한다.
|
||
#
|
||
# ⚠️ 다른 5개 파일(`trade_analyzer`·`schedulers`·`journals`·`risk_management`)의
|
||
# 허용목록과 **통합하지 않았다.** 그쪽은 손익·성과 집계라 "확정 체결"만 세야 하고
|
||
# (PENDING 을 넣으면 실현손익이 오염된다) 여기는 "매수 시도 흔적이 있는가"로
|
||
# 의미가 다르다. 같은 상수로 묶으면 한쪽을 고칠 때 반대쪽이 조용히 망가진다.
|
||
_NON_FILL_BUY_STATUSES = ("FAILED",)
|
||
|
||
# ── 유입 잔고 강등(swing → external) 확정 규칙 ──────────────────────────────
|
||
# [2026-09-30] 사용자 결정: "스윙으로 편입하는 건 없어야 해" → 승격뿐 아니라 강등도
|
||
# 허용한다. 단 **정상 포지션은 절대 강등되지 않는다**가 불변식이다.
|
||
#
|
||
# 레이스: TradeHistory·ActiveTrade 는 둘 다 db_worker 큐를 거쳐 비동기 INSERT 되고
|
||
# (§3.2), 매수 직후 `trade_executor._verify_and_sync_execution()` 이
|
||
# `_delayed_sync_portfolio(0)` 로 즉시 sync 를 돌린다. 그 창에서 강등하면 방금 산
|
||
# 포지션이 손절·익절·트레일링을 통째로 잃는다.
|
||
#
|
||
# 3중 방어:
|
||
# 1) `system_buy_registry` — 주문 성공 시점의 **즉시 인메모리 근거**(주 방어).
|
||
# DB 반영 전이라도 근거가 보인다.
|
||
# 2) 연속 관측 + 최소 경과시간 — 프로세스 재기동으로 1)이 비었을 때의 2차 방어.
|
||
# 같은 종목이 서로 다른 sync 에서 _DEMOTE_CONFIRM_SYNCS 회 이상 근거 없이
|
||
# 관측되고, 첫 관측으로부터 _DEMOTE_MIN_AGE_SEC 이상 지나야 강등한다.
|
||
# (매수 직후 sync 가 여러 번 연달아 도는 경우를 횟수만으로는 못 막는다 →
|
||
# 벽시계 하한을 함께 둔다.)
|
||
# 3) db_worker 큐가 비어 있지 않으면 그 사이클은 강등하지 않는다 — 재시도
|
||
# 백오프(1→2→4초)로 근거 INSERT 가 밀려 있을 수 있다.
|
||
# 반대 방향(유입 잔고 격리가 최대 수 분 늦어짐)은 허용 가능하다.
|
||
_DEMOTE_CONFIRM_SYNCS = 2
|
||
_DEMOTE_MIN_AGE_SEC = 120.0
|
||
# {stock_code: [첫 미관측 monotonic 시각, 미관측 관측 횟수]}
|
||
_demote_miss_state: dict = {}
|
||
|
||
|
||
def _demote_state_reset(stock_code: str = None) -> None:
|
||
"""강등 미관측 카운터 초기화 (테스트·근거 확인 시)."""
|
||
if stock_code is None:
|
||
_demote_miss_state.clear()
|
||
else:
|
||
_demote_miss_state.pop(stock_code, None)
|
||
|
||
|
||
def _db_queue_busy() -> bool:
|
||
"""db_worker 큐에 미처리 태스크가 남아 있는가 (강등 보류 판단용).
|
||
|
||
조회 실패는 **보류(True)** 로 해석한다 — 불확실할 때 강등하지 않는 쪽이 안전하다.
|
||
"""
|
||
try:
|
||
from app.services.db_worker import get_db_task_queue
|
||
return get_db_task_queue().qsize() > 0
|
||
except Exception as e:
|
||
logger.warning(f"db_worker 큐 상태 조회 실패 → 강등 보류: {e}")
|
||
return True
|
||
|
||
|
||
def _should_demote_to_external(
|
||
stock_code: str,
|
||
current_type: str,
|
||
classified: str,
|
||
queue_busy: bool,
|
||
now: float = None,
|
||
) -> bool:
|
||
"""기존 Portfolio 행을 swing → external 로 강등할지 판정 (부수효과=미관측 카운터).
|
||
|
||
Args:
|
||
stock_code: 종목코드
|
||
current_type: 현재 `Portfolio.strategy_type`
|
||
classified: `_classify_holding_strategy_type()` 결과
|
||
queue_busy: db_worker 큐에 미처리 태스크가 있는가
|
||
now: monotonic 시각 (테스트 주입용)
|
||
|
||
Returns:
|
||
bool: True 면 강등. 판정 근거는 위 _DEMOTE_* 주석 참조.
|
||
"""
|
||
import time as _time
|
||
_now = _time.monotonic() if now is None else float(now)
|
||
|
||
# 근거가 보이면 즉시 카운터 소멸 — 이후 어떤 사이클도 누적을 물려받지 않는다.
|
||
if classified != EXTERNAL_STRATEGY_TYPE:
|
||
_demote_state_reset(stock_code)
|
||
return False
|
||
# 강등 대상은 제네릭 'swing' 뿐이다. 'manual'·'us_swing'·'daytrading' 은
|
||
# 애초에 get_swing_holdings(strategy_type=='swing') 에 안 잡혀 스윙 자동 청산
|
||
# 대상이 아니고, 각 엔진이 strategy_name 으로 자기 행을 매칭한다.
|
||
if (current_type or "swing") != "swing":
|
||
return False
|
||
# 1차 방어: 주문 성공 직후의 즉시 인메모리 근거
|
||
try:
|
||
from app.services.system_buy_registry import has_recent_system_buy
|
||
if has_recent_system_buy(stock_code):
|
||
_demote_state_reset(stock_code)
|
||
return False
|
||
except Exception as e:
|
||
logger.warning(f"system_buy_registry 조회 실패 → 강등 보류 ({stock_code}): {e}")
|
||
return False
|
||
|
||
# 2차 방어: 연속 관측 + 최소 경과시간
|
||
st = _demote_miss_state.get(stock_code)
|
||
if st is None:
|
||
_demote_miss_state[stock_code] = [_now, 1]
|
||
return False
|
||
st[1] += 1
|
||
first_ts, count = st[0], st[1]
|
||
|
||
# 3차 방어: db_worker 큐가 밀려 있으면 이번 사이클은 보류 (카운터는 유지)
|
||
if queue_busy:
|
||
return False
|
||
if count < _DEMOTE_CONFIRM_SYNCS or (_now - first_ts) < _DEMOTE_MIN_AGE_SEC:
|
||
return False
|
||
_demote_state_reset(stock_code)
|
||
return True
|
||
|
||
|
||
def _external_stop_breaches(holdings: list) -> list:
|
||
"""유입 격리분 중 기본 손절선(-STOP_LOSS_PERCENT)을 이탈한 종목 목록 (순수 함수).
|
||
|
||
격리분은 **집행하지 않는다** — 이 함수는 "조용한 실패"를 막기 위한 가시화 전용이다.
|
||
ATR 손절(진입가 − 2.5×ATR)은 분봉·일봉이 필요해 여기서 계산하지 않는다.
|
||
따라서 이 판정은 기본 SL 기준이며 실제 전략 손절선보다 느슨할 수 있다.
|
||
|
||
Returns:
|
||
list[dict]: stock_code / stock_name / profit_loss_rate / breach_pct(초과 이탈폭)
|
||
"""
|
||
from app.core.config import settings as _cfg
|
||
|
||
_sl = -abs(float(getattr(_cfg, "STOP_LOSS_PERCENT", 7.0)))
|
||
out = []
|
||
for h in holdings:
|
||
if (getattr(h, "strategy_type", None) or "swing") != EXTERNAL_STRATEGY_TYPE:
|
||
continue
|
||
if not h.quantity or h.quantity <= 0:
|
||
continue
|
||
_avg = float(h.average_price or 0)
|
||
_cur = float(h.current_price or 0)
|
||
if _avg <= 0 or _cur <= 0:
|
||
continue
|
||
_rate = (_cur / _avg - 1) * 100
|
||
if _rate <= _sl:
|
||
out.append({
|
||
"stock_code": h.stock_code,
|
||
"stock_name": h.stock_name or h.stock_code,
|
||
"profit_loss_rate": round(_rate, 2),
|
||
"breach_pct": round(_rate - _sl, 2), # 손절선 대비 추가 이탈폭(음수)
|
||
"stop_loss_pct": _sl,
|
||
})
|
||
return out
|
||
|
||
|
||
def _classify_holding_strategy_type(
|
||
stock_code: str,
|
||
quantity: int,
|
||
dt_qty: dict,
|
||
system_codes: set,
|
||
) -> str:
|
||
"""KIS 잔고 1행의 `Portfolio.strategy_type` 판정 (순수 함수).
|
||
|
||
Args:
|
||
stock_code: 종목코드
|
||
quantity: KIS 잔고 수량
|
||
dt_qty: 데이트레이딩 인메모리 보유 수량 {code: qty}
|
||
system_codes: 시스템 매수 근거가 있는 종목 집합
|
||
(TradeHistory BUY ∪ 시딩 아닌 ActiveTrade ∪ DT 인메모리)
|
||
|
||
Returns:
|
||
"daytrading" | "swing" | "external"
|
||
- 전량이 DT 보유분이면 daytrading (부분 혼재면 스윙 청산 대상 유지)
|
||
- 시스템 매수 근거가 없으면 external (유입 잔고 → 자동 청산 제외)
|
||
"""
|
||
_dt_held = int(dt_qty.get(stock_code, 0) or 0)
|
||
if _dt_held > 0 and quantity <= _dt_held:
|
||
return "daytrading"
|
||
if stock_code in system_codes:
|
||
return "swing"
|
||
return EXTERNAL_STRATEGY_TYPE
|
||
|
||
|
||
def _classify_holding_strategy_type_v2(
|
||
stock_code: str,
|
||
quantity: int,
|
||
dt_qty: dict,
|
||
system_codes: set,
|
||
system_qty: dict,
|
||
) -> str:
|
||
"""`_classify_holding_strategy_type` + **수량 인식** 판정 (순수 함수).
|
||
|
||
## 왜 v2 가 필요한가 — 부분 혼재가 격리를 세탁한다 (2026-09-30 운영 실측)
|
||
|
||
`Portfolio.stock_code` 는 UNIQUE 라 같은 종목의 유입분·스윙·DT 수량이 **한 행에
|
||
합산**된다(CLAUDE.md §3.5). v1 은 "DT 전량이면 daytrading, 부분 혼재면 스윙 청산
|
||
대상 유지" 였기 때문에 유입 잔고에 시스템 매수가 1주라도 섞이면 **전체가 swing
|
||
으로 승격**됐다:
|
||
|
||
11:35 105560 KB금융 external 3주 ← 유입 잔고, 올바르게 격리됨
|
||
11:45 105560 BUY 3주 (DT VWAP 반등) ← DT 가 3주 매수
|
||
12:42 105560 KB금융 swing 6주 ← external → swing 세탁
|
||
|
||
유입 3주가 스윙 자동 손절 대상이 됐다. 사용자 결정("스윙으로 편입하는 건 없어야
|
||
해")의 정면 위반이고, 000100 과 같은 고아 청산으로 이어지는 경로다.
|
||
|
||
## 추가 분기 (v1 대비 단 하나)
|
||
|
||
시스템 근거 수량이 **정확히 DT 보유분과 같고** 행 수량보다 작으면 → `external`.
|
||
즉 "행에 섞인 시스템 몫이 전부 DT" 인 경우다. 이때 external 유지는 **관리 공백을
|
||
만들지 않는다** — DT 엔진은 `day_trading_state.positions`(인메모리)로 자기 수량을
|
||
관리하고 `Portfolio.strategy_type` 을 보지 않으며, 15:20 EOD 에 전량 청산한다.
|
||
청산되면 원장 net(BUY−SELL)이 0 으로 돌아와 순수 유입분만 남는다 → 태그가
|
||
`swing` 으로 굳는 문제가 원천적으로 사라진다.
|
||
|
||
## 왜 `==` 인가 (`<=` 가 아니라)
|
||
|
||
`sys_qty <= dt_held` 로 하면 원장이 스윙 몫을 **과소 집계**했을 때(체결 이력 유실)
|
||
스윙 보유분이 있는 행까지 external 이 되어 손절 관리를 잃는다. `==` 는 과소 집계
|
||
시 분기가 **발동하지 않아** swing 으로 남는다 = 안전한 방향으로 실패한다.
|
||
호출부의 `system_qty` 산출도 같은 이유로 **과대 집계 쪽으로 편향**시킨다
|
||
(BUY 는 PENDING 의 order_quantity 까지 세고, SELL 은 확정 체결만 차감).
|
||
|
||
## 스윙이 섞인 혼재(`dt_held == 0`)는 `swing` 을 유지한다
|
||
|
||
그쪽을 external 로 돌리면 **시스템이 산 수량이 손절·익절 밖에 남는다**
|
||
(CLAUDE.md §8.1-11 위반). 대신 ① `trade_executor` 가 external 행에 대한 스윙
|
||
추가매수를 차단해 이 혼재가 **발생하지 않게** 하고 ② 기존 행은 호출부가
|
||
`⚠️ 유입분 혼재` warning 으로 노출한다. 근본 해결(스윙 청산 시 유입 수량 차감)은
|
||
`swing/exit_service` 변경이 필요해 이 작업 범위 밖이다.
|
||
|
||
Args:
|
||
stock_code: 종목코드
|
||
quantity: KIS 잔고 수량 (= Portfolio 행 수량)
|
||
dt_qty: 데이트레이딩 인메모리 보유 수량 {code: qty}
|
||
system_codes: 시스템 매수 근거가 있는 종목 집합 (v1 과 동일)
|
||
system_qty: 시스템 매수 근거 **수량** {code: qty} — `_compose_system_quantities`
|
||
|
||
Returns:
|
||
"daytrading" | "swing" | "external"
|
||
|
||
## 한계 — 불변식이 보장되지 않는 유일한 경로 (M-3, 2026-09-30)
|
||
|
||
"정상 포지션은 절대 강등되지 않는다" 는 `system_qty > 0` 에 의존하고, 그 값은
|
||
**네 소스의 하한 합성**이다(원장 net / 비DT active ActiveTrade 잔량 / DT 인메모리 /
|
||
system_buy_registry). 따라서 **네 개가 동시에 0 이 되어야** 정상 포지션이 external
|
||
이 된다. 구체적으로 다음이 **전부** 성립할 때다:
|
||
|
||
① 원장 net ≤ 0 — 같은 매도가 중복 INSERT 되어 수량이 두 번 차감됨
|
||
(`db_worker.insert_trade_history` 는 멱등이 아니고 DLQ 가 기록 계열을
|
||
24시간 내 재큐잉한다). **`order_id` 단위 중복 제거로 막았으나**
|
||
`order_id` 가 NULL 인 행은 중복 판정이 불가능해 남는다.
|
||
② 비DT active ActiveTrade 가 없음 — 정합 루프가 `_actual == 0` 으로 closed
|
||
시킨 뒤 재생성되지 않은 경우.
|
||
[2026-09-30] `db_worker` 의 `close_active_trade` 가 상태 계열에서 빠지고
|
||
**충돌 판정만** 받게 바뀌어(나이 상한 7일) 유령 active 행이 줄었다 —
|
||
"닫아라" 지시가 시간이 지나도 거짓이 되지 않으므로 유실이 줄고, 동시에
|
||
유령 행이 유입 잔고를 "시스템 보유" 로 오인시키는 경로도 약해졌다.
|
||
즉 이 하한의 **정확도가 양방향으로 올라갔다**.
|
||
③ DT 인메모리에도 없음 (스윙 포지션이므로 정상)
|
||
④ registry TTL(15분) 경과
|
||
|
||
①~④ 동시 성립은 확률이 낮지만 **불가능하지는 않다.** 발생하면 그 포지션은 자동
|
||
손절·익절에서 빠지고 `/portfolio/summary` 의 `external_stop_breaches` 와
|
||
`🚧 유입 잔고 격리` warning 으로 노출된다(조용히 사라지지는 않는다).
|
||
근본 해결은 `insert_trade_history` 의 멱등화(order_id UNIQUE)이며 별도 작업이다.
|
||
"""
|
||
_base = _classify_holding_strategy_type(stock_code, quantity, dt_qty, system_codes)
|
||
if _base != "swing":
|
||
return _base
|
||
_dt_held = int(dt_qty.get(stock_code, 0) or 0)
|
||
_sys = int(system_qty.get(stock_code, 0) or 0)
|
||
if _sys <= 0 < int(quantity):
|
||
# 근거가 '있었지만' 수량이 남아 있지 않다 → 남은 수량은 유입분이다.
|
||
# [2026-09-30] 이 분기가 없으면 DT 가 EOD 청산한 뒤에도 과거 BUY 기록 때문에
|
||
# `system_codes` 멤버십이 영구히 유지돼 순수 유입분이 swing 으로 되돌아간다
|
||
# (105560 사건의 후속 경로 — 팀리드 요구사항 2).
|
||
# 과소 집계 위험은 호출부가 막는다: 원장 net 외에 **비시딩 ActiveTrade 잔량**,
|
||
# **DT 인메모리 수량**, **system_buy_registry 주문 수량**(원장 INSERT 전)을
|
||
# 모두 하한으로 합성하므로, 살아 있는 시스템 포지션은 _sys > 0 이 보장된다.
|
||
return EXTERNAL_STRATEGY_TYPE
|
||
if 0 < _sys < int(quantity) and _sys == _dt_held:
|
||
# 혼재 + 시스템 몫이 전부 DT → 격리 유지 (DT 는 인메모리로 자기 수량 관리)
|
||
return EXTERNAL_STRATEGY_TYPE
|
||
return "swing"
|
||
|
||
|
||
def _system_buy_codes_stmt():
|
||
"""시스템 매수 근거가 있는 종목코드 statement (수량 무관) — `(stock_code,)`.
|
||
|
||
status 는 **거부목록**으로 판정한다(`_NON_FILL_BUY_STATUSES`) — 허용목록은 값이
|
||
하나 빠지는 순간 정상 보유를 강등시킨다. 기간 제한은 두지 않는다: ghost 필터처럼
|
||
당일만 보면 자정을 넘긴 유입 잔고가 다시 정상 보유로 재편입된다.
|
||
"""
|
||
return (
|
||
select(TradeHistory.stock_code)
|
||
.where(
|
||
TradeHistory.order_type == "BUY",
|
||
or_(
|
||
TradeHistory.status.is_(None),
|
||
TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES),
|
||
),
|
||
)
|
||
.distinct()
|
||
)
|
||
|
||
|
||
def _evidence_active_trade_codes_stmt():
|
||
"""'시스템 매수 근거' 로 인정하는 ActiveTrade 종목코드 statement — `(stock_code,)`.
|
||
|
||
ActiveTrade 행도 근거로 인정한다(체결 이력 유실 대비). 단 sync 가 유입 포지션에
|
||
대해 자동 시딩한 행은 근거가 아니다 — 근거로 쓰면 external → swing 자동 승격이라는
|
||
자기순환이 생긴다. 시딩 판별은 **두 시기 모두** 걸러야 한다:
|
||
|
||
· `strategy_name == 'synced'` (2026-09-29 `7abbb34` 이후 규약)
|
||
· `entry_reason LIKE '외부 유입 포지션 — 동기화 시 자동 등록%'`
|
||
(그 이전 `6207d13` 는 `strategy_name = Portfolio.strategy_type` 즉 제네릭
|
||
'swing' 으로 시딩해 이름만으로는 구별이 불가능하다)
|
||
|
||
entry_reason 필터는 **양방향**으로 필요하다: 빼면 유입 잔고가 자기 시딩 행을
|
||
근거로 삼아 ① 강등이 영구 불가해지고 ② 이미 격리된 행이 오승격된다.
|
||
"""
|
||
return (
|
||
select(ActiveTrade.stock_code)
|
||
.where(
|
||
or_(
|
||
ActiveTrade.strategy_name.is_(None),
|
||
ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES),
|
||
),
|
||
or_(
|
||
ActiveTrade.entry_reason.is_(None),
|
||
ActiveTrade.entry_reason.notlike(
|
||
f"{_SEEDED_AT_ENTRY_REASON_PREFIX}%"
|
||
),
|
||
),
|
||
)
|
||
.distinct()
|
||
)
|
||
|
||
|
||
def _system_buy_qty_stmt():
|
||
"""원장 BUY 수량 합계 statement — `(stock_code, buy_qty)`.
|
||
|
||
**과대 집계 편향**: `executed_quantity` 가 0/NULL 인 PENDING 행은 `order_quantity`
|
||
로 센다 — 주문접수 후 체결 확인이 실패하면 주식은 보유 중인데 executed 가 0 으로
|
||
남는다. 수량이 크게 나오면 "시스템 보유분" 으로 판정돼 관리를 유지하므로 오판의
|
||
방향이 안전하다(CLAUDE.md §8.1-11).
|
||
|
||
BUY 는 `order_id` 중복 제거를 **하지 않는다** — 중복이 있으면 수량이 커지고, 그
|
||
방향은 안전하다. (SELL 은 반대이므로 제거한다: `_system_sell_qty_stmt`)
|
||
"""
|
||
_q = func.coalesce(
|
||
func.nullif(TradeHistory.executed_quantity, 0),
|
||
TradeHistory.order_quantity,
|
||
0,
|
||
)
|
||
return (
|
||
select(TradeHistory.stock_code, func.sum(_q))
|
||
.where(
|
||
TradeHistory.order_type == "BUY",
|
||
or_(
|
||
TradeHistory.status.is_(None),
|
||
TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES),
|
||
),
|
||
)
|
||
.group_by(TradeHistory.stock_code)
|
||
)
|
||
|
||
|
||
def _system_sell_qty_stmt():
|
||
"""원장 SELL 수량 statement — `(stock_code, dedupe_key, qty)`. 합산은 호출부에서.
|
||
|
||
## SELL 은 **양방향 모두 위험**하다 — 정확해야 한다 (2026-09-30 정정)
|
||
|
||
이전 판 docstring 은 "SELL 과대 집계는 위험, 과소는 안전" 이라고 썼는데 **틀렸다.**
|
||
강등만 있던 시점의 논증이었고, 같은 작업에서 **승격 경로가 함께 살아났기 때문에**
|
||
양쪽이 다 위험하다:
|
||
|
||
· **과대 집계**(중복 차감) → `_sys` 축소 → 정상 포지션이 유입분으로 보여 **강등**
|
||
(손절·익절 상실)
|
||
· **과소 집계**(중복 접힘) → `_sys` 확대 → 유입 행이 `_sys >= quantity` 로 보여
|
||
**승격**(유입분이 스윙 자동 손절 대상) + 근거가 계속 보여 강등 불가(고착)
|
||
|
||
## 중복 제거 키
|
||
|
||
`db_worker.insert_trade_history` 는 멱등이 아니고 DLQ 가 기록 계열 태스크를
|
||
재큐잉하므로 같은 매도가 두 행으로 들어올 수 있다. 같은 KIS 주문은 `order_id` 가
|
||
같으므로 그 단위로 1건만 센다.
|
||
|
||
⚠️ **`order_id` 는 NULL 이 아니라 빈 문자열 `''` 로 저장된다.**
|
||
`trade_helpers.build_kis_order_id()` 가 미발견 시 `""` 를 반환하고
|
||
(`swing/exit_service.py` 의 `.get("ODNO", "")` 도 동일), 그 행은
|
||
`status="COMPLETED"` + `executed_quantity` 가 채워져 FAILED 필터에 걸리지 않는다.
|
||
따라서 `coalesce(order_id, ...)` 만으로는 `''` 를 통과시켜 **서로 다른 매도들이 한
|
||
그룹으로 접히고 `max()` 만 남는다**(분할매도 2회로 재현). `nullif(trim(...), '')`
|
||
로 빈 문자열·공백 문자열을 NULL 로 환산한 뒤 `id` 폴백을 쓴다.
|
||
`trim` 을 키에 쓰므로 패딩만 다른 같은 주문번호도 같은 키가 된다.
|
||
|
||
`order_id` 가 비어 있는 행은 중복 판정이 불가능해 `id` 로 개별 계상된다 = 중복
|
||
차감 위험이 남는 유일한 경로다(`_classify_holding_strategy_type_v2` docstring 의
|
||
'한계' 항에 명시).
|
||
|
||
확정 체결(`executed_quantity`)만 차감한다 — 미체결 매도를 차감하면 보유분을
|
||
과소 집계한다.
|
||
"""
|
||
_key = func.coalesce(
|
||
func.nullif(func.trim(TradeHistory.order_id), ""),
|
||
func.concat("id:", TradeHistory.id),
|
||
)
|
||
return (
|
||
select(
|
||
TradeHistory.stock_code,
|
||
_key,
|
||
func.max(func.coalesce(TradeHistory.executed_quantity, 0)),
|
||
)
|
||
.where(
|
||
TradeHistory.order_type == "SELL",
|
||
or_(
|
||
TradeHistory.status.is_(None),
|
||
TradeHistory.status.notin_(_NON_FILL_BUY_STATUSES),
|
||
),
|
||
)
|
||
.group_by(TradeHistory.stock_code, _key)
|
||
)
|
||
|
||
|
||
def _active_trade_qty_stmt():
|
||
"""비시딩·**비DT** active ActiveTrade 잔량 statement — `(stock_code, qty)`.
|
||
|
||
## 왜 DT 행을 제외하는가 (C-1, 2026-09-30)
|
||
|
||
`sync_portfolio` 의 ActiveTrade 정합 루프는 "같은 종목 active 행이 1개면 그 행이
|
||
전량을 대표한다"고 보고 **Portfolio 수량으로 교정**한다. 유입 3주 + DT 3주 = 6주
|
||
행에서 active 행은 DT 1개뿐이므로, DT 행 수량이 **3 → 6 으로 부풀려진다.**
|
||
그 값을 근거 수량으로 쓰면 다음 sync 에서 `_sys = 6 = quantity` 가 되어
|
||
**유입분이 swing 으로 승격되고, 승격 후에는 근거가 계속 보여 강등이 영구 불가**
|
||
(고착)가 된다. 즉 DT ActiveTrade 잔량은 구조적으로 'DT 소유 수량' 의 근거가 될
|
||
수 없다.
|
||
|
||
DT 의 정본 수량은 `day_trading_state.positions`(인메모리)이고 호출부가 그것을
|
||
별도 하한으로 합성한다. 여기서는 DT 행을 뺀다.
|
||
|
||
DT 판별은 `db_worker.not_dt_strategy_clause()` **단일 정본**을 쓴다
|
||
(CLAUDE.md §3.3 — 한 곳만 다르게 고치면 더 나쁜 불일치가 된다).
|
||
|
||
`status == "active"` 한정이고 집계는 `max` 다(`sum` 아님): 2026-09-23 에
|
||
active_trades 유령 행 129개가 실재했으므로(§8.4.1) 중복 행이 수량을 부풀리면
|
||
유입분이 시스템 보유로 오인된다.
|
||
"""
|
||
from app.services.db_worker import not_dt_strategy_clause
|
||
|
||
return (
|
||
select(ActiveTrade.stock_code, func.max(ActiveTrade.quantity))
|
||
.where(
|
||
ActiveTrade.status == "active",
|
||
not_dt_strategy_clause(ActiveTrade.strategy_name),
|
||
or_(
|
||
ActiveTrade.strategy_name.is_(None),
|
||
ActiveTrade.strategy_name.notin_(_SEEDED_AT_STRATEGY_NAMES),
|
||
),
|
||
or_(
|
||
ActiveTrade.entry_reason.is_(None),
|
||
ActiveTrade.entry_reason.notlike(
|
||
f"{_SEEDED_AT_ENTRY_REASON_PREFIX}%"
|
||
),
|
||
),
|
||
)
|
||
.group_by(ActiveTrade.stock_code)
|
||
)
|
||
|
||
|
||
def _compose_system_quantities(
|
||
buy_rows,
|
||
sell_rows,
|
||
at_rows,
|
||
dt_qty: dict,
|
||
registry_qty: dict,
|
||
) -> dict:
|
||
"""시스템 매수 근거 **수량** {code: qty} 합성 (순수 함수).
|
||
|
||
원장 net(BUY − SELL)을 기본값으로 하고, 세 소스를 **하한(max)** 으로 덮는다.
|
||
하한이 여러 개인 이유는 어느 하나가 무너져도 살아 있는 시스템 포지션의 수량이
|
||
0 으로 보이지 않게 하기 위함이다(= 정상 포지션이 유입분으로 오인되지 않는다).
|
||
|
||
Args:
|
||
buy_rows: `_system_buy_qty_stmt()` 결과 `[(code, qty), ...]`
|
||
sell_rows: `_system_sell_qty_stmt()` 결과 `[(code, key, qty), ...]` — 키 단위
|
||
행이므로 여기서 종목별로 합산한다(order_id 중복은 이미 제거됨)
|
||
at_rows: `_active_trade_qty_stmt()` 결과 `[(code, qty), ...]`
|
||
dt_qty: DT 인메모리 보유 수량
|
||
registry_qty: `system_buy_registry.snapshot_quantities()` (원장 INSERT 전 창)
|
||
|
||
Returns:
|
||
dict[str, int]
|
||
"""
|
||
out: dict = {}
|
||
for _c, _q in buy_rows:
|
||
if _c:
|
||
out[_c] = out.get(_c, 0) + int(_q or 0)
|
||
# (code, dedupe_key) 단위로 1건만 센다. SQL 이 이미 GROUP BY 로 접었지만 여기서도
|
||
# 접어야 순수 함수만으로 중복 제거를 검증할 수 있다(그리고 두 겹이 손해가 없다).
|
||
_sold_by_key: dict = {}
|
||
for _row in sell_rows:
|
||
_c, _q = _row[0], _row[-1]
|
||
if not _c:
|
||
continue
|
||
_k = _row[1] if len(_row) >= 3 else None
|
||
# SQL 과 같은 규칙: 빈 문자열·공백 키는 '중복 판정 불가' 이므로 접지 않는다.
|
||
# (order_id 는 NULL 이 아니라 '' 로 저장된다 — _system_sell_qty_stmt docstring)
|
||
if _k is None or not str(_k).strip():
|
||
_sold_by_key[(_c, f"__row{len(_sold_by_key)}__")] = int(_q or 0)
|
||
continue
|
||
_k = str(_k).strip()
|
||
_sold_by_key[(_c, _k)] = max(_sold_by_key.get((_c, _k), 0), int(_q or 0))
|
||
_sold: dict = {}
|
||
for (_c, _k), _q in _sold_by_key.items():
|
||
_sold[_c] = _sold.get(_c, 0) + _q
|
||
for _c, _q in _sold.items():
|
||
out[_c] = out.get(_c, 0) - _q
|
||
out = {_c: max(0, _q) for _c, _q in out.items()}
|
||
for _src in (at_rows, (dt_qty or {}).items(), (registry_qty or {}).items()):
|
||
for _c, _q in _src:
|
||
if _c:
|
||
out[_c] = max(out.get(_c, 0), int(_q or 0))
|
||
return out
|
||
|
||
|
||
def _should_correct_active_trade_quantity(
|
||
portfolio_strategy_type: str,
|
||
active_row_count: int,
|
||
actual_quantity: int,
|
||
system_quantity: int = None,
|
||
) -> bool:
|
||
"""ActiveTrade 잔량을 Portfolio 수량으로 교정해도 되는가 (순수 함수).
|
||
|
||
교정의 전제는 **"active 행 1개 = 그 행이 Portfolio 수량 전량을 대표한다"** 다.
|
||
`strategy_type == 'external'` 행에서는 그 전제가 거짓이다 — 행 수량에 시스템이
|
||
소유하지 않은 유입분이 섞여 있다. 그런 행에서 교정하면
|
||
|
||
· 유입 3주 + DT 3주 = 6주 → DT ActiveTrade 가 3 → 6 으로 부풀려지고
|
||
· 그 값이 다음 sync 의 '시스템 근거 수량' 이 되어 유입분이 swing 으로 승격되며
|
||
(승격 후에는 근거가 계속 보여 강등이 영구 불가 = 고착)
|
||
· DT 청산 시 잔량 6 으로 과다매도·주문거부 위험(CLAUDE.md §3.5)
|
||
|
||
**유령 행 정리(`actual_quantity == 0` → closed)는 이 함수와 무관하게 계속 수행해야
|
||
한다** — 그쪽은 "보유가 없다"는 사실만 쓰고 수량 귀속을 전제하지 않는다.
|
||
호출부가 `actual_quantity == 0` 분기를 먼저 처리하므로 여기서는 방어적으로만 본다.
|
||
|
||
Args:
|
||
portfolio_strategy_type: `Portfolio.strategy_type`
|
||
active_row_count: 그 종목의 status='active' ActiveTrade 행 수
|
||
actual_quantity: 실제 보유 수량(= Portfolio 수량)
|
||
system_quantity: 시스템 매수 근거 수량. **durable 값**(레지스트리 제외)을 넘긴다
|
||
— 휘발성 근거로 ActiveTrade 수량을 영구 변경하면 안 된다.
|
||
`None`(미제공)은 "전제 검증 불가" 로 해석해 **교정하지 않는다**.
|
||
|
||
Returns:
|
||
bool: True 면 교정 가능.
|
||
"""
|
||
if int(active_row_count) != 1:
|
||
return False # 스윙+DT 혼재 등 → 자동 교정하지 않음 (기존 규칙)
|
||
if int(actual_quantity) <= 0:
|
||
return False # 정리(closed) 대상 — 수량 교정 경로가 아니다
|
||
if (portfolio_strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
|
||
return False # 유입분이 섞여 있어 수량을 어느 전략에도 귀속시킬 수 없다
|
||
# [M-3 2026-09-30] **전제를 직접 검증한다.** 태그만 보는 것으로는 부족했다 —
|
||
# 유입 3 + 스윙 3 = 6 행은 `_classify_holding_strategy_type_v2` 가 `swing` 을
|
||
# 유지하므로(혼재 분기는 `_sys == _dt_held` 일 때만 발동) external 예외를
|
||
# 빠져나가 교정이 통과했고, 스윙 ActiveTrade 가 3 → 6 으로 부풀어
|
||
# ① 다음 스윙 청산이 **유입 3주까지 매도**(§3.5 과다매도·주문거부, §8.1-11 위반)
|
||
# ② 부풀린 6 이 `_active_trade_qty_stmt` 하한으로 돌아와 `_sys = 6 = quantity`
|
||
# → 승격 후 근거가 계속 보여 **영구 고착**
|
||
# 이 되었다. 근거 수량이 행 수량을 덮을 때만 "그 행이 전량을 대표한다"가 참이다.
|
||
if system_quantity is None:
|
||
return False # 근거 수량 미제공 → 전제를 검증할 수 없다 → 교정하지 않는다
|
||
if int(system_quantity) < int(actual_quantity):
|
||
return False # 설명되지 않는 수량(= 유입 추정분)이 남아 있다
|
||
return True
|
||
|
||
|
||
def _reconcile_active_trade_quantities(
|
||
active_rows_by_code: dict,
|
||
held_qty: dict,
|
||
strategy_by_code: dict,
|
||
system_qty: dict = None,
|
||
) -> list:
|
||
"""active ActiveTrade 행을 실제 보유 수량에 맞춘다 (ORM 객체를 **제자리 수정**).
|
||
|
||
2026-09-23 버그픽스의 본체다 — 분할매도·수동매도 누락으로 DB 잔량이 실제와
|
||
달라지면 다음 청산이 과다매도가 된다. `sync_portfolio` 에서 분리한 이유는 소스
|
||
문자열이 아니라 **동작으로 검증**할 수 있게 하기 위함이다(M-5).
|
||
|
||
두 가지를 한다:
|
||
1. **유령 행 정리** — 보유 수량이 0 이면 `status='closed'`. 태그와 무관하게
|
||
**항상** 수행한다(수량 귀속을 전제하지 않는 판정이므로).
|
||
2. **잔량 교정** — `_should_correct_active_trade_quantity()` 가 허용할 때만
|
||
`row.quantity = 실제 보유량`.
|
||
|
||
Args:
|
||
active_rows_by_code: {stock_code: [status=='active' ActiveTrade 행, ...]}
|
||
held_qty: {stock_code: Portfolio 수량} (수량 > 0 인 행만)
|
||
strategy_by_code: {stock_code: Portfolio.strategy_type}
|
||
system_qty: {stock_code: 근거 수량} — **durable 값**(레지스트리 제외).
|
||
미제공이면 어떤 행도 수량 교정하지 않는다(전제 검증 불가 → 보류).
|
||
|
||
Returns:
|
||
list[dict]: 수행한 조치 `{"action": "closed"|"corrected"|"skipped_external",
|
||
"stock_code", "from", "to"}` — 호출부가 로그로 남긴다(§4: debug 금지).
|
||
"""
|
||
actions: list = []
|
||
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 len(_rows) == 1 and _actual == 0:
|
||
_rows[0].status = "closed"
|
||
actions.append({"action": "closed", "stock_code": _code,
|
||
"from": int(_rows[0].quantity or 0), "to": 0})
|
||
continue
|
||
_sys = None if system_qty is None else int(system_qty.get(_code, 0) or 0)
|
||
if not _should_correct_active_trade_quantity(_tag, len(_rows), _actual, _sys):
|
||
if len(_rows) == 1 and int(_rows[0].quantity or 0) != _actual:
|
||
# 태그가 external 이든 혼재 swing 이든 "설명되지 않는 수량이 있어
|
||
# 교정을 보류했다" 는 사실은 같다 — 조용히 넘기지 않는다(§4).
|
||
actions.append({
|
||
"action": "skipped_unattributable",
|
||
"stock_code": _code,
|
||
"from": int(_rows[0].quantity or 0),
|
||
"to": _actual,
|
||
"strategy_type": _tag,
|
||
"system_qty": _sys,
|
||
})
|
||
continue
|
||
_row = _rows[0]
|
||
if int(_row.quantity or 0) != _actual:
|
||
actions.append({"action": "corrected", "stock_code": _code,
|
||
"from": int(_row.quantity or 0), "to": _actual})
|
||
_row.quantity = _actual
|
||
return actions
|
||
|
||
|
||
def _mixed_inflow_surplus(stock_code: str, quantity: int, system_qty: dict) -> int:
|
||
"""행 수량 중 시스템 근거로 설명되지 않는 수량(= 유입 추정분). 순수 함수.
|
||
|
||
가시화(warning) 전용이다 — 집행 판단에 쓰지 않는다. 원장 과대 집계 편향 때문에
|
||
실제 유입분보다 **작게** 나올 수 있다(0 이면 "혼재 아님"이 아니라 "확증 불가").
|
||
"""
|
||
return max(0, int(quantity) - int(system_qty.get(stock_code, 0) or 0))
|
||
|
||
# Request/Response Models
|
||
class SellRequest(BaseModel):
|
||
stock_code: str
|
||
quantity: int = Field(gt=0, description="매도 수량 (1 이상)")
|
||
price: Optional[int] = Field(None, gt=0, description="지정가 (None이면 시장가)")
|
||
|
||
def _get_kst_now():
|
||
"""현재 한국 시간 반환 (timezone-naive datetime)"""
|
||
return datetime.now(KST).replace(tzinfo=None)
|
||
|
||
|
||
def _pick_float(d: dict, keys: list) -> float:
|
||
"""Helper to extract float value from dict by key priority."""
|
||
for k in keys:
|
||
if k in d and d.get(k) not in (None, ""):
|
||
try:
|
||
return float(d[k])
|
||
except Exception:
|
||
continue
|
||
return 0.0
|
||
|
||
|
||
def _calculate_portfolio_values(holdings: list) -> dict:
|
||
"""Calculate total value, investment, and profit/loss from holdings.
|
||
|
||
Returns dict with total_stocks, total_value, total_investment, total_profit_loss,
|
||
total_profit_loss_rate, domestic_value, overseas_value_usd.
|
||
"""
|
||
total_stocks = len(holdings)
|
||
|
||
# 국내/해외 분리하여 평가금액 계산
|
||
domestic_value = sum(
|
||
float(h.current_price or 0) * h.quantity
|
||
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
|
||
)
|
||
overseas_value_usd = sum(
|
||
float(h.current_price or 0) * h.quantity
|
||
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
|
||
)
|
||
|
||
# 국내/해외 투자금을 분리 계산 (통화 혼합 방지)
|
||
domestic_investment = sum(
|
||
float(h.average_price or 0) * h.quantity
|
||
for h in holdings if _is_domestic(h.stock_code, getattr(h, "market", None))
|
||
)
|
||
overseas_investment_usd = sum(
|
||
float(h.average_price or 0) * h.quantity
|
||
for h in holdings if not _is_domestic(h.stock_code, getattr(h, "market", None))
|
||
)
|
||
|
||
# 국내 손익 (KRW 단위)
|
||
domestic_profit_loss = domestic_value - domestic_investment
|
||
# 해외 손익 (USD 단위) — 원화 환산은 cache_summary()에서 환율 적용 시 수행
|
||
overseas_profit_loss_usd = overseas_value_usd - overseas_investment_usd
|
||
|
||
# 하위 호환: total_investment 은 국내만 (기존 호출처 동작 유지)
|
||
total_investment = domestic_investment
|
||
total_profit_loss = domestic_profit_loss
|
||
|
||
# 유입 격리(strategy_type='external') 보유분 — 자동 청산 대상에서 빠진 잔고.
|
||
# 총자산·평가액에서 빼지 않는다(계좌 전체 리스크는 이 잔고를 포함해야 한다).
|
||
_external = [
|
||
h for h in holdings
|
||
if (getattr(h, "strategy_type", None) or "swing") == "external"
|
||
]
|
||
external_count = len(_external)
|
||
# 국내 종목은 KRW, 해외 종목은 USD 원본 통화 — 환산은 하지 않는다(표시 전용).
|
||
external_value = sum(float(h.current_price or 0) * h.quantity for h in _external)
|
||
|
||
return {
|
||
"external_count": external_count,
|
||
"external_value": external_value,
|
||
# 손절선 이탈 격리분 — 집행은 하지 않되 프론트·로그에서 반드시 보이게 한다
|
||
"external_stop_breaches": _external_stop_breaches(_external),
|
||
"total_stocks": total_stocks,
|
||
"domestic_value": domestic_value,
|
||
"overseas_value_usd": overseas_value_usd,
|
||
"domestic_investment": domestic_investment,
|
||
"overseas_investment_usd": overseas_investment_usd,
|
||
"domestic_profit_loss": domestic_profit_loss,
|
||
"overseas_profit_loss_usd": overseas_profit_loss_usd,
|
||
"total_investment": total_investment,
|
||
"total_profit_loss": total_profit_loss,
|
||
}
|
||
|
||
|
||
def _extract_balance_data(balance_data: dict) -> dict:
|
||
"""Extract cash and total assets from KIS API balance response.
|
||
|
||
Returns dict with available_cash, total_assets, kis_total_assets, output2.
|
||
"""
|
||
available_cash = 0.0
|
||
total_assets = 0.0
|
||
kis_total_assets = 0.0
|
||
output2 = {}
|
||
|
||
if balance_data:
|
||
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys()}")
|
||
if "output2" in balance_data:
|
||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||
logger.info(f"📊 output2 keys: {output2.keys()}")
|
||
logger.info(f"💰 output2 data: {output2}")
|
||
else:
|
||
logger.warning("⚠️ No 'output2' in balance_data!")
|
||
|
||
if balance_data and "output2" in balance_data:
|
||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||
|
||
# 예수금 우선순위 (2026-09-23):
|
||
# _ord_psbl_cash — 매수가능조회로 받은 주문가능현금(당일 매도대금 포함)
|
||
# prvs_rcdl_excc_amt — 가수도정산금액(주문가능). 데이트레이딩 엔진도 이 값을 쓴다
|
||
# nxdy_excc_amt — 익일정산금액
|
||
# dnca_tot_amt — 예수금총액(D+2 기준). 당일 매수가 많으면 음수가 되므로 최후순위
|
||
# 음수/0은 건너뛰고 첫 양수를 사용한다(화면에 예수금 -913,965원이 뜨던 문제).
|
||
_cash_keys = [
|
||
"_ord_psbl_cash",
|
||
"prvs_rcdl_excc_amt",
|
||
"nxdy_excc_amt",
|
||
"d2_depo_amt",
|
||
"d2_deposit",
|
||
"dnca_tot_amt",
|
||
"nass_amt",
|
||
]
|
||
available_cash = 0.0
|
||
for _k in _cash_keys:
|
||
_v = _pick_float(output2, [_k])
|
||
if _v > 0:
|
||
available_cash = _v
|
||
if _k != "_ord_psbl_cash":
|
||
logger.debug(f"예수금 필드 선택: {_k}={_v:,.0f}")
|
||
break
|
||
|
||
kis_total_assets = _pick_float(output2, ["tot_evlu_amt", "tot_evlu_amt_smtl"])
|
||
|
||
if kis_total_assets > 0:
|
||
total_assets = kis_total_assets
|
||
|
||
try:
|
||
logger.debug(
|
||
"Portfolio balance extract - cash=%s, total_assets=%s, keys=%s",
|
||
f"{available_cash:,.0f}",
|
||
f"{total_assets:,.0f}",
|
||
{k: output2.get(k) for k in [
|
||
"d2_depo_amt", "d2_deposit", "dnca_tot_amt", "nass_amt", "tot_evlu_amt"
|
||
] if k in output2}
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"available_cash": available_cash,
|
||
"total_assets": total_assets,
|
||
"kis_total_assets": kis_total_assets,
|
||
"output2": output2,
|
||
}
|
||
|
||
|
||
def _extract_detailed_balance(balance_data: dict) -> dict:
|
||
"""Extract D+2 settlement and stock value details from KIS API response."""
|
||
d2_settlement = 0.0
|
||
stock_value_from_api = 0.0
|
||
bfdy_total_assets = 0.0
|
||
|
||
if balance_data and "output2" in balance_data:
|
||
output2 = balance_data["output2"][0] if isinstance(balance_data["output2"], list) else balance_data["output2"]
|
||
d2_settlement = _pick_float(output2, ["nxdy_excc_amt", "d2_auto_rdpt_amt"])
|
||
stock_value_from_api = _pick_float(output2, ["scts_evlu_amt", "evlu_amt_smtl_amt"])
|
||
bfdy_total_assets = _pick_float(output2, ["bfdy_tot_asst_evlu_amt"])
|
||
|
||
return {
|
||
"d2_settlement": d2_settlement,
|
||
"stock_value_from_api": stock_value_from_api,
|
||
"bfdy_total_assets": bfdy_total_assets,
|
||
}
|
||
|
||
|
||
def _extract_overseas_balance(overseas_balance_data: dict) -> dict:
|
||
"""Extract overseas holdings total from KIS API response."""
|
||
overseas_total_krw = 0.0
|
||
|
||
if overseas_balance_data and "output2" in overseas_balance_data:
|
||
ovrs_out2 = overseas_balance_data["output2"]
|
||
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"])
|
||
if overseas_total_krw > 0:
|
||
logger.debug(f"💵 해외 잔고 합산: {overseas_total_krw:,.0f}원")
|
||
|
||
return {"overseas_total_krw": overseas_total_krw}
|
||
|
||
|
||
# 단순 인메모리 캐시 (프로세스 단위). 다중 프로세스 환경이면 Redis 등 필요.
|
||
class PortfolioCache:
|
||
def __init__(self):
|
||
self.holdings = [] # List[Portfolio]
|
||
self.summary = None # Dict
|
||
self.last_synced: datetime | None = None
|
||
self.change_flag = False # 외부 이벤트로 변경 감지 (매수/매도 등)
|
||
self.sync_lock = asyncio.Lock()
|
||
self.summary_lock = asyncio.Lock() # summary 수정 보호용 락
|
||
self.min_interval = timedelta(seconds=15) # 최소 동기화 간격
|
||
self.last_attempt: datetime | None = None
|
||
self.last_error: str | None = None
|
||
# 실패 관리 (백오프)
|
||
self.failure_count: int = 0
|
||
self.next_retry_at: datetime | None = None
|
||
self.last_sync_success: bool = False
|
||
self.last_trade_id: int | None = None
|
||
# 최근 수동 매도 {code: (qty, ts)} — KIS 잔고 반영 지연으로 동기화가 판 종목을
|
||
# 되살리는 것 방지 (5분 TTL)
|
||
self.recent_sells: dict[str, tuple[int, datetime]] = {}
|
||
|
||
def note_sell(self, stock_code: str, quantity: int) -> None:
|
||
self.recent_sells[stock_code] = (quantity, datetime.now())
|
||
|
||
def recently_sold_qty(self, stock_code: str, ttl_sec: int = 300) -> int:
|
||
rec = self.recent_sells.get(stock_code)
|
||
if not rec:
|
||
return 0
|
||
qty, ts = rec
|
||
if (datetime.now() - ts).total_seconds() > ttl_sec:
|
||
self.recent_sells.pop(stock_code, None)
|
||
return 0
|
||
return qty
|
||
|
||
def mark_changed(self):
|
||
self.change_flag = True
|
||
|
||
def get_summary_copy(self) -> dict | None:
|
||
"""summary의 스냅샷 반환 (락 없이, 읽기 전용).
|
||
summary가 수정 중일 수 있으므로 복사본을 반환."""
|
||
if self.summary is None:
|
||
return None
|
||
return dict(self.summary)
|
||
|
||
async def apply_buy(self, stock_code: str, stock_name: str, quantity: int, price: float):
|
||
"""매수 체결 즉시 캐시 반영 — KIS 재동기화 전까지 summary 수치 정확하게 유지"""
|
||
async with self.summary_lock:
|
||
if self.summary is None:
|
||
self.mark_changed()
|
||
return
|
||
cost = quantity * price
|
||
# 예수금 차감
|
||
self.summary["available_cash"] = max(0.0, float(self.summary.get("available_cash", 0)) - cost)
|
||
# 주식 평가금액 증가
|
||
self.summary["total_value"] = float(self.summary.get("total_value", 0)) + cost
|
||
# 매입금액 증가
|
||
self.summary["total_investment"] = float(self.summary.get("total_investment", 0)) + cost
|
||
# 총 자산 = 예수금 + 주식평가 (재계산)
|
||
self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"]
|
||
# 평가손익 재계산
|
||
inv = self.summary["total_investment"]
|
||
val = self.summary["total_value"]
|
||
self.summary["total_profit_loss"] = val - inv
|
||
self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0
|
||
self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0])
|
||
self.mark_changed()
|
||
|
||
async def apply_sell(self, stock_code: str, quantity: int, price: float, avg_price: float):
|
||
"""매도 체결 즉시 캐시 반영"""
|
||
async with self.summary_lock:
|
||
if self.summary is None:
|
||
self.mark_changed()
|
||
return
|
||
proceeds = quantity * price
|
||
cost = quantity * avg_price
|
||
# 예수금 증가
|
||
self.summary["available_cash"] = float(self.summary.get("available_cash", 0)) + proceeds
|
||
# 주식 평가금액 감소
|
||
self.summary["total_value"] = max(0.0, float(self.summary.get("total_value", 0)) - proceeds)
|
||
# 매입금액 감소
|
||
self.summary["total_investment"] = max(0.0, float(self.summary.get("total_investment", 0)) - cost)
|
||
# 총 자산 = 예수금 + 주식평가
|
||
self.summary["total_assets"] = self.summary["available_cash"] + self.summary["total_value"]
|
||
# 평가손익 재계산
|
||
inv = self.summary["total_investment"]
|
||
val = self.summary["total_value"]
|
||
self.summary["total_profit_loss"] = val - inv
|
||
self.summary["total_profit_loss_rate"] = ((val - inv) / inv * 100) if inv > 0 else 0
|
||
self.summary["total_stocks"] = len([h for h in self.holdings if h.quantity > 0])
|
||
self.mark_changed()
|
||
|
||
def should_sync(self) -> bool:
|
||
now = datetime.now()
|
||
# 최초 또는 변경 플래그 또는 최소간격 경과 후 오류 상태 재시도
|
||
if self.last_synced is None:
|
||
return True
|
||
if self.change_flag:
|
||
return True
|
||
# 오류 발생 시 백오프 기간에는 재시도 금지
|
||
if self.last_error:
|
||
if self.next_retry_at and now < self.next_retry_at:
|
||
return False
|
||
# 백오프가 지났으면 재시도 허용 (최소 간격과 무관)
|
||
return True
|
||
if (now - self.last_synced) > self.min_interval: # min_interval(15초)마다 갱신
|
||
return True
|
||
return False
|
||
|
||
def _compute_backoff(self) -> timedelta:
|
||
# 30s, 60s, 120s, max 5min
|
||
base = 30
|
||
delay = base * (2 ** max(self.failure_count - 1, 0))
|
||
delay = min(delay, 300)
|
||
return timedelta(seconds=delay)
|
||
|
||
def record_failure(self, err: Exception | str):
|
||
self.last_error = str(err)
|
||
self.failure_count += 1
|
||
self.next_retry_at = datetime.now() + self._compute_backoff()
|
||
self.last_sync_success = False
|
||
|
||
def record_success(self):
|
||
self.last_error = None
|
||
self.failure_count = 0
|
||
self.next_retry_at = None
|
||
self.last_sync_success = True
|
||
|
||
async def cache_summary(self, balance_data: dict = None, overseas_balance_data: dict = None, exchange_rate: float = 0.0):
|
||
"""포트폴리오 요약 캐시 업데이트.
|
||
|
||
Args:
|
||
balance_data: 국내 잔고 KIS API 응답
|
||
overseas_balance_data: 해외 잔고 KIS API 응답
|
||
exchange_rate: USD/KRW 환율 (해외 종목 원화 환산용, 0이면 미제공)
|
||
"""
|
||
# 1. Calculate portfolio values from holdings
|
||
holdings = self.holdings
|
||
portfolio_values = _calculate_portfolio_values(holdings)
|
||
|
||
domestic_value = portfolio_values["domestic_value"]
|
||
overseas_value_usd = portfolio_values["overseas_value_usd"]
|
||
domestic_investment = portfolio_values["domestic_investment"]
|
||
overseas_investment_usd = portfolio_values["overseas_investment_usd"]
|
||
|
||
# 해외 원화 환산 (환율 미제공 시 overseas_balance_data output2의 tot_evlu_amt 사용)
|
||
overseas_value_krw = overseas_value_usd * exchange_rate if exchange_rate > 0 else 0.0
|
||
overseas_investment_krw = overseas_investment_usd * exchange_rate if exchange_rate > 0 else 0.0
|
||
total_value = domestic_value + overseas_value_krw
|
||
|
||
# 손익 계산: 국내(KRW) + 해외(환율 적용 KRW) 분리 후 합산
|
||
domestic_profit_loss = domestic_value - domestic_investment
|
||
overseas_profit_loss_krw = overseas_value_krw - overseas_investment_krw
|
||
total_investment = domestic_investment + overseas_investment_krw
|
||
total_profit_loss = domestic_profit_loss + overseas_profit_loss_krw
|
||
total_profit_loss_rate = (total_profit_loss / total_investment * 100) if total_investment > 0 else 0
|
||
|
||
# 2. Extract balance data from KIS API
|
||
balance_info = _extract_balance_data(balance_data)
|
||
available_cash = balance_info["available_cash"]
|
||
total_assets = balance_info["total_assets"]
|
||
kis_total_assets = balance_info["kis_total_assets"]
|
||
output2 = balance_info["output2"]
|
||
|
||
# Log discrepancies
|
||
if kis_total_assets > 0:
|
||
calculated_assets = total_value + available_cash
|
||
if abs(kis_total_assets - calculated_assets) > 1000:
|
||
logger.debug(
|
||
f"💰 총자산 - KIS API: {kis_total_assets:,.0f}원, "
|
||
f"단순계산(현금+주식): {calculated_assets:,.0f}원, "
|
||
f"차이: {(kis_total_assets - calculated_assets):,.0f}원 (D+2정산액 포함)"
|
||
)
|
||
else:
|
||
total_assets = total_value + available_cash
|
||
|
||
# 3. Extract detailed balance info (D+2, stock value, previous day total)
|
||
detail_info = _extract_detailed_balance(balance_data)
|
||
d2_settlement = detail_info["d2_settlement"]
|
||
stock_value_from_api = detail_info["stock_value_from_api"]
|
||
bfdy_total_assets = detail_info["bfdy_total_assets"]
|
||
|
||
# 4. Extract overseas balance
|
||
overseas_info = _extract_overseas_balance(overseas_balance_data)
|
||
overseas_total_krw = overseas_info["overseas_total_krw"]
|
||
|
||
if overseas_total_krw > 0:
|
||
total_assets += overseas_total_krw
|
||
|
||
# 5. Calculate daily profit/loss
|
||
today_profit_loss = (total_assets - bfdy_total_assets) if bfdy_total_assets > 0 else 0.0
|
||
today_profit_loss_rate = (today_profit_loss / bfdy_total_assets * 100) if bfdy_total_assets > 0 else 0.0
|
||
|
||
# 6. Build and store summary
|
||
summary_data = {
|
||
"total_stocks": portfolio_values["total_stocks"],
|
||
"total_value": total_value,
|
||
"total_investment": total_investment,
|
||
"total_profit_loss": total_profit_loss,
|
||
"total_profit_loss_rate": total_profit_loss_rate,
|
||
"available_cash": available_cash,
|
||
"total_assets": total_assets,
|
||
"d2_settlement": d2_settlement,
|
||
"stock_value_from_api": stock_value_from_api,
|
||
"bfdy_total_assets": bfdy_total_assets,
|
||
"today_profit_loss": today_profit_loss,
|
||
"today_profit_loss_rate": today_profit_loss_rate,
|
||
# 유입 격리(external) 노출 — 자동 청산 대상에서 빠진 보유분을 프론트가
|
||
# 구분할 수 있게 한다(리스크 지표 total_assets/total_value 에는 포함).
|
||
"external_count": portfolio_values["external_count"],
|
||
"external_value": portfolio_values["external_value"],
|
||
"external_stop_breaches": portfolio_values["external_stop_breaches"],
|
||
# 해외 관련 추가 필드
|
||
"overseas_value_usd": overseas_value_usd,
|
||
"overseas_value_krw": overseas_value_krw if exchange_rate > 0 else overseas_total_krw,
|
||
"exchange_rate": exchange_rate,
|
||
}
|
||
|
||
# summary 수정을 락으로 보호
|
||
async with self.summary_lock:
|
||
self.summary = summary_data
|
||
|
||
portfolio_cache = PortfolioCache()
|
||
from pydantic import BaseModel
|
||
|
||
router = APIRouter()
|
||
|
||
|
||
class PortfolioResponse(BaseModel):
|
||
id: int
|
||
stock_code: str
|
||
stock_name: Optional[str] = None
|
||
quantity: int
|
||
average_price: Optional[Decimal] = None
|
||
current_price: Optional[Decimal] = None
|
||
profit_loss: Optional[Decimal] = None
|
||
profit_loss_rate: Optional[Decimal] = None
|
||
market: str = "KRX"
|
||
strategy_type: str = "swing"
|
||
|
||
class Config:
|
||
from_attributes = True
|
||
|
||
|
||
async def _get_latest_executed_trade_id(db: AsyncSession) -> int | None:
|
||
try:
|
||
result = await db.execute(
|
||
select(func.max(TradeHistory.id)).where(TradeHistory.status.in_(['EXECUTED', 'COMPLETED']))
|
||
)
|
||
return result.scalar_one_or_none()
|
||
except Exception as e:
|
||
logger.warning(f"Failed to fetch latest trade id: {e}")
|
||
return None
|
||
|
||
|
||
@router.get("", response_model=List[PortfolioResponse])
|
||
async def get_portfolio(db: AsyncSession = Depends(get_db)):
|
||
"""포트폴리오 조회 (캐시 활용)"""
|
||
# 거래 이력 기준으로 캐시 무효화
|
||
latest_trade_id = await _get_latest_executed_trade_id(db)
|
||
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
|
||
portfolio_cache.mark_changed()
|
||
|
||
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]
|
||
# 필요하면 동기화 시도
|
||
try:
|
||
await sync_portfolio(db, force=False)
|
||
except HTTPException:
|
||
# 강제 동기화가 아닌 경우에는 오류를 사용자에게 전파하지 않고 캐시/빈 목록 반환
|
||
logger.warning("get_portfolio: sync failed, returning cached holdings")
|
||
# 수량 0인 종목 필터링 (안전장치)
|
||
return [h for h in portfolio_cache.holdings if h.quantity > 0]
|
||
|
||
|
||
@router.get("/sync")
|
||
async def sync_portfolio(db: AsyncSession = Depends(get_db), force: bool = True):
|
||
"""실제 계좌와 포트폴리오 동기화 (캐시 반영)
|
||
force=False이면 캐시 정책에 따라 생략될 수 있음.
|
||
"""
|
||
if not force and not portfolio_cache.should_sync():
|
||
logger.debug("Skip sync: cache fresh & no change flag")
|
||
return {"message": "Skipped (cache fresh)", "cached": True}
|
||
# 다른 sync가 진행 중이면 비강제 호출은 기다리지 않고 캐시 반환
|
||
# (KIS 지연 시 요청이 30초씩 쌓여 화면 전체가 느려지던 문제, 2026-09-23)
|
||
if not force and portfolio_cache.sync_lock.locked():
|
||
return {"message": "Sync in progress (using cache)", "cached": True}
|
||
|
||
# 락 획득에 30초 타임아웃 적용 (이전 sync hang 시 데드락 방지)
|
||
try:
|
||
await asyncio.wait_for(portfolio_cache.sync_lock.acquire(), timeout=30)
|
||
except asyncio.TimeoutError:
|
||
portfolio_cache.record_failure("Sync lock timeout (30s)")
|
||
logger.error("Portfolio sync lock timeout — 이전 sync가 30초 이상 hang 중")
|
||
if force:
|
||
raise HTTPException(status_code=503, detail="Portfolio sync timeout")
|
||
return {"message": "Sync timeout (using cache)", "cached": True}
|
||
try:
|
||
portfolio_cache.last_attempt = datetime.now()
|
||
try:
|
||
# 국내·해외 잔고 병렬 조회
|
||
balance_data, overseas_balance_data = await asyncio.gather(
|
||
korea_investment_api.get_balance(),
|
||
korea_investment_api.get_overseas_balance(),
|
||
return_exceptions=True,
|
||
)
|
||
if isinstance(balance_data, Exception):
|
||
logger.warning(f"⚠️ 국내 잔고 조회 실패: {balance_data}")
|
||
balance_data = {}
|
||
if isinstance(overseas_balance_data, Exception):
|
||
logger.warning(f"⚠️ 해외 잔고 조회 실패: {overseas_balance_data}")
|
||
overseas_balance_data = {}
|
||
|
||
# 주문가능현금(당일 매도대금 포함) → output2에 주입해 available_cash로 사용
|
||
try:
|
||
if balance_data and balance_data.get("rt_cd") == "0" and balance_data.get("output2"):
|
||
_buyable = await korea_investment_api.get_buyable_cash()
|
||
if _buyable > 0:
|
||
_o2 = balance_data["output2"]
|
||
(_o2[0] if isinstance(_o2, list) else _o2)["_ord_psbl_cash"] = _buyable
|
||
except Exception as _bc_err:
|
||
logger.debug(f"주문가능현금 조회 실패 (예수금총액 폴백): {_bc_err}")
|
||
|
||
# 디버깅: KIS API 전체 응답 로깅
|
||
logger.info(f"🔍 KIS API balance response keys: {balance_data.keys() if balance_data else 'None'}")
|
||
|
||
# 데이트레이딩 엔진이 보유 중인 종목 → strategy_type='daytrading' 태깅용.
|
||
# (국내 output1 블록 안에서 정의하면 해외 루프에서 NameError → sync 전체 실패)
|
||
try:
|
||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||
_dt_qty = {
|
||
code: int(pos.get("quantity", 0) or 0)
|
||
for code, pos in _dts.positions.items()
|
||
}
|
||
except Exception:
|
||
_dt_qty = {}
|
||
_dt_codes = set(_dt_qty)
|
||
|
||
# Balance API 오류 시 기존 DB 보유종목을 삭제하지 않고 캐시 유지
|
||
if not balance_data or balance_data.get('rt_cd') != '0':
|
||
error_msg = balance_data.get('msg1', 'Unknown error') if balance_data else 'No response'
|
||
logger.warning(f"⚠️ Balance API 오류 - 기존 포트폴리오 유지: {error_msg}")
|
||
# 캐시가 비어있으면 DB에서 로드
|
||
if not portfolio_cache.holdings:
|
||
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||
portfolio_cache.holdings = list(result.scalars().all())
|
||
logger.info(f"📦 DB에서 포트폴리오 로드: {len(portfolio_cache.holdings)}종목")
|
||
# summary는 절대 None으로 덮어쓰지 않는다 — 마지막 성공 summary를 그대로 보존한다.
|
||
# 단, 최초 동기화부터 실패하여 summary가 아직 한 번도 채워진 적 없다면(None)
|
||
# 현금 계산이 영영 막히지 않도록 holdings 기반 부분값(available_cash=0)이라도 채워둔다.
|
||
if portfolio_cache.summary is None:
|
||
try:
|
||
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 반영
|
||
logger.info("📦 summary 최초 미존재 — holdings 기반 부분값으로 채움 (available_cash=0)")
|
||
except Exception as summary_err:
|
||
logger.warning(f"⚠️ 부분 summary 생성 실패 (summary는 None 유지): {summary_err}")
|
||
return {"message": f"Balance API error: {error_msg}", "cached": True, "count": len(portfolio_cache.holdings)}
|
||
|
||
portfolio_cache.record_success()
|
||
|
||
if "output1" in balance_data and balance_data["output1"]:
|
||
logger.info(f"🔍 output1 count: {len(balance_data['output1'])}")
|
||
|
||
# 1. 오늘 발생한 모든 매매 이력 조회 (Ghost Stock 방지 - 시간 범위 확대)
|
||
now = _get_kst_now()
|
||
# 오늘의 시작 시간 (00:00:00) 계산
|
||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
recent_point = today_start
|
||
|
||
logger.info(f"🔎 Checking recent trades since {recent_point} for Ghost detection")
|
||
|
||
trades_stmt = select(TradeHistory).where(
|
||
TradeHistory.executed_at >= recent_point,
|
||
TradeHistory.status.in_(['EXECUTED', 'COMPLETED'])
|
||
).order_by(TradeHistory.executed_at.asc())
|
||
|
||
trade_result = await db.execute(trades_stmt)
|
||
recent_trades = trade_result.scalars().all()
|
||
|
||
# 종목별 최신 상태 추적
|
||
stock_status_map = {}
|
||
for t in recent_trades:
|
||
stock_status_map[t.stock_code] = t.order_type # 덮어쓰기 (마지막이 최신)
|
||
logger.debug(f"Trade trace: {t.stock_name}({t.stock_code}) -> {t.order_type} at {t.executed_at}")
|
||
|
||
# 최신 상태가 'SELL'인 종목 식별
|
||
ghost_candidates = {code for code, status in stock_status_map.items() if status == "SELL"}
|
||
|
||
# 부분 매도 보정: KIS 잔고에서 여전히 수량이 있는 종목은 ghost에서 제외
|
||
if ghost_candidates and "output1" in balance_data:
|
||
still_holding = {
|
||
item.get("pdno")
|
||
for item in balance_data["output1"]
|
||
if int(item.get("hldg_qty", 0)) > 0
|
||
}
|
||
partial_sell_excluded = ghost_candidates & still_holding
|
||
if partial_sell_excluded:
|
||
logger.info(f"↩️ 부분매도 종목은 ghost 제외 (잔고 있음): {partial_sell_excluded}")
|
||
ghost_candidates -= still_holding
|
||
|
||
if ghost_candidates:
|
||
logger.info(f"🚫 Ghost Stock Candidates (Last action today is SELL): {ghost_candidates}")
|
||
|
||
# ── 유입(external) 잔고 격리용 '시스템 매수 근거' 수집 ──────────────
|
||
# [2026-09-28] 모의계좌 교체 등으로 계좌에 편입된 "시스템이 매수하지 않은"
|
||
# 잔고를 strategy_type='external' 로 태깅해 스윙 자동 청산 대상에서 뺀다.
|
||
# 태깅이 없으면 sync 가 전부 'swing' 으로 편입하고 get_swing_holdings()가
|
||
# 곧바로 손절 판정을 내려 유입 잔고를 전략 손절로 처분했다
|
||
# (2026-04~09 고아 청산 9건 / -153,661원).
|
||
# 근거 조회 규칙(status 거부목록 / 시딩 ActiveTrade 제외 2중 필터)은
|
||
# `_system_buy_codes_stmt` · `_evidence_active_trade_codes_stmt` docstring 참조.
|
||
_buy_hist_rows = await db.execute(_system_buy_codes_stmt())
|
||
_system_bought = {c for c in _buy_hist_rows.scalars().all() if c}
|
||
_at_rows_prov = await db.execute(_evidence_active_trade_codes_stmt())
|
||
# ── 근거 집합 두 벌: durable(레지스트리 제외) / with-registry ──────
|
||
# [M-2 2026-09-30] `system_buy_registry` 는 **강등을 막는 근거**로 설계됐다.
|
||
# 그런데 같은 집합을 승격 판정에도 쓰면 안전 방향이 뒤집힌다:
|
||
# · 강등 방어 → "근거가 있다고 보는 것"이 안전 (보수적 = 관리 유지)
|
||
# · 승격 판정 → "근거가 없다고 보는 것"이 안전 (보수적 = 격리 유지)
|
||
# 실제 사고 경로: 유입 3주 + DT 3주 행에서 DT 가 15:20 EOD 청산하면 원장
|
||
# net·비DT active·DT 인메모리가 모두 0 이 되는데 레지스트리만 TTL 내라
|
||
# `_sys=3=quantity` → **승격**되고 `strategy_type='swing'` 이 DB 에 굳는다.
|
||
# 장이 닫혀 다음 사이클이 오지 않으면 그대로 밤을 넘기고 **다음 날 개장 시
|
||
# 유입분이 자동 손절 대상**이 된다(105560 과 같은 결말).
|
||
# → 승격은 durable 근거만 본다. 레지스트리는 신규 행 태깅과 강등 방어에만.
|
||
_system_codes_durable = (
|
||
_system_bought | {c for c in _at_rows_prov.scalars().all() if c} | _dt_codes
|
||
)
|
||
try:
|
||
from app.services.system_buy_registry import snapshot_codes as _sbr_codes
|
||
_registry_codes = _sbr_codes()
|
||
except Exception as _sbr_err:
|
||
logger.warning(f"system_buy_registry 스냅샷 실패: {_sbr_err}")
|
||
_registry_codes = set()
|
||
_system_codes = _system_codes_durable | _registry_codes
|
||
|
||
# ── 시스템 매수 근거 **수량** (부분 혼재 판정용) ───────────────────
|
||
# [2026-09-30] 근거의 '유무'만으로는 부분 혼재를 못 잡는다 — 유입 3주에
|
||
# DT 3주가 섞이자 6주 전체가 swing 으로 세탁된 사건(105560 KB금융).
|
||
# 편향·중복제거·DT 제외의 근거는 각 statement 빌더 docstring 참조.
|
||
_sys_buy = (await db.execute(_system_buy_qty_stmt())).all()
|
||
_sys_sell = (await db.execute(_system_sell_qty_stmt())).all()
|
||
_sys_at = (await db.execute(_active_trade_qty_stmt())).all()
|
||
try:
|
||
from app.services.system_buy_registry import (
|
||
snapshot_quantities as _sbr_qty,
|
||
)
|
||
_sbr_q = _sbr_qty()
|
||
except Exception as _sbq_err:
|
||
logger.warning(f"system_buy_registry 수량 스냅샷 실패: {_sbq_err}")
|
||
_sbr_q = {}
|
||
# durable = 레지스트리 제외 (승격·ActiveTrade 잔량 교정 판정용)
|
||
_system_qty_durable = _compose_system_quantities(
|
||
_sys_buy, _sys_sell, _sys_at, _dt_qty, {}
|
||
)
|
||
_system_qty = _compose_system_quantities(
|
||
_sys_buy, _sys_sell, _sys_at, _dt_qty, _sbr_q
|
||
)
|
||
|
||
# db_worker 큐 적체 여부 — 강등 보류 판단(사이클당 1회 조회).
|
||
_queue_busy = _db_queue_busy()
|
||
|
||
def _classify_durable(_code: str, _qty: int) -> str:
|
||
"""레지스트리를 **제외한** 근거만으로 판정 — 승격 판정 전용 (M-2).
|
||
|
||
레지스트리는 "방금 주문했다" 는 휘발성 사실이라 `strategy_type` 을
|
||
DB 에 영구 기록하는 승격의 근거로 쓸 수 없다.
|
||
"""
|
||
return _classify_holding_strategy_type_v2(
|
||
_code, _qty, _dt_qty, _system_codes_durable, _system_qty_durable
|
||
)
|
||
|
||
def _classify_strategy_type(_code: str, _qty: int) -> str:
|
||
"""Portfolio 행의 strategy_type 판정 (모듈 순수 함수 위임).
|
||
|
||
[2026-09-30] v2 로 승격 — 부분 혼재(유입분 + DT)를 수량으로 구분한다.
|
||
"""
|
||
_t = _classify_holding_strategy_type_v2(
|
||
_code, _qty, _dt_qty, _system_codes, _system_qty
|
||
)
|
||
# 혼재를 조용히 넘기지 않는다 (CLAUDE.md §4: logger.debug 금지)
|
||
_surplus = _mixed_inflow_surplus(_code, _qty, _system_qty)
|
||
if _surplus > 0:
|
||
if _t == EXTERNAL_STRATEGY_TYPE and _code in _system_codes:
|
||
logger.warning(
|
||
f"🧩 부분 혼재 — 격리 유지: {_code} 행 {_qty}주 중 "
|
||
f"시스템 근거 {_system_qty.get(_code, 0)}주(전량 DT) / "
|
||
f"유입 추정 {_surplus}주 → strategy_type='external' "
|
||
f"(DT 는 인메모리로 자기 수량을 관리하므로 관리 공백 없음)"
|
||
)
|
||
elif _t == "swing":
|
||
logger.warning(
|
||
f"🧩 부분 혼재 — 스윙 유지: {_code} 행 {_qty}주 중 "
|
||
f"시스템 근거 {_system_qty.get(_code, 0)}주 / "
|
||
f"유입 추정 {_surplus}주 → strategy_type='swing' 유지. "
|
||
f"시스템 매수분의 손절·익절을 지키기 위한 선택이며, "
|
||
f"유입 추정분이 스윙 자동 청산에 함께 노출된다 — 수동 확인 필요"
|
||
)
|
||
return _t
|
||
|
||
# 기존 레코드 로드 (삭제 대신 업데이트)
|
||
result = await db.execute(select(Portfolio))
|
||
existing_all = {p.stock_code: p for p in result.scalars().all()}
|
||
processed_codes = set()
|
||
new_holdings = []
|
||
if "output1" in balance_data:
|
||
# ── 현재가 병렬 조회 (N+1 순차 → asyncio.gather) ──────────────────
|
||
_prefetch_codes = [
|
||
item.get("pdno") for item in balance_data["output1"]
|
||
if item.get("pdno") not in ghost_candidates
|
||
and int(item.get("hldg_qty", 0)) > 0
|
||
]
|
||
if _prefetch_codes:
|
||
_price_results = await asyncio.gather(
|
||
*[korea_investment_api.get_current_price(c) for c in _prefetch_codes],
|
||
return_exceptions=True,
|
||
)
|
||
_realtime_price_map = dict(zip(_prefetch_codes, _price_results))
|
||
else:
|
||
_realtime_price_map = {}
|
||
# ──────────────────────────────────────────────────────────────────
|
||
|
||
for item in balance_data["output1"]:
|
||
stock_code = item.get("pdno")
|
||
|
||
# 최근 5분 내 수동 매도한 수량 이하로만 남아 있으면 KIS 반영 지연으로 판단
|
||
_sold_qty = portfolio_cache.recently_sold_qty(stock_code)
|
||
if _sold_qty and int(item.get("hldg_qty", 0)) <= _sold_qty:
|
||
logger.info(f"👻 최근 수동 매도 종목 스킵: {stock_code} (KIS 잔고 반영 대기)")
|
||
if stock_code in existing_all:
|
||
await db.delete(existing_all[stock_code])
|
||
continue
|
||
|
||
if stock_code in ghost_candidates:
|
||
logger.info(f"👻 Skipping Ghost Stock: {stock_code} (Latest trade was SELL)")
|
||
# DB에서도 명시적 삭제
|
||
if stock_code in existing_all:
|
||
await db.delete(existing_all[stock_code])
|
||
logger.info(f"🗑️ Force deleted ghost stock from DB: {stock_code}")
|
||
continue
|
||
|
||
stock_name = item.get("prdt_name")
|
||
quantity = int(item.get("hldg_qty", 0))
|
||
if quantity <= 0:
|
||
logger.debug(f"Skipping {stock_name}({stock_code}) with 0 quantity")
|
||
continue
|
||
|
||
average_price = Decimal(str(item.get("pchs_avg_pric", 0)))
|
||
|
||
# 실시간 현재가 (병렬 조회 결과 참조)
|
||
price_resp = _realtime_price_map.get(stock_code)
|
||
try:
|
||
if price_resp and not isinstance(price_resp, Exception) and "output" in price_resp:
|
||
realtime_price = int(price_resp["output"].get("stck_prpr", 0))
|
||
current_price = Decimal(str(realtime_price)) if realtime_price > 0 else Decimal(str(item.get("prpr", 0)))
|
||
else:
|
||
if isinstance(price_resp, Exception):
|
||
logger.warning(f"⚠️ 실시간 현재가 조회 실패 ({stock_code}): {price_resp}, fallback to prpr")
|
||
current_price = Decimal(str(item.get("prpr", 0)))
|
||
except Exception as e:
|
||
logger.warning(f"⚠️ 현재가 파싱 실패 ({stock_code}): {e}, fallback to prpr")
|
||
current_price = Decimal(str(item.get("prpr", 0)))
|
||
|
||
# 평가손익 및 수익률 - KIS API에서 제공하는 값 우선 사용
|
||
profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0)))
|
||
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt", 0)))
|
||
|
||
logger.info(
|
||
f"Portfolio sync - {stock_name}({stock_code}): "
|
||
f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}, "
|
||
f"평가손익={profit_loss}, 수익률={profit_loss_rate}% | "
|
||
f"원본 필드={item}"
|
||
)
|
||
|
||
processed_codes.add(stock_code)
|
||
_classified = _classify_strategy_type(stock_code, quantity)
|
||
if stock_code in existing_all:
|
||
h = existing_all[stock_code]
|
||
h.stock_name = stock_name
|
||
h.quantity = quantity
|
||
h.average_price = average_price
|
||
h.current_price = current_price
|
||
h.profit_loss = profit_loss
|
||
h.profit_loss_rate = profit_loss_rate
|
||
h.market = "KRX"
|
||
# 전량이 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") == EXTERNAL_STRATEGY_TYPE
|
||
and _classify_durable(stock_code, quantity) == "swing"
|
||
):
|
||
# 승격 — 시스템 매수가 실제로 발생한 뒤에만 운용 대상으로.
|
||
# [M-2] durable 근거만 본다(레지스트리 제외) — 휘발성 근거로
|
||
# DB 에 영구 기록하면 EOD 이후 격리가 밤새 풀린다.
|
||
h.strategy_type = "swing"
|
||
logger.warning(
|
||
f"⬆️ 유입 격리 해제(승격): {stock_name}({stock_code}) "
|
||
f"external → swing (시스템 매수 근거 확인)"
|
||
)
|
||
elif _should_demote_to_external(
|
||
stock_code, h.strategy_type or "swing", _classified, _queue_busy
|
||
):
|
||
# [2026-09-30] 강등 — 사용자 결정 "스윙으로 편입하는 건 없어야 해".
|
||
# 레이스 방어 3중( system_buy_registry / 연속관측+경과시간 /
|
||
# db_worker 큐 )을 모두 통과한 경우만 여기 도달한다.
|
||
h.strategy_type = EXTERNAL_STRATEGY_TYPE
|
||
logger.warning(
|
||
f"⬇️ 유입 잔고 격리(기존 강등): {stock_name}({stock_code}) "
|
||
f"{quantity}주 평단={average_price} "
|
||
f"평가액={float(current_price) * quantity:,.0f}원 "
|
||
f"swing → external — 시스템 매수 근거 없음"
|
||
f"(TradeHistory BUY·비시딩 ActiveTrade·DT 인메모리 모두 부재, "
|
||
f"{_DEMOTE_CONFIRM_SYNCS}회 연속 확인 / "
|
||
f"{_DEMOTE_MIN_AGE_SEC:.0f}초 경과) "
|
||
f"→ 자동 손절·익절 대상 제외, 수동 판단 필요"
|
||
)
|
||
new_holdings.append(h)
|
||
else:
|
||
if _classified == EXTERNAL_STRATEGY_TYPE:
|
||
logger.warning(
|
||
f"🚧 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 "
|
||
f"평단={average_price} 평가액={float(current_price) * quantity:,.0f}원 "
|
||
f"— 시스템 매수 이력 없음 → strategy_type='external' "
|
||
f"(자동 손절·익절 대상 제외, 수동 판단 필요)"
|
||
)
|
||
new_holding = Portfolio(
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
quantity=quantity,
|
||
average_price=average_price,
|
||
current_price=current_price,
|
||
profit_loss=profit_loss,
|
||
profit_loss_rate=profit_loss_rate,
|
||
market="KRX",
|
||
strategy_type=_classified,
|
||
)
|
||
db.add(new_holding)
|
||
new_holdings.append(new_holding)
|
||
|
||
# ── 해외 잔고 처리 ─────────────────────────────────────────────────
|
||
if overseas_balance_data and overseas_balance_data.get("rt_cd") == "0" and "output1" in overseas_balance_data:
|
||
ovrs_items = overseas_balance_data["output1"] or []
|
||
# 해외 현재가 병렬 조회
|
||
_ovrs_codes = [
|
||
item.get("ovrs_pdno") for item in ovrs_items
|
||
if item.get("ovrs_pdno") and int(item.get("ord_psbl_qty", 0)) > 0
|
||
]
|
||
if _ovrs_codes:
|
||
_ovrs_price_results = await asyncio.gather(
|
||
*[korea_investment_api.get_overseas_price(c, item.get("ovrs_excg_cd", "NASD"))
|
||
for c, item in zip(_ovrs_codes, ovrs_items)],
|
||
return_exceptions=True,
|
||
)
|
||
_ovrs_price_map = dict(zip(_ovrs_codes, _ovrs_price_results))
|
||
else:
|
||
_ovrs_price_map = {}
|
||
|
||
for item in ovrs_items:
|
||
stock_code = item.get("ovrs_pdno")
|
||
if not stock_code:
|
||
continue
|
||
quantity = int(item.get("ord_psbl_qty", 0))
|
||
if quantity <= 0:
|
||
continue
|
||
|
||
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)))
|
||
|
||
# 실시간 현재가
|
||
price_resp = _ovrs_price_map.get(stock_code)
|
||
try:
|
||
if price_resp and not isinstance(price_resp, Exception) and "output" in price_resp:
|
||
realtime_price = float(price_resp["output"].get("last", 0))
|
||
current_price = Decimal(str(realtime_price)) if realtime_price > 0 else Decimal(str(item.get("now_pric2", 0)))
|
||
else:
|
||
current_price = Decimal(str(item.get("now_pric2", 0)))
|
||
except Exception:
|
||
current_price = Decimal(str(item.get("now_pric2", 0)))
|
||
|
||
profit_loss = Decimal(str(item.get("evlu_pfls_amt", 0)))
|
||
profit_loss_rate = Decimal(str(item.get("evlu_pfls_rt2", 0)))
|
||
|
||
logger.info(
|
||
f"[해외] Portfolio sync - {stock_name}({stock_code}/{exchange_code}): "
|
||
f"수량={quantity}, 평균단가={average_price}, 현재가={current_price}"
|
||
)
|
||
|
||
processed_codes.add(stock_code)
|
||
_classified = _classify_strategy_type(stock_code, quantity)
|
||
if stock_code in existing_all:
|
||
h = existing_all[stock_code]
|
||
h.stock_name = stock_name
|
||
h.quantity = quantity
|
||
h.average_price = average_price
|
||
h.current_price = current_price
|
||
h.profit_loss = profit_loss
|
||
h.profit_loss_rate = profit_loss_rate
|
||
h.market = exchange_code
|
||
# 같은 종목을 스윙+DT로 동시 보유하면 Portfolio 1행에 합쳐지므로,
|
||
# 전량이 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") == EXTERNAL_STRATEGY_TYPE
|
||
and _classify_durable(stock_code, quantity) == "swing"
|
||
):
|
||
# 승격 (국내 경로와 동일 규칙 — durable 근거만, M-2)
|
||
h.strategy_type = "swing"
|
||
logger.warning(
|
||
f"⬆️ [해외] 유입 격리 해제(승격): {stock_name}({stock_code}) external → swing"
|
||
)
|
||
elif _should_demote_to_external(
|
||
stock_code, h.strategy_type or "swing", _classified, _queue_busy
|
||
):
|
||
# 강등 (국내 경로와 동일 규칙 — 레이스 방어 3중 통과 시만)
|
||
h.strategy_type = EXTERNAL_STRATEGY_TYPE
|
||
logger.warning(
|
||
f"⬇️ [해외] 유입 잔고 격리(기존 강등): {stock_name}({stock_code}) "
|
||
f"{quantity}주 평단={average_price} "
|
||
f"평가액={float(current_price) * quantity:,.2f} "
|
||
f"swing → external — 시스템 매수 근거 없음 "
|
||
f"({_DEMOTE_CONFIRM_SYNCS}회 연속 확인 / "
|
||
f"{_DEMOTE_MIN_AGE_SEC:.0f}초 경과) "
|
||
f"→ 자동 손절·익절 대상 제외, 수동 판단 필요"
|
||
)
|
||
new_holdings.append(h)
|
||
else:
|
||
if _classified == EXTERNAL_STRATEGY_TYPE:
|
||
logger.warning(
|
||
f"🚧 [해외] 유입 잔고 격리: {stock_name}({stock_code}) {quantity}주 "
|
||
f"평단={average_price} — 시스템 매수 이력 없음 → strategy_type='external'"
|
||
)
|
||
new_holding = Portfolio(
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
quantity=quantity,
|
||
average_price=average_price,
|
||
current_price=current_price,
|
||
profit_loss=profit_loss,
|
||
profit_loss_rate=profit_loss_rate,
|
||
market=exchange_code,
|
||
strategy_type=_classified,
|
||
)
|
||
db.add(new_holding)
|
||
new_holdings.append(new_holding)
|
||
# ──────────────────────────────────────────────────────────────────
|
||
|
||
# 제거된 종목 반영 (DB에서 완전 삭제)
|
||
for code, h in existing_all.items():
|
||
if code not in processed_codes:
|
||
last_action = stock_status_map.get(code)
|
||
if last_action == "BUY" and h.quantity > 0:
|
||
logger.info(f"🛡️ Preserving {h.stock_name}({code}) - API calls it missing, but recent BUY exists")
|
||
new_holdings.append(h)
|
||
continue
|
||
|
||
logger.info(f"🗑️ Deleting {h.stock_name}({code}) from portfolio DB (not in KIS API response)")
|
||
await db.delete(h)
|
||
# TODO: db_worker 마이그레이션 필요 — 읽기+업데이트가 같은 트랜잭션에서
|
||
# 이뤄져야 하므로(Portfolio SELECT → UPDATE/DELETE → commit) 큐로 분리가 어려움.
|
||
# sync_portfolio()는 API 엔드포인트(get_db 의존성) 경로에서만 호출되므로
|
||
# 실시간 콜백/스케줄러 직접 호출과는 성격이 다름.
|
||
# ActiveTrade 잔량 정합 — 같은 종목에 active 행이 1개뿐일 때만 실제 보유량으로 교정.
|
||
# (분할매도·수동매도 누락으로 DB 잔량이 실제와 달라지면 다음 청산이 과다매도)
|
||
# ActiveTrade 시딩 payload 는 여기서 모으고, commit 성공 후에만 큐에 넣는다
|
||
# (롤백된 Portfolio 에 대해 ActiveTrade 가 생기는 것을 방지).
|
||
_seed_payloads: list[dict] = []
|
||
try:
|
||
from app.core.config import settings as _cfg # 모듈 상단 미임포트(함수 지역 사용)
|
||
_held = {h.stock_code: h.quantity for h in new_holdings if h.quantity > 0}
|
||
_at_rows = (await db.execute(
|
||
select(ActiveTrade).where(ActiveTrade.status == "active")
|
||
)).scalars().all()
|
||
_by_code: dict[str, list] = {}
|
||
for _row in _at_rows:
|
||
_by_code.setdefault(_row.stock_code, []).append(_row)
|
||
# 정합 규칙은 `_reconcile_active_trade_quantities()` 가 정본이다
|
||
# (유령 행 정리 + 잔량 교정, external 행은 교정 제외 — C-1).
|
||
_strategy_by_code = {
|
||
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
|
||
):
|
||
if _act["action"] == "closed":
|
||
logger.info(
|
||
f"🧹 ActiveTrade 정리: {_act['stock_code']} 보유 없음 → closed"
|
||
)
|
||
elif _act["action"] == "corrected":
|
||
logger.info(
|
||
f"🧹 ActiveTrade 잔량 교정: {_act['stock_code']} "
|
||
f"{_act['from']} → {_act['to']}"
|
||
)
|
||
else: # skipped_unattributable
|
||
logger.warning(
|
||
f"🧩 ActiveTrade 잔량 교정 생략: {_act['stock_code']} "
|
||
f"{_act['from']} → {_act['to']} "
|
||
f"(strategy_type='{_act['strategy_type']}', "
|
||
f"근거수량={_act['system_qty']} < 보유={_act['to']}) — "
|
||
f"설명되지 않는 수량(유입 추정분)이 있어 교정하면 다음 청산이 "
|
||
f"유입분까지 매도하고(과다매도) 부풀린 값이 근거로 되돌아와 "
|
||
f"격리가 영구 고착된다"
|
||
)
|
||
|
||
# 외부 유입 포지션(다른 인스턴스/증권사 앱 매수 등)은 ActiveTrade 행이 없어
|
||
# 손절·분할익절·트레일링 관리를 전혀 받지 못한다(2026-09-22 카카오뱅크 -13%).
|
||
# 보유 중인데 active 행이 없으면 현재 평균단가를 진입가로 보고 생성한다.
|
||
# 데이트레이딩 태깅 종목은 제외 — 엔진이 당일 청산 대상으로 복원해버린다.
|
||
#
|
||
# [§8.5 항목4③ 2026-09-27] 직접 `db.add(ActiveTrade(...))` → db_worker 큐 경유.
|
||
# CLAUDE.md §3 예외는 sync_portfolio 의 **Portfolio** 직접 쓰기까지이고,
|
||
# ActiveTrade 쓰기는 반드시 db_task_queue 를 거쳐야 한다.
|
||
# 이중 행 방지 3중 장치:
|
||
# 1) `_managed`(= 이 트랜잭션에서 읽은 status='active' 전 종목) 선제 제외
|
||
# 2) upsert 핸들러가 stock_code + status='active' + strategy_name 동등
|
||
# 조건으로 재조회 → 이미 있으면 UPDATE (동일 payload 재큐잉도 안전)
|
||
# 3) strategy_name 을 시딩 전용값("synced")으로 고정 → 다음 sync 주기가
|
||
# 같은 payload 를 다시 넣어도 1)·2) 에서 걸러진다
|
||
_managed = set(_by_code)
|
||
_seed_sl_pct = abs(_cfg.STOP_LOSS_PERCENT) / 100
|
||
for _h in new_holdings:
|
||
if _h.quantity <= 0 or _h.stock_code in _managed:
|
||
continue
|
||
if (_h.strategy_type or "swing") == "daytrading":
|
||
continue
|
||
# [2026-09-28] 유입 격리분(external)은 ActiveTrade 를 만들지 않는다.
|
||
# ① 사용자 결정: 유입 잔고는 자동 손절·익절 대상에서 제외한다.
|
||
# ② 시딩하면 그 행이 다음 sync 의 '시스템 매수 근거'로 오인될 위험이
|
||
# 있어(strategy_name 이 'synced' 가 아닌 값으로 굳는 경로) 격리가
|
||
# 스스로 풀릴 수 있다.
|
||
if (_h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
|
||
continue
|
||
_avg = float(_h.average_price or 0)
|
||
if _avg <= 0:
|
||
continue
|
||
_cur = float(_h.current_price or _avg)
|
||
# 전략명: 구체적 전략(manual/us_swing 등)은 보존해야 해당 엔진의
|
||
# close_active_trade(strategy_name=...) 와 매칭된다. 제네릭 'swing'
|
||
# 만 시딩 출처가 드러나는 'synced' 로 표기한다(mdd_manager.
|
||
# SWING_STRATEGY_NAMES 에 등재 — 비-DT 로 취급됨).
|
||
_seed_name = _h.strategy_type or "swing"
|
||
if _seed_name == "swing":
|
||
_seed_name = "synced"
|
||
# DLQ 직렬화(json) 대비 — Decimal 대신 순수 float/int/str 만 담는다.
|
||
_seed_payload = {
|
||
"stock_code": _h.stock_code,
|
||
"stock_name": _h.stock_name,
|
||
"quantity": int(_h.quantity),
|
||
"entry_price": _avg,
|
||
"current_price": _cur,
|
||
"stop_loss": round(_avg * (1 - _seed_sl_pct), 2),
|
||
"status": "active",
|
||
"remaining_pct": 1.0,
|
||
"highest_since_entry": max(_avg, _cur),
|
||
"strategy_name": _seed_name,
|
||
"entry_reason": "외부 유입 포지션 — 동기화 시 자동 등록(진입가=평균단가)",
|
||
"currency_code": _h.currency_code or "KRW",
|
||
"exchange_code": _h.exchange_code,
|
||
}
|
||
_seed_uid = getattr(_h, "user_id", None)
|
||
if _seed_uid is not None:
|
||
_seed_payload["user_id"] = int(_seed_uid)
|
||
_seed_payloads.append(_seed_payload)
|
||
logger.info(
|
||
f"🆕 ActiveTrade 자동 등록(큐): {_h.stock_name}({_h.stock_code}) "
|
||
f"{_h.quantity}주 @ {_avg:,.0f} — 외부 유입 포지션"
|
||
)
|
||
except Exception as _rec_err:
|
||
logger.warning(f"ActiveTrade 정합 실패(무시): {_rec_err}")
|
||
|
||
# 계좌에서 사라진 종목의 강등 미관측 카운터 정리 — 재편입 시 옛 누적을
|
||
# 물려받아 첫 관측에 곧바로 강등되는 것을 막는다.
|
||
for _gone in [c for c in _demote_miss_state if c not in processed_codes]:
|
||
_demote_state_reset(_gone)
|
||
|
||
await db.commit()
|
||
|
||
# [§8.5 항목4③] 외부 유입 포지션 ActiveTrade 시딩 — commit 성공 후 큐잉.
|
||
# db_worker 가 stock_code + status='active' + strategy_name 으로 재조회해
|
||
# 기존 행이 있으면 UPDATE 하므로 재큐잉/재시도로도 행이 늘지 않는다.
|
||
if _seed_payloads:
|
||
try:
|
||
from app.services.db_worker import (
|
||
enqueue_active_trade_upsert as _enq_seed_at,
|
||
)
|
||
for _sp in _seed_payloads:
|
||
await _enq_seed_at(_sp)
|
||
logger.info(
|
||
f"🆕 ActiveTrade 시딩 큐잉 완료: {len(_seed_payloads)}건 "
|
||
f"({[p['stock_code'] for p in _seed_payloads]})"
|
||
)
|
||
except Exception as _seed_err:
|
||
logger.warning(f"ActiveTrade 시딩 큐잉 실패(무시): {_seed_err}")
|
||
|
||
# 격리된 유입 잔고는 "조용히 방치"되면 안 된다 — 매 동기화마다 한 줄로 노출.
|
||
# (자동 손절·익절이 걸리지 않으므로 운영자의 수동 판단이 유일한 관리 수단)
|
||
_external_rows = [
|
||
(h.stock_name or h.stock_code, h.stock_code, int(h.quantity),
|
||
float(h.current_price or 0) * int(h.quantity))
|
||
for h in new_holdings
|
||
if h.quantity > 0 and (h.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE
|
||
]
|
||
if _external_rows:
|
||
logger.warning(
|
||
"🚧 유입 격리 보유 %d종목 (자동 청산 제외): %s",
|
||
len(_external_rows),
|
||
", ".join(
|
||
f"{_n}({_c}) {_q}주/{_v:,.0f}" for _n, _c, _q, _v in _external_rows
|
||
),
|
||
)
|
||
# 손절선 이탈은 절대 침묵시키지 않는다 — 이번 사건의 본질이 '조용한 실패'였다.
|
||
# 집행은 하지 않으며 처분 판단은 운영자 수동 청산(portfolio_v2)에 맡긴다.
|
||
for _br in _external_stop_breaches(
|
||
[h for h in new_holdings if h.quantity > 0]
|
||
):
|
||
logger.warning(
|
||
"🔻 유입 격리분 손절선 이탈(자동 청산 안 함): %s(%s) "
|
||
"손익률 %.2f%% / 기준 %.1f%% / 초과 이탈 %.2f%%p — 수동 판단 필요",
|
||
_br["stock_name"], _br["stock_code"], _br["profit_loss_rate"],
|
||
_br["stop_loss_pct"], _br["breach_pct"],
|
||
)
|
||
|
||
portfolio_cache.holdings = [h for h in new_holdings if h.quantity > 0]
|
||
await portfolio_cache.cache_summary(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)
|
||
logger.info(f"Portfolio synced: {len(portfolio_cache.holdings)} holdings")
|
||
return {"message": "Portfolio synchronized", "count": len(portfolio_cache.holdings), "cached": False}
|
||
except StaleDataError as e:
|
||
# 수동 매도(행 삭제)와 동시 실행 시 발생 — 실패로 기록하지 않고 다음 주기에 재시도
|
||
# (2026-09-22 14:33/14:42 수동 청산 중 sync 전체가 실패하던 문제)
|
||
await db.rollback()
|
||
portfolio_cache.mark_changed()
|
||
logger.warning(f"Portfolio sync 충돌(동시 매도) — 다음 주기 재시도: {e}")
|
||
return {"message": "Sync skipped (concurrent update)", "cached": True}
|
||
except Exception as e:
|
||
portfolio_cache.record_failure(e)
|
||
logger.error(f"Portfolio sync failed: {e}")
|
||
if force:
|
||
raise HTTPException(status_code=502, detail=f"Failed to sync portfolio: {e}")
|
||
return {"message": "Sync failed (using cache)", "cached": True, "error": str(e)}
|
||
finally:
|
||
portfolio_cache.sync_lock.release()
|
||
|
||
|
||
@router.get("/summary")
|
||
async def get_portfolio_summary(db: AsyncSession = Depends(get_db)):
|
||
"""포트폴리오 요약 (캐시 활용)"""
|
||
# 거래 이력 기준으로 캐시 무효화
|
||
latest_trade_id = await _get_latest_executed_trade_id(db)
|
||
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
|
||
portfolio_cache.mark_changed()
|
||
|
||
if portfolio_cache.summary and not portfolio_cache.should_sync():
|
||
logger.debug("Portfolio cache hit (summary)")
|
||
resp = portfolio_cache.get_summary_copy() or {}
|
||
else:
|
||
try:
|
||
await sync_portfolio(db, force=False)
|
||
except HTTPException:
|
||
logger.warning("get_portfolio_summary: sync failed, returning cached summary")
|
||
summary_copy = portfolio_cache.get_summary_copy()
|
||
resp = summary_copy or {
|
||
"total_stocks": 0,
|
||
"total_value": 0,
|
||
"total_investment": 0,
|
||
"total_profit_loss": 0,
|
||
"total_profit_loss_rate": 0,
|
||
"available_cash": 0,
|
||
"total_assets": 0,
|
||
}
|
||
|
||
# 누적 실현 손익 (TradeHistory SELL 체결 기준)
|
||
try:
|
||
realized_result = await db.execute(
|
||
select(func.sum(TradeHistory.net_profit_loss)).where(
|
||
TradeHistory.order_type == "SELL",
|
||
TradeHistory.status.in_(["EXECUTED", "COMPLETED"]),
|
||
TradeHistory.net_profit_loss.isnot(None),
|
||
)
|
||
)
|
||
cumulative_realized_pl = float(realized_result.scalar() or 0)
|
||
except Exception as e:
|
||
logger.warning(f"누적 실현손익 조회 실패: {e}")
|
||
cumulative_realized_pl = 0.0
|
||
|
||
resp["cumulative_realized_pl"] = cumulative_realized_pl
|
||
resp.update({
|
||
"sync_status": "ok" if portfolio_cache.last_error is None else "error",
|
||
"last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None,
|
||
"last_error": portfolio_cache.last_error,
|
||
})
|
||
return resp
|
||
|
||
async def _sync_portfolio_background():
|
||
"""백그라운드 포트폴리오 KIS 동기화 (비차단). 이미 동기화 중이면 건너뜀.
|
||
|
||
[DB 직접 접근 예외 사유]
|
||
sync_portfolio()는 KIS API → Portfolio READ/UPSERT → ActiveTrade UPSERT의
|
||
복합 트랜잭션이 한 세션 내에서 원자적으로 실행되어야 합니다.
|
||
db_worker 큐는 단순 INSERT/UPDATE 태스크 단위를 처리하므로
|
||
이 복합 조회+쓰기 흐름을 분리하면 일관성이 깨집니다.
|
||
따라서 이 함수에 한해 AsyncSessionLocal 직접 사용을 허용합니다.
|
||
(실시간 WebSocket 콜백이 아닌 asyncio.create_task 백그라운드 태스크에서 호출)
|
||
"""
|
||
if portfolio_cache.sync_lock.locked():
|
||
return
|
||
try:
|
||
async with AsyncSessionLocal() as db:
|
||
await sync_portfolio(db, force=False)
|
||
except Exception as e:
|
||
logger.debug(f"Background portfolio sync: {e}")
|
||
|
||
|
||
def _build_combined_resp() -> dict:
|
||
"""portfolio_cache에서 combined 응답 딕셔너리 생성"""
|
||
base = portfolio_cache.summary or {
|
||
"total_stocks": 0, "total_value": 0, "total_investment": 0,
|
||
"total_profit_loss": 0, "total_profit_loss_rate": 0,
|
||
"available_cash": 0, "total_assets": 0,
|
||
}
|
||
resp = dict(base)
|
||
resp.update({
|
||
"sync_status": "ok" if portfolio_cache.last_error is None else "error",
|
||
"last_synced": portfolio_cache.last_synced.isoformat() if portfolio_cache.last_synced else None,
|
||
"last_error": portfolio_cache.last_error,
|
||
})
|
||
return {
|
||
"holdings": [h for h in portfolio_cache.holdings if h.quantity > 0],
|
||
"summary": resp,
|
||
}
|
||
|
||
|
||
@router.get("/combined")
|
||
async def get_portfolio_combined(db: AsyncSession = Depends(get_db)):
|
||
"""보유종목 목록 + 요약을 반환.
|
||
|
||
응답 우선순위:
|
||
① 신선한 캐시 → 즉시 반환
|
||
② 캐시 만료 + 매매 변경 없음 → 기존 캐시 즉시 반환 + 백그라운드 KIS 갱신
|
||
③ 서버 재시작 등 캐시 비어있음 → DB 즉시 반환 + 백그라운드 KIS 갱신
|
||
④ 매매 발생(change_flag) 또는 DB도 비어있음 → KIS API 동기 호출 (필수)
|
||
"""
|
||
latest_trade_id = await _get_latest_executed_trade_id(db)
|
||
if latest_trade_id and portfolio_cache.last_trade_id != latest_trade_id:
|
||
portfolio_cache.mark_changed()
|
||
|
||
# ① 신선한 캐시
|
||
if portfolio_cache.holdings and portfolio_cache.summary and not portfolio_cache.should_sync():
|
||
logger.debug("Portfolio cache hit (combined)")
|
||
return _build_combined_resp()
|
||
|
||
# ② 캐시 만료, 매매 변경 없음 → 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())
|
||
return _build_combined_resp()
|
||
|
||
# ③ 캐시 비어있음 (서버 재시작) → DB에서 즉시 로드 + 백그라운드 KIS 갱신
|
||
if not portfolio_cache.holdings:
|
||
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||
db_holdings = list(result.scalars().all())
|
||
if db_holdings:
|
||
portfolio_cache.holdings = db_holdings
|
||
await portfolio_cache.cache_summary() # available_cash=0이지만 holdings는 즉시 표시
|
||
logger.info(f"Portfolio cold-start: DB {len(db_holdings)}종목 즉시 반환, KIS 갱신 백그라운드 시작")
|
||
asyncio.create_task(_sync_portfolio_background())
|
||
return _build_combined_resp()
|
||
|
||
# ④ 매매 발생 또는 DB도 비어있음 → KIS API 동기 호출
|
||
try:
|
||
await sync_portfolio(db, force=False)
|
||
except HTTPException:
|
||
logger.warning("get_portfolio_combined: sync failed, returning cached data")
|
||
|
||
return _build_combined_resp()
|
||
|
||
|
||
@router.post("/refresh")
|
||
async def refresh_portfolio(db: AsyncSession = Depends(get_db)):
|
||
"""강제 캐시 무효화 및 재동기화"""
|
||
portfolio_cache.change_flag = True
|
||
return await sync_portfolio(db, force=True)
|
||
|
||
|
||
async def check_and_execute_exit_trades(db: AsyncSession):
|
||
"""[DEPRECATED] 스윙 포지션 손절/익절 체크.
|
||
|
||
.. deprecated::
|
||
services/swing/exit_service.py의 check_and_execute_exit_v2()로 대체됨.
|
||
swing_scheduler가 호출. 이 함수는 더 이상 활성 스케줄러에서 호출되지 않음.
|
||
하위 호환을 위해 함수는 유지.
|
||
|
||
CLAUDE.md 섹션 6.2 + 7.3 + 7.4 기반:
|
||
- ATR 기반 손절: min(최저가 - 2.5×ATR, 진입가 -6%)
|
||
- 1차 익절(30%/50%): RSI 55~65 + BB 중앙선 상회
|
||
- 2차 익절(50%): RSI ≥ 65 + BB 상단선 도달
|
||
- 트레일링 스탑: 최고가 대비 -3% 하락 시 잔여 전량 청산
|
||
- 폴백: 고정 비율 손절 (STOP_LOSS_PERCENT)
|
||
"""
|
||
import warnings
|
||
warnings.warn(
|
||
"check_and_execute_exit_trades is deprecated. "
|
||
"Use services/swing/exit_service.check_and_execute_exit_v2() instead.",
|
||
DeprecationWarning,
|
||
stacklevel=2,
|
||
)
|
||
from app.core.config import settings
|
||
from datetime import datetime
|
||
|
||
try:
|
||
# 매매가 발생했거나 캐시가 비어있을 때만 KIS 잔고 조회 (API 호출 최소화)
|
||
latest_trade_id = await _get_latest_executed_trade_id(db)
|
||
if latest_trade_id != portfolio_cache.last_trade_id or not portfolio_cache.holdings:
|
||
logger.info(f"🔄 New trade detected (prev={portfolio_cache.last_trade_id}, cur={latest_trade_id}) — syncing balance")
|
||
await sync_portfolio(db, force=True)
|
||
else:
|
||
logger.debug("⏭️ No new trade since last sync — skipping get_balance()")
|
||
|
||
# MDD 업데이트 (async — thread-safe lock 내부)
|
||
summary = await _get_portfolio_summary(db)
|
||
total_assets = float(summary.get("total_assets", 0)) if summary else 0
|
||
mdd_action = await mdd_manager.update(total_assets)
|
||
if mdd_action["action"] != "NONE":
|
||
logger.warning(f"MDD 조치: {mdd_action}")
|
||
# CRITICAL: MDD 액션에 따른 실제 포지션 축소 매도 실행
|
||
try:
|
||
reduction_results = await mdd_manager.execute_reduction(mdd_action)
|
||
if reduction_results:
|
||
logger.warning(f"MDD 강제 축소 완료: {len(reduction_results)}종목 매도")
|
||
except Exception as e:
|
||
logger.error(f"MDD 강제 축소 실행 실패: {e}", exc_info=True)
|
||
|
||
result = await db.execute(
|
||
select(Portfolio).where(Portfolio.quantity > 0)
|
||
)
|
||
holdings = result.scalars().all()
|
||
|
||
if not holdings:
|
||
logger.debug("No holdings to check for exit conditions")
|
||
return
|
||
|
||
executed_exits = []
|
||
|
||
for holding in holdings:
|
||
try:
|
||
# [2026-09-28] 유입 격리분은 자동 청산 대상이 아니다.
|
||
# 이 함수는 deprecated 이고 현재 스케줄러에 배선돼 있지 않지만
|
||
# (auto_trade_scheduler 는 create_task 되지 않는다), 되살아날 때
|
||
# strategy_type 필터가 없는 전수 조회라 격리가 통째로 뚫린다.
|
||
if (holding.strategy_type or "swing") == EXTERNAL_STRATEGY_TYPE:
|
||
continue
|
||
stock_code = holding.stock_code
|
||
stock_name = holding.stock_name
|
||
quantity = holding.quantity
|
||
avg_price = float(holding.average_price or 0)
|
||
market = getattr(holding, "market", None) or ("KRX" if _is_domestic(stock_code) else "NASD")
|
||
domestic = _is_domestic(stock_code, market)
|
||
|
||
# ── 실시간 가격 확인 (국내/해외 분기) ───────────────────────
|
||
latest_price = float(holding.current_price or 0)
|
||
if domestic:
|
||
price_resp = await korea_investment_api.get_current_price(stock_code)
|
||
if price_resp and price_resp.get("output"):
|
||
latest_price = int(price_resp["output"].get("stck_prpr", latest_price))
|
||
else:
|
||
price_resp = await korea_investment_api.get_overseas_price(stock_code, market)
|
||
if price_resp and price_resp.get("output"):
|
||
latest_price = float(price_resp["output"].get("last", latest_price))
|
||
|
||
if latest_price <= 0 or avg_price <= 0:
|
||
continue
|
||
|
||
profit_loss_rate = ((latest_price - avg_price) / avg_price) * 100
|
||
|
||
# ActiveTrade 조회 (분할 청산 상태 추적)
|
||
at_result = await db.execute(
|
||
select(ActiveTrade).where(
|
||
ActiveTrade.stock_code == stock_code,
|
||
ActiveTrade.status == "active"
|
||
)
|
||
)
|
||
active_trade = at_result.scalar_one_or_none()
|
||
remaining_pct = float(active_trade.remaining_pct) if active_trade and active_trade.remaining_pct else 1.0
|
||
|
||
# ── ATR 기반 청산 시도 (국내만, 해외는 고정 비율 폴백) ──────
|
||
exit_signal = None
|
||
if domestic:
|
||
try:
|
||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=30)
|
||
chart_data = chart_resp.get("output2", [])
|
||
if len(chart_data) >= 20:
|
||
closes = [float(d.get("stck_clpr", 0)) for d in reversed(chart_data)]
|
||
highs = [float(d.get("stck_hgpr", 0)) for d in reversed(chart_data)]
|
||
lows = [float(d.get("stck_lwpr", 0)) for d in reversed(chart_data)]
|
||
|
||
atr = calculate_atr(highs, lows, closes)
|
||
rsi = calculate_rsi(closes)
|
||
bb = calculate_bollinger(closes)
|
||
|
||
if atr and rsi is not None and bb:
|
||
bb_upper, bb_middle, bb_lower = bb
|
||
lowest_since = min(lows[-5:]) if lows else avg_price
|
||
trailing_high = max(highs[-10:]) if highs else None
|
||
regime = MarketRegime.BULL
|
||
|
||
exit_signal = check_exit_signal(
|
||
regime=regime,
|
||
rsi=rsi,
|
||
close=latest_price,
|
||
bb_upper=bb_upper,
|
||
bb_middle=bb_middle,
|
||
bb_lower=bb_lower,
|
||
entry_price=avg_price,
|
||
atr=atr,
|
||
lowest_since_entry=lowest_since,
|
||
remaining_pct=remaining_pct,
|
||
trailing_high=trailing_high,
|
||
)
|
||
except Exception as chart_err:
|
||
logger.debug(f"ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
||
else:
|
||
# 해외: 해외 차트로 ATR 계산 시도
|
||
try:
|
||
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, market)
|
||
chart_data = chart_resp.get("output2", [])
|
||
if len(chart_data) >= 20:
|
||
closes = [float(d.get("clos", 0)) for d in reversed(chart_data)]
|
||
highs = [float(d.get("high", 0)) for d in reversed(chart_data)]
|
||
lows = [float(d.get("low", 0)) for d in reversed(chart_data)]
|
||
|
||
atr = calculate_atr(highs, lows, closes)
|
||
rsi = calculate_rsi(closes)
|
||
bb = calculate_bollinger(closes)
|
||
|
||
if atr and rsi is not None and bb:
|
||
bb_upper, bb_middle, bb_lower = bb
|
||
lowest_since = min(lows[-5:]) if lows else avg_price
|
||
trailing_high = max(highs[-10:]) if highs else None
|
||
regime = MarketRegime.BULL
|
||
|
||
exit_signal = check_exit_signal(
|
||
regime=regime,
|
||
rsi=rsi,
|
||
close=latest_price,
|
||
bb_upper=bb_upper,
|
||
bb_middle=bb_middle,
|
||
bb_lower=bb_lower,
|
||
entry_price=avg_price,
|
||
atr=atr,
|
||
lowest_since_entry=lowest_since,
|
||
remaining_pct=remaining_pct,
|
||
trailing_high=trailing_high,
|
||
)
|
||
except Exception as chart_err:
|
||
logger.debug(f"[해외] ATR 청산 계산 실패 ({stock_code}): {chart_err}")
|
||
|
||
should_exit = False
|
||
exit_reason = ""
|
||
sell_quantity = quantity
|
||
order_price = latest_price
|
||
|
||
if exit_signal:
|
||
should_exit = True
|
||
exit_reason = exit_signal.reason
|
||
if exit_signal.action == "SELL_PARTIAL":
|
||
sell_quantity = max(1, int(quantity * exit_signal.sell_pct))
|
||
logger.info(f"ATR 청산: {stock_name} - {exit_reason} (매도 {sell_quantity}주)")
|
||
# [이슈9 연동] STOP_LOSS_PERCENT는 양수(6.0)로 저장됨 — 손절 비교 시 음수 변환
|
||
elif profit_loss_rate <= -abs(settings.STOP_LOSS_PERCENT):
|
||
should_exit = True
|
||
exit_reason = f"고정 손절 ({profit_loss_rate:.2f}% <= -{abs(settings.STOP_LOSS_PERCENT):.1f}%)"
|
||
elif profit_loss_rate >= settings.TAKE_PROFIT_PERCENT:
|
||
should_exit = True
|
||
exit_reason = f"고정 익절 ({profit_loss_rate:.2f}% >= {settings.TAKE_PROFIT_PERCENT}%)"
|
||
|
||
if should_exit:
|
||
logger.info(f"{exit_reason} - {stock_name}({stock_code}) [{'국내' if domestic else '해외/' + market}]")
|
||
# ── 주문 실행 (국내/해외 분기) ───────────────────────────
|
||
if domestic:
|
||
try:
|
||
order_result = await korea_investment_api.place_order(
|
||
stock_code=stock_code,
|
||
order_type="SELL",
|
||
quantity=sell_quantity,
|
||
price=order_price
|
||
)
|
||
except Exception as e:
|
||
logger.warning(f"지정가 실패, 시장가 대체: {e}")
|
||
order_result = await korea_investment_api.place_order(
|
||
stock_code=stock_code,
|
||
order_type="SELL",
|
||
quantity=sell_quantity,
|
||
price=None
|
||
)
|
||
else:
|
||
order_result = await korea_investment_api.place_overseas_order(
|
||
symbol=stock_code,
|
||
order_type="SELL",
|
||
quantity=sell_quantity,
|
||
price=order_price,
|
||
exchange_code=market,
|
||
)
|
||
|
||
# 해외 주문 응답은 ord_prc 대신 ovrs_ord_unpr 사용
|
||
_out = order_result.get("output", {})
|
||
_raw_price = _out.get("ord_prc") or _out.get("ovrs_ord_unpr") or order_price
|
||
# [2026-09-29 C1] `place_order()` 는 raw KIS 응답을 그대로 준다 —
|
||
# 주문번호는 **`output.ODNO`** 이고 top-level `"odno"` 는 없다.
|
||
# 이전 코드가 `order_result.get("odno")` 를 써서 TradeHistory.order_id
|
||
# 가 항상 None 이었다(이 함수는 deprecated·미배선이라 드러나지 않았다).
|
||
# 다른 6개 주문 경로·us_market_scheduler.py:359 와 같은 형태로 통일.
|
||
_order_id = _out.get("ODNO") or order_result.get("odno")
|
||
executed_price = Decimal(str(_raw_price))
|
||
total_amount = Decimal(str(executed_price)) * sell_quantity
|
||
profit_loss = (float(executed_price) - avg_price) * sell_quantity
|
||
|
||
# TradeHistory DB 기록 (db_worker 큐 경유 — CLAUDE.md 3.2 준수)
|
||
from app.services.db_worker import enqueue_trade_history
|
||
_trade_status = "COMPLETED" if order_result.get("rt_cd") == "0" else "FAILED"
|
||
await enqueue_trade_history({
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"order_type": "SELL",
|
||
"order_price": Decimal(str(order_price)),
|
||
"order_quantity": sell_quantity,
|
||
"executed_price": executed_price,
|
||
"executed_quantity": sell_quantity,
|
||
"total_amount": total_amount,
|
||
"status": _trade_status,
|
||
"order_id": _order_id,
|
||
"message": f"자동 청산: {profit_loss_rate:.2f}%",
|
||
"exit_reason": f"{exit_reason}. 평균단가: {avg_price:,.0f}원, 매도가: {latest_price:,.0f}원, 손익: {profit_loss:,.0f}원",
|
||
"created_at": _get_kst_now(),
|
||
"executed_at": _get_kst_now() if order_result.get("rt_cd") == "0" else None,
|
||
})
|
||
|
||
if order_result.get("rt_cd") == "0":
|
||
# 연속 손실 추적 (섹션 7.4) — 인메모리 + DB 영속화
|
||
is_loss = profit_loss < 0
|
||
await loss_tracker.record_trade_result_safe(stock_code, is_loss)
|
||
await loss_tracker.save_to_db(db, stock_code)
|
||
|
||
# 분할 청산 시 ActiveTrade 잔여 비율 업데이트
|
||
# (기존 행 UPDATE 는 같은 트랜잭션에서 SELECT 한 ORM 행 수정 —
|
||
# sync_portfolio 와 동일 패턴으로 유지)
|
||
if exit_signal and exit_signal.action == "SELL_PARTIAL" and sell_quantity < quantity:
|
||
new_remaining = remaining_pct * (1.0 - exit_signal.sell_pct)
|
||
# 잔여 수량 즉시 반영 (다음 사이클 원래 수량 기준 재매도 방지)
|
||
holding.quantity = quantity - sell_quantity
|
||
holding.current_price = Decimal(str(latest_price))
|
||
if active_trade:
|
||
active_trade.remaining_pct = Decimal(str(round(new_remaining, 4)))
|
||
_at_seed_payload = None
|
||
else:
|
||
# [FIX] ActiveTrade 없는 포지션(동기화 유입)은 remaining_pct를
|
||
# 저장할 곳이 없어 부분매도가 매 사이클 반복됨 → 행 생성
|
||
# [§3.2 준수 2026-09-27] 직접 db.add(ActiveTrade(...)) →
|
||
# db_worker 큐 경유. 이 함수는 sync_portfolio/auth 시드
|
||
# 예외에 해당하지 않는다. commit 성공 후에만 큐잉해
|
||
# 롤백된 Portfolio 에 ActiveTrade 가 생기는 것을 방지한다.
|
||
_at_seed_payload = {
|
||
"stock_code": stock_code,
|
||
"strategy_name": "swing",
|
||
"stock_name": stock_name,
|
||
"quantity": quantity - sell_quantity,
|
||
"entry_price": Decimal(str(avg_price)),
|
||
"current_price": Decimal(str(latest_price)),
|
||
"status": "active",
|
||
"remaining_pct": Decimal(str(round(new_remaining, 4))),
|
||
"highest_since_entry": Decimal(str(latest_price)),
|
||
"entry_reason": "동기화 유입 포지션 (부분매도 시 자동 생성)",
|
||
"currency_code": "KRW" if domestic else "USD",
|
||
}
|
||
await db.commit()
|
||
if _at_seed_payload is not None:
|
||
try:
|
||
from app.services.db_worker import (
|
||
enqueue_active_trade_upsert as _enq_at_seed,
|
||
)
|
||
await _enq_at_seed(_at_seed_payload)
|
||
logger.info(
|
||
f"🆕 ActiveTrade 자동 등록(큐): {stock_name}({stock_code}) "
|
||
f"remaining_pct={round(new_remaining, 4)}"
|
||
)
|
||
except Exception as _at_q_err:
|
||
logger.warning(
|
||
f"ActiveTrade 큐잉 실패(무시) {stock_code}: {_at_q_err}"
|
||
)
|
||
else:
|
||
# 전량 매도
|
||
holding.quantity = max(0, quantity - sell_quantity)
|
||
holding.current_price = Decimal(str(latest_price))
|
||
if active_trade:
|
||
active_trade.status = "closed"
|
||
await db.commit()
|
||
|
||
portfolio_cache.mark_changed()
|
||
executed_exits.append({
|
||
"stock": stock_name,
|
||
"reason": exit_reason,
|
||
"profit_loss": profit_loss_rate,
|
||
"sell_quantity": sell_quantity,
|
||
})
|
||
logger.info(f"자동 매도 완료: {stock_name} {sell_quantity}주 @ {latest_price:,.0f}원")
|
||
|
||
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
|
||
asyncio.create_task(_create_journal_background(
|
||
trade_id=None,
|
||
order_id=_order_id,
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
order_type="SELL",
|
||
executed_price=float(executed_price),
|
||
quantity=sell_quantity,
|
||
profit_loss=profit_loss,
|
||
profit_loss_rate=profit_loss_rate,
|
||
exit_reason=exit_reason,
|
||
))
|
||
|
||
except Exception as e:
|
||
logger.error(f"Exit trade error for {holding.stock_code}: {e}", exc_info=True)
|
||
continue
|
||
|
||
if executed_exits:
|
||
logger.info(f"Executed {len(executed_exits)} exit trades: {executed_exits}")
|
||
else:
|
||
logger.debug("No exit conditions met for current holdings")
|
||
|
||
except Exception as e:
|
||
logger.error(f"check_and_execute_exit_trades error: {e}", exc_info=True)
|
||
|
||
|
||
async def _create_journal_background(
|
||
trade_id: Optional[int],
|
||
stock_code: str,
|
||
stock_name: str,
|
||
order_type: str,
|
||
executed_price: float,
|
||
quantity: int,
|
||
profit_loss: float,
|
||
profit_loss_rate: float,
|
||
exit_reason: str,
|
||
order_id: Optional[str] = None,
|
||
):
|
||
"""매도 체결 후 AI 매매일지 자동 생성 (백그라운드 태스크).
|
||
|
||
[2026-09-29 B3] `trade_id` 는 주문 직후엔 알 수 없다(TradeHistory INSERT 가
|
||
db_worker 큐에 있어 PK 미확정). 이전에는 `0` 을 넣었는데, 그 값은 존재하지 않는
|
||
PK 라 `get_uncovered_trades()` 의 커버 판정을 통과하지 못하고 같은 매도에 16:00
|
||
배치가 두 번째 일지를 만들었다. 이제 `order_id` 를 함께 넘겨
|
||
`db_worker._handle_insert_trade_journal()` 이 INSERT 시점에 해소한다.
|
||
"""
|
||
try:
|
||
import httpx
|
||
from app.core.config import settings
|
||
|
||
trade_info = {
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"order_type": order_type,
|
||
"executed_price": executed_price,
|
||
"quantity": quantity,
|
||
"profit_loss": profit_loss,
|
||
"profit_loss_rate": profit_loss_rate,
|
||
"exit_reason": exit_reason,
|
||
}
|
||
|
||
content = None
|
||
mood = None
|
||
tags = []
|
||
|
||
# MCP 서버로 AI 일지 생성 요청 (실패 시 규칙 기반 폴백)
|
||
_mcp_headers = {"X-API-Key": settings.MCP_API_KEY} if settings.MCP_API_KEY else {}
|
||
try:
|
||
async with httpx.AsyncClient(headers=_mcp_headers) as client:
|
||
resp = await client.post(
|
||
f"{settings.MCP_SERVER_URL}/analyze",
|
||
json={"analysis_type": "GENERATE_JOURNAL", "data": trade_info},
|
||
timeout=30.0,
|
||
)
|
||
if resp.status_code == 200:
|
||
result = resp.json().get("result", {})
|
||
content = result.get("content") or result.get("journal") or result.get("analysis")
|
||
mood = result.get("mood")
|
||
tags = result.get("tags", [])
|
||
except Exception as mcp_err:
|
||
# CLAUDE.md §4: 예외 로그는 최소 warning (폴백이 있어도 원인은 남겨야 한다)
|
||
logger.warning(f"MCP 일지 생성 실패 → 규칙 기반 폴백 ({stock_code}): {mcp_err}")
|
||
|
||
# 폴백: 규칙 기반 간단 일지
|
||
if not content:
|
||
direction = "익절" if profit_loss_rate > 0 else "손절"
|
||
sign = "+" if profit_loss > 0 else ""
|
||
content = (
|
||
f"{stock_name}({stock_code}) 매도 체결.\n"
|
||
f"매도가: {executed_price:,.0f}원 × {quantity}주\n"
|
||
f"손익: {sign}{profit_loss:,.0f}원 ({sign}{profit_loss_rate:.2f}%)\n"
|
||
f"사유: {exit_reason}"
|
||
)
|
||
mood = "좋음" if profit_loss > 0 else "나쁨"
|
||
tags = [direction, stock_code]
|
||
|
||
# DB 저장 — CLAUDE.md 3.2: db_worker 큐 경유
|
||
from app.services.db_worker import enqueue_trade_journal_raw
|
||
await enqueue_trade_journal_raw({
|
||
"trade_id": trade_id or None,
|
||
# order_id 는 TradeJournal 컬럼이 아니다 — 워커가 trade_id 해소 후 제거한다.
|
||
"order_id": order_id,
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"journal_date": now_kst(),
|
||
"content": content,
|
||
"strategy_name": "AUTO_TRADE",
|
||
"mood": mood,
|
||
"tags": tags if tags else None,
|
||
})
|
||
logger.info(f"매매일지 큐 등록 완료: {stock_name} (trade_id={trade_id})")
|
||
|
||
# WebSocket으로 프론트엔드에 즉시 알림
|
||
await websocket_manager.broadcast_json({
|
||
"type": "journal_created",
|
||
"data": {"stock_code": stock_code, "stock_name": stock_name},
|
||
})
|
||
except Exception as e:
|
||
logger.error(f"매매일지 생성 오류 ({stock_code}): {e}", exc_info=True)
|
||
|
||
|
||
async def _get_portfolio_summary(db: AsyncSession) -> dict:
|
||
"""포트폴리오 요약 (MDD 계산용)"""
|
||
try:
|
||
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||
holdings = result.scalars().all()
|
||
total_eval = sum(float(h.current_price or 0) * h.quantity for h in holdings)
|
||
total_invest = sum(float(h.average_price or 0) * h.quantity for h in holdings)
|
||
return {"total_assets": total_eval, "total_invest": total_invest}
|
||
except Exception:
|
||
return {"total_assets": 0, "total_invest": 0}
|
||
|
||
|
||
@router.post("/sell")
|
||
async def sell_stock(
|
||
request: SellRequest,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""보유 종목 수동 매도 (청산 버튼)"""
|
||
try:
|
||
stock_code = request.stock_code
|
||
quantity = request.quantity
|
||
price = request.price
|
||
|
||
logger.info(f"🔵 수동 매도 요청: {stock_code} {quantity}주 @ {price or '시장가'}")
|
||
|
||
# 보유 여부 확인
|
||
result = await db.execute(
|
||
select(Portfolio).where(Portfolio.stock_code == stock_code)
|
||
)
|
||
holding = result.scalar_one_or_none()
|
||
|
||
if not holding:
|
||
raise HTTPException(status_code=404, detail=f"보유하지 않은 종목입니다: {stock_code}")
|
||
|
||
if holding.quantity < quantity:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"보유 수량 부족: {holding.quantity}주 보유 중, {quantity}주 매도 요청"
|
||
)
|
||
|
||
# ✅ delete/commit 후 접근 불가를 대비해 미리 캡처
|
||
stock_name = holding.stock_name
|
||
original_holding_qty = holding.quantity
|
||
original_avg_price = float(holding.average_price or 0)
|
||
original_strategy_type = holding.strategy_type or "swing"
|
||
|
||
# 매도 주문 실행
|
||
# [2026-09-23] safe_place_order 경유 — 종목별 세마포어 + Redis 분산락으로
|
||
# 스윙/데이트레이딩 자동 청산과 동시에 매도되는 것을 막는다.
|
||
from app.services.trade_executor import safe_place_order
|
||
order_result = await safe_place_order(
|
||
stock_code=stock_code,
|
||
order_type='SELL',
|
||
quantity=quantity,
|
||
price=price, # None이면 시장가
|
||
is_domestic=True,
|
||
)
|
||
|
||
if not order_result or order_result.get('rt_cd') != '0':
|
||
error_msg = order_result.get('msg1', '알 수 없는 오류') if order_result else 'API 호출 실패'
|
||
raise HTTPException(status_code=500, detail=f"매도 주문 실패: {error_msg}")
|
||
|
||
# TradeHistory 기록
|
||
# 체결가는 나중에 업데이트. current_price 가 NULL 이면 평균단가로 폴백 —
|
||
# None 연산으로 TypeError 가 나면 주문은 이미 나간 뒤라 상태 갱신이 전부 누락된다.
|
||
_exec_raw = price or holding.current_price or holding.average_price or 0
|
||
executed_price = Decimal(str(_exec_raw))
|
||
_avg_dec = Decimal(str(original_avg_price)) if original_avg_price > 0 else None
|
||
profit_loss = float((executed_price - _avg_dec) * quantity) if _avg_dec else 0.0
|
||
profit_loss_rate = float((executed_price / _avg_dec - 1) * 100) if _avg_dec else 0.0
|
||
|
||
# CLAUDE.md 3.2: db_worker 큐 경유
|
||
from app.services.db_worker import enqueue_trade_history
|
||
await enqueue_trade_history({
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"order_type": "SELL",
|
||
"order_price": float(price) if price else None,
|
||
"order_quantity": quantity,
|
||
"executed_price": float(executed_price),
|
||
"executed_quantity": quantity,
|
||
"total_amount": float(executed_price * quantity),
|
||
"status": "COMPLETED",
|
||
"order_id": order_result.get('output', {}).get('ODNO'),
|
||
"message": f"수동 청산: 손익 {profit_loss:+,.0f}원 ({profit_loss_rate:+.2f}%)",
|
||
"exit_reason": f"사용자 수동 청산 (손익: {profit_loss:+,.0f}원, 손익률: {profit_loss_rate:+.2f}%)",
|
||
"net_profit_loss": profit_loss,
|
||
"profit_loss_rate": profit_loss_rate,
|
||
"strategy_type": "manual",
|
||
"created_at": _get_kst_now(),
|
||
"executed_at": _get_kst_now(),
|
||
})
|
||
|
||
# ✅ Portfolio DB 즉시 반영 (매도 후 보유종목에서 즉시 제거)
|
||
if quantity >= original_holding_qty:
|
||
# 전량 매도 → 수량 0 (행 삭제는 동시 실행 중인 sync 와 StaleDataError 충돌을
|
||
# 일으킨다. 수량 0 행은 조회에서 제외되고 다음 sync 가 KIS 기준으로 정리한다)
|
||
holding.quantity = 0
|
||
holding.current_price = Decimal(str(executed_price))
|
||
else:
|
||
# 분할 매도 → 수량 차감
|
||
holding.quantity -= quantity
|
||
holding.current_price = Decimal(str(executed_price))
|
||
|
||
await db.commit()
|
||
|
||
# ActiveTrade 정리 — 누락 시 유령 active 행이 남아, 같은 종목을 다시 매수했을 때
|
||
# 옛 remaining_pct(예: 0.50)가 살아나 1차 익절이 건너뛰어진다.
|
||
# (2026-09-23 확인: 삼아알미늄·LG 등 수동 청산 종목이 active 상태로 잔존)
|
||
from app.services.db_worker import (
|
||
enqueue_close_active_trade as _enq_close_at,
|
||
enqueue_active_trade_upsert as _enq_at_upsert,
|
||
)
|
||
try:
|
||
if quantity >= original_holding_qty:
|
||
await _enq_close_at(stock_code, strategy_name=None)
|
||
else:
|
||
await _enq_at_upsert({
|
||
"stock_code": stock_code,
|
||
"quantity": original_holding_qty - quantity,
|
||
"current_price": float(executed_price),
|
||
})
|
||
except Exception as _at_err:
|
||
logger.warning(f"[SELL] ActiveTrade 정리 실패 {stock_code}: {_at_err}")
|
||
|
||
# LossTracker 연패 기록 — 전량 청산 시에만(분할 매도 이중 카운트 방지).
|
||
# [2026-09-28] 수동 청산이 빠져 있어 실질 3연패 종목이 2로 집계됐다
|
||
# (카카오뱅크: 9-22 수동청산 1건 누락 → CONSECUTIVE_LOSS_BAN 미발동).
|
||
# 조건·순서는 swing/exit_service.py 와 동일하게 record → save_to_db.
|
||
# 유입 격리분(external)은 전략 성과가 아니므로 연패에 넣지 않는다.
|
||
if quantity >= original_holding_qty and original_strategy_type != EXTERNAL_STRATEGY_TYPE:
|
||
try:
|
||
await loss_tracker.record_trade_result_safe(
|
||
stock_code=stock_code,
|
||
is_loss=profit_loss < 0,
|
||
)
|
||
await loss_tracker.save_to_db(stock_code=stock_code)
|
||
except Exception as _lt_err:
|
||
logger.warning(f"[SELL] LossTracker 기록 실패 {stock_code}: {_lt_err}")
|
||
|
||
# 데이트레이딩 엔진이 같은 종목을 보유 중이면 인메모리 잔량도 줄인다.
|
||
# (누락 시 엔진이 매도된 수량을 그대로 들고 있다고 믿고 다음 사이클에 재매도)
|
||
try:
|
||
from app.services.daytrading.position_manager import day_trading_state as _dts
|
||
async with _dts.trade_lock:
|
||
_dt_pos = _dts.positions.get(stock_code)
|
||
if _dt_pos:
|
||
_left = max(0, int(_dt_pos.get("quantity", 0) or 0) - quantity)
|
||
if _left <= 0:
|
||
_dts.remove_position(stock_code)
|
||
logger.info(f"[SELL] DT 포지션 제거(수동 매도): {stock_code}")
|
||
else:
|
||
_dt_pos["quantity"] = _left
|
||
logger.info(f"[SELL] DT 포지션 잔량 갱신(수동 매도): {stock_code} → {_left}주")
|
||
except Exception as _dt_err:
|
||
logger.warning(f"[SELL] DT 인메모리 반영 실패 {stock_code}: {_dt_err}")
|
||
|
||
# ✅ 인메모리 캐시 즉시 반영 (KIS 재동기화 전에도 화면에서 즉시 제거)
|
||
if quantity >= original_holding_qty:
|
||
portfolio_cache.holdings = [
|
||
h for h in portfolio_cache.holdings
|
||
if h.stock_code != stock_code
|
||
]
|
||
else:
|
||
for h in portfolio_cache.holdings:
|
||
if h.stock_code == stock_code:
|
||
h.quantity -= quantity
|
||
break
|
||
# 예수금/평가액 요약도 즉시 반영 + 동기화가 되살리지 않도록 최근 매도 기록
|
||
portfolio_cache.note_sell(stock_code, quantity)
|
||
await portfolio_cache.apply_sell(stock_code, quantity, float(executed_price), original_avg_price)
|
||
portfolio_cache.mark_changed()
|
||
|
||
logger.info(f"✅ 수동 매도 완료: {stock_name} {quantity}주 @ {executed_price:,.0f}원 (손익: {profit_loss:+,.0f}원)")
|
||
|
||
# 매매일지 백그라운드 생성 — trade_id 는 워커가 order_id 로 해소
|
||
asyncio.create_task(_create_journal_background(
|
||
trade_id=None,
|
||
order_id=order_result.get('output', {}).get('ODNO'),
|
||
stock_code=stock_code,
|
||
stock_name=stock_name,
|
||
order_type="SELL",
|
||
executed_price=float(executed_price),
|
||
quantity=quantity,
|
||
profit_loss=float(profit_loss),
|
||
profit_loss_rate=float(profit_loss_rate),
|
||
exit_reason=f"수동 청산 (손익: {profit_loss:+,.0f}원, {profit_loss_rate:+.2f}%)",
|
||
))
|
||
|
||
return {
|
||
"success": True,
|
||
"message": f"{stock_name} {quantity}주 매도 주문 완료",
|
||
"order_id": order_result.get('output', {}).get('ODNO'),
|
||
"profit_loss": float(profit_loss),
|
||
"profit_loss_rate": float(profit_loss_rate)
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Manual sell error: {e}", exc_info=True)
|
||
raise HTTPException(status_code=500, detail=f"매도 처리 중 오류: {str(e)}")
|
||
|
||
|
||
class ActiveTradeStatusUpdateRequest(BaseModel):
|
||
"""활성 거래 상태 업데이트 요청"""
|
||
status: str # 'active', 'pending_sell', 'closed'
|
||
reason: Optional[str] = ""
|
||
updated_at: Optional[str] = None
|
||
remaining_pct: Optional[float] = None # 부분 매도 후 잔여 비율 (예: 0.5). 제공 시 ActiveTrade.remaining_pct 갱신
|
||
quantity: Optional[int] = None # 실제 보유 수량으로 교정 (DB 잔량 드리프트 정정용)
|
||
|
||
|
||
@router.patch("/active-trades/{stock_code}/status")
|
||
async def update_active_trade_status(
|
||
stock_code: str,
|
||
request: ActiveTradeStatusUpdateRequest,
|
||
db: AsyncSession = Depends(get_db)
|
||
):
|
||
"""
|
||
활성 거래의 상태를 업데이트합니다.
|
||
|
||
CLAUDE.md Section 3.2: trade_monitor에서 직접 DB commit 대신 이 API를 호출
|
||
- status: 'active' | 'pending_sell' | 'closed'
|
||
- reason: 상태 변경 이유
|
||
- updated_at: 업데이트 시각
|
||
|
||
Args:
|
||
stock_code: 종목 코드
|
||
request: 상태 업데이트 요청
|
||
db: DB 세션
|
||
|
||
Returns:
|
||
성공 결과 또는 에러
|
||
"""
|
||
try:
|
||
# 활성 거래 조회
|
||
result = await db.execute(
|
||
select(ActiveTrade).where(
|
||
ActiveTrade.stock_code == stock_code,
|
||
ActiveTrade.status != 'closed'
|
||
)
|
||
)
|
||
active_trade = result.scalar_one_or_none()
|
||
|
||
if not active_trade:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail=f"활성 거래를 찾을 수 없음: {stock_code}"
|
||
)
|
||
|
||
# 상태 유효성 검증
|
||
valid_statuses = {'active', 'pending_sell', 'closed'}
|
||
if request.status not in valid_statuses:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"유효하지 않은 상태: {request.status}. 허용값: {valid_statuses}"
|
||
)
|
||
|
||
# 상태 변경 기록 (로깅)
|
||
old_status = active_trade.status
|
||
new_status = request.status
|
||
reason = request.reason or ""
|
||
|
||
logger.info(
|
||
f"[TradeMonitor API] 활성 거래 상태 변경: "
|
||
f"{stock_code} {old_status} → {new_status} (이유: {reason})"
|
||
)
|
||
|
||
# 상태 업데이트
|
||
active_trade.status = new_status
|
||
active_trade.updated_at = datetime.fromisoformat(request.updated_at) if request.updated_at else now_kst()
|
||
|
||
# 부분 매도 시 잔여 비율 갱신 (CLAUDE.md 5.2: +2R 50% 분할 매도 후 잔량 추적 유지)
|
||
if request.remaining_pct is not None:
|
||
active_trade.remaining_pct = Decimal(str(round(request.remaining_pct, 4)))
|
||
|
||
if request.quantity is not None:
|
||
if request.quantity < 0:
|
||
raise HTTPException(status_code=400, detail="quantity는 0 이상이어야 합니다")
|
||
logger.info(
|
||
f"[TradeMonitor API] {stock_code} ActiveTrade 수량 교정: "
|
||
f"{active_trade.quantity} → {request.quantity}"
|
||
)
|
||
active_trade.quantity = request.quantity
|
||
|
||
db.add(active_trade)
|
||
await db.commit()
|
||
await db.refresh(active_trade)
|
||
|
||
return {
|
||
"success": True,
|
||
"stock_code": stock_code,
|
||
"old_status": old_status,
|
||
"new_status": new_status,
|
||
"reason": reason,
|
||
"updated_at": active_trade.updated_at.isoformat(),
|
||
"remaining_pct": float(active_trade.remaining_pct) if active_trade.remaining_pct is not None else None
|
||
}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(
|
||
f"[TradeMonitor API] 상태 업데이트 실패 ({stock_code}): {e}",
|
||
exc_info=True
|
||
)
|
||
await db.rollback()
|
||
raise HTTPException(
|
||
status_code=500,
|
||
detail=f"상태 업데이트 중 오류: {str(e)}"
|
||
)
|