From 1e5fe429c155dc07daadc30910242621475fc0b9 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:14:39 +0800 Subject: [PATCH 1/3] feat(finance): add contract exit liquidity gates Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../examples/contract-liquidity-v0.json | 36 ++ .../extension.toml | 2 +- .../pyproject.toml | 2 +- .../loopx_finance_value_discovery/__init__.py | 8 + .../src/loopx_finance_value_discovery/cli.py | 21 +- .../contract_liquidity.py | 320 ++++++++++++++++++ 6 files changed, 386 insertions(+), 3 deletions(-) create mode 100644 packages/loopx-finance-value-discovery/examples/contract-liquidity-v0.json create mode 100644 packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/contract_liquidity.py diff --git a/packages/loopx-finance-value-discovery/examples/contract-liquidity-v0.json b/packages/loopx-finance-value-discovery/examples/contract-liquidity-v0.json new file mode 100644 index 0000000000..0151f288bf --- /dev/null +++ b/packages/loopx-finance-value-discovery/examples/contract-liquidity-v0.json @@ -0,0 +1,36 @@ +{ + "schema_version": "finance_contract_liquidity_input_v0", + "evaluation_id": "synthetic-perp-exit-2026-01-15", + "instrument_ref": "synthetic-perpetual", + "contract_kind": "perpetual", + "quote_unit": "USD", + "observed_at": "2026-01-15T12:00:00Z", + "evaluation_as_of": "2026-01-15T12:00:30Z", + "maximum_age_seconds": 120, + "maximum_exit_cost_bps": "25", + "minimum_book_coverage_ratio": "1", + "scenarios": [ + { + "scenario_id": "close-long-1000", + "position_direction": "long", + "exit_side": "sell", + "requested_notional": "1000", + "executable_notional": "1200", + "spread_bps": "2", + "price_impact_bps": "4.5", + "fee_bps": "5", + "evidence_refs": ["public-book:snapshot-001"] + }, + { + "scenario_id": "close-short-1000", + "position_direction": "short", + "exit_side": "buy", + "requested_notional": "1000", + "executable_notional": "1100", + "spread_bps": "2.2", + "price_impact_bps": "5.1", + "fee_bps": "5", + "evidence_refs": ["public-book:snapshot-001"] + } + ] +} diff --git a/packages/loopx-finance-value-discovery/extension.toml b/packages/loopx-finance-value-discovery/extension.toml index 58493c09e1..3235aa2a13 100644 --- a/packages/loopx-finance-value-discovery/extension.toml +++ b/packages/loopx-finance-value-discovery/extension.toml @@ -1,6 +1,6 @@ schema_version = "loopx_extension_manifest_v0" id = "loopx-finance-value-discovery" -version = "0.7.0" +version = "0.8.0" requires_loopx_api = ">=1,<2" permissions = [] diff --git a/packages/loopx-finance-value-discovery/pyproject.toml b/packages/loopx-finance-value-discovery/pyproject.toml index 1d0752cd16..2955101a36 100644 --- a/packages/loopx-finance-value-discovery/pyproject.toml +++ b/packages/loopx-finance-value-discovery/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "loopx-finance-value-discovery" -version = "0.7.0" +version = "0.8.0" description = "Public-safe finance value-discovery extension for LoopX." readme = "README.md" requires-python = ">=3.11" diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/__init__.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/__init__.py index 5cbc795bb8..b562531e40 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/__init__.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/__init__.py @@ -16,6 +16,11 @@ FINANCE_CASE_INPUT_SCHEMA_VERSION, validate_finance_case_contract, ) +from .contract_liquidity import ( + FINANCE_CONTRACT_LIQUIDITY_EVALUATION_SCHEMA_VERSION, + FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION, + evaluate_finance_contract_liquidity, +) from .gates import evaluate_finance_case_gates from .metric_packs import ( FINANCE_METRIC_PACK_EVALUATION_SCHEMA_VERSION, @@ -55,6 +60,8 @@ "FINANCE_CASE_EVALUATION_SCHEMA_VERSION", "FINANCE_CASE_INPUT_SCHEMA_VERSION", "FINANCE_CASE_REPLAY_RECEIPT_SCHEMA_VERSION", + "FINANCE_CONTRACT_LIQUIDITY_EVALUATION_SCHEMA_VERSION", + "FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION", "FINANCE_METRIC_PACK_EVALUATION_SCHEMA_VERSION", "FINANCE_METRIC_PACK_INPUT_SCHEMA_VERSION", "FINANCE_METRIC_PACK_REPLAY_SCHEMA_VERSION", @@ -73,6 +80,7 @@ "build_finance_transaction_approval_packet", "build_finance_value_discovery_packet", "evaluate_finance_case_gates", + "evaluate_finance_contract_liquidity", "list_finance_metric_packs", "render_finance_value_discovery_markdown", "replay_finance_beta_attribution", diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py index 153cfd6233..ed787bf3c2 100644 --- a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/cli.py @@ -13,6 +13,10 @@ replay_finance_beta_attribution, ) from .contract import FINANCE_CASE_INPUT_SCHEMA_VERSION +from .contract_liquidity import ( + FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION, + evaluate_finance_contract_liquidity, +) from .metric_packs import ( FINANCE_METRIC_PACK_INPUT_SCHEMA_VERSION, build_finance_metric_pack_evaluation, @@ -137,6 +141,14 @@ def _direct_parser() -> argparse.ArgumentParser: action="store_true", help="Print only the canonical loopx_operation_request_v0 object.", ) + liquidity_parser = sub.add_parser( + "evaluate-contract-liquidity", + help=( + "Evaluate amount- and direction-specific derivatives exit " + "liquidity from frozen provider measurements." + ), + ) + liquidity_parser.add_argument("--input-json", required=True) sub.add_parser("list-packs", help="List bundled industry metric packs.") lark_parser = sub.add_parser( "render-lark-card", @@ -170,6 +182,8 @@ def run(argv: Sequence[str] | None = None) -> int: packet = build_finance_research_dashboard_packet(payload) elif schema_version == FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION: packet = build_finance_transaction_approval_packet(payload) + elif schema_version == FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION: + packet = evaluate_finance_contract_liquidity(payload) else: packet = build_finance_value_discovery_packet(payload) except Exception as exc: @@ -212,6 +226,10 @@ def run(argv: Sequence[str] | None = None) -> int: packet = build_finance_transaction_approval_packet( _load_json(args.input_json) ) + elif args.command == "evaluate-contract-liquidity": + packet = evaluate_finance_contract_liquidity( + _load_json(args.input_json) + ) elif args.command == "list-packs": packet = list_finance_metric_packs() elif args.command == "render-lark-card": @@ -228,7 +246,8 @@ def run(argv: Sequence[str] | None = None) -> int: raise ValueError( "use --doctor, reduce, evaluate, replay, attribute-beta, " "replay-beta, evaluate-pack, replay-pack, list-packs, " - "render-lark-card, or build-operation-request" + "render-lark-card, build-operation-request, or " + "evaluate-contract-liquidity" ) except Exception as exc: print(json.dumps(_error_packet(exc), indent=2, sort_keys=True)) diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/contract_liquidity.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/contract_liquidity.py new file mode 100644 index 0000000000..96e1acc0b2 --- /dev/null +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/contract_liquidity.py @@ -0,0 +1,320 @@ +"""Evaluate provider-neutral derivatives exit-liquidity evidence. + +The Finance extension owns the economic admission rule. Venue adapters own +contract discovery, precision handling and order-book measurement; this module +accepts only their public-safe, frozen measurements and never calls a venue. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation +import re +from typing import Any + +from .boundary import reject_forbidden_material + + +FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION = ( + "finance_contract_liquidity_input_v0" +) +FINANCE_CONTRACT_LIQUIDITY_EVALUATION_SCHEMA_VERSION = ( + "finance_contract_liquidity_evaluation_v0" +) + +_POSITION_DIRECTIONS = {"long", "short"} +_EXIT_SIDE_BY_POSITION = {"long": "sell", "short": "buy"} +_CONTRACT_KINDS = {"perpetual", "future"} +_DECIMAL_TEXT = re.compile(r"^(?:0|[1-9][0-9]{0,17})(?:\.[0-9]{1,18})?$") + + +def _text(value: object, *, field: str, limit: int = 160) -> str: + result = " ".join(str(value or "").split()) + if not result: + raise ValueError(f"{field} is required") + if len(result) > limit: + raise ValueError(f"{field} exceeds {limit} characters") + reject_forbidden_material(result, path=field) + return result + + +def _timestamp(value: object, *, field: str) -> datetime: + text = _text(value, field=field, limit=80) + try: + parsed = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError(f"{field} must be an ISO-8601 timestamp") from exc + if parsed.tzinfo is None: + raise ValueError(f"{field} must include a timezone") + return parsed.astimezone(UTC) + + +def _decimal( + value: object, + *, + field: str, + minimum: Decimal = Decimal("0"), + strictly_positive: bool = False, +) -> Decimal: + if not isinstance(value, str) or not _DECIMAL_TEXT.fullmatch(value): + raise ValueError(f"{field} must be a decimal string") + try: + parsed = Decimal(value) + except InvalidOperation as exc: + raise ValueError(f"{field} must be a decimal string") from exc + if not parsed.is_finite() or parsed < minimum: + raise ValueError(f"{field} must be a finite decimal >= {minimum}") + if strictly_positive and parsed == 0: + raise ValueError(f"{field} must be greater than zero") + return parsed + + +def _decimal_text(value: Decimal) -> str: + if value == 0: + return "0" + normalized = format(value.normalize(), "f") + return normalized.rstrip("0").rstrip(".") if "." in normalized else normalized + + +def _evidence_refs(value: object, *, field: str) -> list[str]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise ValueError(f"{field} must be a list") + if not 1 <= len(value) <= 5: + raise ValueError(f"{field} must contain 1-5 items") + refs = [ + _text(item, field=f"{field}[{index}]", limit=160) + for index, item in enumerate(value) + ] + if len(refs) != len(set(refs)): + raise ValueError(f"{field} must use unique evidence refs") + return refs + + +def _scenario(value: object, *, index: int) -> dict[str, Any]: + field = f"scenarios[{index}]" + if not isinstance(value, Mapping): + raise ValueError(f"{field} must be an object") + allowed = { + "scenario_id", + "position_direction", + "exit_side", + "requested_notional", + "executable_notional", + "spread_bps", + "price_impact_bps", + "fee_bps", + "evidence_refs", + } + if set(value) - allowed: + raise ValueError(f"{field} has unsupported fields") + + direction = _text( + value.get("position_direction"), + field=f"{field}.position_direction", + limit=16, + ).lower() + if direction not in _POSITION_DIRECTIONS: + raise ValueError( + f"{field}.position_direction must be one of {sorted(_POSITION_DIRECTIONS)}" + ) + exit_side = _text( + value.get("exit_side"), field=f"{field}.exit_side", limit=16 + ).lower() + expected_side = _EXIT_SIDE_BY_POSITION[direction] + if exit_side != expected_side: + raise ValueError( + f"{field}.exit_side must be {expected_side} for a {direction} position" + ) + + return { + "scenario_id": _text( + value.get("scenario_id"), field=f"{field}.scenario_id", limit=96 + ), + "position_direction": direction, + "exit_side": exit_side, + "requested_notional": _decimal( + value.get("requested_notional"), + field=f"{field}.requested_notional", + strictly_positive=True, + ), + "executable_notional": _decimal( + value.get("executable_notional"), + field=f"{field}.executable_notional", + ), + "spread_bps": _decimal( + value.get("spread_bps"), field=f"{field}.spread_bps" + ), + "price_impact_bps": _decimal( + value.get("price_impact_bps"), field=f"{field}.price_impact_bps" + ), + "fee_bps": _decimal(value.get("fee_bps"), field=f"{field}.fee_bps"), + "evidence_refs": _evidence_refs( + value.get("evidence_refs"), field=f"{field}.evidence_refs" + ), + } + + +def evaluate_finance_contract_liquidity(value: object) -> dict[str, Any]: + """Return a deterministic exit-cost admission over frozen measurements.""" + + if not isinstance(value, Mapping): + raise ValueError("contract liquidity input must be an object") + reject_forbidden_material(value) + allowed = { + "schema_version", + "evaluation_id", + "instrument_ref", + "contract_kind", + "quote_unit", + "observed_at", + "evaluation_as_of", + "maximum_age_seconds", + "maximum_exit_cost_bps", + "minimum_book_coverage_ratio", + "scenarios", + } + if set(value) - allowed: + raise ValueError("contract liquidity input has unsupported fields") + if value.get("schema_version") != FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION: + raise ValueError( + "schema_version must be " + f"{FINANCE_CONTRACT_LIQUIDITY_INPUT_SCHEMA_VERSION}" + ) + + contract_kind = _text( + value.get("contract_kind"), field="contract_kind", limit=24 + ).lower() + if contract_kind not in _CONTRACT_KINDS: + raise ValueError(f"contract_kind must be one of {sorted(_CONTRACT_KINDS)}") + observed_at = _timestamp(value.get("observed_at"), field="observed_at") + evaluation_as_of = _timestamp( + value.get("evaluation_as_of"), field="evaluation_as_of" + ) + if observed_at > evaluation_as_of: + raise ValueError("observed_at must not be after evaluation_as_of") + maximum_age_seconds = value.get("maximum_age_seconds") + if ( + not isinstance(maximum_age_seconds, int) + or isinstance(maximum_age_seconds, bool) + or not 1 <= maximum_age_seconds <= 86_400 + ): + raise ValueError("maximum_age_seconds must be an integer from 1 to 86400") + maximum_exit_cost_bps = _decimal( + value.get("maximum_exit_cost_bps"), + field="maximum_exit_cost_bps", + strictly_positive=True, + ) + minimum_coverage = _decimal( + value.get("minimum_book_coverage_ratio"), + field="minimum_book_coverage_ratio", + strictly_positive=True, + ) + if minimum_coverage > Decimal("1"): + raise ValueError("minimum_book_coverage_ratio must be <= 1") + + raw_scenarios = value.get("scenarios") + if not isinstance(raw_scenarios, Sequence) or isinstance( + raw_scenarios, (str, bytes, bytearray) + ): + raise ValueError("scenarios must be a list") + if not 1 <= len(raw_scenarios) <= 16: + raise ValueError("scenarios must contain 1-16 items") + scenarios = [ + _scenario(item, index=index) for index, item in enumerate(raw_scenarios) + ] + scenario_ids = [item["scenario_id"] for item in scenarios] + if len(scenario_ids) != len(set(scenario_ids)): + raise ValueError("scenarios must use unique scenario ids") + identities = [ + (item["position_direction"], item["requested_notional"]) + for item in scenarios + ] + if len(identities) != len(set(identities)): + raise ValueError( + "scenarios must use unique position_direction/requested_notional pairs" + ) + + stale_after = observed_at + timedelta(seconds=maximum_age_seconds) + freshness_state = "fresh" if evaluation_as_of <= stale_after else "stale" + results: list[dict[str, Any]] = [] + for scenario in scenarios: + requested = scenario["requested_notional"] + executable = scenario["executable_notional"] + coverage_ratio = executable / requested + total_cost_bps = ( + scenario["spread_bps"] + + scenario["price_impact_bps"] + + scenario["fee_bps"] + ) + reasons: list[str] = [] + if freshness_state == "stale": + reasons.append("measurement_stale") + if coverage_ratio < minimum_coverage: + reasons.append("insufficient_executable_notional") + if total_cost_bps > maximum_exit_cost_bps: + reasons.append("exit_cost_above_limit") + if freshness_state == "stale": + disposition = "insufficient_evidence" + elif reasons: + disposition = "insufficient_liquidity" + else: + disposition = "eligible_for_research_successor" + results.append( + { + "scenario_id": scenario["scenario_id"], + "position_direction": scenario["position_direction"], + "exit_side": scenario["exit_side"], + "requested_notional": _decimal_text(requested), + "executable_notional": _decimal_text(executable), + "book_coverage_ratio": _decimal_text(coverage_ratio), + "spread_bps": _decimal_text(scenario["spread_bps"]), + "price_impact_bps": _decimal_text(scenario["price_impact_bps"]), + "fee_bps": _decimal_text(scenario["fee_bps"]), + "total_exit_cost_bps": _decimal_text(total_cost_bps), + "evidence_refs": scenario["evidence_refs"], + "disposition": disposition, + "reasons": reasons, + } + ) + + if freshness_state == "stale": + disposition = "insufficient_evidence" + elif all( + item["disposition"] == "eligible_for_research_successor" + for item in results + ): + disposition = "eligible_for_research_successor" + else: + disposition = "insufficient_liquidity" + + return { + "ok": True, + "schema_version": FINANCE_CONTRACT_LIQUIDITY_EVALUATION_SCHEMA_VERSION, + "evaluation_id": _text( + value.get("evaluation_id"), field="evaluation_id", limit=96 + ), + "instrument_ref": _text( + value.get("instrument_ref"), field="instrument_ref", limit=120 + ), + "contract_kind": contract_kind, + "quote_unit": _text(value.get("quote_unit"), field="quote_unit", limit=32), + "observed_at": observed_at.isoformat().replace("+00:00", "Z"), + "evaluation_as_of": evaluation_as_of.isoformat().replace("+00:00", "Z"), + "stale_after": stale_after.isoformat().replace("+00:00", "Z"), + "freshness_state": freshness_state, + "limits": { + "maximum_age_seconds": maximum_age_seconds, + "maximum_exit_cost_bps": _decimal_text(maximum_exit_cost_bps), + "minimum_book_coverage_ratio": _decimal_text(minimum_coverage), + }, + "scenario_results": results, + "disposition": disposition, + "boundary": { + "venue_semantics_state": "adapter_asserted", + "investment_value_evaluated": False, + "funding_evaluated": False, + "trading_allowed": False, + "automatic_ready_allowed": False, + }, + } From ff631b52aa257ea859f8589e1df92429e6aa73ca Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:14:44 +0800 Subject: [PATCH 2/3] test(finance): cover directional liquidity admission Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../test_finance_contract_liquidity.py | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 tests/extensions/test_finance_contract_liquidity.py diff --git a/tests/extensions/test_finance_contract_liquidity.py b/tests/extensions/test_finance_contract_liquidity.py new file mode 100644 index 0000000000..fd739c63ee --- /dev/null +++ b/tests/extensions/test_finance_contract_liquidity.py @@ -0,0 +1,183 @@ +from __future__ import annotations + +import json +import sys +import tomllib +from copy import deepcopy +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[2] +EXTENSION_ROOT = ROOT / "packages" / "loopx-finance-value-discovery" +EXTENSION_SRC = EXTENSION_ROOT / "src" +EXAMPLE = EXTENSION_ROOT / "examples" / "contract-liquidity-v0.json" +sys.path.insert(0, str(EXTENSION_SRC)) + +from loopx_finance_value_discovery.cli import run # noqa: E402 +from loopx_finance_value_discovery.contract_liquidity import ( # noqa: E402 + evaluate_finance_contract_liquidity, +) + + +def _example() -> dict[str, object]: + return json.loads(EXAMPLE.read_text(encoding="utf-8")) + + +def test_amount_and_direction_specific_exit_cost_is_derived() -> None: + result = evaluate_finance_contract_liquidity(_example()) + + assert result["schema_version"] == "finance_contract_liquidity_evaluation_v0" + assert result["freshness_state"] == "fresh" + assert result["disposition"] == "eligible_for_research_successor" + assert result["scenario_results"] == [ + { + "scenario_id": "close-long-1000", + "position_direction": "long", + "exit_side": "sell", + "requested_notional": "1000", + "executable_notional": "1200", + "book_coverage_ratio": "1.2", + "spread_bps": "2", + "price_impact_bps": "4.5", + "fee_bps": "5", + "total_exit_cost_bps": "11.5", + "evidence_refs": ["public-book:snapshot-001"], + "disposition": "eligible_for_research_successor", + "reasons": [], + }, + { + "scenario_id": "close-short-1000", + "position_direction": "short", + "exit_side": "buy", + "requested_notional": "1000", + "executable_notional": "1100", + "book_coverage_ratio": "1.1", + "spread_bps": "2.2", + "price_impact_bps": "5.1", + "fee_bps": "5", + "total_exit_cost_bps": "12.3", + "evidence_refs": ["public-book:snapshot-001"], + "disposition": "eligible_for_research_successor", + "reasons": [], + }, + ] + assert result["boundary"] == { + "venue_semantics_state": "adapter_asserted", + "investment_value_evaluated": False, + "funding_evaluated": False, + "trading_allowed": False, + "automatic_ready_allowed": False, + } + + +def test_capacity_and_cost_fail_independently() -> None: + payload = _example() + payload["scenarios"][0]["executable_notional"] = "750" + payload["scenarios"][1]["price_impact_bps"] = "30" + + result = evaluate_finance_contract_liquidity(payload) + + assert result["disposition"] == "insufficient_liquidity" + assert result["scenario_results"][0]["reasons"] == [ + "insufficient_executable_notional" + ] + assert result["scenario_results"][1]["reasons"] == [ + "exit_cost_above_limit" + ] + + +def test_stale_measurement_is_insufficient_evidence_not_a_liquidity_rejection() -> None: + payload = _example() + payload["evaluation_as_of"] = "2026-01-15T12:02:01Z" + + result = evaluate_finance_contract_liquidity(payload) + + assert result["freshness_state"] == "stale" + assert result["disposition"] == "insufficient_evidence" + assert all( + item["disposition"] == "insufficient_evidence" + and item["reasons"] == ["measurement_stale"] + for item in result["scenario_results"] + ) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("funding_rate", "0.0001"), + ("expected_return_bps", "100"), + ("investment_score", "high"), + ("venue", "synthetic-exchange"), + ], +) +def test_funding_value_and_venue_semantics_are_not_admission_inputs( + field: str, value: str +) -> None: + payload = _example() + payload[field] = value + + with pytest.raises(ValueError, match="unsupported fields"): + evaluate_finance_contract_liquidity(payload) + + +def test_exit_side_must_close_the_declared_position_direction() -> None: + payload = _example() + payload["scenarios"][0]["exit_side"] = "buy" + + with pytest.raises(ValueError, match="must be sell for a long position"): + evaluate_finance_contract_liquidity(payload) + + +def test_decimal_measurements_are_strings_and_nonnegative() -> None: + payload = _example() + payload["scenarios"][0]["spread_bps"] = 2 + with pytest.raises(ValueError, match="must be a decimal string"): + evaluate_finance_contract_liquidity(payload) + + payload = _example() + payload["scenarios"][0]["fee_bps"] = "-1" + with pytest.raises(ValueError, match="must be a decimal string"): + evaluate_finance_contract_liquidity(payload) + + payload = _example() + payload["scenarios"][0]["requested_notional"] = "1e1000000" + with pytest.raises(ValueError, match="must be a decimal string"): + evaluate_finance_contract_liquidity(payload) + + +def test_cli_and_managed_runtime_route_the_same_schema( + capsys: pytest.CaptureFixture[str], monkeypatch: pytest.MonkeyPatch +) -> None: + assert ( + run(["evaluate-contract-liquidity", "--input-json", str(EXAMPLE)]) == 0 + ) + direct = json.loads(capsys.readouterr().out) + + monkeypatch.setattr(sys, "stdin", EXAMPLE.open(encoding="utf-8")) + assert run([]) == 0 + managed = json.loads(capsys.readouterr().out) + + assert direct == managed + + +def test_extension_and_package_versions_match() -> None: + manifest = tomllib.loads( + (EXTENSION_ROOT / "extension.toml").read_text(encoding="utf-8") + ) + package = tomllib.loads( + (EXTENSION_ROOT / "pyproject.toml").read_text(encoding="utf-8") + ) + + assert manifest["version"] == package["project"]["version"] == "0.8.0" + + +def test_duplicate_amount_direction_identity_is_rejected() -> None: + payload = _example() + duplicate = deepcopy(payload["scenarios"][0]) + duplicate["scenario_id"] = "another-long-1000" + payload["scenarios"].append(duplicate) + + with pytest.raises(ValueError, match="unique position_direction"): + evaluate_finance_contract_liquidity(payload) From 35967e9b1e121d4d9996b3c1943f65986664de8f Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Sun, 20 Sep 2026 01:14:50 +0800 Subject: [PATCH 3/3] docs(finance): define contract liquidity boundary Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../loopx-finance-value-discovery/CONTRACT.md | 25 +++++++++++ .../loopx-finance-value-discovery/README.md | 44 +++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/loopx-finance-value-discovery/CONTRACT.md b/packages/loopx-finance-value-discovery/CONTRACT.md index e5d7dd52fe..e773cfd0be 100644 --- a/packages/loopx-finance-value-discovery/CONTRACT.md +++ b/packages/loopx-finance-value-discovery/CONTRACT.md @@ -239,6 +239,31 @@ closed. ## Compatibility +### Contract exit liquidity / 合约退出流动性 (extension 0.8.0) + +`finance_contract_liquidity_input_v0` is an additive Finance-owned contract. +It does not alter `finance_case_contract_v1`. The input freezes an instrument +reference, contract kind, quote unit, observation/evaluation timestamps, +freshness limit, cost and book-coverage limits, plus one or more amount- and +direction-specific exit scenarios. Scenarios are unique by position direction +and requested notional. The engine derives book coverage and total exit cost; +it does not accept provider-declared totals, readiness or disposition. + +Venue-specific instrument discovery, tick/lot precision and book collection +remain adapter responsibilities. The public Finance contract rejects venue, +account, funding and investment-value fields. Its output records those +boundaries, labels venue semantics as `adapter_asserted`, and fixes both +`trading_allowed` and `automatic_ready_allowed` to false. Stale evidence cannot +become a negative liquidity conclusion. + +`finance_contract_liquidity_input_v0` 是新增的 Finance 合约,不修改 +`finance_case_contract_v1`。输入冻结合约引用、类型、报价单位、观察/评估时点、 +新鲜度、成本和订单簿覆盖阈值,以及按持仓方向和退出金额区分的场景。场景按方向 +与金额唯一;覆盖率和总退出成本由引擎计算,不接受 provider 自报总数、ready 或 +裁决。场所侧合约发现、tick/lot 精度和订单簿采集仍归私有适配器。公共 Finance +合约拒绝场所、账户、资金费和投资价值字段,并固定不授予交易或自动 ready 权限; +过期证据不能伪装成负面的流动性结论。 + The existing `finance_value_discovery_input_v0` reducer and `finance_value_discovery_extension_v0` provider protocol remain supported. `finance_case_gate_input_v1` now requires a `subject_ref` naming the case diff --git a/packages/loopx-finance-value-discovery/README.md b/packages/loopx-finance-value-discovery/README.md index db8d9ce1ff..bd92726b62 100644 --- a/packages/loopx-finance-value-discovery/README.md +++ b/packages/loopx-finance-value-discovery/README.md @@ -38,6 +38,50 @@ Two P1 overlays reuse that contract without weakening it: directions. They do not contain thresholds or evaluate provider-declared pass/fail states. +Extension 0.8.0 adds `finance_contract_liquidity_input_v0`, a provider-neutral +exit-liquidity admission for derivatives. Venue adapters remain responsible for +contract identity, precision, book collection and evidence timestamps. Finance +evaluates each frozen position direction and requested notional against one +freshness window, minimum executable-book coverage and maximum derived exit +cost. Exit cost is the deterministic sum of spread, price impact and fees; a +provider cannot submit its own total or pass/fail decision. Long positions must +use a sell exit and short positions a buy exit. + +The result is deliberately independent of investment value and funding. Those +fields, venue names and account material are rejected rather than folded into +the liquidity decision. A fresh passing result means only +`eligible_for_research_successor`; it never creates a ready candidate or grants +order, signing or transfer authority. Stale measurements are +`insufficient_evidence`, while fresh amount/cost failures are +`insufficient_liquidity`. + +0.8.0 新增 `finance_contract_liquidity_input_v0`,用于对衍生品退出流动性做 +provider-neutral 准入。私有场所适配器继续负责合约身份、精度、订单簿采集和 +证据时间;Finance 只按持仓方向与申请退出金额,使用冻结的新鲜度、可执行订单簿 +覆盖率和最大退出成本阈值进行确定性判断。退出成本由点差、价格冲击和手续费相加 +得到,不接受 provider 自报总成本或通过/失败;多头必须卖出退出,空头必须买入 +退出。 + +该判断与投资价值和资金费明确隔离,相关字段、场所名称和账户材料会被拒绝,而 +不是混入流动性结论。通过只表示可以进入下一段研究,不能升级 ready,更不授权 +下单、签名或转账;过期测量是证据不足,新鲜但金额或成本不满足才是流动性不足。 + +Run the same contract through the direct CLI or managed extension runtime: + +```bash +loopx-finance-value-discovery evaluate-contract-liquidity \ + --input-json packages/loopx-finance-value-discovery/examples/contract-liquidity-v0.json + +loopx extension run loopx-finance-value-discovery \ + --input-json packages/loopx-finance-value-discovery/examples/contract-liquidity-v0.json \ + --execute --format json +``` + +This slice changes the CLI/managed-Turn contract only. Dashboard and Lark must +consume the same evaluation in their separately owned projection slice; until +that lands, this backend delivery is intentionally partial rather than an +end-to-end visual release. + The packet enforces: - a cross-sectional screen before a named candidate is selected;