"""universe 시트(미보유 위성 유니버스) 전체를 SATELLITE_CANDIDATE_SCORE_V1로 평가. WBS-6 후속 — qualitative_sell_strategy_v1.compute_satellite_candidate_score를 실제 GatherTradingData.xlsx universe 시트(Ticker/Name/Sector/AddedDate, 실측 확인됨)에 연동. 보유 종목(account_snapshot)은 제외하고 미보유 후보만 평가한다. universe.Sector 한글 라벨은 fetch_trade_statistics_motie_v1.SECTOR_HS_MAP 키와 1:1로 일치하지 않으므로 부분 문자열 매칭으로 연결한다. 매칭 실패 종목은 sector_export_trend를 추정하지 않고 None으로 두어 컨플루언스 부족(INSUFFICIENT_DATA_NO_ACTION)으로 자연 처리된다 (추정 금지 원칙 — qualitative_sell_strategy_v1.yaml과 동일). """ from __future__ import annotations import argparse import datetime as dt import json import sys from pathlib import Path from typing import Any from openpyxl import load_workbook ROOT = Path(__file__).resolve().parents[1] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from tools.build_macro_context_from_workbook_v1 import _read_sheet_rows, read_positions, read_macro_pressure_and_regime from tools.fetch_naver_market_data_v1 import _session, compute_relative_return_20d, fetch_price_history from tools.fetch_trade_statistics_motie_v1 import SECTOR_HS_MAP, compute_sector_export_trend, load_trade_statistics_csv from src.quant_engine.qualitative_sell_strategy_v1 import compute_satellite_candidate_score from src.quant_engine.qualitative_sell_strategy_store_v1 import ( QualitativeSellStoreSpec, insert_satellite_recommendation, resolve_store_path, ) DEFAULT_OUTPUT = ROOT / "outputs" / "qualitative_sell_strategy" / "satellite_recommendations.json" DEFAULT_SQLITE_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db" def map_universe_sector_to_hs_sector(universe_sector: str) -> str | None: text = str(universe_sector or "") for hs_sector in SECTOR_HS_MAP: if hs_sector in text: return hs_sector return None def read_universe_candidates(xlsx_path: Path, exclude_tickers: set[str]) -> list[dict[str, Any]]: _, rows = _read_sheet_rows(xlsx_path, "universe") candidates = [] for row in rows: ticker = str(row.get("Ticker") or "").strip() if not ticker or ticker in exclude_tickers: continue candidates.append({ "ticker": ticker, "name": row.get("Name"), "universe_sector": row.get("Sector"), "hs_sector": map_universe_sector_to_hs_sector(row.get("Sector")), }) return candidates def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--workbook", type=Path, default=ROOT / "GatherTradingData.xlsx") ap.add_argument("--benchmark-code", default="069500") ap.add_argument("--trade-csv", type=Path, default=None, help="관세청/산업통상부 수출입통계 CSV — 없으면 sector_export_trend는 전부 DATA_MISSING") ap.add_argument("--apply", action="store_true", help=str(DEFAULT_OUTPUT) + " 저장") 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, ) held = {p["ticker"] for p in read_positions(args.workbook) if str(p["ticker"]).isdigit()} candidates = read_universe_candidates(args.workbook, held) trade_rows = load_trade_statistics_csv(args.trade_csv) if args.trade_csv and args.trade_csv.exists() else [] macro = read_macro_pressure_and_regime(args.workbook) rate_trend = macro.get("rate_trend") session = _session() benchmark = fetch_price_history(session, args.benchmark_code) results = [] for cand in candidates: sector_export_trend = None if cand["hs_sector"] and trade_rows: export_result = compute_sector_export_trend(trade_rows, cand["hs_sector"], compare="yoy") if export_result.get("status") == "OK": sector_export_trend = export_result["sector_export_trend"] relative_return_20d = None if cand["ticker"].isdigit() and len(cand["ticker"]) == 6: try: price = fetch_price_history(session, cand["ticker"]) relative_return_20d = compute_relative_return_20d(price.get("rows", []), benchmark.get("rows", [])) except Exception: # noqa: BLE001 — 개별 종목 수집 실패가 전체 배치를 막지 않음 relative_return_20d = None score = compute_satellite_candidate_score({ "sector_export_trend": sector_export_trend, "fundamental_trajectory": None, # universe 시트에 펀더멘털 추세 없음 — 추정 금지 "relative_return_20d": relative_return_20d, "rate_trend": rate_trend, }) results.append({**cand, "sector_export_trend": sector_export_trend, "relative_return_20d": relative_return_20d, "score": score}) output = { "generated_at": dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat(), "rate_trend": rate_trend, "candidate_count": len(results), "results": results, } if args.apply: DEFAULT_OUTPUT.parent.mkdir(parents=True, exist_ok=True) DEFAULT_OUTPUT.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding="utf-8") if not args.no_sqlite: for cand in results: insert_satellite_recommendation(store_db, output["generated_at"], cand) print(f"written: {DEFAULT_OUTPUT} ({len(results)} candidates)") else: print(json.dumps(output, ensure_ascii=False, indent=2)) return 0 if __name__ == "__main__": raise SystemExit(main())