소스리뷰 버전 적용
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
86
backend/app/services/order_dispatcher.py
Normal file
86
backend/app/services/order_dispatcher.py
Normal 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)
|
||||||
53
backend/app/services/signal_types.py
Normal file
53
backend/app/services/signal_types.py
Normal 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]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
Reference in New Issue
Block a user