"""qualitative_sell_strategy_v1 입력 ctx 조립 오케스트레이터. 데이터 출처 (2026-06-21 세션 실측 기준, KIS Open API 연동 이후): - relative_return_20d, volume_ratio_5d ← tools/fetch_naver_market_data_v1.py (무인증, 동작 확인) - sector_export_trend ← tools/fetch_trade_statistics_motie_v1.py (--csv 경로 권장) - short_turnover_share ← [신규] KIS Open API daily-short-sale(FHPST04830000) output2.ssts_vol_rlim — 실측 동작 확인(실전계좌 도메인, 모의계좌 도메인은 500 에러). --kis-account real 필요. - short_balance_ratio(잔고율) ← 여전히 미확보. KIS API도 제공하지 않음(KRX 공매도종합 포털 대량보유 공시 전용 데이터) — --short-csv 수동 다운로드로만 가능. - microstructure_pressure(호가10단계) ← [신규] KIS Open API inquire-asking-price-exp-ccn (FHKST01010200) output1.total_askp_rsqn/total_bidp_rsqn — 실측 동작 확인(실전+모의 도메인 모두). --kis-account {real,mock}로 활성화. - macro_pressure, rate_trend, next_earnings_date, next_macro_event_date, macro_event_impact ← 기존 GAS 하네스(macro_event_synchronizer_v2, gas_event_calendar.gs)가 이미 산출/수집 중 — 이 스크립트가 중복 수집하지 않고 --context-json/ --workbook으로 그 결과를 주입받는다. - investing.com ← 직접 스크래핑 403(Cloudflare) 차단 확인. 사용 안 함. [CRITICAL] KIS API는 조회(read-only)로만 사용한다 — 매수/매도 주문은 어떤 경우에도 이 코드를 통해 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml, CI 강제 게이트 tools/validate_no_direct_api_trading_v1.py). 사용 예: python tools/build_qualitative_sell_inputs_v1.py \ --ticker 005930 --benchmark-code 069500 --sector 반도체 \ --kis-account real --short-csv Temp/krx_short_balance_manual.csv \ --context-json Temp/macro_context.json --apply """ from __future__ import annotations import argparse import datetime as dt import json import sys from pathlib import Path from typing import Any ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from tools.fetch_naver_market_data_v1 import ( _session, compute_relative_return_20d, compute_volume_ratio_5d, fetch_price_history, ) from tools.fetch_trade_statistics_motie_v1 import ( compute_sector_export_trend, load_trade_statistics_csv, ) from src.quant_engine.qualitative_sell_strategy_v1 import ( compute_microstructure_pressure_from_orderbook, compute_qualitative_sell_strategy, compute_short_interest_composite, ) from src.quant_engine.qualitative_sell_strategy_store_v1 import ( QualitativeSellStoreSpec, insert_sell_strategy_result, resolve_store_path, ) DEFAULT_OUTPUT_DIR = ROOT / "outputs" / "qualitative_sell_strategy" DEFAULT_SQLITE_DB = DEFAULT_OUTPUT_DIR / "qualitative_sell_strategy.db" def _kst_now_iso() -> str: return dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat() def _parse_date(value: str | None) -> dt.date | None: if not value: return None try: return dt.date.fromisoformat(value) except ValueError: return None def load_short_interest_csv(path: Path, code: str) -> dict[str, Any]: """KRX 공매도종합포털 수동 다운로드 CSV. 컬럼: 종목코드, 잔고율, 잔고율변화20일, 거래비중.""" import csv with path.open(encoding="utf-8-sig", newline="") as f: for row in csv.DictReader(f): row_code = str(row.get("종목코드") or row.get("code") or "").strip().zfill(6) if row_code == code: return { "short_balance_ratio": float(row.get("잔고율") or row.get("short_balance_ratio") or 0), "short_balance_ratio_chg_20d": float(row.get("잔고율변화20일") or row.get("short_balance_ratio_chg_20d") or 0), "short_turnover_share": float(row.get("거래비중") or row.get("short_turnover_share") or 0), } return {} def fetch_kis_supplement(code: str, kis_account: str | None) -> dict[str, Any]: """KIS Open API에서 short_turnover_share(공매도거래비중)와 microstructure_pressure (호가10단계)를 조회한다. 조회(read-only)만 수행 — 주문 관련 호출 없음.""" if not kis_account: return {} from src.quant_engine.kis_api_client_v1 import KisCredentials, get_asking_price_10_level, get_daily_short_sale result: dict[str, Any] = {} try: creds = KisCredentials.load(kis_account) except RuntimeError as exc: return {"kis_error": str(exc)} try: ob = get_asking_price_10_level(creds, code) micro = compute_microstructure_pressure_from_orderbook(ob.get("output1", {})) if micro.get("status") == "OK": result["microstructure_pressure"] = micro["microstructure_pressure"] except Exception as exc: # noqa: BLE001 — KIS 호출 실패가 전체 파이프라인을 막지 않음 result["kis_orderbook_error"] = str(exc) try: today = dt.date.today() start = (today - dt.timedelta(days=10)).strftime("%Y%m%d") end = today.strftime("%Y%m%d") ss = get_daily_short_sale(creds, code, start, end) rows = ss.get("output2") or [] if rows: latest = rows[0] ssts_vol_rlim = latest.get("ssts_vol_rlim") if ssts_vol_rlim is not None: result["short_turnover_share"] = float(ssts_vol_rlim) except Exception as exc: # noqa: BLE001 result["kis_short_sale_error"] = str(exc) return result def build_ctx_for_ticker( code: str, benchmark_code: str, sector: str | None, earnings_outlook: str, trade_csv: Path | None, short_csv: Path | None, external_context: dict[str, Any], kis_account: str | None = None, ) -> dict[str, Any]: session = _session() price = fetch_price_history(session, code) benchmark = fetch_price_history(session, benchmark_code) relative_return_20d = compute_relative_return_20d(price.get("rows", []), benchmark.get("rows", [])) volume_ratio_5d = compute_volume_ratio_5d(price.get("rows", [])) kis_supplement = fetch_kis_supplement(code, kis_account) short_inputs: dict[str, Any] = {} if short_csv and short_csv.exists(): short_inputs = load_short_interest_csv(short_csv, code) if "short_turnover_share" in kis_supplement: short_inputs["short_turnover_share"] = kis_supplement["short_turnover_share"] short_inputs.setdefault("relative_return_20d", relative_return_20d) short_inputs.setdefault("volume_ratio_5d", volume_ratio_5d) short_inputs.setdefault("earnings_outlook", earnings_outlook) short_interest = compute_short_interest_composite(short_inputs) sector_export_trend = None if trade_csv and trade_csv.exists() and sector: rows = load_trade_statistics_csv(trade_csv) export_result = compute_sector_export_trend(rows, sector, compare="yoy") if export_result.get("status") == "OK": sector_export_trend = export_result["sector_export_trend"] fundamental_trajectory = external_context.get("fundamental_trajectory") if fundamental_trajectory is None and sector_export_trend is not None: fundamental_trajectory = max(-1.0, min(1.0, -sector_export_trend / 15.0)) ctx: dict[str, Any] = { "today": dt.date.today(), "macro_pressure": external_context.get("macro_pressure"), "fundamental_trajectory": fundamental_trajectory, "short_interest_pressure": short_interest.get("short_interest_pressure"), "microstructure_pressure": kis_supplement.get("microstructure_pressure", external_context.get("microstructure_pressure")), "liquidity_rotation_risk": external_context.get("liquidity_rotation_risk"), "earnings_outlook": earnings_outlook, "next_earnings_date": _parse_date(external_context.get("next_earnings_date")), "next_macro_event_date": _parse_date(external_context.get("next_macro_event_date")), "macro_event_impact": external_context.get("macro_event_impact"), "rate_trend": external_context.get("rate_trend"), } return { "code": code, "ctx": ctx, "short_interest_composite": short_interest, "sector_export_trend": sector_export_trend, "relative_return_20d": relative_return_20d, "volume_ratio_5d": volume_ratio_5d, "kis_supplement": kis_supplement, "generated_at": _kst_now_iso(), } def process_one( ticker: str, name: str, benchmark_code: str, sector: str | None, earnings_outlook: str, trade_csv: Path | None, short_csv: Path | None, workbook: Path | None, context_json: Path | None, kis_account: str | None = None, ) -> dict[str, Any]: external_context: dict[str, Any] = {} if context_json and context_json.exists(): external_context = json.loads(context_json.read_text(encoding="utf-8")) elif workbook and workbook.exists(): from tools.build_macro_context_from_workbook_v1 import build_context_for_ticker external_context = build_context_for_ticker(workbook, ticker, name) assembled = build_ctx_for_ticker( code=ticker, benchmark_code=benchmark_code, sector=sector, earnings_outlook=earnings_outlook, trade_csv=trade_csv, short_csv=short_csv, external_context=external_context, kis_account=kis_account, ) decision = compute_qualitative_sell_strategy(assembled["ctx"]) result = {**assembled, "decision": decision} result["ctx"] = {k: (v.isoformat() if isinstance(v, dt.date) else v) for k, v in result["ctx"].items()} return result def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--ticker", default=None, help="6자리 종목코드(단일 실행 시 필수)") ap.add_argument("--name", default=None, help="실적발표 매칭용 종목명(한글)") ap.add_argument("--benchmark-code", default="069500") ap.add_argument("--sector", default=None, help="fetch_trade_statistics_motie_v1.SECTOR_HS_MAP 키") ap.add_argument("--earnings-outlook", default="STABLE", choices=["IMPROVING", "STABLE", "DETERIORATING"]) ap.add_argument("--trade-csv", type=Path, default=None) ap.add_argument("--short-csv", type=Path, default=None, help="KRX 공매도종합포털 수동 다운로드 CSV") ap.add_argument("--context-json", type=Path, default=None, help="macro_pressure/rate_trend/이벤트일 등 외부 산출값 JSON(수동)") ap.add_argument("--workbook", type=Path, default=None, help="GatherTradingData.xlsx — macro/event_risk/event_calendar 시트에서 컨텍스트 자동 추출(권장)") ap.add_argument("--batch", action="store_true", help="--workbook의 account_snapshot 실보유 종목 전체 순회(국내 6자리 코드만)") ap.add_argument("--kis-account", choices=["real", "mock"], default=None, help="KIS Open API로 호가10단계/공매도거래비중 보강 조회(read-only). " "공매도 일별추이는 real 도메인만 동작 확인됨(mock은 500 에러).") ap.add_argument("--apply", action="store_true", help="outputs/qualitative_sell_strategy/.json 저장") ap.add_argument("--sqlite-db", type=Path, default=DEFAULT_SQLITE_DB, help="JSON 저장과 병행해 시계열 SQLite에도 기록(GAS/xlsx와 무관한 추가 저장소)") ap.add_argument("--store-backend", default="sqlite", help="Storage backend contract placeholder (sqlite today, postgresql planned)") ap.add_argument("--store-location", default=None, help="Backend location/DSN. sqlite path or future postgres DSN.") ap.add_argument("--no-sqlite", action="store_true", help="SQLite 기록 비활성화") args = ap.parse_args() store_db = resolve_store_path( QualitativeSellStoreSpec( backend=args.store_backend, location=args.store_location or args.sqlite_db, ), ROOT, ) if args.batch: if not args.workbook or not args.workbook.exists(): raise SystemExit("--batch는 --workbook 경로가 필요합니다") from tools.build_macro_context_from_workbook_v1 import read_positions positions = [p for p in read_positions(args.workbook) if str(p["ticker"]).isdigit() and len(str(p["ticker"])) == 6] if args.apply: DEFAULT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) results = [] for pos in positions: try: result = process_one( ticker=pos["ticker"], name=str(pos.get("name") or ""), benchmark_code=args.benchmark_code, sector=args.sector, earnings_outlook=args.earnings_outlook, trade_csv=args.trade_csv, short_csv=args.short_csv, workbook=args.workbook, context_json=None, kis_account=args.kis_account, ) except Exception as exc: # noqa: BLE001 — 종목 1건 실패가 배치 전체를 막지 않음 result = {"code": pos["ticker"], "status": "FETCH_ERROR", "note": str(exc)} results.append(result) if args.apply: out_path = DEFAULT_OUTPUT_DIR / f"{pos['ticker']}.json" out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") if not args.no_sqlite and result.get("status") != "FETCH_ERROR": insert_sell_strategy_result(store_db, result) error_count = sum(1 for r in results if r.get("status") == "FETCH_ERROR") action_counts: dict[str, int] = {} for r in results: action = (r.get("decision") or {}).get("action", "N/A") action_counts[action] = action_counts.get(action, 0) + 1 summary = { "generated_at": _kst_now_iso(), "ticker_count": len(results), "error_count": error_count, "action_counts": action_counts, } print(f"SUMMARY: {json.dumps(summary, ensure_ascii=False)}") if args.apply: (DEFAULT_OUTPUT_DIR / "_batch_summary.json").write_text( json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"written {len(results)} files to {DEFAULT_OUTPUT_DIR}") else: print(json.dumps(results, ensure_ascii=False, indent=2)) # 절반 이상 실패면 CI에서 빨간불로 보이도록 — 호출결과를 로그만으로 확인 가능하게 함 if results and error_count / len(results) >= 0.5: print(f"BATCH_GATE: FAIL — error_count={error_count}/{len(results)}") return 1 print("BATCH_GATE: PASS") return 0 if not args.ticker: raise SystemExit("--ticker 또는 --batch 중 하나는 필수입니다") result = process_one( ticker=args.ticker, name=args.name or "", benchmark_code=args.benchmark_code, sector=args.sector, earnings_outlook=args.earnings_outlook, trade_csv=args.trade_csv, short_csv=args.short_csv, workbook=args.workbook, context_json=args.context_json, kis_account=args.kis_account, ) if args.apply: DEFAULT_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) out_path = DEFAULT_OUTPUT_DIR / f"{args.ticker}.json" out_path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8") if not args.no_sqlite: insert_sell_strategy_result(store_db, result) print(f"written: {out_path}") else: print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())