#!/usr/bin/env python3 """ DART 수주(단일판매·공급계약) 공시 SCAN ==================================== 역할 (1단계): Open DART ``list.json`` 을 폴링 → 신규 공시만 DB 저장 → Mattermost 알림. (선택) ``DART_SAVE_CANDIDATES=true`` 이면 ``target_candidates_history`` (strategy_id=DART) 에 후보만 적재. 품질필터 (기본 ON): 1) 건설·재개발·아파트 등 제외 (법인명·공시명·원문) 2) 공시 원문 매출액대비(%) ≥ DART_MIN_SALES_PCT 3) 수출·해외·USD·반도체·방산 등 테마 키워드 필수(DART_REQUIRE_THEME) 하지 않는 것: - 공시 직후 시장가 매수 (즉시매수 금지 — TRIGGER 전략은 후속) - Gemini 식 독립 SafeRequest / JSON 파일 원자저장 (기존 SafeRequest + MariaDB) 실행: cd ~/kis_bot nohup python3 -u -m kis_trader.scan.dart_order_tracker \\ >> logs/dart_order_tracker.log 2>&1 & # 1회만: python3 -m kis_trader.scan.dart_order_tracker --once """ from __future__ import annotations import argparse import datetime as dt import io import random import re import sys import time import zipfile from typing import Any, Dict, List, Optional, Sequence, Set, Tuple from database import TradeDB from kis_trader.utils.env import get_env_bool, get_env_float, get_env_from_db, get_env_int from kis_trader.utils.logger import get_logger, msg_mm, msg_tg from kis_trader.utils.request_handler import SafeRequest logger = get_logger("kis_trader.dart_order") DART_LIST_URL = "https://opendart.fss.or.kr/api/list.json" DART_DOCUMENT_URL = "https://opendart.fss.or.kr/api/document.xml" DART_VIEW_URL = "https://dart.fss.or.kr/dsaf001/main.do?rcpNo={rcept_no}" STRATEGY_ID = "DART" # 매출액대비(%) — 정식공시 / 자율공시 표기 차이 _RE_SALES_PCT = re.compile( r"매출액\s*대비\s*\(%\)\s*([0-9]+(?:\.[0-9]+)?)", re.IGNORECASE, ) _RE_CONTRACT_AMT = re.compile( r"계약금액(?:\s*총액)?\s*\(원\)\s*([0-9,\.]+)", ) def _now_str() -> str: return dt.datetime.now().strftime("%Y-%m-%d %H:%M:%S") def _mm_channel() -> str: ch = (get_env_from_db("DART_MM_CHANNEL", "") or "").strip() if ch: return ch ch = (get_env_from_db("KIS_SYSTEM_MM_CHANNEL", "") or "").strip() if ch: return ch return (get_env_from_db("MATTERMOST_CHANNEL", "stock") or "stock").strip() def _split_keywords(raw: str) -> List[str]: parts = [p.strip() for p in (raw or "").replace("ㆍ", ",").split(",")] return [p for p in parts if p] def _keywords() -> List[str]: raw = get_env_from_db("DART_REPORT_NAME_KEYWORDS", "단일판매,공급계약") or "" return _split_keywords(raw) def _exclude_corp_keywords() -> List[str]: # 재개발·아파트·건설 수주 — 테마 선반영 후 공시되는 경우가 많아 SCAN에서 제외 raw = get_env_from_db( "DART_EXCLUDE_CORP_KEYWORDS", "건설,재개발,아파트,주택,토건,시공,하우징,도시정비,디벨로퍼", ) or "" return _split_keywords(raw) def _exclude_content_keywords() -> List[str]: raw = get_env_from_db( "DART_EXCLUDE_CONTENT_KEYWORDS", "아파트,재개발,주택분양,도시정비,건축공사,도급공사,시공계약,분양", ) or "" return _split_keywords(raw) def _theme_keywords() -> List[str]: # 수출·해외·제조/반도체·방산 쪽 — 국내 아파트 수주와 구분 raw = get_env_from_db( "DART_THEME_KEYWORDS", "수출,해외,USD,달러,반도체,방산,우주,이차전지,배터리,디스플레이," "부품,제조,항공,국방,전자,소재,장비", ) or "" return _split_keywords(raw) def _plain_from_html(text: str) -> str: plain = re.sub(r"<[^>]+>", " ", text or "") plain = re.sub(r"&[a-zA-Z]+;", " ", plain) plain = re.sub(r"\s+", " ", plain) return plain.strip() def _contains_any(hay: str, needles: Sequence[str]) -> Optional[str]: if not hay or not needles: return None for n in needles: if n and n in hay: return n return None def ensure_dart_disclosure_columns(db: TradeDB) -> None: """웹/백테/스캐너 공용 — filter_ok·sales_pct 컬럼 보장.""" db.conn.execute( """ CREATE TABLE IF NOT EXISTS dart_disclosures ( rcept_no VARCHAR(32) NOT NULL PRIMARY KEY, corp_code VARCHAR(16) NOT NULL DEFAULT '', corp_name VARCHAR(100) NOT NULL DEFAULT '', stock_code VARCHAR(20) NOT NULL DEFAULT '', report_nm VARCHAR(255) NOT NULL DEFAULT '', rcept_dt VARCHAR(8) NOT NULL DEFAULT '', url VARCHAR(255) NOT NULL DEFAULT '', first_seen_at VARCHAR(30) NOT NULL, mm_sent TINYINT NOT NULL DEFAULT 0, filter_ok TINYINT NULL, sales_pct DOUBLE NULL, filter_reason VARCHAR(64) NULL, KEY idx_dart_seen (first_seen_at), KEY idx_dart_stock (stock_code) ) CHARACTER SET utf8mb4 """ ) cols = { str(r["Field"]) for r in db.conn.execute("SHOW COLUMNS FROM dart_disclosures").fetchall() } for col, ddl in ( ("filter_ok", "ADD COLUMN filter_ok TINYINT NULL"), ("sales_pct", "ADD COLUMN sales_pct DOUBLE NULL"), ("filter_reason", "ADD COLUMN filter_reason VARCHAR(64) NULL"), ): if col not in cols: try: db.conn.execute(f"ALTER TABLE dart_disclosures {ddl}") except Exception as e: logger.debug("dart_disclosures alter skip %s: %s", col, e) class DartOrderTracker(SafeRequest): """ Open DART 공시검색(list.json) → 수주성 보고서명 필터 → 품질필터 → DB/알림. SafeRequest 상속: 429/5xx 재시도 + 클라이언트 스로틀. """ def __init__(self, db: Optional[TradeDB] = None): # DART 일일 호출 한도 여유 — 폴링은 분 단위라 간격 여유 있게 super().__init__(min_interval_sec=0.35, max_retries=4, timeout_sec=15.0) self.db = db or TradeDB() self._ensure_table() def _ensure_table(self) -> None: ensure_dart_disclosure_columns(self.db) self._backfill_corp_exclude() def _backfill_corp_exclude(self) -> None: """이미 적재된 건설·재개발 법인명은 filter_ok=0 으로 표시 (원문 재다운로드 없음).""" kws = _exclude_corp_keywords() if not kws: return try: rows = self.db.conn.execute( """ SELECT rcept_no, corp_name, report_nm FROM dart_disclosures WHERE filter_ok IS NULL """ ).fetchall() except Exception: return for r in rows: blob = f"{r.get('corp_name') or ''} {r.get('report_nm') or ''}" hit = _contains_any(blob, kws) if hit: try: self.db.conn.execute( """ UPDATE dart_disclosures SET filter_ok=0, filter_reason=%s WHERE rcept_no=%s """, (f"exclude_corp:{hit}"[:64], r["rcept_no"]), ) except Exception: pass else: # 품질필터 도입 전 건 — 백테는 filter_ok=1 만 쓰므로 일단 통과 표시 try: self.db.conn.execute( """ UPDATE dart_disclosures SET filter_ok=1, filter_reason=%s WHERE rcept_no=%s """, ("legacy_pass", r["rcept_no"]), ) except Exception: pass def _api_key(self) -> str: return (get_env_from_db("DART_API_KEY", "") or "").strip() def fetch_list(self, *, bgn_de: str, end_de: str) -> List[Dict[str, Any]]: """공시검색 list.json (페이지 순회, 최대 10페이지).""" key = self._api_key() if not key: logger.error("DART_API_KEY 가 비어 있습니다.") return [] out: List[Dict[str, Any]] = [] page_count = 100 max_pages = max(1, min(50, get_env_int("DART_LIST_MAX_PAGES", 20))) for page_no in range(1, max_pages + 1): params = { "crtfc_key": key, "bgn_de": bgn_de, "end_de": end_de, "page_no": str(page_no), "page_count": str(page_count), } resp = self.get(DART_LIST_URL, params=params) if resp is None or resp.status_code != 200: logger.warning( "DART list HTTP 실패 status=%s page=%s", getattr(resp, "status_code", None), page_no, ) break try: body = resp.json() except Exception as e: logger.warning("DART list JSON 파싱 실패: %s", e) break status = str(body.get("status") or "") # 013 = 조회된 데이터가 없습니다 (정상 empty) if status == "013": break if status != "000": logger.warning( "DART list status=%s msg=%s page=%s", status, body.get("message"), page_no, ) break rows = body.get("list") or [] if isinstance(rows, dict): rows = [rows] if not rows: break out.extend(rows) total_page = int(body.get("total_page") or 1) if page_no >= total_page: break return out def fetch_document_plain(self, rcept_no: str) -> str: """공시 원문 ZIP(XML) → 텍스트. 실패 시 빈 문자열.""" key = self._api_key() rcept_no = str(rcept_no or "").strip() if not key or not rcept_no: return "" resp = self.get( DART_DOCUMENT_URL, params={"crtfc_key": key, "rcept_no": rcept_no}, timeout=30.0, ) if resp is None or resp.status_code != 200 or not resp.content: return "" try: zf = zipfile.ZipFile(io.BytesIO(resp.content)) names = zf.namelist() if not names: return "" raw = zf.read(names[0]) text = raw.decode("utf-8", errors="replace") return _plain_from_html(text) except Exception as e: logger.debug("DART document 파싱 실패 %s: %s", rcept_no, e) return "" def filter_reports(self, rows: Sequence[Dict[str, Any]]) -> List[Dict[str, Any]]: """보고서명 키워드 + (선택) 기재정정 스킵 + 상장만.""" kws = _keywords() skip_corr = get_env_bool("DART_SKIP_CORRECTION", True) listed_only = get_env_bool("DART_LISTED_ONLY", True) hits: List[Dict[str, Any]] = [] for row in rows: name = str(row.get("report_nm") or "").strip() if not name: continue if skip_corr and ("기재정정" in name or name.startswith("[기재정정]")): continue if kws and not any(k in name for k in kws): continue stock = str(row.get("stock_code") or "").strip() if listed_only and not stock: continue hits.append(row) return hits def quality_check( self, item: Dict[str, Any] ) -> Tuple[bool, Dict[str, Any]]: """ 건설 제외 · 매출대비% · 테마 키워드. 반환: (통과여부, meta{sales_pct, filter_reason, theme_hit, plain_len}) """ meta: Dict[str, Any] = { "sales_pct": None, "filter_reason": "pass", "theme_hit": None, "plain_len": 0, } if not get_env_bool("DART_QUALITY_FILTER_ENABLED", True): return True, meta corp = str(item.get("corp_name") or "") report = str(item.get("report_nm") or "") blob_head = f"{corp} {report}" ex_corp = _exclude_corp_keywords() hit = _contains_any(blob_head, ex_corp) if hit: meta["filter_reason"] = f"exclude_corp:{hit}" return False, meta plain = "" if get_env_bool("DART_FETCH_DOCUMENT", True): plain = self.fetch_document_plain(str(item.get("rcept_no") or "")) meta["plain_len"] = len(plain) ex_content = _exclude_content_keywords() if plain: hit = _contains_any(plain, ex_content) if hit: meta["filter_reason"] = f"exclude_content:{hit}" return False, meta min_pct = float(get_env_float("DART_MIN_SALES_PCT", 5.0) or 0.0) sales_pct: Optional[float] = None if plain: m = _RE_SALES_PCT.search(plain) if m: try: sales_pct = float(m.group(1)) except Exception: sales_pct = None meta["sales_pct"] = sales_pct if min_pct > 0: if sales_pct is None: if get_env_bool("DART_SKIP_IF_SALES_PCT_UNKNOWN", True): meta["filter_reason"] = "sales_pct_unknown" return False, meta elif sales_pct < min_pct: meta["filter_reason"] = f"low_sales:{sales_pct}" return False, meta if get_env_bool("DART_REQUIRE_THEME", True): themes = _theme_keywords() theme_blob = f"{blob_head} {plain}" th = _contains_any(theme_blob, themes) meta["theme_hit"] = th if not th: meta["filter_reason"] = "no_theme" return False, meta meta["filter_reason"] = "pass" return True, meta def _known_rcept_nos(self, rcept_nos: Sequence[str]) -> Set[str]: nos = [str(x).strip() for x in rcept_nos if str(x).strip()] if not nos: return set() # pymysql IN — 플레이스홀더 placeholders = ",".join(["%s"] * len(nos)) rows = self.db.conn.execute( f"SELECT rcept_no FROM dart_disclosures WHERE rcept_no IN ({placeholders})", tuple(nos), ).fetchall() return {str(r["rcept_no"]) for r in rows} def _insert_disclosure( self, item: Dict[str, Any], *, mm_sent: bool, filter_ok: bool, sales_pct: Optional[float], filter_reason: str, ) -> bool: """신규 공시 1건 INSERT. 이미 있으면 False.""" rcept_no = str(item.get("rcept_no") or "").strip() if not rcept_no: return False stock = str(item.get("stock_code") or "").strip() corp_name = str(item.get("corp_name") or "").strip()[:100] report_nm = str(item.get("report_nm") or "").strip()[:255] url = DART_VIEW_URL.format(rcept_no=rcept_no) try: self.db.conn.execute( """ INSERT INTO dart_disclosures (rcept_no, corp_code, corp_name, stock_code, report_nm, rcept_dt, url, first_seen_at, mm_sent, filter_ok, sales_pct, filter_reason) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( rcept_no, str(item.get("corp_code") or "").strip()[:16], corp_name, stock[:20], report_nm, str(item.get("rcept_dt") or "").strip()[:8], url[:255], _now_str(), 1 if mm_sent else 0, 1 if filter_ok else 0, sales_pct, (filter_reason or "")[:64], ), ) return True except Exception as e: # PK 중복 등 logger.debug("dart insert skip %s: %s", rcept_no, e) return False def _notify(self, item: Dict[str, Any], meta: Optional[Dict[str, Any]] = None) -> bool: stock = str(item.get("stock_code") or "").strip() or "-" corp = str(item.get("corp_name") or "").strip() report = str(item.get("report_nm") or "").strip() rcept_no = str(item.get("rcept_no") or "").strip() url = DART_VIEW_URL.format(rcept_no=rcept_no) meta = meta or {} pct = meta.get("sales_pct") pct_s = f"{pct:.2f}%" if isinstance(pct, (int, float)) else "-" theme = meta.get("theme_hit") or "-" body = ( f"📢 **[DART 수주/공급계약]**\n" f"- 종목: `{stock}` {corp}\n" f"- 공시: {report}\n" f"- 매출대비: {pct_s} · 테마: {theme}\n" f"- 접수: {item.get('rcept_dt') or '-'}\n" f"- [원문]({url})\n" f"⚠️ 즉시 시장가 매수 금지 — SCAN 후보/알림 전용" ) ok_mm = msg_mm(body, channel_alias=_mm_channel(), jitter=True) # 텔레그램 키가 있으면 병행 (없으면 내부에서 no-op) msg_tg(body, jitter=False) return bool(ok_mm) def _save_candidate(self, item: Dict[str, Any]) -> None: if not get_env_bool("DART_SAVE_CANDIDATES", False): return stock = str(item.get("stock_code") or "").strip() if not stock: return try: from kis_trader.database.db_manager import get_db kdb = get_db() et = _now_str() code = stock.zfill(6) if stock.isdigit() else stock kdb.insert_universe_snapshot( strategy_id=STRATEGY_ID, event_time=et, items=[{ "code": code, "name": str(item.get("corp_name") or stock), }], ) except Exception as e: logger.warning("DART 후보 적재 실패: %s", e) def poll_once(self) -> int: """ 1회 폴링. 품질필터 통과 신규 건수 반환. lookback: 오늘 포함 DART_LOOKBACK_DAYS 일. """ if not get_env_bool("DART_ENABLED", True): logger.info("DART_ENABLED=false — 스킵") return 0 # SCAN 스위치 (신규 키 우선) if not get_env_bool("DART_SCAN_ENABLED", get_env_bool("DART_ENABLED", True)): logger.info("DART_SCAN_ENABLED=false — 스킵") return 0 if not self._api_key(): logger.error("DART_API_KEY 없음 — 중단") return 0 lookback = max(0, get_env_int("DART_LOOKBACK_DAYS", 1)) end = dt.datetime.now().date() bgn = end - dt.timedelta(days=lookback) bgn_de = bgn.strftime("%Y%m%d") end_de = end.strftime("%Y%m%d") rows = self.fetch_list(bgn_de=bgn_de, end_de=end_de) hits = self.filter_reports(rows) if not hits: logger.info( "DART 폴링: list=%d hits=0 (%s~%s)", len(rows), bgn_de, end_de, ) return 0 known = self._known_rcept_nos([str(h.get("rcept_no") or "") for h in hits]) new_count = 0 pass_count = 0 for item in hits: rcept_no = str(item.get("rcept_no") or "").strip() if not rcept_no or rcept_no in known: continue ok, meta = self.quality_check(item) inserted = self._insert_disclosure( item, mm_sent=False, filter_ok=ok, sales_pct=meta.get("sales_pct"), filter_reason=str(meta.get("filter_reason") or ""), ) if not inserted: continue new_count += 1 known.add(rcept_no) if not ok: logger.info( "⛔ DART 필터 %s %s | %s | reason=%s sales=%s", item.get("stock_code"), item.get("corp_name"), str(item.get("report_nm") or "").strip()[:40], meta.get("filter_reason"), meta.get("sales_pct"), ) continue pass_count += 1 mm_ok = self._notify(item, meta) if mm_ok: try: self.db.conn.execute( "UPDATE dart_disclosures SET mm_sent=1 WHERE rcept_no=%s", (rcept_no,), ) except Exception: pass self._save_candidate(item) # 임시 워치 (구독 스위치 ON 시) try: from kis_trader.scan.dart_watchlist import upsert_watch upsert_watch( self.db, stock_code=str(item.get("stock_code") or "").strip(), corp_name=str(item.get("corp_name") or ""), rcept_no=rcept_no, report_nm=str(item.get("report_nm") or ""), ) except Exception as e: logger.debug("DART 워치 적재 스킵: %s", e) logger.info( "🆕 DART 통과 %s %s | sales=%s%% theme=%s | %s", item.get("stock_code"), item.get("corp_name"), meta.get("sales_pct"), meta.get("theme_hit"), str(item.get("report_nm") or "").strip()[:60], ) logger.info( "DART 폴링: list=%d hits=%d new=%d pass=%d (%s~%s)", len(rows), len(hits), new_count, pass_count, bgn_de, end_de, ) return pass_count def run_forever(self) -> None: poll = max(15, get_env_int("DART_POLL_SEC", 60)) logger.info( "DART 수주 감시 시작 poll=%ss lookback=%sd channel=%s quality=%s min_sales=%s theme=%s", poll, get_env_int("DART_LOOKBACK_DAYS", 1), _mm_channel(), get_env_bool("DART_QUALITY_FILTER_ENABLED", True), get_env_float("DART_MIN_SALES_PCT", 5.0), get_env_bool("DART_REQUIRE_THEME", True), ) while True: try: self.poll_once() except Exception as e: logger.exception("DART 폴링 예외: %s", e) # 일반 루프: 기본 간격 + 1~3초 지터 (서버 부하·패턴 고정 방지) time.sleep(float(poll) + random.uniform(1.0, 3.0)) def main(argv: Optional[Sequence[str]] = None) -> int: parser = argparse.ArgumentParser(description="DART 수주/공급계약 공시 SCAN") parser.add_argument( "--once", action="store_true", help="1회 폴링 후 종료 (스모크/cron용)", ) args = parser.parse_args(list(argv) if argv is not None else None) if not get_env_bool("DART_ENABLED", True): logger.warning("DART_ENABLED=false") return 0 tracker = DartOrderTracker() if args.once: n = tracker.poll_once() print(f"dart_order_tracker --once new_pass={n}") return 0 tracker.run_forever() return 0 if __name__ == "__main__": sys.exit(main())