From 4e346e30c2ed49890d490b4511460befe1e3aa6b Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Mon, 14 Sep 2026 07:51:39 +0800 Subject: [PATCH] feat(finance): build simulated approval requests Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../loopx-finance-value-discovery/README.md | 58 ++- .../extension.toml | 2 +- .../pyproject.toml | 2 +- .../loopx_finance_value_discovery/__init__.py | 8 + .../src/loopx_finance_value_discovery/cli.py | 31 +- .../operation_request.py | 418 ++++++++++++++++++ ...t_finance_transaction_approval_consumer.py | 261 +++++++++++ 7 files changed, 773 insertions(+), 7 deletions(-) create mode 100644 packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/operation_request.py create mode 100644 tests/extensions/test_finance_transaction_approval_consumer.py diff --git a/packages/loopx-finance-value-discovery/README.md b/packages/loopx-finance-value-discovery/README.md index 2d6f0ea583..db8d9ce1ff 100644 --- a/packages/loopx-finance-value-discovery/README.md +++ b/packages/loopx-finance-value-discovery/README.md @@ -52,8 +52,62 @@ The packet enforces: It rejects raw provider bodies, private paths, credentials, account or portfolio material, future-dated evidence, unsupported fields, and malformed -public URLs. It never emits investment advice, a price target, a trade, or an -automatic watch. +public URLs. The discovery reducer never emits investment advice, a price +target, a trade, or an automatic watch. + +## Simulation-only transaction confirmation / 仅模拟交易确认 + +Extension 0.7.0 adds the +`finance_transaction_approval_input_v0` consumer, deliberately separate from +the discovery reducer. It turns an explicit, already-bounded candidate action +into Core's canonical `loopx_operation_request_v0`. The request freezes the +order action, public evidence links, evidence observation time, expected +economics, expiry, and no-trade conditions into the same projection shown by +the Dashboard and the Goal Channel Lark card. It always targets +`account:simulation` and the `finance.operation.simulate` executor permission. +It cannot place an order, sign, transfer, infer an approver, or create standing +trading authority. + +0.7.0 新增的 `finance_transaction_approval_input_v0` consumer 与发现 reducer +明确隔离。它只把一个已经收敛、显式提出的候选动作转换成 Core 的 +`loopx_operation_request_v0`,并把下单动作、公开证据链接、证据观察时间、 +预期经济性、过期时间和禁交易条件冻结进 Dashboard 与 Goal Channel 飞书卡片 +共用的投影。目标固定为 `account:simulation`,权限固定为 +`finance.operation.simulate`;它不能真实下单、签名、转账、推断审批人或产生 +持续交易权限。 + +The executor revision must come from the enabled `loopx-finance-execution` +extension readback. Authorized principals must be explicit provider-qualified +identities from the current Goal Channel operator authority; the Finance builder +neither discovers nor broadens them. Build the packet once, retain both its +idempotency key and canonical request, then let Core persist and deliver it +through the existing two-step entrypoint: + +```bash +loopx-finance-value-discovery build-operation-request \ + --input-json transaction-approval.json > approval-packet.json +jq '.operation_request' approval-packet.json > operation-request.json +loopx goal-channel prepare-operation \ + --goal-id --agent-id \ + --summary "Review one simulated finance transaction" \ + --idempotency-key "$(jq -r '.idempotency_key' approval-packet.json)" \ + --request-json operation-request.json --execute --format json +loopx goal-channel deliver-operation \ + --goal-id --proposal-id --execute --format json +``` + +`prepare-operation` only writes Core's canonical proposal; `deliver-operation` +projects that same proposal to the bound Lark group. The Dashboard reads the +same safe projection. Confirm/reject callbacks remain Core-owned and produce an +idempotent receipt. Managed Turn callers may submit the input schema to the +extension runtime and select `operation_request` from its returned packet. + +`executor_revision` 必须来自已启用 `loopx-finance-execution` 的真实 readback; +审批人必须来自当前 Goal Channel 操作权限,并以 provider-qualified principal +显式传入,Finance builder 不发现或扩大审批人范围。两者都不会被猜测。 +`prepare-operation` 只写入 Core 的 canonical proposal,`deliver-operation` 再将 +同一 proposal 投影到 Goal 绑定群;Dashboard 也读取同一安全投影。确认/拒绝 +callback 与幂等 receipt 继续由 Core 负责。 ## Public-Safe Research Surface diff --git a/packages/loopx-finance-value-discovery/extension.toml b/packages/loopx-finance-value-discovery/extension.toml index 91fb6c56a1..58493c09e1 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.6.0" +version = "0.7.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 05c2b4e898..1d0752cd16 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.6.0" +version = "0.7.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 820d98ed25..5cbc795bb8 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 @@ -25,6 +25,11 @@ list_finance_metric_packs, replay_finance_metric_pack_evaluation, ) +from .operation_request import ( + FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION, + FINANCE_TRANSACTION_APPROVAL_PACKET_SCHEMA_VERSION, + build_finance_transaction_approval_packet, +) from .reducer import ( EVIDENCE_AXES, FINANCE_VALUE_DISCOVERY_CARD_SCHEMA_VERSION, @@ -55,6 +60,8 @@ "FINANCE_METRIC_PACK_REPLAY_SCHEMA_VERSION", "FINANCE_RESEARCH_DASHBOARD_INPUT_SCHEMA_VERSION", "FINANCE_RESEARCH_DASHBOARD_PACKET_SCHEMA_VERSION", + "FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION", + "FINANCE_TRANSACTION_APPROVAL_PACKET_SCHEMA_VERSION", "FINANCE_VALUE_DISCOVERY_CARD_SCHEMA_VERSION", "FINANCE_VALUE_DISCOVERY_EXTENSION_PROTOCOL", "FINANCE_VALUE_DISCOVERY_INPUT_SCHEMA_VERSION", @@ -63,6 +70,7 @@ "build_finance_case_evaluation", "build_finance_metric_pack_evaluation", "build_finance_research_dashboard_packet", + "build_finance_transaction_approval_packet", "build_finance_value_discovery_packet", "evaluate_finance_case_gates", "list_finance_metric_packs", 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 64b143ce34..153cfd6233 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 @@ -29,6 +29,10 @@ replay_finance_case_evaluation, ) from .presentation_compat import presentation_api_error +from .operation_request import ( + FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION, + build_finance_transaction_approval_packet, +) FINANCE_RESEARCH_DASHBOARD_INPUT_SCHEMA_VERSION = "finance_research_dashboard_input_v0" @@ -120,6 +124,19 @@ def _direct_parser() -> argparse.ArgumentParser: ) pack_replay_parser.add_argument("--input-json", required=True) pack_replay_parser.add_argument("--expected-json", required=True) + operation_parser = sub.add_parser( + "build-operation-request", + help=( + "Build one simulation-only finance request for LoopX Goal Channel " + "confirmation." + ), + ) + operation_parser.add_argument("--input-json", required=True) + operation_parser.add_argument( + "--request-only", + action="store_true", + help="Print only the canonical loopx_operation_request_v0 object.", + ) sub.add_parser("list-packs", help="List bundled industry metric packs.") lark_parser = sub.add_parser( "render-lark-card", @@ -151,6 +168,8 @@ def run(argv: Sequence[str] | None = None) -> int: from .dashboard import build_finance_research_dashboard_packet packet = build_finance_research_dashboard_packet(payload) + elif schema_version == FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION: + packet = build_finance_transaction_approval_packet(payload) else: packet = build_finance_value_discovery_packet(payload) except Exception as exc: @@ -189,6 +208,10 @@ def run(argv: Sequence[str] | None = None) -> int: _load_json(args.input_json), _load_json(args.expected_json), ) + elif args.command == "build-operation-request": + packet = build_finance_transaction_approval_packet( + _load_json(args.input_json) + ) elif args.command == "list-packs": packet = list_finance_metric_packs() elif args.command == "render-lark-card": @@ -204,13 +227,15 @@ def run(argv: Sequence[str] | None = None) -> int: else: raise ValueError( "use --doctor, reduce, evaluate, replay, attribute-beta, " - "replay-beta, evaluate-pack, replay-pack, list-packs, or " - "render-lark-card" + "replay-beta, evaluate-pack, replay-pack, list-packs, " + "render-lark-card, or build-operation-request" ) except Exception as exc: print(json.dumps(_error_packet(exc), indent=2, sort_keys=True)) return 1 - if args.command != "reduce" or args.format == "json": + if args.command == "build-operation-request" and args.request_only: + print(json.dumps(packet["operation_request"], indent=2, sort_keys=True)) + elif args.command != "reduce" or args.format == "json": print(json.dumps(packet, indent=2, sort_keys=True)) else: print(render_finance_value_discovery_markdown(packet), end="") diff --git a/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/operation_request.py b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/operation_request.py new file mode 100644 index 0000000000..a8283e27d9 --- /dev/null +++ b/packages/loopx-finance-value-discovery/src/loopx_finance_value_discovery/operation_request.py @@ -0,0 +1,418 @@ +"""Build public-safe, simulation-only Finance operation requests. + +This module is the Finance-owned producer for LoopX Core's canonical +``operation.execute`` envelope. It validates domain semantics and emits one +immutable request; Core remains the owner of persistence, human confirmation, +delivery, claim consumption, and outcome readback. +""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from datetime import UTC, datetime, timedelta +from decimal import Decimal, InvalidOperation +import hashlib +from ipaddress import ip_address +import json +import re +from typing import Any +from urllib.parse import urlparse + +from .presentation_validation import evidence_reference + + +FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION = ( + "finance_transaction_approval_input_v0" +) +FINANCE_TRANSACTION_APPROVAL_PACKET_SCHEMA_VERSION = ( + "finance_transaction_approval_packet_v0" +) +LOOPX_OPERATION_REQUEST_SCHEMA_VERSION = "loopx_operation_request_v0" +LOOPX_OPERATION_PROJECTION_SCHEMA_VERSION = "loopx_operation_projection_v0" +FINANCE_ORDER_INTENT_SCHEMA_VERSION = "finance_order_intent_v0" +FINANCE_EXECUTOR_EXTENSION_ID = "loopx-finance-execution" +FINANCE_EXECUTOR_PROTOCOL = "finance_operation_executor_v0" +FINANCE_EXECUTOR_PERMISSION = "finance.operation.simulate" +FINANCE_OPERATION_KIND = "finance.order.simulate" + +_OPAQUE = re.compile(r"^[A-Za-z0-9._:-]{1,200}$") +_PRINCIPAL = re.compile(r"^[a-z][a-z0-9._-]{0,30}:[A-Za-z0-9._:-]{1,200}$") +_PRIVATE_TEXT = ( + re.compile(r"\bBearer\s+", re.IGNORECASE), + re.compile(r"/Users/[A-Za-z0-9._-]+/"), + re.compile(r"/home/[A-Za-z0-9._-]+/"), + re.compile(r"[A-Za-z]:\\\\Users\\\\", re.IGNORECASE), + re.compile(r"\bAKIA[0-9A-Z]{16}\b"), +) + + +def _digest(value: object) -> str: + encoded = json.dumps( + value, + allow_nan=False, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + return hashlib.sha256(encoded).hexdigest() + + +def _text(value: object, *, field: str, limit: int) -> 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") + if any(pattern.search(result) for pattern in _PRIVATE_TEXT): + raise ValueError(f"{field} contains private or credential-like text") + return result + + +def _token(value: object, *, field: str) -> str: + result = str(value or "").strip() + if not _OPAQUE.fullmatch(result): + raise ValueError(f"{field} must be a compact opaque id") + return result + + +def _decimal(value: object, *, field: str, minimum: Decimal | None = None) -> Decimal: + if not isinstance(value, str) or len(value) > 80: + raise ValueError(f"{field} must be a decimal string") + try: + result = Decimal(value) + except InvalidOperation as exc: + raise ValueError(f"{field} must be a decimal string") from exc + if not result.is_finite() or (minimum is not None and result < minimum): + raise ValueError(f"{field} must be a finite decimal >= {minimum}") + return result + + +def _future_expiry(value: object, *, now: datetime) -> str: + text = _text(value, field="expires_at", limit=80) + try: + expires_at = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError("expires_at must be an ISO-8601 timestamp") from exc + if expires_at.tzinfo is None: + raise ValueError("expires_at must include a timezone") + expires_at = expires_at.astimezone(UTC) + if not now < expires_at <= now + timedelta(days=7): + raise ValueError("expires_at must be within the next seven days") + return expires_at.isoformat().replace("+00:00", "Z") + + +def _evidence_observed_at(value: object, *, now: datetime) -> str: + text = _text(value, field="evidence_observed_at", limit=80) + try: + observed_at = datetime.fromisoformat(text.replace("Z", "+00:00")) + except ValueError as exc: + raise ValueError( + "evidence_observed_at must be an ISO-8601 timestamp" + ) from exc + if observed_at.tzinfo is None: + raise ValueError("evidence_observed_at must include a timezone") + observed_at = observed_at.astimezone(UTC) + if observed_at > now: + raise ValueError("evidence_observed_at must not be in the future") + return observed_at.isoformat().replace("+00:00", "Z") + + +def _public_evidence_ref(value: object, *, index: int) -> dict[str, str]: + field = f"evidence_refs[{index}]" + if not isinstance(value, Mapping) or set(value) != {"label", "ref"}: + raise ValueError(f"{field} must contain exactly label and ref") + label = _text(value.get("label"), field=f"{field}.label", limit=36) + ref = evidence_reference(value.get("ref"), context=f"{field}.ref") + parsed = urlparse(ref) + if ( + parsed.scheme != "https" + or not parsed.netloc + or parsed.username + or parsed.password + ): + raise ValueError(f"{field}.ref must be a public HTTPS URL") + hostname = (parsed.hostname or "").lower() + local_hostname = ( + hostname == "localhost" + or hostname.endswith((".local", ".internal", ".corp", ".lan")) + or hostname.startswith(("private.", "internal.")) + ) + try: + address = ip_address(hostname) + except ValueError: + address = None + local_address = bool( + address + and ( + address.is_private + or address.is_loopback + or address.is_link_local + or address.is_reserved + or address.is_unspecified + ) + ) + if local_hostname or local_address: + raise ValueError(f"{field}.ref must not target a local address") + return {"label": label, "ref": ref} + + +def _text_list( + value: object, + *, + field: str, + minimum: int, + maximum: int, + item_limit: int, +) -> list[str]: + if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): + raise ValueError(f"{field} must be a list") + if not minimum <= len(value) <= maximum: + raise ValueError(f"{field} must contain {minimum}-{maximum} items") + result = [ + _text(item, field=f"{field}[{index}]", limit=item_limit) + for index, item in enumerate(value) + ] + if len(result) != len(set(result)): + raise ValueError(f"{field} must not contain duplicates") + return result + + +def build_finance_transaction_approval_packet( + value: object, + *, + now: datetime | None = None, +) -> dict[str, Any]: + """Validate one transaction request and emit Core's canonical request. + + The output can be passed directly to ``loopx goal-channel + prepare-operation`` after selecting ``operation_request``. It is always a + simulation and never carries venue, signer, wallet, transfer, or credential + authority. + """ + + if not isinstance(value, Mapping): + raise ValueError("finance transaction approval input must be an object") + allowed = { + "schema_version", + "request_id", + "candidate_ref", + "asset", + "side", + "quantity", + "quantity_unit", + "order_type", + "limit_price", + "price_unit", + "time_in_force", + "reduce_only", + "maximum_fee", + "fee_unit", + "expected_edge_bps", + "maximum_cost_bps", + "evidence_observed_at", + "evidence_refs", + "no_trade_conditions", + "expires_at", + "authorized_principals", + "executor_revision", + "simulation", + } + if set(value) - allowed: + raise ValueError("finance transaction approval input has unsupported fields") + if value.get("schema_version") != FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION: + raise ValueError( + "schema_version must be " + f"{FINANCE_TRANSACTION_APPROVAL_INPUT_SCHEMA_VERSION}" + ) + if value.get("simulation") is not True: + raise ValueError("simulation must be true") + + request_id = _token(value.get("request_id"), field="request_id") + candidate_ref = _token(value.get("candidate_ref"), field="candidate_ref") + asset = _token(value.get("asset"), field="asset") + side = str(value.get("side") or "").lower() + if side not in {"buy", "sell"}: + raise ValueError("side must be buy or sell") + order_type = str(value.get("order_type") or "").lower() + if order_type != "limit": + raise ValueError("the simulation request accepts limit orders only") + time_in_force = value.get("time_in_force") + if time_in_force not in {"GTC", "IOC"}: + raise ValueError("time_in_force must be GTC or IOC") + if not isinstance(value.get("reduce_only"), bool): + raise ValueError("reduce_only must be true or false") + + quantity = _decimal(value.get("quantity"), field="quantity", minimum=Decimal("0")) + limit_price = _decimal( + value.get("limit_price"), field="limit_price", minimum=Decimal("0") + ) + maximum_fee = _decimal( + value.get("maximum_fee"), field="maximum_fee", minimum=Decimal("0") + ) + if quantity == 0 or limit_price == 0 or maximum_fee == 0: + raise ValueError( + "quantity, limit_price, and maximum_fee must be greater than zero" + ) + expected_edge_bps = _decimal( + value.get("expected_edge_bps"), + field="expected_edge_bps", + minimum=Decimal("0"), + ) + maximum_cost_bps = _decimal( + value.get("maximum_cost_bps"), + field="maximum_cost_bps", + minimum=Decimal("0"), + ) + if expected_edge_bps <= maximum_cost_bps: + raise ValueError("expected_edge_bps must exceed maximum_cost_bps") + + quantity_unit = _token(value.get("quantity_unit"), field="quantity_unit") + price_unit = _token(value.get("price_unit"), field="price_unit") + fee_unit = _token(value.get("fee_unit"), field="fee_unit") + current = (now or datetime.now(UTC)).astimezone(UTC) + evidence_observed_at = _evidence_observed_at( + value.get("evidence_observed_at"), now=current + ) + evidence_value = value.get("evidence_refs") + if ( + not isinstance(evidence_value, Sequence) + or isinstance(evidence_value, (str, bytes, bytearray)) + or not 1 <= len(evidence_value) <= 3 + ): + raise ValueError("evidence_refs must contain 1-3 items") + evidence_refs = [ + _public_evidence_ref(item, index=index) + for index, item in enumerate(evidence_value) + ] + if len({item["ref"] for item in evidence_refs}) != len(evidence_refs): + raise ValueError("evidence_refs must use unique refs") + no_trade_conditions = _text_list( + value.get("no_trade_conditions"), + field="no_trade_conditions", + minimum=1, + maximum=3, + item_limit=120, + ) + principals_value = value.get("authorized_principals") + if ( + not isinstance(principals_value, Sequence) + or isinstance(principals_value, (str, bytes, bytearray)) + or not 1 <= len(principals_value) <= 20 + ): + raise ValueError("authorized_principals must contain 1-20 identities") + principals: list[str] = [] + for raw_principal in principals_value: + principal = str(raw_principal or "").strip() + if not _PRINCIPAL.fullmatch(principal): + raise ValueError( + "authorized_principals must use provider:subject identities" + ) + if principal not in principals: + principals.append(principal) + executor_revision = _token( + value.get("executor_revision"), field="executor_revision" + ) + expires_at = _future_expiry(value.get("expires_at"), now=current) + + payload = { + "schema_version": FINANCE_ORDER_INTENT_SCHEMA_VERSION, + "asset": asset, + "side": side, + "quantity": str(quantity), + "quantity_unit": quantity_unit, + "order_type": order_type, + "limit_price": str(limit_price), + "price_unit": price_unit, + "time_in_force": time_in_force, + "reduce_only": value["reduce_only"], + "maximum_fee": str(maximum_fee), + "fee_unit": fee_unit, + } + maximum_notional = quantity * limit_price + fields = [ + {"label": "Candidate", "value": candidate_ref}, + { + "label": "Action", + "value": ( + f"{side.upper()} {quantity} {quantity_unit} · LIMIT " + f"{limit_price} {price_unit} · {time_in_force}" + ), + }, + { + "label": "Maximum notional", + "value": f"{maximum_notional} {price_unit}", + }, + { + "label": "Economics", + "value": ( + f"edge {expected_edge_bps} bps · max cost {maximum_cost_bps} bps · " + f"max fee {maximum_fee} {fee_unit}" + ), + }, + {"label": "Expires", "value": expires_at}, + {"label": "Evidence observed", "value": evidence_observed_at}, + *[ + {"label": f"Evidence {index + 1}: {item['label']}", "value": item["ref"]} + for index, item in enumerate(evidence_refs) + ], + *[ + {"label": f"No-trade {index + 1}", "value": condition} + for index, condition in enumerate(no_trade_conditions) + ], + ] + for index, field in enumerate(fields): + if len(field["label"]) > 40 or len(field["value"]) > 120: + raise ValueError( + f"operation projection field {index + 1} exceeds Core limits" + ) + projection = { + "schema_version": LOOPX_OPERATION_PROJECTION_SCHEMA_VERSION, + "title": "Simulated finance transaction request", + "subtitle": f"{candidate_ref} · human confirmation required", + "focus": (f"{side.upper()} {quantity} {asset} @ {limit_price} {price_unit}"), + "fields": fields, + "warning": ( + "Simulation only. Approval cannot submit an order, sign, move funds, " + "or grant real-trading authority. Reject if any no-trade condition is met." + ), + "simulated": True, + } + operation_request = { + "schema_version": LOOPX_OPERATION_REQUEST_SCHEMA_VERSION, + "domain": "finance", + "operation_kind": FINANCE_OPERATION_KIND, + "operation_schema": FINANCE_ORDER_INTENT_SCHEMA_VERSION, + "payload_ref": f"finance-order:{request_id}", + "payload": payload, + "payload_digest": _digest(payload), + "projection": projection, + "destination_account_ref": "account:simulation", + "expires_at": expires_at, + "authorized_principals": principals, + "executor": { + "extension_id": FINANCE_EXECUTOR_EXTENSION_ID, + "protocol": FINANCE_EXECUTOR_PROTOCOL, + "permission": FINANCE_EXECUTOR_PERMISSION, + "revision": executor_revision, + }, + } + request_digest = _digest(operation_request) + return { + "ok": True, + "schema_version": FINANCE_TRANSACTION_APPROVAL_PACKET_SCHEMA_VERSION, + "mode": "finance-transaction-approval", + "summary": ( + f"Prepared simulation-only approval request {request_id} for {asset}." + ), + "idempotency_key": f"finance-operation-{request_digest[:32]}", + "operation_request_digest": request_digest, + "operation_request": operation_request, + "boundary": { + "simulation": True, + "human_confirmation_required": True, + "real_order_allowed": False, + "signature_allowed": False, + "transfer_allowed": False, + "external_write_performed": False, + }, + } diff --git a/tests/extensions/test_finance_transaction_approval_consumer.py b/tests/extensions/test_finance_transaction_approval_consumer.py new file mode 100644 index 0000000000..3ea4406b1f --- /dev/null +++ b/tests/extensions/test_finance_transaction_approval_consumer.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import importlib +import json +from pathlib import Path +import subprocess +import sys + +import pytest + +import loopx.chat_action_normalization as chat_action_normalization +from loopx.chat_action_store import ChatActionStore +from loopx.chat_actions import ChatActionService +from loopx.extensions.lark.goal_channel_operation import ( + build_goal_channel_operation_card, +) + + +ROOT = Path(__file__).resolve().parents[2] +PACKAGE = ROOT / "packages" / "loopx-finance-value-discovery" +PACKAGE_SRC = PACKAGE / "src" +EXECUTION_SRC = ROOT / "packages" / "loopx-finance-execution" / "src" +GOAL_ID = "finance-approval-fixture" +AGENT_ID = "finance-fixture-agent" +OPERATOR_ID = "ou_finance_approver" + + +def _module(): + sys.path.insert(0, str(PACKAGE_SRC)) + try: + return importlib.import_module( + "loopx_finance_value_discovery.operation_request" + ) + finally: + sys.path.remove(str(PACKAGE_SRC)) + + +def _execution_module(): + sys.path.insert(0, str(EXECUTION_SRC)) + try: + return importlib.import_module("loopx_finance_execution.simulator") + finally: + sys.path.remove(str(EXECUTION_SRC)) + + +def _input(*, now: datetime) -> dict[str, object]: + return { + "schema_version": "finance_transaction_approval_input_v0", + "request_id": "synthetic-request-1", + "candidate_ref": "candidate:synthetic-1", + "asset": "SYNTH", + "side": "buy", + "quantity": "2.00", + "quantity_unit": "SYNTH", + "order_type": "limit", + "limit_price": "10.00", + "price_unit": "TEST", + "time_in_force": "GTC", + "reduce_only": False, + "maximum_fee": "0.10", + "fee_unit": "TEST", + "expected_edge_bps": "80", + "maximum_cost_bps": "20", + "evidence_observed_at": (now - timedelta(minutes=5)).isoformat(), + "evidence_refs": [ + { + "label": "Synthetic filing", + "ref": "https://example.com/evidence/synthetic-1", + } + ], + "no_trade_conditions": ["Do not proceed if the evidence snapshot is stale."], + "expires_at": (now + timedelta(hours=1)).isoformat(), + "authorized_principals": [f"lark:{OPERATOR_ID}"], + "executor_revision": "simulator-fixture-r1", + "simulation": True, + } + + +def _service(tmp_path: Path) -> ChatActionService: + project = tmp_path / "project" + project.mkdir() + (project / "ACTIVE_GOAL_STATE.md").write_text( + f"---\ngoal_id: {GOAL_ID}\n---\n\n## User Todo\n\n## Agent Todo\n", + encoding="utf-8", + ) + registry = project / ".loopx" / "registry.json" + registry.parent.mkdir() + registry.write_text( + json.dumps( + { + "goals": [ + { + "id": GOAL_ID, + "repo": str(project), + "state_file": "ACTIVE_GOAL_STATE.md", + "coordination": {"registered_agents": [AGENT_ID]}, + } + ] + } + ), + encoding="utf-8", + ) + return ChatActionService( + store=ChatActionStore(tmp_path / "runtime" / "chat" / "actions"), + registry_path=registry, + ) + + +def test_builder_feeds_one_canonical_core_dashboard_and_lark_projection( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _module() + now = datetime(2026, 9, 14, 1, 0, tzinfo=UTC) + monkeypatch.setattr(chat_action_normalization, "now_utc", lambda: now) + packet = module.build_finance_transaction_approval_packet(_input(now=now), now=now) + + assert packet["boundary"] == { + "simulation": True, + "human_confirmation_required": True, + "real_order_allowed": False, + "signature_allowed": False, + "transfer_allowed": False, + "external_write_performed": False, + } + request = packet["operation_request"] + service = _service(tmp_path) + proposal = service.preview( + { + "action_kind": "operation.execute", + "summary": packet["summary"], + "idempotency_key": packet["idempotency_key"], + "context": {"kind": "goal", "goal_id": GOAL_ID}, + "normalized_parameters": { + **request, + "goal_id": GOAL_ID, + "agent_id": AGENT_ID, + }, + } + ) + + projection = proposal["normalized_parameters"]["projection"] + assert proposal["operation"]["lifecycle_state"] == "awaiting_confirmation" + assert projection == request["projection"] + assert [field["label"] for field in projection["fields"]][:6] == [ + "Candidate", + "Action", + "Maximum notional", + "Economics", + "Expires", + "Evidence observed", + ] + assert any(field["label"].startswith("Evidence") for field in projection["fields"]) + assert any(field["label"].startswith("No-trade") for field in projection["fields"]) + card = build_goal_channel_operation_card(proposal) + card_text = json.dumps(card, ensure_ascii=False) + assert "确认模拟执行" in card_text + assert "Synthetic filing" in card_text + assert "Do not proceed if the evidence snapshot is stale." in card_text + assert OPERATOR_ID not in card_text + assert '"schema_version": "finance_order_intent_v0"' not in card_text + + outcome = _execution_module().execute_simulated_finance_operation( + { + "schema_version": "finance_operation_execute_request_v0", + "protocol": request["executor"]["protocol"], + "permission": request["executor"]["permission"], + "operation_id": proposal["proposal_id"], + "operation_kind": request["operation_kind"], + "operation_schema": request["operation_schema"], + "payload": request["payload"], + "payload_digest": request["payload_digest"], + "confirmation_digest": proposal["operation"]["confirmation_digest"], + "claim_id": "claim-fixture-r1", + "executor_revision": request["executor"]["revision"], + "destination_account_ref": request["destination_account_ref"], + } + ) + assert outcome["outcome"] == "simulated_filled" + assert outcome["simulation"] is True + assert outcome["external_write_performed"] is False + + +def test_builder_rejects_real_execution_and_unprofitable_or_private_requests() -> None: + module = _module() + now = datetime(2026, 9, 14, 1, 0, tzinfo=UTC) + + real = _input(now=now) + real["simulation"] = False + with pytest.raises(ValueError, match="simulation must be true"): + module.build_finance_transaction_approval_packet(real, now=now) + + uneconomic = _input(now=now) + uneconomic["expected_edge_bps"] = "20" + with pytest.raises(ValueError, match="must exceed"): + module.build_finance_transaction_approval_packet(uneconomic, now=now) + + private = _input(now=now) + private["evidence_refs"] = [{"label": "local", "ref": "https://localhost/private"}] + with pytest.raises(ValueError, match="unsafe evidence reference"): + module.build_finance_transaction_approval_packet(private, now=now) + + credential_query = _input(now=now) + credential_query["evidence_refs"] = [ + { + "label": "unsafe query", + "ref": "https://example.com/evidence?access_token=secret", + } + ] + with pytest.raises(ValueError, match="sensitive material"): + module.build_finance_transaction_approval_packet(credential_query, now=now) + + future_evidence = _input(now=now) + future_evidence["evidence_observed_at"] = ( + now + timedelta(seconds=1) + ).isoformat() + with pytest.raises(ValueError, match="must not be in the future"): + module.build_finance_transaction_approval_packet(future_evidence, now=now) + + +def test_managed_protocol_and_direct_cli_emit_the_same_request(tmp_path: Path) -> None: + now = datetime.now(UTC) + input_path = tmp_path / "transaction.json" + input_path.write_text(json.dumps(_input(now=now)), encoding="utf-8") + env = {"PYTHONPATH": str(PACKAGE_SRC)} + + managed = subprocess.run( + [sys.executable, "-m", "loopx_finance_value_discovery.cli"], + cwd=ROOT, + env=env, + input=input_path.read_text(encoding="utf-8"), + text=True, + capture_output=True, + check=False, + ) + direct = subprocess.run( + [ + sys.executable, + "-m", + "loopx_finance_value_discovery.cli", + "build-operation-request", + "--input-json", + str(input_path), + "--request-only", + ], + cwd=ROOT, + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert managed.returncode == 0, managed.stderr + assert direct.returncode == 0, direct.stderr + managed_packet = json.loads(managed.stdout) + direct_request = json.loads(direct.stdout) + assert managed_packet["operation_request"] == direct_request + assert managed_packet["operation_request_digest"] == _module()._digest( + direct_request + )