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>
This commit is contained in:
@@ -24,7 +24,7 @@ from app.services.market_analyzer import (
|
||||
analyze_stock_for_batch,
|
||||
RecommendationCriteria, StockRecommendation, KST
|
||||
)
|
||||
from app.services.trade_executor import execute_auto_trade_background, execute_auto_trade
|
||||
from app.services.trade_executor import execute_auto_trade_background
|
||||
from app.services.mean_reversion_strategy import MarketRegime
|
||||
import pytz
|
||||
|
||||
@@ -242,7 +242,58 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list:
|
||||
)
|
||||
]
|
||||
|
||||
def _mean_reversion_rsi_thresholds_v2(conditions: dict) -> tuple:
|
||||
"""추천 스캐너 평균회귀 RSI 임계 (bull, bear, 출처).
|
||||
|
||||
[C1 2026-10-02] 플래그 `MEAN_REVERSION_CANONICAL_PARAMS_ENABLED`
|
||||
- False(기본, 현행 동작 불변): trading_rules conditions 의
|
||||
`rsi_bull_max`/`rsi_bear_max` — DB `trading_rules` → `trading_rules_config.json`
|
||||
→ `_load_active_rules_from_db` 하드코딩 폴백 순. 키가 없으면 40/30.
|
||||
⚠️ 즉 CLAUDE.md §6.2 의 46/35 와 다르다(§0 우선순위가 이 경로에는 적용되지 않음).
|
||||
- True: CLAUDE.md §0 우선순위 — adaptive(`rsi_entry`/`bear_rsi_entry`, fallback_used=False 일
|
||||
때) → strategy_cfg(`MEAN_REVERSION_RSI_THRESHOLD`/`_BEAR_RSI_THRESHOLD`) → settings.
|
||||
mean_reversion_strategy.check_buy_signal 과 같은 출처를 쓴다. 비교식(rsi > 임계 → 탈락,
|
||||
즉 `rsi <= 임계` 통과)·RSI 계산 방식·나머지 필터는 바꾸지 않는다.
|
||||
"""
|
||||
if not settings.MEAN_REVERSION_CANONICAL_PARAMS_ENABLED:
|
||||
return conditions.get("rsi_bull_max", 40), conditions.get("rsi_bear_max", 30), "trading_rules"
|
||||
|
||||
bull = float(settings.MEAN_REVERSION_RSI_THRESHOLD)
|
||||
bear = float(settings.MEAN_REVERSION_BEAR_RSI_THRESHOLD)
|
||||
source = "settings"
|
||||
try:
|
||||
from app.services.strategy_config_service import strategy_cfg
|
||||
_b = strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD")
|
||||
_r = strategy_cfg.get("MEAN_REVERSION_BEAR_RSI_THRESHOLD")
|
||||
if _b is not None:
|
||||
bull, source = float(_b), "strategy_cfg"
|
||||
if _r is not None:
|
||||
bear, source = float(_r), "strategy_cfg"
|
||||
except Exception as e:
|
||||
logger.warning(f"[MR_SCAN] strategy_cfg RSI 임계 조회 실패 — settings 값 사용: {e}")
|
||||
try:
|
||||
from app.services.adaptive_strategy import get_current_adaptive_params
|
||||
_adp = get_current_adaptive_params()
|
||||
if not _adp.fallback_used:
|
||||
bull, bear, source = float(_adp.rsi_entry), float(_adp.bear_rsi_entry), "adaptive"
|
||||
except Exception as e:
|
||||
logger.warning(f"[MR_SCAN] 적응형 RSI 임계 조회 실패 — {source} 값 사용: {e}")
|
||||
return bull, bear, source
|
||||
|
||||
|
||||
def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str) -> bool:
|
||||
"""추천 스캐너의 평균회귀 후보 판정 — **실제 스윙 평균회귀 진입의 1차 게이트**.
|
||||
|
||||
현재 실제 진입 기준 (2026-10-02 기준, 이 함수 기준):
|
||||
- 공통: RSI·종가·BB(상/중/하) 모두 존재, 종가 ≥ MIN_PRICE_KRW,
|
||||
own_trend 게이트(MA20 < MA60×0.97 이면 탈락, MA 없으면 미적용),
|
||||
ADX(14) > 30 이면 탈락(강추세 — 100 초과는 계산오류로 무시)
|
||||
- RSI 는 StockAnalysis.rsi(배치 산출값)를 그대로 쓴다. 거래량 하한·RSI 반등 조건은
|
||||
**이 함수에 없다**(후속 execute_auto_trade 경로의 필터에 의존).
|
||||
- BULL: RSI ≤ bull 임계, %B ≤ 0.35(RSI<25) / 0.30(RSI<30) / 0.20(그 외)
|
||||
- BEAR: RSI ≤ bear 임계, %B ≤ 0.30(RSI<20) / 0.25(RSI<25) / 0.20(그 외)
|
||||
- 임계 출처는 `_mean_reversion_rsi_thresholds_v2` 참고(기본: trading_rules 40/30).
|
||||
"""
|
||||
rsi_val = float(stock.rsi) if stock.rsi else None
|
||||
close = int(stock.current_price) if stock.current_price else 0
|
||||
bb_lower = float(stock.bollinger_lower) if stock.bollinger_lower else None
|
||||
@@ -274,8 +325,10 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str)
|
||||
bb_width = bb_upper - bb_lower
|
||||
pct_b = (close - bb_lower) / bb_width if bb_width > 0 else 0.5
|
||||
|
||||
_bull_thr, _bear_thr, _thr_src = _mean_reversion_rsi_thresholds_v2(conditions)
|
||||
|
||||
if regime == MarketRegime.BULL:
|
||||
rsi_threshold = conditions.get("rsi_bull_max", 40)
|
||||
rsi_threshold = _bull_thr
|
||||
if adx_strong_trend:
|
||||
return False
|
||||
if rsi_val > rsi_threshold:
|
||||
@@ -289,7 +342,7 @@ def is_mean_reversion_candidate(stock, conditions: dict, market_regime_str: str)
|
||||
pct_b_max = 0.20 # RSI 30~40 → BB 하단 근처 요구
|
||||
return pct_b <= pct_b_max
|
||||
else:
|
||||
rsi_threshold = conditions.get("rsi_bear_max", 30)
|
||||
rsi_threshold = _bear_thr
|
||||
if adx_strong_trend:
|
||||
return False
|
||||
if rsi_val > rsi_threshold:
|
||||
@@ -634,57 +687,52 @@ async def get_rule_based_recommendations_realtime():
|
||||
|
||||
@router.post("/auto-trade")
|
||||
async def execute_auto_trading(db: AsyncSession = Depends(get_db)):
|
||||
try:
|
||||
recommendations_response = await get_rule_based_recommendations(realtime=True, auto_trade=False)
|
||||
if not recommendations_response.get("recommendations_by_rule"):
|
||||
return {"message": "추천 종목이 없습니다", "trades": []}
|
||||
|
||||
trade_results = []
|
||||
total_processed = 0
|
||||
max_trades = 5
|
||||
|
||||
# make sure we iterate a list of rule group dicts
|
||||
rec_groups = recommendations_response.get("recommendations_by_rule", [])
|
||||
if isinstance(rec_groups, dict):
|
||||
normalized = []
|
||||
for name, grp in rec_groups.items():
|
||||
if isinstance(grp, dict):
|
||||
normalized.append(grp)
|
||||
else:
|
||||
normalized.append({"rule_name": name, "stocks": grp})
|
||||
rec_groups = normalized
|
||||
"""추천 기반 자동매매 수동 트리거.
|
||||
|
||||
for rule_group in rec_groups:
|
||||
if total_processed >= max_trades:
|
||||
break
|
||||
|
||||
for stock in rule_group.get("stocks", [])[:settings.AUTO_TRADE_TOP_N_PER_RULE]:
|
||||
if total_processed >= max_trades:
|
||||
break
|
||||
|
||||
try:
|
||||
result = await execute_auto_trade(stock, db)
|
||||
total_processed += 1
|
||||
|
||||
if result["action"] in ["BUY", "SELL"]:
|
||||
trade_results.append(result)
|
||||
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error executing auto-trade for {stock.get('stock_code')}: {e}")
|
||||
continue
|
||||
|
||||
return {
|
||||
"message": f"자동 매매 완료: {len(trade_results)}건 실행 / {total_processed}개 분석",
|
||||
"trades": trade_results,
|
||||
"total_analyzed": total_processed
|
||||
}
|
||||
|
||||
[H-3 2026-10-02] 기존에는 종목별 `execute_auto_trade()` 를 직접 호출해
|
||||
시장 국면(SIDEWAYS/CRASH → max_trades=0)·position_multiplier·일일 매수 한도·
|
||||
`_auto_trade_bg_lock`(스윙 스케줄러와의 동시 실행 방지)·`AUTO_TRADE_ENABLED` 를
|
||||
전부 우회했다. 이제 스케줄러와 **같은 진입점** `execute_auto_trade_background()` 로
|
||||
위임해 동일 락·게이트를 탄다. 게이트 판정은 try 밖(§4).
|
||||
"""
|
||||
if not settings.AUTO_TRADE_ENABLED:
|
||||
raise HTTPException(status_code=423, detail="AUTO_TRADE_ENABLED=False — 자동매매 비활성 상태")
|
||||
if settings.TRADING_HALTED:
|
||||
raise HTTPException(status_code=423, detail="TRADING_HALTED — 전체 매수 중단 상태")
|
||||
from app.services.trade_executor import _auto_trade_bg_lock
|
||||
if _auto_trade_bg_lock.locked():
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="자동매매가 이미 실행 중입니다(스윙 스케줄러 등) — 중복 실행 방지",
|
||||
)
|
||||
|
||||
try:
|
||||
recommendations_response = await get_rule_based_recommendations(
|
||||
realtime=True, auto_trade=False, db=db
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 자동 매매 실행 오류: {e}")
|
||||
logger.error(f"❌ 자동 매매 추천 조회 오류: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if not recommendations_response.get("recommendations_by_rule"):
|
||||
return {"message": "추천 종목이 없습니다", "trades": []}
|
||||
|
||||
try:
|
||||
results = await execute_auto_trade_background(recommendations_response, db)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 자동 매매 실행 오류: {e}", exc_info=True)
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
results = results if isinstance(results, list) else []
|
||||
# 반환 리스트에는 SELL·실패건도 섞인다 — 진입 건수는 BUY & success 로 센다(CLAUDE.md §6.4)
|
||||
trades = [r for r in results if isinstance(r, dict) and r.get("action") in ("BUY", "SELL")]
|
||||
buys = sum(1 for r in trades if r.get("action") == "BUY" and r.get("success"))
|
||||
return {
|
||||
"message": f"자동 매매 완료: 진입 {buys}건 / 처리 {len(results)}건",
|
||||
"trades": trades,
|
||||
"total_analyzed": len(results),
|
||||
}
|
||||
|
||||
@router.get("/ai/{stock_code}")
|
||||
async def get_ai_recommendation(stock_code: str):
|
||||
"""종목 기술적 분석 — AI 미사용, 전략 엔진(RSI/BB/MA/ATR/ADX) 직접 계산."""
|
||||
|
||||
Reference in New Issue
Block a user