fix: 하드닝 루프 8회차 — 체결 보정·고아 감지·청산 경보·실시간/차트·리스크 가드 일괄 수정

- 스윙 체결가 확인 전 executed_price NULL + fill_sweep, 진입가 resolve(ActiveTrade↔Portfolio), 0체결/부분체결 매수 장부 원복
- DT 고아 감지(근거 한정)·0체결 매도 복원·stale 점검 확대, 시간손절 -0.3% 원복
- 폴백 손절 ATR 캐시, 거래량부족-차단 신호 매수 차단, 현금 5% 유보 재적용, MDD peak/reset 수정
- KIS 서킷브레이커 청산 우회·경보, db_worker 재큐잉 순서, heartbeat·health-watch 사이드카
- 실시간 WS 재구독·41건 우선순위·다건 프레임 분할, 차트 샘플 제거·당일봉 합성, WS 서브프로토콜 인증
- 프론트: 미관리 그룹·확인 모달·차트 실시간 반영·예수금 표시 정정
- 전문가 에이전트 6종 추가(.claude/agents)
- 유닛 스위트 2667 passed (소켓 차단 하), tsc OK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y83Za98XsNw9zuA4FTUytN
This commit is contained in:
2026-10-10 12:17:49 +09:00
parent 5c4803b339
commit a264561576
230 changed files with 41160 additions and 2334 deletions

View File

@@ -10,6 +10,7 @@ from fastapi import APIRouter, HTTPException, Query, Depends
from typing import List, Optional
from datetime import datetime, date, timedelta
import asyncio
import re
import logging
from collections import namedtuple
@@ -39,6 +40,228 @@ def _today_kst() -> date:
return datetime.now(_KST).date()
def _row_price_date_v2(stock) -> tuple:
"""DB 행의 (price_date 'YYYYMMDD', stale 여부). stock_analysis 에는 price_date 컬럼이 없다.
장전(09:00 이전) 갱신 행은 차트 마지막 봉이 전일이라 analysis_date 를 가격일로 쓰면 거짓이다.
갱신 시각(KST naive)이 analysis_date 당일 09:00 이전이면 가격일 미상("")+stale=True.
"""
ad = stock.analysis_date
if isinstance(ad, datetime):
ad = ad.date()
ts = getattr(stock, "updated_at", None)
if isinstance(ts, datetime) and ts < datetime(ad.year, ad.month, ad.day, 9, 0):
return "", True
return ad.strftime("%Y%m%d"), False
def _persistable_items_v2(items, today: date) -> list:
"""분석 결과 중 DB(analysis_date=오늘)에 저장해도 되는 것만 남긴다.
price_date 가 오늘이 아닌(장전 전일 봉 등) 결과를 오늘 날짜로 UPSERT 하면 소비처
(analysis_date >= today 후보 쿼리)가 전일 데이터를 오늘 것으로 읽는다. 가격일 미상("")은 유지.
"""
ts = today.strftime("%Y%m%d")
out = []
for it in items:
pd_ = str(getattr(it[2], "price_date", "") or "")
if pd_ and pd_ != ts:
continue
out.append(it)
return out
# ── 스캐너 표시용 매수 차단 판정 (2026-10-08) ────────────────────────────────
# 신호 문자열에 이미 "…(거래량부족-차단)", "이격도 과열… - 매수금지", "급등과열: …" 처럼
# 차단 사유가 섞여 있는데 화면은 이를 구분하지 못해 '추천'과 '차단'이 같은 줄에 표시됐다.
_BLOCK_MARKERS = ("차단", "매수금지", "급등과열", "이격도 과열")
def _block_reasons_v2(signals) -> list:
out = []
for sig in (signals or []):
text = str(sig)
if any(m in text for m in _BLOCK_MARKERS):
out.append(text)
return out
_VOL_RATIO_SIGNAL_RE = re.compile(r"거래량 실린 상승 \(([0-9]+(?:\.[0-9]+)?)배\)")
def _stock_volume_ratio_v2(stock):
"""종목의 거래량 배수(20일 평균 대비). 없으면 None.
StockRecommendation 에는 volume_ratio 필드가 없으므로(market_analyzer 소유),
1) 속성이 있으면 사용, 2) 없으면 market_analyzer 가 남기는 신호 문자열
'거래량 실린 상승 (N.N배)' 에서 복원한다. 이 신호는 배수>=1.5 & 상승일 때만 생성되므로
복원 불가면 None(= 배수 미확인, 호출부 fail-closed). 장중 부분거래량은 배수를 낮게 만들 뿐이라
보수적 방향으로만 틀린다.
"""
ratio = getattr(stock, "volume_ratio", None)
if ratio is not None:
return ratio
for sig in (getattr(stock, "signals", None) or []):
m = _VOL_RATIO_SIGNAL_RE.search(str(sig))
if m:
return float(m.group(1))
return None
def _generic_rule_matches_v2(rule, stock, conditions: dict) -> bool:
"""realtime 빌더 else 분기(비-MEAN_REVERSION 규칙)의 후보 판정.
[하드닝 R1] 이전에는 rule_type 을 가리지 않아 SELL 규칙까지 rule_id 가 붙은 매수 후보가
되었고, trade_executor 의 has_rule 강제매수가 '거래량부족-차단' 등 차단 신호를 덮어썼다.
- SELL(및 EXIT/STOP 계열) 규칙은 매수 후보에서 제외
- 차단 신호(_block_reasons_v2)가 있는 종목은 규칙 후보에서 제외
RSI 임계(기본 40)는 기존 값을 유지한다(변경은 백테스트 필요).
"""
if str(getattr(rule, "rule_type", "") or "").upper() in ("SELL", "EXIT", "STOP_LOSS", "TAKE_PROFIT"):
return False
if _block_reasons_v2(getattr(stock, "signals", None)):
return False
conditions = conditions or {}
has_rsi_key = ("rsi_below" in conditions) or ("rsi_threshold" in conditions)
has_vol_key = "volume_above_average" in conditions
if conditions and not (has_rsi_key or has_vol_key):
# 해석 가능한 조건 키가 하나도 없으면 규칙 의도를 알 수 없다 -> fail-closed
return False
if stock.rsi is None:
return False
# rsi_below(엄격 미만) / rsi_threshold(이하, 기존 의미). 둘 다 있으면 더 엄격한 쪽.
if has_rsi_key:
try:
if "rsi_below" in conditions and not (stock.rsi < float(conditions["rsi_below"])):
return False
if "rsi_threshold" in conditions and not (stock.rsi <= float(conditions["rsi_threshold"])):
return False
except (TypeError, ValueError):
return False
elif not (stock.rsi <= 40): # RSI 키가 없는 조건의 기존 기본 동작 유지
return False
if has_vol_key:
# 거래량 배수를 알 수 없으면(필드 미노출) 통과시키지 않는다(fail-closed).
ratio = _stock_volume_ratio_v2(stock)
try:
if ratio is None or not (float(ratio) >= float(conditions["volume_above_average"])):
return False
except (TypeError, ValueError):
return False
return True
# trade_executor 의 'ML HOLD(conf≥0.6) → 매수 차단' 게이트와 같은 신뢰도 기준(리터럴 단일화).
_ML_HOLD_BLOCK_CONF_V2 = 0.6
def _ml_block_reasons_v2(ml_signal, ml_confidence) -> list:
"""trade_executor 실제 매수 ML 게이트와 같은 정의의 ML 차단 사유.
- ML HOLD & 신뢰도 ≥ 0.6 & ML_ENSEMBLE_ENABLED → 매수 차단('ML HOLD(conf≥0.6)')
- ML SELL 은 ML_SELL_SIGNAL_ENABLED=True 일 때만 차단 사유(False 면 trade_executor 가
매수를 막지 않으므로 사유에 넣지 않는다 — 참고 정보는 ml_signal 필드로 노출).
"""
sig = str(ml_signal or "").upper()
reasons = []
try:
conf = float(ml_confidence or 0)
except (TypeError, ValueError):
conf = 0.0
if sig == "HOLD" and settings.ML_ENSEMBLE_ENABLED and conf >= _ML_HOLD_BLOCK_CONF_V2:
reasons.append(f"ML HOLD(conf≥{_ML_HOLD_BLOCK_CONF_V2:g})")
if sig == "SELL" and settings.ML_SELL_SIGNAL_ENABLED:
reasons.append("ML 신호 SELL")
return reasons
def _scanner_flags_v2(stock, rule=None) -> dict:
"""스캐너 항목의 규칙 출처·차단 사유·buy_allowed·ML 판정. 기존 필드는 건드리지 않는다.
buy_allowed = 신호 차단 사유 없음 AND ML 게이트 비차단(`_ml_block_reasons_v2`, trade_executor 와 동일 정의).
(MDD·일일한도 등 실행 시점 게이트는 여기서 판정하지 않는다 — 표시용 1차 판정)
"""
reasons = _block_reasons_v2(getattr(stock, "signals", None))
ml_reasons = _ml_block_reasons_v2(getattr(stock, "ml_signal", None), getattr(stock, "ml_confidence", None))
reasons = reasons + ml_reasons
matched = rule is not None
return {
"match_source": "rule" if matched else "fallback_high_score",
"match_source_label": "활성 규칙 매칭" if matched else "고점수 분석 종목(규칙 미충족 대체)",
"block_reasons": reasons,
"buy_allowed": not reasons,
"ml_blocks_buy": bool(ml_reasons),
}
def _batch_time_iso_v2(stocks) -> str:
"""배치 실제 스캔 시각 — KST 타임존 포함 ISO(예: 2026-10-08T09:10:03+09:00).
DB updated_at/created_at 은 KST naive(core.timeutil). 시각이 없으면 분석일 00:00 KST.
(이전: 날짜만 내려가 브라우저가 UTC 자정 → KST 09:00 으로 표시)
"""
latest = None
for st in stocks:
ts = getattr(st, "updated_at", None) or getattr(st, "created_at", None)
if isinstance(ts, datetime) and (latest is None or ts > latest):
latest = ts
if latest is None:
d = max((st.analysis_date for st in stocks), default=_today_kst())
if isinstance(d, datetime):
d = d.date()
latest = datetime(d.year, d.month, d.day)
if latest.tzinfo is None:
latest = _KST.localize(latest)
else:
latest = latest.astimezone(_KST)
return latest.isoformat()
async def _overlay_realtime_v2(result: dict) -> dict:
"""Redis 실시간 틱(tick:{code}, TTL 10s)이 있으면 현재가와 등락률을 **같은 소스**로 덮어쓴다.
배치 값(change_rate)은 배치 시점 기준이라 실시간 가격과 섞이면 서로 어긋난다.
원본 result 는 변경하지 않는다(자동매매 스냅샷과 공유될 수 있음) — 항목 dict 를 복사한다.
틱이 없는 종목은 배치 값 유지 + price_source="batch".
"""
from app.core.redis import get_json
lists = [result.get("recommendations") or [], result.get("analyzed_stock_list") or []]
for g in result.get("recommendations_by_rule") or []:
lists.append(g.get("stocks") or [])
codes = sorted({it["stock_code"] for lst in lists for it in lst if it.get("stock_code")})[:400]
if not codes:
return result
ticks = await asyncio.gather(*[get_json(f"tick:{c}") for c in codes], return_exceptions=True)
tick_by = {
c: t for c, t in zip(codes, ticks)
if isinstance(t, dict) and (t.get("price") or 0) > 0 and t.get("change_rate") is not None
}
def _apply(item: dict) -> dict:
new = dict(item)
t = tick_by.get(item.get("stock_code"))
if t:
new["batch_current_price"] = item.get("current_price")
new["batch_change_rate"] = item.get("change_rate")
new["current_price"] = int(t["price"])
new["change_rate"] = round(float(t["change_rate"]), 2)
new["price_source"] = "realtime"
else:
new["price_source"] = "batch"
return new
out = dict(result)
out["recommendations"] = [_apply(i) for i in lists[0]]
out["analyzed_stock_list"] = [_apply(i) for i in lists[1]]
out["recommendations_by_rule"] = [
{**g, "stocks": [_apply(i) for i in (g.get("stocks") or [])]}
for g in (result.get("recommendations_by_rule") or [])
]
out["realtime_overlay_count"] = len(tick_by)
return out
# 매매 규칙 namedtuple
Rule = namedtuple('Rule', ['id', 'name', 'description', 'rule_type', 'priority', 'conditions', 'actions', 'is_active'])
@@ -60,7 +283,10 @@ def _stock_analysis_to_dict(stock, rule=None, actions=None) -> dict:
"ml_score": stock.ml_score,
"ml_confidence": round(float(stock.ml_confidence), 4) if stock.ml_confidence else None,
"ml_signal": stock.ml_signal,
"price_date": stock.analysis_date.strftime("%Y%m%d"),
"price_date": _row_price_date_v2(stock)[0],
"price_stale": _row_price_date_v2(stock)[1],
"price_source": "batch",
**_scanner_flags_v2(stock, rule),
}
if rule is not None:
_a = actions or {}
@@ -167,6 +393,17 @@ async def get_recommendations(
"recommendations": recommendations[:20]
}
# 이벤트 루프는 태스크를 약참조로만 보관 — 주문 경로가 중간 GC 되지 않도록 강참조 보관
_bg_trade_tasks: set = set()
def _spawn_bg_trade_v2(coro) -> "asyncio.Task":
task = asyncio.create_task(coro)
_bg_trade_tasks.add(task)
task.add_done_callback(_bg_trade_tasks.discard)
return task
@router.get("/rule-based")
async def get_rule_based_recommendations(
realtime: bool = Query(False, description="실시간 분석 여부 (느림, 30초)"),
@@ -188,10 +425,50 @@ async def get_rule_based_recommendations(
await execute_auto_trade_background(rec_snapshot, bg_db)
except Exception as e:
logger.error(f"[_bg_trade] 백그라운드 자동매매 실행 중 예외: {e}", exc_info=True)
asyncio.create_task(_bg_trade())
_spawn_bg_trade_v2(_bg_trade())
# 표시용 실시간 보정(현재가+등락률 동일 소스). 매매 스냅샷(rec_snapshot)은 원본 유지.
if not realtime:
try:
return await _overlay_realtime_v2(result)
except Exception as e:
logger.warning(f"[rule-based] 실시간 오버레이 실패 — 배치 값 사용: {e}")
return result
def _merge_json_mean_reversion_rule_v2(rules: list) -> list:
"""DB 활성 규칙에 MEAN_REVERSION 이 없으면 trading_rules_config.json 의 MR 규칙을 병합한다.
운영 DB 에는 BUY/SELL/SELL 3개뿐이라 `is_mean_reversion_candidate`(own_trend·ADX·%B 게이트)가
한 번도 호출되지 않았다. 진입 집합이 크게 바뀌므로 플래그
`MEAN_REVERSION_RULE_MERGE_ENABLED`(기본 False, §8.4 백테스트 후 켤 것)로 제어한다.
플래그/파일 처리는 병합 자체에만 영향 — 읽기 실패는 warning 후 DB 규칙 그대로 반환.
"""
if not getattr(settings, "MEAN_REVERSION_RULE_MERGE_ENABLED", False):
return rules
if any(str(getattr(r, "rule_type", "") or "").upper() == "MEAN_REVERSION" for r in rules):
return rules
try:
import json as _json
from pathlib import Path as _Path
config_path = _Path(__file__).parent.parent.parent.parent / "trading_rules_config.json"
with open(config_path, 'r', encoding='utf-8') as f:
config = _json.load(f)
mr = [r for r in config.get("rules", [])
if r.get("is_active") and str(r.get("rule_type", "")).upper() == "MEAN_REVERSION"]
except Exception as e:
logger.warning(f"JSON MEAN_REVERSION 규칙 병합 실패 — DB 규칙만 사용: {e}")
return rules
if not mr:
return rules
r = mr[0]
next_id = max([int(x.id) for x in rules if x.id is not None] + [0]) + 1
return rules + [Rule(
id=next_id, name=r["name"], description=r.get("description", ""),
rule_type="MEAN_REVERSION", priority=r.get("priority", 1),
conditions=r.get("conditions", {}), actions=r.get("actions", {}), is_active=True,
)]
async def _load_active_rules_from_db(db: AsyncSession) -> list:
from app.models.models import TradingRule as TradingRuleModel
try:
@@ -202,12 +479,13 @@ async def _load_active_rules_from_db(db: AsyncSession) -> list:
)
db_rules = result.scalars().all()
if db_rules:
return [
rules = [
Rule(id=r.id, name=r.name, description=r.description or "",
rule_type=r.rule_type, priority=r.priority,
conditions=r.conditions, actions=r.actions, is_active=True)
for r in db_rules
]
return _merge_json_mean_reversion_rule_v2(rules)
except Exception as e:
logger.warning(f"trading_rules DB 조회 실패: {e}")
@@ -451,6 +729,16 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
continue
already_added.add(stock.stock_code)
stock_dict = _stock_analysis_to_dict(stock)
# 규칙 미충족 대체 목록은 매수 후보가 아니다: MR 게이트(own_trend·ADX·%B·RSI)를 통과한
# 종목만 매수 가능, 나머지는 청산 판정 전용(_exit_only — trade_executor 가 should_buy 에서 SKIP).
# 게이트 판정 예외는 fail-closed(청산 전용).
try:
_mr_ok = bool(is_mean_reversion_candidate(stock, {}, current_market_regime))
except Exception as e:
logger.warning(f"[rule-based] 대체 목록 MR 게이트 판정 실패 — 청산 전용 처리 {stock.stock_code}: {e}")
_mr_ok = False
stock_dict["_exit_only"] = not _mr_ok
stock_dict["buy_allowed"] = bool(stock_dict.get("buy_allowed")) and _mr_ok
fallback_stocks.append(stock_dict)
rule_matches.append(stock_dict)
@@ -479,13 +767,20 @@ async def get_rule_based_recommendations_from_db(db: AsyncSession):
"stochastic_k": round(float(s.stochastic_k), 2) if s.stochastic_k else None,
"signals": s.signals or [],
"score": s.score or 0,
"ml_signal": s.ml_signal,
"ml_confidence": round(float(s.ml_confidence), 4) if s.ml_confidence else None,
"price_source": "batch",
"block_reasons": _block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence),
"buy_allowed": not (_block_reasons_v2(s.signals) + _ml_block_reasons_v2(s.ml_signal, s.ml_confidence)),
}
for s in all_analyzed_stocks
],
"recommendations": rule_matches,
"recommendations_by_rule": recommendations_by_rule,
"rules_applied": [{"id": r.id, "name": r.name, "priority": r.priority} for r in active_rules],
"batch_time": (lambda d: d.date() if isinstance(d, datetime) else d)(
# batch_time: 실제 스캔 시각(KST 오프셋 포함 ISO). 날짜만 필요하면 batch_date.
"batch_time": _batch_time_iso_v2(all_analyzed_stocks),
"batch_date": (lambda d: d.date() if isinstance(d, datetime) else d)(
max((s.analysis_date for s in all_analyzed_stocks), default=_today_kst())
).isoformat(),
}
@@ -627,8 +922,7 @@ async def get_rule_based_recommendations_realtime():
elif rule.rule_type in ("TREND", "BREAKOUT", "MOMENTUM"):
matches = False
else:
rsi_threshold = conditions.get("rsi_threshold", 40)
matches = (stock.rsi is not None and stock.rsi <= rsi_threshold)
matches = _generic_rule_matches_v2(rule, stock, conditions)
if matches:
rule_matched_stocks.append(stock)
@@ -673,8 +967,11 @@ async def get_rule_based_recommendations_realtime():
# db_worker 큐 경유 — 실시간 분석 경로에서 AsyncSessionLocal 직접 사용 금지 (CLAUDE.md 3.2)
from app.services.analysis_db_service import queue_bulk_upsert_analysis as _queue_bulk
_today = _today_kst()
_items = [(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks]
await _queue_bulk(_items, _today)
_items = _persistable_items_v2(
[(rec.stock_code, rec.stock_name, rec) for rec in valid_analyzed_stocks], _today
)
if _items:
await _queue_bulk(_items, _today)
return {
"total": len(rule_matches),