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:
@@ -38,6 +38,20 @@ KST = pytz.timezone('Asia/Seoul')
|
||||
def _get_kst_now():
|
||||
return datetime.now(KST).replace(tzinfo=None)
|
||||
|
||||
def _sideways_message_v2(current_price: float, ma200: float, by_hmm: bool) -> str:
|
||||
"""SIDEWAYS 응답 message — 실효 설정값과 판정 출처(rule/HMM)를 반영한다."""
|
||||
_enter = settings.MEAN_REVERSION_ADX_SIDEWAYS - 2.0
|
||||
_exit = settings.MEAN_REVERSION_ADX_SIDEWAYS + 2.0
|
||||
_basis = (
|
||||
"HMM 앙상블 오버라이드"
|
||||
if by_hmm
|
||||
else (f"규칙: ADX<{_enter:g}(해제>{_exit:g}) AND "
|
||||
f"MA200 기울기<{settings.MEAN_REVERSION_SLOPE_THRESHOLD:g}")
|
||||
)
|
||||
return (f"횡보장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, {_basis})"
|
||||
" — 진입 차단")
|
||||
|
||||
|
||||
class RecommendationCriteria(BaseModel):
|
||||
use_macd: bool = False
|
||||
use_rsi: bool = False
|
||||
@@ -60,6 +74,8 @@ class StockRecommendation(BaseModel):
|
||||
signals: List[str]
|
||||
score: float
|
||||
price_date: str = ""
|
||||
# 장중인데 마지막 봉이 오늘 봉이 아님(전일 봉 기반 신호). 매수 후보에서 제외할 것.
|
||||
price_stale: bool = False
|
||||
volume: Optional[int] = None
|
||||
rsi: Optional[float] = None
|
||||
ma5: Optional[float] = None
|
||||
@@ -78,6 +94,7 @@ class StockRecommendation(BaseModel):
|
||||
|
||||
_market_regime_cache: dict = {"result": None, "ts": 0.0}
|
||||
_MARKET_REGIME_CACHE_TTL = 120
|
||||
_OFFHOURS_REGIME_MAX_AGE_SEC = 72 * 3600 # 주말 포함 직전 장중 판정 재사용 한도
|
||||
|
||||
async def _get_hmm_ensemble_info(
|
||||
rule_regime_str: str,
|
||||
@@ -106,10 +123,50 @@ async def _get_hmm_ensemble_info(
|
||||
warn_throttled(logger, "market_analyzer:104", "HMM 앙상블 조회 실패 (규칙 기반 유지): %s", e)
|
||||
return {"hmm": None, "ensemble": None}
|
||||
|
||||
_REGIME_KIS_LOOKBACK_DAYS = 400
|
||||
_regime_source_state: dict = {"source": None, "bars": 0, "naver_bars": 0, "ts": 0.0, "blocked": False, "stale": False}
|
||||
|
||||
|
||||
def get_regime_source_health() -> dict:
|
||||
"""국면 판정 데이터 소스 상태(/health 노출용 사본)."""
|
||||
return dict(_regime_source_state)
|
||||
|
||||
|
||||
def _is_stale_index_chart_v2(chart_newest_first) -> bool:
|
||||
"""장중인데 지수 일봉의 최신 봉 날짜가 오늘(KST)이 아니면 True.
|
||||
|
||||
날짜 필드가 없으면 검증 불가이므로 False(기존 동작 유지).
|
||||
"""
|
||||
if not chart_newest_first:
|
||||
return False
|
||||
latest = str(chart_newest_first[0].get("stck_bsop_date", "") or "")
|
||||
if not latest:
|
||||
return False
|
||||
return latest != _get_kst_now().strftime("%Y%m%d")
|
||||
|
||||
|
||||
def _regime_block_v2(message: str, source: str, bars: int, naver_bars: int) -> dict:
|
||||
"""국면 판별 불가 시 fail-closed 반환 + 원인 로그/상태 기록(차단은 캐시하지 않음)."""
|
||||
logger.error("[Regime] 신규 진입 차단: %s (source=%s, bars=%d, naver_bars=%d)",
|
||||
message, source, bars, naver_bars)
|
||||
_regime_source_state.update(source=source, bars=bars, naver_bars=naver_bars,
|
||||
ts=time.time(), blocked=True)
|
||||
return {"regime": "neutral", "regime_200d": "bear", "message": message,
|
||||
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
|
||||
|
||||
|
||||
async def check_market_regime() -> dict:
|
||||
try:
|
||||
if not is_market_open():
|
||||
return {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5}
|
||||
_off = {"regime": "neutral", "regime_200d": "bear", "message": "장외 시간 - 시장 레짐 판별 스킵", "position_multiplier": 0.5}
|
||||
# 장외 사이클(장전/15:35 확정)이 가짜 'bear' 로 후보를 분류하지 않도록
|
||||
# 직전 장중 판정의 regime_200d 만 빌려온다(배수/게이트는 장외 값 유지).
|
||||
_prev = _market_regime_cache["result"]
|
||||
if (_prev and _prev.get("regime_200d") in ("bull", "bear", "sideways")
|
||||
and (time.monotonic() - _market_regime_cache["ts"]) < _OFFHOURS_REGIME_MAX_AGE_SEC):
|
||||
_off["regime_200d"] = _prev["regime_200d"]
|
||||
_off["regime_200d_source"] = "last_intraday_cache"
|
||||
return _off
|
||||
|
||||
now_ts = time.monotonic()
|
||||
if _market_regime_cache["result"] is not None and (now_ts - _market_regime_cache["ts"]) < _MARKET_REGIME_CACHE_TTL:
|
||||
@@ -119,31 +176,66 @@ async def check_market_regime() -> dict:
|
||||
# naver_data: oldest-first → reversed → newest-first
|
||||
# KIS output2: newest-first (API 기본)
|
||||
chart_data = None
|
||||
_src = "none"
|
||||
_naver_n = 0
|
||||
_naver_stale_fallback = None
|
||||
try:
|
||||
from app.services.naver_finance import get_naver_daily_chart
|
||||
# MA200+기울기(5봉) 판별에 205봉 이상 필요 — 캘린더일 기준이므로 400일 요청(≈270거래일)
|
||||
naver_data = await get_naver_daily_chart("069500", days=400) # oldest-first
|
||||
# 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백(240봉 페이지네이션)으로 넘김
|
||||
_naver_n = len(naver_data) if naver_data else 0
|
||||
# 205봉 미만이면 MA200/SIDEWAYS 판별 불가 → KIS 폴백으로 넘김
|
||||
if naver_data and len(naver_data) >= 205:
|
||||
chart_data = list(reversed(naver_data)) # → newest-first
|
||||
_cand = list(reversed(naver_data)) # → newest-first
|
||||
if _is_stale_index_chart_v2(_cand):
|
||||
# 장중인데 오늘 봉이 없음 → 다음 소스(KIS 범위) 시도, 실패 시 최후 폴백
|
||||
_naver_stale_fallback = _cand
|
||||
else:
|
||||
chart_data = _cand
|
||||
_src = "naver"
|
||||
except Exception as _swallowed:
|
||||
warn_throttled(logger, "market_analyzer:127", "[Regime] 네이버 KODEX200 차트 조회 실패(KIS 폴백 진행): %s", _swallowed)
|
||||
|
||||
if chart_data is None:
|
||||
_api = _real_market_api or korea_investment_api
|
||||
kospi_chart = await _api.get_daily_chart("069500", "D", limit=240)
|
||||
if "output2" not in kospi_chart or not kospi_chart["output2"]:
|
||||
# 국면 판별 불가 시 신규 진입 차단 (기존 0.5배 매매 지속 → 무국면 매매가 실손실 원인)
|
||||
return {"regime": "neutral", "regime_200d": "bear", "message": "시장 지수 확인 불가 — 신규 진입 차단",
|
||||
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
|
||||
chart_data = kospi_chart["output2"] # newest-first
|
||||
# get_daily_chart 는 실측 30행 상한이라 205봉을 못 채운다 → 날짜 범위 페이지네이션 우선
|
||||
_rows = []
|
||||
try:
|
||||
_end = datetime.now().strftime("%Y%m%d")
|
||||
_start = (datetime.now() - timedelta(days=_REGIME_KIS_LOOKBACK_DAYS)).strftime("%Y%m%d")
|
||||
_rows = await _api.get_daily_ohlcv_range("069500", _start, _end) or []
|
||||
except Exception as _e:
|
||||
logger.warning("[Regime] KIS 범위 일봉 폴백 실패: %s", _e)
|
||||
if len(_rows) >= 205 and (_naver_stale_fallback is None
|
||||
or not _is_stale_index_chart_v2(_rows)):
|
||||
chart_data = _rows # newest-first
|
||||
_src = "kis_range"
|
||||
elif _naver_stale_fallback is not None:
|
||||
chart_data = _naver_stale_fallback
|
||||
_src = "naver"
|
||||
else:
|
||||
kospi_chart = await _api.get_daily_chart("069500", "D", limit=240)
|
||||
_out2 = (kospi_chart or {}).get("output2") if isinstance(kospi_chart, dict) else None
|
||||
if not _out2:
|
||||
return _regime_block_v2(
|
||||
"시장 지수 확인 불가 — 신규 진입 차단", "none", 0, _naver_n)
|
||||
chart_data = _out2 if len(_out2) >= len(_rows) else _rows
|
||||
_src = "kis_chart" if chart_data is _out2 else "kis_range"
|
||||
|
||||
# closes: newest-first (closes[0]=최신, closes[-1]=가장 오래된)
|
||||
closes = [float(item.get("stck_clpr", 0)) for item in chart_data]
|
||||
_idx_stale = _is_stale_index_chart_v2(chart_data)
|
||||
_regime_source_state.update(source=_src, bars=len(closes), naver_bars=_naver_n,
|
||||
ts=time.time(), blocked=False, stale=_idx_stale)
|
||||
if _idx_stale:
|
||||
# 당일 봉이 없으면 아래 당일 급락(CRASH) 감지가 건너뛰어진다 — 조용히 넘기지 않는다.
|
||||
warn_throttled(
|
||||
logger, "market_analyzer:regime_stale",
|
||||
"[Regime] 장중 지수 일봉이 낡음(최신 봉 %s != 오늘 %s, source=%s) — 당일 급락 감지 비활성",
|
||||
chart_data[0].get("stck_bsop_date", ""), _get_kst_now().strftime("%Y%m%d"), _src)
|
||||
|
||||
if len(closes) < 60:
|
||||
return {"regime": "neutral", "regime_200d": "bear", "message": "데이터 부족 — 신규 진입 차단",
|
||||
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
|
||||
return _regime_block_v2("데이터 부족 — 신규 진입 차단", _src, len(closes), _naver_n)
|
||||
|
||||
def _cache_and_return(result: dict) -> dict:
|
||||
_market_regime_cache["result"] = result
|
||||
@@ -183,7 +275,10 @@ async def check_market_regime() -> dict:
|
||||
else:
|
||||
# 205봉 미만 → MA200/ADX 국면 판별 불가. 기존에는 MA20 프록시로 상승장 판정 후
|
||||
# 매매를 지속했으나(실계좌 손실 원인), 판별 불가 시 신규 진입을 차단한다.
|
||||
logger.warning(f"국면 판별 불가: 일봉 {len(closes)}개 < 205 — 신규 진입 차단")
|
||||
logger.error(
|
||||
"[Regime] 국면 판별 불가: 일봉 %d개 < 205 (source=%s, naver_bars=%d) — 신규 진입 차단",
|
||||
len(closes), _src, _naver_n)
|
||||
_regime_source_state.update(blocked=True)
|
||||
return _cache_and_return({
|
||||
"regime": "neutral",
|
||||
"regime_200d": "bear",
|
||||
@@ -195,6 +290,7 @@ async def check_market_regime() -> dict:
|
||||
|
||||
# ── HMM 앙상블 (optional) ────────────────────────────────────
|
||||
# 규칙 기반 결과를 먼저 결정한 뒤, HMM 결과와 앙상블
|
||||
_sideways_by_hmm = False
|
||||
_rule_regime_str = "sideways" if is_sideways_200 else ("bull" if is_bull_200 else "bear")
|
||||
# HMM/앙상블에는 oldest-first 데이터 전달 (closes: newest-first → reversed)
|
||||
_closes_oldest = list(reversed(closes))
|
||||
@@ -210,6 +306,7 @@ async def check_market_regime() -> dict:
|
||||
_rule_regime_str, _ensemble.get("hmm_regime"), _ensemble.get("confidence", 0),
|
||||
)
|
||||
is_sideways_200 = True
|
||||
_sideways_by_hmm = True
|
||||
|
||||
# ── 당일 급락 감지 (사이드카/전쟁 등) ───────────────────────
|
||||
# closes[0]이 오늘 데이터일 때만 적용 (일봉 미확정 시 전일 종가가 closes[0]이 되어 오탐 발생)
|
||||
@@ -243,7 +340,7 @@ async def check_market_regime() -> dict:
|
||||
return _cache_and_return({
|
||||
"regime": "sideways",
|
||||
"regime_200d": "sideways",
|
||||
"message": f"횡보장 (코스피: {current_price:.0f}, MA200: {ma200:.0f}, ADX<20 또는 기울기~0) — 진입 차단",
|
||||
"message": _sideways_message_v2(current_price, ma200, _sideways_by_hmm),
|
||||
"position_multiplier": 0.0,
|
||||
"min_score": 9999,
|
||||
"max_trades": 0,
|
||||
@@ -300,6 +397,44 @@ async def check_market_regime() -> dict:
|
||||
"position_multiplier": 0.0, "min_score": 9999, "max_trades": 0}
|
||||
|
||||
|
||||
_VOL_SESSION_MINUTES = 390 # 09:00~15:30
|
||||
_VOL_MIN_ELAPSED_FRACTION = 0.10 # 개장 직후(≈39분) 배율 폭주 방지 하한
|
||||
|
||||
|
||||
def _volume_ratio_v2(volumes: List[int], volume: int, price_date: str, now: Optional[datetime] = None) -> float:
|
||||
"""거래량비. 마지막 봉이 '오늘 장중 부분봉'이면 시간 정규화한다.
|
||||
|
||||
부분봉: volume / (직전 20봉 평균 × 경과 세션 비율). 오늘 봉은 분모에서 제외.
|
||||
그 외(마감 봉·과거 봉): 기존과 동일하게 volume / avg(volumes[-20:]).
|
||||
"""
|
||||
now = now or datetime.now(KST)
|
||||
today = now.strftime("%Y%m%d")
|
||||
minutes = (now.hour * 60 + now.minute) - 9 * 60
|
||||
intraday = (
|
||||
str(price_date) == today
|
||||
and now.weekday() < 5
|
||||
and 0 <= minutes < _VOL_SESSION_MINUTES
|
||||
)
|
||||
if intraday:
|
||||
prior = volumes[-21:-1]
|
||||
if len(prior) < 20:
|
||||
return 0
|
||||
avg_prior = sum(prior) / 20
|
||||
frac = max(_VOL_MIN_ELAPSED_FRACTION, minutes / _VOL_SESSION_MINUTES)
|
||||
denom = avg_prior * frac
|
||||
return (volume / denom) if denom > 0 else 0
|
||||
avg = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0
|
||||
return (volume / avg) if avg > 0 else 0
|
||||
|
||||
|
||||
def _is_stale_intraday_bar_v2(price_date: str, now: Optional[datetime] = None) -> bool:
|
||||
"""장중(is_market_open)인데 마지막 봉 날짜가 오늘이 아니면 True. 장전/장후는 전일 봉이 정상."""
|
||||
now = now or datetime.now(KST)
|
||||
if not price_date or not is_market_open():
|
||||
return False
|
||||
return str(price_date) != now.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def _extract_chart_data(
|
||||
chart_data: List[dict],
|
||||
stock_code: str,
|
||||
@@ -467,6 +602,94 @@ def _trim_to_legacy_window(
|
||||
return trimmed if trimmed else chart_data
|
||||
|
||||
|
||||
# market_data._CHART_SPLIT_MISMATCH_RATIO 와 같은 값(테스트로 동치 고정). 순환 import 회피용 사본.
|
||||
_KRX_SPLIT_MISMATCH_RATIO = 0.20
|
||||
|
||||
_KRX_OVERRIDE_FIELDS = ("stck_clpr", "stck_oprc", "stck_hgpr", "stck_lwpr", "acml_vol")
|
||||
|
||||
|
||||
def _merge_krx_over_naver_v2(
|
||||
naver_rows: Optional[List[dict]], krx_rows: Optional[List[dict]],
|
||||
) -> Optional[List[dict]]:
|
||||
"""네이버 일봉(oldest first)의 같은 날짜 봉을 KRX(stock_daily_prices) 값으로 덮어쓴다.
|
||||
|
||||
- 네이버 종가가 KRX 와 어긋나(실측 82% 봉 >0.05%) 백테스트·상관과 다른 가격열로
|
||||
RSI/BB 게이트가 판정되던 문제 대응. KRX 에 없는 날짜(당일 장중 봉, DB 결측일)는
|
||||
네이버 봉을 그대로 두어 봉 연속성을 유지한다.
|
||||
- KRX 봉의 종가/시가/고가/저가가 0·결측이면 덮어쓰지 않는다.
|
||||
- 입력 배열은 변경하지 않고 새 리스트를 반환한다.
|
||||
"""
|
||||
if not naver_rows:
|
||||
return naver_rows
|
||||
krx_by_date = {}
|
||||
for r in krx_rows or []:
|
||||
d = str(r.get("stck_bsop_date", "") or "")
|
||||
try:
|
||||
if d and all(float(r.get(k, 0) or 0) > 0 for k in ("stck_clpr", "stck_oprc", "stck_hgpr", "stck_lwpr")):
|
||||
krx_by_date[d] = r
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
# 분할·병합 가드: 겹치는 날짜 종가가 20% 넘게 어긋나면(원주가 vs 수정주가) 덮어쓰면
|
||||
# 한 배열 안에 가짜 급락/급등 봉이 생기므로 병합하지 않고 네이버 값을 유지한다.
|
||||
for n in naver_rows:
|
||||
k = krx_by_date.get(str(n.get("stck_bsop_date", "") or ""))
|
||||
if k is None:
|
||||
continue
|
||||
try:
|
||||
nc = float(n.get("stck_clpr", 0) or 0)
|
||||
kc = float(k.get("stck_clpr", 0) or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if nc > 0 and kc > 0 and abs(kc / nc - 1) > _KRX_SPLIT_MISMATCH_RATIO:
|
||||
logger.warning("KRX 병합 생략(분할/수정주가 불연속 추정): %s 네이버 %.0f vs KRX %.0f",
|
||||
n.get("stck_bsop_date"), nc, kc)
|
||||
return list(naver_rows)
|
||||
merged = []
|
||||
for n in naver_rows:
|
||||
k = krx_by_date.get(str(n.get("stck_bsop_date", "") or ""))
|
||||
if k is None:
|
||||
merged.append(n)
|
||||
continue
|
||||
row = dict(n)
|
||||
for key in _KRX_OVERRIDE_FIELDS:
|
||||
if key in k:
|
||||
row[key] = k[key]
|
||||
row["krx_overridden"] = True
|
||||
merged.append(row)
|
||||
return merged
|
||||
|
||||
|
||||
async def _fetch_krx_rows_v2(stock_code: str, limit: int = 240) -> List[dict]:
|
||||
"""stock_daily_prices 최근 limit 행을 KIS 형식(oldest first)으로. 실패 시 [] (네이버 유지)."""
|
||||
try:
|
||||
from sqlalchemy import select as _sa_select
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import StockDailyPrice
|
||||
# DB-READ-EXCEPTION: 배치/분석용 일봉 SELECT 전용(commit·add 없음). 주문·콜백 경로 아님.
|
||||
async with AsyncSessionLocal() as db:
|
||||
res = await db.execute(
|
||||
_sa_select(StockDailyPrice)
|
||||
.where(StockDailyPrice.stock_code == stock_code)
|
||||
.order_by(StockDailyPrice.trade_date.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = res.scalars().all()
|
||||
except Exception as e:
|
||||
logger.warning("KRX 일봉 조회 실패 %s (네이버 값 유지): %s", stock_code, e)
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"stck_bsop_date": r.trade_date.strftime("%Y%m%d"),
|
||||
"stck_clpr": str(r.close_price or 0),
|
||||
"stck_oprc": str(r.open_price or 0),
|
||||
"stck_hgpr": str(r.high_price or 0),
|
||||
"stck_lwpr": str(r.low_price or 0),
|
||||
"acml_vol": str(r.volume or 0),
|
||||
}
|
||||
for r in reversed(rows)
|
||||
]
|
||||
|
||||
|
||||
# ─── ML 앙상블 스킵 집계 (2026-09-28) ───────────────────────────────────────
|
||||
# 9/23~9/28 닷새간 ml_score 가 200/200 NULL 이었는데 아무 로그도 남지 않았다.
|
||||
# 원인은 `except Exception: pass` 였다. 사유별로 집계해 반드시 눈에 띄게 만든다.
|
||||
@@ -601,7 +824,8 @@ def _build_analysis_result_v2(
|
||||
adx_14_val = indicators["adx_14"]
|
||||
|
||||
avg_vol_20 = sum(volumes[-20:]) / 20 if len(volumes) >= 20 else 0
|
||||
volume_ratio = (volume / avg_vol_20) if avg_vol_20 > 0 else 0
|
||||
# 거래량비: 장중 부분봉이면 직전 20봉 평균 × 경과 세션 비율로 정규화 (마감 봉은 기존과 동일)
|
||||
volume_ratio = _volume_ratio_v2(volumes, volume, price_date)
|
||||
|
||||
# 유동성 필터: 20일 평균 거래량 기준으로 판단
|
||||
liquidity_vol = avg_vol_20 if avg_vol_20 > 0 else volume
|
||||
@@ -756,6 +980,7 @@ def _build_analysis_result_v2(
|
||||
signals=signals if signals else ["조건 분석 중"],
|
||||
score=final_score,
|
||||
price_date=price_date,
|
||||
price_stale=_is_stale_intraday_bar_v2(price_date),
|
||||
volume=volume,
|
||||
rsi=rsi,
|
||||
ma5=ma5,
|
||||
@@ -819,6 +1044,30 @@ def _analyze_chart_items_v2(
|
||||
)
|
||||
|
||||
|
||||
async def _analyze_chart_items_offload_v2(
|
||||
chart_data: List[dict],
|
||||
stock_code: str,
|
||||
stock_name: str,
|
||||
criteria: "RecommendationCriteria",
|
||||
ml_chart_data: Optional[List[dict]] = None,
|
||||
) -> Optional[StockRecommendation]:
|
||||
"""_analyze_chart_items_v2 를 워커 스레드로 오프로드 (ML 추론이 이벤트 루프를 막지 않게).
|
||||
|
||||
종목당 소요시간을 기록하고 느리면 warning.
|
||||
"""
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
_analyze_chart_items_v2, chart_data, stock_code, stock_name, criteria, ml_chart_data,
|
||||
)
|
||||
finally:
|
||||
elapsed = time.monotonic() - t0
|
||||
if elapsed >= 1.0:
|
||||
logger.warning("[분석] %s 처리 %.2fs (ML 포함, 스레드 오프로드)", stock_code, elapsed)
|
||||
else:
|
||||
logger.info("[분석] %s 처리 %.3fs", stock_code, elapsed)
|
||||
|
||||
|
||||
async def analyze_stock(stock_code: str, stock_name: str, criteria: RecommendationCriteria) -> Optional[StockRecommendation]:
|
||||
"""차트를 조회한 뒤 _analyze_chart_items에 위임.
|
||||
|
||||
@@ -839,6 +1088,16 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati
|
||||
stock_code, days=_NAVER_ML_CHART_DAYS,
|
||||
)
|
||||
# 룰 지표는 기존과 동일한 창으로 계산한다(ADX Wilder 는 봉수 의존).
|
||||
if ml_chart_data:
|
||||
# 룰/ML 지표를 KRX 가격열로 정합(백테스트·상관과 동일 계열). 모니터링 통계도 누적.
|
||||
krx_rows = await _fetch_krx_rows_v2(stock_code)
|
||||
if krx_rows:
|
||||
try:
|
||||
from app.services.naver_finance import compare_naver_with_krx_v2
|
||||
compare_naver_with_krx_v2(ml_chart_data, krx_rows)
|
||||
except Exception as e:
|
||||
logger.warning("네이버-KRX 대조 실패 %s: %s", stock_code, e)
|
||||
ml_chart_data = _merge_krx_over_naver_v2(ml_chart_data, krx_rows)
|
||||
chart_data = _trim_to_legacy_window(ml_chart_data, _NAVER_LEGACY_DAYS)
|
||||
except Exception as e:
|
||||
logger.warning("네이버 일봉 조회 실패 %s: %s", stock_code, e)
|
||||
@@ -846,14 +1105,14 @@ async def analyze_stock(stock_code: str, stock_name: str, criteria: Recommendati
|
||||
# 네이버 실패 또는 실시간 분석 → KIS API 폴백
|
||||
if chart_data is None:
|
||||
_api = _real_market_api or korea_investment_api
|
||||
# limit 기본값 240 — ML 최소 요구치(80봉)를 충분히 넘는다
|
||||
# 주의: get_daily_chart 는 실측 30행 상한(swing/entry_service 주석) — 80봉 미달이면 ML 스킵(§8.5-24)
|
||||
chart_response = await _api.get_daily_chart(stock_code, "D")
|
||||
if "output2" not in chart_response or not chart_response["output2"]:
|
||||
return None
|
||||
chart_data = list(reversed(chart_response["output2"]))
|
||||
ml_chart_data = None # KIS 경로는 동일 배열을 그대로 ML 에 사용
|
||||
|
||||
return _analyze_chart_items_v2(
|
||||
return await _analyze_chart_items_offload_v2(
|
||||
chart_data, stock_code, stock_name, criteria, ml_chart_data=ml_chart_data,
|
||||
)
|
||||
except Exception as e:
|
||||
@@ -882,7 +1141,7 @@ async def analyze_stock_for_batch_db(
|
||||
)
|
||||
try:
|
||||
chart_data = list(reversed(chart_data_newest_first)) # oldest first
|
||||
return _analyze_chart_items(chart_data, stock_code, stock_name, criteria)
|
||||
return await _analyze_chart_items_offload_v2(chart_data, stock_code, stock_name, criteria)
|
||||
except Exception as e:
|
||||
logger.error(f"Error analyzing {stock_code}: {str(e)}")
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user