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 일일 스케줄(장마감 후) + 파이프라인 계약 검증 게이트
205 lines
8.3 KiB
Python
205 lines
8.3 KiB
Python
"""GatherTradingData.xlsx에서 실제 매크로/이벤트/포지션 컨텍스트를 추출.
|
|
|
|
build_qualitative_sell_inputs_v1.py의 --context-json을 수동 작성하지 않고, 이미
|
|
GAS 하네스가 산출/수집해 둔 시트 값을 그대로 읽어 자동 조립한다(중복 수집 금지
|
|
원칙 — qualitative_sell_strategy_v1.yaml:data_sources 참조).
|
|
|
|
실측 확인된 시트/컬럼(2026-06-21):
|
|
- macro 시트: Symbol='MRS_COMPUTED'.Close = market_risk_score(0~10, 하네스 산출).
|
|
Symbol='^TNX'(US10Y_Yield).Ret20D = 20일 금리추세 proxy(국내 기준금리 시트 없음 —
|
|
한국은행 금통위 일정은 event_calendar Type='BOK'로 별도 포착).
|
|
- event_risk 시트: Date/DaysLeft/Event/Type/Impact(HIGH/MEDIUM/LOW)/Alert/AsOfDate.
|
|
- event_calendar 시트: Date/Event/Type(EARNINGS/FOMC/BOK/...)/Impact/DaysLeft 등.
|
|
Type='EARNINGS'에 종목명이 Event 텍스트에 포함된 행만 종목별 실적발표일로 매칭.
|
|
- account_snapshot 시트: ticker/name/holding_quantity/parse_status='CAPTURE_READ_OK'.
|
|
"""
|
|
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))
|
|
|
|
RATE_RISING_THRESHOLD_PCT = 2.0
|
|
RATE_FALLING_THRESHOLD_PCT = -2.0
|
|
|
|
|
|
def _read_sheet_rows(xlsx_path: Path, sheet: str) -> tuple[tuple, list[dict[str, Any]]]:
|
|
"""헤더 행을 탐색한다. 일부 시트(macro/event_risk)는 1행에 'updated: ...' 배너
|
|
셀 1개만 있고 실제 헤더는 2행 — 비어있거나 단일 셀뿐인 선행 행은 건너뛴다."""
|
|
wb = load_workbook(xlsx_path, read_only=True, data_only=True)
|
|
ws = wb[sheet]
|
|
rows_iter = ws.iter_rows(min_row=1, values_only=True)
|
|
header: tuple = ()
|
|
for row in rows_iter:
|
|
non_empty = [c for c in row if c is not None]
|
|
if len(non_empty) >= 2:
|
|
header = row
|
|
break
|
|
rows = [dict(zip(header, row)) for row in rows_iter if any(c is not None for c in row)]
|
|
return header, rows
|
|
|
|
|
|
def read_macro_pressure_and_regime(xlsx_path: Path) -> dict[str, Any]:
|
|
"""MRS_COMPUTED.Close(0~10) -> macro_pressure(-1~+1, 위험도 높을수록 매도압력).
|
|
|
|
^TNX Ret20D(%) -> rate_trend(RISING/FLAT/FALLING) — 국내 기준금리 시트가 없어
|
|
미국채 10년물 20일 변화율을 proxy로 사용한다(국내 금리는 미 국채와 강한 동행성).
|
|
"""
|
|
_, rows = _read_sheet_rows(xlsx_path, "macro")
|
|
by_symbol = {row.get("Symbol"): row for row in rows}
|
|
|
|
mrs_row = by_symbol.get("MRS_COMPUTED")
|
|
macro_pressure = None
|
|
market_risk_score = None
|
|
if mrs_row is not None and isinstance(mrs_row.get("Close"), (int, float)):
|
|
market_risk_score = float(mrs_row["Close"])
|
|
macro_pressure = max(-1.0, min(1.0, (market_risk_score / 10.0) * 2.0 - 1.0))
|
|
|
|
tnx_row = by_symbol.get("^TNX")
|
|
rate_trend = None
|
|
rate_ret20d_pct = None
|
|
if tnx_row is not None and tnx_row.get("Ret20D") not in (None, ""):
|
|
try:
|
|
rate_ret20d_pct = float(tnx_row["Ret20D"])
|
|
except (TypeError, ValueError):
|
|
rate_ret20d_pct = None
|
|
if rate_ret20d_pct is not None:
|
|
if rate_ret20d_pct >= RATE_RISING_THRESHOLD_PCT:
|
|
rate_trend = "RISING"
|
|
elif rate_ret20d_pct <= RATE_FALLING_THRESHOLD_PCT:
|
|
rate_trend = "FALLING"
|
|
else:
|
|
rate_trend = "FLAT"
|
|
|
|
regime_row = by_symbol.get("REGIME_PRELIM")
|
|
regime_prelim = regime_row.get("Close") if regime_row else None
|
|
|
|
return {
|
|
"macro_pressure": macro_pressure,
|
|
"market_risk_score": market_risk_score,
|
|
"rate_trend": rate_trend,
|
|
"rate_ret20d_pct": rate_ret20d_pct,
|
|
"regime_prelim": regime_prelim,
|
|
"macro_pressure_source": "GatherTradingData.xlsx:macro",
|
|
}
|
|
|
|
|
|
def read_next_macro_event(xlsx_path: Path, today: dt.date | None = None) -> dict[str, Any]:
|
|
"""event_risk 시트에서 오늘 이후 가장 가까운 HIGH 임팩트 이벤트일."""
|
|
today = today or dt.date.today()
|
|
_, rows = _read_sheet_rows(xlsx_path, "event_risk")
|
|
candidates = []
|
|
for row in rows:
|
|
event_date = row.get("Date")
|
|
if not isinstance(event_date, dt.datetime):
|
|
continue
|
|
event_date = event_date.date()
|
|
if event_date < today or row.get("Impact") not in {"HIGH"}:
|
|
continue
|
|
candidates.append((event_date, row.get("Event"), row.get("Impact")))
|
|
if not candidates:
|
|
return {"next_macro_event_date": None, "macro_event_impact": None}
|
|
candidates.sort(key=lambda item: item[0])
|
|
event_date, event_name, impact = candidates[0]
|
|
return {
|
|
"next_macro_event_date": event_date.isoformat(),
|
|
"macro_event_impact": impact,
|
|
"macro_event_name": event_name,
|
|
"macro_event_source": "GatherTradingData.xlsx:event_risk",
|
|
}
|
|
|
|
|
|
def read_next_earnings_date(xlsx_path: Path, company_name: str, today: dt.date | None = None) -> dict[str, Any]:
|
|
"""event_calendar에서 Type='EARNINGS'이며 Event 텍스트에 종목명이 포함된 가장 빠른 미래 일정."""
|
|
today = today or dt.date.today()
|
|
_, rows = _read_sheet_rows(xlsx_path, "event_calendar")
|
|
candidates = []
|
|
name = (company_name or "").strip()
|
|
if not name:
|
|
return {"next_earnings_date": None, "earnings_event_impact": None}
|
|
for row in rows:
|
|
if row.get("Type") != "EARNINGS":
|
|
continue
|
|
event_text = str(row.get("Event") or "")
|
|
if name not in event_text:
|
|
continue
|
|
event_date = row.get("Date")
|
|
if isinstance(event_date, dt.datetime):
|
|
event_date = event_date.date()
|
|
elif isinstance(event_date, str):
|
|
try:
|
|
event_date = dt.date.fromisoformat(event_date)
|
|
except ValueError:
|
|
continue
|
|
else:
|
|
continue
|
|
if event_date < today:
|
|
continue
|
|
candidates.append((event_date, row.get("Impact")))
|
|
if not candidates:
|
|
return {"next_earnings_date": None, "earnings_event_impact": None}
|
|
candidates.sort(key=lambda item: item[0])
|
|
event_date, impact = candidates[0]
|
|
return {
|
|
"next_earnings_date": event_date.isoformat(),
|
|
"earnings_event_impact": impact,
|
|
"earnings_source": "GatherTradingData.xlsx:event_calendar",
|
|
}
|
|
|
|
|
|
def read_positions(xlsx_path: Path) -> list[dict[str, Any]]:
|
|
"""account_snapshot에서 실제 보유 종목 목록(CAPTURE_READ_OK, 보유수량>0)."""
|
|
_, rows = _read_sheet_rows(xlsx_path, "account_snapshot")
|
|
positions: dict[str, dict[str, Any]] = {}
|
|
for row in rows:
|
|
if row.get("parse_status") != "CAPTURE_READ_OK":
|
|
continue
|
|
ticker_raw = row.get("ticker")
|
|
qty = row.get("holding_quantity") or 0
|
|
if ticker_raw is None or not isinstance(qty, (int, float)) or qty <= 0:
|
|
continue
|
|
ticker = str(ticker_raw)
|
|
ticker = ticker.zfill(6) if ticker.isdigit() else ticker
|
|
entry = positions.setdefault(ticker, {"ticker": ticker, "name": row.get("name"), "holding_quantity": 0.0})
|
|
entry["holding_quantity"] += float(qty) # 소수주 분리 행 합산
|
|
return list(positions.values())
|
|
|
|
|
|
def build_context_for_ticker(xlsx_path: Path, ticker: str, company_name: str) -> dict[str, Any]:
|
|
today = dt.date.today()
|
|
ctx: dict[str, Any] = {}
|
|
ctx.update(read_macro_pressure_and_regime(xlsx_path))
|
|
ctx.update(read_next_macro_event(xlsx_path, today))
|
|
ctx.update(read_next_earnings_date(xlsx_path, company_name, today))
|
|
return ctx
|
|
|
|
|
|
def main() -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__)
|
|
ap.add_argument("--xlsx", type=Path, default=ROOT / "GatherTradingData.xlsx")
|
|
ap.add_argument("--ticker", default=None)
|
|
ap.add_argument("--name", default=None, help="실적발표 일정 매칭용 종목명(한글)")
|
|
ap.add_argument("--list-positions", action="store_true")
|
|
args = ap.parse_args()
|
|
|
|
if args.list_positions:
|
|
print(json.dumps(read_positions(args.xlsx), ensure_ascii=False, indent=2))
|
|
return 0
|
|
|
|
result = build_context_for_ticker(args.xlsx, args.ticker or "", args.name or "")
|
|
print(json.dumps(result, ensure_ascii=False, indent=2, default=str))
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|