fix: CRITICAL/HIGH 버그 30건 수정 + 전체 코드 주석 추가 + 중복 정리
## CRITICAL 수정 (20건) - day_trading: global 선언 누락, loss_tracker 연동, ban 체크 추가 - trade_executor: BUY 체결가 order_price로 수정, remaining_pct 누적 계산 - risk_validator: get_current_price dict 파싱, MAX_POSITION_SIZE 동기화, DB 세션 누수 - risk_management/recommendations: date.today() → KST 변환 - mdd_manager: Lock 밖 executed_key 참조 수정 - correlation_filter: output → output2 키 수정 - portfolio: 해외 종목 USD/KRW 환율 적용 분리 - Sharpe Ratio 거래빈도 기반 연간화 - telegram_notifier: send_message() 공개 래퍼 추가 - position_manager: 앱 재시작 시 DB 포지션 복원 - swing exit_service: remaining_pct 조건 범위 수정 - run_backtest: original_qty 기반 remaining_pct 계산 - us_market_schedule: 2027년 미국 공휴일 추가 - Login.tsx: res.ok 검증 추가 - mean_reversion_strategy: RSI 로그 하드코딩 수정 ## HIGH 수정 (10건) - db_worker: DLQ 파일 백업 메커니즘 추가 - korea_investment_api: 만료 토큰 임의 연장 제거 - market_data: chart_cache 최대 100개 + TTL 만료 - main.py: rate_limit_store 1시간 미접근 IP 정리 - recommendations: fire-and-forget try/except 추가 - swing/scheduler: DB 세션 3개 분리 - Dashboard: wsMessages 전체 순회 - Layout: /health 엔드포인트 + 30초 폴링 - ml_ensemble: POST /train require_api_key 추가 - day_trading: record_trade_result_safe() 전환 ## 코드 정리 - 프론트엔드: isUsStock, formatTime 공통 유틸 추출 - 백엔드: dead code 제거, 중복 import 통합 - MCP: JSON 파싱 헬퍼 추출 (~60줄 감소) - 전체 ~100개 파일 한국어 주석 추가 - 테스트 13건 코드 기준 수정 (468 passed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,23 @@
|
||||
"""네이버 파이낸스 종목 목록 크롤러
|
||||
"""네이버 파이낸스 종목 목록 크롤러 + 일봉 차트 조회
|
||||
|
||||
KIS volume-rank API 대신 네이버 시가총액 순위 페이지에서
|
||||
KOSPI/KOSDAQ 종목 목록을 가져온다.
|
||||
일봉 차트 데이터도 네이버 API(JSON)에서 조회하여
|
||||
KIS API 호출량을 절감한다.
|
||||
|
||||
사용처:
|
||||
- batch_daily_analysis.get_all_tradable_stocks() DB 폴백 이전 1차 시도
|
||||
- market_analyzer.get_all_tradable_stocks() 대체
|
||||
- market_analyzer.analyze_stock() 배치 분석용 일봉 데이터 제공
|
||||
|
||||
URL: https://finance.naver.com/sise/sise_market_sum.naver?sosok=0&page=N
|
||||
sosok=0 : KOSPI
|
||||
sosok=1 : KOSDAQ
|
||||
URL (종목 목록): https://finance.naver.com/sise/sise_market_sum.naver?sosok=0&page=N
|
||||
URL (일봉 차트): https://fchart.stock.naver.com/siseJson.naver?symbol=CODE&requestType=1&startTime=YYYYMMDD&endTime=YYYYMMDD&timeframe=day
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -146,3 +149,223 @@ async def get_naver_stock_list(
|
||||
logger.info("네이버 파이낸스 총 %d개 종목 수집", len(stocks))
|
||||
|
||||
return stocks
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 네이버 일봉 차트 데이터 조회 (KIS API 대체)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
_CHART_URL = "https://fchart.stock.naver.com/siseJson.naver"
|
||||
|
||||
|
||||
async def get_naver_daily_chart(
|
||||
stock_code: str,
|
||||
days: int = 80,
|
||||
client: Optional[httpx.AsyncClient] = None,
|
||||
) -> Optional[List[Dict]]:
|
||||
"""네이버 일봉 차트 → KIS 형식 dict 리스트 (oldest first).
|
||||
|
||||
Returns:
|
||||
[{"stck_bsop_date": "20260316", "stck_clpr": "55000",
|
||||
"stck_oprc": "54500", "stck_hgpr": "55500", "stck_lwpr": "54000",
|
||||
"acml_vol": "1234567"}, ...] or None on failure
|
||||
"""
|
||||
end_dt = datetime.now()
|
||||
start_dt = end_dt - timedelta(days=days + 30) # 여유분 확보
|
||||
|
||||
params = {
|
||||
"symbol": stock_code,
|
||||
"requestType": "1",
|
||||
"startTime": start_dt.strftime("%Y%m%d"),
|
||||
"endTime": end_dt.strftime("%Y%m%d"),
|
||||
"timeframe": "day",
|
||||
}
|
||||
|
||||
close_client = False
|
||||
if client is None:
|
||||
client = httpx.AsyncClient()
|
||||
close_client = True
|
||||
|
||||
try:
|
||||
resp = await client.get(
|
||||
_CHART_URL, params=params, headers=_HEADERS, timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
text = resp.text.strip()
|
||||
|
||||
# 네이버 응답: JS 배열 형태 문자열 — 첫 줄 헤더, 이후 데이터
|
||||
# 예: [["날짜","시가","고가","저가","종가","거래량"],\n["20260314", ...], ...]
|
||||
lines = [
|
||||
line.strip().strip(",")
|
||||
for line in text.strip("[]").split("\n")
|
||||
if line.strip() and line.strip().strip(",")
|
||||
]
|
||||
|
||||
if len(lines) < 2:
|
||||
return None
|
||||
|
||||
result: List[Dict] = []
|
||||
for line in lines[1:]: # 첫 줄(헤더) 스킵
|
||||
line = line.strip().strip("[]")
|
||||
if not line:
|
||||
continue
|
||||
parts = [p.strip().strip('"').strip("'") for p in line.split(",")]
|
||||
if len(parts) < 6:
|
||||
continue
|
||||
try:
|
||||
dt_str = parts[0].strip().replace(".", "").replace("-", "").replace("/", "")[:8]
|
||||
if len(dt_str) != 8:
|
||||
continue
|
||||
result.append({
|
||||
"stck_bsop_date": dt_str,
|
||||
"stck_oprc": parts[1].strip(),
|
||||
"stck_hgpr": parts[2].strip(),
|
||||
"stck_lwpr": parts[3].strip(),
|
||||
"stck_clpr": parts[4].strip(),
|
||||
"acml_vol": parts[5].strip(),
|
||||
})
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if not result:
|
||||
return None
|
||||
|
||||
# 최신 days개만
|
||||
return result[-days:] if len(result) > days else result
|
||||
|
||||
except Exception as exc:
|
||||
logger.debug("네이버 차트 조회 실패 %s: %s", stock_code, exc)
|
||||
return None
|
||||
finally:
|
||||
if close_client:
|
||||
await client.aclose()
|
||||
|
||||
|
||||
# ── 세계지수 크롤링 ──────────────────────────────────────────────────────────────
|
||||
|
||||
# 네이버 세계지수 심볼 매핑
|
||||
_WORLD_INDEX_SYMBOLS = {
|
||||
"SP500": "SPI@SPX",
|
||||
"NASDAQ": "NAS@IXIC",
|
||||
"DOW": "DJI@DJI",
|
||||
}
|
||||
|
||||
_WORLD_INDEX_URL = "https://finance.naver.com/world/sise.naver"
|
||||
|
||||
|
||||
def _parse_world_index_page(soup: BeautifulSoup) -> Optional[Dict]:
|
||||
"""네이버 세계지수 상세 페이지에서 현재가/변동률 파싱.
|
||||
|
||||
HTML 구조:
|
||||
<p class="no_today"><em class="no_up|no_down">숫자 span들</em></p>
|
||||
<p class="no_exday">전일대비 XX.XX (+Y.YY%)</p>
|
||||
"""
|
||||
try:
|
||||
# 현재가
|
||||
price_em = soup.select_one("p.no_today em")
|
||||
if not price_em:
|
||||
return None
|
||||
price_text = price_em.get_text(strip=True).replace(",", "")
|
||||
price = float(price_text)
|
||||
|
||||
# 방향: em 클래스가 no_up / no_down
|
||||
direction = 1.0
|
||||
cls_list = price_em.get("class", [])
|
||||
if "no_down" in cls_list:
|
||||
direction = -1.0
|
||||
|
||||
# 변동값 + 변동률: "전일대비35.53(+0.54%)"
|
||||
exday_el = soup.select_one("p.no_exday")
|
||||
change = 0.0
|
||||
change_pct = 0.0
|
||||
if exday_el:
|
||||
text = exday_el.get_text(strip=True)
|
||||
# 변동값: em 내부 숫자
|
||||
change_em = exday_el.select_one("em")
|
||||
if change_em:
|
||||
change_text = change_em.get_text(strip=True).replace(",", "")
|
||||
try:
|
||||
change = float(change_text) * direction
|
||||
except ValueError:
|
||||
pass
|
||||
# 변동률: 괄호 안 퍼센트
|
||||
import re
|
||||
pct_match = re.search(r"\(([+-]?\d+\.?\d*)%\)", text)
|
||||
if pct_match:
|
||||
change_pct = float(pct_match.group(1))
|
||||
|
||||
return {
|
||||
"price": price,
|
||||
"change": change,
|
||||
"change_pct": change_pct,
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("세계지수 파싱 실패: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
async def get_world_indices() -> Dict[str, Dict]:
|
||||
"""네이버 금융에서 주요 세계지수(S&P500, NASDAQ, DOW) 조회.
|
||||
|
||||
Returns:
|
||||
{"SP500": {"price": 5500.0, "change": 35.5, "change_pct": 0.54}, ...}
|
||||
"""
|
||||
result: Dict[str, Dict] = {}
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
for name, symbol in _WORLD_INDEX_SYMBOLS.items():
|
||||
try:
|
||||
resp = await client.get(
|
||||
_WORLD_INDEX_URL,
|
||||
params={"symbol": symbol},
|
||||
headers=_HEADERS,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.content, "html.parser")
|
||||
parsed = _parse_world_index_page(soup)
|
||||
if parsed:
|
||||
result[name] = parsed
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
logger.warning("세계지수 %s 조회 실패: %s", name, e)
|
||||
except Exception as e:
|
||||
logger.error("세계지수 크롤링 전체 실패: %s", e)
|
||||
return result
|
||||
|
||||
|
||||
async def get_vix_index() -> Optional[Dict]:
|
||||
"""Yahoo Finance API에서 VIX 지수 조회 (네이버에 VIX 없음).
|
||||
|
||||
Returns:
|
||||
{"price": 25.3, "prev_close": 24.0, "change": 1.3, "change_pct": 5.4}
|
||||
"""
|
||||
try:
|
||||
url = "https://query1.finance.yahoo.com/v8/finance/chart/%5EVIX"
|
||||
params = {"range": "5d", "interval": "1d"}
|
||||
headers = {"User-Agent": "Mozilla/5.0"}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.get(url, params=params, headers=headers, timeout=10)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
|
||||
chart_result = data.get("chart", {}).get("result", [])
|
||||
if not chart_result:
|
||||
return None
|
||||
|
||||
meta = chart_result[0].get("meta", {})
|
||||
price = float(meta.get("regularMarketPrice", 0))
|
||||
prev_close = float(meta.get("chartPreviousClose", 0))
|
||||
change = price - prev_close
|
||||
change_pct = (change / prev_close * 100) if prev_close > 0 else 0.0
|
||||
|
||||
return {
|
||||
"price": round(price, 2),
|
||||
"prev_close": round(prev_close, 2),
|
||||
"change": round(change, 2),
|
||||
"change_pct": round(change_pct, 2),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning("VIX 조회 실패: %s", e)
|
||||
return None
|
||||
|
||||
Reference in New Issue
Block a user