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 일일 스케줄(장마감 후) + 파이프라인 계약 검증 게이트
147 lines
5.1 KiB
Python
147 lines
5.1 KiB
Python
"""qualitative_sell_strategy_v1 산출물의 SQLite 시계열 저장소.
|
|
|
|
GAS/xlsx 구조와 완전히 분리된 추가(additive) 저장소다 — 이 모듈이 다루는 데이터는
|
|
순수 Python 산출물(KIS API 수집 + confluence 판단 결과)이며, GAS가 쓰지도 읽지도
|
|
않고 사람이 시트에서 직접 편집하지도 않는다. 기존 outputs/qualitative_sell_strategy/
|
|
*.json 파일 출력을 대체하지 않고 병행 저장한다(JSON은 1회성 점검용, SQLite는 시계열
|
|
추이 조회용). 표준 라이브러리 sqlite3만 사용 — 추가 의존성 없음.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
from dataclasses import dataclass
|
|
from typing import Any
|
|
|
|
from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
|
|
|
|
SCHEMA = """
|
|
CREATE TABLE IF NOT EXISTS sell_strategy_results (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
code TEXT NOT NULL,
|
|
generated_at TEXT NOT NULL,
|
|
action TEXT,
|
|
conviction TEXT,
|
|
market_regime TEXT,
|
|
composite_score REAL,
|
|
rationale TEXT,
|
|
raw_json TEXT NOT NULL,
|
|
inserted_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_sell_strategy_code_time
|
|
ON sell_strategy_results(code, generated_at);
|
|
|
|
CREATE TABLE IF NOT EXISTS satellite_recommendations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ticker TEXT NOT NULL,
|
|
generated_at TEXT NOT NULL,
|
|
satellite_action TEXT,
|
|
attractiveness_score REAL,
|
|
market_regime TEXT,
|
|
raw_json TEXT NOT NULL,
|
|
inserted_at TEXT DEFAULT (datetime('now'))
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_satellite_ticker_time
|
|
ON satellite_recommendations(ticker, generated_at);
|
|
"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class QualitativeSellStoreSpec(StoreSpec):
|
|
pass
|
|
|
|
|
|
def default_qualitative_sell_store_path(root: Path) -> Path:
|
|
return default_sqlite_store_path(root, "qualitative_sell_strategy/qualitative_sell_strategy.db")
|
|
|
|
|
|
def resolve_store_path(spec: QualitativeSellStoreSpec, root: Path) -> Path:
|
|
backend, location = normalize_store_spec(
|
|
spec,
|
|
root,
|
|
default_sqlite_name="qualitative_sell_strategy/qualitative_sell_strategy.db",
|
|
)
|
|
if backend != "sqlite":
|
|
raise ValueError(
|
|
"qualitative_sell_strategy_store_v1 currently executes on sqlite only; "
|
|
"the caller contract already allows future PostgreSQL swap-in."
|
|
)
|
|
return Path(location)
|
|
|
|
|
|
def init_db(db_path: Path) -> None:
|
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
conn.executescript(SCHEMA)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def insert_sell_strategy_result(db_path: Path, result: dict[str, Any]) -> None:
|
|
"""build_qualitative_sell_inputs_v1.process_one()의 반환값(dict)을 그대로 받는다."""
|
|
init_db(db_path)
|
|
decision = result.get("decision") or {}
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO sell_strategy_results "
|
|
"(code, generated_at, action, conviction, market_regime, composite_score, rationale, raw_json) "
|
|
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(
|
|
result.get("code"),
|
|
result.get("generated_at"),
|
|
decision.get("action"),
|
|
decision.get("conviction"),
|
|
decision.get("market_regime"),
|
|
decision.get("composite_score"),
|
|
decision.get("rationale"),
|
|
json.dumps(result, ensure_ascii=False, default=str),
|
|
),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def insert_satellite_recommendation(db_path: Path, generated_at: str, candidate: dict[str, Any]) -> None:
|
|
"""build_satellite_candidate_recommendations_v1.py results[i] 항목 하나를 받는다."""
|
|
init_db(db_path)
|
|
score = candidate.get("score") or {}
|
|
conn = sqlite3.connect(db_path)
|
|
try:
|
|
conn.execute(
|
|
"INSERT INTO satellite_recommendations "
|
|
"(ticker, generated_at, satellite_action, attractiveness_score, market_regime, raw_json) "
|
|
"VALUES (?, ?, ?, ?, ?, ?)",
|
|
(
|
|
candidate.get("ticker"),
|
|
generated_at,
|
|
score.get("satellite_action"),
|
|
score.get("attractiveness_score"),
|
|
score.get("market_regime"),
|
|
json.dumps(candidate, ensure_ascii=False, default=str),
|
|
),
|
|
)
|
|
conn.commit()
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
def fetch_recent_sell_strategy_results(db_path: Path, code: str, limit: int = 20) -> list[dict[str, Any]]:
|
|
if not db_path.exists():
|
|
return []
|
|
conn = sqlite3.connect(db_path)
|
|
conn.row_factory = sqlite3.Row
|
|
try:
|
|
rows = conn.execute(
|
|
"SELECT code, generated_at, action, conviction, market_regime, composite_score, rationale "
|
|
"FROM sell_strategy_results WHERE code = ? ORDER BY generated_at DESC LIMIT ?",
|
|
(code, limit),
|
|
).fetchall()
|
|
return [dict(row) for row in rows]
|
|
finally:
|
|
conn.close()
|