## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
380 lines
14 KiB
Python
380 lines
14 KiB
Python
"""Helper functions for trade_executor.py — Buy/Sell logic decomposition.
|
||
|
||
This module extracts complex logic from _execute_buy() and _execute_sell()
|
||
into reusable helper functions, keeping the executor focused on orchestration.
|
||
"""
|
||
|
||
import logging
|
||
from decimal import Decimal
|
||
from datetime import datetime as _dt_utc
|
||
|
||
from app.core.config import settings
|
||
from app.services.korea_investment_api import korea_investment_api
|
||
from app.services.mean_reversion_strategy import (
|
||
calculate_rsi, calculate_bollinger, calculate_atr, MarketRegime
|
||
)
|
||
from app.services.technical_indicators import (
|
||
TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema,
|
||
)
|
||
from app.core.market_schedule import is_opening_range_period
|
||
from app.services.market_analyzer import _get_kst_now
|
||
from app.services.exchange_detector import get_market_info
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# BUY HELPERS
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
async def check_opening_range_volatility() -> bool:
|
||
"""Check if we're in opening range period (09:00~09:30) — volatility avoidance.
|
||
|
||
Returns:
|
||
bool: True if in opening range (SKIP BUY), False otherwise.
|
||
"""
|
||
return is_opening_range_period()
|
||
|
||
|
||
async def check_surge_prevention(
|
||
stock_code: str,
|
||
is_domestic: bool,
|
||
exchange_code: str,
|
||
current_price: float,
|
||
) -> dict:
|
||
"""Check 3-day surge (ATR-based dynamic threshold) — avoid chasing rallies.
|
||
|
||
[동작] 3일 전 종가 대비 현재가 상승률 > threshold(최소 10%, ATR% 기반 동적) 이면
|
||
최근 급등(chase_buy_prevention) 판정 → HOLD 반환.
|
||
|
||
Args:
|
||
stock_code: Stock code (KRX code or ticker)
|
||
is_domestic: True if domestic stock, False if overseas
|
||
exchange_code: Exchange code for overseas stocks
|
||
current_price: Current price
|
||
|
||
Returns:
|
||
dict: {"skip": False} (통과) or {"skip": True, "reason": "chase_buy_prevention"}
|
||
"""
|
||
try:
|
||
if is_domestic:
|
||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D", limit=10)
|
||
chart_data = chart_resp.get("output2", [])
|
||
else:
|
||
chart_resp = await korea_investment_api.get_overseas_daily_chart(
|
||
stock_code, exchange_code, "D"
|
||
)
|
||
chart_data = chart_resp.get("output2", [])
|
||
|
||
if len(chart_data) >= 4:
|
||
closes = (
|
||
[float(item.get("stck_clpr", 0)) for item in chart_data[-4:]]
|
||
if is_domestic
|
||
else [float(item.get("clos", 0)) for item in chart_data[-4:]]
|
||
)
|
||
if len(closes) >= 4:
|
||
price_3days_ago = closes[0]
|
||
recent_gain = (
|
||
((current_price - price_3days_ago) / price_3days_ago) * 100
|
||
if price_3days_ago > 0 else 0
|
||
)
|
||
try:
|
||
highs = (
|
||
[float(item.get("stck_hgpr", 0)) for item in chart_data[-4:]]
|
||
if is_domestic
|
||
else [float(item.get("high", 0)) for item in chart_data[-4:]]
|
||
)
|
||
lows = (
|
||
[float(item.get("stck_lwpr", 0)) for item in chart_data[-4:]]
|
||
if is_domestic
|
||
else [float(item.get("low", 0)) for item in chart_data[-4:]]
|
||
)
|
||
daily_ranges = [
|
||
abs(highs[i] - lows[i]) for i in range(len(closes))
|
||
if closes[i] > 0 and highs[i] > 0 and lows[i] > 0
|
||
]
|
||
atr_pct = (
|
||
(sum(daily_ranges) / len(daily_ranges) / price_3days_ago * 100)
|
||
if daily_ranges and price_3days_ago > 0 else 0
|
||
)
|
||
surge_threshold = max(10.0, atr_pct * 2)
|
||
except Exception:
|
||
surge_threshold = 10.0
|
||
|
||
if recent_gain > surge_threshold:
|
||
return {"skip": True, "reason": "chase_buy_prevention"}
|
||
except Exception as e:
|
||
logger.debug(f"[SURGE_CHECK] {stock_code} chart fetch failed, bypassed: {type(e).__name__}: {e}")
|
||
|
||
return {"skip": False}
|
||
|
||
|
||
async def check_trend_filter(
|
||
stock_code: str,
|
||
stock_name: str,
|
||
is_domestic: bool,
|
||
exchange_code: str,
|
||
current_price: float,
|
||
stock: dict,
|
||
) -> dict:
|
||
"""Apply trend filter (MA + EMA) — skip if downtrend, except MEAN_REVERSION.
|
||
|
||
[동작] MEAN_REVERSION 전략은 과매도 진입이므로 필터 제외.
|
||
MA20 >= MA60 AND current >= MA20 (TREND_TOLERANCE_PCT 범위 내) 통과 조건.
|
||
|
||
Args:
|
||
stock_code: Stock code
|
||
stock_name: Stock name
|
||
is_domestic: True if domestic
|
||
exchange_code: Exchange code (for overseas)
|
||
current_price: Current price
|
||
stock: Stock dict with pre-calculated MA values
|
||
|
||
Returns:
|
||
dict: {"skip": False} (통과) or {"skip": True, "reason": "trend_filter"}
|
||
"""
|
||
_rule_name = stock.get("rule_name", "") or ""
|
||
_rule_type = stock.get("rule_type", "") or ""
|
||
_is_mean_reversion = (
|
||
"MEAN_REVERSION" in _rule_type
|
||
or "평균회귀" in _rule_name
|
||
or "mean_reversion" in _rule_name.lower()
|
||
)
|
||
|
||
if not TREND_FILTER_ENABLED or _is_mean_reversion:
|
||
return {"skip": False} # 필터 미적용
|
||
|
||
try:
|
||
ma20 = stock.get("ma20")
|
||
ma60 = stock.get("ma60")
|
||
ema20 = ema60 = None
|
||
closes_local = []
|
||
|
||
if ma20 is None or ma60 is None:
|
||
if is_domestic:
|
||
chart_resp = await korea_investment_api.get_daily_chart(stock_code, "D")
|
||
closes_local = [float(it.get("stck_clpr", 0)) for it in (chart_resp.get("output") or [])]
|
||
else:
|
||
chart_resp = await korea_investment_api.get_overseas_daily_chart(stock_code, exchange_code, "D")
|
||
closes_local = [float(it.get("clos", 0)) for it in (chart_resp.get("output2") or [])]
|
||
|
||
if len(closes_local) >= max(TREND_MA_SHORT, TREND_MA_LONG):
|
||
ma20 = _ma(closes_local, TREND_MA_SHORT)
|
||
ma60 = _ma(closes_local, TREND_MA_LONG)
|
||
ema20 = _ema(closes_local, TREND_MA_SHORT)
|
||
ema60 = _ema(closes_local, TREND_MA_LONG)
|
||
|
||
ma_ok = bool(
|
||
ma20 and ma60
|
||
and float(ma20) >= float(ma60) * (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 {"skip": True, "reason": "trend_filter"}
|
||
|
||
return {"skip": False}
|
||
|
||
except Exception as e:
|
||
logger.debug(f"[TREND_FILTER] {stock_code}: {type(e).__name__}: {e}")
|
||
return {"skip": True, "reason": "trend_filter_error"}
|
||
|
||
|
||
def calculate_cooldown_minutes(stock: dict, current_price: float) -> int:
|
||
"""Calculate dynamic cooldown minutes based on ATR (higher volatility → shorter cooldown).
|
||
|
||
[동작] ATR%가 높을수록(고변동) 쿨다운 단축 → max(15, 30 - atr_pct×3)분
|
||
예: ATR% = 5% → 쿨다운 = 15분
|
||
ATR% = 1% → 쿨다운 = 27분
|
||
|
||
Args:
|
||
stock: Stock dict with "atr" field
|
||
current_price: Current price (for ATR% calculation)
|
||
|
||
Returns:
|
||
int: Cooldown minutes
|
||
"""
|
||
atr_val = float(stock.get("atr", 0) or 0)
|
||
if atr_val > 0 and current_price > 0:
|
||
atr_pct = atr_val / current_price * 100
|
||
return max(15, int(30 - atr_pct * 3))
|
||
return int(stock.get("add_buy_cooldown_min", 30) or 30)
|
||
|
||
|
||
def get_daily_cutoff_utc() -> _dt_utc:
|
||
"""Get daily cutoff (00:00:00 UTC) for same-day order filtering.
|
||
|
||
[이슈1 수정] TradeHistory.created_at은 UTC 저장이므로 daily_cutoff도 UTC 기준 계산.
|
||
|
||
Returns:
|
||
datetime: Today's 00:00:00 UTC
|
||
"""
|
||
return _dt_utc.utcnow().replace(hour=0, minute=0, second=0, microsecond=0)
|
||
|
||
|
||
def get_leverage_check_keywords() -> set:
|
||
"""Get keywords for leverage/inverse ETF detection.
|
||
|
||
Returns:
|
||
set: Leverage/inverse ETF keywords
|
||
"""
|
||
return {
|
||
"레버리지", "2X", "3X", "인버스", "곱버스", "TIGER 200선물", "KODEX 인버스"
|
||
}
|
||
|
||
|
||
def is_leverage_etf(stock_name: str, keywords: set = None) -> bool:
|
||
"""Check if stock is leverage/inverse ETF.
|
||
|
||
Args:
|
||
stock_name: Stock name
|
||
keywords: Leverage keywords (optional, uses default if None)
|
||
|
||
Returns:
|
||
bool: True if leverage/inverse ETF
|
||
"""
|
||
if keywords is None:
|
||
keywords = get_leverage_check_keywords()
|
||
_name_upper = (stock_name or "").upper()
|
||
return (
|
||
any(kw in (stock_name or "") for kw in keywords)
|
||
or "LEVERAGE" in _name_upper
|
||
or "INVERSE" in _name_upper
|
||
)
|
||
|
||
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
# SELL HELPERS
|
||
# ────────────────────────────────────────────────────────────────────────────
|
||
|
||
def calculate_trade_costs(
|
||
entry_price: float,
|
||
exit_price: float,
|
||
quantity: int,
|
||
is_domestic: bool = True,
|
||
market: str = "KOSPI",
|
||
) -> dict:
|
||
"""매매 수수료 + 세금 계산 (net PnL 산출용).
|
||
|
||
Returns:
|
||
dict: {"buy_commission", "sell_commission", "sell_tax", "total_cost"}
|
||
"""
|
||
buy_commission = entry_price * quantity * settings.KR_COMMISSION_RATE
|
||
sell_commission = exit_price * quantity * settings.KR_COMMISSION_RATE
|
||
|
||
if not is_domestic:
|
||
sell_tax = 0.0
|
||
elif market in ("KOSDAQ", "kosdaq"):
|
||
sell_tax = exit_price * quantity * settings.KR_SELL_TAX_RATE_KOSDAQ
|
||
else:
|
||
sell_tax = exit_price * quantity * settings.KR_SELL_TAX_RATE_KOSPI
|
||
|
||
total_cost = buy_commission + sell_commission + sell_tax
|
||
return {
|
||
"buy_commission": round(buy_commission, 0),
|
||
"sell_commission": round(sell_commission, 0),
|
||
"sell_tax": round(sell_tax, 0),
|
||
"total_cost": round(total_cost, 0),
|
||
}
|
||
|
||
|
||
def calculate_profit_loss_rate(
|
||
current_price: float,
|
||
average_price: float,
|
||
holding_profit_loss_rate: float = None,
|
||
) -> float:
|
||
"""Calculate realized profit/loss rate as percentage.
|
||
|
||
Args:
|
||
current_price: Current price
|
||
average_price: Average entry price
|
||
holding_profit_loss_rate: Fallback value if calculation fails
|
||
|
||
Returns:
|
||
float: Profit/loss rate (%)
|
||
"""
|
||
if average_price > 0:
|
||
return ((current_price - average_price) / average_price) * 100
|
||
return float(holding_profit_loss_rate or 0)
|
||
|
||
|
||
def calculate_atr_based_stops(
|
||
average_price: float,
|
||
current_price: float,
|
||
atr: float,
|
||
stock_dict: dict,
|
||
base_stop_loss: float = -6.0,
|
||
) -> dict:
|
||
"""Calculate ATR-based stop loss and trailing stop.
|
||
|
||
[CLAUDE.md 5.2]
|
||
- ATR Stop: average_price - 2.5×ATR → TIGHTER(더 작은) 값 적용
|
||
- Trailing Stop: high_price - 1.5×ATR (수익 구간에서만)
|
||
|
||
Args:
|
||
average_price: Average entry price
|
||
current_price: Current price
|
||
atr: ATR value
|
||
stock_dict: Stock dict with optional "high_price"
|
||
base_stop_loss: Base stop loss percentage (default -6%)
|
||
|
||
Returns:
|
||
dict: {
|
||
"stop_loss": float (%),
|
||
"trailing_stop_price": float,
|
||
"exit_reason_detail": str
|
||
}
|
||
"""
|
||
stop_loss = base_stop_loss
|
||
trailing_stop_price = None
|
||
exit_reason_detail = ""
|
||
|
||
if atr > 0 and average_price > 0:
|
||
atr_stop_price = average_price - 2.5 * atr
|
||
atr_stop_pct = ((atr_stop_price / average_price) - 1) * 100
|
||
stop_loss = max(stop_loss, atr_stop_pct) # TIGHTER = 0에 가까운 쪽 (음수이므로 max)
|
||
|
||
high_price = float(stock_dict.get("high_price") or current_price)
|
||
trailing_stop_price = high_price - 1.5 * atr
|
||
|
||
profit_loss_rate = calculate_profit_loss_rate(current_price, average_price)
|
||
if trailing_stop_price and current_price <= trailing_stop_price and profit_loss_rate > 0:
|
||
exit_reason_detail = f"ATR 트레일링 스탑: {current_price:.0f} ≤ {trailing_stop_price:.0f}"
|
||
|
||
return {
|
||
"stop_loss": stop_loss,
|
||
"trailing_stop_price": trailing_stop_price,
|
||
"exit_reason_detail": exit_reason_detail,
|
||
}
|
||
|
||
|
||
def build_kis_order_id(order_result: dict) -> str:
|
||
"""Extract KIS order ID from API response.
|
||
|
||
[이슈3 수정] KIS response structure: output.ODNO preferred, fallback to top-level odno.
|
||
|
||
Args:
|
||
order_result: Order API response dict
|
||
|
||
Returns:
|
||
str: Order ID or empty string if not found
|
||
"""
|
||
return (
|
||
order_result.get("output", {}).get("ODNO", "")
|
||
or order_result.get("odno", "")
|
||
)
|
||
|
||
|
||
def get_kst_now() -> _dt_utc:
|
||
"""Get current time in KST (convenience wrapper for compatibility).
|
||
|
||
Returns:
|
||
datetime: Current time in KST
|
||
"""
|
||
return _get_kst_now()
|