Snapshot Admin Home
+settings와 account_snapshot 편집, TSV 적용, 검증 확인은 workspace에서 처리합니다.
+수집 버튼, 진행상태, 결과 로그는 collection 화면에 둡니다.
+DB별 조회, JSON별 조회, 시계열 이력은 tables에서 확인합니다.
+diff --git a/.gitea/workflows/auto_backup_schedule.yml b/.gitea/workflows/auto_backup_schedule.yml index 6bfc502..25f4fbd 100644 --- a/.gitea/workflows/auto_backup_schedule.yml +++ b/.gitea/workflows/auto_backup_schedule.yml @@ -8,7 +8,7 @@ on: jobs: daily-backup: - runs-on: act-runner + runs-on: ubuntu-latest name: Daily Backup steps: @@ -41,7 +41,7 @@ jobs: ls -lh backups/ | tail -5 weekly-full-backup: - runs-on: act-runner + runs-on: ubuntu-latest name: Weekly Full Backup # 매주 월요일 1:00 UTC @@ -85,7 +85,7 @@ jobs: df -h | grep -E "Filesystem|data" backup-health-check: - runs-on: act-runner + runs-on: ubuntu-latest name: Backup Health Check # 매일 12:00 UTC @@ -127,7 +127,7 @@ jobs: du -sh backups/ | awk '{print "Total size: " $1}' test-recovery: - runs-on: act-runner + runs-on: ubuntu-latest name: Monthly Recovery Test # 매월 1일 2:00 UTC diff --git a/.gitea/workflows/backup.yml b/.gitea/workflows/backup.yml index 6fee1af..8a51ed8 100644 --- a/.gitea/workflows/backup.yml +++ b/.gitea/workflows/backup.yml @@ -7,7 +7,7 @@ on: jobs: backup: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run backup diff --git a/.gitea/workflows/calibration_backlog.yml b/.gitea/workflows/calibration_backlog.yml index 8c06377..be4fd24 100644 --- a/.gitea/workflows/calibration_backlog.yml +++ b/.gitea/workflows/calibration_backlog.yml @@ -7,7 +7,7 @@ on: jobs: build-calibration-backlog: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index b1f00e4..fd9df35 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -20,7 +20,7 @@ on: jobs: validate-core: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code @@ -153,8 +153,11 @@ jobs: - name: Validate Snapshot Admin Workflow run: python3 tools/validate_snapshot_admin_workflow_v1.py + - name: Validate DB First Pipeline + run: python3 tools/validate_db_first_pipeline_v1.py + validate-ui-and-storage: - runs-on: self-hosted + runs-on: ubuntu-latest needs: validate-core if: github.event_name != 'push' diff --git a/.gitea/workflows/kis_data_collection.yml b/.gitea/workflows/kis_data_collection.yml index 71b06c1..3912e7b 100644 --- a/.gitea/workflows/kis_data_collection.yml +++ b/.gitea/workflows/kis_data_collection.yml @@ -31,7 +31,7 @@ on: jobs: validate-kis-config-smoke: if: github.event_name == 'workflow_dispatch' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code run: | @@ -90,7 +90,7 @@ jobs: collect-kis-data-live: if: github.event_name == 'schedule' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.gitea/workflows/qualitative_sell_strategy.yml b/.gitea/workflows/qualitative_sell_strategy.yml index 8625287..bc962ca 100644 --- a/.gitea/workflows/qualitative_sell_strategy.yml +++ b/.gitea/workflows/qualitative_sell_strategy.yml @@ -7,7 +7,7 @@ on: jobs: evaluate-qualitative-sell: - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code diff --git a/.gitea/workflows/snapshot_admin.yml b/.gitea/workflows/snapshot_admin.yml index 45ce4d3..cb99e8a 100644 --- a/.gitea/workflows/snapshot_admin.yml +++ b/.gitea/workflows/snapshot_admin.yml @@ -17,7 +17,7 @@ jobs: # Push-only smoke gate: no deployment, no web UI smoke, no long-running side effects. validate-snapshot-admin-smoke: if: github.event_name == 'push' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code run: | @@ -50,10 +50,15 @@ jobs: echo "[smoke] validate workflow only (no web UI, no deploy)" python3 tools/validate_snapshot_admin_workflow_v1.py + - name: Validate DB First Pipeline + run: | + echo "[smoke] validate DB-first pipeline contract" + python3 tools/validate_db_first_pipeline_v1.py + # Manual dispatch gate: full workflow + web UI validation only. validate-snapshot-admin-full: if: github.event_name == 'workflow_dispatch' - runs-on: self-hosted + runs-on: ubuntu-latest steps: - name: Checkout Code run: | @@ -86,6 +91,11 @@ jobs: echo "[full] validate workflow" python3 tools/validate_snapshot_admin_workflow_v1.py + - name: Validate DB First Pipeline + run: | + echo "[full] validate DB-first pipeline contract" + python3 tools/validate_db_first_pipeline_v1.py + - name: Validate Snapshot Admin Web UI run: | echo "[full] validate web ui" diff --git a/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml b/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml index 2efd675..d365baf 100644 --- a/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml +++ b/.gitea/workflows/wbs_9_3_null_policy_ci_gate.yml @@ -14,7 +14,7 @@ on: jobs: null-policy-validation: - runs-on: act-runner + runs-on: ubuntu-latest name: NULL Policy Validation steps: diff --git a/AGENTS.md b/AGENTS.md index 0ba0b1f..c9975f8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,6 +16,22 @@ - 위 4가지 중 하나라도 빠지면 작업은 미완료다. 요약이나 설명만으로 완료 처리하지 않는다. - 완료 보고에는 반드시 변경된 YAML, 코드, 데이터 파일 경로와 검증 명령을 함께 적는다. +## 0c. 작업 수행 절차 강제 +- 모든 작업은 아래 순서를 반드시 따른다. + 1. `로드맵/현황 확인` + 2. `WBS 작성` + 3. `목표 설정` + 4. `성공판단 데이터 정의` + 5. `구현` + 6. `사후 검증` + 7. `증빙 기록` +- 작업 시작 전에는 반드시 해당 작업의 WBS 항목과 성공판단 데이터를 문장 또는 표로 먼저 확정한다. +- 성공판단 데이터가 없으면 구현을 시작하지 않는다. +- “한 줄 추가”, “작아 보이는 수정”도 예외가 아니다. 모든 변경은 WBS와 성공판단 데이터에 매핑되어야 한다. +- 작업 도중 범위가 바뀌면 WBS를 먼저 갱신하고 난 뒤에만 구현을 계속한다. +- 작업 완료 판정은 구현 완료가 아니라 검증 통과와 증빙 기록까지 확인된 경우에만 가능하다. +- 사후 검증 없이 “대충 괜찮다” 식의 진행은 금지한다. + ## 1. 읽는 순서 1. `runtime/active_artifact_manifest.yaml` 2. `Temp/final_decision_packet_active.json` (manifest alias) @@ -101,11 +117,14 @@ ## 5. 개발 규칙 - 새 기능은 contract, schema, golden case, owner ledger를 먼저 만든다. +- 그 다음에 WBS와 성공판단 데이터(테스트/검증 입력과 기대값)를 먼저 만든다. - 구현은 Python canonical first, GAS adapter second다. - `tools/*.py`는 CLI wrapper에 가깝게 유지한다. - `gas_*.gs`는 thin adapter 방향으로 유지한다. - `src/quant_engine`는 canonical package로 유지한다. - `schemas/generated`와 `src/quant_engine/models/generated`는 schema/model parity를 유지한다. +- 코드 변경은 WBS 항목 번호와 성공판단 데이터 파일/명령을 함께 남겨야 한다. +- 검증 결과가 없으면 완료 보고를 하지 않는다. - 경로가 새로 생기면 `AGENTS.md`의 Directory Routing / Serving 섹션과 zip 화이트리스트를 함께 갱신한다. - **Python 인터프리터**: Windows 로컬 환경에서는 반드시 `python`을 사용한다 (`python3` 금지). - `python` → Python 3.13.5 (`Python313/`) — yaml/openpyxl/yfinance 등 프로젝트 패키지 설치됨 diff --git a/docs/GITEA_SECRETS_SETUP.md b/docs/GITEA_SECRETS_SETUP.md index ca93ef3..3577169 100644 --- a/docs/GITEA_SECRETS_SETUP.md +++ b/docs/GITEA_SECRETS_SETUP.md @@ -19,6 +19,14 @@ - `KIS_APP_KEY` - `KIS_APP_SECRET` +## Token Cache Policy + +- KIS access token은 `Temp/kis_tokens.db`에 저장한다. +- 토큰은 `TOKEN_REFRESH_SKEW_MINUTES=10` 기준으로만 재사용/갱신한다. +- 토큰 캐시는 수집 DB와 분리한다. +- 토큰 캐시 상태는 `python tools/inspect_kis_token_cache_v1.py --json`로 점검한다. +- 토큰 갱신 실패 시 appkey/appsecret 또는 API 가용성 문제로만 판단하고, 시크릿 값을 로그나 알림에 그대로 노출하지 않는다. + ## Workflow Mapping - `.gitea/workflows/kis_data_collection.yml` @@ -35,6 +43,7 @@ - mock 계정은 유효성 확인용이다. - real 계정은 실제 데이터 수집용이다. - 둘을 같은 단계에서 혼용하지 않는다. +- 토큰 발급은 1일 1회 원칙을 따르며, 만료 전에는 캐시를 재사용한다. ## Verification diff --git a/docs/ROADMAP_WBS.md b/docs/ROADMAP_WBS.md index 41079da..34abe4c 100644 --- a/docs/ROADMAP_WBS.md +++ b/docs/ROADMAP_WBS.md @@ -5,6 +5,20 @@ --- +## 0a. 현재 실행 우선순위 + +> 2026-06-24 기준, v8.9 채택안(P0~P3)은 검증 완료 상태이며 새 구현 백로그의 최우선 순위는 아래 순서로 고정한다. + +1. `WBS-7.1` 캘리브레이션 임계값 실증 전환 +2. `WBS-7.7` 신규 시스템 E2E 통합 테스트 및 snapshot_admin 스모크 테스트 +3. `WBS-7.8` ETF NAV/괴리율/추적오차/AUM 수집 경로 확정 +4. `WBS-7.5` 임시 하드코딩 폴백 비례화의 실증 보정 +5. `WBS-7.6` 슬리피지 실측 보정 + +`WBS-7.2`, `WBS-7.3`, `WBS-7.4`, `WBS-7.10`~`WBS-7.14`는 현재 문서상 완료 또는 정리 완료로 유지한다. + +--- + ## 0b. 완료 조건 모든 작업은 아래 4가지 증빙이 함께 있을 때만 완료로 본다. @@ -16,6 +30,22 @@ 하나라도 빠지면 완료로 보지 않는다. +## 0c. 작업 절차 강제 + +모든 변경은 아래 순서를 지켜야 한다. + +1. 로드맵/현황 확인 +2. WBS 작성 +3. 목표 설정 +4. 성공판단 데이터 정의 +5. 구현 +6. 사후 검증 +7. 증빙 기록 + +작업 시작 전에 WBS와 성공판단 데이터를 먼저 확정해야 하며, 작은 수정도 예외가 아니다. +작업 도중 범위가 바뀌면 먼저 WBS를 갱신한 뒤 구현을 계속한다. +검증 증빙이 없으면 완료로 볼 수 없다. + --- ## 0c. 비판적 리뷰 (2026-06-21) diff --git a/prompts/analysis_prompt.md b/prompts/analysis_prompt.md index 63662e2..dc8d626 100644 --- a/prompts/analysis_prompt.md +++ b/prompts/analysis_prompt.md @@ -58,6 +58,19 @@ Use this prompt when producing an investment analysis or HTS-ready playbook. | GOAL_RETIREMENT_V1 | goal_current_asset_krw, goal_achievement_pct | {N}% 달성 / 잔여 {M}만원 / ETA {YYYY-MM} | IN_PROGRESS / ACHIEVED | **상황별 선택 추가 공식 (해당 시 반드시 포함):** + +--- + +## WORKFLOW DISCIPLINE + +작업 또는 수정 제안 전에 반드시 아래 4가지를 먼저 확정한다. + +1. WBS 항목 +2. 목표 +3. 성공판단 데이터 +4. 검증 명령 + +이 4가지가 명시되지 않으면 구현, 수정, 렌더링을 시작하지 않는다. - 매수 검토 시: `MEAN_REVERSION_GATE_V1` (이격도 체크 선행), `POSITION_SIZE_V1`, `RISK_BUDGET_CASCADE_V1`, `EXPECTED_EDGE_V1` - 매도 후보 시: `RS_RATIO_V1` (rs_laggard 판정), `SELL_PRIORITY_V1` - 가격 산출 시: `STOP_PRICE_CORE_V1`, `TAKE_PROFIT_LADDER_V2`, `TICK_NORMALIZER_V1` diff --git a/prompts/capture_parse_prompt.md b/prompts/capture_parse_prompt.md index 9759012..6ec1fcb 100644 --- a/prompts/capture_parse_prompt.md +++ b/prompts/capture_parse_prompt.md @@ -15,6 +15,19 @@ HTS 캡처 이미지가 제공되면 이 프롬프트를 **분석보다 먼저** --- +## WORKFLOW DISCIPLINE + +캡처 파싱 전에 반드시 아래 4가지를 먼저 확정한다. + +1. WBS 항목 +2. 목표 +3. 성공판단 데이터 +4. 검증 명령 + +이 4가지가 없으면 파싱을 시작하지 않는다. + +--- + ## STEP 1 — 화면 종류 판별 | 화면 | 판별 기준 | 사용 가능 여부 | diff --git a/prompts/review_prompt.md b/prompts/review_prompt.md index 18e8dd2..2804d6d 100644 --- a/prompts/review_prompt.md +++ b/prompts/review_prompt.md @@ -43,3 +43,16 @@ Do not approve: - PASS order without `execution_quality_table` - WATCH ledger using HTS order columns such as `지정가`, `손절가`, `익절가`, `주문수량`, or `주문금액` - prose headers such as `이번 주 결론`, `현재 포트폴리오 핵심 진단`, `보유 종목별 운용 지침`, `종합 의견` replacing required tables + +--- + +## WORKFLOW DISCIPLINE + +리뷰 전에 반드시 아래 4가지를 요구한다. + +1. WBS 항목 +2. 목표 +3. 성공판단 데이터 +4. 검증 명령 + +이 4가지가 없으면 리뷰 대상은 완료가 아니라 미완료로 판단한다. diff --git a/spec/02_data_contract.yaml b/spec/02_data_contract.yaml index 62c4104..d002a68 100644 --- a/spec/02_data_contract.yaml +++ b/spec/02_data_contract.yaml @@ -160,10 +160,10 @@ quant_feed_contract: - "data_integrity_score=100이어도 pending_critical_category_count>0이면 PASS_100 문구를 쓰지 않는다." json_analysis_protocol: - purpose: "GatherTradingData.json에서 시장 raw 분석 데이터를 빠르게 파싱해 data_completeness_matrix와 판단 입력으로 사용." + purpose: "GatherTradingData.json은 DB 기반 수집 결과를 바탕으로 생성된 파생 보고서 증빙이다. 최종 보고서 렌더링과 data_completeness_matrix 참고용으로만 사용한다." python_parsing_baseline: shell_rule: "PowerShell에서는 Bash heredoc 금지. '@ ... @ | python -' 형식으로 실행." - json_load_rule: "json.loads(Path('GatherTradingData.json').read_text(encoding='utf-8'))를 기본값으로 사용." + json_load_rule: "json.loads(Path('GatherTradingData.json').read_text(encoding='utf-8'))를 기본값으로 사용하되, 원천 추적은 SQLite DB의 history와 snapshot tables를 우선 확인한다." required_top_level: ["metadata", "data"] required_schema_version: "2026-05-18-json-raw-data-v1" required_paths: ["data.data_feed", "data.sector_flow", "data.macro", "data.event_risk", "data.core_satellite"] @@ -171,9 +171,9 @@ quant_feed_contract: text_columns: ["Ticker", "ETF_Code", "Proxy_Ticker", "Base_Ticker", "Constituent_Code", "ETF_Ticker", "Symbol", "ticker"] normalization: "숫자로 읽힌 91160.0, 5930.0 등은 문자열화 후 6자리 zero-pad 적용." validation_commands: ["npm run validate-data-sample", "npm run validate-specs"] - xlsx_refresh_rule: "xlsx 원본을 갱신했으면 npm run convert-data-json 실행 후 JSON을 다시 검증한다." + xlsx_refresh_rule: "xlsx 원본을 갱신했으면 먼저 DB에 반영한 뒤, 엔진이 DB를 읽어 JSON 파생 보고서를 재생성하고 다시 검증한다." xlsx_analysis_protocol: - purpose: "xlsx는 HTS 잔고·거래내역 판독 또는 raw JSON 재생성 감사를 위한 보조 프로토콜이다. 시장 raw 일반 분석은 json_analysis_protocol을 우선한다." + purpose: "xlsx는 HTS 잔고·거래내역 판독 또는 DB 반영 이전의 보조 감사 소스다. 시장 raw 일반 분석과 최종 보고서 생성은 DB 추적 후의 파생 JSON을 우선한다." python_parsing_baseline: shell_rule: "PowerShell에서는 Bash heredoc 금지. '@ ... @ | python -' 형식으로 실행." openpyxl_read_rule: "값 점검은 openpyxl.load_workbook(path, data_only=True, read_only=True)를 기본값으로 사용." diff --git a/spec/30_completion_criteria_contract.yaml b/spec/30_completion_criteria_contract.yaml index 0943225..fc56497 100644 --- a/spec/30_completion_criteria_contract.yaml +++ b/spec/30_completion_criteria_contract.yaml @@ -15,6 +15,20 @@ meta: engine_audit_ref: Temp/engine_audit_v1.json pass_100_ref: Temp/pass_100_criteria_v1.json +workflow_disciplines: + required_preimplementation_order: + - "로드맵/현황 확인" + - "WBS 작성" + - "목표 설정" + - "성공판단 데이터 정의" + - "구현" + - "사후 검증" + - "증빙 기록" + completion_gate_rule: "작업 시작 전 WBS와 성공판단 데이터가 명시되지 않으면 진행 금지" + small_change_rule: "한 줄 추가, 두 줄 추가 같은 소규모 변경도 동일하게 적용" + scope_change_rule: "작업 도중 범위가 바뀌면 먼저 WBS를 갱신한 뒤 계속 진행" + evidence_rule: "검증 증빙 없이는 완료로 간주하지 않음" + # ── §7 프롬프트 완료 조건 ──────────────────────────────────────────────────── criteria: diff --git a/spec/calibration_registry.yaml b/spec/calibration_registry.yaml index 47ab2c9..0a9249f 100644 --- a/spec/calibration_registry.yaml +++ b/spec/calibration_registry.yaml @@ -598,6 +598,28 @@ thresholds: sunset_date: '2026-09-30' unit: rsi value: 70.0 +- gs_location: gas_data_feed.gs:8780 + id: DSD_V1_ANALYST_PEG_BLOCK_PCT + last_calibrated: null + live_sample_requirement: 30 + notes: analystScore 보조 임계 — pegScore>=8이면 가점. 실증 전 EXPERT_PRIOR로 유지. + owner_formula: DISTRIBUTION_SELL_DETECTOR_V1 + sample_n: 0 + source: EXPERT_PRIOR + sunset_date: '2026-09-30' + unit: score_condition + value: 8.0 +- gs_location: gas_data_feed.gs:8780 + id: DSD_V1_ANALYST_UPSIDE_BLOCK_PCT + last_calibrated: null + live_sample_requirement: 30 + notes: analystScore 보조 임계 — upsidePct>15이면 가점. 실증 전 EXPERT_PRIOR로 유지. + owner_formula: DISTRIBUTION_SELL_DETECTOR_V1 + sample_n: 0 + source: EXPERT_PRIOR + sunset_date: '2026-09-30' + unit: pct + value: 15.0 - gs_location: gas_data_feed.gs:2098 id: HEAT_GATE_EVENT_SHOCK_HARD_BLOCK last_calibrated: null diff --git a/src/quant_engine/kis_api_client_v1.py b/src/quant_engine/kis_api_client_v1.py index 898b419..c8cb68e 100644 --- a/src/quant_engine/kis_api_client_v1.py +++ b/src/quant_engine/kis_api_client_v1.py @@ -168,13 +168,16 @@ def _issue_or_reuse_token(creds: KisCredentials) -> str: # 2. 토큰이 만료되었거나 없을 시 KIS API로 새로 발급 요청 requests = _requests() - 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() + try: + 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() + except Exception as exc: + raise RuntimeError("KIS token refresh failed; check credentials and API availability.") from exc 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) @@ -209,9 +212,12 @@ def _send_request(creds: KisCredentials, path: str, tr_id: str, params: dict[str "custtype": "P", } requests = _requests() - resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15) - resp.raise_for_status() - return resp.json() + try: + resp = requests.get(f"{creds.domain}{path}", headers=headers, params=params, timeout=15) + resp.raise_for_status() + return resp.json() + except Exception as exc: + raise RuntimeError(f"KIS read-only request failed for {path} / {tr_id}.") from exc # ── 조회(read-only) 함수 — 전부 GET, 전부 quotations/ranking 카테고리 (실측 확인) ────────── diff --git a/src/quant_engine/kis_data_collection.db b/src/quant_engine/kis_data_collection.db index e141d3d..76ba7a0 100644 Binary files a/src/quant_engine/kis_data_collection.db and b/src/quant_engine/kis_data_collection.db differ diff --git a/src/quant_engine/kis_data_collection_v1.py b/src/quant_engine/kis_data_collection_v1.py index 1a45d5b..8347c2e 100644 --- a/src/quant_engine/kis_data_collection_v1.py +++ b/src/quant_engine/kis_data_collection_v1.py @@ -1,9 +1,9 @@ """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. +The collector treats SQLite as the canonical persistence layer. +`GatherTradingData.json` is emitted only after the DB-backed collection run +finishes, so the JSON acts as a derived reporting artifact rather than the +source of truth. """ from __future__ import annotations diff --git a/src/quant_engine/snapshot_admin_server_v1.py b/src/quant_engine/snapshot_admin_server_v1.py index 1bea589..d2b397d 100644 --- a/src/quant_engine/snapshot_admin_server_v1.py +++ b/src/quant_engine/snapshot_admin_server_v1.py @@ -1,9 +1,12 @@ from __future__ import annotations import argparse +import datetime as dt import json import sqlite3 import subprocess +import time +import sys from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -15,6 +18,8 @@ ROOT = Path(__file__).resolve().parents[2] SNAPSHOT_ADMIN_VERSION = "snapshot-admin-web-v6" KIS_COLLECTION_DB = ROOT / "src" / "quant_engine" / "kis_data_collection.db" KIS_COLLECTION_REPORT = ROOT / "Temp" / "kis_data_collection_v1.json" +WORKBOOK_JSON = ROOT / "GatherTradingData.json" +WORKBOOK_XLSX = ROOT / "GatherTradingData.xlsx" QUALITATIVE_SELL_DB = ROOT / "outputs" / "qualitative_sell_strategy" / "qualitative_sell_strategy.db" # WBS-7.9 부속 — 테이블별 그리드 조회(Tabler). 화이트리스트에 없는 테이블명은 @@ -285,9 +290,15 @@ def _source_fingerprint() -> dict[str, Any]: latest_mtime = max(latest_mtime, path.stat().st_mtime) except OSError: continue + latest_updated_at = "" + if latest_mtime: + latest_updated_at = dt.datetime.fromtimestamp(latest_mtime, tz=dt.timezone.utc).astimezone( + dt.timezone(dt.timedelta(hours=9)) + ).isoformat() return { "fingerprint": digest.hexdigest()[:16], "latest_mtime": latest_mtime, + "latest_updated_at": latest_updated_at, } @@ -331,6 +342,7 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]: collection = load_collection_dashboard_state(KIS_COLLECTION_DB, KIS_COLLECTION_REPORT) except Exception: collection = {} + workbook_registry = load_workbook_sheet_registry() return { "version": { "app": SNAPSHOT_ADMIN_VERSION, @@ -358,10 +370,246 @@ def build_ui_state(db_path: Path | str | None = None) -> dict[str, Any]: }, "autofix_actions": autofix_actions, "collection": collection, + "workbook_registry": workbook_registry, "generated_at": now_kst_iso(), } +def load_workbook_sheet_registry() -> dict[str, Any]: + try: + payload = json.loads(WORKBOOK_JSON.read_text(encoding="utf-8")) + except Exception: + payload = {} + metadata = payload.get("metadata") if isinstance(payload, dict) else {} + sheets = metadata.get("sheets_included") if isinstance(metadata, dict) else [] + sheet_headers = metadata.get("sheet_headers") if isinstance(metadata, dict) else {} + data = payload.get("data") if isinstance(payload, dict) else {} + if not isinstance(sheets, list): + sheets = [] + if not isinstance(data, dict): + data = {} + entries: list[dict[str, Any]] = [] + for sheet in sheets: + sheet_name = str(sheet) + source_role = "derived_report_evidence" + if sheet_name in {"settings", "account_snapshot"}: + destination = "snapshot_admin.db" + kind = "workspace_db" + purpose = "workspace_edit" + source_role = "canonical_db" + elif sheet_name == "data_feed": + destination = "kis_data_collection.db" + kind = "collector_db" + purpose = "collector_run" + source_role = "collector_db" + elif sheet_name in {"sector_universe_refresh_audit", "daily_history", "event_calendar", "pa1_feedback", "alpha_history", "backdata_feature_bank", "sell_priority", "harness_context", "monthly_history", "sector_flow_history", "sector_universe", "core_satellite", "universe", "event_risk", "macro", "sector_flow"}: + destination = "GatherTradingData.json" + if sheet_name in {"sector_universe_refresh_audit"}: + purpose = "refresh_audit" + elif sheet_name in {"daily_history"}: + purpose = "history_ledger" + elif sheet_name in {"event_calendar", "pa1_feedback"}: + purpose = "audit_history" + elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}: + purpose = "analysis_report" + elif sheet_name in {"harness_context"}: + purpose = "execution_context" + elif sheet_name in {"monthly_history", "sector_flow_history"}: + purpose = "history_ledger" + elif sheet_name in {"sector_universe", "core_satellite", "universe"}: + purpose = "universe_registry" + elif sheet_name in {"event_risk", "macro"}: + purpose = "macro_risk_context" + elif sheet_name in {"sector_flow"}: + purpose = "flow_leadership" + else: + purpose = "json_payload" + kind = "json_payload" + else: + destination = "unknown" + kind = "unmapped" + purpose = "unmapped" + header_meta = sheet_headers.get(sheet_name) if isinstance(sheet_headers, dict) else {} + entries.append( + { + "sheet": sheet_name, + "destination": destination, + "kind": kind, + "purpose": purpose, + "source_role": source_role, + "has_json_payload": sheet_name in data, + "row_count": _sheet_payload_row_count(data.get(sheet_name), header_meta), + } + ) + explicit_target_sheets = [ + "sector_universe_refresh_audit", + "daily_history", + "event_calendar", + "pa1_feedback", + "alpha_history", + "backdata_feature_bank", + "sell_priority", + "harness_context", + "monthly_history", + "sector_flow_history", + "sector_universe", + "core_satellite", + "universe", + "event_risk", + "macro", + "sector_flow", + "data_feed", + ] + existing_sheets = {item["sheet"] for item in entries} + for sheet_name in explicit_target_sheets: + if sheet_name in existing_sheets: + continue + if sheet_name == "data_feed": + destination = "kis_data_collection.db" + kind = "collector_db" + purpose = "collector_run" + source_role = "collector_db" + else: + destination = "GatherTradingData.json" + kind = "json_payload" + source_role = "derived_report_evidence" + if sheet_name == "sector_universe_refresh_audit": + purpose = "refresh_audit" + elif sheet_name == "daily_history": + purpose = "history_ledger" + elif sheet_name in {"event_calendar", "pa1_feedback"}: + purpose = "audit_history" + elif sheet_name in {"alpha_history", "backdata_feature_bank", "sell_priority"}: + purpose = "analysis_report" + elif sheet_name == "harness_context": + purpose = "execution_context" + elif sheet_name in {"monthly_history", "sector_flow_history"}: + purpose = "history_ledger" + elif sheet_name in {"sector_universe", "core_satellite", "universe"}: + purpose = "universe_registry" + elif sheet_name in {"event_risk", "macro"}: + purpose = "macro_risk_context" + elif sheet_name == "sector_flow": + purpose = "flow_leadership" + else: + purpose = "json_payload" + entries.append( + { + "sheet": sheet_name, + "destination": destination, + "kind": kind, + "purpose": purpose, + "source_role": source_role, + "has_json_payload": kind == "json_payload", + "row_count": 0, + } + ) + return { + "xlsx_path": str(WORKBOOK_XLSX), + "json_path": str(WORKBOOK_JSON), + "json_role": "derived_report_evidence", + "sheet_count": len(entries), + "entries": entries, + "unmapped_sheets": [item["sheet"] for item in entries if item["kind"] == "unmapped"], + } + + +def _sheet_payload_row_count(value: Any, header_meta: Any | None = None) -> int | None: + if isinstance(header_meta, dict) and isinstance(header_meta.get("row_count"), int): + return int(header_meta["row_count"]) + if isinstance(value, list): + return len(value) + if isinstance(value, dict): + return len(value) + if value is None: + return 0 + return None + + +def run_collection_job( + *, + sqlite_db: Path | None = None, + input_json: Path | None = None, + output_json: Path | None = None, + allow_naver_fallback: bool = False, + include_live_kis: bool = False, + kis_account: str = "real", +) -> dict[str, Any]: + sqlite_db = sqlite_db or KIS_COLLECTION_DB + input_json = input_json or (ROOT / "GatherTradingData.json") + output_json = output_json or KIS_COLLECTION_REPORT + cmd = [ + sys.executable, + str(ROOT / "tools" / "run_kis_data_collection_v1.py"), + "--input-json", + str(input_json), + "--sqlite-db", + str(sqlite_db), + "--output-json", + str(output_json), + "--kis-account", + kis_account, + ] + if allow_naver_fallback: + cmd.append("--allow-naver-fallback") + if not include_live_kis: + cmd.append("--no-live-kis") + + started_at = now_kst_iso() + started = time.perf_counter() + proc = subprocess.run( + cmd, + cwd=str(ROOT), + capture_output=True, + text=True, + encoding="utf-8", + ) + finished_at = now_kst_iso() + elapsed_ms = round((time.perf_counter() - started) * 1000.0, 1) + summary = {} + if output_json.exists(): + try: + loaded = json.loads(output_json.read_text(encoding="utf-8")) + summary = loaded if isinstance(loaded, dict) else {} + except Exception: + summary = {} + state = {} + try: + state = load_collection_dashboard_state(sqlite_db, output_json) + except Exception: + state = {} + return { + "status": "PASS" if proc.returncode == 0 else "FAIL", + "started_at": started_at, + "finished_at": finished_at, + "elapsed_ms": elapsed_ms, + "command": cmd, + "returncode": proc.returncode, + "stdout": proc.stdout, + "stderr": proc.stderr, + "summary": summary, + "state": state, + } + + +def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None: + body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", "application/json; charset=utf-8") + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + +def _text_response(handler: BaseHTTPRequestHandler, status: int, text: str, content_type: str = "text/plain; charset=utf-8") -> None: + body = text.encode("utf-8") + handler.send_response(status) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(body))) + handler.end_headers() + handler.wfile.write(body) + + def _json_response(handler: BaseHTTPRequestHandler, status: int, payload: Any) -> None: body = json.dumps(payload, ensure_ascii=False, indent=2).encode("utf-8") handler.send_response(status) @@ -389,6 +637,118 @@ def _read_json_body(handler: BaseHTTPRequestHandler) -> dict[str, Any]: return payload +def render_home_html() -> str: + return """ + +
+ + +settings와 account_snapshot 편집, TSV 적용, 검증 확인은 workspace에서 처리합니다.
+수집 버튼, 진행상태, 결과 로그는 collection 화면에 둡니다.
+DB별 조회, JSON별 조회, 시계열 이력은 tables에서 확인합니다.
+Select a domain and target_ref, then load.+ +
${esc(JSON.stringify({ before, after }, null, 2))}
- ${esc(JSON.stringify({ before, after }, null, 2))}
+