Skip to content

Commit f3647d5

Browse files
pon00050claude
andcommitted
feat: add corp_actions extractor and upgrade pykrx to 1.2.4
- New extract_corp_actions.py: queries DART crDecsn.json (감자결정) for all CB/BW issuers, outputs corp_actions.parquet with shares_before/after and effective dates for share consolidation events - Upgrade pykrx from 1.0.51 to >=1.2.0 (resolves to 1.2.4) - This enables downstream K adjustment in kr-derivatives screen script, fixing the denomination mismatch between adjusted prices and DART exercise prices (Run 3: flag rate 49.3% → 34.0%) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 9b99bfb commit f3647d5

3 files changed

Lines changed: 259 additions & 135 deletions

File tree

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
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()

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ classifiers = [
1818
dependencies = [
1919
"anthropic>=0.40.0",
2020
"opendartreader",
21-
"pykrx",
21+
"pykrx>=1.2.0",
2222
"pandas",
2323
"pyarrow",
2424
"marimo",

0 commit comments

Comments
 (0)