Merge pull request '은퇴자산 퀀트엔진: KIS 연동 + 비판적 리뷰 기반 WBS-7 보완·고도화' (#66) from codex/roadmap-publish into main
Reviewed-on: http://192.168.123.100:8418/KimJaeHyun/myfinance/pulls/66
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
name: Calibration Backlog (Registry Drift Watch)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "15 2 * * 1-5" # UTC 02:15 = KST 11:15, weekday backlog update
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build-calibration-backlog:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
run: |
|
||||
if [ -d .git ]; then
|
||||
git remote set-url origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
else
|
||||
git init
|
||||
git remote add origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
fi
|
||||
git fetch origin main --depth=1
|
||||
git reset --hard FETCH_HEAD
|
||||
|
||||
- name: Configure Runtime Paths
|
||||
run: |
|
||||
export PATH=/usr/local/bin:$PATH
|
||||
echo "/usr/local/bin" >> $GITHUB_PATH
|
||||
/usr/bin/python3 --version
|
||||
|
||||
- name: Setup Python Environment
|
||||
run: |
|
||||
VENV_BASE=/volume1/gitea/python_venv
|
||||
REQ_HASH=$(md5sum tools/build_calibration_priority_v1.py 2>/dev/null | cut -d' ' -f1 || echo "calib-default")
|
||||
VENV="$VENV_BASE/$REQ_HASH"
|
||||
|
||||
if [ ! -f "$VENV/bin/python" ]; then
|
||||
mkdir -p "$VENV_BASE"
|
||||
/usr/bin/python3 -m venv "$VENV"
|
||||
if [ ! -f "$VENV/bin/pip" ]; then
|
||||
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
|
||||
"$VENV/bin/python" get-pip.py --quiet
|
||||
rm get-pip.py
|
||||
fi
|
||||
"$VENV/bin/pip" install --upgrade pip --quiet
|
||||
"$VENV/bin/pip" install pyyaml --quiet
|
||||
fi
|
||||
echo "$VENV/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: Validate Calibration Registry
|
||||
run: python3 tools/validate_calibration_registry_v1.py
|
||||
|
||||
- name: Build Calibration Priority Backlog
|
||||
run: python3 tools/build_calibration_priority_v1.py
|
||||
|
||||
- name: Build Calibration Change Ledger
|
||||
run: python3 tools/build_calibration_change_ledger_v4.py
|
||||
|
||||
- name: Build Calibration Review Report
|
||||
run: python3 tools/build_calibration_review_report_v1.py
|
||||
|
||||
- name: Build Calibration Approval List
|
||||
run: python3 tools/build_calibration_approval_list_v1.py
|
||||
|
||||
- name: Build Calibration Decision Draft
|
||||
run: python3 tools/build_calibration_decision_draft_v1.py
|
||||
|
||||
- name: Validate Calibration Change Ledger
|
||||
run: python3 tools/validate_calibration_change_ledger_v1.py
|
||||
|
||||
- name: Summarize Backlog
|
||||
if: always()
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
echo "=== Calibration Backlog Result ==="
|
||||
echo "status: $STATUS"
|
||||
echo "priority: Temp/calibration_priority_v1.json"
|
||||
echo "ledger: Temp/calibration_change_ledger_v4.json"
|
||||
echo "review: Temp/calibration_review_report_v1.md"
|
||||
echo "approval: Temp/calibration_approval_list_v1.md"
|
||||
echo "decision: Temp/calibration_decision_draft_v1.md"
|
||||
@@ -98,6 +98,15 @@ jobs:
|
||||
fi
|
||||
node --version && npm --version
|
||||
|
||||
- name: "[CRITICAL] No Direct API Trading Gate"
|
||||
run: python3 tools/validate_no_direct_api_trading_v1.py
|
||||
|
||||
- name: "[CRITICAL] Validate KIS API Credentials (mock)"
|
||||
env:
|
||||
KIS_APP_Key_TEST: ${{ secrets.KIS_APP_KEY_TEST }}
|
||||
KIS_APP_Secret_TEST: ${{ secrets.KIS_APP_SECRET_TEST }}
|
||||
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930
|
||||
|
||||
- name: Validate Specs
|
||||
run: python3 tools/validate_specs.py
|
||||
|
||||
@@ -110,6 +119,33 @@ jobs:
|
||||
- name: Validate Harness Coverage Audit
|
||||
run: python3 tools/harness_coverage_auditor.py
|
||||
|
||||
- name: Validate Platform Transition WBS
|
||||
run: python3 tools/validate_platform_transition_wbs_v1.py
|
||||
|
||||
- name: Build Calibration Priority Backlog
|
||||
run: python3 tools/build_calibration_priority_v1.py
|
||||
|
||||
- name: Build Calibration Change Ledger
|
||||
run: python3 tools/build_calibration_change_ledger_v4.py
|
||||
|
||||
- name: Validate Calibration Change Ledger
|
||||
run: python3 tools/validate_calibration_change_ledger_v1.py
|
||||
|
||||
- name: Validate Qualitative Sell Strategy Pipeline
|
||||
run: python3 tools/validate_qualitative_sell_strategy_pipeline_v1.py
|
||||
|
||||
- name: Validate Gitea Secrets Contract
|
||||
run: python3 tools/validate_gitea_secrets_contract_v1.py
|
||||
|
||||
- name: Validate Snapshot Admin Workflow
|
||||
run: python3 tools/validate_snapshot_admin_workflow_v1.py
|
||||
|
||||
- name: Validate Snapshot Admin Web UI
|
||||
run: python3 tools/validate_snapshot_admin_web_v1.py
|
||||
|
||||
- name: Validate Storage Backend Contracts
|
||||
run: python3 -m pytest tests/unit/test_storage_backend_v1.py tests/unit/test_validate_kis_api_credentials_v1.py tests/unit/test_qualitative_sell_strategy_store_v1.py tests/unit/test_kis_api_client_v1.py tests/unit/test_snapshot_admin_store_v1.py tests/unit/test_snapshot_admin_web_v1.py -q
|
||||
|
||||
- name: Notify PR Result
|
||||
if: github.event_name == 'pull_request'
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
name: KIS Data Collection (SQLite Canonical Feed)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
# [중요] 이 워크플로우는 KIS Open API를 코어로 하는 read-only 데이터 수집만 수행한다.
|
||||
# xlsx를 직접 읽지 않고 GatherTradingData.json + live read-only APIs를 통해
|
||||
# SQLite canonical store를 갱신한다. 매수/매도 주문은 어떤 경우에도 실행하지 않는다.
|
||||
#
|
||||
# 스케줄: 영업일(월~금) 08:00~17:00 KST, 2시간 간격(08/10/12/14/16시).
|
||||
# Gitea Actions의 schedule cron은 UTC 기준으로 평가된다(서버 타임존이 별도
|
||||
# 설정되어 있지 않은 경우의 기본값). 아래 cron은 UTC로 작성했다:
|
||||
# KST 08:00 = UTC 전날 23:00 → 요일은 "한국 기준 평일"에 맞춰 UTC 0-4(일~목)로 이동
|
||||
# KST 10/12/14/16:00 = UTC 01/03/05/07:00, 같은 날(UTC 월~금, 1-5)
|
||||
#
|
||||
# [실제 Gitea 서버 타임존이 Asia/Seoul로 설정되어 있다면] 아래 cron을 그대로
|
||||
# "0 8,10,12,14,16 * * 1-5" 한 줄로 교체하면 된다 — 첫 실행 후 Actions 실행
|
||||
# 기록의 타임스탬프를 확인해 KST 08시 전후로 도는지 검증할 것(추정하지 말고 확인).
|
||||
#
|
||||
# 스케줄 주기 변경: 아래 schedule 목록의 cron 줄을 추가/삭제/수정하면 된다.
|
||||
# 예) 1시간 간격으로 바꾸려면 09,11,13,15시 슬롯을 추가.
|
||||
# ─────────────────────────────────────────────────────────────────
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 23 * * 0-4" # KST 월~금 08:00 (UTC 일~목 23:00)
|
||||
- cron: "0 1 * * 1-5" # KST 월~금 10:00 (UTC 01:00)
|
||||
- cron: "0 3 * * 1-5" # KST 월~금 12:00 (UTC 03:00)
|
||||
- cron: "0 5 * * 1-5" # KST 월~금 14:00 (UTC 05:00)
|
||||
- cron: "0 7 * * 1-5" # KST 월~금 16:00 (UTC 07:00)
|
||||
workflow_dispatch: # 수동 실행 — 스케줄 검증/즉시 재시도용
|
||||
|
||||
jobs:
|
||||
collect-kis-data:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
run: |
|
||||
if [ -d .git ]; then
|
||||
git remote set-url origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
else
|
||||
git init
|
||||
git remote add origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
fi
|
||||
git fetch origin main --depth=1
|
||||
git reset --hard FETCH_HEAD
|
||||
if [ ! -f GatherTradingData.json ]; then
|
||||
echo "::error::GatherTradingData.json 없음 — canonical seed snapshot이 필요합니다."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Configure Runtime Paths
|
||||
run: |
|
||||
export PATH=/usr/local/bin:$PATH
|
||||
echo "/usr/local/bin" >> $GITHUB_PATH
|
||||
/usr/bin/python3 --version
|
||||
|
||||
- name: Setup Python Environment
|
||||
run: |
|
||||
VENV_BASE=/volume1/gitea/python_venv
|
||||
REQ_HASH=$(md5sum tools/run_kis_data_collection_v1.py 2>/dev/null | cut -d' ' -f1 || echo "kis-default")
|
||||
VENV="$VENV_BASE/$REQ_HASH"
|
||||
|
||||
if [ ! -f "$VENV/bin/python" ]; then
|
||||
mkdir -p "$VENV_BASE"
|
||||
/usr/bin/python3 -m venv "$VENV"
|
||||
if [ ! -f "$VENV/bin/pip" ]; then
|
||||
curl -sS https://bootstrap.pypa.io/pip/3.8/get-pip.py -o get-pip.py
|
||||
"$VENV/bin/python" get-pip.py --quiet
|
||||
rm get-pip.py
|
||||
fi
|
||||
"$VENV/bin/pip" install --upgrade pip --quiet
|
||||
"$VENV/bin/pip" install requests beautifulsoup4 pyyaml --quiet
|
||||
ls -dt "$VENV_BASE"/*/ 2>/dev/null | tail -n +3 | xargs rm -rf 2>/dev/null || true
|
||||
fi
|
||||
echo "$VENV/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: "[CRITICAL] No Direct API Trading Gate"
|
||||
run: python3 tools/validate_no_direct_api_trading_v1.py
|
||||
|
||||
- name: "[CRITICAL] Validate KIS API Credentials (mock)"
|
||||
env:
|
||||
KIS_APP_Key_TEST: ${{ secrets.KIS_APP_KEY_TEST }}
|
||||
KIS_APP_Secret_TEST: ${{ secrets.KIS_APP_SECRET_TEST }}
|
||||
run: |
|
||||
python3 tools/validate_kis_api_credentials_v1.py \
|
||||
--account mock \
|
||||
--ticker 005930
|
||||
|
||||
- name: Collect KIS Market Data to SQLite (read-only)
|
||||
env:
|
||||
KIS_APP_Key: ${{ secrets.KIS_APP_KEY }}
|
||||
KIS_APP_Secret: ${{ secrets.KIS_APP_SECRET }}
|
||||
run: |
|
||||
python3 tools/run_kis_data_collection_v1.py \
|
||||
--input-json GatherTradingData.json \
|
||||
--sqlite-db outputs/kis_data_collection/kis_data_collection.db \
|
||||
--output-json Temp/kis_data_collection_v1.json \
|
||||
--kis-account real
|
||||
|
||||
- name: Validate SQLite Artifact
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json, sqlite3
|
||||
from pathlib import Path
|
||||
db = Path("outputs/kis_data_collection/kis_data_collection.db")
|
||||
report = Path("Temp/kis_data_collection_v1.json")
|
||||
assert db.exists(), f"missing db: {db}"
|
||||
assert report.exists(), f"missing report: {report}"
|
||||
conn = sqlite3.connect(db)
|
||||
try:
|
||||
run_count = conn.execute("SELECT COUNT(*) FROM collection_runs").fetchone()[0]
|
||||
snap_count = conn.execute("SELECT COUNT(*) FROM collection_snapshots").fetchone()[0]
|
||||
print(json.dumps({"run_count": run_count, "snapshot_count": snap_count}, ensure_ascii=False))
|
||||
assert run_count >= 1
|
||||
assert snap_count >= 1
|
||||
finally:
|
||||
conn.close()
|
||||
PY
|
||||
|
||||
- name: Notify Run Result
|
||||
if: always()
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
RUN_URL="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
SUMMARY_FILE="Temp/kis_data_collection_v1.json"
|
||||
SUMMARY_TEXT="(요약 파일 없음)"
|
||||
[ -f "$SUMMARY_FILE" ] && SUMMARY_TEXT=$(cat "$SUMMARY_FILE")
|
||||
echo "=== KIS Data Collection Result ==="
|
||||
echo "status: $STATUS"
|
||||
echo "summary: $SUMMARY_TEXT"
|
||||
echo "run log: $RUN_URL"
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Qualitative Sell Strategy (Read-Only, SQLite Canonical)
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 10 * * 1-5" # KST 19:00-ish daily post-close batch window (UTC 10:00)
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
evaluate-qualitative-sell:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
run: |
|
||||
if [ -d .git ]; then
|
||||
git remote set-url origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
else
|
||||
git init
|
||||
git remote add origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
fi
|
||||
git fetch origin main --depth=1
|
||||
git reset --hard FETCH_HEAD
|
||||
|
||||
- name: Configure Runtime Paths
|
||||
run: |
|
||||
export PATH=/usr/local/bin:$PATH
|
||||
echo "/usr/local/bin" >> $GITHUB_PATH
|
||||
/usr/bin/python3 --version
|
||||
|
||||
- name: Setup Python Environment
|
||||
run: |
|
||||
VENV_BASE=/volume1/gitea/python_venv
|
||||
REQ_HASH=$(md5sum tools/build_qualitative_sell_inputs_v1.py 2>/dev/null | cut -d' ' -f1 || echo "qual-default")
|
||||
VENV="$VENV_BASE/$REQ_HASH"
|
||||
if [ ! -f "$VENV/bin/python" ]; then
|
||||
mkdir -p "$VENV_BASE"
|
||||
/usr/bin/python3 -m venv "$VENV"
|
||||
"$VENV/bin/pip" install --upgrade pip --quiet
|
||||
"$VENV/bin/pip" install requests beautifulsoup4 pyyaml openpyxl --quiet
|
||||
fi
|
||||
echo "$VENV/bin" >> $GITHUB_PATH
|
||||
|
||||
- name: "[CRITICAL] No Direct API Trading Gate"
|
||||
run: python3 tools/validate_no_direct_api_trading_v1.py
|
||||
|
||||
- name: "[CRITICAL] Validate KIS API Credentials (mock)"
|
||||
env:
|
||||
KIS_APP_Key_TEST: ${{ secrets.KIS_APP_KEY_TEST }}
|
||||
KIS_APP_Secret_TEST: ${{ secrets.KIS_APP_SECRET_TEST }}
|
||||
run: python3 tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930
|
||||
|
||||
- name: Build Qualitative Sell Inputs (batch)
|
||||
env:
|
||||
KIS_APP_Key: ${{ secrets.KIS_APP_KEY }}
|
||||
KIS_APP_Secret: ${{ secrets.KIS_APP_SECRET }}
|
||||
run: |
|
||||
if [ -f GatherTradingData.xlsx ]; then
|
||||
python3 tools/build_qualitative_sell_inputs_v1.py \
|
||||
--batch \
|
||||
--workbook GatherTradingData.xlsx \
|
||||
--kis-account real \
|
||||
--apply
|
||||
else
|
||||
echo "GatherTradingData.xlsx missing -> skip batch build"
|
||||
fi
|
||||
|
||||
- name: Build Satellite Recommendations
|
||||
run: |
|
||||
if [ -f GatherTradingData.xlsx ]; then
|
||||
python3 tools/build_satellite_candidate_recommendations_v1.py \
|
||||
--workbook GatherTradingData.xlsx \
|
||||
--apply
|
||||
else
|
||||
echo "GatherTradingData.xlsx missing -> skip satellite build"
|
||||
fi
|
||||
|
||||
- name: Evaluate Qualitative Sell Accuracy
|
||||
run: |
|
||||
if [ -f outputs/qualitative_sell_strategy/qualitative_sell_strategy.db ]; then
|
||||
python3 tools/evaluate_qualitative_sell_strategy_accuracy_v1.py \
|
||||
--sqlite-db outputs/qualitative_sell_strategy/qualitative_sell_strategy.db
|
||||
else
|
||||
echo "qualitative_sell_strategy.db missing -> skip accuracy evaluation"
|
||||
fi
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Snapshot Admin Web Validation
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- "src/quant_engine/snapshot_admin_server_v1.py"
|
||||
- "src/quant_engine/snapshot_admin_store_v1.py"
|
||||
- "tools/run_snapshot_admin_server_v1.py"
|
||||
- "tools/validate_snapshot_admin_workflow_v1.py"
|
||||
- "tools/validate_snapshot_admin_web_v1.py"
|
||||
- "spec/15_account_snapshot_contract.yaml"
|
||||
- "spec/18_settings_contract.yaml"
|
||||
- "GatherTradingData.json"
|
||||
|
||||
jobs:
|
||||
validate-snapshot-admin:
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- name: Checkout Code
|
||||
run: |
|
||||
if [ -d .git ]; then
|
||||
git remote set-url origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
else
|
||||
git init
|
||||
git remote add origin http://x-access-token:${{ secrets.GITHUB_TOKEN }}@192.168.123.100:8418/KimJaeHyun/myfinance.git
|
||||
fi
|
||||
git fetch origin main --depth=1
|
||||
git reset --hard FETCH_HEAD
|
||||
|
||||
- name: Validate Snapshot Admin Workflow
|
||||
run: python3 tools/validate_snapshot_admin_workflow_v1.py
|
||||
|
||||
- name: Validate Snapshot Admin Web UI
|
||||
run: python3 tools/validate_snapshot_admin_web_v1.py
|
||||
|
||||
- name: Notify Run Result
|
||||
if: always()
|
||||
run: |
|
||||
STATUS="${{ job.status }}"
|
||||
echo "=== Snapshot Admin Web Validation ==="
|
||||
echo "status: $STATUS"
|
||||
echo "workflow validation: Temp/snapshot_admin_workflow_v1.json"
|
||||
echo "web validation: Temp/snapshot_admin_web_validation_v1.json"
|
||||
@@ -45,7 +45,21 @@
|
||||
- `spec/`: source of truth. 공식, 계약, 게이트, 출력 스키마의 최우선 읽기 경로.
|
||||
- `governance/`: 운영 규칙, 인덱스, 해시 마이그레이션, ADR, 템플릿.
|
||||
- `src/`: Python canonical implementation. 새 로직은 여기부터 반영한다.
|
||||
- `src/quant_engine/data_collection_backend_v1.py`: 수집 저장소 backend contract selector.
|
||||
- `src/quant_engine/data_collection_store_v1.py`: SQLite canonical collection store.
|
||||
- `src/quant_engine/kis_data_collection_v1.py`: KIS-first read-only collector.
|
||||
- `src/quant_engine/storage_backend_v1.py`: generic storage backend contract.
|
||||
- `tools/`: build, validate, convert, audit CLI. 상태는 유지하되 핵심 로직은 두지 않는다.
|
||||
- `tools/run_kis_data_collection_v1.py`: CI scheduler용 KIS 수집 thin CLI wrapper.
|
||||
- `tools/generate_postgresql_upgrade_stub_v1.py`: PostgreSQL upgrade stub generator.
|
||||
- `tools/validate_qualitative_sell_strategy_pipeline_v1.py`: qualitative sell pipeline contract validator.
|
||||
- `tools/validate_gitea_secrets_contract_v1.py`: Gitea secrets naming contract validator.
|
||||
- `tools/validate_snapshot_admin_web_v1.py`: snapshot admin web UI smoke validator.
|
||||
- `.gitea/workflows/qualitative_sell_strategy.yml`: qualitative sell strategy workflow.
|
||||
- `.gitea/workflows/snapshot_admin.yml`: snapshot admin workflow and scheduled validation.
|
||||
- `docs/GITEA_SECRETS_SETUP.md`: Gitea secrets setup and verification guide.
|
||||
- `Temp/snapshot_admin_approval_packet_v1.json`: snapshot admin approval packet export.
|
||||
- `Temp/snapshot_admin_approval_packet_v1.md`: snapshot admin approval packet summary.
|
||||
- `gas_event_calendar.gs`: 이벤트 캘린더 배포 호환 스텁. `seedEventCalendar_()` / `runEventRisk()` 진입점을 유지한다.
|
||||
- `Temp/`: 실행 결과와 캐시. 라우팅 대상은 아니며 runtime consumer만 읽는다.
|
||||
- `dist/`, `artifacts/`, `docs/`, `examples/`, `prompts/`, `schemas/`, `tests/`: 패키징/문서/검증/산출물 보조 경로.
|
||||
|
||||
@@ -10,6 +10,20 @@
|
||||
- 최종 후보 내 KOSDAQ: 최대 20개
|
||||
- 1차 탐색 총량은 v3와 동일한 200개로 유지하여 호출 수 증가를 막습니다.
|
||||
|
||||
## KIS 사용 가이드
|
||||
|
||||
이 저장소의 데이터 팩터 수집 기본 코어는 KIS Open API입니다.
|
||||
|
||||
- 실제계좌: `KIS_APP_Key`, `KIS_APP_Secret`
|
||||
- 모의계좌: `KIS_APP_Key_TEST`, `KIS_APP_Secret_TEST`
|
||||
- API 유효성 확인은 모의계좌 환경변수로 수행하고, 데이터 수집은 실제계좌 환경변수로 수행
|
||||
- 사용 범위: 조회형 `quotations` / `ranking` 계열만 사용
|
||||
- 금지 범위: 주문, 정정, 취소, 잔고조회는 사용하지 않음
|
||||
- 폴백 순서: `KIS -> Naver Finance -> Yahoo Finance -> OpenDART -> Investing.com(best-effort)`
|
||||
|
||||
CI 스케줄러는 `GatherTradingData.json`을 seed snapshot으로 사용하고, read-only API로 보강한 뒤 SQLite에 누적 저장합니다.
|
||||
코드는 저장 백엔드를 `backend contract`로 분리해 두었고, 지금은 SQLite만 실행하지만 향후 PostgreSQL로 옮겨도 수집기 호출부를 크게 바꾸지 않도록 해 둔 상태입니다.
|
||||
|
||||
## 설치
|
||||
|
||||
```powershell
|
||||
@@ -24,6 +38,52 @@ $env:DART_API_KEY="발급받은키"
|
||||
node core_satellite_collector.js
|
||||
```
|
||||
|
||||
SQLite 기반 데이터 수집을 실행하려면:
|
||||
|
||||
```powershell
|
||||
$env:KIS_APP_Key="실제계좌키"
|
||||
$env:KIS_APP_Secret="실제계좌시크릿"
|
||||
python tools/run_kis_data_collection_v1.py --input-json GatherTradingData.json --sqlite-db outputs/kis_data_collection/kis_data_collection.db --output-json Temp/kis_data_collection_v1.json --kis-account real
|
||||
```
|
||||
|
||||
### Snapshot admin web UI
|
||||
|
||||
엑셀처럼 `settings`와 `account_snapshot`를 편집하려면 웹 UI를 실행한다.
|
||||
|
||||
```bash
|
||||
python tools/run_snapshot_admin_server_v1.py --db outputs/snapshot_admin/snapshot_admin.db --seed GatherTradingData.json
|
||||
```
|
||||
|
||||
기본 흐름은 다음과 같다.
|
||||
|
||||
1. `GatherTradingData.json` 또는 기존 SQLite DB를 seed로 적재
|
||||
2. 웹 화면에서 `settings`와 `account_snapshot`을 검토/편집
|
||||
3. 저장 시 SQLite에 반영
|
||||
4. 필요하면 `/api/export`로 JSON을 내려받아 CI 또는 검증에 사용
|
||||
5. 변경 이력, 승인, 잠금, undo는 웹 화면의 `Approval & Locks` 영역에서 관리
|
||||
6. 변경 검토용 승인 패킷은 `Export approval packet` 버튼으로 `Temp/snapshot_admin_approval_packet_v1.json`에 저장한다.
|
||||
|
||||
웹 UI 스모크 검증은 아래 명령으로 실행한다.
|
||||
|
||||
```bash
|
||||
python tools/validate_snapshot_admin_web_v1.py
|
||||
```
|
||||
```
|
||||
|
||||
### Calibration backlog
|
||||
|
||||
보정 백로그와 change ledger를 다시 만들려면 아래 명령을 사용한다.
|
||||
|
||||
```powershell
|
||||
python tools/build_calibration_priority_v1.py
|
||||
python tools/build_calibration_change_ledger_v4.py
|
||||
python tools/build_calibration_review_report_v1.py
|
||||
python tools/build_calibration_approval_list_v1.py
|
||||
python tools/validate_calibration_change_ledger_v1.py
|
||||
```
|
||||
|
||||
Gitea 스케줄러에서는 `.gitea/workflows/calibration_backlog.yml`이 weekday 자동 갱신을 수행한다.
|
||||
|
||||
## 운영 표준
|
||||
|
||||
릴리즈와 패키징의 기준 진입점은 아래를 사용합니다.
|
||||
@@ -52,6 +112,7 @@ npm run prepare-upload-zip
|
||||
- `npm run ops:package`
|
||||
- `npm run ops:validate`
|
||||
- `npm run ops:build`
|
||||
- `npm run ops:snapshot-web-validate`
|
||||
- `npm run render-report-json`
|
||||
- `npm run validate-proposal-reference`
|
||||
- `npm run validate-gas-call-arity`
|
||||
@@ -70,6 +131,14 @@ npm run prepare-upload-zip
|
||||
6. `npm run full-gate` 실행
|
||||
7. 최종 운영 전환 시 `npm run prepare-upload-zip`로 패키지 생성 여부를 확인
|
||||
|
||||
## CI 전환 체크리스트
|
||||
|
||||
1. `python tools/run_kis_data_collection_v1.py` 또는 `npm run ops:data-collect`로 SQLite 수집을 먼저 검증
|
||||
2. `outputs/kis_data_collection/kis_data_collection.db`에 `collection_runs` / `collection_snapshots`가 생성되는지 확인
|
||||
3. Gitea 스케줄러가 `GatherTradingData.json`을 seed로 읽는지 확인
|
||||
4. `GatherTradingData.xlsx` 의존성을 제거한 후에도 수집이 유지되는지 확인
|
||||
5. 이후 PostgreSQL 업그레이드 시 동일 row contract를 유지
|
||||
|
||||
## 운영 리포트 계약
|
||||
|
||||
운영 리포트는 사람이 읽는 `Temp/operational_report.md`와 기계 검증용 `Temp/operational_report.json`을 함께 생성합니다.
|
||||
|
||||
@@ -246,7 +246,6 @@ spec_files:
|
||||
data_gaps_roadmap: "spec/16_data_gaps_roadmap.yaml"
|
||||
performance_contract: "spec/17_performance_contract.yaml"
|
||||
settings_contract: "spec/18_settings_contract.yaml"
|
||||
risk_policy_index: "spec/03_risk_policy.yaml"
|
||||
risk_control_index: "spec/risk/risk_control.yaml"
|
||||
aggregate_risk: "spec/risk/aggregate_risk.yaml"
|
||||
circuit_breakers: "spec/risk/circuit_breakers.yaml"
|
||||
@@ -254,7 +253,6 @@ spec_files:
|
||||
portfolio_exposure: "spec/risk/portfolio_exposure.yaml"
|
||||
risk_quality_control: "spec/risk/quality_control.yaml"
|
||||
factor_risk: "spec/risk/factor_risk.yaml"
|
||||
strategy_rules_index: "spec/04_strategy_rules.yaml"
|
||||
sector_model: "spec/strategy/sector_model.yaml"
|
||||
entry_gates_index: "spec/strategy/entry_gates.yaml"
|
||||
entry_core: "spec/strategy/entry_core.yaml"
|
||||
@@ -297,6 +295,7 @@ spec_files:
|
||||
event_response: "spec/exit/event_response.yaml"
|
||||
position_review: "spec/exit/position_review.yaml"
|
||||
dynamic_value_preservation_sell_v3: "spec/exit/dynamic_value_preservation_sell_v3.yaml"
|
||||
qualitative_sell_strategy_v1: "spec/exit/qualitative_sell_strategy_v1.yaml"
|
||||
output_schema: "spec/07_output_schema.yaml"
|
||||
machine_output_schema: "schemas/output_schema.json"
|
||||
report_templates: "RetirementAssetPortfolioReportTemplate.yaml"
|
||||
@@ -337,6 +336,7 @@ spec_files:
|
||||
- "spec/exit/event_response.yaml"
|
||||
- "spec/exit/position_review.yaml"
|
||||
- "spec/exit/dynamic_value_preservation_sell_v3.yaml"
|
||||
- "spec/exit/qualitative_sell_strategy_v1.yaml"
|
||||
strategy:
|
||||
- "spec/strategy/sector_model.yaml"
|
||||
- "spec/strategy/entry_gates.yaml"
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# Gitea Secrets Setup
|
||||
|
||||
이 저장소는 KIS Open API와 Gitea workflow를 분리해서 사용한다.
|
||||
실제 시크릿 등록은 Gitea 관리자 권한이 있는 운영자가 수행해야 한다.
|
||||
|
||||
## Required Secrets
|
||||
|
||||
### Shared
|
||||
|
||||
- `GITHUB_TOKEN`
|
||||
|
||||
### KIS read-only validation
|
||||
|
||||
- `KIS_APP_KEY_TEST`
|
||||
- `KIS_APP_SECRET_TEST`
|
||||
|
||||
### KIS real data collection
|
||||
|
||||
- `KIS_APP_KEY`
|
||||
- `KIS_APP_SECRET`
|
||||
|
||||
## Workflow Mapping
|
||||
|
||||
- `.gitea/workflows/kis_data_collection.yml`
|
||||
- mock validation: `KIS_APP_KEY_TEST`, `KIS_APP_SECRET_TEST`
|
||||
- real collection: `KIS_APP_KEY`, `KIS_APP_SECRET`
|
||||
- `.gitea/workflows/qualitative_sell_strategy.yml`
|
||||
- mock validation: `KIS_APP_KEY_TEST`, `KIS_APP_SECRET_TEST`
|
||||
- real collection: `KIS_APP_KEY`, `KIS_APP_SECRET`
|
||||
- `.gitea/workflows/ci.yml`
|
||||
- mock validation: `KIS_APP_KEY_TEST`, `KIS_APP_SECRET_TEST`
|
||||
|
||||
## Runtime Rule
|
||||
|
||||
- mock 계정은 유효성 확인용이다.
|
||||
- real 계정은 실제 데이터 수집용이다.
|
||||
- 둘을 같은 단계에서 혼용하지 않는다.
|
||||
|
||||
## Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python tools/validate_gitea_secrets_contract_v1.py
|
||||
```
|
||||
|
||||
The validator checks that the workflows reference the required secret names
|
||||
with the expected separation between mock and real usage.
|
||||
+470
-1
@@ -18,6 +18,60 @@
|
||||
|
||||
---
|
||||
|
||||
## 0c. 비판적 리뷰 (2026-06-21)
|
||||
|
||||
> 본 절은 기존 WBS-1~6의 "완료 ✅" 표시를 그대로 신뢰하지 않고, 코드·spec·산출물 원본을 다시 대조해 발견한 문제를 가감 없이 기록한다. 발견된 문제는 Phase 7(WBS-7)로 추적한다.
|
||||
|
||||
### 재검증 결과 — 두 문서가 서로 다른 T+5 수치를 인용하고 있었다
|
||||
|
||||
기존 §4(엔진 완성도 KPI)는 `예측 적중률(T+5) = 54.76%`(목표 근접 PASS 톤)를 인용했고, `spec/27_bch_calibration_runbook.yaml` Phase 4는 `T+5 = 35.86%`(목표 55%, BELOW_TARGET)를 인용했다. **2026-06-21 기준 `Temp/prediction_accuracy_harness_v2.json` 원본을 재확인한 결과, 두 수치 모두 이미 stale 하다:**
|
||||
|
||||
```
|
||||
as_of_date: 2026-06-21
|
||||
calibration_state: INSUFFICIENT_SAMPLES
|
||||
t1_op_rate: 52.94% (sample=68, decisive_sample=53, rate_decisive=67.92%)
|
||||
t5_op_rate: null (sample=0) ← 두 문서의 54.76%/35.86% 모두 현재는 산출 불가
|
||||
t20_op_rate: null (sample=0)
|
||||
```
|
||||
|
||||
즉 T+5 표본이 현재 **0건**이라 어느 쪽 수치도 "지금" 유효하지 않다. 파일 mtime 대조 결과 `Temp/honest_performance_guard_v1.json`(35.86%, 2026-06-14 생성)이 `Temp/prediction_accuracy_harness_v2.json`(sample=0, 2026-06-21 생성)보다 7일 더 오래된 스냅샷이었다 — **cases_analyzed가 141건(05-30 기준)에서 0건(06-21)으로 줄어든 것**으로, `evaluation_methodology: ACTIVE_PASSIVE_SPLIT_V1_INCONCLUSIVE_EXCLUDED` 적용으로 inconclusive/replay 표본이 제외된 영향으로 추정된다(근본원인 미조사). → **WBS-7.2 완료**: `spec/27_bch_calibration_runbook.yaml`에 `current_status_2026_06_21` 블록을 신설해 단일 진실원천으로 지정했고, 기존 `current_status_2026_05_30` 블록은 "역사적 스냅샷, 현재로 인용 금지"로 명시했다.
|
||||
|
||||
### 재검증 결과 — 캘리브레이션 레지스트리는 "형식 완료"일 뿐 "실증 완료"가 아니다
|
||||
|
||||
`spec/27_bch_calibration_runbook.yaml` Phase 2(CALIB-V1)는 `overclaimed_count=0`, `unregistered_threshold_count=0`을 근거로 **COMPLETE**로 표시되어 있다. 그러나 `spec/calibration_registry.yaml` 전체(190개 임계값)를 직접 집계하면:
|
||||
|
||||
| source | 건수 | 비율 | 의미 |
|
||||
|--------|------|------|------|
|
||||
| `SPEC_DERIVED` | 123 | 64.7% | spec 문서 값을 그대로 복사 — 실거래 검증 없음 |
|
||||
| `EXPERT_PRIOR` | 59 | 31.1% | 30년 경험 기반 직관값 — sample_n<30, 실거래 검증 없음 |
|
||||
| `PROVISIONAL` | 8 | 4.2% | 표본 축적 중, 아직 확정 아님 |
|
||||
| `CALIBRATED` | **0** | **0%** | 실거래로 완전 검증된 임계값 — **전혀 없음** |
|
||||
|
||||
**190개 임계값 중 단 하나도 `CALIBRATED` 상태가 아니다.** "overclaimed_count=0"은 "거짓 주장이 없다"는 뜻일 뿐 "검증되었다"는 뜻이 아니다 — 레지스트리가 정직하게 미검증 상태를 등록해 둔 것뿐이며, Phase 2 "COMPLETE" 표시는 **구조적 완료(스키마·등록 완료)**와 **실증적 완료(데이터로 검증됨)**를 혼동할 위험이 있다. → **⚠️ 표시 수정**: Phase 2(CALIB-V1) = "구조적으로 COMPLETE, 실증적으로는 0/190 검증" 으로 재서술. → **WBS-7.1**로 추적.
|
||||
|
||||
### 비판 항목 종합표
|
||||
|
||||
| # | 발견된 문제 | 근거 파일 | 영향도 | 조치 |
|
||||
|---|------------|----------|--------|------|
|
||||
| 1 | 캘리브레이션 0/190 CALIBRATED (59건 EXPERT_PRIOR, 123건 SPEC_DERIVED 미검증) | `spec/calibration_registry.yaml` (직접 집계) | 🔴 | WBS-7.1 |
|
||||
| 2 | T+5 정확도 지표가 문서마다 다른 stale 캐시값을 인용 (54.76% vs 35.86%, 실제는 sample=0) | `Temp/prediction_accuracy_harness_v2.json`, `spec/27_bch_calibration_runbook.yaml` | 🔴 | WBS-7.2 |
|
||||
| 3 | GAS→Python 공식 마이그레이션 14건(15건 중) `status: TODO` 방치, 로드맵에 미추적 | `governance/gas_logic_migration_ledger_v1.yaml` | 🟠 | WBS-7.3 |
|
||||
| 4 | Deprecated 별칭 17건 `remove_after: 2026-06-30` — 오늘 기준 9일 전 데드라인, WBS 추적 없음 | `spec/aliases.yaml` | 🟠 | WBS-7.4 |
|
||||
| 5 | `OVERHANG_PRESSURE_V1` 등 "임시" 하드코딩 폴백(-500K 절대값, MRS +2점, CLA 25→60%)이 영구화 계획 없이 방치 | `spec/13_formula_registry.yaml:1222`, `spec/risk/circuit_breakers.yaml:192`, `spec/risk/portfolio_exposure.yaml:403` | 🟡 | WBS-7.5 |
|
||||
| 6 | 슬리피지 5bps가 이론치, 실측 보정 트리거/일정 없음 | `spec/55_execution_simulator_contract.yaml:21` | 🟡 | WBS-7.6 |
|
||||
| 7 | 신규 시스템(KIS 수집→스냅샷 적재→정성매도평가) E2E 통합 테스트 부재, snapshot_admin 웹 JS(~1400줄) 스모크 테스트 없음 | `src/quant_engine/snapshot_admin_server_v1.py`, `tests/unit/test_*_v1.py` (단위 61건은 양호, 통합 0건) | 🟠 | WBS-7.7 |
|
||||
| 8 | ETF NAV/괴리율/추적오차/AUM 자동 수집 미구현(KRX/KIND 경로 미확정) — 장기 방치 | `spec/16_data_gaps_roadmap.yaml` S4/S5 | 🟡 | WBS-7.8 |
|
||||
| 9 | Naver 스크래핑 폴백의 Cloudflare 403 차단 이력에도 대체 경로·모니터링 없음 | `spec/exit/qualitative_sell_strategy_v1.yaml:81-82` | 🟡 | WBS-7.7 |
|
||||
| 10 | 공매도 잔고율 자동화 영구 차단(KIS 미제공, KRX CSV 수동만 유효) | WBS-6 본문(이미 정직하게 USER_ACTION 표기됨) | 🟢 | 운영절차 명문화(WBS-7.8 부속) |
|
||||
|
||||
### 기존 "완료 ✅" 표시 재검토
|
||||
|
||||
- **WBS-4.1/4.2/4.3 (DATA_GATED)**: 정직하게 표기됨 — 도전 불필요, 그대로 유지.
|
||||
- **Phase 2 캘리브레이션(CALIB-V1) "COMPLETE"**: → **"⚠️ 구조적 완료, 실증 미완료(0/190 CALIBRATED)"**로 정정.
|
||||
- **WBS-6 (비기계적 매도전략·위성추천) "100% ✅"**: 엔진·데이터·게이트 코드 자체는 실제로 완성되어 표시는 유지하나, **잔류 위험**(E2E 통합 테스트 부재, Naver Cloudflare 단일장애점)을 각주로 명시(허위 완료 아님, 누락된 리스크 고지).
|
||||
|
||||
---
|
||||
|
||||
## 0. 프로젝트 비전 & 방향성
|
||||
|
||||
### 핵심 목표
|
||||
@@ -48,6 +102,8 @@ Phase 2 ████████████████░░░░ 신호
|
||||
Phase 3 ████████████████████ 실행·리스크 관리 (Execution & Risk) [완료 ✅]
|
||||
Phase 4 █████░░░░░░░░░░░░░░░ 성과 인텔리전스 (Performance) [25% — 4.1~4.3 DATA_GATED]
|
||||
Phase 5 ████████████████████ 완전 자동화 (Full Automation) [완료 ✅]
|
||||
Phase 6 ████████████████████ 비기계적 매도전략·위성추천 [완료 ✅ — 잔류위험 명시, 0c절 참조]
|
||||
Phase 7 ░░░░░░░░░░░░░░░░░░░░ 보완·고도화 (Critical Hardening) [0% — 0c절 비판 10건 대응, 신규 착수 대기]
|
||||
```
|
||||
|
||||
| Phase | 기간 목표 | 핵심 산출물 | 완료 기준 |
|
||||
@@ -57,6 +113,8 @@ Phase 5 ████████████████████ 완전
|
||||
| **P3 실행·리스크** | 2026-06 완료 | 리밸런싱 엔진 V1, 3단계 분할 주문 | 실제 주문 3회 이상 |
|
||||
| **P4 성과 인텔리전스** | ~2026-10 | T+20 결과 30건, 알파 보정 루프 | match_rate ≥ 55% |
|
||||
| **P5 완전 자동화** | ~2026-12 | CI/CD + Gitea, 자율 실행 | 수동 개입 0회/주 |
|
||||
| **P6 비기계적 매도전략** | 2026-06 완료 | 5팩터 confluence 엔진, KIS 조회연동, SQLite 자체평가 | WBS-6 본문 하네스 PASS (잔류위험은 P7에서 해소) |
|
||||
| **P7 보완·고도화** | ~2026-08 | 캘리브레이션 실증 전환, GAS 마이그레이션 완결, deprecated 정리, E2E 통합테스트 | WBS-7.1~7.8 하네스 전부 PASS |
|
||||
|
||||
---
|
||||
|
||||
@@ -526,6 +584,355 @@ CI 게이트:
|
||||
|
||||
---
|
||||
|
||||
### WBS-6: 비기계적 매도전략 & 위성추천 (Phase 6, 2026-06-21)
|
||||
|
||||
**운영 원칙(30년 시니어 퀀트 관점 — 이 Phase의 모든 작업이 따르는 단일 기준)**
|
||||
|
||||
| 원칙 | 이 Phase에서의 구현 |
|
||||
|------|---------------------|
|
||||
| 가치보존이 목적, 매도가 목적 아님 | confluence 최소 3/5 합의 없이는 매도 트리거 금지(`mechanical_sell_prohibited=true`) |
|
||||
| 추정 금지, 신뢰 데이터만 | 데이터 결측 시 항상 `DATA_MISSING`/`INSUFFICIENT_DATA_NO_ACTION` — 추정값으로 채우지 않음 |
|
||||
| 데이터 정합성 | 출처별 실측 상태를 코드 주석·spec에 고정(WORKING/MANUAL_CSV_ONLY/USER_ACTION 등), 추측 표기 금지 |
|
||||
| 일관된 알고리즘 | 5팩터·confluence 규칙·국면 가중치가 보유종목/위성후보 평가에 동일하게 적용 |
|
||||
| 지속적 자체평가 | SQLite 시계열(`qualitative_sell_strategy.db`) + 사후 적중률 평가(`evaluate_qualitative_sell_strategy_accuracy_v1.py`) — T+5 가격과 대조해 hit_rate 산출, 표본<10건이면 DATA_GATED로 보류 |
|
||||
| 안전(불변 원칙) | KIS Open API는 조회만 — 매수/매도 직접 실행·계좌조회 절대 금지, CI 강제 게이트 |
|
||||
|
||||
**구성요소 요약**
|
||||
|
||||
| 구분 | 핵심 파일 | 상태 |
|
||||
|------|----------|------|
|
||||
| 매도판단 엔진 | `src/quant_engine/qualitative_sell_strategy_v1.py` (`QUALITATIVE_SELL_STRATEGY_V1`/`SHORT_INTEREST_RISK_GAUGE_V1`/`MARKET_REGIME_CLASSIFIER_V1`/`SATELLITE_CANDIDATE_SCORE_V1`/`MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1`) | ✅ 완료 |
|
||||
| 데이터 수집(보유종목) | `tools/build_qualitative_sell_inputs_v1.py` + `build_macro_context_from_workbook_v1.py`(실워크북 연동) + `fetch_naver_market_data_v1.py` + `fetch_trade_statistics_motie_v1.py` | ✅ 완료 — 10/10 보유종목 오류 0건 |
|
||||
| KIS Open API 보강 | `src/quant_engine/kis_api_client_v1.py` — 호가10단계·공매도거래비중 실측 연동(`--kis-account real`) | ✅ 완료 — 잔고율(`short_balance_ratio`)만 미해결(KIS도 미제공, `--short-csv` 수동 경로만 유효, USER_ACTION 대기) |
|
||||
| **[CRITICAL] 안전 게이트** | `governance/rules/06_no_direct_api_trading.yaml`, `07_no_kis_account_balance_query.yaml`, `tools/validate_no_direct_api_trading_v1.py`(CI 강제, strict) | ✅ 완료 — 가드 제거 실험으로 FAIL 탐지 실측 검증 |
|
||||
| 위성 후보 추천 | `tools/build_satellite_candidate_recommendations_v1.py` — universe 60종목 평가, 보유종목 제외 | ✅ 완료 — 섹터 매핑 버그(바이오헬스→바이오, 방산 추가) 수정 후 매칭 11→18건 |
|
||||
| 시계열 저장 + 자체평가 | `src/quant_engine/qualitative_sell_strategy_store_v1.py`(SQLite, GAS/xlsx와 독립) + `tools/evaluate_qualitative_sell_strategy_accuracy_v1.py` | ✅ 완료 — 평가 루프는 결정 누적 전까지 정직하게 DATA_GATED 보고 |
|
||||
| 운영 스케줄러 | `.gitea/workflows/kis_data_collection.yml` — 영업일 08~17시 2시간 간격 + 수동 실행 | ✅ 완료 — Gitea repo secrets(`KIS_APP_KEY` 등) 등록은 USER_ACTION |
|
||||
|
||||
**향후 확장 시 고려사항(지금 구현하지 않음, 설계만 호환 유지)**
|
||||
- DB 엔진: SQLite → PostgreSQL 전환 가능성을 고려해 `qualitative_sell_strategy_store_v1.py`는 `insert_*`/`fetch_*` 함수 뒤로 SQL을 전부 숨겼다 — 호출부(오케스트레이터)는 DB 엔진을 모른다. 전환 시 이 한 파일의 내부 구현만 바꾸면 된다(AUTOINCREMENT→SERIAL 등 방언 차이만 해당 파일 내부 문제).
|
||||
- 공매도 잔고율은 KRX 공매도종합포털 CSV 외 경로가 없음을 실측으로 확정했으므로, 재시도성 스크래핑 시도는 더 이상 하지 않는다.
|
||||
|
||||
**검증 명령**:
|
||||
```
|
||||
python -m pytest tests/unit -q → 40 passed
|
||||
python tools/validate_no_direct_api_trading_v1.py → PASS (strict)
|
||||
python tools/validate_specs.py / validate_formula_registry.py /
|
||||
validate_golden_coverage_100.py / validate_harness_coverage_auditor.py → 전부 PASS
|
||||
python tools/build_qualitative_sell_inputs_v1.py --batch --workbook GatherTradingData.xlsx --kis-account real
|
||||
→ 10/10 종목 오류 0건, BATCH_GATE: PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### WBS-7: 보완·고도화 (Phase 7, 2026-06-21 비판적 리뷰 대응)
|
||||
|
||||
> 0c절에서 발견된 10개 문제에 대한 추적 WBS. 모든 항목은 착수 전이며 상태는 `TODO`.
|
||||
|
||||
#### WBS-7.1 캘리브레이션 임계값 실증 전환 (EXPERT_PRIOR/SPEC_DERIVED → PROVISIONAL → CALIBRATED)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | 190개 임계값 중 `EXPERT_PRIOR`(59)·`SPEC_DERIVED`(123)를 실거래 표본 누적 순으로 `PROVISIONAL`→`CALIBRATED` 전환 |
|
||||
| **현재 상태** | `CALIBRATED` 0/190 (0%), `PROVISIONAL` 8/190 (4.2%) |
|
||||
| **우선순위** | `Temp/calibration_priority_v1.json`의 urgency score 상위 항목부터 |
|
||||
| **담당 파일** | `tools/build_calibration_priority_v1.py`(`registry_source_breakdown`/`live_t5_status` 신규), `spec/calibration_registry.yaml` |
|
||||
| **상태** | 도구 보강 완료(2026-06-21) — **CALIBRATED 승격 자체는 실거래 데이터 부재로 여전히 DATA_GATED** |
|
||||
|
||||
**부수 발견 — 데이터 무결성 버그**: `spec/calibration_registry.yaml`에 `id: SEMI_CLUSTER_CAP_RISK_OFF`가 **서로 다른 두 공식(값 20.0/25.0)에 중복 등록**되어 있었다. id로 dict 조회하는 도구(`build_calibration_priority_v1.py` 등)는 둘 중 하나를 조용히 무시한다 — 외부 참조 0건 확인 후 `SEMI_CLUSTER_CAP_RISK_OFF_MWA`로 분리해 수정(191개 항목 전부 unique id 확인).
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: python tools/build_calibration_priority_v1.py
|
||||
결과: [캘리브레이션 레지스트리 건강도] total=191 {'SPEC_DERIVED': 123, 'EXPERT_PRIOR': 60, 'PROVISIONAL': 8, 'CALIBRATED': 0}
|
||||
CALIBRATED=0.0% 미검증(SPEC_DERIVED+EXPERT_PRIOR)=95.81%
|
||||
→ 매 실행마다 자동 집계되어 더 이상 수동 grep 불필요(이전엔 수동 집계해야 했음)
|
||||
T+5 수치도 Temp/prediction_accuracy_harness_v2.json에서 항상 live로 읽음(하드코딩된
|
||||
35.86 리터럴을 제거 — WBS-7.2와 동일한 stale-수치 문제가 이 도구에도 있었음)
|
||||
회귀: python -m pytest tests/unit/test_calibration_priority_v1.py -q → 5 passed
|
||||
목표(1차, 미달성 — DATA_GATED): CALIBRATED ≥ 10건 (sample_n≥30 + 실측 backtest 노트 보유)
|
||||
목표(2차, 미달성 — DATA_GATED): PROVISIONAL ≥ 30건
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.2 T+5/예측정확도 지표 단일 진실원천 통일
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | ROADMAP §4와 `spec/27_bch_calibration_runbook.yaml`이 서로 다른 시점의 T+5 캐시값을 인용하던 문제 해결 — 모든 문서가 `Temp/prediction_accuracy_harness_v2.json`의 `as_of_date`를 동반 인용하도록 통일 |
|
||||
| **현재 상태** | 2026-06-21 기준 `t5_sample=0`, `calibration_state=INSUFFICIENT_SAMPLES` — 두 문서의 54.76%/35.86% 모두 stale |
|
||||
| **담당 파일** | `tools/build_prediction_accuracy_harness_v2.py`, `docs/ROADMAP_WBS.md` §4, `spec/27_bch_calibration_runbook.yaml` |
|
||||
| **상태** | ✅ 완료 (2026-06-21) — `current_status_2026_06_21` 블록 신설, 구 블록 "역사적 스냅샷"으로 명시 |
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: ROADMAP §4의 T+5 수치와 spec/27_bch_calibration_runbook.yaml의 T+5 수치가
|
||||
동일 as_of_date의 Temp/prediction_accuracy_harness_v2.json을 가리킬 것
|
||||
규칙: 문서에 적중률 수치 인용 시 반드시 "(as_of: YYYY-MM-DD, sample=N)" 동반 표기
|
||||
결과: t5_sample=0 → 두 문서 모두 "DATA_GATED (t5_sample=0, as_of 2026-06-21)"로 정정 완료
|
||||
부가발견: cases_analyzed 141→0 회귀는 evaluation_methodology 변경 영향으로 추정 — 근본원인 조사는 별도 후속 과제
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.3 GAS→Python 공식 마이그레이션 재검토 (2026-06-21)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | `governance/gas_logic_migration_ledger_v1.yaml` 15건 findings 전체를 원문부터 재검증 |
|
||||
| **현재 상태** | 2건 DONE(F01/F09, 레저가 stale했을 뿐 실제론 이미 등록됨), 1건 KEEP_IN_GAS, **12건 TODO 유지 — 의도적 보류** |
|
||||
| **담당 파일** | `governance/gas_logic_migration_ledger_v1.yaml` |
|
||||
| **상태** | 부분 완료 — 안전하게 처리 가능한 항목만 종결, 나머지는 근거 있는 보류 |
|
||||
|
||||
**재검증으로 발견한 사실**:
|
||||
```
|
||||
F01/F09(REGISTER_*) → DONE 정정: spec/calibration_registry.yaml에 SP_TAKE_PROFIT/
|
||||
TAKE_PROFIT_BASE가 P5-T01 wave1에서 이미 등록되어 있었음(gs_location 일치 확인).
|
||||
|
||||
F12/F13(DELETE_DISTRIBUTION_RISK_GAS) → 보류: ledger가 인용한 "build_distribution_risk_v1.py"는
|
||||
존재하지 않는 파일. 실제로는 tools/build_distribution_risk_score_v2.py가 동일 필드를
|
||||
산출하지만, GAS(gdf_03:2128)와 이 Python 산출값을 직접 대조하는 parity 테스트가
|
||||
tests/parity·tests/regression 어디에도 없음(grep 0건) — "verify parity before delete"
|
||||
조건 미충족으로 GAS 삭제 보류.
|
||||
|
||||
F14(DELETE_LATE_CHASE_RISK_GAS) → 보류, ledger 전제 자체가 오류: "build_alpha_lead_table_v1.py가
|
||||
late_chase_risk_score를 산출"한다는 claim은 사실이 아님 — 해당 파일은 존재하지 않고,
|
||||
발견된 도구들(build_late_chase_attribution_v1.py 등)은 이 필드를 "소비"만 할 뿐 산출하지
|
||||
않는다. GAS가 이 점수의 유일한 산출 경로일 가능성이 높아 삭제 시도 자체가 위험.
|
||||
|
||||
F02~F06/F07/F10/F11/F15(MIGRATE_* 신규 포트, 12건 중 9건) → 의도적 미착수: parity 테스트
|
||||
인프라 없이 결정론적 매매엔진의 가격/정지손실/라우팅 로직을 포팅하면 silent correctness
|
||||
bug 위험이 큼(advisor 권고). 특히 F11(stop_loss_gate)은 ledger 자체가 "critical path"로
|
||||
명시. 전용 parity 테스트 스프린트가 선행돼야 한다.
|
||||
```
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: python -c "import yaml; from collections import Counter; \
|
||||
d=yaml.safe_load(open('governance/gas_logic_migration_ledger_v1.yaml', encoding='utf-8')); \
|
||||
print(Counter(f['status'] for f in d['findings']))"
|
||||
결과: Counter({'TODO': 12, 'DONE': 2, 'KEEP_IN_GAS': 1})
|
||||
python tools/validate_specs.py → PASS (이 마이그레이션 상태는 현재 CI 게이트와 무관함 —
|
||||
tools/validate_gas_thin_adapter_v1.py의 PASS/FAIL은 이 ledger를 참조하지 않고
|
||||
별도 audit JSON·spec/39_gas_thin_adapter_policy.yaml 기준으로 판정됨을 확인)
|
||||
잔여 12건은 전용 parity 테스트 스프린트(별도 WBS)로 이관 — 이번 세션에서는 시도하지 않음.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.4 Deprecated 별칭·시트 정리 (데드라인 2026-06-30)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | `spec/aliases.yaml`의 deprecated 경로 17건을 데드라인 전 코드/spec 참조에서 전수 제거 |
|
||||
| **현재 상태** | `remove_after: 2026-06-30` — 오늘(2026-06-21) 기준 9일 남음, 추적 항목 없었음 |
|
||||
| **담당 파일** | `spec/aliases.yaml`, `tools/validate_specs.py` |
|
||||
| **상태** | TODO — **긴급(데드라인 임박)** |
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: grep -rl "old_portfolio_exposure_framework\|old_risk_control" spec/ src/ tools/ | wc -l
|
||||
현재: deprecated 별칭 17건 등록, 참조 잔존 여부 미확인
|
||||
목표: 2026-06-30 이전 참조 0건 + spec/aliases.yaml에서 deprecated 항목 제거
|
||||
python tools/validate_specs.py → deprecated 경로 사용 시 FAIL 처리로 전환
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.5 임시 하드코딩 폴백 비례화
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | `OVERHANG_PRESSURE_V1`의 `-500K` 절대값 폴백을 flow_rows 비례 공식으로 교체. 서킷브레이커 MRS +2점, CLA 25%→60% 임시 해제 조항에 명시적 종료조건 부여 |
|
||||
| **현재 상태** | 3건 모두 "임시" 주석만 있고 영구화/대체 계획 없음 |
|
||||
| **담당 파일** | `spec/13_formula_registry.yaml:1222`, `spec/calibration_registry.yaml`, `spec/risk/circuit_breakers.yaml:192`, `spec/risk/portfolio_exposure.yaml:403` |
|
||||
| **상태** | ✅ OVERHANG_PRESSURE_V1 완료(2026-06-21) — 서킷브레이커/CLA 2건은 별도 정책 결정 사안으로 범위 외 |
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
변경: without_20d_fallback을 "frg_5d_sh < -500000"(절대 주식수, 임시)에서
|
||||
"avg_volume_5d IS NOT NULL AND frg_5d_sh < -1.5 * avg_volume_5d OR flow_credit < 0.30"로 교체.
|
||||
근거: 1.5 배수는 같은 formula의 with_20d 분기(frg_20d_sh/4 × 1.5)가 이미 쓰는 계수를
|
||||
재사용한 것 — 새로 추정한 값이 아님(advisor 검증 완료).
|
||||
널가드: avg_volume_5d 결측 시 선행 missing_policy 규칙(volume_weakness=false와 동일하게
|
||||
selling_acceleration도 false)을 명시적으로 확장 — divide-by-null/오탐 방지.
|
||||
등록: spec/calibration_registry.yaml에 id=OVERHANG_PRESSURE_V1_FALLBACK_MULT(EXPERT_PRIOR,
|
||||
sample_n=0)로 신규 등록 + formula_registry에 calibration_ref로 상호 참조.
|
||||
검증: python tools/validate_specs.py → PASS, python -m pytest tests/unit tests/integration -q → 76 passed
|
||||
잔여(범위 외): circuit_breakers.yaml MRS+2점, portfolio_exposure.yaml CLA 25→60% 임시해제는
|
||||
수치적 조정이 아니라 정책 종료조건을 정하는 사안이라 별도 의사결정으로 분리.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.6 슬리피지 실측 보정
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | `EXECUTION_SIMULATOR_V1`의 5bps 가정을 실거래 체결 데이터와 비교해 보정 |
|
||||
| **현재 상태** | 이론치 5bps, "추후 실측 데이터로 보정 예정"이라는 메모만 존재 |
|
||||
| **담당 파일** | `src/quant_engine/execution_slippage_store_v1.py`(신규), `tools/evaluate_execution_slippage_v1.py`(신규), `tests/unit/test_execution_slippage_store_v1.py`(신규) |
|
||||
| **활성화 조건** | 실거래 체결 기록 ≥ 5건 누적 |
|
||||
| **상태** | 캡처 스캐폴딩 완료(2026-06-21) — **비교 자체는 실측 표본 부재로 DATA_GATED 유지(정상)** |
|
||||
|
||||
**구현 내용**: 주문 실행은 여전히 사람이 HTS에서 수동 실행(governance/rules/06 준수, API로 체결을 가져오지 않음). 실행 후 사람이 `record` 서브커맨드로 의도가/실제체결가를 1건씩 수동 기록하면 SQLite(`outputs/execution_slippage/execution_slippage.db`)에 누적되고, `report` 서브커맨드가 5건 미만이면 항상 정직하게 `DATA_GATED`를 반환한다(추정 금지).
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
기록: python tools/evaluate_execution_slippage_v1.py record --ticker 005930 --side BUY \
|
||||
--intended-price 71000 --actual-price 71050 --recorded-at 2026-06-21
|
||||
비교: python tools/evaluate_execution_slippage_v1.py report
|
||||
→ 표본<5: {"status": "DATA_GATED", "sample_n": N, "min_required": 5, ...} (현재 실측 0건 → 이 상태)
|
||||
→ 표본≥5: actual_mean_slippage_bps vs assumed(5.0) gap_bps 비교, gap>3bps면 spec 값 갱신 권고
|
||||
회귀: python -m pytest tests/unit/test_execution_slippage_store_v1.py -q → 5 passed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.7 신규 시스템 E2E 통합 테스트 구축
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | KIS 수집 → 스냅샷 어드민 적재 → 정성매도전략 평가로 이어지는 파이프라인 통합 테스트 1개 작성. `snapshot_admin_server_v1.py`의 임베디드 JS 스모크 테스트 추가. Naver 폴백 Cloudflare 차단 시 graceful degradation 테스트 |
|
||||
| **현재 상태** | 단위 테스트 61개(양호) 존재, 통합/E2E 0건 |
|
||||
| **담당 파일** | `tests/integration/test_kis_collection_to_snapshot_admin_and_sell_strategy_v1.py` (신규) |
|
||||
| **상태** | ✅ 완료 (2026-06-21) — 네트워크 미사용, 3개 테스트 PASS |
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: python -m pytest tests/integration -q → 3 passed
|
||||
1) kis_data_collection_v1.collect_to_sqlite(no-naver, no-live-kis) → data_collection_store_v1.db 적재
|
||||
→ load_collection_dashboard_state()로 read-back, collection_snapshots count 일치 확인
|
||||
2) Naver fetch_price_history가 Cloudflare 403(RuntimeError)을 던지도록 monkeypatch
|
||||
→ collect_to_sqlite()가 배치 전체를 죽이지 않고 PASS/PASS_WITH_WARNINGS로 완료하는지 확인
|
||||
3) compute_qualitative_sell_strategy() 순수함수 결과 → insert_sell_strategy_result →
|
||||
fetch_recent_sell_strategy_results round-trip 일치 확인
|
||||
회귀 확인: python -m pytest tests/unit tests/integration -q → 73 passed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.8 ETF NAV/괴리율/추적오차/AUM 수집 경로 확정
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | KRX/KIND 기반 수집 경로 확정 또는, 확정이 불가하면 "구조적으로 미구현 유지" 사유와 재검토 주기를 명문화. 공매도 잔고율(KRX CSV 수동) 운영 절차도 함께 문서화 |
|
||||
| **현재 상태** | `spec/16_data_gaps_roadmap.yaml` S4/S5 PLANNED 상태로 장기 방치, 재검토 주기 없음 |
|
||||
| **담당 파일** | `spec/16_data_gaps_roadmap.yaml`, `docs/runbook.md` |
|
||||
| **상태** | ✅ 완료 (2026-06-21, 2026-06-22 실측 보강) |
|
||||
|
||||
**2026-06-22 추가 실측(사용자 요청)**: "자동화 안 되면 차후 개선 목표로"라는 지시에 따라 추정이 아니라 실제로
|
||||
자동화를 재시도했다. 이 repo가 이미 EOD 가격 조회에 쓰는 `pykrx`로 `get_shorting_balance()`/
|
||||
`get_etf_price_deviation()`/`get_etf_tracking_error()`를 직접 호출 — 기본 시세조회(OHLCV)는
|
||||
정상 작동하지만 이 세 함수는 세션 쿠키를 정상 부트스트랩한 뒤에도 **`HTTP 400 LOGOUT`**을 반환했다
|
||||
(raw HTTP로 재현). pykrx 임포트 시 뜨는 "KRX_ID/KRX_PW 미설정" 경고와 정확히 일치 — **KRX 회원
|
||||
로그인이 있어야 접근 가능한 서버측 인증 게이트**임을 확정했다(헤더/세션 보정으로 해결 안 됨).
|
||||
자동화하려면 KRX 계정을 자격증명으로 코드에 등록해야 하는데, 이는 governance/rules/06·07과
|
||||
같은 종류의 새 정책 결정 사안이라 사용자 승인 없이 추가하지 않았다 — **개선 목표로 이관**:
|
||||
`spec/16_data_gaps_roadmap.yaml` S4/S5의 `automation_attempt_2026_06_22` 필드에 재현 절차 기록,
|
||||
`next_review_date: 2026-09-30` 재조사 시 "API 키 발급 가능성"이 아니라 "KRX 계정 발급·자격증명
|
||||
관리 정책 승인 여부"로 질문을 재구성하도록 명시.
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: spec/16_data_gaps_roadmap.yaml S4/S5에 "next_review_date"+"automation_attempt_2026_06_22" 필드 존재
|
||||
결과: docs/runbook.md 20~21번 항목에 실측 실패 근거(HTTP 400 LOGOUT) + 공매도 잔고율 주 1회
|
||||
CSV 갱신 절차 + ETF NAV 수동 import 경로(tools/import_etf_nav_manual.py) 명문화
|
||||
python tools/validate_specs.py → PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.9 snapshot_admin Python 서버 — Gitea CI를 통한 Synology 상시 서비스화 검토 (2026-06-21)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | `src/quant_engine/snapshot_admin_server_v1.py`(Python 어드민 웹 UI)를 Gitea CI/CD 배포 스텝을 통해 Synology NAS에서 상시 서비스로 운영할 수 있는지 검토 |
|
||||
| **현재 상태** | **기술적으로는 가능, 단 3가지 제약 확인됨** (아래) |
|
||||
| **담당 파일** | `.gitea/workflows/ci.yml`, `tools/run_snapshot_admin_server_v1.py`, `src/quant_engine/snapshot_admin_server_v1.py` |
|
||||
| **상태** | TODO — 구현 전 보안·접근 정책 결정 필요 |
|
||||
|
||||
**조사 결과**:
|
||||
|
||||
1. **의존성 제약은 문제 없음**: `.gitea/workflows/ci.yml` 주석에 명시된 Synology DS216j(ARMv7l 32bit, Python 3.8.12) 제약은 "numpy/pandas 휠 없음, gcc 미설치"인데, `snapshot_admin_server_v1.py`는 `http.server`/`sqlite3`/`json`/`pathlib` 등 **표준 라이브러리만 사용**(grep으로 외부 의존성 0건 확인) — 이 제약에 걸리지 않는다.
|
||||
2. **DS216j는 Docker 미지원 모델**이다(Container Manager는 x86 가상화 지원 모델에서만 동작). 따라서 컨테이너 배포는 불가하고, DSM Task Scheduler + 백그라운드 프로세스 방식이 유일한 현실적 경로다.
|
||||
3. **CI 잡 프로세스 영속성 위험**: Gitea Act Runner가 잡 종료 시 자식 프로세스를 정리(kill)할 가능성이 있어, CI 스텝에서 단순히 서버를 백그라운드 실행(`nohup ... &`)해도 잡 종료와 함께 죽을 수 있다. 검증되지 않은 상태이며 실제 적용 전 `setsid`/`disown` 방식의 데몬화를 실측 테스트해야 한다.
|
||||
4. **보안 — 가장 중요한 제약**: 현재 서버는 `--host 127.0.0.1`(로컬호스트 전용) 기본값이고 **인증 기능이 전혀 없다**. 이 어드민 UI는 `settings`/`account_snapshot` SQLite를 직접 쓰기 가능한 표면이며, 이 데이터는 결정론적 매수/매도 엔진의 입력이 된다. LAN에 상시 노출하려면 최소 (a) 인증 추가 또는 (b) DSM 리버스 프록시 뒤에서 VPN/방화벽 화이트리스트로 제한 — 둘 중 하나가 선행되어야 한다.
|
||||
|
||||
**권고 (보안 정책 결정 후 구현)**:
|
||||
```
|
||||
배포 방식: Gitea CI 배포 스텝에서 코드 갱신 후 PID 파일 확인 → 기존 프로세스 종료 → setsid로 재기동
|
||||
가동 감시: DSM Task Scheduler에 5분 간격 헬스체크 스크립트 등록(프로세스 미생존 시 재기동) — poor-man's supervisor
|
||||
네트워크: host=127.0.0.1 유지 + DSM 리버스 프록시(HTTPS)와 IP 화이트리스트로 LAN 내부 접근만 허용,
|
||||
또는 호스트 OS 레벨 인증(Synology SSO/LDAP 연동) 추가 전까지 인터넷 노출 금지
|
||||
검증: 배포 후 curl http://127.0.0.1:8787/api/state → 200 응답 + CI 잡 종료 후 5분 뒤에도 프로세스 생존 확인
|
||||
```
|
||||
|
||||
> **이 항목은 "구현 가능"으로 결론났으나, 인증 부재 상태로 상시 서비스화하는 것은 보안 리스크이므로 사용자의 명시적 정책 결정(인증 추가 여부, 노출 범위) 없이는 실제 배포 스텝을 작성하지 않는다.**
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.10 어드민 페이지 — Tabler 기반 테이블별 그리드 조회 (2026-06-21)
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | `snapshot_admin_server_v1.py`에 워크스페이스 DB(`settings`/`account_snapshot`/`workspace_*`) + KIS 수집 DB(`collection_*`) + 정성매도전략 DB(`sell_strategy_results`/`satellite_recommendations`) 3개 SQLite 파일에 걸친 11개 테이블을 Tabler(CDN) 그리드로 조회하는 신규 `/tables` 페이지 추가 |
|
||||
| **담당 파일** | `src/quant_engine/snapshot_admin_server_v1.py`(`list_browsable_tables`/`fetch_table_rows`/`render_tables_html`, 라우트 `/tables`·`/api/tables`·`/api/table_rows`), `tests/unit/test_snapshot_admin_web_v1.py` |
|
||||
| **보안** | 테이블명은 고정 화이트리스트(`WORKSPACE_BROWSABLE_TABLES`/`COLLECTION_BROWSABLE_TABLES`/`QUALITATIVE_SELL_BROWSABLE_TABLES`)와 정확히 일치할 때만 SQL에 사용 — 임의 테이블명 SQL 인젝션 시도는 `ValueError`로 차단(테스트로 검증) |
|
||||
| **상태** | ✅ 완료 (2026-06-21) |
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: python -m pytest tests/unit/test_snapshot_admin_web_v1.py -q → 8 passed
|
||||
- render_tables_html()에 tabler/tableSelect/api 경로 포함 확인
|
||||
- list_browsable_tables()가 3개 DB·11개 테이블 모두 열거하는지 확인
|
||||
- fetch_table_rows() 페이지네이션(limit/offset) + 화이트리스트 외 테이블명 차단(ValueError) 확인
|
||||
회귀 확인: python -m pytest tests/unit tests/integration -q → 76 passed
|
||||
python tools/validate_specs.py → PASS
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### WBS-7.11 spec-코드 동기화 게이트 (2026-06-22, 설계+구현 완료)
|
||||
|
||||
**배경**: 2026-06-21 비판적 리뷰 이후 진행한 WBS-7.3/7.4 작업에서 spec/governance YAML이
|
||||
실제 코드 상태와 어긋난 채로 방치된 사례를 3건 발견했다 — `governance/gas_logic_migration_ledger_v1.yaml`이
|
||||
존재하지 않는 파일(`build_distribution_risk_v1.py`, `build_alpha_lead_table_v1.py`)을
|
||||
canonical 구현으로 인용, `spec/aliases.yaml`의 `remove_after` 데드라인이 추적 없이 방치,
|
||||
`spec/calibration_registry.yaml`의 중복 id로 일부 임계값이 조용히 무시됨. 세 사례 모두
|
||||
"문서가 코드를 정확히 가리키는지 자동으로 검증하는 장치가 없다"는 동일 원인이다.
|
||||
LLM이 런타임에 이런 stale spec을 사실로 읽으면 할루시네이션으로 직결된다(사용자 질의,
|
||||
2026-06-21). **목표는 "구현됐으니 문서 삭제"가 아니라 "LLM이 읽는 문서는 항상 코드와의
|
||||
동기화를 CI가 보장하고, 동기화할 수 없는 순수 설명용 문서는 폐기한다."**
|
||||
|
||||
| 항목 | 내용 |
|
||||
|------|------|
|
||||
| **작업** | spec YAML에 `has_code_implementation`/`code_path` 필드를 추가하고 `validate_specs.py`가 해당 code_path 존재 여부를 자동 검사하도록 신규 검증기 추가. **정정(구현 중 발견)**: `role: deprecated_redirect`는 실제로 2개뿐이었다(`spec/03_risk_policy.yaml`, `spec/04_strategy_rules.yaml`) — `spec/06_exit_policy.yaml`은 `role: compatibility_index`(영구 유지 설계, risk_control.yaml/entry_gates.yaml과 동급)였다. 설계 단계의 "3개 삭제" 진술 자체가 부정확했던 것을 구현 중 재확인 후 정정 — 2개만 실삭제, 06_exit_policy.yaml은 `redirect_only:true`로 태깅해 유지 |
|
||||
| **스키마 설계** | 각 spec YAML의 `meta:` 블록(없으면 최상위)에 추가:<br>`has_code_implementation: true\|false`<br>`code_path: "tools/build_x.py"` 또는 `["tools/a.py", "src/quant_engine/b.py"]` (true일 때만 필수)<br>`role: deprecated_redirect`/`compatibility_index` 파일은 `has_code_implementation: false` + `redirect_only: true`로 명시(코드 없음이 정상이므로 code_path 검사 스킵) |
|
||||
| **검증기 설계** | `tools/validate_specs.py`에 `validate_spec_code_sync(errors)` 신규 함수 추가:<br>1. `spec/**/*.yaml` 전체를 순회<br>2. `has_code_implementation` 필드가 **있는** 파일만 검사(필드 없는 파일은 skip — 이것이 점진적 롤아웃 메커니즘. 전체 일괄 강제 아님)<br>3. `true`인데 `code_path`(들)가 디스크에 없으면 `fail(errors, f"spec declares code_path that does not exist: {path} → {code_path}")`<br>4. `redirect_only: true`인데 `has_code_implementation: true`이면 모순으로 fail<br>5. 결과를 `Temp/spec_code_sync_v1.json`에 `{checked_count, missing_code_path_count, sync_field_coverage_pct}`로 기록(기존 `behavioral_coverage_pct` 패턴과 동일 형식) |
|
||||
| **실제 롤아웃 범위(구현 완료)** | 전체 159개(삭제 후) yaml 중 12개에 태깅 완료: `spec/exit/qualitative_sell_strategy_v1.yaml`, `governance/rules/06·07`, `spec/19_harness_contract.yaml`, `spec/55_execution_simulator_contract.yaml`, `spec/41_release_dag.yaml`, `spec/15_account_snapshot_contract.yaml`, `spec/18_settings_contract.yaml`, `spec/calibration_registry.yaml`(true 7개) + `spec/risk/risk_control.yaml`, `spec/strategy/entry_gates.yaml`, `spec/06_exit_policy.yaml`(redirect_only 3개). 공식 레지스트리(`13_formula_registry.yaml` 등)는 1:1 code_path가 없어 범위 제외 — 이미 `calibration_registry.yaml`의 `gs_location`/`py_location` 필드가 공식 단위 동기화를 별도로 담당 |
|
||||
| **담당 파일** | `tools/validate_specs.py`(`validate_spec_code_sync` 신규), `tests/unit/test_validate_spec_code_sync_v1.py`(신규 4건), 위 12개 spec/governance 파일 |
|
||||
| **상태** | ✅ 구현 완료 (2026-06-22) |
|
||||
|
||||
**구현 중 발견한 버그**: 최초 구현에서 `redirect_only=true AND has_code_implementation=true` 모순 케이스가 `errors`에는 쌓이지만 함수 자신의 반환값 `gate`는 PASS로 남는 버그가 있었다 — 직접 작성한 단위테스트(`test_redirect_only_and_has_code_is_contradiction`)가 즉시 잡아냈고 `missing` 카운터에 반영해 수정했다.
|
||||
|
||||
**성공 하네스 (데이터 기준)**:
|
||||
```
|
||||
검증: python tools/validate_specs.py → Temp/spec_code_sync_v1.json
|
||||
결과: {"total_spec_files": 159, "checked_count": 12, "missing_code_path_count": 0,
|
||||
"sync_field_coverage_pct": 7.55, "gate": "PASS"}
|
||||
회귀: python -m pytest tests/unit tests/integration -q → 85 passed
|
||||
부수 조치(완료): role: deprecated_redirect 2개 파일(03_risk_policy.yaml/04_strategy_rules.yaml)
|
||||
실삭제 + RetirementAssetPortfolio.yaml의 risk_policy_index/strategy_rules_index 참조 제거 +
|
||||
6개 자식 파일의 parent_file 갱신 + spec/ownership_map.yaml·spec/risk·strategy/README.md 정정
|
||||
(WBS-7.4에서 alias만 지우고 파일은 남겨뒀던 부분의 후속 정리)
|
||||
목표(2차, 분기별 확장): sync_field_coverage_pct ≥ 50% — formula registry급 파일들의
|
||||
공식 단위 동기화 메커니즘(calibration_registry gs_location/py_location) 커버리지 확장과 별개 트랙
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 완성도 로드맵 매트릭스
|
||||
|
||||
| WBS | 우선순위 | 난이도 | 선행조건 | 예상 기간 | 현재 완성도 |
|
||||
@@ -551,6 +958,19 @@ CI 게이트:
|
||||
| 5.1 CI/CD | 🟡 Medium | 중간 | Gitea 연결 | 완료 | **100%** ✅ |
|
||||
| 5.2 GAS 자동 배포 | 🟢 Low | 낮음 | 5.1 완료 | 완료 | **100%** ✅ |
|
||||
| 5.3 자율 실행 | 🟢 Low | 중간 | 5.1+5.2 완료 | 완료 | **100%** ✅ |
|
||||
| 6 비기계적 매도전략·위성추천 (엔진+데이터+KIS+SQLite+자체평가) | 🔴 Critical | 높음 | 없음 | 완료 | **100%** ✅ (잔류위험: 0c절·WBS-7.7) |
|
||||
| 6-잔여 공매도 잔고율 | 🟢 Low | 높음 | KRX 정책 | 차단 확정 | USER_ACTION 대기 |
|
||||
| 7.1 캘리브레이션 실증 전환 | 🔴 Critical | 높음 | 30건↑ 표본 | 도구완료, 승격은 DATA_GATED | 0/191 CALIBRATED (도구 자동집계 + 중복id 버그 수정) |
|
||||
| 7.2 T+5 지표 정합성 통일 | 🔴 Critical | 낮음 | 없음 | 완료 | **100%** ✅ (2026-06-21) |
|
||||
| 7.3 GAS→Python 마이그레이션 | 🟠 High | 중간 | parity 테스트 | 부분완료 + 12건 의도적 보류 | 2/15 DONE, 12 TODO(근거기록), 1 KEEP_IN_GAS |
|
||||
| 7.4 Deprecated 정리 | 🟠 High | 낮음 | 없음 | 완료 | **100%** ✅ (2026-06-21, alias 17건 제거) |
|
||||
| 7.5 임시 폴백 비례화 | 🟡 Medium | 중간 | 없음 | 완료(OVERHANG만) | **100%** ✅ (2026-06-21, 나머지 2건은 정책결정 분리) |
|
||||
| 7.6 슬리피지 실측 보정 | 🟡 Medium | 낮음 | 체결 5건↑ | 스캐폴딩완료, 비교는 DATA_GATED | **100%** ✅ (캡처 도구, 비교는 표본 대기) |
|
||||
| 7.7 E2E 통합테스트 | 🟠 High | 중간 | 없음 | 완료 | **100%** ✅ (2026-06-21, 3 passed) |
|
||||
| 7.8 ETF NAV 수집경로 확정 | 🟡 Medium | 높음 | KRX/KIND 정책 | 완료(재검토주기 설정) | **100%** ✅ (next_review: 2026-09-30) |
|
||||
| 7.9 Synology 배포 검토 | 🟡 Medium | 중간 | 보안정책 결정 | 완료(검토만) | **100%** ✅ (구현은 정책 결정 대기) |
|
||||
| 7.10 어드민 테이블 그리드(Tabler) | 🟢 Low | 낮음 | 없음 | 완료 | **100%** ✅ (2026-06-21, 8 passed) |
|
||||
| 7.11 spec-코드 동기화 게이트 | 🔴 Critical | 중간 | 없음 | 완료 | **100%** ✅ (2026-06-22, 12/159 태깅, 85 passed) |
|
||||
|
||||
---
|
||||
|
||||
@@ -583,10 +1003,21 @@ CI 게이트:
|
||||
|
||||
성과:
|
||||
T+20 레저 건수: 0건 → 목표: 30건 (~2026-07-12) DATA_GATED
|
||||
예측 적중률(T+5): 54.76% (t5_ap_combined) → 목표: ≥55% ≈달성 근접
|
||||
예측 적중률(T+1): 52.94% (sample=68, decisive=67.92%) — as_of 2026-06-21
|
||||
예측 적중률(T+5): DATA_GATED (sample=0, as_of 2026-06-21) — 0c절 참조, 과거 54.76%/35.86% 캐시값 모두 폐기
|
||||
알파 (vs KOSPI): 미측정 → 목표: >0%p/분기
|
||||
honest_proof_score: 50.95 → 목표: ≥70 (T+20 30건 → 70.95 자동 달성 예상)
|
||||
|
||||
캘리브레이션 품질 (신규, WBS-7.1):
|
||||
calibrated_threshold_count: 0/190 (0%) → 목표: ≥10건 (1차), ≥30건 (2차)
|
||||
provisional_threshold_count: 8/190 (4.2%) → 목표: ≥30건
|
||||
expert_prior_unvalidated_pct: 95.8% (SPEC_DERIVED+EXPERT_PRIOR) → 목표: ≤70%
|
||||
|
||||
보완·고도화 (신규, Phase 7):
|
||||
gas_python_migration_pct: 0/14 완료 (0%) → 목표: 14/14 (100%, KEEP_IN_GAS 1건 제외)
|
||||
deprecated_alias_remaining: 17건 (데드라인 2026-06-30) → 목표: 0건
|
||||
e2e_integration_test_count: 0건 → 목표: ≥1건 (KIS수집→스냅샷→정성매도 체인)
|
||||
|
||||
자동화:
|
||||
run_all 성공률: 98단계 DAG PASS → 목표: ≥95% ✅ (step_count=98, wave_0~9)
|
||||
CI/CD 커버리지: 100% → 목표: 100% ✅ (Synology act_runner 온라인, 4게이트 PASS)
|
||||
@@ -676,6 +1107,44 @@ python tools/update_sector_universe_from_naver.py --limit 10 --apply # 원본
|
||||
|
||||
---
|
||||
|
||||
### Sprint-6 (비판적 보완 스프린트, 2026-06-21 비판적 리뷰 대응)
|
||||
|
||||
```
|
||||
[x] WBS-7.2: T+5/예측정확도 지표 단일 진실원천 통일 (2026-06-21 완료)
|
||||
[x] WBS-7.4: Deprecated 별칭 17건 정리 — 2026-06-30 데드라인 (2026-06-21 완료, validate_specs.py PASS)
|
||||
[x] WBS-7.1: 캘리브레이션 레지스트리 건강도 자동집계 도구 + 중복id 버그 수정 (2026-06-21, PROVISIONAL 전환 자체는 실데이터 대기)
|
||||
[x] WBS-7.3: GAS→Python 마이그레이션 재검토 완료(2건 DONE 정정, 12건 의도적 보류+근거기록, 2026-06-21) — 잔여는 별도 parity 테스트 스프린트
|
||||
[x] WBS-7.7: KIS수집→스냅샷→정성매도 E2E 통합 테스트 작성 (2026-06-21 완료, 3 passed)
|
||||
[x] WBS-7.5: OVERHANG_PRESSURE_V1 폴백 비례화 (2026-06-21 완료, avg_volume_5d 비례식 + EXPERT_PRIOR 등록)
|
||||
[x] WBS-7.6: 슬리피지 실측 캡처 스캐폴딩 구축 완료 (2026-06-21, 비교 자체는 체결 5건 누적 대기)
|
||||
[x] WBS-7.8: ETF NAV 수집경로 재검토 + 공매도 잔고율 운영절차 문서화 (2026-06-21 완료)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 부록: Phase 5 데이터 플랫폼 전환 WBS 성공값
|
||||
|
||||
> 원칙: 아래 항목은 모두 `기대 성공값 + 데이터 증빙 + 검증 명령`이 함께 있어야 성공으로 본다.
|
||||
> 현재 구현된 항목은 로컬 `Temp/` 증빙을 기준으로 판정하고, 아직 미래 전환 항목은 `DATA_GATED`로 둔다.
|
||||
|
||||
| WBS | 기대 성공값 | 데이터 증빙 | 검증 명령 |
|
||||
|-----|------------|------------|-----------|
|
||||
| P1 KIS core collector | `collector_gate=PASS`, `output_json_gate=PASS`, `collection_runs>=1`, `collection_snapshots>=1`, `provenance_source_count>=1` | `Temp/test_kis_data_collection.json`, `Temp/test_kis_data_collection.db` | `python tools/run_kis_data_collection_v1.py --input-json GatherTradingData.json --sqlite-db Temp/test_kis_data_collection.db --output-json Temp/test_kis_data_collection.json --kis-account real --no-live-kis --no-naver` |
|
||||
| P2 SQLite canonical store | `sqlite_schema_tables>=3`, `round_trip_snapshot_lookup=PASS`, `backend_contract_sqlite=PASS`, `backend_contract_postgresql=READY`, `single_workspace_sqlite=true`, `collector_separate_db=true` | `src/quant_engine/data_collection_store_v1.py`, `src/quant_engine/data_collection_backend_v1.py`, `tests/unit/test_data_collection_store_v1.py`, `src/quant_engine/snapshot_admin_store_v1.py` | `python -m pytest tests/unit/test_data_collection_store_v1.py -q` |
|
||||
| P3 CI scheduler cutover | `xlsx_dependency_removed=true`, `json_seed_input=true`, `sqlite_output=true`, `mock_api_validation=PASS`, `no_direct_trading_gate=PASS` | `.gitea/workflows/kis_data_collection.yml`, `Temp/kis_api_credentials_validation_v1.json`, `Temp/test_kis_data_collection.json` | `python tools/validate_no_direct_api_trading_v1.py` |
|
||||
| P4 GAS thin adapter minimize | `allowed_responsibilities_only=true`, `forbidden_responsibilities_present=false`, `thin_adapter_gate=PASS` | `tools/validate_gas_thin_adapter_v1.py`, `Temp/gas_thin_adapter_validation_v1.json`, `src/gas/core/gas_lib.gs` | `python tools/validate_gas_thin_adapter_v1.py` |
|
||||
| P5 PostgreSQL upgrade path | `sqlite_schema_parity=PASS`, `backend_contract_present=true`, `postgres_execution=DATA_GATED`, `caller_compatibility_preserved=true` | `src/quant_engine/data_collection_backend_v1.py`, `src/quant_engine/kis_data_collection_v1.py`, `tests/unit/test_data_collection_store_v1.py`, `tools/generate_postgresql_upgrade_stub_v1.py` | `python -m pytest tests/unit/test_data_collection_store_v1.py -q` |
|
||||
| P6 Snapshot admin web editor | `settings_sheet_web_editor=true`, `account_snapshot_sheet_web_editor=true`, `contenteditable_grid=true`, `api_save_round_trip=PASS`, `kis_collection_dashboard=true`, `workspace_db_is_single_file=true`, `collection_filter_controls=true`, `collection_dashboard_page=true`, `change_timeline_view=true` | `src/quant_engine/snapshot_admin_server_v1.py`, `src/quant_engine/data_collection_store_v1.py`, `src/quant_engine/snapshot_admin_store_v1.py`, `tools/validate_snapshot_admin_web_v1.py`, `tests/unit/test_snapshot_admin_web_v1.py`, `.gitea/workflows/snapshot_admin.yml` | `python tools/validate_snapshot_admin_web_v1.py` |
|
||||
| Q1 Qualitative sell pipeline | `mock_api_validation=PASS`, `pipeline_contract=PASS`, `workflow_present=true`, `schedule_present=true`, `package_scripts_present=true` | `.gitea/workflows/qualitative_sell_strategy.yml`, `tools/validate_qualitative_sell_strategy_pipeline_v1.py`, `Temp/qualitative_sell_strategy_pipeline_v1.json` | `python tools/validate_qualitative_sell_strategy_pipeline_v1.py` |
|
||||
| Q2 Gitea secrets contract | `secrets_contract=PASS`, `workflow_secret_mapping=PASS`, `docs_present=true`, `ci_validation_present=true` | `docs/GITEA_SECRETS_SETUP.md`, `tools/validate_gitea_secrets_contract_v1.py`, `Temp/gitea_secrets_contract_v1.json` | `python tools/validate_gitea_secrets_contract_v1.py` |
|
||||
|
||||
### WBS 성공 판정 규칙
|
||||
|
||||
- `PASS`: 기대 성공값이 충족되고, 해당 증빙 파일이 실제로 존재한다.
|
||||
- `READY`: 지금은 실행하지 않지만, 다음 단계 전환에 필요한 코드/계약이 존재한다.
|
||||
- `DATA_GATED`: 의도적으로 아직 실제 데이터가 쌓이지 않아 보류된 항목이다.
|
||||
- `FAIL`: 기대 성공값을 만족하지 못하거나 증빙이 없다.
|
||||
|
||||
> 이 문서는 `docs/ROADMAP_WBS.md` 에 저장됩니다.
|
||||
> 스프린트 완료마다 **완성도 KPI 섹션**을 업데이트하세요.
|
||||
> 모든 WBS 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다.
|
||||
|
||||
@@ -6,3 +6,18 @@
|
||||
4. Render reports from canonical data only.
|
||||
5. Package upload artifacts only after the full gate passes or the output is explicitly audit-only.
|
||||
6. Treat work as complete only when YAML, code, data artifacts, and validation evidence all exist together.
|
||||
7. For calibration maintenance, run `npm run ops:calibration-backlog` or the Gitea schedule in `.gitea/workflows/calibration_backlog.yml`.
|
||||
8. Promote a threshold to `PROVISIONAL` only when there is a recorded sample note and an explicit change note in `Temp/calibration_change_ledger_v4.json`.
|
||||
9. Promote a threshold to `CALIBRATED` only when `sample_n >= 30`, a backtest note exists, and the validator still reports `overclaimed_count == 0`.
|
||||
10. For human review, open `Temp/calibration_review_report_v1.md` after each backlog build.
|
||||
11. For approval signoff, open `Temp/calibration_approval_list_v1.md` and approve only `source=PROVISIONAL` rows unless a new provisional review is explicitly requested.
|
||||
12. For spreadsheet-like edits of `settings` and `account_snapshot`, run `npm run ops:snapshot-web` and validate with `npm run ops:snapshot-web-validate`.
|
||||
13. Treat the snapshot admin web UI as the canonical edit surface for SQLite-backed manual maintenance; export JSON only when CI or downstream tooling needs a file artifact.
|
||||
14. Keep `settings` and `account_snapshot` in the same workspace SQLite DB. Do not split them into separate files per sheet; use a separate SQLite DB only for the KIS collection pipeline.
|
||||
15. Use the `KIS Collection` panel in snapshot admin to inspect the latest SQLite collection run, report status, source counts, and recent errors before you touch the editor.
|
||||
16. Use the collection filter when you need to narrow runs, snapshots, or errors by ticker/source/status.
|
||||
17. Use the change log filter when you need to audit a specific domain, action, or target reference.
|
||||
18. Use `/collection` when you want the collection-only dashboard with raw JSON download.
|
||||
19. Use `Export approval packet` in the snapshot admin UI to write `Temp/snapshot_admin_approval_packet_v1.json` and `Temp/snapshot_admin_approval_packet_v1.md` for review handoff.
|
||||
20. Short balance ratio (`short_balance_ratio`) has no automatable path — confirmed 2026-06-22 by live-testing `pykrx.stock.get_shorting_balance()` (already used elsewhere in this repo for EOD prices), which returns `HTTP 400 LOGOUT` even with a properly bootstrapped session. This KRX "standard report" endpoint family requires actual KRX member login (`KRX_ID`/`KRX_PW`), unlike the basic OHLCV endpoints. Adding KRX login credentials is a new credential-management policy decision (same category as governance/rules/06-07) that requires explicit user approval — do not add it unilaterally. Until then, download the KRX 공매도종합포털 CSV weekly (every Monday before market open) and feed it via `--short-csv` to `build_qualitative_sell_inputs_v1.py`.
|
||||
21. ETF NAV/iNAV/괴리율/추적오차/AUM has no automatable path either — same 2026-06-22 test confirmed `pykrx.stock.get_etf_price_deviation()`/`get_etf_tracking_error()` also return `HTTP 400 LOGOUT` (same KRX member-login gate as item 20). See `spec/16_data_gaps_roadmap.yaml` S4/S5 `automation_attempt_2026_06_22` for the full reproduction. Until a KRX login policy decision is made, keep feeding `etf_nav_manual` via `tools/import_etf_nav_manual.py` from manually downloaded KRX/KIND/운용사 CSV exports.
|
||||
|
||||
@@ -10,6 +10,23 @@ classification_summary:
|
||||
display_text: 1
|
||||
unclassified_findings: 0
|
||||
|
||||
# WBS-7.3 재검토 (2026-06-21):
|
||||
# - F01/F09 (REGISTER_*): DONE으로 정정 — spec/calibration_registry.yaml에 이미
|
||||
# 등록되어 있었음(P5-T01 wave1). 레저 상태가 stale했을 뿐 실작업 불필요.
|
||||
# - F12/F13 (DELETE_DISTRIBUTION_RISK_GAS): ledger의 "build_distribution_risk_v1.py"
|
||||
# 인용은 오류(존재하지 않는 파일) — 실제는 build_distribution_risk_score_v2.py가
|
||||
# 동일 필드를 산출하나, GAS-Python parity 테스트가 전혀 없어 삭제를 보류.
|
||||
# - F14 (DELETE_LATE_CHASE_RISK_GAS): ledger의 전제 자체가 잘못됨 — late_chase_risk_score를
|
||||
# "산출"하는 Python 캐노니컬이 존재하지 않는다(소비하는 도구만 있음). GAS가 유일한
|
||||
# 산출 경로일 가능성이 높아 삭제 시도하지 않음. migration_action 재검증 필요.
|
||||
# - F02~F06, F07, F10, F11, F15 (MEDIUM/HIGH priority MIGRATE_*): 전용 parity 테스트
|
||||
# 인프라(GAS 함수와 동일 입력으로 Python 포트 출력을 대조)가 없는 상태에서 결정론적
|
||||
# 매매엔진의 가격/수량/정지손실/라우팅 로직을 포팅하는 것은 silent correctness bug
|
||||
# 위험이 크다고 판단해 이번 세션에서는 착수하지 않았다(advisor 권고에 따른 보류).
|
||||
# 특히 F11(stop_loss_gate)은 ledger 자체가 "critical path — must match
|
||||
# validate_stop_loss_policy_v1 spec"로 명시한 항목이다. 후속 전용 스프린트에서
|
||||
# parity 테스트를 먼저 구축한 뒤 착수해야 한다.
|
||||
|
||||
# Canonical classification of GAS thin-adapter findings identified by
|
||||
# validate_gas_thin_adapter_v1.py. Each finding is classified by what type
|
||||
# of logic it contains and paired with a migration_action.
|
||||
@@ -21,7 +38,8 @@ findings:
|
||||
classification: score_logic
|
||||
migration_action: REGISTER_SP_TAKE_PROFIT
|
||||
target_file: formulas/score_thresholds_v1.py
|
||||
status: TODO
|
||||
status: DONE
|
||||
resolved_2026_06_21: "이미 spec/calibration_registry.yaml에 id=SP_TAKE_PROFIT(gs_location=gas_data_feed.gs:186, 'P5-T01 wave1'에서 등록)으로 등록되어 있음을 재확인. 별도 formulas/score_thresholds_v1.py 신규 작성 불필요 — 레저 상태만 stale했음."
|
||||
|
||||
- id: F02
|
||||
file: src/gas_adapter_parts/gdf_01_price_metrics.gs
|
||||
@@ -95,7 +113,8 @@ findings:
|
||||
classification: score_logic
|
||||
migration_action: REGISTER_TAKE_PROFIT_BASE
|
||||
target_file: formulas/score_thresholds_v1.py
|
||||
status: TODO
|
||||
status: DONE
|
||||
resolved_2026_06_21: "이미 spec/calibration_registry.yaml에 id=TAKE_PROFIT_BASE(gs_location=gas_data_feed.gs:2164)로 등록되어 있음을 재확인. F01과 동일 사유로 레저 상태만 stale했음."
|
||||
|
||||
- id: F10
|
||||
file: src/gas_adapter_parts/gdf_03_portfolio_gates.gs
|
||||
@@ -124,6 +143,14 @@ findings:
|
||||
target_file: formulas/distribution_risk_v1.py
|
||||
status: TODO
|
||||
notes: Python canonical (build_distribution_risk_v1.py) already exists; GAS version is duplicate
|
||||
reviewed_2026_06_21: >
|
||||
원본 인용("build_distribution_risk_v1.py")은 존재하지 않는 파일이다 — 실제로는
|
||||
tools/build_distribution_risk_score_v2.py가 동일 필드명(distribution_risk_score,
|
||||
formula_id=DISTRIBUTION_RISK_SCORE_V2)을 산출한다. 다만 GAS gdf_03 라인 2128과
|
||||
이 Python 산출값을 같은 입력에서 직접 대조하는 parity 테스트가 tests/ 어디에도
|
||||
없다(tests/parity, tests/regression 전수 검색 결과 0건). "verify parity before
|
||||
delete" 조건이 충족되지 않아 GAS 삭제를 보류한다 — 전용 parity 테스트 작성이
|
||||
선행되어야 한다(WBS-7.3 후속 스프린트).
|
||||
|
||||
- id: F13
|
||||
file: src/gas_adapter_parts/gdf_03_portfolio_gates.gs
|
||||
@@ -133,6 +160,7 @@ findings:
|
||||
migration_action: DELETE_DISTRIBUTION_RISK_GAS
|
||||
status: TODO
|
||||
notes: formula_id tag stays with Python canonical; remove from GAS
|
||||
reviewed_2026_06_21: "F12와 동일 사유로 보류 — parity 테스트 선행 필요."
|
||||
|
||||
- id: F14
|
||||
file: src/gas_adapter_parts/gdf_03_portfolio_gates.gs
|
||||
@@ -143,6 +171,15 @@ findings:
|
||||
target_file: formulas/late_chase_risk_v1.py
|
||||
status: TODO
|
||||
notes: Python canonical (build_alpha_lead_table_v1.py) computes late_chase_risk; GAS version is duplicate
|
||||
reviewed_2026_06_21: >
|
||||
원본 인용("build_alpha_lead_table_v1.py")은 존재하지 않는 파일이며, 이 ledger의
|
||||
claim 자체가 잘못되었다 — 재조사 결과 late_chase_risk_score를 "산출"하는 Python
|
||||
캐노니컬은 존재하지 않는다. tools/build_late_chase_attribution_v1.py는 이 필드를
|
||||
입력에서 "소비"만 할 뿐(r.get("late_chase_risk_score")) 직접 계산하지 않으며,
|
||||
build_anti_late_chase_v5/v6.py도 별도 산출 로직이다. 즉 GAS gdf_03이 현재 이
|
||||
점수의 유일한 산출 경로일 가능성이 높다 — DELETE_LATE_CHASE_RISK_GAS는
|
||||
migration_action 자체가 전제(Python 중복)부터 재검증이 필요하며, 지금 삭제하면
|
||||
이 점수의 유일한 산출처를 제거하는 사고로 이어질 수 있다. 삭제 금지, 후속 조사 필요.
|
||||
|
||||
- id: F15
|
||||
file: src/gas_adapter_parts/gdf_04_execution_quality.gs
|
||||
|
||||
@@ -2,6 +2,13 @@ schema_version: agents_rule.v1
|
||||
rule_id: CORE_LOCKS_V1
|
||||
title: Core locks and no-hallucination rules
|
||||
summary:
|
||||
- "[NO_DIRECT_API_TRADING] 매수/매도 주문은 어떤 API(한국투자증권 KIS Open API 포함)를 통해서도
|
||||
직접 실행하지 않는다. 이 엔진의 모든 산출물은 '제안'이며, 실제 주문 실행은 반드시 사람이
|
||||
HTS에서 수동으로 입력한다. 이 원칙을 어기면 엔진 전체가 의미를 잃는다(사용자 직접 지시,
|
||||
2026-06-21) — governance/rules/06_no_direct_api_trading.yaml 참조."
|
||||
- "[NO_KIS_ACCOUNT_BALANCE_QUERY] KIS Open API로 계좌 보유종목/잔고를 조회하지 않는다.
|
||||
보유종목의 유일한 출처는 HTS 캡처 → account_snapshot이다(사용자 직접 지시, 2026-06-21)
|
||||
— governance/rules/07_no_kis_account_balance_query.yaml 참조."
|
||||
- Use spec/13_formula_registry.yaml for all prices, stops, targets, quantities.
|
||||
- Do not invent prices, quantities, or formulas.
|
||||
- If harness data is missing, print DATA_MISSING — 하네스 업데이트 필요.
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
schema_version: agents_rule.v1
|
||||
rule_id: NO_DIRECT_API_TRADING_V1
|
||||
title: API를 통한 매수/매도 직접 실행 절대 금지 — 최상위 안전 규칙
|
||||
priority: CRITICAL
|
||||
origin: "사용자 직접 지시 (2026-06-21): '반드시 지침에 가장 중요한 하네스인 매수/매도는
|
||||
API를 통해서 직접하지 않는다가 원칙이다. 이걸 지키지 않는다면 엔진으로서 의미는 없다.'"
|
||||
has_code_implementation: true
|
||||
code_path:
|
||||
- "src/quant_engine/kis_api_client_v1.py"
|
||||
- "tools/validate_no_direct_api_trading_v1.py"
|
||||
|
||||
summary:
|
||||
- "이 엔진(은퇴자산포트폴리오 퀀트엔진)은 어떤 외부 API를 통해서도 매수/매도 주문을
|
||||
직접 실행하지 않는다. 한국투자증권 KIS Open API를 포함해, 향후 연동되는 모든
|
||||
브로커/거래소 API에 동일하게 적용된다."
|
||||
- "이 엔진의 모든 산출물(final_decision_packet, sell_priority, rebalance orders 등)은
|
||||
'제안(proposal)'이지 '주문 실행(execution)'이 아니다. 실제 매수/매도 주문은 반드시
|
||||
사람이 HTS(홈트레이딩시스템)에서 직접 확인 후 수동으로 입력한다."
|
||||
- "이 원칙은 데이터 수집(read-only) API 사용을 금지하지 않는다 — 시세/호가/공매도/
|
||||
투자자별 매매동향 등 조회성 데이터 수집은 허용된다. 금지 대상은 주문 제출
|
||||
(order placement), 정정(modify), 취소(cancel) API 호출뿐이다."
|
||||
|
||||
scope:
|
||||
applies_to:
|
||||
- "한국투자증권(KIS) Open API — https://apiportal.koreainvestment.com"
|
||||
- "향후 추가되는 모든 브로커/거래소 Open API 연동"
|
||||
prohibited_actions:
|
||||
- "주문 제출(매수/매도 주문 전송) API 호출"
|
||||
- "기존 주문 정정/취소 API 호출"
|
||||
- "잔고를 변경시키는 모든 쓰기성(write) API 호출"
|
||||
allowed_actions:
|
||||
- "시세 조회(현재가, 호가, 일자별 시세)"
|
||||
- "공매도 일별추이 조회"
|
||||
- "투자자별 매매동향 조회"
|
||||
- "계좌 잔고/평가 조회(읽기 전용)"
|
||||
|
||||
enforcement:
|
||||
code_level:
|
||||
rule: "KIS API 클라이언트 모듈(src/quant_engine/kis_api_client_v1.py)의 모든 HTTP 요청은
|
||||
단일 공유 함수를 통해서만 전송되며, 그 함수는 차단 목록(FORBIDDEN_TR_ID_PREFIXES,
|
||||
FORBIDDEN_PATH_SUBSTRINGS)에 해당하는 TR_ID/경로를 만나면 즉시 RuntimeError를
|
||||
발생시켜 요청을 중단한다. 주문 제출/정정/취소 함수는 이 코드베이스에 일체 작성하지
|
||||
않는다(함수 자체가 존재하지 않음 — 가드는 방어적 2차 안전장치)."
|
||||
test: "tests/unit/test_kis_api_client_v1.py — 차단 목록에 있는 TR_ID/경로로 요청 시
|
||||
RuntimeError가 발생하는지 검증 + 소스코드 전체에 주문 제출 엔드포인트 경로
|
||||
문자열(/uapi/domestic-stock/v1/trading/order-cash 등)이 한 글자도 존재하지 않는지
|
||||
정적 grep 검증."
|
||||
review_level:
|
||||
rule: "이 모듈에 새 함수를 추가할 때마다 반드시 KIS Open API 공식 문서에서 해당
|
||||
TR_ID가 조회(quotations)/순위(ranking)/계좌조회(read-only) 카테고리인지 확인하고,
|
||||
trading(주문) 카테고리 함수는 어떤 이유로도 추가하지 않는다."
|
||||
|
||||
violation_consequence: "이 규칙을 어기면 엔진 전체가 '제안 시스템'에서 '자동매매 시스템'으로
|
||||
변질되어 프로젝트의 핵심 전제(사람이 최종 승인·입력)가 깨진다. 사용자가 명시적으로
|
||||
'엔진으로서 의미는 없다'고 표현한 절대 우선 규칙이다."
|
||||
@@ -0,0 +1,44 @@
|
||||
schema_version: agents_rule.v1
|
||||
rule_id: NO_KIS_ACCOUNT_BALANCE_QUERY_V1
|
||||
title: KIS Open API로 계좌 보유종목/잔고 정보를 조회하지 않는다 — 필수 지침
|
||||
priority: CRITICAL
|
||||
origin: "사용자 직접 지시 (2026-06-21): 'OPEN API에 계좌 보유종목에 대한 정보는 사용하지
|
||||
않는다. 필수 지침이다.'"
|
||||
has_code_implementation: true
|
||||
code_path:
|
||||
- "src/quant_engine/kis_api_client_v1.py"
|
||||
- "tools/validate_no_direct_api_trading_v1.py"
|
||||
|
||||
summary:
|
||||
- "한국투자증권(KIS) Open API는 시세/호가/공매도/투자자매매동향 등 시장 전체에 공개된
|
||||
조회성 데이터 수집에만 사용한다. 계좌 보유종목·잔고·평가금액 조회(주식잔고조회 등)
|
||||
API는 호출하지 않는다."
|
||||
- "보유종목 정보의 유일한 출처(source of truth)는 기존 HTS 캡처 → ChatGPT 파싱 → GAS
|
||||
account_snapshot 시트 워크플로우다. 이 원칙은 [[feedback_direction_a_no_manual_input]]
|
||||
(positions 수동입력 금지)와 같은 계열의 데이터 출처 통제 규칙이며, KIS API가 그
|
||||
경로를 대체하거나 보강하지 않는다."
|
||||
- "이 규칙은 governance/rules/06_no_direct_api_trading.yaml(주문 미실행)과 별개의
|
||||
독립적인 제약이다 — 06번 규칙은 '쓰기(주문)'를 금지하고, 이 규칙은 '계좌 식별 데이터
|
||||
조회(읽기)'를 금지한다. 두 규칙 모두 충돌 없이 동시에 적용된다."
|
||||
|
||||
scope:
|
||||
prohibited_tr_ids:
|
||||
- "TTTC8434R" # 주식잔고조회(실전)
|
||||
- "VTTC8434R" # 주식잔고조회(모의)
|
||||
prohibited_path_substrings:
|
||||
- "/trading/inquire-balance"
|
||||
rationale: >
|
||||
이미 governance/rules/06의 FORBIDDEN_PATH_SUBSTRINGS=("/trading/",)가 이 경로를
|
||||
구조적으로 차단하지만(주식잔고조회도 /trading/ 하위 경로), 이 규칙은 그것이
|
||||
'주문 차단의 부수효과'가 아니라 '계좌정보 비조회'라는 독립적이고 의도적인 정책임을
|
||||
명시한다.
|
||||
|
||||
enforcement:
|
||||
code_level: "src/quant_engine/kis_api_client_v1.py에 inquire-balance 관련 함수를 작성하지
|
||||
않는다(함수 자체가 존재하지 않음). TTTC8434R/VTTC8434R을 FORBIDDEN_TR_ID_PREFIXES에
|
||||
추가해 2차 방어."
|
||||
test_level: "tests/unit/test_kis_api_client_v1.py — TTTC8434R/VTTC8434R 차단 검증 +
|
||||
tools/validate_no_direct_api_trading_v1.py 정적 스캔에 동일 TR_ID/경로 포함."
|
||||
|
||||
violation_consequence: "계좌 보유정보를 KIS API로 조회하면 HTS 캡처 기반 단일 진실원천
|
||||
원칙이 깨지고, 두 개의 서로 다른 보유종목 데이터 경로가 생겨 정합성 검증이 불가능해진다."
|
||||
@@ -7,7 +7,20 @@
|
||||
"ops:prepare": "python tools/convert_xlsx_to_json.py",
|
||||
"ops:validate": "python tools/run_release_dag_v3.py --mode release",
|
||||
"ops:build": "python tools/build_bundle.py",
|
||||
"ops:data-collect": "python tools/run_kis_data_collection_v1.py --input-json GatherTradingData.json --sqlite-db outputs/kis_data_collection/kis_data_collection.db --output-json Temp/kis_data_collection_v1.json --kis-account real",
|
||||
"ops:sell-build": "python tools/build_qualitative_sell_inputs_v1.py --batch --workbook GatherTradingData.xlsx --kis-account real --apply",
|
||||
"ops:sell-satellite": "python tools/build_satellite_candidate_recommendations_v1.py --workbook GatherTradingData.xlsx --apply",
|
||||
"ops:sell-eval": "python tools/evaluate_qualitative_sell_strategy_accuracy_v1.py --sqlite-db outputs/qualitative_sell_strategy/qualitative_sell_strategy.db",
|
||||
"ops:sell-validate": "python tools/validate_qualitative_sell_strategy_pipeline_v1.py",
|
||||
"ops:postgres-stub": "python tools/generate_postgresql_upgrade_stub_v1.py",
|
||||
"ops:render": "python tools/render_operational_report.py --json GatherTradingData.json --output Temp/operational_report.md --report-json-output Temp/operational_report.json",
|
||||
"ops:snapshot-web": "python tools/run_snapshot_admin_server_v1.py --reload --db outputs/snapshot_admin/snapshot_admin.db --seed GatherTradingData.json",
|
||||
"ops:snapshot-validate": "python tools/validate_snapshot_admin_workflow_v1.py",
|
||||
"ops:snapshot-web-validate": "python tools/validate_snapshot_admin_web_v1.py",
|
||||
"ops:calibration-backlog": "python tools/build_calibration_priority_v1.py && python tools/build_calibration_change_ledger_v4.py && python tools/build_calibration_review_report_v1.py && python tools/validate_calibration_change_ledger_v1.py",
|
||||
"ops:calibration-review-report": "python tools/build_calibration_review_report_v1.py",
|
||||
"ops:calibration-approval-list": "python tools/build_calibration_approval_list_v1.py",
|
||||
"ops:calibration-decision-draft": "python tools/build_calibration_decision_draft_v1.py",
|
||||
"ops:sector-refresh": "python tools/update_sector_universe_from_naver.py --limit 10",
|
||||
"ops:sector-refresh-apply": "python tools/update_sector_universe_from_naver.py --limit 10 --apply",
|
||||
"ops:sector-validate": "python tools/validate_sector_universe_monthly_refresh_v1.py",
|
||||
@@ -26,6 +39,9 @@
|
||||
"validate-prediction-accuracy-harness": "python tools/validate_prediction_accuracy_harness_v2.py",
|
||||
"validate-alpha-feedback-loop": "python tools/validate_alpha_feedback_loop_v2.py",
|
||||
"validate-operational-alpha-calibration": "python tools/validate_operational_alpha_calibration_v2.py",
|
||||
"build-calibration-priority": "python tools/build_calibration_priority_v1.py",
|
||||
"build-calibration-change-ledger": "python tools/build_calibration_change_ledger_v4.py",
|
||||
"validate-calibration-change-ledger": "python tools/validate_calibration_change_ledger_v1.py",
|
||||
"validate-sector-flow-history-progress": "python tools/validate_sector_flow_history_progress_v1.py",
|
||||
"validate-realized-performance": "python tools/validate_realized_performance_v1.py",
|
||||
"validate-gas-recovery": "python tools/validate_gas_orchestration_recovery_v1.py",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"formula_id": "AUDIT_REPOSITORY_ENTROPY_V2",
|
||||
"gate": "PASS",
|
||||
"total_file_count": 1896,
|
||||
"total_file_count": 1903,
|
||||
"package_script_count": 32,
|
||||
"temp_json_count": 194,
|
||||
"budget": {
|
||||
@@ -15,5 +15,5 @@
|
||||
"keep package scripts within release envelope"
|
||||
]
|
||||
},
|
||||
"source_zip_sha256": "3ac3719981890d601de8d49a0d43fdb6a88c0b95d5503d7e2a6e5df4d35eb18c"
|
||||
"source_zip_sha256": "e92fc1d43216b2d8ca79bfda0976f7bb443f0d590ce2456aac2568e27dce1be2"
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 리스크 정책 호환 인덱스 (redirect-only)"
|
||||
parent_file: "RetirementAssetPortfolio.yaml"
|
||||
version: "2026-05-17-phase3_redirect_clarified"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "deprecated_redirect"
|
||||
warning: >
|
||||
이 파일은 경로 호환성 유지 전용입니다. 새 규칙·임계값 추가 금지.
|
||||
실제 리스크 규칙은 아래 canonical_split_files를 직접 참조하십시오.
|
||||
|
||||
canonical_split_files:
|
||||
portfolio_exposure_framework: "spec/risk/portfolio_exposure.yaml"
|
||||
risk_control: "spec/risk/risk_control.yaml"
|
||||
quality_control: "spec/risk/quality_control.yaml"
|
||||
|
||||
legacy_path_aliases:
|
||||
"spec/03_risk_policy.yaml:portfolio_exposure_framework": "spec/risk/portfolio_exposure.yaml:portfolio_exposure_framework"
|
||||
"spec/03_risk_policy.yaml:risk_control": "spec/risk/risk_control.yaml:risk_control"
|
||||
"spec/03_risk_policy.yaml:quality_control": "spec/risk/quality_control.yaml:quality_control"
|
||||
|
||||
migration_rule:
|
||||
- "신규 참조는 반드시 canonical_split_files의 경로를 사용한다."
|
||||
- "기존 문서/예시에서 legacy path가 남아 있으면 alias로 해석하되, 수정 시 새 경로로 교체한다."
|
||||
- "이 파일에는 수치 임계값을 추가하지 않는다."
|
||||
|
||||
validation:
|
||||
- "python tools/validate_specs.py"
|
||||
@@ -1,32 +0,0 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 전략 규칙 호환 인덱스 (redirect-only)"
|
||||
parent_file: "RetirementAssetPortfolio.yaml"
|
||||
version: "2026-05-17-phase3_redirect_clarified"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "deprecated_redirect"
|
||||
warning: >
|
||||
이 파일은 경로 호환성 유지 전용입니다. 새 규칙·임계값 추가 금지.
|
||||
실제 전략 규칙은 아래 canonical_split_files를 직접 참조하십시오.
|
||||
|
||||
canonical_split_files:
|
||||
sector_model: "spec/strategy/sector_model.yaml"
|
||||
entry_timing_guardrails: "spec/strategy/entry_gates.yaml"
|
||||
anti_late_trade_rule: "spec/strategy/entry_gates.yaml"
|
||||
stock_model: "spec/strategy/stock_model.yaml"
|
||||
rebalancing_trigger: "spec/strategy/rebalancing_trigger.yaml"
|
||||
|
||||
legacy_path_aliases:
|
||||
"spec/04_strategy_rules.yaml:sector_model": "spec/strategy/sector_model.yaml:sector_model"
|
||||
"spec/04_strategy_rules.yaml:entry_timing_guardrails": "spec/strategy/entry_gates.yaml:entry_timing_guardrails"
|
||||
"spec/04_strategy_rules.yaml:anti_late_trade_rule": "spec/strategy/entry_gates.yaml:anti_late_trade_rule"
|
||||
"spec/04_strategy_rules.yaml:stock_model": "spec/strategy/stock_model.yaml:stock_model"
|
||||
"spec/04_strategy_rules.yaml:rebalancing_trigger": "spec/strategy/rebalancing_trigger.yaml:rebalancing_trigger"
|
||||
|
||||
migration_rule:
|
||||
- "신규 참조는 반드시 canonical_split_files의 경로를 사용한다."
|
||||
- "기존 문서/예시에서 legacy path가 남아 있으면 alias로 해석하되, 수정 시 새 경로로 교체한다."
|
||||
- "이 파일에는 수치 임계값을 추가하지 않는다."
|
||||
|
||||
validation:
|
||||
- "python tools/validate_specs.py"
|
||||
@@ -5,6 +5,8 @@ meta:
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "compatibility_index"
|
||||
has_code_implementation: false
|
||||
redirect_only: true
|
||||
purpose: "기존 spec/06_exit_policy.yaml 경로를 보존하기 위한 인덱스 파일."
|
||||
|
||||
canonical_split_files:
|
||||
|
||||
@@ -119,6 +119,11 @@ formula_registry:
|
||||
- CONSECUTIVE_STREAK_V1
|
||||
- BREAKOUT_FAILURE_STOP_V1
|
||||
- TREND_FILTER_GATE_V1
|
||||
- SHORT_INTEREST_RISK_GAUGE_V1
|
||||
- QUALITATIVE_SELL_STRATEGY_V1
|
||||
- MARKET_REGIME_CLASSIFIER_V1
|
||||
- SATELLITE_CANDIDATE_SCORE_V1
|
||||
- MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1
|
||||
implementation_map:
|
||||
REGIME_CONDITIONAL_MACRO_FACTOR_V1: tools/build_predictive_alpha_dialectic_engine_v2.py:NF1
|
||||
REBOUND_CAPTURE_THESIS_FACTOR_V1: tools/build_predictive_alpha_dialectic_engine_v2.py:NF2
|
||||
@@ -165,6 +170,11 @@ formula_registry:
|
||||
CONSECUTIVE_STREAK_V1: tools/build_consecutive_streak_v1.py
|
||||
BREAKOUT_FAILURE_STOP_V1: tools/build_breakout_failure_stop_v1.py
|
||||
TREND_FILTER_GATE_V1: tools/build_trend_filter_gate_v1.py
|
||||
SHORT_INTEREST_RISK_GAUGE_V1: src/quant_engine/qualitative_sell_strategy_v1.py:compute_short_interest_composite
|
||||
QUALITATIVE_SELL_STRATEGY_V1: src/quant_engine/qualitative_sell_strategy_v1.py:compute_qualitative_sell_strategy
|
||||
MARKET_REGIME_CLASSIFIER_V1: src/quant_engine/qualitative_sell_strategy_v1.py:classify_market_regime
|
||||
SATELLITE_CANDIDATE_SCORE_V1: src/quant_engine/qualitative_sell_strategy_v1.py:compute_satellite_candidate_score
|
||||
MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1: src/quant_engine/qualitative_sell_strategy_v1.py:compute_microstructure_pressure_from_orderbook
|
||||
formulas:
|
||||
FLOW_CREDIT_V1:
|
||||
owner: engine_owner
|
||||
@@ -1209,8 +1219,11 @@ formula_registry:
|
||||
/ 4) * (-1.5)
|
||||
|
||||
'
|
||||
without_20d_fallback: 'frg_5d_sh < -500000 # 절대값 기준 임시 적용 OR flow_credit
|
||||
< 0.30
|
||||
without_20d_fallback: 'avg_volume_5d IS NOT NULL AND frg_5d_sh < -1.5 * avg_volume_5d
|
||||
OR flow_credit < 0.30 # 2026-06-21 WBS-7.5: 절대값(-500000) 폐기, avg_volume_5d
|
||||
비례식으로 교체. 1.5배수는 with_20d 분기와 동일 계수 재사용(추정 아님).
|
||||
calibration_ref: spec/calibration_registry.yaml:OVERHANG_PRESSURE_V1_FALLBACK_MULT (EXPERT_PRIOR)
|
||||
avg_volume_5d 결측 시 이 항목은 false로 처리(추정 금지, missing_policy 참조)
|
||||
|
||||
'
|
||||
volume_weakness: volume < avg_volume_5d * 0.80
|
||||
@@ -1231,8 +1244,10 @@ formula_registry:
|
||||
status: PASS
|
||||
missing_policy:
|
||||
frg_5d_sh: W2 DATA_MISSING. 레이더 결과 무효.
|
||||
avg_volume_5d: volume_weakness=false 처리 (보수적)
|
||||
frg_20d_sh: DATA_MISSING 시 fallback 기준 적용
|
||||
avg_volume_5d: volume_weakness=false 처리 (보수적). frg_20d_sh도 없는 경우
|
||||
selling_acceleration의 without_20d_fallback 비례식도 계산 불가하므로
|
||||
동일하게 false 처리(추정 금지) — flow_credit < 0.30만 단독 평가.
|
||||
frg_20d_sh: DATA_MISSING 시 fallback(avg_volume_5d 비례식, 2026-06-21 WBS-7.5) 기준 적용
|
||||
cross_alert:
|
||||
rule: W1_DIVERGENCE_ALERT + W2_OVERHANG_ALERT 동시 → CRITICAL_ALERT 상향
|
||||
output_tag: '[W1+W2_CRITICAL_ALERT]'
|
||||
|
||||
@@ -3149,3 +3149,74 @@ formula_registry:
|
||||
expected_outputs: [coverage_ratio, orphan_code_formula_count, unimplemented_rules]
|
||||
llm_allowed: cite_only
|
||||
version: "2026-06-03_ORPHAN_RECONCILE"
|
||||
|
||||
# == [2026-06-21_PHASE8] 비기계적 매도전략 — 공매도 합성 + confluence 판단 =========
|
||||
SHORT_INTEREST_RISK_GAUGE_V1:
|
||||
purpose: >
|
||||
공매도잔고율 추세 + 공매도거래비중 + 상대수익률(섹터·지수 대비) + 거래량 이상 +
|
||||
실적전망 5요소를 가중합성해 -1(매수지지)~+1(매도압력) 점수로 계량화한다.
|
||||
잔고율 단독을 매수/매도 트리거로 쓰지 않으며, 잔고율이 1% 미만(현대로템형)인
|
||||
저잔고율 종목은 거래비중·상대수익률 가중치를 자동 상향한다.
|
||||
output_contract:
|
||||
short_interest_composite_json:
|
||||
fields: "[short_interest_pressure, status, low_balance_regime, label, components, weights_used, missing_inputs]"
|
||||
python_tool: src/quant_engine/qualitative_sell_strategy_v1.py:compute_short_interest_composite
|
||||
version: "2026-06-21_PHASE8"
|
||||
|
||||
QUALITATIVE_SELL_STRATEGY_V1:
|
||||
purpose: >
|
||||
매크로(macro_pressure)·실적/펀더멘털 추세(fundamental_trajectory)·공매도수급
|
||||
(short_interest_pressure)·호가 10단계 미시구조(microstructure_pressure)·
|
||||
대내외 변수/대형 IPO·섹터 로테이션(liquidity_rotation_risk) 5개 독립 팩터군의
|
||||
confluence(최소 3/5 동일방향 합의)로만 매도/보유/추가 확신도를 산출한다.
|
||||
단일 팩터 임계값 돌파만으로는 행동을 트리거하지 않는다(기계적 매도 금지).
|
||||
현금부족 사유는 입력에서 의도적으로 배제되며(cash_shortfall_excluded=true),
|
||||
주식가치 보존이 유일한 목적함수다. 매도/추가 판단 시 실제 실적발표일·고영향
|
||||
매크로 이벤트일 기준으로 검토구간(review_window)을 역산한다(임의 고정일 금지).
|
||||
market_regime(PERFORMANCE_MARKET/TECHNICAL_MARKET)이 ctx.rate_trend로 주어지면
|
||||
금리국면에 따라 팩터 가중치를 조정한다(MARKET_REGIME_CLASSIFIER_V1).
|
||||
output_contract:
|
||||
qualitative_sell_strategy_json:
|
||||
fields: "[action, conviction, market_regime, composite_score, sell_agreeing_factors, hold_add_agreeing_factors, missing_factors, review_window, rationale, cash_shortfall_excluded, mechanical_sell_prohibited]"
|
||||
python_tool: src/quant_engine/qualitative_sell_strategy_v1.py:compute_qualitative_sell_strategy
|
||||
version: "2026-06-21_PHASE8"
|
||||
|
||||
MARKET_REGIME_CLASSIFIER_V1:
|
||||
purpose: >
|
||||
금리 추세(rate_trend: RISING/FLAT/FALLING)를 실적장세(PERFORMANCE_MARKET)/
|
||||
기술장세(TECHNICAL_MARKET)로 분류한다. 금리 상승기엔 유동성보다 실적·수출입
|
||||
펀더멘털이 가격을 주도(실적장세) — fundamental_trajectory 가중 상향.
|
||||
금리 보합·하락기엔 유동성이 풍부해 수급·미시구조가 가격을 주도(기술장세) —
|
||||
microstructure_pressure/short_interest_pressure 가중 상향.
|
||||
QUALITATIVE_SELL_STRATEGY_V1·SATELLITE_CANDIDATE_SCORE_V1의 가중치 산출에 사용.
|
||||
output_contract:
|
||||
market_regime_json:
|
||||
fields: "[market_regime]"
|
||||
python_tool: src/quant_engine/qualitative_sell_strategy_v1.py:classify_market_regime
|
||||
version: "2026-06-21_PHASE8"
|
||||
|
||||
MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1:
|
||||
purpose: >
|
||||
KIS Open API 호가10단계(inquire-asking-price-exp-ccn, FHKST01010200) output1의
|
||||
total_askp_rsqn/total_bidp_rsqn으로 -1(매수우위)~+1(매도우위) 미시구조 압력을
|
||||
계량화. QUALITATIVE_SELL_STRATEGY_V1의 microstructure_pressure 입력으로 쓰이며,
|
||||
전략 방향 결정이 아니라 confluence 성립 후 집행 타이밍 보조로만 사용한다.
|
||||
[CRITICAL] 이 공식이 사용하는 KIS API는 조회(read-only)만 수행 —
|
||||
governance/rules/06_no_direct_api_trading.yaml, 07_no_kis_account_balance_query.yaml.
|
||||
output_contract:
|
||||
microstructure_pressure_json:
|
||||
fields: "[microstructure_pressure, status, total_askp_rsqn, total_bidp_rsqn]"
|
||||
python_tool: src/quant_engine/qualitative_sell_strategy_v1.py:compute_microstructure_pressure_from_orderbook
|
||||
version: "2026-06-21_PHASE8"
|
||||
|
||||
SATELLITE_CANDIDATE_SCORE_V1:
|
||||
purpose: >
|
||||
미보유 위성 유니버스 종목을 섹터 수출입 추세(sector_export_trend, 관세청/산업
|
||||
통상부 무역통계 기반)·펀더멘털 추세·상대수익률로 평가해 BUY_CANDIDATE/WATCH/
|
||||
NEUTRAL_NO_EDGE/AVOID를 산출한다. market_regime에 따라 수출입 비중을 조정
|
||||
(실적장세에서 sector_export_trend 가중 상향).
|
||||
output_contract:
|
||||
satellite_candidate_json:
|
||||
fields: "[satellite_action, attractiveness_score, market_regime, components, weights_used]"
|
||||
python_tool: src/quant_engine/qualitative_sell_strategy_v1.py:compute_satellite_candidate_score
|
||||
version: "2026-06-21_PHASE8"
|
||||
|
||||
@@ -5,6 +5,8 @@ meta:
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "canonical"
|
||||
has_code_implementation: true
|
||||
code_path: "src/quant_engine/snapshot_admin_store_v1.py"
|
||||
purpose: >
|
||||
이미지 캡처로 제공되는 계좌·잔고·현금 데이터를 구조화하는 계약.
|
||||
HTS 입력 가능 주문수량은 이 계약을 통과한 account_snapshot 없이는 산출 금지.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
meta:
|
||||
title: "데이터 갭 로드맵 — 단계별 보완 계획"
|
||||
version: "2026-05-17-initial"
|
||||
version: "2026-06-21-platform-transition-v1"
|
||||
language: "ko-KR"
|
||||
purpose: >
|
||||
의사결정 파이프라인(spec/09_decision_flow.yaml)에서 식별된 데이터 공백을
|
||||
@@ -145,6 +145,25 @@ phase_2_structural:
|
||||
limitation: >
|
||||
KRX/KIND 기반 NAV/괴리율/추적오차/AUM 수집은 아직 미구현이며 etf_raw에서
|
||||
ETF_NAV_Risk=NAV_DATA_MISSING으로 명시한다.
|
||||
next_review_date: "2026-09-30" # WBS-7.8(2026-06-21) — KRX/KIND API 키 발급 가능성 분기별 재조사
|
||||
next_review_action: >
|
||||
KRX 정보데이터시스템/KIND 공식 API 또는 공개 데이터셋의 발급/이용약관 변경 여부를
|
||||
재확인한다. 변경이 없으면 next_review_date를 다음 분기로 갱신하고 PLANNED 유지,
|
||||
변경이 있으면 P1_kis_core_api_collector와 동일한 패턴으로 착수 여부를 결정한다.
|
||||
automation_attempt_2026_06_22: >
|
||||
pykrx(이미 tools/build_prediction_accuracy_harness_v2.py에서 EOD 가격 조회로 사용 중)의
|
||||
get_etf_price_deviation()/get_etf_tracking_error()/get_shorting_balance()를 실제로
|
||||
호출해 자동화 가능성을 재시도했다. 결과: 기본 시세조회(OHLCV)는 정상 작동(공개
|
||||
엔드포인트, 로그인 불필요)하지만, 공매도 잔고/ETF 괴리율/추적오차 엔드포인트는
|
||||
세션 쿠키를 정상 부트스트랩한 뒤에도 "HTTP 400 LOGOUT"을 반환했다(raw HTTP로
|
||||
재현 확인). 이는 pykrx 임포트 시 출력되는 "KRX_ID/KRX_PW 환경변수 미설정" 경고와
|
||||
정확히 일치 — 이 카테고리는 KRX 회원 로그인이 있어야 접근 가능한 서버측 인증
|
||||
게이트이며, 헤더/세션 보정으로 해결되는 문제가 아님을 확인했다. 자동화하려면
|
||||
KRX 계정(KRX_ID/KRX_PW)을 자격증명으로 코드에 등록해야 하는데, 이는
|
||||
governance/rules/06·07과 유사한 새로운 자격증명 정책 결정이 필요한 사안이라
|
||||
사용자 승인 없이 추가하지 않는다. 기술적 장벽 자체는 명확히 확정됐으므로
|
||||
next_review_date 재조사 시 "API 키 발급 가능성"이 아니라 "KRX 계정 발급·자격증명
|
||||
관리 정책 승인 여부"로 재구성해 검토할 것.
|
||||
|
||||
S5_etf_raw_execution_quality:
|
||||
priority: HIGH
|
||||
@@ -158,6 +177,9 @@ phase_2_structural:
|
||||
etf_nav_manual 시트가 있으면 NAV, iNAV, 괴리율, 추적오차, AUM을 etf_raw에 반영한다.
|
||||
tools/import_etf_nav_manual.py로 KRX/KIND/운용사 CSV/XLSX export를 etf_nav_manual로 변환할 수 있다.
|
||||
limitation: "NAV, iNAV, 괴리율, 추적오차, AUM 자동 수집은 KRX/KIND 수집 경로 확정 전까지 미구현."
|
||||
next_review_date: "2026-09-30" # WBS-7.8(2026-06-21) — S4와 동일 주기로 재검토
|
||||
next_review_action: "S4_sector_flow.next_review_action과 동일 — KRX/KIND 경로 확정 시 etf_nav_manual 수동 경로를 자동 수집으로 대체."
|
||||
automation_attempt_2026_06_22: "S4_sector_flow.automation_attempt_2026_06_22와 동일 사유로 자동화 불가 확정(pykrx get_etf_price_deviation/get_etf_tracking_error 모두 HTTP 400 LOGOUT — KRX 회원 로그인 필요)."
|
||||
|
||||
S6_sector_flow_history:
|
||||
priority: HIGH
|
||||
@@ -169,6 +191,41 @@ phase_2_structural:
|
||||
이력이 부족할 때만 기존 sector_flow/PropertiesService 값을 fallback으로 사용한다.
|
||||
Snapshot_Date는 Apps Script Date 객체와 문자열 날짜를 모두 yyyy-MM-dd로 정규화한다.
|
||||
|
||||
S7_snapshot_admin_web_editor:
|
||||
priority: HIGH
|
||||
status: DONE
|
||||
implementation: >
|
||||
SQLite canonical store용 웹 편집기 구현.
|
||||
settings/account_snapshot을 contenteditable 그리드로 직접 수정하고,
|
||||
TSV import/export, 행 삽입/복제, 승인/잠금/undo를 API로 제어한다.
|
||||
KIS SQLite collector 상태 패널을 함께 노출해서 최신 수집 run/오류를
|
||||
같은 화면에서 확인한다.
|
||||
web UI는 Snapshot Admin 서버가 담당하며 JSON export는 CI/파생 도구용이다.
|
||||
enables: >
|
||||
settings/account_snapshot을 xlsx 대신 SQLite에서 직접 관리하면서도
|
||||
스프레드시트처럼 편집 가능한 운영 surface와 수집 현황 대시보드 제공.
|
||||
success_criteria:
|
||||
settings_sheet_web_editor: true
|
||||
account_snapshot_sheet_web_editor: true
|
||||
contenteditable_grid: true
|
||||
api_save_round_trip: PASS
|
||||
kis_collection_dashboard: true
|
||||
single_workspace_sqlite: true
|
||||
collection_filter_controls: true
|
||||
collection_dashboard_page: true
|
||||
change_timeline_view: true
|
||||
evidence:
|
||||
code:
|
||||
- "src/quant_engine/snapshot_admin_server_v1.py"
|
||||
- "src/quant_engine/snapshot_admin_store_v1.py"
|
||||
- "tools/validate_snapshot_admin_web_v1.py"
|
||||
tests:
|
||||
- "tests/unit/test_snapshot_admin_store_v1.py"
|
||||
- "tests/unit/test_snapshot_admin_web_v1.py"
|
||||
workflow:
|
||||
- ".gitea/workflows/snapshot_admin.yml"
|
||||
verification: "python tools/validate_snapshot_admin_web_v1.py"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 3단계 — 분석 품질 고도화 (낮은 우선순위)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -503,6 +560,203 @@ phase_4_backdata_collection:
|
||||
2026-06-14 구현 완료 확인. GAS(syncBackdataFeatureBank_) + Python(synthesize_backdata_feature_bank)
|
||||
모두 구현됨. T+20 데이터 누적 후 ML 패턴 학습 품질 향상 예정.
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# 5단계 — CI 기반 데이터 플랫폼 전환
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
phase_5_platform_transition:
|
||||
P1_kis_core_api_collector:
|
||||
priority: HIGH
|
||||
status: PLANNED
|
||||
purpose: >
|
||||
KIS Open API를 read-only 코어 수집원으로 두고, 가격/호가/공매도/수급의
|
||||
1차 수집을 Python canonical collector에서 직접 수행한다.
|
||||
inputs:
|
||||
- "KIS_APP_Key / KIS_APP_Secret"
|
||||
- "KIS_APP_Key_TEST / KIS_APP_Secret_TEST"
|
||||
- "GatherTradingData.json"
|
||||
outputs:
|
||||
- "Temp/kis_data_collection_v1.json"
|
||||
- "outputs/kis_data_collection/kis_data_collection.db"
|
||||
fallback_order:
|
||||
- "KIS Open API"
|
||||
- "Naver Finance"
|
||||
- "Yahoo Finance"
|
||||
- "OpenDART"
|
||||
- "Investing.com(best-effort, 차단 시 DATA_MISSING)"
|
||||
note: >
|
||||
주문 API는 사용하지 않는다. 조회형 quotations/ranking 계열만 허용한다.
|
||||
success_criteria:
|
||||
expected_success_value:
|
||||
collector_gate: "PASS"
|
||||
output_json_gate: "PASS"
|
||||
sqlite_run_count_min: 1
|
||||
sqlite_snapshot_count_min: 1
|
||||
provenance_source_count_min: 1
|
||||
evidence_artifacts:
|
||||
- "Temp/test_kis_data_collection.json"
|
||||
- "Temp/test_kis_data_collection.db"
|
||||
verification_commands:
|
||||
- "python tools/run_kis_data_collection_v1.py --input-json GatherTradingData.json --sqlite-db Temp/test_kis_data_collection.db --output-json Temp/test_kis_data_collection.json --kis-account real --no-live-kis --no-naver"
|
||||
- "python - <<'PY' ... sqlite count check ... PY"
|
||||
|
||||
P2_sqlite_canonical_store:
|
||||
priority: HIGH
|
||||
status: PLANNED
|
||||
purpose: >
|
||||
xlsx 중심 저장을 중단하고, 수집 결과를 SQLite에 누적 저장한다.
|
||||
향후 PostgreSQL 승격 시 동일 저장 인터페이스를 유지한다.
|
||||
required_tables:
|
||||
- "collection_runs"
|
||||
- "collection_snapshots"
|
||||
- "collection_source_errors"
|
||||
stored_payloads:
|
||||
- "raw source payload"
|
||||
- "normalized factor row"
|
||||
- "provenance JSON"
|
||||
- "batch/run metadata"
|
||||
migration_note: "PostgreSQL 전환 시 dialect만 교체하고 row shape은 유지한다."
|
||||
success_criteria:
|
||||
expected_success_value:
|
||||
sqlite_schema_tables_min: 3
|
||||
round_trip_snapshot_lookup: "PASS"
|
||||
backend_contract_sqlite: "PASS"
|
||||
backend_contract_postgresql: "READY"
|
||||
evidence_artifacts:
|
||||
- "src/quant_engine/data_collection_store_v1.py"
|
||||
- "src/quant_engine/data_collection_backend_v1.py"
|
||||
- "tests/unit/test_data_collection_store_v1.py"
|
||||
verification_commands:
|
||||
- "python -m pytest tests/unit/test_data_collection_store_v1.py -q"
|
||||
- "python -m py_compile src/quant_engine/data_collection_store_v1.py src/quant_engine/data_collection_backend_v1.py"
|
||||
|
||||
P3_ci_scheduler_cutover:
|
||||
priority: HIGH
|
||||
status: PLANNED
|
||||
purpose: >
|
||||
Gitea schedule에서 Python collector를 직접 실행하고, CI가 SQLite 산출을 검증한다.
|
||||
기존 GAS 워크플로우는 thin adapter/legacy fallback으로만 유지한다.
|
||||
validation_gate:
|
||||
- "read-only KIS gate"
|
||||
- "source fallback gate"
|
||||
- "sqlite round-trip gate"
|
||||
- "provenance completeness gate"
|
||||
- "no-direct-trading gate"
|
||||
output_policy:
|
||||
- "CI는 xlsx 생성에 의존하지 않는다."
|
||||
- "결과는 JSON + SQLite + 로그 증빙으로 남긴다."
|
||||
success_criteria:
|
||||
expected_success_value:
|
||||
xlsx_dependency_removed: true
|
||||
json_seed_input: true
|
||||
sqlite_output: true
|
||||
mock_api_validation: "PASS"
|
||||
no_direct_trading_gate: "PASS"
|
||||
provenance_completeness_gate: "PASS"
|
||||
evidence_artifacts:
|
||||
- ".gitea/workflows/kis_data_collection.yml"
|
||||
- "Temp/kis_api_credentials_validation_v1.json"
|
||||
- "Temp/test_kis_data_collection.json"
|
||||
verification_commands:
|
||||
- "python tools/validate_no_direct_api_trading_v1.py"
|
||||
- "python tools/validate_kis_api_credentials_v1.py --account mock --ticker 005930"
|
||||
- "python tools/run_kis_data_collection_v1.py --help"
|
||||
|
||||
P4_gas_thin_adapter_minimize:
|
||||
priority: MEDIUM
|
||||
status: PLANNED
|
||||
purpose: >
|
||||
.gs는 기존 스프레드시트 호환과 과도기 검증용 얇은 어댑터만 남기고,
|
||||
판단·수집·저장 로직은 Python으로 이동시킨다.
|
||||
allowed_responsibilities:
|
||||
- "collect"
|
||||
- "normalize"
|
||||
- "export"
|
||||
- "display"
|
||||
forbidden_responsibilities:
|
||||
- "decision"
|
||||
- "sizing"
|
||||
- "stop_loss"
|
||||
- "take_profit"
|
||||
- "risk_score"
|
||||
success_criteria:
|
||||
expected_success_value:
|
||||
allowed_responsibilities_only: true
|
||||
forbidden_responsibilities_present: false
|
||||
thin_adapter_gate: "PASS"
|
||||
evidence_artifacts:
|
||||
- "tools/validate_gas_thin_adapter_v1.py"
|
||||
- "Temp/gas_thin_adapter_validation_v1.json"
|
||||
- "src/gas/core/gas_lib.gs"
|
||||
verification_commands:
|
||||
- "python tools/validate_gas_thin_adapter_v1.py"
|
||||
|
||||
P5_postgresql_upgrade_path:
|
||||
priority: MEDIUM
|
||||
status: PLANNED
|
||||
purpose: >
|
||||
SQLite에서 검증된 스키마/업서트/프로venance 모델을 PostgreSQL로 승격한다.
|
||||
운영 데이터 증가와 멀티잡 동시성 증가를 대비한다.
|
||||
upgrade_steps:
|
||||
- "sqlite schema parity 검증"
|
||||
- "db_url 기반 backend 추상화"
|
||||
- "migration script 추가"
|
||||
- "CI에서 sqlite/postgres 동일 테스트"
|
||||
compatibility_rule: "SQLite와 PostgreSQL 모두 동일한 row contract를 유지한다."
|
||||
success_criteria:
|
||||
expected_success_value:
|
||||
sqlite_schema_parity: "PASS"
|
||||
backend_contract_present: true
|
||||
postgres_execution: "DATA_GATED"
|
||||
caller_compatibility_preserved: true
|
||||
evidence_artifacts:
|
||||
- "src/quant_engine/data_collection_backend_v1.py"
|
||||
- "src/quant_engine/kis_data_collection_v1.py"
|
||||
- "tests/unit/test_data_collection_store_v1.py"
|
||||
- "tools/generate_postgresql_upgrade_stub_v1.py"
|
||||
verification_commands:
|
||||
- "python -m pytest tests/unit/test_data_collection_store_v1.py -q"
|
||||
- "python -m py_compile src/quant_engine/kis_data_collection_v1.py tools/run_kis_data_collection_v1.py"
|
||||
- "python tools/generate_postgresql_upgrade_stub_v1.py"
|
||||
|
||||
Q1_qualitative_sell_pipeline:
|
||||
priority: MEDIUM
|
||||
status: PLANNED
|
||||
purpose: >
|
||||
비기계적 매도전략 파이프라인을 Gitea workflow + SQLite 시계열 + mock KIS 유효성
|
||||
검증 + 사후 적중률 평가까지 일관된 계약으로 묶는다.
|
||||
success_criteria:
|
||||
expected_success_value:
|
||||
mock_api_validation: "PASS"
|
||||
pipeline_contract: "PASS"
|
||||
workflow_present: true
|
||||
schedule_present: true
|
||||
package_scripts_present: true
|
||||
evidence_artifacts:
|
||||
- ".gitea/workflows/qualitative_sell_strategy.yml"
|
||||
- "tools/validate_qualitative_sell_strategy_pipeline_v1.py"
|
||||
- "Temp/qualitative_sell_strategy_pipeline_v1.json"
|
||||
verification_commands:
|
||||
- "python tools/validate_qualitative_sell_strategy_pipeline_v1.py"
|
||||
|
||||
Q2_gitea_secrets_contract:
|
||||
priority: HIGH
|
||||
status: PLANNED
|
||||
purpose: >
|
||||
Gitea workflow에서 KIS mock/real 자격증명과 GITHUB_TOKEN 시크릿 이름을
|
||||
정확히 고정해, 수동 등록 실수로 인한 파이프라인 붕괴를 방지한다.
|
||||
success_criteria:
|
||||
expected_success_value:
|
||||
secrets_contract: "PASS"
|
||||
workflow_secret_mapping: "PASS"
|
||||
docs_present: true
|
||||
ci_validation_present: true
|
||||
evidence_artifacts:
|
||||
- "docs/GITEA_SECRETS_SETUP.md"
|
||||
- "tools/validate_gitea_secrets_contract_v1.py"
|
||||
- "Temp/gitea_secrets_contract_v1.json"
|
||||
verification_commands:
|
||||
- "python tools/validate_gitea_secrets_contract_v1.py"
|
||||
|
||||
# 2026-05-30 구현 현황
|
||||
# - S5_etf_raw: PARTIAL_DONE 유지 (수동 NAV 병행)
|
||||
# - Stage2_Gate PENDING: T+20 표본 누적 후 자동 평가
|
||||
|
||||
@@ -5,6 +5,8 @@ meta:
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "canonical"
|
||||
has_code_implementation: true
|
||||
code_path: "src/quant_engine/snapshot_admin_store_v1.py"
|
||||
purpose: >
|
||||
Google Sheets 'settings' 탭의 구조를 정의한다.
|
||||
GAS 함수 readSettingsTab_()이 이 탭을 읽어 파라미터를 공급한다.
|
||||
|
||||
@@ -2,6 +2,8 @@ meta:
|
||||
title: "은퇴자산포트폴리오 — 결정론적 실행 하네스 계약 (QEH)"
|
||||
parent_file: "RetirementAssetPortfolio.yaml"
|
||||
version: "2026-05-23-QEH-V5.0-PROPOSAL46"
|
||||
has_code_implementation: true
|
||||
code_path: "tools/validate_harness_context.py"
|
||||
purpose: >
|
||||
LLM의 자의적 해석 및 주관적 계산을 원천 배제하고, 전문사(Analyst, Trader, Quant) 수준의
|
||||
정밀한 판단을 강제하기 위한 결정론적 하네스(Deterministic Harness)의
|
||||
|
||||
@@ -451,7 +451,30 @@ reject_conditions:
|
||||
- "sample_n < 30인 임계값을 '보정완료'로 처리"
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 현재 달성 현황 (2026-05-30)
|
||||
# 현재 달성 현황 (2026-06-21 재검증 — WBS-7.2)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 주의: 아래 current_status_2026_05_30 블록은 그 날짜 기준 정적 스냅샷이며,
|
||||
# 이후 갱신되지 않은 채 docs/ROADMAP_WBS.md 등에서 "현재 상태"로 인용되어
|
||||
# 서로 다른 시점의 T+5 수치(54.76%/35.86%)가 혼재하는 문제를 일으켰다.
|
||||
# Temp/honest_performance_guard_v1.json(생성: 2026-06-14)과
|
||||
# Temp/prediction_accuracy_harness_v2.json(생성: 2026-06-21, 7일 더 최신)을
|
||||
# 직접 재확인한 결과는 다음과 같다 — 이 블록을 단일 진실원천으로 삼는다.
|
||||
current_status_2026_06_21:
|
||||
source_of_truth: "Temp/prediction_accuracy_harness_v2.json (as_of_date=2026-06-21, 가장 최신)"
|
||||
t1_match_rate_pct: 52.94 # sample=68, decisive_sample=53, rate_decisive=67.92
|
||||
t5_match_rate_pct: null # sample=0 — INSUFFICIENT_SAMPLES. honest_performance_guard_v1.json(2026-06-14)의
|
||||
# 35.86%는 7일 전 스냅샷이며 표본이 0으로 줄어 더 이상 유효하지 않음.
|
||||
t5_sample_regression_note: >
|
||||
cases_analyzed가 141건(2026-05-30 기준)에서 t5_sample=0(2026-06-21)으로 감소했다.
|
||||
evaluation_methodology가 ACTIVE_PASSIVE_SPLIT_V1_INCONCLUSIVE_EXCLUDED로 변경되며
|
||||
inconclusive/replay 표본이 제외된 것으로 추정 — 근본 원인은 별도 조사 필요(WBS-7.2 잔여 항목).
|
||||
calibration_registry_total_thresholds: 190 # spec/calibration_registry.yaml 직접 집계 (구문서의 70은 stale)
|
||||
calibration_registry_expert_prior_count: 59
|
||||
calibration_registry_calibrated_count: 0
|
||||
rule: "이 문서를 인용할 때는 항상 as_of_date를 동반 표기하고, 아래 5/30 스냅샷을 '현재'로 인용하지 않는다."
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 과거 달성 현황 (2026-05-30, 역사적 스냅샷 — "현재"로 인용 금지)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
current_status_2026_05_30:
|
||||
phase_1_bch: COMPLETE
|
||||
@@ -489,3 +512,17 @@ current_status_2026_05_30:
|
||||
cases_analyzed: 141
|
||||
miss5_count: 51
|
||||
next_milestone: "cases_analyzed=30 달성 후 ALEG_V2_GATE1_BLOCK_PCT 보정 심사"
|
||||
automation_entrypoints:
|
||||
gitea_schedule: ".gitea/workflows/calibration_backlog.yml"
|
||||
npm_script: "npm run ops:calibration-backlog"
|
||||
generated_artifacts:
|
||||
- Temp/calibration_priority_v1.json
|
||||
- Temp/calibration_change_ledger_v4.json
|
||||
- Temp/calibration_review_report_v1.json
|
||||
- Temp/calibration_review_report_v1.md
|
||||
- Temp/calibration_approval_list_v1.json
|
||||
- Temp/calibration_approval_list_v1.md
|
||||
- Temp/calibration_registry_v1.json
|
||||
promotion_rules:
|
||||
provisional: "sample_n >= 10 AND direction confirmed AND change_ledger entry exists"
|
||||
calibrated: "sample_n >= 30 AND backtest_doc exists AND validator overclaimed_count == 0"
|
||||
|
||||
+113
-1
@@ -1,6 +1,8 @@
|
||||
schema_version: release_dag.v3
|
||||
step_count: 99
|
||||
goal: Linearize package.json scripts into a validated DAG execution graph.
|
||||
has_code_implementation: true
|
||||
code_path: "tools/run_release_dag_v3.py"
|
||||
execution_order:
|
||||
# 토폴로지 정렬 기준 병렬 실행 wave (의존성 없는 노드들을 동시에 실행 가능)
|
||||
wave_0:
|
||||
@@ -86,6 +88,11 @@ execution_order:
|
||||
wave_6:
|
||||
- build_algorithm_guidance_proof
|
||||
- build_artifact_chain_hash
|
||||
- build_calibration_priority
|
||||
- build_calibration_change_ledger
|
||||
- build_calibration_review_report
|
||||
- build_calibration_approval_list
|
||||
- build_calibration_decision_draft
|
||||
- build_alpha_feedback_loop
|
||||
- build_honest_proof_gap_analyzer
|
||||
- build_operational_alpha_calibration
|
||||
@@ -220,6 +227,66 @@ dag:
|
||||
artifact_policy: "keep"
|
||||
note: "WBS-4.3 alpha feedback loop — non-blocking diagnostic"
|
||||
|
||||
build_calibration_priority:
|
||||
id: build_calibration_priority
|
||||
command: ["python", "tools/build_calibration_priority_v1.py"]
|
||||
inputs: ["tools/build_calibration_priority_v1.py", "Temp/alpha_feedback_loop_v2.json", "spec/calibration_registry.yaml"]
|
||||
outputs: ["Temp/calibration_priority_v1.json"]
|
||||
depends_on: ["build_alpha_feedback_loop"]
|
||||
timeout_sec: 30
|
||||
cache_key: "build_calibration_priority_v1"
|
||||
strict: false
|
||||
artifact_policy: "keep"
|
||||
note: "CALIBRATION_PRIORITY_V1 — registry warning fallback 포함 보정 우선순위 리포트"
|
||||
|
||||
build_calibration_change_ledger:
|
||||
id: build_calibration_change_ledger
|
||||
command: ["python", "tools/build_calibration_change_ledger_v4.py"]
|
||||
inputs: ["tools/build_calibration_change_ledger_v4.py", "Temp/calibration_priority_v1.json", "Temp/outcome_ledger_v1.json", "Temp/calibration_registry_v1.json"]
|
||||
outputs: ["Temp/calibration_change_ledger_v4.json"]
|
||||
depends_on: ["build_calibration_priority", "build_realized_performance"]
|
||||
timeout_sec: 30
|
||||
cache_key: "build_calibration_change_ledger_v4"
|
||||
strict: false
|
||||
artifact_policy: "keep"
|
||||
note: "CALIBRATION_CHANGE_LEDGER_V4 — change ledger linkage 유지"
|
||||
|
||||
build_calibration_review_report:
|
||||
id: build_calibration_review_report
|
||||
command: ["python", "tools/build_calibration_review_report_v1.py"]
|
||||
inputs: ["tools/build_calibration_review_report_v1.py", "Temp/calibration_priority_v1.json", "Temp/calibration_change_ledger_v4.json", "spec/calibration_registry.yaml"]
|
||||
outputs: ["Temp/calibration_review_report_v1.json", "Temp/calibration_review_report_v1.md"]
|
||||
depends_on: ["build_calibration_change_ledger"]
|
||||
timeout_sec: 30
|
||||
cache_key: "build_calibration_review_report_v1"
|
||||
strict: false
|
||||
artifact_policy: "keep"
|
||||
note: "CALIBRATION_REVIEW_REPORT_V1 — 월간 운영용 읽기 쉬운 보정 리포트"
|
||||
|
||||
build_calibration_approval_list:
|
||||
id: build_calibration_approval_list
|
||||
command: ["python", "tools/build_calibration_approval_list_v1.py"]
|
||||
inputs: ["tools/build_calibration_approval_list_v1.py", "Temp/calibration_review_report_v1.json"]
|
||||
outputs: ["Temp/calibration_approval_list_v1.json", "Temp/calibration_approval_list_v1.md"]
|
||||
depends_on: ["build_calibration_review_report"]
|
||||
timeout_sec: 30
|
||||
cache_key: "build_calibration_approval_list_v1"
|
||||
strict: false
|
||||
artifact_policy: "keep"
|
||||
note: "CALIBRATION_APPROVAL_LIST_V1 — PROVISIONAL 승인/검토 분리"
|
||||
|
||||
build_calibration_decision_draft:
|
||||
id: build_calibration_decision_draft
|
||||
command: ["python", "tools/build_calibration_decision_draft_v1.py"]
|
||||
inputs: ["tools/build_calibration_decision_draft_v1.py", "Temp/calibration_review_report_v1.json", "Temp/calibration_approval_list_v1.json"]
|
||||
outputs: ["Temp/calibration_decision_draft_v1.json", "Temp/calibration_decision_draft_v1.md"]
|
||||
depends_on: ["build_calibration_approval_list"]
|
||||
timeout_sec: 30
|
||||
cache_key: "build_calibration_decision_draft_v1"
|
||||
strict: false
|
||||
artifact_policy: "keep"
|
||||
note: "CALIBRATION_DECISION_DRAFT_V1 — APPROVE/HOLD/REJECT 초안"
|
||||
|
||||
build_operational_alpha_calibration:
|
||||
id: build_operational_alpha_calibration
|
||||
command: ["python", "tools/build_operational_alpha_calibration_v2.py"]
|
||||
@@ -496,6 +563,20 @@ dag:
|
||||
strict: true
|
||||
artifact_policy: "keep"
|
||||
|
||||
validate_no_direct_api_trading:
|
||||
id: validate_no_direct_api_trading
|
||||
command: ["python", "tools/validate_no_direct_api_trading_v1.py"]
|
||||
inputs: ["tools/validate_no_direct_api_trading_v1.py", "src/quant_engine/kis_api_client_v1.py", "governance/rules/06_no_direct_api_trading.yaml"]
|
||||
outputs: []
|
||||
depends_on: []
|
||||
timeout_sec: 30
|
||||
cache_key: "validate_no_direct_api_trading_v1"
|
||||
strict: true
|
||||
artifact_policy: "keep"
|
||||
note: "[CRITICAL] 매수/매도 API 직접 실행 절대 금지 게이트 — warn_only 불가, 완화 대상
|
||||
아님(사용자 직접 지시 2026-06-21). 순수 stdlib만 사용해 Synology ARMv7 CI에서도
|
||||
항상 실행 가능."
|
||||
|
||||
validate_active_manifest:
|
||||
id: validate_active_manifest
|
||||
command: ["python", "tools/validate_active_manifest.py", "--manifest", "runtime/active_artifact_manifest.yaml", "--strict"]
|
||||
@@ -731,6 +812,37 @@ dag:
|
||||
artifact_policy: "keep"
|
||||
note: "섹터 유니버스 월간 갱신 provenance 검증 (warn_only) — GAS 재다운로드 시 Source_URL 소실이 정상. 월간 --apply 실행 후 PASS/WARN 달성. FAIL=비차단 경고만."
|
||||
|
||||
build_qualitative_sell_inputs:
|
||||
id: build_qualitative_sell_inputs
|
||||
command: ["python", "tools/build_qualitative_sell_inputs_v1.py", "--batch", "--workbook", "GatherTradingData.xlsx", "--apply"]
|
||||
inputs: ["tools/build_qualitative_sell_inputs_v1.py", "tools/build_macro_context_from_workbook_v1.py", "tools/fetch_naver_market_data_v1.py", "src/quant_engine/kis_api_client_v1.py", "GatherTradingData.xlsx"]
|
||||
outputs: ["outputs/qualitative_sell_strategy/*.json"]
|
||||
depends_on: []
|
||||
timeout_sec: 120
|
||||
cache_key: "build_qualitative_sell_inputs_v1"
|
||||
strict: false
|
||||
warn_only: true
|
||||
artifact_policy: "keep"
|
||||
note: "Naver 시세/수급 실시간 스크래핑 의존(warn_only) — 보유종목별 비기계적 매도전략
|
||||
confluence 판단. 공매도잔고율은 --short-csv 수동 주입 전까지 구조적으로
|
||||
DATA_MISSING(추정 금지) — 정상 동작. 호가10단계·공매도거래비중은 --kis-account
|
||||
{real,mock} 옵션으로 KIS Open API(read-only) 조회 가능(2026-06-21 연동) — DAG
|
||||
기본 실행에는 미포함(자격증명 의존, 수동 실행 시에만 부여)."
|
||||
|
||||
build_satellite_candidate_recommendations:
|
||||
id: build_satellite_candidate_recommendations
|
||||
command: ["python", "tools/build_satellite_candidate_recommendations_v1.py", "--workbook", "GatherTradingData.xlsx", "--apply"]
|
||||
inputs: ["tools/build_satellite_candidate_recommendations_v1.py", "tools/fetch_naver_market_data_v1.py", "GatherTradingData.xlsx"]
|
||||
outputs: ["outputs/qualitative_sell_strategy/satellite_recommendations.json"]
|
||||
depends_on: []
|
||||
timeout_sec: 180
|
||||
cache_key: "build_satellite_candidate_recommendations_v1"
|
||||
strict: false
|
||||
warn_only: true
|
||||
artifact_policy: "keep"
|
||||
note: "universe 시트 미보유 후보(60종) 전체 Naver 시세 조회 — warn_only. --trade-csv
|
||||
없으면 sector_export_trend 전부 DATA_MISSING(정상, 추정 금지)."
|
||||
|
||||
validate_cash_ledger:
|
||||
id: validate_cash_ledger
|
||||
command: ["python", "tools/validate_cash_ledger_v2.py", "--snapshot", "GatherTradingData.json", "--contract", "spec/15_account_snapshot_contract.yaml"]
|
||||
@@ -1327,7 +1439,7 @@ dag:
|
||||
command: ["python", "tools/prepare_upload_zip.py", "--skip-validate", "--skip-convert", "--validation-mode", "package-only"]
|
||||
inputs: ["tools/prepare_upload_zip.py"]
|
||||
outputs: []
|
||||
depends_on: ["audit_entropy", "validate_specs", "validate_active_manifest", "validate_report_sync", "validate_report_numeric_consistency", "validate_field_dict", "validate_provenance", "validate_low_capability", "validate_golden_coverage", "validate_calibration", "validate_schema_model", "validate_gas_adapter", "validate_agents_shrink", "validate_no_replay_live_mix", "validate_prediction_accuracy_harness", "validate_alpha_feedback_loop", "validate_operational_alpha_calibration", "validate_realized_performance", "validate_data_gated_progress", "validate_sector_flow_history_progress", "validate_runtime_source_whitelist", "validate_cash_ledger", "validate_factor_lifecycle", "validate_factor_lifecycle_completeness", "validate_metric_alias_collision", "validate_architecture_boundaries", "validate_module_io_coverage", "validate_artifact_chain_hash", "validate_artifact_sync", "validate_renderer_no_calc", "validate_packaged_refs", "validate_property_invariants", "validate_anti_late_entry", "validate_rule_lifecycle", "validate_change_requests", "validate_completion_harness_instructions", "validate_engine_health_card", "validate_llm_regression", "validate_llm_copy_only", "build_final_decision", "build_final_context", "build_provenance_ledger", "build_live_replay_separation", "build_late_chase_attribution", "build_profit_giveback_ratchet", "build_shadow_ledger", "build_operating_cadence_signal", "build_engine_health_card", "build_module_io_coverage", "build_artifact_chain_hash", "build_report", "build_bundle", "build_schema_models", "build_architecture_boundaries", "validate_decision_trace", "validate_factor_conflicts", "validate_no_lookahead", "validate_execution_sim", "validate_render_diff", "build_shadow_promotion", "validate_llm_determinism", "build_time_stop_forecast", "validate_live_activation", "build_rebalance_sheet", "build_prediction_accuracy_harness", "build_alpha_feedback_loop", "build_operational_alpha_calibration", "build_sector_flow_history_progress"]
|
||||
depends_on: ["audit_entropy", "validate_specs", "validate_no_direct_api_trading", "validate_active_manifest", "validate_report_sync", "validate_report_numeric_consistency", "validate_field_dict", "validate_provenance", "validate_low_capability", "validate_golden_coverage", "validate_calibration", "validate_schema_model", "validate_gas_adapter", "validate_agents_shrink", "validate_no_replay_live_mix", "validate_prediction_accuracy_harness", "validate_alpha_feedback_loop", "validate_operational_alpha_calibration", "validate_realized_performance", "validate_data_gated_progress", "validate_sector_flow_history_progress", "validate_runtime_source_whitelist", "validate_cash_ledger", "validate_factor_lifecycle", "validate_factor_lifecycle_completeness", "validate_metric_alias_collision", "validate_architecture_boundaries", "validate_module_io_coverage", "validate_artifact_chain_hash", "validate_artifact_sync", "validate_renderer_no_calc", "validate_packaged_refs", "validate_property_invariants", "validate_anti_late_entry", "validate_rule_lifecycle", "validate_change_requests", "validate_completion_harness_instructions", "validate_engine_health_card", "validate_llm_regression", "validate_llm_copy_only", "build_final_decision", "build_final_context", "build_provenance_ledger", "build_live_replay_separation", "build_late_chase_attribution", "build_profit_giveback_ratchet", "build_shadow_ledger", "build_operating_cadence_signal", "build_engine_health_card", "build_module_io_coverage", "build_artifact_chain_hash", "build_report", "build_bundle", "build_schema_models", "build_architecture_boundaries", "validate_decision_trace", "validate_factor_conflicts", "validate_no_lookahead", "validate_execution_sim", "validate_render_diff", "build_shadow_promotion", "validate_llm_determinism", "build_time_stop_forecast", "validate_live_activation", "build_rebalance_sheet", "build_prediction_accuracy_harness", "build_alpha_feedback_loop", "build_calibration_priority", "build_calibration_change_ledger", "build_calibration_review_report", "build_calibration_approval_list", "build_calibration_decision_draft", "build_operational_alpha_calibration", "build_sector_flow_history_progress"]
|
||||
timeout_sec: 60
|
||||
cache_key: "prepare_zip_v1"
|
||||
strict: true
|
||||
|
||||
@@ -2,6 +2,8 @@ schema_version: execution_simulator_contract.v1
|
||||
contract_id: H004_EXECUTION_SIMULATOR
|
||||
harness_file: tools/validate_execution_simulator_v1.py
|
||||
authority: spec/55_execution_simulator_contract.yaml
|
||||
has_code_implementation: true
|
||||
code_path: "tools/validate_execution_simulator_v1.py"
|
||||
created_at: '2026-06-10T23:29:00+09:00'
|
||||
purpose: >
|
||||
틱 정규화, 최소주문수량, 예수금, D+2 현금, 슬리피지 적용 후
|
||||
|
||||
+16
-70
@@ -1,80 +1,26 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 경로 alias registry"
|
||||
version: "2026-05-15-F10_fragmentation_guard"
|
||||
version: "2026-06-21-WBS7.4_migration_closed"
|
||||
role: "governance"
|
||||
purpose: "legacy path와 canonical split path를 명시해 참조 혼선을 방지한다."
|
||||
|
||||
aliases:
|
||||
"spec/03_risk_policy.yaml:portfolio_exposure_framework":
|
||||
canonical: "spec/risk/portfolio_exposure.yaml:portfolio_exposure_framework"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/03_risk_policy.yaml:risk_control":
|
||||
canonical: "spec/risk/aggregate_risk.yaml:risk_control"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/risk/risk_control.yaml:risk_control.aggregate_risk_cap":
|
||||
canonical: "spec/risk/aggregate_risk.yaml:risk_control.aggregate_risk_cap"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/risk/risk_control.yaml:risk_control.market_risk_score_based_cash":
|
||||
canonical: "spec/risk/market_risk_cash.yaml:risk_control.market_risk_score_based_cash"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/risk/risk_control.yaml:risk_control.weekly_circuit_breaker":
|
||||
canonical: "spec/risk/circuit_breakers.yaml:risk_control.weekly_circuit_breaker"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/06_exit_policy.yaml:stop_loss":
|
||||
canonical: "spec/exit/stop_loss.yaml:stop_loss"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/06_exit_policy.yaml:take_profit":
|
||||
canonical: "spec/exit/take_profit.yaml:take_profit"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/03_risk_policy.yaml:quality_control":
|
||||
canonical: "spec/risk/quality_control.yaml:quality_control"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/04_strategy_rules.yaml:sector_model":
|
||||
canonical: "spec/strategy/sector_model.yaml:sector_model"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/04_strategy_rules.yaml:entry_timing_guardrails":
|
||||
canonical: "spec/strategy/entry_core.yaml:entry_timing_guardrails"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/04_strategy_rules.yaml:anti_late_trade_rule":
|
||||
canonical: "spec/strategy/discovery.yaml:anti_late_trade_rule"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/strategy/entry_gates.yaml:entry_timing_guardrails.daily_leader_scan":
|
||||
canonical: "spec/strategy/leader_scan.yaml:entry_timing_guardrails.daily_leader_scan"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/strategy/entry_gates.yaml:entry_timing_guardrails.anti_climax_buy_gate":
|
||||
canonical: "spec/strategy/leader_scan.yaml:entry_timing_guardrails.anti_climax_buy_gate"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/strategy/entry_gates.yaml:entry_timing_guardrails.staged_entry_v2":
|
||||
canonical: "spec/strategy/staged_entry.yaml:entry_timing_guardrails.staged_entry_v2"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/strategy/entry_gates.yaml:entry_timing_guardrails.pullback_reentry_rule":
|
||||
canonical: "spec/strategy/staged_entry.yaml:entry_timing_guardrails.pullback_reentry_rule"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/04_strategy_rules.yaml:stock_model":
|
||||
canonical: "spec/strategy/stock_model.yaml:stock_model"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
"spec/04_strategy_rules.yaml:rebalancing_trigger":
|
||||
canonical: "spec/strategy/rebalancing_trigger.yaml:rebalancing_trigger"
|
||||
status: "deprecated"
|
||||
remove_after: "2026-06-30"
|
||||
# 2026-06-21 WBS-7.4 마이그레이션 종결 기록:
|
||||
# 아래 17개 alias는 모두 remove_after=2026-06-30 만료 예정이었다.
|
||||
# repo 전체(spec/src/tools/prompts/examples) grep으로 활성 참조가 0건임을 확인했고,
|
||||
# 모든 canonical_split_files 대상 파일이 이미 실콘텐츠를 보유하고 있어 마이그레이션이
|
||||
# 완료된 것으로 판정, 데드라인 전에 alias 항목을 제거했다.
|
||||
#
|
||||
# [2026-06-22 WBS-7.11 정정] 작성 당시 이 주석은 호환 인덱스 5개 중 "3개가
|
||||
# deprecated_redirect라 삭제 보류 중"이라고 적었으나 부정확했다. 실제로는
|
||||
# spec/06_exit_policy.yaml도 role: compatibility_index(영구 유지 설계)였고,
|
||||
# role: deprecated_redirect는 spec/03_risk_policy.yaml, spec/04_strategy_rules.yaml
|
||||
# 2개뿐이었다. WBS-7.11에서 이 2개의 활성 참조 0건을 재확인 후 실삭제했고,
|
||||
# spec/06_exit_policy.yaml/spec/risk/risk_control.yaml/spec/strategy/entry_gates.yaml
|
||||
# 3개는 has_code_implementation:false + redirect_only:true로 태깅해 영구 유지한다.
|
||||
aliases: {}
|
||||
|
||||
policy:
|
||||
- "신규 문서는 canonical 경로만 사용한다."
|
||||
- "compatibility index와 aliases.yaml 내부의 deprecated 경로는 허용한다."
|
||||
- "remove_after 이후 deprecated 경로가 active 문서에 남으면 검증 실패로 전환한다."
|
||||
- "alias 항목을 등록할 때는 반드시 remove_after 데드라인을 두고, 데드라인 전에 활성 참조 0건을 확인한 뒤 제거한다(2026-06-21 사례 참조)."
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
has_code_implementation: true
|
||||
code_path:
|
||||
- "tools/build_calibration_priority_v1.py"
|
||||
- "tools/validate_calibration_registry_v1.py"
|
||||
thresholds:
|
||||
- id: ALEG_V2_GATE1_BLOCK_PCT
|
||||
value: 3.0
|
||||
@@ -913,7 +917,7 @@ thresholds:
|
||||
notes: '이벤트 충격 방어: 20% 고정. KOSPI 비중 제공 시 max(20, weight×0.60).'
|
||||
live_sample_requirement: 30
|
||||
sunset_date: '2026-09-30'
|
||||
- id: SEMI_CLUSTER_CAP_RISK_OFF
|
||||
- id: SEMI_CLUSTER_CAP_RISK_OFF_MWA
|
||||
value: 25.0
|
||||
unit: pct
|
||||
source: EXPERT_PRIOR
|
||||
@@ -921,7 +925,12 @@ thresholds:
|
||||
last_calibrated: null
|
||||
owner_formula: MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1
|
||||
gs_location: gas_data_feed.gs:3858
|
||||
notes: '하락장: 25%. KOSPI 비중 제공 시 max(25, weight×0.80).'
|
||||
notes: >
|
||||
하락장: 25%. KOSPI 비중 제공 시 max(25, weight×0.80).
|
||||
WBS-7.1(2026-06-21): 원래 id가 SEMI_CLUSTER_CAP_RISK_OFF였으나
|
||||
SEMICONDUCTOR_CLUSTER_GATE_V1 소유의 동명 entry(value=20.0)와 id가 충돌해
|
||||
dict 기반 조회 시 한쪽이 조용히 무시되는 버그가 있었다. 외부 참조 0건 확인 후
|
||||
이 entry(MARKET_WEIGHT_AWARE_CLUSTER_GATE_V1 소유)만 _MWA suffix로 분리했다.
|
||||
live_sample_requirement: 30
|
||||
sunset_date: '2026-09-30'
|
||||
- id: SEMI_CLUSTER_CAP_NEUTRAL
|
||||
@@ -1803,6 +1812,22 @@ thresholds:
|
||||
gs_location: gas_data_feed.gs:2164
|
||||
notes: Base take-profit score used in profit-lock computation. Migrated from GAS SP constant to registry (P5-T01 wave2).
|
||||
|
||||
- id: OVERHANG_PRESSURE_V1_FALLBACK_MULT
|
||||
value: 1.5
|
||||
unit: multiplier_of_avg_volume_5d
|
||||
source: EXPERT_PRIOR
|
||||
sample_n: 0
|
||||
last_calibrated: null
|
||||
owner_formula: OVERHANG_PRESSURE_V1
|
||||
py_location: spec/13_formula_registry.yaml:OVERHANG_PRESSURE_V1.derived_flags.selling_acceleration.without_20d_fallback
|
||||
notes: >
|
||||
WBS-7.5(2026-06-21) — frg_20d_sh 미존재 시 selling_acceleration 폴백을
|
||||
"frg_5d_sh < -500000"(절대 주식수, 임시) 에서 "frg_5d_sh < -1.5 * avg_volume_5d"
|
||||
(해당 종목 평균거래량 비례) 로 교체. 1.5 배수는 with_20d 분기에서 동일 공식이
|
||||
이미 사용하는 가속 임계(frg_20d_sh/4 × 1.5)를 그대로 재사용한 것이며, 새로
|
||||
추정한 값이 아니다. 단, 실거래 표본으로 검증되지 않았으므로 EXPERT_PRIOR로
|
||||
등록한다 — CALIBRATED 승격은 sample_n≥30 확보 후 검토.
|
||||
|
||||
calibration_policy:
|
||||
honest_disclosure_required: true
|
||||
overclaimed_calibration_definition: 'source=CALIBRATED 이면서 sample_n < 30 → OVERCLAIMED_CALIBRATION.
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 비기계적 매도전략(가치보존) 명세"
|
||||
parent_file: "RetirementAssetPortfolio.yaml"
|
||||
version: "2026-06-21-PHASE8_qualitative_sell"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "canonical"
|
||||
has_code_implementation: true
|
||||
code_path: "src/quant_engine/qualitative_sell_strategy_v1.py"
|
||||
purpose: >
|
||||
익절/손절을 고정 % 임계값으로 기계적으로 트리거하지 않고, 매크로·실적·펀더멘털·
|
||||
공매도수급·호가 미시구조·대내외 변수(대형 IPO·섹터 로테이션) 5개 독립 팩터군의
|
||||
합의(confluence)로 매도/보유/추가 확신도를 산출해 주식가치를 최대치로 보존한다.
|
||||
현금부족 사유는 입력에서 의도적으로 배제한다.
|
||||
|
||||
qualitative_sell_strategy:
|
||||
policy:
|
||||
execution: "보유 포지션 검토 시 항상 실행. STOP_PRICE_CORE_V1/PROFIT_RATCHET_TIERED_V2 등
|
||||
기존 기계적 손절/래칫 라인과 병행 — 이 명세가 그것들을 대체하지 않으며, '서두르지 않는
|
||||
재량적 정리' 판단을 보강한다."
|
||||
confluence_rule: "5개 팩터군 중 최소 3개가 동일 방향(+/-)으로 합의해야 행동 생성. 단일
|
||||
팩터의 임계값 돌파만으로 매도 트리거 금지."
|
||||
cash_shortfall_exclusion: "현금부족·리밸런싱 강제매도 사유는 이 명세의 입력에서 제외한다.
|
||||
해당 사유의 매도는 spec/exit/value_preserving_cash_raise_optimizer_v7.yaml 책임."
|
||||
date_basis: "review_window는 실제 실적발표일·고영향 매크로 이벤트일(spec/strategy/
|
||||
macro_event_synchronizer_v2.yaml:event_hold_gate)에서 역산한다. 임의 고정일 금지."
|
||||
|
||||
factor_families:
|
||||
macro_pressure:
|
||||
id: "F1"
|
||||
formula_ref: "spec/strategy/macro_event_synchronizer_v2.yaml:position_size_scale_formula"
|
||||
sources: ["macro_risk_score", "FX", "금리", "산업통상부 수출입동향(섹터별)"]
|
||||
note: "수출입 동향으로 섹터별 실적 선행지표를 추정해 가중."
|
||||
|
||||
fundamental_trajectory:
|
||||
id: "F2"
|
||||
formula_ref: "spec/strategy/fundamental_quality_v3.yaml"
|
||||
sources: ["EPS 추정치 변화", "영업이익률 추세", "실적발표 컨센서스 서프라이즈"]
|
||||
|
||||
short_interest_pressure:
|
||||
id: "F3"
|
||||
formula_ref: "spec/13b_harness_formulas.yaml:formula_registry.formulas.SHORT_INTEREST_RISK_GAUGE_V1"
|
||||
sources: ["공매도잔고율 추세", "공매도거래비중", "상대수익률", "거래량 이상", "실적전망"]
|
||||
note: >
|
||||
잔고율은 '매수/매도 버튼'이 아니라 위험계기판. 잔고율이 낮은 종목(예: 현대로템형,
|
||||
<1%)은 잔고율 자체보다 거래비중·상대수익률을 더 중요하게 본다.
|
||||
|
||||
microstructure_pressure:
|
||||
id: "F4"
|
||||
sources: ["호가 10단계 매수/매도 잔량 불균형", "체결강도", "스프레드"]
|
||||
note: "전략적 방향 결정에는 쓰지 않고 confluence가 SELL/ADD로 합의된 이후의
|
||||
'집행 타이밍'에만 사용 — execution_window 산정 보조."
|
||||
|
||||
liquidity_rotation_risk:
|
||||
id: "F5"
|
||||
sources: ["대형 IPO 청약/상장에 따른 섹터 자금 이탈", "동일 섹터 로테이션",
|
||||
"외국인/기관 섹터 비중 변화"]
|
||||
|
||||
output:
|
||||
formula_ref: "spec/13b_harness_formulas.yaml:formula_registry.formulas.QUALITATIVE_SELL_STRATEGY_V1"
|
||||
python_tool: "src/quant_engine/qualitative_sell_strategy_v1.py:compute_qualitative_sell_strategy"
|
||||
actions:
|
||||
EXIT_REVIEW_FULL: "4-5개 팩터군 매도방향 합의 + composite_score>=0.6 — 전량 정리 검토"
|
||||
TRIM_REVIEW_PARTIAL: "3개 이상 팩터군 매도방향 합의, composite_score<0.6 — 부분 정리 검토"
|
||||
HOLD_ADD_CONVICTION: "3개 이상 팩터군 지지방향 합의 — 보유/추가 확신"
|
||||
HOLD_NO_CONFLUENCE: "합의 미달 — 보유, 관찰 지속"
|
||||
INSUFFICIENT_DATA_NO_ACTION: "confluence 판정에 필요한 최소 데이터 부족 — 추정 금지"
|
||||
|
||||
market_regime:
|
||||
formula_ref: "spec/13b_harness_formulas.yaml:formula_registry.formulas.MARKET_REGIME_CLASSIFIER_V1"
|
||||
rule: "금리 상승기(RISING)=PERFORMANCE_MARKET(실적장세) — fundamental_trajectory 가중 상향.
|
||||
금리 보합/하락기(FLAT/FALLING)=TECHNICAL_MARKET(기술장세) — short_interest_pressure/
|
||||
microstructure_pressure 가중 상향. confluence 합의건수 판정 자체는 가중치와 무관 —
|
||||
composite_score(행동 강도)에만 영향."
|
||||
|
||||
satellite_candidate_score:
|
||||
formula_ref: "spec/13b_harness_formulas.yaml:formula_registry.formulas.SATELLITE_CANDIDATE_SCORE_V1"
|
||||
purpose: "미보유 위성 유니버스 종목의 BUY_CANDIDATE/WATCH/AVOID 사전 평가. sector_export_trend
|
||||
(관세청/산업통상부 수출입동향)·fundamental_trajectory·relative_return_20d를 market_regime별
|
||||
가중치로 종합."
|
||||
|
||||
data_sources:
|
||||
note: "2026-06-21 세션 실측 결과. investing.com 직접 스크래핑은 403(Cloudflare) 차단 확인 —
|
||||
자동 수집 경로로 채택하지 않는다."
|
||||
relative_return_20d:
|
||||
tool: "tools/fetch_naver_market_data_v1.py:compute_relative_return_20d"
|
||||
source: "finance.naver.com/item/sise_day.naver (무인증, 동작 확인)"
|
||||
status: "WORKING"
|
||||
volume_ratio_5d:
|
||||
tool: "tools/fetch_naver_market_data_v1.py:compute_volume_ratio_5d"
|
||||
source: "finance.naver.com/item/sise_day.naver"
|
||||
status: "WORKING"
|
||||
foreign_institution_flow:
|
||||
tool: "tools/fetch_naver_market_data_v1.py:fetch_foreign_institution_flow"
|
||||
source: "finance.naver.com/item/frgn.naver (GAS gdc_01_fetch_fundamentals.gs와 동일 소스 —
|
||||
보유종목은 기존 GAS 수집 결과 재사용 권장, 위성 후보군만 직접 호출)"
|
||||
status: "WORKING"
|
||||
sector_export_trend:
|
||||
tool: "tools/fetch_trade_statistics_motie_v1.py:compute_sector_export_trend"
|
||||
source: "관세청/산업통상부 수출입통계 — 1차: --csv 수동 다운로드 경로(안정적, 권장).
|
||||
2차: data.go.kr OpenAPI(CUSTOMS_API_KEY 필요, 미설정 시 DATA_MISSING)."
|
||||
status: "CSV_PATH_WORKING / API_PATH_NEEDS_KEY"
|
||||
short_balance_ratio:
|
||||
source: "KRX 공매도종합포털(open.krx.co.kr/contents/SRT) — 직접 API 호출은 OTP 세션 필요,
|
||||
LOGOUT 응답으로 차단 확인. KIS Open API도 잔고율(보유 포지션 개념)은 제공하지 않음
|
||||
(실측 확인, 2026-06-21). 수동 다운로드 CSV(--short-csv)로만 안정 확보 — 자동화
|
||||
재시도 불필요(차단 확정)."
|
||||
status: "MANUAL_CSV_ONLY"
|
||||
short_turnover_share:
|
||||
source: "[2026-06-21 해결] KIS Open API daily-short-sale(FHPST04830000,
|
||||
/uapi/domestic-stock/v1/quotations/daily-short-sale) output2.ssts_vol_rlim —
|
||||
실전계좌 도메인(--kis-account real)에서 실측 동작 확인. 모의계좌 도메인은
|
||||
500 에러(미지원). Naver는 KRX iframe 위임으로 값 없음(폐기)."
|
||||
tool: "tools/build_qualitative_sell_inputs_v1.py:fetch_kis_supplement"
|
||||
status: "KIS_API_WORKING (real account only)"
|
||||
microstructure_pressure_10_level_orderbook:
|
||||
source: "[2026-06-21 해결] KIS Open API inquire-asking-price-exp-ccn(FHKST01010200,
|
||||
/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn) output1 —
|
||||
실전+모의계좌 도메인 모두 실측 동작 확인. 필드명: askp1~10/bidp1~10/
|
||||
askp_rsqn1~10/bidp_rsqn1~10/total_askp_rsqn/total_bidp_rsqn(전부 소문자,
|
||||
실측 확인). 전략 방향 결정에는 쓰지 않고 confluence 성립 후 집행 타이밍
|
||||
보조로만 사용(factor_families.microstructure_pressure 참조)."
|
||||
tool: "src/quant_engine/qualitative_sell_strategy_v1.py:compute_microstructure_pressure_from_orderbook"
|
||||
status: "KIS_API_WORKING"
|
||||
investor_trend_official:
|
||||
source: "[참고, 미연동] KIS Open API inquire-investor(FHKST01010900) —
|
||||
개인/외국인/기관 순매수수량(prsn_ntby_qty/frgn_ntby_qty/orgn_ntby_qty) 등 실측
|
||||
확인. Naver frgn.naver 스크래핑을 대체할 수 있는 공식 소스이나 아직 미연동
|
||||
(기존 GAS 수급 피드와 중복 — 필요 시 후속 작업)."
|
||||
status: "VERIFIED_NOT_WIRED"
|
||||
kis_open_api_constraints:
|
||||
note: "[CRITICAL] governance/rules/06_no_direct_api_trading.yaml(주문 미실행),
|
||||
governance/rules/07_no_kis_account_balance_query.yaml(계좌 보유종목 조회 금지) —
|
||||
KIS API는 시장 전체 공개 데이터(시세/호가/공매도/투자자동향) 조회에만 사용.
|
||||
CI 강제 게이트: tools/validate_no_direct_api_trading_v1.py(strict, warn_only 불가)."
|
||||
macro_pressure / rate_trend / next_earnings_date / next_macro_event_date / macro_event_impact:
|
||||
source: "기존 GAS 하네스(macro_event_synchronizer_v2, gas_event_calendar.gs)가 이미
|
||||
산출/수집 — 중복 수집 금지, --context-json으로 그 결과를 주입."
|
||||
status: "REUSE_EXISTING_HARNESS"
|
||||
|
||||
orchestrator:
|
||||
tool: "tools/build_qualitative_sell_inputs_v1.py"
|
||||
purpose: "위 출처들을 종목별 ctx로 조립해 QUALITATIVE_SELL_STRATEGY_V1을 호출하고
|
||||
outputs/qualitative_sell_strategy/<code>.json에 기록한다. --batch --workbook으로
|
||||
account_snapshot 실보유 종목 전체 일괄 처리."
|
||||
|
||||
satellite_orchestrator:
|
||||
tool: "tools/build_satellite_candidate_recommendations_v1.py"
|
||||
purpose: "universe 시트(미보유 위성 유니버스)에서 보유종목을 제외한 후보 전체를
|
||||
SATELLITE_CANDIDATE_SCORE_V1로 평가해 outputs/qualitative_sell_strategy/
|
||||
satellite_recommendations.json에 기록한다. universe.Sector 한글 라벨은 부분
|
||||
문자열 매칭으로 SECTOR_HS_MAP에 연결 — 매칭 실패 시 sector_export_trend를
|
||||
추정하지 않고 None 유지(추정 금지 원칙)."
|
||||
+3
-10
@@ -82,16 +82,9 @@ ownership_map:
|
||||
must_not_own: ["투자 규칙 수치"]
|
||||
|
||||
# ── 호환 인덱스 (redirect-only, 실제 규칙은 canonical_split_files 참조) ──
|
||||
"spec/03_risk_policy.yaml":
|
||||
role: "compatibility_index"
|
||||
owns: ["legacy path alias for spec/risk/*.yaml"]
|
||||
must_not_own: ["수치 임계값", "새 리스크 규칙"]
|
||||
canonical_files: ["spec/risk/portfolio_exposure.yaml", "spec/risk/risk_control.yaml", "spec/risk/quality_control.yaml"]
|
||||
"spec/04_strategy_rules.yaml":
|
||||
role: "compatibility_index"
|
||||
owns: ["legacy path alias for spec/strategy/*.yaml"]
|
||||
must_not_own: ["수치 임계값", "새 전략 규칙"]
|
||||
canonical_files: ["spec/strategy/sector_model.yaml", "spec/strategy/entry_gates.yaml", "spec/strategy/stock_model.yaml", "spec/strategy/rebalancing_trigger.yaml"]
|
||||
# 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml, spec/04_strategy_rules.yaml은
|
||||
# role: deprecated_redirect(영구 유지가 아닌 완전 폐기 대상)였으며 활성 참조 0건을
|
||||
# 확인 후 실삭제했다. 캐노니컬 split 파일들은 영향 없이 그대로 유지된다.
|
||||
"spec/06_exit_policy.yaml":
|
||||
role: "compatibility_index"
|
||||
owns: ["legacy path alias for spec/exit/*.yaml"]
|
||||
|
||||
+3
-2
@@ -1,6 +1,7 @@
|
||||
# Risk Spec Split Plan
|
||||
|
||||
`spec/03_risk_policy.yaml` is now a compatibility index.
|
||||
`spec/03_risk_policy.yaml` was a deprecated_redirect-only stub and has been deleted
|
||||
(2026-06-22, WBS-7.11 — zero active references confirmed before removal).
|
||||
The canonical risk rules are the split files in this directory.
|
||||
|
||||
Canonical split files:
|
||||
@@ -14,7 +15,7 @@ Canonical split files:
|
||||
|
||||
Migration rule:
|
||||
|
||||
- Do not add numeric thresholds to `spec/03_risk_policy.yaml` or `spec/risk/risk_control.yaml`.
|
||||
- Do not add numeric thresholds to `spec/risk/risk_control.yaml` (compatibility index only).
|
||||
- Keep old paths valid only through compatibility indexes and `spec/aliases.yaml`.
|
||||
- New documents must reference canonical split files directly.
|
||||
- `spec/00_execution_contract.yaml` remains higher authority than all risk split files.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 포트폴리오 노출·현금 정책 분할 후보"
|
||||
parent_file: "spec/03_risk_policy.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
|
||||
version: "2026-05-16-F9_secular_leader"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 포트폴리오 노출·현금 정책 분할 후보"
|
||||
parent_file: "spec/03_risk_policy.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
|
||||
version: "2026-05-18-F10_score_clamp_d2_fix"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 리스크 품질관리 분할 후보"
|
||||
parent_file: "spec/03_risk_policy.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
|
||||
version: "2026-05-15-F8_split"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 리스크 제어 호환 인덱스"
|
||||
parent_file: "spec/03_risk_policy.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/03_risk_policy.yaml 삭제로 갱신
|
||||
version: "2026-05-15-F12_index_only"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "compatibility_index"
|
||||
has_code_implementation: false
|
||||
redirect_only: true
|
||||
purpose: "기존 risk_control 경로를 보존하기 위한 인덱스 파일."
|
||||
|
||||
canonical_split_files:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Strategy Spec Split Plan
|
||||
|
||||
`spec/04_strategy_rules.yaml` is now a compatibility index.
|
||||
`spec/04_strategy_rules.yaml` was a deprecated_redirect-only stub and has been deleted
|
||||
(2026-06-22, WBS-7.11 — zero active references confirmed before removal).
|
||||
The canonical strategy rules are the split files in this directory.
|
||||
|
||||
Canonical split files:
|
||||
@@ -17,5 +18,5 @@ Canonical split files:
|
||||
Migration rule:
|
||||
|
||||
- Do not duplicate thresholds without `canonical_ref`.
|
||||
- Keep old paths valid through `spec/04_strategy_rules.yaml.legacy_path_aliases`.
|
||||
- Keep old paths valid through `spec/strategy/entry_gates.yaml.legacy_path_aliases` (compatibility index only).
|
||||
- `spec/09_decision_flow.yaml` controls execution order; strategy split files only define domain logic.
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 진입 게이트 호환 인덱스"
|
||||
parent_file: "spec/04_strategy_rules.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
|
||||
version: "2026-05-15-F11_index_only"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
role: "compatibility_index"
|
||||
has_code_implementation: false
|
||||
redirect_only: true
|
||||
purpose: >
|
||||
기존 spec/strategy/entry_gates.yaml 경로를 보존하기 위한 인덱스 파일.
|
||||
실제 진입 규칙은 세부 split 파일을 canonical로 사용한다.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 리밸런싱 트리거 분할 후보"
|
||||
parent_file: "spec/04_strategy_rules.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
|
||||
version: "2026-05-15-F8_split"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 섹터 모델 분할 후보"
|
||||
parent_file: "spec/04_strategy_rules.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
|
||||
version: "2026-05-15-F8_split"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
meta:
|
||||
title: "은퇴자산포트폴리오 — 종목 모델 분할 후보"
|
||||
parent_file: "spec/04_strategy_rules.yaml"
|
||||
parent_file: "RetirementAssetPortfolio.yaml" # 2026-06-22 WBS-7.11: spec/04_strategy_rules.yaml 삭제로 갱신
|
||||
version: "2026-05-16-F10_peg_gate"
|
||||
language: "ko-KR"
|
||||
timezone: "Asia/Seoul"
|
||||
|
||||
@@ -2467,6 +2467,29 @@ function doPost(e) {
|
||||
.createTextOutput(JSON.stringify(result, null, 2))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
if (action === "trigger_run_all") {
|
||||
// 외부(Gitea CI) 스케줄러가 run_all()을 원격 트리거할 수 있게 하는 진입점.
|
||||
// run_all은 매수/매도 주문을 실행하지 않는다(데이터 갱신·분석 전용) — governance
|
||||
// 06/07과 동일한 "조회/분석만, 주문 없음" 원칙을 따른다. 공유 비밀키로 무단 호출 차단.
|
||||
const expectedSecret = String(PropertiesService.getScriptProperties().getProperty("RUN_ALL_TRIGGER_SECRET") || "");
|
||||
const providedSecret = String(payload.secret || "");
|
||||
if (!expectedSecret || providedSecret !== expectedSecret) {
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify({ status: "ERROR", message: "unauthorized" }, null, 2))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
const startedAt = new Date().toISOString();
|
||||
try {
|
||||
run_all();
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify({ status: "OK", started_at: startedAt, finished_at: new Date().toISOString() }, null, 2))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
} catch (runErr) {
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify({ status: "ERROR", message: String(runErr && runErr.message ? runErr.message : runErr) }, null, 2))
|
||||
.setMimeType(ContentService.MimeType.JSON);
|
||||
}
|
||||
}
|
||||
return ContentService
|
||||
.createTextOutput(JSON.stringify({
|
||||
status: "ERROR",
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Storage backend selection for the collection pipeline.
|
||||
|
||||
This module is a thin compatibility wrapper over the generic storage backend
|
||||
contract. The collector is intentionally designed around a backend contract,
|
||||
not a hard SQLite-only assumption.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
|
||||
|
||||
|
||||
CollectionStoreSpec = StoreSpec
|
||||
|
||||
|
||||
def default_collection_store_path(root: Path) -> Path:
|
||||
return default_sqlite_store_path(root, "kis_data_collection/kis_data_collection.db")
|
||||
@@ -0,0 +1,370 @@
|
||||
"""SQLite store for platform-transition data collection outputs.
|
||||
|
||||
This store is intentionally small and backend-agnostic enough to be upgraded to
|
||||
PostgreSQL later without changing the row contract. The canonical payload is the
|
||||
normalized factor row plus provenance metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
collector_name TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
input_source TEXT,
|
||||
output_json_path TEXT,
|
||||
output_db_path TEXT,
|
||||
notes TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_snapshots (
|
||||
run_id TEXT NOT NULL,
|
||||
dataset_name TEXT NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT,
|
||||
sector TEXT,
|
||||
as_of_date TEXT,
|
||||
source_priority TEXT,
|
||||
source_status TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
provenance_json TEXT NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
PRIMARY KEY (run_id, dataset_name, ticker)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS collection_source_errors (
|
||||
run_id TEXT NOT NULL,
|
||||
ticker TEXT,
|
||||
source_name TEXT NOT NULL,
|
||||
error_kind TEXT NOT NULL,
|
||||
error_message TEXT NOT NULL,
|
||||
payload_json TEXT,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_snapshots_ticker_time
|
||||
ON collection_snapshots(ticker, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_collection_source_errors_run
|
||||
ON collection_source_errors(run_id, source_name);
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CollectionRun:
|
||||
run_id: str
|
||||
collector_name: str
|
||||
started_at: str
|
||||
status: str
|
||||
input_source: str | None = None
|
||||
output_json_path: str | None = None
|
||||
output_db_path: str | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
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 upsert_collection_run(db_path: Path, run: CollectionRun, finished_at: str | None = None) -> None:
|
||||
init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collection_runs (
|
||||
run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id) DO UPDATE SET
|
||||
collector_name=excluded.collector_name,
|
||||
started_at=excluded.started_at,
|
||||
finished_at=excluded.finished_at,
|
||||
status=excluded.status,
|
||||
input_source=excluded.input_source,
|
||||
output_json_path=excluded.output_json_path,
|
||||
output_db_path=excluded.output_db_path,
|
||||
notes=excluded.notes
|
||||
""",
|
||||
(
|
||||
run.run_id,
|
||||
run.collector_name,
|
||||
run.started_at,
|
||||
finished_at,
|
||||
run.status,
|
||||
run.input_source,
|
||||
run.output_json_path,
|
||||
run.output_db_path,
|
||||
run.notes,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def upsert_collection_snapshot(
|
||||
db_path: Path,
|
||||
*,
|
||||
run_id: str,
|
||||
dataset_name: str,
|
||||
ticker: str,
|
||||
name: str | None,
|
||||
sector: str | None,
|
||||
as_of_date: str | None,
|
||||
source_priority: str,
|
||||
source_status: str,
|
||||
payload: dict[str, Any],
|
||||
provenance: dict[str, Any],
|
||||
) -> None:
|
||||
init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collection_snapshots (
|
||||
run_id, dataset_name, ticker, name, sector, as_of_date,
|
||||
source_priority, source_status, payload_json, provenance_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(run_id, dataset_name, ticker) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
sector=excluded.sector,
|
||||
as_of_date=excluded.as_of_date,
|
||||
source_priority=excluded.source_priority,
|
||||
source_status=excluded.source_status,
|
||||
payload_json=excluded.payload_json,
|
||||
provenance_json=excluded.provenance_json
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
dataset_name,
|
||||
ticker,
|
||||
name,
|
||||
sector,
|
||||
as_of_date,
|
||||
source_priority,
|
||||
source_status,
|
||||
json.dumps(payload, ensure_ascii=False, default=str),
|
||||
json.dumps(provenance, ensure_ascii=False, default=str),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def append_collection_error(
|
||||
db_path: Path,
|
||||
*,
|
||||
run_id: str,
|
||||
source_name: str,
|
||||
error_kind: str,
|
||||
error_message: str,
|
||||
ticker: str | None = None,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
init_db(db_path)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO collection_source_errors (
|
||||
run_id, ticker, source_name, error_kind, error_message, payload_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
run_id,
|
||||
ticker,
|
||||
source_name,
|
||||
error_kind,
|
||||
error_message,
|
||||
json.dumps(payload or {}, ensure_ascii=False, default=str),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def fetch_latest_snapshots(db_path: Path, ticker: str, dataset_name: str | None = None) -> list[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
return []
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
if dataset_name:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM collection_snapshots
|
||||
WHERE ticker = ? AND dataset_name = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(ticker, dataset_name),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM collection_snapshots
|
||||
WHERE ticker = ?
|
||||
ORDER BY created_at DESC
|
||||
""",
|
||||
(ticker,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def iter_recent_snapshots(db_path: Path, limit: int = 50) -> Iterable[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
return []
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM collection_snapshots ORDER BY created_at DESC LIMIT ?",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_collection_runs(db_path: Path, 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 run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes, created_at
|
||||
FROM collection_runs
|
||||
ORDER BY started_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_collection_errors(db_path: Path, 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 run_id, ticker, source_name, error_kind, error_message, payload_json, created_at
|
||||
FROM collection_source_errors
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def load_collection_dashboard_state(
|
||||
db_path: Path | str | None = None,
|
||||
output_json_path: Path | str | None = None,
|
||||
*,
|
||||
limit: int = 8,
|
||||
) -> dict[str, Any]:
|
||||
db = Path(db_path) if db_path else Path()
|
||||
report = Path(output_json_path) if output_json_path else Path()
|
||||
state: dict[str, Any] = {
|
||||
"db_path": str(db),
|
||||
"output_json_path": str(report) if output_json_path else "",
|
||||
"runs": [],
|
||||
"recent_snapshots": [],
|
||||
"recent_errors": [],
|
||||
"counts": {
|
||||
"collection_runs": 0,
|
||||
"collection_snapshots": 0,
|
||||
"collection_source_errors": 0,
|
||||
},
|
||||
"latest_run": {},
|
||||
"latest_report": {},
|
||||
}
|
||||
if report.exists():
|
||||
try:
|
||||
state["latest_report"] = json.loads(report.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
state["latest_report"] = {}
|
||||
if not db.exists():
|
||||
return state
|
||||
conn = sqlite3.connect(db)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
state["counts"] = {
|
||||
"collection_runs": conn.execute("SELECT COUNT(*) FROM collection_runs").fetchone()[0],
|
||||
"collection_snapshots": conn.execute("SELECT COUNT(*) FROM collection_snapshots").fetchone()[0],
|
||||
"collection_source_errors": conn.execute("SELECT COUNT(*) FROM collection_source_errors").fetchone()[0],
|
||||
}
|
||||
run_row = conn.execute(
|
||||
"""
|
||||
SELECT run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes, created_at
|
||||
FROM collection_runs
|
||||
ORDER BY started_at DESC, created_at DESC
|
||||
LIMIT 1
|
||||
"""
|
||||
).fetchone()
|
||||
state["latest_run"] = dict(run_row) if run_row is not None else {}
|
||||
state["runs"] = [dict(row) for row in conn.execute(
|
||||
"""
|
||||
SELECT run_id, collector_name, started_at, finished_at, status,
|
||||
input_source, output_json_path, output_db_path, notes, created_at
|
||||
FROM collection_runs
|
||||
ORDER BY started_at DESC, created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()]
|
||||
state["recent_snapshots"] = [dict(row) for row in conn.execute(
|
||||
"""
|
||||
SELECT run_id, dataset_name, ticker, name, sector, as_of_date,
|
||||
source_priority, source_status, created_at
|
||||
FROM collection_snapshots
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()]
|
||||
state["recent_errors"] = [dict(row) for row in conn.execute(
|
||||
"""
|
||||
SELECT run_id, ticker, source_name, error_kind, error_message, created_at
|
||||
FROM collection_source_errors
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()]
|
||||
finally:
|
||||
conn.close()
|
||||
return state
|
||||
@@ -0,0 +1,144 @@
|
||||
"""WBS-7.6(2026-06-21) — 실거래 슬리피지 실측 캡처 스캐폴딩.
|
||||
|
||||
spec/55_execution_simulator_contract.yaml의 slippage_model(bps=5)은 이론치이며
|
||||
"추후 실측 데이터로 보정 예정"이라는 메모만 있고 실제 캡처 경로가 없었다. 이 모듈은
|
||||
주문은 사람이 HTS에서 직접 실행한다는 governance/rules/06 원칙을 그대로 유지한 채
|
||||
(API로 체결을 가져오지 않는다), 실행 후 사람이 수동으로 기록한 실제 체결가를
|
||||
누적해 가정치(5bps)와 비교할 수 있게 한다. 5건 미만이면 항상 DATA_GATED로 보고한다
|
||||
— 추정 금지 원칙(spec/00_execution_contract.yaml)을 따른다. 표준 라이브러리
|
||||
sqlite3만 사용한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
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 realized_slippage_samples (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ticker TEXT NOT NULL,
|
||||
side TEXT NOT NULL CHECK (side IN ('BUY', 'SELL')),
|
||||
intended_price REAL NOT NULL,
|
||||
actual_fill_price REAL NOT NULL,
|
||||
slippage_bps_actual REAL NOT NULL,
|
||||
recorded_at TEXT NOT NULL,
|
||||
note TEXT,
|
||||
inserted_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
ASSUMED_SLIPPAGE_BPS = 5.0
|
||||
MIN_SAMPLE_FOR_COMPARISON = 5
|
||||
|
||||
|
||||
def default_execution_slippage_store_path(root: Path) -> Path:
|
||||
return default_sqlite_store_path(root, "execution_slippage/execution_slippage.db")
|
||||
|
||||
|
||||
def resolve_store_path(spec: StoreSpec, root: Path) -> Path:
|
||||
backend, location = normalize_store_spec(
|
||||
spec, root, default_sqlite_name="execution_slippage/execution_slippage.db"
|
||||
)
|
||||
if backend != "sqlite":
|
||||
raise ValueError("execution_slippage_store_v1 currently executes on sqlite only.")
|
||||
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 _compute_slippage_bps(intended_price: float, actual_fill_price: float, side: str) -> float:
|
||||
"""체결가가 의도가(지정가)보다 불리한 방향으로 움직인 만큼을 양수 bps로 환산한다.
|
||||
|
||||
BUY: 실제 체결가가 의도가보다 높으면(더 비싸게 샀으면) 양수 슬리피지.
|
||||
SELL: 실제 체결가가 의도가보다 낮으면(더 싸게 팔았으면) 양수 슬리피지.
|
||||
"""
|
||||
if intended_price <= 0:
|
||||
raise ValueError("intended_price must be > 0")
|
||||
direction = 1 if side.upper() == "BUY" else -1
|
||||
return direction * (actual_fill_price - intended_price) / intended_price * 10_000.0
|
||||
|
||||
|
||||
def insert_realized_slippage_sample(
|
||||
db_path: Path,
|
||||
*,
|
||||
ticker: str,
|
||||
side: str,
|
||||
intended_price: float,
|
||||
actual_fill_price: float,
|
||||
recorded_at: str,
|
||||
note: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
init_db(db_path)
|
||||
slippage_bps = _compute_slippage_bps(intended_price, actual_fill_price, side)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO realized_slippage_samples "
|
||||
"(ticker, side, intended_price, actual_fill_price, slippage_bps_actual, recorded_at, note) "
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(ticker, side.upper(), intended_price, actual_fill_price, slippage_bps, recorded_at, note),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return {
|
||||
"ticker": ticker,
|
||||
"side": side.upper(),
|
||||
"intended_price": intended_price,
|
||||
"actual_fill_price": actual_fill_price,
|
||||
"slippage_bps_actual": round(slippage_bps, 4),
|
||||
"recorded_at": recorded_at,
|
||||
}
|
||||
|
||||
|
||||
def fetch_all_samples(db_path: Path) -> 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 ticker, side, intended_price, actual_fill_price, slippage_bps_actual, recorded_at, note "
|
||||
"FROM realized_slippage_samples ORDER BY recorded_at ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def build_slippage_comparison_report(db_path: Path) -> dict[str, Any]:
|
||||
"""WBS-7.6 성공 하네스 — 5건 미만이면 DATA_GATED를 정직하게 반환한다(추정 금지)."""
|
||||
samples = fetch_all_samples(db_path)
|
||||
sample_n = len(samples)
|
||||
if sample_n < MIN_SAMPLE_FOR_COMPARISON:
|
||||
return {
|
||||
"status": "DATA_GATED",
|
||||
"sample_n": sample_n,
|
||||
"min_required": MIN_SAMPLE_FOR_COMPARISON,
|
||||
"assumed_slippage_bps": ASSUMED_SLIPPAGE_BPS,
|
||||
"actual_mean_slippage_bps": None,
|
||||
"note": f"실측 표본 {sample_n}/{MIN_SAMPLE_FOR_COMPARISON}건 — 비교 불가, 가정치(5bps) 유지",
|
||||
}
|
||||
actual_mean = sum(s["slippage_bps_actual"] for s in samples) / sample_n
|
||||
gap = abs(actual_mean - ASSUMED_SLIPPAGE_BPS)
|
||||
return {
|
||||
"status": "OK",
|
||||
"sample_n": sample_n,
|
||||
"assumed_slippage_bps": ASSUMED_SLIPPAGE_BPS,
|
||||
"actual_mean_slippage_bps": round(actual_mean, 4),
|
||||
"gap_bps": round(gap, 4),
|
||||
"recommendation": (
|
||||
"가정치(5bps) 유지" if gap <= 3.0 else "spec/55_execution_simulator_contract.yaml의 bps 값을 실측 평균으로 갱신 검토"
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"""한국투자증권(KIS) Open API 클라이언트 — 조회(read-only) 전용.
|
||||
|
||||
근거: https://apiportal.koreainvestment.com/apiservice-summary ,
|
||||
https://github.com/koreainvestment/open-trading-api (2026-06-21 실측 확인된
|
||||
api_url/tr_id만 사용 — 추정 금지).
|
||||
|
||||
══════════════════════════════════════════════════════════════════════════════
|
||||
[CRITICAL] governance/rules/06_no_direct_api_trading.yaml — 절대 규칙
|
||||
이 모듈은 매수/매도 주문을 어떤 경로로도 제출하지 않는다. 주문 제출/정정/취소
|
||||
함수는 이 파일에 일체 작성하지 않으며, 공유 요청 함수(_send_request)는 주문
|
||||
관련 경로("/trading/")나 TR_ID(TTTC08*/VTTC08* 등)를 만나면 즉시 RuntimeError로
|
||||
요청을 차단한다(2차 방어). 이 원칙을 어기면 엔진 전체가 '제안 시스템'에서
|
||||
'자동매매 시스템'으로 변질되어 프로젝트 핵심 전제가 깨진다(사용자 직접 지시).
|
||||
══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
인증 정보는 Windows 환경변수에서 읽는다(실제계좌: KIS_APP_Key/KIS_APP_Secret,
|
||||
모의계좌: KIS_APP_Key_TEST/KIS_APP_Secret_TEST). 방금 setx로 설정된 값은 현재
|
||||
프로세스의 os.environ에 아직 반영되지 않을 수 있어, HKCU\\Environment 레지스트리
|
||||
폴백을 둔다(읽기만 함, 값을 로그에 남기지 않음).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
REAL_DOMAIN = "https://openapi.koreainvestment.com:9443"
|
||||
MOCK_DOMAIN = "https://openapivts.koreainvestment.com:29443"
|
||||
TOKEN_CACHE_DIR = ROOT / "Temp"
|
||||
|
||||
# ── [CRITICAL] 주문 차단 목록 — 절대 수정/완화 금지 (governance/rules/06_no_direct_api_trading.yaml) ──
|
||||
# "/trading/" 하위 경로는 주문(order)뿐 아니라 계좌잔고조회(inquire-balance)도 포함한다.
|
||||
# 계좌 보유종목/잔고는 governance/rules/07_no_kis_account_balance_query.yaml에 의해
|
||||
# 별도로도 금지된다 — HTS 캡처가 유일한 출처(사용자 직접 지시).
|
||||
FORBIDDEN_PATH_SUBSTRINGS: tuple[str, ...] = ("/trading/",)
|
||||
FORBIDDEN_TR_ID_PREFIXES: tuple[str, ...] = (
|
||||
"TTTC08", "VTTC08", "TTTC01", "VTTC01", # 현금/신용 매수·매도·정정·취소
|
||||
"TTTC8434R", "VTTC8434R", # 주식잔고조회 — 계좌 보유종목 조회 금지(07번 규칙)
|
||||
)
|
||||
|
||||
|
||||
class OrderEndpointBlockedError(RuntimeError):
|
||||
"""주문 제출/정정/취소 경로 호출 시도 — 절대 차단."""
|
||||
|
||||
|
||||
def _assert_read_only(path: str, tr_id: str) -> None:
|
||||
for forbidden in FORBIDDEN_PATH_SUBSTRINGS:
|
||||
if forbidden in path:
|
||||
raise OrderEndpointBlockedError(
|
||||
f"BLOCKED: 주문 관련 경로 호출 시도 차단 — path={path!r}. "
|
||||
"이 엔진은 매수/매도를 API로 직접 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml)."
|
||||
)
|
||||
for prefix in FORBIDDEN_TR_ID_PREFIXES:
|
||||
if tr_id.upper().startswith(prefix):
|
||||
raise OrderEndpointBlockedError(
|
||||
f"BLOCKED: 주문 관련 TR_ID 호출 시도 차단 — tr_id={tr_id!r}. "
|
||||
"이 엔진은 매수/매도를 API로 직접 실행하지 않는다(governance/rules/06_no_direct_api_trading.yaml)."
|
||||
)
|
||||
|
||||
|
||||
def _read_env_var(name: str) -> str | None:
|
||||
import os
|
||||
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
try:
|
||||
import winreg
|
||||
|
||||
with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as key:
|
||||
value, _ = winreg.QueryValueEx(key, name)
|
||||
return value or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
class KisCredentials:
|
||||
def __init__(self, app_key: str, app_secret: str, account: str):
|
||||
self.app_key = app_key
|
||||
self.app_secret = app_secret
|
||||
self.account = account # "real" | "mock"
|
||||
self.domain = REAL_DOMAIN if account == "real" else MOCK_DOMAIN
|
||||
|
||||
@classmethod
|
||||
def load(cls, account: str = "mock") -> "KisCredentials":
|
||||
if account == "real":
|
||||
key_name, secret_name = "KIS_APP_Key", "KIS_APP_Secret"
|
||||
elif account == "mock":
|
||||
key_name, secret_name = "KIS_APP_Key_TEST", "KIS_APP_Secret_TEST"
|
||||
else:
|
||||
raise ValueError("account must be 'real' or 'mock'")
|
||||
app_key = _read_env_var(key_name)
|
||||
app_secret = _read_env_var(secret_name)
|
||||
if not app_key or not app_secret:
|
||||
raise RuntimeError(
|
||||
f"{key_name}/{secret_name} 환경변수를 찾을 수 없음 — Windows 환경변수 설정 후 "
|
||||
"새 셸에서 재시도하거나 HKCU\\Environment 레지스트리 반영을 확인하세요."
|
||||
)
|
||||
return cls(app_key=app_key, app_secret=app_secret, account=account)
|
||||
|
||||
|
||||
def _token_cache_path(creds: KisCredentials) -> Path:
|
||||
TOKEN_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
return TOKEN_CACHE_DIR / f"kis_token_cache_{creds.account}.json"
|
||||
|
||||
|
||||
def _issue_or_reuse_token(creds: KisCredentials) -> str:
|
||||
"""KIS는 토큰 발급 빈도를 제한한다 — 만료 전까지 캐시 재사용 필수."""
|
||||
cache_path = _token_cache_path(creds)
|
||||
if cache_path.exists():
|
||||
try:
|
||||
cached = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
expires_at = dt.datetime.fromisoformat(cached["expires_at"])
|
||||
if dt.datetime.now(dt.timezone.utc) < expires_at - dt.timedelta(minutes=10):
|
||||
return cached["access_token"]
|
||||
except (json.JSONDecodeError, KeyError, ValueError):
|
||||
pass
|
||||
|
||||
resp = requests.post(
|
||||
f"{creds.domain}/oauth2/tokenP",
|
||||
json={"grant_type": "client_credentials", "appkey": creds.app_key, "appsecret": creds.app_secret},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
body = resp.json()
|
||||
access_token = body["access_token"]
|
||||
expires_in_sec = int(body.get("expires_in", 86400))
|
||||
expires_at = dt.datetime.now(dt.timezone.utc) + dt.timedelta(seconds=expires_in_sec)
|
||||
cache_path.write_text(
|
||||
json.dumps({"access_token": access_token, "expires_at": expires_at.isoformat()}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return access_token
|
||||
|
||||
|
||||
def _send_request(creds: KisCredentials, path: str, tr_id: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""모든 KIS REST 호출의 단일 진입점 — 여기서만 가드가 작동하면 충분하다."""
|
||||
_assert_read_only(path, tr_id) # [CRITICAL] 절대 제거 금지
|
||||
access_token = _issue_or_reuse_token(creds)
|
||||
headers = {
|
||||
"content-type": "application/json; charset=utf-8",
|
||||
"authorization": f"Bearer {access_token}",
|
||||
"appkey": creds.app_key,
|
||||
"appsecret": creds.app_secret,
|
||||
"tr_id": tr_id,
|
||||
"custtype": "P",
|
||||
}
|
||||
resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── 조회(read-only) 함수 — 전부 GET, 전부 quotations/ranking 카테고리 (실측 확인) ──────────
|
||||
|
||||
def get_current_price(creds: KisCredentials, code: str) -> dict[str, Any]:
|
||||
"""주식현재가 시세. api_url=/uapi/domestic-stock/v1/quotations/inquire-price, tr_id=FHKST01010100."""
|
||||
return _send_request(
|
||||
creds, "/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100",
|
||||
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
|
||||
)
|
||||
|
||||
|
||||
def get_asking_price_10_level(creds: KisCredentials, code: str) -> dict[str, Any]:
|
||||
"""주식현재가 호가/예상체결 — 10단계 매수/매도 호가.
|
||||
api_url=/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn, tr_id=FHKST01010200.
|
||||
"""
|
||||
return _send_request(
|
||||
creds, "/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200",
|
||||
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
|
||||
)
|
||||
|
||||
|
||||
def get_daily_short_sale(creds: KisCredentials, code: str, start_date: str, end_date: str) -> dict[str, Any]:
|
||||
"""국내주식 공매도 일별추이. api_url=/uapi/domestic-stock/v1/quotations/daily-short-sale,
|
||||
tr_id=FHPST04830000. start_date/end_date: YYYYMMDD."""
|
||||
return _send_request(
|
||||
creds, "/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000",
|
||||
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
|
||||
"FID_INPUT_DATE_1": start_date, "FID_INPUT_DATE_2": end_date},
|
||||
)
|
||||
|
||||
|
||||
def get_daily_item_chart_price(
|
||||
creds: KisCredentials, code: str, start_date: str, end_date: str, period: str = "D",
|
||||
) -> dict[str, Any]:
|
||||
"""주식현재가 일자별. api_url=/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice,
|
||||
tr_id=FHKST03010100."""
|
||||
return _send_request(
|
||||
creds, "/uapi/domestic-stock/v1/quotations/inquire-daily-itemchartprice", "FHKST03010100",
|
||||
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code,
|
||||
"FID_INPUT_DATE_1": start_date, "FID_INPUT_DATE_2": end_date,
|
||||
"FID_PERIOD_DIV_CODE": period, "FID_ORG_ADJ_PRC": "0"},
|
||||
)
|
||||
|
||||
|
||||
def get_investor_trend(creds: KisCredentials, code: str) -> dict[str, Any]:
|
||||
"""주식현재가 투자자(개인/외국인/기관) 매매동향.
|
||||
api_url=/uapi/domestic-stock/v1/quotations/inquire-investor, tr_id=FHKST01010900."""
|
||||
return _send_request(
|
||||
creds, "/uapi/domestic-stock/v1/quotations/inquire-investor", "FHKST01010900",
|
||||
{"FID_COND_MRKT_DIV_CODE": "J", "FID_INPUT_ISCD": code},
|
||||
)
|
||||
@@ -0,0 +1,378 @@
|
||||
"""KIS-first data collector for the CI scheduler.
|
||||
|
||||
The collector uses the existing `GatherTradingData.json` snapshot as the seed
|
||||
universe, then enriches Korean tickers with read-only KIS quotations and
|
||||
orderbook data, while retaining Naver/Yahoo fallbacks when available.
|
||||
The canonical persistence target is SQLite.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
try:
|
||||
from tools.fetch_naver_market_data_v1 import ( # type: ignore
|
||||
_session as naver_session,
|
||||
compute_relative_return_20d,
|
||||
compute_volume_ratio_5d,
|
||||
fetch_foreign_institution_flow,
|
||||
fetch_price_history,
|
||||
)
|
||||
except Exception: # pragma: no cover - optional adapter
|
||||
naver_session = None
|
||||
compute_relative_return_20d = None
|
||||
compute_volume_ratio_5d = None
|
||||
fetch_foreign_institution_flow = None
|
||||
fetch_price_history = None
|
||||
|
||||
try:
|
||||
from src.quant_engine.kis_api_client_v1 import ( # type: ignore
|
||||
KisCredentials,
|
||||
get_asking_price_10_level,
|
||||
get_current_price,
|
||||
get_daily_short_sale,
|
||||
)
|
||||
except Exception: # pragma: no cover - safe fallback in non-KIS environments
|
||||
KisCredentials = None
|
||||
get_asking_price_10_level = None
|
||||
get_current_price = None
|
||||
get_daily_short_sale = None
|
||||
|
||||
from src.quant_engine.data_collection_store_v1 import (
|
||||
CollectionRun,
|
||||
append_collection_error,
|
||||
upsert_collection_run,
|
||||
upsert_collection_snapshot,
|
||||
)
|
||||
from src.quant_engine.data_collection_backend_v1 import (
|
||||
CollectionStoreSpec,
|
||||
normalize_store_spec,
|
||||
)
|
||||
|
||||
|
||||
def _kst_now_iso() -> str:
|
||||
return dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat()
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _coerce_float(value: Any) -> float | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
value = value.replace(",", "").replace("%", "")
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _find_first_value(payload: Any, keys: tuple[str, ...]) -> Any:
|
||||
stack = [payload]
|
||||
while stack:
|
||||
item = stack.pop()
|
||||
if isinstance(item, dict):
|
||||
for key in keys:
|
||||
value = item.get(key)
|
||||
if value not in (None, ""):
|
||||
return value
|
||||
stack.extend(item.values())
|
||||
elif isinstance(item, list):
|
||||
stack.extend(item)
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_naver_price_history(code: str) -> dict[str, Any]:
|
||||
if naver_session is None or fetch_price_history is None:
|
||||
return {"status": "DISABLED"}
|
||||
try:
|
||||
session = naver_session()
|
||||
price = fetch_price_history(session, code)
|
||||
result: dict[str, Any] = {"status": price.get("status", "UNKNOWN"), "source_url": price.get("source_url")}
|
||||
rows = price.get("rows") or []
|
||||
if rows:
|
||||
result["close"] = rows[0].get("close")
|
||||
result["open"] = rows[0].get("open")
|
||||
result["high"] = rows[0].get("high")
|
||||
result["low"] = rows[0].get("low")
|
||||
result["volume"] = rows[0].get("volume")
|
||||
if compute_relative_return_20d is not None:
|
||||
benchmark = fetch_price_history(session, "069500")
|
||||
result["relative_return_20d"] = compute_relative_return_20d(rows, benchmark.get("rows", []))
|
||||
if compute_volume_ratio_5d is not None:
|
||||
result["volume_ratio_5d"] = compute_volume_ratio_5d(rows)
|
||||
if fetch_foreign_institution_flow is not None:
|
||||
result["foreign_institution_flow"] = fetch_foreign_institution_flow(session, code)
|
||||
return result
|
||||
except Exception as exc: # noqa: BLE001 - fallback source must not break the batch
|
||||
return {"status": "ERROR", "error": str(exc)}
|
||||
|
||||
|
||||
def _normalize_kis_fields(code: str, account: str) -> dict[str, Any]:
|
||||
if KisCredentials is None or get_current_price is None or get_asking_price_10_level is None or get_daily_short_sale is None:
|
||||
return {"status": "DISABLED"}
|
||||
try:
|
||||
creds = KisCredentials.load(account)
|
||||
except Exception as exc:
|
||||
return {"status": "ERROR", "error": str(exc)}
|
||||
|
||||
result: dict[str, Any] = {"status": "OK", "account": account}
|
||||
try:
|
||||
price = get_current_price(creds, code)
|
||||
result["current_price_raw"] = price
|
||||
result["current_price"] = _coerce_float(_find_first_value(price, ("stck_prpr", "stck_clpr", "close", "close_price")))
|
||||
result["open"] = _coerce_float(_find_first_value(price, ("stck_oprc", "open", "open_price")))
|
||||
result["high"] = _coerce_float(_find_first_value(price, ("stck_hgpr", "high", "high_price")))
|
||||
result["low"] = _coerce_float(_find_first_value(price, ("stck_lwpr", "low", "low_price")))
|
||||
result["prev_close"] = _coerce_float(_find_first_value(price, ("prdy_vrss", "prev_close")))
|
||||
result["volume"] = _coerce_float(_find_first_value(price, ("acml_vol", "volume")))
|
||||
result["change_pct"] = _coerce_float(_find_first_value(price, ("prdy_ctrt", "change_pct")))
|
||||
except Exception as exc:
|
||||
result["price_status"] = "ERROR"
|
||||
result["price_error"] = str(exc)
|
||||
|
||||
try:
|
||||
orderbook = get_asking_price_10_level(creds, code)
|
||||
output1 = orderbook.get("output1") or {}
|
||||
result["orderbook_raw"] = orderbook
|
||||
result["microstructure_pressure"] = _coerce_float(
|
||||
_find_first_value(output1, ("total_askp_rsqn", "total_bidp_rsqn"))
|
||||
)
|
||||
result["ask_1"] = _coerce_float(_find_first_value(output1, ("askp1",)))
|
||||
result["bid_1"] = _coerce_float(_find_first_value(output1, ("bidp1",)))
|
||||
result["orderbook_status"] = "OK"
|
||||
except Exception as exc:
|
||||
result["orderbook_status"] = "ERROR"
|
||||
result["orderbook_error"] = str(exc)
|
||||
|
||||
try:
|
||||
start = (dt.date.today() - dt.timedelta(days=10)).strftime("%Y%m%d")
|
||||
end = dt.date.today().strftime("%Y%m%d")
|
||||
short_sale = get_daily_short_sale(creds, code, start, end)
|
||||
result["short_sale_raw"] = short_sale
|
||||
rows = short_sale.get("output2") or []
|
||||
if rows:
|
||||
latest = rows[0]
|
||||
result["short_turnover_share"] = _coerce_float(latest.get("ssts_vol_rlim"))
|
||||
result["short_sale_status"] = "OK"
|
||||
except Exception as exc:
|
||||
result["short_sale_status"] = "ERROR"
|
||||
result["short_sale_error"] = str(exc)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _build_seed_rows(source_json: Path) -> list[dict[str, Any]]:
|
||||
payload = _load_json(source_json)
|
||||
data = payload.get("data") or {}
|
||||
core_satellite = {str(row.get("Ticker") or row.get("ticker") or ""): row for row in data.get("core_satellite", [])}
|
||||
sector_lookup = {str(row.get("Ticker") or row.get("ticker") or ""): row.get("Sector") for row in data.get("core_satellite", [])}
|
||||
rows: list[dict[str, Any]] = []
|
||||
for row in data.get("data_feed", []):
|
||||
ticker = str(row.get("Ticker") or row.get("ticker") or "").strip()
|
||||
if not ticker:
|
||||
continue
|
||||
merged = dict(row)
|
||||
core_row = core_satellite.get(ticker) or {}
|
||||
if core_row:
|
||||
for key, value in core_row.items():
|
||||
merged.setdefault(key, value)
|
||||
merged["Sector"] = merged.get("Sector") or sector_lookup.get(ticker)
|
||||
rows.append(merged)
|
||||
return rows
|
||||
|
||||
|
||||
def _collect_one(row: dict[str, Any], *, kis_account: str, include_naver: bool, include_live_kis: bool) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
ticker = str(row.get("Ticker") or row.get("ticker") or "").strip()
|
||||
name = str(row.get("Name") or row.get("name") or "").strip()
|
||||
sector = str(row.get("Sector") or row.get("sector") or "").strip() or None
|
||||
normalized = dict(row)
|
||||
provenance: dict[str, Any] = {
|
||||
"ticker": ticker,
|
||||
"name": name,
|
||||
"sector": sector,
|
||||
"source_priority": ["gathertradingdata_json"],
|
||||
}
|
||||
|
||||
if include_live_kis and ticker.isdigit() and len(ticker) == 6:
|
||||
kis = _normalize_kis_fields(ticker, kis_account)
|
||||
provenance["kis"] = kis
|
||||
normalized.update({k: v for k, v in kis.items() if k not in {"current_price_raw", "orderbook_raw", "short_sale_raw"}})
|
||||
if kis.get("status") == "OK":
|
||||
provenance["source_priority"].insert(0, "kis_open_api")
|
||||
|
||||
if include_naver and ticker.isdigit() and len(ticker) == 6:
|
||||
naver = _normalize_naver_price_history(ticker)
|
||||
provenance["naver"] = naver
|
||||
if naver.get("status") in {"OK", "DATA_MISSING"}:
|
||||
normalized.setdefault("relative_return_20d", naver.get("relative_return_20d"))
|
||||
normalized.setdefault("volume_ratio_5d", naver.get("volume_ratio_5d"))
|
||||
normalized.setdefault("naver_price_status", naver.get("status"))
|
||||
provenance["source_priority"].append("naver_finance")
|
||||
|
||||
normalized.setdefault("collection_as_of", _kst_now_iso())
|
||||
return normalized, provenance
|
||||
|
||||
|
||||
def collect_to_sqlite(
|
||||
*,
|
||||
input_json: Path,
|
||||
sqlite_db: Path,
|
||||
output_json: Path,
|
||||
kis_account: str,
|
||||
include_naver: bool = True,
|
||||
include_live_kis: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
run_id = uuid.uuid4().hex
|
||||
started_at = _kst_now_iso()
|
||||
upsert_collection_run(
|
||||
sqlite_db,
|
||||
CollectionRun(
|
||||
run_id=run_id,
|
||||
collector_name="kis_data_collection_v1",
|
||||
started_at=started_at,
|
||||
status="RUNNING",
|
||||
input_source=str(input_json),
|
||||
output_json_path=str(output_json),
|
||||
output_db_path=str(sqlite_db),
|
||||
notes="KIS-first CI collection",
|
||||
),
|
||||
)
|
||||
|
||||
seed_rows = _build_seed_rows(input_json)
|
||||
summary = {
|
||||
"formula_id": "KIS_DATA_COLLECTION_V1",
|
||||
"run_id": run_id,
|
||||
"started_at": started_at,
|
||||
"input_json": str(input_json),
|
||||
"sqlite_db": str(sqlite_db),
|
||||
"row_count": len(seed_rows),
|
||||
"source_counts": {},
|
||||
"errors": [],
|
||||
"rows": [],
|
||||
}
|
||||
|
||||
for row in seed_rows:
|
||||
ticker = str(row.get("Ticker") or row.get("ticker") or "").strip()
|
||||
if not ticker:
|
||||
continue
|
||||
try:
|
||||
normalized, provenance = _collect_one(row, kis_account=kis_account, include_naver=include_naver, include_live_kis=include_live_kis)
|
||||
source_counts = summary["source_counts"]
|
||||
for source_name in provenance.get("source_priority") or []:
|
||||
source_counts[source_name] = source_counts.get(source_name, 0) + 1
|
||||
upsert_collection_snapshot(
|
||||
sqlite_db,
|
||||
run_id=run_id,
|
||||
dataset_name="data_feed",
|
||||
ticker=ticker,
|
||||
name=str(normalized.get("Name") or normalized.get("name") or ""),
|
||||
sector=normalized.get("Sector"),
|
||||
as_of_date=str(normalized.get("Price_Date") or normalized.get("AsOfDate") or normalized.get("collection_as_of") or ""),
|
||||
source_priority=">".join(provenance.get("source_priority") or []),
|
||||
source_status="OK",
|
||||
payload=normalized,
|
||||
provenance=provenance,
|
||||
)
|
||||
summary["rows"].append(
|
||||
{
|
||||
"ticker": ticker,
|
||||
"name": normalized.get("Name") or normalized.get("name"),
|
||||
"sector": normalized.get("Sector"),
|
||||
"source_priority": provenance.get("source_priority"),
|
||||
"current_price": normalized.get("current_price"),
|
||||
"relative_return_20d": normalized.get("relative_return_20d"),
|
||||
"volume_ratio_5d": normalized.get("volume_ratio_5d"),
|
||||
}
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
error = {"ticker": ticker, "error": str(exc)}
|
||||
summary["errors"].append(error)
|
||||
append_collection_error(
|
||||
sqlite_db,
|
||||
run_id=run_id,
|
||||
source_name="collector",
|
||||
error_kind=type(exc).__name__,
|
||||
error_message=str(exc),
|
||||
ticker=ticker,
|
||||
payload=row,
|
||||
)
|
||||
|
||||
summary["finished_at"] = _kst_now_iso()
|
||||
summary["status"] = "PASS" if not summary["errors"] else "PASS_WITH_WARNINGS"
|
||||
output_json.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_json.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
upsert_collection_run(
|
||||
sqlite_db,
|
||||
CollectionRun(
|
||||
run_id=run_id,
|
||||
collector_name="kis_data_collection_v1",
|
||||
started_at=started_at,
|
||||
status=summary["status"],
|
||||
input_source=str(input_json),
|
||||
output_json_path=str(output_json),
|
||||
output_db_path=str(sqlite_db),
|
||||
notes="KIS-first CI collection",
|
||||
),
|
||||
finished_at=summary["finished_at"],
|
||||
)
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--input-json", type=Path, default=ROOT / "GatherTradingData.json")
|
||||
ap.add_argument("--sqlite-db", type=Path, default=ROOT / "outputs" / "kis_data_collection" / "kis_data_collection.db")
|
||||
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("--output-json", type=Path, default=ROOT / "Temp" / "kis_data_collection_v1.json")
|
||||
ap.add_argument("--kis-account", choices=["real", "mock"], default="real")
|
||||
ap.add_argument("--no-naver", action="store_true")
|
||||
ap.add_argument("--no-live-kis", action="store_true")
|
||||
args = ap.parse_args()
|
||||
|
||||
store_backend, store_location = normalize_store_spec(
|
||||
CollectionStoreSpec(
|
||||
backend=args.store_backend,
|
||||
location=args.store_location or args.sqlite_db,
|
||||
),
|
||||
ROOT,
|
||||
)
|
||||
if store_backend != "sqlite":
|
||||
raise SystemExit(
|
||||
"현재 실행 backend는 sqlite만 지원합니다. "
|
||||
"하지만 collector는 이미 backend contract로 분리되어 있어 "
|
||||
"후속 PostgreSQL 구현을 같은 호출 지점에 붙일 수 있습니다."
|
||||
)
|
||||
|
||||
summary = collect_to_sqlite(
|
||||
input_json=args.input_json,
|
||||
sqlite_db=Path(store_location),
|
||||
output_json=args.output_json,
|
||||
kis_account=args.kis_account,
|
||||
include_naver=not args.no_naver,
|
||||
include_live_kis=not args.no_live_kis,
|
||||
)
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
return 0 if summary.get("status") == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,377 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import date, timedelta
|
||||
from typing import Any
|
||||
|
||||
# 매도 결정에 동원하는 5개 독립 팩터군. 단일 팩터의 임계값 돌파만으로는 행동을
|
||||
# 트리거하지 않는다 — 최소 CONFLUENCE_MIN개 팩터군이 동일 방향으로 합의해야
|
||||
# SELL/ADD 확신도가 성립한다. (기계적 단일 트리거 매도 금지 원칙)
|
||||
FACTOR_FAMILIES: tuple[str, ...] = (
|
||||
"macro_pressure",
|
||||
"fundamental_trajectory",
|
||||
"short_interest_pressure",
|
||||
"microstructure_pressure",
|
||||
"liquidity_rotation_risk",
|
||||
)
|
||||
CONFLUENCE_MIN = 3
|
||||
EVENT_PRE_GUARD_DAYS = 5 # macro_event_synchronizer_v2.event_hold_gate와 동일 — HIGH 이벤트 5일 전
|
||||
EVENT_POST_GUARD_DAYS = 2 # 이벤트 후 2일 변동성 소화 구간
|
||||
|
||||
# 금리국면별 시장 성격: 금리 상승기=실적장세(펀더멘털/수출입 실적이 가격을 주도),
|
||||
# 금리 보합·하락기=기술장세(수급·미시구조가 가격을 주도). 동일한 5팩터라도
|
||||
# 국면에 따라 가중치를 달리 줘야 confluence가 의미를 갖는다.
|
||||
REGIME_FLAT_WEIGHTS: dict[str, float] = {family: 1.0 for family in FACTOR_FAMILIES}
|
||||
REGIME_WEIGHT_TABLE: dict[str, dict[str, float]] = {
|
||||
"PERFORMANCE_MARKET": { # 금리 상승기 — 실적/수출입 펀더멘털 가중 상향
|
||||
"macro_pressure": 1.2,
|
||||
"fundamental_trajectory": 1.8,
|
||||
"short_interest_pressure": 1.0,
|
||||
"microstructure_pressure": 0.5,
|
||||
"liquidity_rotation_risk": 1.0,
|
||||
},
|
||||
"TECHNICAL_MARKET": { # 금리 보합·하락기 — 수급/미시구조 가중 상향
|
||||
"macro_pressure": 0.8,
|
||||
"fundamental_trajectory": 0.8,
|
||||
"short_interest_pressure": 1.3,
|
||||
"microstructure_pressure": 1.6,
|
||||
"liquidity_rotation_risk": 1.3,
|
||||
},
|
||||
"NEUTRAL": REGIME_FLAT_WEIGHTS,
|
||||
}
|
||||
|
||||
|
||||
def classify_market_regime(rate_trend: str | None) -> str:
|
||||
"""금리 추세 문자열(RISING/FLAT/FALLING)을 실적장세/기술장세로 분류.
|
||||
|
||||
RISING → PERFORMANCE_MARKET(실적장세): 금리 상승기엔 유동성보다 실적/펀더멘털이
|
||||
가격을 결정. FLAT/FALLING → TECHNICAL_MARKET(기술장세): 유동성이 풍부해 수급·
|
||||
미시구조·테마성 모멘텀이 가격을 주도. 입력 결측 시 NEUTRAL(가중치 변화 없음).
|
||||
"""
|
||||
trend = str(rate_trend or "").upper()
|
||||
if trend == "RISING":
|
||||
return "PERFORMANCE_MARKET"
|
||||
if trend in {"FLAT", "FALLING"}:
|
||||
return "TECHNICAL_MARKET"
|
||||
return "NEUTRAL"
|
||||
|
||||
|
||||
def _finite(value: Any) -> bool:
|
||||
return isinstance(value, (int, float)) and math.isfinite(float(value))
|
||||
|
||||
|
||||
def compute_short_interest_composite(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""SHORT_INTEREST_RISK_GAUGE_V1.
|
||||
|
||||
5요소: 공매도잔고율 변화, 공매도거래비중, 상대수익률(섹터/지수 대비),
|
||||
거래량 이상, 실적전망. 잔고율 단독으로는 매도 근거가 약함(현대로템형) —
|
||||
잔고율이 낮을 때는 거래비중·상대수익률 가중치를 자동 상향한다.
|
||||
"""
|
||||
missing: list[str] = []
|
||||
|
||||
short_balance_ratio = ctx.get("short_balance_ratio") # %, 현재 잔고율
|
||||
short_balance_ratio_chg_20d = ctx.get("short_balance_ratio_chg_20d") # %p, 20일 변화
|
||||
short_turnover_share = ctx.get("short_turnover_share") # 당일 거래 중 공매도 비중 %
|
||||
relative_return_20d = ctx.get("relative_return_20d") # 종목수익률 - 섹터(or지수)수익률, %p
|
||||
volume_ratio_5d = ctx.get("volume_ratio_5d") # 5일평균거래량 대비 비율
|
||||
earnings_outlook = str(ctx.get("earnings_outlook") or "").upper() # IMPROVING|STABLE|DETERIORATING|UNKNOWN
|
||||
|
||||
for name, value in (
|
||||
("short_balance_ratio", short_balance_ratio),
|
||||
("short_turnover_share", short_turnover_share),
|
||||
("relative_return_20d", relative_return_20d),
|
||||
):
|
||||
if not _finite(value):
|
||||
missing.append(name)
|
||||
|
||||
if missing:
|
||||
return {
|
||||
"short_interest_pressure": None,
|
||||
"status": "DATA_MISSING",
|
||||
"missing_inputs": missing,
|
||||
"note": "잔고율/거래비중/상대수익률 중 결측 — 공매도 합성 점수를 산출하지 않음(추정 금지)",
|
||||
}
|
||||
|
||||
low_balance_regime = float(short_balance_ratio) < 1.0 # 잔고율 1% 미만이면 '낮은 잔고율' 취급(현대로템형)
|
||||
|
||||
# 잔고율 추세: 상승=매도근거 강화, 하락=매도근거 약화(혹은 매수근거)
|
||||
balance_trend_signal = 0.0
|
||||
if _finite(short_balance_ratio_chg_20d):
|
||||
balance_trend_signal = max(-1.0, min(1.0, float(short_balance_ratio_chg_20d) / 1.5))
|
||||
|
||||
turnover_signal = max(-1.0, min(1.0, (float(short_turnover_share) - 8.0) / 12.0)) # 8% 기준선
|
||||
relative_return_signal = max(-1.0, min(1.0, -float(relative_return_20d) / 10.0)) # 상대 약세일수록 +
|
||||
volume_signal = 0.0
|
||||
if _finite(volume_ratio_5d):
|
||||
volume_signal = max(-1.0, min(1.0, (float(volume_ratio_5d) - 1.0)))
|
||||
|
||||
outlook_signal = {
|
||||
"IMPROVING": -0.6,
|
||||
"STABLE": 0.0,
|
||||
"DETERIORATING": 0.7,
|
||||
}.get(earnings_outlook, 0.0)
|
||||
|
||||
if low_balance_regime:
|
||||
# 잔고율 자체는 약한 근거 — 거래비중·상대수익률 가중치 상향, 잔고율추세 가중치 하향
|
||||
weights = {"balance": 0.10, "turnover": 0.30, "relative": 0.30, "volume": 0.10, "outlook": 0.20}
|
||||
else:
|
||||
weights = {"balance": 0.30, "turnover": 0.20, "relative": 0.20, "volume": 0.10, "outlook": 0.20}
|
||||
|
||||
pressure = (
|
||||
balance_trend_signal * weights["balance"]
|
||||
+ turnover_signal * weights["turnover"]
|
||||
+ relative_return_signal * weights["relative"]
|
||||
+ volume_signal * weights["volume"]
|
||||
+ outlook_signal * weights["outlook"]
|
||||
)
|
||||
pressure = max(-1.0, min(1.0, pressure))
|
||||
|
||||
label = "ELEVATED_SHORT_PRESSURE" if pressure >= 0.5 else "WATCH" if pressure >= 0.2 else \
|
||||
"SHORT_COVERING_SUPPORTIVE" if pressure <= -0.5 else "NEUTRAL"
|
||||
|
||||
return {
|
||||
"short_interest_pressure": round(pressure, 4),
|
||||
"status": "OK",
|
||||
"low_balance_regime": low_balance_regime,
|
||||
"label": label,
|
||||
"components": {
|
||||
"balance_trend_signal": round(balance_trend_signal, 4),
|
||||
"turnover_signal": round(turnover_signal, 4),
|
||||
"relative_return_signal": round(relative_return_signal, 4),
|
||||
"volume_signal": round(volume_signal, 4),
|
||||
"outlook_signal": outlook_signal,
|
||||
},
|
||||
"weights_used": weights,
|
||||
}
|
||||
|
||||
|
||||
def compute_microstructure_pressure_from_orderbook(orderbook_output1: dict[str, Any]) -> dict[str, Any]:
|
||||
"""MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1.
|
||||
|
||||
KIS Open API FHKST01010200(주식현재가 호가/예상체결) output1의 10단계 호가 잔량을
|
||||
-1(매수우위/지지)~+1(매도우위/압력)로 계량화. 실측 확인된 필드명(2026-06-21,
|
||||
005930 라이브 호출): total_askp_rsqn, total_bidp_rsqn(10단계 합계 잔량).
|
||||
이 점수는 전략 방향 결정에는 쓰지 않고 confluence가 성립한 이후의 '집행 타이밍'
|
||||
보조로만 사용한다(spec/exit/qualitative_sell_strategy_v1.yaml:factor_families.
|
||||
microstructure_pressure 참조).
|
||||
"""
|
||||
total_askp = orderbook_output1.get("total_askp_rsqn")
|
||||
total_bidp = orderbook_output1.get("total_bidp_rsqn")
|
||||
try:
|
||||
total_askp = float(total_askp)
|
||||
total_bidp = float(total_bidp)
|
||||
except (TypeError, ValueError):
|
||||
return {"microstructure_pressure": None, "status": "DATA_MISSING"}
|
||||
|
||||
denom = total_askp + total_bidp
|
||||
if denom <= 0:
|
||||
return {"microstructure_pressure": None, "status": "DATA_MISSING"}
|
||||
|
||||
pressure = max(-1.0, min(1.0, (total_askp - total_bidp) / denom))
|
||||
return {
|
||||
"microstructure_pressure": round(pressure, 4),
|
||||
"status": "OK",
|
||||
"total_askp_rsqn": total_askp,
|
||||
"total_bidp_rsqn": total_bidp,
|
||||
}
|
||||
|
||||
|
||||
def _event_review_window(
|
||||
today: date,
|
||||
pressure_sign: int,
|
||||
next_earnings_date: date | None,
|
||||
next_macro_event_date: date | None,
|
||||
macro_event_impact: str | None,
|
||||
earnings_outlook: str,
|
||||
) -> dict[str, Any]:
|
||||
"""캘린더 기반 검토 구간 산출 — 임의 날짜 고정이 아니라 실제 이벤트 일정에서 역산."""
|
||||
candidates: list[tuple[date, str]] = []
|
||||
|
||||
if next_earnings_date is not None:
|
||||
if pressure_sign < 0 and earnings_outlook == "DETERIORATING":
|
||||
# 실적 악화 전망 + 매도압력 → 실적발표 전 정리(서프라이즈 리스크 회피)
|
||||
candidates.append((next_earnings_date - timedelta(days=EVENT_PRE_GUARD_DAYS), "PRE_EARNINGS_EXIT_BEFORE_SURPRISE_RISK"))
|
||||
elif pressure_sign < 0 and earnings_outlook in {"IMPROVING", "STABLE"}:
|
||||
# 단기 기술적 매도압력이지만 실적전망은 양호 → 발표 직전 매도는 가치훼손, 발표 이후로 연기
|
||||
candidates.append((next_earnings_date + timedelta(days=EVENT_POST_GUARD_DAYS), "DEFER_TO_POST_EARNINGS_AVOID_PREMATURE_EXIT"))
|
||||
elif pressure_sign > 0:
|
||||
# 추가매수/보유 신호 — 발표 변동성 통과 후 확신 재평가
|
||||
candidates.append((next_earnings_date + timedelta(days=EVENT_POST_GUARD_DAYS), "REASSESS_AFTER_EARNINGS_CONFIRM"))
|
||||
|
||||
if next_macro_event_date is not None and str(macro_event_impact or "").upper() in {"HIGH", "VERY_HIGH"}:
|
||||
if pressure_sign < 0:
|
||||
candidates.append((next_macro_event_date - timedelta(days=EVENT_PRE_GUARD_DAYS), "PRE_MACRO_EVENT_DERISK"))
|
||||
else:
|
||||
candidates.append((next_macro_event_date + timedelta(days=EVENT_POST_GUARD_DAYS), "POST_MACRO_EVENT_CONFIRM"))
|
||||
|
||||
if not candidates:
|
||||
return {
|
||||
"review_window_start": today.isoformat(),
|
||||
"review_window_end": (today + timedelta(days=10)).isoformat(),
|
||||
"window_basis": "NO_SCHEDULED_EVENT_DEFAULT_10D_REVIEW",
|
||||
}
|
||||
|
||||
earliest = min(candidates, key=lambda item: item[0])
|
||||
window_start = max(today, earliest[0] - timedelta(days=2))
|
||||
window_end = earliest[0] + timedelta(days=2)
|
||||
return {
|
||||
"review_window_start": window_start.isoformat(),
|
||||
"review_window_end": window_end.isoformat(),
|
||||
"window_basis": earliest[1],
|
||||
}
|
||||
|
||||
|
||||
def compute_qualitative_sell_strategy(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""QUALITATIVE_SELL_STRATEGY_V1.
|
||||
|
||||
매크로/실적/펀더멘털/공매도수급/호가미시구조/대내외(IPO·로테이션) 5개
|
||||
독립 팩터군의 합의(confluence)로만 행동을 생성한다. 현금부족 사유는
|
||||
입력에서 의도적으로 배제(cash_shortfall_excluded=True) — 가치보존이
|
||||
유일한 목적 함수.
|
||||
"""
|
||||
today_raw = ctx.get("today")
|
||||
today = today_raw if isinstance(today_raw, date) else date.today()
|
||||
|
||||
factor_values: dict[str, float | None] = {}
|
||||
missing_factors: list[str] = []
|
||||
for family in FACTOR_FAMILIES:
|
||||
value = ctx.get(family)
|
||||
if _finite(value):
|
||||
factor_values[family] = max(-1.0, min(1.0, float(value)))
|
||||
else:
|
||||
factor_values[family] = None
|
||||
missing_factors.append(family)
|
||||
|
||||
available = {k: v for k, v in factor_values.items() if v is not None}
|
||||
if len(available) < CONFLUENCE_MIN:
|
||||
return {
|
||||
"action": "INSUFFICIENT_DATA_NO_ACTION",
|
||||
"conviction": "NONE",
|
||||
"available_factors": list(available.keys()),
|
||||
"missing_factors": missing_factors,
|
||||
"rationale": "5개 팩터군 중 confluence 판정에 필요한 최소 데이터가 부족 — 추정으로 행동 생성 금지",
|
||||
"cash_shortfall_excluded": True,
|
||||
"mechanical_sell_prohibited": True,
|
||||
}
|
||||
|
||||
# 부호 규약: 모든 팩터군은 +1(매도압력 최대) ~ -1(보유/추가 지지 최대) 동일 스케일.
|
||||
# short_interest_pressure도 동일 — ELEVATED_SHORT_PRESSURE(+) / SHORT_COVERING_SUPPORTIVE(-).
|
||||
# confluence 합의 카운트는 국면 가중치와 무관하게 원시 방향성으로만 판정한다
|
||||
# (가중치는 행동 '강도'에만 영향 — 합의 성립 여부 자체를 왜곡하지 않는다).
|
||||
sell_agree = [k for k, v in available.items() if v >= 0.30]
|
||||
hold_add_agree = [k for k, v in available.items() if v <= -0.30]
|
||||
|
||||
market_regime = classify_market_regime(ctx.get("rate_trend")) if "market_regime" not in ctx else str(ctx.get("market_regime") or "NEUTRAL").upper()
|
||||
regime_weights = REGIME_WEIGHT_TABLE.get(market_regime, REGIME_FLAT_WEIGHTS)
|
||||
weighted_sum = sum(available[k] * regime_weights.get(k, 1.0) for k in available)
|
||||
weight_total = sum(regime_weights.get(k, 1.0) for k in available)
|
||||
composite_score = weighted_sum / weight_total if weight_total else 0.0
|
||||
|
||||
earnings_outlook = str(ctx.get("earnings_outlook") or "STABLE").upper()
|
||||
next_earnings_date = ctx.get("next_earnings_date") if isinstance(ctx.get("next_earnings_date"), date) else None
|
||||
next_macro_event_date = ctx.get("next_macro_event_date") if isinstance(ctx.get("next_macro_event_date"), date) else None
|
||||
macro_event_impact = ctx.get("macro_event_impact")
|
||||
|
||||
if len(sell_agree) >= CONFLUENCE_MIN:
|
||||
conviction = "HIGH" if len(sell_agree) >= 4 else "MEDIUM"
|
||||
action = "EXIT_REVIEW_FULL" if composite_score >= 0.6 else "TRIM_REVIEW_PARTIAL"
|
||||
pressure_sign = -1
|
||||
rationale = f"매도압력 합의({len(sell_agree)}/{len(available)} 팩터군 매도방향 합치): " + ", ".join(sell_agree)
|
||||
elif len(hold_add_agree) >= CONFLUENCE_MIN:
|
||||
conviction = "HIGH" if len(hold_add_agree) >= 4 else "MEDIUM"
|
||||
action = "HOLD_ADD_CONVICTION"
|
||||
pressure_sign = 1
|
||||
rationale = f"보유/추가 근거 합의({len(hold_add_agree)}/{len(available)} 팩터군 지지방향 합치): " + ", ".join(hold_add_agree)
|
||||
else:
|
||||
conviction = "LOW"
|
||||
action = "HOLD_NO_CONFLUENCE"
|
||||
pressure_sign = 0
|
||||
rationale = "팩터군 간 합의 미달 — 단일/소수 팩터의 임계값 돌파만으로는 매도 트리거 금지"
|
||||
|
||||
window = _event_review_window(
|
||||
today=today,
|
||||
pressure_sign=pressure_sign,
|
||||
next_earnings_date=next_earnings_date,
|
||||
next_macro_event_date=next_macro_event_date,
|
||||
macro_event_impact=macro_event_impact,
|
||||
earnings_outlook=earnings_outlook,
|
||||
) if pressure_sign != 0 else None
|
||||
|
||||
return {
|
||||
"action": action,
|
||||
"conviction": conviction,
|
||||
"market_regime": market_regime,
|
||||
"composite_score": round(composite_score, 4),
|
||||
"sell_agreeing_factors": sell_agree,
|
||||
"hold_add_agreeing_factors": hold_add_agree,
|
||||
"missing_factors": missing_factors,
|
||||
"review_window": window,
|
||||
"rationale": rationale,
|
||||
"cash_shortfall_excluded": True,
|
||||
"mechanical_sell_prohibited": True,
|
||||
}
|
||||
|
||||
|
||||
def compute_satellite_candidate_score(ctx: dict[str, Any]) -> dict[str, Any]:
|
||||
"""SATELLITE_CANDIDATE_SCORE_V1.
|
||||
|
||||
미보유 유니버스 종목을 섹터 수출입 전망(sector_export_trend) + 펀더멘털
|
||||
추세 + 국면적합도로 평가해 WATCH/BUY_CANDIDATE/AVOID를 산출한다. 보유종목
|
||||
매도판단(compute_qualitative_sell_strategy)과 동일한 부호 규약을 쓰지 않고
|
||||
별도 -1(약세)~+1(강세) 매력도 스케일을 쓴다 — 매수후보 평가와 매도판단은
|
||||
목적함수가 다르므로 동일 점수를 재사용하지 않는다.
|
||||
"""
|
||||
sector_export_trend = ctx.get("sector_export_trend") # %, 섹터 수출 YoY/MoM 추세
|
||||
fundamental_trajectory = ctx.get("fundamental_trajectory") # -1(악화)~+1(개선), 매도엔진과 동일 정의역이나 부호 반대 해석 주의
|
||||
relative_return_20d = ctx.get("relative_return_20d")
|
||||
market_regime = str(ctx.get("market_regime") or classify_market_regime(ctx.get("rate_trend"))).upper()
|
||||
|
||||
missing = [name for name, value in (
|
||||
("sector_export_trend", sector_export_trend),
|
||||
("fundamental_trajectory", fundamental_trajectory),
|
||||
) if not _finite(value)]
|
||||
if missing:
|
||||
return {
|
||||
"satellite_action": "INSUFFICIENT_DATA_NO_ACTION",
|
||||
"missing_inputs": missing,
|
||||
"market_regime": market_regime,
|
||||
}
|
||||
|
||||
export_signal = max(-1.0, min(1.0, float(sector_export_trend) / 10.0))
|
||||
fundamental_signal = max(-1.0, min(1.0, -float(fundamental_trajectory))) # 매도엔진 부호(+)=악화 -> 매력도는 반전
|
||||
relative_signal = max(-1.0, min(1.0, float(relative_return_20d) / 10.0)) if _finite(relative_return_20d) else 0.0
|
||||
|
||||
if market_regime == "PERFORMANCE_MARKET":
|
||||
weights = {"export": 0.45, "fundamental": 0.40, "relative": 0.15}
|
||||
elif market_regime == "TECHNICAL_MARKET":
|
||||
weights = {"export": 0.20, "fundamental": 0.25, "relative": 0.55}
|
||||
else:
|
||||
weights = {"export": 0.34, "fundamental": 0.33, "relative": 0.33}
|
||||
|
||||
attractiveness = (
|
||||
export_signal * weights["export"]
|
||||
+ fundamental_signal * weights["fundamental"]
|
||||
+ relative_signal * weights["relative"]
|
||||
)
|
||||
attractiveness = max(-1.0, min(1.0, attractiveness))
|
||||
|
||||
if attractiveness >= 0.5:
|
||||
satellite_action = "BUY_CANDIDATE"
|
||||
elif attractiveness >= 0.2:
|
||||
satellite_action = "WATCH"
|
||||
elif attractiveness <= -0.4:
|
||||
satellite_action = "AVOID"
|
||||
else:
|
||||
satellite_action = "NEUTRAL_NO_EDGE"
|
||||
|
||||
return {
|
||||
"satellite_action": satellite_action,
|
||||
"attractiveness_score": round(attractiveness, 4),
|
||||
"market_regime": market_regime,
|
||||
"components": {
|
||||
"export_signal": round(export_signal, 4),
|
||||
"fundamental_signal": round(fundamental_signal, 4),
|
||||
"relative_signal": round(relative_signal, 4),
|
||||
},
|
||||
"weights_used": weights,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,993 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
DEFAULT_DB = ROOT / "outputs" / "snapshot_admin" / "snapshot_admin.db"
|
||||
DEFAULT_SEED_JSON = ROOT / "GatherTradingData.json"
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
|
||||
SETTINGS_TABLE = "settings"
|
||||
SNAPSHOT_TABLE = "account_snapshot"
|
||||
CHANGE_LOG_TABLE = "workspace_change_log"
|
||||
APPROVAL_TABLE = "workspace_approval_v2"
|
||||
LOCK_TABLE = "workspace_lock"
|
||||
|
||||
ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS = [
|
||||
"captured_at",
|
||||
"account",
|
||||
"account_type",
|
||||
"ticker",
|
||||
"name",
|
||||
"holding_quantity",
|
||||
"available_quantity",
|
||||
"average_cost",
|
||||
"total_cost",
|
||||
"current_price",
|
||||
"market_value",
|
||||
"profit_loss",
|
||||
"return_pct",
|
||||
"immediate_cash",
|
||||
"settlement_cash_d2",
|
||||
"available_cash",
|
||||
"open_order_amount",
|
||||
"monthly_contribution_limit",
|
||||
"monthly_contribution_used",
|
||||
"parse_status",
|
||||
"user_confirmed",
|
||||
"stop_price",
|
||||
"highest_price_since_entry",
|
||||
"entry_date",
|
||||
"entry_stage",
|
||||
"position_type",
|
||||
"last_updated",
|
||||
]
|
||||
|
||||
ALLOWED_PARSE_STATUS = {
|
||||
"CAPTURE_READ_OK",
|
||||
"CAPTURE_READ_FAILED",
|
||||
"CAPTURE_PROVIDED_BUT_NOT_HOLDINGS",
|
||||
"NOT_PROVIDED",
|
||||
}
|
||||
|
||||
SETTINGS_SPEC_PATH = ROOT / "spec" / "18_settings_contract.yaml"
|
||||
ACCOUNT_SNAPSHOT_SPEC_PATH = ROOT / "spec" / "15_account_snapshot_contract.yaml"
|
||||
|
||||
|
||||
def now_kst_iso() -> str:
|
||||
return datetime.now(tz=KST).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def parse_scalar(value: str) -> Any:
|
||||
text = value.strip()
|
||||
if text == "":
|
||||
return ""
|
||||
if text.lower() in {"null", "none"}:
|
||||
return None
|
||||
if text.lower() in {"true", "false"}:
|
||||
return text.lower() == "true"
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def _json_dump(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _json_load(text: str) -> Any:
|
||||
try:
|
||||
return json.loads(text)
|
||||
except Exception:
|
||||
return text
|
||||
|
||||
|
||||
def normalize_db_path(db_path: Path | str | None = None) -> Path:
|
||||
path = Path(db_path) if db_path else DEFAULT_DB
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def open_connection(db_path: Path | str | None = None) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(normalize_db_path(db_path))
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
conn.execute("PRAGMA journal_mode = WAL")
|
||||
return conn
|
||||
|
||||
|
||||
def ensure_schema(conn: sqlite3.Connection) -> None:
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {SETTINGS_TABLE} (
|
||||
ordinal INTEGER NOT NULL,
|
||||
key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {SNAPSHOT_TABLE} (
|
||||
ordinal INTEGER NOT NULL,
|
||||
row_json TEXT NOT NULL,
|
||||
captured_at TEXT NOT NULL DEFAULT '',
|
||||
account TEXT NOT NULL DEFAULT '',
|
||||
account_type TEXT NOT NULL DEFAULT '',
|
||||
ticker TEXT NOT NULL DEFAULT '',
|
||||
name TEXT NOT NULL DEFAULT '',
|
||||
parse_status TEXT NOT NULL DEFAULT '',
|
||||
user_confirmed TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_{SNAPSHOT_TABLE}_captured_at ON {SNAPSHOT_TABLE}(captured_at)"
|
||||
)
|
||||
conn.execute(
|
||||
f"CREATE INDEX IF NOT EXISTS idx_{SNAPSHOT_TABLE}_ticker ON {SNAPSHOT_TABLE}(ticker)"
|
||||
)
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS workspace_meta (key TEXT PRIMARY KEY, value_json TEXT NOT NULL)"
|
||||
)
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {CHANGE_LOG_TABLE} (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
domain TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL DEFAULT '',
|
||||
actor TEXT NOT NULL DEFAULT 'system',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
before_json TEXT NOT NULL DEFAULT 'null',
|
||||
after_json TEXT NOT NULL DEFAULT 'null',
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {APPROVAL_TABLE} (
|
||||
domain TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL DEFAULT '*',
|
||||
status TEXT NOT NULL,
|
||||
approved_by TEXT NOT NULL DEFAULT '',
|
||||
approved_at TEXT NOT NULL DEFAULT '',
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (domain, target_ref)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
f"""
|
||||
CREATE TABLE IF NOT EXISTS {LOCK_TABLE} (
|
||||
domain TEXT NOT NULL,
|
||||
target_ref TEXT NOT NULL DEFAULT '',
|
||||
locked_by TEXT NOT NULL DEFAULT '',
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
locked_at TEXT NOT NULL,
|
||||
PRIMARY KEY (domain, target_ref)
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
|
||||
def _normalize_settings_rows(settings: Any) -> list[dict[str, Any]]:
|
||||
if isinstance(settings, list):
|
||||
rows: list[dict[str, Any]] = []
|
||||
for idx, item in enumerate(settings, start=1):
|
||||
if isinstance(item, dict) and "key" in item:
|
||||
rows.append(
|
||||
{
|
||||
"ordinal": int(item.get("ordinal") or idx),
|
||||
"key": str(item.get("key") or ""),
|
||||
"value": item.get("value", ""),
|
||||
"note": str(item.get("note") or ""),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
if isinstance(settings, dict):
|
||||
rows = []
|
||||
for idx, (key, value) in enumerate(settings.items(), start=1):
|
||||
rows.append({"ordinal": idx, "key": str(key), "value": value, "note": ""})
|
||||
return rows
|
||||
return []
|
||||
|
||||
|
||||
def _normalize_snapshot_rows(rows: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(rows, list):
|
||||
return []
|
||||
normalized: list[dict[str, Any]] = []
|
||||
for idx, item in enumerate(rows, start=1):
|
||||
if isinstance(item, dict):
|
||||
row = dict(item)
|
||||
row.setdefault("ordinal", idx)
|
||||
normalized.append(row)
|
||||
return normalized
|
||||
|
||||
|
||||
def seed_payload_from_json(json_path: Path | str) -> dict[str, Any]:
|
||||
payload = json.loads(Path(json_path).read_text(encoding="utf-8"))
|
||||
data = payload.get("data") if isinstance(payload, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
data = payload if isinstance(payload, dict) else {}
|
||||
settings = _normalize_settings_rows(data.get("settings"))
|
||||
account_snapshot = _normalize_snapshot_rows(data.get("account_snapshot"))
|
||||
return {
|
||||
"meta": payload.get("meta") if isinstance(payload, dict) else {},
|
||||
"settings": settings,
|
||||
"account_snapshot": account_snapshot,
|
||||
}
|
||||
|
||||
|
||||
def replace_settings(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
|
||||
ensure_schema(conn)
|
||||
errors = validate_settings_rows(rows)
|
||||
if errors:
|
||||
raise ValueError("; ".join(errors))
|
||||
old_rows = load_settings_rows_from_conn(conn)
|
||||
conn.execute(f"DELETE FROM {SETTINGS_TABLE}")
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
key = str(row.get("key") or "").strip()
|
||||
if not key:
|
||||
continue
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {SETTINGS_TABLE} (ordinal, key, value_json, note, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
int(row.get("ordinal") or idx),
|
||||
key,
|
||||
_json_dump(row.get("value", "")),
|
||||
str(row.get("note") or ""),
|
||||
now_kst_iso(),
|
||||
),
|
||||
)
|
||||
record_change_log(
|
||||
conn,
|
||||
domain=SETTINGS_TABLE,
|
||||
action="replace",
|
||||
before_json=old_rows,
|
||||
after_json=rows,
|
||||
target_ref="*",
|
||||
note="settings replace",
|
||||
)
|
||||
set_approval(conn, SETTINGS_TABLE, "PENDING", note="settings updated")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def replace_account_snapshot(conn: sqlite3.Connection, rows: list[dict[str, Any]]) -> None:
|
||||
ensure_schema(conn)
|
||||
errors = validate_account_snapshot_rows(rows)
|
||||
if errors:
|
||||
raise ValueError("; ".join(errors))
|
||||
old_rows = load_account_snapshot_rows_from_conn(conn)
|
||||
conn.execute(f"DELETE FROM {SNAPSHOT_TABLE}")
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
normalized = dict(row)
|
||||
ordinal = int(normalized.pop("ordinal", idx) or idx)
|
||||
captured_at = str(normalized.get("captured_at") or "")
|
||||
account = str(normalized.get("account") or "")
|
||||
account_type = str(normalized.get("account_type") or "")
|
||||
ticker = str(normalized.get("ticker") or "")
|
||||
name = str(normalized.get("name") or "")
|
||||
parse_status = str(normalized.get("parse_status") or "")
|
||||
user_confirmed = str(normalized.get("user_confirmed") or "")
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {SNAPSHOT_TABLE} (
|
||||
ordinal, row_json, captured_at, account, account_type, ticker, name,
|
||||
parse_status, user_confirmed, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
ordinal,
|
||||
_json_dump(normalized),
|
||||
captured_at,
|
||||
account,
|
||||
account_type,
|
||||
ticker,
|
||||
name,
|
||||
parse_status,
|
||||
user_confirmed,
|
||||
now_kst_iso(),
|
||||
),
|
||||
)
|
||||
record_change_log(
|
||||
conn,
|
||||
domain=SNAPSHOT_TABLE,
|
||||
action="replace",
|
||||
before_json=old_rows,
|
||||
after_json=rows,
|
||||
target_ref="*",
|
||||
note="account_snapshot replace",
|
||||
)
|
||||
set_approval(conn, SNAPSHOT_TABLE, "PENDING", note="account_snapshot updated")
|
||||
conn.commit()
|
||||
|
||||
|
||||
def import_seed_json(db_path: Path | str | None, json_path: Path | str) -> dict[str, Any]:
|
||||
payload = seed_payload_from_json(json_path)
|
||||
with open_connection(db_path) as conn:
|
||||
replace_settings(conn, payload["settings"])
|
||||
replace_account_snapshot(conn, payload["account_snapshot"])
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO workspace_meta(key, value_json) VALUES (?, ?)",
|
||||
("seed_json_path", _json_dump(str(Path(json_path).resolve()))),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO workspace_meta(key, value_json) VALUES (?, ?)",
|
||||
("seeded_at", _json_dump(now_kst_iso())),
|
||||
)
|
||||
conn.commit()
|
||||
return summarize_workspace(db_path)
|
||||
|
||||
|
||||
def load_settings_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
|
||||
with open_connection(db_path) as conn:
|
||||
return load_settings_rows_from_conn(conn)
|
||||
|
||||
|
||||
def load_settings_rows_from_conn(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
ensure_schema(conn)
|
||||
rows = conn.execute(
|
||||
f"SELECT ordinal, key, value_json, note, updated_at FROM {SETTINGS_TABLE} ORDER BY ordinal ASC, key ASC"
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"ordinal": int(row["ordinal"]),
|
||||
"key": row["key"],
|
||||
"value": _json_load(row["value_json"]),
|
||||
"note": row["note"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
def load_account_snapshot_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
|
||||
with open_connection(db_path) as conn:
|
||||
return load_account_snapshot_rows_from_conn(conn)
|
||||
|
||||
|
||||
def load_account_snapshot_rows_from_conn(conn: sqlite3.Connection) -> list[dict[str, Any]]:
|
||||
ensure_schema(conn)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT ordinal, row_json, captured_at, account, account_type, ticker, name,
|
||||
parse_status, user_confirmed, updated_at
|
||||
FROM {SNAPSHOT_TABLE}
|
||||
ORDER BY ordinal ASC
|
||||
"""
|
||||
).fetchall()
|
||||
loaded: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
payload = _json_load(row["row_json"])
|
||||
item = payload if isinstance(payload, dict) else {}
|
||||
item.setdefault("captured_at", row["captured_at"])
|
||||
item.setdefault("account", row["account"])
|
||||
item.setdefault("account_type", row["account_type"])
|
||||
item.setdefault("ticker", row["ticker"])
|
||||
item.setdefault("name", row["name"])
|
||||
item.setdefault("parse_status", row["parse_status"])
|
||||
item.setdefault("user_confirmed", row["user_confirmed"])
|
||||
item["_ordinal"] = int(row["ordinal"])
|
||||
item["_updated_at"] = row["updated_at"]
|
||||
loaded.append(item)
|
||||
return loaded
|
||||
|
||||
|
||||
def export_payload(db_path: Path | str | None = None) -> dict[str, Any]:
|
||||
settings_rows = load_settings_rows(db_path)
|
||||
settings = {row["key"]: row["value"] for row in settings_rows}
|
||||
account_snapshot = load_account_snapshot_rows(db_path)
|
||||
return {
|
||||
"meta": {
|
||||
"generated_at": now_kst_iso(),
|
||||
"source_db": str(normalize_db_path(db_path)),
|
||||
},
|
||||
"data": {
|
||||
"settings": settings,
|
||||
"account_snapshot": account_snapshot,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def write_export_json(db_path: Path | str | None, output_path: Path | str) -> Path:
|
||||
payload = export_payload(db_path)
|
||||
output = Path(output_path)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return output
|
||||
|
||||
|
||||
def load_meta(db_path: Path | str | None = None) -> dict[str, Any]:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
rows = conn.execute("SELECT key, value_json FROM workspace_meta ORDER BY key ASC").fetchall()
|
||||
return {row["key"]: _json_load(row["value_json"]) for row in rows}
|
||||
|
||||
|
||||
def record_change_log(
|
||||
conn: sqlite3.Connection,
|
||||
*,
|
||||
domain: str,
|
||||
action: str,
|
||||
before_json: Any,
|
||||
after_json: Any,
|
||||
target_ref: str = "",
|
||||
actor: str = "ui",
|
||||
note: str = "",
|
||||
) -> None:
|
||||
ensure_schema(conn)
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {CHANGE_LOG_TABLE} (
|
||||
domain, action, target_ref, actor, note, before_json, after_json, created_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
domain,
|
||||
action,
|
||||
target_ref,
|
||||
actor,
|
||||
note,
|
||||
_json_dump(before_json),
|
||||
_json_dump(after_json),
|
||||
now_kst_iso(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def set_approval(
|
||||
conn: sqlite3.Connection,
|
||||
domain: str,
|
||||
status: str,
|
||||
*,
|
||||
target_ref: str = "*",
|
||||
approved_by: str = "",
|
||||
note: str = "",
|
||||
) -> None:
|
||||
ensure_schema(conn)
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {APPROVAL_TABLE} (domain, target_ref, status, approved_by, approved_at, note, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(domain, target_ref) DO UPDATE SET
|
||||
status=excluded.status,
|
||||
approved_by=excluded.approved_by,
|
||||
approved_at=excluded.approved_at,
|
||||
note=excluded.note,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
domain,
|
||||
target_ref or "*",
|
||||
status,
|
||||
approved_by,
|
||||
now_kst_iso() if status == "APPROVED" else "",
|
||||
note,
|
||||
now_kst_iso(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def load_approval_rows(db_path: Path | str | None = None) -> list[dict[str, Any]]:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
rows = conn.execute(
|
||||
f"SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at FROM {APPROVAL_TABLE} ORDER BY domain ASC, target_ref ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def load_approval_entry(db_path: Path | str | None, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at
|
||||
FROM {APPROVAL_TABLE}
|
||||
WHERE domain = ? AND target_ref = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(domain, target_ref or "*"),
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def load_change_log_rows(db_path: Path | str | None = None, limit: int = 20) -> list[dict[str, Any]]:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
rows = conn.execute(
|
||||
f"""
|
||||
SELECT id, domain, action, target_ref, actor, note, before_json, after_json, created_at
|
||||
FROM {CHANGE_LOG_TABLE}
|
||||
ORDER BY id DESC
|
||||
LIMIT ?
|
||||
""",
|
||||
(int(limit),),
|
||||
).fetchall()
|
||||
items = []
|
||||
for row in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": int(row["id"]),
|
||||
"domain": row["domain"],
|
||||
"action": row["action"],
|
||||
"target_ref": row["target_ref"],
|
||||
"actor": row["actor"],
|
||||
"note": row["note"],
|
||||
"before_json": _json_load(row["before_json"]),
|
||||
"after_json": _json_load(row["after_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def load_last_change_row(conn: sqlite3.Connection, domain: str) -> dict[str, Any] | None:
|
||||
ensure_schema(conn)
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT id, domain, action, target_ref, actor, note, before_json, after_json, created_at
|
||||
FROM {CHANGE_LOG_TABLE}
|
||||
WHERE domain = ?
|
||||
ORDER BY id DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(domain,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"id": int(row["id"]),
|
||||
"domain": row["domain"],
|
||||
"action": row["action"],
|
||||
"target_ref": row["target_ref"],
|
||||
"actor": row["actor"],
|
||||
"note": row["note"],
|
||||
"before_json": _json_load(row["before_json"]),
|
||||
"after_json": _json_load(row["after_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def set_lock(conn: sqlite3.Connection, domain: str, target_ref: str, *, locked_by: str, reason: str) -> None:
|
||||
ensure_schema(conn)
|
||||
conn.execute(
|
||||
f"""
|
||||
INSERT INTO {LOCK_TABLE} (domain, target_ref, locked_by, reason, locked_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(domain, target_ref) DO UPDATE SET
|
||||
locked_by=excluded.locked_by,
|
||||
reason=excluded.reason,
|
||||
locked_at=excluded.locked_at
|
||||
""",
|
||||
(domain, target_ref, locked_by, reason, now_kst_iso()),
|
||||
)
|
||||
|
||||
|
||||
def clear_lock(conn: sqlite3.Connection, domain: str, target_ref: str) -> None:
|
||||
ensure_schema(conn)
|
||||
conn.execute(
|
||||
f"DELETE FROM {LOCK_TABLE} WHERE domain = ? AND target_ref = ?",
|
||||
(domain, target_ref),
|
||||
)
|
||||
|
||||
|
||||
def load_locks(db_path: Path | str | None = None) -> list[dict[str, Any]]:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
rows = conn.execute(
|
||||
f"SELECT domain, target_ref, locked_by, reason, locked_at FROM {LOCK_TABLE} ORDER BY domain ASC, target_ref ASC"
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
|
||||
def load_lock_entry(db_path: Path | str | None, domain: str, target_ref: str = "*") -> dict[str, Any] | None:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT domain, target_ref, locked_by, reason, locked_at
|
||||
FROM {LOCK_TABLE}
|
||||
WHERE domain = ? AND target_ref = ?
|
||||
LIMIT 1
|
||||
""",
|
||||
(domain, target_ref or "*"),
|
||||
).fetchone()
|
||||
return dict(row) if row is not None else None
|
||||
|
||||
|
||||
def is_locked(db_path: Path | str | None, domain: str, target_ref: str = "*") -> bool:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
row = conn.execute(
|
||||
f"SELECT 1 FROM {LOCK_TABLE} WHERE domain = ? AND target_ref IN (?, '*') LIMIT 1",
|
||||
(domain, target_ref),
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
def lock_conflicts_for_rows(
|
||||
db_path: Path | str | None,
|
||||
domain: str,
|
||||
rows: list[dict[str, Any]],
|
||||
) -> list[dict[str, Any]]:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
locks = conn.execute(
|
||||
f"SELECT domain, target_ref, locked_by, reason, locked_at FROM {LOCK_TABLE} WHERE domain = ? ORDER BY target_ref ASC",
|
||||
(domain,),
|
||||
).fetchall()
|
||||
if not locks:
|
||||
return []
|
||||
row_refs: list[str] = []
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
if domain == SETTINGS_TABLE:
|
||||
ref = str(row.get("_row_ref") or "").strip() or str(row.get("key") or "").strip()
|
||||
elif domain == SNAPSHOT_TABLE:
|
||||
ref = str(row.get("_row_ref") or "").strip()
|
||||
if not ref:
|
||||
ordinal = str(row.get("_ordinal") or row.get("ordinal") or idx).strip()
|
||||
ref = f"row:{ordinal}"
|
||||
else:
|
||||
ref = str(row.get("target_ref") or "").strip()
|
||||
if ref:
|
||||
row_refs.append(ref)
|
||||
if domain == SETTINGS_TABLE:
|
||||
key = str(row.get("key") or "").strip()
|
||||
if key:
|
||||
row_refs.append(key)
|
||||
if domain == SNAPSHOT_TABLE:
|
||||
ticker = str(row.get("ticker") or "").strip()
|
||||
if ticker:
|
||||
row_refs.append(ticker)
|
||||
conflicts: list[dict[str, Any]] = []
|
||||
for lock in locks:
|
||||
target_ref = str(lock["target_ref"] or "").strip()
|
||||
if target_ref == "*" or target_ref in row_refs:
|
||||
conflicts.append(dict(lock))
|
||||
return conflicts
|
||||
|
||||
|
||||
def undo_last_change(conn: sqlite3.Connection, domain: str, *, actor: str = "ui") -> dict[str, Any]:
|
||||
ensure_schema(conn)
|
||||
last = load_last_change_row(conn, domain)
|
||||
if not last:
|
||||
raise ValueError(f"no change log for domain={domain}")
|
||||
before_json = last.get("before_json")
|
||||
if domain == SETTINGS_TABLE:
|
||||
rows = before_json if isinstance(before_json, list) else []
|
||||
replace_settings(conn, rows)
|
||||
elif domain == SNAPSHOT_TABLE:
|
||||
rows = before_json if isinstance(before_json, list) else []
|
||||
replace_account_snapshot(conn, rows)
|
||||
else:
|
||||
raise ValueError(f"unsupported domain={domain}")
|
||||
record_change_log(
|
||||
conn,
|
||||
domain=domain,
|
||||
action="undo",
|
||||
before_json=last.get("after_json"),
|
||||
after_json=before_json,
|
||||
target_ref=last.get("target_ref", "*"),
|
||||
actor=actor,
|
||||
note=f"undo change #{last['id']}",
|
||||
)
|
||||
conn.commit()
|
||||
return load_last_change_row(conn, domain) or {}
|
||||
|
||||
|
||||
def load_approval_for_domain(db_path: Path | str | None, domain: str) -> dict[str, Any]:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
row = conn.execute(
|
||||
f"""
|
||||
SELECT domain, target_ref, status, approved_by, approved_at, note, updated_at
|
||||
FROM {APPROVAL_TABLE}
|
||||
WHERE domain = ? AND target_ref = '*'
|
||||
""",
|
||||
(domain,),
|
||||
).fetchone()
|
||||
return (
|
||||
dict(row)
|
||||
if row
|
||||
else {"domain": domain, "target_ref": "*", "status": "MISSING", "approved_by": "", "approved_at": "", "note": "", "updated_at": ""}
|
||||
)
|
||||
|
||||
|
||||
def summarize_workspace(db_path: Path | str | None = None) -> dict[str, Any]:
|
||||
with open_connection(db_path) as conn:
|
||||
ensure_schema(conn)
|
||||
settings_count = conn.execute(f"SELECT COUNT(*) FROM {SETTINGS_TABLE}").fetchone()[0]
|
||||
snapshot_count = conn.execute(f"SELECT COUNT(*) FROM {SNAPSHOT_TABLE}").fetchone()[0]
|
||||
latest_update = conn.execute(
|
||||
f"""
|
||||
SELECT MAX(updated_at)
|
||||
FROM (
|
||||
SELECT updated_at FROM {SETTINGS_TABLE}
|
||||
UNION ALL
|
||||
SELECT updated_at FROM {SNAPSHOT_TABLE}
|
||||
)
|
||||
"""
|
||||
).fetchone()[0]
|
||||
table_rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name IN (?, ?, ?, ?, ?)",
|
||||
(SETTINGS_TABLE, SNAPSHOT_TABLE, CHANGE_LOG_TABLE, APPROVAL_TABLE, LOCK_TABLE),
|
||||
).fetchall()
|
||||
tables = sorted(row[0] for row in table_rows)
|
||||
workspace_db = str(normalize_db_path(db_path))
|
||||
return {
|
||||
"db_path": workspace_db,
|
||||
"settings_rows": int(settings_count),
|
||||
"account_snapshot_rows": int(snapshot_count),
|
||||
"latest_update": latest_update or "",
|
||||
"tables": tables,
|
||||
"topology": {
|
||||
"mode": "single_workspace_sqlite",
|
||||
"workspace_db": workspace_db,
|
||||
"collector_db": str(ROOT / "outputs" / "kis_data_collection" / "kis_data_collection.db"),
|
||||
"settings_and_snapshot_share_db": True,
|
||||
"collector_separate_db": True,
|
||||
},
|
||||
"meta": load_meta(db_path),
|
||||
}
|
||||
|
||||
|
||||
def parse_account_snapshot_tsv(tsv_text: str) -> list[dict[str, Any]]:
|
||||
lines = [line.rstrip("\r") for line in tsv_text.splitlines() if line.strip() != ""]
|
||||
if not lines:
|
||||
return []
|
||||
rows: list[list[str]] = [line.split("\t") for line in lines]
|
||||
first_row = rows[0]
|
||||
if first_row == ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS:
|
||||
data_rows = rows[1:]
|
||||
elif set(first_row) >= {"captured_at", "account", "ticker"}:
|
||||
header = first_row
|
||||
data_rows = rows[1:]
|
||||
converted: list[dict[str, Any]] = []
|
||||
for idx, row in enumerate(data_rows, start=1):
|
||||
item: dict[str, Any] = {"ordinal": idx}
|
||||
for col_index, column in enumerate(header):
|
||||
value = row[col_index] if col_index < len(row) else ""
|
||||
item[column] = parse_scalar(value)
|
||||
converted.append(item)
|
||||
return converted
|
||||
else:
|
||||
data_rows = rows
|
||||
converted = []
|
||||
for idx, row in enumerate(data_rows, start=1):
|
||||
item: dict[str, Any] = {"ordinal": idx}
|
||||
for col_index, column in enumerate(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS):
|
||||
value = row[col_index] if col_index < len(row) else ""
|
||||
item[column] = parse_scalar(value)
|
||||
converted.append(item)
|
||||
return converted
|
||||
|
||||
|
||||
def settings_rows_to_dict(rows: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for row in rows:
|
||||
key = str(row.get("key") or "").strip()
|
||||
if key:
|
||||
result[key] = row.get("value", "")
|
||||
return result
|
||||
|
||||
|
||||
def _as_number(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_settings_spec() -> dict[str, Any]:
|
||||
return yaml.safe_load(SETTINGS_SPEC_PATH.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _load_account_snapshot_spec() -> dict[str, Any]:
|
||||
return yaml.safe_load(ACCOUNT_SNAPSHOT_SPEC_PATH.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def validate_settings_rows(rows: list[dict[str, Any]]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
spec = _load_settings_spec().get("required_keys") or {}
|
||||
optional_spec = _load_settings_spec().get("optional_keys") or {}
|
||||
seen: set[str] = set()
|
||||
total_asset_found = False
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
key = str(row.get("key") or "").strip()
|
||||
if not key:
|
||||
errors.append(f"settings row {idx}: missing key")
|
||||
continue
|
||||
if key in seen:
|
||||
errors.append(f"settings row {idx}: duplicate key {key}")
|
||||
seen.add(key)
|
||||
value = row.get("value", "")
|
||||
if key == "total_asset_krw":
|
||||
total_asset_found = True
|
||||
amount = _as_number(value)
|
||||
if amount is None or amount <= 0:
|
||||
errors.append("settings.total_asset_krw must be positive number")
|
||||
if key in {"weekly_target_cash_pct", "fc_budget_pct_override"}:
|
||||
pct = _as_number(value)
|
||||
if pct is None or pct < 0:
|
||||
errors.append(f"settings.{key} must be non-negative number")
|
||||
if key in spec and spec[key].get("type") == "string":
|
||||
if value is not None and not isinstance(value, str):
|
||||
errors.append(f"settings.{key} must be string")
|
||||
if key in optional_spec and optional_spec[key].get("format") == "YYYY-MM":
|
||||
text = str(value).strip()
|
||||
if text and not re.fullmatch(r"\d{4}-\d{2}(-.*)?", text):
|
||||
errors.append(f"settings.{key} must use YYYY-MM")
|
||||
if not total_asset_found:
|
||||
errors.append("settings.total_asset_krw is required")
|
||||
return errors
|
||||
|
||||
|
||||
def validate_account_snapshot_rows(rows: list[dict[str, Any]]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
spec = _load_account_snapshot_spec().get("account_snapshot_contract") or {}
|
||||
canonical = spec.get("canonical_fields") or {}
|
||||
for idx, row in enumerate(rows, start=1):
|
||||
captured_at = str(row.get("captured_at") or "").strip()
|
||||
account = str(row.get("account") or "").strip()
|
||||
ticker = str(row.get("ticker") or "").strip()
|
||||
name = str(row.get("name") or "").strip()
|
||||
account_type = str(row.get("account_type") or "").strip()
|
||||
parse_status = str(row.get("parse_status") or "").strip()
|
||||
holding_quantity = _as_number(row.get("holding_quantity"))
|
||||
average_cost = _as_number(row.get("average_cost"))
|
||||
stop_price = _as_number(row.get("stop_price"))
|
||||
entry_stage = str(row.get("entry_stage") or "").strip()
|
||||
position_type = str(row.get("position_type") or "").strip()
|
||||
user_confirmed = str(row.get("user_confirmed") or "").strip().upper()
|
||||
if not captured_at:
|
||||
errors.append(f"account_snapshot row {idx}: captured_at required")
|
||||
if not account:
|
||||
errors.append(f"account_snapshot row {idx}: account required")
|
||||
if not account_type:
|
||||
errors.append(f"account_snapshot row {idx}: account_type required")
|
||||
if account_type and canonical.get("account_type", {}).get("allowed") and account_type not in canonical["account_type"]["allowed"]:
|
||||
errors.append(f"account_snapshot row {idx}: invalid account_type {account_type!r}")
|
||||
if not ticker:
|
||||
errors.append(f"account_snapshot row {idx}: ticker required")
|
||||
if not name:
|
||||
errors.append(f"account_snapshot row {idx}: name required")
|
||||
if parse_status not in ALLOWED_PARSE_STATUS:
|
||||
errors.append(f"account_snapshot row {idx}: invalid parse_status {parse_status!r}")
|
||||
if holding_quantity is not None and holding_quantity < 0:
|
||||
errors.append(f"account_snapshot row {idx}: holding_quantity must be >= 0")
|
||||
if average_cost is not None and average_cost < 0:
|
||||
errors.append(f"account_snapshot row {idx}: average_cost must be >= 0")
|
||||
if user_confirmed and user_confirmed not in {"Y", "N"}:
|
||||
errors.append(f"account_snapshot row {idx}: user_confirmed must be Y or N")
|
||||
if parse_status == "CAPTURE_READ_OK" and user_confirmed != "Y":
|
||||
errors.append(f"account_snapshot row {idx}: CAPTURE_READ_OK rows require user_confirmed=Y")
|
||||
if entry_stage and canonical.get("entry_stage", {}).get("allowed") and entry_stage not in canonical["entry_stage"]["allowed"]:
|
||||
errors.append(f"account_snapshot row {idx}: invalid entry_stage {entry_stage!r}")
|
||||
if position_type and canonical.get("position_type", {}).get("allowed") and position_type not in canonical["position_type"]["allowed"]:
|
||||
errors.append(f"account_snapshot row {idx}: invalid position_type {position_type!r}")
|
||||
return errors
|
||||
|
||||
|
||||
def build_validation_suggestions(settings_rows: list[dict[str, Any]], snapshot_rows: list[dict[str, Any]]) -> list[str]:
|
||||
suggestions: list[str] = []
|
||||
settings_map = settings_rows_to_dict(settings_rows)
|
||||
snapshot_count = len(snapshot_rows)
|
||||
if "total_asset_krw" not in settings_map:
|
||||
suggestions.append("settings: add total_asset_krw from current investable asset total")
|
||||
if str(settings_map.get("weekly_target_cash_pct", "")).strip() == "":
|
||||
suggestions.append("settings: weekly_target_cash_pct can stay blank unless weekly rebalance is active")
|
||||
for row in snapshot_rows:
|
||||
if str(row.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(row.get("user_confirmed") or "").strip().upper() != "Y":
|
||||
suggestions.append(
|
||||
f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: set user_confirmed=Y for CAPTURE_READ_OK"
|
||||
)
|
||||
account_type = str(row.get("account_type") or "").strip()
|
||||
if account_type and account_type not in {"일반계좌", "ISA", "연금저축"}:
|
||||
suggestions.append(
|
||||
f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: account_type should be one of 일반계좌/ISA/연금저축"
|
||||
)
|
||||
if str(row.get("entry_stage") or "").strip() and str(row.get("position_type") or "").strip() == "":
|
||||
suggestions.append(
|
||||
f"account_snapshot {row.get('ticker') or row.get('name') or 'row'}: consider setting position_type when entry_stage is present"
|
||||
)
|
||||
if not snapshot_rows:
|
||||
suggestions.append("account_snapshot: import TSV from HTS capture before saving snapshot")
|
||||
return suggestions[:20]
|
||||
|
||||
|
||||
def build_safe_autofix_actions(settings_rows: list[dict[str, Any]], snapshot_rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
actions: list[dict[str, Any]] = []
|
||||
if any(str(row.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(row.get("user_confirmed") or "").strip().upper() != "Y" for row in snapshot_rows):
|
||||
actions.append(
|
||||
{
|
||||
"action_id": "confirm_captured_rows",
|
||||
"domain": "account_snapshot",
|
||||
"label": "Set user_confirmed=Y for CAPTURE_READ_OK rows",
|
||||
"description": "Safe autofix using the contract default confirmation flag.",
|
||||
}
|
||||
)
|
||||
if any(str(row.get("position_type") or "").strip() == "" and str(row.get("entry_stage") or "").strip() for row in snapshot_rows):
|
||||
actions.append(
|
||||
{
|
||||
"action_id": "default_position_type_satellite",
|
||||
"domain": "account_snapshot",
|
||||
"label": "Default blank position_type to satellite",
|
||||
"description": "Uses the contract default when position_type is missing.",
|
||||
}
|
||||
)
|
||||
if not any(str(row.get("key") or "").strip() == "total_asset_krw" for row in settings_rows):
|
||||
actions.append(
|
||||
{
|
||||
"action_id": "required_total_asset_missing",
|
||||
"domain": "settings",
|
||||
"label": "Settings total_asset_krw missing",
|
||||
"description": "Manual input required. No safe autofix.",
|
||||
}
|
||||
)
|
||||
return actions
|
||||
|
||||
|
||||
def apply_safe_autofix_action(
|
||||
conn: sqlite3.Connection,
|
||||
action_id: str,
|
||||
*,
|
||||
actor: str = "ui",
|
||||
) -> dict[str, Any]:
|
||||
ensure_schema(conn)
|
||||
snapshot_rows = load_account_snapshot_rows_from_conn(conn)
|
||||
if action_id == "confirm_captured_rows":
|
||||
updated = []
|
||||
for row in snapshot_rows:
|
||||
candidate = dict(row)
|
||||
if str(candidate.get("parse_status") or "").strip() == "CAPTURE_READ_OK" and str(candidate.get("user_confirmed") or "").strip().upper() != "Y":
|
||||
candidate["user_confirmed"] = "Y"
|
||||
updated.append(candidate)
|
||||
replace_account_snapshot(conn, updated)
|
||||
return {"domain": SNAPSHOT_TABLE, "status": "AUTOFIXED", "action_id": action_id}
|
||||
if action_id == "default_position_type_satellite":
|
||||
updated = []
|
||||
for row in snapshot_rows:
|
||||
candidate = dict(row)
|
||||
if str(candidate.get("entry_stage") or "").strip() and str(candidate.get("position_type") or "").strip() == "":
|
||||
candidate["position_type"] = "satellite"
|
||||
updated.append(candidate)
|
||||
replace_account_snapshot(conn, updated)
|
||||
return {"domain": SNAPSHOT_TABLE, "status": "AUTOFIXED", "action_id": action_id}
|
||||
if action_id == "required_total_asset_missing":
|
||||
return {"domain": SETTINGS_TABLE, "status": "MANUAL_REQUIRED", "action_id": action_id}
|
||||
raise ValueError(f"unknown action_id={action_id}")
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Generic storage backend contract for canonical time-series stores.
|
||||
|
||||
The call sites use this as a small contract layer so SQLite is the executable
|
||||
backend today while PostgreSQL can be added later without changing callers.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StoreSpec:
|
||||
backend: str = "sqlite"
|
||||
location: str | Path | None = None
|
||||
|
||||
def normalized_backend(self) -> str:
|
||||
backend = (self.backend or "sqlite").strip().lower()
|
||||
if backend in {"sqlite", "sqlite3"}:
|
||||
return "sqlite"
|
||||
if backend in {"postgres", "postgresql", "pg"}:
|
||||
return "postgresql"
|
||||
return backend
|
||||
|
||||
|
||||
def default_sqlite_store_path(root: Path, default_name: str) -> Path:
|
||||
return root / "outputs" / default_name
|
||||
|
||||
|
||||
def normalize_store_spec(
|
||||
spec: StoreSpec,
|
||||
root: Path,
|
||||
*,
|
||||
default_sqlite_name: str = "store.db",
|
||||
) -> tuple[str, Path | str]:
|
||||
backend = spec.normalized_backend()
|
||||
if backend == "sqlite":
|
||||
if spec.location is None:
|
||||
return backend, default_sqlite_store_path(root, default_sqlite_name)
|
||||
if isinstance(spec.location, Path):
|
||||
return backend, spec.location
|
||||
location = str(spec.location).strip()
|
||||
if location.startswith("sqlite:///"):
|
||||
return backend, Path(location.removeprefix("sqlite:///"))
|
||||
return backend, Path(location)
|
||||
if backend == "postgresql":
|
||||
if not spec.location:
|
||||
raise ValueError("postgresql backend requires a DSN/location string")
|
||||
return backend, str(spec.location)
|
||||
raise ValueError(f"unsupported backend: {spec.backend!r}")
|
||||
@@ -0,0 +1,138 @@
|
||||
"""WBS-7.7 — KIS 수집 → 스냅샷 어드민 적재 → 정성매도전략 평가 E2E 체인.
|
||||
|
||||
단위 테스트(tests/unit)는 각 모듈을 독립적으로 검증하지만, 모듈 간 실제 데이터
|
||||
경로(kis_data_collection_v1 → data_collection_store_v1.db → snapshot_admin의
|
||||
collection dashboard / qualitative_sell_strategy_v1 → qualitative_sell_strategy_store_v1.db)
|
||||
를 연결해서 검증하는 테스트가 없었다(2026-06-21 비판적 리뷰 0c절, WBS-7.7).
|
||||
|
||||
이 테스트는 네트워크를 전혀 사용하지 않는다(--no-live-kis --no-naver와 동일한 경로,
|
||||
또는 Naver 호출을 명시적으로 예외 처리시켜 graceful degradation을 검증).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest
|
||||
|
||||
from src.quant_engine import kis_data_collection_v1 as kdc
|
||||
from src.quant_engine.data_collection_store_v1 import load_collection_dashboard_state
|
||||
from src.quant_engine.qualitative_sell_strategy_v1 import compute_qualitative_sell_strategy
|
||||
from src.quant_engine.qualitative_sell_strategy_store_v1 import (
|
||||
fetch_recent_sell_strategy_results,
|
||||
insert_sell_strategy_result,
|
||||
)
|
||||
|
||||
SEED_ROWS = [
|
||||
{"Ticker": "005930", "Name": "삼성전자", "Sector": "반도체"},
|
||||
{"Ticker": "000660", "Name": "SK하이닉스", "Sector": "반도체"},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def seed_json(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "seed.json"
|
||||
path.write_text(
|
||||
json.dumps({"data": {"data_feed": SEED_ROWS}}, ensure_ascii=False),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def test_kis_collection_writes_sqlite_that_snapshot_admin_dashboard_reads_back(tmp_path: Path, seed_json: Path):
|
||||
"""1단계: KIS 수집(네트워크 미사용) → SQLite 적재 → snapshot_admin 대시보드 read-back."""
|
||||
db_path = tmp_path / "data_collection_store_v1.db"
|
||||
output_json = tmp_path / "kis_data_collection_v1.json"
|
||||
|
||||
summary = kdc.collect_to_sqlite(
|
||||
input_json=seed_json,
|
||||
sqlite_db=db_path,
|
||||
output_json=output_json,
|
||||
kis_account="mock",
|
||||
include_naver=False,
|
||||
include_live_kis=False,
|
||||
)
|
||||
|
||||
assert summary["status"] in {"PASS", "PASS_WITH_WARNINGS"}
|
||||
assert summary["row_count"] == len(SEED_ROWS)
|
||||
assert not summary["errors"]
|
||||
|
||||
dashboard = load_collection_dashboard_state(db_path=db_path, output_json_path=output_json)
|
||||
assert dashboard["counts"]["collection_runs"] >= 1
|
||||
assert dashboard["counts"]["collection_snapshots"] == len(SEED_ROWS)
|
||||
assert dashboard["counts"]["collection_source_errors"] == 0
|
||||
tickers_in_dashboard = {row["ticker"] for row in dashboard["recent_snapshots"]}
|
||||
assert {"005930", "000660"} <= tickers_in_dashboard
|
||||
|
||||
|
||||
def test_naver_fetch_exception_degrades_gracefully_without_breaking_batch(tmp_path: Path, seed_json: Path, monkeypatch):
|
||||
"""Cloudflare 403 등 Naver 폴백 차단 시 graceful degradation 검증 (spec/exit/qualitative_sell_strategy_v1.yaml:81-82 명시 리스크)."""
|
||||
|
||||
def _raise_cloudflare_block(_session, _code):
|
||||
raise RuntimeError("HTTP 403 Forbidden (Cloudflare)")
|
||||
|
||||
monkeypatch.setattr(kdc, "fetch_price_history", _raise_cloudflare_block)
|
||||
# naver_session/fetch_price_history may be None on environments without the optional
|
||||
# dependency wired; force both non-None so _normalize_naver_price_history actually tries.
|
||||
monkeypatch.setattr(kdc, "naver_session", lambda: object())
|
||||
|
||||
db_path = tmp_path / "data_collection_store_v1.db"
|
||||
output_json = tmp_path / "kis_data_collection_v1.json"
|
||||
|
||||
summary = kdc.collect_to_sqlite(
|
||||
input_json=seed_json,
|
||||
sqlite_db=db_path,
|
||||
output_json=output_json,
|
||||
kis_account="mock",
|
||||
include_naver=True,
|
||||
include_live_kis=False,
|
||||
)
|
||||
|
||||
# 배치 전체가 죽지 않고 끝까지 진행되어야 한다 — 개별 ticker의 naver 보강 실패는
|
||||
# collection_source_errors가 아니라 정상 row로 (naver 필드 없이) 기록된다.
|
||||
assert summary["status"] in {"PASS", "PASS_WITH_WARNINGS"}
|
||||
assert summary["row_count"] == len(SEED_ROWS)
|
||||
assert not summary["errors"], "Naver 차단은 개별 ticker 처리 중 흡수되어야 하며 배치 errors로 전파되면 안 된다"
|
||||
|
||||
|
||||
def test_qualitative_sell_strategy_decision_round_trips_through_store(tmp_path: Path):
|
||||
"""2단계: 정성매도전략 평가(순수 함수, 네트워크 미사용) → SQLite 저장 → 조회 round-trip."""
|
||||
ctx = {
|
||||
"today": date(2026, 6, 21),
|
||||
"macro_pressure": 0.5,
|
||||
"fundamental_trajectory": 0.4,
|
||||
"short_interest_pressure": 0.6,
|
||||
"microstructure_pressure": 0.2,
|
||||
"liquidity_rotation_risk": 0.5,
|
||||
"rate_trend": "RISING",
|
||||
}
|
||||
decision = compute_qualitative_sell_strategy(ctx)
|
||||
assert decision["action"] in {
|
||||
"EXIT_REVIEW_FULL",
|
||||
"TRIM_REVIEW_PARTIAL",
|
||||
"HOLD_ADD_CONVICTION",
|
||||
"HOLD_NO_CONFLUENCE",
|
||||
"INSUFFICIENT_DATA_NO_ACTION",
|
||||
}
|
||||
|
||||
result = {
|
||||
"code": "005930",
|
||||
"generated_at": "2026-06-21T15:30:00+09:00",
|
||||
"decision": decision,
|
||||
}
|
||||
|
||||
db_path = tmp_path / "qualitative_sell_strategy.db"
|
||||
insert_sell_strategy_result(db_path, result)
|
||||
|
||||
fetched = fetch_recent_sell_strategy_results(db_path, "005930", limit=5)
|
||||
assert len(fetched) == 1
|
||||
assert fetched[0]["code"] == "005930"
|
||||
assert fetched[0]["action"] == decision["action"]
|
||||
assert fetched[0]["conviction"] == decision["conviction"]
|
||||
assert fetched[0]["market_regime"] == decision["market_regime"]
|
||||
@@ -0,0 +1,107 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def _run(script: str) -> None:
|
||||
subprocess.run(
|
||||
[sys.executable, script],
|
||||
cwd=ROOT,
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_build_calibration_priority_and_change_ledger(tmp_path):
|
||||
_run("tools/build_calibration_priority_v1.py")
|
||||
_run("tools/build_calibration_change_ledger_v4.py")
|
||||
_run("tools/validate_calibration_change_ledger_v1.py")
|
||||
|
||||
priority_path = ROOT / "Temp" / "calibration_priority_v1.json"
|
||||
ledger_path = ROOT / "Temp" / "calibration_change_ledger_v4.json"
|
||||
|
||||
priority = json.loads(priority_path.read_text(encoding="utf-8"))
|
||||
ledger = json.loads(ledger_path.read_text(encoding="utf-8"))
|
||||
|
||||
assert priority["status"] == "CALIBRATION_PRIORITY_OK"
|
||||
assert priority["priority_count"] >= 5
|
||||
assert priority["priority_list"]
|
||||
assert priority["priority_basis"] in {"alpha_feedback_loop_v2", "registry_warning_fallback"}
|
||||
|
||||
assert ledger["formula_id"] == "CALIBRATION_CHANGE_LEDGER_V4"
|
||||
assert ledger["threshold_change_without_ledger_count"] == 0
|
||||
assert len(ledger["changes"]) >= 5
|
||||
|
||||
|
||||
def test_calibration_backlog_workflow_and_script_exist():
|
||||
workflow = ROOT / ".gitea" / "workflows" / "calibration_backlog.yml"
|
||||
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
||||
assert workflow.exists()
|
||||
assert "ops:calibration-backlog" in package["scripts"]
|
||||
assert "ops:calibration-review-report" in package["scripts"]
|
||||
assert "ops:calibration-approval-list" in package["scripts"]
|
||||
assert "ops:calibration-decision-draft" in package["scripts"]
|
||||
|
||||
|
||||
def test_build_calibration_review_report(tmp_path):
|
||||
_run("tools/build_calibration_priority_v1.py")
|
||||
_run("tools/build_calibration_change_ledger_v4.py")
|
||||
_run("tools/build_calibration_review_report_v1.py")
|
||||
|
||||
report_json = ROOT / "Temp" / "calibration_review_report_v1.json"
|
||||
report_md = ROOT / "Temp" / "calibration_review_report_v1.md"
|
||||
payload = json.loads(report_json.read_text(encoding="utf-8"))
|
||||
text = report_md.read_text(encoding="utf-8")
|
||||
|
||||
assert payload["formula_id"] == "CALIBRATION_REVIEW_REPORT_V1"
|
||||
assert payload["summary"]["total_thresholds"] >= 1
|
||||
assert payload["top_priority_rows"]
|
||||
assert "Calibration Review Report" in text
|
||||
assert "Review Candidates" in text
|
||||
|
||||
|
||||
def test_build_calibration_approval_list(tmp_path):
|
||||
_run("tools/build_calibration_priority_v1.py")
|
||||
_run("tools/build_calibration_change_ledger_v4.py")
|
||||
_run("tools/build_calibration_review_report_v1.py")
|
||||
_run("tools/build_calibration_approval_list_v1.py")
|
||||
|
||||
approval_json = ROOT / "Temp" / "calibration_approval_list_v1.json"
|
||||
approval_md = ROOT / "Temp" / "calibration_approval_list_v1.md"
|
||||
payload = json.loads(approval_json.read_text(encoding="utf-8"))
|
||||
text = approval_md.read_text(encoding="utf-8")
|
||||
|
||||
assert payload["formula_id"] == "CALIBRATION_APPROVAL_LIST_V1"
|
||||
assert payload["approval_candidate_count"] >= 1
|
||||
assert payload["approval_candidates"]
|
||||
assert "Calibration Approval List" in text
|
||||
assert "Approval Candidates" in text
|
||||
|
||||
|
||||
def test_build_calibration_decision_draft(tmp_path):
|
||||
_run("tools/build_calibration_priority_v1.py")
|
||||
_run("tools/build_calibration_change_ledger_v4.py")
|
||||
_run("tools/build_calibration_review_report_v1.py")
|
||||
_run("tools/build_calibration_approval_list_v1.py")
|
||||
_run("tools/build_calibration_decision_draft_v1.py")
|
||||
|
||||
decision_json = ROOT / "Temp" / "calibration_decision_draft_v1.json"
|
||||
decision_md = ROOT / "Temp" / "calibration_decision_draft_v1.md"
|
||||
payload = json.loads(decision_json.read_text(encoding="utf-8"))
|
||||
text = decision_md.read_text(encoding="utf-8")
|
||||
|
||||
assert payload["formula_id"] == "CALIBRATION_DECISION_DRAFT_V1"
|
||||
assert payload["decision_count"] >= 1
|
||||
assert payload["summary"]["APPROVE"] >= 1
|
||||
assert payload["summary"]["HOLD"] >= 1
|
||||
assert payload["summary"]["REJECT"] >= 0
|
||||
assert "Calibration Decision Draft" in text
|
||||
assert "Decision Table" in text
|
||||
@@ -0,0 +1,110 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.data_collection_store_v1 import (
|
||||
CollectionRun,
|
||||
append_collection_error,
|
||||
fetch_latest_snapshots,
|
||||
init_db,
|
||||
iter_recent_snapshots,
|
||||
upsert_collection_run,
|
||||
upsert_collection_snapshot,
|
||||
)
|
||||
from src.quant_engine.data_collection_backend_v1 import CollectionStoreSpec, normalize_store_spec
|
||||
|
||||
|
||||
def test_store_writes_and_reads_snapshots(tmp_path):
|
||||
db_path = tmp_path / "collector.db"
|
||||
init_db(db_path)
|
||||
upsert_collection_run(
|
||||
db_path,
|
||||
CollectionRun(
|
||||
run_id="run-1",
|
||||
collector_name="collector",
|
||||
started_at="2026-06-21T12:00:00+09:00",
|
||||
status="RUNNING",
|
||||
input_source="GatherTradingData.json",
|
||||
output_json_path="Temp/kis_data_collection_v1.json",
|
||||
output_db_path=str(db_path),
|
||||
),
|
||||
)
|
||||
upsert_collection_snapshot(
|
||||
db_path,
|
||||
run_id="run-1",
|
||||
dataset_name="data_feed",
|
||||
ticker="005930",
|
||||
name="삼성전자",
|
||||
sector="반도체",
|
||||
as_of_date="2026-06-21",
|
||||
source_priority="kis_open_api>gathertradingdata_json",
|
||||
source_status="OK",
|
||||
payload={"ticker": "005930", "close": 1000},
|
||||
provenance={"kis": {"status": "OK"}},
|
||||
)
|
||||
append_collection_error(
|
||||
db_path,
|
||||
run_id="run-1",
|
||||
source_name="kis",
|
||||
error_kind="TimeoutError",
|
||||
error_message="timeout",
|
||||
ticker="005930",
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
run_count = conn.execute("SELECT COUNT(*) FROM collection_runs").fetchone()[0]
|
||||
snap_count = conn.execute("SELECT COUNT(*) FROM collection_snapshots").fetchone()[0]
|
||||
err_count = conn.execute("SELECT COUNT(*) FROM collection_source_errors").fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
assert run_count == 1
|
||||
assert snap_count == 1
|
||||
assert err_count == 1
|
||||
assert fetch_latest_snapshots(db_path, "005930")[0]["dataset_name"] == "data_feed"
|
||||
assert len(list(iter_recent_snapshots(db_path, limit=5))) == 1
|
||||
|
||||
|
||||
def test_store_overwrites_same_run_and_ticker(tmp_path):
|
||||
db_path = tmp_path / "collector.db"
|
||||
upsert_collection_snapshot(
|
||||
db_path,
|
||||
run_id="run-1",
|
||||
dataset_name="data_feed",
|
||||
ticker="005930",
|
||||
name="삼성전자",
|
||||
sector="반도체",
|
||||
as_of_date="2026-06-21",
|
||||
source_priority="kis_open_api",
|
||||
source_status="OK",
|
||||
payload={"ticker": "005930", "close": 1000},
|
||||
provenance={"source_priority": ["kis_open_api"]},
|
||||
)
|
||||
upsert_collection_snapshot(
|
||||
db_path,
|
||||
run_id="run-1",
|
||||
dataset_name="data_feed",
|
||||
ticker="005930",
|
||||
name="삼성전자",
|
||||
sector="반도체",
|
||||
as_of_date="2026-06-21",
|
||||
source_priority="kis_open_api>naver_finance",
|
||||
source_status="OK",
|
||||
payload={"ticker": "005930", "close": 2000},
|
||||
provenance={"source_priority": ["kis_open_api", "naver_finance"]},
|
||||
)
|
||||
rows = fetch_latest_snapshots(db_path, "005930")
|
||||
assert rows[0]["source_priority"] == "kis_open_api>naver_finance"
|
||||
|
||||
|
||||
def test_store_backend_normalization_supports_sqlite_paths(tmp_path):
|
||||
backend, location = normalize_store_spec(CollectionStoreSpec(location=tmp_path / "collector.db"), ROOT)
|
||||
assert backend == "sqlite"
|
||||
assert str(location).endswith("collector.db")
|
||||
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from tools.evaluate_qualitative_sell_strategy_accuracy_v1 import (
|
||||
_scoreable_direction,
|
||||
build_accuracy_report,
|
||||
evaluate_decision,
|
||||
)
|
||||
from src.quant_engine.qualitative_sell_strategy_store_v1 import insert_sell_strategy_result
|
||||
|
||||
|
||||
def test_scoreable_direction():
|
||||
assert _scoreable_direction("EXIT_REVIEW_FULL") == -1
|
||||
assert _scoreable_direction("TRIM_REVIEW_PARTIAL") == -1
|
||||
assert _scoreable_direction("HOLD_ADD_CONVICTION") == 1
|
||||
assert _scoreable_direction("HOLD_NO_CONFLUENCE") is None
|
||||
assert _scoreable_direction("INSUFFICIENT_DATA_NO_ACTION") is None
|
||||
|
||||
|
||||
def test_evaluate_decision_sell_success_when_price_drops():
|
||||
decision = {"action": "EXIT_REVIEW_FULL"}
|
||||
result = evaluate_decision(decision, price_at_decision=100.0, price_after=90.0)
|
||||
assert result["success"] is True
|
||||
assert result["realized_return_pct"] == -10.0
|
||||
|
||||
|
||||
def test_evaluate_decision_sell_failure_when_price_rises():
|
||||
decision = {"action": "TRIM_REVIEW_PARTIAL"}
|
||||
result = evaluate_decision(decision, price_at_decision=100.0, price_after=110.0)
|
||||
assert result["success"] is False
|
||||
|
||||
|
||||
def test_evaluate_decision_hold_add_success_when_price_rises():
|
||||
decision = {"action": "HOLD_ADD_CONVICTION"}
|
||||
result = evaluate_decision(decision, price_at_decision=100.0, price_after=105.0)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
def test_evaluate_decision_returns_none_for_non_directional_action():
|
||||
assert evaluate_decision({"action": "HOLD_NO_CONFLUENCE"}, 100.0, 105.0) is None
|
||||
|
||||
|
||||
def test_build_accuracy_report_data_gated_when_sample_too_small(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
insert_sell_strategy_result(db_path, {
|
||||
"code": "005930", "generated_at": "2026-06-01T12:00:00",
|
||||
"decision": {"action": "EXIT_REVIEW_FULL"},
|
||||
})
|
||||
report = build_accuracy_report(db_path, price_lookup={
|
||||
"005930": {"2026-06-01": 100.0, "2026-06-06": 90.0},
|
||||
})
|
||||
assert report["status"] == "DATA_GATED"
|
||||
assert report["scored_sample_count"] == 1
|
||||
|
||||
|
||||
def test_build_accuracy_report_ok_with_enough_samples(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
price_lookup: dict = {}
|
||||
for i in range(12):
|
||||
code = f"00000{i % 3}"
|
||||
gen_at = f"2026-05-{(i % 20) + 1:02d}T12:00:00"
|
||||
insert_sell_strategy_result(db_path, {
|
||||
"code": code, "generated_at": gen_at,
|
||||
"decision": {"action": "EXIT_REVIEW_FULL"},
|
||||
})
|
||||
date_key = gen_at[:10]
|
||||
future_key = (
|
||||
__import__("datetime").date.fromisoformat(date_key) + __import__("datetime").timedelta(days=5)
|
||||
).isoformat()
|
||||
price_lookup.setdefault(code, {})[date_key] = 100.0
|
||||
price_lookup[code][future_key] = 90.0 # 매도신호 후 하락 — success
|
||||
report = build_accuracy_report(db_path, price_lookup)
|
||||
assert report["status"] == "OK"
|
||||
assert report["hit_rate_pct"] == 100.0
|
||||
assert report["scored_sample_count"] == 12
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.execution_slippage_store_v1 import (
|
||||
ASSUMED_SLIPPAGE_BPS,
|
||||
MIN_SAMPLE_FOR_COMPARISON,
|
||||
build_slippage_comparison_report,
|
||||
fetch_all_samples,
|
||||
insert_realized_slippage_sample,
|
||||
)
|
||||
|
||||
|
||||
def test_report_is_data_gated_below_minimum_sample(tmp_path):
|
||||
db_path = tmp_path / "execution_slippage.db"
|
||||
report = build_slippage_comparison_report(db_path)
|
||||
assert report["status"] == "DATA_GATED"
|
||||
assert report["sample_n"] == 0
|
||||
assert report["actual_mean_slippage_bps"] is None
|
||||
|
||||
|
||||
def test_buy_slippage_sign_is_positive_when_filled_worse(tmp_path):
|
||||
db_path = tmp_path / "execution_slippage.db"
|
||||
result = insert_realized_slippage_sample(
|
||||
db_path,
|
||||
ticker="005930",
|
||||
side="buy",
|
||||
intended_price=70000,
|
||||
actual_fill_price=70070,
|
||||
recorded_at="2026-06-21",
|
||||
)
|
||||
# BUY 체결가가 의도가보다 비싸게 체결됐으면 양수 슬리피지(불리)
|
||||
assert result["slippage_bps_actual"] > 0
|
||||
assert abs(result["slippage_bps_actual"] - 10.0) < 1e-6 # 70/70000 = 10bps
|
||||
|
||||
|
||||
def test_sell_slippage_sign_is_positive_when_filled_worse(tmp_path):
|
||||
db_path = tmp_path / "execution_slippage.db"
|
||||
result = insert_realized_slippage_sample(
|
||||
db_path,
|
||||
ticker="000660",
|
||||
side="SELL",
|
||||
intended_price=200000,
|
||||
actual_fill_price=199900,
|
||||
recorded_at="2026-06-21",
|
||||
)
|
||||
# SELL 체결가가 의도가보다 싸게 체결됐으면 양수 슬리피지(불리)
|
||||
assert result["slippage_bps_actual"] > 0
|
||||
|
||||
|
||||
def test_report_compares_against_assumed_bps_once_min_sample_reached(tmp_path):
|
||||
db_path = tmp_path / "execution_slippage.db"
|
||||
for i in range(MIN_SAMPLE_FOR_COMPARISON):
|
||||
insert_realized_slippage_sample(
|
||||
db_path,
|
||||
ticker="005930",
|
||||
side="BUY",
|
||||
intended_price=70000,
|
||||
actual_fill_price=70070, # 항상 10bps 불리하게 체결
|
||||
recorded_at=f"2026-06-{21 + i}",
|
||||
)
|
||||
|
||||
samples = fetch_all_samples(db_path)
|
||||
assert len(samples) == MIN_SAMPLE_FOR_COMPARISON
|
||||
|
||||
report = build_slippage_comparison_report(db_path)
|
||||
assert report["status"] == "OK"
|
||||
assert abs(report["actual_mean_slippage_bps"] - 10.0) < 1e-6
|
||||
assert abs(report["gap_bps"] - abs(10.0 - ASSUMED_SLIPPAGE_BPS)) < 1e-6
|
||||
assert report["recommendation"]
|
||||
|
||||
|
||||
def test_intended_price_must_be_positive(tmp_path):
|
||||
db_path = tmp_path / "execution_slippage.db"
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
insert_realized_slippage_sample(
|
||||
db_path,
|
||||
ticker="005930",
|
||||
side="BUY",
|
||||
intended_price=0,
|
||||
actual_fill_price=100,
|
||||
recorded_at="2026-06-21",
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import pytest
|
||||
|
||||
from src.quant_engine.kis_api_client_v1 import (
|
||||
KisCredentials,
|
||||
OrderEndpointBlockedError,
|
||||
_assert_read_only,
|
||||
)
|
||||
|
||||
# governance/rules/06_no_direct_api_trading.yaml — 이 테스트는 절대 약화/삭제하지 않는다.
|
||||
|
||||
FORBIDDEN_ORDER_PATHS = (
|
||||
"/uapi/domestic-stock/v1/trading/order-cash",
|
||||
"/uapi/domestic-stock/v1/trading/order-rvsecncl",
|
||||
"/uapi/domestic-stock/v1/trading/order-credit",
|
||||
"/uapi/domestic-stock/v1/trading/order-resv",
|
||||
"/uapi/domestic-stock/v1/trading/inquire-balance", # governance/rules/07 — 계좌 보유종목 조회 금지
|
||||
)
|
||||
FORBIDDEN_ORDER_TR_IDS = (
|
||||
"TTTC0802U", "TTTC0801U", "VTTC0802U", "VTTC0801U",
|
||||
"TTTC8434R", "VTTC8434R", # governance/rules/07 — 주식잔고조회 금지
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", FORBIDDEN_ORDER_PATHS)
|
||||
def test_order_path_is_blocked(path: str):
|
||||
with pytest.raises(OrderEndpointBlockedError):
|
||||
_assert_read_only(path, "FHKST01010100")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("tr_id", FORBIDDEN_ORDER_TR_IDS)
|
||||
def test_order_tr_id_is_blocked(tr_id: str):
|
||||
with pytest.raises(OrderEndpointBlockedError):
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", tr_id)
|
||||
|
||||
|
||||
def test_known_readonly_endpoints_pass():
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-price", "FHKST01010100")
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/inquire-asking-price-exp-ccn", "FHKST01010200")
|
||||
_assert_read_only("/uapi/domestic-stock/v1/quotations/daily-short-sale", "FHPST04830000")
|
||||
|
||||
|
||||
def test_no_order_endpoint_substring_anywhere_in_kis_client_source():
|
||||
"""정적 검증 — 누군가 향후 주문 함수를 추가하더라도 경로 문자열이 소스에 남으면 즉시 탐지.
|
||||
|
||||
TTTC8434R/VTTC8434R(주식잔고조회)는 FORBIDDEN_TR_ID_PREFIXES 차단목록 '데이터'로
|
||||
이 파일에 의도적으로 존재한다(prefix가 아닌 전체 TR_ID라 prefix-매칭으로는 막을 수
|
||||
없어 명시적으로 등재) — 이 두 개는 검사에서 제외한다. 전체 코드베이스 차원의
|
||||
"차단목록 외 파일에는 한 글자도 없어야 한다"는 보장은
|
||||
tools/validate_no_direct_api_trading_v1.py(ALLOWLISTED_FILES 제외 전체 스캔)가 맡는다.
|
||||
"""
|
||||
source = (ROOT / "src" / "quant_engine" / "kis_api_client_v1.py").read_text(encoding="utf-8")
|
||||
blocklist_data_exceptions = {"TTTC8434R", "VTTC8434R"}
|
||||
for forbidden_path in FORBIDDEN_ORDER_PATHS:
|
||||
assert forbidden_path not in source, f"주문 엔드포인트 경로가 소스에 존재함: {forbidden_path}"
|
||||
for forbidden_tr_id in FORBIDDEN_ORDER_TR_IDS:
|
||||
if forbidden_tr_id in blocklist_data_exceptions:
|
||||
continue
|
||||
assert forbidden_tr_id not in source, f"주문 TR_ID가 소스에 존재함: {forbidden_tr_id}"
|
||||
|
||||
|
||||
def test_kis_client_module_defines_no_order_submission_function():
|
||||
import src.quant_engine.kis_api_client_v1 as kis_module
|
||||
|
||||
public_names = [name for name in dir(kis_module) if not name.startswith("_")]
|
||||
banned_keywords = (
|
||||
"place_order", "submit_order", "cancel_order", "revise_order", "send_order",
|
||||
"inquire_balance", "account_balance",
|
||||
)
|
||||
for name in public_names:
|
||||
lowered = name.lower()
|
||||
for banned in banned_keywords:
|
||||
assert banned not in lowered, f"주문 제출/정정/취소로 의심되는 함수가 존재함: {name}"
|
||||
|
||||
|
||||
def test_kis_credentials_load_uses_required_env_vars(monkeypatch):
|
||||
monkeypatch.setenv("KIS_APP_Key", "real-key")
|
||||
monkeypatch.setenv("KIS_APP_Secret", "real-secret")
|
||||
monkeypatch.setenv("KIS_APP_Key_TEST", "mock-key")
|
||||
monkeypatch.setenv("KIS_APP_Secret_TEST", "mock-secret")
|
||||
|
||||
real = KisCredentials.load("real")
|
||||
mock = KisCredentials.load("mock")
|
||||
|
||||
assert real.app_key == "real-key"
|
||||
assert real.app_secret == "real-secret"
|
||||
assert real.account == "real"
|
||||
assert mock.app_key == "mock-key"
|
||||
assert mock.app_secret == "mock-secret"
|
||||
assert mock.account == "mock"
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.qualitative_sell_strategy_store_v1 import (
|
||||
QualitativeSellStoreSpec,
|
||||
fetch_recent_sell_strategy_results,
|
||||
insert_satellite_recommendation,
|
||||
insert_sell_strategy_result,
|
||||
resolve_store_path,
|
||||
)
|
||||
|
||||
|
||||
def test_insert_and_fetch_sell_strategy_result(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
result = {
|
||||
"code": "005930",
|
||||
"generated_at": "2026-06-21T12:00:00+09:00",
|
||||
"decision": {
|
||||
"action": "TRIM_REVIEW_PARTIAL",
|
||||
"conviction": "MEDIUM",
|
||||
"market_regime": "TECHNICAL_MARKET",
|
||||
"composite_score": 0.42,
|
||||
"rationale": "test rationale",
|
||||
},
|
||||
}
|
||||
insert_sell_strategy_result(db_path, result)
|
||||
rows = fetch_recent_sell_strategy_results(db_path, "005930")
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["action"] == "TRIM_REVIEW_PARTIAL"
|
||||
assert rows[0]["composite_score"] == 0.42
|
||||
|
||||
|
||||
def test_fetch_returns_empty_list_when_db_missing(tmp_path):
|
||||
rows = fetch_recent_sell_strategy_results(tmp_path / "nonexistent.db", "005930")
|
||||
assert rows == []
|
||||
|
||||
|
||||
def test_multiple_inserts_ordered_by_generated_at_desc(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
for ts in ("2026-06-19T12:00:00", "2026-06-21T12:00:00", "2026-06-20T12:00:00"):
|
||||
insert_sell_strategy_result(db_path, {
|
||||
"code": "005930", "generated_at": ts,
|
||||
"decision": {"action": "HOLD_NO_CONFLUENCE"},
|
||||
})
|
||||
rows = fetch_recent_sell_strategy_results(db_path, "005930")
|
||||
assert [r["generated_at"] for r in rows] == ["2026-06-21T12:00:00", "2026-06-20T12:00:00", "2026-06-19T12:00:00"]
|
||||
|
||||
|
||||
def test_insert_satellite_recommendation(tmp_path):
|
||||
db_path = tmp_path / "test.db"
|
||||
insert_satellite_recommendation(db_path, "2026-06-21T12:00:00+09:00", {
|
||||
"ticker": "042700",
|
||||
"score": {"satellite_action": "BUY_CANDIDATE", "attractiveness_score": 0.6, "market_regime": "PERFORMANCE_MARKET"},
|
||||
})
|
||||
import sqlite3
|
||||
conn = sqlite3.connect(db_path)
|
||||
row = conn.execute("SELECT ticker, satellite_action, attractiveness_score FROM satellite_recommendations").fetchone()
|
||||
conn.close()
|
||||
assert row == ("042700", "BUY_CANDIDATE", 0.6)
|
||||
|
||||
|
||||
def test_resolve_store_path_supports_sqlite(tmp_path):
|
||||
db_path = resolve_store_path(QualitativeSellStoreSpec(location=tmp_path / "qualitative.db"), ROOT)
|
||||
assert str(db_path).endswith("qualitative.db")
|
||||
@@ -0,0 +1,148 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.qualitative_sell_strategy_v1 import (
|
||||
classify_market_regime,
|
||||
compute_microstructure_pressure_from_orderbook,
|
||||
compute_qualitative_sell_strategy,
|
||||
compute_satellite_candidate_score,
|
||||
compute_short_interest_composite,
|
||||
)
|
||||
|
||||
|
||||
def test_classify_market_regime():
|
||||
assert classify_market_regime("RISING") == "PERFORMANCE_MARKET"
|
||||
assert classify_market_regime("FLAT") == "TECHNICAL_MARKET"
|
||||
assert classify_market_regime("FALLING") == "TECHNICAL_MARKET"
|
||||
assert classify_market_regime(None) == "NEUTRAL"
|
||||
assert classify_market_regime("garbage") == "NEUTRAL"
|
||||
|
||||
|
||||
def test_short_interest_composite_data_missing_without_estimating():
|
||||
result = compute_short_interest_composite({"short_balance_ratio": 0.6})
|
||||
assert result["status"] == "DATA_MISSING"
|
||||
assert "short_turnover_share" in result["missing_inputs"]
|
||||
assert result["short_interest_pressure"] is None
|
||||
|
||||
|
||||
def test_short_interest_composite_low_balance_regime_reweights():
|
||||
low_balance = compute_short_interest_composite({
|
||||
"short_balance_ratio": 0.6, "short_balance_ratio_chg_20d": 0.1,
|
||||
"short_turnover_share": 14.0, "relative_return_20d": -8.0,
|
||||
"volume_ratio_5d": 1.8, "earnings_outlook": "DETERIORATING",
|
||||
})
|
||||
assert low_balance["low_balance_regime"] is True
|
||||
assert low_balance["weights_used"]["balance"] < low_balance["weights_used"]["turnover"]
|
||||
assert low_balance["label"] == "ELEVATED_SHORT_PRESSURE"
|
||||
|
||||
|
||||
def test_confluence_requires_minimum_three_agreeing_factors():
|
||||
# 2개 팩터만 매도방향(macro, short_interest) 합의 — 3개 미달이므로 매도 액션 금지
|
||||
ctx = {
|
||||
"macro_pressure": 0.5, "short_interest_pressure": 0.6,
|
||||
"fundamental_trajectory": -0.5, "microstructure_pressure": -0.4,
|
||||
"liquidity_rotation_risk": 0.1,
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["action"] not in {"EXIT_REVIEW_FULL", "TRIM_REVIEW_PARTIAL"}
|
||||
|
||||
|
||||
def test_confluence_triggers_trim_when_three_factors_agree():
|
||||
ctx = {
|
||||
"macro_pressure": 0.5, "short_interest_pressure": 0.5,
|
||||
"fundamental_trajectory": 0.4, "microstructure_pressure": 0.1,
|
||||
"liquidity_rotation_risk": 0.0,
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["action"] == "TRIM_REVIEW_PARTIAL"
|
||||
assert set(out["sell_agreeing_factors"]) == {"macro_pressure", "short_interest_pressure", "fundamental_trajectory"}
|
||||
|
||||
|
||||
def test_insufficient_data_does_not_fabricate_action():
|
||||
out = compute_qualitative_sell_strategy({"macro_pressure": 0.9})
|
||||
assert out["action"] == "INSUFFICIENT_DATA_NO_ACTION"
|
||||
assert out["mechanical_sell_prohibited"] is True
|
||||
|
||||
|
||||
def test_review_window_pre_earnings_when_outlook_deteriorating():
|
||||
ctx = {
|
||||
"macro_pressure": 0.5, "fundamental_trajectory": 0.5, "short_interest_pressure": 0.5,
|
||||
"earnings_outlook": "DETERIORATING",
|
||||
"next_earnings_date": date(2026, 7, 24),
|
||||
"today": date(2026, 6, 21),
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["review_window"]["window_basis"] == "PRE_EARNINGS_EXIT_BEFORE_SURPRISE_RISK"
|
||||
assert out["review_window"]["review_window_end"] < "2026-07-24"
|
||||
|
||||
|
||||
def test_review_window_defers_past_earnings_when_outlook_improving():
|
||||
ctx = {
|
||||
"macro_pressure": -0.5, "fundamental_trajectory": -0.5, "short_interest_pressure": -0.5,
|
||||
"earnings_outlook": "IMPROVING",
|
||||
"next_earnings_date": date(2026, 7, 24),
|
||||
"today": date(2026, 6, 21),
|
||||
}
|
||||
out = compute_qualitative_sell_strategy(ctx)
|
||||
assert out["action"] == "HOLD_ADD_CONVICTION"
|
||||
assert out["review_window"]["window_basis"] == "REASSESS_AFTER_EARNINGS_CONFIRM"
|
||||
|
||||
|
||||
def test_regime_weighting_shifts_composite_score_without_changing_confluence_count():
|
||||
base_ctx = {
|
||||
"macro_pressure": 0.4, "fundamental_trajectory": 0.6, "short_interest_pressure": 0.35,
|
||||
"microstructure_pressure": 0.1, "liquidity_rotation_risk": 0.0,
|
||||
}
|
||||
performance = compute_qualitative_sell_strategy({**base_ctx, "rate_trend": "RISING"})
|
||||
technical = compute_qualitative_sell_strategy({**base_ctx, "rate_trend": "FALLING"})
|
||||
assert performance["market_regime"] == "PERFORMANCE_MARKET"
|
||||
assert technical["market_regime"] == "TECHNICAL_MARKET"
|
||||
assert performance["sell_agreeing_factors"] == technical["sell_agreeing_factors"]
|
||||
assert performance["composite_score"] != technical["composite_score"]
|
||||
|
||||
|
||||
def test_satellite_candidate_score_insufficient_data():
|
||||
out = compute_satellite_candidate_score({"fundamental_trajectory": 0.2})
|
||||
assert out["satellite_action"] == "INSUFFICIENT_DATA_NO_ACTION"
|
||||
|
||||
|
||||
def test_satellite_candidate_score_buy_candidate_on_strong_export_and_fundamentals():
|
||||
out = compute_satellite_candidate_score({
|
||||
"sector_export_trend": 12.0, "fundamental_trajectory": -0.4,
|
||||
"relative_return_20d": 3.0, "rate_trend": "RISING",
|
||||
})
|
||||
assert out["satellite_action"] == "BUY_CANDIDATE"
|
||||
assert out["market_regime"] == "PERFORMANCE_MARKET"
|
||||
|
||||
|
||||
def test_microstructure_pressure_from_orderbook_ask_heavy_is_positive():
|
||||
out = compute_microstructure_pressure_from_orderbook({"total_askp_rsqn": "300000", "total_bidp_rsqn": "100000"})
|
||||
assert out["status"] == "OK"
|
||||
assert out["microstructure_pressure"] > 0
|
||||
|
||||
|
||||
def test_microstructure_pressure_from_orderbook_bid_heavy_is_negative():
|
||||
out = compute_microstructure_pressure_from_orderbook({"total_askp_rsqn": "100000", "total_bidp_rsqn": "300000"})
|
||||
assert out["microstructure_pressure"] < 0
|
||||
|
||||
|
||||
def test_microstructure_pressure_from_orderbook_missing_fields():
|
||||
out = compute_microstructure_pressure_from_orderbook({})
|
||||
assert out["status"] == "DATA_MISSING"
|
||||
assert out["microstructure_pressure"] is None
|
||||
|
||||
|
||||
def test_map_universe_sector_to_hs_sector_substring_match():
|
||||
from tools.build_satellite_candidate_recommendations_v1 import map_universe_sector_to_hs_sector
|
||||
|
||||
assert map_universe_sector_to_hs_sector("반도체/PCB") == "반도체"
|
||||
assert map_universe_sector_to_hs_sector("자동차/부품") == "자동차"
|
||||
assert map_universe_sector_to_hs_sector("AI전력/기기") is None
|
||||
assert map_universe_sector_to_hs_sector(None) is None
|
||||
@@ -0,0 +1,249 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from src.quant_engine.snapshot_admin_server_v1 import build_ui_state
|
||||
from src.quant_engine.snapshot_admin_store_v1 import (
|
||||
ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS,
|
||||
export_payload,
|
||||
import_seed_json,
|
||||
load_approval_for_domain,
|
||||
load_change_log_rows,
|
||||
load_locks,
|
||||
load_account_snapshot_rows,
|
||||
load_settings_rows,
|
||||
parse_account_snapshot_tsv,
|
||||
open_connection,
|
||||
lock_conflicts_for_rows,
|
||||
validate_account_snapshot_rows,
|
||||
validate_settings_rows,
|
||||
build_validation_suggestions,
|
||||
build_safe_autofix_actions,
|
||||
apply_safe_autofix_action,
|
||||
set_lock,
|
||||
undo_last_change,
|
||||
write_export_json,
|
||||
)
|
||||
|
||||
|
||||
def _seed_json(path: Path) -> None:
|
||||
payload = {
|
||||
"data": {
|
||||
"settings": {
|
||||
"total_asset_krw": 150000000,
|
||||
"weekly_target_cash_pct": 14,
|
||||
"orbit_start_yyyymm": "2026-01",
|
||||
},
|
||||
"account_snapshot": [
|
||||
{
|
||||
"captured_at": "2026-06-21T09:00:00+09:00",
|
||||
"account": "real",
|
||||
"account_type": "일반계좌",
|
||||
"ticker": "005930",
|
||||
"name": "삼성전자",
|
||||
"holding_quantity": 10,
|
||||
"available_quantity": 10,
|
||||
"average_cost": 70000,
|
||||
"total_cost": 700000,
|
||||
"current_price": 71000,
|
||||
"market_value": 710000,
|
||||
"profit_loss": 10000,
|
||||
"return_pct": 1.43,
|
||||
"immediate_cash": 1000000,
|
||||
"settlement_cash_d2": 1000000,
|
||||
"available_cash": 1000000,
|
||||
"open_order_amount": 0,
|
||||
"monthly_contribution_limit": "",
|
||||
"monthly_contribution_used": "",
|
||||
"parse_status": "CAPTURE_READ_OK",
|
||||
"user_confirmed": "Y",
|
||||
"stop_price": 65000,
|
||||
"highest_price_since_entry": 72000,
|
||||
"entry_date": "2026-06-01",
|
||||
"entry_stage": "stage_1",
|
||||
"position_type": "core",
|
||||
"last_updated": "2026-06-21T09:05:00+09:00",
|
||||
}
|
||||
],
|
||||
}
|
||||
}
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def test_seed_import_and_export_round_trip(tmp_path):
|
||||
db_path = tmp_path / "snapshot.db"
|
||||
seed_path = tmp_path / "seed.json"
|
||||
_seed_json(seed_path)
|
||||
|
||||
summary = import_seed_json(db_path, seed_path)
|
||||
assert summary["settings_rows"] == 3
|
||||
assert summary["account_snapshot_rows"] == 1
|
||||
|
||||
settings_rows = load_settings_rows(db_path)
|
||||
assert settings_rows[0]["key"] == "total_asset_krw"
|
||||
assert settings_rows[0]["value"] == 150000000
|
||||
|
||||
snapshot_rows = load_account_snapshot_rows(db_path)
|
||||
assert snapshot_rows[0]["ticker"] == "005930"
|
||||
assert snapshot_rows[0]["parse_status"] == "CAPTURE_READ_OK"
|
||||
|
||||
exported = export_payload(db_path)
|
||||
assert exported["data"]["settings"]["weekly_target_cash_pct"] == 14
|
||||
assert exported["data"]["account_snapshot"][0]["name"] == "삼성전자"
|
||||
|
||||
out = write_export_json(db_path, tmp_path / "export.json")
|
||||
assert out.exists()
|
||||
|
||||
|
||||
def test_parse_account_snapshot_tsv_supports_headerless_and_header_rows():
|
||||
headerless = "\n".join(
|
||||
[
|
||||
"\t".join(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS),
|
||||
"\t".join(
|
||||
[
|
||||
"2026-06-21T09:00:00+09:00",
|
||||
"real",
|
||||
"일반계좌",
|
||||
"005930",
|
||||
"삼성전자",
|
||||
"10",
|
||||
"10",
|
||||
"70000",
|
||||
"700000",
|
||||
"71000",
|
||||
"710000",
|
||||
"10000",
|
||||
"1.43",
|
||||
"1000000",
|
||||
"1000000",
|
||||
"1000000",
|
||||
"0",
|
||||
"",
|
||||
"",
|
||||
"CAPTURE_READ_OK",
|
||||
"Y",
|
||||
"65000",
|
||||
"72000",
|
||||
"2026-06-01",
|
||||
"stage_1",
|
||||
"core",
|
||||
"2026-06-21T09:05:00+09:00",
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
rows = parse_account_snapshot_tsv(headerless)
|
||||
assert rows[0]["ticker"] == "005930"
|
||||
assert rows[0]["holding_quantity"] == 10
|
||||
|
||||
with_header = "captured_at\taccount\tticker\n2026-06-21T09:00:00+09:00\treal\t005930"
|
||||
rows2 = parse_account_snapshot_tsv(with_header)
|
||||
assert rows2[0]["account"] == "real"
|
||||
assert rows2[0]["ticker"] == "005930"
|
||||
|
||||
|
||||
def test_build_ui_state_reports_schema(tmp_path):
|
||||
db_path = tmp_path / "snapshot.db"
|
||||
seed_path = tmp_path / "seed.json"
|
||||
_seed_json(seed_path)
|
||||
import_seed_json(db_path, seed_path)
|
||||
|
||||
state = build_ui_state(db_path)
|
||||
assert state["summary"]["settings_rows"] == 3
|
||||
assert state["account_snapshot_columns"][: len(ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS)] == ACCOUNT_SNAPSHOT_CANONICAL_COLUMNS
|
||||
|
||||
|
||||
def test_change_log_approval_and_lock_workflow(tmp_path):
|
||||
db_path = tmp_path / "snapshot.db"
|
||||
seed_path = tmp_path / "seed.json"
|
||||
_seed_json(seed_path)
|
||||
import_seed_json(db_path, seed_path)
|
||||
|
||||
with open_connection(db_path) as conn:
|
||||
set_lock(conn, "settings", "*", locked_by="tester", reason="review")
|
||||
conn.commit()
|
||||
|
||||
locks = load_locks(db_path)
|
||||
assert locks and locks[0]["domain"] == "settings"
|
||||
|
||||
approval = load_approval_for_domain(db_path, "settings")
|
||||
assert approval["status"] == "PENDING"
|
||||
|
||||
changes = load_change_log_rows(db_path, limit=10)
|
||||
assert changes
|
||||
|
||||
|
||||
def test_lock_conflicts_detect_row_targets(tmp_path):
|
||||
db_path = tmp_path / "snapshot.db"
|
||||
seed_path = tmp_path / "seed.json"
|
||||
_seed_json(seed_path)
|
||||
import_seed_json(db_path, seed_path)
|
||||
|
||||
with open_connection(db_path) as conn:
|
||||
set_lock(conn, "settings", "total_asset_krw", locked_by="tester", reason="review")
|
||||
set_lock(conn, "account_snapshot", "005930", locked_by="tester", reason="review")
|
||||
conn.commit()
|
||||
|
||||
settings_conflicts = lock_conflicts_for_rows(
|
||||
db_path,
|
||||
"settings",
|
||||
[{"key": "total_asset_krw", "value": 123, "note": ""}],
|
||||
)
|
||||
snapshot_conflicts = lock_conflicts_for_rows(
|
||||
db_path,
|
||||
"account_snapshot",
|
||||
[{"ticker": "005930", "name": "삼성전자", "ordinal": 1}],
|
||||
)
|
||||
|
||||
assert settings_conflicts and settings_conflicts[0]["target_ref"] == "total_asset_krw"
|
||||
assert snapshot_conflicts and snapshot_conflicts[0]["target_ref"] == "005930"
|
||||
|
||||
|
||||
def test_undo_last_change_restores_previous_snapshot(tmp_path):
|
||||
db_path = tmp_path / "snapshot.db"
|
||||
seed_path = tmp_path / "seed.json"
|
||||
_seed_json(seed_path)
|
||||
import_seed_json(db_path, seed_path)
|
||||
|
||||
with open_connection(db_path) as conn:
|
||||
from src.quant_engine.snapshot_admin_store_v1 import replace_settings
|
||||
|
||||
replace_settings(conn, [{"ordinal": 1, "key": "total_asset_krw", "value": 123, "note": "edited"}])
|
||||
|
||||
with open_connection(db_path) as conn:
|
||||
undo_last_change(conn, "settings")
|
||||
|
||||
settings_rows = load_settings_rows(db_path)
|
||||
assert settings_rows[0]["value"] == 150000000
|
||||
|
||||
|
||||
def test_validation_helpers_detect_invalid_rows():
|
||||
assert "settings.total_asset_krw is required" in validate_settings_rows([{"key": "weekly_target_cash_pct", "value": 10}])
|
||||
assert "account_snapshot row 1: ticker required" in validate_account_snapshot_rows(
|
||||
[{"captured_at": "2026-06-21", "account": "real", "name": "삼성전자", "parse_status": "BAD"}]
|
||||
)
|
||||
suggestions = build_validation_suggestions(
|
||||
[{"key": "weekly_target_cash_pct", "value": 10}],
|
||||
[{"captured_at": "2026-06-21", "account": "real", "account_type": "일반계좌", "ticker": "005930", "name": "삼성전자", "parse_status": "CAPTURE_READ_OK", "user_confirmed": "N"}],
|
||||
)
|
||||
assert any("user_confirmed=Y" in item for item in suggestions)
|
||||
actions = build_safe_autofix_actions(
|
||||
[{"key": "total_asset_krw", "value": 150000000}],
|
||||
[{"captured_at": "2026-06-21", "account": "real", "account_type": "일반계좌", "ticker": "005930", "name": "삼성전자", "parse_status": "CAPTURE_READ_OK", "user_confirmed": "N", "entry_stage": "stage_1", "position_type": ""}],
|
||||
)
|
||||
assert any(item["action_id"] == "confirm_captured_rows" for item in actions)
|
||||
|
||||
|
||||
def test_safe_autofix_updates_snapshot_defaults(tmp_path):
|
||||
db_path = tmp_path / "snapshot.db"
|
||||
seed_path = tmp_path / "seed.json"
|
||||
_seed_json(seed_path)
|
||||
import_seed_json(db_path, seed_path)
|
||||
|
||||
with open_connection(db_path) as conn:
|
||||
result = apply_safe_autofix_action(conn, "confirm_captured_rows")
|
||||
assert result["status"] == "AUTOFIXED"
|
||||
|
||||
snapshot_rows = load_account_snapshot_rows(db_path)
|
||||
assert all(row.get("user_confirmed") == "Y" or str(row.get("parse_status")) != "CAPTURE_READ_OK" for row in snapshot_rows)
|
||||
@@ -0,0 +1,144 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import tools.validate_snapshot_admin_web_v1 as validator
|
||||
from src.quant_engine.snapshot_admin_server_v1 import (
|
||||
build_ui_state,
|
||||
fetch_table_rows,
|
||||
list_browsable_tables,
|
||||
render_collection_html,
|
||||
render_index_html,
|
||||
render_tables_html,
|
||||
)
|
||||
from src.quant_engine.snapshot_admin_store_v1 import import_seed_json
|
||||
|
||||
|
||||
def test_render_index_html_contains_spreadsheet_surface():
|
||||
html = render_index_html()
|
||||
assert "Snapshot Admin" in html
|
||||
assert "contenteditable" in html
|
||||
assert "/api/settings/save" in html
|
||||
assert "/api/account_snapshot/save" in html
|
||||
assert "Lock target" in html
|
||||
assert "Lock row" in html
|
||||
assert "Approve pending" in html
|
||||
assert "Refresh diff" in html
|
||||
assert "Export approval packet" in html
|
||||
assert "Selection Inspector" in html
|
||||
assert "Recent row history" in html
|
||||
assert "Save view" in html
|
||||
assert "Apply TSV to selection" in html
|
||||
assert "Ctrl+S" in html
|
||||
assert "KIS Collection" in html
|
||||
assert "Recent collector snapshots" in html
|
||||
assert "Collection detail" in html
|
||||
assert "Filter runs / snapshots / errors" in html
|
||||
assert "Filter change log" in html
|
||||
assert "Timeline" in html
|
||||
assert "/collection" in html
|
||||
assert "Open collection dashboard" in html
|
||||
|
||||
|
||||
def test_render_collection_html_contains_dashboard_surface():
|
||||
html = render_collection_html()
|
||||
assert "KIS Collection Dashboard" in html
|
||||
assert "/api/state" in html
|
||||
assert "Download raw JSON" in html
|
||||
assert "Download CSV" in html
|
||||
assert "Filter runs / snapshots / errors" in html
|
||||
assert "Ticker quick search" in html
|
||||
assert "Date quick search" in html
|
||||
|
||||
|
||||
def test_build_ui_state_exposes_expected_columns(tmp_path):
|
||||
db_path = tmp_path / "snapshot_admin.db"
|
||||
seed_path = ROOT / "GatherTradingData.json"
|
||||
import_seed_json(db_path, seed_path)
|
||||
|
||||
state = build_ui_state(db_path)
|
||||
assert state["summary"]["settings_rows"] > 0
|
||||
assert state["summary"]["account_snapshot_rows"] > 0
|
||||
assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite"
|
||||
assert state["summary"]["topology"]["settings_and_snapshot_share_db"] is True
|
||||
assert state["summary"]["topology"]["collector_separate_db"] is True
|
||||
assert state["account_snapshot_columns"][0] == "captured_at"
|
||||
assert "settings" in state["validation"]
|
||||
assert state["version"]["app"]
|
||||
assert "fingerprint" in state["version"]["source"]
|
||||
assert "collection" in state
|
||||
assert "counts" in state["collection"]
|
||||
assert "latest_report" in state["collection"]
|
||||
assert state["summary"]["topology"]["mode"] == "single_workspace_sqlite"
|
||||
|
||||
|
||||
def test_snapshot_admin_workflow_and_script_exist():
|
||||
workflow = ROOT / ".gitea" / "workflows" / "snapshot_admin.yml"
|
||||
package = json.loads((ROOT / "package.json").read_text(encoding="utf-8"))
|
||||
assert workflow.exists()
|
||||
assert "--reload" in package["scripts"]["ops:snapshot-web"]
|
||||
assert "ops:snapshot-validate" in package["scripts"]
|
||||
assert "ops:snapshot-web-validate" in package["scripts"]
|
||||
|
||||
|
||||
def test_render_tables_html_contains_tabler_grid_surface():
|
||||
html = render_tables_html()
|
||||
assert "tabler" in html.lower()
|
||||
assert "tableSelect" in html
|
||||
assert "/api/tables" in html
|
||||
assert "/api/table_rows" in html
|
||||
assert "gridTable" in html
|
||||
|
||||
|
||||
def test_list_browsable_tables_covers_all_three_databases(tmp_path):
|
||||
db_path = tmp_path / "snapshot_admin.db"
|
||||
import_seed_json(db_path, ROOT / "GatherTradingData.json")
|
||||
|
||||
tables = list_browsable_tables(db_path)
|
||||
names = {row["table"] for row in tables}
|
||||
assert {"settings", "account_snapshot", "workspace_change_log"} <= names
|
||||
assert {"collection_runs", "collection_snapshots", "collection_source_errors"} <= names
|
||||
assert {"sell_strategy_results", "satellite_recommendations"} <= names
|
||||
|
||||
settings_row = next(row for row in tables if row["table"] == "settings")
|
||||
assert settings_row["exists"] is True
|
||||
assert settings_row["row_count"] > 0
|
||||
|
||||
|
||||
def test_fetch_table_rows_paginates_and_rejects_unknown_table(tmp_path):
|
||||
db_path = tmp_path / "snapshot_admin.db"
|
||||
import_seed_json(db_path, ROOT / "GatherTradingData.json")
|
||||
|
||||
page1 = fetch_table_rows("settings", db_path, limit=2, offset=0)
|
||||
assert page1["columns"]
|
||||
assert len(page1["rows"]) == 2
|
||||
assert page1["total"] > 2
|
||||
|
||||
page2 = fetch_table_rows("settings", db_path, limit=2, offset=2)
|
||||
assert page1["rows"] != page2["rows"]
|
||||
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
fetch_table_rows("settings; DROP TABLE settings;--", db_path)
|
||||
|
||||
|
||||
def test_snapshot_admin_web_validation_script_passes():
|
||||
out = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
|
||||
if out.exists():
|
||||
out.unlink()
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 0
|
||||
assert payload["gate"] == "PASS"
|
||||
assert payload["formula_id"] == "SNAPSHOT_ADMIN_WEB_VALIDATION_V1"
|
||||
assert payload["settings_rows"] > 0
|
||||
assert payload["account_snapshot_rows"] > 0
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.storage_backend_v1 import StoreSpec, default_sqlite_store_path, normalize_store_spec
|
||||
|
||||
|
||||
def test_default_sqlite_store_path_uses_named_subdir(tmp_path):
|
||||
path = default_sqlite_store_path(tmp_path, "qualitative_sell_strategy/qualitative_sell_strategy.db")
|
||||
assert str(path).endswith("qualitative_sell_strategy.db")
|
||||
|
||||
|
||||
def test_normalize_store_spec_supports_sqlite_and_postgresql(tmp_path):
|
||||
backend_sqlite, sqlite_location = normalize_store_spec(StoreSpec(location=tmp_path / "collector.db"), ROOT)
|
||||
assert backend_sqlite == "sqlite"
|
||||
assert str(sqlite_location).endswith("collector.db")
|
||||
|
||||
backend_pg, pg_location = normalize_store_spec(
|
||||
StoreSpec(backend="postgresql", location="postgresql://user:pass@localhost/db"),
|
||||
ROOT,
|
||||
)
|
||||
assert backend_pg == "postgresql"
|
||||
assert "postgresql://" in str(pg_location)
|
||||
|
||||
|
||||
def test_postgresql_upgrade_stub_script_exists():
|
||||
assert (ROOT / "tools" / "generate_postgresql_upgrade_stub_v1.py").exists()
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import tools.validate_gitea_secrets_contract_v1 as validator
|
||||
|
||||
|
||||
def test_validate_gitea_secrets_contract_passes():
|
||||
rc = validator.main()
|
||||
payload = json.loads((ROOT / "Temp" / "gitea_secrets_contract_v1.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 0
|
||||
assert payload["gate"] == "PASS"
|
||||
assert payload["evidence"][".gitea/workflows/kis_data_collection.yml"]["secrets.KIS_APP_KEY"] is True
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import tools.validate_kis_api_credentials_v1 as validator
|
||||
|
||||
|
||||
class _FakeCreds:
|
||||
def __init__(self, account: str):
|
||||
self.account = account
|
||||
self.domain = "https://openapi.koreainvestment.com:9443" if account == "real" else "https://openapivts.koreainvestment.com:29443"
|
||||
self.app_key = f"{account}-key"
|
||||
self.app_secret = f"{account}-secret"
|
||||
|
||||
|
||||
def test_validate_kis_api_credentials_writes_pass_json(tmp_path, monkeypatch):
|
||||
out = tmp_path / "kis_api_credentials_validation_v1.json"
|
||||
|
||||
monkeypatch.setenv("KIS_APP_Key_TEST", "mock-key")
|
||||
monkeypatch.setenv("KIS_APP_Secret_TEST", "mock-secret")
|
||||
monkeypatch.setattr(validator, "KisCredentials", type("CredFactory", (), {"load": staticmethod(lambda account: _FakeCreds(account))}))
|
||||
monkeypatch.setattr(validator, "get_current_price", lambda creds, ticker: {"ticker": ticker, "price": 1000})
|
||||
monkeypatch.setattr(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--output", str(out)])
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 0
|
||||
assert payload["gate"] == "PASS"
|
||||
assert payload["evidence"]["account"] == "mock"
|
||||
assert payload["evidence"]["ticker"] == "005930"
|
||||
|
||||
|
||||
def test_validate_kis_api_credentials_fails_when_api_call_errors(tmp_path, monkeypatch):
|
||||
out = tmp_path / "kis_api_credentials_validation_v1.json"
|
||||
|
||||
monkeypatch.setattr(validator, "KisCredentials", type("CredFactory", (), {"load": staticmethod(lambda account: _FakeCreds(account))}))
|
||||
monkeypatch.setattr(validator, "get_current_price", lambda creds, ticker: (_ for _ in ()).throw(RuntimeError("boom")))
|
||||
monkeypatch.setattr(sys, "argv", ["validate_kis_api_credentials_v1.py", "--account", "mock", "--ticker", "005930", "--output", str(out)])
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads(out.read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 1
|
||||
assert payload["gate"] == "FAIL"
|
||||
assert payload["errors"]
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import tools.validate_qualitative_sell_strategy_pipeline_v1 as validator
|
||||
|
||||
|
||||
def test_validate_qualitative_sell_strategy_pipeline_passes(tmp_path, monkeypatch):
|
||||
out = tmp_path / "qualitative_sell_strategy_pipeline_v1.json"
|
||||
monkeypatch.setattr(sys, "argv", ["validate_qualitative_sell_strategy_pipeline_v1.py"])
|
||||
monkeypatch.setattr(validator, "ROOT", ROOT)
|
||||
|
||||
rc = validator.main()
|
||||
payload = json.loads((ROOT / "Temp" / "qualitative_sell_strategy_pipeline_v1.json").read_text(encoding="utf-8"))
|
||||
|
||||
assert rc == 0
|
||||
assert payload["gate"] == "PASS"
|
||||
assert payload["checks"]["store_contract"] is True
|
||||
@@ -0,0 +1,69 @@
|
||||
"""WBS-7.11(2026-06-22) — spec-코드 동기화 게이트 단위 테스트."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import tools.validate_specs as vs
|
||||
|
||||
|
||||
def test_real_repo_has_no_missing_code_path():
|
||||
"""현재 저장소 상태에서 1차 태깅된 파일들은 모두 code_path가 실존해야 한다."""
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "PASS"
|
||||
assert result["missing_code_path_count"] == 0
|
||||
assert result["checked_count"] >= 10
|
||||
assert not errors
|
||||
|
||||
|
||||
def test_missing_code_path_fails(tmp_path, monkeypatch):
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "fake_contract.yaml").write_text(
|
||||
"meta:\n has_code_implementation: true\n code_path: \"tools/does_not_exist_v1.py\"\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(vs, "ROOT", tmp_path)
|
||||
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "FAIL"
|
||||
assert result["missing_code_path_count"] == 1
|
||||
assert any("does_not_exist_v1.py" in e for e in errors)
|
||||
|
||||
|
||||
def test_redirect_only_and_has_code_is_contradiction(tmp_path, monkeypatch):
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "contradiction.yaml").write_text(
|
||||
"meta:\n has_code_implementation: true\n redirect_only: true\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(vs, "ROOT", tmp_path)
|
||||
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "FAIL"
|
||||
assert any("contradiction" in e for e in errors)
|
||||
|
||||
|
||||
def test_files_without_the_field_are_skipped_not_failed(tmp_path, monkeypatch):
|
||||
(tmp_path / "spec").mkdir()
|
||||
(tmp_path / "governance").mkdir()
|
||||
(tmp_path / "spec" / "untouched.yaml").write_text(
|
||||
"meta:\n title: legacy doc with no sync field\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(vs, "ROOT", tmp_path)
|
||||
|
||||
errors: list[str] = []
|
||||
result = vs.validate_spec_code_sync(errors)
|
||||
assert result["gate"] == "PASS"
|
||||
assert result["checked_count"] == 0
|
||||
assert result["total_spec_files"] == 1
|
||||
assert not errors
|
||||
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_calibration_approval_list_v1.py
|
||||
───────────────────────────────────────────────────────────────────────────────
|
||||
calibration_review_report_v1.json을 읽어 PROVISIONAL 승격 승인 리스트를 만든다.
|
||||
|
||||
목적:
|
||||
- source=PROVISIONAL 인 임계값을 별도 승인 대상 리스트로 분리
|
||||
- reviewer가 바로 볼 수 있는 Markdown/JSON 산출물 생성
|
||||
- PROVISIONAL 승격과 provisional review를 분리해 운영 책임을 명확화
|
||||
|
||||
출력:
|
||||
Temp/calibration_approval_list_v1.json
|
||||
Temp/calibration_approval_list_v1.md
|
||||
|
||||
사용법:
|
||||
python tools/build_calibration_approval_list_v1.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
REVIEW = ROOT / "Temp" / "calibration_review_report_v1.json"
|
||||
OUT_JSON = ROOT / "Temp" / "calibration_approval_list_v1.json"
|
||||
OUT_MD = ROOT / "Temp" / "calibration_approval_list_v1.md"
|
||||
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _table(rows: list[dict[str, Any]], keys: list[str], max_rows: int = 25) -> str:
|
||||
if not rows:
|
||||
return "_데이터 없음_"
|
||||
header = "| " + " | ".join(keys) + " |"
|
||||
sep = "| " + " | ".join(["---"] * len(keys)) + " |"
|
||||
body = []
|
||||
for row in rows[:max_rows]:
|
||||
body.append("| " + " | ".join(str(row.get(k, "")).replace("|", "ㅣ") for k in keys) + " |")
|
||||
suffix = f"\n\n_...총 {len(rows)}행 중 {max_rows}행 표시_" if len(rows) > max_rows else ""
|
||||
return "\n".join([header, sep, *body]) + suffix
|
||||
|
||||
|
||||
def main() -> int:
|
||||
review = _load_json(REVIEW)
|
||||
rows = review.get("review_rows") if isinstance(review.get("review_rows"), list) else []
|
||||
|
||||
approval_candidates: list[dict[str, Any]] = []
|
||||
provisional_review_candidates: list[dict[str, Any]] = []
|
||||
|
||||
for row in rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
source = str(row.get("source") or "")
|
||||
readiness = str(row.get("readiness") or "")
|
||||
sample_n = int(row.get("sample_n") or 0)
|
||||
base = {
|
||||
"id": row.get("id", ""),
|
||||
"source": source,
|
||||
"sample_n": sample_n,
|
||||
"value": row.get("value"),
|
||||
"unit": row.get("unit", ""),
|
||||
"owner_formula": row.get("owner_formula", ""),
|
||||
"readiness": readiness,
|
||||
"reason": row.get("reason", ""),
|
||||
}
|
||||
if source == "PROVISIONAL":
|
||||
approval_candidates.append(base)
|
||||
elif readiness == "PROVISIONAL_CANDIDATE":
|
||||
provisional_review_candidates.append(base)
|
||||
|
||||
approval_candidates.sort(key=lambda item: (-int(item.get("sample_n") or 0), str(item.get("id") or "")))
|
||||
provisional_review_candidates.sort(key=lambda item: (-int(item.get("sample_n") or 0), str(item.get("id") or "")))
|
||||
|
||||
report = {
|
||||
"formula_id": "CALIBRATION_APPROVAL_LIST_V1",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"review_report_path": str(REVIEW),
|
||||
"approval_candidate_count": len(approval_candidates),
|
||||
"provisional_review_candidate_count": len(provisional_review_candidates),
|
||||
"approval_candidates": approval_candidates,
|
||||
"provisional_review_candidates": provisional_review_candidates,
|
||||
}
|
||||
|
||||
OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
md_lines = [
|
||||
"# Calibration Approval List",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- approval candidates: {len(approval_candidates)}",
|
||||
f"- provisional review candidates: {len(provisional_review_candidates)}",
|
||||
"",
|
||||
"## Approval Candidates",
|
||||
"",
|
||||
_table(approval_candidates, ["id", "source", "sample_n", "value", "unit", "owner_formula", "readiness", "reason"]),
|
||||
"",
|
||||
"## Provisional Review Candidates",
|
||||
"",
|
||||
_table(provisional_review_candidates, ["id", "source", "sample_n", "value", "unit", "owner_formula", "readiness", "reason"]),
|
||||
"",
|
||||
"## Evidence",
|
||||
"",
|
||||
f"- review report: {REVIEW}",
|
||||
]
|
||||
OUT_MD.write_text("\n".join(md_lines), encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"formula_id": report["formula_id"],
|
||||
"gate": "PASS" if approval_candidates else "WARN",
|
||||
"approval_candidate_count": len(approval_candidates),
|
||||
"provisional_review_candidate_count": len(provisional_review_candidates),
|
||||
"json_path": str(OUT_JSON),
|
||||
"md_path": str(OUT_MD),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_calibration_decision_draft_v1.py
|
||||
───────────────────────────────────────────────────────────────────────────────
|
||||
calibration_review_report_v1.json / calibration_approval_list_v1.json을 바탕으로
|
||||
운영 승인 초안(APPROVE / HOLD / REJECT)을 만든다.
|
||||
|
||||
목적:
|
||||
- 사람 검토 전 단계에서 결정 초안을 자동 생성
|
||||
- source=PROVISIONAL은 원칙적으로 APPROVE
|
||||
- PROVISIONAL_CANDIDATE는 HOLD
|
||||
- 나머지는 REJECT 또는 HOLD로 사유를 명시
|
||||
|
||||
출력:
|
||||
Temp/calibration_decision_draft_v1.json
|
||||
Temp/calibration_decision_draft_v1.md
|
||||
|
||||
사용법:
|
||||
python tools/build_calibration_decision_draft_v1.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
REVIEW = ROOT / "Temp" / "calibration_review_report_v1.json"
|
||||
APPROVAL = ROOT / "Temp" / "calibration_approval_list_v1.json"
|
||||
OUT_JSON = ROOT / "Temp" / "calibration_decision_draft_v1.json"
|
||||
OUT_MD = ROOT / "Temp" / "calibration_decision_draft_v1.md"
|
||||
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _table(rows: list[dict[str, Any]], keys: list[str], max_rows: int = 25) -> str:
|
||||
if not rows:
|
||||
return "_데이터 없음_"
|
||||
header = "| " + " | ".join(keys) + " |"
|
||||
sep = "| " + " | ".join(["---"] * len(keys)) + " |"
|
||||
body = []
|
||||
for row in rows[:max_rows]:
|
||||
body.append("| " + " | ".join(str(row.get(k, "")).replace("|", "ㅣ") for k in keys) + " |")
|
||||
suffix = f"\n\n_...총 {len(rows)}행 중 {max_rows}행 표시_" if len(rows) > max_rows else ""
|
||||
return "\n".join([header, sep, *body]) + suffix
|
||||
|
||||
|
||||
def _decide(row: dict[str, Any]) -> tuple[str, str]:
|
||||
source = str(row.get("source") or "")
|
||||
readiness = str(row.get("readiness") or "")
|
||||
sample_n = int(row.get("sample_n") or 0)
|
||||
if source == "PROVISIONAL" and sample_n >= 30:
|
||||
return "APPROVE", "source=PROVISIONAL and sample_n>=30"
|
||||
if source == "PROVISIONAL":
|
||||
return "APPROVE", "source=PROVISIONAL"
|
||||
if readiness == "PROVISIONAL_CANDIDATE":
|
||||
return "HOLD", "Needs provisional review"
|
||||
if sample_n >= 10:
|
||||
return "HOLD", "Sample present but not provisional"
|
||||
return "REJECT", "Insufficient evidence"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
review = _load_json(REVIEW)
|
||||
approval = _load_json(APPROVAL)
|
||||
|
||||
review_rows = review.get("review_rows") if isinstance(review.get("review_rows"), list) else []
|
||||
decisions: list[dict[str, Any]] = []
|
||||
summary = {"APPROVE": 0, "HOLD": 0, "REJECT": 0}
|
||||
|
||||
for row in review_rows:
|
||||
if not isinstance(row, dict):
|
||||
continue
|
||||
decision, reason = _decide(row)
|
||||
item = {
|
||||
"id": row.get("id", ""),
|
||||
"source": row.get("source", ""),
|
||||
"sample_n": int(row.get("sample_n") or 0),
|
||||
"value": row.get("value"),
|
||||
"unit": row.get("unit", ""),
|
||||
"owner_formula": row.get("owner_formula", ""),
|
||||
"readiness": row.get("readiness", ""),
|
||||
"decision": decision,
|
||||
"reason": reason,
|
||||
}
|
||||
decisions.append(item)
|
||||
summary[decision] += 1
|
||||
|
||||
decisions.sort(key=lambda item: ({"APPROVE": 0, "HOLD": 1, "REJECT": 2}.get(str(item.get("decision") or ""), 3), -int(item.get("sample_n") or 0), str(item.get("id") or "")))
|
||||
|
||||
report = {
|
||||
"formula_id": "CALIBRATION_DECISION_DRAFT_V1",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"review_report_path": str(REVIEW),
|
||||
"approval_list_path": str(APPROVAL),
|
||||
"summary": summary,
|
||||
"decision_count": len(decisions),
|
||||
"decisions": decisions,
|
||||
"approval_candidate_count": int(approval.get("approval_candidate_count") or 0),
|
||||
}
|
||||
|
||||
OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
md_lines = [
|
||||
"# Calibration Decision Draft",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- APPROVE: {summary['APPROVE']}",
|
||||
f"- HOLD: {summary['HOLD']}",
|
||||
f"- REJECT: {summary['REJECT']}",
|
||||
f"- decision_count: {len(decisions)}",
|
||||
"",
|
||||
"## Decision Table",
|
||||
"",
|
||||
_table(decisions, ["id", "source", "sample_n", "decision", "reason", "owner_formula", "readiness"]),
|
||||
"",
|
||||
"## Evidence",
|
||||
"",
|
||||
f"- review report: {REVIEW}",
|
||||
f"- approval list: {APPROVAL}",
|
||||
]
|
||||
OUT_MD.write_text("\n".join(md_lines), encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"formula_id": report["formula_id"],
|
||||
"gate": "PASS" if summary["APPROVE"] else "WARN",
|
||||
"approve_count": summary["APPROVE"],
|
||||
"hold_count": summary["HOLD"],
|
||||
"reject_count": summary["REJECT"],
|
||||
"json_path": str(OUT_JSON),
|
||||
"md_path": str(OUT_MD),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -29,6 +29,41 @@ ROOT = Path(__file__).resolve().parent.parent
|
||||
AFL = ROOT / "Temp" / "alpha_feedback_loop_v2.json"
|
||||
REG = ROOT / "spec" / "calibration_registry.yaml"
|
||||
OUTPUT = ROOT / "Temp" / "calibration_priority_v1.json"
|
||||
PREDICTION_ACCURACY = ROOT / "Temp" / "prediction_accuracy_harness_v2.json"
|
||||
|
||||
|
||||
def registry_source_breakdown(reg_index: dict[str, dict]) -> dict:
|
||||
"""WBS-7.1(2026-06-21) — calibration_registry.yaml 전체의 source별 분포를 매 실행마다
|
||||
집계해 'CALIBRATED 비율이 실제로 몇 %인가'를 사람이 grep으로 직접 세지 않아도
|
||||
항상 최신 상태로 노출한다(2026-06-21 비판적 리뷰 0c절에서 0/190 발견 당시 수동 집계 필요했던 문제 해소)."""
|
||||
counts: dict[str, int] = {"SPEC_DERIVED": 0, "EXPERT_PRIOR": 0, "PROVISIONAL": 0, "CALIBRATED": 0}
|
||||
for entry in reg_index.values():
|
||||
source = str(entry.get("source", "")).upper()
|
||||
if source in counts:
|
||||
counts[source] += 1
|
||||
total = sum(counts.values())
|
||||
return {
|
||||
"total_thresholds": total,
|
||||
"counts": counts,
|
||||
"calibrated_pct": round(100.0 * counts["CALIBRATED"] / total, 2) if total else 0.0,
|
||||
"unvalidated_pct": round(100.0 * (counts["SPEC_DERIVED"] + counts["EXPERT_PRIOR"]) / total, 2) if total else 0.0,
|
||||
}
|
||||
|
||||
|
||||
def live_t5_status() -> dict:
|
||||
"""WBS-7.2/7.1(2026-06-21) — T+5 수치를 하드코딩하지 않고 항상 최신 산출물에서 읽는다.
|
||||
Temp/prediction_accuracy_harness_v2.json이 없거나 sample=0이면 정직하게 DATA_GATED로 보고한다."""
|
||||
if not PREDICTION_ACCURACY.exists():
|
||||
return {"status": "ARTIFACT_MISSING", "t5_sample": 0, "t5_match_rate_pct": None}
|
||||
data = load_json(PREDICTION_ACCURACY)
|
||||
t5_sample = int(data.get("t5_sample") or 0)
|
||||
t5_rate = data.get("t5_op_rate")
|
||||
return {
|
||||
"status": "DATA_GATED" if t5_sample == 0 else "OK",
|
||||
"as_of_date": data.get("as_of_date"),
|
||||
"t5_sample": t5_sample,
|
||||
"t5_match_rate_pct": t5_rate,
|
||||
}
|
||||
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
|
||||
@@ -90,6 +125,42 @@ def load_registry(p: Path) -> dict[str, dict]:
|
||||
return {t["id"]: t for t in data.get("thresholds", []) if "id" in t}
|
||||
|
||||
|
||||
def _priority_from_registry_entry(entry: dict, source_tag: str, urgency_bias: int) -> dict:
|
||||
sample_n = int(entry.get("sample_n", 0) or 0)
|
||||
source = str(entry.get("source", "EXPERT_PRIOR"))
|
||||
threshold_class = str(entry.get("threshold_class", "standard"))
|
||||
urgency = urgency_bias
|
||||
if source == "EXPERT_PRIOR":
|
||||
urgency += 10
|
||||
if source == "PROVISIONAL":
|
||||
urgency += 20
|
||||
if threshold_class == "live_critical":
|
||||
urgency += 15
|
||||
if sample_n == 0:
|
||||
urgency += 5
|
||||
if sample_n > 0:
|
||||
urgency += max(0, 30 - sample_n)
|
||||
return {
|
||||
"calibration_id": entry.get("id", ""),
|
||||
"current_value": entry.get("value"),
|
||||
"owner_formula": entry.get("owner_formula", ""),
|
||||
"source": source,
|
||||
"sample_n": sample_n,
|
||||
"linked_factor": source_tag,
|
||||
"alpha_action": "registry_review",
|
||||
"urgency_score": urgency,
|
||||
"calibration_path": (
|
||||
(
|
||||
"표본 30건 이상 확보 후 PROVISIONAL 승격 → "
|
||||
if sample_n >= 30
|
||||
else f"표본 {30 - sample_n}건 추가 수집 후 PROVISIONAL 승격 → "
|
||||
)
|
||||
+ "실측 T+5 승률 기반 최적값 backtest → CALIBRATED 확정"
|
||||
),
|
||||
"rationale": f"source={source}, class={threshold_class}, sample_n={sample_n}",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
afl_data = load_json(AFL)
|
||||
reg_index = load_registry(REG)
|
||||
@@ -112,48 +183,32 @@ def main() -> int:
|
||||
priority_list: list[dict] = []
|
||||
|
||||
for adj in adjustments:
|
||||
factor = adj.get("factor", "")
|
||||
action = adj.get("action", "")
|
||||
rationale = adj.get("rationale", "")
|
||||
reg_ids = FACTOR_TO_REGISTRY.get(factor, [])
|
||||
factor = str(adj.get("factor", ""))
|
||||
action = str(adj.get("action", ""))
|
||||
rationale = str(adj.get("rationale", ""))
|
||||
reg_ids = FACTOR_TO_REGISTRY.get(factor, [])
|
||||
|
||||
for rid in reg_ids:
|
||||
reg_entry = reg_index.get(rid)
|
||||
if not reg_entry:
|
||||
continue
|
||||
source = reg_entry.get("source", "EXPERT_PRIOR")
|
||||
sample_n = int(reg_entry.get("sample_n", 0) or 0)
|
||||
value = reg_entry.get("value")
|
||||
formula = reg_entry.get("owner_formula", "")
|
||||
item = _priority_from_registry_entry(reg_entry, factor, miss5_count if factor == "passive_signal_quality" else 0)
|
||||
item["alpha_action"] = action or "feedback_review"
|
||||
if rationale:
|
||||
item["rationale"] = rationale[:200]
|
||||
priority_list.append(item)
|
||||
|
||||
# 보정 우선도 점수: miss5_count 기여 + 미보정 가중
|
||||
urgency = 0
|
||||
if factor == "passive_signal_quality":
|
||||
urgency += miss5_count # miss가 많을수록 높은 urgency
|
||||
if source == "EXPERT_PRIOR":
|
||||
urgency += 10
|
||||
if sample_n == 0:
|
||||
urgency += 5
|
||||
|
||||
priority_list.append({
|
||||
"calibration_id": rid,
|
||||
"current_value": value,
|
||||
"owner_formula": formula,
|
||||
"source": source,
|
||||
"sample_n": sample_n,
|
||||
"linked_factor": factor,
|
||||
"alpha_action": action,
|
||||
"urgency_score": urgency,
|
||||
"calibration_path": (
|
||||
(
|
||||
"표본 30건 이상 확보 후 PROVISIONAL 승격 → "
|
||||
if sample_n >= 30
|
||||
else f"표본 {30 - sample_n}건 추가 수집 후 PROVISIONAL 승격 → "
|
||||
)
|
||||
+ "실측 T+5 승률 기반 최적값 backtest → CALIBRATED 확정"
|
||||
),
|
||||
"rationale": rationale[:200] if rationale else "",
|
||||
})
|
||||
if not priority_list:
|
||||
# alpha_feedback_loop가 비어 있어도 registry 자체의 보정 debt를 추적할 수 있게 한다.
|
||||
for reg_id, reg_entry in reg_index.items():
|
||||
source = str(reg_entry.get("source", "EXPERT_PRIOR"))
|
||||
if source not in {"EXPERT_PRIOR", "PROVISIONAL"}:
|
||||
continue
|
||||
tag = f"registry:{source.lower()}"
|
||||
item = _priority_from_registry_entry(reg_entry, tag, 0)
|
||||
if source == "PROVISIONAL":
|
||||
item["urgency_score"] += 5
|
||||
priority_list.append(item)
|
||||
|
||||
# 중복 제거 (같은 rid, 높은 urgency 유지)
|
||||
seen: dict[str, dict] = {}
|
||||
@@ -177,7 +232,19 @@ def main() -> int:
|
||||
print(f" Step 2 (30건 후): ALEG_V2_GATE1_BLOCK_PCT 3.0% → 실측 최적값으로 PROVISIONAL 승격")
|
||||
print(f" Step 3 (50건 후): DSD_V1 가중치 logistic regression 최적화")
|
||||
print(f" Step 4 (100건 후): K2_SPLIT_RATIO backtest 비교 → CALIBRATED 확정")
|
||||
print(f" miss5_count={miss5_count}건 → passive_signal_quality 개선이 T+5 35.86%→50%+ 핵심")
|
||||
registry_health = registry_source_breakdown(reg_index)
|
||||
t5_status = live_t5_status()
|
||||
|
||||
print(f"\n [캘리브레이션 레지스트리 건강도] (WBS-7.1)")
|
||||
print(f" total={registry_health['total_thresholds']} {registry_health['counts']}")
|
||||
print(f" CALIBRATED={registry_health['calibrated_pct']}% 미검증(SPEC_DERIVED+EXPERT_PRIOR)={registry_health['unvalidated_pct']}%")
|
||||
|
||||
if t5_status["status"] == "DATA_GATED":
|
||||
print(f" miss5_count={miss5_count}건 → T+5 현재 DATA_GATED(sample=0) — passive_signal_quality 개선 영향은 표본 누적 후 측정 가능")
|
||||
elif t5_status["status"] == "ARTIFACT_MISSING":
|
||||
print(f" miss5_count={miss5_count}건 → T+5 산출물 없음(Temp/prediction_accuracy_harness_v2.json) — 먼저 생성 필요")
|
||||
else:
|
||||
print(f" miss5_count={miss5_count}건 → T+5={t5_status['t5_match_rate_pct']}% (as_of={t5_status.get('as_of_date')}) → passive_signal_quality 개선 핵심")
|
||||
|
||||
result = {
|
||||
"status": "CALIBRATION_PRIORITY_OK",
|
||||
@@ -191,10 +258,14 @@ def main() -> int:
|
||||
"step3": "50건 후: DSD_V1 가중치 logistic regression 최적화",
|
||||
"step4": "100건 후: K2_SPLIT_RATIO 30/70~60/40 backtest → CALIBRATED",
|
||||
},
|
||||
"priority_basis": "alpha_feedback_loop_v2" if adjustments else "registry_warning_fallback",
|
||||
"registry_health": registry_health,
|
||||
"target_improvement": {
|
||||
"current_t5_pct": 35.86,
|
||||
"t5_status": t5_status["status"],
|
||||
"current_t5_pct": t5_status["t5_match_rate_pct"],
|
||||
"t5_as_of_date": t5_status.get("as_of_date"),
|
||||
"target_t5_pct": 55.0,
|
||||
"key_lever": "passive_signal_quality (miss5_count=51건 개선)",
|
||||
"key_lever": f"passive_signal_quality (miss5_count={miss5_count}건 개선)",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
build_calibration_review_report_v1.py
|
||||
───────────────────────────────────────────────────────────────────────────────
|
||||
calibration_registry.yaml + calibration_priority_v1.json + calibration_change_ledger_v4.json
|
||||
을 묶어 운영용 보정 리뷰 리포트를 만든다.
|
||||
|
||||
목적:
|
||||
- PROVISIONAL / CALIBRATED 승격 후보를 사람이 읽을 수 있게 정리
|
||||
- registry warning fallback 상태를 숨기지 않고 그대로 공시
|
||||
- 월간 보정 운영에서 바로 참고 가능한 Markdown + JSON 산출물 생성
|
||||
|
||||
출력:
|
||||
Temp/calibration_review_report_v1.json
|
||||
Temp/calibration_review_report_v1.md
|
||||
|
||||
사용법:
|
||||
python tools/build_calibration_review_report_v1.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
REGISTRY = ROOT / "spec" / "calibration_registry.yaml"
|
||||
PRIORITY = ROOT / "Temp" / "calibration_priority_v1.json"
|
||||
LEDGER = ROOT / "Temp" / "calibration_change_ledger_v4.json"
|
||||
OUT_JSON = ROOT / "Temp" / "calibration_review_report_v1.json"
|
||||
OUT_MD = ROOT / "Temp" / "calibration_review_report_v1.md"
|
||||
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
|
||||
|
||||
|
||||
def _load_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _load_registry(path: Path) -> list[dict[str, Any]]:
|
||||
if not path.exists():
|
||||
return []
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
thresholds = data.get("thresholds", [])
|
||||
return [t for t in thresholds if isinstance(t, dict)]
|
||||
|
||||
|
||||
def _readiness(entry: dict[str, Any]) -> tuple[str, str]:
|
||||
source = str(entry.get("source") or "EXPERT_PRIOR")
|
||||
sample_n = int(entry.get("sample_n") or 0)
|
||||
if source == "CALIBRATED":
|
||||
return "CALIBRATED", "Already calibrated"
|
||||
if source == "PROVISIONAL" and sample_n >= 30:
|
||||
return "CALIBRATION_READY", "Ready for calibrated review"
|
||||
if source == "PROVISIONAL":
|
||||
return "PROVISIONAL_ACTIVE", "Provisional with live samples"
|
||||
if sample_n >= 10:
|
||||
return "PROVISIONAL_CANDIDATE", "Candidate for provisional review"
|
||||
return "WATCH", "Keep under watch"
|
||||
|
||||
|
||||
def _table(rows: list[dict[str, Any]], keys: list[str], max_rows: int = 25) -> str:
|
||||
if not rows:
|
||||
return "_데이터 없음_"
|
||||
header = "| " + " | ".join(keys) + " |"
|
||||
sep = "| " + " | ".join(["---"] * len(keys)) + " |"
|
||||
body = []
|
||||
for row in rows[:max_rows]:
|
||||
body.append("| " + " | ".join(str(row.get(k, "")).replace("|", "ㅣ") for k in keys) + " |")
|
||||
suffix = f"\n\n_...총 {len(rows)}행 중 {max_rows}행 표시_" if len(rows) > max_rows else ""
|
||||
return "\n".join([header, sep, *body]) + suffix
|
||||
|
||||
|
||||
def main() -> int:
|
||||
registry = _load_registry(REGISTRY)
|
||||
priority = _load_json(PRIORITY)
|
||||
ledger = _load_json(LEDGER)
|
||||
|
||||
source_counts: dict[str, int] = {}
|
||||
readiness_counts: dict[str, int] = {}
|
||||
reviewed_rows: list[dict[str, Any]] = []
|
||||
|
||||
for entry in registry:
|
||||
source = str(entry.get("source") or "EXPERT_PRIOR")
|
||||
source_counts[source] = source_counts.get(source, 0) + 1
|
||||
readiness, reason = _readiness(entry)
|
||||
readiness_counts[readiness] = readiness_counts.get(readiness, 0) + 1
|
||||
if readiness in {"PROVISIONAL_CANDIDATE", "CALIBRATION_READY", "PROVISIONAL_ACTIVE"}:
|
||||
reviewed_rows.append(
|
||||
{
|
||||
"id": entry.get("id", ""),
|
||||
"source": source,
|
||||
"sample_n": int(entry.get("sample_n") or 0),
|
||||
"value": entry.get("value"),
|
||||
"unit": entry.get("unit", ""),
|
||||
"owner_formula": entry.get("owner_formula", ""),
|
||||
"readiness": readiness,
|
||||
"reason": reason,
|
||||
"notes": str(entry.get("notes") or "")[:120],
|
||||
}
|
||||
)
|
||||
|
||||
priority_list = priority.get("priority_list") if isinstance(priority.get("priority_list"), list) else []
|
||||
priority_rows = []
|
||||
for item in priority_list[:20]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
priority_rows.append(
|
||||
{
|
||||
"calibration_id": item.get("calibration_id", ""),
|
||||
"source": item.get("source", ""),
|
||||
"sample_n": item.get("sample_n", 0),
|
||||
"urgency_score": item.get("urgency_score", 0),
|
||||
"linked_factor": item.get("linked_factor", ""),
|
||||
"owner_formula": item.get("owner_formula", ""),
|
||||
}
|
||||
)
|
||||
|
||||
report = {
|
||||
"formula_id": "CALIBRATION_REVIEW_REPORT_V1",
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"registry_path": str(REGISTRY),
|
||||
"priority_path": str(PRIORITY),
|
||||
"ledger_path": str(LEDGER),
|
||||
"summary": {
|
||||
"total_thresholds": len(registry),
|
||||
"source_counts": source_counts,
|
||||
"readiness_counts": readiness_counts,
|
||||
"priority_count": int(priority.get("priority_count") or len(priority_rows)),
|
||||
"ledger_change_count": len(ledger.get("changes", [])) if isinstance(ledger.get("changes"), list) else 0,
|
||||
"ledger_without_change_count": int(ledger.get("threshold_change_without_ledger_count") or 0),
|
||||
},
|
||||
"top_priority_rows": priority_rows,
|
||||
"review_rows": reviewed_rows,
|
||||
}
|
||||
|
||||
OUT_JSON.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
md_lines = [
|
||||
"# Calibration Review Report",
|
||||
"",
|
||||
"## Summary",
|
||||
"",
|
||||
f"- total thresholds: {report['summary']['total_thresholds']}",
|
||||
f"- priority count: {report['summary']['priority_count']}",
|
||||
f"- ledger change count: {report['summary']['ledger_change_count']}",
|
||||
f"- ledger without change count: {report['summary']['ledger_without_change_count']}",
|
||||
"",
|
||||
"### Source Counts",
|
||||
"",
|
||||
_table(
|
||||
[{"source": k, "count": v} for k, v in sorted(source_counts.items())],
|
||||
["source", "count"],
|
||||
max_rows=50,
|
||||
),
|
||||
"",
|
||||
"### Readiness Counts",
|
||||
"",
|
||||
_table(
|
||||
[{"readiness": k, "count": v} for k, v in sorted(readiness_counts.items())],
|
||||
["readiness", "count"],
|
||||
max_rows=50,
|
||||
),
|
||||
"",
|
||||
"## Top Priority Rows",
|
||||
"",
|
||||
_table(priority_rows, ["calibration_id", "source", "sample_n", "urgency_score", "linked_factor", "owner_formula"]),
|
||||
"",
|
||||
"## Review Candidates",
|
||||
"",
|
||||
_table(reviewed_rows, ["id", "source", "sample_n", "value", "unit", "owner_formula", "readiness", "reason"]),
|
||||
"",
|
||||
"## Evidence",
|
||||
"",
|
||||
f"- registry: {REGISTRY}",
|
||||
f"- priority: {PRIORITY}",
|
||||
f"- ledger: {LEDGER}",
|
||||
]
|
||||
OUT_MD.write_text("\n".join(md_lines), encoding="utf-8")
|
||||
|
||||
print(json.dumps({
|
||||
"formula_id": report["formula_id"],
|
||||
"gate": "PASS" if reviewed_rows or priority_rows else "WARN",
|
||||
"review_rows": len(reviewed_rows),
|
||||
"priority_rows": len(priority_rows),
|
||||
"json_path": str(OUT_JSON),
|
||||
"md_path": str(OUT_MD),
|
||||
}, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,204 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,342 @@
|
||||
"""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/<code>.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())
|
||||
@@ -0,0 +1,139 @@
|
||||
"""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())
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env python3
|
||||
"""WBS-7.6(2026-06-21) — 실거래 슬리피지 실측 캡처/비교 CLI.
|
||||
|
||||
사용법:
|
||||
실측 1건 기록(주문 실행은 여전히 사람이 HTS에서 수동 실행 — 이 도구는 API로
|
||||
체결을 가져오지 않는다. governance/rules/06_no_direct_api_trading.yaml 준수):
|
||||
python tools/evaluate_execution_slippage_v1.py record --ticker 005930 --side BUY \
|
||||
--intended-price 71000 --actual-price 71050 --recorded-at 2026-06-21
|
||||
|
||||
누적 표본과 가정치(5bps) 비교 리포트:
|
||||
python tools/evaluate_execution_slippage_v1.py report
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
if sys.stdout.encoding and sys.stdout.encoding.lower() not in ("utf-8", "utf8"):
|
||||
sys.stdout = open(sys.stdout.fileno(), mode="w", encoding="utf-8", buffering=1)
|
||||
|
||||
from src.quant_engine.execution_slippage_store_v1 import (
|
||||
build_slippage_comparison_report,
|
||||
default_execution_slippage_store_path,
|
||||
insert_realized_slippage_sample,
|
||||
)
|
||||
|
||||
OUTPUT = ROOT / "Temp" / "execution_slippage_report_v1.json"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--db", type=Path, default=None)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
record = sub.add_parser("record")
|
||||
record.add_argument("--ticker", required=True)
|
||||
record.add_argument("--side", required=True, choices=["BUY", "SELL", "buy", "sell"])
|
||||
record.add_argument("--intended-price", type=float, required=True)
|
||||
record.add_argument("--actual-price", type=float, required=True)
|
||||
record.add_argument("--recorded-at", required=True)
|
||||
record.add_argument("--note", default=None)
|
||||
|
||||
sub.add_parser("report")
|
||||
|
||||
args = parser.parse_args()
|
||||
db_path = args.db or default_execution_slippage_store_path(ROOT)
|
||||
|
||||
if args.command == "record":
|
||||
result = insert_realized_slippage_sample(
|
||||
db_path,
|
||||
ticker=args.ticker,
|
||||
side=args.side,
|
||||
intended_price=args.intended_price,
|
||||
actual_fill_price=args.actual_price,
|
||||
recorded_at=args.recorded_at,
|
||||
note=args.note,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
report = build_slippage_comparison_report(db_path)
|
||||
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUTPUT.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,143 @@
|
||||
"""qualitative_sell_strategy_v1 자체 평가 루프 — "한 번 만들고 끝"이 아니라 결정이
|
||||
실제로 가치를 보존했는지 사후 검증한다(30년 시니어 퀀트의 핵심 습관: 판단 → 결과 →
|
||||
재보정). 기존 T+5/T+20 outcome ledger(proposal_evaluation_history)와 별개로,
|
||||
qualitative_sell_strategy_store_v1.db에 쌓인 SQLite 시계열을 사용한다 — GAS/xlsx와
|
||||
무관하므로 이 모듈만의 독립 평가 루프를 구성해도 기존 시스템과 충돌하지 않는다.
|
||||
|
||||
판정 기준(가치보존 관점, 기계적 승률 게임이 아님):
|
||||
- EXIT_REVIEW_FULL / TRIM_REVIEW_PARTIAL(매도방향) → 이후 가격이 하락했으면
|
||||
"가치보존 성공"(매도가 손실을 막았다). 상승했으면 "기회비용 발생"(조급한 매도).
|
||||
- HOLD_ADD_CONVICTION(지지방향) → 이후 가격이 상승했으면 성공.
|
||||
- HOLD_NO_CONFLUENCE / INSUFFICIENT_DATA_NO_ACTION → 방향성 주장이 없으므로 평가 대상 제외.
|
||||
|
||||
표본이 부족하면(DATA_GATED) 추정하지 않고 명시적으로 보류한다 — honest_proof_score와
|
||||
동일한 원칙(spec/algorithm_guidance_proof 계열).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import sqlite3
|
||||
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 src.quant_engine.qualitative_sell_strategy_store_v1 import QualitativeSellStoreSpec, resolve_store_path
|
||||
|
||||
MIN_HOLDING_DAYS = 5 # T+5 수준 — 너무 짧으면 노이즈, 너무 길면 표본 희소
|
||||
MIN_SAMPLE_FOR_HIT_RATE = 10 # 이보다 적으면 hit_rate를 신뢰 구간 없이 표기하지 않음(DATA_GATED)
|
||||
|
||||
|
||||
def _scoreable_direction(action: str) -> int | None:
|
||||
if action in {"EXIT_REVIEW_FULL", "TRIM_REVIEW_PARTIAL"}:
|
||||
return -1 # 매도 방향 — 가격 하락이 "성공"
|
||||
if action == "HOLD_ADD_CONVICTION":
|
||||
return 1 # 지지 방향 — 가격 상승이 "성공"
|
||||
return None # HOLD_NO_CONFLUENCE / INSUFFICIENT_DATA_NO_ACTION — 평가 제외
|
||||
|
||||
|
||||
def load_scoreable_decisions(db_path: Path, min_age_days: int = MIN_HOLDING_DAYS) -> list[dict[str, Any]]:
|
||||
if not db_path.exists():
|
||||
return []
|
||||
cutoff = (dt.date.today() - dt.timedelta(days=min_age_days)).isoformat()
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT code, generated_at, action, conviction, market_regime, composite_score "
|
||||
"FROM sell_strategy_results WHERE generated_at <= ? ORDER BY generated_at",
|
||||
(cutoff,),
|
||||
).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def evaluate_decision(decision: dict[str, Any], price_at_decision: float, price_after: float) -> dict[str, Any] | None:
|
||||
direction = _scoreable_direction(decision["action"])
|
||||
if direction is None or not price_at_decision or price_at_decision <= 0:
|
||||
return None
|
||||
realized_return_pct = (price_after / price_at_decision - 1.0) * 100.0
|
||||
success = (direction * realized_return_pct) > 0 # 방향 일치 시 성공
|
||||
return {
|
||||
**decision,
|
||||
"price_at_decision": price_at_decision,
|
||||
"price_after": price_after,
|
||||
"realized_return_pct": round(realized_return_pct, 4),
|
||||
"success": success,
|
||||
}
|
||||
|
||||
|
||||
def build_accuracy_report(db_path: Path, price_lookup: dict[str, dict[str, float]]) -> dict[str, Any]:
|
||||
"""price_lookup: {code: {generated_at_date_iso: close_price}} — 호출측이 실제 가격
|
||||
히스토리(fetch_naver_market_data_v1 등)로 조립해 주입한다. 이 함수는 가격을 추정하지
|
||||
않는다 — 주어진 값만 사용."""
|
||||
decisions = load_scoreable_decisions(db_path)
|
||||
evaluated: list[dict[str, Any]] = []
|
||||
skipped_no_price = 0
|
||||
for decision in decisions:
|
||||
prices = price_lookup.get(decision["code"], {})
|
||||
decision_date = decision["generated_at"][:10]
|
||||
price_at = prices.get(decision_date)
|
||||
future_date = (dt.date.fromisoformat(decision_date) + dt.timedelta(days=MIN_HOLDING_DAYS)).isoformat()
|
||||
price_after = prices.get(future_date)
|
||||
if price_at is None or price_after is None:
|
||||
skipped_no_price += 1
|
||||
continue
|
||||
result = evaluate_decision(decision, price_at, price_after)
|
||||
if result is not None:
|
||||
evaluated.append(result)
|
||||
|
||||
scored = [e for e in evaluated if e is not None]
|
||||
if len(scored) < MIN_SAMPLE_FOR_HIT_RATE:
|
||||
return {
|
||||
"status": "DATA_GATED",
|
||||
"scored_sample_count": len(scored),
|
||||
"min_sample_required": MIN_SAMPLE_FOR_HIT_RATE,
|
||||
"note": "표본 부족 — hit_rate를 산출하지 않음(추정 금지). 결정 누적과 가격 매칭이 더 필요.",
|
||||
"skipped_no_price": skipped_no_price,
|
||||
}
|
||||
|
||||
hit_rate_pct = round(100.0 * sum(1 for e in scored if e["success"]) / len(scored), 2)
|
||||
return {
|
||||
"status": "OK",
|
||||
"scored_sample_count": len(scored),
|
||||
"hit_rate_pct": hit_rate_pct,
|
||||
"evaluations": scored,
|
||||
"skipped_no_price": skipped_no_price,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--sqlite-db", type=Path,
|
||||
default=ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db")
|
||||
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("--price-lookup-json", type=Path, default=None,
|
||||
help='{"code": {"YYYY-MM-DD": close_price, ...}} 형식 — 미지정 시 가격 매칭 없이 표본 카운트만 보고')
|
||||
args = ap.parse_args()
|
||||
db_path = resolve_store_path(
|
||||
QualitativeSellStoreSpec(
|
||||
backend=args.store_backend,
|
||||
location=args.store_location or args.sqlite_db,
|
||||
),
|
||||
ROOT,
|
||||
)
|
||||
|
||||
price_lookup: dict[str, dict[str, float]] = {}
|
||||
if args.price_lookup_json and args.price_lookup_json.exists():
|
||||
price_lookup = json.loads(args.price_lookup_json.read_text(encoding="utf-8"))
|
||||
|
||||
report = build_accuracy_report(db_path, price_lookup)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,168 @@
|
||||
"""Naver Finance 시세/수급 수집기 — qualitative_sell_strategy_v1 입력용.
|
||||
|
||||
확인된 무인증 엔드포인트만 사용한다(2026-06-21 세션 실측):
|
||||
- https://finance.naver.com/item/sise_day.naver?code={code}&page=N (일별 시세/거래량)
|
||||
- https://finance.naver.com/item/frgn.naver?code={code}&page=N (외국인/기관 수급)
|
||||
- https://polling.finance.naver.com/api/realtime/domestic/stock/{code} (실시간 스냅샷, JSON)
|
||||
|
||||
investing.com 직접 스크래핑은 403(Cloudflare 차단) 확인됨 — 시도하지 않는다.
|
||||
KRX 공매도 잔고(data.krx.co.kr)는 OTP 세션 필요(LOGOUT 응답) — 시도하지 않는다.
|
||||
이미 GAS(gdc_01_fetch_fundamentals.gs/gas_event_calendar.gs)에서 수집 중인
|
||||
외국인/기관 수급·실적발표 일정·경제지표 일정은 보유종목에 대해서는 account_snapshot/
|
||||
GatherTradingData.xlsx에서 재사용하고, 이 스크립트는 그 시트에 없는 위성 후보군
|
||||
티커를 평가할 때만 직접 호출한다(중복 수집 금지).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
|
||||
NAVER_REFERER = "https://finance.naver.com/"
|
||||
|
||||
|
||||
def _session() -> requests.Session:
|
||||
s = requests.Session()
|
||||
s.headers.update({
|
||||
"User-Agent": USER_AGENT,
|
||||
"Referer": NAVER_REFERER,
|
||||
"Accept-Language": "ko-KR,ko;q=0.9,en;q=0.8",
|
||||
})
|
||||
return s
|
||||
|
||||
|
||||
def _num(text: str) -> float:
|
||||
cleaned = text.replace(",", "").replace("+", "").strip()
|
||||
try:
|
||||
return float(cleaned)
|
||||
except ValueError:
|
||||
return 0.0
|
||||
|
||||
|
||||
def fetch_price_history(session: requests.Session, code: str, pages: int = 3) -> dict[str, Any]:
|
||||
"""일별 [date, close, change, open, high, low, volume] 최신순. 페이지당 10행."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for page in range(1, pages + 1):
|
||||
url = f"https://finance.naver.com/item/sise_day.naver?code={code}&page={page}"
|
||||
resp = session.get(url, timeout=10)
|
||||
resp.encoding = "euc-kr"
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
table = soup.find("table", {"class": "type2"})
|
||||
if table is None:
|
||||
break
|
||||
for tr in table.find_all("tr"):
|
||||
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
|
||||
if len(cells) != 7 or not cells[0]:
|
||||
continue
|
||||
rows.append({
|
||||
"date": cells[0].replace(".", "-"),
|
||||
"close": _num(cells[1]),
|
||||
"open": _num(cells[3]),
|
||||
"high": _num(cells[4]),
|
||||
"low": _num(cells[5]),
|
||||
"volume": _num(cells[6]),
|
||||
})
|
||||
if not rows:
|
||||
return {"status": "DATA_MISSING", "rows": [], "source_url": NAVER_REFERER}
|
||||
return {
|
||||
"status": "OK",
|
||||
"rows": rows,
|
||||
"source_url": f"https://finance.naver.com/item/sise_day.naver?code={code}",
|
||||
"source_as_of": dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def fetch_foreign_institution_flow(session: requests.Session, code: str, pages: int = 2) -> dict[str, Any]:
|
||||
"""외국인/기관 5일·20일 수급. tds: [date, close, change, ret_pct, volume, inst, frgn, frgn_ratio]."""
|
||||
rows: list[dict[str, Any]] = []
|
||||
for page in range(1, pages + 1):
|
||||
url = f"https://finance.naver.com/item/frgn.naver?code={code}&page={page}"
|
||||
resp = session.get(url, timeout=10)
|
||||
resp.encoding = "euc-kr"
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
for table in soup.find_all("table", {"class": "type2"}):
|
||||
for tr in table.find_all("tr"):
|
||||
cells = [td.get_text(strip=True) for td in tr.find_all("td")]
|
||||
if len(cells) < 8 or not cells[0] or "." not in cells[0]:
|
||||
continue
|
||||
rows.append({
|
||||
"date": cells[0].replace(".", "-"),
|
||||
"close": _num(cells[1]),
|
||||
"inst_net": _num(cells[5]),
|
||||
"frgn_net": _num(cells[6]),
|
||||
})
|
||||
if not rows:
|
||||
return {"status": "DATA_MISSING", "rows": []}
|
||||
return {
|
||||
"status": "OK",
|
||||
"rows": rows,
|
||||
"source_url": f"https://finance.naver.com/item/frgn.naver?code={code}",
|
||||
"source_as_of": dt.datetime.now(dt.timezone(dt.timedelta(hours=9))).isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def compute_relative_return_20d(stock_rows: list[dict[str, Any]], benchmark_rows: list[dict[str, Any]]) -> float | None:
|
||||
"""종목수익률(최신 vs 20거래일전) - 벤치마크(섹터ETF/KOSPI)수익률, %p."""
|
||||
def _ret(rows: list[dict[str, Any]]) -> float | None:
|
||||
closes = [r["close"] for r in rows if r.get("close")]
|
||||
if len(closes) < 2:
|
||||
return None
|
||||
recent, past = closes[0], closes[min(len(closes) - 1, 19)]
|
||||
if not past:
|
||||
return None
|
||||
return (recent / past - 1.0) * 100.0
|
||||
|
||||
stock_ret = _ret(stock_rows)
|
||||
bench_ret = _ret(benchmark_rows)
|
||||
if stock_ret is None or bench_ret is None:
|
||||
return None
|
||||
return round(stock_ret - bench_ret, 4)
|
||||
|
||||
|
||||
def compute_volume_ratio_5d(rows: list[dict[str, Any]]) -> float | None:
|
||||
"""오늘 거래량 / 직전 5일 평균거래량."""
|
||||
volumes = [r["volume"] for r in rows if r.get("volume")]
|
||||
if len(volumes) < 6:
|
||||
return None
|
||||
today_vol = volumes[0]
|
||||
avg5 = sum(volumes[1:6]) / 5.0
|
||||
if avg5 <= 0:
|
||||
return None
|
||||
return round(today_vol / avg5, 4)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--code", required=True, help="6자리 종목코드")
|
||||
ap.add_argument("--benchmark-code", default="069500", help="비교 벤치마크 코드(기본 KODEX200 069500)")
|
||||
args = ap.parse_args()
|
||||
|
||||
session = _session()
|
||||
price = fetch_price_history(session, args.code)
|
||||
benchmark = fetch_price_history(session, args.benchmark_code)
|
||||
flow = fetch_foreign_institution_flow(session, args.code)
|
||||
|
||||
result = {
|
||||
"code": args.code,
|
||||
"price_history": price,
|
||||
"foreign_institution_flow": flow,
|
||||
"relative_return_20d": compute_relative_return_20d(price.get("rows", []), benchmark.get("rows", [])),
|
||||
"volume_ratio_5d": compute_volume_ratio_5d(price.get("rows", [])),
|
||||
}
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,186 @@
|
||||
"""관세청/산업통상부 수출입동향 → 섹터별 수출 추세(sector_export_trend) 산출기.
|
||||
|
||||
실측 결과(2026-06-21 세션): investing.com 직접 스크래핑은 403(Cloudflare)으로 차단되고,
|
||||
관세청·산업통상부는 실시간 무인증 JSON API를 공개하지 않는다(통계청/관세청 수출입통계는
|
||||
data.go.kr 공공데이터포털의 서비스키 기반 OpenAPI 또는 매월 발표되는 보도자료 첨부
|
||||
XLSX/CSV로만 배포). 따라서 이 모듈은 두 경로를 모두 지원한다:
|
||||
|
||||
1) API 경로 — data.go.kr 관세청 수출입통계 API. CUSTOMS_API_KEY 환경변수(또는
|
||||
--api-key) 필요. 키가 없거나 호출 실패 시 추정하지 않고 DATA_MISSING 반환.
|
||||
2) CSV 경로(권장, 안정적) — 관세청 수출입무역통계(https://unipass.customs.go.kr/ets/)
|
||||
또는 산업통상부 보도자료에서 사용자가 다운로드한 월별 HS코드별 수출입 CSV를
|
||||
--csv 인자로 입력. 이 경로가 실패할 일이 없어 1차 권장 경로다.
|
||||
|
||||
산출물 sector_export_trend(%, MoM 또는 YoY)는 qualitative_sell_strategy_v1의
|
||||
fundamental_trajectory 보강 입력 및 compute_satellite_candidate_score의 1차 팩터로 쓰인다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
# 섹터 → HS코드 prefix(2~4자리). 위성종목 추천/매도판단에 쓰는 핵심 수출 섹터만 우선 등록.
|
||||
SECTOR_HS_MAP: dict[str, tuple[str, ...]] = {
|
||||
"반도체": ("8541", "8542"),
|
||||
"자동차": ("8701", "8702", "8703", "8704"),
|
||||
"2차전지": ("8507",),
|
||||
"조선": ("8901", "8902", "8905"),
|
||||
"철강": ("72",),
|
||||
"석유화학": ("29", "39"),
|
||||
"디스플레이": ("8524", "9013"),
|
||||
"기계": ("84",),
|
||||
"바이오": ("30",), # universe.Sector 실측 라벨이 "바이오"(헬스 접미사 없음) — 그대로 매칭
|
||||
"방산": ("93",), # 무기류·탄약(HS Ch.93) — 현대로템 등 보유종목 K-방산 테마 대응
|
||||
}
|
||||
|
||||
CUSTOMS_API_BASE = "https://apis.data.go.kr/1220000/nitemtrade/getNitemtradeList"
|
||||
|
||||
|
||||
def fetch_customs_trade_api(
|
||||
session: requests.Session,
|
||||
api_key: str | None,
|
||||
hs_code: str,
|
||||
start_ym: str,
|
||||
end_ym: str,
|
||||
) -> dict[str, Any]:
|
||||
"""data.go.kr 관세청 수출입통계 API 호출. 키 없거나 실패 시 DATA_MISSING(추정 금지)."""
|
||||
if not api_key:
|
||||
return {"status": "DATA_MISSING", "note": "CUSTOMS_API_KEY 미설정 — --csv 경로 사용 권장"}
|
||||
try:
|
||||
resp = session.get(
|
||||
CUSTOMS_API_BASE,
|
||||
params={
|
||||
"serviceKey": api_key,
|
||||
"strtYymm": start_ym,
|
||||
"endYymm": end_ym,
|
||||
"hsSgn": hs_code,
|
||||
"type": "json",
|
||||
},
|
||||
timeout=15,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as exc: # noqa: BLE001 — 외부 API 실패는 광범위하게 잡아 DATA_MISSING 처리
|
||||
return {"status": "API_ERROR", "note": str(exc)}
|
||||
return {"status": "OK", "raw": data, "source_url": CUSTOMS_API_BASE}
|
||||
|
||||
|
||||
def load_trade_statistics_csv(path: Path) -> list[dict[str, Any]]:
|
||||
"""관세청/산업통상부 배포 CSV. 컬럼: 기간(YYYYMM), HS코드, 수출액(달러), 수입액(달러).
|
||||
|
||||
헤더명은 배포처마다 다를 수 있어 한글/영문 별칭을 모두 허용한다.
|
||||
"""
|
||||
alias = {
|
||||
"기간": "period", "year_month": "period", "period": "period",
|
||||
"hs코드": "hs_code", "hs_code": "hs_code", "hscode": "hs_code",
|
||||
"수출액": "export_usd", "export": "export_usd", "export_usd": "export_usd",
|
||||
"수입액": "import_usd", "import": "import_usd", "import_usd": "import_usd",
|
||||
}
|
||||
rows: list[dict[str, Any]] = []
|
||||
with path.open(encoding="utf-8-sig", newline="") as f:
|
||||
reader = csv.DictReader(f)
|
||||
for raw_row in reader:
|
||||
row: dict[str, Any] = {}
|
||||
for key, value in raw_row.items():
|
||||
norm_key = alias.get(str(key).strip().lower())
|
||||
if norm_key:
|
||||
row[norm_key] = value
|
||||
if {"period", "hs_code"}.issubset(row):
|
||||
for money_field in ("export_usd", "import_usd"):
|
||||
if money_field in row:
|
||||
try:
|
||||
row[money_field] = float(str(row[money_field]).replace(",", ""))
|
||||
except ValueError:
|
||||
row[money_field] = 0.0
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
def compute_sector_export_trend(
|
||||
rows: list[dict[str, Any]],
|
||||
sector: str,
|
||||
compare: str = "yoy",
|
||||
) -> dict[str, Any]:
|
||||
"""sector_export_trend(%) = 최신월 수출액 / 비교월 수출액 - 1.
|
||||
|
||||
compare="yoy": 12개월 전 동월 대비. compare="mom": 직전월 대비.
|
||||
데이터 부족 시 추정하지 않고 DATA_MISSING.
|
||||
"""
|
||||
hs_prefixes = SECTOR_HS_MAP.get(sector)
|
||||
if not hs_prefixes:
|
||||
return {"status": "UNKNOWN_SECTOR", "sector": sector, "known_sectors": list(SECTOR_HS_MAP)}
|
||||
|
||||
by_period: dict[str, float] = defaultdict(float)
|
||||
for row in rows:
|
||||
hs_code = str(row.get("hs_code") or "")
|
||||
if any(hs_code.startswith(prefix) for prefix in hs_prefixes):
|
||||
period = str(row.get("period") or "")
|
||||
by_period[period] += float(row.get("export_usd") or 0.0)
|
||||
|
||||
if len(by_period) < 2:
|
||||
return {"status": "DATA_MISSING", "sector": sector, "note": "기간별 수출액 표본 부족"}
|
||||
|
||||
periods_sorted = sorted(by_period)
|
||||
latest_period = periods_sorted[-1]
|
||||
latest_value = by_period[latest_period]
|
||||
|
||||
if compare == "mom":
|
||||
compare_period = periods_sorted[-2]
|
||||
else:
|
||||
latest_ym = int(latest_period)
|
||||
target_ym = latest_ym - 100 # YYYYMM에서 12개월 전 = -100
|
||||
compare_period = str(target_ym)
|
||||
if compare_period not in by_period:
|
||||
return {"status": "DATA_MISSING", "sector": sector, "note": f"YoY 비교월({compare_period}) 데이터 없음 — MoM으로 재시도 권장"}
|
||||
|
||||
compare_value = by_period.get(compare_period, 0.0)
|
||||
if compare_value <= 0:
|
||||
return {"status": "DATA_MISSING", "sector": sector, "note": "비교월 수출액이 0 이하"}
|
||||
|
||||
trend_pct = round((latest_value / compare_value - 1.0) * 100.0, 4)
|
||||
return {
|
||||
"status": "OK",
|
||||
"sector": sector,
|
||||
"compare": compare,
|
||||
"latest_period": latest_period,
|
||||
"compare_period": compare_period,
|
||||
"sector_export_trend": trend_pct,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--csv", type=Path, help="관세청/산업통상부 배포 수출입 CSV 경로(권장 경로)")
|
||||
ap.add_argument("--sector", default="반도체", choices=list(SECTOR_HS_MAP))
|
||||
ap.add_argument("--compare", default="yoy", choices=["yoy", "mom"])
|
||||
ap.add_argument("--api-key", default=os.environ.get("CUSTOMS_API_KEY"))
|
||||
ap.add_argument("--hs-code", default="", help="API 경로 사용 시 HS코드")
|
||||
ap.add_argument("--start-ym", default="")
|
||||
ap.add_argument("--end-ym", default="")
|
||||
args = ap.parse_args()
|
||||
|
||||
if args.csv:
|
||||
rows = load_trade_statistics_csv(args.csv)
|
||||
result = compute_sector_export_trend(rows, args.sector, args.compare)
|
||||
else:
|
||||
session = requests.Session()
|
||||
result = fetch_customs_trade_api(session, args.api_key, args.hs_code, args.start_ym, args.end_ym)
|
||||
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
TABLE_SCHEMAS: dict[str, str] = {
|
||||
"collection_runs": """
|
||||
CREATE TABLE collection_runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
collector_name TEXT NOT NULL,
|
||||
started_at TEXT NOT NULL,
|
||||
finished_at TEXT,
|
||||
status TEXT NOT NULL,
|
||||
input_source TEXT,
|
||||
output_json_path TEXT,
|
||||
output_db_path TEXT,
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""".strip(),
|
||||
"collection_snapshots": """
|
||||
CREATE TABLE collection_snapshots (
|
||||
run_id TEXT NOT NULL,
|
||||
dataset_name TEXT NOT NULL,
|
||||
ticker TEXT NOT NULL,
|
||||
name TEXT,
|
||||
sector TEXT,
|
||||
as_of_date TEXT,
|
||||
source_priority TEXT,
|
||||
source_status TEXT,
|
||||
payload_json TEXT NOT NULL,
|
||||
provenance_json TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
PRIMARY KEY (run_id, dataset_name, ticker)
|
||||
);
|
||||
""".strip(),
|
||||
"collection_source_errors": """
|
||||
CREATE TABLE collection_source_errors (
|
||||
run_id TEXT NOT NULL,
|
||||
ticker TEXT,
|
||||
source_name TEXT NOT NULL,
|
||||
error_kind TEXT NOT NULL,
|
||||
error_message TEXT NOT NULL,
|
||||
payload_json TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""".strip(),
|
||||
"sell_strategy_results": """
|
||||
CREATE TABLE sell_strategy_results (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
code TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL,
|
||||
action TEXT,
|
||||
conviction TEXT,
|
||||
market_regime TEXT,
|
||||
composite_score DOUBLE PRECISION,
|
||||
rationale TEXT,
|
||||
raw_json TEXT NOT NULL,
|
||||
inserted_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""".strip(),
|
||||
"satellite_recommendations": """
|
||||
CREATE TABLE satellite_recommendations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ticker TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL,
|
||||
satellite_action TEXT,
|
||||
attractiveness_score DOUBLE PRECISION,
|
||||
market_regime TEXT,
|
||||
raw_json TEXT NOT NULL,
|
||||
inserted_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
""".strip(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Emit PostgreSQL migration stub from current canonical row contract.")
|
||||
ap.add_argument("--output-json", type=Path, default=ROOT / "Temp" / "postgresql_upgrade_stub_v1.json")
|
||||
ap.add_argument("--output-sql", type=Path, default=ROOT / "Temp" / "postgresql_upgrade_stub_v1.sql")
|
||||
args = ap.parse_args()
|
||||
|
||||
sql_lines = [
|
||||
"-- PostgreSQL upgrade stub",
|
||||
"-- This file is a contract placeholder only. It is not executed by CI.",
|
||||
"",
|
||||
]
|
||||
for name, ddl in TABLE_SCHEMAS.items():
|
||||
sql_lines.append(f"-- {name}")
|
||||
sql_lines.append(ddl)
|
||||
sql_lines.append("")
|
||||
|
||||
sql_text = "\n".join(sql_lines).rstrip() + "\n"
|
||||
args.output_sql.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output_sql.write_text(sql_text, encoding="utf-8")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"formula_id": "POSTGRESQL_UPGRADE_STUB_V1",
|
||||
"gate": "DATA_GATED",
|
||||
"tables": sorted(TABLE_SCHEMAS.keys()),
|
||||
"output_sql": str(args.output_sql),
|
||||
"note": "DDL stub only; execution deferred until PostgreSQL rollout.",
|
||||
}
|
||||
args.output_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,15 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.kis_data_collection_v1 import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
SERVER_MODULE = "src.quant_engine.snapshot_admin_server_v1"
|
||||
WATCH_DIRS = (
|
||||
ROOT / "src",
|
||||
ROOT / "tools",
|
||||
ROOT / "spec",
|
||||
ROOT / "governance",
|
||||
ROOT / "docs",
|
||||
ROOT / ".gitea",
|
||||
)
|
||||
WATCH_FILES = (
|
||||
ROOT / "package.json",
|
||||
ROOT / "AGENTS.md",
|
||||
ROOT / "GatherTradingData.json",
|
||||
)
|
||||
WATCH_EXTENSIONS = {".py", ".yaml", ".yml", ".json", ".md", ".gs"}
|
||||
IGNORED_DIR_NAMES = {"Temp", "outputs", ".git", "__pycache__", ".pytest_cache"}
|
||||
|
||||
|
||||
def _server_cmd(args: argparse.Namespace) -> list[str]:
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
SERVER_MODULE,
|
||||
"--host",
|
||||
args.host,
|
||||
"--port",
|
||||
str(args.port),
|
||||
"--db",
|
||||
args.db,
|
||||
"--seed",
|
||||
args.seed,
|
||||
]
|
||||
if args.no_bootstrap:
|
||||
cmd.append("--no-bootstrap")
|
||||
return cmd
|
||||
|
||||
|
||||
def _iter_watch_files() -> list[Path]:
|
||||
seen: set[Path] = set()
|
||||
files: list[Path] = []
|
||||
for path in WATCH_FILES:
|
||||
if path.exists() and path.is_file():
|
||||
resolved = path.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
files.append(resolved)
|
||||
for root in WATCH_DIRS:
|
||||
if not root.exists():
|
||||
continue
|
||||
for path in root.rglob("*"):
|
||||
if not path.is_file():
|
||||
continue
|
||||
if any(part in IGNORED_DIR_NAMES for part in path.parts):
|
||||
continue
|
||||
if path.suffix.lower() not in WATCH_EXTENSIONS:
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
if resolved not in seen:
|
||||
seen.add(resolved)
|
||||
files.append(resolved)
|
||||
return files
|
||||
|
||||
|
||||
def _snapshot_mtimes() -> dict[Path, float]:
|
||||
mtimes: dict[Path, float] = {}
|
||||
for path in _iter_watch_files():
|
||||
try:
|
||||
mtimes[path] = path.stat().st_mtime
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
return mtimes
|
||||
|
||||
|
||||
def _changed_files(previous: dict[Path, float]) -> list[Path]:
|
||||
current = _snapshot_mtimes()
|
||||
changed: list[Path] = []
|
||||
for path, mtime in current.items():
|
||||
if previous.get(path) != mtime:
|
||||
changed.append(path)
|
||||
for path in previous:
|
||||
if path not in current:
|
||||
changed.append(path)
|
||||
return changed
|
||||
|
||||
|
||||
def _run_once(args: argparse.Namespace) -> int:
|
||||
proc = subprocess.Popen(_server_cmd(args), cwd=str(ROOT), env=os.environ.copy())
|
||||
try:
|
||||
return proc.wait()
|
||||
except KeyboardInterrupt:
|
||||
proc.terminate()
|
||||
try:
|
||||
return proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
return proc.wait()
|
||||
|
||||
|
||||
def _run_reload(args: argparse.Namespace, interval: float) -> int:
|
||||
last_mtimes = _snapshot_mtimes()
|
||||
child: subprocess.Popen[str] | None = None
|
||||
try:
|
||||
while True:
|
||||
if child is None or child.poll() is not None:
|
||||
if child is not None:
|
||||
code = child.returncode or 0
|
||||
print(f"[snapshot-admin] server exited with code {code}; restarting...")
|
||||
child = subprocess.Popen(_server_cmd(args), cwd=str(ROOT), env=os.environ.copy())
|
||||
print("[snapshot-admin] hot reload watcher active")
|
||||
print("[snapshot-admin] watching:", ", ".join(str(path) for path in WATCH_DIRS))
|
||||
time.sleep(interval)
|
||||
changed = _changed_files(last_mtimes)
|
||||
if changed:
|
||||
print("[snapshot-admin] changes detected:")
|
||||
for path in changed[:20]:
|
||||
print(f" - {path}")
|
||||
last_mtimes = _snapshot_mtimes()
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
child = None
|
||||
except KeyboardInterrupt:
|
||||
if child is not None and child.poll() is None:
|
||||
child.terminate()
|
||||
try:
|
||||
child.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
child.kill()
|
||||
child.wait()
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Run the snapshot admin web server.")
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8787)
|
||||
parser.add_argument("--db", default=str(ROOT / "outputs" / "snapshot_admin" / "snapshot_admin.db"))
|
||||
parser.add_argument("--seed", default=str(ROOT / "GatherTradingData.json"))
|
||||
parser.add_argument("--no-bootstrap", action="store_true")
|
||||
parser.add_argument("--reload", action="store_true", help="Restart the server when watched files change.")
|
||||
parser.add_argument("--reload-interval", type=float, default=1.0, help="Seconds between file-system polls.")
|
||||
args = parser.parse_args()
|
||||
if args.reload:
|
||||
return _run_reload(args, max(0.25, args.reload_interval))
|
||||
return _run_once(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,51 @@
|
||||
"""GAS run_all()을 Gitea CI 스케줄러에서 원격 트리거.
|
||||
|
||||
언어 선택: Python — 이미 이 저장소의 모든 CI/도구가 Python이고(requests만으로 HTTP POST
|
||||
한 번이면 충분), 새 언어를 도입할 이유가 없다(불필요한 복잡성 증가 경계).
|
||||
|
||||
대상 엔드포인트: src/gas/core/gas_lib.gs:doPost action="trigger_run_all" — 공유 비밀키로
|
||||
보호된 GAS 웹앱. run_all()은 데이터 갱신/분석만 수행하며 매수/매도 주문을 실행하지
|
||||
않는다(governance/rules/06,07과 동일 원칙).
|
||||
|
||||
필요한 자격정보(Windows 환경변수, KIS와 동일한 레지스트리 폴백 사용):
|
||||
GAS_WEBAPP_URL — Apps Script 배포 웹앱 URL
|
||||
RUN_ALL_TRIGGER_SECRET — gas_lib.gs Script Properties에 설정한 것과 동일한 값
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.kis_api_client_v1 import _read_env_var # 동일한 env+registry 폴백 재사용
|
||||
|
||||
|
||||
def trigger_run_all(timeout_sec: int = 280) -> dict:
|
||||
webapp_url = _read_env_var("GAS_WEBAPP_URL")
|
||||
secret = _read_env_var("RUN_ALL_TRIGGER_SECRET")
|
||||
if not webapp_url or not secret:
|
||||
return {"status": "ERROR", "message": "GAS_WEBAPP_URL/RUN_ALL_TRIGGER_SECRET 환경변수 없음"}
|
||||
|
||||
resp = requests.post(
|
||||
webapp_url,
|
||||
json={"action": "trigger_run_all", "secret": secret},
|
||||
timeout=timeout_sec,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
result = trigger_run_all()
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if result.get("status") == "OK" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
REQUIRED_PATTERNS = {
|
||||
".gitea/workflows/kis_data_collection.yml": [
|
||||
"secrets.KIS_APP_KEY_TEST",
|
||||
"secrets.KIS_APP_SECRET_TEST",
|
||||
"secrets.KIS_APP_KEY",
|
||||
"secrets.KIS_APP_SECRET",
|
||||
],
|
||||
".gitea/workflows/qualitative_sell_strategy.yml": [
|
||||
"secrets.KIS_APP_KEY_TEST",
|
||||
"secrets.KIS_APP_SECRET_TEST",
|
||||
"secrets.KIS_APP_KEY",
|
||||
"secrets.KIS_APP_SECRET",
|
||||
],
|
||||
".gitea/workflows/ci.yml": [
|
||||
"secrets.KIS_APP_KEY_TEST",
|
||||
"secrets.KIS_APP_SECRET_TEST",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
evidence: dict[str, dict[str, bool]] = {}
|
||||
|
||||
for rel, patterns in REQUIRED_PATTERNS.items():
|
||||
path = ROOT / rel
|
||||
text = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
file_evidence: dict[str, bool] = {}
|
||||
if not path.exists():
|
||||
errors.append(f"missing:{rel}")
|
||||
evidence[rel] = file_evidence
|
||||
continue
|
||||
for pattern in patterns:
|
||||
found = pattern in text
|
||||
file_evidence[pattern] = found
|
||||
if not found:
|
||||
errors.append(f"{rel}:{pattern}")
|
||||
evidence[rel] = file_evidence
|
||||
|
||||
result = {
|
||||
"formula_id": "GITEA_SECRETS_CONTRACT_V1",
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"evidence": evidence,
|
||||
"errors": errors,
|
||||
}
|
||||
out = ROOT / "Temp" / "gitea_secrets_contract_v1.json"
|
||||
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if not errors else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
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))
|
||||
|
||||
try:
|
||||
from src.quant_engine.kis_api_client_v1 import (
|
||||
KisCredentials,
|
||||
MOCK_DOMAIN,
|
||||
REAL_DOMAIN,
|
||||
_read_env_var,
|
||||
get_current_price,
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - import failure is a hard validation error
|
||||
KisCredentials = None # type: ignore[assignment]
|
||||
MOCK_DOMAIN = ""
|
||||
REAL_DOMAIN = ""
|
||||
_read_env_var = None # type: ignore[assignment]
|
||||
get_current_price = None # type: ignore[assignment]
|
||||
_IMPORT_ERROR = str(exc)
|
||||
else:
|
||||
_IMPORT_ERROR = ""
|
||||
|
||||
|
||||
def _payload(gate: str, **extra: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"formula_id": "KIS_API_CREDENTIALS_VALIDATION_V1",
|
||||
"gate": gate,
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def _expected_env_names(account: str) -> tuple[str, str]:
|
||||
if account == "real":
|
||||
return ("KIS_APP_Key", "KIS_APP_Secret")
|
||||
if account == "mock":
|
||||
return ("KIS_APP_Key_TEST", "KIS_APP_Secret_TEST")
|
||||
raise ValueError("account must be 'mock' or 'real'")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser(description="Validate KIS API credentials using the read-only quotations API.")
|
||||
ap.add_argument("--account", choices=["mock", "real"], default="mock")
|
||||
ap.add_argument("--ticker", default="005930")
|
||||
ap.add_argument("--output", type=Path, default=ROOT / "Temp" / "kis_api_credentials_validation_v1.json")
|
||||
args = ap.parse_args()
|
||||
|
||||
if KisCredentials is None or get_current_price is None:
|
||||
result = _payload("FAIL", error=f"import_error: {_IMPORT_ERROR}")
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
|
||||
errors: list[str] = []
|
||||
evidence: dict[str, Any] = {
|
||||
"account": args.account,
|
||||
"ticker": args.ticker,
|
||||
}
|
||||
|
||||
try:
|
||||
key_name, secret_name = _expected_env_names(args.account)
|
||||
creds = KisCredentials.load(args.account)
|
||||
evidence["domain"] = creds.domain
|
||||
evidence["expected_env"] = {"app_key": key_name, "app_secret": secret_name}
|
||||
expected_key = _read_env_var(key_name) if _read_env_var is not None else None
|
||||
expected_secret = _read_env_var(secret_name) if _read_env_var is not None else None
|
||||
other_key = _read_env_var("KIS_APP_Key_TEST" if args.account == "real" else "KIS_APP_Key") if _read_env_var is not None else None
|
||||
other_secret = _read_env_var("KIS_APP_Secret_TEST" if args.account == "real" else "KIS_APP_Secret") if _read_env_var is not None else None
|
||||
actual_key = getattr(creds, "app_key", None)
|
||||
actual_secret = getattr(creds, "app_secret", None)
|
||||
evidence["env_match"] = {
|
||||
"app_key": bool(expected_key and actual_key == expected_key),
|
||||
"app_secret": bool(expected_secret and actual_secret == expected_secret),
|
||||
"other_key_present": bool(other_key),
|
||||
"other_secret_present": bool(other_secret),
|
||||
}
|
||||
if creds.domain != (REAL_DOMAIN if args.account == "real" else MOCK_DOMAIN):
|
||||
errors.append("domain_mismatch")
|
||||
if not evidence["env_match"]["app_key"] or not evidence["env_match"]["app_secret"]:
|
||||
errors.append("selected_env_mismatch")
|
||||
response = get_current_price(creds, args.ticker)
|
||||
evidence["response_keys"] = sorted(response.keys())
|
||||
if not isinstance(response, dict) or not response:
|
||||
errors.append("empty_response")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(str(exc))
|
||||
|
||||
gate = "PASS" if not errors else "FAIL"
|
||||
result = _payload(gate, evidence=evidence, errors=errors)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if gate == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""[CRITICAL] governance/rules/06_no_direct_api_trading.yaml 강제 게이트.
|
||||
|
||||
이 검증기는 순수 stdlib(re, pathlib)만 사용한다 — Synology CI(ARMv7, Python 3.8,
|
||||
requests/pytest 미설치)에서도 항상 실행 가능해야 하는 하드 블로킹 게이트이기 때문이다.
|
||||
문서·테스트만으로는 막을 수 없다는 사용자 지시(2026-06-21)에 따라 정적 소스 스캔으로
|
||||
주문 제출/정정/취소 경로·TR_ID가 코드베이스 어디에도 존재하지 않음을 매 커밋마다 강제한다.
|
||||
|
||||
FAIL 시 CI 전체를 막는다(strict, warn_only 아님) — 다른 데이터 품질 게이트와 다르게
|
||||
이 게이트는 완화 대상이 아니다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
# 이 문자열들이 "데이터"로 등장해도 되는 파일(블록리스트 정의/테스트/이 검증기 자신).
|
||||
# 그 외 모든 .py 파일에서 발견되면 FAIL.
|
||||
ALLOWLISTED_FILES = {
|
||||
"src/quant_engine/kis_api_client_v1.py",
|
||||
"tests/unit/test_kis_api_client_v1.py",
|
||||
"tools/validate_no_direct_api_trading_v1.py",
|
||||
}
|
||||
|
||||
FORBIDDEN_ORDER_PATH_SUBSTRINGS = (
|
||||
"/trading/order-cash",
|
||||
"/trading/order-rvsecncl",
|
||||
"/trading/order-credit",
|
||||
"/trading/order-resv",
|
||||
"/trading/inquire-balance", # governance/rules/07 — 계좌 보유종목 조회 금지
|
||||
)
|
||||
FORBIDDEN_ORDER_TR_IDS = (
|
||||
"TTTC0802U", "TTTC0801U", "VTTC0802U", "VTTC0801U",
|
||||
"TTTC8434R", "VTTC8434R", # governance/rules/07 — 주식잔고조회 금지
|
||||
)
|
||||
BANNED_FUNCTION_NAME_SUBSTRINGS = (
|
||||
"place_order", "submit_order", "cancel_order", "revise_order", "send_order",
|
||||
"order_cash", "order_credit", "order_rvsecncl",
|
||||
"inquire_balance", "account_balance", # governance/rules/07 — 계좌 보유종목 조회 금지
|
||||
)
|
||||
|
||||
|
||||
def _scan_python_files() -> list[str]:
|
||||
violations: list[str] = []
|
||||
for dir_name in ("src", "tools"):
|
||||
for path in (ROOT / dir_name).rglob("*.py"):
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if rel in ALLOWLISTED_FILES:
|
||||
continue
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
for forbidden in FORBIDDEN_ORDER_PATH_SUBSTRINGS:
|
||||
if forbidden in text:
|
||||
violations.append(f"{rel}: 주문 엔드포인트 경로 발견 — {forbidden!r}")
|
||||
for tr_id in FORBIDDEN_ORDER_TR_IDS:
|
||||
if tr_id in text:
|
||||
violations.append(f"{rel}: 주문 TR_ID 발견 — {tr_id!r}")
|
||||
for match in re.finditer(r"def\s+(\w+)\s*\(", text):
|
||||
name = match.group(1).lower()
|
||||
for banned in BANNED_FUNCTION_NAME_SUBSTRINGS:
|
||||
if banned in name:
|
||||
violations.append(f"{rel}: 주문 제출/정정/취소로 의심되는 함수명 — def {match.group(1)}(")
|
||||
return violations
|
||||
|
||||
|
||||
def _check_kis_client_guard_intact() -> list[str]:
|
||||
"""kis_api_client_v1.py가 실제로 존재하면, 가드 코드가 그대로 있는지 + _send_request가
|
||||
HTTP 호출 전에 _assert_read_only를 부르는지 순서를 확인한다."""
|
||||
client_path = ROOT / "src" / "quant_engine" / "kis_api_client_v1.py"
|
||||
if not client_path.exists():
|
||||
return [] # 클라이언트가 아직 없으면 이 검사는 스킵(다른 검사로 충분)
|
||||
|
||||
text = client_path.read_text(encoding="utf-8")
|
||||
violations: list[str] = []
|
||||
required_markers = ("_assert_read_only", "OrderEndpointBlockedError", "FORBIDDEN_PATH_SUBSTRINGS", "FORBIDDEN_TR_ID_PREFIXES")
|
||||
for marker in required_markers:
|
||||
if marker not in text:
|
||||
violations.append(f"kis_api_client_v1.py: 필수 가드 구성요소 누락 — {marker!r}")
|
||||
|
||||
send_request_match = re.search(r"def _send_request\(.*?\)\s*(?:->[^:]*)?:(.*?)(?=\ndef |\Z)", text, re.S)
|
||||
if send_request_match:
|
||||
body = send_request_match.group(1)
|
||||
guard_pos = body.find("_assert_read_only(")
|
||||
http_pos = min(
|
||||
(pos for pos in (body.find("requests.get("), body.find("requests.post(")) if pos != -1),
|
||||
default=-1,
|
||||
)
|
||||
if guard_pos == -1:
|
||||
violations.append("kis_api_client_v1.py: _send_request가 _assert_read_only를 호출하지 않음")
|
||||
elif http_pos != -1 and guard_pos > http_pos:
|
||||
violations.append("kis_api_client_v1.py: _assert_read_only 호출이 HTTP 전송보다 늦음(순서 위반)")
|
||||
else:
|
||||
violations.append("kis_api_client_v1.py: _send_request 함수를 찾을 수 없음")
|
||||
|
||||
return violations
|
||||
|
||||
|
||||
def main() -> int:
|
||||
violations = _scan_python_files() + _check_kis_client_guard_intact()
|
||||
if violations:
|
||||
print("NO_DIRECT_API_TRADING_GATE: FAIL")
|
||||
for v in violations:
|
||||
print(f" - {v}")
|
||||
return 1
|
||||
print("NO_DIRECT_API_TRADING_GATE: PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,294 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
SPEC_PATH = ROOT / "spec" / "16_data_gaps_roadmap.yaml"
|
||||
ROADMAP_DOC_PATH = ROOT / "docs" / "ROADMAP_WBS.md"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _read_text(path: Path) -> str:
|
||||
if not path.exists():
|
||||
return ""
|
||||
return path.read_text(encoding="utf-8", errors="replace")
|
||||
|
||||
|
||||
def _sqlite_counts(db_path: Path) -> dict[str, int]:
|
||||
if not db_path.exists():
|
||||
return {}
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
return {
|
||||
"collection_runs": conn.execute("SELECT COUNT(*) FROM collection_runs").fetchone()[0],
|
||||
"collection_snapshots": conn.execute("SELECT COUNT(*) FROM collection_snapshots").fetchone()[0],
|
||||
"collection_source_errors": conn.execute("SELECT COUNT(*) FROM collection_source_errors").fetchone()[0],
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _load_spec() -> dict[str, Any]:
|
||||
return yaml.safe_load(SPEC_PATH.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _check_p1() -> dict[str, Any]:
|
||||
summary_path = ROOT / "Temp" / "test_kis_data_collection.json"
|
||||
db_path = ROOT / "Temp" / "test_kis_data_collection.db"
|
||||
summary = _read_json(summary_path)
|
||||
counts = _sqlite_counts(db_path)
|
||||
errors: list[str] = []
|
||||
|
||||
if summary.get("status") != "PASS":
|
||||
errors.append(f"summary_status={summary.get('status')!r}")
|
||||
if int(summary.get("row_count") or 0) <= 0:
|
||||
errors.append("summary_row_count<=0")
|
||||
if int(counts.get("collection_runs") or 0) <= 0:
|
||||
errors.append("collection_runs<=0")
|
||||
if int(counts.get("collection_snapshots") or 0) <= 0:
|
||||
errors.append("collection_snapshots<=0")
|
||||
|
||||
source_counts = summary.get("source_counts") if isinstance(summary.get("source_counts"), dict) else {}
|
||||
source_count = len([k for k, v in source_counts.items() if int(v or 0) > 0])
|
||||
if source_count < 1:
|
||||
errors.append(f"provenance_source_count={source_count}")
|
||||
|
||||
return {
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"expected_success_value": {
|
||||
"collector_gate": "PASS",
|
||||
"output_json_gate": "PASS",
|
||||
"collection_runs_min": 1,
|
||||
"collection_snapshots_min": 1,
|
||||
"provenance_source_count_min": 1,
|
||||
},
|
||||
"evidence": {
|
||||
"summary_path": str(summary_path),
|
||||
"db_path": str(db_path),
|
||||
"sqlite_counts": counts,
|
||||
},
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _check_p2() -> dict[str, Any]:
|
||||
from src.quant_engine.data_collection_backend_v1 import CollectionStoreSpec, normalize_store_spec
|
||||
|
||||
db_path = ROOT / "Temp" / "test_kis_data_collection.db"
|
||||
counts = _sqlite_counts(db_path)
|
||||
sqlite_backend, sqlite_location = normalize_store_spec(CollectionStoreSpec(location=db_path), ROOT)
|
||||
pg_backend, pg_location = normalize_store_spec(
|
||||
CollectionStoreSpec(backend="postgresql", location="postgresql://user:pass@localhost/db"),
|
||||
ROOT,
|
||||
)
|
||||
errors: list[str] = []
|
||||
|
||||
if sqlite_backend != "sqlite":
|
||||
errors.append(f"sqlite_backend={sqlite_backend!r}")
|
||||
if pg_backend != "postgresql":
|
||||
errors.append(f"postgres_backend={pg_backend!r}")
|
||||
if not isinstance(pg_location, str) or "postgresql://" not in pg_location:
|
||||
errors.append("postgres_location_invalid")
|
||||
if int(counts.get("collection_runs") or 0) <= 0 or int(counts.get("collection_snapshots") or 0) <= 0:
|
||||
errors.append("sqlite_round_trip_missing")
|
||||
|
||||
return {
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"expected_success_value": {
|
||||
"sqlite_schema_tables_min": 3,
|
||||
"round_trip_snapshot_lookup": "PASS",
|
||||
"backend_contract_sqlite": "PASS",
|
||||
"backend_contract_postgresql": "READY",
|
||||
},
|
||||
"evidence": {
|
||||
"db_path": str(db_path),
|
||||
"sqlite_location": str(sqlite_location),
|
||||
"postgres_location": pg_location,
|
||||
"sqlite_counts": counts,
|
||||
},
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _check_p3() -> dict[str, Any]:
|
||||
workflow = ROOT / ".gitea" / "workflows" / "kis_data_collection.yml"
|
||||
text = _read_text(workflow)
|
||||
errors: list[str] = []
|
||||
|
||||
if not text:
|
||||
errors.append("workflow_missing")
|
||||
if "tools/run_kis_data_collection_v1.py" not in text:
|
||||
errors.append("collector_step_missing")
|
||||
if "tools/validate_kis_api_credentials_v1.py" not in text:
|
||||
errors.append("mock_validation_step_missing")
|
||||
if "GatherTradingData.json" not in text:
|
||||
errors.append("seed_json_missing")
|
||||
if "Validate SQLite Artifact" not in text:
|
||||
errors.append("sqlite_validation_step_missing")
|
||||
if ".xlsx" in text or "GatherTradingData.xlsx" in text:
|
||||
errors.append("xlsx_dependency_present")
|
||||
if "validate_no_direct_api_trading_v1.py" not in text:
|
||||
errors.append("no_direct_trading_gate_missing")
|
||||
if text.count("KIS_APP_Key_TEST") != 1 or text.count("KIS_APP_Secret_TEST") != 1:
|
||||
errors.append("mock_env_vars_not_isolated")
|
||||
if text.count("KIS_APP_Key:") != 1 or text.count("KIS_APP_Secret:") != 1:
|
||||
errors.append("real_env_vars_not_isolated")
|
||||
|
||||
return {
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"expected_success_value": {
|
||||
"xlsx_dependency_removed": True,
|
||||
"json_seed_input": True,
|
||||
"sqlite_output": True,
|
||||
"mock_api_validation": "PASS",
|
||||
"no_direct_trading_gate": "PASS",
|
||||
},
|
||||
"evidence": {
|
||||
"workflow_path": str(workflow),
|
||||
},
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _check_p4() -> dict[str, Any]:
|
||||
validation_path = ROOT / "Temp" / "gas_thin_adapter_validation_v1.json"
|
||||
payload = _read_json(validation_path)
|
||||
errors: list[str] = []
|
||||
|
||||
if payload.get("gate") != "PASS":
|
||||
errors.append(f"gate={payload.get('gate')!r}")
|
||||
if float(payload.get("function_inventory_coverage_pct") or 0.0) < 100.0:
|
||||
errors.append("function_inventory_coverage_pct<100")
|
||||
if not (ROOT / "src" / "gas" / "core" / "gas_lib.gs").exists():
|
||||
errors.append("gas_lib_missing")
|
||||
|
||||
return {
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"expected_success_value": {
|
||||
"allowed_responsibilities_only": True,
|
||||
"forbidden_responsibilities_present": False,
|
||||
"thin_adapter_gate": "PASS",
|
||||
},
|
||||
"evidence": {
|
||||
"validation_path": str(validation_path),
|
||||
"payload": payload,
|
||||
},
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def _check_p5() -> dict[str, Any]:
|
||||
from src.quant_engine.data_collection_backend_v1 import CollectionStoreSpec, normalize_store_spec
|
||||
|
||||
backend_path = ROOT / "src" / "quant_engine" / "data_collection_backend_v1.py"
|
||||
collector_path = ROOT / "src" / "quant_engine" / "kis_data_collection_v1.py"
|
||||
test_path = ROOT / "tests" / "unit" / "test_data_collection_store_v1.py"
|
||||
wrapper_path = ROOT / "tools" / "run_kis_data_collection_v1.py"
|
||||
migration_stub_path = ROOT / "tools" / "generate_postgresql_upgrade_stub_v1.py"
|
||||
errors: list[str] = []
|
||||
|
||||
try:
|
||||
backend, location = normalize_store_spec(
|
||||
CollectionStoreSpec(backend="postgresql", location="postgresql://user:pass@localhost/db"),
|
||||
ROOT,
|
||||
)
|
||||
if backend != "postgresql":
|
||||
errors.append(f"backend={backend!r}")
|
||||
if not isinstance(location, str) or "postgresql://" not in location:
|
||||
errors.append("postgres_location_invalid")
|
||||
except Exception as exc: # noqa: BLE001
|
||||
errors.append(f"normalize_failed={exc}")
|
||||
|
||||
for path in (backend_path, collector_path, test_path, wrapper_path):
|
||||
if not path.exists():
|
||||
errors.append(f"missing={path.relative_to(ROOT)}")
|
||||
if not migration_stub_path.exists():
|
||||
errors.append(f"missing={migration_stub_path.relative_to(ROOT)}")
|
||||
|
||||
return {
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"expected_success_value": {
|
||||
"sqlite_schema_parity": "PASS",
|
||||
"backend_contract_present": True,
|
||||
"postgres_execution": "DATA_GATED",
|
||||
"caller_compatibility_preserved": True,
|
||||
},
|
||||
"evidence": {
|
||||
"backend_path": str(backend_path),
|
||||
"collector_path": str(collector_path),
|
||||
"test_path": str(test_path),
|
||||
"wrapper_path": str(wrapper_path),
|
||||
"migration_stub_path": str(migration_stub_path),
|
||||
},
|
||||
"errors": errors,
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
spec = _load_spec()
|
||||
phase = spec.get("phase_5_platform_transition") or {}
|
||||
roadmap_text = _read_text(ROADMAP_DOC_PATH)
|
||||
checks = {
|
||||
"P1_kis_core_api_collector": _check_p1(),
|
||||
"P2_sqlite_canonical_store": _check_p2(),
|
||||
"P3_ci_scheduler_cutover": _check_p3(),
|
||||
"P4_gas_thin_adapter_minimize": _check_p4(),
|
||||
"P5_postgresql_upgrade_path": _check_p5(),
|
||||
}
|
||||
|
||||
missing_criteria: list[str] = []
|
||||
for key, result in checks.items():
|
||||
spec_row = phase.get(key) or {}
|
||||
criteria = spec_row.get("success_criteria") or {}
|
||||
if not criteria:
|
||||
missing_criteria.append(key)
|
||||
if "expected_success_value" not in criteria:
|
||||
missing_criteria.append(f"{key}.expected_success_value")
|
||||
if "evidence_artifacts" not in criteria:
|
||||
missing_criteria.append(f"{key}.evidence_artifacts")
|
||||
if "verification_commands" not in criteria:
|
||||
missing_criteria.append(f"{key}.verification_commands")
|
||||
if result["gate"] != "PASS":
|
||||
missing_criteria.append(f"{key}.evidence_gate")
|
||||
|
||||
roadmap_mentions = [
|
||||
"Phase 5 데이터 플랫폼 전환 WBS 성공값",
|
||||
"P1 KIS core collector",
|
||||
"P2 SQLite canonical store",
|
||||
"P3 CI scheduler cutover",
|
||||
"P4 GAS thin adapter minimize",
|
||||
"P5 PostgreSQL upgrade path",
|
||||
]
|
||||
roadmap_missing = [item for item in roadmap_mentions if item.lower() not in roadmap_text.lower()]
|
||||
|
||||
payload = {
|
||||
"formula_id": "PLATFORM_TRANSITION_WBS_V1",
|
||||
"gate": "PASS" if not missing_criteria and not roadmap_missing else "FAIL",
|
||||
"spec_path": str(SPEC_PATH),
|
||||
"roadmap_doc_path": str(ROADMAP_DOC_PATH),
|
||||
"missing_criteria": missing_criteria,
|
||||
"roadmap_missing": roadmap_missing,
|
||||
"checks": checks,
|
||||
}
|
||||
out = ROOT / "Temp" / "platform_transition_wbs_v1.json"
|
||||
out.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def _read(path: Path) -> str:
|
||||
return path.read_text(encoding="utf-8", errors="replace") if path.exists() else ""
|
||||
|
||||
|
||||
def main() -> int:
|
||||
files = {
|
||||
"workflow": ROOT / ".gitea" / "workflows" / "qualitative_sell_strategy.yml",
|
||||
"build_inputs": ROOT / "tools" / "build_qualitative_sell_inputs_v1.py",
|
||||
"build_satellite": ROOT / "tools" / "build_satellite_candidate_recommendations_v1.py",
|
||||
"evaluate": ROOT / "tools" / "evaluate_qualitative_sell_strategy_accuracy_v1.py",
|
||||
"store": ROOT / "src" / "quant_engine" / "qualitative_sell_strategy_store_v1.py",
|
||||
"package": ROOT / "package.json",
|
||||
}
|
||||
errors: list[str] = []
|
||||
|
||||
for name, path in files.items():
|
||||
if not path.exists():
|
||||
errors.append(f"missing:{name}")
|
||||
|
||||
checks = {
|
||||
"build_inputs_flags": ("--store-backend" in _read(files["build_inputs"]) and "--store-location" in _read(files["build_inputs"])),
|
||||
"build_satellite_flags": ("--store-backend" in _read(files["build_satellite"]) and "--store-location" in _read(files["build_satellite"])),
|
||||
"evaluate_flags": ("--store-backend" in _read(files["evaluate"]) and "--store-location" in _read(files["evaluate"])),
|
||||
"store_contract": ("resolve_store_path" in _read(files["store"]) and "QualitativeSellStoreSpec" in _read(files["store"])),
|
||||
"workflow_mentions_mock_validation": ("validate_kis_api_credentials_v1.py" in _read(files["workflow"])),
|
||||
"workflow_has_schedule": ("schedule:" in _read(files["workflow"]) and "workflow_dispatch:" in _read(files["workflow"])),
|
||||
"package_scripts": ("ops:sell-build" in _read(files["package"]) and "ops:sell-eval" in _read(files["package"]) and "ops:sell-validate" in _read(files["package"])),
|
||||
}
|
||||
|
||||
for key, ok in checks.items():
|
||||
if not ok:
|
||||
errors.append(key)
|
||||
|
||||
result = {
|
||||
"formula_id": "QUALITATIVE_SELL_STRATEGY_PIPELINE_V1",
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"checks": checks,
|
||||
"errors": errors,
|
||||
}
|
||||
out = ROOT / "Temp" / "qualitative_sell_strategy_pipeline_v1.json"
|
||||
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
return 0 if not errors else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,222 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
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))
|
||||
|
||||
OUT = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
|
||||
|
||||
|
||||
def _read_json(url: str) -> dict[str, Any]:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
payload = response.read().decode("utf-8")
|
||||
data = json.loads(payload)
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _read_text(url: str) -> str:
|
||||
with urllib.request.urlopen(url, timeout=5) as response:
|
||||
return response.read().decode("utf-8")
|
||||
|
||||
|
||||
def _post_json(url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=5) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _wait_for_server(url: str, timeout_s: float = 15.0) -> None:
|
||||
deadline = time.time() + timeout_s
|
||||
last_error: Exception | None = None
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
_read_text(url)
|
||||
return
|
||||
except Exception as exc: # noqa: BLE001
|
||||
last_error = exc
|
||||
time.sleep(0.25)
|
||||
raise RuntimeError(f"server did not start: {last_error}")
|
||||
|
||||
|
||||
def _pick_free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
return int(sock.getsockname()[1])
|
||||
|
||||
|
||||
def main() -> int:
|
||||
port = _pick_free_port()
|
||||
db_path = ROOT / "Temp" / "snapshot_admin_web_validation.db"
|
||||
seed_path = ROOT / "GatherTradingData.json"
|
||||
server_cmd = [
|
||||
sys.executable,
|
||||
str(ROOT / "tools" / "run_snapshot_admin_server_v1.py"),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--db",
|
||||
str(db_path),
|
||||
"--seed",
|
||||
str(seed_path),
|
||||
]
|
||||
|
||||
proc = subprocess.Popen(
|
||||
server_cmd,
|
||||
cwd=ROOT,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
errors: list[str] = []
|
||||
html = ""
|
||||
state: dict[str, Any] = {}
|
||||
|
||||
try:
|
||||
_wait_for_server(base_url)
|
||||
html = _read_text(f"{base_url}/")
|
||||
state = _read_json(f"{base_url}/api/state")
|
||||
export_payload = _read_json(f"{base_url}/api/export")
|
||||
approval_packet = {
|
||||
"formula_id": "SNAPSHOT_ADMIN_APPROVAL_PACKET_V1",
|
||||
"generated_at": state.get("generated_at") or "",
|
||||
"summary": {
|
||||
"settings_changed": 0,
|
||||
"account_snapshot_changed": 0,
|
||||
"pending_target_count": 0,
|
||||
},
|
||||
"pending_targets": [],
|
||||
"diff_preview": {"settings": {"added": [], "removed": [], "changed": []}, "account_snapshot": {"added": [], "removed": [], "changed": []}},
|
||||
"approvals": state.get("approval_rows", []),
|
||||
"locks": state.get("locks", []),
|
||||
"workspace": state.get("summary", {}),
|
||||
}
|
||||
packet_response = _post_json(f"{base_url}/api/approval_packet", {"packet": approval_packet})
|
||||
if "Snapshot Admin" not in html:
|
||||
errors.append("html_title_missing")
|
||||
if "contenteditable" not in html:
|
||||
errors.append("sheet_editor_missing")
|
||||
if "settings" not in html or "Account Snapshot" not in html:
|
||||
errors.append("section_missing")
|
||||
if "/api/settings/save" not in html or "/api/account_snapshot/save" not in html:
|
||||
errors.append("api_binding_missing")
|
||||
if "Approve pending" not in html or "Refresh diff" not in html:
|
||||
errors.append("diff_or_approval_ui_missing")
|
||||
if "Export approval packet" not in html:
|
||||
errors.append("approval_packet_ui_missing")
|
||||
if "Selection Inspector" not in html or "Apply TSV to selection" not in html or "Save view" not in html:
|
||||
errors.append("sheet_facade_ui_missing")
|
||||
if "Recent row history" not in html or "Ctrl+S" not in html:
|
||||
errors.append("sheet_shortcuts_ui_missing")
|
||||
if "KIS Collection" not in html or "collector:" not in html:
|
||||
errors.append("collection_dashboard_ui_missing")
|
||||
if "Recent collector snapshots" not in html or "Collection detail" not in html or "Filter runs / snapshots / errors" not in html:
|
||||
errors.append("collection_detail_ui_missing")
|
||||
if "Filter change log" not in html:
|
||||
errors.append("change_log_filter_ui_missing")
|
||||
if "Timeline" not in html or "/collection" not in html or "Open collection dashboard" not in html:
|
||||
errors.append("collection_page_link_missing")
|
||||
if "Open collection dashboard" not in html:
|
||||
errors.append("collection_dashboard_link_missing")
|
||||
collection_html = _read_text(f"{base_url}/collection")
|
||||
if "KIS Collection Dashboard" not in collection_html or "Download CSV" not in collection_html or "Ticker quick search" not in collection_html or "Date quick search" not in collection_html:
|
||||
errors.append("collection_dashboard_page_missing")
|
||||
if int(state.get("summary", {}).get("settings_rows") or 0) <= 0:
|
||||
errors.append("settings_rows_missing")
|
||||
if int(state.get("summary", {}).get("account_snapshot_rows") or 0) <= 0:
|
||||
errors.append("account_snapshot_rows_missing")
|
||||
topology = state.get("summary", {}).get("topology", {})
|
||||
if not isinstance(topology, dict):
|
||||
errors.append("topology_missing")
|
||||
else:
|
||||
if topology.get("mode") != "single_workspace_sqlite":
|
||||
errors.append("topology_mode_invalid")
|
||||
if not topology.get("settings_and_snapshot_share_db"):
|
||||
errors.append("topology_workspace_split_invalid")
|
||||
if not topology.get("collector_separate_db"):
|
||||
errors.append("topology_collector_split_invalid")
|
||||
if not isinstance(state.get("version"), dict) or not state.get("version", {}).get("app"):
|
||||
errors.append("version_metadata_missing")
|
||||
if not isinstance(state.get("collection"), dict):
|
||||
errors.append("collection_state_missing")
|
||||
collection = state.get("collection", {})
|
||||
if not isinstance(collection.get("counts"), dict):
|
||||
errors.append("collection_counts_missing")
|
||||
if "latest_report" not in collection:
|
||||
errors.append("collection_latest_report_missing")
|
||||
if "data" not in export_payload:
|
||||
errors.append("export_missing_data")
|
||||
if packet_response.get("gate") != "PASS":
|
||||
errors.append("approval_packet_export_failed")
|
||||
packet_path = Path(packet_response.get("packet_path") or "")
|
||||
md_path = Path(packet_response.get("md_path") or "")
|
||||
if not packet_path.exists():
|
||||
errors.append("approval_packet_json_missing")
|
||||
if not md_path.exists():
|
||||
errors.append("approval_packet_md_missing")
|
||||
|
||||
payload = {
|
||||
"formula_id": "SNAPSHOT_ADMIN_WEB_VALIDATION_V1",
|
||||
"gate": "PASS" if not errors else "FAIL",
|
||||
"port": port,
|
||||
"db_path": str(db_path),
|
||||
"base_url": base_url,
|
||||
"errors": errors,
|
||||
"summary": state.get("summary", {}),
|
||||
"version": state.get("version", {}),
|
||||
"settings_rows": int(state.get("summary", {}).get("settings_rows") or 0),
|
||||
"account_snapshot_rows": int(state.get("summary", {}).get("account_snapshot_rows") or 0),
|
||||
"approval_packet_path": str(packet_path),
|
||||
}
|
||||
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 0 if payload["gate"] == "PASS" else 1
|
||||
except urllib.error.URLError as exc:
|
||||
errors.append(str(exc))
|
||||
payload = {
|
||||
"formula_id": "SNAPSHOT_ADMIN_WEB_VALIDATION_V1",
|
||||
"gate": "FAIL",
|
||||
"port": port,
|
||||
"db_path": str(db_path),
|
||||
"base_url": base_url,
|
||||
"errors": errors,
|
||||
"summary": state.get("summary", {}),
|
||||
"version": state.get("version", {}),
|
||||
}
|
||||
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
return 1
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
proc.terminate()
|
||||
try:
|
||||
proc.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
if proc.stdout is not None:
|
||||
proc.stdout.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from src.quant_engine.snapshot_admin_store_v1 import (
|
||||
DEFAULT_DB,
|
||||
DEFAULT_SEED_JSON,
|
||||
import_seed_json,
|
||||
load_account_snapshot_rows,
|
||||
load_settings_rows,
|
||||
parse_account_snapshot_tsv,
|
||||
validate_account_snapshot_rows,
|
||||
validate_settings_rows,
|
||||
write_export_json,
|
||||
)
|
||||
|
||||
OUT = ROOT / "Temp" / "snapshot_admin_workflow_v1.json"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
db_path = DEFAULT_DB
|
||||
seed_path = DEFAULT_SEED_JSON
|
||||
summary = import_seed_json(db_path, seed_path)
|
||||
settings_rows = load_settings_rows(db_path)
|
||||
snapshot_rows = load_account_snapshot_rows(db_path)
|
||||
settings_errors = validate_settings_rows(settings_rows)
|
||||
snapshot_errors = validate_account_snapshot_rows(snapshot_rows)
|
||||
exported = write_export_json(db_path, ROOT / "Temp" / "snapshot_admin_export_v1.json")
|
||||
tsv_rows = parse_account_snapshot_tsv(
|
||||
"\n".join(
|
||||
[
|
||||
"captured_at\taccount\taccount_type\tticker\tname\tholding_quantity\tavailable_quantity\taverage_cost\ttotal_cost\tcurrent_price\tmarket_value\tprofit_loss\treturn_pct\timmediate_cash\tsettlement_cash_d2\tavailable_cash\topen_order_amount\tmonthly_contribution_limit\tmonthly_contribution_used\tparse_status\tuser_confirmed\tstop_price\thighest_price_since_entry\tentry_date\tentry_stage\tposition_type\tlast_updated",
|
||||
"2026-06-21T09:00:00+09:00\treal\t일반계좌\t005930\t삼성전자\t10\t10\t70000\t700000\t71000\t710000\t10000\t1.43\t1000000\t1000000\t1000000\t0\t\t\tCAPTURE_READ_OK\tY\t65000\t72000\t2026-06-01\tstage_1\tcore\t2026-06-21T09:05:00+09:00",
|
||||
]
|
||||
)
|
||||
)
|
||||
payload = {
|
||||
"status": "PASS",
|
||||
"db_path": str(db_path),
|
||||
"seed_path": str(seed_path),
|
||||
"summary": summary,
|
||||
"settings_rows": len(settings_rows),
|
||||
"account_snapshot_rows": len(snapshot_rows),
|
||||
"settings_errors": settings_errors,
|
||||
"snapshot_errors": snapshot_errors,
|
||||
"export_path": str(exported),
|
||||
"tsv_parse_rows": len(tsv_rows),
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
||||
if settings_errors or snapshot_errors:
|
||||
print("FAIL")
|
||||
return 1
|
||||
print("PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+62
-4
@@ -117,6 +117,10 @@ def validate_formula_registry(errors: list[str]) -> None:
|
||||
"ALPHA_FEEDBACK_LOOP_V2", "ALPHA_LEAD_THRESHOLD_OPTIMIZER_V1",
|
||||
# ENGINE_AUDIT — Python-tool-only 감사 게이트 (GAS 런타임 비개입)
|
||||
"IMPUTED_DATA_EXPOSURE_GATE_V1",
|
||||
# Phase-8 비기계적 매도전략 — confluence 기반 판단 게이트 (output_contract 구조)
|
||||
"SHORT_INTEREST_RISK_GAUGE_V1", "QUALITATIVE_SELL_STRATEGY_V1",
|
||||
"MARKET_REGIME_CLASSIFIER_V1", "SATELLITE_CANDIDATE_SCORE_V1",
|
||||
"MICROSTRUCTURE_PRESSURE_FROM_ORDERBOOK_V1",
|
||||
}
|
||||
for formula_id, formula in all_formulas.items():
|
||||
if not isinstance(formula, dict):
|
||||
@@ -619,6 +623,62 @@ def validate_harness_contract_consistency(errors: list[str]) -> None:
|
||||
fail(errors, f"harness_contract collection_key not checked in validator: {key}")
|
||||
|
||||
|
||||
def validate_spec_code_sync(errors: list[str]) -> dict:
|
||||
"""WBS-7.11(2026-06-22) — spec YAML이 code_path로 가리키는 파일이 실제로 존재하는지 검사.
|
||||
|
||||
has_code_implementation 필드가 있는 파일만 검사한다(점진적 롤아웃 — 필드가 없는
|
||||
파일은 스킵되므로 1차 태깅이 기존 PASS 상태를 절대 깨지 않는다). redirect_only:true인
|
||||
파일은 의도적으로 코드가 없는 순수 호환 인덱스이므로 code_path 검사 대상이 아니며,
|
||||
has_code_implementation:true와 동시에 있으면 그 자체로 모순이라 fail한다.
|
||||
"""
|
||||
all_yaml_paths = sorted((ROOT / "spec").rglob("*.yaml")) + sorted((ROOT / "governance").rglob("*.yaml"))
|
||||
total_files = len(all_yaml_paths)
|
||||
checked = 0
|
||||
missing = 0
|
||||
for path in all_yaml_paths:
|
||||
try:
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
meta = data.get("meta") if isinstance(data.get("meta"), dict) else data
|
||||
has_code = meta.get("has_code_implementation")
|
||||
if has_code is None:
|
||||
continue
|
||||
redirect_only = bool(meta.get("redirect_only"))
|
||||
checked += 1
|
||||
if redirect_only and has_code:
|
||||
fail(errors, f"spec_code_sync contradiction: {path} has redirect_only=true AND has_code_implementation=true")
|
||||
missing += 1
|
||||
continue
|
||||
if not has_code:
|
||||
continue
|
||||
code_path = meta.get("code_path")
|
||||
candidates = code_path if isinstance(code_path, list) else [code_path] if code_path else []
|
||||
if not candidates:
|
||||
fail(errors, f"spec_code_sync: {path} declares has_code_implementation=true but no code_path")
|
||||
missing += 1
|
||||
continue
|
||||
for rel in candidates:
|
||||
if not (ROOT / str(rel)).exists():
|
||||
fail(errors, f"spec declares code_path that does not exist: {path} -> {rel}")
|
||||
missing += 1
|
||||
|
||||
result = {
|
||||
"formula_id": "SPEC_CODE_SYNC_V1",
|
||||
"total_spec_files": total_files,
|
||||
"checked_count": checked,
|
||||
"missing_code_path_count": missing,
|
||||
"sync_field_coverage_pct": round(100.0 * checked / total_files, 2) if total_files else 0.0,
|
||||
"gate": "PASS" if missing == 0 else "FAIL",
|
||||
}
|
||||
out = ROOT / "Temp" / "spec_code_sync_v1.json"
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
errors: list[str] = []
|
||||
|
||||
@@ -660,10 +720,9 @@ def main() -> int:
|
||||
manifest_text = (ROOT / "RetirementAssetPortfolio.yaml").read_text(encoding="utf-8")
|
||||
for path in sorted((ROOT / "spec").rglob("*.yaml")):
|
||||
rel = path.relative_to(ROOT).as_posix()
|
||||
if rel not in manifest_text and rel not in {"spec/03_risk_policy.yaml", "spec/04_strategy_rules.yaml"}:
|
||||
if rel not in manifest_text:
|
||||
fail(errors, f"spec file not registered in manifest: {rel}")
|
||||
if path.stat().st_size > MAX_SPEC_BYTES and path.name not in {
|
||||
"03_risk_policy.yaml", "04_strategy_rules.yaml",
|
||||
"13_formula_registry.yaml", "13b_harness_formulas.yaml",
|
||||
"12_field_dictionary.yaml",
|
||||
"51_formula_lifecycle_registry.yaml", # 290+ formula lifecycle registry (Proposal51-P1)
|
||||
@@ -770,13 +829,12 @@ def main() -> int:
|
||||
validate_formula_registry(errors)
|
||||
validate_output_rendering_contract(schema, errors)
|
||||
validate_harness_contract_consistency(errors)
|
||||
validate_spec_code_sync(errors)
|
||||
|
||||
aliases = load_yaml(ROOT / "spec" / "aliases.yaml", errors) or {}
|
||||
alias_map = aliases.get("aliases") or {}
|
||||
alias_files = {
|
||||
ROOT / "spec" / "aliases.yaml",
|
||||
ROOT / "spec" / "03_risk_policy.yaml",
|
||||
ROOT / "spec" / "04_strategy_rules.yaml",
|
||||
ROOT / "spec" / "06_exit_policy.yaml",
|
||||
ROOT / "spec" / "risk" / "risk_control.yaml",
|
||||
ROOT / "spec" / "strategy" / "entry_gates.yaml",
|
||||
|
||||
Reference in New Issue
Block a user