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

@@ -15,6 +15,7 @@
import asyncio
import logging
import re
import time
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional, Tuple
@@ -24,7 +25,7 @@ from bs4 import BeautifulSoup
from app.core.config import settings
from app.core.redis import setex_json, get_json
from app.services.db_worker import db_task_queue
from app.core.timeutil import now_kst
from app.core.timeutil import now_kst, KST
from app.core.log_throttle import warn_throttled # noqa: E402
@@ -96,6 +97,86 @@ _HEADERS = {
)
}
# 수집 0건이 이 횟수만큼 연속되면 error + 텔레그램 경보 (getattr: settings 에 없으면 기본값)
_EMPTY_ALERT_DEFAULT = 5
# 0건 결과 단기 캐시(초) — 점수 0.0 을 정상 캐시하지 않고, 재크롤링 폭주만 막는다
_EMPTY_NEGATIVE_TTL = 300
# 수집 건강 상태 (프로세스 내 메모리). /health 등에서 get_news_collect_health() 로 조회.
_health: Dict[str, Dict[str, Any]] = {
"stock": {"consecutive_empty": 0, "last_success_at": None, "alerted": False},
"market": {"consecutive_empty": 0, "last_success_at": None, "alerted": False},
}
def get_news_collect_health() -> Dict[str, Any]:
"""마지막 뉴스 수집 성공 시각과 연속 0건 횟수 (종목/시장 구분)."""
out: Dict[str, Any] = {}
for kind, st in _health.items():
ts = st["last_success_at"]
out[kind] = {
"consecutive_empty": st["consecutive_empty"],
"last_success_at": ts.isoformat() if ts else None,
}
return out
async def _record_collect_result(kind: str, count: int) -> None:
"""수집 결과를 기록하고 연속 0건이 임계에 도달하면 error + 텔레그램 경보."""
st = _health[kind]
if count > 0:
st["consecutive_empty"] = 0
st["last_success_at"] = now_kst()
st["alerted"] = False
return
st["consecutive_empty"] += 1
threshold = int(getattr(settings, "NEWS_EMPTY_ALERT_THRESHOLD", _EMPTY_ALERT_DEFAULT))
if st["consecutive_empty"] < threshold or st["alerted"]:
return
st["alerted"] = True
last = st["last_success_at"].isoformat() if st["last_success_at"] else "없음"
msg = (
f"[NEWS] {kind} 뉴스 수집 {st['consecutive_empty']}회 연속 0건 - "
f"악재 차단/축소 필터가 동작하지 않음 (마지막 성공: {last})"
)
logger.error(msg)
try:
from app.services.telegram_notifier import telegram_notifier, _escape_markdown_v2
await telegram_notifier.send_message(_escape_markdown_v2(msg))
except Exception as e:
logger.warning(f"[NEWS] 텔레그램 경보 전송 실패: {e}")
def _to_kst_naive(dt: datetime) -> datetime:
"""aware 값은 KST 로 변환 후 tz 제거, naive 는 KST 로 간주하고 그대로 반환."""
if dt.tzinfo is not None:
return dt.astimezone(KST).replace(tzinfo=None)
return dt
def _parse_iso_to_kst(value: str) -> datetime:
"""ISO 문자열('...Z' 포함) → KST naive. 실패 시 ValueError."""
return _to_kst_naive(datetime.fromisoformat(value.replace("Z", "+00:00")))
def _parse_naver_json_datetime(raw: Any) -> datetime:
"""네이버 모바일 JSON 의 datetime → KST naive.
'YYYYMMDDHHMM[SS]' 는 KST 로 간주(오프셋 없음), ISO 문자열은 오프셋을 존중해 KST 로 변환.
파싱 실패 시 현재 KST.
"""
dt_raw = str(raw or "").strip()
for fmt in ("%Y%m%d%H%M", "%Y%m%d%H%M%S"):
try:
return datetime.strptime(dt_raw, fmt)
except ValueError:
continue
try:
return _parse_iso_to_kst(dt_raw)
except Exception:
return now_kst()
# ═════════════════════════════════════════════════════════════════════════════
# 2. 감성 분석 함수
@@ -183,12 +264,12 @@ def _compute_aggregate_score(articles: List[Dict[str, Any]]) -> float:
pub_at = art.get("published_at")
if isinstance(pub_at, str):
try:
pub_at = datetime.fromisoformat(pub_at.replace("Z", "+00:00"))
pub_at = _parse_iso_to_kst(pub_at)
except Exception:
pub_at = now
if isinstance(pub_at, datetime):
hours_ago = (now - pub_at.replace(tzinfo=None)).total_seconds() / 3600
hours_ago = (now - _to_kst_naive(pub_at)).total_seconds() / 3600
else:
hours_ago = 24 # 파싱 실패 시 기본 24시간
@@ -212,11 +293,59 @@ def _compute_aggregate_score(articles: List[Dict[str, Any]]) -> float:
# 3. 뉴스 수집 (네이버 금융 — 종목별)
# ═════════════════════════════════════════════════════════════════════════════
async def _collect_stock_news_json(stock_code: str, limit: int) -> List[Dict[str, Any]]:
"""m.stock.naver.com 종목 뉴스 JSON API (1차 소스). 실패/0건이면 빈 리스트.
응답 스키마는 외부 호출 금지로 실측하지 못했다(추정): 그룹 리스트
[{"items":[{"officeId","articleId","officeName","datetime":"YYYYMMDDHHMM","title"}]}]
또는 items 리스트 자체. 두 형태 모두 방어적으로 파싱한다.
"""
url = f"https://m.stock.naver.com/api/news/stock/{stock_code}?pageSize={limit}&page=1"
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(url, headers=_HEADERS)
if resp.status_code != 200:
logger.warning(f"[NEWS] 네이버 모바일 종목뉴스 HTTP {resp.status_code}: {stock_code}")
return []
data = resp.json()
groups = data if isinstance(data, list) else (data.get("items") or data.get("newsList") or [])
raw_items: List[Dict[str, Any]] = []
for g in groups:
if isinstance(g, dict) and isinstance(g.get("items"), list):
raw_items.extend(i for i in g["items"] if isinstance(i, dict))
elif isinstance(g, dict):
raw_items.append(g)
articles: List[Dict[str, Any]] = []
for it in raw_items:
title = (it.get("title") or "").strip()
if not title:
continue
office, art = it.get("officeId"), it.get("articleId")
link = f"https://n.news.naver.com/mnews/article/{office}/{art}" if office and art else ""
published_at = _parse_naver_json_datetime(it.get("datetime") or it.get("dt"))
score, label = analyze_sentiment_keyword(title)
articles.append({
"title": title,
"source": it.get("officeName") or "네이버금융",
"url": link,
"stock_codes": [stock_code],
"sentiment": label,
"sentiment_score": score,
"published_at": published_at,
})
if len(articles) >= limit:
break
return articles
async def collect_stock_news(
stock_code: str,
limit: int = 0,
) -> List[Dict[str, Any]]:
"""네이버 금융에서 종목별 뉴스를 크롤링하고 감성 분석을 수행한다.
"""종목별 뉴스를 수집하고 감성 분석을 수행한다.
1차: m.stock.naver.com JSON API, 2차(폴백): finance.naver.com HTML 크롤링.
Args:
stock_code: 종목 코드 (예: "005930")
@@ -228,11 +357,25 @@ async def collect_stock_news(
if limit <= 0:
limit = settings.NEWS_MAX_ARTICLES
try:
articles = await _collect_stock_news_json(stock_code, limit)
if articles:
return articles
except httpx.TimeoutException:
logger.warning(f"[NEWS] 네이버 모바일 종목뉴스 타임아웃: {stock_code}")
except Exception as e:
logger.warning(f"[NEWS] 네이버 모바일 종목뉴스 오류 ({stock_code}): {e}")
return await _collect_stock_news_html(stock_code, limit)
async def _collect_stock_news_html(stock_code: str, limit: int) -> List[Dict[str, Any]]:
"""finance.naver.com HTML 크롤링 (폴백)."""
url = f"https://finance.naver.com/item/news_news.naver?code={stock_code}&page=1"
articles: List[Dict[str, Any]] = []
try:
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(url, headers=_HEADERS)
if resp.status_code != 200:
@@ -254,10 +397,7 @@ async def collect_stock_news(
source_tag = row.select_one(".info")
source = source_tag.get_text(strip=True) if source_tag else "네이버금융"
# 날짜 파싱 (YYYY.MM.DD HH:MM 형식)
published_at = _parse_naver_date(date_str)
# 감성 분석 (1단계: 키워드)
score, label = analyze_sentiment_keyword(title)
articles.append({
@@ -285,8 +425,91 @@ async def collect_stock_news(
# 4. 뉴스 수집 (네이버 시장 뉴스 — 코스피/코스닥)
# ═════════════════════════════════════════════════════════════════════════════
# 시장 뉴스 JSON 후보 엔드포인트 (실측 불가 - 추정). 순서대로 시도, 첫 비어있지 않은 결과 사용.
_MARKET_NEWS_JSON_URLS = (
"https://m.stock.naver.com/api/news/mainnews?pageSize={limit}&page=1",
"https://m.stock.naver.com/api/news/category/domestic?pageSize={limit}&page=1",
)
def _extract_news_items(data: Any) -> List[Dict[str, Any]]:
"""다양한 JSON 형태(list / {items|newsList|news} / 그룹 중첩)에서 기사 dict 목록 추출."""
if isinstance(data, dict):
groups = data.get("items") or data.get("newsList") or data.get("news") or []
else:
groups = data
out: List[Dict[str, Any]] = []
if not isinstance(groups, list):
return out
for g in groups:
if isinstance(g, dict) and isinstance(g.get("items"), list):
out.extend(i for i in g["items"] if isinstance(i, dict))
elif isinstance(g, dict):
out.append(g)
return out
async def _collect_market_news_json(limit: int) -> List[Dict[str, Any]]:
"""m.stock.naver.com 시장(주요) 뉴스 JSON (1차 소스). 실패/0건이면 빈 리스트.
스키마·경로는 외부 호출 금지로 실측하지 못했다(추정). 후보 URL 을 순차 시도한다.
"""
articles: List[Dict[str, Any]] = []
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
for tmpl in _MARKET_NEWS_JSON_URLS:
url = tmpl.format(limit=limit)
try:
resp = await client.get(url, headers=_HEADERS)
if resp.status_code != 200:
logger.warning(f"[NEWS] 시장뉴스 JSON HTTP {resp.status_code}: {url}")
continue
raw_items = _extract_news_items(resp.json())
except Exception as e:
logger.warning(f"[NEWS] 시장뉴스 JSON 오류 ({url}): {e}")
continue
for it in raw_items:
title = (it.get("title") or it.get("titleFull") or "").strip()
if not title:
continue
office, art = it.get("officeId"), it.get("articleId")
link = f"https://n.news.naver.com/mnews/article/{office}/{art}" if office and art else ""
score, label = analyze_sentiment_keyword(title)
articles.append({
"title": title,
"source": it.get("officeName") or "네이버금융",
"url": link,
"stock_codes": [],
"sentiment": label,
"sentiment_score": score,
"published_at": _parse_naver_json_datetime(it.get("datetime") or it.get("dt")),
})
if len(articles) >= limit:
return articles
if articles:
return articles
return articles
async def collect_market_news(limit: int = 0) -> List[Dict[str, Any]]:
"""네이버 금융 시장 뉴스(코스피/코스닥) 크롤링 + 감성 분석.
"""시장 뉴스 수집 + 감성 분석.
1차: m.stock.naver.com JSON, 2차(폴백): finance.naver.com HTML 크롤링.
"""
if limit <= 0:
limit = settings.NEWS_MAX_ARTICLES
try:
articles = await _collect_market_news_json(limit)
if articles:
return articles
except Exception as e:
logger.warning(f"[NEWS] 시장뉴스 JSON 경로 실패, HTML 폴백: {e}")
return await _collect_market_news_html(limit)
async def _collect_market_news_html(limit: int) -> List[Dict[str, Any]]:
"""네이버 금융 시장 뉴스(코스피/코스닥) HTML 크롤링 (폴백).
Returns:
[{"title", "source", "url", "published_at", "sentiment", "sentiment_score"}, ...]
@@ -304,7 +527,7 @@ async def collect_market_news(limit: int = 0) -> List[Dict[str, Any]]:
if len(articles) >= limit:
break
try:
async with httpx.AsyncClient(timeout=15.0) as client:
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True) as client:
resp = await client.get(page_url, headers=_HEADERS)
if resp.status_code != 200:
@@ -393,7 +616,7 @@ async def collect_newsapi_news(
pub_str = item.get("publishedAt", "")
try:
published_at = datetime.fromisoformat(pub_str.replace("Z", "+00:00"))
published_at = _parse_iso_to_kst(pub_str)
except Exception:
published_at = now_kst()
@@ -438,9 +661,18 @@ async def get_stock_news_score(stock_code: str) -> float:
if cached is not None:
return float(cached)
# 1-b. 최근 0건 판정(단기 음성 캐시) — 재크롤링 폭주만 막는다. 점수 캐시가 아니다.
if await get_json(f"news_score:stock_empty:{stock_code}") is not None:
return 0.0
# 2. 실시간 크롤링
try:
articles = await collect_stock_news(stock_code)
await _record_collect_result("stock", len(articles))
if not articles:
# '데이터 없음' 은 중립 점수 0.0 으로 캐시하지 않는다(악재 필터 무력화를 가리므로)
await setex_json(f"news_score:stock_empty:{stock_code}", _EMPTY_NEGATIVE_TTL, 1)
return 0.0
score = _compute_aggregate_score(articles)
# 캐시 저장
@@ -470,6 +702,10 @@ async def get_market_news_score() -> float:
if cached is not None:
return float(cached)
# 최근 0건 판정(단기 음성 캐시) — 재크롤링 폭주만 막는다. 점수 캐시가 아니다.
if await get_json("news_score:market_empty") is not None:
return 0.0
try:
# 시장 뉴스 + NewsAPI 병렬 수집
market_task = collect_market_news()
@@ -477,6 +713,10 @@ async def get_market_news_score() -> float:
market_news, api_news = await asyncio.gather(market_task, api_task)
all_articles = market_news + api_news
await _record_collect_result("market", len(all_articles))
if not all_articles:
await setex_json("news_score:market_empty", _EMPTY_NEGATIVE_TTL, 1)
return 0.0 # 데이터 없음 — 중립 점수를 캐시하지 않는다
score = _compute_aggregate_score(all_articles)
await setex_json(cache_key, settings.NEWS_CACHE_TTL, score)