코드 변경

This commit is contained in:
2026-03-10 09:49:03 +09:00
parent a15ecaa314
commit 096e087a9a
8 changed files with 930 additions and 290 deletions

Submodule .claude/worktrees/naughty-carson added at 3a08cd8609

View File

@@ -74,6 +74,8 @@ async def _get_preset(preset_key: str) -> Optional[Dict[str, Any]]:
"MEAN_REVERSION_TAKE_PROFIT_PCT": strategy_cfg.get("TAKE_PROFIT_PERCENT", 12.0),
"MAX_TOTAL_POSITIONS": strategy_cfg.get("MAX_HOLDINGS_COUNT", 5),
"MEAN_REVERSION_RSI_THRESHOLD": strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0),
"RSI_OVERBOUGHT_LEVEL": strategy_cfg.get("RSI_OVERBOUGHT_LEVEL", 70.0),
"ATR_STOP_MULTIPLIER": strategy_cfg.get("ATR_STOP_MULTIPLIER", 2.5),
}
return {"name": "현재 설정", "params": params}
return _STRATEGY_PRESETS.get(preset_key)

View File

@@ -268,6 +268,7 @@ class BacktestEngine:
rsi_threshold = params.get("MEAN_REVERSION_RSI_THRESHOLD", 30)
stop_loss = params.get("MEAN_REVERSION_STOP_LOSS_PCT", -6.0) / 100
take_profit = params.get("MEAN_REVERSION_TAKE_PROFIT_PCT", 12.0) / 100
rsi_overbought = params.get("RSI_OVERBOUGHT_LEVEL", 70.0)
current_price = df.loc[current_idx, "close"]
@@ -297,7 +298,7 @@ class BacktestEngine:
"price": current_price,
"reason": f"익절 {pnl_pct*100:.1f}%"
}
elif rsi > 70 and bb_pos > 0.8:
elif rsi > rsi_overbought and bb_pos > 0.8:
return {
"action": "SELL",
"price": current_price,

View File

@@ -534,31 +534,15 @@ async def analyze_stock_with_indicators(stock_code: str, stock_name: str) -> Opt
return await analyze_stock(stock_code, stock_name, criteria)
async def get_all_tradable_stocks() -> List[tuple]:
SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE",
"선물", "스팩", "리츠", "SPAC",
]
"""네이버 파이낸스에서 KOSPI+KOSDAQ 시가총액 순위 종목 조회.
KIS volume-rank API 대신 네이버 파이낸스 크롤링을 사용한다.
실서버 API 키 불필요.
"""
try:
_list_api = _real_market_api or korea_investment_api
await asyncio.sleep(3.0)
kospi_result = await _list_api.get_stock_list("J")
await asyncio.sleep(3.0)
kosdaq_result = await _list_api.get_stock_list("Q")
stocks = []
for result in (kospi_result, kosdaq_result):
if "output" in result:
for item in result["output"]:
stock_code = item.get("mksc_shrn_iscd", "")
stock_name = item.get("hts_kor_isnm", "")
if not stock_code or not stock_name:
continue
if any(kw in stock_name for kw in SKIP_KEYWORDS):
continue
stocks.append((stock_code, stock_name))
from app.services.naver_finance import get_naver_stock_list
stocks = await get_naver_stock_list(max_pages_per_market=10)
return stocks
except Exception as e:
logger.error(f"Error fetching stock list from KIS API: {e}", exc_info=True)
logger.error(f"네이버 파이낸스 종목 조회 실패: {e}", exc_info=True)
return []

View File

@@ -25,7 +25,7 @@ from typing import Optional, Dict, List, Tuple
from dataclasses import dataclass
from enum import Enum
from app.core.config import settings
from app.services.strategy_config_service import strategy_cfg
logger = logging.getLogger(__name__)
@@ -225,7 +225,7 @@ def check_buy_signal(
return None
if regime == MarketRegime.BULL:
rsi_ok = rsi <= settings.MEAN_REVERSION_RSI_THRESHOLD # ← 기본값 30
rsi_ok = rsi <= strategy_cfg.get("MEAN_REVERSION_RSI_THRESHOLD", 30.0)
if rsi_ok:
# [2026-03] RSI 반등 확인: rsi_prev 제공 시 RSI 하락 중이면 진입 금지
@@ -303,8 +303,10 @@ def check_exit_signal(
# ── 1. 손절 (최우선) ──────────────────────────────────────────
# [2026-03] ATR 손절 기준: lowest_since_entry → entry_price
# 이유: 매수 직후 저점이 낮으면 ATR 손절가가 현재가보다 훨씬 아래 → 손절 사실상 무력화
atr_stop = entry_price - 2.5 * atr
pct_stop = entry_price * 0.94 # -6%
atr_stop_mult = strategy_cfg.get("ATR_STOP_MULTIPLIER", 2.5)
stop_loss_pct = strategy_cfg.get("STOP_LOSS_PERCENT", -6.0) # e.g. -6.0
atr_stop = entry_price - atr_stop_mult * atr
pct_stop = entry_price * (1 + stop_loss_pct / 100)
stop_price = max(atr_stop, pct_stop) # 높은 값 = 타이트한 손절
if close <= stop_price:
@@ -392,13 +394,13 @@ def calculate_position_size(
if rsi is not None:
if rsi <= 20:
multiplier = settings.MEAN_REVERSION_MULTIPLIER_STRONG
multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_STRONG", 1.50)
elif rsi <= 25:
multiplier = settings.MEAN_REVERSION_MULTIPLIER_NORMAL
multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_NORMAL", 1.25)
else:
multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK
multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK", 1.00)
else:
multiplier = settings.MEAN_REVERSION_MULTIPLIER_WEAK
multiplier = strategy_cfg.get("MEAN_REVERSION_MULTIPLIER_WEAK", 1.00)
if adx is not None and adx < 15:
multiplier = min(multiplier + 0.25, 2.0)

View File

@@ -0,0 +1,121 @@
"""네이버 파이낸스 종목 목록 크롤러
KIS volume-rank API 대신 네이버 시가총액 순위 페이지에서
KOSPI/KOSDAQ 종목 목록을 가져온다.
사용처:
- batch_daily_analysis.get_all_tradable_stocks() DB 폴백 이전 1차 시도
- market_analyzer.get_all_tradable_stocks() 대체
URL: https://finance.naver.com/sise/sise_market_sum.naver?sosok=0&page=N
sosok=0 : KOSPI
sosok=1 : KOSDAQ
"""
import asyncio
import logging
from typing import List, Tuple
import httpx
from bs4 import BeautifulSoup
logger = logging.getLogger(__name__)
_HEADERS = {"User-Agent": "Mozilla/5.0 (compatible; autostock-batch/1.0)"}
_BASE_URL = "https://finance.naver.com/sise/sise_market_sum.naver"
SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE",
"선물", "스팩", "리츠", "SPAC",
]
_MIN_PRICE = 5_000 # 5,000원 미만 제외
def _parse_page(html: bytes) -> List[Tuple[str, str, int]]:
"""HTML 파싱 → [(종목코드, 종목명, 현재가), ...]"""
soup = BeautifulSoup(html, "html.parser", from_encoding="euc-kr")
table = soup.find("table", {"class": "type_2"})
if not table:
return []
result = []
for row in table.find_all("tr"):
a = row.find("a", class_="tltle")
if not a:
continue
href = a.get("href", "")
if "code=" not in href:
continue
code = href.split("code=")[1]
name = a.text.strip()
# 현재가
tds = row.find_all("td", class_="number")
try:
price = int(tds[0].text.strip().replace(",", "")) if tds else 0
except (ValueError, IndexError):
price = 0
result.append((code, name, price))
return result
async def _fetch_page(client: httpx.AsyncClient, sosok: int, page: int) -> List[Tuple[str, str, int]]:
try:
resp = await client.get(
_BASE_URL,
params={"sosok": sosok, "page": page},
headers=_HEADERS,
timeout=10,
)
resp.raise_for_status()
return _parse_page(resp.content)
except Exception as exc:
logger.warning("네이버 파이낸스 조회 실패 sosok=%d page=%d: %s", sosok, page, exc)
return []
async def get_naver_stock_list(
max_pages_per_market: int = 10,
min_price: int = _MIN_PRICE,
) -> List[Tuple[str, str]]:
"""KOSPI + KOSDAQ 시가총액 순위 종목 조회.
Args:
max_pages_per_market: 시장별 최대 페이지 수 (페이지당 ~50종목)
min_price: 최소 현재가 필터 (기본 5,000원)
Returns:
[(종목코드, 종목명), ...] — ETF/레버리지/스팩 제외, 가격 필터 적용
"""
stocks: List[Tuple[str, str]] = []
seen_codes: set = set()
async with httpx.AsyncClient() as client:
for sosok, market_name in ((0, "KOSPI"), (1, "KOSDAQ")):
market_stocks = []
for page in range(1, max_pages_per_market + 1):
rows = await _fetch_page(client, sosok, page)
if not rows:
break # 마지막 페이지
for code, name, price in rows:
if code in seen_codes:
continue
if price < min_price:
continue
name_upper = name.upper()
if any(kw.upper() in name_upper for kw in SKIP_KEYWORDS):
continue
seen_codes.add(code)
market_stocks.append((code, name))
await asyncio.sleep(0.3) # 서버 부하 방지
logger.info("네이버 %s: %d개 종목 수집", market_name, len(market_stocks))
stocks.extend(market_stocks)
logger.info("네이버 파이낸스 총 %d개 종목 수집", len(stocks))
return stocks

View File

@@ -31,7 +31,7 @@ sys.path.insert(0, str(Path(__file__).parent))
from app.core.config import settings
from app.core.database import AsyncSessionLocal # 🔧 공통 DB 모듈 사용
from sqlalchemy import select as sa_select
from sqlalchemy import select as sa_select, func as sa_func
from app.models.models import StockDailyPrice
from app.services.analysis_db_service import bulk_upsert_analysis, passes_price_filter, run_db_scanner # 🔧 단일화
from app.services.market_analyzer import analyze_stock_for_batch_db, StockRecommendation
@@ -118,14 +118,64 @@ async def detect_and_log_market_regime() -> str:
# 2. 종목 목록 조회
# -------------------------------------------------------------------
async def _get_stocks_from_db(limit: int = 500) -> list[tuple[str, str]]:
"""stock_daily_prices DB에서 종목 목록 조회 (KIS API 실패 시 폴백).
최근 거래일 기준으로 데이터가 있는 종목을 조회한다.
ETF / 레버리지 / 인버스 / 스팩 등 특수종목은 이름 기반으로 제외.
"""
SKIP_KEYWORDS = [
"ETF", "레버리지", "인버스", "KODEX", "TIGER", "ARIRANG",
"KBSTAR", "HANARO", "SOL", "ACE", "KOSEF", "KINDEX", "RISE",
"선물", "스팩", "리츠", "SPAC",
]
async with AsyncSessionLocal() as db:
# 최근 거래일 조회
latest_date_result = await db.execute(
sa_select(sa_func.max(StockDailyPrice.trade_date))
)
latest_date = latest_date_result.scalar()
if not latest_date:
logger.warning("⚠️ stock_daily_prices 테이블이 비어 있음")
return []
logger.info("📂 DB 폴백: %s 기준 종목 조회", latest_date)
# 최근 거래일 종목 전체 조회 (종목코드, 종목명)
result = await db.execute(
sa_select(StockDailyPrice.stock_code, StockDailyPrice.stock_name)
.where(StockDailyPrice.trade_date == latest_date)
.where(StockDailyPrice.close_price > 5000)
.order_by(StockDailyPrice.volume.desc())
.limit(limit * 3) # 필터 후 충분한 수가 남도록 여유분 조회
)
rows = result.all()
stocks = []
for code, name in rows:
if not name:
continue
name_upper = name.upper()
if any(kw.upper() in name_upper for kw in SKIP_KEYWORDS):
continue
stocks.append((code, name))
if len(stocks) >= limit:
break
logger.info("📂 DB 폴백 결과: %d개 종목 (%s 기준)", len(stocks), latest_date)
return stocks
async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]:
"""KIS 거래량 순위 API로 거래 가능 종목 조회 (KOSPI+KOSDAQ 각 top 40).
KIS FHPST01710000: 거래량 순위 — 5,000원 이상 활성 종목.
ETF / 레버리지 / 스팩 등 특수종목 자동 제외.
Raises:
RuntimeError: 조회 결과가 MIN_STOCK_COUNT(50) 미만인 경우 배치 중단
KIS API 결과가 MIN_STOCK_COUNT(50) 미만이면 stock_daily_prices DB 폴백을 사용한다.
DB 폴백도 실패하면 RuntimeError를 발생시켜 배치를 중단한다.
"""
from app.services.market_analyzer import get_all_tradable_stocks as _kis_get_stocks
@@ -133,10 +183,21 @@ async def get_all_tradable_stocks(limit: int = 500) -> list[tuple[str, str]]:
stocks = await _kis_get_stocks()
if len(stocks) < MIN_STOCK_COUNT:
raise RuntimeError(
f"종목 조회 실패: KIS API 결과 부족 ({len(stocks)}개 < {MIN_STOCK_COUNT}개). "
f"KIS API 설정을 확인하세요."
logger.warning(
"⚠️ KIS API 결과 부족 (%d개 < %d개) — DB 폴백 사용",
len(stocks), MIN_STOCK_COUNT,
)
db_stocks = await _get_stocks_from_db(limit)
if len(db_stocks) < MIN_STOCK_COUNT:
raise RuntimeError(
f"종목 조회 실패: KIS API({len(stocks)}개) 및 DB 폴백({len(db_stocks)}개) 모두 "
f"최소 기준({MIN_STOCK_COUNT}개) 미달. KIS API 설정 및 DB 데이터를 확인하세요."
)
result = db_stocks[:limit]
logger.info("✅ DB 폴백 선정 %d개", len(result))
return result
result = stocks[:limit]
logger.info("✅ 선정 %d개", len(result))

File diff suppressed because it is too large Load Diff