Files
kis_bot/kis_trader/scan/dart_order_tracker.py
Your Name 61bec4bd1d feat: Add DART strategy and related configurations
ㅇ
Changes:
- Introduced the DART strategy to the trading system, including its configuration and integration into the existing framework.
- Updated the database schema to include DART-specific tables for disclosures and watchlists.
- Enhanced the backtesting and parameter search functionalities to support the DART strategy.
- Implemented new rules for browser verification and API interactions to ensure compliance with the updated DART strategy.

Impact:
- These additions expand the trading capabilities of the system, allowing for more comprehensive analysis and execution of DART-related strategies, while maintaining system integrity and performance.
2026-07-21 07:50:24 +09:00

643 lines
23 KiB
Python

#!/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())