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:
@@ -9,7 +9,9 @@
|
||||
# 필요한 라이브러리 및 모듈 임포트
|
||||
from datetime import datetime, timedelta
|
||||
import asyncio
|
||||
import time
|
||||
import random
|
||||
import re
|
||||
import logging
|
||||
import pytz
|
||||
|
||||
@@ -260,16 +262,16 @@ async def _fetch_chart_from_db(stock_code: str, limit: int) -> list:
|
||||
DB에 데이터가 없으면 빈 리스트 반환.
|
||||
|
||||
[READ-ONLY 함수 — DB 쓰기 없음]
|
||||
SELECT만 수행하므로 CLAUDE.md 3.1 "실시간 계층 DB I/O 금지" 규칙의 예외 대상.
|
||||
단, WebSocket 경로에서 이 함수를 직접 호출하면 안 됩니다.
|
||||
WebSocket 핸들러는 get_chart_data_cached()를 통해 인메모리 캐시(1차)를 우선 사용하고,
|
||||
캐시 미스 시에도 이 함수를 호출하지 말고 캐시된 값(빈 리스트 포함)을 반환해야 합니다.
|
||||
REST 엔드포인트에서만 DB 폴백이 허용됩니다.
|
||||
REST /chart 와 WebSocket get_chart(백그라운드 태스크, 인메모리 캐시 미스 시) 양쪽에서 호출된다.
|
||||
SELECT 1회만 수행하고 add/commit 이 없다. KIS 수신 루프·주문 경로에서는 호출되지 않는다.
|
||||
[M-5] OHLC 중 0/결측이 있는 행(거래정지·수집 결함)은 제외 — 네이버 경로와 동일 기준.
|
||||
"""
|
||||
try:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import StockDailyPrice
|
||||
|
||||
# DB-READ-EXCEPTION: 차트 일봉 SELECT 전용(commit·add 없음). WS get_chart 는 메시지 루프가 아닌
|
||||
# 백그라운드 태스크에서 캐시 미스일 때만 도달한다. 쓰기는 이 경로에 추가하지 말 것.
|
||||
async with AsyncSessionLocal() as db:
|
||||
result = await db.execute(
|
||||
select(StockDailyPrice)
|
||||
@@ -284,51 +286,348 @@ async def _fetch_chart_from_db(stock_code: str, limit: int) -> list:
|
||||
|
||||
# DB 결과는 newest first → oldest first로 역순 변환
|
||||
chart_data = []
|
||||
skipped = 0
|
||||
for row in reversed(rows):
|
||||
o = float(row.open_price or 0)
|
||||
h = float(row.high_price or 0)
|
||||
l = float(row.low_price or 0)
|
||||
c = float(row.close_price or 0)
|
||||
if o <= 0 or h <= 0 or l <= 0 or c <= 0:
|
||||
skipped += 1
|
||||
continue
|
||||
chart_data.append({
|
||||
"date": row.trade_date.strftime("%Y%m%d"),
|
||||
"open": float(row.open_price or 0),
|
||||
"high": float(row.high_price or 0),
|
||||
"low": float(row.low_price or 0),
|
||||
"close": float(row.close_price or 0),
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": int(row.volume or 0),
|
||||
})
|
||||
if skipped:
|
||||
logger.info(f"차트 DB 0/결측 OHLC 행 제외 ({stock_code}): {skipped}행")
|
||||
return chart_data
|
||||
except Exception as e:
|
||||
logger.warning(f"DB 차트 조회 실패 ({stock_code}): {e}")
|
||||
return []
|
||||
|
||||
|
||||
async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list:
|
||||
"""캐시된 차트 데이터 반환 — REST endpoint와 WebSocket 핸들러가 공유.
|
||||
_CHART_LIVE_TTL = 30 # 당일 캔들을 합성/병합한 결과의 캐시 TTL(초)
|
||||
|
||||
조회 우선순위:
|
||||
1. 인메모리 캐시 (10분 TTL)
|
||||
2. DB (stock_daily_prices) — 일봉 전용, 배치가 사전 수집한 데이터
|
||||
3. KIS API 폴백 (DB에 데이터 부족 시)
|
||||
|
||||
realtime 보정 없음 (현재가는 WebSocket tick이 처리).
|
||||
def _chart_session_state(now_kst: datetime) -> tuple:
|
||||
"""(expected_last_date, in_session) — 일봉에 있어야 할 가장 최근 거래일과 장중 여부.
|
||||
|
||||
expected_last_date: 오늘이 거래일이고 09:00 이후면 오늘, 아니면 직전 거래일(YYYYMMDD).
|
||||
"""
|
||||
from app.core.market_schedule import is_krx_holiday
|
||||
d = now_kst.date()
|
||||
is_trading_day = d.weekday() < 5 and not is_krx_holiday(d)
|
||||
started = now_kst.time() >= datetime.strptime("09:00", "%H:%M").time()
|
||||
in_session = is_trading_day and started and now_kst.time() < datetime.strptime("15:30", "%H:%M").time()
|
||||
if is_trading_day and started:
|
||||
return d.strftime("%Y%m%d"), in_session
|
||||
d -= timedelta(days=1)
|
||||
while d.weekday() >= 5 or is_krx_holiday(d):
|
||||
d -= timedelta(days=1)
|
||||
return d.strftime("%Y%m%d"), False
|
||||
|
||||
|
||||
def _naver_rows_to_bars(naver_data: list) -> list:
|
||||
rows = []
|
||||
for item in naver_data or []:
|
||||
o = float(item.get("stck_oprc") or 0)
|
||||
h = float(item.get("stck_hgpr") or 0)
|
||||
l = float(item.get("stck_lwpr") or 0)
|
||||
c = float(item.get("stck_clpr") or 0)
|
||||
d = item.get("stck_bsop_date", "")
|
||||
if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0:
|
||||
continue
|
||||
rows.append({"date": d, "open": o, "high": h, "low": l, "close": c,
|
||||
"volume": int(item.get("acml_vol") or 0)})
|
||||
return rows
|
||||
|
||||
|
||||
_DAYBAR_KEY_PREFIX = "daybar:"
|
||||
_DAYBAR_MIN_TTL = 60 # 저장 TTL 하한(초)
|
||||
_CHART_POST_CLOSE_END = "15:40" # 장 마감 직후(단일가 체결 전 값 가능) 짧은 캐시 구간 끝
|
||||
|
||||
|
||||
def _daybar_ttl_seconds(now_kst: datetime) -> int:
|
||||
"""daybar 키 TTL — 당일 15:40(KST)까지 남은 초(하한 _DAYBAR_MIN_TTL)."""
|
||||
end = datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time()
|
||||
end_dt = now_kst.replace(hour=end.hour, minute=end.minute, second=0, microsecond=0)
|
||||
return max(_DAYBAR_MIN_TTL, int((end_dt - now_kst).total_seconds()))
|
||||
|
||||
|
||||
def _is_post_close_window(now_kst: datetime) -> bool:
|
||||
"""거래일 15:30~15:40 — 네이버 당일봉이 단일가 체결 전 값일 수 있어 길게 캐시하지 않는다."""
|
||||
from app.core.market_schedule import is_krx_holiday
|
||||
d = now_kst.date()
|
||||
if d.weekday() >= 5 or is_krx_holiday(d):
|
||||
return False
|
||||
t = now_kst.time()
|
||||
return (datetime.strptime("15:30", "%H:%M").time() <= t
|
||||
< datetime.strptime(_CHART_POST_CLOSE_END, "%H:%M").time())
|
||||
|
||||
|
||||
def _parse_bar(raw, expected: str) -> dict | None:
|
||||
"""OHLC 가 모두 양수인 당일 봉 dict 만 통과(날짜는 expected 와 같아야 함)."""
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
try:
|
||||
price = float(raw.get("price", raw.get("close")) or 0)
|
||||
op = float(raw.get("open") or 0)
|
||||
hi = float(raw.get("high") or 0)
|
||||
lo = float(raw.get("low") or 0)
|
||||
vol = int(raw.get("volume") or 0)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if price <= 0 or op <= 0 or hi <= 0 or lo <= 0:
|
||||
return None
|
||||
return {"date": expected, "open": op, "high": max(hi, price, op),
|
||||
"low": min(lo, price, op), "close": price, "volume": vol}
|
||||
|
||||
|
||||
async def _today_bar_from_tick(stock_code: str, expected: str) -> dict | None:
|
||||
"""Redis tick:{code}(KIS 실시간 체결, 시가/고가/저가 포함)으로 당일 봉 합성.
|
||||
|
||||
tick 은 TTL 10초라 저유동 종목은 자주 비므로, 합성한 당일봉을 daybar:{code}(TTL 15:40 까지)에
|
||||
누적(고가=max, 저가=min)해 두고 tick 이 없을 때 그 값을 쓴다. 둘 다 없거나 불완전하면 None.
|
||||
"""
|
||||
from app.core.redis import get_json, setex_json
|
||||
tick = None
|
||||
try:
|
||||
tick = await get_json(f"tick:{stock_code}")
|
||||
except Exception as e:
|
||||
warn_throttled(logger, "market_data:tick_bar", "tick 캐시 조회 실패 (%s): %s", stock_code, e)
|
||||
saved = None
|
||||
try:
|
||||
raw_saved = await get_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}")
|
||||
if isinstance(raw_saved, dict) and str(raw_saved.get("date")) == expected: # 전일 잔존 값 무시
|
||||
saved = _parse_bar(raw_saved, expected)
|
||||
except Exception as e:
|
||||
warn_throttled(logger, "market_data:daybar_get", "daybar 조회 실패 (%s): %s", stock_code, e)
|
||||
bar = _parse_bar(tick, expected) if isinstance(tick, dict) else None
|
||||
if bar is None:
|
||||
return saved
|
||||
if saved is not None:
|
||||
# 시가는 tick(KIS 당일 시가) 우선, 고저는 누적 극값 유지
|
||||
bar["high"] = max(bar["high"], saved["high"])
|
||||
bar["low"] = min(bar["low"], saved["low"])
|
||||
try:
|
||||
await setex_json(f"{_DAYBAR_KEY_PREFIX}{stock_code}",
|
||||
_daybar_ttl_seconds(datetime.now(_KST)), bar)
|
||||
except Exception as e:
|
||||
warn_throttled(logger, "market_data:daybar_set", "daybar 저장 실패 (%s): %s", stock_code, e)
|
||||
return bar
|
||||
|
||||
|
||||
def _missing_trading_days_v2(bars: list, now_kst: datetime) -> list:
|
||||
"""bars 첫 날짜~마지막 날짜 사이, 최근 10 캘린더일 창 안에서 빠진 거래일(YYYYMMDD)."""
|
||||
from app.core.market_schedule import is_krx_holiday
|
||||
try:
|
||||
have = {str(b.get("date"))[:8] for b in bars}
|
||||
first = datetime.strptime(min(have), "%Y%m%d").date()
|
||||
last = datetime.strptime(max(have), "%Y%m%d").date()
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
d = max(first, now_kst.date() - timedelta(days=10))
|
||||
missing = []
|
||||
while d < last:
|
||||
ds = d.strftime("%Y%m%d")
|
||||
if d.weekday() < 5 and not is_krx_holiday(d) and ds not in have:
|
||||
missing.append(ds)
|
||||
d += timedelta(days=1)
|
||||
return missing
|
||||
|
||||
|
||||
_CHART_SPLIT_MISMATCH_RATIO = 0.20 # 분할·수정주가 판정 임계(종가 비율 편차)
|
||||
|
||||
|
||||
async def _merge_recent_daily_bars(stock_code: str, bars: list, now_kst: datetime | None = None) -> tuple:
|
||||
"""DB 일봉이 최신 거래일보다 뒤처졌을 때 누락 봉을 채운다. (bars, live_merged) 반환.
|
||||
|
||||
원인(2026-10-08): DB(stock_daily_prices) 갱신이 하루 이상 늦어도 _CHART_STALE_DAYS(7일)
|
||||
미만이면 폴백이 없고, 장중 당일 봉은 어디서도 합성되지 않아 차트가 멈춰 보였다.
|
||||
1) 누락 거래일: 네이버 최근 일봉으로 보충 (읽기 전용 HTTP, DB 쓰기 없음)
|
||||
2) 장중: 당일 봉을 Redis tick(시가/고가/저가/현재가/누적거래량)으로만 합성 (KIS REST 호출 없음)
|
||||
예외는 경고만 남기고 원본 bars 를 반환한다(차트는 계속 나와야 한다).
|
||||
"""
|
||||
now_kst = now_kst or datetime.now(_KST)
|
||||
expected, in_session = _chart_session_state(now_kst)
|
||||
out = list(bars)
|
||||
last = str(out[-1].get("date", ""))[:8] if out else ""
|
||||
live = False
|
||||
|
||||
# 최근 10일 창 안의 중간 결손 거래일(예: DB 일괄 수집 누락) 탐지 — last 이전 결손도 백필 대상
|
||||
gaps = _missing_trading_days_v2(out, now_kst) if out else []
|
||||
if not last or last < expected or gaps:
|
||||
try:
|
||||
from app.services.naver_finance import get_naver_daily_chart
|
||||
naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=10))
|
||||
have = {str(b.get("date"))[:8]: b for b in out}
|
||||
# 장중 네이버의 당일 진행 봉은 부분봉이다 → 버린다(당일봉은 tick 합성만 인정)
|
||||
if in_session:
|
||||
naver_rows = [r for r in naver_rows if r["date"] != expected]
|
||||
# 원주가/수정주가 혼재 방지: 겹치는 날짜 종가가 분할 수준(>20%)으로 어긋날 때만 병합 안 함.
|
||||
# 네이버-KRX 일상적 차이(최대 ~1.3%)는 DB 봉을 유지하고 누락 날짜만 보충한다.
|
||||
mismatch = any(
|
||||
float(have[r["date"]].get("close") or 0) > 0
|
||||
and abs(r["close"] / float(have[r["date"]]["close"]) - 1) > _CHART_SPLIT_MISMATCH_RATIO
|
||||
for r in naver_rows if r["date"] in have
|
||||
)
|
||||
if mismatch:
|
||||
logger.warning(f"차트 보충 생략({stock_code}): DB/네이버 종가 불일치(분할·수정주가 추정)")
|
||||
else:
|
||||
first = str(out[0].get("date"))[:8] if out else ""
|
||||
added = [r for r in naver_rows if r["date"] not in have and r["date"] > first]
|
||||
if added:
|
||||
out.extend(added)
|
||||
out.sort(key=lambda b: b["date"])
|
||||
last = out[-1]["date"]
|
||||
logger.info(f"차트 누락 봉 보충({stock_code}): {[a['date'] for a in added]}")
|
||||
except Exception as e:
|
||||
logger.warning(f"차트 누락 봉 보충 실패 ({stock_code}): {e}")
|
||||
|
||||
if in_session:
|
||||
# [M-7] 실시간 체결 캐시(Redis tick:{code}, TTL 10초)로 합성
|
||||
tick_bar = await _today_bar_from_tick(stock_code, expected)
|
||||
if tick_bar is not None:
|
||||
if out and str(out[-1].get("date"))[:8] == expected:
|
||||
out[-1] = tick_bar
|
||||
else:
|
||||
out.append(tick_bar)
|
||||
return out, True
|
||||
# [R1 하드닝] 차트 경로는 KIS REST 를 호출하지 않는다(주문·청산과 전역 쓰로틀 공유 방지).
|
||||
# tick 이 없으면(WS 등록 한도 밖 종목 등) 당일봉 없이 DB/네이버 봉만 반환한다.
|
||||
return out, live
|
||||
|
||||
|
||||
_CHART_MAX_LIMIT = 240
|
||||
# 네이버 폴백 조회 기간(캘린더일) — 주/월봉은 집계 전 일봉이 충분해야 한다(L-4).
|
||||
# 캘린더 ×7 / ×31 ≈ 거래일 ×5 / ×22.
|
||||
_NAVER_DAYS_PER_BAR = {"D": 2, "W": 7, "M": 31}
|
||||
_NAVER_MAX_DAYS = 7500
|
||||
|
||||
_OVERSEAS_TICKER_RE = re.compile(r"[A-Za-z][A-Za-z.\-]{0,9}")
|
||||
|
||||
|
||||
def _is_overseas_ticker(stock_code: str) -> bool:
|
||||
"""미국 티커 여부. 국내 단축코드(6자리, 숫자 포함 — 예: 005930, 0004V0)는 False."""
|
||||
c = (stock_code or "").strip()
|
||||
return bool(c) and not any(ch.isdigit() for ch in c) and bool(_OVERSEAS_TICKER_RE.fullmatch(c))
|
||||
|
||||
|
||||
def parse_chart_limit_v2(raw, default: int = 200) -> int:
|
||||
"""[L-3] 클라이언트가 보낸 limit 을 안전하게 정수화(1~240). 변환 불가면 default."""
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(1, min(value, _CHART_MAX_LIMIT))
|
||||
|
||||
|
||||
def is_chart_cache_fresh_v2(stock_code: str, period: str = "D", limit: int = 200) -> bool:
|
||||
"""[M-7] 차트 캐시 엔트리가 자신의 ttl(장중 합성 30초 / 그 외 10분) 안에 있는지."""
|
||||
cached = chart_cache.get(f"{stock_code}__{period}__{min(limit, _CHART_MAX_LIMIT)}")
|
||||
if not cached:
|
||||
return False
|
||||
age = (datetime.now() - cached["timestamp"]).total_seconds()
|
||||
return age < cached.get("ttl", _CHART_CACHE_TTL)
|
||||
|
||||
|
||||
def _chart_cache_put(cache_key: str, data: list, ttl: int, current_time: datetime) -> None:
|
||||
# 캐시 크기 제한: 초과 시 만료 항목 → 가장 오래된 항목 순으로 제거
|
||||
if len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
|
||||
expired_keys = [
|
||||
k for k, v in chart_cache.items()
|
||||
if (current_time - v["timestamp"]).total_seconds() >= v.get("ttl", _CHART_CACHE_TTL)
|
||||
]
|
||||
for k in expired_keys:
|
||||
del chart_cache[k]
|
||||
while len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
|
||||
oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"])
|
||||
del chart_cache[oldest_key]
|
||||
chart_cache[cache_key] = {"timestamp": current_time, "data": data, "ttl": ttl}
|
||||
|
||||
|
||||
def _overseas_rows_to_bars(result) -> list:
|
||||
"""KIS 해외 일봉(HHDFS76240000) output2 → 차트 봉 (oldest first). 0/결측 OHLC 제외."""
|
||||
items = result.get("output2", []) if isinstance(result, dict) else (result or [])
|
||||
bars = []
|
||||
for item in items or []:
|
||||
try:
|
||||
o = float(item.get("open") or 0)
|
||||
h = float(item.get("high") or 0)
|
||||
l = float(item.get("low") or 0)
|
||||
c = float(item.get("clos") or 0)
|
||||
d = str(item.get("xymd") or "")
|
||||
vol = int(float(item.get("tvol") or 0))
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
continue
|
||||
if len(d) != 8 or o <= 0 or h <= 0 or l <= 0 or c <= 0:
|
||||
continue
|
||||
bars.append({"date": d, "open": o, "high": h, "low": l, "close": c, "volume": vol})
|
||||
bars.sort(key=lambda b: b["date"])
|
||||
return bars
|
||||
|
||||
|
||||
async def _get_overseas_chart_impl(stock_code: str, period: str, limit: int) -> dict:
|
||||
"""[H-6] 미국 티커 차트 — 기존 해외 차트 경로(KIS HHDFS76240000, Redis 5분 캐시) 사용."""
|
||||
cache_key = f"{stock_code}__{period}__{limit}"
|
||||
current_time = datetime.now()
|
||||
try:
|
||||
from app.services.exchange_detector import detect_exchange
|
||||
result = await korea_investment_api.get_overseas_daily_chart(
|
||||
stock_code, detect_exchange(stock_code), period
|
||||
)
|
||||
bars = _overseas_rows_to_bars(result)[-limit:]
|
||||
except Exception as e:
|
||||
warn_throttled(logger, "market_data:overseas_chart",
|
||||
"해외 차트 조회 실패 (%s): %s", stock_code, e)
|
||||
bars = []
|
||||
if not bars:
|
||||
return {"data": [], "is_sample": False, "reason": "overseas_chart_unavailable", "source": None}
|
||||
_chart_cache_put(cache_key, bars, _CHART_CACHE_TTL, current_time)
|
||||
return {"data": list(bars), "is_sample": False, "reason": None, "source": "overseas"}
|
||||
|
||||
|
||||
async def get_chart_data_cached_v2(stock_code: str, period: str, limit: int) -> dict:
|
||||
"""차트 데이터 + 메타 반환 — REST /chart 와 WebSocket get_chart 가 공유.
|
||||
|
||||
반환: {"data": [...], "is_sample": False, "reason": None|"no_data"|"overseas_chart_unavailable",
|
||||
"source": "cache"|"db"|"naver"|"overseas"|None}
|
||||
|
||||
조회 우선순위 (국내):
|
||||
1. 인메모리 캐시 (엔트리별 ttl — 장중 합성 30초 / 그 외 10분)
|
||||
2. DB (stock_daily_prices) — 0/결측 OHLC 제외
|
||||
3. 네이버 일봉 폴백 (DB 없음/오래됨)
|
||||
+ 누락 거래일 보충·장중 당일봉 합성(_merge_recent_daily_bars)
|
||||
|
||||
[H-6] 데이터가 없으면 난수 샘플을 만들지 않고 빈 배열 + reason 을 돌려준다.
|
||||
(과거 _generate_sample_chart 난수 차트가 실데이터처럼 표시됐다.)
|
||||
"""
|
||||
if period not in ("D", "W", "M"):
|
||||
period = "D"
|
||||
limit = min(limit, 240)
|
||||
limit = max(1, min(int(limit), _CHART_MAX_LIMIT))
|
||||
|
||||
cache_key = f"{stock_code}__{period}__{limit}"
|
||||
current_time = datetime.now()
|
||||
|
||||
# 1차: 인메모리 캐시 (TTL 만료 항목 삭제 + 크기 제한)
|
||||
# 1차: 인메모리 캐시 (TTL 만료 항목 삭제)
|
||||
cached = chart_cache.get(cache_key)
|
||||
if cached:
|
||||
if (current_time - cached["timestamp"]).total_seconds() < _CHART_CACHE_TTL:
|
||||
return list(cached["data"])
|
||||
else:
|
||||
# 만료 항목 즉시 삭제
|
||||
del chart_cache[cache_key]
|
||||
if (current_time - cached["timestamp"]).total_seconds() < cached.get("ttl", _CHART_CACHE_TTL):
|
||||
return {"data": list(cached["data"]), "is_sample": False, "reason": None, "source": "cache"}
|
||||
del chart_cache[cache_key]
|
||||
|
||||
# 2차: DB 조회 (KIS API 사용 안 함 — 배치가 수집한 일봉 데이터만 사용)
|
||||
# 주/월봉은 일봉 데이터에서 집계
|
||||
if _is_overseas_ticker(stock_code):
|
||||
return await _get_overseas_chart_impl(stock_code, period, limit)
|
||||
|
||||
# 2차: DB 조회 — 주/월봉은 일봉 데이터에서 집계
|
||||
db_limit = limit if period == "D" else limit * 7 if period == "W" else limit * 31
|
||||
db_data = await _fetch_chart_from_db(stock_code, min(db_limit, 1200))
|
||||
source = "db"
|
||||
|
||||
# DB 데이터가 없거나 오래됐으면(마지막 봉이 _CHART_STALE_DAYS일 이상 전) 네이버 차트 폴백
|
||||
# — KRX 일괄 수집(DATAGOKR)이 멈춘 종목은 DB가 수개월 전에서 끊겨 차트가 현재가와 어긋난다.
|
||||
@@ -344,56 +643,61 @@ async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> lis
|
||||
logger.info(f"차트 DB 데이터 오래됨 ({stock_code}, 마지막 {db_data[-1].get('date')}) → 네이버 폴백")
|
||||
try:
|
||||
from app.services.naver_finance import get_naver_daily_chart
|
||||
naver_data = await get_naver_daily_chart(stock_code, days=limit * 2)
|
||||
if naver_data:
|
||||
naver_rows = []
|
||||
for item in naver_data:
|
||||
o = float(item.get("stck_oprc") or 0)
|
||||
h = float(item.get("stck_hgpr") or 0)
|
||||
l = float(item.get("stck_lwpr") or 0)
|
||||
c = float(item.get("stck_clpr") or 0)
|
||||
d = item.get("stck_bsop_date", "")
|
||||
if not d or c <= 0 or o <= 0 or h <= 0 or l <= 0:
|
||||
continue
|
||||
naver_rows.append({
|
||||
"date": d,
|
||||
"open": o,
|
||||
"high": h,
|
||||
"low": l,
|
||||
"close": c,
|
||||
"volume": int(item.get("acml_vol") or 0),
|
||||
})
|
||||
if naver_rows:
|
||||
db_data = naver_rows[-limit:]
|
||||
naver_days = min(limit * _NAVER_DAYS_PER_BAR[period], _NAVER_MAX_DAYS)
|
||||
naver_rows = _naver_rows_to_bars(await get_naver_daily_chart(stock_code, days=naver_days))
|
||||
if naver_rows:
|
||||
# 일봉은 limit 개, 주/월봉은 집계 전이므로 전부 유지(집계 후 limit 으로 자른다)
|
||||
db_data = naver_rows[-limit:] if period == "D" else naver_rows
|
||||
source = "naver"
|
||||
logger.info(f"차트 네이버 폴백: {stock_code} ({len(db_data)}행)")
|
||||
except Exception as e:
|
||||
logger.warning(f"네이버 차트 폴백 실패 ({stock_code}): {e}")
|
||||
|
||||
if not db_data:
|
||||
return _generate_sample_chart(stock_code, period, limit).get("data", [])
|
||||
return {"data": [], "is_sample": False, "reason": "no_data", "source": None}
|
||||
|
||||
# 주/월봉 집계
|
||||
if period in ("W", "M"):
|
||||
db_data = _aggregate_weekly_monthly(db_data, period)
|
||||
db_data = db_data[-limit:] # 최신 N개만
|
||||
# 최신 거래일 봉 보충 + 장중 당일 봉 합성 (일봉 원본에 먼저 적용 → 주/월봉에도 반영)
|
||||
db_data, _live = await _merge_recent_daily_bars(stock_code, db_data)
|
||||
if period == "D":
|
||||
db_data = db_data[-limit:]
|
||||
else:
|
||||
db_data = _aggregate_weekly_monthly(db_data, period)[-limit:]
|
||||
|
||||
# 캐시 크기 제한: 초과 시 가장 오래된 항목 제거
|
||||
if len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
|
||||
# 만료 항목 우선 정리
|
||||
expired_keys = [
|
||||
k for k, v in chart_cache.items()
|
||||
if (current_time - v["timestamp"]).total_seconds() >= _CHART_CACHE_TTL
|
||||
]
|
||||
for k in expired_keys:
|
||||
del chart_cache[k]
|
||||
# 그래도 초과면 가장 오래된 항목 제거
|
||||
while len(chart_cache) >= _CHART_CACHE_MAX_SIZE:
|
||||
oldest_key = min(chart_cache, key=lambda k: chart_cache[k]["timestamp"])
|
||||
del chart_cache[oldest_key]
|
||||
# 장중이면 tick 유무와 무관하게 짧은 TTL — 구독 워밍업이 첫 tick 보다 먼저 끝나
|
||||
# 당일봉 없는 결과가 10분 캐시되는 것을 막는다.
|
||||
_now_kst = datetime.now(_KST)
|
||||
_, _in_session = _chart_session_state(_now_kst)
|
||||
_short = _live or _in_session or _is_post_close_window(_now_kst)
|
||||
_chart_cache_put(cache_key, db_data,
|
||||
_CHART_LIVE_TTL if _short else _CHART_CACHE_TTL, current_time)
|
||||
return {"data": list(db_data), "is_sample": False, "reason": None, "source": source}
|
||||
|
||||
chart_cache[cache_key] = {"timestamp": current_time, "data": db_data}
|
||||
logger.debug(f"차트 DB 히트: {stock_code} period={period} ({len(db_data)}행)")
|
||||
return db_data
|
||||
|
||||
async def get_chart_data_cached(stock_code: str, period: str, limit: int) -> list:
|
||||
"""캐시된 차트 데이터(list) 반환 — 호환 래퍼. 메타가 필요하면 get_chart_data_cached_v2.
|
||||
|
||||
데이터가 없으면 빈 리스트(샘플 난수 차트를 반환하지 않는다 — H-6).
|
||||
"""
|
||||
return (await get_chart_data_cached_v2(stock_code, period, limit))["data"]
|
||||
|
||||
|
||||
async def build_ws_chart_response_v2(stock_code: str, period, limit_raw) -> dict:
|
||||
"""WebSocket `chart_data` 응답 생성. 예외는 빈 배열 + reason/error 로 변환한다.
|
||||
|
||||
필드: type, stock_code, period(요청값 그대로 — 프론트가 이것으로 응답을 매칭), data,
|
||||
is_sample(항상 False), reason(None|no_data|overseas_chart_unavailable|chart_fetch_failed),
|
||||
error(None|예외 종류명)
|
||||
"""
|
||||
limit = parse_chart_limit_v2(limit_raw)
|
||||
try:
|
||||
res = await get_chart_data_cached_v2(stock_code, period, limit)
|
||||
return {"type": "chart_data", "stock_code": stock_code, "period": period,
|
||||
"data": res["data"], "is_sample": False, "reason": res["reason"], "error": None}
|
||||
except Exception as e:
|
||||
logger.error(f"get_chart 오류 ({stock_code}): {type(e).__name__}: {e}")
|
||||
return {"type": "chart_data", "stock_code": stock_code, "period": period,
|
||||
"data": [], "is_sample": False, "reason": "chart_fetch_failed",
|
||||
"error": type(e).__name__}
|
||||
|
||||
|
||||
@router.get("/chart/{stock_code}")
|
||||
@@ -403,32 +707,38 @@ async def get_stock_chart(
|
||||
realtime: bool = Query(False, description="최신 현재가를 포함하여 실시간으로 보정된 마지막 캔들 반환"),
|
||||
limit: int = Query(100, description="조회할 데이터 개수 (기본: 100, 최대: 240)")
|
||||
):
|
||||
"""주식 차트 데이터 조회 엔드포인트 (DB 전용 — KIS API 미사용)
|
||||
"""주식 차트 데이터 조회 엔드포인트
|
||||
|
||||
Args:
|
||||
stock_code: 종목 코드
|
||||
stock_code: 종목 코드 (국내 6자리 또는 미국 티커)
|
||||
period: D(일봉), W(주봉), M(월봉)
|
||||
limit: 조회할 데이터 개수 (일봉 100개는 약 3개월치 데이터)
|
||||
|
||||
데이터가 없으면 data=[] + reason. is_sample 은 항상 False(샘플 난수 차트를 만들지 않는다).
|
||||
"""
|
||||
try:
|
||||
chart_data = await get_chart_data_cached(stock_code, period, limit)
|
||||
|
||||
res = await get_chart_data_cached_v2(stock_code, period, parse_chart_limit_v2(limit, 100))
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"data": chart_data,
|
||||
"is_sample": not chart_data,
|
||||
"data": res["data"],
|
||||
"is_sample": False,
|
||||
"reason": res["reason"],
|
||||
"realtime": False,
|
||||
"latest": None,
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
sample = _generate_sample_chart(stock_code, period)
|
||||
sample["error"] = str(e)
|
||||
sample["is_sample"] = True
|
||||
sample["realtime"] = False
|
||||
sample["latest"] = None
|
||||
return sample
|
||||
logger.error(f"차트 조회 오류 ({stock_code}): {type(e).__name__}: {e}")
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"period": period,
|
||||
"data": [],
|
||||
"is_sample": False,
|
||||
"reason": "chart_fetch_failed",
|
||||
"error": type(e).__name__,
|
||||
"realtime": False,
|
||||
"latest": None,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/news")
|
||||
@@ -488,13 +798,134 @@ async def get_market_news(limit: int = 10):
|
||||
][:limit]
|
||||
|
||||
|
||||
# ── 종목명 해석 ─────────────────────────────────────────────────────────────
|
||||
# KIS inquire-price(FHKST01010100)/호가 응답에는 종목명(hts_kor_isnm)이 없거나 비어 있어
|
||||
# 화면에 "009420 009420" 으로 보였다(2026-10-08). 폴백 순서:
|
||||
# KIS 응답 필드 → 프로세스 캐시 → DB(StockAnalysis → Portfolio → StockDailyPrice) → 네이버 basic API
|
||||
_STOCK_NAME_MEM: Dict[str, str] = {}
|
||||
_STOCK_NAME_MEM_MAX = 2000
|
||||
_STOCK_NAME_MISS: Dict[str, float] = {} # code -> monotonic ts (이름 못 찾음 네거티브 캐시)
|
||||
_STOCK_NAME_MISS_TTL = 600.0 # 초
|
||||
|
||||
|
||||
def _name_from_kis_output(*outputs) -> str:
|
||||
for o in outputs:
|
||||
if not isinstance(o, dict):
|
||||
continue
|
||||
for k in ("hts_kor_isnm", "prdt_abrv_name", "stck_prdt_name", "prdt_name"):
|
||||
v = str(o.get(k) or "").strip()
|
||||
if v:
|
||||
return v
|
||||
return ""
|
||||
|
||||
|
||||
async def _resolve_stock_name(stock_code: str, kis_name: str = "") -> str:
|
||||
"""종목명 해석. 못 찾으면 "" (코드로 대체하지 않는다 — 표시는 프론트 몫)."""
|
||||
name = (kis_name or "").strip()
|
||||
if name:
|
||||
_STOCK_NAME_MEM[stock_code] = name
|
||||
return name
|
||||
cached = _STOCK_NAME_MEM.get(stock_code)
|
||||
if cached:
|
||||
return cached
|
||||
_miss_ts = _STOCK_NAME_MISS.get(stock_code)
|
||||
if _miss_ts is not None:
|
||||
if (time.monotonic() - _miss_ts) < _STOCK_NAME_MISS_TTL:
|
||||
return ""
|
||||
_STOCK_NAME_MISS.pop(stock_code, None)
|
||||
try:
|
||||
from app.core.database import AsyncSessionLocal
|
||||
from app.models.models import StockAnalysis, Portfolio, StockDailyPrice
|
||||
# DB-READ-EXCEPTION: READ-ONLY SELECT (REST 엔드포인트, commit 없음)
|
||||
async with AsyncSessionLocal() as _db:
|
||||
for model, order in (
|
||||
(StockAnalysis, StockAnalysis.analysis_date.desc()),
|
||||
(Portfolio, None),
|
||||
(StockDailyPrice, StockDailyPrice.trade_date.desc()),
|
||||
):
|
||||
q = select(model.stock_name).where(
|
||||
model.stock_code == stock_code,
|
||||
model.stock_name.isnot(None),
|
||||
model.stock_name != "",
|
||||
)
|
||||
if order is not None:
|
||||
q = q.order_by(order)
|
||||
row = (await _db.execute(q.limit(1))).scalar_one_or_none()
|
||||
if row and str(row).strip() and str(row).strip() != stock_code:
|
||||
name = str(row).strip()
|
||||
break
|
||||
except Exception as e:
|
||||
warn_throttled(logger, "market_data:name_db", "종목명 DB 조회 실패 (%s): %s", stock_code, e)
|
||||
if not name:
|
||||
try:
|
||||
from app.services.naver_finance import get_naver_stock_name
|
||||
name = await get_naver_stock_name(stock_code)
|
||||
except Exception as e:
|
||||
warn_throttled(logger, "market_data:name_naver", "종목명 네이버 조회 실패 (%s): %s", stock_code, e)
|
||||
if name:
|
||||
if len(_STOCK_NAME_MEM) >= _STOCK_NAME_MEM_MAX:
|
||||
_STOCK_NAME_MEM.pop(next(iter(_STOCK_NAME_MEM)), None)
|
||||
_STOCK_NAME_MEM[stock_code] = name
|
||||
_STOCK_NAME_MISS.pop(stock_code, None)
|
||||
else:
|
||||
# 이름 없음도 짧게 캐시 — 호가 폴링마다 DB 3회 + 네이버 1회 반복 방지
|
||||
if len(_STOCK_NAME_MISS) >= _STOCK_NAME_MEM_MAX:
|
||||
_STOCK_NAME_MISS.pop(next(iter(_STOCK_NAME_MISS)), None)
|
||||
_STOCK_NAME_MISS[stock_code] = time.monotonic()
|
||||
return name
|
||||
|
||||
|
||||
# 호가 응답이 4.5~13초였던 원인(2026-10-08): 호가 1건이 KIS 전역 쓰로틀 락·세마포어에서
|
||||
# 현재가/잔고/스케줄러 호출 뒤에 줄을 서고, 화면 폴링마다 같은 종목을 중복 조회했다.
|
||||
# → 종목별 짧은 TTL 캐시 + 동시 요청 합치기(in-flight). 실패 응답은 캐시하지 않는다.
|
||||
_ORDERBOOK_TTL = 1.5 # 초
|
||||
_ORDERBOOK_CACHE_MAX = 200
|
||||
_orderbook_cache: Dict[str, tuple] = {} # code -> (monotonic ts, payload)
|
||||
_orderbook_inflight: Dict[str, "asyncio.Future"] = {}
|
||||
|
||||
|
||||
@router.get("/orderbook/{stock_code}")
|
||||
async def get_orderbook(stock_code: str):
|
||||
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가)"""
|
||||
"""호가 정보 조회 엔드포인트 (매수/매도 10단계 호가) — 1.5초 TTL 캐시 + 동시요청 합치기."""
|
||||
import time as _time
|
||||
now = _time.monotonic()
|
||||
hit = _orderbook_cache.get(stock_code)
|
||||
if hit and now - hit[0] < _ORDERBOOK_TTL:
|
||||
return hit[1]
|
||||
fut = _orderbook_inflight.get(stock_code)
|
||||
if fut is not None:
|
||||
return await asyncio.shield(fut)
|
||||
|
||||
fut = asyncio.get_running_loop().create_future()
|
||||
_orderbook_inflight[stock_code] = fut
|
||||
try:
|
||||
result = await _get_orderbook_impl(stock_code)
|
||||
if isinstance(result, dict) and result.get("asks") and not result.get("error"):
|
||||
if len(_orderbook_cache) >= _ORDERBOOK_CACHE_MAX:
|
||||
_orderbook_cache.pop(min(_orderbook_cache, key=lambda k: _orderbook_cache[k][0]), None)
|
||||
_orderbook_cache[stock_code] = (_time.monotonic(), result)
|
||||
fut.set_result(result)
|
||||
return result
|
||||
except BaseException as e:
|
||||
if not fut.done():
|
||||
fut.set_exception(e)
|
||||
fut.exception() # 대기자가 없을 때 'never retrieved' 경고 방지
|
||||
raise
|
||||
finally:
|
||||
_orderbook_inflight.pop(stock_code, None)
|
||||
|
||||
|
||||
async def _get_orderbook_impl(stock_code: str):
|
||||
try:
|
||||
logger.debug(f"Fetching orderbook for stock: {stock_code}")
|
||||
# 한국투자증권 API를 통해 호가 데이터 조회
|
||||
data = await korea_investment_api.get_orderbook(stock_code)
|
||||
# 종목명 해석은 KIS 호출과 병렬로 (추가 지연 방지)
|
||||
_name_task = asyncio.create_task(_resolve_stock_name(stock_code))
|
||||
try:
|
||||
# 한국투자증권 API를 통해 호가 데이터 조회
|
||||
data = await korea_investment_api.get_orderbook(stock_code)
|
||||
except BaseException:
|
||||
_name_task.cancel()
|
||||
raise
|
||||
|
||||
# API 응답 코드가 '0'(성공)이 아닌 경우 에러 처리
|
||||
if data.get("rt_cd") != "0":
|
||||
@@ -518,7 +949,9 @@ async def get_orderbook(stock_code: str):
|
||||
# 호가 데이터를 담을 딕셔너리 초기화
|
||||
orderbook = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": output1.get("hts_kor_isnm", ""),
|
||||
"stock_name": await _resolve_stock_name(
|
||||
stock_code, _name_from_kis_output(output1, output2) or (await _name_task)
|
||||
),
|
||||
"current_price": current_price,
|
||||
"current_volume": current_volume,
|
||||
"asks": [], # 매도 호가 (낮은 가격 -> 높은 가격 순으로 임시 저장 후 reverse)
|
||||
@@ -847,10 +1280,10 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||
signals.append(f"RSI 과매수({rsi:.1f})")
|
||||
elif rsi < 40: # 매수 고려
|
||||
buy_signals += 0.5
|
||||
signals.append(f"RSI 매수권장({rsi:.1f})")
|
||||
signals.append(f"RSI 저점권({rsi:.1f}, 약한 매수 우위)")
|
||||
elif rsi > 60: # 매도 고려
|
||||
sell_signals += 0.5
|
||||
signals.append(f"RSI 매도권장({rsi:.1f})")
|
||||
signals.append(f"RSI 고점권({rsi:.1f}, 약한 매도 우위)")
|
||||
|
||||
# 이동평균선(MA) 분석
|
||||
ma5 = indicators.get("ma5")
|
||||
@@ -961,9 +1394,26 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||
trend = "DOWN"
|
||||
|
||||
# 4. 종합 결과 반환
|
||||
# 제목/추천/목표가는 최종 결론(recommendation)과 반드시 일치해야 한다.
|
||||
# (2026-10-08: HOLD 13% 인데 '매수 추천'·목표가·손절가가 함께 표시된 모순)
|
||||
_targets = _suggest_targets(current_price, _atr_for_targets)
|
||||
if recommendation != "BUY":
|
||||
# 목표가·손절가는 '신규 매수 진입' 기준 값이므로 BUY 결론에서만 의미가 있다.
|
||||
_targets = {
|
||||
"target_price": None,
|
||||
"stop_loss_price": None,
|
||||
"target_pct": None,
|
||||
"stop_loss_pct": None,
|
||||
"risk_reward": None,
|
||||
"target_note": f"{recommendation} 결론 — 신규 진입 목표가·손절가 제시 안 함",
|
||||
}
|
||||
_headline = {"BUY": "매수 추천", "SELL": "매도 추천", "HOLD": "관망"}.get(recommendation, "관망")
|
||||
_stock_name = await _resolve_stock_name(stock_code, _name_from_kis_output(output))
|
||||
return {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": output.get("hts_kor_isnm", "") or output.get("stck_prdt_name", ""),
|
||||
"stock_name": _stock_name,
|
||||
"action": recommendation,
|
||||
"headline": _headline,
|
||||
"current_price": current_price,
|
||||
"high_price": high_price,
|
||||
"low_price": low_price,
|
||||
@@ -976,7 +1426,7 @@ async def get_ai_analysis(stock_code: str, db: AsyncSession = Depends(get_db)):
|
||||
# 매수 추천 금액 (실제 자동매매 사이징과 동일 기준)
|
||||
**_suggest_buy_amount(current_price, recommendation, confidence, stock_code),
|
||||
# 목표가·손절가 (CLAUDE.md §5.2)
|
||||
**_suggest_targets(current_price, _atr_for_targets),
|
||||
**_targets,
|
||||
"analyzed_at": datetime.now(_KST).isoformat(), # KST +09:00 포함
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user