da0e1b0f7e
매크로·실적·펀더멘털·공매도수급·호가미시구조·대내외 변수 5개 독립 팩터군의 confluence(최소 3/5 합의) 없이는 매도 트리거를 금지하는 정성적 매도판단 엔진과, 보유종목 제외 위성후보 추천 로직을 추가한다. - 단일 팩터 임계값 돌파만으로는 매도 신호를 생성하지 않음 (mechanical_sell_prohibited=true) - 데이터 결측 시 항상 DATA_MISSING/INSUFFICIENT_DATA_NO_ACTION — 추정값으로 채우지 않음 - KIS 호가10단계·공매도거래비중 + Naver 시세/수급 스크래핑 입력 연동 - SQLite 시계열 저장 + 사후 적중률 자체평가 (evaluate_qualitative_sell_strategy_accuracy_v1) - Gitea 일일 스케줄(장마감 후) + 파이프라인 계약 검증 게이트
140 lines
6.3 KiB
Python
140 lines
6.3 KiB
Python
"""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())
|