Files
auto-stock/backend/app/api/endpoints/portfolio.py
shuim 9de0715165 fix: 스윙 자동편입 차단 + DT 처리량 복구 + ATR/양봉 세션 경계 + DLQ 안전
사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 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>
2026-09-30 16:50:54 +09:00

2751 lines
141 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
포트폴리오 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)}"
)