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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user