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:
@@ -42,13 +42,35 @@ SKIP_KEYWORDS = [
|
||||
"선물", "스팩", "리츠", "SPAC",
|
||||
]
|
||||
|
||||
# 벤치마크/국면 판정용 ETF (SKIP_KEYWORDS 로 제외되지만 --include-benchmarks 시 허용).
|
||||
# 기본 OFF: 켜면 이 종목들이 DB 스캐너·배치 유니버스에 들어간다(레버리지/인버스 포함).
|
||||
BENCHMARK_ETF_CODES = frozenset({"069500", "122630", "252670", "114800"})
|
||||
_include_benchmarks = False
|
||||
# 실전 유니버스 ETF(주식시세 API 에 없음) 중 ETF API 로 추가 수집할 코드. 기본 비어 있음(OFF).
|
||||
_extra_etf_codes: frozenset = frozenset()
|
||||
_etf_from_universe = False # True 면 collect_all_v2 시작 시 stock_analysis 기준 후보를 합친다
|
||||
|
||||
# 증분 모드에서 종목수 부족(한쪽 시장 누락) 날짜를 재검사할 달력일 범위와 판정 비율
|
||||
INCOMPLETE_LOOKBACK_DAYS = 90
|
||||
INCOMPLETE_LOCAL_WINDOW_DAYS = 10 # 지역 기준선: 전후 달력일 (상장 종목수 증가 추세 보정)
|
||||
INCOMPLETE_RATIO = 0.85
|
||||
|
||||
# 공공데이터포털 API 설정
|
||||
DATAGOKR_BASE_URL = (
|
||||
"https://apis.data.go.kr/1160100/service"
|
||||
"/GetStockSecuritiesInfoService/getStockPriceInfo"
|
||||
)
|
||||
# 벤치마크 ETF 는 주식시세 API 에 없을 가능성이 높아 증권상품시세(ETF) 서비스에서 별도 수집한다.
|
||||
# 경로는 공개 문서 기억 기반의 추정이며 실호출로 확인되지 않았다 — 응답이 비면 warning 을 남긴다.
|
||||
DATAGOKR_ETF_URL = (
|
||||
"https://apis.data.go.kr/1160100/service"
|
||||
"/GetSecuritiesProductInfoService/getETFPriceInfo"
|
||||
)
|
||||
ROWS_PER_PAGE = 1000 # API 최대 허용값
|
||||
|
||||
# 수정주가 미반영(분할·병합·권리락) 이벤트 감지: |실제 종가비 - 1 - fltRt| 가 이 값을 넘으면 이벤트
|
||||
PRICE_EVENT_TOL = 0.25
|
||||
|
||||
# 날짜별 요청 간격 (초)
|
||||
REQUEST_INTERVAL = 0.2
|
||||
|
||||
@@ -76,11 +98,18 @@ def generate_trading_dates(start: str, end: str) -> list[str]:
|
||||
# ─── 공공데이터포털 API 수집 ───────────────────────────────────────
|
||||
|
||||
async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame:
|
||||
"""기존 시그니처 유지 래퍼. 실패/무데이터 구분이 필요하면 fetch_daily_prices_v2."""
|
||||
df, _status = await fetch_daily_prices_v2(market, bas_dd)
|
||||
return df
|
||||
|
||||
|
||||
async def fetch_daily_prices_v2(market: str, bas_dd: str) -> tuple[pd.DataFrame, str]:
|
||||
"""공공데이터포털 금융위원회 주식시세 API로 전종목 OHLCV 조회.
|
||||
|
||||
market: 'kospi' | 'kosdaq'
|
||||
bas_dd: 'YYYYMMDD'
|
||||
반환: DataFrame (stock_code, stock_name, date, open, high, low, close, volume, change_rate)
|
||||
반환: (DataFrame, status) status = "ok"(데이터 있음) | "empty"(응답 정상·항목 없음) | "error"(요청 실패)
|
||||
DataFrame 컬럼: (stock_code, stock_name, date, open, high, low, close, volume, change_rate)
|
||||
"""
|
||||
mrkt_cls = "KOSPI" if market == "kospi" else "KOSDAQ"
|
||||
api_key = settings.DATAGOKR_API_KEY
|
||||
@@ -93,6 +122,7 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame:
|
||||
trade_date = datetime.strptime(bas_dd, "%Y%m%d").date()
|
||||
rows: list[dict] = []
|
||||
page = 1
|
||||
status = "empty"
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
while True:
|
||||
@@ -110,6 +140,7 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame:
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.error("API 요청 실패 [%s %s page%d]: %s", market, bas_dd, page, exc)
|
||||
status = "error"
|
||||
break
|
||||
|
||||
body = data.get("response", {}).get("body", {})
|
||||
@@ -126,12 +157,12 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame:
|
||||
|
||||
for item in items:
|
||||
name = item.get("itmsNm", "")
|
||||
if any(kw in name for kw in SKIP_KEYWORDS):
|
||||
code_raw = str(item.get("srtnCd", "")).strip().zfill(6)
|
||||
keep_benchmark = _include_benchmarks and code_raw in BENCHMARK_ETF_CODES
|
||||
if not keep_benchmark and any(kw in name for kw in SKIP_KEYWORDS):
|
||||
continue
|
||||
|
||||
volume = int(item.get("trqu", 0) or 0)
|
||||
if volume == 0:
|
||||
continue # 거래 정지
|
||||
|
||||
close = int(item.get("clpr", 0) or 0)
|
||||
if close == 0:
|
||||
@@ -145,14 +176,23 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame:
|
||||
# srtnCd: 단축코드(6자리), 앞에 공백 있을 수 있어 strip
|
||||
stock_code = str(item.get("srtnCd", "")).strip().zfill(6)
|
||||
|
||||
o_, h_, l_, c_ = halted_aware_ohlc(
|
||||
int(item.get("mkp", 0) or 0),
|
||||
int(item.get("hipr", 0) or 0),
|
||||
int(item.get("lopr", 0) or 0),
|
||||
close,
|
||||
volume,
|
||||
)
|
||||
if volume == 0:
|
||||
change_rate = 0.0 # 거래정지 봉: 가격 변동 없음(종가 = 직전 기준가)
|
||||
rows.append({
|
||||
"stock_code": stock_code,
|
||||
"stock_name": name,
|
||||
"date": trade_date,
|
||||
"open": int(item.get("mkp", 0) or 0),
|
||||
"high": int(item.get("hipr", 0) or 0),
|
||||
"low": int(item.get("lopr", 0) or 0),
|
||||
"close": close,
|
||||
"open": o_,
|
||||
"high": h_,
|
||||
"low": l_,
|
||||
"close": c_,
|
||||
"volume": volume,
|
||||
"change_rate": change_rate,
|
||||
"market_cap": int(item.get("mrktTotAmt", 0) or 0) or None,
|
||||
@@ -166,9 +206,177 @@ async def fetch_daily_prices(market: str, bas_dd: str) -> pd.DataFrame:
|
||||
page += 1
|
||||
await asyncio.sleep(REQUEST_INTERVAL)
|
||||
|
||||
if rows:
|
||||
status = "ok" if status != "error" else "error" # 중간 페이지 실패 = 불완전
|
||||
return (pd.DataFrame(rows) if rows else pd.DataFrame()), status
|
||||
|
||||
|
||||
def normalize_ohlc(open_: int, high: int, low: int, close: int) -> tuple[int, int, int, int]:
|
||||
"""시가·고가·저가가 0(단일가 체결 등 미산출)이면 종가로 채우고 high/low 정합을 보장. 순수 함수.
|
||||
|
||||
close 는 호출부에서 >0 보장. 0 OHLC 가 저장되면 백테스트 TR·손절 판정이 왜곡된다.
|
||||
"""
|
||||
o = open_ if open_ > 0 else close
|
||||
h = high if high > 0 else close
|
||||
l = low if low > 0 else close
|
||||
h = max(h, o, l, close)
|
||||
l = min(l, o, h, close)
|
||||
return o, h, l, close
|
||||
|
||||
|
||||
def halted_aware_ohlc(open_: int, high: int, low: int, close: int,
|
||||
volume: int) -> tuple[int, int, int, int]:
|
||||
"""거래량 0(거래정지) 봉은 OHLC 를 종가로 평탄화해 저장한다. 순수 함수.
|
||||
|
||||
정지 봉을 버리면 재개일 봉이 정지 직전 봉에 바로 붙어 MA·RSI·ATR 기간이 짧아지고
|
||||
재개 갭이 1봉 수익률이 된다. volume=0 봉은 소비처의 거래량 하한 게이트에서 진입이 거부된다.
|
||||
"""
|
||||
if volume <= 0:
|
||||
return close, close, close, close
|
||||
return normalize_ohlc(open_, high, low, close)
|
||||
|
||||
|
||||
def is_price_event(close: float, prev_close: float, change_rate: float | None,
|
||||
tol: float | None = None) -> bool:
|
||||
"""원주가 불연속(액면분할·병합·권리락) 판정. 순수 함수.
|
||||
|
||||
fltRt(거래소 산출 등락률, 소수)와 실제 종가비 괴리가 tol 초과면 True.
|
||||
stock_daily_prices 는 수정주가가 아니므로 이 구간의 수익률은 소비처에서 쓰면 안 된다.
|
||||
"""
|
||||
tol = PRICE_EVENT_TOL if tol is None else tol
|
||||
if not prev_close or prev_close <= 0 or not close or close <= 0:
|
||||
return False
|
||||
if change_rate is None:
|
||||
return False
|
||||
return abs((close / prev_close - 1.0) - float(change_rate)) > tol
|
||||
|
||||
|
||||
async def get_prev_closes(session, codes: list[str], before: date) -> dict[str, tuple[date, int]]:
|
||||
"""before 이전 종목별 마지막 종가 (읽기 전용)."""
|
||||
if not codes:
|
||||
return {}
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT DISTINCT ON (stock_code) stock_code, trade_date, close_price "
|
||||
"FROM stock_daily_prices WHERE trade_date < :before AND stock_code = ANY(:codes) "
|
||||
"ORDER BY stock_code, trade_date DESC"
|
||||
),
|
||||
{"before": before, "codes": list(codes)},
|
||||
)
|
||||
return {r[0]: (r[1], int(r[2])) for r in result.all()}
|
||||
|
||||
|
||||
def detect_price_events(df: pd.DataFrame, prev: dict[str, tuple[date, int]],
|
||||
max_gap_days: int = 4) -> list[tuple[str, date, float, float]]:
|
||||
"""수집 프레임과 직전 종가로 이벤트 목록 [(code, date, 실제등락, fltRt)]. 순수 함수."""
|
||||
out: list[tuple[str, date, float, float]] = []
|
||||
for _, row in df.iterrows():
|
||||
p = prev.get(row["stock_code"])
|
||||
if not p:
|
||||
continue
|
||||
d = row["date"].date() if isinstance(row["date"], pd.Timestamp) else row["date"]
|
||||
if (d - p[0]).days > max_gap_days:
|
||||
continue # 누락 구간은 이벤트가 아니라 갭
|
||||
cr = row["change_rate"]
|
||||
cr = float(cr) if pd.notna(cr) else None
|
||||
if is_price_event(float(row["close"]), float(p[1]), cr):
|
||||
out.append((row["stock_code"], d, float(row["close"]) / p[1] - 1.0, cr))
|
||||
return out
|
||||
|
||||
|
||||
async def fetch_benchmark_etf_prices(bas_dd: str) -> pd.DataFrame:
|
||||
"""벤치마크 ETF(BENCHMARK_ETF_CODES) 일봉 — 증권상품시세 API (경로 추정, 미검증)."""
|
||||
return await fetch_etf_prices_v2(bas_dd, BENCHMARK_ETF_CODES)
|
||||
|
||||
|
||||
async def fetch_etf_prices_v2(bas_dd: str, codes) -> pd.DataFrame:
|
||||
"""지정한 ETF/ETN 코드(codes)의 일봉 — 증권상품시세 API (경로 추정, 미검증).
|
||||
|
||||
전체 ETF 를 저장하면 DB 스캐너 유니버스가 오염되므로 반드시 codes 로 한정한다.
|
||||
"""
|
||||
wanted = {str(c).strip().zfill(6) for c in (codes or ())}
|
||||
if not wanted:
|
||||
return pd.DataFrame()
|
||||
api_key = settings.DATAGOKR_API_KEY
|
||||
if not api_key:
|
||||
raise RuntimeError("DATAGOKR_API_KEY 가 설정되지 않았습니다.")
|
||||
trade_date = datetime.strptime(bas_dd, "%Y%m%d").date()
|
||||
rows: list[dict] = []
|
||||
page = 1
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
while True:
|
||||
params = {
|
||||
"serviceKey": api_key, "numOfRows": str(ROWS_PER_PAGE),
|
||||
"pageNo": str(page), "resultType": "json", "basDt": bas_dd,
|
||||
}
|
||||
try:
|
||||
resp = await client.get(DATAGOKR_ETF_URL, params=params)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc:
|
||||
logger.warning("ETF API 요청 실패 [%s page%d]: %s", bas_dd, page, exc)
|
||||
break
|
||||
body = data.get("response", {}).get("body", {})
|
||||
total_count = int(body.get("totalCount", 0) or 0)
|
||||
items_wrap = body.get("items", {})
|
||||
if not items_wrap:
|
||||
break
|
||||
items = items_wrap.get("item", [])
|
||||
if not isinstance(items, list):
|
||||
items = [items]
|
||||
for item in items:
|
||||
code = str(item.get("srtnCd", "")).strip().zfill(6)
|
||||
if code not in wanted:
|
||||
continue
|
||||
volume = int(float(item.get("trqu", 0) or 0))
|
||||
close = int(float(item.get("clpr", 0) or 0))
|
||||
if close == 0:
|
||||
continue
|
||||
if volume == 0:
|
||||
cr = 0.0
|
||||
else:
|
||||
try:
|
||||
cr = float(item.get("fltRt", 0) or 0) / 100.0
|
||||
except (TypeError, ValueError):
|
||||
cr = 0.0
|
||||
o_, h_, l_, c_ = halted_aware_ohlc(
|
||||
int(float(item.get("mkp", 0) or 0)),
|
||||
int(float(item.get("hipr", 0) or 0)),
|
||||
int(float(item.get("lopr", 0) or 0)),
|
||||
close,
|
||||
volume,
|
||||
)
|
||||
rows.append({
|
||||
"stock_code": code, "stock_name": item.get("itmsNm", ""),
|
||||
"date": trade_date,
|
||||
"open": o_, "high": h_, "low": l_,
|
||||
"close": c_, "volume": volume, "change_rate": cr,
|
||||
"market_cap": int(float(item.get("mrktTotAmt", 0) or 0)) or None,
|
||||
})
|
||||
if (page - 1) * ROWS_PER_PAGE + len(items) >= total_count or len(items) < ROWS_PER_PAGE:
|
||||
break
|
||||
page += 1
|
||||
await asyncio.sleep(REQUEST_INTERVAL)
|
||||
return pd.DataFrame(rows) if rows else pd.DataFrame()
|
||||
|
||||
|
||||
async def get_universe_etf_candidate_codes(session, lookback_days: int = 30) -> list[str]:
|
||||
"""실전 분석 유니버스(stock_analysis)에 있으나 일봉 DB 에 행이 없는 종목 코드 (읽기 전용 SELECT).
|
||||
|
||||
대부분 ETF/ETN 이다(추정: 주식시세 API 에 없음). --etf-from-universe 로 ETF API 수집 대상이 된다.
|
||||
"""
|
||||
since = date.today() - timedelta(days=lookback_days)
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT DISTINCT a.stock_code FROM stock_analysis a "
|
||||
"WHERE a.analysis_date >= :since AND NOT EXISTS ("
|
||||
"SELECT 1 FROM stock_daily_prices p WHERE p.stock_code = a.stock_code)"
|
||||
),
|
||||
{"since": since},
|
||||
)
|
||||
return sorted(str(r[0]).strip().zfill(6) for r in result.all())
|
||||
|
||||
|
||||
# ─── DB 헬퍼 ──────────────────────────────────────────────────────
|
||||
|
||||
async def create_tables():
|
||||
@@ -232,6 +440,78 @@ async def upsert_daily_prices(session, df: pd.DataFrame) -> int:
|
||||
return inserted
|
||||
|
||||
|
||||
def find_incomplete_dates(
|
||||
counts: dict[date, int],
|
||||
window_start: date,
|
||||
window_end: date,
|
||||
is_holiday=None,
|
||||
ratio: float = INCOMPLETE_RATIO,
|
||||
) -> list[date]:
|
||||
"""[window_start, window_end] 평일 중 행 수 누락/부족(한쪽 시장 누락 의심) 날짜.
|
||||
|
||||
기준 = 창 내 양수 행 수의 중앙값 x ratio. 휴장일(is_holiday)은 제외한다.
|
||||
순수 함수 (DB·네트워크 없음).
|
||||
"""
|
||||
positives = sorted(v for v in counts.values() if v > 0)
|
||||
if not positives:
|
||||
return []
|
||||
median = positives[len(positives) // 2]
|
||||
threshold = median * ratio
|
||||
out: list[date] = []
|
||||
d = window_start
|
||||
while d <= window_end:
|
||||
if d.weekday() < 5 and not (is_holiday and is_holiday(d)):
|
||||
if counts.get(d, 0) < threshold:
|
||||
out.append(d)
|
||||
d += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def find_incomplete_dates_v2(
|
||||
counts: dict[date, int],
|
||||
window_start: date,
|
||||
window_end: date,
|
||||
is_holiday=None,
|
||||
ratio: float = INCOMPLETE_RATIO,
|
||||
local_days: int | None = None,
|
||||
) -> list[date]:
|
||||
"""find_incomplete_dates 의 지역 기준선 버전. 순수 함수.
|
||||
|
||||
기준 = 해당 날짜 전후 local_days(달력일) 내 양수 행 수 중앙값 x ratio.
|
||||
넓은 창에서 전역 중앙값을 쓰면 종목수 증가 추세로 과거 정상일이 오탐된다.
|
||||
이웃이 없으면 창 전체 중앙값으로 폴백. 휴장일(is_holiday)·주말 제외.
|
||||
"""
|
||||
local_days = INCOMPLETE_LOCAL_WINDOW_DAYS if local_days is None else local_days
|
||||
all_pos = sorted(v for v in counts.values() if v > 0)
|
||||
if not all_pos:
|
||||
return []
|
||||
global_median = all_pos[len(all_pos) // 2]
|
||||
out: list[date] = []
|
||||
d = window_start
|
||||
while d <= window_end:
|
||||
if d.weekday() < 5 and not (is_holiday and is_holiday(d)):
|
||||
neigh = sorted(
|
||||
v for k, v in counts.items()
|
||||
if v > 0 and k != d and abs((k - d).days) <= local_days
|
||||
)
|
||||
base = neigh[len(neigh) // 2] if neigh else global_median
|
||||
if counts.get(d, 0) < base * ratio:
|
||||
out.append(d)
|
||||
d += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
async def get_recent_date_counts(session, since: date) -> dict[date, int]:
|
||||
result = await session.execute(
|
||||
text(
|
||||
"SELECT trade_date, COUNT(*) FROM stock_daily_prices "
|
||||
"WHERE trade_date >= :since GROUP BY trade_date"
|
||||
),
|
||||
{"since": since},
|
||||
)
|
||||
return {r[0]: int(r[1]) for r in result.all()}
|
||||
|
||||
|
||||
# ─── 날짜별 수집 ──────────────────────────────────────────────────
|
||||
|
||||
async def collect_date(
|
||||
@@ -251,13 +531,53 @@ async def collect_date(
|
||||
markets.append("kosdaq")
|
||||
|
||||
frames: list[pd.DataFrame] = []
|
||||
statuses: dict[str, str] = {}
|
||||
for mkt in markets:
|
||||
df = await fetch_daily_prices(mkt, bas_dd)
|
||||
df, st = await fetch_daily_prices_v2(mkt, bas_dd)
|
||||
statuses[mkt] = st
|
||||
if not df.empty:
|
||||
frames.append(df)
|
||||
await asyncio.sleep(REQUEST_INTERVAL)
|
||||
|
||||
if _include_benchmarks and frames:
|
||||
try:
|
||||
etf_df = await fetch_benchmark_etf_prices(bas_dd)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] 벤치마크 ETF 수집 실패: %s", bas_dd, exc)
|
||||
etf_df = pd.DataFrame()
|
||||
got = set(etf_df["stock_code"]) if not etf_df.empty else set()
|
||||
if not got:
|
||||
logger.warning(
|
||||
"[%s] --include-benchmarks 인데 벤치마크 ETF(%s)가 응답에 없음 — "
|
||||
"regime_hmm/stock_pattern_memory DB 폴백은 계속 빈 결과",
|
||||
bas_dd, ",".join(sorted(BENCHMARK_ETF_CODES)),
|
||||
)
|
||||
else:
|
||||
frames.append(etf_df)
|
||||
|
||||
if _extra_etf_codes and frames:
|
||||
try:
|
||||
extra_df = await fetch_etf_prices_v2(bas_dd, _extra_etf_codes)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] 유니버스 ETF 수집 실패: %s", bas_dd, exc)
|
||||
extra_df = pd.DataFrame()
|
||||
if extra_df.empty:
|
||||
logger.warning(
|
||||
"[%s] --etf-codes/--etf-from-universe 지정 %d종목이 ETF API 응답에 없음 "
|
||||
"(엔드포인트 경로 미검증 — 실호출 확인 필요)",
|
||||
bas_dd, len(_extra_etf_codes),
|
||||
)
|
||||
else:
|
||||
frames.append(extra_df)
|
||||
|
||||
any_error = any(st == "error" for st in statuses.values())
|
||||
# 일부 시장만 비었거나 실패 = 불완전 (휴장일이면 전 시장이 비므로 해당 없음)
|
||||
partial = bool(frames) and any(st != "ok" for st in statuses.values())
|
||||
|
||||
if not frames:
|
||||
if any_error:
|
||||
logger.error("[%d/%d] %s: API 오류로 수집 실패 %s", i, total, bas_dd, statuses)
|
||||
return False, 0
|
||||
logger.info("[%d/%d] %s: 데이터 없음 (휴장일?)", i, total, bas_dd)
|
||||
return True, 0
|
||||
|
||||
@@ -265,8 +585,29 @@ async def collect_date(
|
||||
combined = combined.drop_duplicates(subset=["stock_code", "date"])
|
||||
|
||||
async with AsyncSessionLocal() as db:
|
||||
# 수정주가 불연속 감지 (읽기 전용 · 경고 전용 — 저장은 막지 않는다)
|
||||
try:
|
||||
td = combined["date"].iloc[0]
|
||||
td = td.date() if isinstance(td, pd.Timestamp) else td
|
||||
prev = await get_prev_closes(db, list(combined["stock_code"]), td)
|
||||
events = detect_price_events(combined, prev)
|
||||
if events:
|
||||
logger.warning(
|
||||
"[%s] 원주가 불연속(분할/병합/권리락 의심) %d건 — 소비처에서 해당 구간 수익률 제외 필요: %s",
|
||||
bas_dd, len(events),
|
||||
[(c, round(a, 3), round(b, 3)) for c, _d, a, b in events[:10]],
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[%s] 가격 이벤트 감지 실패(건너뜀): %s", bas_dd, exc)
|
||||
count = await upsert_daily_prices(db, combined)
|
||||
|
||||
if partial:
|
||||
logger.warning(
|
||||
"[%d/%d] %s: %d종목 저장했으나 불완전 %s — 다음 --update 에서 재수집",
|
||||
i, total, bas_dd, count, statuses,
|
||||
)
|
||||
return False, count
|
||||
|
||||
logger.info("[%d/%d] %s: %d종목 저장", i, total, bas_dd, count)
|
||||
return True, count
|
||||
|
||||
@@ -283,24 +624,84 @@ async def collect_all(
|
||||
start_date: str = "20200101",
|
||||
update_mode: bool = False,
|
||||
):
|
||||
"""메인 수집 로직 (공공데이터포털 금융위원회 주식시세 API)."""
|
||||
"""메인 수집 로직 (공공데이터포털 금융위원회 주식시세 API). 시그니처 유지 래퍼."""
|
||||
return await collect_all_v2(
|
||||
market=market, limit=limit, start_date=start_date, update_mode=update_mode,
|
||||
)
|
||||
|
||||
|
||||
async def collect_all_v2(
|
||||
market: str = "all",
|
||||
limit: int = 0,
|
||||
start_date: str = "20200101",
|
||||
update_mode: bool = False,
|
||||
dates_override: list[str] | None = None,
|
||||
rescan_days: int | None = None,
|
||||
):
|
||||
"""collect_all + 명시 날짜 백필(dates_override) / 부분일 장기 재검사(rescan_days)."""
|
||||
await create_tables()
|
||||
|
||||
global _extra_etf_codes
|
||||
if _etf_from_universe:
|
||||
async with AsyncSessionLocal() as db:
|
||||
cand = await get_universe_etf_candidate_codes(db)
|
||||
_extra_etf_codes = frozenset(_extra_etf_codes | set(cand))
|
||||
logger.info("유니버스 ETF 후보 %d종목 추가 수집 대상", len(cand))
|
||||
|
||||
if dates_override:
|
||||
# 1회성 백필: 지정 날짜만 재수집 (부분 수집일 복구용)
|
||||
dates = sorted({d.strip() for d in dates_override if d.strip()})
|
||||
total = len(dates)
|
||||
logger.info("명시 날짜 백필: %s", dates)
|
||||
semaphore = asyncio.Semaphore(3)
|
||||
results = await asyncio.gather(
|
||||
*[collect_date(i, total, d, market, semaphore) for i, d in enumerate(dates, 1)],
|
||||
return_exceptions=True,
|
||||
)
|
||||
ok = sum(1 for r in results if isinstance(r, tuple) and r[0])
|
||||
logger.info("백필 완료: 성공 %d/%d", ok, total)
|
||||
return
|
||||
lookback = INCOMPLETE_LOOKBACK_DAYS if rescan_days is None else max(1, int(rescan_days))
|
||||
|
||||
end_date = datetime.now().strftime("%Y%m%d")
|
||||
actual_start = start_date
|
||||
|
||||
extra_dates: list[str] = []
|
||||
if update_mode:
|
||||
async with AsyncSessionLocal() as db:
|
||||
last_date = await get_last_trade_date(db)
|
||||
counts: dict[date, int] = {}
|
||||
if last_date and market == "all":
|
||||
try:
|
||||
since = date.today() - timedelta(days=lookback)
|
||||
counts = await get_recent_date_counts(db, since)
|
||||
except Exception as exc:
|
||||
logger.warning("누락일 점검 실패(건너뜀): %s", exc)
|
||||
if last_date:
|
||||
if market == "all" and counts:
|
||||
try:
|
||||
from app.core.market_schedule import is_krx_holiday
|
||||
except Exception:
|
||||
is_krx_holiday = None
|
||||
gaps = find_incomplete_dates_v2(
|
||||
counts,
|
||||
date.today() - timedelta(days=lookback),
|
||||
last_date,
|
||||
is_holiday=is_krx_holiday,
|
||||
)
|
||||
extra_dates = [g.strftime("%Y%m%d") for g in gaps]
|
||||
if extra_dates:
|
||||
logger.warning("불완전/누락 날짜 재수집 대상: %s", extra_dates)
|
||||
next_day = last_date + timedelta(days=1)
|
||||
actual_start = next_day.strftime("%Y%m%d")
|
||||
if next_day >= date.today():
|
||||
if next_day >= date.today() and not extra_dates:
|
||||
logger.info("이미 최신 데이터입니다 (마지막: %s)", last_date)
|
||||
return
|
||||
logger.info("증분 업데이트: %s ~", actual_start)
|
||||
|
||||
dates = generate_trading_dates(actual_start, end_date)
|
||||
dates = generate_trading_dates(actual_start, end_date) if actual_start <= end_date else []
|
||||
if extra_dates:
|
||||
dates = sorted(set(dates) | set(extra_dates))
|
||||
if not dates:
|
||||
logger.warning("수집할 날짜가 없습니다.")
|
||||
return
|
||||
@@ -350,11 +751,38 @@ def main():
|
||||
"--start-date", default="20200101",
|
||||
help="수집 시작일 YYYYMMDD (기본: 20200101)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--include-benchmarks", action="store_true",
|
||||
help="벤치마크 ETF(069500 등)를 ETF 제외 필터에서 허용 (기본 OFF)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--etf-codes", default="",
|
||||
help="쉼표 구분 ETF/ETN 6자리 코드를 ETF 시세 API 로 추가 수집(기본 OFF, 경로 미검증)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--etf-from-universe", action="store_true",
|
||||
help="stock_analysis 에 있으나 일봉 DB 에 없는 종목(대부분 ETF)을 ETF API 로 추가 수집",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dates", default="",
|
||||
help="쉼표 구분 YYYYMMDD 만 재수집 (부분 수집일 백필, 예: 20221020,20221101,20221102,20221103,20221104,20221107,20221130,20230120,20260625,20260720,20260914)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--rescan-days", type=int, default=0,
|
||||
help="--update 시 부분 수집일 재검사 달력일 범위 (기본 %d, 예: 365)" % INCOMPLETE_LOOKBACK_DAYS,
|
||||
)
|
||||
parser.add_argument(
|
||||
"--update", action="store_true",
|
||||
help="증분 업데이트 모드 (마지막 저장일 이후만 수집)",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
global _include_benchmarks
|
||||
_include_benchmarks = bool(args.include_benchmarks)
|
||||
global _extra_etf_codes
|
||||
codes = {c.strip().zfill(6) for c in args.etf_codes.split(",") if c.strip()}
|
||||
_extra_etf_codes = frozenset(codes)
|
||||
global _etf_from_universe
|
||||
_etf_from_universe = bool(args.etf_from_universe)
|
||||
|
||||
logger.info("일봉 데이터 수집 시작 (공공데이터포털)")
|
||||
logger.info(" 시장: %s", args.market)
|
||||
@@ -363,11 +791,13 @@ def main():
|
||||
logger.info(" 증분 모드: %s", args.update)
|
||||
|
||||
start_time = time.time()
|
||||
asyncio.run(collect_all(
|
||||
asyncio.run(collect_all_v2(
|
||||
market=args.market,
|
||||
limit=args.limit,
|
||||
start_date=args.start_date,
|
||||
update_mode=args.update,
|
||||
dates_override=[d for d in args.dates.split(",") if d.strip()] or None,
|
||||
rescan_days=args.rescan_days or None,
|
||||
))
|
||||
elapsed = time.time() - start_time
|
||||
logger.info("총 소요시간: %.1f초 (%.1f분)", elapsed, elapsed / 60)
|
||||
|
||||
Reference in New Issue
Block a user