|
| 1 | +""" |
| 2 | +extract_corp_actions.py — Extract capital reduction (감자결정) events from DART. |
| 3 | +
|
| 4 | +Endpoint: https://opendart.fss.or.kr/api/crDecsn.json (DS005 #2020026) |
| 5 | +
|
| 6 | +Share consolidations (주식병합) are reported as 감자결정. This extractor |
| 7 | +captures the shares-before and shares-after counts, allowing downstream |
| 8 | +consumers to compute cumulative adjustment factors for price/exercise-price |
| 9 | +denomination alignment. |
| 10 | +
|
| 11 | +Why this exists: |
| 12 | + pykrx adjusted=True retroactively scales historical prices by all |
| 13 | + consolidation factors, but DART exercise prices (cv_prc) remain at the |
| 14 | + original filing denomination. Without adjustment factors, moneyness (S/K) |
| 15 | + is inflated by the consolidation ratio for affected tickers. |
| 16 | +
|
| 17 | +Output: |
| 18 | + 01_Data/processed/corp_actions.parquet |
| 19 | + Columns: corp_code, rcept_no, effective_date, shares_before, shares_after, |
| 20 | + reduction_ratio, method |
| 21 | +
|
| 22 | +Usage: |
| 23 | + python 02_Pipeline/extract_corp_actions.py |
| 24 | + python 02_Pipeline/extract_corp_actions.py --sample 10 --sleep 0.5 |
| 25 | +""" |
| 26 | + |
| 27 | +from __future__ import annotations |
| 28 | + |
| 29 | +import argparse |
| 30 | +import datetime |
| 31 | +import logging |
| 32 | +import re |
| 33 | +import sys |
| 34 | +import time |
| 35 | +from pathlib import Path |
| 36 | + |
| 37 | +import pandas as pd |
| 38 | +from dotenv import load_dotenv |
| 39 | + |
| 40 | +from _pipeline_helpers import ( |
| 41 | + DART_STATUS_NOT_FOUND, |
| 42 | + DART_STATUS_OK, |
| 43 | + _dart_api_key, |
| 44 | + _norm_corp_code, |
| 45 | + fetch_with_backoff, |
| 46 | +) |
| 47 | + |
| 48 | +load_dotenv() |
| 49 | + |
| 50 | +logging.basicConfig( |
| 51 | + level=logging.INFO, |
| 52 | + format="%(asctime)s [%(levelname)s] %(message)s", |
| 53 | + handlers=[logging.StreamHandler(stream=sys.stdout)], |
| 54 | +) |
| 55 | +log = logging.getLogger(__name__) |
| 56 | + |
| 57 | +ROOT = Path(__file__).parent.parent |
| 58 | +PROCESSED = ROOT / "01_Data" / "processed" |
| 59 | + |
| 60 | +DART_CR_URL = "https://opendart.fss.or.kr/api/crDecsn.json" |
| 61 | +SLEEP_DEFAULT = 0.5 |
| 62 | + |
| 63 | + |
| 64 | +def _parse_int(raw) -> int | None: |
| 65 | + """Parse a comma-formatted integer string.""" |
| 66 | + if not raw: |
| 67 | + return None |
| 68 | + s = str(raw).strip().replace(",", "") |
| 69 | + if not s or s == "-": |
| 70 | + return None |
| 71 | + try: |
| 72 | + return int(s) |
| 73 | + except (ValueError, TypeError): |
| 74 | + return None |
| 75 | + |
| 76 | + |
| 77 | +def _parse_date(raw) -> str | None: |
| 78 | + """Parse DART date (YYYYMMDD or '2022년 06월 27일') to ISO YYYY-MM-DD.""" |
| 79 | + if not raw: |
| 80 | + return None |
| 81 | + raw = str(raw).strip() |
| 82 | + if raw == "-": |
| 83 | + return None |
| 84 | + if len(raw) == 8 and raw.isdigit(): |
| 85 | + return f"{raw[:4]}-{raw[4:6]}-{raw[6:]}" |
| 86 | + m = re.match(r"(\d{4})년\s*(\d{1,2})월\s*(\d{1,2})일", raw) |
| 87 | + if m: |
| 88 | + return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}" |
| 89 | + dt = pd.to_datetime(raw, errors="coerce") |
| 90 | + return str(dt.date()) if not pd.isna(dt) else None |
| 91 | + |
| 92 | + |
| 93 | +def _parse_float(raw) -> float | None: |
| 94 | + """Parse a percentage or decimal string.""" |
| 95 | + if not raw: |
| 96 | + return None |
| 97 | + s = str(raw).strip().replace(",", "").replace("%", "") |
| 98 | + if not s or s == "-": |
| 99 | + return None |
| 100 | + try: |
| 101 | + return float(s) |
| 102 | + except (ValueError, TypeError): |
| 103 | + return None |
| 104 | + |
| 105 | + |
| 106 | +def _parse_response(data: dict, corp_code: str) -> list[dict]: |
| 107 | + """Parse a crDecsn.json response into rows.""" |
| 108 | + status = str(data.get("status", "")) |
| 109 | + if status == DART_STATUS_NOT_FOUND: |
| 110 | + return [] |
| 111 | + if status not in (DART_STATUS_OK, ""): |
| 112 | + log.debug("DART status %s for corp_code=%s", status, corp_code) |
| 113 | + return [] |
| 114 | + |
| 115 | + items = data.get("list", []) |
| 116 | + if not items: |
| 117 | + return [] |
| 118 | + |
| 119 | + rows = [] |
| 120 | + for item in items: |
| 121 | + shares_before = _parse_int(item.get("bfcr_tisstk_ostk")) |
| 122 | + shares_after = _parse_int(item.get("atcr_tisstk_ostk")) |
| 123 | + |
| 124 | + rows.append({ |
| 125 | + "corp_code": corp_code, |
| 126 | + "rcept_no": item.get("rcept_no", ""), |
| 127 | + "effective_date": _parse_date(item.get("cr_std")), |
| 128 | + "shares_before": shares_before, |
| 129 | + "shares_after": shares_after, |
| 130 | + "reduction_ratio": _parse_float(item.get("cr_rt_ostk")), |
| 131 | + "method": (item.get("cr_mth") or "").strip(), |
| 132 | + }) |
| 133 | + |
| 134 | + return rows |
| 135 | + |
| 136 | + |
| 137 | +def fetch_corp_actions( |
| 138 | + force: bool = False, |
| 139 | + sample: int | None = None, |
| 140 | + sleep: float = SLEEP_DEFAULT, |
| 141 | +) -> pd.DataFrame: |
| 142 | + """Fetch capital reduction events for all companies in cb_bw_events.parquet. |
| 143 | +
|
| 144 | + Queries only corp_codes that have CB/BW issuances, since those are the |
| 145 | + only ones where denomination mismatch matters. |
| 146 | + """ |
| 147 | + out = PROCESSED / "corp_actions.parquet" |
| 148 | + if out.exists() and not force: |
| 149 | + log.info("corp_actions.parquet exists, loading cached (use --force to refresh)") |
| 150 | + return pd.read_parquet(out) |
| 151 | + |
| 152 | + cb_path = PROCESSED / "cb_bw_events.parquet" |
| 153 | + if not cb_path.exists(): |
| 154 | + raise FileNotFoundError( |
| 155 | + "cb_bw_events.parquet not found. Run extract_cb_bw.py first." |
| 156 | + ) |
| 157 | + |
| 158 | + cb = pd.read_parquet(cb_path) |
| 159 | + corp_codes = sorted(cb["corp_code"].astype(str).str.zfill(8).unique()) |
| 160 | + if sample is not None: |
| 161 | + corp_codes = corp_codes[:sample] |
| 162 | + |
| 163 | + log.info("Querying DART crDecsn.json for %d corp_codes...", len(corp_codes)) |
| 164 | + |
| 165 | + api_key = _dart_api_key() |
| 166 | + all_rows: list[dict] = [] |
| 167 | + hits = 0 |
| 168 | + |
| 169 | + for i, cc in enumerate(corp_codes, 1): |
| 170 | + if i % 100 == 0 or i == 1: |
| 171 | + log.info("Corp action fetch %d/%d (hits so far: %d)", i, len(corp_codes), hits) |
| 172 | + |
| 173 | + try: |
| 174 | + data = fetch_with_backoff( |
| 175 | + DART_CR_URL, |
| 176 | + params={ |
| 177 | + "crtfc_key": api_key, |
| 178 | + "corp_code": cc, |
| 179 | + "bgn_de": "20150101", |
| 180 | + "end_de": datetime.date.today().strftime("%Y%m%d"), |
| 181 | + }, |
| 182 | + ) |
| 183 | + rows = _parse_response(data, cc) |
| 184 | + if rows: |
| 185 | + hits += 1 |
| 186 | + all_rows.extend(rows) |
| 187 | + except Exception as exc: |
| 188 | + log.warning("Error for corp_code=%s: %s", cc, exc) |
| 189 | + |
| 190 | + time.sleep(sleep) |
| 191 | + |
| 192 | + columns = [ |
| 193 | + "corp_code", "rcept_no", "effective_date", |
| 194 | + "shares_before", "shares_after", "reduction_ratio", "method", |
| 195 | + ] |
| 196 | + df = pd.DataFrame(all_rows, columns=columns) if all_rows else pd.DataFrame(columns=columns) |
| 197 | + |
| 198 | + # Deduplicate by (corp_code, rcept_no) |
| 199 | + before = len(df) |
| 200 | + df = df.drop_duplicates(subset=["corp_code", "rcept_no"]) |
| 201 | + if len(df) < before: |
| 202 | + log.info("Dropped %d duplicate rows", before - len(df)) |
| 203 | + |
| 204 | + PROCESSED.mkdir(parents=True, exist_ok=True) |
| 205 | + df.to_parquet(out, index=False) |
| 206 | + log.info( |
| 207 | + "Written %d corp action events (%d companies with actions) to %s", |
| 208 | + len(df), hits, out, |
| 209 | + ) |
| 210 | + return df |
| 211 | + |
| 212 | + |
| 213 | +def main(): |
| 214 | + parser = argparse.ArgumentParser(description="Extract capital reduction events from DART") |
| 215 | + parser.add_argument("--force", action="store_true") |
| 216 | + parser.add_argument("--sample", type=int, default=None) |
| 217 | + parser.add_argument("--sleep", type=float, default=SLEEP_DEFAULT) |
| 218 | + args = parser.parse_args() |
| 219 | + |
| 220 | + fetch_corp_actions(force=args.force, sample=args.sample, sleep=args.sleep) |
| 221 | + |
| 222 | + |
| 223 | +def _configure_stdout() -> None: |
| 224 | + if sys.platform == "win32": |
| 225 | + try: |
| 226 | + sys.stdout.reconfigure(encoding="utf-8") |
| 227 | + sys.stderr.reconfigure(encoding="utf-8") |
| 228 | + except AttributeError: |
| 229 | + pass |
| 230 | + |
| 231 | + |
| 232 | +if __name__ == "__main__": |
| 233 | + _configure_stdout() |
| 234 | + main() |
0 commit comments