KIS Open API 조회전용 연동 + 직접매매 절대금지 안전게이트
매수/매도 주문 및 계좌 잔고조회를 API로 직접 실행하지 않는다는 원칙을 코드 레벨에서 강제하는 안전게이트(governance/rules/06, 07)와 함께, 시세/호가/공매도거래비중 등 조회전용 KIS Open API 연동 및 SQLite 수집 파이프라인을 추가한다. - kis_api_client_v1: 모든 요청이 _assert_read_only를 통과해야 하며 /trading/ 경로·주문 TR_ID는 RuntimeError로 즉시 차단 - kis_data_collection_v1: KIS 우선 + Naver 폴백, 네트워크 실패는 개별 ticker 단위로 흡수(배치 전체 중단 없음) - data_collection_store_v1 / storage_backend_v1: SQLite 캐노니컬 저장소, PostgreSQL 전환 대비 백엔드 추상화 - Gitea 영업일 스케줄(2시간 간격) + CI 강제 게이트 (validate_no_direct_api_trading_v1, validate_kis_api_credentials_v1)
This commit is contained in:
@@ -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,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,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"]
|
||||
Reference in New Issue
Block a user