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:
2026-06-21 20:13:57 +09:00
92 changed files with 12213 additions and 211 deletions
+80
View File
@@ -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"
+36
View File
@@ -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: |
+131
View File
@@ -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
+44
View File
@@ -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"
+14
View File
@@ -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/`: 패키징/문서/검증/산출물 보조 경로.
+69
View File
@@ -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`을 함께 생성합니다.
+2 -2
View File
@@ -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"
+48
View File
@@ -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
View File
@@ -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 항목의 구현 시 반드시 **하네스 성공 기준**을 먼저 충족 후 다음 단계로 진행합니다.
+15
View File
@@ -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.
+39 -2
View File
@@ -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
+7
View File
@@ -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 캡처 기반 단일 진실원천
원칙이 깨지고, 두 개의 서로 다른 보유종목 데이터 경로가 생겨 정합성 검증이 불가능해진다."
+16
View File
@@ -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",
+2 -2
View File
@@ -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"
}
-28
View File
@@ -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"
-32
View File
@@ -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"
+2
View File
@@ -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:
+19 -4
View File
@@ -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]'
+71
View File
@@ -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"
+2
View File
@@ -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 없이는 산출 금지.
+255 -1
View File
@@ -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 표본 누적 후 자동 평가
+2
View File
@@ -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
View File
@@ -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)의
+38 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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 사례 참조)."
+27 -2
View File
@@ -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.
+153
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
+3 -1
View File
@@ -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:
+3 -2
View File
@@ -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.
+3 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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"
+23
View File
@@ -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 값을 실측 평균으로 갱신 검토"
),
}
+212
View File
@@ -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},
)
+378
View File
@@ -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
+993
View File
@@ -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}")
+50
View File
@@ -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"]
+107
View File
@@ -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
+110
View File
@@ -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",
)
+98
View File
@@ -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
+249
View File
@@ -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)
+144
View File
@@ -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
+32
View File
@@ -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
+136
View File
@@ -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())
+110 -39
View File
@@ -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}건 개선)",
},
}
+205
View File
@@ -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())
+342
View File
@@ -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())
+75
View File
@@ -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())
+168
View File
@@ -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())
+186
View File
@@ -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())
+15
View File
@@ -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())
+164
View File
@@ -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())
+51
View File
@@ -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())
+106
View File
@@ -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())
+112
View File
@@ -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())
+222
View File
@@ -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
View File
@@ -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",