사용자 요구 "스윙으로 편입하는 건 없어야 해" — 시스템이 매수하지 않은 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>
190 lines
9.1 KiB
Python
190 lines
9.1 KiB
Python
"""추천 종목 수동 매수 엔드포인트 (프론트 "매수" 버튼 — 청산 버튼의 매수 대응).
|
||
|
||
POST /api/v1/portfolio/buy
|
||
- quantity 생략 시: 총자산 × MAX_POSITION_SIZE 예산으로 시장가 수량 자동 산출
|
||
- 리스크 가드(§5): 가격 필터·연속손실 ban·MDD 신규진입 차단·최대 보유수·TRADING_HALTED
|
||
- 주문은 trade_executor.safe_place_order (종목별 세마포어 + Redis 분산락) 경유
|
||
- DB 쓰기는 db_worker 큐 경유(insert_trade_history / upsert_portfolio), 이후 KIS 잔고 재동기화
|
||
"""
|
||
|
||
import asyncio
|
||
import time
|
||
import logging
|
||
from datetime import datetime
|
||
from typing import Optional
|
||
|
||
import pytz
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy import func, select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.core.config import settings
|
||
from app.core.database import get_db
|
||
from app.core.security import require_api_key
|
||
from app.models.models import Portfolio
|
||
from app.services.korea_investment_api import korea_investment_api
|
||
from app.services.loss_tracker import loss_tracker
|
||
from app.services.mdd_manager import mdd_manager
|
||
from app.services.utils import _is_domestic
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter()
|
||
_KST = pytz.timezone("Asia/Seoul")
|
||
|
||
|
||
class BuyRequest(BaseModel):
|
||
stock_code: str
|
||
stock_name: Optional[str] = None
|
||
quantity: Optional[int] = Field(None, gt=0, description="매수 수량 (None이면 예산 기반 자동 산출)")
|
||
price: Optional[int] = Field(None, gt=0, description="지정가 (None이면 시장가)")
|
||
|
||
|
||
def _kst_now() -> datetime:
|
||
return datetime.now(_KST).replace(tzinfo=None)
|
||
|
||
|
||
# 종목별 수동 매수 중복 방지: 진행 중(in-flight) 또는 최근 완료(쿨다운) 시 거부.
|
||
# KIS 지연으로 주문이 30~60초 걸릴 때 화면 타임아웃 → 재클릭 → 같은 종목 3회 매수된 사고 방지.
|
||
_inflight: set[str] = set()
|
||
_last_done: dict[str, float] = {}
|
||
_DUP_COOLDOWN_SEC = 120.0
|
||
|
||
|
||
@router.post("/buy", dependencies=[Depends(require_api_key)])
|
||
async def buy_stock(request: BuyRequest, db: AsyncSession = Depends(get_db)):
|
||
"""추천 종목 수동 매수 (매수 버튼)."""
|
||
stock_code = request.stock_code.strip()
|
||
if not _is_domestic(stock_code):
|
||
raise HTTPException(status_code=400, detail="국내 종목(6자리 코드)만 수동 매수를 지원합니다")
|
||
if settings.TRADING_HALTED:
|
||
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
|
||
|
||
if stock_code in _inflight:
|
||
raise HTTPException(status_code=409, detail="같은 종목 매수 주문이 아직 처리 중입니다 (KIS 응답 대기). 잠시 후 포트폴리오를 확인하세요")
|
||
_since = time.monotonic() - _last_done.get(stock_code, -1e9)
|
||
if _since < _DUP_COOLDOWN_SEC:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail=f"{int(_DUP_COOLDOWN_SEC - _since)}초 전 같은 종목을 매수했습니다. 중복 주문 방지 쿨다운 중",
|
||
)
|
||
_inflight.add(stock_code)
|
||
try:
|
||
return await _buy_stock_impl(request, stock_code, db)
|
||
finally:
|
||
_inflight.discard(stock_code)
|
||
|
||
|
||
async def _buy_stock_impl(request: BuyRequest, stock_code: str, db: AsyncSession):
|
||
|
||
# ── 현재가 ──────────────────────────────────────────────────────────
|
||
try:
|
||
price_data = await korea_investment_api.get_current_price(stock_code)
|
||
output = (price_data or {}).get("output") or {}
|
||
current_price = float(output.get("stck_prpr") or 0)
|
||
stock_name = request.stock_name or output.get("hts_kor_isnm") or stock_code
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"현재가 조회 실패: {e}")
|
||
if current_price <= 0:
|
||
raise HTTPException(status_code=404, detail=f"현재가를 가져올 수 없습니다: {stock_code}")
|
||
|
||
# ── 리스크 가드 (CLAUDE.md §5) ───────────────────────────────────────
|
||
if current_price <= settings.MIN_PRICE_KRW:
|
||
raise HTTPException(status_code=400, detail=f"가격 필터: {settings.MIN_PRICE_KRW:,}원 이하 종목 매수 금지")
|
||
if await loss_tracker.is_banned_safe(stock_code):
|
||
raise HTTPException(status_code=400, detail="연속 손실 진입 금지 종목입니다 (loss_tracker ban)")
|
||
if not await mdd_manager.can_open_new_position():
|
||
raise HTTPException(status_code=400, detail="MDD 신규 진입 중단 상태입니다")
|
||
|
||
held = await db.execute(select(Portfolio).where(Portfolio.stock_code == stock_code))
|
||
holding = held.scalar_one_or_none()
|
||
if holding is None:
|
||
cnt = (await db.execute(
|
||
select(func.count()).select_from(Portfolio).where(Portfolio.quantity > 0)
|
||
)).scalar() or 0
|
||
if cnt >= settings.MAX_HOLDINGS_COUNT:
|
||
raise HTTPException(status_code=400, detail=f"최대 보유 종목 수({settings.MAX_HOLDINGS_COUNT}) 초과")
|
||
|
||
# ── 수량 산출 (예산 = 총자산 × MAX_POSITION_SIZE, 예수금 한도) ────────────
|
||
quantity = request.quantity
|
||
order_price = float(request.price) if request.price else current_price
|
||
if quantity is None:
|
||
try:
|
||
balance = await korea_investment_api.get_balance()
|
||
output2 = balance.get("output2") or [{}]
|
||
b = output2[0] if isinstance(output2, list) else output2
|
||
available_cash = float(b.get("dnca_tot_amt") or 0)
|
||
total_assets = float(b.get("tot_evlu_amt") or 0)
|
||
except Exception as e:
|
||
raise HTTPException(status_code=502, detail=f"잔고 조회 실패: {e}")
|
||
budget = min(total_assets * settings.MAX_POSITION_SIZE, available_cash)
|
||
quantity = int(budget // order_price)
|
||
if quantity < 1:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"예산 부족: 예산 {budget:,.0f}원 < 1주 {order_price:,.0f}원 (예수금 {available_cash:,.0f}원)",
|
||
)
|
||
|
||
logger.info(f"🟢 수동 매수 요청: {stock_name}({stock_code}) {quantity}주 @ {request.price or '시장가'}")
|
||
|
||
# ── 주문 (세마포어 + 분산락) ─────────────────────────────────────────
|
||
from app.services.trade_executor import safe_place_order, _delayed_sync_portfolio
|
||
order_result = await safe_place_order(
|
||
stock_code=stock_code, order_type="BUY", quantity=quantity,
|
||
price=request.price, is_domestic=True,
|
||
)
|
||
if not order_result or order_result.get("rt_cd") != "0":
|
||
msg = order_result.get("msg1", "알 수 없는 오류") if order_result else "API 호출 실패"
|
||
raise HTTPException(status_code=500, detail=f"매수 주문 실패: {msg}")
|
||
order_id = (order_result.get("output") or {}).get("ODNO")
|
||
|
||
# [2026-09-30] 유입 잔고 강등 레이스 방어 — DB(TradeHistory) 반영은 db_worker
|
||
# 큐를 거쳐 비동기라, 3초 뒤 `_delayed_sync_portfolio` 가 먼저 돌면 근거가 없어
|
||
# 보인다. 주문 성공 시점에 인메모리 근거를 즉시 남긴다.
|
||
from app.services.system_buy_registry import note_system_buy
|
||
note_system_buy(stock_code, quantity)
|
||
|
||
# ── DB 기록 (db_worker 큐) ────────────────────────────────────────────
|
||
from app.services.db_worker import enqueue_trade_history, enqueue_portfolio_upsert
|
||
now = _kst_now()
|
||
await enqueue_trade_history({
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"order_type": "BUY",
|
||
"order_price": float(request.price) if request.price else None,
|
||
"order_quantity": quantity,
|
||
"executed_price": float(order_price),
|
||
"executed_quantity": quantity,
|
||
"total_amount": float(order_price * quantity),
|
||
"status": "COMPLETED",
|
||
"order_id": order_id,
|
||
"message": "수동 매수 (추천 패널)",
|
||
"strategy_type": "manual",
|
||
"created_at": now,
|
||
"executed_at": now,
|
||
})
|
||
if holding is None:
|
||
await enqueue_portfolio_upsert({
|
||
"stock_code": stock_code,
|
||
"stock_name": stock_name,
|
||
"quantity": quantity,
|
||
"average_price": order_price,
|
||
"current_price": current_price,
|
||
"market": "KRX",
|
||
"strategy_type": "manual",
|
||
"currency_code": "KRW",
|
||
"exchange_code": "KRX",
|
||
})
|
||
# 체결 후 KIS 잔고 기준 재동기화 (평균단가·수량 정확화)
|
||
asyncio.create_task(_delayed_sync_portfolio(3))
|
||
|
||
_last_done[stock_code] = time.monotonic()
|
||
logger.info(f"✅ 수동 매수 주문 완료: {stock_name} {quantity}주 @ {order_price:,.0f}원 (주문번호 {order_id})")
|
||
return {
|
||
"success": True,
|
||
"message": f"{stock_name} {quantity}주 매수 주문 완료",
|
||
"order_id": order_id,
|
||
"quantity": quantity,
|
||
"price": order_price,
|
||
}
|