test(validation): 토큰 위생 및 플랫폼 통합 검증 체계 고도화
Snapshot Admin Web Validation / validate-snapshot-admin-smoke (push) Has been cancelled
Snapshot Admin Web Validation / validate-snapshot-admin-full (push) Has been cancelled
Quant Engine CI/CD Pipeline / validate-core (pull_request) Has been cancelled
Quant Engine CI/CD Pipeline / validate-ui-and-storage (pull_request) Has been cancelled
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (pull_request) Has been cancelled

This commit is contained in:
2026-06-24 18:06:05 +09:00
parent 9abb8d3bc3
commit 27730704ae
24 changed files with 850 additions and 54 deletions
@@ -12,6 +12,8 @@ from __future__ import annotations
import json
import sys
import urllib.request
import socket
from datetime import date
from pathlib import Path
@@ -21,6 +23,8 @@ if str(ROOT) not in sys.path:
import unittest
import tools.validate_platform_transition_wbs_v1 as platform_transition_validator
import tools.validate_snapshot_admin_web_v1 as snapshot_admin_validator
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
@@ -175,3 +179,166 @@ class TestKisCollectionIntegration(unittest.TestCase):
self.assertEqual(fetched[0]["action"], decision["action"])
self.assertEqual(fetched[0]["conviction"], decision["conviction"])
self.assertEqual(fetched[0]["market_regime"], decision["market_regime"])
def test_snapshot_admin_and_platform_transition_validators_remain_passable(self):
"""E2E 체인 결과물이 snapshot_admin 및 platform-transition 검증에 그대로 연결되는지 확인."""
snapshot_out = ROOT / "Temp" / "snapshot_admin_web_validation_v1.json"
transition_out = ROOT / "Temp" / "platform_transition_wbs_v1.json"
if snapshot_out.exists():
snapshot_out.unlink()
if transition_out.exists():
transition_out.unlink()
snapshot_rc = snapshot_admin_validator.main()
transition_rc = platform_transition_validator.main()
snapshot_payload = json.loads(snapshot_out.read_text(encoding="utf-8"))
transition_payload = json.loads(transition_out.read_text(encoding="utf-8"))
self.assertEqual(snapshot_rc, 0)
self.assertEqual(snapshot_payload["gate"], "PASS")
self.assertGreater(snapshot_payload["settings_rows"], 0)
self.assertGreater(snapshot_payload["account_snapshot_rows"], 0)
self.assertEqual(transition_rc, 0)
self.assertEqual(transition_payload["gate"], "PASS")
self.assertFalse(transition_payload["missing_criteria"])
self.assertFalse(transition_payload["roadmap_missing"])
self.assertTrue(transition_payload["checks"]["P1_kis_core_api_collector"]["gate"] == "PASS")
self.assertTrue(transition_payload["checks"]["P2_sqlite_canonical_store"]["gate"] == "PASS")
self.assertTrue(transition_payload["checks"]["P3_ci_scheduler_cutover"]["gate"] == "PASS")
self.assertTrue(transition_payload["checks"]["P4_gas_thin_adapter_minimize"]["gate"] == "PASS")
self.assertTrue(transition_payload["checks"]["P5_postgresql_upgrade_path"]["gate"] == "PASS")
def test_snapshot_admin_collection_run_updates_state_with_live_price(self):
"""실제 수집 후 snapshot_admin state가 최신 run 및 live price를 반영하는지 확인."""
import tempfile
import subprocess
import time
with tempfile.TemporaryDirectory() as tmpdir:
tmp_path = Path(tmpdir)
db_path = tmp_path / "snapshot_admin.db"
seed_path = tmp_path / "seed.json"
seed_payload = {
"data": {
"settings": [
{"ordinal": 1, "key": "total_asset_krw", "value": 500000000, "note": "seed"},
{"ordinal": 2, "key": "settlement_cash_d2_krw", "value": 250000000, "note": "seed"},
],
"data_feed": [
{"Ticker": "005930", "Name": "삼성전자", "Sector": "반도체"},
{"Ticker": "000660", "Name": "SK하이닉스", "Sector": "반도체"},
],
"account_snapshot": [
{
"captured_at": "2026-06-24T11:15:47+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": 0,
"settlement_cash_d2": 0,
"available_cash": 0,
"open_order_amount": 0,
"monthly_contribution_limit": 0,
"monthly_contribution_used": 0,
"parse_status": "CAPTURE_PROVIDED_BUT_NOT_HOLDINGS",
"user_confirmed": "N",
"stop_price": 65000,
"highest_price_since_entry": 72000,
"entry_date": "2026-06-01",
"entry_stage": "stage_1",
"position_type": "core",
"last_updated": "2026-06-24T11:15:47+09:00",
}
],
}
}
seed_path.write_text(json.dumps(seed_payload, ensure_ascii=False, indent=2), encoding="utf-8")
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(("127.0.0.1", 0))
port = int(sock.getsockname()[1])
proc = subprocess.Popen(
[
sys.executable,
"-m",
"src.quant_engine.snapshot_admin_server_v1",
"--host",
"127.0.0.1",
"--port",
str(port),
"--db",
str(db_path),
"--seed",
str(seed_path),
],
cwd=ROOT,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
encoding="utf-8",
)
try:
base_url = f"http://127.0.0.1:{port}"
deadline = time.time() + 20
while time.time() < deadline:
if proc.poll() is not None:
output = proc.stdout.read() if proc.stdout else ""
self.fail(f"snapshot_admin server exited early with code {proc.returncode}:\n{output}")
try:
with urllib.request.urlopen(f"{base_url}/api/state", timeout=2) as response:
json.loads(response.read().decode("utf-8"))
break
except Exception:
time.sleep(0.25)
else:
output = proc.stdout.read() if proc.stdout else ""
self.fail(f"snapshot_admin server did not become ready:\n{output}")
request = urllib.request.Request(
f"{base_url}/api/collection/run",
data=json.dumps(
{
"mode": "real",
"kis_account": "real",
"include_live_kis": True,
"allow_naver_fallback": False,
},
ensure_ascii=False,
).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(request, timeout=120) as response:
run_payload = json.loads(response.read().decode("utf-8"))
with urllib.request.urlopen(f"{base_url}/api/state", timeout=10) as response:
state_payload = json.loads(response.read().decode("utf-8"))
collection = state_payload.get("collection", {})
latest_report = collection.get("latest_report", {})
latest_run = collection.get("latest_run", {})
recent_runs = collection.get("runs", [])
self.assertEqual(run_payload["status"], "PASS")
self.assertTrue(any(run.get("run_id") == run_payload["summary"]["run_id"] for run in recent_runs))
self.assertIn(latest_report.get("run_id"), {run_payload["summary"]["run_id"], latest_run.get("run_id")})
self.assertGreaterEqual(latest_report["source_counts"]["kis_open_api"], 1)
self.assertGreaterEqual(len(latest_report.get("rows", [])), 1)
self.assertIsNotNone(latest_report["rows"][0].get("current_price"))
finally:
proc.terminate()
try:
proc.wait(timeout=10)
except Exception:
proc.kill()
@@ -1,6 +1,7 @@
from __future__ import annotations
import sys
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
@@ -88,3 +89,29 @@ def test_intended_price_must_be_positive(tmp_path):
actual_fill_price=100,
recorded_at="2026-06-21",
)
def test_cli_report_is_data_gated_below_minimum_sample(tmp_path):
db_path = tmp_path / "execution_slippage.db"
report_path = ROOT / "Temp" / "execution_slippage_report_v1.json"
if report_path.exists():
report_path.unlink()
result = subprocess.run(
[
sys.executable,
"tools/evaluate_execution_slippage_v1.py",
"--db",
str(db_path),
"report",
],
cwd=ROOT,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
assert "DATA_GATED" in result.stdout
payload = report_path.read_text(encoding="utf-8")
assert '"status": "DATA_GATED"' in payload
+25
View File
@@ -0,0 +1,25 @@
from __future__ import annotations
import json
import sys
import unittest
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_token_hygiene_v1 as validator
class TestKisTokenHygieneV1(unittest.TestCase):
def test_validator_reports_pass(self):
rc = validator.main()
self.assertEqual(rc, 0)
payload = json.loads((ROOT / "Temp" / "kis_token_hygiene_v1.json").read_text(encoding="utf-8"))
self.assertEqual(payload["gate"], "PASS")
self.assertIn("sanitized_token_refresh_error", payload["evidence"][str(ROOT / "src" / "quant_engine" / "kis_api_client_v1.py")])
if __name__ == "__main__":
unittest.main()
+222
View File
@@ -4,6 +4,7 @@ import json
import sys
import unittest
from pathlib import Path
from unittest.mock import Mock, patch
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
@@ -57,6 +58,14 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
self.assertIn("/api/account_snapshot/save", html)
self.assertIn("opsBanner", html)
self.assertIn("bannerApprovalSummary", html)
self.assertIn("heroNextAction", html)
self.assertIn("heroPrimaryAction", html)
self.assertIn("heroCollection", html)
self.assertIn("approvalPanel", html)
self.assertIn("collectionPanel", html)
self.assertIn("selectionPanel", html)
self.assertIn("open when you need row-level operations", html)
self.assertIn("open", html)
self.assertIn("snapshot-panel", html)
self.assertIn("selected-field", html)
self.assertIn("settingsCountChip", html)
@@ -68,6 +77,7 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
self.assertIn("Export approval packet", html)
self.assertIn("Selection Inspector", html)
self.assertIn("Recent row history", html)
self.assertIn("Recent change summary", html)
self.assertIn("Save view", html)
self.assertIn("Apply TSV to selection", html)
self.assertIn("Ctrl+S", html)
@@ -80,26 +90,238 @@ class TestSnapshotAdminWebV1(unittest.TestCase):
self.assertIn("/collection", html)
self.assertIn("Open collection dashboard", html)
def test_render_home_html_contains_role_based_entrances(self):
from src.quant_engine.snapshot_admin_server_v1 import render_home_html
html = render_home_html()
self.assertIn("Snapshot Admin Home", html)
self.assertIn("1. Workspace", html)
self.assertIn("2. Collection", html)
self.assertIn("3. Tables", html)
self.assertIn("Open workspace", html)
self.assertIn("Open collection", html)
self.assertIn("Open tables", html)
self.assertIn("/workspace", html)
self.assertIn("/tables", html)
def test_render_tables_html_contains_table_group_summary(self):
html = render_tables_html()
self.assertIn("Snapshot Admin — Table Browser", html)
self.assertIn("DB별 수정, JSON별 검토, 수집 증빙 확인", html)
self.assertIn("DB 먼저 / JSON은 증빙", html)
self.assertIn("tablePurposeNavigator", html)
self.assertIn("DB 먼저", html)
self.assertIn("JSON은 증빙", html)
self.assertIn("Edit only canonical rows", html)
self.assertIn("DB tables", html)
self.assertIn("Editable source of truth.", html)
self.assertIn("Workbook sheets", html)
self.assertIn("Derived report evidence.", html)
self.assertIn("tablesVersionTop", html)
self.assertIn("tablesVersionBottom", html)
self.assertIn("final_updated_at=", html)
self.assertIn("tablesCoverageWarning", html)
self.assertIn("workbookSheetSelect", html)
self.assertIn("Workbook sheets only", html)
self.assertIn("workbookSheetMeta", html)
self.assertIn("workbookSheetSurfaceMeta", html)
self.assertIn("workbookSheetDetail", html)
self.assertIn("workbookSheetPreview", html)
self.assertIn("tableSelect", html)
self.assertIn("DB Table", html)
self.assertIn("tableGroupSummary", html)
self.assertIn("tableSourceSummary", html)
self.assertIn("Registry details", html)
self.assertIn("History details", html)
self.assertIn("Workspace", html)
self.assertIn("Collection", html)
self.assertIn("Strategy", html)
self.assertIn("JSON", html)
self.assertIn("tableWorkspaceSection", html)
self.assertIn("tableJsonSection", html)
self.assertIn("tableEditState", html)
self.assertIn("bg-danger-lt", html)
self.assertIn("Save applies only to the canonical workspace DB.", html)
self.assertIn("Derived JSON Evidence Preview", html)
self.assertIn("jsonReportStatus", html)
self.assertIn("jsonReportFocus", html)
self.assertIn("jsonReportStats", html)
self.assertIn("jsonReportDetail", html)
self.assertIn("Purpose: edit canonical workspace rows only.", html)
self.assertIn("Collection purpose: monitor collector runs and snapshots.", html)
self.assertIn("Latest run summary loads from `/api/state`.", html)
self.assertIn("Purpose: inspect DB-backed JSON evidence and row payloads.", html)
self.assertIn("Workspace tables are editable only when the table is in the canonical workspace DB.", html)
self.assertIn("DB별 / JSON별 조회 기준", html)
self.assertIn("This page is intentionally a triage surface, not a generic table dump.", html)
self.assertIn("read-only because this table belongs to collector or strategy storage", html)
self.assertIn("Table Browser", html)
self.assertIn("Save changes", html)
self.assertIn("Clear filters", html)
self.assertIn("• current", html)
self.assertIn("workbookRegistrySummary", html)
self.assertIn("dbRegistryBody", html)
self.assertIn("Derived report registry", html)
self.assertIn("DB tables", html)
self.assertIn("workbookPurposeFilters", html)
self.assertIn("Recorded surface", html)
self.assertIn("History details", html)
self.assertIn("historyChangeBody", html)
self.assertIn("historyRunBody", html)
self.assertIn("Derived JSON evidence view", html)
self.assertIn("Derived JSON Evidence Preview", html)
self.assertIn("JSON evidence", html)
def test_render_tables_html_exposes_workbook_registry_surface(self):
html = render_tables_html()
self.assertIn("Workbook sheets", html)
self.assertIn("XLSX:", html)
self.assertIn("JSON evidence:", html)
self.assertIn("JSON role:", html)
self.assertIn("Unmapped:", html)
self.assertIn("Purpose filter:", html)
self.assertIn("destination:", html)
self.assertIn("recorded surface:", html)
self.assertIn("Selected sheet:", html)
self.assertIn("surface:", html)
self.assertIn("version=", html)
self.assertIn("No workbook sheet selected.", html)
self.assertIn("tableSelect", html)
self.assertIn("DB tables only", html)
self.assertIn("Registry details", html)
def test_workbook_registry_maps_xlsx_sheets_to_recording_surfaces(self):
from src.quant_engine.snapshot_admin_server_v1 import load_workbook_sheet_registry
registry = load_workbook_sheet_registry()
self.assertEqual(registry["sheet_count"], 19)
entries = {row["sheet"]: row for row in registry["entries"]}
self.assertEqual(entries["settings"]["kind"], "workspace_db")
self.assertEqual(entries["account_snapshot"]["kind"], "workspace_db")
self.assertEqual(entries["data_feed"]["kind"], "collector_db")
self.assertEqual(entries["settings"]["purpose"], "workspace_edit")
self.assertEqual(entries["data_feed"]["purpose"], "collector_run")
self.assertEqual(registry["json_role"], "derived_report_evidence")
for sheet 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",
]:
self.assertEqual(entries[sheet]["kind"], "json_payload")
self.assertEqual(entries[sheet]["destination"], "GatherTradingData.json")
self.assertEqual(entries[sheet]["source_role"], "derived_report_evidence")
self.assertEqual(entries["sector_universe_refresh_audit"]["kind"], "json_payload")
self.assertEqual(entries["daily_history"]["kind"], "json_payload")
self.assertEqual(entries["sector_universe_refresh_audit"]["purpose"], "refresh_audit")
self.assertEqual(entries["daily_history"]["purpose"], "history_ledger")
self.assertEqual(entries["event_calendar"]["purpose"], "audit_history")
self.assertEqual(entries["alpha_history"]["purpose"], "analysis_report")
self.assertEqual(entries["harness_context"]["purpose"], "execution_context")
self.assertEqual(entries["monthly_history"]["purpose"], "history_ledger")
self.assertEqual(entries["sector_universe"]["purpose"], "universe_registry")
self.assertEqual(entries["event_risk"]["purpose"], "macro_risk_context")
self.assertEqual(entries["sector_flow"]["purpose"], "flow_leadership")
self.assertNotIn("sector_universe_refresh_audit", registry["unmapped_sheets"])
self.assertNotIn("daily_history", registry["unmapped_sheets"])
def test_render_collection_html_contains_dashboard_surface(self):
html = render_collection_html()
self.assertIn("KIS Collection Dashboard", html)
self.assertIn("/api/state", html)
self.assertIn("/api/collection/run", html)
self.assertIn("Collect now", html)
self.assertIn("collectionModeInput", html)
self.assertIn("collectionAccountInput", html)
self.assertIn("collectionRunLog", html)
self.assertIn("collectionRunBanner", html)
self.assertIn("collectionModeBadge", html)
self.assertIn("collectionLiveStatus", html)
self.assertIn("collectionProgressChip", html)
self.assertIn("collectionStageChip", html)
self.assertIn("collectionResultChip", html)
self.assertIn("collectionTrendSummary", html)
self.assertIn("collectionTrendChart", html)
self.assertIn("Auto refreshes every 15 seconds", html)
self.assertIn("older → newer", html)
self.assertIn("Snapshots / run", html)
self.assertIn("Errors / run", html)
self.assertIn("[RUN]", html)
self.assertIn("[SNAPSHOT]", html)
self.assertIn("[ERROR]", html)
self.assertIn("live source: active | KIS rows=", html)
self.assertIn("collectionDetailAnchor", html)
self.assertIn("collectionRunLogAnchor", html)
self.assertIn("Live KIS on", html)
self.assertIn("live source: unknown", html)
self.assertNotIn('value="mock"', html)
self.assertIn("Download raw JSON", html)
self.assertIn("Download CSV", html)
self.assertIn("Filter runs / snapshots / errors", html)
self.assertIn("Unified activity timeline", html)
self.assertIn("date", html)
self.assertIn("Ticker quick search", html)
self.assertIn("Date quick search", html)
def test_run_collection_job_returns_progress_payload(self):
import tempfile
import shutil
from src.quant_engine.snapshot_admin_server_v1 import KIS_COLLECTION_DB, KIS_COLLECTION_REPORT, run_collection_job
tmp_dir = tempfile.mkdtemp()
try:
sqlite_db = Path(tmp_dir) / "kis_data_collection.db"
output_json = Path(tmp_dir) / "kis_data_collection_v1.json"
output_json.write_text(
json.dumps(
{
"generated_at": "2026-06-24T10:00:00+09:00",
"row_count": 1,
"source_counts": {"kis_open_api": 1},
},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
fake_proc = Mock(returncode=0, stdout="ok", stderr="")
with patch("src.quant_engine.snapshot_admin_server_v1.subprocess.run", return_value=fake_proc) as run_mock:
payload = run_collection_job(
sqlite_db=sqlite_db,
input_json=Path(tmp_dir) / "GatherTradingData.json",
output_json=output_json,
kis_account="real",
include_live_kis=True,
allow_naver_fallback=False,
)
self.assertEqual(payload["status"], "PASS")
self.assertEqual(payload["returncode"], 0)
self.assertEqual(payload["stdout"], "ok")
self.assertIn("started_at", payload)
self.assertIn("finished_at", payload)
self.assertIn("elapsed_ms", payload)
self.assertEqual(payload["summary"]["row_count"], 1)
self.assertEqual(payload["summary"]["source_counts"]["kis_open_api"], 1)
self.assertEqual(payload["state"]["db_path"], str(sqlite_db))
run_mock.assert_called_once()
self.assertTrue(str(KIS_COLLECTION_DB).endswith("kis_data_collection.db"))
self.assertTrue(str(KIS_COLLECTION_REPORT).endswith("kis_data_collection_v1.json"))
finally:
shutil.rmtree(tmp_dir, ignore_errors=True)
def test_build_ui_state_exposes_expected_columns(self):
import tempfile
import shutil