diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8b5c30a..eec2739 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -29,7 +29,7 @@ "displayName": "Quant Stock Technical", "source": "./plugins/quant-stock-technical", "description": "Acquire audited KIS Korean and U.S. EOD data and screen a deterministic KOSPI, KOSDAQ, NYSE, and NASDAQ technical universe.", - "version": "0.4.4", + "version": "0.4.5", "author": { "name": "mrcha033" }, @@ -137,7 +137,7 @@ "displayName": "Quant Stock Polling Trader", "source": "./plugins/quant-stock-polling-trader", "description": "Build and supervise deterministic daily KIS shadow sessions from official calendars through reconciliation.", - "version": "0.5.3", + "version": "0.5.4", "author": { "name": "mrcha033" }, diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ab7c552..f786353 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -28,6 +28,12 @@ jobs: run: python -B scripts/sync_distribution.py --check - name: Run deterministic calculator self-test run: python -B skills/quant-stock-technical/scripts/analyze_stock.py --self-test + - name: Run first-hour research calculator self-test + run: python -B skills/quant-stock-technical/scripts/analyze_stock_v2.py --self-test + - name: Run read-only opening-hour collector self-test + run: python -B skills/quant-stock-technical/scripts/collect_open1h_research.py --self-test + - name: Run opening-hour evaluator self-test + run: python -B skills/quant-stock-technical/scripts/evaluate_open1h_research.py --self-test - name: Run four-exchange universe builder self-test run: python -B skills/quant-stock-technical/scripts/build_universe_manifest.py --self-test - name: Run deterministic universe screen self-test @@ -42,6 +48,8 @@ jobs: run: python -B skills/quant-stock-polling-trader/scripts/market_calendar.py self-test - name: Run recurring daily pipeline self-test run: python -B skills/quant-stock-polling-trader/scripts/daily_pipeline.py self-test + - name: Run strategy-position lifecycle self-test + run: python -B skills/quant-stock-polling-trader/scripts/position_lifecycle.py --self-test - name: Run systemd generator self-test run: python -B skills/quant-stock-polling-trader/scripts/systemd_units.py self-test - name: Run mandatory handoff-gate self-test @@ -76,6 +84,12 @@ jobs: run: python -B tests/test_screen_universe.py - name: Run screening and execution regression run: python -B tests/test_quant_execution.py + - name: Run QTA2 execution and market-regime regression + run: | + python -B tests/test_qta2_execution.py + python -B tests/test_qta2_market_regime.py + - name: Run strategy-position ledger regression + run: python -B tests/test_position_lifecycle.py - name: Run systemd unit generator regression run: python -B tests/test_systemd_units.py - name: Run KIS U.S. EOD regression diff --git a/plugins/quant-stock-polling-trader/.claude-plugin/plugin.json b/plugins/quant-stock-polling-trader/.claude-plugin/plugin.json index 8fc67a1..bec07ec 100644 --- a/plugins/quant-stock-polling-trader/.claude-plugin/plugin.json +++ b/plugins/quant-stock-polling-trader/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "quant-stock-polling-trader", "displayName": "Quant Stock Polling Trader", - "version": "0.5.3", + "version": "0.5.4", "description": "Run a direct deterministic daily KIS shadow workflow from official calendars through reconciliation.", "author": { "name": "mrcha033", diff --git a/plugins/quant-stock-polling-trader/.codex-plugin/plugin.json b/plugins/quant-stock-polling-trader/.codex-plugin/plugin.json index 2c21d48..a4c5cdf 100644 --- a/plugins/quant-stock-polling-trader/.codex-plugin/plugin.json +++ b/plugins/quant-stock-polling-trader/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "quant-stock-polling-trader", - "version": "0.5.3", + "version": "0.5.4", "description": "Run a direct deterministic daily KIS shadow workflow.", "author": { "name": "mrcha033", diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/SKILL.md b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/SKILL.md index 013bf90..9674a9c 100644 --- a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/SKILL.md +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/SKILL.md @@ -88,6 +88,12 @@ without making a Korean session wait for U.S. EOD or vice versa. Finish an initial multi-year bootstrap before relying on a daily timer; the timer is intended to resume and increment an existing cache. +Set optional `qta_method_version` to `qta-2.0.0` for the research-only +first-hour model. The runner then performs one read-only benchmark-regime call +per active exchange and cycle before stock quotes. A negative, stale, +wrong-session, or unavailable regime observation blocks new entries for that +cycle. QTA2 cannot run in paper or live mode. + To receive optional ntfy completion/block notifications, place these values in the same mode-`0600` environment file used by the generated services: @@ -111,6 +117,15 @@ The account alias is nonsecret and configured by the user. A current session is never entered twice. An unresolved broker mutation from a paper-mode run still requires reconciliation before another entry. +Filled strategy quantities are stored in +`RUNTIME_ROOT/state///positions.sqlite3`, outside the +date directory. Before a later session reads quotes, the runner reconciles +that ledger against the frozen KIS account snapshot. Matching positions roll +forward as `CARRY_EXIT_ONLY`; quantity differences, working orders, duplicate +ownership, or a prior `MANUAL_BLOCK` stop new entries. Holdings not created by +this strategy remain visible as unmanaged broker positions and are never +silently adopted or sold. + ## Direct planning and polling For a non-recurring session, read `references/execution-contract.md`, @@ -170,6 +185,7 @@ python3 scripts/account_snapshot.py self-test python3 scripts/market_calendar.py self-test python3 scripts/daily_pipeline.py self-test python3 scripts/plan_orders.py --self-test +python3 scripts/position_lifecycle.py --self-test python3 scripts/run_session.py self-test python3 scripts/reconcile.py --self-test python3 scripts/systemd_units.py self-test diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md index 5d5345d..a8638d6 100644 --- a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md @@ -38,6 +38,7 @@ Use `qta-daily-shadow-config/v2`: "mode": "shadow", "history_start_date": "2021-01-01", "minimum_sessions": 756, + "qta_method_version": "qta-2.0.0", "request_interval_ms": 120, "catalog_coverage_contract": { "schema": "qta-catalog-coverage-contract/v1", @@ -94,7 +95,11 @@ Use `qta-daily-shadow-config/v2`: } ``` -Only the fields shown above are required. Two optional fields may be added: +`qta_method_version` is optional and defaults to `qta-1.0.0`. Select +`qta-2.0.0` only for shadow research; the screen and runner then enforce its +liquidity and same-session market-regime contracts. + +Three other optional fields may be added: ```json { @@ -118,6 +123,12 @@ The intent ledger is: RUNTIME_ROOT/state/// ``` +The strategy-position ledger persists across dates at: + +```text +RUNTIME_ROOT/state///positions.sqlite3 +``` + The workflow descriptor is `RUNTIME_ROOT/current/.json`, and date-specific artifacts are under `RUNTIME_ROOT/workflows//`. diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/execution-contract.md b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/execution-contract.md index d71cc7e..edbd101 100644 --- a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/execution-contract.md +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/references/execution-contract.md @@ -5,7 +5,7 @@ Keep three independent contracts: ```text -qta-1.0.0 completed T-1 adjusted daily data -> technical payload +qta-1.0.0/2.0.0 completed T-1 adjusted daily data -> technical payload qta-screen-1.0.0 legacy KR/US manifest -> deterministic candidates qta-screen-1.1.0 selected-market manifest -> exchange-ranked candidates open1h-exec-2.0.0 broker/account snapshots -> intents and order lifecycle @@ -62,6 +62,15 @@ resolved-tick alignment, and the emitted 2R take-profit geometry. A smaller, reranked, or semantically forged object cannot make itself acceptable merely by hashing its own contents. +For `qta-2.0.0`, the validator instead recomputes its first-hour total, +verifies the market-specific turnover floor, and requires the exact +research-only market-regime policy. QTA2 can run only in shadow mode. Before +stock quotes in each cycle, the runner reads KOSPI Composite, KOSDAQ +Composite, Nasdaq Composite, or the documented S&P 500 broad-market proxy for +NYSE candidates. It verifies the current-session timestamp, a maximum age of +90 seconds, and return from previous close of at least zero basis points. A +failed check skips new entries for that exchange and cycle. + Each account position must include `market` and `symbol`, plus `exchange` for a v2 screen. Each item in `open_orders` must represent a currently working order and include `side, market, symbol`, plus `exchange` for v2. Existing positions @@ -164,6 +173,9 @@ KIS live US shadow: ceil(intent_count * 1 * 0.12s) Toss shadow: ceil(intent_count * 2 * 0.10s) ``` +QTA2 adds one KIS benchmark request per active exchange to the same cycle +budget. + Reject a configured interval below that floor. This is only an API-pacing feasibility bound, not proof of network latency. Production promotion also requires observed shadow percentiles. @@ -211,6 +223,16 @@ At the window end, freeze new entries, cancel outstanding paper-mode entry orders, and reconcile. Continue managing already filled paper positions. Automatic market flattening is not implemented. +The account/market strategy-position ledger is stored one directory above the +date-specific intent ledger. Authoritative cumulative entry fills create or +update a position with its original stop, target, tick, FX, and plan identity. +Before the next session, exact broker quantities are reconciled and valid +residuals roll to `CARRY_EXIT_ONLY`; they are excluded from new additions. +The bundled lifecycle module deterministically calculates stop, gap-stop, +target, close-liquidation, partial-fill, and daily-loss decisions, but the +current polling service does not yet send live exit mutations. Do not describe +that decision module as active automatic liquidation. + ## State machine ```text diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/broker_adapters.py b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/broker_adapters.py index 42f78bb..8062df2 100755 --- a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/broker_adapters.py +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/broker_adapters.py @@ -9,7 +9,8 @@ import time from collections.abc import Callable from dataclasses import dataclass, replace -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal, ROUND_HALF_UP from typing import Any, ClassVar from urllib.error import HTTPError, URLError from urllib.parse import urlencode @@ -765,6 +766,36 @@ class KisBroker: "NYSE": "NYS", "AMEX": "AMS", } + REGIME_BENCHMARKS: ClassVar[dict[str, dict[str, str]]] = { + "KOSPI": { + "market": "KR", + "benchmark_id": "KOSPI_COMPOSITE", + "provider_symbol": "0001", + "regime_proxy_id": "KOSPI_COMPOSITE", + "timezone": "Asia/Seoul", + }, + "KOSDAQ": { + "market": "KR", + "benchmark_id": "KOSDAQ_COMPOSITE", + "provider_symbol": "1001", + "regime_proxy_id": "KOSDAQ_COMPOSITE", + "timezone": "Asia/Seoul", + }, + "NYSE": { + "market": "US", + "benchmark_id": "NYSE_COMPOSITE", + "provider_symbol": "SPX", + "regime_proxy_id": "S&P_500", + "timezone": "America/New_York", + }, + "NASDAQ": { + "market": "US", + "benchmark_id": "NASDAQ_COMPOSITE", + "provider_symbol": "COMP", + "regime_proxy_id": "NASDAQ_COMPOSITE", + "timezone": "America/New_York", + }, + } def __init__( self, @@ -1122,6 +1153,189 @@ def quote(self, market: str, symbol: str, *, venue: str) -> dict[str, Any]: "raw_status": "OK", } + def benchmark_quote( + self, + *, + exchange: str, + session_date: str, + ) -> dict[str, Any]: + """Return one same-session, read-only benchmark regime observation.""" + exchange = str(exchange).upper() + contract = self.REGIME_BENCHMARKS.get(exchange) + if contract is None: + raise BlockedError(f"unsupported benchmark exchange: {exchange}") + try: + parsed_session_date = date.fromisoformat(session_date) + except (TypeError, ValueError) as exc: + raise BlockedError("benchmark session_date must be YYYY-MM-DD") from exc + if parsed_session_date.isoformat() != session_date: + raise BlockedError("benchmark session_date must be YYYY-MM-DD") + compact_date = session_date.replace("-", "") + received_at = datetime.now(timezone.utc) + + if contract["market"] == "KR": + tr_id = "FHPUP02110100" + response = self.transport.request( + "GET", + ( + f"{self.base_url}/uapi/domestic-stock/v1/quotations/" + "inquire-index-tickprice" + ), + headers=self.headers(tr_id), + query={ + "FID_INPUT_ISCD": contract["provider_symbol"], + "FID_COND_MRKT_DIV_CODE": "U", + }, + timeout_seconds=5, + ) + body = kis_result(response, f"{exchange} benchmark") + rows = body.get("output") + if not isinstance(rows, list) or not rows: + raise BlockedError( + f"KIS {exchange} benchmark output must be a non-empty array" + ) + valid_rows = [ + row + for row in rows + if isinstance(row, dict) + and kis_market_timestamp( + date_value=compact_date, + time_value=row.get("stck_cntg_hour"), + timezone_name=contract["timezone"], + ) + is not None + ] + if not valid_rows: + raise BlockedError( + f"KIS {exchange} benchmark has no broker timestamp" + ) + latest = max( + valid_rows, + key=lambda row: str(row["stck_cntg_hour"]), + ) + source_timestamp = kis_market_timestamp( + date_value=compact_date, + time_value=latest["stck_cntg_hour"], + timezone_name=contract["timezone"], + ) + current = decimal_value( + latest.get("bstp_nmix_prpr"), + f"{exchange} benchmark current", + ) + difference = abs( + decimal_value( + latest.get("bstp_nmix_prdy_vrss"), + f"{exchange} benchmark previous difference", + ) + ) + sign = str(latest.get("prdy_vrss_sign") or "").strip() + if sign in {"1", "2"}: + signed_difference = difference + elif sign == "3": + signed_difference = Decimal(0) + elif sign in {"4", "5"}: + signed_difference = -difference + else: + raise BlockedError( + f"KIS {exchange} benchmark has an unknown previous-close sign" + ) + previous_close = current - signed_difference + if current <= 0 or previous_close <= 0: + raise BlockedError( + f"KIS {exchange} benchmark prices must be positive" + ) + else: + if self.environment == "paper": + raise BlockedError( + "KIS U.S. paper benchmark feed is unsupported; use " + "kis-live/shadow" + ) + tr_id = "FHKST03030200" + response = self.transport.request( + "GET", + ( + f"{self.base_url}/uapi/overseas-price/v1/quotations/" + "inquire-time-indexchartprice" + ), + headers=self.headers(tr_id), + query={ + "FID_COND_MRKT_DIV_CODE": "N", + "FID_INPUT_ISCD": contract["provider_symbol"], + "FID_HOUR_CLS_CODE": "0", + "FID_PW_DATA_INCU_YN": "N", + }, + timeout_seconds=5, + ) + body = kis_result(response, f"{exchange} benchmark") + summary = require_mapping( + body.get("output1"), + f"KIS {exchange} benchmark output1", + ) + rows = body.get("output2") + if not isinstance(rows, list) or not rows: + raise BlockedError( + f"KIS {exchange} benchmark output2 must be a non-empty array" + ) + valid_rows = [ + row + for row in rows + if isinstance(row, dict) + and str(row.get("stck_bsop_date") or "").strip() == compact_date + and kis_market_timestamp( + date_value=row.get("stck_bsop_date"), + time_value=row.get("stck_cntg_hour"), + timezone_name=contract["timezone"], + ) + is not None + ] + if not valid_rows: + raise BlockedError( + f"KIS {exchange} benchmark has no bar for {session_date}" + ) + latest = max( + valid_rows, + key=lambda row: str(row["stck_cntg_hour"]), + ) + source_timestamp = kis_market_timestamp( + date_value=latest["stck_bsop_date"], + time_value=latest["stck_cntg_hour"], + timezone_name=contract["timezone"], + ) + current = decimal_value( + latest.get("optn_prpr"), + f"{exchange} benchmark current", + ) + previous_close = decimal_value( + summary.get("ovrs_nmix_prdy_clpr"), + f"{exchange} benchmark previous close", + ) + if current <= 0 or previous_close <= 0: + raise BlockedError( + f"KIS {exchange} benchmark prices must be positive" + ) + + if source_timestamp is None: + raise BlockedError( + f"KIS {exchange} benchmark source timestamp is invalid" + ) + change_bps = ( + (current / previous_close - Decimal(1)) * Decimal(10000) + ).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return { + "schema": "qta-market-regime-quote/v1", + "broker": "kis", + "exchange": exchange, + "benchmark_id": contract["benchmark_id"], + "regime_proxy_id": contract["regime_proxy_id"], + "provider_symbol": contract["provider_symbol"], + "current": format(current, "f"), + "previous_close": format(previous_close, "f"), + "change_bps": format(change_bps, "f"), + "source_timestamp": source_timestamp, + "received_at": received_at.isoformat(), + "raw_status": "OK", + } + def build_cancel_request( self, *, @@ -1330,6 +1544,13 @@ def get_order( "FT_CCLD_QTY", "ft_ccld_qty", ) + average_fill_price = self._case_value( + record, + "AVG_PRVS", + "avg_prvs", + "FT_CCLD_UNPR3", + "ft_ccld_unpr3", + ) remaining = self._case_value( record, "RMN_QTY", "rmn_qty", "NCCS_QTY", "nccs_qty" ) @@ -1348,6 +1569,17 @@ def get_order( if remaining not in (None, "") else None ) + average_fill_decimal = ( + decimal_value(average_fill_price, "average fill price") + if average_fill_price not in (None, "") + else None + ) + if average_fill_decimal is not None and average_fill_decimal <= 0: + if filled_decimal is not None and filled_decimal > 0: + raise BlockedError( + "KIS filled order has a nonpositive average fill price" + ) + average_fill_decimal = None cancel_value = str( self._case_value(record, "CNCL_YN", "cncl_yn", "CANCEL_YN") or "" ).upper() @@ -1379,6 +1611,9 @@ def get_order( "filled_quantity": None if filled_decimal is None else format(filled_decimal, "f"), + "average_fill_price": None + if average_fill_decimal is None + else format(average_fill_decimal, "f"), "remaining_quantity": None if remaining_decimal is None else format(remaining_decimal, "f"), diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/daily_pipeline.py b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/daily_pipeline.py index 5ad57d9..3794470 100644 --- a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/daily_pipeline.py +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/daily_pipeline.py @@ -50,6 +50,7 @@ OPTIONAL_CONFIG_FIELDS = { "exposure_component_paths", "exposure_component_max_age_seconds", + "qta_method_version", } EXECUTION_DEFAULT_FIELDS = { "snapshot_max_age_seconds", @@ -209,6 +210,11 @@ def normalize_config(raw: Any, path: Path) -> dict[str, Any]: if not isinstance(execution, dict): raise PipelineBlockedError("execution_policy must be an object") exact_fields(execution, EXECUTION_DEFAULT_FIELDS, "execution_policy") + qta_method_version = raw.get("qta_method_version", "qta-1.0.0") + if qta_method_version not in {"qta-1.0.0", "qta-2.0.0"}: + raise PipelineBlockedError( + "qta_method_version must be qta-1.0.0 or qta-2.0.0" + ) component_paths = raw.get("exposure_component_paths", []) if not isinstance(component_paths, list): raise PipelineBlockedError("exposure_component_paths must be an array") @@ -272,6 +278,7 @@ def normalize_config(raw: Any, path: Path) -> dict[str, Any]: "risk_policy": risk, "exposure_component_paths": normalized_component_paths, "exposure_component_max_age_seconds": max_age, + "qta_method_version": qta_method_version, "config_path": path.resolve(), } @@ -697,6 +704,8 @@ def prepare( str(selector_path), "--output", str(screen_path), + "--method-version", + config["qta_method_version"], ), f"{market} screen", ) diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/execution_core.py b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/execution_core.py index d6b2dd4..2bd45fd 100755 --- a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/execution_core.py +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/execution_core.py @@ -31,6 +31,9 @@ SCREEN_SCHEMA_V2 = "qta-screen/v2" MARKET_CURRENCY = {"KR": "KRW", "US": "USD"} V2_SELECTOR_VERSION = "qta-screen-1.1.0" +QTA_METHOD_V1 = "qta-1.0.0" +QTA_METHOD_V2 = "qta-2.0.0" +SUPPORTED_QTA_METHODS = {QTA_METHOD_V1, QTA_METHOD_V2} V2_EXCHANGES_BY_MARKET = { "KR": ("KOSPI", "KOSDAQ"), "US": ("NYSE", "NASDAQ"), @@ -149,6 +152,23 @@ "reference_observations", "assumptions", } +QTA2_READY_FIELDS = QTA_READY_FIELDS | { + "validation_status", + "liquidity", + "market_regime", +} +QTA2_LIQUIDITY_FIELDS = { + "median_20_session_turnover", + "minimum_turnover", + "currency", + "status", +} +QTA2_MARKET_REGIME_FIELDS = { + "metric", + "minimum_change_bps", + "max_age_seconds", + "status", +} QTA_BLOCKED_FIELDS = { "source_skill", "result_schema", @@ -183,11 +203,27 @@ "60d gap p95", } QTA_SCORE_BASIS = "ticker-relative historical percentile; not probability of profit" +QTA2_SCORE_BASIS = ( + "ticker-relative historical percentiles aligned to first-hour " + "continuation; not probability of profit" +) QTA_ASSUMPTIONS = [ "input rows are finalized completed daily sessions", "prices are corporate-action adjusted and aligned to analysis-date raw price scale", "fees, tax, FX, slippage, position size, and execution are excluded", ] +QTA2_ASSUMPTIONS = [ + *QTA_ASSUMPTIONS, + "QTA 2.0 is research-only until multi-session walk-forward validation", + "same-session benchmark regime admission is required downstream", +] +QTA2_LIQUIDITY_FLOOR = { + "KR": Decimal("1000000000"), + "US": Decimal("1000000"), +} +QTA2_REGIME_METRIC = "same_session_previous_close_return_bps" +QTA2_REGIME_MINIMUM_CHANGE_BPS = Decimal("0") +QTA2_REGIME_MAX_AGE_SECONDS = 90 QTA_SETUP_STATUSES = {"READY", "CONDITIONAL"} QTA_OPINIONS = {"강한 긍정", "긍정", "중립", "부정", "강한 부정"} QTA_MIN_SHARED_SESSIONS = 756 @@ -1126,7 +1162,7 @@ def validate_v2_qta_identity( if ( qta.get("source_skill") != "quant-stock-technical" or qta.get("result_schema") != "quant-stock-technical/v1" - or qta.get("method_version") != "qta-1.0.0" + or qta.get("method_version") not in SUPPORTED_QTA_METHODS ): raise BlockedError(f"{label} provenance is invalid") if qta.get("market") != instrument["market"]: @@ -1142,7 +1178,14 @@ def validate_v2_ready_qta( *, screen_analysis_date: date, ) -> None: - exact_fields(qta, QTA_READY_FIELDS, label) + method_version = qta.get("method_version") + exact_fields( + qta, + QTA2_READY_FIELDS + if method_version == QTA_METHOD_V2 + else QTA_READY_FIELDS, + label, + ) validate_v2_qta_identity(qta, instrument, label) if qta["calculation_status"] != "READY": raise BlockedError(f"{label}.calculation_status must be READY") @@ -1161,7 +1204,12 @@ def validate_v2_ready_qta( raise BlockedError( f"{label}.shared_sessions must be an integer >= {QTA_MIN_SHARED_SESSIONS}" ) - if qta["score_basis"] != QTA_SCORE_BASIS: + expected_score_basis = ( + QTA2_SCORE_BASIS + if method_version == QTA_METHOD_V2 + else QTA_SCORE_BASIS + ) + if qta["score_basis"] != expected_score_basis: raise BlockedError(f"{label}.score_basis is invalid") horizon_scores: dict[str, Decimal] = {} @@ -1226,7 +1274,8 @@ def validate_v2_ready_qta( expected_take_profit = entry + Decimal(2) * (entry - stop) if take_profit != expected_take_profit: raise BlockedError( - f"{label}.take_profit_price must equal the qta-1.0.0 2R target" + f"{label}.take_profit_price must equal the " + f"{method_version} 2R target" ) total_score = validate_json_number( qta["total_score"], @@ -1234,17 +1283,97 @@ def validate_v2_ready_qta( minimum=Decimal(0), maximum=Decimal(100), ) - recomputed_total = ( - Decimal("0.25") * horizon_scores["short"] - + Decimal("0.35") * horizon_scores["medium"] - + Decimal("0.40") * horizon_scores["long"] - - Decimal("0.20") * max(risk_score - Decimal(50), Decimal(0)) - ) + if method_version == QTA_METHOD_V2: + recomputed_total = ( + Decimal("0.50") * horizon_scores["short"] + + Decimal("0.30") * horizon_scores["medium"] + + Decimal("0.20") * (Decimal(100) - risk_score) + ) + else: + recomputed_total = ( + Decimal("0.25") * horizon_scores["short"] + + Decimal("0.35") * horizon_scores["medium"] + + Decimal("0.40") * horizon_scores["long"] + - Decimal("0.20") * max(risk_score - Decimal(50), Decimal(0)) + ) recomputed_total = min(Decimal(100), max(Decimal(0), recomputed_total)) if abs(total_score - recomputed_total) > Decimal("0.02"): raise BlockedError( - f"{label}.total_score does not match the qta-1.0.0 score formula" + f"{label}.total_score does not match the {method_version} score formula" + ) + liquidity_ready = True + if method_version == QTA_METHOD_V2: + if qta["validation_status"] != "RESEARCH_ONLY": + raise BlockedError( + f"{label}.validation_status must be RESEARCH_ONLY" + ) + liquidity = qta["liquidity"] + if not isinstance(liquidity, dict): + raise BlockedError(f"{label}.liquidity must be an object") + exact_fields( + liquidity, + QTA2_LIQUIDITY_FIELDS, + f"{label}.liquidity", + ) + median_turnover = validate_json_number( + liquidity["median_20_session_turnover"], + f"{label}.liquidity.median_20_session_turnover", + minimum=Decimal(0), ) + minimum_turnover = validate_json_number( + liquidity["minimum_turnover"], + f"{label}.liquidity.minimum_turnover", + minimum=Decimal(0), + ) + expected_currency = MARKET_CURRENCY[instrument["market"]] + if liquidity["currency"] != expected_currency: + raise BlockedError( + f"{label}.liquidity.currency must be {expected_currency}" + ) + expected_floor = QTA2_LIQUIDITY_FLOOR[instrument["market"]] + if minimum_turnover != expected_floor: + raise BlockedError( + f"{label}.liquidity.minimum_turnover does not match QTA 2.0" + ) + liquidity_ready = median_turnover >= minimum_turnover + expected_liquidity_status = "READY" if liquidity_ready else "BLOCKED" + if liquidity["status"] != expected_liquidity_status: + raise BlockedError( + f"{label}.liquidity.status does not match turnover" + ) + market_regime = qta["market_regime"] + if not isinstance(market_regime, dict): + raise BlockedError(f"{label}.market_regime must be an object") + exact_fields( + market_regime, + QTA2_MARKET_REGIME_FIELDS, + f"{label}.market_regime", + ) + if market_regime["metric"] != QTA2_REGIME_METRIC: + raise BlockedError(f"{label}.market_regime.metric is invalid") + minimum_change = validate_json_number( + market_regime["minimum_change_bps"], + f"{label}.market_regime.minimum_change_bps", + minimum=Decimal("-10000"), + maximum=Decimal("10000"), + ) + if minimum_change != QTA2_REGIME_MINIMUM_CHANGE_BPS: + raise BlockedError( + f"{label}.market_regime.minimum_change_bps does not match " + "QTA 2.0" + ) + if ( + isinstance(market_regime["max_age_seconds"], bool) + or market_regime["max_age_seconds"] + != QTA2_REGIME_MAX_AGE_SECONDS + ): + raise BlockedError( + f"{label}.market_regime.max_age_seconds does not match QTA 2.0" + ) + if market_regime["status"] != "REQUIRED": + raise BlockedError( + f"{label}.market_regime.status must be REQUIRED" + ) allowed_setup_statuses = ( {"READY", "CONDITIONAL"} if total_score == Decimal(60) @@ -1252,6 +1381,8 @@ def validate_v2_ready_qta( if total_score > Decimal(60) else {"CONDITIONAL"} ) + if not liquidity_ready: + allowed_setup_statuses = {"CONDITIONAL"} if qta["setup_status"] not in allowed_setup_statuses: raise BlockedError(f"{label}.setup_status does not match total_score") @@ -1280,8 +1411,15 @@ def validate_v2_ready_qta( f"between {QTA_MIN_REFERENCE_OBSERVATIONS} and " f"{maximum_reference_count}" ) - if qta["assumptions"] != QTA_ASSUMPTIONS: - raise BlockedError(f"{label}.assumptions do not match qta-1.0.0") + expected_assumptions = ( + QTA2_ASSUMPTIONS + if method_version == QTA_METHOD_V2 + else QTA_ASSUMPTIONS + ) + if qta["assumptions"] != expected_assumptions: + raise BlockedError( + f"{label}.assumptions do not match {method_version}" + ) def validate_v2_blocked_qta( @@ -1321,6 +1459,16 @@ def validate_v2_qta( def qta_ranking_key(qta: dict[str, Any]) -> tuple[Any, ...]: + if qta.get("method_version") == QTA_METHOD_V2: + return ( + -decimal_value(qta["total_score"], "qta.total_score"), + -decimal_value(qta["short"]["score"], "qta.short.score"), + -decimal_value(qta["medium"]["score"], "qta.medium.score"), + decimal_value(qta["risk"]["score"], "qta.risk.score"), + -decimal_value(qta["long"]["score"], "qta.long.score"), + str(qta["market"]), + str(qta["ticker"]), + ) return ( -decimal_value(qta["total_score"], "qta.total_score"), -decimal_value(qta["medium"]["score"], "qta.medium.score"), @@ -1420,6 +1568,7 @@ def validate_v2_selected( selected: dict[str, Any], *, analysis_date: date, + method_version: str, ) -> dict[tuple[str, str], dict[str, Any]]: expected_exchanges = set(V2_EXCHANGE_CONTRACTS) if set(selected) != expected_exchanges: @@ -1472,6 +1621,10 @@ def validate_v2_selected( screen_analysis_date=analysis_date, require_ready=True, ) + if qta["method_version"] != method_version: + raise BlockedError( + f"{label}.qta method differs from screen.method_version" + ) key = (exchange, instrument["broker_symbol"]) if key in seen: raise BlockedError( @@ -1494,8 +1647,10 @@ def validate_v2_screen(screen: dict[str, Any]) -> None: raise BlockedError(f"qta-screen/v2 schema must be {SCREEN_SCHEMA_V2}") if screen["screen_status"] != "READY": raise BlockedError("screen_status must be READY") - if screen["method_version"] != "qta-1.0.0": - raise BlockedError("method_version must be qta-1.0.0") + if screen["method_version"] not in SUPPORTED_QTA_METHODS: + raise BlockedError( + "method_version must be qta-1.0.0 or qta-2.0.0" + ) if screen["selector_version"] != V2_SELECTOR_VERSION: raise BlockedError(f"selector_version must be {V2_SELECTOR_VERSION}") validate_sha256(screen["screen_hash"], "screen_hash") @@ -1541,6 +1696,7 @@ def validate_v2_screen(screen: dict[str, Any]) -> None: selected_by_key = validate_v2_selected( selected, analysis_date=analysis_date, + method_version=screen["method_version"], ) decisions = screen["decisions"] if not isinstance(decisions, list): @@ -1586,6 +1742,10 @@ def validate_v2_screen(screen: dict[str, Any]) -> None: screen_analysis_date=analysis_date, require_ready=False, ) + if qta["method_version"] != screen["method_version"]: + raise BlockedError( + f"{label}.qta method differs from screen.method_version" + ) calculation_ready = qta["calculation_status"] == "READY" if not calculation_ready: computed_blocked_count += 1 @@ -1960,7 +2120,7 @@ def plan_orders( qta.get("source_skill") != "quant-stock-technical" or qta.get("result_schema") != "quant-stock-technical/v1" or qta.get("calculation_status") != "READY" - or qta.get("method_version") != "qta-1.0.0" + or qta.get("method_version") != screen_raw["method_version"] ): raise BlockedError("selected QTA payload contract is invalid") if is_v2: @@ -2137,6 +2297,7 @@ def plan_orders( "instrument_type": instrument["instrument_type"], "benchmark_id": instrument["benchmark_id"], "venue": instrument["venue"], + "resolved_tick_size": format(tick, "f"), "tick_contract_hash": sha256_json(instrument["tick_contract"]), } ) diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/position_lifecycle.py b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/position_lifecycle.py new file mode 100644 index 0000000..e60c987 --- /dev/null +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/position_lifecycle.py @@ -0,0 +1,1236 @@ +#!/usr/bin/env python3 +"""Deterministic strategy-position lifecycle and exit decisions.""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys +import tempfile +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path +from typing import Any + +from execution_core import ( + SCREEN_SCHEMA_V2, + BlockedError, + canonical_json, + decimal_value, + exact_fields, + normalize_symbol, + normalized_account_snapshot, + parse_aware_datetime, + positive_decimal, + round_down_to_tick, + sha256_json, +) + +POSITION_POLICY_SCHEMA = "qta-position-policy/v1" +STRATEGY_POSITION_SCHEMA = "qta-strategy-position/v1" +EXIT_DECISION_SCHEMA = "qta-exit-decision/v1" +POSITION_RECONCILIATION_SCHEMA = "qta-position-reconciliation/v1" +POSITION_POLICY_FIELDS = { + "schema", + "close_liquidation_seconds_before_close", + "exit_order_ttl_seconds", + "max_exit_replacements", + "quote_max_age_seconds", + "partial_entry_action", + "gap_down_action", + "daily_loss_action", + "overnight_residual_action", +} +STRATEGY_POSITION_FIELDS = { + "schema", + "position_id", + "plan_hash", + "entry_intent_id", + "market", + "exchange", + "symbol", + "venue", + "currency", + "entry_session_date", + "last_managed_session_date", + "planned_quantity", + "acquired_quantity", + "exited_quantity", + "average_entry_price", + "average_exit_price", + "stop_price", + "take_profit_price", + "tick_size", + "fx_to_krw", + "daily_baseline_session_date", + "daily_baseline_price", + "realized_pnl_native", + "daily_realized_pnl_native", + "status", +} +POSITION_STATUSES = { + "PENDING_ENTRY", + "OPEN", + "EXIT_PENDING", + "EXIT_PARTIAL", + "CARRY_EXIT_ONLY", + "CLOSED", + "MANUAL_BLOCK", +} +MARKET_EXCHANGES = { + "KR": {"KOSPI", "KOSDAQ"}, + "US": {"NYSE", "NASDAQ"}, +} +MARKET_CURRENCY = {"KR": "KRW", "US": "USD"} + + +def require_iso_date(value: Any, field: str) -> str: + if not isinstance(value, str): + raise BlockedError(f"{field} must be YYYY-MM-DD") + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise BlockedError(f"{field} must be YYYY-MM-DD") from exc + if parsed.isoformat() != value: + raise BlockedError(f"{field} must be YYYY-MM-DD") + return value + + +def require_nonnegative_integer(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise BlockedError(f"{field} must be a nonnegative integer") + return value + + +def require_positive_integer(value: Any, field: str) -> int: + amount = require_nonnegative_integer(value, field) + if amount == 0: + raise BlockedError(f"{field} must be a positive integer") + return amount + + +def normalized_position_policy(value: dict[str, Any]) -> dict[str, Any]: + exact_fields(value, POSITION_POLICY_FIELDS, "position policy") + if value["schema"] != POSITION_POLICY_SCHEMA: + raise BlockedError( + f"position policy schema must be {POSITION_POLICY_SCHEMA}" + ) + close_seconds = require_positive_integer( + value["close_liquidation_seconds_before_close"], + "close_liquidation_seconds_before_close", + ) + if close_seconds > 3600: + raise BlockedError( + "close_liquidation_seconds_before_close must be <= 3600" + ) + ttl = require_positive_integer( + value["exit_order_ttl_seconds"], "exit_order_ttl_seconds" + ) + if ttl > 300: + raise BlockedError("exit_order_ttl_seconds must be <= 300") + replacements = require_nonnegative_integer( + value["max_exit_replacements"], "max_exit_replacements" + ) + if replacements > 20: + raise BlockedError("max_exit_replacements must be <= 20") + quote_age = require_positive_integer( + value["quote_max_age_seconds"], "quote_max_age_seconds" + ) + if quote_age > 60: + raise BlockedError("quote_max_age_seconds must be <= 60") + expected_enums = { + "partial_entry_action": "CANCEL_REMAINDER_MANAGE_FILLED", + "gap_down_action": "MARKETABLE_LIMIT", + "daily_loss_action": "ENTRY_FREEZE_LIQUIDATE", + "overnight_residual_action": "EXIT_ONLY_NEXT_SESSION", + } + for field, expected in expected_enums.items(): + if value[field] != expected: + raise BlockedError(f"{field} must be {expected}") + return { + **value, + "close_liquidation_seconds_before_close": close_seconds, + "exit_order_ttl_seconds": ttl, + "max_exit_replacements": replacements, + "quote_max_age_seconds": quote_age, + } + + +def strategy_position_id(plan_hash: str, intent: dict[str, Any]) -> str: + if len(plan_hash) != 64 or any( + character not in "0123456789abcdef" for character in plan_hash + ): + raise BlockedError("plan_hash must be lowercase SHA-256") + seed = { + "plan_hash": plan_hash, + "entry_intent_id": intent.get("intent_id"), + "market": intent.get("market"), + "exchange": intent.get("exchange"), + "symbol": intent.get("symbol"), + "venue": intent.get("venue"), + } + return sha256_json(seed)[:32] + + +def position_from_intent( + *, + plan_hash: str, + intent: dict[str, Any], + session_date: str, + fx_to_krw: Any, +) -> dict[str, Any]: + session = require_iso_date(session_date, "session_date") + market = str(intent.get("market", "")).upper() + if market not in MARKET_EXCHANGES: + raise BlockedError("intent market must be KR or US") + exchange = str(intent.get("exchange", "")).upper() + if exchange not in MARKET_EXCHANGES[market]: + raise BlockedError("intent exchange does not match market") + symbol = normalize_symbol(market, str(intent.get("symbol", ""))) + venue = str(intent.get("venue", "")).upper() + if not venue: + raise BlockedError("intent venue is required") + quantity = positive_decimal(intent.get("quantity"), "intent quantity") + if quantity != quantity.to_integral_value(): + raise BlockedError("strategy positions require whole-share quantity") + currency = str(intent.get("currency", "")).upper() + if currency != MARKET_CURRENCY[market]: + raise BlockedError("intent currency does not match market") + fx = positive_decimal(fx_to_krw, "fx_to_krw") + stop = positive_decimal(intent.get("stop_price"), "stop_price") + take_profit = positive_decimal( + intent.get("take_profit_price"), "take_profit_price" + ) + tick_source = intent.get("resolved_tick_size") + if tick_source is None: + tick_source = intent.get("tick_size") + if tick_source is None: + raise BlockedError("intent resolved tick size is required") + tick = positive_decimal(tick_source, "tick_size") + entry_intent_id = str(intent.get("intent_id", "")) + if len(entry_intent_id) != 32: + raise BlockedError("entry intent ID must contain 32 characters") + output = { + "schema": STRATEGY_POSITION_SCHEMA, + "position_id": strategy_position_id(plan_hash, intent), + "plan_hash": plan_hash, + "entry_intent_id": entry_intent_id, + "market": market, + "exchange": exchange, + "symbol": symbol, + "venue": venue, + "currency": currency, + "entry_session_date": session, + "last_managed_session_date": session, + "planned_quantity": format(quantity, "f"), + "acquired_quantity": "0", + "exited_quantity": "0", + "average_entry_price": None, + "average_exit_price": None, + "stop_price": format(stop, "f"), + "take_profit_price": format(take_profit, "f"), + "tick_size": format(tick, "f"), + "fx_to_krw": format(fx, "f"), + "daily_baseline_session_date": None, + "daily_baseline_price": None, + "realized_pnl_native": "0", + "daily_realized_pnl_native": "0", + "status": "PENDING_ENTRY", + } + return normalize_strategy_position(output) + + +def normalize_strategy_position(value: dict[str, Any]) -> dict[str, Any]: + exact_fields(value, STRATEGY_POSITION_FIELDS, "strategy position") + if value["schema"] != STRATEGY_POSITION_SCHEMA: + raise BlockedError( + f"strategy position schema must be {STRATEGY_POSITION_SCHEMA}" + ) + position_id = str(value["position_id"]) + if len(position_id) != 32 or any( + character not in "0123456789abcdef" for character in position_id + ): + raise BlockedError("position_id must be 32 lowercase hex characters") + plan_hash = str(value["plan_hash"]) + if len(plan_hash) != 64 or any( + character not in "0123456789abcdef" for character in plan_hash + ): + raise BlockedError("plan_hash must be lowercase SHA-256") + market = str(value["market"]).upper() + exchange = str(value["exchange"]).upper() + if market not in MARKET_EXCHANGES or exchange not in MARKET_EXCHANGES[market]: + raise BlockedError("strategy position exchange does not match market") + symbol = normalize_symbol(market, str(value["symbol"])) + currency = str(value["currency"]).upper() + if currency != MARKET_CURRENCY[market]: + raise BlockedError("strategy position currency does not match market") + entry_date = require_iso_date( + value["entry_session_date"], "entry_session_date" + ) + managed_date = require_iso_date( + value["last_managed_session_date"], "last_managed_session_date" + ) + if managed_date < entry_date: + raise BlockedError("last managed session cannot precede entry session") + planned = positive_decimal(value["planned_quantity"], "planned_quantity") + acquired = decimal_value(value["acquired_quantity"], "acquired_quantity") + exited = decimal_value(value["exited_quantity"], "exited_quantity") + for amount, field in ( + (planned, "planned_quantity"), + (acquired, "acquired_quantity"), + (exited, "exited_quantity"), + ): + if amount < 0 or amount != amount.to_integral_value(): + raise BlockedError(f"{field} must be a nonnegative whole number") + if acquired > planned: + raise BlockedError("acquired quantity exceeds planned quantity") + if exited > acquired: + raise BlockedError("exited quantity exceeds acquired quantity") + average_entry = value["average_entry_price"] + if acquired > 0: + average_entry = format( + positive_decimal(average_entry, "average_entry_price"), "f" + ) + elif average_entry is not None: + raise BlockedError("zero acquired quantity cannot have an entry price") + average_exit = value["average_exit_price"] + if exited > 0: + average_exit = format( + positive_decimal(average_exit, "average_exit_price"), "f" + ) + elif average_exit is not None: + raise BlockedError("zero exited quantity cannot have an exit price") + baseline_date = value["daily_baseline_session_date"] + baseline_price = value["daily_baseline_price"] + if baseline_date is None: + if baseline_price is not None: + raise BlockedError("daily baseline date and price must both be null") + else: + baseline_date = require_iso_date( + baseline_date, "daily_baseline_session_date" + ) + if baseline_date != managed_date: + raise BlockedError("daily baseline must match last managed session") + baseline_price = format( + positive_decimal(baseline_price, "daily_baseline_price"), "f" + ) + stop = positive_decimal(value["stop_price"], "stop_price") + take_profit = positive_decimal(value["take_profit_price"], "take_profit_price") + if stop >= take_profit: + raise BlockedError("stop price must be below take-profit price") + tick = positive_decimal(value["tick_size"], "tick_size") + fx = positive_decimal(value["fx_to_krw"], "fx_to_krw") + realized = decimal_value(value["realized_pnl_native"], "realized_pnl_native") + daily_realized = decimal_value( + value["daily_realized_pnl_native"], "daily_realized_pnl_native" + ) + status = str(value["status"]) + if status not in POSITION_STATUSES: + raise BlockedError(f"unsupported strategy position status: {status}") + if acquired == 0 and status != "PENDING_ENTRY": + raise BlockedError("zero acquired quantity must be PENDING_ENTRY") + if acquired > 0 and exited == acquired and status != "CLOSED": + raise BlockedError("fully exited position must be CLOSED") + if acquired > exited and status in {"PENDING_ENTRY", "CLOSED"}: + raise BlockedError("open quantity requires an open position status") + return { + **value, + "market": market, + "exchange": exchange, + "symbol": symbol, + "currency": currency, + "entry_session_date": entry_date, + "last_managed_session_date": managed_date, + "planned_quantity": format(planned, "f"), + "acquired_quantity": format(acquired, "f"), + "exited_quantity": format(exited, "f"), + "average_entry_price": average_entry, + "average_exit_price": average_exit, + "stop_price": format(stop, "f"), + "take_profit_price": format(take_profit, "f"), + "tick_size": format(tick, "f"), + "fx_to_krw": format(fx, "f"), + "daily_baseline_session_date": baseline_date, + "daily_baseline_price": baseline_price, + "realized_pnl_native": format(realized, "f"), + "daily_realized_pnl_native": format(daily_realized, "f"), + "status": status, + } + + +def open_quantity(position: dict[str, Any]) -> Decimal: + normalized = normalize_strategy_position(position) + return Decimal(normalized["acquired_quantity"]) - Decimal( + normalized["exited_quantity"] + ) + + +def record_entry_fill( + position: dict[str, Any], + *, + cumulative_filled_quantity: Any, + average_fill_price: Any, + session_date: str, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + session = require_iso_date(session_date, "session_date") + cumulative = decimal_value( + cumulative_filled_quantity, "cumulative_filled_quantity" + ) + prior = Decimal(normalized["acquired_quantity"]) + planned = Decimal(normalized["planned_quantity"]) + if ( + cumulative < prior + or cumulative > planned + or cumulative != cumulative.to_integral_value() + ): + raise BlockedError("entry cumulative fill is non-monotonic or out of bounds") + if cumulative == 0: + return normalized + average = positive_decimal(average_fill_price, "average_fill_price") + if session < normalized["entry_session_date"]: + raise BlockedError("entry fill session precedes the planned entry session") + exited = Decimal(normalized["exited_quantity"]) + next_status = "CLOSED" if cumulative == exited else "OPEN" + output = { + **normalized, + "last_managed_session_date": session, + "acquired_quantity": format(cumulative, "f"), + "average_entry_price": format(average, "f"), + "daily_baseline_session_date": session, + "daily_baseline_price": format(average, "f"), + "status": next_status, + } + return normalize_strategy_position(output) + + +def roll_position_session( + position: dict[str, Any], + *, + session_date: str, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + session = require_iso_date(session_date, "session_date") + if session < normalized["last_managed_session_date"]: + raise BlockedError("cannot roll a position backward") + if session == normalized["last_managed_session_date"]: + return normalized + status = ( + "CLOSED" + if open_quantity(normalized) == 0 + else "CARRY_EXIT_ONLY" + ) + return normalize_strategy_position( + { + **normalized, + "last_managed_session_date": session, + "daily_baseline_session_date": None, + "daily_baseline_price": None, + "daily_realized_pnl_native": "0", + "status": status, + } + ) + + +def set_daily_baseline( + position: dict[str, Any], + *, + session_date: str, + price: Any, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + session = require_iso_date(session_date, "session_date") + if session != normalized["last_managed_session_date"]: + raise BlockedError("daily baseline session must be the managed session") + baseline = positive_decimal(price, "daily baseline price") + existing_date = normalized["daily_baseline_session_date"] + if existing_date is not None: + if ( + existing_date != session + or Decimal(str(normalized["daily_baseline_price"])) != baseline + ): + raise BlockedError("daily baseline is immutable within one session") + return normalized + return normalize_strategy_position( + { + **normalized, + "daily_baseline_session_date": session, + "daily_baseline_price": format(baseline, "f"), + } + ) + + +def record_exit_fill( + position: dict[str, Any], + *, + cumulative_exited_quantity: Any, + average_exit_price: Any, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + cumulative = decimal_value( + cumulative_exited_quantity, "cumulative_exited_quantity" + ) + prior = Decimal(normalized["exited_quantity"]) + acquired = Decimal(normalized["acquired_quantity"]) + if ( + cumulative < prior + or cumulative > acquired + or cumulative != cumulative.to_integral_value() + ): + raise BlockedError("exit cumulative fill is non-monotonic or out of bounds") + if cumulative == 0: + return normalized + average_exit = positive_decimal(average_exit_price, "average_exit_price") + entry = positive_decimal( + normalized["average_entry_price"], "average_entry_price" + ) + prior_average_exit = ( + Decimal(str(normalized["average_exit_price"])) + if prior > 0 + else Decimal(0) + ) + prior_exit_proceeds = prior_average_exit * prior + cumulative_exit_proceeds = average_exit * cumulative + incremental_quantity = cumulative - prior + incremental_exit_proceeds = cumulative_exit_proceeds - prior_exit_proceeds + realized = ( + Decimal(normalized["realized_pnl_native"]) + + incremental_exit_proceeds + - entry * incremental_quantity + ) + baseline_value = normalized["daily_baseline_price"] + if baseline_value is None: + raise BlockedError("daily baseline is required before recording an exit") + baseline = positive_decimal(baseline_value, "daily_baseline_price") + daily_realized = ( + Decimal(normalized["daily_realized_pnl_native"]) + + incremental_exit_proceeds + - baseline * incremental_quantity + ) + status = "CLOSED" if cumulative == acquired else "EXIT_PARTIAL" + return normalize_strategy_position( + { + **normalized, + "exited_quantity": format(cumulative, "f"), + "average_exit_price": format(average_exit, "f"), + "realized_pnl_native": format(realized, "f"), + "daily_realized_pnl_native": format(daily_realized, "f"), + "status": status, + } + ) + + +def partial_entry_requires_cancel( + position: dict[str, Any], + *, + broker_remaining_quantity: Any, +) -> bool: + normalized = normalize_strategy_position(position) + remaining = decimal_value( + broker_remaining_quantity, "broker_remaining_quantity" + ) + return Decimal(normalized["acquired_quantity"]) > 0 and remaining > 0 + + +def normalize_exit_quote( + quote: dict[str, Any], + *, + position: dict[str, Any], + observed_at: datetime, + max_age_seconds: int, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + if str(quote.get("market", "")).upper() != normalized["market"]: + raise BlockedError("exit quote market does not match position") + if normalize_symbol( + normalized["market"], str(quote.get("symbol", "")) + ) != normalized["symbol"]: + raise BlockedError("exit quote symbol does not match position") + last = positive_decimal(quote.get("last_price"), "exit quote last_price") + bid = positive_decimal(quote.get("best_bid"), "exit quote best_bid") + ask = positive_decimal(quote.get("best_ask"), "exit quote best_ask") + if bid > ask: + raise BlockedError("exit quote best bid exceeds best ask") + trade_time = parse_aware_datetime( + quote.get("trade_timestamp"), "exit quote trade_timestamp" + ) + book_time = parse_aware_datetime( + quote.get("book_timestamp"), "exit quote book_timestamp" + ) + received = parse_aware_datetime( + quote.get("received_at"), "exit quote received_at" + ) + now = observed_at.astimezone(timezone.utc) + for supplied, field in ( + (trade_time, "trade_timestamp"), + (book_time, "book_timestamp"), + (received, "received_at"), + ): + age = (now - supplied.astimezone(timezone.utc)).total_seconds() + if age < -1: + raise BlockedError(f"exit quote {field} is future-dated") + if age > max_age_seconds: + raise BlockedError(f"exit quote {field} is stale") + tick = Decimal(normalized["tick_size"]) + return { + "last_price": format(last, "f"), + "best_bid": format(bid, "f"), + "best_ask": format(ask, "f"), + "limit_price": format(round_down_to_tick(bid, tick), "f"), + "trade_timestamp": trade_time.isoformat(), + "book_timestamp": book_time.isoformat(), + "received_at": received.isoformat(), + } + + +def exit_decision( + position: dict[str, Any], + quote: dict[str, Any], + policy: dict[str, Any], + *, + observed_at: datetime, + regular_close: datetime, + first_valid_quote_of_session: bool, + daily_loss_breached: bool, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + normalized_policy = normalized_position_policy(policy) + quantity = open_quantity(normalized) + if quantity <= 0: + action = "CLOSED" + reason = "NO_OPEN_QUANTITY" + normalized_quote = None + limit_price = None + else: + if observed_at.tzinfo is None or observed_at.utcoffset() is None: + raise BlockedError("observed_at must be timezone-aware") + if regular_close.tzinfo is None or regular_close.utcoffset() is None: + raise BlockedError("regular_close must be timezone-aware") + if observed_at >= regular_close: + action = "MANUAL_BLOCK" + reason = "MARKET_CLOSED_WITH_RESIDUAL" + normalized_quote = None + limit_price = None + else: + normalized_quote = normalize_exit_quote( + quote, + position=normalized, + observed_at=observed_at, + max_age_seconds=normalized_policy["quote_max_age_seconds"], + ) + last = Decimal(normalized_quote["last_price"]) + stop = Decimal(normalized["stop_price"]) + target = Decimal(normalized["take_profit_price"]) + close_trigger = regular_close - timedelta( + seconds=normalized_policy[ + "close_liquidation_seconds_before_close" + ] + ) + action = "HOLD" + reason = "NO_EXIT_TRIGGER" + if daily_loss_breached: + action = "SUBMIT_EXIT" + reason = "DAILY_LOSS_LIMIT" + elif last <= stop: + action = "SUBMIT_EXIT" + reason = ( + "STOP_GAP" + if first_valid_quote_of_session and last < stop + else "STOP" + ) + elif last >= target: + action = "SUBMIT_EXIT" + reason = "TAKE_PROFIT" + elif observed_at >= close_trigger: + action = "SUBMIT_EXIT" + reason = "MARKET_CLOSE" + limit_price = ( + normalized_quote["limit_price"] + if action == "SUBMIT_EXIT" + else None + ) + without_hash = { + "schema": EXIT_DECISION_SCHEMA, + "position_id": normalized["position_id"], + "session_date": normalized["last_managed_session_date"], + "observed_at": observed_at.isoformat(), + "action": action, + "reason": reason, + "quantity": format(max(quantity, Decimal(0)), "f"), + "order_type": "LIMIT" if action == "SUBMIT_EXIT" else None, + "limit_price": limit_price, + "quote": normalized_quote, + "daily_loss_breached": bool(daily_loss_breached), + } + return {**without_hash, "decision_hash": sha256_json(without_hash)} + + +def marked_daily_pnl_krw( + position: dict[str, Any], + *, + best_bid: Any, + estimated_exit_cost_bps: Any, +) -> Decimal: + normalized = normalize_strategy_position(position) + if normalized["daily_baseline_price"] is None: + raise BlockedError("daily baseline is required for daily P&L") + bid = positive_decimal(best_bid, "best_bid") + cost_bps = decimal_value(estimated_exit_cost_bps, "estimated_exit_cost_bps") + if cost_bps < 0 or cost_bps > 10000: + raise BlockedError("estimated_exit_cost_bps must be between 0 and 10000") + quantity = open_quantity(normalized) + baseline = Decimal(str(normalized["daily_baseline_price"])) + daily_realized = Decimal(normalized["daily_realized_pnl_native"]) + exit_cost = bid * quantity * cost_bps / Decimal(10000) + pnl_native = daily_realized + (bid - baseline) * quantity - exit_cost + return pnl_native * Decimal(normalized["fx_to_krw"]) + + +def portfolio_daily_loss_krw( + marked_pnl_by_position: dict[str, Decimal], +) -> Decimal: + pnl = sum(marked_pnl_by_position.values(), Decimal(0)) + return max(-pnl, Decimal(0)) + + +def daily_loss_limit_breached(loss_krw: Any, maximum_loss_krw: Any) -> bool: + loss = decimal_value(loss_krw, "loss_krw") + maximum = positive_decimal(maximum_loss_krw, "maximum_loss_krw") + if loss < 0: + raise BlockedError("loss_krw must be nonnegative") + return loss >= maximum + + +def exit_order_id( + decision: dict[str, Any], + *, + attempt: int, +) -> str: + attempt_number = require_nonnegative_integer(attempt, "exit attempt") + if decision.get("schema") != EXIT_DECISION_SCHEMA: + raise BlockedError("exit decision schema is invalid") + if decision.get("action") != "SUBMIT_EXIT": + raise BlockedError("exit order requires a SUBMIT_EXIT decision") + seed = { + "position_id": decision["position_id"], + "session_date": decision["session_date"], + "reason": decision["reason"], + "quantity": decision["quantity"], + "limit_price": decision["limit_price"], + "attempt": attempt_number, + } + return sha256_json(seed)[:32] + + +@dataclass(frozen=True) +class StoredPosition: + position_id: str + status: str + payload: dict[str, Any] + + +class PositionLedger: + """Account/market-level position registry that survives session dates.""" + + def __init__(self, path: str | Path): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.connection = sqlite3.connect(str(self.path)) + self.connection.execute("PRAGMA journal_mode=WAL") + self.connection.execute("PRAGMA synchronous=FULL") + self.connection.executescript( + """ + CREATE TABLE IF NOT EXISTS strategy_positions ( + position_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS position_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + position_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + """ + ) + self.connection.commit() + + def close(self) -> None: + self.connection.close() + + def put( + self, + position: dict[str, Any], + *, + event_type: str, + event_payload: dict[str, Any], + ) -> None: + normalized = normalize_strategy_position(position) + if not event_type: + raise BlockedError("position event type is required") + if not isinstance(event_payload, dict): + raise BlockedError("position event payload must be an object") + now = datetime.now(timezone.utc).isoformat() + rendered = canonical_json(normalized) + self.connection.execute("BEGIN IMMEDIATE") + try: + existing = self.connection.execute( + """ + SELECT payload_json FROM strategy_positions + WHERE position_id = ? + """, + (normalized["position_id"],), + ).fetchone() + if existing is None: + self.connection.execute( + """ + INSERT INTO strategy_positions + (position_id, status, payload_json, updated_at) + VALUES (?, ?, ?, ?) + """, + ( + normalized["position_id"], + normalized["status"], + rendered, + now, + ), + ) + else: + self.connection.execute( + """ + UPDATE strategy_positions + SET status = ?, payload_json = ?, updated_at = ? + WHERE position_id = ? + """, + ( + normalized["status"], + rendered, + now, + normalized["position_id"], + ), + ) + self.connection.execute( + """ + INSERT INTO position_events + (position_id, event_type, payload_json, created_at) + VALUES (?, ?, ?, ?) + """, + ( + normalized["position_id"], + event_type, + canonical_json(event_payload), + now, + ), + ) + except BaseException: + self.connection.rollback() + raise + else: + self.connection.commit() + + def get(self, position_id: str) -> StoredPosition: + row = self.connection.execute( + """ + SELECT position_id, status, payload_json + FROM strategy_positions WHERE position_id = ? + """, + (position_id,), + ).fetchone() + if row is None: + raise BlockedError(f"unknown strategy position: {position_id}") + return StoredPosition( + position_id=str(row[0]), + status=str(row[1]), + payload=normalize_strategy_position(json.loads(row[2])), + ) + + def get_optional(self, position_id: str) -> StoredPosition | None: + row = self.connection.execute( + """ + SELECT position_id, status, payload_json + FROM strategy_positions WHERE position_id = ? + """, + (position_id,), + ).fetchone() + if row is None: + return None + return StoredPosition( + position_id=str(row[0]), + status=str(row[1]), + payload=normalize_strategy_position(json.loads(row[2])), + ) + + def open_positions(self) -> list[StoredPosition]: + rows = self.connection.execute( + """ + SELECT position_id, status, payload_json + FROM strategy_positions + WHERE status != 'CLOSED' + ORDER BY position_id + """ + ).fetchall() + return [ + StoredPosition( + position_id=str(row[0]), + status=str(row[1]), + payload=normalize_strategy_position(json.loads(row[2])), + ) + for row in rows + ] + + def events(self, position_id: str) -> list[dict[str, Any]]: + rows = self.connection.execute( + """ + SELECT sequence, event_type, payload_json, created_at + FROM position_events + WHERE position_id = ? + ORDER BY sequence + """, + (position_id,), + ).fetchall() + return [ + { + "sequence": int(row[0]), + "event_type": str(row[1]), + "payload": json.loads(row[2]), + "created_at": str(row[3]), + } + for row in rows + ] + + +def reconcile_account_positions( + ledger: PositionLedger, + *, + account_snapshot: dict[str, Any], + session_date: str, +) -> dict[str, Any]: + """Match bot-owned positions to the broker snapshot and roll valid carries.""" + session = require_iso_date(session_date, "session_date") + account = normalized_account_snapshot( + account_snapshot, + screen_schema=SCREEN_SCHEMA_V2, + ) + market = account["market"] + account_quantities: dict[tuple[str, str], Decimal] = {} + for item in account["positions"]: + key = (str(item["exchange"]), str(item["symbol"])) + quantity = decimal_value(item.get("quantity"), "account position quantity") + if quantity < 0: + raise BlockedError("account position quantity must be nonnegative") + account_quantities[key] = account_quantities.get(key, Decimal(0)) + quantity + open_order_keys = { + (str(item["exchange"]), str(item["symbol"])) + for item in account["open_orders"] + } + + stored_positions = [ + item for item in ledger.open_positions() if item.payload["market"] == market + ] + by_key: dict[tuple[str, str], list[StoredPosition]] = {} + for stored in stored_positions: + key = (stored.payload["exchange"], stored.payload["symbol"]) + by_key.setdefault(key, []).append(stored) + + results: list[dict[str, Any]] = [] + managed_keys: set[tuple[str, str]] = set() + for key in sorted(by_key): + positions = by_key[key] + managed_keys.add(key) + expected = sum( + (open_quantity(item.payload) for item in positions), Decimal(0) + ) + observed = account_quantities.get(key, Decimal(0)) + reasons: list[str] = [] + if len(positions) != 1: + reasons.append("duplicate_strategy_positions_for_symbol") + if observed != expected: + reasons.append("broker_quantity_differs_from_strategy_ledger") + if key in open_order_keys: + reasons.append("broker_has_open_order_for_strategy_symbol") + if any(item.status == "MANUAL_BLOCK" for item in positions): + reasons.append("prior_manual_block_requires_explicit_repair") + + if reasons: + for stored in positions: + if stored.status != "MANUAL_BLOCK": + blocked_position = normalize_strategy_position( + {**stored.payload, "status": "MANUAL_BLOCK"} + ) + ledger.put( + blocked_position, + event_type="ACCOUNT_RECONCILIATION_BLOCKED", + event_payload={ + "session_date": session, + "expected_quantity": format(expected, "f"), + "observed_quantity": format(observed, "f"), + "reasons": reasons, + }, + ) + state = "MANUAL_BLOCK" + else: + stored = positions[0] + rolled = roll_position_session( + stored.payload, + session_date=session, + ) + if rolled != stored.payload: + ledger.put( + rolled, + event_type="POSITION_CARRIED", + event_payload={ + "from_session_date": stored.payload[ + "last_managed_session_date" + ], + "to_session_date": session, + "quantity": format(expected, "f"), + }, + ) + state = rolled["status"] + results.append( + { + "exchange": key[0], + "symbol": key[1], + "strategy_position_ids": [ + item.position_id for item in positions + ], + "expected_quantity": format(expected, "f"), + "observed_quantity": format(observed, "f"), + "state": state, + "reasons": reasons, + } + ) + + unmanaged = [ + { + "exchange": key[0], + "symbol": key[1], + "quantity": format(quantity, "f"), + } + for key, quantity in sorted(account_quantities.items()) + if key not in managed_keys and quantity > 0 + ] + without_hash = { + "schema": POSITION_RECONCILIATION_SCHEMA, + "account_as_of": account["as_of"], + "account_alias": account["account_alias"], + "market": market, + "session_date": session, + "status": ( + "MANUAL_BLOCK" + if any(item["state"] == "MANUAL_BLOCK" for item in results) + else "READY" + ), + "managed_positions": results, + "unmanaged_broker_positions": unmanaged, + "api_mutation_count": 0, + "live_enabled": False, + } + return { + **without_hash, + "reconciliation_hash": sha256_json(without_hash), + } + + +def atomic_write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(json.dumps(value, sort_keys=True, separators=(",", ":"))) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def self_test() -> None: + plan_hash = "a" * 64 + intent = { + "intent_id": "b" * 32, + "market": "KR", + "exchange": "KOSPI", + "symbol": "005930", + "venue": "KRX", + "currency": "KRW", + "quantity": "10", + "stop_price": "65000", + "take_profit_price": "80000", + "resolved_tick_size": "100", + } + policy = normalized_position_policy( + { + "schema": POSITION_POLICY_SCHEMA, + "close_liquidation_seconds_before_close": 600, + "exit_order_ttl_seconds": 15, + "max_exit_replacements": 3, + "quote_max_age_seconds": 5, + "partial_entry_action": "CANCEL_REMAINDER_MANAGE_FILLED", + "gap_down_action": "MARKETABLE_LIMIT", + "daily_loss_action": "ENTRY_FREEZE_LIQUIDATE", + "overnight_residual_action": "EXIT_ONLY_NEXT_SESSION", + } + ) + position = position_from_intent( + plan_hash=plan_hash, + intent=intent, + session_date="2026-07-29", + fx_to_krw="1", + ) + position = record_entry_fill( + position, + cumulative_filled_quantity="4", + average_fill_price="70000", + session_date="2026-07-29", + ) + assert partial_entry_requires_cancel( + position, broker_remaining_quantity="6" + ) + now = datetime(2026, 7, 29, 9, 1, tzinfo=timezone(timedelta(hours=9))) + quote = { + "market": "KR", + "symbol": "005930", + "last_price": "64000", + "best_bid": "63900", + "best_ask": "64000", + "trade_timestamp": now.isoformat(), + "book_timestamp": now.isoformat(), + "received_at": now.isoformat(), + } + decision = exit_decision( + position, + quote, + policy, + observed_at=now, + regular_close=datetime( + 2026, 7, 29, 15, 30, tzinfo=timezone(timedelta(hours=9)) + ), + first_valid_quote_of_session=True, + daily_loss_breached=False, + ) + assert decision["reason"] == "STOP_GAP" + assert decision["quantity"] == "4" + assert decision["limit_price"] == "63900" + assert len(exit_order_id(decision, attempt=0)) == 32 + assert marked_daily_pnl_krw( + position, + best_bid="64000", + estimated_exit_cost_bps="30", + ) < 0 + position = record_exit_fill( + position, + cumulative_exited_quantity="2", + average_exit_price="64000", + ) + assert position["status"] == "EXIT_PARTIAL" + assert open_quantity(position) == Decimal(2) + carried = roll_position_session(position, session_date="2026-07-30") + assert carried["status"] == "CARRY_EXIT_ONLY" + with tempfile.TemporaryDirectory(prefix="qta-position-ledger-") as temporary: + ledger = PositionLedger(Path(temporary) / "positions.sqlite3") + try: + ledger.put( + carried, + event_type="POSITION_CARRIED", + event_payload={"session_date": "2026-07-30"}, + ) + assert ledger.open_positions()[0].status == "CARRY_EXIT_ONLY" + assert ledger.events(carried["position_id"])[0]["event_type"] == ( + "POSITION_CARRIED" + ) + finally: + ledger.close() + print( + json.dumps( + { + "schema": STRATEGY_POSITION_SCHEMA, + "self_test": "PASS", + "decision_hash": decision["decision_hash"], + "live_enabled": False, + "api_mutation_count": 0, + }, + sort_keys=True, + ) + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--self-test", action="store_true") + subparsers = parser.add_subparsers(dest="command") + reconcile_parser = subparsers.add_parser("reconcile-account") + reconcile_parser.add_argument("--ledger", required=True) + reconcile_parser.add_argument("--account-snapshot", required=True) + reconcile_parser.add_argument("--session-date", required=True) + reconcile_parser.add_argument("--output", required=True) + args = parser.parse_args(argv) + try: + if args.self_test: + self_test() + return 0 + if args.command != "reconcile-account": + parser.error("use --self-test or reconcile-account") + paths = { + name: Path(getattr(args, name)) + for name in ("ledger", "account_snapshot", "output") + } + if any(not path.is_absolute() for path in paths.values()): + raise BlockedError("ledger, account snapshot, and output paths must be absolute") + if ( + not paths["account_snapshot"].is_file() + or paths["account_snapshot"].is_symlink() + ): + raise BlockedError( + "account snapshot must be a regular non-symlink file" + ) + account_value = json.loads( + paths["account_snapshot"].read_text(encoding="utf-8") + ) + if not isinstance(account_value, dict): + raise BlockedError("account snapshot must contain one JSON object") + ledger = PositionLedger(paths["ledger"]) + try: + receipt = reconcile_account_positions( + ledger, + account_snapshot=account_value, + session_date=args.session_date, + ) + finally: + ledger.close() + atomic_write_json(paths["output"], receipt) + print(json.dumps(receipt, sort_keys=True)) + return 0 + except ( + AssertionError, + BlockedError, + ValueError, + OSError, + json.JSONDecodeError, + ) as exc: + print( + json.dumps( + { + "status": "BLOCKED", + "reason": str(exc), + "api_mutation_count": 0, + "live_enabled": False, + }, + sort_keys=True, + ) + ) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/run_session.py b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/run_session.py index f8ff63b..1a500b3 100755 --- a/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/run_session.py +++ b/plugins/quant-stock-polling-trader/skills/quant-stock-polling-trader/scripts/run_session.py @@ -28,6 +28,10 @@ MARKET_CURRENCY, MAX_SNAPSHOT_AGE_SECONDS, PLAN_SCHEMA, + QTA2_REGIME_MAX_AGE_SECONDS, + QTA2_REGIME_MINIMUM_CHANGE_BPS, + QTA2_REGIME_METRIC, + QTA_METHOD_V2, SCREEN_SCHEMA_V2, V2_EXCHANGE_CONTRACTS, V2_EXCHANGES_BY_MARKET, @@ -52,6 +56,13 @@ validate_nonempty_string, validate_sha256, ) +from position_lifecycle import ( + PositionLedger, + position_from_intent, + reconcile_account_positions, + record_entry_fill, + strategy_position_id, +) VENUE_SCHEMA = "qta-venue-map/v1" SESSION_STATUS_SCHEMA = "qta-session-status/v2" @@ -157,6 +168,7 @@ "instrument_type", "benchmark_id", "venue", + "resolved_tick_size", "tick_contract_hash", } SKIP_REASONS = { @@ -166,6 +178,26 @@ "max_concurrent_positions", "quantity_below_one", } +MARKET_REGIME_QUOTE_FIELDS = { + "schema", + "broker", + "exchange", + "benchmark_id", + "regime_proxy_id", + "provider_symbol", + "current", + "previous_close", + "change_bps", + "source_timestamp", + "received_at", + "raw_status", +} +REGIME_PROXY_BY_EXCHANGE = { + "KOSPI": "KOSPI_COMPOSITE", + "KOSDAQ": "KOSDAQ_COMPOSITE", + "NYSE": "S&P_500", + "NASDAQ": "NASDAQ_COMPOSITE", +} def validate_positive_integer(value: Any, field: str) -> int: @@ -332,6 +364,21 @@ def validate_intent( intent["tick_contract_hash"], f"{label}.tick_contract_hash", ) + tick_size = validate_decimal_text( + intent["resolved_tick_size"], + f"{label}.resolved_tick_size", + positive=True, + ) + for price, field in ( + (entry, "entry_trigger"), + (limit_price, "limit_price"), + (stop, "stop_price"), + (take_profit, "take_profit_price"), + ): + if price % tick_size != 0: + raise BlockedError( + f"{label}.{field} must align to resolved_tick_size" + ) intent_seed.update( { "exchange": exchange, @@ -880,7 +927,7 @@ def normalize_toss_order(raw: dict[str, Any]) -> dict[str, Any]: "normalized_status": status_map.get(raw_status, "UNKNOWN"), "raw_status": raw_status, "filled_quantity": execution.get("filledQuantity"), - "average_filled_price": execution.get("averageFilledPrice"), + "average_fill_price": execution.get("averageFilledPrice"), "raw": redact(raw), } @@ -984,6 +1031,11 @@ def create_broker(broker_name: str) -> Any: def validate_mode(plan: dict[str, Any], broker_name: str, mode: str) -> None: validate_broker_binding(plan, broker_name) context = plan["context"] + screen_method = plan["frozen_inputs"]["screen"].get("method_version") + if screen_method == "qta-2.0.0" and mode != "shadow": + raise BlockedError( + "qta-2.0.0 is RESEARCH_ONLY and may run only in shadow mode" + ) if mode == "live": raise BlockedError( "live promotion is intentionally disabled until accepted-timeout " @@ -1051,6 +1103,33 @@ def quote_http_request_count( return DEFAULT_QUOTE_HTTP_REQUESTS_PER_INTENT +def plan_uses_qta2(plan: dict[str, Any]) -> bool: + frozen_inputs = plan.get("frozen_inputs") + if not isinstance(frozen_inputs, dict): + return False + screen = frozen_inputs.get("screen") + return ( + isinstance(screen, dict) + and screen.get("method_version") == QTA_METHOD_V2 + ) + + +def active_qta2_exchanges(plan: dict[str, Any]) -> list[str]: + if not plan_uses_qta2(plan): + return [] + present = { + str(intent.get("exchange") or "").upper() + for intent in plan.get("intents", []) + if isinstance(intent, dict) + } + market = str(plan.get("context", {}).get("market") or "").upper() + return [ + exchange + for exchange in V2_EXCHANGES_BY_MARKET.get(market, ()) + if exchange in present + ] + + def polling_admission( plan: dict[str, Any], broker_name: str, @@ -1063,8 +1142,9 @@ def polling_admission( quote_requests = sum( quote_http_request_count(broker_name, intent) for intent in plan["intents"] ) + regime_requests = len(active_qta2_exchanges(plan)) mutation_requests = intent_count if mode == "paper" else 0 - request_budget = quote_requests + mutation_requests + request_budget = quote_requests + regime_requests + mutation_requests pacing_seconds = BROKER_PACING_SECONDS[broker_name] * request_budget minimum_interval = max( 1, @@ -1083,6 +1163,7 @@ def polling_admission( "quote_request_contract": ( "KIS-live US=1; KIS KR and Toss=2 per waiting intent" ), + "market_regime_request_budget": regime_requests, "worst_case_mutation_requests": mutation_requests, "worst_case_request_budget": request_budget, "minimum_request_interval_ms": int(BROKER_PACING_SECONDS[broker_name] * 1000), @@ -1095,6 +1176,104 @@ def polling_admission( } +def evaluate_market_regime( + *, + exchange: str, + quote: dict[str, Any], + observed_at: datetime, + session_date: str, +) -> dict[str, Any]: + """Validate and deterministically admit one QTA2 exchange regime quote.""" + exchange = str(exchange).upper() + if exchange not in V2_EXCHANGE_CONTRACTS: + raise BlockedError(f"unsupported market regime exchange: {exchange}") + if observed_at.tzinfo is None or observed_at.utcoffset() is None: + raise BlockedError("market regime observed_at must be timezone-aware") + exact_fields(quote, MARKET_REGIME_QUOTE_FIELDS, "market regime quote") + reasons: list[str] = [] + expected_benchmark = V2_EXCHANGE_CONTRACTS[exchange]["benchmark_id"] + if quote["schema"] != "qta-market-regime-quote/v1": + reasons.append("invalid_schema") + if quote["broker"] != "kis": + reasons.append("invalid_broker") + if quote["exchange"] != exchange: + reasons.append("exchange_mismatch") + if quote["benchmark_id"] != expected_benchmark: + reasons.append("benchmark_mismatch") + if quote["regime_proxy_id"] != REGIME_PROXY_BY_EXCHANGE[exchange]: + reasons.append("proxy_mismatch") + if quote["raw_status"] != "OK": + reasons.append("raw_status_not_ok") + + current = positive_decimal(quote["current"], "market regime current") + previous_close = positive_decimal( + quote["previous_close"], + "market regime previous_close", + ) + change_bps = decimal_value( + quote["change_bps"], + "market regime change_bps", + ) + recomputed_change = ( + (current / previous_close - Decimal(1)) * Decimal(10000) + ) + if abs(change_bps - recomputed_change) > Decimal("0.02"): + reasons.append("change_bps_mismatch") + + zone_name = ( + "Asia/Seoul" + if exchange in {"KOSPI", "KOSDAQ"} + else "America/New_York" + ) + for timestamp_field in ("source_timestamp", "received_at"): + try: + timestamp = parse_aware_datetime( + quote[timestamp_field], + f"market regime {timestamp_field}", + ) + except BlockedError: + reasons.append(f"{timestamp_field}_invalid") + continue + age_seconds = ( + observed_at.astimezone(timezone.utc) + - timestamp.astimezone(timezone.utc) + ).total_seconds() + if age_seconds < -1 or age_seconds > QTA2_REGIME_MAX_AGE_SECONDS: + reasons.append(f"{timestamp_field}_stale") + if ( + timestamp_field == "source_timestamp" + and timestamp.astimezone(ZoneInfo(zone_name)).date().isoformat() + != session_date + ): + reasons.append("source_session_date_mismatch") + + quality_valid = not reasons + admitted = ( + quality_valid + and change_bps >= QTA2_REGIME_MINIMUM_CHANGE_BPS + ) + if quality_valid and not admitted: + reasons.append("benchmark_below_minimum") + without_hash = { + "metric": QTA2_REGIME_METRIC, + "exchange": exchange, + "benchmark_id": expected_benchmark, + "regime_proxy_id": quote["regime_proxy_id"], + "minimum_change_bps": format( + QTA2_REGIME_MINIMUM_CHANGE_BPS, + "f", + ), + "observed_change_bps": format(change_bps, "f"), + "max_age_seconds": QTA2_REGIME_MAX_AGE_SECONDS, + "quality_valid": quality_valid, + "admitted": admitted, + "reasons": reasons, + "quote": quote, + "observed_at": observed_at.isoformat(), + } + return {**without_hash, "decision_hash": sha256_json(without_hash)} + + def wait_until(target: datetime) -> None: while True: remaining = target.timestamp() - datetime.now(timezone.utc).timestamp() @@ -1270,6 +1449,102 @@ def validate_ledger_scope(ledger: Ledger, plan: dict[str, Any]) -> None: ) +def position_ledger_path(state_directory: Path) -> Path: + """Return the account/market ledger shared by all session-date directories.""" + if not state_directory.is_absolute(): + raise BlockedError("state_directory must be absolute") + return state_directory.parent / "positions.sqlite3" + + +def persist_entry_fill( + position_ledger: PositionLedger, + *, + plan: dict[str, Any], + intent: dict[str, Any], + snapshot: dict[str, Any], + session_date: str, +) -> dict[str, Any] | None: + """Persist an authoritative cumulative entry fill exactly once per change.""" + raw_filled = snapshot.get("filled_quantity") + if raw_filled in (None, ""): + return None + cumulative = decimal_value(raw_filled, "broker filled_quantity") + if cumulative < 0 or cumulative != cumulative.to_integral_value(): + raise BlockedError( + "broker filled_quantity must be a nonnegative whole number" + ) + if cumulative == 0: + return None + raw_average = snapshot.get("average_fill_price") + if raw_average in (None, ""): + raise BlockedError( + "broker reported an entry fill without average_fill_price" + ) + average = positive_decimal(raw_average, "broker average_fill_price") + account = plan["frozen_inputs"]["account"] + fx_to_krw = account.get("fx_to_krw") + if intent["market"] == "KR": + fx_to_krw = "1" + elif fx_to_krw in (None, ""): + raise BlockedError( + "a U.S. strategy position requires frozen fx_to_krw" + ) + + position_id = strategy_position_id(plan["plan_hash"], intent) + stored = position_ledger.get_optional(position_id) + if stored is None: + position = position_from_intent( + plan_hash=plan["plan_hash"], + intent=intent, + session_date=session_date, + fx_to_krw=fx_to_krw, + ) + event_type = "ENTRY_FILL_CREATED" + else: + position = stored.payload + if ( + position["plan_hash"] != plan["plan_hash"] + or position["entry_intent_id"] != intent["intent_id"] + ): + raise BlockedError( + "strategy position identity differs from plan entry intent" + ) + prior_quantity = Decimal(position["acquired_quantity"]) + prior_average = ( + None + if position["average_entry_price"] is None + else Decimal(position["average_entry_price"]) + ) + if cumulative == prior_quantity and average == prior_average: + return position + if Decimal(position["exited_quantity"]) > 0: + raise BlockedError( + "entry fill changed after exit fills were recorded" + ) + event_type = "ENTRY_FILL_UPDATED" + + updated = record_entry_fill( + position, + cumulative_filled_quantity=format(cumulative, "f"), + average_fill_price=format(average, "f"), + session_date=session_date, + ) + position_ledger.put( + updated, + event_type=event_type, + event_payload={ + "broker_order_id": snapshot.get("broker_order_id"), + "normalized_status": snapshot.get("normalized_status"), + "ordered_quantity": snapshot.get("ordered_quantity"), + "filled_quantity": format(cumulative, "f"), + "average_fill_price": format(average, "f"), + "remaining_quantity": snapshot.get("remaining_quantity"), + "session_date": session_date, + }, + ) + return updated + + def run_session( plan: dict[str, Any], broker_name: str, @@ -1324,20 +1599,48 @@ def run_session( "cycles_completed": 0, "cycles_skipped": 0, "quotes_evaluated": 0, + "market_regime_queries": 0, + "market_regime_passes": 0, + "market_regime_blocks": 0, "order_status_queries": 0, "submits_started": 0, "expected_http_requests_started": 0, "max_schedule_lateness_ms": 0, "max_cycle_duration_ms": 0, "max_quote_latency_ms": 0, + "max_market_regime_latency_ms": 0, "max_order_status_latency_ms": 0, "max_submit_latency_ms": 0, + "position_reconciliation": None, } with SingleWriterLock(state_directory / "writer.lock"): ledger = Ledger(state_directory / "ledger.sqlite3") + position_ledger = PositionLedger(position_ledger_path(state_directory)) try: validate_ledger_scope(ledger, plan) + position_reconciliation = reconcile_account_positions( + position_ledger, + account_snapshot=plan["frozen_inputs"]["account"], + session_date=trading_date, + ) + polling_metrics["position_reconciliation"] = { + "status": position_reconciliation["status"], + "reconciliation_hash": position_reconciliation[ + "reconciliation_hash" + ], + "managed_position_count": len( + position_reconciliation["managed_positions"] + ), + "unmanaged_broker_position_count": len( + position_reconciliation["unmanaged_broker_positions"] + ), + } + if position_reconciliation["status"] != "READY": + raise BlockedError( + "strategy position ledger differs from the frozen broker " + "account snapshot; reconcile MANUAL_BLOCK before new entries" + ) warmup = warm_broker_before_open(broker, start) for intent in plan["intents"]: ledger.create_intent(plan["plan_hash"], intent) @@ -1417,6 +1720,86 @@ def run_session( }, ) ] + regime_by_exchange: dict[str, dict[str, Any]] = {} + if plan_uses_qta2(plan): + waiting_by_exchange: dict[str, list[dict[str, Any]]] = {} + for candidate in plan["intents"]: + if ledger.get(candidate["intent_id"]).state == "WAIT_TRIGGER": + waiting_by_exchange.setdefault( + candidate["exchange"], + [], + ).append(candidate) + for exchange in active_qta2_exchanges(plan): + waiting = waiting_by_exchange.get(exchange, []) + if not waiting: + continue + if datetime.now(timezone.utc).timestamp() >= cycle_deadline: + freeze_reason = "cycle_latency_budget_exceeded" + break + regime_started_at = datetime.now(timezone.utc) + regime_started_monotonic = time.monotonic() + polling_metrics["market_regime_queries"] += 1 + polling_metrics["expected_http_requests_started"] += 1 + try: + regime_quote = broker.benchmark_quote( + exchange=exchange, + session_date=trading_date, + ) + regime_observed_at = datetime.now(timezone.utc) + regime_decision = evaluate_market_regime( + exchange=exchange, + quote=regime_quote, + observed_at=regime_observed_at, + session_date=trading_date, + ) + except (BlockedError, TransportFailure) as exc: + regime_decision = { + "metric": QTA2_REGIME_METRIC, + "exchange": exchange, + "benchmark_id": V2_EXCHANGE_CONTRACTS[exchange][ + "benchmark_id" + ], + "minimum_change_bps": format( + QTA2_REGIME_MINIMUM_CHANGE_BPS, + "f", + ), + "max_age_seconds": QTA2_REGIME_MAX_AGE_SECONDS, + "quality_valid": False, + "admitted": False, + "reasons": ["benchmark_quote_unavailable"], + "error": str(exc), + "observed_at": datetime.now( + timezone.utc + ).isoformat(), + } + regime_latency_ms = int( + (time.monotonic() - regime_started_monotonic) * 1000 + ) + regime_decision["latency_ms"] = regime_latency_ms + regime_decision["started_at"] = ( + regime_started_at.isoformat() + ) + regime_by_exchange[exchange] = regime_decision + polling_metrics["max_market_regime_latency_ms"] = max( + polling_metrics["max_market_regime_latency_ms"], + regime_latency_ms, + ) + metric = ( + "market_regime_passes" + if regime_decision["admitted"] + else "market_regime_blocks" + ) + polling_metrics[metric] += 1 + cycle_events.append( + ( + waiting[0]["intent_id"], + "MARKET_REGIME_EVALUATED", + regime_decision, + ) + ) + if freeze_reason == "cycle_latency_budget_exceeded": + ledger.append_events(cycle_events) + break for intent in plan["intents"]: if datetime.now(timezone.utc).timestamp() >= cycle_deadline: freeze_reason = "cycle_latency_budget_exceeded" @@ -1424,6 +1807,25 @@ def run_session( record = ledger.get(intent["intent_id"]) venue = venue_by_intent[intent["intent_id"]] if record.state == "WAIT_TRIGGER": + if plan_uses_qta2(plan): + regime_decision = regime_by_exchange.get( + intent["exchange"] + ) + if ( + regime_decision is None + or not regime_decision["admitted"] + ): + cycle_events.append( + ( + intent["intent_id"], + "ENTRY_BLOCKED_BY_MARKET_REGIME", + { + "exchange": intent["exchange"], + "decision": regime_decision, + }, + ) + ) + continue if not entry_window_open(end): freeze_reason = "entry_window_closed" break @@ -1721,6 +2123,28 @@ def run_session( status_latency_ms, ) next_state = snapshot["normalized_status"] + try: + persist_entry_fill( + position_ledger, + plan=plan, + intent=intent, + snapshot=snapshot, + session_date=trading_date, + ) + except BlockedError as exc: + ledger.transition( + intent["intent_id"], + "UNKNOWN", + { + "reason": ( + "strategy position ledger sync failed: " + f"{exc}" + ), + "snapshot": snapshot, + }, + ) + freeze_reason = "position_ledger_sync_failed" + break if next_state != record.state: if next_state not in { "ACKNOWLEDGED", @@ -1769,6 +2193,21 @@ def run_session( break cycle += 1 + for intent in plan["intents"]: + record = ledger.get(intent["intent_id"]) + if record.state == "WAIT_TRIGGER": + ledger.transition( + intent["intent_id"], + "CANCELLED", + { + "reason": ( + "entry_window_closed" + if freeze_reason == "entry_window_closed" + else str(freeze_reason or "entry_window_ended") + ) + }, + ) + if plan["order_policy"]["cancel_remainder_at_window_end"]: for intent in plan["intents"]: record = ledger.get(intent["intent_id"]) @@ -1798,6 +2237,13 @@ def run_session( venue=venue_by_intent[intent["intent_id"]], trading_date=trading_date, ) + persist_entry_fill( + position_ledger, + plan=plan, + intent=intent, + snapshot=cancel_snapshot, + session_date=trading_date, + ) next_state = cancel_snapshot["normalized_status"] if next_state in {"FILLED", "CANCELLED"} or ( next_state == "REJECTED" @@ -1883,6 +2329,7 @@ def run_session( polling_metrics, ) finally: + position_ledger.close() ledger.close() diff --git a/plugins/quant-stock-technical/.claude-plugin/plugin.json b/plugins/quant-stock-technical/.claude-plugin/plugin.json index e7d9c1c..2d5f36b 100644 --- a/plugins/quant-stock-technical/.claude-plugin/plugin.json +++ b/plugins/quant-stock-technical/.claude-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json", "name": "quant-stock-technical", "displayName": "Quant Stock Technical", - "version": "0.4.4", + "version": "0.4.5", "description": "Acquire audited KIS Korean and U.S. EOD data and screen a deterministic KOSPI, KOSDAQ, NYSE, and NASDAQ technical universe.", "author": { "name": "mrcha033", diff --git a/plugins/quant-stock-technical/.codex-plugin/plugin.json b/plugins/quant-stock-technical/.codex-plugin/plugin.json index 1996b5e..f6f19bb 100644 --- a/plugins/quant-stock-technical/.codex-plugin/plugin.json +++ b/plugins/quant-stock-technical/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "quant-stock-technical", - "version": "0.4.4", + "version": "0.4.5", "description": "Acquire audited KIS Korean and U.S. EOD data and screen a deterministic four-exchange stock universe.", "author": { "name": "mrcha033", diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/SKILL.md b/plugins/quant-stock-technical/skills/quant-stock-technical/SKILL.md index d241206..8f312cb 100644 --- a/plugins/quant-stock-technical/skills/quant-stock-technical/SKILL.md +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/SKILL.md @@ -62,7 +62,8 @@ invalid cutoff row remains blocking. ## Workflow 1. Read `references/methodology.md` when explaining, reviewing, or changing the model. -2. Run `scripts/analyze_stock.py` with the user-supplied files and identifiers. +2. Run `scripts/analyze_stock.py` for the stable QTA1 baseline. Use + `scripts/analyze_stock_v2.py` only for first-hour research/shadow work. 3. Return the calculator output without changing values or labels. 4. State the data source and cutoff. Keep `score_basis` visible whenever a user could mistake the score for a probability. 5. If the calculator returns `BLOCKED`, report the missing or invalid input. Do not manufacture a partial recommendation. @@ -87,8 +88,11 @@ For a Korean, U.S., or all-four-exchange target universe, first read `scripts/build_universe_manifest.py`, then read `references/screen-contract.md` and run `scripts/screen_universe.py`. Use `qta-universe-manifest/v1` only for legacy, explicitly curated KR/US inputs. The selector is a separate -cross-sectional strategy assumption; it does not change `qta-1.0.0` or turn -its score into a probability. Pass the complete frozen screen artifact to +cross-sectional strategy assumption; it does not turn either score into a +probability. `screen_universe.py --method-version qta-2.0.0` selects the +research-only first-hour model; its output additionally requires a fresh +same-session benchmark-regime admission in the execution runner. Pass the +complete frozen screen artifact to `$quant-stock-polling-trader`; never submit orders from this skill. After changing universe or screening code, run: @@ -97,6 +101,9 @@ After changing universe or screening code, run: python3 scripts/fetch_kis_kr_eod.py --self-test python3 scripts/fetch_kis_us_eod.py --self-test python3 scripts/build_universe_manifest.py --self-test +python3 scripts/analyze_stock_v2.py --self-test +python3 scripts/collect_open1h_research.py --self-test +python3 scripts/evaluate_open1h_research.py --self-test python3 scripts/screen_universe.py --self-test ``` @@ -104,7 +111,9 @@ When `$stock-scenario-story` will run next, save the unmodified JSON output to a ## Output boundaries -- Interpret the 0-100 score as historical technical strength under method `qta-1.0.0`, not as probability of profit. +- Interpret the 0-100 score as historical technical strength under the emitted + method version, not as probability of profit. `qta-2.0.0` remains + `RESEARCH_ONLY` until multi-session walk-forward evidence exists. - Treat entry, stop, and take-profit as calculated price levels, not execution or suitability advice. - Keep brokerage fees, tax, FX, slippage, position sizing, account constraints, and order semantics outside this technical score unless a later version explicitly models them. - Do not claim real-time monitoring. This version consumes completed end-of-day data. diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/references/methodology.md b/plugins/quant-stock-technical/skills/quant-stock-technical/references/methodology.md index d25a5f8..edebb15 100644 --- a/plugins/quant-stock-technical/skills/quant-stock-technical/references/methodology.md +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/references/methodology.md @@ -119,3 +119,44 @@ calculation_status = READY ``` `stock-scenario-story` accepts only the complete unmodified JSON result. A Markdown rendering, hand-entered score summary, incomplete payload, or failed calculation is not a valid handoff. + +## QTA 2.0.0 first-hour research model + +`qta-2.0.0` reuses the same causal daily features, cutoff rules, price levels, +and per-ticker historical percentile transform. It changes only the horizon +aggregation for the first regular-session hour: + +```text +clip( + 0.50 * short + + 0.30 * medium + + 0.20 * (100 - risk), + 0, + 100 +) +``` + +Long-horizon strength remains visible in the payload but has zero weight in +the first-hour total. A setup also requires median 20-session turnover of at +least KRW 1,000,000,000 for Korean instruments or USD 1,000,000 for U.S. +instruments. + +The frozen payload requires a downstream same-session market-regime check: + +```text +metric = same_session_previous_close_return_bps +minimum_change_bps = 0 +max_age_seconds = 90 +``` + +The runner admits a new entry only when the exchange benchmark or documented +broad-market proxy is fresh, belongs to the current session, and is not below +the previous close. It never applies this check to exit management. + +The model was created after a full-universe diagnostic for the 2026-07-28 +opening hour. On that single session relative to QTA1, Spearman rank +correlation increased on KOSDAQ, KOSPI, NASDAQ, and NYSE. This is diagnostic +evidence, not sufficient promotion evidence. The payload therefore emits +`validation_status=RESEARCH_ONLY`, and the execution skill accepts it only in +shadow mode until multiple later sessions are evaluated without retuning the +formula. diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/references/screen-contract.md b/plugins/quant-stock-technical/skills/quant-stock-technical/references/screen-contract.md index a7b78c7..bf70deb 100644 --- a/plugins/quant-stock-technical/skills/quant-stock-technical/references/screen-contract.md +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/references/screen-contract.md @@ -123,12 +123,16 @@ no-trade result when no setup passes; use one or more only when underfilling that exchange should block the whole screen. The same exact `eligible_setup_statuses` restriction from v1 applies. -Run the same `qta-1.0.0` calculator once per included instrument. Use +By default, run the stable `qta-1.0.0` calculator once per included +instrument. Pass `--method-version qta-2.0.0` only for the research-only +first-hour model described in `methodology.md`. Use `canonical_symbol` as the QTA ticker, the explicit exchange benchmark CSV, and `tick_contract.resolved_tick_size`; never use `data_symbol` or `broker_symbol` interchangeably. -Apply the v1 score ordering independently inside each exchange. The final +Apply the method-specific score ordering independently inside each exchange. +QTA1 orders medium, long, short, then risk after total score. QTA2 orders +short, medium, risk, then long after total score. The final tie-break is canonical symbol ascending. Set `exchange_rank` before taking the configured top K. A score remains ticker-relative rather than a cross-sectional percentile or probability. @@ -151,3 +155,9 @@ Emit `qta-screen/v2` with: The polling trader combines KOSPI then KOSDAQ for a Korean session, and NYSE then NASDAQ for a U.S. session. It orders by the frozen exchange order, `exchange_rank`, then `broker_symbol`; it does not rescore candidates. + +`screen_universe.py` uses up to eight worker processes by default. `--workers +1` provides the serial reference path. Both paths must emit byte-identical +artifacts. When `--output` is supplied, the complete screen is written only to +that file and stdout contains a compact hash/status summary rather than +thousands of instrument payloads. diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/analyze_stock.py b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/analyze_stock.py index 6d7a0e0..1fba418 100644 --- a/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/analyze_stock.py +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/analyze_stock.py @@ -240,14 +240,65 @@ def rolling_volatility(values: list[float], period: int = 20) -> list[Optional[f def rolling_max_drawdown(values: list[float], period: int = 252) -> list[Optional[float]]: + """Calculate exact sliding-window drawdown in amortized linear time.""" + if period <= 0: + raise BlockedError("maximum drawdown period must be positive") output: list[Optional[float]] = [None] * len(values) - for index in range(period - 1, len(values)): - peak = values[index - period + 1] - worst = 0.0 - for value in values[index - period + 1 : index + 1]: - peak = max(peak, value) - worst = max(worst, 1.0 - value / peak) - output[index] = worst + + # A segment stores (maximum price, minimum price, largest earlier/later + # peak-to-trough ratio). The operation is associative, so a two-stack + # aggregate queue supports an exact ordered sliding-window query. + Segment = tuple[float, float, float] + + def singleton(value: float) -> Segment: + return (value, value, 1.0) + + def combine(left: Segment, right: Segment) -> Segment: + return ( + max(left[0], right[0]), + min(left[1], right[1]), + max(left[2], right[2], left[0] / right[1]), + ) + + incoming: list[tuple[Segment, Segment]] = [] + outgoing: list[tuple[Segment, Segment]] = [] + + def push(value: float) -> None: + item = singleton(value) + aggregate = item if not incoming else combine(incoming[-1][1], item) + incoming.append((item, aggregate)) + + def pop_oldest() -> None: + if not outgoing: + while incoming: + item, _ = incoming.pop() + aggregate = ( + item + if not outgoing + else combine(item, outgoing[-1][1]) + ) + outgoing.append((item, aggregate)) + outgoing.pop() + + def aggregate() -> Segment: + if outgoing and incoming: + return combine(outgoing[-1][1], incoming[-1][1]) + if outgoing: + return outgoing[-1][1] + if incoming: + return incoming[-1][1] + raise BlockedError("cannot aggregate an empty drawdown window") + + window_size = 0 + for index, value in enumerate(values): + push(value) + window_size += 1 + if window_size > period: + pop_oldest() + window_size -= 1 + if window_size == period: + maximum_ratio = aggregate()[2] + output[index] = 1.0 - 1.0 / maximum_ratio return output diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/analyze_stock_v2.py b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/analyze_stock_v2.py new file mode 100644 index 0000000..11c92fb --- /dev/null +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/analyze_stock_v2.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""First-hour-aligned QTA 2.0 daily setup score.""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +from datetime import date +from pathlib import Path +from typing import Any + +import analyze_stock as qta1 + + +METHOD_VERSION = "qta-2.0.0" +VALIDATION_STATUS = "RESEARCH_ONLY" +LIQUIDITY_FLOOR = { + "KR": 1_000_000_000.0, + "US": 1_000_000.0, +} +MARKET_REGIME_POLICY = { + "metric": "same_session_previous_close_return_bps", + "minimum_change_bps": 0.0, + "max_age_seconds": 90, + "status": "REQUIRED", +} + + +def calculate( + ticker_bars: list[qta1.Bar], + benchmark_bars: list[qta1.Bar], + market: str, + ticker: str, + tick_size: float, + source_name: str, +) -> dict[str, Any]: + """Reuse the causal QTA1 features but score the first-hour horizon.""" + market = market.upper() + if market not in LIQUIDITY_FLOOR: + raise qta1.BlockedError("QTA 2.0 market must be KR or US") + result = qta1.calculate( + ticker_bars, + benchmark_bars, + market, + ticker, + tick_size, + source_name, + ) + short = float(result["short"]["score"]) + medium = float(result["medium"]["score"]) + risk = float(result["risk"]["score"]) + total = min( + 100.0, + max( + 0.0, + 0.50 * short + + 0.30 * medium + + 0.20 * (100.0 - risk), + ), + ) + median_turnover = statistics.median( + bar.close * bar.volume for bar in ticker_bars[-20:] + ) + minimum_turnover = LIQUIDITY_FLOOR[market] + liquidity_ready = median_turnover >= minimum_turnover + result.update( + { + "method_version": METHOD_VERSION, + "validation_status": VALIDATION_STATUS, + "score_basis": ( + "ticker-relative historical percentiles aligned to first-hour " + "continuation; not probability of profit" + ), + "liquidity": { + "median_20_session_turnover": round(median_turnover, 2), + "minimum_turnover": minimum_turnover, + "currency": "KRW" if market == "KR" else "USD", + "status": "READY" if liquidity_ready else "BLOCKED", + }, + "market_regime": dict(MARKET_REGIME_POLICY), + "total_score": round(total, 2), + "setup_status": ( + "READY" + if total >= 60.0 and liquidity_ready + else "CONDITIONAL" + ), + "assumptions": [ + *result["assumptions"], + "QTA 2.0 is research-only until multi-session walk-forward validation", + "same-session benchmark regime admission is required downstream", + ], + } + ) + return result + + +def self_test() -> None: + ticker = qta1.synthetic_bars(1100, 0.0006, 0.0) + benchmark = qta1.synthetic_bars(1100, 0.0003, 1.0) + result = calculate( + ticker, + benchmark, + "US", + "SYNTH", + 0.01, + "deterministic-self-test", + ) + expected = min( + 100.0, + max( + 0.0, + 0.50 * result["short"]["score"] + + 0.30 * result["medium"]["score"] + + 0.20 * (100.0 - result["risk"]["score"]), + ), + ) + assert abs(result["total_score"] - round(expected, 2)) <= 0.01 + assert result["method_version"] == METHOD_VERSION + assert result["validation_status"] == VALIDATION_STATUS + print( + json.dumps( + { + "self_test": "PASS", + "method_version": METHOD_VERSION, + "validation_status": VALIDATION_STATUS, + "total_score": result["total_score"], + }, + sort_keys=True, + ) + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ticker-csv") + parser.add_argument("--benchmark-csv") + parser.add_argument("--market") + parser.add_argument("--ticker") + parser.add_argument("--tick-size", type=float) + parser.add_argument("--source-name", default="user-supplied-eod-csv") + parser.add_argument("--analysis-date") + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def blocked(reason: str, market: Any, ticker: Any) -> dict[str, Any]: + return { + "source_skill": "quant-stock-technical", + "result_schema": "quant-stock-technical/v1", + "calculation_status": "BLOCKED", + "reason": reason, + "method_version": METHOD_VERSION, + "market": str(market or "").upper(), + "ticker": str(ticker or "").strip().upper(), + } + + +def main() -> int: + args = parse_args() + if args.self_test: + self_test() + return 0 + missing = [ + name + for name in ( + "ticker_csv", + "benchmark_csv", + "market", + "ticker", + "tick_size", + ) + if getattr(args, name) is None + ] + if missing: + print( + json.dumps( + blocked( + "missing arguments: " + ", ".join(missing), + args.market, + args.ticker, + ), + indent=2, + ) + ) + return 2 + try: + requested = ( + date.fromisoformat(args.analysis_date) + if args.analysis_date + else None + ) + ticker_rows, benchmark_rows = qta1.align( + qta1.read_csv(str(Path(args.ticker_csv))), + qta1.read_csv(str(Path(args.benchmark_csv))), + requested, + ) + result = calculate( + ticker_rows, + benchmark_rows, + args.market, + args.ticker, + args.tick_size, + args.source_name, + ) + except (qta1.BlockedError, OSError, ValueError) as exc: + print( + json.dumps( + blocked(str(exc), args.market, args.ticker), + ensure_ascii=False, + indent=2, + ) + ) + return 2 + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/collect_open1h_research.py b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/collect_open1h_research.py new file mode 100644 index 0000000..88fc3c1 --- /dev/null +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/collect_open1h_research.py @@ -0,0 +1,920 @@ +#!/usr/bin/env python3 +"""Collect deterministic first-60-minute KIS bars for QTA research.""" + +from __future__ import annotations + +import argparse +import json +import sys +import tempfile +from datetime import date +from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN +from pathlib import Path +from typing import Any, Mapping + +import fetch_kis_kr_eod as shared +import screen_universe as screen + + +JOB_SCHEMA = "qta-open1h-research-job/v1" +BARS_SCHEMA = "qta-open1h-bars/v1" +SNAPSHOT_SCHEMA = "qta-open1h-snapshot/v1" +JOB_FIELDS = { + "schema", + "manifest_path", + "market", + "session_date", + "output_directory", + "environment", + "request_interval_ms", +} +MARKET_CONTRACTS = { + "KR": { + "exchanges": ("KOSPI", "KOSDAQ"), + "interval_minutes": 1, + "window_start": "090000", + "window_end": "100000", + "expected_bars": 61, + "path": ( + "/uapi/domestic-stock/v1/quotations/" + "inquire-time-dailychartprice" + ), + "tr_id": "FHKST03010230", + }, + "US": { + "exchanges": ("NYSE", "NASDAQ"), + "interval_minutes": 5, + "window_start": "093000", + "window_end": "103000", + "expected_bars": 13, + "path": ( + "/uapi/overseas-price/v1/quotations/" + "inquire-time-itemchartprice" + ), + "tr_id": "HHDFS76950200", + }, +} +US_EXCHANGE_CODES = {"NYSE": "NYS", "NASDAQ": "NAS"} +BAR_FIELDS = { + "time", + "open", + "high", + "low", + "close", + "volume", + "turnover", +} +BARS_FIELDS = { + "schema", + "source", + "market", + "exchange", + "canonical_symbol", + "broker_symbol", + "session_date", + "interval_minutes", + "window_start", + "window_end", + "bars", + "bars_hash", + "summary", +} +SUMMARY_FIELDS = { + "bar_count", + "start_open", + "end_close", + "window_high", + "window_low", + "window_volume", + "window_turnover", + "return_60m_bps", + "maximum_excursion_bps", + "minimum_excursion_bps", +} + + +class ResearchBlockedError(ValueError): + """Raised when first-hour research data cannot be used without guessing.""" + + +def exact_fields(value: Mapping[str, Any], expected: set[str], label: str) -> None: + if set(value) != expected: + raise ResearchBlockedError( + f"{label} fields mismatch; missing={sorted(expected - set(value))}, " + f"extra={sorted(set(value) - expected)}" + ) + + +def require_date(value: Any, field: str) -> str: + if not isinstance(value, str): + raise ResearchBlockedError(f"{field} must be YYYY-MM-DD") + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise ResearchBlockedError(f"{field} must be YYYY-MM-DD") from exc + if parsed.isoformat() != value: + raise ResearchBlockedError(f"{field} must be YYYY-MM-DD") + return value + + +def require_absolute_path(value: Any, field: str) -> Path: + path = Path(str(value)).expanduser() + if not path.is_absolute(): + raise ResearchBlockedError(f"{field} must be absolute") + return path + + +def decimal_value( + value: Any, + field: str, + *, + allow_zero: bool = False, +) -> Decimal: + if isinstance(value, bool): + raise ResearchBlockedError(f"{field} must be decimal") + try: + parsed = Decimal(str(value).replace(",", "").strip()) + except (InvalidOperation, ValueError) as exc: + raise ResearchBlockedError(f"{field} must be decimal") from exc + if not parsed.is_finite() or ( + parsed < 0 if allow_zero else parsed <= 0 + ): + qualifier = "nonnegative" if allow_zero else "positive" + raise ResearchBlockedError(f"{field} must be {qualifier}") + return parsed + + +def decimal_text(value: Decimal) -> str: + rendered = format(value, "f") + if "." in rendered: + rendered = rendered.rstrip("0").rstrip(".") + return rendered or "0" + + +def bps(numerator: Decimal, denominator: Decimal) -> str: + value = ( + (numerator / denominator - Decimal(1)) * Decimal(10000) + ).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN) + return format(value, "f") + + +def load_job(path: Path) -> dict[str, Any]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ResearchBlockedError(f"job must be readable JSON: {path}") from exc + if not isinstance(raw, dict): + raise ResearchBlockedError("job must contain one JSON object") + exact_fields(raw, JOB_FIELDS, "job") + if raw["schema"] != JOB_SCHEMA: + raise ResearchBlockedError(f"job.schema must be {JOB_SCHEMA}") + market = str(raw["market"]).upper() + if market not in MARKET_CONTRACTS: + raise ResearchBlockedError("job.market must be KR or US") + session_date = require_date(raw["session_date"], "job.session_date") + manifest_path = require_absolute_path(raw["manifest_path"], "job.manifest_path") + if not manifest_path.is_file() or manifest_path.is_symlink(): + raise ResearchBlockedError( + "job.manifest_path must be a regular non-symlink file" + ) + output_directory = require_absolute_path( + raw["output_directory"], "job.output_directory" + ) + if output_directory.exists() and ( + not output_directory.is_dir() or output_directory.is_symlink() + ): + raise ResearchBlockedError( + "job.output_directory must be a non-symlink directory" + ) + environment = str(raw["environment"]).lower() + if environment not in shared.BASE_URLS: + raise ResearchBlockedError("job.environment must be live or paper") + interval = raw["request_interval_ms"] + if ( + isinstance(interval, bool) + or not isinstance(interval, int) + or interval < (1000 if environment == "paper" else 100) + ): + floor = 1000 if environment == "paper" else 100 + raise ResearchBlockedError( + f"job.request_interval_ms must be an integer >= {floor}" + ) + try: + manifest_raw = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest = screen.normalize_manifest_v2(manifest_raw) + except ( + OSError, + json.JSONDecodeError, + screen.ScreenBlockedError, + ) as exc: + raise ResearchBlockedError(f"manifest validation failed: {exc}") from exc + if manifest["as_of"] != session_date: + raise ResearchBlockedError( + "manifest.as_of must equal the research session_date" + ) + if manifest["analysis_date"] >= session_date: + raise ResearchBlockedError( + "manifest.analysis_date must precede the research session_date" + ) + instruments = [ + instrument + for instrument in manifest["instruments"] + if instrument["market"] == market + ] + if not instruments: + raise ResearchBlockedError("manifest has no instruments for job.market") + return { + "schema": JOB_SCHEMA, + "manifest_path": str(manifest_path), + "manifest": manifest, + "market": market, + "session_date": session_date, + "output_directory": output_directory, + "environment": environment, + "request_interval_ms": interval, + "instruments": sorted( + instruments, + key=lambda item: ( + MARKET_CONTRACTS[market]["exchanges"].index(item["exchange"]), + item["canonical_symbol"], + ), + ), + } + + +def normalize_bar( + raw: Mapping[str, Any], + *, + market: str, + session_date: str, +) -> dict[str, str] | None: + compact_date = session_date.replace("-", "") + if market == "KR": + raw_date = str(raw.get("stck_bsop_date", "")).strip() + raw_time = str(raw.get("stck_cntg_hour", "")).strip() + if raw_date != compact_date: + return None + fields = { + "open": raw.get("stck_oprc"), + "high": raw.get("stck_hgpr"), + "low": raw.get("stck_lwpr"), + "close": raw.get("stck_prpr"), + "volume": raw.get("cntg_vol", "0") or "0", + "turnover": raw.get("acml_tr_pbmn", "0") or "0", + } + else: + raw_date = str(raw.get("xymd", "")).strip() + raw_time = str(raw.get("xhms", "")).strip() + if raw_date != compact_date: + return None + fields = { + "open": raw.get("open"), + "high": raw.get("high"), + "low": raw.get("low"), + "close": raw.get("last"), + "volume": raw.get("evol", "0") or "0", + "turnover": raw.get("eamt", "0") or "0", + } + if len(raw_time) != 6 or not raw_time.isdigit(): + raise ResearchBlockedError("KIS minute row has invalid local time") + output = { + "time": raw_time, + "open": decimal_text(decimal_value(fields["open"], "minute open")), + "high": decimal_text(decimal_value(fields["high"], "minute high")), + "low": decimal_text(decimal_value(fields["low"], "minute low")), + "close": decimal_text(decimal_value(fields["close"], "minute close")), + "volume": decimal_text( + decimal_value(fields["volume"], "minute volume", allow_zero=True) + ), + "turnover": decimal_text( + decimal_value(fields["turnover"], "minute turnover", allow_zero=True) + ), + } + high = Decimal(output["high"]) + low = Decimal(output["low"]) + if high < max( + Decimal(output["open"]), + low, + Decimal(output["close"]), + ): + raise ResearchBlockedError("KIS minute row high is inconsistent") + if low > min( + Decimal(output["open"]), + high, + Decimal(output["close"]), + ): + raise ResearchBlockedError("KIS minute row low is inconsistent") + return output + + +def normalized_window_bars( + raw_rows: Any, + *, + market: str, + session_date: str, +) -> list[dict[str, str]]: + if not isinstance(raw_rows, list) or any( + not isinstance(item, dict) for item in raw_rows + ): + raise ResearchBlockedError("KIS minute output must be an object array") + contract = MARKET_CONTRACTS[market] + by_time: dict[str, dict[str, str]] = {} + for raw in raw_rows: + bar = normalize_bar( + raw, + market=market, + session_date=session_date, + ) + if bar is None: + continue + if not ( + contract["window_start"] + <= bar["time"] + <= contract["window_end"] + ): + continue + existing = by_time.get(bar["time"]) + if existing is not None and existing != bar: + raise ResearchBlockedError( + f"conflicting duplicate minute bar at {bar['time']}" + ) + by_time[bar["time"]] = bar + bars = [by_time[key] for key in sorted(by_time)] + if not bars: + raise ResearchBlockedError("KIS response has no first-hour bars") + return bars + + +def minute_of_day(value: str) -> int: + if len(value) != 6 or not value.isdigit(): + raise ResearchBlockedError("bar time must be HHMMSS") + hour = int(value[:2]) + minute = int(value[2:4]) + second = int(value[4:]) + if hour > 23 or minute > 59 or second != 0: + raise ResearchBlockedError("bar time must be a whole valid minute") + return hour * 60 + minute + + +def coverage_summary( + bars: list[dict[str, str]], + *, + market: str, +) -> dict[str, Any]: + contract = MARKET_CONTRACTS[market] + first_time = bars[0]["time"] + last_time = bars[-1]["time"] + first_minute = minute_of_day(first_time) + last_minute = minute_of_day(last_time) + start_minute = minute_of_day(contract["window_start"]) + end_minute = minute_of_day(contract["window_end"]) + expected = int(contract["expected_bars"]) + interval = int(contract["interval_minutes"]) + if ( + first_time == contract["window_start"] + and last_time == contract["window_end"] + and len(bars) == expected + ): + status = "COMPLETE_GRID" + elif ( + first_time == contract["window_start"] + and last_time == contract["window_end"] + ): + status = "EXACT_ENDPOINTS_SPARSE" + elif ( + first_minute <= start_minute + interval + and last_minute >= end_minute - interval + ): + status = "NEAR_ENDPOINTS" + else: + status = "PARTIAL_WINDOW" + return { + "coverage_status": status, + "first_bar_time": first_time, + "last_bar_time": last_time, + "expected_bar_count": expected, + "missing_bar_count": max(0, expected - len(bars)), + "coverage_fraction": format( + Decimal(len(bars)) / Decimal(expected), "f" + ), + "observed_span_minutes": last_minute - first_minute, + } + + +def summarize_bars( + bars: list[dict[str, str]], + *, + market: str, +) -> dict[str, Any]: + start_open = Decimal(bars[0]["open"]) + end_close = Decimal(bars[-1]["close"]) + window_high = max(Decimal(bar["high"]) for bar in bars) + window_low = min(Decimal(bar["low"]) for bar in bars) + window_volume = sum( + (Decimal(bar["volume"]) for bar in bars), Decimal(0) + ) + if market == "KR": + # The domestic endpoint exposes cumulative traded value on each bar. + window_turnover = Decimal(bars[-1]["turnover"]) + else: + window_turnover = sum( + (Decimal(bar["turnover"]) for bar in bars), Decimal(0) + ) + return { + "bar_count": len(bars), + "start_open": decimal_text(start_open), + "end_close": decimal_text(end_close), + "window_high": decimal_text(window_high), + "window_low": decimal_text(window_low), + "window_volume": decimal_text(window_volume), + "window_turnover": decimal_text(window_turnover), + "return_60m_bps": bps(end_close, start_open), + "maximum_excursion_bps": bps(window_high, start_open), + "minimum_excursion_bps": bps(window_low, start_open), + } + + +def fetch_raw_rows( + client: shared.KisReadClient, + *, + market: str, + exchange: str, + broker_symbol: str, + session_date: str, +) -> list[dict[str, Any]]: + contract = MARKET_CONTRACTS[market] + if market == "KR": + body = client.get( + contract["path"], + contract["tr_id"], + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": broker_symbol, + "FID_INPUT_HOUR_1": contract["window_end"], + "FID_INPUT_DATE_1": session_date.replace("-", ""), + "FID_PW_DATA_INCU_YN": "N", + "FID_FAKE_TICK_INCU_YN": "", + }, + ) + else: + body = client.get( + contract["path"], + contract["tr_id"], + { + "AUTH": "", + "EXCD": US_EXCHANGE_CODES[exchange], + "SYMB": broker_symbol, + "NMIN": str(contract["interval_minutes"]), + "PINC": "1", + "NEXT": "", + "NREC": "120", + "FILL": "", + "KEYB": "", + }, + ) + rows = body.get("output2") + if not isinstance(rows, list): + raise ResearchBlockedError("KIS minute response output2 must be an array") + return rows + + +def bars_artifact( + *, + instrument: dict[str, Any], + market: str, + session_date: str, + raw_rows: list[dict[str, Any]], +) -> dict[str, Any]: + contract = MARKET_CONTRACTS[market] + bars = normalized_window_bars( + raw_rows, + market=market, + session_date=session_date, + ) + summary = summarize_bars(bars, market=market) + output = { + "schema": BARS_SCHEMA, + "source": { + "provider": "KIS_OPEN_API", + "endpoint": contract["path"], + "tr_id": contract["tr_id"], + "read_only": True, + }, + "market": market, + "exchange": instrument["exchange"], + "canonical_symbol": instrument["canonical_symbol"], + "broker_symbol": instrument["broker_symbol"], + "session_date": session_date, + "interval_minutes": contract["interval_minutes"], + "window_start": contract["window_start"], + "window_end": contract["window_end"], + "bars": bars, + "bars_hash": shared.sha256_bytes( + shared.canonical_json(bars).encode("utf-8") + ), + "summary": summary, + } + return normalize_bars_artifact(output) + + +def normalize_bars_artifact(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise ResearchBlockedError("bars artifact must be an object") + exact_fields(value, BARS_FIELDS, "bars artifact") + if value["schema"] != BARS_SCHEMA: + raise ResearchBlockedError(f"bars artifact schema must be {BARS_SCHEMA}") + market = str(value["market"]).upper() + if market not in MARKET_CONTRACTS: + raise ResearchBlockedError("bars artifact market must be KR or US") + exchange = str(value["exchange"]).upper() + if exchange not in MARKET_CONTRACTS[market]["exchanges"]: + raise ResearchBlockedError("bars artifact exchange does not match market") + session_date = require_date(value["session_date"], "bars session_date") + source = value["source"] + if not isinstance(source, dict) or source != { + "provider": "KIS_OPEN_API", + "endpoint": MARKET_CONTRACTS[market]["path"], + "tr_id": MARKET_CONTRACTS[market]["tr_id"], + "read_only": True, + }: + raise ResearchBlockedError("bars source contract is invalid") + if value["interval_minutes"] != MARKET_CONTRACTS[market]["interval_minutes"]: + raise ResearchBlockedError("bars interval does not match market") + if value["window_start"] != MARKET_CONTRACTS[market]["window_start"]: + raise ResearchBlockedError("bars window_start does not match market") + if value["window_end"] != MARKET_CONTRACTS[market]["window_end"]: + raise ResearchBlockedError("bars window_end does not match market") + bars = value["bars"] + if not isinstance(bars, list): + raise ResearchBlockedError("bars must be an array") + for index, bar in enumerate(bars): + if not isinstance(bar, dict): + raise ResearchBlockedError(f"bars[{index}] must be an object") + exact_fields(bar, BAR_FIELDS, f"bars[{index}]") + normalized_bars = normalized_window_bars( + [ + ( + { + "stck_bsop_date": session_date.replace("-", ""), + "stck_cntg_hour": bar["time"], + "stck_oprc": bar["open"], + "stck_hgpr": bar["high"], + "stck_lwpr": bar["low"], + "stck_prpr": bar["close"], + "cntg_vol": bar["volume"], + "acml_tr_pbmn": bar["turnover"], + } + if market == "KR" + else { + "xymd": session_date.replace("-", ""), + "xhms": bar["time"], + "open": bar["open"], + "high": bar["high"], + "low": bar["low"], + "last": bar["close"], + "evol": bar["volume"], + "eamt": bar["turnover"], + } + ) + for bar in bars + ], + market=market, + session_date=session_date, + ) + expected_hash = shared.sha256_bytes( + shared.canonical_json(normalized_bars).encode("utf-8") + ) + if value["bars_hash"] != expected_hash: + raise ResearchBlockedError("bars_hash does not match bars") + summary = value["summary"] + if not isinstance(summary, dict): + raise ResearchBlockedError("bars summary must be an object") + exact_fields(summary, SUMMARY_FIELDS, "bars summary") + expected_summary = summarize_bars(normalized_bars, market=market) + if summary != expected_summary: + raise ResearchBlockedError("bars summary does not match bars") + canonical_symbol = str(value["canonical_symbol"]).strip() + broker_symbol = str(value["broker_symbol"]).strip() + if not canonical_symbol or not broker_symbol: + raise ResearchBlockedError("bars symbols must be non-empty") + return { + **value, + "market": market, + "exchange": exchange, + "canonical_symbol": canonical_symbol, + "broker_symbol": broker_symbol, + "session_date": session_date, + "bars": normalized_bars, + "bars_hash": expected_hash, + "summary": expected_summary, + } + + +def record_from_artifact(path: Path, artifact: dict[str, Any]) -> dict[str, Any]: + return { + "market": artifact["market"], + "exchange": artifact["exchange"], + "canonical_symbol": artifact["canonical_symbol"], + "broker_symbol": artifact["broker_symbol"], + "session_date": artifact["session_date"], + "interval_minutes": artifact["interval_minutes"], + "bars_path": str(path.resolve()), + "bars_file_sha256": shared.sha256_file(path), + "bars_hash": artifact["bars_hash"], + **coverage_summary(artifact["bars"], market=artifact["market"]), + **artifact["summary"], + } + + +def cache_path( + output_directory: Path, + instrument: dict[str, Any], +) -> Path: + return ( + output_directory + / "bars" + / instrument["exchange"] + / f"{instrument['canonical_symbol']}.json" + ) + + +def collect( + job: dict[str, Any], + *, + client: shared.KisReadClient | None = None, +) -> dict[str, Any]: + output_directory = job["output_directory"] + output_directory.mkdir(parents=True, exist_ok=True) + if client is None: + key, secret, token = shared.load_api_credentials(job["environment"]) + client = shared.KisReadClient( + environment=job["environment"], + app_key=key, + app_secret=secret, + access_token=token, + interval_ms=job["request_interval_ms"], + ) + records: list[dict[str, Any]] = [] + failures: list[dict[str, str]] = [] + cache_hits = 0 + instruments = job["instruments"] + for index, instrument in enumerate(instruments, start=1): + path = cache_path(output_directory, instrument) + try: + if path.exists(): + artifact = normalize_bars_artifact( + json.loads(path.read_text(encoding="utf-8")) + ) + if ( + artifact["market"] != job["market"] + or artifact["exchange"] != instrument["exchange"] + or artifact["canonical_symbol"] + != instrument["canonical_symbol"] + or artifact["broker_symbol"] != instrument["broker_symbol"] + or artifact["session_date"] != job["session_date"] + ): + raise ResearchBlockedError( + "cached bars identity does not match instrument" + ) + cache_hits += 1 + else: + raw_rows = fetch_raw_rows( + client, + market=job["market"], + exchange=instrument["exchange"], + broker_symbol=instrument["broker_symbol"], + session_date=job["session_date"], + ) + artifact = bars_artifact( + instrument=instrument, + market=job["market"], + session_date=job["session_date"], + raw_rows=raw_rows, + ) + shared.atomic_write_json(path, artifact) + records.append(record_from_artifact(path, artifact)) + except ( + OSError, + json.JSONDecodeError, + shared.EodBlockedError, + ResearchBlockedError, + ) as exc: + failures.append( + { + "market": job["market"], + "exchange": instrument["exchange"], + "canonical_symbol": instrument["canonical_symbol"], + "broker_symbol": instrument["broker_symbol"], + "reason": str(exc), + } + ) + if index % 100 == 0 or index == len(instruments): + print( + shared.canonical_json( + { + "progress": index, + "total": len(instruments), + "ready": len(records), + "blocked": len(failures), + "requests": client.request_count, + } + ), + file=sys.stderr, + flush=True, + ) + records.sort(key=lambda item: (item["exchange"], item["canonical_symbol"])) + failures.sort( + key=lambda item: (item["exchange"], item["canonical_symbol"]) + ) + coverage_counts = { + status: sum( + record["coverage_status"] == status for record in records + ) + for status in ( + "COMPLETE_GRID", + "EXACT_ENDPOINTS_SPARSE", + "NEAR_ENDPOINTS", + "PARTIAL_WINDOW", + ) + } + without_hash = { + "schema": SNAPSHOT_SCHEMA, + "source_skill": "quant-stock-technical", + "source_contract": { + "provider": "KIS_OPEN_API", + "read_only": True, + "market": job["market"], + "tr_id": MARKET_CONTRACTS[job["market"]]["tr_id"], + "endpoint": MARKET_CONTRACTS[job["market"]]["path"], + }, + "manifest_path": job["manifest_path"], + "manifest_hash": job["manifest"]["manifest_hash"], + "analysis_date": job["manifest"]["analysis_date"], + "session_date": job["session_date"], + "market": job["market"], + "window_start": MARKET_CONTRACTS[job["market"]]["window_start"], + "window_end": MARKET_CONTRACTS[job["market"]]["window_end"], + "interval_minutes": MARKET_CONTRACTS[job["market"]][ + "interval_minutes" + ], + "instrument_count": len(instruments), + "ready_count": len(records), + "blocked_count": len(failures), + "cache_hit_count": cache_hits, + "request_count": client.request_count, + "retry_count": client.retry_count, + "coverage_counts": coverage_counts, + "records": records, + "failures": failures, + "api_mutation_count": 0, + "live_enabled": False, + } + snapshot = { + **without_hash, + "snapshot_hash": shared.sha256_bytes( + shared.canonical_json(without_hash).encode("utf-8") + ), + } + shared.atomic_write_json(output_directory / "open1h-snapshot.json", snapshot) + return snapshot + + +def synthetic_rows( + *, + market: str, + session_date: str, +) -> list[dict[str, str]]: + contract = MARKET_CONTRACTS[market] + start_hour = 9 + start_minute = 0 if market == "KR" else 30 + rows: list[dict[str, str]] = [] + for index in range(contract["expected_bars"]): + total_minutes = start_hour * 60 + start_minute + ( + index * contract["interval_minutes"] + ) + stamp = f"{total_minutes // 60:02d}{total_minutes % 60:02d}00" + opening = Decimal("100") + Decimal(index) / Decimal("10") + row = { + "open": decimal_text(opening), + "high": decimal_text(opening + Decimal("1")), + "low": decimal_text(opening - Decimal("1")), + "close": decimal_text(opening + Decimal("0.5")), + "volume": "10", + "turnover": str(1000 + index), + } + if market == "KR": + rows.append( + { + "stck_bsop_date": session_date.replace("-", ""), + "stck_cntg_hour": stamp, + "stck_oprc": row["open"], + "stck_hgpr": row["high"], + "stck_lwpr": row["low"], + "stck_prpr": row["close"], + "cntg_vol": row["volume"], + "acml_tr_pbmn": row["turnover"], + } + ) + else: + rows.append( + { + "xymd": session_date.replace("-", ""), + "xhms": stamp, + "open": row["open"], + "high": row["high"], + "low": row["low"], + "last": row["close"], + "evol": row["volume"], + "eamt": row["turnover"], + } + ) + return list(reversed(rows)) + + +def self_test() -> None: + for market in ("KR", "US"): + exchange = MARKET_CONTRACTS[market]["exchanges"][0] + instrument = { + "exchange": exchange, + "canonical_symbol": "005930" if market == "KR" else "TEST", + "broker_symbol": "005930" if market == "KR" else "TEST", + } + artifact = bars_artifact( + instrument=instrument, + market=market, + session_date="2026-07-28", + raw_rows=synthetic_rows( + market=market, + session_date="2026-07-28", + ), + ) + assert artifact["summary"]["bar_count"] == ( + MARKET_CONTRACTS[market]["expected_bars"] + ) + assert Decimal(artifact["summary"]["return_60m_bps"]) > 0 + with tempfile.TemporaryDirectory( + prefix="qta-open1h-research-" + ) as temporary: + path = Path(temporary) / "bars.json" + shared.atomic_write_json(path, artifact) + loaded = normalize_bars_artifact( + json.loads(path.read_text(encoding="utf-8")) + ) + assert loaded == artifact + print( + shared.canonical_json( + { + "self_test": "PASS", + "schema": SNAPSHOT_SCHEMA, + "markets": ["KR", "US"], + "api_mutation_count": 0, + "live_enabled": False, + } + ) + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + subparsers = parser.add_subparsers(dest="command") + collect_parser = subparsers.add_parser("collect") + collect_parser.add_argument("--job", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.self_test: + self_test() + return 0 + if args.command != "collect": + raise ResearchBlockedError("use collect --job or --self-test") + job_path = require_absolute_path(args.job, "job path") + result = collect(load_job(job_path)) + except ( + ResearchBlockedError, + shared.EodBlockedError, + screen.ScreenBlockedError, + OSError, + json.JSONDecodeError, + ) as exc: + print( + shared.canonical_json( + { + "status": "BLOCKED", + "reason": str(exc), + "api_mutation_count": 0, + "live_enabled": False, + } + ), + file=sys.stderr, + ) + return 2 + print(shared.canonical_json(result)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/evaluate_open1h_research.py b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/evaluate_open1h_research.py new file mode 100644 index 0000000..88bf812 --- /dev/null +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/evaluate_open1h_research.py @@ -0,0 +1,721 @@ +#!/usr/bin/env python3 +"""Evaluate frozen QTA scores against completed first-60-minute outcomes.""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import statistics +import sys +import tempfile +from decimal import Decimal, InvalidOperation, ROUND_CEILING, ROUND_HALF_EVEN +from pathlib import Path +from typing import Any, Iterable + +import collect_open1h_research as intraday +import fetch_kis_kr_eod as shared +import screen_universe as screen + + +JOB_SCHEMA = "qta-open1h-evaluation-job/v1" +DATASET_SCHEMA = "qta-open1h-evaluation/v1" +JOB_FIELDS = { + "schema", + "screen_path", + "snapshot_path", + "output_path", + "top_fraction", +} +SNAPSHOT_FIELDS = { + "schema", + "source_skill", + "source_contract", + "manifest_path", + "manifest_hash", + "analysis_date", + "session_date", + "market", + "window_start", + "window_end", + "interval_minutes", + "instrument_count", + "ready_count", + "blocked_count", + "cache_hit_count", + "request_count", + "retry_count", + "coverage_counts", + "records", + "failures", + "api_mutation_count", + "live_enabled", + "snapshot_hash", +} + + +class EvaluationBlockedError(ValueError): + """Raised when a QTA/opening-hour comparison would be incomplete.""" + + +def exact_fields(value: dict[str, Any], expected: set[str], label: str) -> None: + if set(value) != expected: + raise EvaluationBlockedError( + f"{label} fields mismatch; missing={sorted(expected - set(value))}, " + f"extra={sorted(set(value) - expected)}" + ) + + +def decimal_value(value: Any, field: str) -> Decimal: + if isinstance(value, bool): + raise EvaluationBlockedError(f"{field} must be decimal") + try: + parsed = Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise EvaluationBlockedError(f"{field} must be decimal") from exc + if not parsed.is_finite(): + raise EvaluationBlockedError(f"{field} must be finite") + return parsed + + +def positive_fraction(value: Any, field: str) -> Decimal: + parsed = decimal_value(value, field) + if parsed <= 0 or parsed >= 1: + raise EvaluationBlockedError(f"{field} must be greater than 0 and below 1") + return parsed + + +def absolute_file(value: Any, field: str, *, must_exist: bool) -> Path: + path = Path(str(value)).expanduser() + if not path.is_absolute(): + raise EvaluationBlockedError(f"{field} must be absolute") + if must_exist and (not path.is_file() or path.is_symlink()): + raise EvaluationBlockedError( + f"{field} must be a regular non-symlink file" + ) + return path + + +def load_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise EvaluationBlockedError(f"{label} must be readable JSON") from exc + if not isinstance(value, dict): + raise EvaluationBlockedError(f"{label} must be one JSON object") + return value + + +def load_job(path: Path) -> dict[str, Any]: + raw = load_object(path, "evaluation job") + exact_fields(raw, JOB_FIELDS, "evaluation job") + if raw["schema"] != JOB_SCHEMA: + raise EvaluationBlockedError(f"job.schema must be {JOB_SCHEMA}") + screen_path = absolute_file(raw["screen_path"], "screen_path", must_exist=True) + snapshot_path = absolute_file( + raw["snapshot_path"], "snapshot_path", must_exist=True + ) + output_path = absolute_file(raw["output_path"], "output_path", must_exist=False) + return { + "schema": JOB_SCHEMA, + "screen_path": screen_path, + "snapshot_path": snapshot_path, + "output_path": output_path, + "top_fraction": positive_fraction(raw["top_fraction"], "top_fraction"), + } + + +def normalized_screen(path: Path) -> dict[str, Any]: + value = load_object(path, "screen") + supplied_hash = value.get("screen_hash") + if not isinstance(supplied_hash, str): + raise EvaluationBlockedError("screen_hash is required") + without_hash = {key: item for key, item in value.items() if key != "screen_hash"} + if screen.sha256_json(without_hash) != supplied_hash: + raise EvaluationBlockedError("screen_hash does not match screen") + if ( + value.get("schema") != screen.SCREEN_SCHEMA_V2 + or value.get("source_skill") != "quant-stock-technical" + or value.get("method_version") not in {"qta-1.0.0", "qta-2.0.0"} + ): + raise EvaluationBlockedError( + "evaluation requires a qta-screen/v2 QTA 1.0.0 or 2.0.0 artifact" + ) + decisions = value.get("decisions") + if not isinstance(decisions, list) or not decisions: + raise EvaluationBlockedError("screen decisions must be a non-empty array") + return value + + +def normalized_snapshot(path: Path) -> dict[str, Any]: + value = load_object(path, "opening-hour snapshot") + exact_fields(value, SNAPSHOT_FIELDS, "opening-hour snapshot") + if value["schema"] != intraday.SNAPSHOT_SCHEMA: + raise EvaluationBlockedError( + f"snapshot.schema must be {intraday.SNAPSHOT_SCHEMA}" + ) + if value["api_mutation_count"] != 0 or value["live_enabled"] is not False: + raise EvaluationBlockedError("research snapshot must be read-only") + supplied_hash = value["snapshot_hash"] + without_hash = { + key: item for key, item in value.items() if key != "snapshot_hash" + } + expected_hash = shared.sha256_bytes( + shared.canonical_json(without_hash).encode("utf-8") + ) + if supplied_hash != expected_hash: + raise EvaluationBlockedError("snapshot_hash does not match snapshot") + if ( + value["instrument_count"] + != value["ready_count"] + value["blocked_count"] + ): + raise EvaluationBlockedError("snapshot counts do not reconcile") + if value["ready_count"] != len(value["records"]): + raise EvaluationBlockedError("snapshot ready_count does not match records") + if value["blocked_count"] != len(value["failures"]): + raise EvaluationBlockedError("snapshot blocked_count does not match failures") + return value + + +def eod_features( + ticker_path: Path, + *, + analysis_date: str, +) -> dict[str, str]: + try: + with ticker_path.open("r", encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + except OSError as exc: + raise EvaluationBlockedError(f"cannot read ticker CSV: {ticker_path}") from exc + selected = [row for row in rows if str(row.get("date")) <= analysis_date] + if not selected or str(selected[-1].get("date")) != analysis_date: + raise EvaluationBlockedError( + f"ticker CSV has no completed {analysis_date} cutoff" + ) + if len(selected) < 253: + raise EvaluationBlockedError("ticker CSV needs at least 253 cutoff rows") + closes = [ + decimal_value(row.get("close"), "ticker close") for row in selected + ] + volumes = [ + decimal_value(row.get("volume"), "ticker volume") for row in selected + ] + dollar_turnover = [ + closes[index] * volumes[index] + for index in range(max(0, len(selected) - 20), len(selected)) + ] + return { + "previous_close": format(closes[-1], "f"), + "return_5_bps": intraday.bps(closes[-1], closes[-6]), + "return_20_bps": intraday.bps(closes[-1], closes[-21]), + "return_63_bps": intraday.bps(closes[-1], closes[-64]), + "median_20d_dollar_turnover": format( + statistics.median(dollar_turnover), "f" + ), + } + + +def first_bar_close_cross( + bars: list[dict[str, str]], + entry: Decimal, +) -> str | None: + previous: Decimal | None = None + for bar in bars: + current = Decimal(bar["close"]) + if previous is not None and previous < entry <= current: + return bar["time"] + previous = current + return None + + +def combine_rows( + *, + screen_value: dict[str, Any], + snapshot: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + if screen_value["manifest_hash"] != snapshot["manifest_hash"]: + raise EvaluationBlockedError("screen and snapshot manifest hashes differ") + if screen_value["analysis_date"] != snapshot["analysis_date"]: + raise EvaluationBlockedError("screen and snapshot analysis dates differ") + decision_by_key: dict[tuple[str, str], dict[str, Any]] = {} + for decision in screen_value["decisions"]: + if not isinstance(decision, dict): + raise EvaluationBlockedError("screen decision must be an object") + key = (str(decision.get("exchange")), str(decision.get("canonical_symbol"))) + if key in decision_by_key: + raise EvaluationBlockedError(f"duplicate screen decision: {key}") + decision_by_key[key] = decision + rows: list[dict[str, Any]] = [] + blocked: list[dict[str, str]] = [] + for record in snapshot["records"]: + if not isinstance(record, dict): + raise EvaluationBlockedError("snapshot record must be an object") + key = (str(record.get("exchange")), str(record.get("canonical_symbol"))) + decision = decision_by_key.get(key) + if decision is None: + raise EvaluationBlockedError(f"snapshot record has no screen decision: {key}") + qta = decision.get("qta") + if ( + not isinstance(qta, dict) + or qta.get("calculation_status") != "READY" + or qta.get("method_version") != screen_value["method_version"] + ): + blocked.append( + { + "exchange": key[0], + "canonical_symbol": key[1], + "reason": str( + qta.get("reason", "QTA calculation is not READY") + if isinstance(qta, dict) + else "QTA payload is missing" + ), + } + ) + continue + bars_path = absolute_file( + record.get("bars_path"), "record.bars_path", must_exist=True + ) + if shared.sha256_file(bars_path) != record.get("bars_file_sha256"): + raise EvaluationBlockedError(f"bars file hash mismatch: {bars_path}") + artifact = intraday.normalize_bars_artifact( + load_object(bars_path, "bars artifact") + ) + if artifact["bars_hash"] != record.get("bars_hash"): + raise EvaluationBlockedError(f"bars hash mismatch: {bars_path}") + instrument = decision.get("instrument") + if not isinstance(instrument, dict): + raise EvaluationBlockedError("screen decision instrument is missing") + ticker_path = absolute_file( + instrument.get("ticker_csv"), "ticker_csv", must_exist=True + ) + if shared.sha256_file(ticker_path) != instrument.get("ticker_csv_sha256"): + raise EvaluationBlockedError(f"ticker CSV hash mismatch: {ticker_path}") + eod = eod_features( + ticker_path, + analysis_date=screen_value["analysis_date"], + ) + entry = decimal_value(qta["entry_price"], "QTA entry price") + start_open = Decimal(record["start_open"]) + end_close = Decimal(record["end_close"]) + cross_time = first_bar_close_cross(artifact["bars"], entry) + rows.append( + { + "market": decision["market"], + "exchange": decision["exchange"], + "canonical_symbol": decision["canonical_symbol"], + "broker_symbol": instrument["broker_symbol"], + "instrument_type": instrument["instrument_type"], + "analysis_date": screen_value["analysis_date"], + "session_date": snapshot["session_date"], + "qta_method_version": qta["method_version"], + "qta_total_score": format( + decimal_value(qta["total_score"], "QTA total score"), "f" + ), + "qta_short_score": format( + decimal_value(qta["short"]["score"], "QTA short score"), "f" + ), + "qta_medium_score": format( + decimal_value(qta["medium"]["score"], "QTA medium score"), "f" + ), + "qta_long_score": format( + decimal_value(qta["long"]["score"], "QTA long score"), "f" + ), + "qta_risk_score": format( + decimal_value(qta["risk"]["score"], "QTA risk score"), "f" + ), + "qta_entry_price": format(entry, "f"), + "qta_stop_price": format( + decimal_value(qta["stop_price"], "QTA stop price"), "f" + ), + "qta_take_profit_price": format( + decimal_value( + qta["take_profit_price"], "QTA take-profit price" + ), + "f", + ), + **eod, + "opening_gap_bps": intraday.bps( + start_open, Decimal(eod["previous_close"]) + ), + "coverage_status": record["coverage_status"], + "first_bar_time": record["first_bar_time"], + "last_bar_time": record["last_bar_time"], + "bar_count": record["bar_count"], + "expected_bar_count": record["expected_bar_count"], + "missing_bar_count": record["missing_bar_count"], + "coverage_fraction": record["coverage_fraction"], + "observed_span_minutes": record["observed_span_minutes"], + "return_60m_bps": record["return_60m_bps"], + "maximum_excursion_bps": record["maximum_excursion_bps"], + "minimum_excursion_bps": record["minimum_excursion_bps"], + "window_volume": record["window_volume"], + "window_turnover": record["window_turnover"], + "entry_distance_from_previous_close_bps": intraday.bps( + entry, Decimal(eod["previous_close"]) + ), + "entry_touched_by_minute_high": ( + Decimal(record["window_high"]) >= entry + ), + "entry_crossed_by_bar_close": cross_time is not None, + "entry_cross_bar_time": cross_time, + "end_return_from_entry_bps": intraday.bps(end_close, entry), + } + ) + rows.sort(key=lambda item: (item["exchange"], item["canonical_symbol"])) + blocked.sort(key=lambda item: (item["exchange"], item["canonical_symbol"])) + return rows, blocked + + +def average_ranks(values: list[Decimal]) -> list[float]: + order = sorted(range(len(values)), key=lambda index: (values[index], index)) + ranks = [0.0] * len(values) + cursor = 0 + while cursor < len(order): + end = cursor + 1 + while end < len(order) and values[order[end]] == values[order[cursor]]: + end += 1 + average = (cursor + 1 + end) / 2.0 + for position in range(cursor, end): + ranks[order[position]] = average + cursor = end + return ranks + + +def pearson(left: Iterable[float], right: Iterable[float]) -> float | None: + x = list(left) + y = list(right) + if len(x) != len(y) or len(x) < 2: + return None + x_mean = sum(x) / len(x) + y_mean = sum(y) / len(y) + numerator = sum((a - x_mean) * (b - y_mean) for a, b in zip(x, y)) + x_scale = math.sqrt(sum((a - x_mean) ** 2 for a in x)) + y_scale = math.sqrt(sum((b - y_mean) ** 2 for b in y)) + if x_scale == 0 or y_scale == 0: + return None + return numerator / (x_scale * y_scale) + + +def rounded_metric(value: float | None) -> str | None: + if value is None: + return None + return format( + Decimal(str(value)).quantize( + Decimal("0.000001"), rounding=ROUND_HALF_EVEN + ), + "f", + ) + + +def group_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + returns = [Decimal(row["return_60m_bps"]) for row in rows] + touched = sum(bool(row["entry_touched_by_minute_high"]) for row in rows) + crossed = sum(bool(row["entry_crossed_by_bar_close"]) for row in rows) + positive = sum(value > 0 for value in returns) + return { + "count": len(rows), + "mean_return_60m_bps": ( + format(sum(returns, Decimal(0)) / len(returns), "f") + if returns + else None + ), + "median_return_60m_bps": ( + format(statistics.median(returns), "f") if returns else None + ), + "positive_fraction": ( + format(Decimal(positive) / len(rows), "f") if rows else None + ), + "entry_touch_fraction": ( + format(Decimal(touched) / len(rows), "f") if rows else None + ), + "bar_close_cross_fraction": ( + format(Decimal(crossed) / len(rows), "f") if rows else None + ), + } + + +def exchange_diagnostics( + rows: list[dict[str, Any]], + *, + top_fraction: Decimal, +) -> dict[str, Any]: + if not rows: + raise EvaluationBlockedError("exchange diagnostics require rows") + qta_scores = [Decimal(row["qta_total_score"]) for row in rows] + returns = [Decimal(row["return_60m_bps"]) for row in rows] + pearson_value = pearson( + [float(value) for value in qta_scores], + [float(value) for value in returns], + ) + spearman_value = pearson( + average_ranks(qta_scores), + average_ranks(returns), + ) + top_count = max( + 1, + int( + (Decimal(len(rows)) * top_fraction).to_integral_value( + rounding=ROUND_CEILING + ) + ), + ) + by_outcome = sorted( + rows, + key=lambda item: ( + -Decimal(item["return_60m_bps"]), + item["canonical_symbol"], + ), + ) + by_score = sorted( + rows, + key=lambda item: ( + -Decimal(item["qta_total_score"]), + item["canonical_symbol"], + ), + ) + winner_keys = { + row["canonical_symbol"] for row in by_outcome[:top_count] + } + score_keys = {row["canonical_symbol"] for row in by_score[:top_count]} + overlap = len(winner_keys & score_keys) + baseline = Decimal(top_count) / Decimal(len(rows)) + precision = Decimal(overlap) / Decimal(top_count) + lift = precision / baseline + deciles: list[dict[str, Any]] = [] + for index in range(10): + start = len(by_score) * index // 10 + end = len(by_score) * (index + 1) // 10 + bucket = by_score[start:end] + deciles.append( + { + "qta_score_decile": index + 1, + **group_summary(bucket), + } + ) + + def compact(row: dict[str, Any]) -> dict[str, Any]: + return { + key: row[key] + for key in ( + "canonical_symbol", + "qta_total_score", + "qta_short_score", + "qta_medium_score", + "qta_long_score", + "qta_risk_score", + "return_5_bps", + "return_20_bps", + "median_20d_dollar_turnover", + "opening_gap_bps", + "coverage_status", + "first_bar_time", + "last_bar_time", + "bar_count", + "return_60m_bps", + "maximum_excursion_bps", + "minimum_excursion_bps", + "entry_touched_by_minute_high", + "entry_crossed_by_bar_close", + "end_return_from_entry_bps", + ) + } + + return { + "count": len(rows), + "top_fraction": format(top_fraction, "f"), + "top_count": top_count, + "pearson_qta_total_vs_return_60m": rounded_metric(pearson_value), + "spearman_qta_total_vs_return_60m": rounded_metric(spearman_value), + "top_outcome_overlap_count": overlap, + "top_outcome_precision": format(precision, "f"), + "top_outcome_recall": format(precision, "f"), + "top_outcome_lift": format(lift, "f"), + "overall": group_summary(rows), + "qta_score_deciles": deciles, + "top_20_outcomes": [compact(row) for row in by_outcome[:20]], + "bottom_20_outcomes": [compact(row) for row in by_outcome[-20:]], + "top_20_qta_scores": [compact(row) for row in by_score[:20]], + } + + +def evaluate(job: dict[str, Any]) -> dict[str, Any]: + screen_value = normalized_screen(job["screen_path"]) + snapshot = normalized_snapshot(job["snapshot_path"]) + rows, qta_blocked = combine_rows( + screen_value=screen_value, + snapshot=snapshot, + ) + if not rows: + raise EvaluationBlockedError("no READY QTA/opening-hour joined rows") + analysis_rows = [ + row + for row in rows + if row["coverage_status"] + in { + "COMPLETE_GRID", + "EXACT_ENDPOINTS_SPARSE", + "NEAR_ENDPOINTS", + } + ] + if not analysis_rows: + raise EvaluationBlockedError( + "no joined rows have complete or near-complete opening-hour endpoints" + ) + diagnostics = { + exchange: exchange_diagnostics( + [row for row in analysis_rows if row["exchange"] == exchange], + top_fraction=job["top_fraction"], + ) + for exchange in intraday.MARKET_CONTRACTS[snapshot["market"]]["exchanges"] + if any(row["exchange"] == exchange for row in analysis_rows) + } + without_hash = { + "schema": DATASET_SCHEMA, + "source_skill": "quant-stock-technical", + "baseline_method_version": screen_value["method_version"], + "target_definition": ( + "last observed close divided by first observed open; no interpolation; " + "COMPLETE_GRID, EXACT_ENDPOINTS_SPARSE, and NEAR_ENDPOINTS only" + ), + "market": snapshot["market"], + "analysis_date": snapshot["analysis_date"], + "session_date": snapshot["session_date"], + "screen_path": str(job["screen_path"]), + "screen_hash": screen_value["screen_hash"], + "snapshot_path": str(job["snapshot_path"]), + "snapshot_hash": snapshot["snapshot_hash"], + "top_fraction": format(job["top_fraction"], "f"), + "joined_count": len(rows), + "analysis_eligible_count": len(analysis_rows), + "coverage_counts": snapshot["coverage_counts"], + "intraday_blocked_count": snapshot["blocked_count"], + "qta_blocked_after_join_count": len(qta_blocked), + "diagnostics_by_exchange": diagnostics, + "rows": rows, + "qta_blocked_after_join": qta_blocked, + "api_mutation_count": 0, + "live_enabled": False, + } + output = { + **without_hash, + "evaluation_hash": shared.sha256_bytes( + shared.canonical_json(without_hash).encode("utf-8") + ), + } + shared.atomic_write_json(job["output_path"], output) + return output + + +def self_test() -> None: + sample = [ + { + "canonical_symbol": f"T{index}", + "qta_total_score": str(index), + "qta_short_score": str(index), + "qta_medium_score": str(index), + "qta_long_score": str(index), + "qta_risk_score": str(100 - index), + "return_5_bps": str(index), + "return_20_bps": str(index), + "median_20d_dollar_turnover": str(index * 1000000), + "opening_gap_bps": "0", + "coverage_status": "COMPLETE_GRID", + "first_bar_time": "093000", + "last_bar_time": "103000", + "bar_count": 13, + "return_60m_bps": str(index * 2), + "maximum_excursion_bps": str(index * 3), + "minimum_excursion_bps": str(-index), + "entry_touched_by_minute_high": index % 2 == 0, + "entry_crossed_by_bar_close": index % 3 == 0, + "end_return_from_entry_bps": str(index), + } + for index in range(1, 21) + ] + diagnostics = exchange_diagnostics(sample, top_fraction=Decimal("0.1")) + assert diagnostics["spearman_qta_total_vs_return_60m"] == "1.000000" + assert diagnostics["top_outcome_overlap_count"] == 2 + bars = intraday.synthetic_rows(market="US", session_date="2026-07-28") + artifact = intraday.bars_artifact( + instrument={ + "exchange": "NASDAQ", + "canonical_symbol": "TEST", + "broker_symbol": "TEST", + }, + market="US", + session_date="2026-07-28", + raw_rows=bars, + ) + assert first_bar_close_cross(artifact["bars"], Decimal("101.2")) is not None + with tempfile.TemporaryDirectory(prefix="qta-open1h-evaluation-") as temporary: + output = Path(temporary) / "result.json" + shared.atomic_write_json(output, {"self_test": "PASS"}) + assert load_object(output, "self-test")["self_test"] == "PASS" + print( + shared.canonical_json( + { + "self_test": "PASS", + "schema": DATASET_SCHEMA, + "api_mutation_count": 0, + "live_enabled": False, + } + ) + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + subparsers = parser.add_subparsers(dest="command") + evaluate_parser = subparsers.add_parser("evaluate") + evaluate_parser.add_argument("--job", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.self_test: + self_test() + return 0 + if args.command != "evaluate": + raise EvaluationBlockedError("use evaluate --job or --self-test") + job_path = absolute_file(args.job, "job path", must_exist=True) + result = evaluate(load_job(job_path)) + except ( + EvaluationBlockedError, + intraday.ResearchBlockedError, + screen.ScreenBlockedError, + OSError, + json.JSONDecodeError, + ) as exc: + print( + shared.canonical_json( + { + "status": "BLOCKED", + "reason": str(exc), + "api_mutation_count": 0, + "live_enabled": False, + } + ), + file=sys.stderr, + ) + return 2 + print( + shared.canonical_json( + { + "status": "READY", + "market": result["market"], + "joined_count": result["joined_count"], + "evaluation_hash": result["evaluation_hash"], + "api_mutation_count": 0, + "live_enabled": False, + } + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/screen_universe.py b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/screen_universe.py index 48dc5a4..c4f97f8 100755 --- a/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/screen_universe.py +++ b/plugins/quant-stock-technical/skills/quant-stock-technical/scripts/screen_universe.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -"""Run qta-1.0.0 across a frozen v1/v2 universe and apply its selector.""" +"""Run an approved QTA method across a frozen v1/v2 universe.""" from __future__ import annotations import argparse +import concurrent.futures import hashlib import json +import os import re import sys from datetime import date @@ -14,6 +16,7 @@ from typing import Any import analyze_stock as qta +import analyze_stock_v2 as qta2 MANIFEST_SCHEMA = "qta-universe-manifest/v1" SCREEN_SCHEMA = "qta-screen/v1" @@ -952,13 +955,18 @@ def instrument_tick_size(instrument: dict[str, Any]) -> str: return str(instrument["tick_contract"]["resolved_tick_size"]) -def blocked_payload(instrument: dict[str, Any], reason: str) -> dict[str, Any]: +def blocked_payload( + instrument: dict[str, Any], + reason: str, + *, + method_version: str, +) -> dict[str, Any]: return { "source_skill": "quant-stock-technical", "result_schema": "quant-stock-technical/v1", "calculation_status": "BLOCKED", "reason": reason, - "method_version": qta.METHOD_VERSION, + "method_version": method_version, "market": instrument_market(instrument), "ticker": instrument_ticker(instrument), } @@ -968,6 +976,8 @@ def analyze_instrument( instrument: dict[str, Any], analysis_date: date, manifest_directory: Path, + *, + calculator: Any = qta, ) -> dict[str, Any]: ticker_path = Path(instrument["ticker_csv"]) benchmark_path = Path(instrument["benchmark_csv"]) @@ -988,7 +998,7 @@ def analyze_instrument( qta.read_csv(str(benchmark_path)), analysis_date, ) - return qta.calculate( + return calculator.calculate( ticker_rows, benchmark_rows, instrument_market(instrument), @@ -997,7 +1007,32 @@ def analyze_instrument( instrument["source_name"], ) except (qta.BlockedError, OSError, ValueError) as exc: - return blocked_payload(instrument, str(exc)) + return blocked_payload( + instrument, + str(exc), + method_version=calculator.METHOD_VERSION, + ) + + +def analyze_instrument_task( + task: tuple[dict[str, Any], str, str, str], +) -> dict[str, Any]: + instrument, analysis_date_text, manifest_directory_text, method_version = task + calculators = { + qta.METHOD_VERSION: qta, + qta2.METHOD_VERSION: qta2, + } + calculator = calculators.get(method_version) + if calculator is None: + raise ScreenBlockedError( + f"unsupported QTA method version: {method_version!r}" + ) + return analyze_instrument( + instrument, + date.fromisoformat(analysis_date_text), + Path(manifest_directory_text), + calculator=calculator, + ) def score(payload: dict[str, Any], path: str) -> Decimal: @@ -1012,6 +1047,16 @@ def score(payload: dict[str, Any], path: str) -> Decimal: def ranking_key(payload: dict[str, Any]) -> tuple[Any, ...]: + if payload.get("method_version") == qta2.METHOD_VERSION: + return ( + -score(payload, "total_score"), + -score(payload, "short.score"), + -score(payload, "medium.score"), + score(payload, "risk.score"), + -score(payload, "long.score"), + str(payload["market"]), + str(payload["ticker"]), + ) return ( -score(payload, "total_score"), -score(payload, "medium.score"), @@ -1112,11 +1157,16 @@ def finalize_screen( decision["ticker"], ) in selected_keys + method_versions = { + str(result.get("method_version")) for result in ordered_results + } + if len(method_versions) != 1: + raise ScreenBlockedError("screen results contain multiple QTA methods") output = { "source_skill": "quant-stock-technical", "schema": SCREEN_SCHEMA, "screen_status": screen_status, - "method_version": qta.METHOD_VERSION, + "method_version": method_versions.pop(), "selector_version": SELECTOR_VERSION, "analysis_date": manifest["analysis_date"], "manifest_hash": sha256_json(manifest), @@ -1240,11 +1290,16 @@ def finalize_screen_v2( for key in selected_keys: decision_by_key[key]["selected"] = True + method_versions = { + str(result.get("method_version")) for result in ordered_results + } + if len(method_versions) != 1: + raise ScreenBlockedError("screen results contain multiple QTA methods") output = { "source_skill": "quant-stock-technical", "schema": SCREEN_SCHEMA_V2, "screen_status": screen_status, - "method_version": qta.METHOD_VERSION, + "method_version": method_versions.pop(), "selector_version": SELECTOR_VERSION_V2, "analysis_date": manifest["analysis_date"], "manifest_hash": manifest["manifest_hash"], @@ -1264,6 +1319,9 @@ def build_screen( manifest: dict[str, Any], selector: dict[str, Any], manifest_directory: Path, + *, + method_version: str = qta.METHOD_VERSION, + workers: int = 1, ) -> dict[str, Any]: normalized_manifest = normalize_manifest(manifest) normalized_selector = normalize_selector(selector) @@ -1278,10 +1336,39 @@ def build_screen( ): raise ScreenBlockedError("qta-universe-manifest/v2 requires qta-screen-1.1.0") analysis_date = date.fromisoformat(normalized_manifest["analysis_date"]) - results = [ - analyze_instrument(instrument, analysis_date, manifest_directory) + calculators = { + qta.METHOD_VERSION: qta, + qta2.METHOD_VERSION: qta2, + } + calculator = calculators.get(method_version) + if calculator is None: + raise ScreenBlockedError( + f"unsupported QTA method version: {method_version!r}" + ) + if isinstance(workers, bool) or workers <= 0 or workers > 32: + raise ScreenBlockedError("workers must be an integer between 1 and 32") + tasks = [ + ( + instrument, + analysis_date.isoformat(), + str(manifest_directory), + calculator.METHOD_VERSION, + ) for instrument in normalized_manifest["instruments"] ] + if workers == 1: + results = [analyze_instrument_task(task) for task in tasks] + else: + with concurrent.futures.ProcessPoolExecutor( + max_workers=workers + ) as executor: + results = list( + executor.map( + analyze_instrument_task, + tasks, + chunksize=8, + ) + ) if normalized_manifest["schema"] == MANIFEST_SCHEMA: return finalize_screen(normalized_manifest, normalized_selector, results) return finalize_screen_v2(normalized_manifest, normalized_selector, results) @@ -1763,6 +1850,17 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--manifest") parser.add_argument("--selector") parser.add_argument("--output") + parser.add_argument( + "--method-version", + choices=(qta.METHOD_VERSION, qta2.METHOD_VERSION), + default=qta.METHOD_VERSION, + ) + parser.add_argument( + "--workers", + type=int, + default=0, + help="worker processes; 0 selects up to 8 local CPUs", + ) parser.add_argument("--self-test", action="store_true") return parser.parse_args() @@ -1772,8 +1870,33 @@ def emit(value: dict[str, Any], output: str | None) -> None: value, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False ) if output: - Path(output).write_text(rendered + "\n", encoding="utf-8") - print(rendered) + output_path = Path(output) + output_path.write_text(rendered + "\n", encoding="utf-8") + summary = { + "source_skill": value.get( + "source_skill", + "quant-stock-technical", + ), + "schema": value.get("schema"), + "screen_status": value.get("screen_status"), + "method_version": value.get("method_version"), + "analysis_date": value.get("analysis_date"), + "blocked_count": value.get("blocked_count"), + "screen_hash": value.get("screen_hash"), + "reason": value.get("reason"), + "output": str(output_path.resolve()), + } + print( + json.dumps( + summary, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + ) + else: + print(rendered) def main() -> int: @@ -1802,6 +1925,12 @@ def main() -> int: manifest, load_object(Path(args.selector).resolve()), manifest_path.parent, + method_version=args.method_version, + workers=( + min(8, max(1, os.cpu_count() or 1)) + if args.workers == 0 + else args.workers + ), ) except (ScreenBlockedError, OSError, ValueError, json.JSONDecodeError) as exc: emit( diff --git a/release/catalog.json b/release/catalog.json index cb7e115..9a50705 100644 --- a/release/catalog.json +++ b/release/catalog.json @@ -96,7 +96,7 @@ }, { "name": "quant-stock-polling-trader", - "version": "0.5.3", + "version": "0.5.4", "runtime": { "codex": "supported", "claudeCode": "supported", @@ -112,11 +112,11 @@ "frozen screen, account, exposure, calendar, risk, and execution-policy inputs", "systemd for generated Linux user units" ], - "notes": "The daily KIS live/shadow pipeline is a direct prepare, snapshot/plan, entry, and reconciliation flow with API mutations fixed to zero. Daily preparation starts at 01:00 in each market timezone and refreshes only KOSPI/KOSDAQ for KR or NYSE/NASDAQ for US, avoiding cross-market cutoff errors and duplicate reads. Optional ntfy completion/block notifications read only a protected topic URL/token and cannot alter the worker result. The workflow does not require approved source digests, provenance signatures, account-identity HMACs, trading-arm artifacts, or Toss/NH files." + "notes": "The daily KIS live/shadow pipeline now accepts research-only QTA2 screens, performs a fresh same-session benchmark-regime check before entry quotes, and persists strategy fill ownership in an account/market position ledger across session dates. Daily preparation remains market-scoped, ntfy stays best-effort, and API mutations remain fixed to zero." }, { "name": "quant-stock-technical", - "version": "0.4.4", + "version": "0.4.5", "runtime": { "codex": "supported", "claudeCode": "supported", @@ -128,7 +128,7 @@ "user-provided finalized OHLCV and benchmark files, or KIS Open API credentials for read-only Korean and U.S. EOD acquisition", "outbound KRX, Nasdaq Trader, KIS, and benchmark source access" ], - "notes": "The Korean and U.S. EOD collectors are resumable and read-only. qta-universe-manifest/v2 now accepts exactly a Korean pair, a U.S. pair, or all four exchanges while keeping all four count keys and zeroing inactive exchanges. Coverage is enforced for every active exchange. Complete byte-equivalent cache files are not rewritten, and invalid KIS adjusted rows remain source-audited without interpolation." + "notes": "Adds research-only qta-2.0.0 for the first regular-session hour, full-universe read-only opening-hour collection/evaluation, deterministic parallel screening, and compact stdout receipts. QTA1 remains byte-equivalent as the stable comparison baseline." }, { "name": "stock-scenario-story", diff --git a/skills/quant-stock-polling-trader/SKILL.md b/skills/quant-stock-polling-trader/SKILL.md index 013bf90..9674a9c 100644 --- a/skills/quant-stock-polling-trader/SKILL.md +++ b/skills/quant-stock-polling-trader/SKILL.md @@ -88,6 +88,12 @@ without making a Korean session wait for U.S. EOD or vice versa. Finish an initial multi-year bootstrap before relying on a daily timer; the timer is intended to resume and increment an existing cache. +Set optional `qta_method_version` to `qta-2.0.0` for the research-only +first-hour model. The runner then performs one read-only benchmark-regime call +per active exchange and cycle before stock quotes. A negative, stale, +wrong-session, or unavailable regime observation blocks new entries for that +cycle. QTA2 cannot run in paper or live mode. + To receive optional ntfy completion/block notifications, place these values in the same mode-`0600` environment file used by the generated services: @@ -111,6 +117,15 @@ The account alias is nonsecret and configured by the user. A current session is never entered twice. An unresolved broker mutation from a paper-mode run still requires reconciliation before another entry. +Filled strategy quantities are stored in +`RUNTIME_ROOT/state///positions.sqlite3`, outside the +date directory. Before a later session reads quotes, the runner reconciles +that ledger against the frozen KIS account snapshot. Matching positions roll +forward as `CARRY_EXIT_ONLY`; quantity differences, working orders, duplicate +ownership, or a prior `MANUAL_BLOCK` stop new entries. Holdings not created by +this strategy remain visible as unmanaged broker positions and are never +silently adopted or sold. + ## Direct planning and polling For a non-recurring session, read `references/execution-contract.md`, @@ -170,6 +185,7 @@ python3 scripts/account_snapshot.py self-test python3 scripts/market_calendar.py self-test python3 scripts/daily_pipeline.py self-test python3 scripts/plan_orders.py --self-test +python3 scripts/position_lifecycle.py --self-test python3 scripts/run_session.py self-test python3 scripts/reconcile.py --self-test python3 scripts/systemd_units.py self-test diff --git a/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md b/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md index 5d5345d..a8638d6 100644 --- a/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md +++ b/skills/quant-stock-polling-trader/references/daily-pipeline-contract.md @@ -38,6 +38,7 @@ Use `qta-daily-shadow-config/v2`: "mode": "shadow", "history_start_date": "2021-01-01", "minimum_sessions": 756, + "qta_method_version": "qta-2.0.0", "request_interval_ms": 120, "catalog_coverage_contract": { "schema": "qta-catalog-coverage-contract/v1", @@ -94,7 +95,11 @@ Use `qta-daily-shadow-config/v2`: } ``` -Only the fields shown above are required. Two optional fields may be added: +`qta_method_version` is optional and defaults to `qta-1.0.0`. Select +`qta-2.0.0` only for shadow research; the screen and runner then enforce its +liquidity and same-session market-regime contracts. + +Three other optional fields may be added: ```json { @@ -118,6 +123,12 @@ The intent ledger is: RUNTIME_ROOT/state/// ``` +The strategy-position ledger persists across dates at: + +```text +RUNTIME_ROOT/state///positions.sqlite3 +``` + The workflow descriptor is `RUNTIME_ROOT/current/.json`, and date-specific artifacts are under `RUNTIME_ROOT/workflows//`. diff --git a/skills/quant-stock-polling-trader/references/execution-contract.md b/skills/quant-stock-polling-trader/references/execution-contract.md index d71cc7e..edbd101 100644 --- a/skills/quant-stock-polling-trader/references/execution-contract.md +++ b/skills/quant-stock-polling-trader/references/execution-contract.md @@ -5,7 +5,7 @@ Keep three independent contracts: ```text -qta-1.0.0 completed T-1 adjusted daily data -> technical payload +qta-1.0.0/2.0.0 completed T-1 adjusted daily data -> technical payload qta-screen-1.0.0 legacy KR/US manifest -> deterministic candidates qta-screen-1.1.0 selected-market manifest -> exchange-ranked candidates open1h-exec-2.0.0 broker/account snapshots -> intents and order lifecycle @@ -62,6 +62,15 @@ resolved-tick alignment, and the emitted 2R take-profit geometry. A smaller, reranked, or semantically forged object cannot make itself acceptable merely by hashing its own contents. +For `qta-2.0.0`, the validator instead recomputes its first-hour total, +verifies the market-specific turnover floor, and requires the exact +research-only market-regime policy. QTA2 can run only in shadow mode. Before +stock quotes in each cycle, the runner reads KOSPI Composite, KOSDAQ +Composite, Nasdaq Composite, or the documented S&P 500 broad-market proxy for +NYSE candidates. It verifies the current-session timestamp, a maximum age of +90 seconds, and return from previous close of at least zero basis points. A +failed check skips new entries for that exchange and cycle. + Each account position must include `market` and `symbol`, plus `exchange` for a v2 screen. Each item in `open_orders` must represent a currently working order and include `side, market, symbol`, plus `exchange` for v2. Existing positions @@ -164,6 +173,9 @@ KIS live US shadow: ceil(intent_count * 1 * 0.12s) Toss shadow: ceil(intent_count * 2 * 0.10s) ``` +QTA2 adds one KIS benchmark request per active exchange to the same cycle +budget. + Reject a configured interval below that floor. This is only an API-pacing feasibility bound, not proof of network latency. Production promotion also requires observed shadow percentiles. @@ -211,6 +223,16 @@ At the window end, freeze new entries, cancel outstanding paper-mode entry orders, and reconcile. Continue managing already filled paper positions. Automatic market flattening is not implemented. +The account/market strategy-position ledger is stored one directory above the +date-specific intent ledger. Authoritative cumulative entry fills create or +update a position with its original stop, target, tick, FX, and plan identity. +Before the next session, exact broker quantities are reconciled and valid +residuals roll to `CARRY_EXIT_ONLY`; they are excluded from new additions. +The bundled lifecycle module deterministically calculates stop, gap-stop, +target, close-liquidation, partial-fill, and daily-loss decisions, but the +current polling service does not yet send live exit mutations. Do not describe +that decision module as active automatic liquidation. + ## State machine ```text diff --git a/skills/quant-stock-polling-trader/scripts/broker_adapters.py b/skills/quant-stock-polling-trader/scripts/broker_adapters.py index 42f78bb..8062df2 100755 --- a/skills/quant-stock-polling-trader/scripts/broker_adapters.py +++ b/skills/quant-stock-polling-trader/scripts/broker_adapters.py @@ -9,7 +9,8 @@ import time from collections.abc import Callable from dataclasses import dataclass, replace -from datetime import datetime, timedelta, timezone +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal, ROUND_HALF_UP from typing import Any, ClassVar from urllib.error import HTTPError, URLError from urllib.parse import urlencode @@ -765,6 +766,36 @@ class KisBroker: "NYSE": "NYS", "AMEX": "AMS", } + REGIME_BENCHMARKS: ClassVar[dict[str, dict[str, str]]] = { + "KOSPI": { + "market": "KR", + "benchmark_id": "KOSPI_COMPOSITE", + "provider_symbol": "0001", + "regime_proxy_id": "KOSPI_COMPOSITE", + "timezone": "Asia/Seoul", + }, + "KOSDAQ": { + "market": "KR", + "benchmark_id": "KOSDAQ_COMPOSITE", + "provider_symbol": "1001", + "regime_proxy_id": "KOSDAQ_COMPOSITE", + "timezone": "Asia/Seoul", + }, + "NYSE": { + "market": "US", + "benchmark_id": "NYSE_COMPOSITE", + "provider_symbol": "SPX", + "regime_proxy_id": "S&P_500", + "timezone": "America/New_York", + }, + "NASDAQ": { + "market": "US", + "benchmark_id": "NASDAQ_COMPOSITE", + "provider_symbol": "COMP", + "regime_proxy_id": "NASDAQ_COMPOSITE", + "timezone": "America/New_York", + }, + } def __init__( self, @@ -1122,6 +1153,189 @@ def quote(self, market: str, symbol: str, *, venue: str) -> dict[str, Any]: "raw_status": "OK", } + def benchmark_quote( + self, + *, + exchange: str, + session_date: str, + ) -> dict[str, Any]: + """Return one same-session, read-only benchmark regime observation.""" + exchange = str(exchange).upper() + contract = self.REGIME_BENCHMARKS.get(exchange) + if contract is None: + raise BlockedError(f"unsupported benchmark exchange: {exchange}") + try: + parsed_session_date = date.fromisoformat(session_date) + except (TypeError, ValueError) as exc: + raise BlockedError("benchmark session_date must be YYYY-MM-DD") from exc + if parsed_session_date.isoformat() != session_date: + raise BlockedError("benchmark session_date must be YYYY-MM-DD") + compact_date = session_date.replace("-", "") + received_at = datetime.now(timezone.utc) + + if contract["market"] == "KR": + tr_id = "FHPUP02110100" + response = self.transport.request( + "GET", + ( + f"{self.base_url}/uapi/domestic-stock/v1/quotations/" + "inquire-index-tickprice" + ), + headers=self.headers(tr_id), + query={ + "FID_INPUT_ISCD": contract["provider_symbol"], + "FID_COND_MRKT_DIV_CODE": "U", + }, + timeout_seconds=5, + ) + body = kis_result(response, f"{exchange} benchmark") + rows = body.get("output") + if not isinstance(rows, list) or not rows: + raise BlockedError( + f"KIS {exchange} benchmark output must be a non-empty array" + ) + valid_rows = [ + row + for row in rows + if isinstance(row, dict) + and kis_market_timestamp( + date_value=compact_date, + time_value=row.get("stck_cntg_hour"), + timezone_name=contract["timezone"], + ) + is not None + ] + if not valid_rows: + raise BlockedError( + f"KIS {exchange} benchmark has no broker timestamp" + ) + latest = max( + valid_rows, + key=lambda row: str(row["stck_cntg_hour"]), + ) + source_timestamp = kis_market_timestamp( + date_value=compact_date, + time_value=latest["stck_cntg_hour"], + timezone_name=contract["timezone"], + ) + current = decimal_value( + latest.get("bstp_nmix_prpr"), + f"{exchange} benchmark current", + ) + difference = abs( + decimal_value( + latest.get("bstp_nmix_prdy_vrss"), + f"{exchange} benchmark previous difference", + ) + ) + sign = str(latest.get("prdy_vrss_sign") or "").strip() + if sign in {"1", "2"}: + signed_difference = difference + elif sign == "3": + signed_difference = Decimal(0) + elif sign in {"4", "5"}: + signed_difference = -difference + else: + raise BlockedError( + f"KIS {exchange} benchmark has an unknown previous-close sign" + ) + previous_close = current - signed_difference + if current <= 0 or previous_close <= 0: + raise BlockedError( + f"KIS {exchange} benchmark prices must be positive" + ) + else: + if self.environment == "paper": + raise BlockedError( + "KIS U.S. paper benchmark feed is unsupported; use " + "kis-live/shadow" + ) + tr_id = "FHKST03030200" + response = self.transport.request( + "GET", + ( + f"{self.base_url}/uapi/overseas-price/v1/quotations/" + "inquire-time-indexchartprice" + ), + headers=self.headers(tr_id), + query={ + "FID_COND_MRKT_DIV_CODE": "N", + "FID_INPUT_ISCD": contract["provider_symbol"], + "FID_HOUR_CLS_CODE": "0", + "FID_PW_DATA_INCU_YN": "N", + }, + timeout_seconds=5, + ) + body = kis_result(response, f"{exchange} benchmark") + summary = require_mapping( + body.get("output1"), + f"KIS {exchange} benchmark output1", + ) + rows = body.get("output2") + if not isinstance(rows, list) or not rows: + raise BlockedError( + f"KIS {exchange} benchmark output2 must be a non-empty array" + ) + valid_rows = [ + row + for row in rows + if isinstance(row, dict) + and str(row.get("stck_bsop_date") or "").strip() == compact_date + and kis_market_timestamp( + date_value=row.get("stck_bsop_date"), + time_value=row.get("stck_cntg_hour"), + timezone_name=contract["timezone"], + ) + is not None + ] + if not valid_rows: + raise BlockedError( + f"KIS {exchange} benchmark has no bar for {session_date}" + ) + latest = max( + valid_rows, + key=lambda row: str(row["stck_cntg_hour"]), + ) + source_timestamp = kis_market_timestamp( + date_value=latest["stck_bsop_date"], + time_value=latest["stck_cntg_hour"], + timezone_name=contract["timezone"], + ) + current = decimal_value( + latest.get("optn_prpr"), + f"{exchange} benchmark current", + ) + previous_close = decimal_value( + summary.get("ovrs_nmix_prdy_clpr"), + f"{exchange} benchmark previous close", + ) + if current <= 0 or previous_close <= 0: + raise BlockedError( + f"KIS {exchange} benchmark prices must be positive" + ) + + if source_timestamp is None: + raise BlockedError( + f"KIS {exchange} benchmark source timestamp is invalid" + ) + change_bps = ( + (current / previous_close - Decimal(1)) * Decimal(10000) + ).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) + return { + "schema": "qta-market-regime-quote/v1", + "broker": "kis", + "exchange": exchange, + "benchmark_id": contract["benchmark_id"], + "regime_proxy_id": contract["regime_proxy_id"], + "provider_symbol": contract["provider_symbol"], + "current": format(current, "f"), + "previous_close": format(previous_close, "f"), + "change_bps": format(change_bps, "f"), + "source_timestamp": source_timestamp, + "received_at": received_at.isoformat(), + "raw_status": "OK", + } + def build_cancel_request( self, *, @@ -1330,6 +1544,13 @@ def get_order( "FT_CCLD_QTY", "ft_ccld_qty", ) + average_fill_price = self._case_value( + record, + "AVG_PRVS", + "avg_prvs", + "FT_CCLD_UNPR3", + "ft_ccld_unpr3", + ) remaining = self._case_value( record, "RMN_QTY", "rmn_qty", "NCCS_QTY", "nccs_qty" ) @@ -1348,6 +1569,17 @@ def get_order( if remaining not in (None, "") else None ) + average_fill_decimal = ( + decimal_value(average_fill_price, "average fill price") + if average_fill_price not in (None, "") + else None + ) + if average_fill_decimal is not None and average_fill_decimal <= 0: + if filled_decimal is not None and filled_decimal > 0: + raise BlockedError( + "KIS filled order has a nonpositive average fill price" + ) + average_fill_decimal = None cancel_value = str( self._case_value(record, "CNCL_YN", "cncl_yn", "CANCEL_YN") or "" ).upper() @@ -1379,6 +1611,9 @@ def get_order( "filled_quantity": None if filled_decimal is None else format(filled_decimal, "f"), + "average_fill_price": None + if average_fill_decimal is None + else format(average_fill_decimal, "f"), "remaining_quantity": None if remaining_decimal is None else format(remaining_decimal, "f"), diff --git a/skills/quant-stock-polling-trader/scripts/daily_pipeline.py b/skills/quant-stock-polling-trader/scripts/daily_pipeline.py index 5ad57d9..3794470 100644 --- a/skills/quant-stock-polling-trader/scripts/daily_pipeline.py +++ b/skills/quant-stock-polling-trader/scripts/daily_pipeline.py @@ -50,6 +50,7 @@ OPTIONAL_CONFIG_FIELDS = { "exposure_component_paths", "exposure_component_max_age_seconds", + "qta_method_version", } EXECUTION_DEFAULT_FIELDS = { "snapshot_max_age_seconds", @@ -209,6 +210,11 @@ def normalize_config(raw: Any, path: Path) -> dict[str, Any]: if not isinstance(execution, dict): raise PipelineBlockedError("execution_policy must be an object") exact_fields(execution, EXECUTION_DEFAULT_FIELDS, "execution_policy") + qta_method_version = raw.get("qta_method_version", "qta-1.0.0") + if qta_method_version not in {"qta-1.0.0", "qta-2.0.0"}: + raise PipelineBlockedError( + "qta_method_version must be qta-1.0.0 or qta-2.0.0" + ) component_paths = raw.get("exposure_component_paths", []) if not isinstance(component_paths, list): raise PipelineBlockedError("exposure_component_paths must be an array") @@ -272,6 +278,7 @@ def normalize_config(raw: Any, path: Path) -> dict[str, Any]: "risk_policy": risk, "exposure_component_paths": normalized_component_paths, "exposure_component_max_age_seconds": max_age, + "qta_method_version": qta_method_version, "config_path": path.resolve(), } @@ -697,6 +704,8 @@ def prepare( str(selector_path), "--output", str(screen_path), + "--method-version", + config["qta_method_version"], ), f"{market} screen", ) diff --git a/skills/quant-stock-polling-trader/scripts/execution_core.py b/skills/quant-stock-polling-trader/scripts/execution_core.py index d6b2dd4..2bd45fd 100755 --- a/skills/quant-stock-polling-trader/scripts/execution_core.py +++ b/skills/quant-stock-polling-trader/scripts/execution_core.py @@ -31,6 +31,9 @@ SCREEN_SCHEMA_V2 = "qta-screen/v2" MARKET_CURRENCY = {"KR": "KRW", "US": "USD"} V2_SELECTOR_VERSION = "qta-screen-1.1.0" +QTA_METHOD_V1 = "qta-1.0.0" +QTA_METHOD_V2 = "qta-2.0.0" +SUPPORTED_QTA_METHODS = {QTA_METHOD_V1, QTA_METHOD_V2} V2_EXCHANGES_BY_MARKET = { "KR": ("KOSPI", "KOSDAQ"), "US": ("NYSE", "NASDAQ"), @@ -149,6 +152,23 @@ "reference_observations", "assumptions", } +QTA2_READY_FIELDS = QTA_READY_FIELDS | { + "validation_status", + "liquidity", + "market_regime", +} +QTA2_LIQUIDITY_FIELDS = { + "median_20_session_turnover", + "minimum_turnover", + "currency", + "status", +} +QTA2_MARKET_REGIME_FIELDS = { + "metric", + "minimum_change_bps", + "max_age_seconds", + "status", +} QTA_BLOCKED_FIELDS = { "source_skill", "result_schema", @@ -183,11 +203,27 @@ "60d gap p95", } QTA_SCORE_BASIS = "ticker-relative historical percentile; not probability of profit" +QTA2_SCORE_BASIS = ( + "ticker-relative historical percentiles aligned to first-hour " + "continuation; not probability of profit" +) QTA_ASSUMPTIONS = [ "input rows are finalized completed daily sessions", "prices are corporate-action adjusted and aligned to analysis-date raw price scale", "fees, tax, FX, slippage, position size, and execution are excluded", ] +QTA2_ASSUMPTIONS = [ + *QTA_ASSUMPTIONS, + "QTA 2.0 is research-only until multi-session walk-forward validation", + "same-session benchmark regime admission is required downstream", +] +QTA2_LIQUIDITY_FLOOR = { + "KR": Decimal("1000000000"), + "US": Decimal("1000000"), +} +QTA2_REGIME_METRIC = "same_session_previous_close_return_bps" +QTA2_REGIME_MINIMUM_CHANGE_BPS = Decimal("0") +QTA2_REGIME_MAX_AGE_SECONDS = 90 QTA_SETUP_STATUSES = {"READY", "CONDITIONAL"} QTA_OPINIONS = {"강한 긍정", "긍정", "중립", "부정", "강한 부정"} QTA_MIN_SHARED_SESSIONS = 756 @@ -1126,7 +1162,7 @@ def validate_v2_qta_identity( if ( qta.get("source_skill") != "quant-stock-technical" or qta.get("result_schema") != "quant-stock-technical/v1" - or qta.get("method_version") != "qta-1.0.0" + or qta.get("method_version") not in SUPPORTED_QTA_METHODS ): raise BlockedError(f"{label} provenance is invalid") if qta.get("market") != instrument["market"]: @@ -1142,7 +1178,14 @@ def validate_v2_ready_qta( *, screen_analysis_date: date, ) -> None: - exact_fields(qta, QTA_READY_FIELDS, label) + method_version = qta.get("method_version") + exact_fields( + qta, + QTA2_READY_FIELDS + if method_version == QTA_METHOD_V2 + else QTA_READY_FIELDS, + label, + ) validate_v2_qta_identity(qta, instrument, label) if qta["calculation_status"] != "READY": raise BlockedError(f"{label}.calculation_status must be READY") @@ -1161,7 +1204,12 @@ def validate_v2_ready_qta( raise BlockedError( f"{label}.shared_sessions must be an integer >= {QTA_MIN_SHARED_SESSIONS}" ) - if qta["score_basis"] != QTA_SCORE_BASIS: + expected_score_basis = ( + QTA2_SCORE_BASIS + if method_version == QTA_METHOD_V2 + else QTA_SCORE_BASIS + ) + if qta["score_basis"] != expected_score_basis: raise BlockedError(f"{label}.score_basis is invalid") horizon_scores: dict[str, Decimal] = {} @@ -1226,7 +1274,8 @@ def validate_v2_ready_qta( expected_take_profit = entry + Decimal(2) * (entry - stop) if take_profit != expected_take_profit: raise BlockedError( - f"{label}.take_profit_price must equal the qta-1.0.0 2R target" + f"{label}.take_profit_price must equal the " + f"{method_version} 2R target" ) total_score = validate_json_number( qta["total_score"], @@ -1234,17 +1283,97 @@ def validate_v2_ready_qta( minimum=Decimal(0), maximum=Decimal(100), ) - recomputed_total = ( - Decimal("0.25") * horizon_scores["short"] - + Decimal("0.35") * horizon_scores["medium"] - + Decimal("0.40") * horizon_scores["long"] - - Decimal("0.20") * max(risk_score - Decimal(50), Decimal(0)) - ) + if method_version == QTA_METHOD_V2: + recomputed_total = ( + Decimal("0.50") * horizon_scores["short"] + + Decimal("0.30") * horizon_scores["medium"] + + Decimal("0.20") * (Decimal(100) - risk_score) + ) + else: + recomputed_total = ( + Decimal("0.25") * horizon_scores["short"] + + Decimal("0.35") * horizon_scores["medium"] + + Decimal("0.40") * horizon_scores["long"] + - Decimal("0.20") * max(risk_score - Decimal(50), Decimal(0)) + ) recomputed_total = min(Decimal(100), max(Decimal(0), recomputed_total)) if abs(total_score - recomputed_total) > Decimal("0.02"): raise BlockedError( - f"{label}.total_score does not match the qta-1.0.0 score formula" + f"{label}.total_score does not match the {method_version} score formula" + ) + liquidity_ready = True + if method_version == QTA_METHOD_V2: + if qta["validation_status"] != "RESEARCH_ONLY": + raise BlockedError( + f"{label}.validation_status must be RESEARCH_ONLY" + ) + liquidity = qta["liquidity"] + if not isinstance(liquidity, dict): + raise BlockedError(f"{label}.liquidity must be an object") + exact_fields( + liquidity, + QTA2_LIQUIDITY_FIELDS, + f"{label}.liquidity", + ) + median_turnover = validate_json_number( + liquidity["median_20_session_turnover"], + f"{label}.liquidity.median_20_session_turnover", + minimum=Decimal(0), ) + minimum_turnover = validate_json_number( + liquidity["minimum_turnover"], + f"{label}.liquidity.minimum_turnover", + minimum=Decimal(0), + ) + expected_currency = MARKET_CURRENCY[instrument["market"]] + if liquidity["currency"] != expected_currency: + raise BlockedError( + f"{label}.liquidity.currency must be {expected_currency}" + ) + expected_floor = QTA2_LIQUIDITY_FLOOR[instrument["market"]] + if minimum_turnover != expected_floor: + raise BlockedError( + f"{label}.liquidity.minimum_turnover does not match QTA 2.0" + ) + liquidity_ready = median_turnover >= minimum_turnover + expected_liquidity_status = "READY" if liquidity_ready else "BLOCKED" + if liquidity["status"] != expected_liquidity_status: + raise BlockedError( + f"{label}.liquidity.status does not match turnover" + ) + market_regime = qta["market_regime"] + if not isinstance(market_regime, dict): + raise BlockedError(f"{label}.market_regime must be an object") + exact_fields( + market_regime, + QTA2_MARKET_REGIME_FIELDS, + f"{label}.market_regime", + ) + if market_regime["metric"] != QTA2_REGIME_METRIC: + raise BlockedError(f"{label}.market_regime.metric is invalid") + minimum_change = validate_json_number( + market_regime["minimum_change_bps"], + f"{label}.market_regime.minimum_change_bps", + minimum=Decimal("-10000"), + maximum=Decimal("10000"), + ) + if minimum_change != QTA2_REGIME_MINIMUM_CHANGE_BPS: + raise BlockedError( + f"{label}.market_regime.minimum_change_bps does not match " + "QTA 2.0" + ) + if ( + isinstance(market_regime["max_age_seconds"], bool) + or market_regime["max_age_seconds"] + != QTA2_REGIME_MAX_AGE_SECONDS + ): + raise BlockedError( + f"{label}.market_regime.max_age_seconds does not match QTA 2.0" + ) + if market_regime["status"] != "REQUIRED": + raise BlockedError( + f"{label}.market_regime.status must be REQUIRED" + ) allowed_setup_statuses = ( {"READY", "CONDITIONAL"} if total_score == Decimal(60) @@ -1252,6 +1381,8 @@ def validate_v2_ready_qta( if total_score > Decimal(60) else {"CONDITIONAL"} ) + if not liquidity_ready: + allowed_setup_statuses = {"CONDITIONAL"} if qta["setup_status"] not in allowed_setup_statuses: raise BlockedError(f"{label}.setup_status does not match total_score") @@ -1280,8 +1411,15 @@ def validate_v2_ready_qta( f"between {QTA_MIN_REFERENCE_OBSERVATIONS} and " f"{maximum_reference_count}" ) - if qta["assumptions"] != QTA_ASSUMPTIONS: - raise BlockedError(f"{label}.assumptions do not match qta-1.0.0") + expected_assumptions = ( + QTA2_ASSUMPTIONS + if method_version == QTA_METHOD_V2 + else QTA_ASSUMPTIONS + ) + if qta["assumptions"] != expected_assumptions: + raise BlockedError( + f"{label}.assumptions do not match {method_version}" + ) def validate_v2_blocked_qta( @@ -1321,6 +1459,16 @@ def validate_v2_qta( def qta_ranking_key(qta: dict[str, Any]) -> tuple[Any, ...]: + if qta.get("method_version") == QTA_METHOD_V2: + return ( + -decimal_value(qta["total_score"], "qta.total_score"), + -decimal_value(qta["short"]["score"], "qta.short.score"), + -decimal_value(qta["medium"]["score"], "qta.medium.score"), + decimal_value(qta["risk"]["score"], "qta.risk.score"), + -decimal_value(qta["long"]["score"], "qta.long.score"), + str(qta["market"]), + str(qta["ticker"]), + ) return ( -decimal_value(qta["total_score"], "qta.total_score"), -decimal_value(qta["medium"]["score"], "qta.medium.score"), @@ -1420,6 +1568,7 @@ def validate_v2_selected( selected: dict[str, Any], *, analysis_date: date, + method_version: str, ) -> dict[tuple[str, str], dict[str, Any]]: expected_exchanges = set(V2_EXCHANGE_CONTRACTS) if set(selected) != expected_exchanges: @@ -1472,6 +1621,10 @@ def validate_v2_selected( screen_analysis_date=analysis_date, require_ready=True, ) + if qta["method_version"] != method_version: + raise BlockedError( + f"{label}.qta method differs from screen.method_version" + ) key = (exchange, instrument["broker_symbol"]) if key in seen: raise BlockedError( @@ -1494,8 +1647,10 @@ def validate_v2_screen(screen: dict[str, Any]) -> None: raise BlockedError(f"qta-screen/v2 schema must be {SCREEN_SCHEMA_V2}") if screen["screen_status"] != "READY": raise BlockedError("screen_status must be READY") - if screen["method_version"] != "qta-1.0.0": - raise BlockedError("method_version must be qta-1.0.0") + if screen["method_version"] not in SUPPORTED_QTA_METHODS: + raise BlockedError( + "method_version must be qta-1.0.0 or qta-2.0.0" + ) if screen["selector_version"] != V2_SELECTOR_VERSION: raise BlockedError(f"selector_version must be {V2_SELECTOR_VERSION}") validate_sha256(screen["screen_hash"], "screen_hash") @@ -1541,6 +1696,7 @@ def validate_v2_screen(screen: dict[str, Any]) -> None: selected_by_key = validate_v2_selected( selected, analysis_date=analysis_date, + method_version=screen["method_version"], ) decisions = screen["decisions"] if not isinstance(decisions, list): @@ -1586,6 +1742,10 @@ def validate_v2_screen(screen: dict[str, Any]) -> None: screen_analysis_date=analysis_date, require_ready=False, ) + if qta["method_version"] != screen["method_version"]: + raise BlockedError( + f"{label}.qta method differs from screen.method_version" + ) calculation_ready = qta["calculation_status"] == "READY" if not calculation_ready: computed_blocked_count += 1 @@ -1960,7 +2120,7 @@ def plan_orders( qta.get("source_skill") != "quant-stock-technical" or qta.get("result_schema") != "quant-stock-technical/v1" or qta.get("calculation_status") != "READY" - or qta.get("method_version") != "qta-1.0.0" + or qta.get("method_version") != screen_raw["method_version"] ): raise BlockedError("selected QTA payload contract is invalid") if is_v2: @@ -2137,6 +2297,7 @@ def plan_orders( "instrument_type": instrument["instrument_type"], "benchmark_id": instrument["benchmark_id"], "venue": instrument["venue"], + "resolved_tick_size": format(tick, "f"), "tick_contract_hash": sha256_json(instrument["tick_contract"]), } ) diff --git a/skills/quant-stock-polling-trader/scripts/position_lifecycle.py b/skills/quant-stock-polling-trader/scripts/position_lifecycle.py new file mode 100644 index 0000000..e60c987 --- /dev/null +++ b/skills/quant-stock-polling-trader/scripts/position_lifecycle.py @@ -0,0 +1,1236 @@ +#!/usr/bin/env python3 +"""Deterministic strategy-position lifecycle and exit decisions.""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys +import tempfile +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path +from typing import Any + +from execution_core import ( + SCREEN_SCHEMA_V2, + BlockedError, + canonical_json, + decimal_value, + exact_fields, + normalize_symbol, + normalized_account_snapshot, + parse_aware_datetime, + positive_decimal, + round_down_to_tick, + sha256_json, +) + +POSITION_POLICY_SCHEMA = "qta-position-policy/v1" +STRATEGY_POSITION_SCHEMA = "qta-strategy-position/v1" +EXIT_DECISION_SCHEMA = "qta-exit-decision/v1" +POSITION_RECONCILIATION_SCHEMA = "qta-position-reconciliation/v1" +POSITION_POLICY_FIELDS = { + "schema", + "close_liquidation_seconds_before_close", + "exit_order_ttl_seconds", + "max_exit_replacements", + "quote_max_age_seconds", + "partial_entry_action", + "gap_down_action", + "daily_loss_action", + "overnight_residual_action", +} +STRATEGY_POSITION_FIELDS = { + "schema", + "position_id", + "plan_hash", + "entry_intent_id", + "market", + "exchange", + "symbol", + "venue", + "currency", + "entry_session_date", + "last_managed_session_date", + "planned_quantity", + "acquired_quantity", + "exited_quantity", + "average_entry_price", + "average_exit_price", + "stop_price", + "take_profit_price", + "tick_size", + "fx_to_krw", + "daily_baseline_session_date", + "daily_baseline_price", + "realized_pnl_native", + "daily_realized_pnl_native", + "status", +} +POSITION_STATUSES = { + "PENDING_ENTRY", + "OPEN", + "EXIT_PENDING", + "EXIT_PARTIAL", + "CARRY_EXIT_ONLY", + "CLOSED", + "MANUAL_BLOCK", +} +MARKET_EXCHANGES = { + "KR": {"KOSPI", "KOSDAQ"}, + "US": {"NYSE", "NASDAQ"}, +} +MARKET_CURRENCY = {"KR": "KRW", "US": "USD"} + + +def require_iso_date(value: Any, field: str) -> str: + if not isinstance(value, str): + raise BlockedError(f"{field} must be YYYY-MM-DD") + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise BlockedError(f"{field} must be YYYY-MM-DD") from exc + if parsed.isoformat() != value: + raise BlockedError(f"{field} must be YYYY-MM-DD") + return value + + +def require_nonnegative_integer(value: Any, field: str) -> int: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise BlockedError(f"{field} must be a nonnegative integer") + return value + + +def require_positive_integer(value: Any, field: str) -> int: + amount = require_nonnegative_integer(value, field) + if amount == 0: + raise BlockedError(f"{field} must be a positive integer") + return amount + + +def normalized_position_policy(value: dict[str, Any]) -> dict[str, Any]: + exact_fields(value, POSITION_POLICY_FIELDS, "position policy") + if value["schema"] != POSITION_POLICY_SCHEMA: + raise BlockedError( + f"position policy schema must be {POSITION_POLICY_SCHEMA}" + ) + close_seconds = require_positive_integer( + value["close_liquidation_seconds_before_close"], + "close_liquidation_seconds_before_close", + ) + if close_seconds > 3600: + raise BlockedError( + "close_liquidation_seconds_before_close must be <= 3600" + ) + ttl = require_positive_integer( + value["exit_order_ttl_seconds"], "exit_order_ttl_seconds" + ) + if ttl > 300: + raise BlockedError("exit_order_ttl_seconds must be <= 300") + replacements = require_nonnegative_integer( + value["max_exit_replacements"], "max_exit_replacements" + ) + if replacements > 20: + raise BlockedError("max_exit_replacements must be <= 20") + quote_age = require_positive_integer( + value["quote_max_age_seconds"], "quote_max_age_seconds" + ) + if quote_age > 60: + raise BlockedError("quote_max_age_seconds must be <= 60") + expected_enums = { + "partial_entry_action": "CANCEL_REMAINDER_MANAGE_FILLED", + "gap_down_action": "MARKETABLE_LIMIT", + "daily_loss_action": "ENTRY_FREEZE_LIQUIDATE", + "overnight_residual_action": "EXIT_ONLY_NEXT_SESSION", + } + for field, expected in expected_enums.items(): + if value[field] != expected: + raise BlockedError(f"{field} must be {expected}") + return { + **value, + "close_liquidation_seconds_before_close": close_seconds, + "exit_order_ttl_seconds": ttl, + "max_exit_replacements": replacements, + "quote_max_age_seconds": quote_age, + } + + +def strategy_position_id(plan_hash: str, intent: dict[str, Any]) -> str: + if len(plan_hash) != 64 or any( + character not in "0123456789abcdef" for character in plan_hash + ): + raise BlockedError("plan_hash must be lowercase SHA-256") + seed = { + "plan_hash": plan_hash, + "entry_intent_id": intent.get("intent_id"), + "market": intent.get("market"), + "exchange": intent.get("exchange"), + "symbol": intent.get("symbol"), + "venue": intent.get("venue"), + } + return sha256_json(seed)[:32] + + +def position_from_intent( + *, + plan_hash: str, + intent: dict[str, Any], + session_date: str, + fx_to_krw: Any, +) -> dict[str, Any]: + session = require_iso_date(session_date, "session_date") + market = str(intent.get("market", "")).upper() + if market not in MARKET_EXCHANGES: + raise BlockedError("intent market must be KR or US") + exchange = str(intent.get("exchange", "")).upper() + if exchange not in MARKET_EXCHANGES[market]: + raise BlockedError("intent exchange does not match market") + symbol = normalize_symbol(market, str(intent.get("symbol", ""))) + venue = str(intent.get("venue", "")).upper() + if not venue: + raise BlockedError("intent venue is required") + quantity = positive_decimal(intent.get("quantity"), "intent quantity") + if quantity != quantity.to_integral_value(): + raise BlockedError("strategy positions require whole-share quantity") + currency = str(intent.get("currency", "")).upper() + if currency != MARKET_CURRENCY[market]: + raise BlockedError("intent currency does not match market") + fx = positive_decimal(fx_to_krw, "fx_to_krw") + stop = positive_decimal(intent.get("stop_price"), "stop_price") + take_profit = positive_decimal( + intent.get("take_profit_price"), "take_profit_price" + ) + tick_source = intent.get("resolved_tick_size") + if tick_source is None: + tick_source = intent.get("tick_size") + if tick_source is None: + raise BlockedError("intent resolved tick size is required") + tick = positive_decimal(tick_source, "tick_size") + entry_intent_id = str(intent.get("intent_id", "")) + if len(entry_intent_id) != 32: + raise BlockedError("entry intent ID must contain 32 characters") + output = { + "schema": STRATEGY_POSITION_SCHEMA, + "position_id": strategy_position_id(plan_hash, intent), + "plan_hash": plan_hash, + "entry_intent_id": entry_intent_id, + "market": market, + "exchange": exchange, + "symbol": symbol, + "venue": venue, + "currency": currency, + "entry_session_date": session, + "last_managed_session_date": session, + "planned_quantity": format(quantity, "f"), + "acquired_quantity": "0", + "exited_quantity": "0", + "average_entry_price": None, + "average_exit_price": None, + "stop_price": format(stop, "f"), + "take_profit_price": format(take_profit, "f"), + "tick_size": format(tick, "f"), + "fx_to_krw": format(fx, "f"), + "daily_baseline_session_date": None, + "daily_baseline_price": None, + "realized_pnl_native": "0", + "daily_realized_pnl_native": "0", + "status": "PENDING_ENTRY", + } + return normalize_strategy_position(output) + + +def normalize_strategy_position(value: dict[str, Any]) -> dict[str, Any]: + exact_fields(value, STRATEGY_POSITION_FIELDS, "strategy position") + if value["schema"] != STRATEGY_POSITION_SCHEMA: + raise BlockedError( + f"strategy position schema must be {STRATEGY_POSITION_SCHEMA}" + ) + position_id = str(value["position_id"]) + if len(position_id) != 32 or any( + character not in "0123456789abcdef" for character in position_id + ): + raise BlockedError("position_id must be 32 lowercase hex characters") + plan_hash = str(value["plan_hash"]) + if len(plan_hash) != 64 or any( + character not in "0123456789abcdef" for character in plan_hash + ): + raise BlockedError("plan_hash must be lowercase SHA-256") + market = str(value["market"]).upper() + exchange = str(value["exchange"]).upper() + if market not in MARKET_EXCHANGES or exchange not in MARKET_EXCHANGES[market]: + raise BlockedError("strategy position exchange does not match market") + symbol = normalize_symbol(market, str(value["symbol"])) + currency = str(value["currency"]).upper() + if currency != MARKET_CURRENCY[market]: + raise BlockedError("strategy position currency does not match market") + entry_date = require_iso_date( + value["entry_session_date"], "entry_session_date" + ) + managed_date = require_iso_date( + value["last_managed_session_date"], "last_managed_session_date" + ) + if managed_date < entry_date: + raise BlockedError("last managed session cannot precede entry session") + planned = positive_decimal(value["planned_quantity"], "planned_quantity") + acquired = decimal_value(value["acquired_quantity"], "acquired_quantity") + exited = decimal_value(value["exited_quantity"], "exited_quantity") + for amount, field in ( + (planned, "planned_quantity"), + (acquired, "acquired_quantity"), + (exited, "exited_quantity"), + ): + if amount < 0 or amount != amount.to_integral_value(): + raise BlockedError(f"{field} must be a nonnegative whole number") + if acquired > planned: + raise BlockedError("acquired quantity exceeds planned quantity") + if exited > acquired: + raise BlockedError("exited quantity exceeds acquired quantity") + average_entry = value["average_entry_price"] + if acquired > 0: + average_entry = format( + positive_decimal(average_entry, "average_entry_price"), "f" + ) + elif average_entry is not None: + raise BlockedError("zero acquired quantity cannot have an entry price") + average_exit = value["average_exit_price"] + if exited > 0: + average_exit = format( + positive_decimal(average_exit, "average_exit_price"), "f" + ) + elif average_exit is not None: + raise BlockedError("zero exited quantity cannot have an exit price") + baseline_date = value["daily_baseline_session_date"] + baseline_price = value["daily_baseline_price"] + if baseline_date is None: + if baseline_price is not None: + raise BlockedError("daily baseline date and price must both be null") + else: + baseline_date = require_iso_date( + baseline_date, "daily_baseline_session_date" + ) + if baseline_date != managed_date: + raise BlockedError("daily baseline must match last managed session") + baseline_price = format( + positive_decimal(baseline_price, "daily_baseline_price"), "f" + ) + stop = positive_decimal(value["stop_price"], "stop_price") + take_profit = positive_decimal(value["take_profit_price"], "take_profit_price") + if stop >= take_profit: + raise BlockedError("stop price must be below take-profit price") + tick = positive_decimal(value["tick_size"], "tick_size") + fx = positive_decimal(value["fx_to_krw"], "fx_to_krw") + realized = decimal_value(value["realized_pnl_native"], "realized_pnl_native") + daily_realized = decimal_value( + value["daily_realized_pnl_native"], "daily_realized_pnl_native" + ) + status = str(value["status"]) + if status not in POSITION_STATUSES: + raise BlockedError(f"unsupported strategy position status: {status}") + if acquired == 0 and status != "PENDING_ENTRY": + raise BlockedError("zero acquired quantity must be PENDING_ENTRY") + if acquired > 0 and exited == acquired and status != "CLOSED": + raise BlockedError("fully exited position must be CLOSED") + if acquired > exited and status in {"PENDING_ENTRY", "CLOSED"}: + raise BlockedError("open quantity requires an open position status") + return { + **value, + "market": market, + "exchange": exchange, + "symbol": symbol, + "currency": currency, + "entry_session_date": entry_date, + "last_managed_session_date": managed_date, + "planned_quantity": format(planned, "f"), + "acquired_quantity": format(acquired, "f"), + "exited_quantity": format(exited, "f"), + "average_entry_price": average_entry, + "average_exit_price": average_exit, + "stop_price": format(stop, "f"), + "take_profit_price": format(take_profit, "f"), + "tick_size": format(tick, "f"), + "fx_to_krw": format(fx, "f"), + "daily_baseline_session_date": baseline_date, + "daily_baseline_price": baseline_price, + "realized_pnl_native": format(realized, "f"), + "daily_realized_pnl_native": format(daily_realized, "f"), + "status": status, + } + + +def open_quantity(position: dict[str, Any]) -> Decimal: + normalized = normalize_strategy_position(position) + return Decimal(normalized["acquired_quantity"]) - Decimal( + normalized["exited_quantity"] + ) + + +def record_entry_fill( + position: dict[str, Any], + *, + cumulative_filled_quantity: Any, + average_fill_price: Any, + session_date: str, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + session = require_iso_date(session_date, "session_date") + cumulative = decimal_value( + cumulative_filled_quantity, "cumulative_filled_quantity" + ) + prior = Decimal(normalized["acquired_quantity"]) + planned = Decimal(normalized["planned_quantity"]) + if ( + cumulative < prior + or cumulative > planned + or cumulative != cumulative.to_integral_value() + ): + raise BlockedError("entry cumulative fill is non-monotonic or out of bounds") + if cumulative == 0: + return normalized + average = positive_decimal(average_fill_price, "average_fill_price") + if session < normalized["entry_session_date"]: + raise BlockedError("entry fill session precedes the planned entry session") + exited = Decimal(normalized["exited_quantity"]) + next_status = "CLOSED" if cumulative == exited else "OPEN" + output = { + **normalized, + "last_managed_session_date": session, + "acquired_quantity": format(cumulative, "f"), + "average_entry_price": format(average, "f"), + "daily_baseline_session_date": session, + "daily_baseline_price": format(average, "f"), + "status": next_status, + } + return normalize_strategy_position(output) + + +def roll_position_session( + position: dict[str, Any], + *, + session_date: str, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + session = require_iso_date(session_date, "session_date") + if session < normalized["last_managed_session_date"]: + raise BlockedError("cannot roll a position backward") + if session == normalized["last_managed_session_date"]: + return normalized + status = ( + "CLOSED" + if open_quantity(normalized) == 0 + else "CARRY_EXIT_ONLY" + ) + return normalize_strategy_position( + { + **normalized, + "last_managed_session_date": session, + "daily_baseline_session_date": None, + "daily_baseline_price": None, + "daily_realized_pnl_native": "0", + "status": status, + } + ) + + +def set_daily_baseline( + position: dict[str, Any], + *, + session_date: str, + price: Any, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + session = require_iso_date(session_date, "session_date") + if session != normalized["last_managed_session_date"]: + raise BlockedError("daily baseline session must be the managed session") + baseline = positive_decimal(price, "daily baseline price") + existing_date = normalized["daily_baseline_session_date"] + if existing_date is not None: + if ( + existing_date != session + or Decimal(str(normalized["daily_baseline_price"])) != baseline + ): + raise BlockedError("daily baseline is immutable within one session") + return normalized + return normalize_strategy_position( + { + **normalized, + "daily_baseline_session_date": session, + "daily_baseline_price": format(baseline, "f"), + } + ) + + +def record_exit_fill( + position: dict[str, Any], + *, + cumulative_exited_quantity: Any, + average_exit_price: Any, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + cumulative = decimal_value( + cumulative_exited_quantity, "cumulative_exited_quantity" + ) + prior = Decimal(normalized["exited_quantity"]) + acquired = Decimal(normalized["acquired_quantity"]) + if ( + cumulative < prior + or cumulative > acquired + or cumulative != cumulative.to_integral_value() + ): + raise BlockedError("exit cumulative fill is non-monotonic or out of bounds") + if cumulative == 0: + return normalized + average_exit = positive_decimal(average_exit_price, "average_exit_price") + entry = positive_decimal( + normalized["average_entry_price"], "average_entry_price" + ) + prior_average_exit = ( + Decimal(str(normalized["average_exit_price"])) + if prior > 0 + else Decimal(0) + ) + prior_exit_proceeds = prior_average_exit * prior + cumulative_exit_proceeds = average_exit * cumulative + incremental_quantity = cumulative - prior + incremental_exit_proceeds = cumulative_exit_proceeds - prior_exit_proceeds + realized = ( + Decimal(normalized["realized_pnl_native"]) + + incremental_exit_proceeds + - entry * incremental_quantity + ) + baseline_value = normalized["daily_baseline_price"] + if baseline_value is None: + raise BlockedError("daily baseline is required before recording an exit") + baseline = positive_decimal(baseline_value, "daily_baseline_price") + daily_realized = ( + Decimal(normalized["daily_realized_pnl_native"]) + + incremental_exit_proceeds + - baseline * incremental_quantity + ) + status = "CLOSED" if cumulative == acquired else "EXIT_PARTIAL" + return normalize_strategy_position( + { + **normalized, + "exited_quantity": format(cumulative, "f"), + "average_exit_price": format(average_exit, "f"), + "realized_pnl_native": format(realized, "f"), + "daily_realized_pnl_native": format(daily_realized, "f"), + "status": status, + } + ) + + +def partial_entry_requires_cancel( + position: dict[str, Any], + *, + broker_remaining_quantity: Any, +) -> bool: + normalized = normalize_strategy_position(position) + remaining = decimal_value( + broker_remaining_quantity, "broker_remaining_quantity" + ) + return Decimal(normalized["acquired_quantity"]) > 0 and remaining > 0 + + +def normalize_exit_quote( + quote: dict[str, Any], + *, + position: dict[str, Any], + observed_at: datetime, + max_age_seconds: int, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + if str(quote.get("market", "")).upper() != normalized["market"]: + raise BlockedError("exit quote market does not match position") + if normalize_symbol( + normalized["market"], str(quote.get("symbol", "")) + ) != normalized["symbol"]: + raise BlockedError("exit quote symbol does not match position") + last = positive_decimal(quote.get("last_price"), "exit quote last_price") + bid = positive_decimal(quote.get("best_bid"), "exit quote best_bid") + ask = positive_decimal(quote.get("best_ask"), "exit quote best_ask") + if bid > ask: + raise BlockedError("exit quote best bid exceeds best ask") + trade_time = parse_aware_datetime( + quote.get("trade_timestamp"), "exit quote trade_timestamp" + ) + book_time = parse_aware_datetime( + quote.get("book_timestamp"), "exit quote book_timestamp" + ) + received = parse_aware_datetime( + quote.get("received_at"), "exit quote received_at" + ) + now = observed_at.astimezone(timezone.utc) + for supplied, field in ( + (trade_time, "trade_timestamp"), + (book_time, "book_timestamp"), + (received, "received_at"), + ): + age = (now - supplied.astimezone(timezone.utc)).total_seconds() + if age < -1: + raise BlockedError(f"exit quote {field} is future-dated") + if age > max_age_seconds: + raise BlockedError(f"exit quote {field} is stale") + tick = Decimal(normalized["tick_size"]) + return { + "last_price": format(last, "f"), + "best_bid": format(bid, "f"), + "best_ask": format(ask, "f"), + "limit_price": format(round_down_to_tick(bid, tick), "f"), + "trade_timestamp": trade_time.isoformat(), + "book_timestamp": book_time.isoformat(), + "received_at": received.isoformat(), + } + + +def exit_decision( + position: dict[str, Any], + quote: dict[str, Any], + policy: dict[str, Any], + *, + observed_at: datetime, + regular_close: datetime, + first_valid_quote_of_session: bool, + daily_loss_breached: bool, +) -> dict[str, Any]: + normalized = normalize_strategy_position(position) + normalized_policy = normalized_position_policy(policy) + quantity = open_quantity(normalized) + if quantity <= 0: + action = "CLOSED" + reason = "NO_OPEN_QUANTITY" + normalized_quote = None + limit_price = None + else: + if observed_at.tzinfo is None or observed_at.utcoffset() is None: + raise BlockedError("observed_at must be timezone-aware") + if regular_close.tzinfo is None or regular_close.utcoffset() is None: + raise BlockedError("regular_close must be timezone-aware") + if observed_at >= regular_close: + action = "MANUAL_BLOCK" + reason = "MARKET_CLOSED_WITH_RESIDUAL" + normalized_quote = None + limit_price = None + else: + normalized_quote = normalize_exit_quote( + quote, + position=normalized, + observed_at=observed_at, + max_age_seconds=normalized_policy["quote_max_age_seconds"], + ) + last = Decimal(normalized_quote["last_price"]) + stop = Decimal(normalized["stop_price"]) + target = Decimal(normalized["take_profit_price"]) + close_trigger = regular_close - timedelta( + seconds=normalized_policy[ + "close_liquidation_seconds_before_close" + ] + ) + action = "HOLD" + reason = "NO_EXIT_TRIGGER" + if daily_loss_breached: + action = "SUBMIT_EXIT" + reason = "DAILY_LOSS_LIMIT" + elif last <= stop: + action = "SUBMIT_EXIT" + reason = ( + "STOP_GAP" + if first_valid_quote_of_session and last < stop + else "STOP" + ) + elif last >= target: + action = "SUBMIT_EXIT" + reason = "TAKE_PROFIT" + elif observed_at >= close_trigger: + action = "SUBMIT_EXIT" + reason = "MARKET_CLOSE" + limit_price = ( + normalized_quote["limit_price"] + if action == "SUBMIT_EXIT" + else None + ) + without_hash = { + "schema": EXIT_DECISION_SCHEMA, + "position_id": normalized["position_id"], + "session_date": normalized["last_managed_session_date"], + "observed_at": observed_at.isoformat(), + "action": action, + "reason": reason, + "quantity": format(max(quantity, Decimal(0)), "f"), + "order_type": "LIMIT" if action == "SUBMIT_EXIT" else None, + "limit_price": limit_price, + "quote": normalized_quote, + "daily_loss_breached": bool(daily_loss_breached), + } + return {**without_hash, "decision_hash": sha256_json(without_hash)} + + +def marked_daily_pnl_krw( + position: dict[str, Any], + *, + best_bid: Any, + estimated_exit_cost_bps: Any, +) -> Decimal: + normalized = normalize_strategy_position(position) + if normalized["daily_baseline_price"] is None: + raise BlockedError("daily baseline is required for daily P&L") + bid = positive_decimal(best_bid, "best_bid") + cost_bps = decimal_value(estimated_exit_cost_bps, "estimated_exit_cost_bps") + if cost_bps < 0 or cost_bps > 10000: + raise BlockedError("estimated_exit_cost_bps must be between 0 and 10000") + quantity = open_quantity(normalized) + baseline = Decimal(str(normalized["daily_baseline_price"])) + daily_realized = Decimal(normalized["daily_realized_pnl_native"]) + exit_cost = bid * quantity * cost_bps / Decimal(10000) + pnl_native = daily_realized + (bid - baseline) * quantity - exit_cost + return pnl_native * Decimal(normalized["fx_to_krw"]) + + +def portfolio_daily_loss_krw( + marked_pnl_by_position: dict[str, Decimal], +) -> Decimal: + pnl = sum(marked_pnl_by_position.values(), Decimal(0)) + return max(-pnl, Decimal(0)) + + +def daily_loss_limit_breached(loss_krw: Any, maximum_loss_krw: Any) -> bool: + loss = decimal_value(loss_krw, "loss_krw") + maximum = positive_decimal(maximum_loss_krw, "maximum_loss_krw") + if loss < 0: + raise BlockedError("loss_krw must be nonnegative") + return loss >= maximum + + +def exit_order_id( + decision: dict[str, Any], + *, + attempt: int, +) -> str: + attempt_number = require_nonnegative_integer(attempt, "exit attempt") + if decision.get("schema") != EXIT_DECISION_SCHEMA: + raise BlockedError("exit decision schema is invalid") + if decision.get("action") != "SUBMIT_EXIT": + raise BlockedError("exit order requires a SUBMIT_EXIT decision") + seed = { + "position_id": decision["position_id"], + "session_date": decision["session_date"], + "reason": decision["reason"], + "quantity": decision["quantity"], + "limit_price": decision["limit_price"], + "attempt": attempt_number, + } + return sha256_json(seed)[:32] + + +@dataclass(frozen=True) +class StoredPosition: + position_id: str + status: str + payload: dict[str, Any] + + +class PositionLedger: + """Account/market-level position registry that survives session dates.""" + + def __init__(self, path: str | Path): + self.path = Path(path) + self.path.parent.mkdir(parents=True, exist_ok=True) + self.connection = sqlite3.connect(str(self.path)) + self.connection.execute("PRAGMA journal_mode=WAL") + self.connection.execute("PRAGMA synchronous=FULL") + self.connection.executescript( + """ + CREATE TABLE IF NOT EXISTS strategy_positions ( + position_id TEXT PRIMARY KEY, + status TEXT NOT NULL, + payload_json TEXT NOT NULL, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS position_events ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + position_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + """ + ) + self.connection.commit() + + def close(self) -> None: + self.connection.close() + + def put( + self, + position: dict[str, Any], + *, + event_type: str, + event_payload: dict[str, Any], + ) -> None: + normalized = normalize_strategy_position(position) + if not event_type: + raise BlockedError("position event type is required") + if not isinstance(event_payload, dict): + raise BlockedError("position event payload must be an object") + now = datetime.now(timezone.utc).isoformat() + rendered = canonical_json(normalized) + self.connection.execute("BEGIN IMMEDIATE") + try: + existing = self.connection.execute( + """ + SELECT payload_json FROM strategy_positions + WHERE position_id = ? + """, + (normalized["position_id"],), + ).fetchone() + if existing is None: + self.connection.execute( + """ + INSERT INTO strategy_positions + (position_id, status, payload_json, updated_at) + VALUES (?, ?, ?, ?) + """, + ( + normalized["position_id"], + normalized["status"], + rendered, + now, + ), + ) + else: + self.connection.execute( + """ + UPDATE strategy_positions + SET status = ?, payload_json = ?, updated_at = ? + WHERE position_id = ? + """, + ( + normalized["status"], + rendered, + now, + normalized["position_id"], + ), + ) + self.connection.execute( + """ + INSERT INTO position_events + (position_id, event_type, payload_json, created_at) + VALUES (?, ?, ?, ?) + """, + ( + normalized["position_id"], + event_type, + canonical_json(event_payload), + now, + ), + ) + except BaseException: + self.connection.rollback() + raise + else: + self.connection.commit() + + def get(self, position_id: str) -> StoredPosition: + row = self.connection.execute( + """ + SELECT position_id, status, payload_json + FROM strategy_positions WHERE position_id = ? + """, + (position_id,), + ).fetchone() + if row is None: + raise BlockedError(f"unknown strategy position: {position_id}") + return StoredPosition( + position_id=str(row[0]), + status=str(row[1]), + payload=normalize_strategy_position(json.loads(row[2])), + ) + + def get_optional(self, position_id: str) -> StoredPosition | None: + row = self.connection.execute( + """ + SELECT position_id, status, payload_json + FROM strategy_positions WHERE position_id = ? + """, + (position_id,), + ).fetchone() + if row is None: + return None + return StoredPosition( + position_id=str(row[0]), + status=str(row[1]), + payload=normalize_strategy_position(json.loads(row[2])), + ) + + def open_positions(self) -> list[StoredPosition]: + rows = self.connection.execute( + """ + SELECT position_id, status, payload_json + FROM strategy_positions + WHERE status != 'CLOSED' + ORDER BY position_id + """ + ).fetchall() + return [ + StoredPosition( + position_id=str(row[0]), + status=str(row[1]), + payload=normalize_strategy_position(json.loads(row[2])), + ) + for row in rows + ] + + def events(self, position_id: str) -> list[dict[str, Any]]: + rows = self.connection.execute( + """ + SELECT sequence, event_type, payload_json, created_at + FROM position_events + WHERE position_id = ? + ORDER BY sequence + """, + (position_id,), + ).fetchall() + return [ + { + "sequence": int(row[0]), + "event_type": str(row[1]), + "payload": json.loads(row[2]), + "created_at": str(row[3]), + } + for row in rows + ] + + +def reconcile_account_positions( + ledger: PositionLedger, + *, + account_snapshot: dict[str, Any], + session_date: str, +) -> dict[str, Any]: + """Match bot-owned positions to the broker snapshot and roll valid carries.""" + session = require_iso_date(session_date, "session_date") + account = normalized_account_snapshot( + account_snapshot, + screen_schema=SCREEN_SCHEMA_V2, + ) + market = account["market"] + account_quantities: dict[tuple[str, str], Decimal] = {} + for item in account["positions"]: + key = (str(item["exchange"]), str(item["symbol"])) + quantity = decimal_value(item.get("quantity"), "account position quantity") + if quantity < 0: + raise BlockedError("account position quantity must be nonnegative") + account_quantities[key] = account_quantities.get(key, Decimal(0)) + quantity + open_order_keys = { + (str(item["exchange"]), str(item["symbol"])) + for item in account["open_orders"] + } + + stored_positions = [ + item for item in ledger.open_positions() if item.payload["market"] == market + ] + by_key: dict[tuple[str, str], list[StoredPosition]] = {} + for stored in stored_positions: + key = (stored.payload["exchange"], stored.payload["symbol"]) + by_key.setdefault(key, []).append(stored) + + results: list[dict[str, Any]] = [] + managed_keys: set[tuple[str, str]] = set() + for key in sorted(by_key): + positions = by_key[key] + managed_keys.add(key) + expected = sum( + (open_quantity(item.payload) for item in positions), Decimal(0) + ) + observed = account_quantities.get(key, Decimal(0)) + reasons: list[str] = [] + if len(positions) != 1: + reasons.append("duplicate_strategy_positions_for_symbol") + if observed != expected: + reasons.append("broker_quantity_differs_from_strategy_ledger") + if key in open_order_keys: + reasons.append("broker_has_open_order_for_strategy_symbol") + if any(item.status == "MANUAL_BLOCK" for item in positions): + reasons.append("prior_manual_block_requires_explicit_repair") + + if reasons: + for stored in positions: + if stored.status != "MANUAL_BLOCK": + blocked_position = normalize_strategy_position( + {**stored.payload, "status": "MANUAL_BLOCK"} + ) + ledger.put( + blocked_position, + event_type="ACCOUNT_RECONCILIATION_BLOCKED", + event_payload={ + "session_date": session, + "expected_quantity": format(expected, "f"), + "observed_quantity": format(observed, "f"), + "reasons": reasons, + }, + ) + state = "MANUAL_BLOCK" + else: + stored = positions[0] + rolled = roll_position_session( + stored.payload, + session_date=session, + ) + if rolled != stored.payload: + ledger.put( + rolled, + event_type="POSITION_CARRIED", + event_payload={ + "from_session_date": stored.payload[ + "last_managed_session_date" + ], + "to_session_date": session, + "quantity": format(expected, "f"), + }, + ) + state = rolled["status"] + results.append( + { + "exchange": key[0], + "symbol": key[1], + "strategy_position_ids": [ + item.position_id for item in positions + ], + "expected_quantity": format(expected, "f"), + "observed_quantity": format(observed, "f"), + "state": state, + "reasons": reasons, + } + ) + + unmanaged = [ + { + "exchange": key[0], + "symbol": key[1], + "quantity": format(quantity, "f"), + } + for key, quantity in sorted(account_quantities.items()) + if key not in managed_keys and quantity > 0 + ] + without_hash = { + "schema": POSITION_RECONCILIATION_SCHEMA, + "account_as_of": account["as_of"], + "account_alias": account["account_alias"], + "market": market, + "session_date": session, + "status": ( + "MANUAL_BLOCK" + if any(item["state"] == "MANUAL_BLOCK" for item in results) + else "READY" + ), + "managed_positions": results, + "unmanaged_broker_positions": unmanaged, + "api_mutation_count": 0, + "live_enabled": False, + } + return { + **without_hash, + "reconciliation_hash": sha256_json(without_hash), + } + + +def atomic_write_json(path: Path, value: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{path.name}.", dir=path.parent + ) + temporary = Path(temporary_name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + handle.write(json.dumps(value, sort_keys=True, separators=(",", ":"))) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + except BaseException: + temporary.unlink(missing_ok=True) + raise + + +def self_test() -> None: + plan_hash = "a" * 64 + intent = { + "intent_id": "b" * 32, + "market": "KR", + "exchange": "KOSPI", + "symbol": "005930", + "venue": "KRX", + "currency": "KRW", + "quantity": "10", + "stop_price": "65000", + "take_profit_price": "80000", + "resolved_tick_size": "100", + } + policy = normalized_position_policy( + { + "schema": POSITION_POLICY_SCHEMA, + "close_liquidation_seconds_before_close": 600, + "exit_order_ttl_seconds": 15, + "max_exit_replacements": 3, + "quote_max_age_seconds": 5, + "partial_entry_action": "CANCEL_REMAINDER_MANAGE_FILLED", + "gap_down_action": "MARKETABLE_LIMIT", + "daily_loss_action": "ENTRY_FREEZE_LIQUIDATE", + "overnight_residual_action": "EXIT_ONLY_NEXT_SESSION", + } + ) + position = position_from_intent( + plan_hash=plan_hash, + intent=intent, + session_date="2026-07-29", + fx_to_krw="1", + ) + position = record_entry_fill( + position, + cumulative_filled_quantity="4", + average_fill_price="70000", + session_date="2026-07-29", + ) + assert partial_entry_requires_cancel( + position, broker_remaining_quantity="6" + ) + now = datetime(2026, 7, 29, 9, 1, tzinfo=timezone(timedelta(hours=9))) + quote = { + "market": "KR", + "symbol": "005930", + "last_price": "64000", + "best_bid": "63900", + "best_ask": "64000", + "trade_timestamp": now.isoformat(), + "book_timestamp": now.isoformat(), + "received_at": now.isoformat(), + } + decision = exit_decision( + position, + quote, + policy, + observed_at=now, + regular_close=datetime( + 2026, 7, 29, 15, 30, tzinfo=timezone(timedelta(hours=9)) + ), + first_valid_quote_of_session=True, + daily_loss_breached=False, + ) + assert decision["reason"] == "STOP_GAP" + assert decision["quantity"] == "4" + assert decision["limit_price"] == "63900" + assert len(exit_order_id(decision, attempt=0)) == 32 + assert marked_daily_pnl_krw( + position, + best_bid="64000", + estimated_exit_cost_bps="30", + ) < 0 + position = record_exit_fill( + position, + cumulative_exited_quantity="2", + average_exit_price="64000", + ) + assert position["status"] == "EXIT_PARTIAL" + assert open_quantity(position) == Decimal(2) + carried = roll_position_session(position, session_date="2026-07-30") + assert carried["status"] == "CARRY_EXIT_ONLY" + with tempfile.TemporaryDirectory(prefix="qta-position-ledger-") as temporary: + ledger = PositionLedger(Path(temporary) / "positions.sqlite3") + try: + ledger.put( + carried, + event_type="POSITION_CARRIED", + event_payload={"session_date": "2026-07-30"}, + ) + assert ledger.open_positions()[0].status == "CARRY_EXIT_ONLY" + assert ledger.events(carried["position_id"])[0]["event_type"] == ( + "POSITION_CARRIED" + ) + finally: + ledger.close() + print( + json.dumps( + { + "schema": STRATEGY_POSITION_SCHEMA, + "self_test": "PASS", + "decision_hash": decision["decision_hash"], + "live_enabled": False, + "api_mutation_count": 0, + }, + sort_keys=True, + ) + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--self-test", action="store_true") + subparsers = parser.add_subparsers(dest="command") + reconcile_parser = subparsers.add_parser("reconcile-account") + reconcile_parser.add_argument("--ledger", required=True) + reconcile_parser.add_argument("--account-snapshot", required=True) + reconcile_parser.add_argument("--session-date", required=True) + reconcile_parser.add_argument("--output", required=True) + args = parser.parse_args(argv) + try: + if args.self_test: + self_test() + return 0 + if args.command != "reconcile-account": + parser.error("use --self-test or reconcile-account") + paths = { + name: Path(getattr(args, name)) + for name in ("ledger", "account_snapshot", "output") + } + if any(not path.is_absolute() for path in paths.values()): + raise BlockedError("ledger, account snapshot, and output paths must be absolute") + if ( + not paths["account_snapshot"].is_file() + or paths["account_snapshot"].is_symlink() + ): + raise BlockedError( + "account snapshot must be a regular non-symlink file" + ) + account_value = json.loads( + paths["account_snapshot"].read_text(encoding="utf-8") + ) + if not isinstance(account_value, dict): + raise BlockedError("account snapshot must contain one JSON object") + ledger = PositionLedger(paths["ledger"]) + try: + receipt = reconcile_account_positions( + ledger, + account_snapshot=account_value, + session_date=args.session_date, + ) + finally: + ledger.close() + atomic_write_json(paths["output"], receipt) + print(json.dumps(receipt, sort_keys=True)) + return 0 + except ( + AssertionError, + BlockedError, + ValueError, + OSError, + json.JSONDecodeError, + ) as exc: + print( + json.dumps( + { + "status": "BLOCKED", + "reason": str(exc), + "api_mutation_count": 0, + "live_enabled": False, + }, + sort_keys=True, + ) + ) + return 2 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/quant-stock-polling-trader/scripts/run_session.py b/skills/quant-stock-polling-trader/scripts/run_session.py index f8ff63b..1a500b3 100755 --- a/skills/quant-stock-polling-trader/scripts/run_session.py +++ b/skills/quant-stock-polling-trader/scripts/run_session.py @@ -28,6 +28,10 @@ MARKET_CURRENCY, MAX_SNAPSHOT_AGE_SECONDS, PLAN_SCHEMA, + QTA2_REGIME_MAX_AGE_SECONDS, + QTA2_REGIME_MINIMUM_CHANGE_BPS, + QTA2_REGIME_METRIC, + QTA_METHOD_V2, SCREEN_SCHEMA_V2, V2_EXCHANGE_CONTRACTS, V2_EXCHANGES_BY_MARKET, @@ -52,6 +56,13 @@ validate_nonempty_string, validate_sha256, ) +from position_lifecycle import ( + PositionLedger, + position_from_intent, + reconcile_account_positions, + record_entry_fill, + strategy_position_id, +) VENUE_SCHEMA = "qta-venue-map/v1" SESSION_STATUS_SCHEMA = "qta-session-status/v2" @@ -157,6 +168,7 @@ "instrument_type", "benchmark_id", "venue", + "resolved_tick_size", "tick_contract_hash", } SKIP_REASONS = { @@ -166,6 +178,26 @@ "max_concurrent_positions", "quantity_below_one", } +MARKET_REGIME_QUOTE_FIELDS = { + "schema", + "broker", + "exchange", + "benchmark_id", + "regime_proxy_id", + "provider_symbol", + "current", + "previous_close", + "change_bps", + "source_timestamp", + "received_at", + "raw_status", +} +REGIME_PROXY_BY_EXCHANGE = { + "KOSPI": "KOSPI_COMPOSITE", + "KOSDAQ": "KOSDAQ_COMPOSITE", + "NYSE": "S&P_500", + "NASDAQ": "NASDAQ_COMPOSITE", +} def validate_positive_integer(value: Any, field: str) -> int: @@ -332,6 +364,21 @@ def validate_intent( intent["tick_contract_hash"], f"{label}.tick_contract_hash", ) + tick_size = validate_decimal_text( + intent["resolved_tick_size"], + f"{label}.resolved_tick_size", + positive=True, + ) + for price, field in ( + (entry, "entry_trigger"), + (limit_price, "limit_price"), + (stop, "stop_price"), + (take_profit, "take_profit_price"), + ): + if price % tick_size != 0: + raise BlockedError( + f"{label}.{field} must align to resolved_tick_size" + ) intent_seed.update( { "exchange": exchange, @@ -880,7 +927,7 @@ def normalize_toss_order(raw: dict[str, Any]) -> dict[str, Any]: "normalized_status": status_map.get(raw_status, "UNKNOWN"), "raw_status": raw_status, "filled_quantity": execution.get("filledQuantity"), - "average_filled_price": execution.get("averageFilledPrice"), + "average_fill_price": execution.get("averageFilledPrice"), "raw": redact(raw), } @@ -984,6 +1031,11 @@ def create_broker(broker_name: str) -> Any: def validate_mode(plan: dict[str, Any], broker_name: str, mode: str) -> None: validate_broker_binding(plan, broker_name) context = plan["context"] + screen_method = plan["frozen_inputs"]["screen"].get("method_version") + if screen_method == "qta-2.0.0" and mode != "shadow": + raise BlockedError( + "qta-2.0.0 is RESEARCH_ONLY and may run only in shadow mode" + ) if mode == "live": raise BlockedError( "live promotion is intentionally disabled until accepted-timeout " @@ -1051,6 +1103,33 @@ def quote_http_request_count( return DEFAULT_QUOTE_HTTP_REQUESTS_PER_INTENT +def plan_uses_qta2(plan: dict[str, Any]) -> bool: + frozen_inputs = plan.get("frozen_inputs") + if not isinstance(frozen_inputs, dict): + return False + screen = frozen_inputs.get("screen") + return ( + isinstance(screen, dict) + and screen.get("method_version") == QTA_METHOD_V2 + ) + + +def active_qta2_exchanges(plan: dict[str, Any]) -> list[str]: + if not plan_uses_qta2(plan): + return [] + present = { + str(intent.get("exchange") or "").upper() + for intent in plan.get("intents", []) + if isinstance(intent, dict) + } + market = str(plan.get("context", {}).get("market") or "").upper() + return [ + exchange + for exchange in V2_EXCHANGES_BY_MARKET.get(market, ()) + if exchange in present + ] + + def polling_admission( plan: dict[str, Any], broker_name: str, @@ -1063,8 +1142,9 @@ def polling_admission( quote_requests = sum( quote_http_request_count(broker_name, intent) for intent in plan["intents"] ) + regime_requests = len(active_qta2_exchanges(plan)) mutation_requests = intent_count if mode == "paper" else 0 - request_budget = quote_requests + mutation_requests + request_budget = quote_requests + regime_requests + mutation_requests pacing_seconds = BROKER_PACING_SECONDS[broker_name] * request_budget minimum_interval = max( 1, @@ -1083,6 +1163,7 @@ def polling_admission( "quote_request_contract": ( "KIS-live US=1; KIS KR and Toss=2 per waiting intent" ), + "market_regime_request_budget": regime_requests, "worst_case_mutation_requests": mutation_requests, "worst_case_request_budget": request_budget, "minimum_request_interval_ms": int(BROKER_PACING_SECONDS[broker_name] * 1000), @@ -1095,6 +1176,104 @@ def polling_admission( } +def evaluate_market_regime( + *, + exchange: str, + quote: dict[str, Any], + observed_at: datetime, + session_date: str, +) -> dict[str, Any]: + """Validate and deterministically admit one QTA2 exchange regime quote.""" + exchange = str(exchange).upper() + if exchange not in V2_EXCHANGE_CONTRACTS: + raise BlockedError(f"unsupported market regime exchange: {exchange}") + if observed_at.tzinfo is None or observed_at.utcoffset() is None: + raise BlockedError("market regime observed_at must be timezone-aware") + exact_fields(quote, MARKET_REGIME_QUOTE_FIELDS, "market regime quote") + reasons: list[str] = [] + expected_benchmark = V2_EXCHANGE_CONTRACTS[exchange]["benchmark_id"] + if quote["schema"] != "qta-market-regime-quote/v1": + reasons.append("invalid_schema") + if quote["broker"] != "kis": + reasons.append("invalid_broker") + if quote["exchange"] != exchange: + reasons.append("exchange_mismatch") + if quote["benchmark_id"] != expected_benchmark: + reasons.append("benchmark_mismatch") + if quote["regime_proxy_id"] != REGIME_PROXY_BY_EXCHANGE[exchange]: + reasons.append("proxy_mismatch") + if quote["raw_status"] != "OK": + reasons.append("raw_status_not_ok") + + current = positive_decimal(quote["current"], "market regime current") + previous_close = positive_decimal( + quote["previous_close"], + "market regime previous_close", + ) + change_bps = decimal_value( + quote["change_bps"], + "market regime change_bps", + ) + recomputed_change = ( + (current / previous_close - Decimal(1)) * Decimal(10000) + ) + if abs(change_bps - recomputed_change) > Decimal("0.02"): + reasons.append("change_bps_mismatch") + + zone_name = ( + "Asia/Seoul" + if exchange in {"KOSPI", "KOSDAQ"} + else "America/New_York" + ) + for timestamp_field in ("source_timestamp", "received_at"): + try: + timestamp = parse_aware_datetime( + quote[timestamp_field], + f"market regime {timestamp_field}", + ) + except BlockedError: + reasons.append(f"{timestamp_field}_invalid") + continue + age_seconds = ( + observed_at.astimezone(timezone.utc) + - timestamp.astimezone(timezone.utc) + ).total_seconds() + if age_seconds < -1 or age_seconds > QTA2_REGIME_MAX_AGE_SECONDS: + reasons.append(f"{timestamp_field}_stale") + if ( + timestamp_field == "source_timestamp" + and timestamp.astimezone(ZoneInfo(zone_name)).date().isoformat() + != session_date + ): + reasons.append("source_session_date_mismatch") + + quality_valid = not reasons + admitted = ( + quality_valid + and change_bps >= QTA2_REGIME_MINIMUM_CHANGE_BPS + ) + if quality_valid and not admitted: + reasons.append("benchmark_below_minimum") + without_hash = { + "metric": QTA2_REGIME_METRIC, + "exchange": exchange, + "benchmark_id": expected_benchmark, + "regime_proxy_id": quote["regime_proxy_id"], + "minimum_change_bps": format( + QTA2_REGIME_MINIMUM_CHANGE_BPS, + "f", + ), + "observed_change_bps": format(change_bps, "f"), + "max_age_seconds": QTA2_REGIME_MAX_AGE_SECONDS, + "quality_valid": quality_valid, + "admitted": admitted, + "reasons": reasons, + "quote": quote, + "observed_at": observed_at.isoformat(), + } + return {**without_hash, "decision_hash": sha256_json(without_hash)} + + def wait_until(target: datetime) -> None: while True: remaining = target.timestamp() - datetime.now(timezone.utc).timestamp() @@ -1270,6 +1449,102 @@ def validate_ledger_scope(ledger: Ledger, plan: dict[str, Any]) -> None: ) +def position_ledger_path(state_directory: Path) -> Path: + """Return the account/market ledger shared by all session-date directories.""" + if not state_directory.is_absolute(): + raise BlockedError("state_directory must be absolute") + return state_directory.parent / "positions.sqlite3" + + +def persist_entry_fill( + position_ledger: PositionLedger, + *, + plan: dict[str, Any], + intent: dict[str, Any], + snapshot: dict[str, Any], + session_date: str, +) -> dict[str, Any] | None: + """Persist an authoritative cumulative entry fill exactly once per change.""" + raw_filled = snapshot.get("filled_quantity") + if raw_filled in (None, ""): + return None + cumulative = decimal_value(raw_filled, "broker filled_quantity") + if cumulative < 0 or cumulative != cumulative.to_integral_value(): + raise BlockedError( + "broker filled_quantity must be a nonnegative whole number" + ) + if cumulative == 0: + return None + raw_average = snapshot.get("average_fill_price") + if raw_average in (None, ""): + raise BlockedError( + "broker reported an entry fill without average_fill_price" + ) + average = positive_decimal(raw_average, "broker average_fill_price") + account = plan["frozen_inputs"]["account"] + fx_to_krw = account.get("fx_to_krw") + if intent["market"] == "KR": + fx_to_krw = "1" + elif fx_to_krw in (None, ""): + raise BlockedError( + "a U.S. strategy position requires frozen fx_to_krw" + ) + + position_id = strategy_position_id(plan["plan_hash"], intent) + stored = position_ledger.get_optional(position_id) + if stored is None: + position = position_from_intent( + plan_hash=plan["plan_hash"], + intent=intent, + session_date=session_date, + fx_to_krw=fx_to_krw, + ) + event_type = "ENTRY_FILL_CREATED" + else: + position = stored.payload + if ( + position["plan_hash"] != plan["plan_hash"] + or position["entry_intent_id"] != intent["intent_id"] + ): + raise BlockedError( + "strategy position identity differs from plan entry intent" + ) + prior_quantity = Decimal(position["acquired_quantity"]) + prior_average = ( + None + if position["average_entry_price"] is None + else Decimal(position["average_entry_price"]) + ) + if cumulative == prior_quantity and average == prior_average: + return position + if Decimal(position["exited_quantity"]) > 0: + raise BlockedError( + "entry fill changed after exit fills were recorded" + ) + event_type = "ENTRY_FILL_UPDATED" + + updated = record_entry_fill( + position, + cumulative_filled_quantity=format(cumulative, "f"), + average_fill_price=format(average, "f"), + session_date=session_date, + ) + position_ledger.put( + updated, + event_type=event_type, + event_payload={ + "broker_order_id": snapshot.get("broker_order_id"), + "normalized_status": snapshot.get("normalized_status"), + "ordered_quantity": snapshot.get("ordered_quantity"), + "filled_quantity": format(cumulative, "f"), + "average_fill_price": format(average, "f"), + "remaining_quantity": snapshot.get("remaining_quantity"), + "session_date": session_date, + }, + ) + return updated + + def run_session( plan: dict[str, Any], broker_name: str, @@ -1324,20 +1599,48 @@ def run_session( "cycles_completed": 0, "cycles_skipped": 0, "quotes_evaluated": 0, + "market_regime_queries": 0, + "market_regime_passes": 0, + "market_regime_blocks": 0, "order_status_queries": 0, "submits_started": 0, "expected_http_requests_started": 0, "max_schedule_lateness_ms": 0, "max_cycle_duration_ms": 0, "max_quote_latency_ms": 0, + "max_market_regime_latency_ms": 0, "max_order_status_latency_ms": 0, "max_submit_latency_ms": 0, + "position_reconciliation": None, } with SingleWriterLock(state_directory / "writer.lock"): ledger = Ledger(state_directory / "ledger.sqlite3") + position_ledger = PositionLedger(position_ledger_path(state_directory)) try: validate_ledger_scope(ledger, plan) + position_reconciliation = reconcile_account_positions( + position_ledger, + account_snapshot=plan["frozen_inputs"]["account"], + session_date=trading_date, + ) + polling_metrics["position_reconciliation"] = { + "status": position_reconciliation["status"], + "reconciliation_hash": position_reconciliation[ + "reconciliation_hash" + ], + "managed_position_count": len( + position_reconciliation["managed_positions"] + ), + "unmanaged_broker_position_count": len( + position_reconciliation["unmanaged_broker_positions"] + ), + } + if position_reconciliation["status"] != "READY": + raise BlockedError( + "strategy position ledger differs from the frozen broker " + "account snapshot; reconcile MANUAL_BLOCK before new entries" + ) warmup = warm_broker_before_open(broker, start) for intent in plan["intents"]: ledger.create_intent(plan["plan_hash"], intent) @@ -1417,6 +1720,86 @@ def run_session( }, ) ] + regime_by_exchange: dict[str, dict[str, Any]] = {} + if plan_uses_qta2(plan): + waiting_by_exchange: dict[str, list[dict[str, Any]]] = {} + for candidate in plan["intents"]: + if ledger.get(candidate["intent_id"]).state == "WAIT_TRIGGER": + waiting_by_exchange.setdefault( + candidate["exchange"], + [], + ).append(candidate) + for exchange in active_qta2_exchanges(plan): + waiting = waiting_by_exchange.get(exchange, []) + if not waiting: + continue + if datetime.now(timezone.utc).timestamp() >= cycle_deadline: + freeze_reason = "cycle_latency_budget_exceeded" + break + regime_started_at = datetime.now(timezone.utc) + regime_started_monotonic = time.monotonic() + polling_metrics["market_regime_queries"] += 1 + polling_metrics["expected_http_requests_started"] += 1 + try: + regime_quote = broker.benchmark_quote( + exchange=exchange, + session_date=trading_date, + ) + regime_observed_at = datetime.now(timezone.utc) + regime_decision = evaluate_market_regime( + exchange=exchange, + quote=regime_quote, + observed_at=regime_observed_at, + session_date=trading_date, + ) + except (BlockedError, TransportFailure) as exc: + regime_decision = { + "metric": QTA2_REGIME_METRIC, + "exchange": exchange, + "benchmark_id": V2_EXCHANGE_CONTRACTS[exchange][ + "benchmark_id" + ], + "minimum_change_bps": format( + QTA2_REGIME_MINIMUM_CHANGE_BPS, + "f", + ), + "max_age_seconds": QTA2_REGIME_MAX_AGE_SECONDS, + "quality_valid": False, + "admitted": False, + "reasons": ["benchmark_quote_unavailable"], + "error": str(exc), + "observed_at": datetime.now( + timezone.utc + ).isoformat(), + } + regime_latency_ms = int( + (time.monotonic() - regime_started_monotonic) * 1000 + ) + regime_decision["latency_ms"] = regime_latency_ms + regime_decision["started_at"] = ( + regime_started_at.isoformat() + ) + regime_by_exchange[exchange] = regime_decision + polling_metrics["max_market_regime_latency_ms"] = max( + polling_metrics["max_market_regime_latency_ms"], + regime_latency_ms, + ) + metric = ( + "market_regime_passes" + if regime_decision["admitted"] + else "market_regime_blocks" + ) + polling_metrics[metric] += 1 + cycle_events.append( + ( + waiting[0]["intent_id"], + "MARKET_REGIME_EVALUATED", + regime_decision, + ) + ) + if freeze_reason == "cycle_latency_budget_exceeded": + ledger.append_events(cycle_events) + break for intent in plan["intents"]: if datetime.now(timezone.utc).timestamp() >= cycle_deadline: freeze_reason = "cycle_latency_budget_exceeded" @@ -1424,6 +1807,25 @@ def run_session( record = ledger.get(intent["intent_id"]) venue = venue_by_intent[intent["intent_id"]] if record.state == "WAIT_TRIGGER": + if plan_uses_qta2(plan): + regime_decision = regime_by_exchange.get( + intent["exchange"] + ) + if ( + regime_decision is None + or not regime_decision["admitted"] + ): + cycle_events.append( + ( + intent["intent_id"], + "ENTRY_BLOCKED_BY_MARKET_REGIME", + { + "exchange": intent["exchange"], + "decision": regime_decision, + }, + ) + ) + continue if not entry_window_open(end): freeze_reason = "entry_window_closed" break @@ -1721,6 +2123,28 @@ def run_session( status_latency_ms, ) next_state = snapshot["normalized_status"] + try: + persist_entry_fill( + position_ledger, + plan=plan, + intent=intent, + snapshot=snapshot, + session_date=trading_date, + ) + except BlockedError as exc: + ledger.transition( + intent["intent_id"], + "UNKNOWN", + { + "reason": ( + "strategy position ledger sync failed: " + f"{exc}" + ), + "snapshot": snapshot, + }, + ) + freeze_reason = "position_ledger_sync_failed" + break if next_state != record.state: if next_state not in { "ACKNOWLEDGED", @@ -1769,6 +2193,21 @@ def run_session( break cycle += 1 + for intent in plan["intents"]: + record = ledger.get(intent["intent_id"]) + if record.state == "WAIT_TRIGGER": + ledger.transition( + intent["intent_id"], + "CANCELLED", + { + "reason": ( + "entry_window_closed" + if freeze_reason == "entry_window_closed" + else str(freeze_reason or "entry_window_ended") + ) + }, + ) + if plan["order_policy"]["cancel_remainder_at_window_end"]: for intent in plan["intents"]: record = ledger.get(intent["intent_id"]) @@ -1798,6 +2237,13 @@ def run_session( venue=venue_by_intent[intent["intent_id"]], trading_date=trading_date, ) + persist_entry_fill( + position_ledger, + plan=plan, + intent=intent, + snapshot=cancel_snapshot, + session_date=trading_date, + ) next_state = cancel_snapshot["normalized_status"] if next_state in {"FILLED", "CANCELLED"} or ( next_state == "REJECTED" @@ -1883,6 +2329,7 @@ def run_session( polling_metrics, ) finally: + position_ledger.close() ledger.close() diff --git a/skills/quant-stock-technical/SKILL.md b/skills/quant-stock-technical/SKILL.md index d241206..8f312cb 100644 --- a/skills/quant-stock-technical/SKILL.md +++ b/skills/quant-stock-technical/SKILL.md @@ -62,7 +62,8 @@ invalid cutoff row remains blocking. ## Workflow 1. Read `references/methodology.md` when explaining, reviewing, or changing the model. -2. Run `scripts/analyze_stock.py` with the user-supplied files and identifiers. +2. Run `scripts/analyze_stock.py` for the stable QTA1 baseline. Use + `scripts/analyze_stock_v2.py` only for first-hour research/shadow work. 3. Return the calculator output without changing values or labels. 4. State the data source and cutoff. Keep `score_basis` visible whenever a user could mistake the score for a probability. 5. If the calculator returns `BLOCKED`, report the missing or invalid input. Do not manufacture a partial recommendation. @@ -87,8 +88,11 @@ For a Korean, U.S., or all-four-exchange target universe, first read `scripts/build_universe_manifest.py`, then read `references/screen-contract.md` and run `scripts/screen_universe.py`. Use `qta-universe-manifest/v1` only for legacy, explicitly curated KR/US inputs. The selector is a separate -cross-sectional strategy assumption; it does not change `qta-1.0.0` or turn -its score into a probability. Pass the complete frozen screen artifact to +cross-sectional strategy assumption; it does not turn either score into a +probability. `screen_universe.py --method-version qta-2.0.0` selects the +research-only first-hour model; its output additionally requires a fresh +same-session benchmark-regime admission in the execution runner. Pass the +complete frozen screen artifact to `$quant-stock-polling-trader`; never submit orders from this skill. After changing universe or screening code, run: @@ -97,6 +101,9 @@ After changing universe or screening code, run: python3 scripts/fetch_kis_kr_eod.py --self-test python3 scripts/fetch_kis_us_eod.py --self-test python3 scripts/build_universe_manifest.py --self-test +python3 scripts/analyze_stock_v2.py --self-test +python3 scripts/collect_open1h_research.py --self-test +python3 scripts/evaluate_open1h_research.py --self-test python3 scripts/screen_universe.py --self-test ``` @@ -104,7 +111,9 @@ When `$stock-scenario-story` will run next, save the unmodified JSON output to a ## Output boundaries -- Interpret the 0-100 score as historical technical strength under method `qta-1.0.0`, not as probability of profit. +- Interpret the 0-100 score as historical technical strength under the emitted + method version, not as probability of profit. `qta-2.0.0` remains + `RESEARCH_ONLY` until multi-session walk-forward evidence exists. - Treat entry, stop, and take-profit as calculated price levels, not execution or suitability advice. - Keep brokerage fees, tax, FX, slippage, position sizing, account constraints, and order semantics outside this technical score unless a later version explicitly models them. - Do not claim real-time monitoring. This version consumes completed end-of-day data. diff --git a/skills/quant-stock-technical/references/methodology.md b/skills/quant-stock-technical/references/methodology.md index d25a5f8..edebb15 100644 --- a/skills/quant-stock-technical/references/methodology.md +++ b/skills/quant-stock-technical/references/methodology.md @@ -119,3 +119,44 @@ calculation_status = READY ``` `stock-scenario-story` accepts only the complete unmodified JSON result. A Markdown rendering, hand-entered score summary, incomplete payload, or failed calculation is not a valid handoff. + +## QTA 2.0.0 first-hour research model + +`qta-2.0.0` reuses the same causal daily features, cutoff rules, price levels, +and per-ticker historical percentile transform. It changes only the horizon +aggregation for the first regular-session hour: + +```text +clip( + 0.50 * short + + 0.30 * medium + + 0.20 * (100 - risk), + 0, + 100 +) +``` + +Long-horizon strength remains visible in the payload but has zero weight in +the first-hour total. A setup also requires median 20-session turnover of at +least KRW 1,000,000,000 for Korean instruments or USD 1,000,000 for U.S. +instruments. + +The frozen payload requires a downstream same-session market-regime check: + +```text +metric = same_session_previous_close_return_bps +minimum_change_bps = 0 +max_age_seconds = 90 +``` + +The runner admits a new entry only when the exchange benchmark or documented +broad-market proxy is fresh, belongs to the current session, and is not below +the previous close. It never applies this check to exit management. + +The model was created after a full-universe diagnostic for the 2026-07-28 +opening hour. On that single session relative to QTA1, Spearman rank +correlation increased on KOSDAQ, KOSPI, NASDAQ, and NYSE. This is diagnostic +evidence, not sufficient promotion evidence. The payload therefore emits +`validation_status=RESEARCH_ONLY`, and the execution skill accepts it only in +shadow mode until multiple later sessions are evaluated without retuning the +formula. diff --git a/skills/quant-stock-technical/references/screen-contract.md b/skills/quant-stock-technical/references/screen-contract.md index a7b78c7..bf70deb 100644 --- a/skills/quant-stock-technical/references/screen-contract.md +++ b/skills/quant-stock-technical/references/screen-contract.md @@ -123,12 +123,16 @@ no-trade result when no setup passes; use one or more only when underfilling that exchange should block the whole screen. The same exact `eligible_setup_statuses` restriction from v1 applies. -Run the same `qta-1.0.0` calculator once per included instrument. Use +By default, run the stable `qta-1.0.0` calculator once per included +instrument. Pass `--method-version qta-2.0.0` only for the research-only +first-hour model described in `methodology.md`. Use `canonical_symbol` as the QTA ticker, the explicit exchange benchmark CSV, and `tick_contract.resolved_tick_size`; never use `data_symbol` or `broker_symbol` interchangeably. -Apply the v1 score ordering independently inside each exchange. The final +Apply the method-specific score ordering independently inside each exchange. +QTA1 orders medium, long, short, then risk after total score. QTA2 orders +short, medium, risk, then long after total score. The final tie-break is canonical symbol ascending. Set `exchange_rank` before taking the configured top K. A score remains ticker-relative rather than a cross-sectional percentile or probability. @@ -151,3 +155,9 @@ Emit `qta-screen/v2` with: The polling trader combines KOSPI then KOSDAQ for a Korean session, and NYSE then NASDAQ for a U.S. session. It orders by the frozen exchange order, `exchange_rank`, then `broker_symbol`; it does not rescore candidates. + +`screen_universe.py` uses up to eight worker processes by default. `--workers +1` provides the serial reference path. Both paths must emit byte-identical +artifacts. When `--output` is supplied, the complete screen is written only to +that file and stdout contains a compact hash/status summary rather than +thousands of instrument payloads. diff --git a/skills/quant-stock-technical/scripts/analyze_stock.py b/skills/quant-stock-technical/scripts/analyze_stock.py index 6d7a0e0..1fba418 100644 --- a/skills/quant-stock-technical/scripts/analyze_stock.py +++ b/skills/quant-stock-technical/scripts/analyze_stock.py @@ -240,14 +240,65 @@ def rolling_volatility(values: list[float], period: int = 20) -> list[Optional[f def rolling_max_drawdown(values: list[float], period: int = 252) -> list[Optional[float]]: + """Calculate exact sliding-window drawdown in amortized linear time.""" + if period <= 0: + raise BlockedError("maximum drawdown period must be positive") output: list[Optional[float]] = [None] * len(values) - for index in range(period - 1, len(values)): - peak = values[index - period + 1] - worst = 0.0 - for value in values[index - period + 1 : index + 1]: - peak = max(peak, value) - worst = max(worst, 1.0 - value / peak) - output[index] = worst + + # A segment stores (maximum price, minimum price, largest earlier/later + # peak-to-trough ratio). The operation is associative, so a two-stack + # aggregate queue supports an exact ordered sliding-window query. + Segment = tuple[float, float, float] + + def singleton(value: float) -> Segment: + return (value, value, 1.0) + + def combine(left: Segment, right: Segment) -> Segment: + return ( + max(left[0], right[0]), + min(left[1], right[1]), + max(left[2], right[2], left[0] / right[1]), + ) + + incoming: list[tuple[Segment, Segment]] = [] + outgoing: list[tuple[Segment, Segment]] = [] + + def push(value: float) -> None: + item = singleton(value) + aggregate = item if not incoming else combine(incoming[-1][1], item) + incoming.append((item, aggregate)) + + def pop_oldest() -> None: + if not outgoing: + while incoming: + item, _ = incoming.pop() + aggregate = ( + item + if not outgoing + else combine(item, outgoing[-1][1]) + ) + outgoing.append((item, aggregate)) + outgoing.pop() + + def aggregate() -> Segment: + if outgoing and incoming: + return combine(outgoing[-1][1], incoming[-1][1]) + if outgoing: + return outgoing[-1][1] + if incoming: + return incoming[-1][1] + raise BlockedError("cannot aggregate an empty drawdown window") + + window_size = 0 + for index, value in enumerate(values): + push(value) + window_size += 1 + if window_size > period: + pop_oldest() + window_size -= 1 + if window_size == period: + maximum_ratio = aggregate()[2] + output[index] = 1.0 - 1.0 / maximum_ratio return output diff --git a/skills/quant-stock-technical/scripts/analyze_stock_v2.py b/skills/quant-stock-technical/scripts/analyze_stock_v2.py new file mode 100644 index 0000000..11c92fb --- /dev/null +++ b/skills/quant-stock-technical/scripts/analyze_stock_v2.py @@ -0,0 +1,221 @@ +#!/usr/bin/env python3 +"""First-hour-aligned QTA 2.0 daily setup score.""" + +from __future__ import annotations + +import argparse +import json +import statistics +import sys +from datetime import date +from pathlib import Path +from typing import Any + +import analyze_stock as qta1 + + +METHOD_VERSION = "qta-2.0.0" +VALIDATION_STATUS = "RESEARCH_ONLY" +LIQUIDITY_FLOOR = { + "KR": 1_000_000_000.0, + "US": 1_000_000.0, +} +MARKET_REGIME_POLICY = { + "metric": "same_session_previous_close_return_bps", + "minimum_change_bps": 0.0, + "max_age_seconds": 90, + "status": "REQUIRED", +} + + +def calculate( + ticker_bars: list[qta1.Bar], + benchmark_bars: list[qta1.Bar], + market: str, + ticker: str, + tick_size: float, + source_name: str, +) -> dict[str, Any]: + """Reuse the causal QTA1 features but score the first-hour horizon.""" + market = market.upper() + if market not in LIQUIDITY_FLOOR: + raise qta1.BlockedError("QTA 2.0 market must be KR or US") + result = qta1.calculate( + ticker_bars, + benchmark_bars, + market, + ticker, + tick_size, + source_name, + ) + short = float(result["short"]["score"]) + medium = float(result["medium"]["score"]) + risk = float(result["risk"]["score"]) + total = min( + 100.0, + max( + 0.0, + 0.50 * short + + 0.30 * medium + + 0.20 * (100.0 - risk), + ), + ) + median_turnover = statistics.median( + bar.close * bar.volume for bar in ticker_bars[-20:] + ) + minimum_turnover = LIQUIDITY_FLOOR[market] + liquidity_ready = median_turnover >= minimum_turnover + result.update( + { + "method_version": METHOD_VERSION, + "validation_status": VALIDATION_STATUS, + "score_basis": ( + "ticker-relative historical percentiles aligned to first-hour " + "continuation; not probability of profit" + ), + "liquidity": { + "median_20_session_turnover": round(median_turnover, 2), + "minimum_turnover": minimum_turnover, + "currency": "KRW" if market == "KR" else "USD", + "status": "READY" if liquidity_ready else "BLOCKED", + }, + "market_regime": dict(MARKET_REGIME_POLICY), + "total_score": round(total, 2), + "setup_status": ( + "READY" + if total >= 60.0 and liquidity_ready + else "CONDITIONAL" + ), + "assumptions": [ + *result["assumptions"], + "QTA 2.0 is research-only until multi-session walk-forward validation", + "same-session benchmark regime admission is required downstream", + ], + } + ) + return result + + +def self_test() -> None: + ticker = qta1.synthetic_bars(1100, 0.0006, 0.0) + benchmark = qta1.synthetic_bars(1100, 0.0003, 1.0) + result = calculate( + ticker, + benchmark, + "US", + "SYNTH", + 0.01, + "deterministic-self-test", + ) + expected = min( + 100.0, + max( + 0.0, + 0.50 * result["short"]["score"] + + 0.30 * result["medium"]["score"] + + 0.20 * (100.0 - result["risk"]["score"]), + ), + ) + assert abs(result["total_score"] - round(expected, 2)) <= 0.01 + assert result["method_version"] == METHOD_VERSION + assert result["validation_status"] == VALIDATION_STATUS + print( + json.dumps( + { + "self_test": "PASS", + "method_version": METHOD_VERSION, + "validation_status": VALIDATION_STATUS, + "total_score": result["total_score"], + }, + sort_keys=True, + ) + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ticker-csv") + parser.add_argument("--benchmark-csv") + parser.add_argument("--market") + parser.add_argument("--ticker") + parser.add_argument("--tick-size", type=float) + parser.add_argument("--source-name", default="user-supplied-eod-csv") + parser.add_argument("--analysis-date") + parser.add_argument("--self-test", action="store_true") + return parser.parse_args() + + +def blocked(reason: str, market: Any, ticker: Any) -> dict[str, Any]: + return { + "source_skill": "quant-stock-technical", + "result_schema": "quant-stock-technical/v1", + "calculation_status": "BLOCKED", + "reason": reason, + "method_version": METHOD_VERSION, + "market": str(market or "").upper(), + "ticker": str(ticker or "").strip().upper(), + } + + +def main() -> int: + args = parse_args() + if args.self_test: + self_test() + return 0 + missing = [ + name + for name in ( + "ticker_csv", + "benchmark_csv", + "market", + "ticker", + "tick_size", + ) + if getattr(args, name) is None + ] + if missing: + print( + json.dumps( + blocked( + "missing arguments: " + ", ".join(missing), + args.market, + args.ticker, + ), + indent=2, + ) + ) + return 2 + try: + requested = ( + date.fromisoformat(args.analysis_date) + if args.analysis_date + else None + ) + ticker_rows, benchmark_rows = qta1.align( + qta1.read_csv(str(Path(args.ticker_csv))), + qta1.read_csv(str(Path(args.benchmark_csv))), + requested, + ) + result = calculate( + ticker_rows, + benchmark_rows, + args.market, + args.ticker, + args.tick_size, + args.source_name, + ) + except (qta1.BlockedError, OSError, ValueError) as exc: + print( + json.dumps( + blocked(str(exc), args.market, args.ticker), + ensure_ascii=False, + indent=2, + ) + ) + return 2 + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/quant-stock-technical/scripts/collect_open1h_research.py b/skills/quant-stock-technical/scripts/collect_open1h_research.py new file mode 100644 index 0000000..88fc3c1 --- /dev/null +++ b/skills/quant-stock-technical/scripts/collect_open1h_research.py @@ -0,0 +1,920 @@ +#!/usr/bin/env python3 +"""Collect deterministic first-60-minute KIS bars for QTA research.""" + +from __future__ import annotations + +import argparse +import json +import sys +import tempfile +from datetime import date +from decimal import Decimal, InvalidOperation, ROUND_HALF_EVEN +from pathlib import Path +from typing import Any, Mapping + +import fetch_kis_kr_eod as shared +import screen_universe as screen + + +JOB_SCHEMA = "qta-open1h-research-job/v1" +BARS_SCHEMA = "qta-open1h-bars/v1" +SNAPSHOT_SCHEMA = "qta-open1h-snapshot/v1" +JOB_FIELDS = { + "schema", + "manifest_path", + "market", + "session_date", + "output_directory", + "environment", + "request_interval_ms", +} +MARKET_CONTRACTS = { + "KR": { + "exchanges": ("KOSPI", "KOSDAQ"), + "interval_minutes": 1, + "window_start": "090000", + "window_end": "100000", + "expected_bars": 61, + "path": ( + "/uapi/domestic-stock/v1/quotations/" + "inquire-time-dailychartprice" + ), + "tr_id": "FHKST03010230", + }, + "US": { + "exchanges": ("NYSE", "NASDAQ"), + "interval_minutes": 5, + "window_start": "093000", + "window_end": "103000", + "expected_bars": 13, + "path": ( + "/uapi/overseas-price/v1/quotations/" + "inquire-time-itemchartprice" + ), + "tr_id": "HHDFS76950200", + }, +} +US_EXCHANGE_CODES = {"NYSE": "NYS", "NASDAQ": "NAS"} +BAR_FIELDS = { + "time", + "open", + "high", + "low", + "close", + "volume", + "turnover", +} +BARS_FIELDS = { + "schema", + "source", + "market", + "exchange", + "canonical_symbol", + "broker_symbol", + "session_date", + "interval_minutes", + "window_start", + "window_end", + "bars", + "bars_hash", + "summary", +} +SUMMARY_FIELDS = { + "bar_count", + "start_open", + "end_close", + "window_high", + "window_low", + "window_volume", + "window_turnover", + "return_60m_bps", + "maximum_excursion_bps", + "minimum_excursion_bps", +} + + +class ResearchBlockedError(ValueError): + """Raised when first-hour research data cannot be used without guessing.""" + + +def exact_fields(value: Mapping[str, Any], expected: set[str], label: str) -> None: + if set(value) != expected: + raise ResearchBlockedError( + f"{label} fields mismatch; missing={sorted(expected - set(value))}, " + f"extra={sorted(set(value) - expected)}" + ) + + +def require_date(value: Any, field: str) -> str: + if not isinstance(value, str): + raise ResearchBlockedError(f"{field} must be YYYY-MM-DD") + try: + parsed = date.fromisoformat(value) + except ValueError as exc: + raise ResearchBlockedError(f"{field} must be YYYY-MM-DD") from exc + if parsed.isoformat() != value: + raise ResearchBlockedError(f"{field} must be YYYY-MM-DD") + return value + + +def require_absolute_path(value: Any, field: str) -> Path: + path = Path(str(value)).expanduser() + if not path.is_absolute(): + raise ResearchBlockedError(f"{field} must be absolute") + return path + + +def decimal_value( + value: Any, + field: str, + *, + allow_zero: bool = False, +) -> Decimal: + if isinstance(value, bool): + raise ResearchBlockedError(f"{field} must be decimal") + try: + parsed = Decimal(str(value).replace(",", "").strip()) + except (InvalidOperation, ValueError) as exc: + raise ResearchBlockedError(f"{field} must be decimal") from exc + if not parsed.is_finite() or ( + parsed < 0 if allow_zero else parsed <= 0 + ): + qualifier = "nonnegative" if allow_zero else "positive" + raise ResearchBlockedError(f"{field} must be {qualifier}") + return parsed + + +def decimal_text(value: Decimal) -> str: + rendered = format(value, "f") + if "." in rendered: + rendered = rendered.rstrip("0").rstrip(".") + return rendered or "0" + + +def bps(numerator: Decimal, denominator: Decimal) -> str: + value = ( + (numerator / denominator - Decimal(1)) * Decimal(10000) + ).quantize(Decimal("0.01"), rounding=ROUND_HALF_EVEN) + return format(value, "f") + + +def load_job(path: Path) -> dict[str, Any]: + try: + raw = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise ResearchBlockedError(f"job must be readable JSON: {path}") from exc + if not isinstance(raw, dict): + raise ResearchBlockedError("job must contain one JSON object") + exact_fields(raw, JOB_FIELDS, "job") + if raw["schema"] != JOB_SCHEMA: + raise ResearchBlockedError(f"job.schema must be {JOB_SCHEMA}") + market = str(raw["market"]).upper() + if market not in MARKET_CONTRACTS: + raise ResearchBlockedError("job.market must be KR or US") + session_date = require_date(raw["session_date"], "job.session_date") + manifest_path = require_absolute_path(raw["manifest_path"], "job.manifest_path") + if not manifest_path.is_file() or manifest_path.is_symlink(): + raise ResearchBlockedError( + "job.manifest_path must be a regular non-symlink file" + ) + output_directory = require_absolute_path( + raw["output_directory"], "job.output_directory" + ) + if output_directory.exists() and ( + not output_directory.is_dir() or output_directory.is_symlink() + ): + raise ResearchBlockedError( + "job.output_directory must be a non-symlink directory" + ) + environment = str(raw["environment"]).lower() + if environment not in shared.BASE_URLS: + raise ResearchBlockedError("job.environment must be live or paper") + interval = raw["request_interval_ms"] + if ( + isinstance(interval, bool) + or not isinstance(interval, int) + or interval < (1000 if environment == "paper" else 100) + ): + floor = 1000 if environment == "paper" else 100 + raise ResearchBlockedError( + f"job.request_interval_ms must be an integer >= {floor}" + ) + try: + manifest_raw = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest = screen.normalize_manifest_v2(manifest_raw) + except ( + OSError, + json.JSONDecodeError, + screen.ScreenBlockedError, + ) as exc: + raise ResearchBlockedError(f"manifest validation failed: {exc}") from exc + if manifest["as_of"] != session_date: + raise ResearchBlockedError( + "manifest.as_of must equal the research session_date" + ) + if manifest["analysis_date"] >= session_date: + raise ResearchBlockedError( + "manifest.analysis_date must precede the research session_date" + ) + instruments = [ + instrument + for instrument in manifest["instruments"] + if instrument["market"] == market + ] + if not instruments: + raise ResearchBlockedError("manifest has no instruments for job.market") + return { + "schema": JOB_SCHEMA, + "manifest_path": str(manifest_path), + "manifest": manifest, + "market": market, + "session_date": session_date, + "output_directory": output_directory, + "environment": environment, + "request_interval_ms": interval, + "instruments": sorted( + instruments, + key=lambda item: ( + MARKET_CONTRACTS[market]["exchanges"].index(item["exchange"]), + item["canonical_symbol"], + ), + ), + } + + +def normalize_bar( + raw: Mapping[str, Any], + *, + market: str, + session_date: str, +) -> dict[str, str] | None: + compact_date = session_date.replace("-", "") + if market == "KR": + raw_date = str(raw.get("stck_bsop_date", "")).strip() + raw_time = str(raw.get("stck_cntg_hour", "")).strip() + if raw_date != compact_date: + return None + fields = { + "open": raw.get("stck_oprc"), + "high": raw.get("stck_hgpr"), + "low": raw.get("stck_lwpr"), + "close": raw.get("stck_prpr"), + "volume": raw.get("cntg_vol", "0") or "0", + "turnover": raw.get("acml_tr_pbmn", "0") or "0", + } + else: + raw_date = str(raw.get("xymd", "")).strip() + raw_time = str(raw.get("xhms", "")).strip() + if raw_date != compact_date: + return None + fields = { + "open": raw.get("open"), + "high": raw.get("high"), + "low": raw.get("low"), + "close": raw.get("last"), + "volume": raw.get("evol", "0") or "0", + "turnover": raw.get("eamt", "0") or "0", + } + if len(raw_time) != 6 or not raw_time.isdigit(): + raise ResearchBlockedError("KIS minute row has invalid local time") + output = { + "time": raw_time, + "open": decimal_text(decimal_value(fields["open"], "minute open")), + "high": decimal_text(decimal_value(fields["high"], "minute high")), + "low": decimal_text(decimal_value(fields["low"], "minute low")), + "close": decimal_text(decimal_value(fields["close"], "minute close")), + "volume": decimal_text( + decimal_value(fields["volume"], "minute volume", allow_zero=True) + ), + "turnover": decimal_text( + decimal_value(fields["turnover"], "minute turnover", allow_zero=True) + ), + } + high = Decimal(output["high"]) + low = Decimal(output["low"]) + if high < max( + Decimal(output["open"]), + low, + Decimal(output["close"]), + ): + raise ResearchBlockedError("KIS minute row high is inconsistent") + if low > min( + Decimal(output["open"]), + high, + Decimal(output["close"]), + ): + raise ResearchBlockedError("KIS minute row low is inconsistent") + return output + + +def normalized_window_bars( + raw_rows: Any, + *, + market: str, + session_date: str, +) -> list[dict[str, str]]: + if not isinstance(raw_rows, list) or any( + not isinstance(item, dict) for item in raw_rows + ): + raise ResearchBlockedError("KIS minute output must be an object array") + contract = MARKET_CONTRACTS[market] + by_time: dict[str, dict[str, str]] = {} + for raw in raw_rows: + bar = normalize_bar( + raw, + market=market, + session_date=session_date, + ) + if bar is None: + continue + if not ( + contract["window_start"] + <= bar["time"] + <= contract["window_end"] + ): + continue + existing = by_time.get(bar["time"]) + if existing is not None and existing != bar: + raise ResearchBlockedError( + f"conflicting duplicate minute bar at {bar['time']}" + ) + by_time[bar["time"]] = bar + bars = [by_time[key] for key in sorted(by_time)] + if not bars: + raise ResearchBlockedError("KIS response has no first-hour bars") + return bars + + +def minute_of_day(value: str) -> int: + if len(value) != 6 or not value.isdigit(): + raise ResearchBlockedError("bar time must be HHMMSS") + hour = int(value[:2]) + minute = int(value[2:4]) + second = int(value[4:]) + if hour > 23 or minute > 59 or second != 0: + raise ResearchBlockedError("bar time must be a whole valid minute") + return hour * 60 + minute + + +def coverage_summary( + bars: list[dict[str, str]], + *, + market: str, +) -> dict[str, Any]: + contract = MARKET_CONTRACTS[market] + first_time = bars[0]["time"] + last_time = bars[-1]["time"] + first_minute = minute_of_day(first_time) + last_minute = minute_of_day(last_time) + start_minute = minute_of_day(contract["window_start"]) + end_minute = minute_of_day(contract["window_end"]) + expected = int(contract["expected_bars"]) + interval = int(contract["interval_minutes"]) + if ( + first_time == contract["window_start"] + and last_time == contract["window_end"] + and len(bars) == expected + ): + status = "COMPLETE_GRID" + elif ( + first_time == contract["window_start"] + and last_time == contract["window_end"] + ): + status = "EXACT_ENDPOINTS_SPARSE" + elif ( + first_minute <= start_minute + interval + and last_minute >= end_minute - interval + ): + status = "NEAR_ENDPOINTS" + else: + status = "PARTIAL_WINDOW" + return { + "coverage_status": status, + "first_bar_time": first_time, + "last_bar_time": last_time, + "expected_bar_count": expected, + "missing_bar_count": max(0, expected - len(bars)), + "coverage_fraction": format( + Decimal(len(bars)) / Decimal(expected), "f" + ), + "observed_span_minutes": last_minute - first_minute, + } + + +def summarize_bars( + bars: list[dict[str, str]], + *, + market: str, +) -> dict[str, Any]: + start_open = Decimal(bars[0]["open"]) + end_close = Decimal(bars[-1]["close"]) + window_high = max(Decimal(bar["high"]) for bar in bars) + window_low = min(Decimal(bar["low"]) for bar in bars) + window_volume = sum( + (Decimal(bar["volume"]) for bar in bars), Decimal(0) + ) + if market == "KR": + # The domestic endpoint exposes cumulative traded value on each bar. + window_turnover = Decimal(bars[-1]["turnover"]) + else: + window_turnover = sum( + (Decimal(bar["turnover"]) for bar in bars), Decimal(0) + ) + return { + "bar_count": len(bars), + "start_open": decimal_text(start_open), + "end_close": decimal_text(end_close), + "window_high": decimal_text(window_high), + "window_low": decimal_text(window_low), + "window_volume": decimal_text(window_volume), + "window_turnover": decimal_text(window_turnover), + "return_60m_bps": bps(end_close, start_open), + "maximum_excursion_bps": bps(window_high, start_open), + "minimum_excursion_bps": bps(window_low, start_open), + } + + +def fetch_raw_rows( + client: shared.KisReadClient, + *, + market: str, + exchange: str, + broker_symbol: str, + session_date: str, +) -> list[dict[str, Any]]: + contract = MARKET_CONTRACTS[market] + if market == "KR": + body = client.get( + contract["path"], + contract["tr_id"], + { + "FID_COND_MRKT_DIV_CODE": "J", + "FID_INPUT_ISCD": broker_symbol, + "FID_INPUT_HOUR_1": contract["window_end"], + "FID_INPUT_DATE_1": session_date.replace("-", ""), + "FID_PW_DATA_INCU_YN": "N", + "FID_FAKE_TICK_INCU_YN": "", + }, + ) + else: + body = client.get( + contract["path"], + contract["tr_id"], + { + "AUTH": "", + "EXCD": US_EXCHANGE_CODES[exchange], + "SYMB": broker_symbol, + "NMIN": str(contract["interval_minutes"]), + "PINC": "1", + "NEXT": "", + "NREC": "120", + "FILL": "", + "KEYB": "", + }, + ) + rows = body.get("output2") + if not isinstance(rows, list): + raise ResearchBlockedError("KIS minute response output2 must be an array") + return rows + + +def bars_artifact( + *, + instrument: dict[str, Any], + market: str, + session_date: str, + raw_rows: list[dict[str, Any]], +) -> dict[str, Any]: + contract = MARKET_CONTRACTS[market] + bars = normalized_window_bars( + raw_rows, + market=market, + session_date=session_date, + ) + summary = summarize_bars(bars, market=market) + output = { + "schema": BARS_SCHEMA, + "source": { + "provider": "KIS_OPEN_API", + "endpoint": contract["path"], + "tr_id": contract["tr_id"], + "read_only": True, + }, + "market": market, + "exchange": instrument["exchange"], + "canonical_symbol": instrument["canonical_symbol"], + "broker_symbol": instrument["broker_symbol"], + "session_date": session_date, + "interval_minutes": contract["interval_minutes"], + "window_start": contract["window_start"], + "window_end": contract["window_end"], + "bars": bars, + "bars_hash": shared.sha256_bytes( + shared.canonical_json(bars).encode("utf-8") + ), + "summary": summary, + } + return normalize_bars_artifact(output) + + +def normalize_bars_artifact(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + raise ResearchBlockedError("bars artifact must be an object") + exact_fields(value, BARS_FIELDS, "bars artifact") + if value["schema"] != BARS_SCHEMA: + raise ResearchBlockedError(f"bars artifact schema must be {BARS_SCHEMA}") + market = str(value["market"]).upper() + if market not in MARKET_CONTRACTS: + raise ResearchBlockedError("bars artifact market must be KR or US") + exchange = str(value["exchange"]).upper() + if exchange not in MARKET_CONTRACTS[market]["exchanges"]: + raise ResearchBlockedError("bars artifact exchange does not match market") + session_date = require_date(value["session_date"], "bars session_date") + source = value["source"] + if not isinstance(source, dict) or source != { + "provider": "KIS_OPEN_API", + "endpoint": MARKET_CONTRACTS[market]["path"], + "tr_id": MARKET_CONTRACTS[market]["tr_id"], + "read_only": True, + }: + raise ResearchBlockedError("bars source contract is invalid") + if value["interval_minutes"] != MARKET_CONTRACTS[market]["interval_minutes"]: + raise ResearchBlockedError("bars interval does not match market") + if value["window_start"] != MARKET_CONTRACTS[market]["window_start"]: + raise ResearchBlockedError("bars window_start does not match market") + if value["window_end"] != MARKET_CONTRACTS[market]["window_end"]: + raise ResearchBlockedError("bars window_end does not match market") + bars = value["bars"] + if not isinstance(bars, list): + raise ResearchBlockedError("bars must be an array") + for index, bar in enumerate(bars): + if not isinstance(bar, dict): + raise ResearchBlockedError(f"bars[{index}] must be an object") + exact_fields(bar, BAR_FIELDS, f"bars[{index}]") + normalized_bars = normalized_window_bars( + [ + ( + { + "stck_bsop_date": session_date.replace("-", ""), + "stck_cntg_hour": bar["time"], + "stck_oprc": bar["open"], + "stck_hgpr": bar["high"], + "stck_lwpr": bar["low"], + "stck_prpr": bar["close"], + "cntg_vol": bar["volume"], + "acml_tr_pbmn": bar["turnover"], + } + if market == "KR" + else { + "xymd": session_date.replace("-", ""), + "xhms": bar["time"], + "open": bar["open"], + "high": bar["high"], + "low": bar["low"], + "last": bar["close"], + "evol": bar["volume"], + "eamt": bar["turnover"], + } + ) + for bar in bars + ], + market=market, + session_date=session_date, + ) + expected_hash = shared.sha256_bytes( + shared.canonical_json(normalized_bars).encode("utf-8") + ) + if value["bars_hash"] != expected_hash: + raise ResearchBlockedError("bars_hash does not match bars") + summary = value["summary"] + if not isinstance(summary, dict): + raise ResearchBlockedError("bars summary must be an object") + exact_fields(summary, SUMMARY_FIELDS, "bars summary") + expected_summary = summarize_bars(normalized_bars, market=market) + if summary != expected_summary: + raise ResearchBlockedError("bars summary does not match bars") + canonical_symbol = str(value["canonical_symbol"]).strip() + broker_symbol = str(value["broker_symbol"]).strip() + if not canonical_symbol or not broker_symbol: + raise ResearchBlockedError("bars symbols must be non-empty") + return { + **value, + "market": market, + "exchange": exchange, + "canonical_symbol": canonical_symbol, + "broker_symbol": broker_symbol, + "session_date": session_date, + "bars": normalized_bars, + "bars_hash": expected_hash, + "summary": expected_summary, + } + + +def record_from_artifact(path: Path, artifact: dict[str, Any]) -> dict[str, Any]: + return { + "market": artifact["market"], + "exchange": artifact["exchange"], + "canonical_symbol": artifact["canonical_symbol"], + "broker_symbol": artifact["broker_symbol"], + "session_date": artifact["session_date"], + "interval_minutes": artifact["interval_minutes"], + "bars_path": str(path.resolve()), + "bars_file_sha256": shared.sha256_file(path), + "bars_hash": artifact["bars_hash"], + **coverage_summary(artifact["bars"], market=artifact["market"]), + **artifact["summary"], + } + + +def cache_path( + output_directory: Path, + instrument: dict[str, Any], +) -> Path: + return ( + output_directory + / "bars" + / instrument["exchange"] + / f"{instrument['canonical_symbol']}.json" + ) + + +def collect( + job: dict[str, Any], + *, + client: shared.KisReadClient | None = None, +) -> dict[str, Any]: + output_directory = job["output_directory"] + output_directory.mkdir(parents=True, exist_ok=True) + if client is None: + key, secret, token = shared.load_api_credentials(job["environment"]) + client = shared.KisReadClient( + environment=job["environment"], + app_key=key, + app_secret=secret, + access_token=token, + interval_ms=job["request_interval_ms"], + ) + records: list[dict[str, Any]] = [] + failures: list[dict[str, str]] = [] + cache_hits = 0 + instruments = job["instruments"] + for index, instrument in enumerate(instruments, start=1): + path = cache_path(output_directory, instrument) + try: + if path.exists(): + artifact = normalize_bars_artifact( + json.loads(path.read_text(encoding="utf-8")) + ) + if ( + artifact["market"] != job["market"] + or artifact["exchange"] != instrument["exchange"] + or artifact["canonical_symbol"] + != instrument["canonical_symbol"] + or artifact["broker_symbol"] != instrument["broker_symbol"] + or artifact["session_date"] != job["session_date"] + ): + raise ResearchBlockedError( + "cached bars identity does not match instrument" + ) + cache_hits += 1 + else: + raw_rows = fetch_raw_rows( + client, + market=job["market"], + exchange=instrument["exchange"], + broker_symbol=instrument["broker_symbol"], + session_date=job["session_date"], + ) + artifact = bars_artifact( + instrument=instrument, + market=job["market"], + session_date=job["session_date"], + raw_rows=raw_rows, + ) + shared.atomic_write_json(path, artifact) + records.append(record_from_artifact(path, artifact)) + except ( + OSError, + json.JSONDecodeError, + shared.EodBlockedError, + ResearchBlockedError, + ) as exc: + failures.append( + { + "market": job["market"], + "exchange": instrument["exchange"], + "canonical_symbol": instrument["canonical_symbol"], + "broker_symbol": instrument["broker_symbol"], + "reason": str(exc), + } + ) + if index % 100 == 0 or index == len(instruments): + print( + shared.canonical_json( + { + "progress": index, + "total": len(instruments), + "ready": len(records), + "blocked": len(failures), + "requests": client.request_count, + } + ), + file=sys.stderr, + flush=True, + ) + records.sort(key=lambda item: (item["exchange"], item["canonical_symbol"])) + failures.sort( + key=lambda item: (item["exchange"], item["canonical_symbol"]) + ) + coverage_counts = { + status: sum( + record["coverage_status"] == status for record in records + ) + for status in ( + "COMPLETE_GRID", + "EXACT_ENDPOINTS_SPARSE", + "NEAR_ENDPOINTS", + "PARTIAL_WINDOW", + ) + } + without_hash = { + "schema": SNAPSHOT_SCHEMA, + "source_skill": "quant-stock-technical", + "source_contract": { + "provider": "KIS_OPEN_API", + "read_only": True, + "market": job["market"], + "tr_id": MARKET_CONTRACTS[job["market"]]["tr_id"], + "endpoint": MARKET_CONTRACTS[job["market"]]["path"], + }, + "manifest_path": job["manifest_path"], + "manifest_hash": job["manifest"]["manifest_hash"], + "analysis_date": job["manifest"]["analysis_date"], + "session_date": job["session_date"], + "market": job["market"], + "window_start": MARKET_CONTRACTS[job["market"]]["window_start"], + "window_end": MARKET_CONTRACTS[job["market"]]["window_end"], + "interval_minutes": MARKET_CONTRACTS[job["market"]][ + "interval_minutes" + ], + "instrument_count": len(instruments), + "ready_count": len(records), + "blocked_count": len(failures), + "cache_hit_count": cache_hits, + "request_count": client.request_count, + "retry_count": client.retry_count, + "coverage_counts": coverage_counts, + "records": records, + "failures": failures, + "api_mutation_count": 0, + "live_enabled": False, + } + snapshot = { + **without_hash, + "snapshot_hash": shared.sha256_bytes( + shared.canonical_json(without_hash).encode("utf-8") + ), + } + shared.atomic_write_json(output_directory / "open1h-snapshot.json", snapshot) + return snapshot + + +def synthetic_rows( + *, + market: str, + session_date: str, +) -> list[dict[str, str]]: + contract = MARKET_CONTRACTS[market] + start_hour = 9 + start_minute = 0 if market == "KR" else 30 + rows: list[dict[str, str]] = [] + for index in range(contract["expected_bars"]): + total_minutes = start_hour * 60 + start_minute + ( + index * contract["interval_minutes"] + ) + stamp = f"{total_minutes // 60:02d}{total_minutes % 60:02d}00" + opening = Decimal("100") + Decimal(index) / Decimal("10") + row = { + "open": decimal_text(opening), + "high": decimal_text(opening + Decimal("1")), + "low": decimal_text(opening - Decimal("1")), + "close": decimal_text(opening + Decimal("0.5")), + "volume": "10", + "turnover": str(1000 + index), + } + if market == "KR": + rows.append( + { + "stck_bsop_date": session_date.replace("-", ""), + "stck_cntg_hour": stamp, + "stck_oprc": row["open"], + "stck_hgpr": row["high"], + "stck_lwpr": row["low"], + "stck_prpr": row["close"], + "cntg_vol": row["volume"], + "acml_tr_pbmn": row["turnover"], + } + ) + else: + rows.append( + { + "xymd": session_date.replace("-", ""), + "xhms": stamp, + "open": row["open"], + "high": row["high"], + "low": row["low"], + "last": row["close"], + "evol": row["volume"], + "eamt": row["turnover"], + } + ) + return list(reversed(rows)) + + +def self_test() -> None: + for market in ("KR", "US"): + exchange = MARKET_CONTRACTS[market]["exchanges"][0] + instrument = { + "exchange": exchange, + "canonical_symbol": "005930" if market == "KR" else "TEST", + "broker_symbol": "005930" if market == "KR" else "TEST", + } + artifact = bars_artifact( + instrument=instrument, + market=market, + session_date="2026-07-28", + raw_rows=synthetic_rows( + market=market, + session_date="2026-07-28", + ), + ) + assert artifact["summary"]["bar_count"] == ( + MARKET_CONTRACTS[market]["expected_bars"] + ) + assert Decimal(artifact["summary"]["return_60m_bps"]) > 0 + with tempfile.TemporaryDirectory( + prefix="qta-open1h-research-" + ) as temporary: + path = Path(temporary) / "bars.json" + shared.atomic_write_json(path, artifact) + loaded = normalize_bars_artifact( + json.loads(path.read_text(encoding="utf-8")) + ) + assert loaded == artifact + print( + shared.canonical_json( + { + "self_test": "PASS", + "schema": SNAPSHOT_SCHEMA, + "markets": ["KR", "US"], + "api_mutation_count": 0, + "live_enabled": False, + } + ) + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + subparsers = parser.add_subparsers(dest="command") + collect_parser = subparsers.add_parser("collect") + collect_parser.add_argument("--job", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.self_test: + self_test() + return 0 + if args.command != "collect": + raise ResearchBlockedError("use collect --job or --self-test") + job_path = require_absolute_path(args.job, "job path") + result = collect(load_job(job_path)) + except ( + ResearchBlockedError, + shared.EodBlockedError, + screen.ScreenBlockedError, + OSError, + json.JSONDecodeError, + ) as exc: + print( + shared.canonical_json( + { + "status": "BLOCKED", + "reason": str(exc), + "api_mutation_count": 0, + "live_enabled": False, + } + ), + file=sys.stderr, + ) + return 2 + print(shared.canonical_json(result)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/quant-stock-technical/scripts/evaluate_open1h_research.py b/skills/quant-stock-technical/scripts/evaluate_open1h_research.py new file mode 100644 index 0000000..88bf812 --- /dev/null +++ b/skills/quant-stock-technical/scripts/evaluate_open1h_research.py @@ -0,0 +1,721 @@ +#!/usr/bin/env python3 +"""Evaluate frozen QTA scores against completed first-60-minute outcomes.""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +import statistics +import sys +import tempfile +from decimal import Decimal, InvalidOperation, ROUND_CEILING, ROUND_HALF_EVEN +from pathlib import Path +from typing import Any, Iterable + +import collect_open1h_research as intraday +import fetch_kis_kr_eod as shared +import screen_universe as screen + + +JOB_SCHEMA = "qta-open1h-evaluation-job/v1" +DATASET_SCHEMA = "qta-open1h-evaluation/v1" +JOB_FIELDS = { + "schema", + "screen_path", + "snapshot_path", + "output_path", + "top_fraction", +} +SNAPSHOT_FIELDS = { + "schema", + "source_skill", + "source_contract", + "manifest_path", + "manifest_hash", + "analysis_date", + "session_date", + "market", + "window_start", + "window_end", + "interval_minutes", + "instrument_count", + "ready_count", + "blocked_count", + "cache_hit_count", + "request_count", + "retry_count", + "coverage_counts", + "records", + "failures", + "api_mutation_count", + "live_enabled", + "snapshot_hash", +} + + +class EvaluationBlockedError(ValueError): + """Raised when a QTA/opening-hour comparison would be incomplete.""" + + +def exact_fields(value: dict[str, Any], expected: set[str], label: str) -> None: + if set(value) != expected: + raise EvaluationBlockedError( + f"{label} fields mismatch; missing={sorted(expected - set(value))}, " + f"extra={sorted(set(value) - expected)}" + ) + + +def decimal_value(value: Any, field: str) -> Decimal: + if isinstance(value, bool): + raise EvaluationBlockedError(f"{field} must be decimal") + try: + parsed = Decimal(str(value)) + except (InvalidOperation, ValueError) as exc: + raise EvaluationBlockedError(f"{field} must be decimal") from exc + if not parsed.is_finite(): + raise EvaluationBlockedError(f"{field} must be finite") + return parsed + + +def positive_fraction(value: Any, field: str) -> Decimal: + parsed = decimal_value(value, field) + if parsed <= 0 or parsed >= 1: + raise EvaluationBlockedError(f"{field} must be greater than 0 and below 1") + return parsed + + +def absolute_file(value: Any, field: str, *, must_exist: bool) -> Path: + path = Path(str(value)).expanduser() + if not path.is_absolute(): + raise EvaluationBlockedError(f"{field} must be absolute") + if must_exist and (not path.is_file() or path.is_symlink()): + raise EvaluationBlockedError( + f"{field} must be a regular non-symlink file" + ) + return path + + +def load_object(path: Path, label: str) -> dict[str, Any]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise EvaluationBlockedError(f"{label} must be readable JSON") from exc + if not isinstance(value, dict): + raise EvaluationBlockedError(f"{label} must be one JSON object") + return value + + +def load_job(path: Path) -> dict[str, Any]: + raw = load_object(path, "evaluation job") + exact_fields(raw, JOB_FIELDS, "evaluation job") + if raw["schema"] != JOB_SCHEMA: + raise EvaluationBlockedError(f"job.schema must be {JOB_SCHEMA}") + screen_path = absolute_file(raw["screen_path"], "screen_path", must_exist=True) + snapshot_path = absolute_file( + raw["snapshot_path"], "snapshot_path", must_exist=True + ) + output_path = absolute_file(raw["output_path"], "output_path", must_exist=False) + return { + "schema": JOB_SCHEMA, + "screen_path": screen_path, + "snapshot_path": snapshot_path, + "output_path": output_path, + "top_fraction": positive_fraction(raw["top_fraction"], "top_fraction"), + } + + +def normalized_screen(path: Path) -> dict[str, Any]: + value = load_object(path, "screen") + supplied_hash = value.get("screen_hash") + if not isinstance(supplied_hash, str): + raise EvaluationBlockedError("screen_hash is required") + without_hash = {key: item for key, item in value.items() if key != "screen_hash"} + if screen.sha256_json(without_hash) != supplied_hash: + raise EvaluationBlockedError("screen_hash does not match screen") + if ( + value.get("schema") != screen.SCREEN_SCHEMA_V2 + or value.get("source_skill") != "quant-stock-technical" + or value.get("method_version") not in {"qta-1.0.0", "qta-2.0.0"} + ): + raise EvaluationBlockedError( + "evaluation requires a qta-screen/v2 QTA 1.0.0 or 2.0.0 artifact" + ) + decisions = value.get("decisions") + if not isinstance(decisions, list) or not decisions: + raise EvaluationBlockedError("screen decisions must be a non-empty array") + return value + + +def normalized_snapshot(path: Path) -> dict[str, Any]: + value = load_object(path, "opening-hour snapshot") + exact_fields(value, SNAPSHOT_FIELDS, "opening-hour snapshot") + if value["schema"] != intraday.SNAPSHOT_SCHEMA: + raise EvaluationBlockedError( + f"snapshot.schema must be {intraday.SNAPSHOT_SCHEMA}" + ) + if value["api_mutation_count"] != 0 or value["live_enabled"] is not False: + raise EvaluationBlockedError("research snapshot must be read-only") + supplied_hash = value["snapshot_hash"] + without_hash = { + key: item for key, item in value.items() if key != "snapshot_hash" + } + expected_hash = shared.sha256_bytes( + shared.canonical_json(without_hash).encode("utf-8") + ) + if supplied_hash != expected_hash: + raise EvaluationBlockedError("snapshot_hash does not match snapshot") + if ( + value["instrument_count"] + != value["ready_count"] + value["blocked_count"] + ): + raise EvaluationBlockedError("snapshot counts do not reconcile") + if value["ready_count"] != len(value["records"]): + raise EvaluationBlockedError("snapshot ready_count does not match records") + if value["blocked_count"] != len(value["failures"]): + raise EvaluationBlockedError("snapshot blocked_count does not match failures") + return value + + +def eod_features( + ticker_path: Path, + *, + analysis_date: str, +) -> dict[str, str]: + try: + with ticker_path.open("r", encoding="utf-8-sig", newline="") as handle: + rows = list(csv.DictReader(handle)) + except OSError as exc: + raise EvaluationBlockedError(f"cannot read ticker CSV: {ticker_path}") from exc + selected = [row for row in rows if str(row.get("date")) <= analysis_date] + if not selected or str(selected[-1].get("date")) != analysis_date: + raise EvaluationBlockedError( + f"ticker CSV has no completed {analysis_date} cutoff" + ) + if len(selected) < 253: + raise EvaluationBlockedError("ticker CSV needs at least 253 cutoff rows") + closes = [ + decimal_value(row.get("close"), "ticker close") for row in selected + ] + volumes = [ + decimal_value(row.get("volume"), "ticker volume") for row in selected + ] + dollar_turnover = [ + closes[index] * volumes[index] + for index in range(max(0, len(selected) - 20), len(selected)) + ] + return { + "previous_close": format(closes[-1], "f"), + "return_5_bps": intraday.bps(closes[-1], closes[-6]), + "return_20_bps": intraday.bps(closes[-1], closes[-21]), + "return_63_bps": intraday.bps(closes[-1], closes[-64]), + "median_20d_dollar_turnover": format( + statistics.median(dollar_turnover), "f" + ), + } + + +def first_bar_close_cross( + bars: list[dict[str, str]], + entry: Decimal, +) -> str | None: + previous: Decimal | None = None + for bar in bars: + current = Decimal(bar["close"]) + if previous is not None and previous < entry <= current: + return bar["time"] + previous = current + return None + + +def combine_rows( + *, + screen_value: dict[str, Any], + snapshot: dict[str, Any], +) -> tuple[list[dict[str, Any]], list[dict[str, str]]]: + if screen_value["manifest_hash"] != snapshot["manifest_hash"]: + raise EvaluationBlockedError("screen and snapshot manifest hashes differ") + if screen_value["analysis_date"] != snapshot["analysis_date"]: + raise EvaluationBlockedError("screen and snapshot analysis dates differ") + decision_by_key: dict[tuple[str, str], dict[str, Any]] = {} + for decision in screen_value["decisions"]: + if not isinstance(decision, dict): + raise EvaluationBlockedError("screen decision must be an object") + key = (str(decision.get("exchange")), str(decision.get("canonical_symbol"))) + if key in decision_by_key: + raise EvaluationBlockedError(f"duplicate screen decision: {key}") + decision_by_key[key] = decision + rows: list[dict[str, Any]] = [] + blocked: list[dict[str, str]] = [] + for record in snapshot["records"]: + if not isinstance(record, dict): + raise EvaluationBlockedError("snapshot record must be an object") + key = (str(record.get("exchange")), str(record.get("canonical_symbol"))) + decision = decision_by_key.get(key) + if decision is None: + raise EvaluationBlockedError(f"snapshot record has no screen decision: {key}") + qta = decision.get("qta") + if ( + not isinstance(qta, dict) + or qta.get("calculation_status") != "READY" + or qta.get("method_version") != screen_value["method_version"] + ): + blocked.append( + { + "exchange": key[0], + "canonical_symbol": key[1], + "reason": str( + qta.get("reason", "QTA calculation is not READY") + if isinstance(qta, dict) + else "QTA payload is missing" + ), + } + ) + continue + bars_path = absolute_file( + record.get("bars_path"), "record.bars_path", must_exist=True + ) + if shared.sha256_file(bars_path) != record.get("bars_file_sha256"): + raise EvaluationBlockedError(f"bars file hash mismatch: {bars_path}") + artifact = intraday.normalize_bars_artifact( + load_object(bars_path, "bars artifact") + ) + if artifact["bars_hash"] != record.get("bars_hash"): + raise EvaluationBlockedError(f"bars hash mismatch: {bars_path}") + instrument = decision.get("instrument") + if not isinstance(instrument, dict): + raise EvaluationBlockedError("screen decision instrument is missing") + ticker_path = absolute_file( + instrument.get("ticker_csv"), "ticker_csv", must_exist=True + ) + if shared.sha256_file(ticker_path) != instrument.get("ticker_csv_sha256"): + raise EvaluationBlockedError(f"ticker CSV hash mismatch: {ticker_path}") + eod = eod_features( + ticker_path, + analysis_date=screen_value["analysis_date"], + ) + entry = decimal_value(qta["entry_price"], "QTA entry price") + start_open = Decimal(record["start_open"]) + end_close = Decimal(record["end_close"]) + cross_time = first_bar_close_cross(artifact["bars"], entry) + rows.append( + { + "market": decision["market"], + "exchange": decision["exchange"], + "canonical_symbol": decision["canonical_symbol"], + "broker_symbol": instrument["broker_symbol"], + "instrument_type": instrument["instrument_type"], + "analysis_date": screen_value["analysis_date"], + "session_date": snapshot["session_date"], + "qta_method_version": qta["method_version"], + "qta_total_score": format( + decimal_value(qta["total_score"], "QTA total score"), "f" + ), + "qta_short_score": format( + decimal_value(qta["short"]["score"], "QTA short score"), "f" + ), + "qta_medium_score": format( + decimal_value(qta["medium"]["score"], "QTA medium score"), "f" + ), + "qta_long_score": format( + decimal_value(qta["long"]["score"], "QTA long score"), "f" + ), + "qta_risk_score": format( + decimal_value(qta["risk"]["score"], "QTA risk score"), "f" + ), + "qta_entry_price": format(entry, "f"), + "qta_stop_price": format( + decimal_value(qta["stop_price"], "QTA stop price"), "f" + ), + "qta_take_profit_price": format( + decimal_value( + qta["take_profit_price"], "QTA take-profit price" + ), + "f", + ), + **eod, + "opening_gap_bps": intraday.bps( + start_open, Decimal(eod["previous_close"]) + ), + "coverage_status": record["coverage_status"], + "first_bar_time": record["first_bar_time"], + "last_bar_time": record["last_bar_time"], + "bar_count": record["bar_count"], + "expected_bar_count": record["expected_bar_count"], + "missing_bar_count": record["missing_bar_count"], + "coverage_fraction": record["coverage_fraction"], + "observed_span_minutes": record["observed_span_minutes"], + "return_60m_bps": record["return_60m_bps"], + "maximum_excursion_bps": record["maximum_excursion_bps"], + "minimum_excursion_bps": record["minimum_excursion_bps"], + "window_volume": record["window_volume"], + "window_turnover": record["window_turnover"], + "entry_distance_from_previous_close_bps": intraday.bps( + entry, Decimal(eod["previous_close"]) + ), + "entry_touched_by_minute_high": ( + Decimal(record["window_high"]) >= entry + ), + "entry_crossed_by_bar_close": cross_time is not None, + "entry_cross_bar_time": cross_time, + "end_return_from_entry_bps": intraday.bps(end_close, entry), + } + ) + rows.sort(key=lambda item: (item["exchange"], item["canonical_symbol"])) + blocked.sort(key=lambda item: (item["exchange"], item["canonical_symbol"])) + return rows, blocked + + +def average_ranks(values: list[Decimal]) -> list[float]: + order = sorted(range(len(values)), key=lambda index: (values[index], index)) + ranks = [0.0] * len(values) + cursor = 0 + while cursor < len(order): + end = cursor + 1 + while end < len(order) and values[order[end]] == values[order[cursor]]: + end += 1 + average = (cursor + 1 + end) / 2.0 + for position in range(cursor, end): + ranks[order[position]] = average + cursor = end + return ranks + + +def pearson(left: Iterable[float], right: Iterable[float]) -> float | None: + x = list(left) + y = list(right) + if len(x) != len(y) or len(x) < 2: + return None + x_mean = sum(x) / len(x) + y_mean = sum(y) / len(y) + numerator = sum((a - x_mean) * (b - y_mean) for a, b in zip(x, y)) + x_scale = math.sqrt(sum((a - x_mean) ** 2 for a in x)) + y_scale = math.sqrt(sum((b - y_mean) ** 2 for b in y)) + if x_scale == 0 or y_scale == 0: + return None + return numerator / (x_scale * y_scale) + + +def rounded_metric(value: float | None) -> str | None: + if value is None: + return None + return format( + Decimal(str(value)).quantize( + Decimal("0.000001"), rounding=ROUND_HALF_EVEN + ), + "f", + ) + + +def group_summary(rows: list[dict[str, Any]]) -> dict[str, Any]: + returns = [Decimal(row["return_60m_bps"]) for row in rows] + touched = sum(bool(row["entry_touched_by_minute_high"]) for row in rows) + crossed = sum(bool(row["entry_crossed_by_bar_close"]) for row in rows) + positive = sum(value > 0 for value in returns) + return { + "count": len(rows), + "mean_return_60m_bps": ( + format(sum(returns, Decimal(0)) / len(returns), "f") + if returns + else None + ), + "median_return_60m_bps": ( + format(statistics.median(returns), "f") if returns else None + ), + "positive_fraction": ( + format(Decimal(positive) / len(rows), "f") if rows else None + ), + "entry_touch_fraction": ( + format(Decimal(touched) / len(rows), "f") if rows else None + ), + "bar_close_cross_fraction": ( + format(Decimal(crossed) / len(rows), "f") if rows else None + ), + } + + +def exchange_diagnostics( + rows: list[dict[str, Any]], + *, + top_fraction: Decimal, +) -> dict[str, Any]: + if not rows: + raise EvaluationBlockedError("exchange diagnostics require rows") + qta_scores = [Decimal(row["qta_total_score"]) for row in rows] + returns = [Decimal(row["return_60m_bps"]) for row in rows] + pearson_value = pearson( + [float(value) for value in qta_scores], + [float(value) for value in returns], + ) + spearman_value = pearson( + average_ranks(qta_scores), + average_ranks(returns), + ) + top_count = max( + 1, + int( + (Decimal(len(rows)) * top_fraction).to_integral_value( + rounding=ROUND_CEILING + ) + ), + ) + by_outcome = sorted( + rows, + key=lambda item: ( + -Decimal(item["return_60m_bps"]), + item["canonical_symbol"], + ), + ) + by_score = sorted( + rows, + key=lambda item: ( + -Decimal(item["qta_total_score"]), + item["canonical_symbol"], + ), + ) + winner_keys = { + row["canonical_symbol"] for row in by_outcome[:top_count] + } + score_keys = {row["canonical_symbol"] for row in by_score[:top_count]} + overlap = len(winner_keys & score_keys) + baseline = Decimal(top_count) / Decimal(len(rows)) + precision = Decimal(overlap) / Decimal(top_count) + lift = precision / baseline + deciles: list[dict[str, Any]] = [] + for index in range(10): + start = len(by_score) * index // 10 + end = len(by_score) * (index + 1) // 10 + bucket = by_score[start:end] + deciles.append( + { + "qta_score_decile": index + 1, + **group_summary(bucket), + } + ) + + def compact(row: dict[str, Any]) -> dict[str, Any]: + return { + key: row[key] + for key in ( + "canonical_symbol", + "qta_total_score", + "qta_short_score", + "qta_medium_score", + "qta_long_score", + "qta_risk_score", + "return_5_bps", + "return_20_bps", + "median_20d_dollar_turnover", + "opening_gap_bps", + "coverage_status", + "first_bar_time", + "last_bar_time", + "bar_count", + "return_60m_bps", + "maximum_excursion_bps", + "minimum_excursion_bps", + "entry_touched_by_minute_high", + "entry_crossed_by_bar_close", + "end_return_from_entry_bps", + ) + } + + return { + "count": len(rows), + "top_fraction": format(top_fraction, "f"), + "top_count": top_count, + "pearson_qta_total_vs_return_60m": rounded_metric(pearson_value), + "spearman_qta_total_vs_return_60m": rounded_metric(spearman_value), + "top_outcome_overlap_count": overlap, + "top_outcome_precision": format(precision, "f"), + "top_outcome_recall": format(precision, "f"), + "top_outcome_lift": format(lift, "f"), + "overall": group_summary(rows), + "qta_score_deciles": deciles, + "top_20_outcomes": [compact(row) for row in by_outcome[:20]], + "bottom_20_outcomes": [compact(row) for row in by_outcome[-20:]], + "top_20_qta_scores": [compact(row) for row in by_score[:20]], + } + + +def evaluate(job: dict[str, Any]) -> dict[str, Any]: + screen_value = normalized_screen(job["screen_path"]) + snapshot = normalized_snapshot(job["snapshot_path"]) + rows, qta_blocked = combine_rows( + screen_value=screen_value, + snapshot=snapshot, + ) + if not rows: + raise EvaluationBlockedError("no READY QTA/opening-hour joined rows") + analysis_rows = [ + row + for row in rows + if row["coverage_status"] + in { + "COMPLETE_GRID", + "EXACT_ENDPOINTS_SPARSE", + "NEAR_ENDPOINTS", + } + ] + if not analysis_rows: + raise EvaluationBlockedError( + "no joined rows have complete or near-complete opening-hour endpoints" + ) + diagnostics = { + exchange: exchange_diagnostics( + [row for row in analysis_rows if row["exchange"] == exchange], + top_fraction=job["top_fraction"], + ) + for exchange in intraday.MARKET_CONTRACTS[snapshot["market"]]["exchanges"] + if any(row["exchange"] == exchange for row in analysis_rows) + } + without_hash = { + "schema": DATASET_SCHEMA, + "source_skill": "quant-stock-technical", + "baseline_method_version": screen_value["method_version"], + "target_definition": ( + "last observed close divided by first observed open; no interpolation; " + "COMPLETE_GRID, EXACT_ENDPOINTS_SPARSE, and NEAR_ENDPOINTS only" + ), + "market": snapshot["market"], + "analysis_date": snapshot["analysis_date"], + "session_date": snapshot["session_date"], + "screen_path": str(job["screen_path"]), + "screen_hash": screen_value["screen_hash"], + "snapshot_path": str(job["snapshot_path"]), + "snapshot_hash": snapshot["snapshot_hash"], + "top_fraction": format(job["top_fraction"], "f"), + "joined_count": len(rows), + "analysis_eligible_count": len(analysis_rows), + "coverage_counts": snapshot["coverage_counts"], + "intraday_blocked_count": snapshot["blocked_count"], + "qta_blocked_after_join_count": len(qta_blocked), + "diagnostics_by_exchange": diagnostics, + "rows": rows, + "qta_blocked_after_join": qta_blocked, + "api_mutation_count": 0, + "live_enabled": False, + } + output = { + **without_hash, + "evaluation_hash": shared.sha256_bytes( + shared.canonical_json(without_hash).encode("utf-8") + ), + } + shared.atomic_write_json(job["output_path"], output) + return output + + +def self_test() -> None: + sample = [ + { + "canonical_symbol": f"T{index}", + "qta_total_score": str(index), + "qta_short_score": str(index), + "qta_medium_score": str(index), + "qta_long_score": str(index), + "qta_risk_score": str(100 - index), + "return_5_bps": str(index), + "return_20_bps": str(index), + "median_20d_dollar_turnover": str(index * 1000000), + "opening_gap_bps": "0", + "coverage_status": "COMPLETE_GRID", + "first_bar_time": "093000", + "last_bar_time": "103000", + "bar_count": 13, + "return_60m_bps": str(index * 2), + "maximum_excursion_bps": str(index * 3), + "minimum_excursion_bps": str(-index), + "entry_touched_by_minute_high": index % 2 == 0, + "entry_crossed_by_bar_close": index % 3 == 0, + "end_return_from_entry_bps": str(index), + } + for index in range(1, 21) + ] + diagnostics = exchange_diagnostics(sample, top_fraction=Decimal("0.1")) + assert diagnostics["spearman_qta_total_vs_return_60m"] == "1.000000" + assert diagnostics["top_outcome_overlap_count"] == 2 + bars = intraday.synthetic_rows(market="US", session_date="2026-07-28") + artifact = intraday.bars_artifact( + instrument={ + "exchange": "NASDAQ", + "canonical_symbol": "TEST", + "broker_symbol": "TEST", + }, + market="US", + session_date="2026-07-28", + raw_rows=bars, + ) + assert first_bar_close_cross(artifact["bars"], Decimal("101.2")) is not None + with tempfile.TemporaryDirectory(prefix="qta-open1h-evaluation-") as temporary: + output = Path(temporary) / "result.json" + shared.atomic_write_json(output, {"self_test": "PASS"}) + assert load_object(output, "self-test")["self_test"] == "PASS" + print( + shared.canonical_json( + { + "self_test": "PASS", + "schema": DATASET_SCHEMA, + "api_mutation_count": 0, + "live_enabled": False, + } + ) + ) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--self-test", action="store_true") + subparsers = parser.add_subparsers(dest="command") + evaluate_parser = subparsers.add_parser("evaluate") + evaluate_parser.add_argument("--job", required=True) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + try: + if args.self_test: + self_test() + return 0 + if args.command != "evaluate": + raise EvaluationBlockedError("use evaluate --job or --self-test") + job_path = absolute_file(args.job, "job path", must_exist=True) + result = evaluate(load_job(job_path)) + except ( + EvaluationBlockedError, + intraday.ResearchBlockedError, + screen.ScreenBlockedError, + OSError, + json.JSONDecodeError, + ) as exc: + print( + shared.canonical_json( + { + "status": "BLOCKED", + "reason": str(exc), + "api_mutation_count": 0, + "live_enabled": False, + } + ), + file=sys.stderr, + ) + return 2 + print( + shared.canonical_json( + { + "status": "READY", + "market": result["market"], + "joined_count": result["joined_count"], + "evaluation_hash": result["evaluation_hash"], + "api_mutation_count": 0, + "live_enabled": False, + } + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/skills/quant-stock-technical/scripts/screen_universe.py b/skills/quant-stock-technical/scripts/screen_universe.py index 48dc5a4..c4f97f8 100755 --- a/skills/quant-stock-technical/scripts/screen_universe.py +++ b/skills/quant-stock-technical/scripts/screen_universe.py @@ -1,11 +1,13 @@ #!/usr/bin/env python3 -"""Run qta-1.0.0 across a frozen v1/v2 universe and apply its selector.""" +"""Run an approved QTA method across a frozen v1/v2 universe.""" from __future__ import annotations import argparse +import concurrent.futures import hashlib import json +import os import re import sys from datetime import date @@ -14,6 +16,7 @@ from typing import Any import analyze_stock as qta +import analyze_stock_v2 as qta2 MANIFEST_SCHEMA = "qta-universe-manifest/v1" SCREEN_SCHEMA = "qta-screen/v1" @@ -952,13 +955,18 @@ def instrument_tick_size(instrument: dict[str, Any]) -> str: return str(instrument["tick_contract"]["resolved_tick_size"]) -def blocked_payload(instrument: dict[str, Any], reason: str) -> dict[str, Any]: +def blocked_payload( + instrument: dict[str, Any], + reason: str, + *, + method_version: str, +) -> dict[str, Any]: return { "source_skill": "quant-stock-technical", "result_schema": "quant-stock-technical/v1", "calculation_status": "BLOCKED", "reason": reason, - "method_version": qta.METHOD_VERSION, + "method_version": method_version, "market": instrument_market(instrument), "ticker": instrument_ticker(instrument), } @@ -968,6 +976,8 @@ def analyze_instrument( instrument: dict[str, Any], analysis_date: date, manifest_directory: Path, + *, + calculator: Any = qta, ) -> dict[str, Any]: ticker_path = Path(instrument["ticker_csv"]) benchmark_path = Path(instrument["benchmark_csv"]) @@ -988,7 +998,7 @@ def analyze_instrument( qta.read_csv(str(benchmark_path)), analysis_date, ) - return qta.calculate( + return calculator.calculate( ticker_rows, benchmark_rows, instrument_market(instrument), @@ -997,7 +1007,32 @@ def analyze_instrument( instrument["source_name"], ) except (qta.BlockedError, OSError, ValueError) as exc: - return blocked_payload(instrument, str(exc)) + return blocked_payload( + instrument, + str(exc), + method_version=calculator.METHOD_VERSION, + ) + + +def analyze_instrument_task( + task: tuple[dict[str, Any], str, str, str], +) -> dict[str, Any]: + instrument, analysis_date_text, manifest_directory_text, method_version = task + calculators = { + qta.METHOD_VERSION: qta, + qta2.METHOD_VERSION: qta2, + } + calculator = calculators.get(method_version) + if calculator is None: + raise ScreenBlockedError( + f"unsupported QTA method version: {method_version!r}" + ) + return analyze_instrument( + instrument, + date.fromisoformat(analysis_date_text), + Path(manifest_directory_text), + calculator=calculator, + ) def score(payload: dict[str, Any], path: str) -> Decimal: @@ -1012,6 +1047,16 @@ def score(payload: dict[str, Any], path: str) -> Decimal: def ranking_key(payload: dict[str, Any]) -> tuple[Any, ...]: + if payload.get("method_version") == qta2.METHOD_VERSION: + return ( + -score(payload, "total_score"), + -score(payload, "short.score"), + -score(payload, "medium.score"), + score(payload, "risk.score"), + -score(payload, "long.score"), + str(payload["market"]), + str(payload["ticker"]), + ) return ( -score(payload, "total_score"), -score(payload, "medium.score"), @@ -1112,11 +1157,16 @@ def finalize_screen( decision["ticker"], ) in selected_keys + method_versions = { + str(result.get("method_version")) for result in ordered_results + } + if len(method_versions) != 1: + raise ScreenBlockedError("screen results contain multiple QTA methods") output = { "source_skill": "quant-stock-technical", "schema": SCREEN_SCHEMA, "screen_status": screen_status, - "method_version": qta.METHOD_VERSION, + "method_version": method_versions.pop(), "selector_version": SELECTOR_VERSION, "analysis_date": manifest["analysis_date"], "manifest_hash": sha256_json(manifest), @@ -1240,11 +1290,16 @@ def finalize_screen_v2( for key in selected_keys: decision_by_key[key]["selected"] = True + method_versions = { + str(result.get("method_version")) for result in ordered_results + } + if len(method_versions) != 1: + raise ScreenBlockedError("screen results contain multiple QTA methods") output = { "source_skill": "quant-stock-technical", "schema": SCREEN_SCHEMA_V2, "screen_status": screen_status, - "method_version": qta.METHOD_VERSION, + "method_version": method_versions.pop(), "selector_version": SELECTOR_VERSION_V2, "analysis_date": manifest["analysis_date"], "manifest_hash": manifest["manifest_hash"], @@ -1264,6 +1319,9 @@ def build_screen( manifest: dict[str, Any], selector: dict[str, Any], manifest_directory: Path, + *, + method_version: str = qta.METHOD_VERSION, + workers: int = 1, ) -> dict[str, Any]: normalized_manifest = normalize_manifest(manifest) normalized_selector = normalize_selector(selector) @@ -1278,10 +1336,39 @@ def build_screen( ): raise ScreenBlockedError("qta-universe-manifest/v2 requires qta-screen-1.1.0") analysis_date = date.fromisoformat(normalized_manifest["analysis_date"]) - results = [ - analyze_instrument(instrument, analysis_date, manifest_directory) + calculators = { + qta.METHOD_VERSION: qta, + qta2.METHOD_VERSION: qta2, + } + calculator = calculators.get(method_version) + if calculator is None: + raise ScreenBlockedError( + f"unsupported QTA method version: {method_version!r}" + ) + if isinstance(workers, bool) or workers <= 0 or workers > 32: + raise ScreenBlockedError("workers must be an integer between 1 and 32") + tasks = [ + ( + instrument, + analysis_date.isoformat(), + str(manifest_directory), + calculator.METHOD_VERSION, + ) for instrument in normalized_manifest["instruments"] ] + if workers == 1: + results = [analyze_instrument_task(task) for task in tasks] + else: + with concurrent.futures.ProcessPoolExecutor( + max_workers=workers + ) as executor: + results = list( + executor.map( + analyze_instrument_task, + tasks, + chunksize=8, + ) + ) if normalized_manifest["schema"] == MANIFEST_SCHEMA: return finalize_screen(normalized_manifest, normalized_selector, results) return finalize_screen_v2(normalized_manifest, normalized_selector, results) @@ -1763,6 +1850,17 @@ def parse_args() -> argparse.Namespace: parser.add_argument("--manifest") parser.add_argument("--selector") parser.add_argument("--output") + parser.add_argument( + "--method-version", + choices=(qta.METHOD_VERSION, qta2.METHOD_VERSION), + default=qta.METHOD_VERSION, + ) + parser.add_argument( + "--workers", + type=int, + default=0, + help="worker processes; 0 selects up to 8 local CPUs", + ) parser.add_argument("--self-test", action="store_true") return parser.parse_args() @@ -1772,8 +1870,33 @@ def emit(value: dict[str, Any], output: str | None) -> None: value, ensure_ascii=False, sort_keys=True, indent=2, allow_nan=False ) if output: - Path(output).write_text(rendered + "\n", encoding="utf-8") - print(rendered) + output_path = Path(output) + output_path.write_text(rendered + "\n", encoding="utf-8") + summary = { + "source_skill": value.get( + "source_skill", + "quant-stock-technical", + ), + "schema": value.get("schema"), + "screen_status": value.get("screen_status"), + "method_version": value.get("method_version"), + "analysis_date": value.get("analysis_date"), + "blocked_count": value.get("blocked_count"), + "screen_hash": value.get("screen_hash"), + "reason": value.get("reason"), + "output": str(output_path.resolve()), + } + print( + json.dumps( + summary, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ) + ) + else: + print(rendered) def main() -> int: @@ -1802,6 +1925,12 @@ def main() -> int: manifest, load_object(Path(args.selector).resolve()), manifest_path.parent, + method_version=args.method_version, + workers=( + min(8, max(1, os.cpu_count() or 1)) + if args.workers == 0 + else args.workers + ), ) except (ScreenBlockedError, OSError, ValueError, json.JSONDecodeError) as exc: emit( diff --git a/tests/test_position_lifecycle.py b/tests/test_position_lifecycle.py new file mode 100644 index 0000000..2df4f6a --- /dev/null +++ b/tests/test_position_lifecycle.py @@ -0,0 +1,565 @@ +#!/usr/bin/env python3 +"""Regression tests for deterministic strategy-position management.""" + +from __future__ import annotations + +import importlib.util +import sys +import tempfile +import unittest +from datetime import datetime, timedelta, timezone +from decimal import Decimal +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "skills" / "quant-stock-polling-trader" / "scripts" +sys.path.insert(0, str(SCRIPTS)) +SPEC = importlib.util.spec_from_file_location( + "qta_position_lifecycle", + SCRIPTS / "position_lifecycle.py", +) +if SPEC is None or SPEC.loader is None: + raise RuntimeError("cannot load position_lifecycle.py") +MODULE = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = MODULE +SPEC.loader.exec_module(MODULE) + +import broker_adapters as BROKERS +import run_session as RUNNER + + +def policy() -> dict[str, object]: + return { + "schema": MODULE.POSITION_POLICY_SCHEMA, + "close_liquidation_seconds_before_close": 600, + "exit_order_ttl_seconds": 15, + "max_exit_replacements": 3, + "quote_max_age_seconds": 5, + "partial_entry_action": "CANCEL_REMAINDER_MANAGE_FILLED", + "gap_down_action": "MARKETABLE_LIMIT", + "daily_loss_action": "ENTRY_FREEZE_LIQUIDATE", + "overnight_residual_action": "EXIT_ONLY_NEXT_SESSION", + } + + +def intent() -> dict[str, str]: + return { + "intent_id": "b" * 32, + "market": "KR", + "exchange": "KOSPI", + "symbol": "005930", + "venue": "KRX", + "currency": "KRW", + "quantity": "10", + "stop_price": "65000", + "take_profit_price": "80000", + "resolved_tick_size": "100", + } + + +def open_position(quantity: str = "10") -> dict[str, object]: + value = intent() + value["quantity"] = quantity + position = MODULE.position_from_intent( + plan_hash="a" * 64, + intent=value, + session_date="2026-07-29", + fx_to_krw="1", + ) + return MODULE.record_entry_fill( + position, + cumulative_filled_quantity=quantity, + average_fill_price="70000", + session_date="2026-07-29", + ) + + +def quote( + now: datetime, + *, + last: str, + bid: str, + ask: str, +) -> dict[str, str]: + return { + "market": "KR", + "symbol": "005930", + "last_price": last, + "best_bid": bid, + "best_ask": ask, + "trade_timestamp": now.isoformat(), + "book_timestamp": now.isoformat(), + "received_at": now.isoformat(), + } + + +def account_snapshot( + *, + strategy_quantity: str, + open_orders: list[dict[str, str]] | None = None, +) -> dict[str, object]: + return { + "schema": "qta-account-snapshot/v2", + "broker": "kis", + "environment": "shadow", + "account_alias": "live-main", + "market": "KR", + "currency": "KRW", + "as_of": "2026-07-30T08:50:00+09:00", + "settled_cash": "150000", + "borrowed_buying_power": "0", + "fx_to_krw": "1", + "positions": [ + { + "market": "KR", + "exchange": "KOSPI", + "symbol": "005930", + "quantity": strategy_quantity, + "market_value_krw": "700000", + }, + { + "market": "KR", + "exchange": "KOSDAQ", + "symbol": "035900", + "quantity": "1", + "market_value_krw": "100000", + }, + ], + "open_orders": open_orders or [], + } + + +class PositionLifecycleTests(unittest.TestCase): + def setUp(self) -> None: + self.zone = timezone(timedelta(hours=9)) + self.open = datetime(2026, 7, 29, 9, 0, tzinfo=self.zone) + self.close = datetime(2026, 7, 29, 15, 30, tzinfo=self.zone) + + def decide( + self, + position: dict[str, object], + observed: datetime, + *, + last: str, + bid: str, + ask: str, + first: bool = False, + daily_loss: bool = False, + ) -> dict[str, object]: + return MODULE.exit_decision( + position, + quote(observed, last=last, bid=bid, ask=ask), + policy(), + observed_at=observed, + regular_close=self.close, + first_valid_quote_of_session=first, + daily_loss_breached=daily_loss, + ) + + def test_gap_below_stop_exits_at_current_bid(self) -> None: + decision = self.decide( + open_position(), + self.open, + last="64000", + bid="63900", + ask="64000", + first=True, + ) + self.assertEqual(decision["action"], "SUBMIT_EXIT") + self.assertEqual(decision["reason"], "STOP_GAP") + self.assertEqual(decision["limit_price"], "63900") + + def test_stop_take_profit_and_hold_are_distinct(self) -> None: + position = open_position() + stop = self.decide( + position, + self.open + timedelta(minutes=1), + last="65000", + bid="65000", + ask="65100", + ) + target = self.decide( + position, + self.open + timedelta(minutes=2), + last="80100", + bid="80000", + ask="80100", + ) + hold = self.decide( + position, + self.open + timedelta(minutes=3), + last="72000", + bid="71900", + ask="72000", + ) + self.assertEqual(stop["reason"], "STOP") + self.assertEqual(target["reason"], "TAKE_PROFIT") + self.assertEqual(hold["action"], "HOLD") + + def test_close_liquidation_begins_ten_minutes_before_close(self) -> None: + before = self.decide( + open_position(), + self.close - timedelta(seconds=601), + last="72000", + bid="71900", + ask="72000", + ) + at_cutoff = self.decide( + open_position(), + self.close - timedelta(seconds=600), + last="72000", + bid="71900", + ask="72000", + ) + self.assertEqual(before["action"], "HOLD") + self.assertEqual(at_cutoff["reason"], "MARKET_CLOSE") + + def test_residual_after_close_is_manual_block(self) -> None: + decision = self.decide( + open_position(), + self.close, + last="72000", + bid="71900", + ask="72000", + ) + self.assertEqual(decision["action"], "MANUAL_BLOCK") + self.assertEqual(decision["reason"], "MARKET_CLOSED_WITH_RESIDUAL") + self.assertIsNone(decision["quote"]) + + def test_daily_loss_has_priority(self) -> None: + decision = self.decide( + open_position(), + self.open + timedelta(minutes=1), + last="80000", + bid="79900", + ask="80000", + daily_loss=True, + ) + self.assertEqual(decision["reason"], "DAILY_LOSS_LIMIT") + + def test_partial_entry_cancels_remainder_and_manages_fill(self) -> None: + position = MODULE.position_from_intent( + plan_hash="a" * 64, + intent=intent(), + session_date="2026-07-29", + fx_to_krw="1", + ) + position = MODULE.record_entry_fill( + position, + cumulative_filled_quantity="3", + average_fill_price="70000", + session_date="2026-07-29", + ) + self.assertTrue( + MODULE.partial_entry_requires_cancel( + position, broker_remaining_quantity="7" + ) + ) + self.assertEqual(MODULE.open_quantity(position), Decimal(3)) + + def test_partial_exit_preserves_only_remaining_quantity(self) -> None: + position = MODULE.record_exit_fill( + open_position(), + cumulative_exited_quantity="4", + average_exit_price="68000", + ) + self.assertEqual(position["status"], "EXIT_PARTIAL") + self.assertEqual(MODULE.open_quantity(position), Decimal(6)) + + def test_carry_rolls_to_exit_only_and_does_not_become_new_entry(self) -> None: + partially_exited = MODULE.record_exit_fill( + open_position(), + cumulative_exited_quantity="2", + average_exit_price="68000", + ) + carried = MODULE.roll_position_session( + partially_exited, session_date="2026-07-30" + ) + self.assertEqual(carried["status"], "CARRY_EXIT_ONLY") + self.assertIsNone(carried["daily_baseline_price"]) + based = MODULE.set_daily_baseline( + carried, + session_date="2026-07-30", + price="69000", + ) + with self.assertRaisesRegex(MODULE.BlockedError, "immutable"): + MODULE.set_daily_baseline( + based, + session_date="2026-07-30", + price="69100", + ) + finished = MODULE.record_exit_fill( + based, + cumulative_exited_quantity="10", + average_exit_price="69600", + ) + self.assertEqual(finished["status"], "CLOSED") + self.assertEqual(finished["daily_realized_pnl_native"], "8000") + + def test_loss_threshold_is_exact_and_current_150k_cannot_reach_2m(self) -> None: + self.assertFalse( + MODULE.daily_loss_limit_breached("150000", "2000000") + ) + self.assertTrue( + MODULE.daily_loss_limit_breached("2000000", "2000000") + ) + + def test_stale_or_mismatched_quote_never_triggers_exit(self) -> None: + observed = self.open + timedelta(seconds=10) + stale = quote( + self.open, + last="64000", + bid="63900", + ask="64000", + ) + with self.assertRaisesRegex(MODULE.BlockedError, "stale"): + MODULE.exit_decision( + open_position(), + stale, + policy(), + observed_at=observed, + regular_close=self.close, + first_valid_quote_of_session=True, + daily_loss_breached=False, + ) + + def test_position_registry_survives_session_directories(self) -> None: + carried = MODULE.roll_position_session( + open_position(), session_date="2026-07-30" + ) + with tempfile.TemporaryDirectory(prefix="qta-position-test-") as temporary: + path = Path(temporary) / "account" / "KR" / "positions.sqlite3" + ledger = MODULE.PositionLedger(path) + try: + ledger.put( + carried, + event_type="POSITION_CARRIED", + event_payload={"from": "2026-07-29", "to": "2026-07-30"}, + ) + finally: + ledger.close() + reopened = MODULE.PositionLedger(path) + try: + stored = reopened.open_positions() + self.assertEqual(len(stored), 1) + self.assertEqual(stored[0].status, "CARRY_EXIT_ONLY") + finally: + reopened.close() + + def test_entry_fill_snapshot_creates_and_updates_persistent_position( + self, + ) -> None: + plan = { + "plan_hash": "a" * 64, + "frozen_inputs": {"account": {"fx_to_krw": "1"}}, + } + with tempfile.TemporaryDirectory(prefix="qta-fill-ledger-") as temporary: + path = Path(temporary) / "account" / "KR" / "positions.sqlite3" + ledger = MODULE.PositionLedger(path) + try: + self.assertIsNone( + RUNNER.persist_entry_fill( + ledger, + plan=plan, + intent=intent(), + snapshot={ + "filled_quantity": "0", + "average_fill_price": None, + }, + session_date="2026-07-29", + ) + ) + self.assertEqual(ledger.open_positions(), []) + first = RUNNER.persist_entry_fill( + ledger, + plan=plan, + intent=intent(), + snapshot={ + "broker_order_id": "123", + "normalized_status": "PARTIALLY_FILLED", + "ordered_quantity": "10", + "filled_quantity": "3", + "average_fill_price": "70000", + "remaining_quantity": "7", + }, + session_date="2026-07-29", + ) + self.assertIsNotNone(first) + self.assertEqual(first["acquired_quantity"], "3") + position_id = first["position_id"] + self.assertEqual(len(ledger.events(position_id)), 1) + repeated = RUNNER.persist_entry_fill( + ledger, + plan=plan, + intent=intent(), + snapshot={ + "broker_order_id": "123", + "normalized_status": "PARTIALLY_FILLED", + "ordered_quantity": "10", + "filled_quantity": "3", + "average_fill_price": "70000", + "remaining_quantity": "7", + }, + session_date="2026-07-29", + ) + self.assertEqual(repeated, first) + self.assertEqual(len(ledger.events(position_id)), 1) + updated = RUNNER.persist_entry_fill( + ledger, + plan=plan, + intent=intent(), + snapshot={ + "broker_order_id": "123", + "normalized_status": "PARTIALLY_FILLED", + "ordered_quantity": "10", + "filled_quantity": "5", + "average_fill_price": "70100", + "remaining_quantity": "5", + }, + session_date="2026-07-29", + ) + self.assertEqual(updated["acquired_quantity"], "5") + self.assertEqual(updated["average_entry_price"], "70100") + self.assertEqual(len(ledger.events(position_id)), 2) + finally: + ledger.close() + + def test_entry_fill_without_average_price_is_blocked(self) -> None: + plan = { + "plan_hash": "a" * 64, + "frozen_inputs": {"account": {"fx_to_krw": "1"}}, + } + with tempfile.TemporaryDirectory(prefix="qta-fill-ledger-") as temporary: + ledger = MODULE.PositionLedger(Path(temporary) / "positions.sqlite3") + try: + with self.assertRaisesRegex( + MODULE.BlockedError, "average_fill_price" + ): + RUNNER.persist_entry_fill( + ledger, + plan=plan, + intent=intent(), + snapshot={ + "filled_quantity": "1", + "average_fill_price": None, + }, + session_date="2026-07-29", + ) + self.assertEqual(ledger.open_positions(), []) + finally: + ledger.close() + + def test_kis_order_snapshots_expose_authoritative_average_fill_price( + self, + ) -> None: + broker = BROKERS.KisBroker( + app_key="key", + app_secret="secret", + account_prefix="12345678", + account_product="01", + environment="live", + access_token="token", + ) + cases = ( + ( + "KR", + { + "odno": "123", + "ord_qty": "10", + "tot_ccld_qty": "3", + "rmn_qty": "7", + "avg_prvs": "70000", + }, + "70000", + ), + ( + "US", + { + "odno": "123", + "ft_ord_qty": "10", + "ft_ccld_qty": "3", + "nccs_qty": "7", + "ft_ccld_unpr3": "201.25", + }, + "201.25", + ), + ) + for market, record, expected in cases: + broker.order_history = lambda **_kwargs: [record] + snapshot = broker.get_order( + market=market, + trading_date="2026-07-29", + symbol="005930" if market == "KR" else "AAPL", + venue="KRX" if market == "KR" else "NASD", + broker_order_id="123", + ) + self.assertEqual(snapshot["average_fill_price"], expected) + self.assertEqual(snapshot["normalized_status"], "PARTIALLY_FILLED") + + def test_account_reconciliation_rolls_exact_carry_and_lists_manual_holdings( + self, + ) -> None: + with tempfile.TemporaryDirectory(prefix="qta-reconcile-") as temporary: + ledger = MODULE.PositionLedger(Path(temporary) / "positions.sqlite3") + try: + position = open_position() + ledger.put( + position, + event_type="ENTRY_FILL_CREATED", + event_payload={"filled_quantity": "10"}, + ) + receipt = MODULE.reconcile_account_positions( + ledger, + account_snapshot=account_snapshot(strategy_quantity="10"), + session_date="2026-07-30", + ) + self.assertEqual(receipt["status"], "READY") + self.assertEqual( + receipt["managed_positions"][0]["state"], + "CARRY_EXIT_ONLY", + ) + self.assertEqual( + receipt["unmanaged_broker_positions"], + [ + { + "exchange": "KOSDAQ", + "symbol": "035900", + "quantity": "1", + } + ], + ) + self.assertEqual(receipt["api_mutation_count"], 0) + self.assertFalse(receipt["live_enabled"]) + finally: + ledger.close() + + def test_account_quantity_mismatch_becomes_manual_block(self) -> None: + with tempfile.TemporaryDirectory(prefix="qta-reconcile-") as temporary: + ledger = MODULE.PositionLedger(Path(temporary) / "positions.sqlite3") + try: + position = open_position() + ledger.put( + position, + event_type="ENTRY_FILL_CREATED", + event_payload={"filled_quantity": "10"}, + ) + receipt = MODULE.reconcile_account_positions( + ledger, + account_snapshot=account_snapshot(strategy_quantity="9"), + session_date="2026-07-30", + ) + self.assertEqual(receipt["status"], "MANUAL_BLOCK") + self.assertIn( + "broker_quantity_differs_from_strategy_ledger", + receipt["managed_positions"][0]["reasons"], + ) + self.assertEqual( + ledger.open_positions()[0].status, + "MANUAL_BLOCK", + ) + finally: + ledger.close() + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qta2_execution.py b/tests/test_qta2_execution.py new file mode 100644 index 0000000..b6d4f81 --- /dev/null +++ b/tests/test_qta2_execution.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Regression tests for the QTA 2.0 shadow execution contract.""" + +from __future__ import annotations + +import sys +import unittest +from copy import deepcopy +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "skills" / "quant-stock-polling-trader" / "scripts" +sys.path.insert(0, str(ROOT)) +sys.path.insert(0, str(SCRIPTS)) + +import execution_core +import run_session +from tests.test_quant_execution import ( + complete_v2_screen, + rehash_screen, + v2_selected_item, +) + + +def qta2_screen() -> dict[str, object]: + selected = { + "KOSPI": [ + v2_selected_item( + "KOSPI", + 1, + "005930", + "005930", + "005930", + ) + ], + "KOSDAQ": [], + "NYSE": [], + "NASDAQ": [], + } + screen = complete_v2_screen(execution_core, selected) + screen["method_version"] = execution_core.QTA_METHOD_V2 + payloads = [ + selected["KOSPI"][0]["qta"], + screen["decisions"][0]["qta"], + ] + for payload in payloads: + payload.update( + { + "method_version": execution_core.QTA_METHOD_V2, + "validation_status": "RESEARCH_ONLY", + "score_basis": execution_core.QTA2_SCORE_BASIS, + "liquidity": { + "median_20_session_turnover": 2_000_000_000.0, + "minimum_turnover": 1_000_000_000.0, + "currency": "KRW", + "status": "READY", + }, + "market_regime": { + "metric": execution_core.QTA2_REGIME_METRIC, + "minimum_change_bps": 0.0, + "max_age_seconds": ( + execution_core.QTA2_REGIME_MAX_AGE_SECONDS + ), + "status": "REQUIRED", + }, + "assumptions": list(execution_core.QTA2_ASSUMPTIONS), + } + ) + screen["selected"]["KOSPI"][0]["qta"] = deepcopy(payloads[0]) + rehash_screen(execution_core, screen) + return screen + + +class Qta2ExecutionTests(unittest.TestCase): + def test_qta2_screen_is_valid_and_shadow_only(self) -> None: + screen = qta2_screen() + execution_core.validate_screen(screen) + plan = { + "context": {"broker": "kis", "environment": "shadow"}, + "frozen_inputs": {"screen": screen}, + } + run_session.validate_mode(plan, "kis-live", "shadow") + with self.assertRaisesRegex( + execution_core.BlockedError, "RESEARCH_ONLY" + ): + paper_plan = { + "context": {"broker": "kis", "environment": "paper"}, + "frozen_inputs": {"screen": screen}, + } + run_session.validate_mode(paper_plan, "kis-paper", "paper") + + def test_qta2_formula_tamper_is_rejected(self) -> None: + screen = qta2_screen() + screen["selected"]["KOSPI"][0]["qta"]["total_score"] = 71.0 + screen["decisions"][0]["qta"]["total_score"] = 71.0 + rehash_screen(execution_core, screen) + with self.assertRaisesRegex( + execution_core.BlockedError, + "qta-2.0.0 score formula", + ): + execution_core.validate_screen(screen) + + def test_qta2_liquidity_status_tamper_is_rejected(self) -> None: + screen = qta2_screen() + for payload in ( + screen["selected"]["KOSPI"][0]["qta"], + screen["decisions"][0]["qta"], + ): + payload["liquidity"]["status"] = "BLOCKED" + rehash_screen(execution_core, screen) + with self.assertRaisesRegex( + execution_core.BlockedError, + "liquidity.status", + ): + execution_core.validate_screen(screen) + + def test_qta2_market_regime_tamper_is_rejected(self) -> None: + screen = qta2_screen() + for payload in ( + screen["selected"]["KOSPI"][0]["qta"], + screen["decisions"][0]["qta"], + ): + payload["market_regime"]["minimum_change_bps"] = -50.0 + rehash_screen(execution_core, screen) + with self.assertRaisesRegex( + execution_core.BlockedError, + "minimum_change_bps", + ): + execution_core.validate_screen(screen) + + def test_screen_and_nested_qta_methods_must_match(self) -> None: + screen = qta2_screen() + screen["method_version"] = execution_core.QTA_METHOD_V1 + rehash_screen(execution_core, screen) + with self.assertRaisesRegex( + execution_core.BlockedError, + "method differs", + ): + execution_core.validate_screen(screen) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_qta2_market_regime.py b/tests/test_qta2_market_regime.py new file mode 100644 index 0000000..20cc733 --- /dev/null +++ b/tests/test_qta2_market_regime.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Regression tests for QTA2 same-session market-regime admission.""" + +from __future__ import annotations + +import sys +import unittest +from datetime import datetime, timedelta, timezone +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCRIPTS = ROOT / "skills" / "quant-stock-polling-trader" / "scripts" +sys.path.insert(0, str(SCRIPTS)) + +from broker_adapters import HttpResponse, KisBroker, QueueTransport +from execution_core import BlockedError +from run_session import evaluate_market_regime + + +def kis_broker(responses: list[HttpResponse], *, environment: str = "live") -> KisBroker: + return KisBroker( + app_key="test-key", + app_secret="test-secret", + account_prefix="12345678", + account_product="01", + environment=environment, + transport=QueueTransport(responses), + access_token="test-token", + ) + + +class Qta2MarketRegimeTests(unittest.TestCase): + def test_kr_benchmark_quote_and_admission(self) -> None: + broker = kis_broker( + [ + HttpResponse( + 200, + {}, + { + "rt_cd": "0", + "msg_cd": "0", + "msg1": "OK", + "output": [ + { + "stck_cntg_hour": "090005", + "bstp_nmix_prpr": "3200", + "bstp_nmix_prdy_vrss": "16", + "prdy_vrss_sign": "2", + } + ], + }, + ) + ] + ) + quote = broker.benchmark_quote( + exchange="KOSPI", + session_date="2026-07-29", + ) + observed = datetime.fromisoformat(quote["source_timestamp"]) + timedelta( + seconds=1 + ) + quote["received_at"] = observed.astimezone(timezone.utc).isoformat() + decision = evaluate_market_regime( + exchange="KOSPI", + quote=quote, + observed_at=observed, + session_date="2026-07-29", + ) + self.assertTrue(decision["quality_valid"]) + self.assertTrue(decision["admitted"]) + self.assertEqual(quote["previous_close"], "3184") + + def test_negative_benchmark_blocks_entry(self) -> None: + now = datetime(2026, 7, 29, 9, 0, 5, tzinfo=timezone(timedelta(hours=9))) + quote = { + "schema": "qta-market-regime-quote/v1", + "broker": "kis", + "exchange": "KOSDAQ", + "benchmark_id": "KOSDAQ_COMPOSITE", + "regime_proxy_id": "KOSDAQ_COMPOSITE", + "provider_symbol": "1001", + "current": "990", + "previous_close": "1000", + "change_bps": "-100.00", + "source_timestamp": now.isoformat(), + "received_at": now.isoformat(), + "raw_status": "OK", + } + decision = evaluate_market_regime( + exchange="KOSDAQ", + quote=quote, + observed_at=now, + session_date="2026-07-29", + ) + self.assertTrue(decision["quality_valid"]) + self.assertFalse(decision["admitted"]) + self.assertIn("benchmark_below_minimum", decision["reasons"]) + + def test_stale_benchmark_blocks_entry(self) -> None: + now = datetime(2026, 7, 29, 9, 2, 0, tzinfo=timezone(timedelta(hours=9))) + stale = now - timedelta(seconds=91) + quote = { + "schema": "qta-market-regime-quote/v1", + "broker": "kis", + "exchange": "KOSPI", + "benchmark_id": "KOSPI_COMPOSITE", + "regime_proxy_id": "KOSPI_COMPOSITE", + "provider_symbol": "0001", + "current": "1001", + "previous_close": "1000", + "change_bps": "10.00", + "source_timestamp": stale.isoformat(), + "received_at": now.isoformat(), + "raw_status": "OK", + } + decision = evaluate_market_regime( + exchange="KOSPI", + quote=quote, + observed_at=now, + session_date="2026-07-29", + ) + self.assertFalse(decision["quality_valid"]) + self.assertFalse(decision["admitted"]) + self.assertIn("source_timestamp_stale", decision["reasons"]) + + def test_us_benchmark_uses_timestamped_intraday_price(self) -> None: + broker = kis_broker( + [ + HttpResponse( + 200, + {}, + { + "rt_cd": "0", + "msg_cd": "0", + "msg1": "OK", + "output1": { + "ovrs_nmix_prdy_clpr": "25000", + "ovrs_nmix_prpr": "25100", + }, + "output2": [ + { + "stck_bsop_date": "20260729", + "stck_cntg_hour": "093000", + "optn_prpr": "25025", + "optn_oprc": "25020", + "optn_hgpr": "25030", + "optn_lwpr": "25010", + "cntg_vol": "0", + } + ], + }, + ) + ] + ) + quote = broker.benchmark_quote( + exchange="NASDAQ", + session_date="2026-07-29", + ) + self.assertEqual(quote["current"], "25025") + self.assertEqual(quote["previous_close"], "25000") + self.assertEqual(quote["change_bps"], "10.00") + self.assertEqual(quote["regime_proxy_id"], "NASDAQ_COMPOSITE") + + def test_us_paper_benchmark_is_rejected(self) -> None: + broker = kis_broker([], environment="paper") + with self.assertRaisesRegex(BlockedError, "paper benchmark"): + broker.benchmark_quote( + exchange="NASDAQ", + session_date="2026-07-29", + ) + + +if __name__ == "__main__": + unittest.main()