fix: 매매 파라미터 3중 소스 정합 + 리스크 가드 복원 + 죽은 코드 2,695줄 제거
## 배경 "전략이 혼란스럽고 현재와 맞지 않는다"를 조사한 결과, 전략 설계가 아니라 전략을 검증했다는 도구와 파라미터 소스가 어긋나 있었다. ## 파라미터 소스 단일화 - 우선순위가 adaptive > strategy_cfg(DB) > config.py 인데 문서는 맨 마지막인 config.py 를 기술하고 있었다. 11곳이 불일치(BULL RSI 46 vs DB 30, SL -7% vs -6%). - DEFAULT_PARAMS 를 config.py 에 정합 + alembic 0012 로 기존 DB 행 갱신 (운영자가 UI 로 바꾼 값은 보존, 옛 기본값과 같은 행만 갱신, 테이블 가드·멱등) ## 백테스트를 실전 로직에 정합 - calculate_adx_simple 이 정확히 period 배(14.0×) 발산 → Aggressive 게이트 `ADX>25` 가 전 레짐 100% 통과. 전환 빈도 5.6%→2.0%(-65%), 성과 상방 편향 - 백테스트 RSI 는 SMA, 실전은 Wilder → 진입 판정 10~11% 불일치 - us_backtest_engine RSI 1봉 지연 제거 (룩어헤드 없음을 절단 테스트로 증명) - backend/backtest_results/ 25건은 폐기 대상 (§8.4) ## 리스크 가드 복원 - 4% 절대 상한이 try 블록 안에 있어 예외 시 적응형 6~7% 가 통과 → try 밖으로 - 청산을 매수보다 먼저 평가 (보유 종목이 추천에 들면 손절 대신 물타기가 나갔다) - 리스크 가드 4개를 매수 전용화 → MDD -10%·보유한도 도달 시에도 손절 작동 - MDD 갱신 5회 연속 실패 시 신규 진입 차단 fail-safe (청산은 계속 허용) - 스윙 청산이 DT 보유분까지 매도하던 문제, sell_pct 기준 혼재 정정 ## 죽은 코드 제거 (2,695줄) - short_term_strategy, kosdaq_scanner, app/strategy/ 패키지 - app/trading/ + korea_investment_api_adapter (real_market_api 정본은 korea_investment_api.py:1482, adapter 는 import 0건) ## 지표·사이징 단일화 (수치 무변경 증명) - indicators_core.py: 3,274건 비교 최대 오차 0.000e+00 - sizing_core.py: 42,105건 비교 불일치 0 - 미국 전략 상수 13개 config 편입 (hex 까지 동일, 3,168 케이스 전수 비교) ## 테스트 안전 (실 KIS 호출 차단) - 유닛 테스트가 실제 KIS 에 매도 주문을 2회 전송하고 있었다. 리팩터링이 close_stale_positions 의 대상을 _execute_exit → _execute_exit_stale 로 바꿨는데 목이 전자에 걸려 빗나갔다. test_api_endpoints 도 캡처된 싱글톤 참조 누수로 실제 잔고 조회를 타 141초를 소비했다. - backend/tests/conftest.py 신설: 주문·토큰·시세·잔고를 호출 즉시 예외로 차단. 예외는 BaseException 파생(프로덕션 except Exception 폴백에 삼켜지지 않게), 클래스 메서드에 적용(캡처된 참조까지 덮음), DATABASE_URL `_test` 가드 강제 - 전체 유닛 스위트: 365초/6 failed → 11.8초/0 failed (소켓 차단 하 559 passed) ## 신규 (둘 다 플래그 OFF, 백테스트 미검증) - range_trading_strategy.py — SIDEWAYS 담당 전략이 없던 빈 칸. 미배선 - check_bear_buy_signal_v2 — BEAR 4중 AND(발화율 2.3%) → 점수제(12.4%) ## 문서 - CLAUDE.md 를 실제 코드 기준으로 재작성. §0 파라미터 우선순위, §4.1 테스트 규약, §6.4 문서화되지 않았던 진입 차단 게이트 9종, §8.4 백테스트 폐기 근거, §8.5 미해결 19항목 - 루트 md 6개 → 2개(docs/ 이동), 중복 배치 문서 병합, 스테일 worktree 제거 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,7 @@ import time
|
||||
from decimal import Decimal
|
||||
from datetime import datetime, timedelta
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, desc
|
||||
from sqlalchemy import select, desc, and_, or_
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.database import AsyncSessionLocal
|
||||
@@ -29,6 +29,10 @@ from app.services.mean_reversion_strategy import (
|
||||
from app.services.technical_indicators import (
|
||||
TREND_FILTER_ENABLED, TREND_MA_SHORT, TREND_MA_LONG, TREND_TOLERANCE_PCT, _ma, _ema,
|
||||
)
|
||||
# §8.5-2 포지션 사이징 단일 출처 (순수 계산 모듈 — settings/DB/네트워크 없음)
|
||||
from app.services.sizing_core import (
|
||||
amount_to_quantity, clamp_by_ratios, effective_min_buy_amount, half_kelly_fraction,
|
||||
)
|
||||
from app.services.signal_types import BUY_KEYWORDS, SELL_KEYWORDS, STRONG_BUY_KEYWORDS, BLOCK_KEYWORDS
|
||||
from app.services.correlation_filter import validate_correlation_filter
|
||||
from app.services.exchange_detector import is_us_stock, detect_exchange, get_market_info
|
||||
@@ -42,6 +46,39 @@ from app.services.trade_helpers import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── 잔량 추적 해석 (CLAUDE.md §8.5 항목4) ─────────────────────────────────────
|
||||
def _resolve_remaining_pct_v2(stock: dict, holding=None) -> tuple[float, bool]:
|
||||
"""remaining_pct 와 '잔량 추적 가능 여부' 를 함께 반환.
|
||||
|
||||
기존 `stock.get("remaining_pct", 1.0) or 1.0` 은 '추적 소스 없음(None)' 과
|
||||
'실제로 잔량 100%' 를 구분하지 못했다. ActiveTrade 행이 없는 보유분
|
||||
(브로커 잔고 sync 로 생긴 Portfolio 행 / legacy 행)은 매 사이클 잔량 100%로
|
||||
간주되어 SELL_PARTIAL(50%)이 무한 반복됐다.
|
||||
|
||||
Args:
|
||||
stock: 종목 dict (remaining_pct 주입 대상)
|
||||
holding: Portfolio ORM (현재 판정에 미사용 — 향후 확장용, 시그니처 고정)
|
||||
|
||||
Returns:
|
||||
(remaining_pct, is_tracked)
|
||||
- is_tracked=False → 잔량을 신뢰할 수 없음. 호출측은 부분매도를 스킵하고
|
||||
전량 청산 신호만 채택해야 한다. 값은 기존 동작과 동일한 1.0 을 준다.
|
||||
- remaining_pct 는 항상 0 < x <= 1.0 범위로 클램프.
|
||||
"""
|
||||
raw = stock.get("remaining_pct") if isinstance(stock, dict) else None
|
||||
if raw is None:
|
||||
return 1.0, False
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return 1.0, False
|
||||
if not (0.0 < val <= 1.0):
|
||||
# 0 이하(전량 소진) 또는 1 초과(오염) → 추적 불가로 간주
|
||||
return 1.0, False
|
||||
return val, True
|
||||
|
||||
|
||||
# ── 종목별 세마포어 — 동일 종목 동시 주문 방지 ────────────────────────────────
|
||||
_order_semaphores: dict = {}
|
||||
_SEMAPHORE_MAX_SIZE = 200
|
||||
@@ -464,17 +501,18 @@ async def _calculate_kelly_position(
|
||||
return 0.0 # 데이터 부족 → v2 fallback
|
||||
|
||||
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 # 전승 또는 전패 → Kelly 계산 불가
|
||||
# [§8.5-2 통합 2026-09-27] Kelly 통계 수식은 sizing_core.half_kelly_fraction 로 이동
|
||||
# (순수 계산). DB 조회·예외 처리·fallback 판단은 이 함수에 남는다.
|
||||
# min_trades=5 미만 / 전승 / 전패 → None → 기존과 동일하게 0.0 반환(v2 fallback).
|
||||
half_kelly = half_kelly_fraction(rates, min_trades=5)
|
||||
if half_kelly is None:
|
||||
return 0.0
|
||||
|
||||
p = len(wins) / len(rates) # 승률
|
||||
b = (sum(wins) / len(wins)) / (sum(losses) / len(losses)) # Payoff Ratio
|
||||
kelly_f = (p * b - (1 - p)) / b # Kelly 공식
|
||||
half_kelly = max(0.0, kelly_f / 2) # Half-Kelly (보수적)
|
||||
clamped = min(half_kelly, max_per_position) # 종목당 최대 4% 상한 적용
|
||||
return min(total_assets * clamped, available_cash, total_assets * max_per_position)
|
||||
# = min(총자산×clamped, 가용현금, 총자산×max_per_position) — 통합 전과 동일
|
||||
return clamp_by_ratios(
|
||||
total_assets * clamped, total_assets, [max_per_position], available_cash,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.debug(f"[kelly] {stock_code} 계산 오류: {type(e).__name__}: {e}")
|
||||
return 0.0
|
||||
@@ -844,14 +882,21 @@ async def _execute_buy(
|
||||
_strategy_total_alloc += _h_qty * _h_price
|
||||
|
||||
# ── 7.0. 적응형 전략 파라미터 적용 ─────────────────────────────────────
|
||||
# position_size 상한을 적응형 모드에 따라 조정 (Crisis=0, Defensive=3% 등)
|
||||
_adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4%
|
||||
# position_size 상한을 적응형 모드에 따라 조정 (Crisis=1.5%, Defensive=4% 등)
|
||||
#
|
||||
# [HIGH-2 수정] settings.MAX_POSITION_SIZE(4%)는 CLAUDE.md §5.1/§8.2가 규정한
|
||||
# "종목당 절대 상한(hard ceiling)"이다. adaptive_strategy._MODE_PARAMS 는 문서·백테스트
|
||||
# 기준값이므로 그 숫자 자체는 건드리지 않고(Normal 6% / Aggressive 7% 유지),
|
||||
# 소비 지점인 여기서 min()으로 클램프해 절대 상한을 넘지 못하게 한다.
|
||||
# → 결과적으로 Normal/Aggressive의 실효 종목당 비중은 4%로 묶이고,
|
||||
# Defensive(4%)/Crisis(1.5%)처럼 4% 이하인 모드만 적응형 값이 실제로 작동한다.
|
||||
_adaptive_position_cap = settings.MAX_POSITION_SIZE # 기본 4% = 절대 상한
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
_adp = get_current_adaptive_params()
|
||||
if not _adp.fallback_used:
|
||||
_adaptive_position_cap = _adp.position_size
|
||||
# Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.5)
|
||||
_adaptive_position_cap = min(_adp.position_size, settings.MAX_POSITION_SIZE)
|
||||
# Crisis 모드: max_daily_trades=0이면 신규 진입 차단 (CLAUDE.md 섹션 8.4)
|
||||
if _adp.trading_mode == "crisis" and _adp.max_daily_trades <= 0:
|
||||
return {"action": "SKIP", "reason": "adaptive_crisis_mode", "stock": stock_name}
|
||||
except Exception:
|
||||
@@ -920,11 +965,25 @@ async def _execute_buy(
|
||||
current_price=current_price,
|
||||
current_volume=float(stock.get("volume", 0) or 0),
|
||||
)
|
||||
# CLAUDE.md 5.1: 패턴 조정 후에도 종목당 4% 상한 재적용
|
||||
max_by_position = total_assets * settings.MAX_POSITION_SIZE
|
||||
invest_amount = min(invest_amount, max_by_position, available_cash)
|
||||
except Exception as _pm_err:
|
||||
logger.debug(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (무시): {_pm_err}")
|
||||
# [HIGH-1 수정] 조용한 실패 방지 — debug → warning.
|
||||
logger.warning(f"[PATTERN_MEMORY] {stock_code} 포지션 조정 실패 (기본 투자금액 유지): {_pm_err}")
|
||||
|
||||
# [HIGH-1 수정] CLAUDE.md §5.1 종목당 4% 절대 상한 재적용.
|
||||
# 과거에는 이 클램프가 위 try 블록 안에 있어, get_pattern_adjusted_invest_amount()가
|
||||
# 예외를 던지면 클램프를 건너뛰고 Kelly/v2가 _adaptive_position_cap(적응형 6~7%)로
|
||||
# 산출한 금액이 그대로 통과했다. 예외 발생 여부와 무관하게 항상 적용되도록 try 밖으로 이동.
|
||||
# total_assets=0(잔고 조회 실패 등)이면 비율 상한을 계산할 수 없으므로 현금 상한만 적용
|
||||
# — 0으로 클램프해 전 종목이 quantity_zero로 조용히 탈락하는 것을 방지.
|
||||
# [§8.5-2 통합 2026-09-27] 클램프 수식만 sizing_core 로 위임 — 클램프 **순서**
|
||||
# (글로벌 자본 가드 → 스윙 자본 캡 → 패턴 메모리 → 절대 상한)와 total_assets>0
|
||||
# 분기는 최근 확정된 동작이므로 그대로 유지한다.
|
||||
if total_assets > 0:
|
||||
invest_amount = clamp_by_ratios(
|
||||
invest_amount, total_assets, [settings.MAX_POSITION_SIZE], available_cash,
|
||||
)
|
||||
else:
|
||||
invest_amount = min(invest_amount, available_cash)
|
||||
|
||||
# [BUG FIX] 국면별 배율(_position_multiplier)과 뉴스 배율(_news_position_scale)을
|
||||
# invest_amount에 반영 — 기존에는 stock dict에 주입만 되고 실제로 사용되지 않았음.
|
||||
@@ -948,7 +1007,9 @@ async def _execute_buy(
|
||||
)
|
||||
|
||||
# 투자금액 → 주수 환산 (Decimal 사용으로 부동소수점 오차 방지)
|
||||
quantity = int(Decimal(str(invest_amount)) / Decimal(str(current_price)))
|
||||
# [§8.5-2 통합 2026-09-27] sizing_core.amount_to_quantity 위임 — 동일한
|
||||
# int(Decimal(str(amount))/Decimal(str(price))) 경로. max(1, ...) 없음(예산 0 → 0주).
|
||||
quantity = amount_to_quantity(invest_amount, current_price)
|
||||
logger.info(
|
||||
f"[BUY_CALC] {stock_code}/{stock_name}: cash={available_cash:,.0f} assets={total_assets:,.0f} "
|
||||
f"invest={invest_amount:,.0f} qty={quantity} price={current_price:,.0f}"
|
||||
@@ -963,18 +1024,26 @@ async def _execute_buy(
|
||||
logger.info(f"[BUY_SKIP] {stock_code}/{stock_name}: {_qz_reason} (invest={invest_amount:,.0f})")
|
||||
return {"action": "SKIP", "reason": _qz_reason, "stock": stock_name}
|
||||
|
||||
# 최소 매수 금액 가드: 수수료 대비 비효율 방지 (기본 config.MIN_BUY_AMOUNT_KRW, 50만원)
|
||||
# 큰 계좌는 기존과 동일하게 config 하한(기본 50만원)이 그대로 적용된다.
|
||||
# 소액 계좌(총자산이 작아 6% 예산 자체가 50만원 미만인 경우)는 이 하한 때문에
|
||||
# 전종목이 영구 차단되는 문제를 완화하기 위해, "config 하한"과
|
||||
# "총자산 × 적응형 포지션상한 × 0.9" 중 더 작은 값을 실효 하한으로 사용한다.
|
||||
# (총자산이 충분히 크면 항상 config값이 더 작게 선택되어 기존 동작과 동일 — 소액 계좌만 완화됨.)
|
||||
_MIN_BUY_AMOUNT = settings.MIN_BUY_AMOUNT_KRW
|
||||
# 최소 매수 금액 가드: 수수료 대비 비효율 방지 (기본 config.MIN_BUY_AMOUNT_KRW, 15만원)
|
||||
#
|
||||
# [MEDIUM-5 문서 불일치 — 의도된 설계]
|
||||
# CLAUDE.md §5.1/§8.2는 최소주문금액을 "15만원 고정"으로 서술하지만,
|
||||
# 실제 구현은 아래와 같이 소액 계좌에서만 자동 하향된다:
|
||||
# 실효하한 = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형포지션상한 × 0.9)
|
||||
# 이유: 총자산이 작으면 종목당 예산(총자산×4%) 자체가 15만원 미만이 되어
|
||||
# 모든 종목이 min_amount_guard로 영구 차단된다(2026-09-23 하루 141건 차단 사례).
|
||||
# 큰 계좌에서는 항상 config값(15만원)이 더 작게 선택되므로 문서와 동일하게 동작한다.
|
||||
# → 동작을 문서에 맞추는 대신 문서(§5.1)를 이 동작에 맞춰 보정할 예정. 코드 변경 금지.
|
||||
# 예) 총자산 300만원 × 4% × 0.9 = 108,000원이 실효 하한(15만원 아님).
|
||||
# [§8.5-2 통합 2026-09-27] 계산식만 sizing_core.effective_min_buy_amount 로 위임.
|
||||
# = min(MIN_BUY_AMOUNT_KRW, 총자산 × 적응형cap × 0.9). 로직·계수 변경 없음.
|
||||
try:
|
||||
if total_assets > 0 and _adaptive_position_cap > 0:
|
||||
_small_account_cap = total_assets * _adaptive_position_cap * 0.9
|
||||
if _small_account_cap > 0:
|
||||
_MIN_BUY_AMOUNT = min(_MIN_BUY_AMOUNT, _small_account_cap)
|
||||
_MIN_BUY_AMOUNT = effective_min_buy_amount(
|
||||
config_min_amount=settings.MIN_BUY_AMOUNT_KRW,
|
||||
total_assets=total_assets,
|
||||
position_cap_ratio=_adaptive_position_cap,
|
||||
factor=0.9,
|
||||
)
|
||||
except Exception:
|
||||
_MIN_BUY_AMOUNT = settings.MIN_BUY_AMOUNT_KRW
|
||||
_actual_amount = quantity * current_price
|
||||
@@ -1194,6 +1263,12 @@ async def _execute_sell(
|
||||
# 수량이 합산돼 있다. 행 전체를 대상으로 잡으면 다른 전략 보유분까지 매도된다
|
||||
# → 데이트레이딩 엔진이 들고 있는 수량은 제외한 '이 전략의 보유분'만 대상으로 한다.
|
||||
total_qty = holding.quantity
|
||||
# [M-3 2026-09-27] 변수 기준 구분:
|
||||
# _row_total_qty = Portfolio 행 수량(스윙+DT 합산) — 포트폴리오 캐시/DB 행 기준
|
||||
# total_qty = 이 전략의 보유분(_own_qty, DT 제외) — 주문·잔량 회계 기준
|
||||
# 캐시 반영은 반드시 행 합산 기준(_row_total_qty)을 써야 DT 보유분이 사라지지 않는다
|
||||
# (swing/exit_service.reflect_sell / portfolio_cache_bridge 와 동일 기준).
|
||||
_row_total_qty = int(total_qty or 0)
|
||||
_strategy_tag = stock.get("strategy_type", "swing")
|
||||
if _strategy_tag != "daytrading":
|
||||
try:
|
||||
@@ -1212,11 +1287,47 @@ async def _execute_sell(
|
||||
logger.info(
|
||||
f"[SELL] {stock_name}: 보유 {total_qty}주 중 DT {_dt_qty}주 제외 → {_own}주 대상"
|
||||
)
|
||||
total_qty = _own
|
||||
total_qty = _own # = _own_qty (이 전략 보유분)
|
||||
|
||||
# ── 매도 수량 결정 (원본 수량 기준) ──────────────────────────────────────
|
||||
# [M-1 2026-09-27] sell_pct 의 의미 = '진입 시 원본 수량 대비' 비율.
|
||||
# check_exit_signal(>0.50) / _check_momentum_exit(>0.75, 0.45~0.75) /
|
||||
# range_trading_strategy(>0.50) 의 remaining_pct 게이트가 원본 누적
|
||||
# (1.0 → 0.70 → 0.40)을 전제하므로 매도 수량도 원본 기준이어야 한다.
|
||||
# 기존 코드는 수량은 '현재 잔량' 기준, new_remaining 은 '원본' 기준 차감이라
|
||||
# 분할이 반복될수록 과소매도 + remaining_pct 오염이 누적됐다.
|
||||
# → swing/exit_service.py 와 동일하게 원본 수량을 remaining_pct 로 역산한다.
|
||||
_rp_for_qty, _rp_tracked_for_qty = _resolve_remaining_pct_v2(stock, holding)
|
||||
_original_qty = total_qty
|
||||
if _rp_tracked_for_qty and 0.0 < _rp_for_qty < 1.0:
|
||||
# 0 나눗셈 가드 = 위 조건(0 < rp), original_qty < total_qty 가드 = max()
|
||||
_original_qty = max(total_qty, int(round(total_qty / _rp_for_qty)))
|
||||
elif not _rp_tracked_for_qty and sell_pct < 1.0:
|
||||
# 추적 불가(ActiveTrade active 행 없음/오염) → 역산 불가.
|
||||
# 상위 execute_auto_trade 가 이 경우 부분매도 신호를 폐기하므로(§8.5 항목4①)
|
||||
# 정상 경로에서는 도달하지 않는다. 방어적으로 '현재 잔량 = 원본' 으로 두어
|
||||
# 기존 동작(현재 잔량 기준 비율 매도)을 유지한다.
|
||||
logger.warning(
|
||||
f"[SELL] {stock_code}: remaining_pct 추적 불가 상태에서 부분매도 요청 "
|
||||
f"(sell_pct={sell_pct*100:.0f}%) — 원본 수량 역산 불가, 현재 잔량 기준 적용"
|
||||
)
|
||||
|
||||
quantity = max(1, int(total_qty * sell_pct)) # 최소 1주
|
||||
if sell_pct >= 1.0:
|
||||
quantity = total_qty # 전량매도 시 잔여분 없도록
|
||||
quantity = total_qty # 전량매도 시 잔여분 없도록 (기존 동작 유지)
|
||||
else:
|
||||
quantity = min(max(int(_original_qty * sell_pct), 1), total_qty)
|
||||
|
||||
# [M-2 2026-09-27] 계산된 매도 수량이 보유 전량과 같아지면(1~2주 소량 보유 +
|
||||
# sell_pct=0.5 → max(1,·) 로 전량 체결) 실질적 전량 청산이다. sell_pct<1.0
|
||||
# 분기를 그대로 타면 close_active_trade 가 호출되지 않아 quantity=0 유령 active
|
||||
# 행이 남고, loss_tracker.record_trade_result_safe() 도 스킵돼 §5.1 3연패 밴
|
||||
# 카운트에서 이 청산이 빠진다 → 실제 체결 기준으로 전량 청산 처리한다.
|
||||
_full_exit = sell_pct >= 1.0 or (total_qty > 0 and quantity >= total_qty)
|
||||
if _full_exit and sell_pct < 1.0:
|
||||
logger.info(
|
||||
f"[SELL] {stock_code}: 부분매도 요청({sell_pct*100:.0f}%)이 보유 전량"
|
||||
f"({total_qty}주)과 동일 → 전량 청산으로 처리 (ActiveTrade 종료 + 손실추적)"
|
||||
)
|
||||
|
||||
_avg = float(holding.average_price or 0)
|
||||
profit_loss_rate = calculate_profit_loss_rate(current_price, _avg, holding.profit_loss_rate)
|
||||
@@ -1231,7 +1342,8 @@ async def _execute_sell(
|
||||
_avg, current_price, atr, stock,
|
||||
base_stop_loss=base_stop_loss
|
||||
)
|
||||
stop_loss = atr_stops["stop_loss"]
|
||||
# [2026-09-27] atr_stops["stop_loss"] 는 _execute_sell 내에서 사용되지 않아 제거
|
||||
# (실제 손절 판정은 execute_auto_trade의 ATR 강제 손절/check_exit_signal에서 수행)
|
||||
trailing_stop_price = atr_stops["trailing_stop_price"]
|
||||
exit_reason_detail = atr_stops["exit_reason_detail"] or f"손익률 {profit_loss_rate:.2f}%"
|
||||
|
||||
@@ -1309,7 +1421,8 @@ async def _execute_sell(
|
||||
|
||||
if success:
|
||||
# [CLAUDE.md 5.5 / 8.1#6] 연속 손실 추적 — 전량매도 시 loss_tracker 기록
|
||||
if sell_pct >= 1.0:
|
||||
# [M-2] _full_exit = sell_pct>=1.0 또는 '계산 수량 == 보유 전량'
|
||||
if _full_exit:
|
||||
try:
|
||||
from app.services.loss_tracker import loss_tracker
|
||||
is_loss = profit_loss_rate < 0
|
||||
@@ -1326,36 +1439,88 @@ async def _execute_sell(
|
||||
# 실제 행의 strategy_name 을 사용한다. strategy_type("swing")으로 닫으면
|
||||
# 듀얼 전략 행(MOMENTUM_BREAKOUT/PULLBACK_BUY)과 매칭되지 않아 유령 행이 남는다.
|
||||
_at_name = stock.get("strategy_name")
|
||||
# [§8.5 항목3 2026-09-27] 역추적 쿼리에 DT 제외 조건이 없어, 같은 종목에
|
||||
# DT 행이 병존하면 스윙 매도가 DT 행의 strategy_name 을 집어 DT
|
||||
# ActiveTrade 를 닫아버렸다 → not_dt_strategy_clause 로 통일.
|
||||
# 전략명이 NULL 인 비-DT 행(legacy/동기화 유입)은 이름으로 매칭할 수
|
||||
# 없으므로 payload 에 strategy_name_not 필터를 써서 그 행을 직접 겨냥한다
|
||||
# ('swing' 으로 폴백하면 NULL 행과 매칭되지 않아 유령 active 행이 남는다).
|
||||
_at_filter_not_dt = False
|
||||
if not _at_name:
|
||||
try:
|
||||
from app.models.models import ActiveTrade as _AT
|
||||
from app.services.db_worker import not_dt_strategy_clause
|
||||
_at_row = (await db.execute(
|
||||
select(_AT)
|
||||
.where(_AT.stock_code == stock_code, _AT.status == "active")
|
||||
.where(
|
||||
_AT.stock_code == stock_code,
|
||||
_AT.status == "active",
|
||||
not_dt_strategy_clause(_AT.strategy_name),
|
||||
)
|
||||
.order_by(_AT.id.desc())
|
||||
.limit(1)
|
||||
)).scalars().first()
|
||||
_at_name = _at_row.strategy_name if _at_row else None
|
||||
if not _at_name:
|
||||
# 비-DT 행이 없거나(=닫을 대상 없음) 전략명이 NULL 인 경우.
|
||||
# DT 로 태깅된 보유분은 기존 동작(=DT 행 종료)을 유지한다.
|
||||
from app.services.db_worker import DT_STRATEGY_NAMES
|
||||
_st = stock.get("strategy_type") or "swing"
|
||||
if _st in DT_STRATEGY_NAMES:
|
||||
_at_name = _st
|
||||
else:
|
||||
_at_filter_not_dt = True
|
||||
except Exception:
|
||||
_at_name = None
|
||||
_at_filter_not_dt = False
|
||||
_at_name = _at_name or stock.get("strategy_type", "swing")
|
||||
|
||||
if sell_pct >= 1.0:
|
||||
from app.services.db_worker import enqueue_close_active_trade
|
||||
await enqueue_close_active_trade(stock_code, strategy_name=_at_name)
|
||||
if _full_exit: # [M-2] 실제 체결이 보유 전량이면 active 행을 닫는다
|
||||
if _at_filter_not_dt:
|
||||
from app.services.db_worker import (
|
||||
DT_STRATEGY_NAMES as _DTN,
|
||||
enqueue_db_write,
|
||||
)
|
||||
await enqueue_db_write({
|
||||
"type": "close_active_trade",
|
||||
"data": {
|
||||
"stock_code": stock_code,
|
||||
"strategy_name_not": list(_DTN),
|
||||
},
|
||||
})
|
||||
else:
|
||||
from app.services.db_worker import enqueue_close_active_trade
|
||||
await enqueue_close_active_trade(stock_code, strategy_name=_at_name)
|
||||
else:
|
||||
from app.services.db_worker import enqueue_active_trade_upsert
|
||||
# [BUG FIX] 기존 remaining_pct에서 sell_pct를 차감해야 누적 분할매도가 올바르게 반영됨
|
||||
# 예: 1차 50% 매도 후 remaining=0.5, 2차 50% 매도 시 remaining=0.0
|
||||
remaining_pct = float(stock.get("remaining_pct", 1.0) or 1.0)
|
||||
new_remaining = max(0.0, remaining_pct - sell_pct)
|
||||
await enqueue_active_trade_upsert({
|
||||
# [§8.5 항목4] 추적 여부 확인용 (미추적 시 기존과 동일하게 1.0).
|
||||
# 잔량 비율 자체는 아래에서 '원본 수량 기준 실제 체결' 로 계산한다.
|
||||
remaining_pct, _rp_tracked = _resolve_remaining_pct_v2(stock, holding)
|
||||
if not _rp_tracked:
|
||||
logger.info(
|
||||
f"[SELL] {stock_code}: remaining_pct 추적 불가 → 1.0 기준으로 "
|
||||
f"ActiveTrade 잔량 재설정 (sell_pct={sell_pct*100:.0f}%)"
|
||||
)
|
||||
# [M-1] 원본 수량 기준 실제 잔량 비율. 뺄셈(remaining_pct - sell_pct)은
|
||||
# 매도 수량이 현재 잔량 기준일 때와 어긋나 잔량이 오염됐다 →
|
||||
# 실제 체결 수량으로 계산한다 (예: 원본 100, 잔량 70, 50주 매도 → 0.20).
|
||||
new_remaining = max(0.0, (total_qty - quantity) / _original_qty) \
|
||||
if _original_qty > 0 else 0.0
|
||||
_at_payload = {
|
||||
"stock_code": stock_code,
|
||||
"strategy_name": _at_name,
|
||||
"remaining_pct": new_remaining,
|
||||
"quantity": max(0, total_qty - quantity), # 잔량 갱신(과다매도 방지)
|
||||
"highest_since_entry": current_price,
|
||||
})
|
||||
}
|
||||
if _at_filter_not_dt:
|
||||
# 전략명 NULL 인 비-DT 행 대상 (이름 동등 매칭 불가).
|
||||
# 대상 행이 아예 없으면 db_worker 가 필수 키 누락으로 스킵 —
|
||||
# 매도 경로에서 새 active 행을 만들지 않는 것이 의도된 동작이다.
|
||||
from app.services.db_worker import DT_STRATEGY_NAMES as _DTN2
|
||||
_at_payload["strategy_name_not"] = list(_DTN2)
|
||||
else:
|
||||
_at_payload["strategy_name"] = _at_name
|
||||
await enqueue_active_trade_upsert(_at_payload)
|
||||
except Exception as _at_err:
|
||||
logger.warning(f"[SELL] ActiveTrade 업데이트 실패 {stock_code}: {_at_err}")
|
||||
|
||||
@@ -1365,7 +1530,11 @@ async def _execute_sell(
|
||||
avg = float(holding.average_price or current_price)
|
||||
await portfolio_cache.apply_sell(stock_code, quantity, current_price, avg)
|
||||
# [CLAUDE.md 5.2] 분할매도: 잔여 수량이 있으면 캐시에서 제거하지 않음
|
||||
remaining_qty = total_qty - quantity
|
||||
# [M-3 2026-09-27] 캐시/Portfolio 행 기준 잔량은 '행 합산(_row_total_qty)'
|
||||
# 에서 매도 수량을 뺀 값이어야 한다. 스윙 보유분(total_qty=_own_qty)으로
|
||||
# 계산하면 스윙 전량 청산 시 remaining_qty=0 이 되어 DT 보유분까지 캐시에서
|
||||
# 삭제됐다 (Portfolio DB 행은 합산 수량으로 잔존 → 캐시/DB 불일치).
|
||||
remaining_qty = max(0, _row_total_qty - quantity)
|
||||
if remaining_qty <= 0:
|
||||
portfolio_cache.holdings = [
|
||||
h for h in portfolio_cache.holdings if h.stock_code != stock_code
|
||||
@@ -1464,19 +1633,20 @@ async def execute_auto_trade_background(recommendations_response: dict, db: Asyn
|
||||
"""
|
||||
if not isinstance(recommendations_response, dict):
|
||||
logger.error("[AUTO_TRADE_BG] recommendations_response is not a dict: %r", recommendations_response)
|
||||
return
|
||||
return []
|
||||
|
||||
if _auto_trade_bg_lock.locked():
|
||||
logger.info("[AUTO_TRADE_BG] 이전 실행이 진행 중 — 이번 호출 스킵 (중복 실행 방지)")
|
||||
return
|
||||
return []
|
||||
async with _auto_trade_bg_lock:
|
||||
await _execute_auto_trade_background_locked(recommendations_response, db)
|
||||
return await _execute_auto_trade_background_locked(recommendations_response, db)
|
||||
|
||||
|
||||
async def _execute_auto_trade_background_locked(recommendations_response: dict, db: AsyncSession):
|
||||
# trade_results 는 except 절에서도 참조하므로 try 밖에서 바인딩한다(항상 list 반환 보장).
|
||||
trade_results: list = []
|
||||
try:
|
||||
total_processed = 0
|
||||
trade_results = []
|
||||
|
||||
# 시장 국면 조회 (BEAR 시 max_trades 축소, position_multiplier 감소)
|
||||
market_regime = None
|
||||
@@ -1509,10 +1679,10 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict,
|
||||
f"[AUTO_TRADE_BG] 일일 매수 한도 도달 (오늘 {_bought_today}종목 / 한도 "
|
||||
f"{min(settings.MAX_DAILY_TRADES, regime_max_trades)}) — 신규 진입 스킵"
|
||||
)
|
||||
return
|
||||
return []
|
||||
|
||||
result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||
held_stocks = {p.stock_code for p in result.scalars().all()} # 현재 보유 종목 집합
|
||||
# [2026-09-27] held_stocks 집합은 파일 전체에서 미사용이라 전용 SELECT 와 함께 제거.
|
||||
# 보유 여부는 execute_auto_trade() 내부에서 종목별로 직접 조회한다.
|
||||
|
||||
# recommendations_by_rule 정규화 (list 또는 dict 모두 처리)
|
||||
rec_groups = recommendations_response.get("recommendations_by_rule", [])
|
||||
@@ -1580,11 +1750,19 @@ async def _execute_auto_trade_background_locked(recommendations_response: dict,
|
||||
logger.error(f"[AUTO_TRADE_BG] {stock.get('stock_code')} 처리 오류: {type(e).__name__}: {e}")
|
||||
continue
|
||||
|
||||
# [관측성 2026-09-27] 체결 결과를 호출부로 반환한다. 기존엔 trade_results 를
|
||||
# 만들어놓고 return 이 없어 항상 None 이 나갔고, swing/entry_service.py 의
|
||||
# isinstance(result, list) 분기가 영구 False → "진입 n건" 로그가 사라졌다.
|
||||
# 담기는 값은 action 이 BUY/SELL 인 dict 뿐이다(SKIP/HOLD/*_FAILED 는 제외).
|
||||
return trade_results
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"[AUTO_TRADE_BG] 전체 오류: {type(e).__name__}: {e} — "
|
||||
f"recommendations_response keys={list(recommendations_response.keys()) if isinstance(recommendations_response, dict) else '?'}"
|
||||
)
|
||||
# 예외 시점까지 실제로 체결된 건은 유실 없이 보고한다(부분 결과 반환).
|
||||
return trade_results
|
||||
|
||||
|
||||
async def _background_sync_portfolio():
|
||||
@@ -1661,14 +1839,45 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
if current_price <= settings.MIN_PRICE_USD and not _is_etf: # CLAUDE.md 5.4: "초과"
|
||||
return {"action": "SKIP", "reason": "penny_stock", "stock": stock_name}
|
||||
|
||||
# ── 현재 보유 조회 (리스크 가드보다 먼저) ────────────────────────────
|
||||
# [2026-09-27 M3] MDD/밴/보유수/쿨다운 가드는 '신규 매수'에만 적용해야 한다.
|
||||
# 기존에는 즉시 SKIP 반환해 보유 종목의 check_exit_signal·ATR 강제 손절에
|
||||
# 도달조차 못했다(MDD -10%·보유 20종목 = 가장 청산이 필요한 국면에서 손절 불가).
|
||||
# → 보유가 있으면 사유를 _buy_blocked_reason 에 담고 청산 판정까지 진행한다.
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.stock_code == stock_code,
|
||||
Portfolio.quantity > 0,
|
||||
)
|
||||
)
|
||||
holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단
|
||||
|
||||
_buy_blocked_reason: str | None = None
|
||||
# [2026-09-27 H-1] 당일 SELL 체결 여부. 보유 종목의 청산 판정은 계속 진행하되,
|
||||
# '부분매도'가 사이클마다 반복되는 것(계단식 청산 + 왕복 수수료)을 막기 위한 플래그.
|
||||
_sold_today = False
|
||||
|
||||
def _guard(reason: str):
|
||||
"""보유 없으면 즉시 SKIP dict 반환, 보유 중이면 매수만 차단하고 계속 진행."""
|
||||
nonlocal _buy_blocked_reason
|
||||
if holding is None:
|
||||
return {"action": "SKIP", "reason": reason, "stock": stock_name}
|
||||
if _buy_blocked_reason is None:
|
||||
_buy_blocked_reason = reason
|
||||
return None
|
||||
|
||||
# ── MDD / 손실추적 / 보유수 ──────────────────────────────────────────
|
||||
if not await mdd_manager.can_open_new_position():
|
||||
# MDD -10% 이상 낙폭 → 신규 진입 중단
|
||||
return {"action": "SKIP", "reason": "mdd_pause", "stock": stock_name}
|
||||
_r = _guard("mdd_pause")
|
||||
if _r is not None:
|
||||
return _r
|
||||
|
||||
if await loss_tracker.is_banned_safe(stock_code):
|
||||
# 3회 연속 손실 종목 → 30거래일 진입 금지
|
||||
return {"action": "SKIP", "reason": "consecutive_loss_ban", "stock": stock_name}
|
||||
_r = _guard("consecutive_loss_ban")
|
||||
if _r is not None:
|
||||
return _r
|
||||
|
||||
held_result = await db.execute(select(Portfolio).where(Portfolio.quantity > 0))
|
||||
# 적응형 전략: max_holdings 오버라이드 (실패 시 settings 기본값 fallback)
|
||||
@@ -1682,30 +1891,43 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
pass
|
||||
if len(held_result.scalars().all()) >= _adaptive_max_holdings:
|
||||
# 동시 보유 한도 초과 (적응형: max_holdings)
|
||||
return {"action": "SKIP", "reason": "max_holdings_reached", "stock": stock_name}
|
||||
_r = _guard("max_holdings_reached")
|
||||
if _r is not None:
|
||||
return _r
|
||||
|
||||
# ── 당일 매도 후 재매수 쿨다운 ───────────────────────────────────────
|
||||
# 당일 이미 SELL 체결된 종목은 당일 재매수 금지 (섹션 4.3)
|
||||
# [2026-09-27 §8.5 항목5] PENDING(접수만 되고 미체결) SELL 주문을 포함한다.
|
||||
# 근거: models.py:141 status(String), :157 created_at(DateTime, default=now_kst),
|
||||
# :158 executed_at(DateTime, nullable) — 둘 다 KST naive 저장.
|
||||
# _execute_sell(trade_executor.py:1303·1329-1330)은 rt_cd=="1" 일 때
|
||||
# status="PENDING", executed_at=None 으로 기록하므로 executed_at 날짜
|
||||
# 필터만으로는 PENDING 행이 전부 탈락한다 → 지정가 부분매도가 미체결인
|
||||
# 동안 _sold_today=False 로 남아 사이클마다 중복 부분매도가 쌓였다.
|
||||
# PENDING 은 created_at 으로 '당일'만 인정 → 체결·취소 없이 남은 과거 유령 행이
|
||||
# 쿨다운을 영구히 걸지 않는다.
|
||||
cutoff_time = _get_kst_now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
recent_sell = await db.execute(
|
||||
select(TradeHistory).where(
|
||||
TradeHistory.stock_code == stock_code,
|
||||
TradeHistory.order_type == "SELL",
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
or_(
|
||||
and_(
|
||||
TradeHistory.status.in_(["COMPLETED", "PARTIAL"]),
|
||||
TradeHistory.executed_at >= cutoff_time,
|
||||
),
|
||||
and_(
|
||||
TradeHistory.status == "PENDING",
|
||||
TradeHistory.created_at >= cutoff_time,
|
||||
),
|
||||
),
|
||||
)
|
||||
)
|
||||
if recent_sell.first():
|
||||
return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name}
|
||||
|
||||
# ── 현재 보유 조회 ────────────────────────────────────────────────────
|
||||
result = await db.execute(
|
||||
select(Portfolio).where(
|
||||
Portfolio.stock_code == stock_code,
|
||||
Portfolio.quantity > 0,
|
||||
)
|
||||
)
|
||||
holding = result.scalar_one_or_none() # None이면 신규매수, 있으면 추가매수/매도 판단
|
||||
_sold_today = True
|
||||
_r = _guard("cooldown_sell_today")
|
||||
if _r is not None:
|
||||
return _r
|
||||
|
||||
# [BUG FIX] ActiveTrade.remaining_pct 조회 — 미주입 시 1차 익절 후에도
|
||||
# remaining_pct가 항상 1.0으로 간주되어 SELL_PARTIAL이 반복 발생하는 문제 방지.
|
||||
@@ -1713,17 +1935,35 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
if holding is not None and stock.get("remaining_pct") is None:
|
||||
try:
|
||||
from app.models.models import ActiveTrade
|
||||
# [2026-09-27 M6] swing/position_manager.get_swing_active_trade 및
|
||||
# db_worker 와 동일 패턴: day_trade 행 제외 + 최신 행 우선.
|
||||
# (같은 종목에 swing/day_trade 행이 동시에 active 일 때 DT 행의
|
||||
# remaining_pct 가 스윙 청산 판단에 주입되는 것을 방지)
|
||||
# [2026-09-27 §8.5 항목3] `!= "day_trade"` 는 SQL 3값 논리로
|
||||
# strategy_name IS NULL 인 legacy 스윙 행까지 탈락시켜, 잔량 추적
|
||||
# 소스를 잃고 remaining_pct 가 매 사이클 1.0 으로 뭉개졌다.
|
||||
# → db_worker.not_dt_strategy_clause() 로 3곳을 동일 표현으로 통일.
|
||||
from app.services.db_worker import not_dt_strategy_clause
|
||||
_at_result = await db.execute(
|
||||
select(ActiveTrade).where(
|
||||
select(ActiveTrade)
|
||||
.where(
|
||||
ActiveTrade.stock_code == stock_code,
|
||||
ActiveTrade.status == "active",
|
||||
not_dt_strategy_clause(ActiveTrade.strategy_name),
|
||||
)
|
||||
.order_by(ActiveTrade.id.desc())
|
||||
.limit(1)
|
||||
)
|
||||
_active_trade = _at_result.scalars().first()
|
||||
if _active_trade is not None and _active_trade.remaining_pct is not None:
|
||||
stock["remaining_pct"] = float(_active_trade.remaining_pct)
|
||||
except Exception as _at_err:
|
||||
logger.debug(f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패: {_at_err}")
|
||||
# 조회 실패 = 잔량 추적 불가 → _resolve_remaining_pct_v2 가 부분매도를
|
||||
# 차단한다. 조용히 넘기면 원인 추적이 불가하므로 최소 warning.
|
||||
logger.warning(
|
||||
f"[TRADE] {stock_code}: ActiveTrade remaining_pct 조회 실패 "
|
||||
f"(부분매도 차단, 전량 청산만 허용): {_at_err}"
|
||||
)
|
||||
|
||||
# ── 신호 분석 (Enum 기반) ─────────────────────────────────────────────
|
||||
text_signals = " ".join(signals)
|
||||
@@ -1774,11 +2014,25 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
logger.debug(f"[ML_WEIGHT] {stock_code} 가중치 계산 오류: {e}")
|
||||
|
||||
has_rule = stock.get("rule_id") is not None
|
||||
if has_rule:
|
||||
# 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호)
|
||||
if any(b in text_signals for b in BLOCK_KEYWORDS):
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": "disparity_overheated"}
|
||||
|
||||
# ── BLOCK 키워드(과열·이격) 판정 ─────────────────────────────────────
|
||||
# [2026-09-27 H1-A] 기존에는 BLOCK 감지 시 즉시 HOLD를 return 해
|
||||
# 아래 청산 판정(check_exit_signal·ATR 강제 손절)에 도달하지 못했다.
|
||||
# 보유 종목이 과열이면 오히려 매도해야 하는 국면인데 손절·전량익절·트레일링이
|
||||
# 전부 스킵됐다. → 조기 return 대신 should_buy 만 끄고 청산 판정까지 진행한다.
|
||||
# 판정 임계값·BLOCK_KEYWORDS 목록은 무변경(제어 흐름만 수정).
|
||||
_block_overheat_reason: str | None = None
|
||||
if any(b in text_signals for b in BLOCK_KEYWORDS):
|
||||
_block_overheat_reason = (
|
||||
"disparity_overheated" if has_rule else "overheat_no_rule"
|
||||
)
|
||||
should_buy = False
|
||||
if holding is None:
|
||||
# 보유 없는 종목은 청산 판정이 무의미 → 기존과 동일하게 즉시 HOLD 반환
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason}
|
||||
|
||||
if has_rule and _block_overheat_reason is None:
|
||||
# 규칙 기반 종목: BLOCK 신호만 없으면 should_buy = True (규칙 자체가 매수 신호)
|
||||
# ML HOLD는 신뢰도 60% 이상일 때만 rule 매수를 차단
|
||||
# (신뢰도 40% 미만의 약한 HOLD는 rule 판단을 우선)
|
||||
ml_hold_active = (
|
||||
@@ -1789,10 +2043,6 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
)
|
||||
if not ml_hold_active:
|
||||
should_buy = True
|
||||
else:
|
||||
# 비규칙 종목: BLOCK 신호 있으면 과열 HOLD
|
||||
if any(b in text_signals for b in BLOCK_KEYWORDS):
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": "overheat_no_rule"}
|
||||
|
||||
# ML HOLD이면 매수 억제 — 단, 신뢰도 60% 이상의 '확신 있는' HOLD만 차단.
|
||||
# [FIX] 기존에는 신뢰도 무관하게 차단해, 일봉 데이터 부족으로 ML 피처 추출이
|
||||
@@ -1839,7 +2089,8 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
if rsi_val is not None and bb_data is not None and atr > 0:
|
||||
bb_upper, bb_middle, bb_lower = bb_data if isinstance(bb_data, (tuple, list)) else (0, 0, 0)
|
||||
# remaining_pct: 현재 보유 비율 (1차 익절 전 = 1.0, 후 = 0.5 등)
|
||||
remaining_pct = float(stock.get("remaining_pct", 1.0) or 1.0)
|
||||
# [§8.5 항목4] None = '추적 소스 없음' 을 명시 상태로 분리.
|
||||
remaining_pct, _remaining_tracked = _resolve_remaining_pct_v2(stock, holding)
|
||||
trailing_high = float(stock.get("high_price") or current_price)
|
||||
lowest_since = float(stock.get("lowest_since_entry") or avg_price)
|
||||
|
||||
@@ -1856,6 +2107,24 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
remaining_pct=remaining_pct,
|
||||
trailing_high=trailing_high,
|
||||
)
|
||||
# [§8.5 항목4①] 잔량 추적 불가(ActiveTrade 행 없음/오염) 상태에서는
|
||||
# 부분매도를 채택하지 않는다 — 매 사이클 remaining_pct=1.0 으로
|
||||
# 재평가되어 계단식 분할매도 + 왕복 수수료가 무한 반복된다.
|
||||
# 전량 청산(손절·RSI 전량·트레일링)은 그대로 허용한다.
|
||||
if (
|
||||
_exit_signal is not None
|
||||
and _exit_signal.sell_pct < 1.0
|
||||
and not _remaining_tracked
|
||||
):
|
||||
logger.info(
|
||||
f"[EXIT_SIGNAL] {stock_code}/{stock_name} 부분매도 차단: "
|
||||
f"remaining_pct 추적 불가(ActiveTrade active 행 없음 — "
|
||||
f"브로커 잔고 sync/legacy 보유분) "
|
||||
f"sell_pct={_exit_signal.sell_pct*100:.0f}%, "
|
||||
f"reason={_exit_signal.reason} — 전량 청산 신호만 허용"
|
||||
)
|
||||
_exit_signal = None
|
||||
|
||||
if _exit_signal:
|
||||
should_sell = True
|
||||
_exit_sell_pct = _exit_signal.sell_pct
|
||||
@@ -1876,7 +2145,9 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
# ── 뉴스 감성 필터 (매수 전) ───────────────────────────────────────
|
||||
# NEWS_ENABLED 시 종목 뉴스 점수 조회 → 강한 악재 차단 / 약한 악재 포지션 축소
|
||||
_news_position_scale = 1.0 # 뉴스에 의한 포지션 배율 (기본 100%)
|
||||
if should_buy and settings.NEWS_ENABLED:
|
||||
# [2026-09-27 H1] 청산 대상(보유+매도신호)은 뉴스 필터를 타지 않는다 —
|
||||
# news_negative SKIP 이 손절보다 먼저 반환되면 청산이 또 막힌다.
|
||||
if should_buy and not (should_sell and holding) and settings.NEWS_ENABLED:
|
||||
try:
|
||||
from app.services.news_sentiment import get_stock_news_score
|
||||
news_score = await get_stock_news_score(stock_code)
|
||||
@@ -1901,11 +2172,31 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
stock["_news_position_scale"] = _news_position_scale
|
||||
|
||||
# ── 매매 실행 위임 ────────────────────────────────────────────────────
|
||||
if should_buy:
|
||||
logger.info(f"[TRADE] 매수 실행: {stock_code}/{stock_name} @ {current_price:,.0f}")
|
||||
return await _execute_buy(
|
||||
stock, db, holding, current_price, signals, score_val,
|
||||
is_domestic, exchange_code, stock_code, stock_name,
|
||||
# [2026-09-27 H1] 청산은 항상 매수보다 우선한다.
|
||||
# 기존에는 should_buy 를 먼저 평가해, 보유 종목이 당일 룰 추천에 포함되면
|
||||
# (rule 기반은 BLOCK/ML HOLD 만 없으면 should_buy=True 강제)
|
||||
# 손절·전량익절·트레일링이 전부 무력화되고 물타기 매수가 나갔다.
|
||||
# [2026-09-27 H-1] 당일 이미 SELL 체결된 보유 종목의 '부분매도' 반복 차단.
|
||||
# _guard() 가 보유 종목의 cooldown_sell_today 조기 SKIP 을 제거한 뒤부터,
|
||||
# ActiveTrade 행이 없는 보유분(브로커 sync·legacy strategy_name IS NULL)은
|
||||
# remaining_pct 가 매 사이클 1.0 으로 간주되어 SELL_PARTIAL(0.5)이 무한 반복됐다.
|
||||
# → 전량 청산(손절·RSI≥70·트레일링)은 그대로 허용하고, 부분매도만 당일 1회로 제한한다.
|
||||
if _sold_today and should_sell and holding and _exit_sell_pct < 1.0:
|
||||
logger.info(
|
||||
f"[TRADE] {stock_code}/{stock_name} 부분매도 차단: cooldown_sell_today "
|
||||
f"(당일 매도 기록 존재, sell_pct={_exit_sell_pct*100:.0f}%, "
|
||||
f"reason={(_exit_signal.reason if _exit_signal else ' '.join(signals))}) "
|
||||
f"— 전량 청산 신호만 허용"
|
||||
)
|
||||
return {"action": "SKIP", "reason": "cooldown_sell_today", "stock": stock_name}
|
||||
|
||||
if should_buy and should_sell and holding:
|
||||
logger.warning(
|
||||
f"[TRADE_CONFLICT] {stock_code}/{stock_name}: 동일 사이클에 매수·매도 신호 동시 발생 "
|
||||
f"→ 청산 우선 실행. has_rule={has_rule} score={score_val} "
|
||||
f"ml_signal={ml_signal_trade} ml_conf={ml_confidence_trade} "
|
||||
f"exit={(_exit_signal.reason if _exit_signal else None)} "
|
||||
f"sell_pct={_exit_sell_pct} signals={signals}"
|
||||
)
|
||||
|
||||
if should_sell and holding:
|
||||
@@ -1916,8 +2207,26 @@ async def execute_auto_trade(stock: dict, db: AsyncSession):
|
||||
sell_pct=_exit_sell_pct, # [CLAUDE.md 5.2] 분할매도 비율 전달
|
||||
)
|
||||
|
||||
if should_buy:
|
||||
# [M3] 리스크 가드(MDD/밴/보유수/당일매도쿨다운)는 신규 매수만 차단한다
|
||||
if _buy_blocked_reason is not None:
|
||||
logger.info(
|
||||
f"[TRADE] {stock_code}/{stock_name} 매수 차단: {_buy_blocked_reason} (청산 판정은 수행됨)"
|
||||
)
|
||||
return {"action": "SKIP", "reason": _buy_blocked_reason, "stock": stock_name}
|
||||
logger.info(f"[TRADE] 매수 실행: {stock_code}/{stock_name} @ {current_price:,.0f}")
|
||||
return await _execute_buy(
|
||||
stock, db, holding, current_price, signals, score_val,
|
||||
is_domestic, exchange_code, stock_code, stock_name,
|
||||
)
|
||||
|
||||
logger.info(f"[TRADE] ⏸️ HOLD: {stock_code}/{stock_name} (buy={should_buy}, sell={should_sell}, has_rule={has_rule})")
|
||||
|
||||
# [2026-09-27 H1-A] BLOCK(과열·이격) 사유는 조기 return 제거 후에도 그대로 노출 —
|
||||
# 호출측 로깅·집계 호환을 위해 기존 reason 문자열을 유지한다.
|
||||
if _block_overheat_reason is not None:
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": _block_overheat_reason}
|
||||
|
||||
return {"action": "HOLD", "stock": stock_name, "reason": "no_buy_signal(진입조건 미충족)"}
|
||||
|
||||
except ValueError as e:
|
||||
|
||||
Reference in New Issue
Block a user