- korea_investment_api: 주문 계열 엔드포인트는 내부 재시도 금지(_NON_IDEMPOTENT_ENDPOINTS). 상위 _place_order_with_retry 와 중첩돼 타임아웃 시 동일 주문 최대 12회 전송 가능했음. get_todays_orders() 추가. - trade_executor: 예외 후 재시도 전에 당일 주문내역으로 접수 여부 확인(_find_recent_order) → 접수됐으면 재주문하지 않고 기존 주문 반환. - daytrading/exit_service: 주문 체결 이후 상태 반영을 _finalize_exit 로 분리하고 asyncio.shield 로 보호 — 스케줄러 단계 타임아웃이 이 구간을 취소하면 브로커는 팔았는데 인메모리는 그대로라 다음 사이클이 재매도. - swing/position_manager: active 행이 2개면 MultipleResultsFound 로 해당 종목 청산이 통째로 스킵됐음(손절 미작동) → 최신 행 사용. - swing/exit_service: 큐 지연·DLQ 시 같은 분할매도가 반복되지 않도록 180초 인메모리 쿨다운. - portfolio_v2: 부분매도 ActiveTrade 잔량 갱신 + 전략명 매핑(daytrading→day_trade, us_swing→us_mean_reversion) — 매칭 실패로 active 행이 영구 잔존했음. - portfolio.sell_stock: safe_place_order 경유(세마포어·분산락) + DT 인메모리 잔량 반영. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
116 lines
3.6 KiB
Python
116 lines
3.6 KiB
Python
"""스윙 포지션 관리 -- DB 조회 전용
|
|
|
|
strategy_type='swing' 필터를 적용해 데이트레이딩 포지션과 완전히 분리.
|
|
모든 함수는 읽기 전용 -- 쓰기는 db_worker.py의 큐를 통해서만 수행.
|
|
|
|
DB 테이블 매핑:
|
|
- Portfolio: 보유 종목 (strategy_type 컬럼으로 swing/daytrading 구분)
|
|
- ActiveTrade: 활성 매매 (strategy_name 컬럼으로 구분, 분할 청산 상태 추적)
|
|
|
|
사용 위치:
|
|
- exit_service.py: 청산 대상 포지션 조회
|
|
- entry_service.py: 포지션 한도 체크
|
|
- scheduler.py: 스윙 스케줄러 사이클
|
|
"""
|
|
import logging
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.models.models import Portfolio, ActiveTrade
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# 스윙 전략 식별자 -- Portfolio.strategy_type과 매칭
|
|
STRATEGY_TYPE = "swing"
|
|
|
|
|
|
async def get_swing_holdings(db: AsyncSession) -> list:
|
|
"""strategy_type='swing'인 보유 종목 목록 반환.
|
|
|
|
Portfolio 테이블에서 수량 > 0이고 strategy_type='swing'인 레코드 조회.
|
|
|
|
Args:
|
|
db: AsyncSession 인스턴스
|
|
|
|
Returns:
|
|
list[Portfolio]: 스윙 보유 종목 ORM 객체 목록
|
|
"""
|
|
result = await db.execute(
|
|
select(Portfolio).where(
|
|
Portfolio.quantity > 0,
|
|
Portfolio.strategy_type == STRATEGY_TYPE,
|
|
)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def get_swing_active_trades(db: AsyncSession) -> list:
|
|
"""활성 스윙 ActiveTrade 목록 반환.
|
|
|
|
strategy_name != 'day_trade' 조건으로 데이트레이딩 포지션 제외.
|
|
분할 청산 진행 상태(remaining_pct), 최고가(highest_since_entry) 포함.
|
|
|
|
Args:
|
|
db: AsyncSession 인스턴스
|
|
|
|
Returns:
|
|
list[ActiveTrade]: 활성 스윙 매매 ORM 객체 목록
|
|
"""
|
|
result = await db.execute(
|
|
select(ActiveTrade).where(
|
|
ActiveTrade.status == "active",
|
|
ActiveTrade.strategy_name != "day_trade",
|
|
)
|
|
)
|
|
return result.scalars().all()
|
|
|
|
|
|
async def get_swing_active_trade(db: AsyncSession, stock_code: str) -> ActiveTrade | None:
|
|
"""특정 종목의 스윙 ActiveTrade 반환.
|
|
|
|
분할 청산 상태(remaining_pct)와 최고가(highest_since_entry) 조회용.
|
|
청산 로직에서 전략별 분기(모멘텀/평균회귀)에도 사용.
|
|
|
|
Args:
|
|
db: AsyncSession 인스턴스
|
|
stock_code: 종목 코드
|
|
|
|
Returns:
|
|
ActiveTrade | None: 활성 매매 ORM 객체, 없으면 None
|
|
"""
|
|
# [2026-09-23] scalar_one_or_none() 은 행이 2개 이상이면 MultipleResultsFound 를 던지고,
|
|
# 호출측 except 가 이를 삼켜 해당 종목의 청산(손절 포함)이 통째로 스킵됐다.
|
|
# db_worker legacy 경로와 동일하게 가장 최근 행을 사용한다.
|
|
result = await db.execute(
|
|
select(ActiveTrade)
|
|
.where(
|
|
ActiveTrade.stock_code == stock_code,
|
|
ActiveTrade.status == "active",
|
|
ActiveTrade.strategy_name != "day_trade",
|
|
)
|
|
.order_by(ActiveTrade.id.desc())
|
|
.limit(1)
|
|
)
|
|
return result.scalars().first()
|
|
|
|
|
|
async def count_swing_positions(db: AsyncSession) -> int:
|
|
"""현재 스윙 보유 종목 수 반환.
|
|
|
|
MAX_SWING_POSITIONS(25) 한도 체크에 사용.
|
|
|
|
Args:
|
|
db: AsyncSession 인스턴스
|
|
|
|
Returns:
|
|
int: 스윙 보유 종목 수
|
|
"""
|
|
from sqlalchemy import func
|
|
result = await db.execute(
|
|
select(func.count(Portfolio.id)).where(
|
|
Portfolio.quantity > 0,
|
|
Portfolio.strategy_type == STRATEGY_TYPE,
|
|
)
|
|
)
|
|
return result.scalar_one() or 0
|