Files
auto-stock/backend/app/services/trade_helpers.py
shuim 9fab4018d4 fix: 프로그램 검증 루프 7회차 — 리스크·주문·청산·인프라 결함 일괄 수정 + 거래 확대 결정 반영
하네스 규칙 검사 전역 위반 117→0, 회차별 검증(code-verifier opus 4도메인)으로 Critical/High 해소.

주문·리스크
- MDD 강제축소 반복 매도(종목별 완료 집합·재시도 상한·날짜 롤오버·잔고 오류 보류·epoch·장외 미주문)
- KIS 거부 FAILED 기록, 결과 불명(전송 후) 재전송 금지·PENDING, 미전송 실패만 재시도(msg_cd 분류)
- 국내 전량·손실 매도 시장가, 부분 익절 직전 현재가, 지정가만 미체결 취소(cancel_order_v2: TR 0013U·조직번호) + 0체결 롤백
- 누적 4%·현금·레버리지(신규분 포함)·사이클 내 매수 누적, 수동매수 공용 가드(buy_guards), 수동매도 DT 차감·락
- 가드 우회 엔드포인트 차단(/recommendations/auto-trade 위임, US /execute 매수 410·매도 가드)
- 매수 슬롯=성공 매수, 평가 상한·회전, 업무거부/계좌 쿨다운, 프로세스 내 in-flight 주문 중복 차단
데이트레이딩
- 진입·청산 shield, 청산 진행 중 표식(소유 토큰), 2단계 청산 체크, 결과 불명 → 보유 감소 판정·비DT 차감 상한
- 전일 포지션 재시도(일시 실패 미산정), 분할매도 영속, ORB 구간 손절, /cycle 락, 사이클 내 현금 차감, 체결 수량 보정
스윙
- 손절 공백(판정 예외 시 하드 바닥), 단일 손절 소스·범위 검증, 매도 직전 KIS 잔고 상한(min(주문가능,보유)−DT)
- 결과 불명 체결 대조·장부 반영, 대체가(나이·진입 이후·ATR 캐시), 분할 단계 기록, 국면 실패 fail-closed
- 듀얼 플레이북·서킷브레이커 게이트, 평균회귀 우선·듀얼 독립, 시간손절 거래일
- 거래 확대(사용자 결정, 백테스트 미검증): RSI 46/35 정본 경로, 모멘텀 v2 ON, SIDEWAYS ADX AND, 트레일링 1차 익절 후, 20일 시간손절 ON
인프라·보안
- 계좌·손익 GET 인증, 세션 is_active·관리자 경로, 서명키 파생, realip, WS 토큰 마스킹
- KIS 토큰 즉시 실패(sleep 제거)·무효 시 캐시 삭제, 주문 5xx+JSON 본문 반환
- db_worker 종료 drain(멱등 재실행·커밋 불명 격리·예산·밴 커밋), stop_grace 40s, Redis 타임아웃·volatile-lru, /health DLQ·캐시
- KRX 휴장일, py3.11 비호환 f-string 수정 + Jenkins 이미지 내 compileall·헬스 실패 시 빌드 실패
하네스: rules_check 에 PEP 701 f-string 검사, 마커(allow-*), deploy 스모크 401 기준
CLAUDE.md: 실제 동작·플래그·백로그(§8.5-27~38) 갱신

backend 1451 passed / hooks 112 passed / tsc ok / py3.9 compile 0 errors

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 12:01:31 +09:00

383 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
from app.core.timeutil import now_kst
from app.core.log_throttle import warn_throttled # noqa: E402
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:
warn_throttled(logger, "trade_helpers:108", 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:
warn_throttled(logger, "trade_helpers:185", 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 now_kst().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()