ㅇ 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.
643 lines
23 KiB
Python
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())
|