소스리뷰 버전 적용

This commit is contained in:
2026-03-06 19:10:39 +09:00
parent ab75dc66f8
commit 2b9a90a03f
6 changed files with 528 additions and 95 deletions

View File

@@ -147,6 +147,7 @@ class Settings(BaseSettings):
# Trading Settings (CLAUDE.md 섹션 6 기반) # Trading Settings (CLAUDE.md 섹션 6 기반)
MAX_POSITION_SIZE: float = 0.04 # 1종목당 최대 3-4% (섹션 6.1) MAX_POSITION_SIZE: float = 0.04 # 1종목당 최대 3-4% (섹션 6.1)
MAX_DAILY_TRADES: int = 5 MAX_DAILY_TRADES: int = 5
MAX_DAILY_BUYS_PER_STOCK: int = 2 # 종목당 일일 최대 매수 횟수 (.env로 조정 가능)
STOP_LOSS_PERCENT: float = -6.0 # 저변동 종목 손절 -6% (섹션 6.2) STOP_LOSS_PERCENT: float = -6.0 # 저변동 종목 손절 -6% (섹션 6.2)
TAKE_PROFIT_PERCENT: float = 5.0 # 익절 기본값 TAKE_PROFIT_PERCENT: float = 5.0 # 익절 기본값
AUTO_TRADE_ENABLED: bool = True AUTO_TRADE_ENABLED: bool = True

View File

@@ -1030,6 +1030,55 @@ class KoreaInvestmentAPI:
"odno": None "odno": None
} }
async def get_order_execution(self, order_id: str, is_domestic: bool = True) -> Dict[str, Any]:
"""주문 체결 내역 단건 조회.
국내: TTTC8001R (실전) / VTTC8001R (모의)
Returns:
{"filled_qty": int, "filled_price": float}
체결 미확인 시 filled_qty=0 반환.
"""
try:
account_number_prefix = self.account_number[:8]
account_number_suffix = self.account_number[8:]
is_virtual = "vts" in self.base_url.lower()
today = datetime.now().strftime("%Y%m%d")
if is_domestic:
tr_id = "VTTC8001R" if is_virtual else "TTTC8001R"
result, _ = await self._make_request(
"GET",
"/uapi/domestic-stock/v1/trading/inquire-ccnl",
tr_id,
params={
"CANO": account_number_prefix,
"ACNT_PRDT_CD": account_number_suffix,
"INQR_STRT_DT": today,
"INQR_END_DT": today,
"SLL_BUY_DVSN_CD": "00",
"INQR_DVSN": "00",
"PDNO": "",
"CCLD_DVSN": "00",
"ORD_GNO_BRNO": "",
"ODNO": order_id,
"INQR_DVSN_3": "00",
"INQR_DVSN_1": "",
"CTX_AREA_FK100": "",
"CTX_AREA_NK100": "",
},
)
output = result.get("output1", [])
if output:
row = output[0] if isinstance(output, list) else output
filled_qty = int(row.get("tot_ccld_qty", 0) or 0)
filled_price = float(row.get("avg_prvs", 0) or 0)
return {"filled_qty": filled_qty, "filled_price": filled_price}
# 해외 체결 조회는 별도 TR_ID 필요 — 현재 미지원 (포트폴리오 동기화로 대체)
return {"filled_qty": 0, "filled_price": 0.0}
except Exception as e:
logger.debug(f"[get_order_execution] order_id={order_id} 조회 오류: {e}")
return {"filled_qty": 0, "filled_price": 0.0}
async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]: async def cancel_order(self, order_id: str, quantity: int) -> Dict[str, Any]:
"""주문 취소""" """주문 취소"""
try: try:

View File

@@ -0,0 +1,86 @@
"""주문 실행 디스패처 — 국내/해외 공통 인터페이스.
trade_executor.py 의 is_domestic 분기를 여기서 캡슐화.
신규 시장(예: 일본, 홍콩) 추가 시 이 모듈만 수정.
"""
from __future__ import annotations
import logging
from typing import Protocol
from app.services.korea_investment_api import korea_investment_api
logger = logging.getLogger(__name__)
class OrderDispatcher(Protocol):
"""주문 실행 인터페이스."""
async def place(
self,
stock_code: str,
order_type: str,
quantity: int,
price: float,
) -> dict: ...
async def get_execution(self, order_id: str) -> dict: ...
def adjust_price(self, price: float, order_type: str) -> float: ...
class DomesticOrderDispatcher:
"""국내 주식 주문 실행."""
async def place(
self,
stock_code: str,
order_type: str,
quantity: int,
price: float,
**_,
) -> dict:
return await korea_investment_api.place_order(stock_code, order_type, quantity, price)
async def get_execution(self, order_id: str) -> dict:
return await korea_investment_api.get_order_execution(order_id, is_domestic=True)
def adjust_price(self, price: float, order_type: str) -> float:
"""BUY +0.5% / SELL -0.5%, 원화 정수 반환."""
adjusted = price * 1.005 if order_type == "BUY" else price * 0.995
return float(int(adjusted))
class OverseasOrderDispatcher:
"""해외 주식 주문 실행."""
def __init__(self, exchange_code: str = "NASD"):
self.exchange_code = exchange_code
async def place(
self,
stock_code: str,
order_type: str,
quantity: int,
price: float,
**_,
) -> dict:
return await korea_investment_api.place_overseas_order(
stock_code, order_type, quantity, float(price), self.exchange_code
)
async def get_execution(self, order_id: str) -> dict:
# 해외 체결 조회 미지원 — 포트폴리오 동기화로 대체
return {"filled_qty": 0, "filled_price": 0.0}
def adjust_price(self, price: float, order_type: str) -> float:
"""BUY +0.5% / SELL -0.5%, USD 소수점 2자리 반환."""
adjusted = price * 1.005 if order_type == "BUY" else price * 0.995
return round(adjusted, 2)
def get_dispatcher(is_domestic: bool, exchange_code: str = "NASD") -> DomesticOrderDispatcher | OverseasOrderDispatcher:
"""is_domestic 에 따라 적절한 디스패처 반환."""
if is_domestic:
return DomesticOrderDispatcher()
return OverseasOrderDispatcher(exchange_code=exchange_code)

View File

@@ -0,0 +1,53 @@
"""신호 타입 Enum — 텍스트 키워드 오타/대소문자 오류 방지.
technical_indicators.py 의 description 문자열과 1:1 매핑.
trade_executor.py 에서 하드코딩 리스트 대신 사용.
"""
from enum import Enum
class BuySignal(str, Enum):
정배열눌림목 = "정배열 눌림목 매수"
눌림목진입 = "눌림목 진입 매수타점"
RSI바닥탈출 = "RSI 바닥 탈출 반등"
RSI안정상승 = "RSI 안정 상승"
스토캐스틱과매도 = "스토캐스틱 과매도"
MA5상향 = "5일선 > 20일선"
MA20상향 = "20일선 > 60일선"
골든크로스 = "골든크로스"
볼린저하한선 = "볼린저 하한선 터치"
횡보장평균회귀 = "횡보장 — 평균회귀 유리"
거래량증가상승 = "거래량 증가와 상승"
class SellSignal(str, Enum):
역배열하락 = "역배열 하락 매도타점"
RSI과매수 = "RSI 과매수"
스토캐스틱과매수 = "스토캐스틱 과매수"
데드크로스 = "데드크로스"
이동평균역배열 = "이동평균 역배열"
볼린저상한선 = "볼린저 상한선 돌파"
손절라인이탈 = "손절라인 이탈"
class StrongBuySignal(str, Enum):
"""매수 신호 중 '강한 신호' — 단독 매수 요건 충족."""
RSI바닥탈출 = "RSI 바닥 탈출 반등"
볼린저하한선 = "볼린저 하한선 터치"
눌림목진입 = "눌림목 진입 매수타점"
정배열눌림목 = "정배열 눌림목 매수"
class BlockSignal(str, Enum):
"""발생 시 매수를 즉시 차단하는 신호."""
이격도과열 = "이격도 과열"
매수금지 = "매수금지"
급등추격금지 = "급등 종목 추격 금지"
급등과열 = "급등과열"
# 편의 상수 — 반복 list comprehension 없이 바로 사용 가능
BUY_KEYWORDS: list[str] = [s.value for s in BuySignal]
SELL_KEYWORDS: list[str] = [s.value for s in SellSignal]
STRONG_BUY_KEYWORDS: list[str] = [s.value for s in StrongBuySignal]
BLOCK_KEYWORDS: list[str] = [s.value for s in BlockSignal]

View File

@@ -29,6 +29,24 @@ def _ma(values: list, window: int) -> Optional[float]:
return None return None
def _ema(values: list, window: int) -> Optional[float]:
"""EMA(Exponential Moving Average) 계산.
SMA 시드 → 이후 지수 가중 평균 순차 적용.
values 길이가 window 미만이면 None 반환.
"""
try:
if len(values) < window:
return None
k = 2.0 / (window + 1)
ema = sum(values[:window]) / window
for v in values[window:]:
ema = v * k + ema * (1 - k)
return ema
except Exception:
return None
def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]: def _passes_uptrend(closes: list, ma_short: int = TREND_MA_SHORT, ma_long: int = TREND_MA_LONG, tol: float = TREND_TOLERANCE_PCT) -> Optional[bool]:
if not closes or len(closes) < max(ma_short, ma_long): if not closes or len(closes) < max(ma_short, ma_long):
return None return None

View File

@@ -15,8 +15,9 @@ from app.services.loss_tracker import loss_tracker
from app.services.market_analyzer import check_market_regime, _get_kst_now from app.services.market_analyzer import check_market_regime, _get_kst_now
from app.services.mean_reversion_strategy import MarketRegime, calculate_position_size_v2 from app.services.mean_reversion_strategy import MarketRegime, calculate_position_size_v2
from app.services.technical_indicators import ( from app.services.technical_indicators import (
TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema,
) )
from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -86,18 +87,18 @@ async def _place_order_with_retry(
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait = 1 * (2 ** attempt) # 1s → 2s → 4s wait = 1 * (2 ** attempt) # 1s → 2s → 4s
logger.warning( logger.warning(
f"⚠️ 주문 실패 (rt_cd={result.get('rt_cd')}) " f"[ORDER_RETRY] {stock_code} {order_type} rt_cd={result.get('rt_cd')} "
f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries}): {stock_code}" f"→ {wait}초 후 재시도 ({attempt + 1}/{max_retries})"
) )
await asyncio.sleep(wait) await asyncio.sleep(wait)
except Exception as e: except Exception as e:
last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None} last_result = {"rt_cd": "-1", "msg1": str(e), "odno": None}
if attempt < max_retries - 1: if attempt < max_retries - 1:
wait = 1 * (2 ** attempt) wait = 1 * (2 ** attempt)
logger.warning(f"⚠️ 주문 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {e}") logger.warning(f"[ORDER_RETRY] {stock_code} {order_type} 예외 ({attempt + 1}/{max_retries}), {wait}초 후 재시도: {type(e).__name__}: {e}")
await asyncio.sleep(wait) await asyncio.sleep(wait)
else: else:
logger.error(f"❌ 주문 최대 재시도 초과: {stock_code} {order_type} — {e}") logger.error(f"[ORDER_MAX_RETRY] {stock_code} {order_type} 최대 재시도 초과: {type(e).__name__}: {e}")
return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None} return last_result or {"rt_cd": "-1", "msg1": "max_retries_exceeded", "odno": None}
@@ -108,11 +109,11 @@ async def _auto_cancel_if_pending(order_id: str, stock_code: str, quantity: int,
try: try:
result = await korea_investment_api.cancel_order(order_id, quantity) result = await korea_investment_api.cancel_order(order_id, quantity)
if result.get("rt_cd") == "0": if result.get("rt_cd") == "0":
logger.info(f"미체결 자동 취소 완료: {stock_code} order_id={order_id}") logger.info(f"[AUTO_CANCEL] 미체결 자동 취소 완료: {stock_code} order_id={order_id}")
else: else:
logger.debug(f"[auto_cancel] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)") logger.debug(f"[AUTO_CANCEL] {stock_code} — {result.get('msg1')} (이미 체결됐을 수 있음)")
except Exception as e: except Exception as e:
logger.debug(f"[auto_cancel] {stock_code} — {e}") logger.debug(f"[AUTO_CANCEL] {stock_code} — {type(e).__name__}: {e}")
# ── 계좌 정보 30초 인메모리 캐시 ────────────────────────────────────────────── # ── 계좌 정보 30초 인메모리 캐시 ──────────────────────────────────────────────
@@ -132,12 +133,179 @@ def _set_account_cache(available_cash: float, total_assets: float) -> None:
_account_cache["ts"] = time.monotonic() _account_cache["ts"] = time.monotonic()
# ── 지정가 ±0.5% 조정 — 체결률 향상 ────────────────────────────────────────── # ── 지정가 조정 — 체결률 향상 ──────────────────────────────────────────────────
def _adjust_order_price(price: float, order_type: str) -> int: def _adjust_order_price(price: float, order_type: str, is_domestic: bool = True) -> float:
"""BUY +0.5% / SELL -0.5% 지정가 조정.""" """BUY +0.5% / SELL -0.5% 지정가 조정.
국내: 원화 정수 반환 (KIS는 원화 단위 정수만 허용).
해외: 소수점 2자리 반환 (USD 단위).
"""
if order_type == "BUY": if order_type == "BUY":
return int(price * 1.005) adjusted = price * 1.005
return int(price * 0.995) else:
adjusted = price * 0.995
if is_domestic:
return float(int(adjusted)) # 원화 정수
return round(adjusted, 2) # USD 소수점 2자리
# ── Kelly Criterion 포지션 사이징 ─────────────────────────────────────────────
async def _calculate_kelly_position(
db: AsyncSession,
stock_code: str,
total_assets: float,
available_cash: float,
max_per_position: float = 0.04,
lookback: int = 20,
) -> float:
"""Kelly Criterion 기반 최적 포지션 사이징 (Half-Kelly 적용).
f* = (p*b - (1-p)) / b
- p: 최근 N거래 승률
- b: 평균이익률 / 평균손실률
데이터 부족(< 5개)이면 0.0 반환 → 호출측에서 v2 fallback.
"""
try:
recent = await db.execute(
select(TradeHistory)
.where(
TradeHistory.stock_code == stock_code,
TradeHistory.order_type == "SELL",
TradeHistory.status == "COMPLETED",
TradeHistory.profit_loss_rate.isnot(None),
)
.order_by(desc(TradeHistory.executed_at))
.limit(lookback)
)
trades = recent.scalars().all()
if len(trades) < 5:
return 0.0
rates = [float(t.profit_loss_rate) for t in trades]
wins = [r for r in rates if r > 0]
losses = [abs(r) for r in rates if r < 0]
if not wins or not losses:
return 0.0
p = len(wins) / len(rates)
b = (sum(wins) / len(wins)) / (sum(losses) / len(losses))
kelly_f = (p * b - (1 - p)) / b
half_kelly = max(0.0, kelly_f / 2)
clamped = min(half_kelly, max_per_position)
return min(total_assets * clamped, available_cash, total_assets * max_per_position)
except Exception as e:
logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}")
return 0.0
# ── 체결 확인 + 부분 체결 처리 + 슬리피지 로깅 ───────────────────────────────
async def _verify_and_sync_execution(
order_id: str,
stock_code: str,
stock_name: str,
ordered_quantity: int,
order_price: float,
order_type: str,
is_domestic: bool,
exchange_code: str,
db_trade_id: int,
delay: int = 3,
) -> None:
"""주문 후 KIS 체결 내역 조회 → DB 업데이트 + 슬리피지 기록 + 부분 체결 재주문."""
await asyncio.sleep(delay)
if not order_id:
await _delayed_sync_portfolio(0)
return
try:
exec_result = await korea_investment_api.get_order_execution(order_id, is_domestic)
filled_qty = int(exec_result.get("filled_qty", 0))
filled_price = float(exec_result.get("filled_price", order_price) or order_price)
# ── 슬리피지 계산 및 로깅 ─────────────────────────────────────────────
slippage_pct = 0.0
if order_price > 0 and filled_price > 0:
slippage_pct = ((filled_price - order_price) / order_price) * 100
logger.info(
f"[SLIPPAGE] {stock_code} {order_type} "
f"주문가={order_price:.2f} 체결가={filled_price:.2f} "
f"슬리피지={slippage_pct:+.3f}%"
)
# ── DB 업데이트 (별도 세션) ───────────────────────────────────────────
if filled_qty > 0:
async with AsyncSessionLocal() as session:
row = await session.execute(
select(TradeHistory).where(TradeHistory.id == db_trade_id)
)
trade = row.scalar_one_or_none()
if trade:
partial = filled_qty < ordered_quantity
trade.executed_quantity = filled_qty
trade.executed_price = Decimal(str(filled_price))
trade.status = "PARTIAL" if partial else "COMPLETED"
trade.executed_at = _get_kst_now()
trade.slippage = Decimal(str(round(slippage_pct, 4)))
await session.commit()
# ── 부분 체결 → 미체결 수량 재주문 ──────────────────────
if partial:
remain = ordered_quantity - filled_qty
logger.warning(
f"[PARTIAL_FILL] {stock_code} {order_type} "
f"{filled_qty}/{ordered_quantity}주 체결 → 잔여 {remain}주 재주문"
)
asyncio.create_task(
_reorder_unfilled(
stock_code=stock_code,
stock_name=stock_name,
order_type=order_type,
quantity=remain,
price=order_price,
is_domestic=is_domestic,
exchange_code=exchange_code,
)
)
await _delayed_sync_portfolio(0)
except Exception as e:
logger.warning(
f"[VERIFY_EXEC] {stock_code} {order_type} 체결 확인 실패 — "
f"{type(e).__name__}: {e} (포트폴리오 동기화만 실행)"
)
await _delayed_sync_portfolio(0)
# ── 부분 체결 후 미체결 수량 재주문 ──────────────────────────────────────────
async def _reorder_unfilled(
stock_code: str,
stock_name: str,
order_type: str,
quantity: int,
price: float,
is_domestic: bool,
exchange_code: str = "NASD",
) -> None:
"""부분 체결 잔여 수량 재주문 (1회 한정)."""
try:
result = await _place_order_with_retry(
is_domestic=is_domestic,
stock_code=stock_code,
order_type=order_type,
quantity=quantity,
price=price,
exchange_code=exchange_code,
max_retries=2,
)
if result.get("rt_cd") == "0":
logger.info(f"[REORDER] {stock_code} {order_type} 미체결 {quantity}주 재주문 성공")
else:
logger.warning(f"[REORDER] {stock_code} {order_type} 미체결 재주문 실패: {result.get('msg1')}")
except Exception as e:
logger.error(f"[REORDER_ERROR] {stock_code} {order_type}: {type(e).__name__}: {e}")
async def _execute_buy( async def _execute_buy(
@@ -152,7 +320,7 @@ async def _execute_buy(
stock_code: str, stock_code: str,
stock_name: str, stock_name: str,
) -> dict: ) -> dict:
"""BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → sync.""" """BUY 주문 실행 로직 — 선체크 → semaphore → 주문 → TradeHistory 기록 → 체결 확인."""
# ── 1. 3일 급등 체크 (ATR 기반 동적 임계값) ─────────────────────────────── # ── 1. 3일 급등 체크 (ATR 기반 동적 임계값) ───────────────────────────────
try: try:
@@ -175,7 +343,6 @@ async def _execute_buy(
((current_price - price_3days_ago) / price_3days_ago) * 100 ((current_price - price_3days_ago) / price_3days_ago) * 100
if price_3days_ago > 0 else 0 if price_3days_ago > 0 else 0
) )
# ATR 기반 동적 임계값: 최근 3일 평균 일간 변동폭 * 2, 최소 10%
try: try:
highs = ( highs = (
[float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]] [float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]]
@@ -201,13 +368,16 @@ async def _execute_buy(
if recent_gain > surge_threshold: if recent_gain > surge_threshold:
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"} return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
except Exception as e: except Exception as e:
logger.debug(f"[chase_buy] 차트 조회 실패, 우회: {stock_code} — {e}") logger.debug(f"[SURGE_CHECK] {stock_code} 차트 조회 실패, 우회: {type(e).__name__}: {e}")
# ── 2. 트렌드 필터 ──────────────────────────────────────────────────────── # ── 2. 트렌드 필터 (MA + EMA 병행) ───────────────────────────────────────
if TREND_FILTER_ENABLED: if TREND_FILTER_ENABLED:
try: try:
ma20 = stock.get("ma20") ma20 = stock.get("ma20")
ma60 = stock.get("ma60") ma60 = stock.get("ma60")
ema20 = ema60 = None
closes_local: list = []
if ma20 is None or ma60 is None: if ma20 is None or ma60 is None:
if is_domestic: if is_domestic:
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D") chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
@@ -219,14 +389,24 @@ async def _execute_buy(
if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG): if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG):
ma20 = _ma(closes_local, TREND_MA_SHORT) ma20 = _ma(closes_local, TREND_MA_SHORT)
ma60 = _ma(closes_local, TREND_MA_LONG) ma60 = _ma(closes_local, TREND_MA_LONG)
ema20 = _ema(closes_local, TREND_MA_SHORT)
ema60 = _ema(closes_local, TREND_MA_LONG)
if not ( ma_ok = bool(
ma20 and ma60 ma20 and ma60
and float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT) and float(ma20) >= float(ma60) * (1 - TREND_TOLERANCE_PCT)
and current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT) and current_price >= float(ma20) * (1 - TREND_TOLERANCE_PCT)
): )
ema_ok = bool(
ema20 and ema60
and ema20 >= ema60 * (1 - TREND_TOLERANCE_PCT)
and current_price >= ema20 * (1 - TREND_TOLERANCE_PCT)
) if ema20 and ema60 else False
if not (ma_ok or ema_ok):
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter"} return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter"}
except Exception: except Exception as e:
logger.debug(f"[TREND_FILTER] {stock_code}: {type(e).__name__}: {e}")
return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"} return {"action": "HOLD", "stock": stock_name, "reason": "trend_filter_error"}
# ── 3. 현금·자산 조회 (30초 캐시 활용) ────────────────────────────────── # ── 3. 현금·자산 조회 (30초 캐시 활용) ──────────────────────────────────
@@ -256,7 +436,7 @@ async def _execute_buy(
if pending_check.scalars().first(): if pending_check.scalars().first():
return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"} return {"action": "SKIP", "stock": stock_name, "reason": "already_ordered_today"}
# ── 5. 일 매수 횟수 제한 ───────────────────────────────────────────────── # ── 5. 일 매수 횟수 제한 (settings 기반) ─────────────────────────────────
daily_buy_check = await db.execute( daily_buy_check = await db.execute(
select(TradeHistory).where( select(TradeHistory).where(
TradeHistory.stock_code == stock_code, TradeHistory.stock_code == stock_code,
@@ -265,11 +445,18 @@ async def _execute_buy(
TradeHistory.created_at >= daily_cutoff, TradeHistory.created_at >= daily_cutoff,
) )
) )
if len(daily_buy_check.scalars().all()) >= 2: if len(daily_buy_check.scalars().all()) >= settings.MAX_DAILY_BUYS_PER_STOCK:
return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"} return {"action": "HOLD", "stock": stock_name, "reason": "daily_trade_limit"}
# ── 6. 쿨다운 체크 ──────────────────────────────────────────────────────── # ── 6. 쿨다운 체크 (ATR 기반 동적) ──────────────────────────────────────
cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30) atr_val = float(stock.get("atr", 0) or 0)
if atr_val > 0 and current_price > 0:
atr_pct = atr_val / current_price * 100
# 고변동(ATR% > 3%) → 쿨다운 단축, 저변동 → 기본 30분
cooldown_min = max(15, int(30 - atr_pct * 3))
else:
cooldown_min = int(stock.get("add_buy_cooldown_min", 30) or 30)
cooldown_check = await db.execute( cooldown_check = await db.execute(
select(TradeHistory) select(TradeHistory)
.where( .where(
@@ -284,18 +471,24 @@ async def _execute_buy(
if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60: if recent_buys and (_get_kst_now() - recent_buys[0].created_at).total_seconds() < cooldown_min * 60:
return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"} return {"action": "HOLD", "stock": stock_name, "reason": "cooldown"}
# ── 7. 포지션 사이징 ────────────────────────────────────────────────────── # ── 7. 포지션 사이징 (Kelly 우선, v2 fallback) ────────────────────────────
current_value = float(holding.quantity * current_price) if holding else 0.0 current_value = float(holding.quantity * current_price) if holding else 0.0
regime_str = stock.get("_regime_str", "bear") regime_str = stock.get("_regime_str", "bear")
regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR regime = MarketRegime.BULL if regime_str == "bull" else MarketRegime.BEAR
invest_amount = calculate_position_size_v2(
total_assets=total_assets, invest_amount = await _calculate_kelly_position(
available_cash=available_cash, db, stock_code, total_assets, available_cash
current_strategy_allocation=current_value,
regime=regime,
rsi=stock.get("rsi"),
adx=stock.get("adx_14"),
) )
if invest_amount <= 0:
# Kelly 데이터 부족 → v2 fallback
invest_amount = calculate_position_size_v2(
total_assets=total_assets,
available_cash=available_cash,
current_strategy_allocation=current_value,
regime=regime,
rsi=stock.get("rsi"),
adx=stock.get("adx_14"),
)
if invest_amount <= 0 and total_assets <= 0: if invest_amount <= 0 and total_assets <= 0:
invest_amount = available_cash * 0.04 invest_amount = available_cash * 0.04
@@ -309,7 +502,7 @@ async def _execute_buy(
# ── 9. 주문 실행 (종목별 세마포어 + 재시도) ─────────────────────────────── # ── 9. 주문 실행 (종목별 세마포어 + 재시도) ───────────────────────────────
try: try:
order_price = _adjust_order_price(current_price, "BUY") order_price = _adjust_order_price(current_price, "BUY", is_domestic)
async with _get_order_semaphore(stock_code): async with _get_order_semaphore(stock_code):
order_result = await _place_order_with_retry( order_result = await _place_order_with_retry(
is_domestic=is_domestic, is_domestic=is_domestic,
@@ -345,21 +538,34 @@ async def _execute_buy(
executed_at=_get_kst_now() if success else None, executed_at=_get_kst_now() if success else None,
) )
db.add(trade) db.add(trade)
await db.flush() # id 확보
trade_id = trade.id
await db.commit() await db.commit()
if success: if success:
# 즉시 캐시 반영 (UI 실시간성)
try: try:
from app.api.endpoints.portfolio import portfolio_cache from app.api.endpoints.portfolio import portfolio_cache
portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price) portfolio_cache.apply_buy(stock_code, stock_name, quantity, current_price)
except Exception: except Exception:
pass pass
asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) asyncio.create_task(
_verify_and_sync_execution(
order_id=order_result.get("odno", ""),
stock_code=stock_code,
stock_name=stock_name,
ordered_quantity=quantity,
order_price=order_price,
order_type="BUY",
is_domestic=is_domestic,
exchange_code=exchange_code,
db_trade_id=trade_id,
delay=3,
)
)
elif is_pending: elif is_pending:
# PENDING 주문 → 1분 후 자동 취소 order_id_val = order_result.get("odno")
order_id = order_result.get("odno") if order_id_val:
if order_id: asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity))
asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity))
return { return {
"action": "BUY", "action": "BUY",
@@ -369,7 +575,11 @@ async def _execute_buy(
"success": success, "success": success,
} }
except ValueError as e:
logger.error(f"[BUY_VALIDATION] {stock_code}: {e}")
return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)}
except Exception as e: except Exception as e:
logger.error(f"[BUY_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True)
return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)} return {"action": "BUY_FAILED", "stock": stock_name, "error": str(e)}
finally: finally:
await _release_order_lock(stock_code, "BUY") await _release_order_lock(stock_code, "BUY")
@@ -386,7 +596,7 @@ async def _execute_sell(
stock_code: str, stock_code: str,
stock_name: str, stock_name: str,
) -> dict: ) -> dict:
"""SELL 주문 실행 로직 — 중복체크 선행 → semaphore → 주문 → TradeHistory 기록 → sync.""" """SELL 주문 실행 로직 — 중복체크 → semaphore → 주문 → TradeHistory → 체결 확인."""
quantity = holding.quantity quantity = holding.quantity
_avg = float(holding.average_price or 0) _avg = float(holding.average_price or 0)
@@ -402,12 +612,10 @@ async def _execute_sell(
_avg_price = float(holding.average_price or 0) _avg_price = float(holding.average_price or 0)
exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%" exit_reason_detail = f"손익률 {profit_loss_rate:.2f}%"
if atr > 0 and _avg_price > 0: if atr > 0 and _avg_price > 0:
# ATR 손절: 진입가 - 2.5*ATR (CLAUDE.md 규칙)
atr_stop_price = _avg_price - 2.5 * atr atr_stop_price = _avg_price - 2.5 * atr
atr_stop_pct = ((atr_stop_price / _avg_price) - 1) * 100 atr_stop_pct = ((atr_stop_price / _avg_price) - 1) * 100
stop_loss = max(stop_loss, atr_stop_pct) # 더 보수적인 값 사용 stop_loss = max(stop_loss, atr_stop_pct)
# 트레일링 스탑: 고점 - 1.5*ATR (CLAUDE.md 규칙)
high_price = float(stock.get("high_price") or current_price) high_price = float(stock.get("high_price") or current_price)
trailing_stop_price = high_price - 1.5 * atr trailing_stop_price = high_price - 1.5 * atr
if current_price <= trailing_stop_price and profit_loss_rate > 0: if current_price <= trailing_stop_price and profit_loss_rate > 0:
@@ -418,7 +626,7 @@ async def _execute_sell(
return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"} return {"action": "SKIP", "stock": stock_name, "reason": "order_lock_busy"}
try: try:
# ── 1. 중복 주문 체크 (DB + Redis 락 이중 방어) ────────────────────── # ── 1. 중복 주문 체크 (10초 이내) ────────────────────────────────────
recent_cutoff = _get_kst_now() - timedelta(seconds=10) recent_cutoff = _get_kst_now() - timedelta(seconds=10)
duplicate_check = await db.execute( duplicate_check = await db.execute(
select(TradeHistory).where( select(TradeHistory).where(
@@ -431,7 +639,7 @@ async def _execute_sell(
return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name} return {"action": "SKIP", "reason": "duplicate_order", "stock": stock_name}
# ── 2. 주문 실행 (종목별 세마포어 + 재시도) ────────────────────────── # ── 2. 주문 실행 (종목별 세마포어 + 재시도) ──────────────────────────
order_price = _adjust_order_price(current_price, "SELL") order_price = _adjust_order_price(current_price, "SELL", is_domestic)
async with _get_order_semaphore(stock_code): async with _get_order_semaphore(stock_code):
order_result = await _place_order_with_retry( order_result = await _place_order_with_retry(
is_domestic=is_domestic, is_domestic=is_domestic,
@@ -469,10 +677,11 @@ async def _execute_sell(
executed_at=_get_kst_now() if success else None, executed_at=_get_kst_now() if success else None,
) )
db.add(trade) db.add(trade)
await db.flush() # id 확보
trade_id = trade.id
await db.commit() await db.commit()
if success: if success:
# 즉시 캐시 반영
try: try:
from app.api.endpoints.portfolio import portfolio_cache from app.api.endpoints.portfolio import portfolio_cache
avg = float(holding.average_price or current_price) avg = float(holding.average_price or current_price)
@@ -482,11 +691,24 @@ async def _execute_sell(
] ]
except Exception: except Exception:
pass pass
asyncio.create_task(_delayed_sync_portfolio(delay_seconds=3)) asyncio.create_task(
_verify_and_sync_execution(
order_id=order_result.get("odno", ""),
stock_code=stock_code,
stock_name=stock_name,
ordered_quantity=quantity,
order_price=order_price,
order_type="SELL",
is_domestic=is_domestic,
exchange_code=exchange_code,
db_trade_id=trade_id,
delay=3,
)
)
elif is_pending: elif is_pending:
order_id = order_result.get("odno") order_id_val = order_result.get("odno")
if order_id: if order_id_val:
asyncio.create_task(_auto_cancel_if_pending(order_id, stock_code, quantity)) asyncio.create_task(_auto_cancel_if_pending(order_id_val, stock_code, quantity))
return { return {
"action": "SELL", "action": "SELL",
@@ -497,7 +719,11 @@ async def _execute_sell(
"success": success, "success": success,
} }
except ValueError as e:
logger.error(f"[SELL_VALIDATION] {stock_code}: {e}")
return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)}
except Exception as e: except Exception as e:
logger.error(f"[SELL_ERROR] {stock_code}: {type(e).__name__}: {e}", exc_info=True)
return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)} return {"action": "SELL_FAILED", "stock": stock_name, "error": str(e)}
finally: finally:
await _release_order_lock(stock_code, "SELL") await _release_order_lock(stock_code, "SELL")
@@ -506,7 +732,7 @@ async def _execute_sell(
async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession): async def execute_auto_trade_background(recommendations_response: dict, db: AsyncSession):
"""recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행.""" """recommendations_response dict 에서 추천 종목을 꺼내 자동매매 실행."""
if not isinstance(recommendations_response, dict): if not isinstance(recommendations_response, dict):
logger.error("recommendations_response is not a dict: %r", recommendations_response) logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response)
return return
try: try:
@@ -537,15 +763,15 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
unique_stocks: dict = {} unique_stocks: dict = {}
for rule_group in rec_groups: for rule_group in rec_groups:
if not isinstance(rule_group, dict): if not isinstance(rule_group, dict):
logger.warning("Invalid rule_group: %r", rule_group) logger.warning("[AUTO_TRADE_BG] Invalid rule_group: %r", rule_group)
continue continue
stocks_list = rule_group.get("stocks") stocks_list = rule_group.get("stocks")
if not isinstance(stocks_list, list): if not isinstance(stocks_list, list):
logger.warning("Invalid stocks list: %r", stocks_list) logger.warning("[AUTO_TRADE_BG] Invalid stocks list: %r", stocks_list)
continue continue
for stock in stocks_list: for stock in stocks_list:
if not isinstance(stock, dict): if not isinstance(stock, dict):
logger.warning("Skipping non-dict stock entry: %r", stock) logger.warning("[AUTO_TRADE_BG] Skipping non-dict stock entry: %r", stock)
continue continue
stock_code = stock.get("stock_code") stock_code = stock.get("stock_code")
if stock_code not in unique_stocks: if stock_code not in unique_stocks:
@@ -560,7 +786,7 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
for stock in sorted_stocks[:max_trades]: for stock in sorted_stocks[:max_trades]:
if not isinstance(stock, dict): if not isinstance(stock, dict):
logger.warning("Skipping invalid stock entry: %r", stock) logger.warning("[AUTO_TRADE_BG] Skipping invalid stock entry: %r", stock)
continue continue
try: try:
stock["_position_multiplier"] = position_multiplier stock["_position_multiplier"] = position_multiplier
@@ -576,13 +802,13 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
except Exception as e: except Exception as e:
logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}") logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {e}")
continue continue
except Exception as e: except Exception as e:
logger.error( logger.error(
f"❌ Background auto-trade error: {e} — " f"[AUTO_TRADE_BG] 전체 오류: {type(e).__name__}: {e} — "
f"recommendations_response={recommendations_response!r}" f"recommendations_response keys={list(recommendations_response.keys()) if isinstance(recommendations_response, dict) else '?'}"
) )
@@ -592,21 +818,20 @@ async def _background_sync_portfolio():
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
await sync_portfolio(session, force=True) await sync_portfolio(session, force=True)
except Exception as e: except Exception as e:
logger.error(f"Background portfolio sync failed: {e}") logger.error(f"[PORTFOLIO_SYNC] 백그라운드 동기화 실패: {type(e).__name__}: {e}")
async def _delayed_sync_portfolio(delay_seconds: int = 3): async def _delayed_sync_portfolio(delay_seconds: int = 3):
try: try:
await asyncio.sleep(delay_seconds) await asyncio.sleep(delay_seconds)
from app.api.endpoints.portfolio import sync_portfolio from app.api.endpoints.portfolio import sync_portfolio
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
try: try:
await sync_portfolio(session, force=True) await sync_portfolio(session, force=True)
except Exception as inner_e: except Exception as inner_e:
logger.error(f"Sync execution failed: {inner_e}") logger.error(f"[PORTFOLIO_SYNC] 실행 실패: {type(inner_e).__name__}: {inner_e}")
except Exception as e: except Exception as e:
logger.error(f"Delayed portfolio sync failed: {e}") logger.error(f"[PORTFOLIO_SYNC] 딜레이 동기화 실패: {type(e).__name__}: {e}")
async def execute_auto_trade(stock: dict, db: AsyncSession): async def execute_auto_trade(stock: dict, db: AsyncSession):
@@ -668,67 +893,64 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
) )
holding = result.scalar_one_or_none() holding = result.scalar_one_or_none()
# ── 신호 분석 ───────────────────────────────────────────────────────── # ── 신호 분석 (Enum 기반) ─────────────────────────────────────────────
text_signals = " ".join(signals) text_signals = " ".join(signals)
try: try:
score_val = float(stock.get("score", 0) or 0) score_val = float(stock.get("score", 0) or 0)
except Exception: except Exception:
score_val = 0.0 score_val = 0.0
buy_keywords = [
"정배열 눌림목 매수", "눌림목 진입 매수타점", "RSI 바닥 탈출 반등",
"RSI 안정 상승", "스토캐스틱 과매도", "5일선 > 20일선", "20일선 > 60일선",
"골든크로스", "볼린저 하한선 터치", "횡보장 — 평균회귀 유리", "거래량 증가와 상승",
]
signals_without_volume_weak = [s for s in signals if "거래량부족" not in s] signals_without_volume_weak = [s for s in signals if "거래량부족" not in s]
text_signals_filtered = " ".join(signals_without_volume_weak) text_signals_filtered = " ".join(signals_without_volume_weak)
should_buy = any(k in text_signals_filtered for k in buy_keywords)
should_buy = any(k in text_signals_filtered for k in BUY_KEYWORDS)
if not signals_without_volume_weak and signals: if not signals_without_volume_weak and signals:
should_buy = False should_buy = False
sell_keywords = [ should_sell = any(k in text_signals for k in SELL_KEYWORDS)
"역배열 하락 매도타점", "RSI 과매수", "스토캐스틱 과매수", "데드크로스",
"이동평균 역배열", "볼린저 상한선 돌파", "손절라인 이탈",
]
should_sell = any(k in text_signals for k in sell_keywords)
# ── ML 가중치 통합 (60% ML + 40% 규칙) ──────────────────────────────
ml_score_trade = stock.get("ml_score") ml_score_trade = stock.get("ml_score")
ml_confidence_trade = stock.get("ml_confidence") ml_confidence_trade = stock.get("ml_confidence")
ml_signal_trade = stock.get("ml_signal") ml_signal_trade = stock.get("ml_signal")
if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None: if settings.ML_ENSEMBLE_ENABLED and ml_score_trade is not None:
if ( try:
ml_signal_trade == "BUY" weighted_score = (
and ml_score_trade >= settings.ML_ENSEMBLE_BUY_THRESHOLD score_val * settings.ML_ENSEMBLE_RULE_WEIGHT
and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN + float(ml_score_trade) * settings.ML_ENSEMBLE_ML_WEIGHT
): )
should_buy = True if (
elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5: ml_signal_trade == "BUY"
should_sell = True and weighted_score >= settings.ML_ENSEMBLE_BUY_THRESHOLD
elif ml_signal_trade == "HOLD" and (ml_confidence_trade or 0) < settings.ML_ENSEMBLE_CONFIDENCE_MIN: and (ml_confidence_trade or 0) >= settings.ML_ENSEMBLE_CONFIDENCE_MIN
should_buy = False ):
should_buy = True
elif ml_signal_trade == "SELL" and (ml_confidence_trade or 0) >= 0.5:
should_sell = True
elif (
ml_signal_trade == "HOLD"
and weighted_score < settings.ML_ENSEMBLE_BUY_THRESHOLD * 0.7
):
should_buy = False
except Exception as e:
logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}")
has_rule = stock.get("rule_id") is not None has_rule = stock.get("rule_id") is not None
if has_rule: if has_rule:
if "이격도 과열" in text_signals or "매수금지" in text_signals: if any(b in text_signals for b in BLOCK_KEYWORDS):
return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"} return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"}
if "급등 종목 추격 금지" in text_signals or "급등과열" in text_signals:
return {"action": "HOLD", "stock": stock_name, "reason": "chase_buy_prevention"}
ml_hold_active = settings.ML_ENSEMBLE_ENABLED and ml_signal_trade == "HOLD" and not should_buy ml_hold_active = settings.ML_ENSEMBLE_ENABLED and ml_signal_trade == "HOLD" and not should_buy
if not ml_hold_active: if not ml_hold_active:
should_buy = True should_buy = True
else: else:
if "급등과열" in text_signals or "이격도 과열" in text_signals or "매수금지" in text_signals: if any(b in text_signals for b in BLOCK_KEYWORDS):
return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"} return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"}
if should_buy and not has_rule: if should_buy and not has_rule:
strong_buy_kw = [ has_strong_signal = any(k in text_signals_filtered for k in STRONG_BUY_KEYWORDS)
"RSI 바닥 탈출 반등", "볼린저 하한선 터치", matched_count = sum(1 for k in BUY_KEYWORDS if k in text_signals_filtered)
"눌림목 진입 매수타점", "정배열 눌림목 매수",
]
has_strong_signal = any(k in text_signals_filtered for k in strong_buy_kw)
matched_count = sum(1 for k in buy_keywords if k in text_signals_filtered)
if not has_strong_signal and (matched_count < 2 or score_val < 2): if not has_strong_signal and (matched_count < 2 or score_val < 2):
should_buy = False should_buy = False
@@ -757,5 +979,9 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
return {"action": "HOLD", "stock": stock_name} return {"action": "HOLD", "stock": stock_name}
except Exception as e: except ValueError as e:
logger.error(f"[TRADE_VALIDATION] {stock.get('stock_code')}: {e}")
return {"action": "ERROR", "error": str(e)}
except Exception as e:
logger.error(f"[TRADE_ERROR] {stock.get('stock_code')}: {type(e).__name__}: {e}", exc_info=True)
return {"action": "ERROR", "error": str(e)} return {"action": "ERROR", "error": str(e)}